diff --git a/docs/features/magic-notes/progress.md b/docs/features/magic-notes/progress.md index c06463b2..0e847310 100644 --- a/docs/features/magic-notes/progress.md +++ b/docs/features/magic-notes/progress.md @@ -1,5 +1,24 @@ # Magic Notes Progress +## 2026-10-08: Rich Text Table Editor Fix + +Replaced the Electron-incompatible native prompt flow for table insertion with the shared form +Modal. The dialog validates 1-20 rows and 1-12 columns, supports cancellation and Escape, restores +focus to the toolbar trigger, and preserves the editor selection while it is open. Table row, +column, and delete controls now use compact localized icon buttons, remain disabled outside a table +cell, guard against nested insertion, preserve the active cell, and create separate Quill undo +steps. Non-empty table Delta content remains readable in the read-only record renderer. + +Focused validation: `npx vitest run src/renderer/src/MagicNoteEditor.test.tsx +src/renderer/src/MagicNoteContent.test.tsx` passed 18 tests. `npm run typecheck` and affected-file +ESLint passed. Full suite and runtime Electron validation were not run. + +The table content check now also applies to save validation. An empty-cell table remains an active +draft after focus leaves the composer, saves through the renderer, IPC and storage layers, and is +restored after reopening. A rich Delta containing only a terminal newline remains rejected, and a +failed save leaves the draft available for retry. Focused renderer and storage regression tests +cover these cases. + ## 2026-10-03: Compact Notes List And Narrow Drawer The notes list now defaults to 200px with a 160px minimum. At workspace widths diff --git a/docs/features/magic-notes/technical-design.md b/docs/features/magic-notes/technical-design.md index 346d0211..07525a14 100644 --- a/docs/features/magic-notes/technical-design.md +++ b/docs/features/magic-notes/technical-design.md @@ -71,6 +71,16 @@ save remounts at the returned revision and establishes a new baseline; an unchan background revision retains the existing editor and baseline. Aborted canvas initialization and file reads for an unmounted text editor cannot write back. +Rich text tables remain native Quill table Delta operations. Insertion stores the active editor +range while the shared table-size dialog is open, rejects invalid dimensions before changing the +Delta, and refuses insertion when that range is already a table cell. Row, column, and whole-table +commands require a cell selection, preserve the selected range, and cut Quill history around each +command so each toolbar action is independently undoable. The read-only rich renderer loads the +same Delta with Quill's `readOnly` mode, so table content follows the same persisted schema without +an editable toolbar. A table row operation is content even when its cell text is empty: the +workspace keeps the composer open after focus leaves it and submits the Delta through the normal +IPC and storage path. A genuinely empty rich Delta is still rejected before IPC. + The real saved formatted-text probe reproduced identical values with different Delta key order: storage returned `{insert, attributes}` and Quill emitted `{attributes, insert}`. The old `JSON.stringify` comparison therefore reported diff --git a/docs/features/magic-notes/ui-design.md b/docs/features/magic-notes/ui-design.md index 840f3542..ef5f3541 100644 --- a/docs/features/magic-notes/ui-design.md +++ b/docs/features/magic-notes/ui-design.md @@ -49,4 +49,12 @@ Tab 使用工作栏现有的共享页签、焦点和关闭模式。搜索有持久标签,列表用可键盘访问的按钮;打开采集后聚焦预览,取消返回触发按钮,触发按钮已不可见时尝试返回会话标题菜单按钮。新建标题出现后聚焦输入框。面板中的 Escape 取消当前草稿,或从详情返回列表;需要放弃输入时先显示草稿确认。确认框初始聚焦“继续编辑”,限制 Tab 焦点范围,Escape 保留草稿并关闭确认。 +## 富文本表格 + +富文本编辑器使用共享表单 Modal 插入表格,行数限制为 1-20,列数限制为 +1-12。取消、关闭、Escape 和遮罩点击不会修改内容,关闭后焦点返回“插入表格” +按钮。表格操作使用紧凑图标按钮,保留本地化的可访问名称和提示;未选中表格单元格 +时,插入行、插入列和删除操作保持禁用,避免误操作或嵌套表格。每个表格操作形成 +独立的撤销步骤。已保存表格在只读记录视图中继续由 Quill 以不可编辑单元格显示。 + 面板服从工作栏现有宽度和停靠规则,以单列切换列表、详情和采集编辑。按钮组可换行,文字摘要保留换行并折行长文本;Markdown 和代码以原始文字显示。头部与快速追加区固定,中间内容独立滚动;完整预览输入区可纵向调整大小。配色、按钮、焦点环、通知和确认使用共享组件与令牌。真实 Electron 已在实测 214px 面板验证无横向溢出,以及原生键盘返回列表、打开笔记和追加保存。 diff --git a/src/main/magic-notes/magic-note-storage.test.ts b/src/main/magic-notes/magic-note-storage.test.ts index 5dd9a01f..8a1fb19f 100644 --- a/src/main/magic-notes/magic-note-storage.test.ts +++ b/src/main/magic-notes/magic-note-storage.test.ts @@ -118,6 +118,24 @@ describe('Magic note SQLite and filesystem storage', () => { expect(database.getMagicNote(created.id)).toEqual(before) }) + it('persists and reopens a rich table whose cells contain no text', () => { + const { database, directory } = setup() + const note = database.createMagicNote({ title: 'Empty table' }) + const table: MagicNoteContent = { + version: 1, + ops: [{ insert: '\n', attributes: { table: 'row-1' } }] + } + + const saved = database.createMagicNoteEntry({ noteId: note.id, content: table, plainText: 'ignored' }) + expect(saved.entries.at(-1)?.content).toEqual(table) + + database.close() + const reopened = new AssistantDatabase(join(directory, 'assistant.sqlite')) + connections.push(reopened) + reopened.initialize(directory) + expect(reopened.getMagicNote(note.id).entries.at(-1)?.content).toEqual(table) + }) + it.each(['missing', 'invalid-json', 'invalid-media'] as const)('retains all assets when a later retained body has %s corruption', (failure) => { const { database, directory } = setup() const note = database.createMagicNote({ title: 'Retained bodies', content: rich }) diff --git a/src/main/magic-notes/magic-notes-ipc.test.ts b/src/main/magic-notes/magic-notes-ipc.test.ts index b766a7e7..fecc34fe 100644 --- a/src/main/magic-notes/magic-notes-ipc.test.ts +++ b/src/main/magic-notes/magic-notes-ipc.test.ts @@ -16,6 +16,21 @@ function fixture(database: Partial) { } describe('async magic notes IPC', () => { + it('passes an empty-cell table through schema and content validation', async () => { + const createMagicNoteEntry = vi.fn() + createMagicNoteEntry.mockResolvedValue({ + id: 'note', title: 'Note', preview: '', entryCount: 1, pinned: false, tags: [], + revision: 1, createdAt: '', updatedAt: '', createdEntryId: 'entry', entries: [] + }) + const f = fixture({ createMagicNoteEntry }) + const content = { version: 1 as const, ops: [{ insert: '\n', attributes: { table: 'row-1' } }] } + + await f.invoke(ipcChannels.magicNotesCreateEntry, { + noteId: '00000000-0000-4000-8000-000000000001', content + }) + expect(createMagicNoteEntry).toHaveBeenCalledWith(expect.objectContaining({ content, plainText: '' })) + }) + it('resolves list DTOs and preserves sender/schema checks before storage', async () => { const listMagicNotes = vi.fn(async () => []) const createMagicNote = vi.fn() diff --git a/src/renderer/src/MagicNoteContent.test.tsx b/src/renderer/src/MagicNoteContent.test.tsx index b8c6fb36..4685e621 100644 --- a/src/renderer/src/MagicNoteContent.test.tsx +++ b/src/renderer/src/MagicNoteContent.test.tsx @@ -33,4 +33,27 @@ describe('MagicNoteContent dispatch', () => { expect(ref.current).toBeNull() expect(destroy).toHaveBeenCalledOnce() }) + + it('renders non-empty rich table content as a read-only roundtrip', () => { + const view = render( + + ) + + expect(view.container.querySelectorAll('table td')).toHaveLength(2) + expect(view.container.querySelector('.ql-editor')).toHaveAttribute( + 'contenteditable', + 'false' + ) + expect(view.container.querySelector('.ql-editor')).toHaveTextContent('NameValue') + }) }) diff --git a/src/renderer/src/MagicNoteEditor.test.tsx b/src/renderer/src/MagicNoteEditor.test.tsx index 606a7609..fc408565 100644 --- a/src/renderer/src/MagicNoteEditor.test.tsx +++ b/src/renderer/src/MagicNoteEditor.test.tsx @@ -1,10 +1,10 @@ /// -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { readFileSync } from 'node:fs' import { join } from 'node:path' import Quill from 'quill' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { MagicNoteRichContent } from '../../shared/magic-notes-contracts' import { MagicNoteEditor } from './MagicNoteEditor' @@ -14,6 +14,8 @@ const stylesheet = readFileSync( ) describe('MagicNoteEditor', () => { + afterEach(cleanup) + it('establishes the baseline from real Quill normalization and reports later edits separately', () => { const onReady = vi.fn() const onChange = vi.fn() @@ -45,6 +47,32 @@ describe('MagicNoteEditor', () => { expect(onChange).toHaveBeenCalledWith(normalized) unmount() }) + + it('round-trips non-empty table content through the editor baseline', () => { + const initialContent: MagicNoteRichContent = { + version: 1, + ops: [ + { insert: 'Name' }, + { insert: '\n', attributes: { table: 'table-1' } }, + { insert: 'Value' }, + { insert: '\n', attributes: { table: 'table-1' } } + ] + } + const onReady = vi.fn() + const { container } = render( + + ) + + expect(onReady.mock.calls[0]![0].ops).toEqual(initialContent.ops) + expect(container.querySelectorAll('.ql-editor table td')).toHaveLength(2) + expect(container.querySelector('.ql-editor')).toHaveTextContent('NameValue') + }) it('uses the themed muted text color for its placeholder', () => { expect(stylesheet).toMatch( /\.magic-note-editor__content\s+\.ql-editor\.ql-blank::before\s*\{\s*color:\s*var\(--text-muted\);\s*\}/ @@ -75,6 +103,134 @@ describe('MagicNoteEditor', () => { expect( screen.getByRole('button', { name: '上传视频或附件' }) ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: '插入表格' }) + ).toBeInTheDocument() + }) + + it('opens a validated table dialog, supports cancellation, and restores focus', async () => { + const onChange = vi.fn<(content: MagicNoteRichContent) => void>() + const { container } = render( + + ) + + const tableButton = container.querySelector('.ql-table')! + expect(tableButton).toBeEnabled() + expect(container.querySelector('.magic-note-editor__table-toolbar')).toBeNull() + const prompt = vi.spyOn(window, 'prompt') + fireEvent.click(tableButton) + + const dialog = await screen.findByRole('dialog', { name: '插入表格' }) + expect(prompt).not.toHaveBeenCalled() + fireEvent.change(within(dialog).getByLabelText('行数(1-20)'), { + target: { value: '21' } + }) + fireEvent.click(within(dialog).getByRole('button', { name: '插入表格' })) + expect(within(dialog).getByRole('alert')).toHaveTextContent('行数必须为 1-20') + expect(onChange).toHaveBeenCalledOnce() + + fireEvent.click(within(dialog).getByRole('button', { name: '取消' })) + await waitFor(() => expect(dialog).not.toBeInTheDocument()) + expect(document.activeElement).toBe(tableButton) + expect(container.querySelector('.ql-editor table')).toBeNull() + }) + + it('inserts and mutates a native Quill table through separate table actions', async () => { + const onChange = vi.fn<(content: MagicNoteRichContent) => void>() + const { container } = render( + + ) + + fireEvent.click(container.querySelector('.ql-table')!) + const dialog = await screen.findByRole('dialog', { name: '插入表格' }) + fireEvent.click(within(dialog).getByRole('button', { name: '插入表格' })) + + await waitFor(() => { + const content = onChange.mock.calls.at(-1)?.[0] + const tableCells = + content?.ops.filter((operation) => operation.attributes?.table) + expect(tableCells).toHaveLength(3) + expect(container.querySelectorAll('.ql-editor table td')).toHaveLength(9) + }) + + expect(container.querySelector('.magic-note-editor__table-toolbar')).toBeInTheDocument() + + fireEvent.click( + container.querySelector('.magic-note-editor__table-action--row-below')! + ) + await waitFor(() => { + expect(container.querySelectorAll('.ql-editor table tr')).toHaveLength(4) + }) + const quill = Quill.find(container.querySelector('.ql-container')!) as Quill + quill.history.undo() + await waitFor(() => { + expect(container.querySelectorAll('.ql-editor table tr')).toHaveLength(3) + }) + + fireEvent.click( + container.querySelector('.magic-note-editor__table-action--row-below')! + ) + await waitFor(() => { + expect(container.querySelectorAll('.ql-editor table tr')).toHaveLength(4) + }) + + fireEvent.click( + container.querySelector('.magic-note-editor__table-action--column-right')! + ) + await waitFor(() => { + expect(container.querySelectorAll('.ql-editor table td')).toHaveLength(16) + }) + + fireEvent.click( + container.querySelector('.magic-note-editor__table-action--delete-row')! + ) + await waitFor(() => { + expect(container.querySelectorAll('.ql-editor table tr')).toHaveLength(3) + expect(container.querySelectorAll('.ql-editor table td')).toHaveLength(12) + }) + + fireEvent.click( + container.querySelector('.magic-note-editor__table-action--delete-column')! + ) + await waitFor(() => { + expect(container.querySelectorAll('.ql-editor table td')).toHaveLength(9) + }) + + fireEvent.click( + container.querySelector('.magic-note-editor__table-action--delete-table')! + ) + await waitFor(() => { + expect(container.querySelector('.ql-editor table')).toBeNull() + }) + }) + + it('allows deleting the last row and column without corrupting the document', async () => { + const onChange = vi.fn<(content: MagicNoteRichContent) => void>() + const { container } = render( + + ) + fireEvent.click(container.querySelector('.ql-table')!) + const dialog = await screen.findByRole('dialog', { name: '插入表格' }) + fireEvent.change(within(dialog).getByLabelText('行数(1-20)'), { target: { value: '1' } }) + fireEvent.change(within(dialog).getByLabelText('列数(1-12)'), { target: { value: '1' } }) + fireEvent.click(within(dialog).getByRole('button', { name: '插入表格' })) + + await waitFor(() => expect(container.querySelectorAll('.ql-editor table td')).toHaveLength(1)) + fireEvent.click(container.querySelector('.magic-note-editor__table-action--delete-row')!) + await waitFor(() => expect(container.querySelectorAll('.ql-editor table td')).toHaveLength(0)) + expect(container.querySelector('.magic-note-editor__table-toolbar')).toBeNull() }) it.each([ diff --git a/src/renderer/src/MagicNoteEditor.tsx b/src/renderer/src/MagicNoteEditor.tsx index db611341..70670bc0 100644 --- a/src/renderer/src/MagicNoteEditor.tsx +++ b/src/renderer/src/MagicNoteEditor.tsx @@ -1,12 +1,26 @@ import { useEffect, useRef, + useState, type ClipboardEvent as ReactClipboardEvent, type DragEvent as ReactDragEvent } from 'react' +import { createPortal } from 'react-dom' import Quill, { type Delta, type EmitterSource } from 'quill' +import Table from 'quill/modules/table' import 'quill/dist/quill.snow.css' -import { Paperclip } from 'lucide-react' +import { + ArrowDownToLine, + ArrowLeftToLine, + ArrowRightToLine, + ArrowUpToLine, + Columns3, + Paperclip, + Rows3, + Table2, + Trash2, + X +} from 'lucide-react' import { useTranslation } from 'react-i18next' import './magic-note-embeds' import { @@ -23,6 +37,7 @@ import { type MagicNoteRichContent } from '../../shared/magic-notes-contracts' import { readFileAsDataUrl } from './file-data-url' +import { activateModalFocus, trapTabFocus } from './dialog-focus' const supportedImageTypes = new Set([ 'image/jpeg', @@ -96,6 +111,148 @@ function richContentFromQuill(quill: Quill): MagicNoteRichContent { } } +const MAX_TABLE_ROWS = 20 +const MAX_TABLE_COLUMNS = 12 + +function tableSelection( + quill: Quill, + range = quill.getSelection() +): boolean { + return range !== null && + (quill.getModule('table') as Table).getTable(range)[2] !== null +} + +function TableInsertDialog({ + onClose, + onInsert, + restoreFocus +}: { + onClose: () => void + onInsert: (rows: number, columns: number) => void + restoreFocus: () => HTMLElement | null +}): React.JSX.Element { + const { t } = useTranslation('magicNotes') + const dialogRef = useRef(null) + const rowsRef = useRef(null) + const restoreFocusRef = useRef(restoreFocus) + const [rows, setRows] = useState('3') + const [columns, setColumns] = useState('3') + const [error, setError] = useState('') + + useEffect(() => { + const restoreFocus = restoreFocusRef.current + const restoreModalFocus = activateModalFocus( + () => rowsRef.current, + restoreFocus + ) + return () => { + restoreModalFocus() + restoreFocus()?.focus() + } + }, []) + + const submit = (): void => { + const rowCount = Number(rows) + const columnCount = Number(columns) + if ( + !Number.isInteger(rowCount) || + !Number.isInteger(columnCount) || + rowCount < 1 || + rowCount > MAX_TABLE_ROWS || + columnCount < 1 || + columnCount > MAX_TABLE_COLUMNS + ) { + setError(t('editor.tableSizeError')) + return + } + onInsert(rowCount, columnCount) + } + + return createPortal( +
{ + if (event.target === event.currentTarget) onClose() + }}> +
{ + if (event.key === 'Escape') { + event.preventDefault() + onClose() + } else { + trapTabFocus(event, dialogRef.current) + } + }} + role="dialog" + tabIndex={-1} + > +
+
+

{t('editor.tableDialogTitle')}

+

{t('editor.tableDialogDescription')}

+
+ +
+
+
+ + +
+ {error && {error}} +
+
+ + +
+
+
, + document.body + ) +} + export function MagicNoteEditor({ initialContent, ariaDescribedBy, @@ -118,6 +275,10 @@ export function MagicNoteEditor({ const onParagraphCommitRef = useRef(onParagraphCommit) const translateRef = useRef(t) const initialPlaceholderRef = useRef(t('editor.placeholder')) + const tableTriggerRef = useRef(null) + const tableSelectionRef = useRef<{ index: number; length: number } | null>(null) + const [tableDialogOpen, setTableDialogOpen] = useState(false) + const [hasTableSelection, setHasTableSelection] = useState(false) useEffect(() => { onChangeRef.current = onChange @@ -292,6 +453,35 @@ export function MagicNoteEditor({ event: ReactDragEvent ): File[] => [...event.dataTransfer.files] + const runTableAction = (action: (table: Table) => void): void => { + const quill = quillRef.current + if (!quill) return + const range = quill.getSelection(true) ?? tableSelectionRef.current + if (!range) return + if (!tableSelection(quill, range)) return + tableSelectionRef.current = range + quill.setSelection(range, 'silent') + quill.history.cutoff() + action(quill.getModule('table') as Table) + quill.history.cutoff() + tableSelectionRef.current = quill.getSelection() ?? tableSelectionRef.current + setHasTableSelection(tableSelection(quill, tableSelectionRef.current)) + } + + const insertTable = (rows: number, columns: number): void => { + const quill = quillRef.current + const range = tableSelectionRef.current + if (!quill || !range || tableSelection(quill)) return + tableSelectionRef.current = range + quill.setSelection(range, 'silent') + quill.history.cutoff() + ;(quill.getModule('table') as Table).insertTable(rows, columns) + quill.history.cutoff() + setHasTableSelection(true) + setTableDialogOpen(false) + tableTriggerRef.current?.focus() + } + useEffect(() => { const toolbar = toolbarRef.current const editor = editorRef.current @@ -312,6 +502,7 @@ export function MagicNoteEditor({ 'blockquote', 'code-block', 'code', + 'table', 'list', 'indent', 'align', @@ -323,9 +514,28 @@ export function MagicNoteEditor({ toolbar: { container: toolbar, handlers: { - image: () => imageInputRef.current?.click() + image: () => imageInputRef.current?.click(), + table: () => { + if (tableSelection(quill)) { + return + } + tableTriggerRef.current = toolbar.querySelector('.ql-table') + tableSelectionRef.current = quill.getSelection(true) ?? { + index: quill.getLength() - 1, + length: 0 + } + setTableDialogOpen(true) + }, + tableInsertRowAbove: () => runTableAction((table) => table.insertRowAbove()), + tableInsertRowBelow: () => runTableAction((table) => table.insertRowBelow()), + tableInsertColumnLeft: () => runTableAction((table) => table.insertColumnLeft()), + tableInsertColumnRight: () => runTableAction((table) => table.insertColumnRight()), + tableDeleteRow: () => runTableAction((table) => table.deleteRow()), + tableDeleteColumn: () => runTableAction((table) => table.deleteColumn()), + tableDelete: () => runTableAction((table) => table.deleteTable()) } }, + table: true, history: { delay: 500, maxStack: 100, @@ -339,6 +549,12 @@ export function MagicNoteEditor({ select.tabIndex = -1 }) quillRef.current = quill + const updateTableSelection = (): void => { + const range = quill.getSelection() ?? tableSelectionRef.current + setHasTableSelection(tableSelection(quill, range)) + } + quill.on('selection-change', updateTableSelection) + quill.on('text-change', updateTableSelection) if (initialContent) { quill.setContents(initialContent.ops, 'silent') } @@ -369,6 +585,8 @@ export function MagicNoteEditor({ onReadyRef.current?.(initialValue) return () => { quill.off('text-change', handleChange) + quill.off('selection-change', updateTableSelection) + quill.off('text-change', updateTableSelection) quillRef.current = null } }, [initialContent]) @@ -510,6 +728,14 @@ export function MagicNoteEditor({ className="ql-code-block" type="button" /> + - - -
+ +
+ {hasTableSelection && ( +
+ + {t('editor.tableToolbarLabel')} + + + + + + + + +
+ )} +
+ {tableDialogOpen && ( + { + setTableDialogOpen(false) + tableTriggerRef.current?.focus() + }} + onInsert={insertTable} + restoreFocus={() => tableTriggerRef.current} + /> + )} ({ MagicNoteEditor: ({ onChange, onParagraphCommit }: { onChange: (content: MagicNoteRichContent) => void onParagraphCommit?: (content: MagicNoteRichContent) => void - }) => + +
})) vi.mock('./MagicCanvasThumbnail', () => ({ MagicCanvasThumbnail: () => 画布首页 })) @@ -856,6 +860,32 @@ describe('MagicNotesWorkspace overview navigation', () => { await waitFor(() => expect(createEntry).toHaveBeenCalledWith({ noteId, content })) }) + it('keeps an empty-cell table draft expanded after focus leaves the editor', async () => { + render() + await openNote() + newEntry() + fireEvent.click(screen.getByTestId('magic-note-table-editor')) + screen.getByDisplayValue(detail.title).focus() + expect(document.querySelector('.magic-note-composer')).toHaveClass('magic-note-composer--active') + }) + + it('saves an empty-cell table instead of treating it as an empty entry', async () => { + render() + await openNote() + newEntry() + + fireEvent.click(screen.getByRole('button', { name: '保存记录' })) + expect(screen.getByText('请先输入记录内容')).toBeVisible() + expect(createEntry).not.toHaveBeenCalled() + + fireEvent.click(screen.getByTestId('magic-note-table-editor')) + fireEvent.click(screen.getByRole('button', { name: '保存记录' })) + await waitFor(() => expect(createEntry).toHaveBeenCalledWith({ + noteId, + content: { version: 1, ops: [{ insert: '\n', attributes: { table: 'row-1' } }] } + })) + }) + it('cancels a new entry only after confirming that its draft will be discarded', async () => { render() await openNote() diff --git a/src/renderer/src/MagicNotesWorkspace.tsx b/src/renderer/src/MagicNotesWorkspace.tsx index c496ea47..528cd40b 100644 --- a/src/renderer/src/MagicNotesWorkspace.tsx +++ b/src/renderer/src/MagicNotesWorkspace.tsx @@ -237,9 +237,10 @@ function hasContent(content?: NoteContent): boolean { if (content?.version === 2) return canvasHasContent(content) return Boolean( content?.ops.some((operation) => - typeof operation.insert === 'string' + operation.attributes?.table !== undefined || + (typeof operation.insert === 'string' ? operation.insert.trim().length > 0 - : true + : true) ) ) } diff --git a/src/renderer/src/i18n/locales/en-US/magicNotes.ts b/src/renderer/src/i18n/locales/en-US/magicNotes.ts index 9a6b7d7e..c03efad0 100644 --- a/src/renderer/src/i18n/locales/en-US/magicNotes.ts +++ b/src/renderer/src/i18n/locales/en-US/magicNotes.ts @@ -274,6 +274,22 @@ export const magicNotes = { numberedList: 'Numbered list', blockquote: 'Block quote', codeBlock: 'Code block', + insertTable: 'Insert table', + tableToolbarLabel: 'Table toolbar', + tableDialogTitle: 'Insert table', + tableDialogDescription: 'Set the number of rows and columns.', + tableRows: 'Rows (1-20)', + tableColumns: 'Columns (1-12)', + tableSizeError: 'Rows must be an integer from 1 to 20 and columns from 1 to 12.', + cancelTableDialog: 'Cancel', + closeTableDialog: 'Close insert table dialog', + insertTableRowAbove: 'Insert row above', + insertTableRowBelow: 'Insert row below', + insertTableColumnLeft: 'Insert column left', + insertTableColumnRight: 'Insert column right', + deleteTableRow: 'Delete current row', + deleteTableColumn: 'Delete current column', + deleteTable: 'Delete table', insertImage: 'Insert local image', uploadAttachment: 'Upload video or attachment', undo: 'Undo', diff --git a/src/renderer/src/i18n/locales/zh-CN/magicNotes.ts b/src/renderer/src/i18n/locales/zh-CN/magicNotes.ts index 3c886355..444e23fb 100644 --- a/src/renderer/src/i18n/locales/zh-CN/magicNotes.ts +++ b/src/renderer/src/i18n/locales/zh-CN/magicNotes.ts @@ -261,6 +261,22 @@ export const magicNotes = { numberedList: '编号列表', blockquote: '引用', codeBlock: '代码块', + insertTable: '插入表格', + tableToolbarLabel: '表格工具栏', + tableDialogTitle: '插入表格', + tableDialogDescription: '设置表格的行数和列数。', + tableRows: '行数(1-20)', + tableColumns: '列数(1-12)', + tableSizeError: '行数必须为 1-20 的整数,列数必须为 1-12 的整数。', + cancelTableDialog: '取消', + closeTableDialog: '关闭插入表格对话框', + insertTableRowAbove: '在上方插入行', + insertTableRowBelow: '在下方插入行', + insertTableColumnLeft: '在左侧插入列', + insertTableColumnRight: '在右侧插入列', + deleteTableRow: '删除当前行', + deleteTableColumn: '删除当前列', + deleteTable: '删除整张表', insertImage: '插入本地图片', uploadAttachment: '上传视频或附件', undo: '撤销', diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index c9cb250f..514b06bd 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -2885,6 +2885,56 @@ color: var(--text-secondary); } +.magic-note-editor__table-toolbar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-1); + padding: var(--space-1) var(--space-2); + border-bottom: 1px solid var(--border-subtle); + background: var(--surface-muted); +} + +.magic-note-editor__table-toolbar-label { + margin-right: var(--space-1); + color: var(--text-muted); + font-size: var(--font-caption); +} + +.magic-note-editor__table-toolbar button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: 0; + border-radius: var(--radius-control); + background: transparent; + color: var(--text-secondary); +} + +.magic-note-editor__table-toolbar button:hover { + background: var(--surface-raised); + color: var(--text-primary); +} + +.magic-note-editor__table-toolbar button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +.magic-note-editor__toolbar.ql-toolbar .magic-note-editor__table-action { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.magic-note-editor__toolbar.ql-toolbar button:disabled { + cursor: not-allowed; + opacity: 0.42; +} + .magic-note-editor__toolbar.ql-toolbar .ql-stroke { stroke: currentColor; } @@ -2947,6 +2997,21 @@ font-size: 24px; } +.magic-note-editor .ql-editor table, +.magic-note-content .ql-editor table { + width: 100%; + margin: var(--space-3) 0; + border-collapse: collapse; +} + +.magic-note-editor .ql-editor td, +.magic-note-content .ql-editor td { + min-width: 96px; + padding: var(--space-2); + border: 1px solid var(--border-control); + vertical-align: top; +} + .magic-note-editor .ql-editor img, .magic-note-content .ql-editor img { display: block; diff --git a/src/shared/magic-notes-contracts.ts b/src/shared/magic-notes-contracts.ts index 021f496e..f86a01b7 100644 --- a/src/shared/magic-notes-contracts.ts +++ b/src/shared/magic-notes-contracts.ts @@ -154,7 +154,8 @@ const magicNoteAttributesSchema = z align: z.enum(['center', 'right', 'justify']).optional(), indent: z.number().int().min(1).max(8).optional(), size: z.enum(['small', 'large', 'huge']).optional(), - color: z.enum(MAGIC_NOTE_TEXT_COLORS).optional() + color: z.enum(MAGIC_NOTE_TEXT_COLORS).optional(), + table: z.string().min(1).max(64).optional() }) .strict()