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:
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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: '撤销',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user