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
|
# 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
|
## 2026-10-03: Compact Notes List And Narrow Drawer
|
||||||
|
|
||||||
The notes list now defaults to 200px with a 160px minimum. At workspace widths
|
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
|
background revision retains the existing editor and baseline. Aborted canvas
|
||||||
initialization and file reads for an unmounted text editor cannot write back.
|
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
|
The real saved formatted-text probe reproduced identical values with different
|
||||||
Delta key order: storage returned `{insert, attributes}` and Quill emitted
|
Delta key order: storage returned `{insert, attributes}` and Quill emitted
|
||||||
`{attributes, insert}`. The old `JSON.stringify` comparison therefore reported
|
`{attributes, insert}`. The old `JSON.stringify` comparison therefore reported
|
||||||
|
|||||||
@@ -49,4 +49,12 @@
|
|||||||
|
|
||||||
Tab 使用工作栏现有的共享页签、焦点和关闭模式。搜索有持久标签,列表用可键盘访问的按钮;打开采集后聚焦预览,取消返回触发按钮,触发按钮已不可见时尝试返回会话标题菜单按钮。新建标题出现后聚焦输入框。面板中的 Escape 取消当前草稿,或从详情返回列表;需要放弃输入时先显示草稿确认。确认框初始聚焦“继续编辑”,限制 Tab 焦点范围,Escape 保留草稿并关闭确认。
|
Tab 使用工作栏现有的共享页签、焦点和关闭模式。搜索有持久标签,列表用可键盘访问的按钮;打开采集后聚焦预览,取消返回触发按钮,触发按钮已不可见时尝试返回会话标题菜单按钮。新建标题出现后聚焦输入框。面板中的 Escape 取消当前草稿,或从详情返回列表;需要放弃输入时先显示草稿确认。确认框初始聚焦“继续编辑”,限制 Tab 焦点范围,Escape 保留草稿并关闭确认。
|
||||||
|
|
||||||
|
## 富文本表格
|
||||||
|
|
||||||
|
富文本编辑器使用共享表单 Modal 插入表格,行数限制为 1-20,列数限制为
|
||||||
|
1-12。取消、关闭、Escape 和遮罩点击不会修改内容,关闭后焦点返回“插入表格”
|
||||||
|
按钮。表格操作使用紧凑图标按钮,保留本地化的可访问名称和提示;未选中表格单元格
|
||||||
|
时,插入行、插入列和删除操作保持禁用,避免误操作或嵌套表格。每个表格操作形成
|
||||||
|
独立的撤销步骤。已保存表格在只读记录视图中继续由 Quill 以不可编辑单元格显示。
|
||||||
|
|
||||||
面板服从工作栏现有宽度和停靠规则,以单列切换列表、详情和采集编辑。按钮组可换行,文字摘要保留换行并折行长文本;Markdown 和代码以原始文字显示。头部与快速追加区固定,中间内容独立滚动;完整预览输入区可纵向调整大小。配色、按钮、焦点环、通知和确认使用共享组件与令牌。真实 Electron 已在实测 214px 面板验证无横向溢出,以及原生键盘返回列表、打开笔记和追加保存。
|
面板服从工作栏现有宽度和停靠规则,以单列切换列表、详情和采集编辑。按钮组可换行,文字摘要保留换行并折行长文本;Markdown 和代码以原始文字显示。头部与快速追加区固定,中间内容独立滚动;完整预览输入区可纵向调整大小。配色、按钮、焦点环、通知和确认使用共享组件与令牌。真实 Electron 已在实测 214px 面板验证无横向溢出,以及原生键盘返回列表、打开笔记和追加保存。
|
||||||
|
|||||||
@@ -118,6 +118,24 @@ describe('Magic note SQLite and filesystem storage', () => {
|
|||||||
expect(database.getMagicNote(created.id)).toEqual(before)
|
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) => {
|
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 { database, directory } = setup()
|
||||||
const note = database.createMagicNote({ title: 'Retained bodies', content: rich })
|
const note = database.createMagicNote({ title: 'Retained bodies', content: rich })
|
||||||
|
|||||||
@@ -16,6 +16,21 @@ function fixture(database: Partial<AssistantStoragePort>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('async magic notes IPC', () => {
|
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 () => {
|
it('resolves list DTOs and preserves sender/schema checks before storage', async () => {
|
||||||
const listMagicNotes = vi.fn(async () => [])
|
const listMagicNotes = vi.fn(async () => [])
|
||||||
const createMagicNote = vi.fn()
|
const createMagicNote = vi.fn()
|
||||||
|
|||||||
@@ -33,4 +33,27 @@ describe('MagicNoteContent dispatch', () => {
|
|||||||
expect(ref.current).toBeNull()
|
expect(ref.current).toBeNull()
|
||||||
expect(destroy).toHaveBeenCalledOnce()
|
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" />
|
/// <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 { readFileSync } from 'node:fs'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import Quill from 'quill'
|
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 type { MagicNoteRichContent } from '../../shared/magic-notes-contracts'
|
||||||
import { MagicNoteEditor } from './MagicNoteEditor'
|
import { MagicNoteEditor } from './MagicNoteEditor'
|
||||||
|
|
||||||
@@ -14,6 +14,8 @@ const stylesheet = readFileSync(
|
|||||||
)
|
)
|
||||||
|
|
||||||
describe('MagicNoteEditor', () => {
|
describe('MagicNoteEditor', () => {
|
||||||
|
afterEach(cleanup)
|
||||||
|
|
||||||
it('establishes the baseline from real Quill normalization and reports later edits separately', () => {
|
it('establishes the baseline from real Quill normalization and reports later edits separately', () => {
|
||||||
const onReady = vi.fn()
|
const onReady = vi.fn()
|
||||||
const onChange = vi.fn()
|
const onChange = vi.fn()
|
||||||
@@ -45,6 +47,32 @@ describe('MagicNoteEditor', () => {
|
|||||||
expect(onChange).toHaveBeenCalledWith(normalized)
|
expect(onChange).toHaveBeenCalledWith(normalized)
|
||||||
unmount()
|
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', () => {
|
it('uses the themed muted text color for its placeholder', () => {
|
||||||
expect(stylesheet).toMatch(
|
expect(stylesheet).toMatch(
|
||||||
/\.magic-note-editor__content\s+\.ql-editor\.ql-blank::before\s*\{\s*color:\s*var\(--text-muted\);\s*\}/
|
/\.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(
|
expect(
|
||||||
screen.getByRole('button', { name: '上传视频或附件' })
|
screen.getByRole('button', { name: '上传视频或附件' })
|
||||||
).toBeInTheDocument()
|
).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([
|
it.each([
|
||||||
|
|||||||
@@ -1,12 +1,26 @@
|
|||||||
import {
|
import {
|
||||||
useEffect,
|
useEffect,
|
||||||
useRef,
|
useRef,
|
||||||
|
useState,
|
||||||
type ClipboardEvent as ReactClipboardEvent,
|
type ClipboardEvent as ReactClipboardEvent,
|
||||||
type DragEvent as ReactDragEvent
|
type DragEvent as ReactDragEvent
|
||||||
} from 'react'
|
} from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import Quill, { type Delta, type EmitterSource } from 'quill'
|
import Quill, { type Delta, type EmitterSource } from 'quill'
|
||||||
|
import Table from 'quill/modules/table'
|
||||||
import 'quill/dist/quill.snow.css'
|
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 { useTranslation } from 'react-i18next'
|
||||||
import './magic-note-embeds'
|
import './magic-note-embeds'
|
||||||
import {
|
import {
|
||||||
@@ -23,6 +37,7 @@ import {
|
|||||||
type MagicNoteRichContent
|
type MagicNoteRichContent
|
||||||
} from '../../shared/magic-notes-contracts'
|
} from '../../shared/magic-notes-contracts'
|
||||||
import { readFileAsDataUrl } from './file-data-url'
|
import { readFileAsDataUrl } from './file-data-url'
|
||||||
|
import { activateModalFocus, trapTabFocus } from './dialog-focus'
|
||||||
|
|
||||||
const supportedImageTypes = new Set([
|
const supportedImageTypes = new Set([
|
||||||
'image/jpeg',
|
'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({
|
export function MagicNoteEditor({
|
||||||
initialContent,
|
initialContent,
|
||||||
ariaDescribedBy,
|
ariaDescribedBy,
|
||||||
@@ -118,6 +275,10 @@ export function MagicNoteEditor({
|
|||||||
const onParagraphCommitRef = useRef(onParagraphCommit)
|
const onParagraphCommitRef = useRef(onParagraphCommit)
|
||||||
const translateRef = useRef(t)
|
const translateRef = useRef(t)
|
||||||
const initialPlaceholderRef = useRef(t('editor.placeholder'))
|
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(() => {
|
useEffect(() => {
|
||||||
onChangeRef.current = onChange
|
onChangeRef.current = onChange
|
||||||
@@ -292,6 +453,35 @@ export function MagicNoteEditor({
|
|||||||
event: ReactDragEvent<HTMLDivElement>
|
event: ReactDragEvent<HTMLDivElement>
|
||||||
): File[] => [...event.dataTransfer.files]
|
): 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(() => {
|
useEffect(() => {
|
||||||
const toolbar = toolbarRef.current
|
const toolbar = toolbarRef.current
|
||||||
const editor = editorRef.current
|
const editor = editorRef.current
|
||||||
@@ -312,6 +502,7 @@ export function MagicNoteEditor({
|
|||||||
'blockquote',
|
'blockquote',
|
||||||
'code-block',
|
'code-block',
|
||||||
'code',
|
'code',
|
||||||
|
'table',
|
||||||
'list',
|
'list',
|
||||||
'indent',
|
'indent',
|
||||||
'align',
|
'align',
|
||||||
@@ -323,9 +514,28 @@ export function MagicNoteEditor({
|
|||||||
toolbar: {
|
toolbar: {
|
||||||
container: toolbar,
|
container: toolbar,
|
||||||
handlers: {
|
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: {
|
history: {
|
||||||
delay: 500,
|
delay: 500,
|
||||||
maxStack: 100,
|
maxStack: 100,
|
||||||
@@ -339,6 +549,12 @@ export function MagicNoteEditor({
|
|||||||
select.tabIndex = -1
|
select.tabIndex = -1
|
||||||
})
|
})
|
||||||
quillRef.current = quill
|
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) {
|
if (initialContent) {
|
||||||
quill.setContents(initialContent.ops, 'silent')
|
quill.setContents(initialContent.ops, 'silent')
|
||||||
}
|
}
|
||||||
@@ -369,6 +585,8 @@ export function MagicNoteEditor({
|
|||||||
onReadyRef.current?.(initialValue)
|
onReadyRef.current?.(initialValue)
|
||||||
return () => {
|
return () => {
|
||||||
quill.off('text-change', handleChange)
|
quill.off('text-change', handleChange)
|
||||||
|
quill.off('selection-change', updateTableSelection)
|
||||||
|
quill.off('text-change', updateTableSelection)
|
||||||
quillRef.current = null
|
quillRef.current = null
|
||||||
}
|
}
|
||||||
}, [initialContent])
|
}, [initialContent])
|
||||||
@@ -510,6 +728,14 @@ export function MagicNoteEditor({
|
|||||||
className="ql-code-block"
|
className="ql-code-block"
|
||||||
type="button"
|
type="button"
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
aria-label={t('editor.insertTable')}
|
||||||
|
className="ql-table"
|
||||||
|
ref={tableTriggerRef}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Table2 aria-hidden="true" size={15} />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
aria-label={t('editor.insertImage')}
|
aria-label={t('editor.insertImage')}
|
||||||
className="ql-image"
|
className="ql-image"
|
||||||
@@ -539,9 +765,93 @@ export function MagicNoteEditor({
|
|||||||
>
|
>
|
||||||
↷
|
↷
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div ref={editorRef} className="magic-note-editor__content" />
|
{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
|
<input
|
||||||
ref={imageInputRef}
|
ref={imageInputRef}
|
||||||
hidden
|
hidden
|
||||||
|
|||||||
@@ -13,11 +13,15 @@ vi.mock('./MagicNoteEditor', () => ({
|
|||||||
MagicNoteEditor: ({ onChange, onParagraphCommit }: {
|
MagicNoteEditor: ({ onChange, onParagraphCommit }: {
|
||||||
onChange: (content: MagicNoteRichContent) => void
|
onChange: (content: MagicNoteRichContent) => void
|
||||||
onParagraphCommit?: (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' }] }
|
const content = { version: 1 as const, ops: [{ insert: '新的句子\n' }] }
|
||||||
onChange(content)
|
onChange(content)
|
||||||
onParagraphCommit?.(content)
|
onParagraphCommit?.(content)
|
||||||
}}>模拟输入并回车</button>
|
}}>模拟输入并回车</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="画布首页" /> }))
|
vi.mock('./MagicCanvasThumbnail', () => ({ MagicCanvasThumbnail: () => <img alt="画布首页" /> }))
|
||||||
@@ -856,6 +860,32 @@ describe('MagicNotesWorkspace overview navigation', () => {
|
|||||||
await waitFor(() => expect(createEntry).toHaveBeenCalledWith({ noteId, content }))
|
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 () => {
|
it('cancels a new entry only after confirming that its draft will be discarded', async () => {
|
||||||
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||||
await openNote()
|
await openNote()
|
||||||
|
|||||||
@@ -237,9 +237,10 @@ function hasContent(content?: NoteContent): boolean {
|
|||||||
if (content?.version === 2) return canvasHasContent(content)
|
if (content?.version === 2) return canvasHasContent(content)
|
||||||
return Boolean(
|
return Boolean(
|
||||||
content?.ops.some((operation) =>
|
content?.ops.some((operation) =>
|
||||||
typeof operation.insert === 'string'
|
operation.attributes?.table !== undefined ||
|
||||||
|
(typeof operation.insert === 'string'
|
||||||
? operation.insert.trim().length > 0
|
? operation.insert.trim().length > 0
|
||||||
: true
|
: true)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -274,6 +274,22 @@ export const magicNotes = {
|
|||||||
numberedList: 'Numbered list',
|
numberedList: 'Numbered list',
|
||||||
blockquote: 'Block quote',
|
blockquote: 'Block quote',
|
||||||
codeBlock: 'Code block',
|
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',
|
insertImage: 'Insert local image',
|
||||||
uploadAttachment: 'Upload video or attachment',
|
uploadAttachment: 'Upload video or attachment',
|
||||||
undo: 'Undo',
|
undo: 'Undo',
|
||||||
|
|||||||
@@ -261,6 +261,22 @@ export const magicNotes = {
|
|||||||
numberedList: '编号列表',
|
numberedList: '编号列表',
|
||||||
blockquote: '引用',
|
blockquote: '引用',
|
||||||
codeBlock: '代码块',
|
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: '插入本地图片',
|
insertImage: '插入本地图片',
|
||||||
uploadAttachment: '上传视频或附件',
|
uploadAttachment: '上传视频或附件',
|
||||||
undo: '撤销',
|
undo: '撤销',
|
||||||
|
|||||||
@@ -2885,6 +2885,56 @@
|
|||||||
color: var(--text-secondary);
|
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 {
|
.magic-note-editor__toolbar.ql-toolbar .ql-stroke {
|
||||||
stroke: currentColor;
|
stroke: currentColor;
|
||||||
}
|
}
|
||||||
@@ -2947,6 +2997,21 @@
|
|||||||
font-size: 24px;
|
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-editor .ql-editor img,
|
||||||
.magic-note-content .ql-editor img {
|
.magic-note-content .ql-editor img {
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@@ -154,7 +154,8 @@ const magicNoteAttributesSchema = z
|
|||||||
align: z.enum(['center', 'right', 'justify']).optional(),
|
align: z.enum(['center', 'right', 'justify']).optional(),
|
||||||
indent: z.number().int().min(1).max(8).optional(),
|
indent: z.number().int().min(1).max(8).optional(),
|
||||||
size: z.enum(['small', 'large', 'huge']).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()
|
.strict()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user