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
@@ -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()