feat(magic-notes): add rich table editing

Add native Quill table insertion and contextual row, column, and table actions. Preserve table-only drafts through composer validation, IPC, and storage, and document the saved behavior.\n\nRelease note: 魔法笔记支持插入和编辑表格,空单元格表格也可保存并在重新打开后恢复。
This commit is contained in:
mesalogo
2026-10-08 02:02:24 +08:00
parent 2a92f8f875
commit 3d063937c3
14 changed files with 699 additions and 11 deletions
+19
View File
@@ -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
@@ -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
+8
View File
@@ -49,4 +49,12 @@
Tab 使用工作栏现有的共享页签、焦点和关闭模式。搜索有持久标签,列表用可键盘访问的按钮;打开采集后聚焦预览,取消返回触发按钮,触发按钮已不可见时尝试返回会话标题菜单按钮。新建标题出现后聚焦输入框。面板中的 Escape 取消当前草稿,或从详情返回列表;需要放弃输入时先显示草稿确认。确认框初始聚焦“继续编辑”,限制 Tab 焦点范围,Escape 保留草稿并关闭确认。
## 富文本表格
富文本编辑器使用共享表单 Modal 插入表格,行数限制为 1-20,列数限制为
1-12。取消、关闭、Escape 和遮罩点击不会修改内容,关闭后焦点返回“插入表格”
按钮。表格操作使用紧凑图标按钮,保留本地化的可访问名称和提示;未选中表格单元格
时,插入行、插入列和删除操作保持禁用,避免误操作或嵌套表格。每个表格操作形成
独立的撤销步骤。已保存表格在只读记录视图中继续由 Quill 以不可编辑单元格显示。
面板服从工作栏现有宽度和停靠规则,以单列切换列表、详情和采集编辑。按钮组可换行,文字摘要保留换行并折行长文本;Markdown 和代码以原始文字显示。头部与快速追加区固定,中间内容独立滚动;完整预览输入区可纵向调整大小。配色、按钮、焦点环、通知和确认使用共享组件与令牌。真实 Electron 已在实测 214px 面板验证无横向溢出,以及原生键盘返回列表、打开笔记和追加保存。
@@ -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 })
@@ -16,6 +16,21 @@ function fixture(database: Partial<AssistantStoragePort>) {
}
describe('async magic notes IPC', () => {
it('passes an empty-cell table through schema and content validation', async () => {
const createMagicNoteEntry = vi.fn<AssistantStoragePort['createMagicNoteEntry']>()
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()
@@ -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(
<MagicNoteContent
content={{
version: 1,
ops: [
{ insert: 'Name' },
{ insert: '\n', attributes: { table: 'table-1' } },
{ insert: 'Value' },
{ insert: '\n', attributes: { table: 'table-1' } }
]
}}
/>
)
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')
})
})
+158 -2
View File
@@ -1,10 +1,10 @@
/// <reference types="node" />
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(
<MagicNoteEditor
ariaLabel="笔记正文"
initialContent={initialContent}
onChange={vi.fn()}
onReady={onReady}
onError={vi.fn()}
/>
)
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(
<MagicNoteEditor
ariaLabel="笔记正文"
onChange={onChange}
onError={vi.fn()}
/>
)
const tableButton = container.querySelector<HTMLButtonElement>('.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(
<MagicNoteEditor
ariaLabel="笔记正文"
onChange={onChange}
onError={vi.fn()}
/>
)
fireEvent.click(container.querySelector<HTMLButtonElement>('.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<HTMLButtonElement>('.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<HTMLButtonElement>('.magic-note-editor__table-action--row-below')!
)
await waitFor(() => {
expect(container.querySelectorAll('.ql-editor table tr')).toHaveLength(4)
})
fireEvent.click(
container.querySelector<HTMLButtonElement>('.magic-note-editor__table-action--column-right')!
)
await waitFor(() => {
expect(container.querySelectorAll('.ql-editor table td')).toHaveLength(16)
})
fireEvent.click(
container.querySelector<HTMLButtonElement>('.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<HTMLButtonElement>('.magic-note-editor__table-action--delete-column')!
)
await waitFor(() => {
expect(container.querySelectorAll('.ql-editor table td')).toHaveLength(9)
})
fireEvent.click(
container.querySelector<HTMLButtonElement>('.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(
<MagicNoteEditor
ariaLabel="笔记正文"
onChange={onChange}
onError={vi.fn()}
/>
)
fireEvent.click(container.querySelector<HTMLButtonElement>('.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<HTMLButtonElement>('.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([
+315 -5
View File
@@ -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<HTMLElement>(null)
const rowsRef = useRef<HTMLInputElement>(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(
<div className="custom-task-dialog" onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose()
}}>
<section
ref={dialogRef}
aria-describedby="magic-note-table-description"
aria-labelledby="magic-note-table-title"
aria-modal="true"
className="custom-task-dialog__surface"
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault()
onClose()
} else {
trapTabFocus(event, dialogRef.current)
}
}}
role="dialog"
tabIndex={-1}
>
<header className="custom-task-dialog__header">
<div>
<h2 id="magic-note-table-title">{t('editor.tableDialogTitle')}</h2>
<p id="magic-note-table-description">{t('editor.tableDialogDescription')}</p>
</div>
<button
aria-label={t('editor.closeTableDialog')}
className="icon-button"
onClick={onClose}
type="button"
>
<X aria-hidden="true" size={17} />
</button>
</header>
<div className="custom-task-dialog__content">
<div className="custom-task-dialog__two-columns">
<label className="custom-task-dialog__field">
<span>{t('editor.tableRows')}</span>
<input
ref={rowsRef}
aria-invalid={Boolean(error)}
inputMode="numeric"
max={MAX_TABLE_ROWS}
min="1"
onChange={(event) => {
setRows(event.target.value)
setError('')
}}
type="number"
value={rows}
/>
</label>
<label className="custom-task-dialog__field">
<span>{t('editor.tableColumns')}</span>
<input
aria-invalid={Boolean(error)}
inputMode="numeric"
max={MAX_TABLE_COLUMNS}
min="1"
onChange={(event) => {
setColumns(event.target.value)
setError('')
}}
type="number"
value={columns}
/>
</label>
</div>
{error && <small className="custom-task-dialog__form-error" role="alert">{error}</small>}
</div>
<footer className="custom-task-dialog__actions">
<button className="secondary-button" onClick={onClose} type="button">
{t('editor.cancelTableDialog')}
</button>
<button className="primary-button" onClick={submit} type="button">
{t('editor.insertTable')}
</button>
</footer>
</section>
</div>,
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<HTMLButtonElement | null>(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<HTMLDivElement>
): 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<HTMLButtonElement>('.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"
/>
<button
aria-label={t('editor.insertTable')}
className="ql-table"
ref={tableTriggerRef}
type="button"
>
<Table2 aria-hidden="true" size={15} />
</button>
<button
aria-label={t('editor.insertImage')}
className="ql-image"
@@ -539,9 +765,93 @@ export function MagicNoteEditor({
>
↷
</button>
</span>
</div>
<div ref={editorRef} className="magic-note-editor__content" />
</span>
</div>
{hasTableSelection && (
<div
aria-label={t('editor.tableToolbarLabel')}
className="magic-note-editor__table-toolbar"
role="toolbar"
>
<span className="magic-note-editor__table-toolbar-label">
{t('editor.tableToolbarLabel')}
</span>
<button
aria-label={t('editor.insertTableRowAbove')}
className="magic-note-editor__table-action magic-note-editor__table-action--row-above"
title={t('editor.insertTableRowAbove')}
onClick={() => runTableAction((table) => table.insertRowAbove())}
type="button"
>
<ArrowUpToLine aria-hidden="true" size={15} />
</button>
<button
aria-label={t('editor.insertTableRowBelow')}
className="magic-note-editor__table-action magic-note-editor__table-action--row-below"
title={t('editor.insertTableRowBelow')}
onClick={() => runTableAction((table) => table.insertRowBelow())}
type="button"
>
<ArrowDownToLine aria-hidden="true" size={15} />
</button>
<button
aria-label={t('editor.insertTableColumnLeft')}
className="magic-note-editor__table-action magic-note-editor__table-action--column-left"
title={t('editor.insertTableColumnLeft')}
onClick={() => runTableAction((table) => table.insertColumnLeft())}
type="button"
>
<ArrowLeftToLine aria-hidden="true" size={15} />
</button>
<button
aria-label={t('editor.insertTableColumnRight')}
className="magic-note-editor__table-action magic-note-editor__table-action--column-right"
title={t('editor.insertTableColumnRight')}
onClick={() => runTableAction((table) => table.insertColumnRight())}
type="button"
>
<ArrowRightToLine aria-hidden="true" size={15} />
</button>
<button
aria-label={t('editor.deleteTableRow')}
className="magic-note-editor__table-action magic-note-editor__table-action--delete-row"
title={t('editor.deleteTableRow')}
onClick={() => runTableAction((table) => table.deleteRow())}
type="button"
>
<Rows3 aria-hidden="true" size={15} />
</button>
<button
aria-label={t('editor.deleteTableColumn')}
className="magic-note-editor__table-action magic-note-editor__table-action--delete-column"
title={t('editor.deleteTableColumn')}
onClick={() => runTableAction((table) => table.deleteColumn())}
type="button"
>
<Columns3 aria-hidden="true" size={15} />
</button>
<button
aria-label={t('editor.deleteTable')}
className="magic-note-editor__table-action magic-note-editor__table-action--delete-table"
title={t('editor.deleteTable')}
onClick={() => runTableAction((table) => table.deleteTable())}
type="button"
>
<Trash2 aria-hidden="true" size={15} />
</button>
</div>
)}
<div ref={editorRef} className="magic-note-editor__content" />
{tableDialogOpen && (
<TableInsertDialog
onClose={() => {
setTableDialogOpen(false)
tableTriggerRef.current?.focus()
}}
onInsert={insertTable}
restoreFocus={() => tableTriggerRef.current}
/>
)}
<input
ref={imageInputRef}
hidden
+31 -1
View File
@@ -13,11 +13,15 @@ vi.mock('./MagicNoteEditor', () => ({
MagicNoteEditor: ({ onChange, onParagraphCommit }: {
onChange: (content: MagicNoteRichContent) => void
onParagraphCommit?: (content: MagicNoteRichContent) => void
}) => <button data-testid="magic-note-editor" type="button" onClick={() => {
}) => <div><button data-testid="magic-note-editor" type="button" onClick={() => {
const content = { version: 1 as const, ops: [{ insert: '新的句子\n' }] }
onChange(content)
onParagraphCommit?.(content)
}}>模拟输入并回车</button>
<button data-testid="magic-note-table-editor" type="button" onClick={() => onChange({ version: 1, ops: [
{ insert: '\n', attributes: { table: 'row-1' } }
] })}>模拟空表格</button>
</div>
}))
vi.mock('./MagicCanvasThumbnail', () => ({ MagicCanvasThumbnail: () => <img alt="画布首页" /> }))
@@ -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(<MagicNotesWorkspace onNotify={onNotify} />)
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(<MagicNotesWorkspace onNotify={onNotify} />)
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(<MagicNotesWorkspace onNotify={onNotify} />)
await openNote()
+3 -2
View File
@@ -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)
)
)
}
@@ -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',
@@ -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: '撤销',
+65
View File
@@ -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;
+2 -1
View File
@@ -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()