feat: add file-backed canvas notes

Magic Notes previously supported rich-text records without a paged drawing or
PDF annotation workflow. Add the integrated PeopleLib Fabric and Quill editor
with flowing text, pen/highlighter, object transforms, images, paper templates,
PDF backgrounds with native text extraction, and raster PDF export. Saves are
manual; undo/redo remains scoped to an annotation page or Quill mode.

Analyze drafts, saved canvases and canvas-source todos with page images and
text when the default model supports images, or an explicitly labelled
text-only fallback. Keep successful saves when automatic analysis fails and
prevent MCP plain-text replacement of canvas records.

Move entry bodies and binary assets into local note files with schema 38
migration and verified readback. SQLite retains metadata, indexes, revisions,
todos and comments. Backups must coordinate SQLite and the adjacent notes
directory while writes are paused or the app is closed; no one-click backup
action is included.

Show a continuous record stream with independent collapsible record index and
AI pane, real first-page thumbnails, and scroll-only record navigation. Open a
blank text/canvas composer by default and reset it after creation. Protect
unsaved drafts and establish normalized editor baselines after saving so clean
records do not prompt again when leaving.

Validation: latest recorded focused run passed 149 tests across five files;
typecheck, lint and git diff --check passed. The prior user-reported full run
passed 4,713 tests before later corrections and UI changes; it is not a full
validation of this final snapshot. Tests were not rerun for this commit, as
requested; independent systematic review follows the commit.

Release note: Create paged notes with handwriting, flowing text and PDF
annotations, browse records through thumbnails, and request AI comments with
an explicit text-only fallback. Existing notes migrate to local file storage;
backups now need both the database and notes directory.
This commit is contained in:
mesalogo
2026-09-19 11:54:23 +08:00
parent c7a93803c9
commit 17186b0c11
58 changed files with 8240 additions and 429 deletions
+27
View File
@@ -0,0 +1,27 @@
import { forwardRef, useState, useRef, useImperativeHandle } from 'react'
import { useTranslation } from 'react-i18next'
import { MagicCanvasEditor, type MagicCanvasEditorHandle, type MagicNoteCanvasContent } from './MagicCanvasEditor'
import './magic-canvas.css'
export type MagicCanvasContentProps = {
content: MagicNoteCanvasContent
onError?: (message: string) => void
onEdit?: () => void
}
export type MagicCanvasContentHandle = Pick<MagicCanvasEditorHandle, 'capturePages'>
export const MagicCanvasContent = forwardRef<MagicCanvasContentHandle, MagicCanvasContentProps>(function MagicCanvasContent({ content, onError, onEdit }, ref) {
const { t } = useTranslation('magicNotes')
const editorRef = useRef<MagicCanvasEditorHandle>(null)
useImperativeHandle(ref, () => ({ async capturePages() {
if (!editorRef.current) throw new Error(t('canvas.notReady'))
return editorRef.current.capturePages()
} }), [t])
const [snapshot, setSnapshot] = useState({ content, revision: 0 })
if (snapshot.content !== content) setSnapshot({ content, revision: snapshot.revision + 1 })
return <div className="magic-canvas-content">
{onEdit && <button type="button" className="magic-canvas-open" onClick={onEdit}>{t('canvas.edit')}</button>}
<MagicCanvasEditor ref={editorRef} key={snapshot.revision} initialContent={content} disabled onChange={() => {}} onError={onError ?? (() => {})} />
</div>
})
@@ -0,0 +1,96 @@
import { act, render } from '@testing-library/react'
import { createRef, StrictMode } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { MagicCanvasEditor, type MagicCanvasEditorHandle, createEmptyCanvasContent } from './MagicCanvasEditor'
import { toCoreContent } from './magic-canvas/model'
const mock = vi.hoisted(() => ({ mount: vi.fn() }))
vi.mock('./magic-canvas/canvas-note.mjs', () => ({ mountCanvasNote: mock.mount }))
describe('MagicCanvasEditor lifecycle', () => {
beforeEach(() => mock.mount.mockReset())
it('aborts old asynchronous callbacks and destroys every StrictMode instance', async () => {
const content = createEmptyCanvasContent()
const controllers: { destroy: ReturnType<typeof vi.fn>; options: { signal: AbortSignal; onChange: (value: unknown) => void } }[] = []
mock.mount.mockImplementation((_host, core, options) => {
const controller = { options, destroy: vi.fn(async () => {}), content: () => core, flush: async () => core, setDisabled: vi.fn(), focus: vi.fn(), capturePages: async () => [] }
controllers.push(controller)
return controller
})
const onChange = vi.fn()
const view = render(<StrictMode><MagicCanvasEditor initialContent={content} onChange={onChange} onError={vi.fn()} /></StrictMode>)
await act(async () => {})
expect(controllers).toHaveLength(2)
expect(controllers[0]!.options.signal.aborted).toBe(true)
controllers[0]!.options.onChange(toCoreContent(content))
expect(onChange).not.toHaveBeenCalled()
view.unmount()
for (const controller of controllers) {
expect(controller.destroy).toHaveBeenCalledOnce()
expect(controller.options.signal.aborted).toBe(true)
}
})
it('flush waits for the core queue and current callbacks do not remount the editor', async () => {
const content = createEmptyCanvasContent()
const core = toCoreContent(content)
let finish!: (value: typeof core) => void
const pending = new Promise<typeof core>((resolve) => { finish = resolve })
const capturePages = vi.fn(async () => [{ pageId: core.pages[0]!.id, dataUrl: 'data:image/png;base64,AAAA' }])
const controller = { destroy: vi.fn(async () => {}), content: () => core, flush: vi.fn(() => pending), setDisabled: vi.fn(), focus: vi.fn(), capturePages }
mock.mount.mockReturnValue(controller)
const ref = createRef<MagicCanvasEditorHandle>()
const first = vi.fn()
const second = vi.fn()
const view = render(<MagicCanvasEditor ref={ref} initialContent={content} onChange={first} onError={vi.fn()} />)
const flushed = ref.current!.flush()
let settled = false
void flushed.then(() => { settled = true })
await act(async () => {})
expect(settled).toBe(false)
view.rerender(<MagicCanvasEditor ref={ref} initialContent={content} onChange={second} onError={vi.fn()} disabled />)
expect(mock.mount).toHaveBeenCalledOnce()
expect(mock.mount.mock.calls[0]![2].disabled).toBe(true)
await act(async () => { finish(core) })
expect(controller.setDisabled).toHaveBeenLastCalledWith(true)
expect(await flushed).toEqual(content)
const options = mock.mount.mock.calls[0]![2]
expect(options.signal.aborted).toBe(false)
act(() => options.onChange(core))
expect(first).not.toHaveBeenCalled()
expect(second).toHaveBeenCalledOnce()
expect(first).not.toHaveBeenCalled()
expect(await ref.current!.capturePages()).toHaveLength(1)
ref.current!.focus()
expect(controller.focus).toHaveBeenCalledOnce()
view.unmount()
})
it('reports the initialized flush as the baseline before enabling edits and ignores stale initialization', async () => {
const content = createEmptyCanvasContent()
const normalized = { ...toCoreContent(content), flow: { version: 1 as const, ops: [{ insert: 'Normalized\n' }] } }
const complete: (() => void)[] = []
const controllers: { setDisabled: ReturnType<typeof vi.fn> }[] = []
mock.mount.mockImplementation((_host, core) => {
const pending = new Promise<typeof normalized>((resolve) => complete.push(() => resolve(normalized)))
const controller = { destroy: vi.fn(async () => {}), content: () => core, flush: vi.fn(() => pending), setDisabled: vi.fn(), focus: vi.fn() }
controllers.push(controller)
return controller
})
const onReady = vi.fn()
const onChange = vi.fn()
const ref = createRef<MagicCanvasEditorHandle>()
const view = render(<StrictMode><MagicCanvasEditor ref={ref} initialContent={content} onReady={onReady} onChange={onChange} onError={vi.fn()} /></StrictMode>)
const flushed = ref.current!.flush()
await act(async () => complete[0]!())
expect(onReady).not.toHaveBeenCalled()
expect(controllers[0]!.setDisabled).not.toHaveBeenCalled()
await act(async () => complete[1]!())
expect(onReady).toHaveBeenCalledOnce()
expect(onReady).toHaveBeenCalledWith(await flushed)
expect(onChange).not.toHaveBeenCalled()
expect(controllers[1]!.setDisabled).toHaveBeenLastCalledWith(false)
view.unmount()
})
})
+147
View File
@@ -0,0 +1,147 @@
import { forwardRef, useEffect, useImperativeHandle, useRef, useLayoutEffect } from 'react'
import { mountCanvasNote, type CanvasController } from './magic-canvas/canvas-note.mjs'
import { createEmptyCanvasContent, fromCoreContent, toCoreContent, type MagicNoteCanvasContent } from './magic-canvas/model'
import { readFileAsDataUrl } from './file-data-url'
import 'quill/dist/quill.snow.css'
import './magic-canvas.css'
export { createEmptyCanvasContent, canvasHasContent } from './magic-canvas/model'
export type { MagicNoteCanvasContent } from './magic-canvas/model'
export type MagicCanvasEditorHandle = {
flush(): Promise<MagicNoteCanvasContent>
focus(): void
capturePages(): Promise<{ pageId: string; dataUrl: string }[]>
}
export type MagicCanvasEditorProps = {
initialContent?: MagicNoteCanvasContent
onChange(content: MagicNoteCanvasContent): void
onReady?(content: MagicNoteCanvasContent): void
onError(message: string): void
disabled?: boolean
}
export const MagicCanvasEditor = forwardRef<MagicCanvasEditorHandle, MagicCanvasEditorProps>(function MagicCanvasEditor({ initialContent, onChange, onReady, onError, disabled = false }, ref) {
const host = useRef<HTMLDivElement>(null)
const controller = useRef<CanvasController | null>(null)
const initial = useRef(initialContent)
const disabledRef = useRef(disabled)
const ready = useRef<Promise<void>>(Promise.resolve())
const initialized = useRef(false)
const saved = useRef<MagicNoteCanvasContent | null>(null)
const serialize = useRef<((core: ReturnType<CanvasController['content']>) => MagicNoteCanvasContent) | null>(null)
const callbacks = useRef({ onChange, onReady, onError })
useLayoutEffect(() => { callbacks.current = { onChange, onReady, onError }; disabledRef.current = disabled }, [onChange, onReady, onError, disabled])
useImperativeHandle(ref, () => ({
async flush() {
await ready.current
if (!controller.current || !serialize.current) throw new Error('画布尚未加载')
return serialize.current(await controller.current.flush())
},
focus() { controller.current?.focus() },
async capturePages() {
if (!controller.current) throw new Error('画布尚未加载')
return controller.current.capturePages()
}
}), [])
useEffect(() => {
if (!host.current) return
const abort = new AbortController()
const content = saved.current ?? initial.current ?? createEmptyCanvasContent()
const assets = structuredClone(content.assets)
const pdfText = content.pages.flatMap((page) => page.background.type === 'pdf' && page.background.text !== undefined
? [{ assetId: page.background.assetId, pageNumber: page.background.pageNumber, text: page.background.text }] : [])
const convert = (core: ReturnType<CanvasController['content']>) => fromCoreContent(core, assets, pdfText)
serialize.current = convert
const pickPdf = () => new Promise<{ assetId: string; name: string } | null>((resolve, reject) => {
const input = document.createElement('input')
input.type = 'file'
input.accept = 'application/pdf,.pdf'
input.hidden = true
host.current?.append(input)
const finish = (result: { assetId: string; name: string } | null) => { input.remove(); resolve(result) }
abort.signal.addEventListener('abort', () => finish(null), { once: true })
input.addEventListener('cancel', () => finish(null), { once: true })
input.addEventListener('change', () => {
const file = input.files?.[0]
if (!file) { finish(null); return }
void (async () => {
if (file.size > 32 * 1024 * 1024) throw new Error('PDF 文件不能超过 32 MB')
const dataUrl = await readFileAsDataUrl(file, 'application/pdf', abort.signal)
if (abort.signal.aborted) { finish(null); return }
let asset = assets.find((candidate) => candidate.dataUrl === dataUrl)
if (!asset) {
asset = { id: crypto.randomUUID(), name: file.name, mimeType: 'application/pdf', dataUrl }
assets.push(asset)
}
finish({ assetId: asset.id, name: file.name })
})().catch((error: unknown) => { input.remove(); reject(error) })
}, { once: true })
input.click()
})
let instance: CanvasController
try {
instance = mountCanvasNote(host.current, toCoreContent(content), {
disabled: true,
signal: abort.signal,
onChange(core) {
if (abort.signal.aborted || !initialized.current) return
const next = convert(core)
saved.current = next
callbacks.current.onChange(next)
},
onError: (message) => { if (!abort.signal.aborted) callbacks.current.onError(message) },
pickPdf,
async readPdf({ assetId }) {
const asset = assets.find((candidate) => candidate.id === assetId)
if (!asset) throw new Error('PDF 底版资源不可用')
const binary = atob(asset.dataUrl.slice(asset.dataUrl.indexOf(',') + 1))
return Uint8Array.from(binary, (character) => character.charCodeAt(0))
},
onPdfText(assetId, pageNumber, text) {
const previous = pdfText.find((item) => item.assetId === assetId && item.pageNumber === pageNumber)
if (previous) previous.text = text
else pdfText.push({ assetId, pageNumber, text })
},
async savePdf(bytes, name) {
if (abort.signal.aborted) return
const url = URL.createObjectURL(new Blob([new Uint8Array(bytes)], { type: 'application/pdf' }))
const link = document.createElement('a')
link.href = url
link.download = name
link.click()
setTimeout(() => URL.revokeObjectURL(url), 1000)
}
})
controller.current = instance
ready.current = instance.flush().then((core) => {
if (abort.signal.aborted) return
const normalized = convert(core)
saved.current = normalized
callbacks.current.onReady?.(normalized)
initialized.current = true
instance.setDisabled(disabledRef.current)
})
void ready.current.catch((error: unknown) => {
if (!abort.signal.aborted) callbacks.current.onError(error instanceof Error ? error.message : '画布加载失败')
})
} catch (error) {
callbacks.current.onError(error instanceof Error ? error.message : '画布加载失败')
return () => abort.abort()
}
return () => {
abort.abort()
initialized.current = false
saved.current = convert(instance.content())
controller.current = null
void instance.destroy()
}
}, [])
useEffect(() => { if (initialized.current) controller.current?.setDisabled(disabled) }, [disabled])
return <div className="magic-canvas-editor" ref={host} aria-label="分页画布编辑器" />
})
@@ -0,0 +1,45 @@
import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MagicCanvasThumbnail } from './MagicCanvasThumbnail'
import { mountCanvasNote } from './magic-canvas/canvas-note.mjs'
import type { MagicNoteCanvasContent } from './magic-canvas/model'
import './i18n'
vi.mock('./magic-canvas/canvas-note.mjs', () => ({ mountCanvasNote: vi.fn() }))
afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals() })
describe('MagicCanvasThumbnail cleanup', () => {
it.each(['unmount', 'replace'] as const)('immediately destroys pending capture on %s and releases the shared render queue', async (action) => {
vi.stubGlobal('IntersectionObserver', undefined)
const content: MagicNoteCanvasContent = { version: 2, kind: 'paged-canvas', assets: [], pages: [
{ id: 'page', width: 794, height: 1123, objects: [], background: { type: 'template', template: 'blank' } }
] }
let rejectCapture!: (error: Error) => void
const pending = new Promise<{ pageId: string; dataUrl: string }[]>((_resolve, reject) => { rejectCapture = reject })
const first = {
capturePages: vi.fn(() => pending),
destroy: vi.fn(() => {
// Core destroy cancels tasks synchronously, then waits for its operation queue.
rejectCapture(new DOMException('Cancelled', 'AbortError'))
return pending.then(() => {}, () => {})
})
}
const second = { capturePages: vi.fn(async () => [{ pageId: 'page', dataUrl: 'data:image/png;base64,YQ==' }]), destroy: vi.fn(async () => {}) }
vi.mocked(mountCanvasNote)
.mockReturnValueOnce(first as unknown as ReturnType<typeof mountCanvasNote>)
.mockReturnValueOnce(second as unknown as ReturnType<typeof mountCanvasNote>)
const view = render(<MagicCanvasThumbnail content={content} />)
await waitFor(() => expect(first.capturePages).toHaveBeenCalledOnce())
if (action === 'unmount') view.unmount()
else view.rerender(<MagicCanvasThumbnail content={structuredClone(content)} />)
expect(first.destroy).toHaveBeenCalledOnce()
if (action === 'unmount') render(<MagicCanvasThumbnail content={structuredClone(content)} />)
await screen.findByRole('img')
expect(second.capturePages).toHaveBeenCalledWith({ firstPageOnly: true, thumbnailWidth: 240 })
await waitFor(() => expect(document.querySelector('.magic-note-thumbnail-renderer')).toBeNull())
act(() => cleanup())
expect(first.destroy).toHaveBeenCalledOnce()
expect(second.destroy).toHaveBeenCalledOnce()
})
})
+66
View File
@@ -0,0 +1,66 @@
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { mountCanvasNote } from './magic-canvas/canvas-note.mjs'
import { toCoreContent, type MagicNoteCanvasContent } from './magic-canvas/model'
// Serialize temporary renderers so a long record list never mounts many Fabric editors.
let renders = Promise.resolve()
const images = new WeakMap<MagicNoteCanvasContent, string>()
export function MagicCanvasThumbnail({ content }: { content: MagicNoteCanvasContent }): React.JSX.Element {
const { t } = useTranslation('magicNotes')
const host = useRef<HTMLSpanElement>(null)
const [result, setResult] = useState<{ content: MagicNoteCanvasContent; url?: string; failed?: boolean }>()
useEffect(() => {
const abort = new AbortController()
const render = (): void => {
renders = renders.then(async () => {
if (abort.signal.aborted) return
const cached = images.get(content)
if (cached) { setResult({ content, url: cached }); return }
const surface = document.createElement('div')
surface.className = 'magic-note-thumbnail-renderer'
surface.setAttribute('aria-hidden', 'true')
surface.inert = true
document.body.append(surface)
let controller: ReturnType<typeof mountCanvasNote> | undefined
let destruction: Promise<void> | undefined
const destroy = (): Promise<void> => destruction ??= controller?.destroy() ?? Promise.resolve()
const cancel = (): void => { void destroy().catch(() => {}) }
try {
controller = mountCanvasNote(surface, toCoreContent(content), {
disabled: true, signal: abort.signal,
async readPdf({ assetId }) {
const asset = content.assets.find((item) => item.id === assetId)
if (!asset) throw new Error('Missing PDF asset')
const binary = atob(asset.dataUrl.slice(asset.dataUrl.indexOf(',') + 1))
return Uint8Array.from(binary, (character) => character.charCodeAt(0))
}
})
abort.signal.addEventListener('abort', cancel, { once: true })
const [image] = await controller.capturePages({ firstPageOnly: true, thumbnailWidth: 240 })
if (image && !abort.signal.aborted) {
images.set(content, image.dataUrl)
setResult({ content, url: image.dataUrl })
}
} catch {
if (!abort.signal.aborted) setResult({ content, failed: true })
} finally {
abort.signal.removeEventListener('abort', cancel)
try { await destroy() }
finally { surface.remove() }
}
}).catch(() => {})
}
const observer = typeof IntersectionObserver !== 'undefined' ? new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) { observer?.disconnect(); render() }
}) : undefined
if (observer && host.current) observer.observe(host.current)
else render()
return () => { observer?.disconnect(); abort.abort() }
}, [content])
const current = result?.content === content ? result : undefined
return <span ref={host} className="magic-note-record__image">
{current?.url ? <img src={current.url} alt={t('records.firstPage')} /> : <span>{t(current?.failed ? 'records.previewFailed' : 'status.loading')}</span>}
</span>
}
@@ -0,0 +1,36 @@
import { act, cleanup, render, screen } from '@testing-library/react'
import { createRef } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MagicNoteContent } from './MagicNoteContent'
import type { MagicCanvasContentHandle } from './MagicCanvasContent'
import { createEmptyCanvasContent } from './magic-canvas/model'
import './i18n'
const core = vi.hoisted(() => ({ mount: vi.fn() }))
vi.mock('./magic-canvas/canvas-note.mjs', () => ({ mountCanvasNote: core.mount }))
afterEach(cleanup)
describe('MagicNoteContent dispatch', () => {
it('switches between rich text and a read-only canvas with capture access', async () => {
const content = createEmptyCanvasContent()
const images = [{ pageId: content.pages[0]!.id, dataUrl: 'data:image/png;base64,YQ==' }]
const capturePages = vi.fn(async () => images)
const destroy = vi.fn(async () => {})
core.mount.mockImplementation((_host, value) => ({
content: () => value, flush: async () => value, destroy, capturePages,
focus: vi.fn(), setDisabled: vi.fn()
}))
const ref = createRef<MagicCanvasContentHandle>()
const view = render(<MagicNoteContent content={{ version: 1, ops: [{ insert: 'Rich text remains readable\n' }] }} />)
expect(screen.getByText('Rich text remains readable')).toBeVisible()
view.rerender(<MagicNoteContent content={content} canvasRef={ref} />)
await act(async () => {})
expect(core.mount.mock.calls[0]![2].disabled).toBe(true)
expect(await ref.current!.capturePages()).toEqual(images)
view.rerender(<MagicNoteContent content={{ version: 1, ops: [{ insert: 'Back to text\n' }] }} />)
expect(screen.getByText('Back to text')).toBeVisible()
expect(ref.current).toBeNull()
expect(destroy).toHaveBeenCalledOnce()
})
})
+14 -1
View File
@@ -1,10 +1,23 @@
import { useEffect, useRef } from 'react'
import Quill from 'quill'
import { useTranslation } from 'react-i18next'
import type { MagicNoteRichContent } from '../../shared/magic-notes-contracts'
import type { MagicNoteContent as NoteContent, MagicNoteRichContent } from '../../shared/magic-notes-contracts'
import { MagicCanvasContent, type MagicCanvasContentHandle } from './MagicCanvasContent'
import './magic-note-embeds'
export function MagicNoteContent({
content, onError, canvasRef
}: {
content: NoteContent
onError?: (message: string) => void
canvasRef?: React.Ref<MagicCanvasContentHandle>
}): React.JSX.Element {
return content.version === 2
? <MagicCanvasContent ref={canvasRef} content={content} onError={onError} />
: <RichNoteContent content={content} />
}
function RichNoteContent({
content
}: {
content: MagicNoteRichContent
+31
View File
@@ -14,6 +14,37 @@ const stylesheet = readFileSync(
)
describe('MagicNoteEditor', () => {
it('establishes the baseline from real Quill normalization and reports later edits separately', () => {
const onReady = vi.fn()
const onChange = vi.fn()
const { container, unmount } = render(<MagicNoteEditor ariaLabel="笔记正文" initialContent={{ version: 1, ops: [{ insert: 'Saved' }] }} onReady={onReady} onChange={onChange} onError={vi.fn()} />)
expect(onReady).toHaveBeenCalledWith({ version: 1, ops: [{ insert: 'Saved\n' }] })
const quill = Quill.find(container.querySelector('.ql-container')!) as Quill
quill.setText('Changed', 'user')
expect(onReady).toHaveBeenCalledOnce()
expect(onChange).toHaveBeenLastCalledWith({ version: 1, ops: [{ insert: 'Changed\n' }] })
unmount()
})
it('emits the initial document when no readiness callback is supplied', () => {
const onChange = vi.fn()
const { unmount } = render(<MagicNoteEditor ariaLabel="笔记正文" onChange={onChange} onError={vi.fn()} />)
expect(onChange).toHaveBeenCalledWith({ version: 1, ops: [{ insert: '\n' }] })
unmount()
})
it('normalizes persisted formatted Delta field order without reporting a new edit', () => {
const stored: MagicNoteRichContent = { version: 1, ops: [{ insert: 'Saved', attributes: { bold: true } }, { insert: '\n' }] }
const onReady = vi.fn()
const onChange = vi.fn()
const { unmount } = render(<MagicNoteEditor ariaLabel="笔记正文" initialContent={stored} onReady={onReady} onChange={onChange} onError={vi.fn()} />)
const normalized = onReady.mock.calls[0]![0]
expect(normalized).toEqual(stored)
expect(JSON.stringify(normalized)).not.toBe(JSON.stringify(stored))
expect(onChange).toHaveBeenCalledOnce()
expect(onChange).toHaveBeenCalledWith(normalized)
unmount()
})
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*\}/
+8 -2
View File
@@ -38,6 +38,7 @@ export type MagicNoteEditorProps = {
ariaInvalid?: boolean
ariaLabel: string
onChange: (content: MagicNoteRichContent) => void
onReady?: (content: MagicNoteRichContent) => void
onError: (message: string) => void
onParagraphCommit?: (content: MagicNoteRichContent) => void
}
@@ -101,6 +102,7 @@ export function MagicNoteEditor({
ariaInvalid = false,
ariaLabel,
onChange,
onReady,
onError,
onParagraphCommit
}: MagicNoteEditorProps): React.JSX.Element {
@@ -111,6 +113,7 @@ export function MagicNoteEditor({
const attachmentInputRef = useRef<HTMLInputElement>(null)
const quillRef = useRef<Quill | null>(null)
const onChangeRef = useRef(onChange)
const onReadyRef = useRef(onReady)
const onErrorRef = useRef(onError)
const onParagraphCommitRef = useRef(onParagraphCommit)
const translateRef = useRef(t)
@@ -118,10 +121,11 @@ export function MagicNoteEditor({
useEffect(() => {
onChangeRef.current = onChange
onReadyRef.current = onReady
onErrorRef.current = onError
onParagraphCommitRef.current = onParagraphCommit
translateRef.current = t
}, [onChange, onError, onParagraphCommit, t])
}, [onChange, onReady, onError, onParagraphCommit, t])
const insertFiles = async (
files: File[],
@@ -259,6 +263,7 @@ export function MagicNoteEditor({
}
}))
)
if (quillRef.current !== quill) return
let index = quill.getSelection(true)?.index ?? quill.getLength() - 1
for (const embed of embeds) {
if (embed.kind === 'image') {
@@ -360,7 +365,8 @@ export function MagicNoteEditor({
}
}
quill.on('text-change', handleChange)
emitChange()
const initialValue = emitChange()
onReadyRef.current?.(initialValue)
return () => {
quill.off('text-change', handleChange)
quillRef.current = null
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -37,7 +37,7 @@ const fontSetup = readFileSync(
)
function themeTokens(selector: string): Record<string, string> {
const selectorIndex = stylesheet.indexOf(selector)
const selectorIndex = stylesheet.indexOf(`${selector} {`)
const blockStart = stylesheet.indexOf('{', selectorIndex)
const blockEnd = stylesheet.indexOf('}', blockStart)
if (selectorIndex < 0 || blockStart < 0 || blockEnd < 0) {
@@ -2,17 +2,23 @@ import type { TranslationShape } from '../../resource-types'
import type { magicNotes as chineseMagicNotes } from '../zh-CN/magicNotes'
export const magicNotes = {
records: { title: 'Records', pane: 'Record index', hide: 'Hide record index', show: 'Show record index', pages: '{{count}} pages', firstPage: 'Canvas first page', previewFailed: 'First page preview unavailable' },
canvas: {
savedAnalysisFailed: 'Entry saved, but automatic analysis failed: {{error}}. You can analyze it again later.',
entryType: 'New entry type', text: 'Text', canvas: 'Canvas', edit: 'Expand canvas editor',
analyzeDraft: 'Analyze canvas draft', notReady: 'Canvas is not ready. Try again shortly.',
discardDescription: 'Switching will discard unsaved text, canvas changes, and attachments.',
inputMode: { text: 'Text only', images: 'Image analysis', 'canvas-images': 'Canvas image analysis', 'text-fallback': 'Text only (canvas images not used)' }
},
page: {
title: 'Magic Notes',
description:
'Capture rich text, local media, attachments, and to-do lists globally, with read-only comments from AI.',
contentLabel: 'Magic Notes content'
},
tabs: {
notes: 'Notes',
todos: 'To-dos'
},
actions: {
switchToTodos: 'Switch to to-dos',
switchToNotes: 'Switch to notes',
more: 'More note actions {{title}}',
cancel: 'Cancel',
retry: 'Retry',
@@ -55,7 +61,7 @@ export const magicNotes = {
noteDeleted: 'Note deleted',
entrySaved: 'Entry saved',
entryDeleted: 'Entry deleted',
entryUpdated: 'Entry updated and previous AI comments cleared',
entryUpdated: 'Entry updated',
aiCommentAdded: 'AI comment added',
todoCompleted: 'To-do completed',
todoReopened: 'To-do marked incomplete'
@@ -1,14 +1,20 @@
export const magicNotes = {
records: { title: '记录', pane: '记录索引', hide: '收起记录索引', show: '展开记录索引', pages: '{{count}} 页', firstPage: '画布首页', previewFailed: '首页预览加载失败' },
canvas: {
savedAnalysisFailed: '记录已保存,但自动分析失败:{{error}}。可稍后重新分析。',
entryType: '新记录类型', text: '文字', canvas: '画布', edit: '展开编辑画布',
analyzeDraft: '分析画布草稿', notReady: '画布尚未加载,请稍后重试',
discardDescription: '切换后,当前未保存的文字、画布和附件将被丢弃。',
inputMode: { text: '仅文字分析', images: '图像分析', 'canvas-images': '画布图像分析', 'text-fallback': '仅文字分析(未使用画布图像)' }
},
page: {
title: '魔法笔记',
description: '全局记录富文本、本地媒体、附件和待办清单,由 AI 提供只读评论。',
contentLabel: '魔法笔记内容'
},
tabs: {
notes: '笔记',
todos: '待办'
},
actions: {
switchToTodos: '切换到待办',
switchToNotes: '切换到笔记',
more: '更多笔记操作 {{title}}',
cancel: '取消',
retry: '重试',
@@ -51,7 +57,7 @@ export const magicNotes = {
noteDeleted: '笔记已删除',
entrySaved: '记录已保存',
entryDeleted: '记录已删除',
entryUpdated: '记录已更新,原 AI 评论已清除',
entryUpdated: '记录已更新',
aiCommentAdded: 'AI 评论已添加',
todoCompleted: '待办已完成',
todoReopened: '待办已恢复为未完成'
+90
View File
@@ -0,0 +1,90 @@
.magic-canvas-editor, .magic-canvas-content {
display: flex;
flex-direction: column;
flex: 0 0 auto;
min-width: 0;
min-height: 0;
height: auto;
font-family: var(--font-family-ui);
}
.magic-canvas-editor .canvas-note-root {
display: flex;
flex-direction: column;
flex: 0 0 auto;
min-width: 0;
min-height: 320px;
overflow: hidden;
border: 1px solid var(--border-default);
border-radius: var(--radius-control);
background: var(--surface-raised);
}
.magic-canvas-editor .canvas-note-toolbar {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
align-items: center;
gap: var(--space-1);
padding: var(--space-2);
background: var(--surface-subtle);
border-bottom: 1px solid var(--border-default);
}
.magic-canvas-editor .canvas-note-tool-group, .magic-canvas-editor .canvas-note-page-controls {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-1);
min-width: 0;
}
.magic-canvas-editor .canvas-note-tool-group { border-right: 1px solid var(--border-subtle); padding-right: var(--space-1); }
.magic-canvas-editor .canvas-note-page-controls { margin-left: auto; }
.magic-canvas-editor .canvas-note-button, .magic-canvas-editor .canvas-note-toolbar select, .magic-canvas-editor .canvas-note-color, .magic-canvas-open {
height: 32px;
min-width: 32px;
padding: var(--space-1);
margin: 0;
border: 1px solid var(--border-control);
border-radius: var(--radius-control);
color: var(--text-primary);
background: var(--surface-raised);
font: inherit;
font-size: var(--font-body);
cursor: pointer;
}
.magic-canvas-editor .canvas-note-button { display: inline-flex; justify-content: center; align-items: center; }
.magic-canvas-editor .canvas-note-icon { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; }
.magic-canvas-editor .canvas-note-color { width: 34px; }
.magic-canvas-editor .canvas-note-page-counter { min-width: 48px; color: var(--text-secondary); text-align: center; font-size: var(--font-body); }
.magic-canvas-editor button:hover:not(:disabled), .magic-canvas-open:hover { background: var(--accent-subtle); border-color: var(--accent); }
.magic-canvas-editor .canvas-note-button.canvas-note-active { background: var(--accent-subtle); border-color: var(--accent-selected); color: var(--accent); }
.magic-canvas-editor .canvas-note-delete-confirm { color: var(--danger); border-color: var(--danger-border); background: var(--danger-subtle); }
.magic-canvas-editor :disabled { opacity: .5; cursor: not-allowed; }
.magic-canvas-editor :focus-visible, .magic-canvas-open:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.magic-canvas-editor .canvas-note-viewport { min-width: 0; min-height: 0; flex: 0 0 auto; overflow: auto; padding: var(--space-4); background: var(--surface-canvas); }
.magic-canvas-editor .canvas-note-page { position: relative; margin: 0 auto; flex-shrink: 0; overflow: hidden; box-shadow: var(--shadow-card); }
.magic-canvas-editor .canvas-note-background { z-index: 0; display: block; }
.magic-canvas-editor .canvas-note-fabric-container { z-index: 2; }
.magic-canvas-editor .canvas-note-fabric-container canvas { display: block; }
.magic-canvas-editor .canvas-flow-layer { position: absolute; z-index: 1; left: 50%; overflow: hidden; transform: translateX(-50%); pointer-events: none; }
.magic-canvas-editor .canvas-flow-active { cursor: text; outline: 1px dashed var(--accent); outline-offset: var(--space-1); }
/* 纸张是导出内容,始终保留实际墨色,不跟随应用主题反色。 */
.magic-canvas-editor .canvas-flow-quill.ql-container.ql-snow { overflow: hidden; border: 0; color: #111827; font-family: var(--font-family-ui); font-size: 16px; }
.magic-canvas-editor .canvas-flow-surface.ql-editor { min-height: 0; max-height: none; padding: 0; overflow: visible; column-fill: auto; color: #111827; line-height: 1.7; }
.magic-canvas-editor .canvas-flow-surface.ql-blank::before { left: 0; right: 0; }
.magic-canvas-editor .canvas-flow-layer:not(.canvas-flow-active) .ql-blank::before { display: none; }
.magic-canvas-editor .canvas-flow-page-break { width: 100%; height: 1px; margin: 0; padding: 0; break-after: column; border: 0; }
.magic-canvas-editor .canvas-flow-toolbar-host { flex: 0 0 auto; background: var(--surface-subtle); border-bottom: 1px solid var(--border-default); }
.magic-canvas-editor .canvas-flow-toolbar-host.hidden { display: none; }
.magic-canvas-editor .canvas-flow-toolbar.ql-toolbar { border: 0; font: inherit; }
.magic-canvas-editor .canvas-flow-toolbar .ql-formats { display: inline-flex; flex-wrap: wrap; align-items: center; gap: var(--space-1); margin: 0; }
.magic-canvas-editor .canvas-flow-toolbar button { width: 32px; height: 32px; }
.magic-canvas-editor .canvas-flow-toolbar .ql-picker { color: var(--text-primary); }
.magic-canvas-editor .canvas-flow-toolbar .ql-stroke { stroke: var(--text-primary); }
.magic-canvas-editor .canvas-flow-toolbar .ql-fill { fill: var(--text-primary); }
.magic-canvas-editor .canvas-flow-toolbar .ql-picker-options { color: var(--text-primary); background: var(--surface-raised); border-color: var(--border-control); box-shadow: var(--shadow-card); }
.magic-canvas-editor .canvas-flow-toolbar button.ql-active .ql-stroke { stroke: var(--accent); }
.magic-canvas-content .canvas-note-tool-group, .magic-canvas-content .canvas-note-add-page, .magic-canvas-content .canvas-note-delete-page { display: none; }
.magic-canvas-open { align-self: flex-end; margin-bottom: var(--space-2); padding-inline: var(--space-3); }
@media (max-width: 720px) {
.magic-canvas-editor .canvas-note-page-controls { flex-basis: 100%; justify-content: flex-end; }
.magic-canvas-editor .canvas-note-viewport { padding: var(--space-2); }
}
+12
View File
@@ -0,0 +1,12 @@
# PeopleLib Canvas Core
`canvas-note.mjs` and `canvas-flow.mjs` are adapted from PeopleLib 2.1.7,
whose package declares the MIT license and author `peoplelib`.
The source repository did not contain a project-level license text.
The original implementation is retained with GoodBuddy module imports,
document-owned assets, lifecycle fixes, and React integration.
Fabric 7.4.0 and jsPDF 4.2.1 are distributed under MIT; Quill is BSD-3-Clause;
PDF.js is Apache-2.0. Their original license files remain in their npm packages.
The application packaging configuration includes the Fabric, jsPDF and Quill
license files and this attribution notice.
+164
View File
@@ -0,0 +1,164 @@
# React Canvas Integration
The shared `MagicNoteCanvasContent` in `src/shared/magic-notes-contracts.ts` is
the persisted contract. `model.ts` adapts it to the retained PeopleLib paged
canvas core. The core stays in ES modules; the React boundary and model adapter
are TypeScript, with an explicit `canvas-note.d.mts` controller declaration.
## Public API
`MagicCanvasEditor.tsx` exports `MagicCanvasEditor` and its types:
```ts
type MagicCanvasEditorProps = {
initialContent?: MagicNoteCanvasContent
onChange(content: MagicNoteCanvasContent): void
onReady?(content: MagicNoteCanvasContent): void
onError(message: string): void
disabled?: boolean
}
type MagicCanvasEditorHandle = {
flush(): Promise<MagicNoteCanvasContent>
focus(): void
capturePages(): Promise<{ pageId: string; dataUrl: string }[]>
}
```
`initialContent` is read once. Use a note ID or edit-session key to remount when
opening another note or replacing the entire document. Ordinary parent renders,
callback changes, and `disabled` changes do not recreate the editor. Await
`flush()` before saving, switching notes, or starting analysis. It includes the
current Fabric objects, pending text, queued imports, and flow pagination.
Initialization keeps the core disabled until its first flush completes. `onReady`
reports that normalized document once per mounted instance, before edits are
enabled. Use it as the dirty comparison baseline for the loaded revision; later
`onChange` callbacks and flushes do not advance the baseline. Imperative flush
waits for initialization, and aborted instances cannot report readiness or changes.
`capturePages()` returns page-sized PNG data URLs containing the paper/PDF
background, flow text, and Fabric annotations. It waits for pending edits and
serializes capture with editor operations. The caller owns error handling for
imperative methods; toolbar failures use `onError`. Pass that callback to the
application notification mechanism.
Record navigation uses `MagicCanvasThumbnail` with a short-lived core renderer.
The core controller accepts `capturePages({ firstPageOnly: true, thumbnailWidth: 240 })`
to composite only page zero, including the background, body and annotations.
Visible thumbnails are queued and cached by content identity; they do not leave
full editors mounted in the record list. The React editor's AI capture method
continues to capture every page.
The same module exports `createEmptyCanvasContent()` and
`canvasHasContent(content?)`.
`MagicCanvasContent.tsx` exports the read-only viewer:
```ts
type MagicCanvasContentProps = {
content: MagicNoteCanvasContent
onError?: (message: string) => void
onEdit?: () => void
}
```
The viewer supports previous/next page. When `onEdit` is supplied, it displays
an edit action for the workspace to open the editor. Passing a new content
reference refreshes the viewer. It does not own a modal or persistence.
## Editing And Assets
- Blank, ruled, grid and dot templates; add/delete pages; PDF pages retain size.
- Pen, highlighter, whole-object eraser, Fabric selection/move/scale/rotation,
floating editable text, and JPEG/PNG/GIF/WebP images.
- Quill body text flows across pages. Adding a page in flow mode inserts a page
break. Deleting a page removes its break and reflows body text. Deleting a
nonempty annotation/PDF page requires a second click within five seconds.
- Undo/redo is per annotation page or Quill body mode, matching PeopleLib.
Page insertion/deletion and PDF import are not document-wide undo steps.
- File inputs import images and PDFs. PDFs replace a pristine single blank page
or are inserted after the current page. A busy import cannot be queued twice.
- Image objects persist `assetId`; PDF backgrounds persist `assetId` and
one-based `pageNumber`. Asset data URLs are stored only in the document's
`assets` array. No PeopleLib draft tokens, file paths, or resource IPC are used.
- Native PDF text is extracted into each PDF background's `text` field. Scanned
PDF pages remain renderable without fabricated text or automatic OCR calls.
- PDF export downloads a Blob using jsPDF. Each output page is a raster
composite, preserving visual annotations and flow text. The exported PDF does
not retain the imported PDF's searchable text layer.
- Flow export writes actual numbered/bullet/checked/unchecked marker spans,
including nested decimal/letter/Roman numbering and list indentation. It does
not rely on Quill's editor-only pseudo-elements.
### Editor Limits
The current core limits are narrower than the shared schema. Shared acceptance
alone does not guarantee that a document fits the editor; the core applies the
following limits on input and serialization.
| Item | Core limit and behavior |
| --- | --- |
| Flow text | 20,000 UTF-16 code units (`string.length`), excluding exactly one terminal newline. Internal newlines count; page-break embeds do not. |
| Flow operations | 5,000 input Delta operations, including newline and page-break operations. |
| Pages | 50, including automatic flow pages and PDF pages. Initial page normalization currently keeps the first 50. Flow beyond 50 pages reports an error. |
| Images | 2 MiB per JPEG/PNG/GIF/WebP image. |
| PDF file input | 32 MiB per input file; imported pages also count toward the 50-page limit. |
| Page dimensions | Positive dimensions at most 3,000 per side, rounded to integers; invalid dimensions fall back to 794 by 1123 defaults per axis. |
| List indent | Integer levels 1 through 8, or omitted for level zero. |
Input validation and serialization use the same flow text/operation checks.
Exactly 20,000 body code units plus Quill's terminal newline round-trip intact.
Oversized user edits revert to the preceding Delta and call `onError`.
Oversized initial/API content throws a `RangeError`; `flush()` rejects rather
than returning a document with missing flow text. Callers must not save a
replacement empty document after that rejection.
Vite imports Fabric, jsPDF and Quill as modules. PDF.js, its worker URL and
binary asset map load asynchronously only when a PDF is imported or displayed;
opening ordinary notes or template-only canvases does not initialize PDF.js.
Each PDF uses an explicit module Worker, avoiding PDF.js's blob
worker fallback under `file://`. `pdf-assets.mjs` maps PDF.js requests to bundled
CMaps, fonts and WASM rather than a remote service, and decodes inlined binary
assets without a CSP-blocked data URL fetch. `NOTICE.md` records source attribution;
packaging includes the dependency licenses.
`magic-canvas.css` scopes styles to these components and uses application theme
tokens. Paper keeps its real ink colors for consistent export. Narrow viewports
wrap the toolbar and scroll horizontally within the paper viewport. Editors and
saved viewers grow naturally to the current page's displayed height plus their
toolbars and padding; vertical scrolling belongs to the surrounding note stream.
Pagination replaces the current page within the same entry, including its height.
## Lifecycle And Validation
The controller is returned synchronously so React cleanup can immediately abort
an initializing editor. PDF module loading remains inside the queued document
load awaited by `flush()`; after loading modules, an aborted editor cannot start
a Worker. Cleanup cancels Fabric restoration, PDF loading/render
tasks and flow animation frames, disconnects the Quill mutation observer, and
waits for queued work before disposing the canvas. Aborted instances cannot
notify the parent.
Focused tests cover template/asset/model round trips, PDF native text and shared
schema acceptance, resource retention for undo, StrictMode cleanup, asynchronous
flush, and callback/disabled updates without remounting.
The isolated Electron probe additionally exercised real pen/highlighter input,
eraser, selection transforms, floating text, image insertion, undo/redo, page
creation, 100 paragraphs flowing into three pages, PNG capture, read-only
pagination, two-page native-text PDF import, one change callback per import,
PDF download, dark/narrow layout, and six rapid PDF editor remounts. No model
requests were made. The same scenario passed against the Vite production build
loaded through `file://` with the application's unmodified CSP. Workspace saving,
SQLite persistence and AI integration are documented in the
[feature technical design](../../../../docs/features/magic-notes/technical-design.md);
current validation status is in the
[feature progress record](../../../../docs/features/magic-notes/progress.md).
The review regression probe also passed in real Electron against a production
`file://` build: a 20,000-character Chinese body survived flush, excess user input
was reverted, and an oversized API edit rejected flush. Captured PNG and the
exported PDF re-rendered through PDF.js both contained all ten marker regions
(decimal/letter/Roman, bullet, checked/unchecked, nested bullet and restarted
numbering), with identical ink-pixel counts in the corresponding regions.
@@ -0,0 +1,484 @@
// Adapted from PeopleLib (MIT). See NOTICE.md.
/* global document, TextEncoder, btoa, Image, cancelAnimationFrame, requestAnimationFrame, XMLSerializer, getComputedStyle */
import Quill from 'quill';
let pageBreakRegistered = false;
const FLOW_VERSION = 1;
const MAX_OPS = 5000;
const MAX_TEXT = 20000;
const PAGE_ID_RE = /^[a-z0-9][a-z0-9_-]{0,127}$/i;
const INLINE_FORMATS = ['bold', 'italic', 'underline', 'strike', 'code'];
const BLOCK_FORMATS = ['header', 'blockquote', 'code-block', 'list', 'indent'];
const FLOW_FORMATS = [...INLINE_FORMATS, ...BLOCK_FORMATS, 'canvasPageBreak'];
function cloneJson(value) {
return JSON.parse(JSON.stringify(value));
}
function normalizedAttributes(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const result = {};
for (const key of INLINE_FORMATS) {
if (value[key] === true) result[key] = true;
}
if (value.header === 1 || value.header === 2) result.header = value.header;
if (value.blockquote === true) result.blockquote = true;
if (value['code-block'] === true || value['code-block'] === 'plain') {
result['code-block'] = 'plain';
}
if (['ordered', 'bullet', 'checked', 'unchecked'].includes(value.list)) result.list = value.list;
if (Number.isInteger(value.indent) && value.indent >= 1 && value.indent <= 8) result.indent = value.indent;
return Object.keys(result).length ? result : null;
}
export function normalizeFlowContent(value) {
if (!value || value.version !== FLOW_VERSION || !Array.isArray(value.ops)) return null;
if (value.ops.length > MAX_OPS) throw new RangeError(`全局文本最多支持 ${MAX_OPS.toLocaleString()} 个格式片段`);
const ops = [];
const pageIds = new Set();
let textLength = 0;
for (const raw of value.ops) {
if (!raw || typeof raw !== 'object' || !Object.hasOwn(raw, 'insert')) continue;
if (typeof raw.insert === 'string') {
textLength += raw.insert.length;
if (!raw.insert) continue;
const attributes = normalizedAttributes(raw.attributes);
ops.push({
insert: raw.insert,
...(attributes ? { attributes } : {})
});
continue;
}
const pageId = raw.insert && typeof raw.insert === 'object'
? String(raw.insert.canvasPageBreak || '')
: '';
if (!PAGE_ID_RE.test(pageId) || pageIds.has(pageId)) continue;
pageIds.add(pageId);
ops.push({ insert: { canvasPageBreak: pageId } });
}
// Quill 的最后一个换行是文档终止符,不计入正文;内部换行仍计数。
const lastInsert = ops.at(-1)?.insert;
if (typeof lastInsert === 'string' && lastInsert.endsWith('\n')) textLength -= 1;
if (textLength > MAX_TEXT) throw new RangeError(`全局文本最多支持 ${MAX_TEXT.toLocaleString()} 个字符(不含末尾换行)`);
return textLength > 0 || ops.some((op) => typeof op.insert === 'object')
? { version: FLOW_VERSION, ops } : null;
}
export function flowPlainText(value) {
const flow = normalizeFlowContent(value);
if (!flow) return '';
return flow.ops
.filter((op) => typeof op.insert === 'string')
.map((op) => op.insert)
.join('')
.replace(/\n$/, '');
}
function registerPageBreak(Quill) {
if (pageBreakRegistered) return;
const BlockEmbed = Quill.import('blots/block/embed');
class CanvasPageBreak extends BlockEmbed {
static create(value) {
const node = super.create();
const pageId = String(value || '');
if (PAGE_ID_RE.test(pageId)) node.dataset.pageId = pageId;
node.setAttribute('aria-hidden', 'true');
return node;
}
static value(node) {
return String(node?.dataset?.pageId || '');
}
}
CanvasPageBreak.blotName = 'canvasPageBreak';
CanvasPageBreak.tagName = 'div';
CanvasPageBreak.className = 'canvas-flow-page-break';
Quill.register(CanvasPageBreak, true);
pageBreakRegistered = true;
}
function makeFormatButton(name, title, value = null) {
const button = document.createElement('button');
button.type = 'button';
button.className = `ql-${name}`;
if (value != null) button.value = value;
button.title = title;
button.setAttribute('aria-label', title);
return button;
}
function createToolbar() {
const toolbar = document.createElement('div');
toolbar.className = 'canvas-flow-toolbar ql-toolbar ql-snow';
toolbar.setAttribute('role', 'toolbar');
toolbar.setAttribute('aria-label', '全局文本格式');
const formats = document.createElement('span');
formats.className = 'ql-formats';
const header = document.createElement('select');
header.className = 'ql-header';
header.title = '段落样式';
[
['', '正文'],
['1', '一级标题'],
['2', '二级标题']
].forEach(([value, label], index) => {
const option = document.createElement('option');
option.value = value;
option.textContent = label;
option.selected = index === 0;
header.appendChild(option);
});
formats.append(
header,
makeFormatButton('bold', '加粗'),
makeFormatButton('italic', '斜体'),
makeFormatButton('underline', '下划线'),
makeFormatButton('strike', '删除线'),
makeFormatButton('blockquote', '引用'),
makeFormatButton('code-block', '代码块'),
makeFormatButton('list', '有序列表', 'ordered'),
makeFormatButton('list', '无序列表', 'bullet'),
makeFormatButton('list', '待办清单', 'check')
);
toolbar.appendChild(formats);
return toolbar;
}
function dataUrl(value) {
const bytes = new TextEncoder().encode(value);
let binary = '';
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
}
return `data:image/svg+xml;base64,${btoa(binary)}`;
}
function imageFromUrl(url) {
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error('全局文本导出失败'));
image.src = url;
});
}
export function mountFlowText(layerHost, toolbarHost, initialContent, options = {}) {
const initial = normalizeFlowContent(initialContent);
registerPageBreak(Quill);
layerHost.textContent = '';
toolbarHost.textContent = '';
const toolbar = createToolbar();
const editorHost = document.createElement('div');
editorHost.className = 'canvas-flow-quill';
layerHost.appendChild(editorHost);
toolbarHost.appendChild(toolbar);
const quill = new Quill(editorHost, {
theme: 'snow',
placeholder: '输入正文,内容超过纸张后会自动分页',
formats: FLOW_FORMATS,
modules: {
toolbar,
history: {
delay: 700,
maxStack: 100,
userOnly: true
}
}
});
const surface = quill.root;
surface.classList.add('canvas-flow-surface');
surface.setAttribute('aria-label', '画布全局文本');
surface.setAttribute('aria-multiline', 'true');
if (initial) quill.setContents(initial.ops, 'silent');
quill.history.clear();
let destroyed = false;
let active = false;
let frame = 0;
let secondFrame = 0;
let measuredPages = 1;
let suppressUserFollowSelection = false;
let layout = { width: 640, height: 960, gap: 48, pageIndex: 0 };
let pendingResolvers = [];
function content() {
return normalizeFlowContent({
version: FLOW_VERSION,
ops: quill.getContents().ops
});
}
function resolvePending() {
const resolvers = pendingResolvers;
pendingResolvers = [];
resolvers.forEach((resolve) => resolve());
}
function pageCount() {
const rootRect = surface.getBoundingClientRect();
const span = layout.width + layout.gap;
let maxColumn = 0;
for (const child of surface.children) {
for (const rect of child.getClientRects()) {
const relativeLeft = rect.left - rootRect.left;
maxColumn = Math.max(maxColumn, Math.max(0, Math.round(relativeLeft / span)));
}
}
const scrollColumns = Math.max(1, Math.ceil(
(Math.max(layout.width, surface.scrollWidth) + layout.gap) / span
));
return Math.max(1, maxColumn + 1, scrollColumns);
}
function measure() {
if (destroyed) return;
const next = pageCount();
if (next !== measuredPages) {
measuredPages = next;
options.onPageCount?.(next);
}
options.onHistoryChange?.();
resolvePending();
}
function selectionPage() {
const range = quill.getSelection();
if (!range) return layout.pageIndex;
const index = Math.min(Math.max(0, range.index), Math.max(0, quill.getLength() - 1));
const bounds = quill.getBounds(index, Math.max(0, range.length));
const span = layout.width + layout.gap;
return Math.max(0, Math.round((Number(bounds?.left) || 0) / span));
}
function followSelection() {
if (!active || destroyed) return;
options.onActivePage?.(selectionPage());
}
function scheduleLayout() {
if (destroyed) return Promise.resolve();
const promise = new Promise((resolve) => pendingResolvers.push(resolve));
if (frame) cancelAnimationFrame(frame);
if (secondFrame) cancelAnimationFrame(secondFrame);
frame = requestAnimationFrame(() => {
frame = 0;
secondFrame = requestAnimationFrame(() => {
secondFrame = 0;
measure();
});
});
return promise;
}
function applyLayout() {
const container = surface.parentElement;
const span = layout.width + layout.gap;
layerHost.style.width = `${layout.width}px`;
layerHost.style.height = `${layout.height}px`;
if (container) {
container.style.width = `${layout.width}px`;
container.style.height = `${layout.height}px`;
}
surface.style.width = `${layout.width}px`;
surface.style.height = `${layout.height}px`;
surface.style.columnWidth = `${layout.width}px`;
surface.style.columnGap = `${layout.gap}px`;
surface.style.transform = `translateX(${-layout.pageIndex * span}px)`;
scheduleLayout();
}
function findPageBreak(pageId) {
const target = String(pageId || '');
let index = 0;
for (const op of quill.getContents().ops) {
if (op.insert && typeof op.insert === 'object'
&& op.insert.canvasPageBreak === target) return index;
index += typeof op.insert === 'string' ? op.insert.length : 1;
}
return -1;
}
quill.on('text-change', (delta, oldDelta, source) => {
if (source === 'user') {
try {
content();
} catch (error) {
quill.setContents(oldDelta, 'silent');
options.onError?.(error.message);
scheduleLayout();
return;
}
}
const layoutPromise = scheduleLayout();
options.onHistoryChange?.();
if (source === 'user') {
const follow = !suppressUserFollowSelection;
suppressUserFollowSelection = false;
options.onChange?.(content(), delta, oldDelta);
if (follow) layoutPromise.then(followSelection);
}
});
quill.on('selection-change', (range, oldRange, source) => {
if (source === 'user' && range) requestAnimationFrame(followSelection);
});
return {
pageCount,
content,
text: () => flowPlainText(content()),
hasContent: () => !!flowPlainText(content()).trim(),
pageBreakIds() {
const ids = [];
for (const op of content()?.ops || []) {
if (op.insert && typeof op.insert === 'object' && op.insert.canvasPageBreak) {
ids.push(op.insert.canvasPageBreak);
}
}
return ids;
},
activePageIndex: selectionPage,
insertPageBreak(pageId) {
const id = String(pageId || '');
if (!PAGE_ID_RE.test(id) || findPageBreak(id) >= 0) return false;
const range = quill.getSelection();
const index = range
? Math.min(quill.getLength() - 1, range.index + range.length)
: Math.max(0, quill.getLength() - 1);
suppressUserFollowSelection = true;
quill.insertEmbed(index, 'canvasPageBreak', id, 'user');
quill.setSelection(index + 1, 0, 'silent');
scheduleLayout();
return true;
},
removePageBreak(pageId) {
const index = findPageBreak(pageId);
if (index < 0) return false;
suppressUserFollowSelection = true;
quill.deleteText(index, 1, 'user');
if (index > 0 && quill.getText(index - 1, 1) === '\n') {
suppressUserFollowSelection = true;
quill.deleteText(index - 1, 1, 'user');
}
scheduleLayout();
return true;
},
setActive(nextActive) {
active = Boolean(nextActive);
toolbarHost.classList.toggle('hidden', !active);
layerHost.classList.toggle('canvas-flow-active', active);
quill.enable(active);
},
setBusy(value) {
quill.enable(active && !value);
},
setLayout(width, height, pageIndex) {
layout = {
width: Math.max(240, Math.round(Number(width) || 640)),
height: Math.max(240, Math.round(Number(height) || 960)),
gap: 48,
pageIndex: Math.max(0, Math.round(Number(pageIndex) || 0))
};
applyLayout();
},
setPageIndex(pageIndex) {
layout.pageIndex = Math.max(0, Math.round(Number(pageIndex) || 0));
applyLayout();
},
focus() {
if (!active) return;
quill.focus();
},
undo() {
quill.history.undo();
scheduleLayout();
},
redo() {
quill.history.redo();
scheduleLayout();
},
canUndo: () => (quill.history.stack?.undo?.length || 0) > 0,
canRedo: () => (quill.history.stack?.redo?.length || 0) > 0,
async flush() {
await scheduleLayout();
return cloneJson(content());
},
async renderPage(pageIndex, pageWidth, pageHeight) {
const canvas = document.createElement('canvas');
canvas.width = pageWidth;
canvas.height = pageHeight;
if (!flowPlainText(content()).trim()) return canvas;
const clone = surface.cloneNode(true);
const originals = [surface, ...surface.querySelectorAll('*')];
const copies = [clone, ...clone.querySelectorAll('*')];
const properties = ['font-family', 'font-size', 'font-weight', 'font-style', 'line-height',
'text-decoration', 'text-align', 'white-space', 'word-break', 'overflow-wrap',
'padding', 'margin', 'border', 'background-color', 'color', 'column-fill', 'break-after',
'position', 'list-style-type'];
originals.forEach((node, index) => {
const computed = getComputedStyle(node);
properties.forEach((property) => copies[index].style.setProperty(property, computed.getPropertyValue(property)));
});
clone.removeAttribute('contenteditable');
clone.classList.remove('ql-blank');
clone.querySelectorAll('.ql-ui, .ql-cursor').forEach((node) => node.remove());
const counters = Array(9).fill(0);
for (const node of clone.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li[data-list]')) {
if (node.tagName !== 'LI') {
counters.fill(0);
continue;
}
const depth = Number(node.className.match(/\bql-indent-([1-8])\b/)?.[1] || 0);
counters.fill(0, depth + 1);
let label = { bullet: '\u2022', checked: '\u2611', unchecked: '\u2610' }[node.dataset.list];
if (node.dataset.list === 'ordered') {
let number = ++counters[depth];
label = '';
if (depth % 3 === 1) {
while (number > 0) {
number -= 1;
label = String.fromCharCode(97 + number % 26) + label;
number = Math.floor(number / 26);
}
} else if (depth % 3 === 2) {
for (const [value, roman] of [[1000, 'm'], [900, 'cm'], [500, 'd'], [400, 'cd'], [100, 'c'], [90, 'xc'], [50, 'l'], [40, 'xl'], [10, 'x'], [9, 'ix'], [5, 'v'], [4, 'iv'], [1, 'i']]) {
while (number >= value) { label += roman; number -= value; }
}
} else label = String(number);
label += '.';
}
const marker = document.createElement('span');
marker.textContent = label || '';
marker.style.cssText = 'position:absolute;display:inline-block;margin-left:-1.5em;width:1.2em;text-align:right;white-space:nowrap';
node.prepend(marker);
}
clone.style.position = 'relative';
clone.style.margin = '0';
clone.style.padding = '0';
clone.style.overflow = 'visible';
clone.style.color = '#111827';
clone.style.background = 'transparent';
clone.style.transform = `translateX(${-Math.max(0, pageIndex) * (layout.width + layout.gap)}px)`;
const x = Math.max(0, Math.round((pageWidth - layout.width) / 2));
const y = Math.max(0, Math.round((pageHeight - layout.height) / 2));
const serialized = new XMLSerializer().serializeToString(clone);
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${pageWidth}" height="${pageHeight}"><foreignObject x="${x}" y="${y}" width="${layout.width}" height="${layout.height}"><div xmlns="http://www.w3.org/1999/xhtml" style="width:${layout.width}px;height:${layout.height}px;overflow:hidden;font-family:'Microsoft YaHei','Segoe UI',sans-serif;font-size:16px;line-height:1.7;color:#111827">${serialized}</div></foreignObject></svg>`;
const image = await imageFromUrl(dataUrl(svg));
canvas.getContext('2d')?.drawImage(image, 0, 0, pageWidth, pageHeight);
return canvas;
},
destroy() {
destroyed = true;
quill.disable();
quill.off('text-change');
quill.off('selection-change');
quill.scroll.observer.disconnect();
if (frame) cancelAnimationFrame(frame);
if (secondFrame) cancelAnimationFrame(secondFrame);
resolvePending();
toolbarHost.textContent = '';
layerHost.textContent = '';
}
};
}
@@ -0,0 +1,85 @@
/* global document, HTMLCanvasElement, XMLSerializer */
import { afterEach, describe, expect, it, vi } from 'vitest';
import Quill from 'quill';
import { flowPlainText, mountFlowText, normalizeFlowContent } from './canvas-flow.mjs';
const mounted = [];
afterEach(() => {
mounted.splice(0).forEach((editor) => editor.destroy());
document.body.replaceChildren();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('exports actual list labels, nested numbering, checkbox states and indentation', async () => {
const rows = [['ordered', 0], ['ordered', 0], ['ordered', 1], ['ordered', 2], ['bullet', 0], ['checked', 0], ['unchecked', 0]];
const ops = rows.flatMap(([list, indent]) => [{ insert: 'Body' }, { insert: '\n', attributes: { list, ...(indent ? { indent } : {}) } }]);
ops.push({ insert: 'Reset\n' }, { insert: 'Body' }, { insert: '\n', attributes: { list: 'ordered' } });
const { editor } = mount({ version: 1, ops });
const sourceItems = [...document.querySelectorAll('.ql-editor li')];
sourceItems.forEach((item) => { item.style.listStyleType = 'none'; });
sourceItems[2].style.padding = '0 0 0 72px';
sourceItems[3].style.padding = '0 0 0 120px';
let clone;
const serialize = XMLSerializer.prototype.serializeToString;
vi.spyOn(XMLSerializer.prototype, 'serializeToString').mockImplementation(function (node) {
clone = node.cloneNode(true);
return serialize.call(this, node);
});
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ drawImage: vi.fn() });
vi.stubGlobal('Image', class {
set src(value) { if (value) this.onload(); }
});
await editor.renderPage(0, 794, 1123);
const items = [...clone.querySelectorAll('li')];
expect(items.map((item) => item.firstChild.textContent)).toEqual(['1.', '2.', 'a.', 'i.', '\u2022', '\u2611', '\u2610', '1.']);
expect(items[2].style.paddingLeft).toBe('72px');
expect(items[3].style.paddingLeft).toBe('120px');
expect(items.every((item) => item.style.listStyleType === 'none')).toBe(true);
expect(clone.querySelector('.ql-ui')).toBeNull();
});
function mount(initial) {
const layer = document.createElement('div');
const toolbar = document.createElement('div');
document.body.append(layer, toolbar);
const onError = vi.fn();
const editor = mountFlowText(layer, toolbar, initial, { onError });
mounted.push(editor);
return { editor, quill: Quill.find(layer.firstElementChild), onError };
}
describe('canvas flow limits', () => {
it('round trips exactly 20,000 characters with a separate formatted terminal newline', () => {
const content = { version: 1, ops: [{ insert: '中'.repeat(20000) }, { insert: '\n', attributes: { list: 'checked', indent: 2 } }] };
expect(normalizeFlowContent(content)).toEqual(content);
expect(flowPlainText(content)).toHaveLength(20000);
expect(normalizeFlowContent({ version: 1, ops: [{ insert: 'x'.repeat(20000) }] }).ops[0].insert).toHaveLength(20000);
});
it('counts internal newlines but not page-break embeds or the single terminal newline', () => {
const content = { version: 1, ops: [{ insert: 'x'.repeat(19999) + '\n' }, { insert: { canvasPageBreak: 'page-2' } }, { insert: '\n' }] };
expect(normalizeFlowContent(content)).toEqual(content);
expect(() => normalizeFlowContent({ version: 1, ops: [{ insert: 'x'.repeat(20000) + '\n\n' }] })).toThrow(/20,000/);
expect(normalizeFlowContent({ version: 1, ops: [{ insert: ' \n' }] })).not.toBeNull();
});
it('rejects oversized initial documents and operation arrays instead of returning null', () => {
const content = { version: 1, ops: [{ insert: 'x'.repeat(20001) + '\n' }] };
expect(() => normalizeFlowContent(content)).toThrow(/20,000/);
expect(() => mount(content)).toThrow(/20,000/);
expect(() => normalizeFlowContent({ version: 1, ops: Array.from({ length: 5001 }, () => ({ insert: 'x' })) })).toThrow(/5,000/);
});
it('flush preserves the boundary, rolls back excess user input, and rejects oversized API edits', async () => {
const { editor, quill, onError } = mount();
quill.setText('中'.repeat(20000), 'user');
expect(flowPlainText(await editor.flush())).toHaveLength(20000);
quill.insertText(20000, 'x', 'user');
expect(onError).toHaveBeenCalledWith(expect.stringContaining('20,000'));
expect(flowPlainText(await editor.flush())).toHaveLength(20000);
quill.setText('x'.repeat(20001), 'api');
await expect(editor.flush()).rejects.toThrow(/20,000/);
expect(quill.getText()).toHaveLength(20002);
});
});
@@ -0,0 +1,22 @@
import type { CoreContent } from './model'
export interface CanvasController {
content(): CoreContent
flush(): Promise<CoreContent>
capturePages(options?: { firstPageOnly?: boolean; thumbnailWidth?: number }): Promise<{ pageId: string; dataUrl: string }[]>
focus(): void
setDisabled(value: boolean): void
destroy(): Promise<void>
hasContent(): boolean
}
export function mountCanvasNote(host: HTMLElement, initialContent: CoreContent, options: {
disabled?: boolean
signal?: AbortSignal
onChange?(content: CoreContent): void
onError?(message: string): void
pickPdf?(): Promise<{ assetId: string; name: string } | null>
readPdf?(reference: { assetId: string }): Promise<Uint8Array>
onPdfText?(assetId: string, pageNumber: number, text: string): void
savePdf?(bytes: Uint8Array, name: string): Promise<void>
}): CanvasController
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,10 @@
import { expect, it, vi } from 'vitest';
it('loads the real canvas core without initializing PDF.js or requiring DOMMatrix', async () => {
vi.stubGlobal('DOMMatrix', undefined);
try {
await expect(import('./canvas-note.mjs')).resolves.toHaveProperty('mountCanvasNote', expect.any(Function));
} finally {
vi.unstubAllGlobals();
}
});
@@ -0,0 +1,120 @@
/* global HTMLCanvasElement, document, WheelEvent */
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { mountCanvasNote } from './canvas-note.mjs';
vi.mock('fabric', () => ({
FabricObject: {},
Canvas: class {
constructor(element) { this.upperCanvasEl = element; }
on() {}
getObjects() { return []; }
toObject() { return { objects: [] }; }
discardActiveObject() {}
requestRenderAll() {}
setDimensions() {}
async loadFromJSON() {}
async dispose() {}
},
FabricImage: class {}, IText: class {}, PencilBrush: class {}, StaticCanvas: class {}
}));
vi.mock('./canvas-flow.mjs', () => ({
normalizeFlowContent: () => null,
mountFlowText: (host) => {
host.innerHTML = '<div contenteditable="true"><p>Body text</p></div>';
return {
setActive() {}, setLayout() {}, focus() {}, destroy() {},
canUndo: () => false, canRedo: () => false, content: () => null,
flush: async () => {}
};
}
}));
let editor;
let host;
let viewport;
let target;
beforeEach(async () => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
save() {}, restore() {}, setTransform() {}, clearRect() {}, fillRect() {}
});
host = document.createElement('div');
document.body.append(host);
editor = mountCanvasNote(host);
await editor.flush();
viewport = host.querySelector('.canvas-note-viewport');
target = host.querySelector('.canvas-note-interaction-canvas');
Object.defineProperties(viewport, {
clientWidth: { value: 500, configurable: true },
clientHeight: { value: 400, configurable: true },
scrollWidth: { value: 1200, configurable: true },
scrollHeight: { value: 1400, configurable: true }
});
});
afterEach(async () => {
await editor.destroy();
host.remove();
vi.restoreAllMocks();
});
function wheel(init = {}) {
const event = new WheelEvent('wheel', { bubbles: true, cancelable: true, ...init });
target.dispatchEvent(event);
return event;
}
it.each(['drawing', 'flow', 'readonly'])('scrolls both axes over %s without changing content', (mode) => {
if (mode === 'readonly') editor.setDisabled(true);
else host.querySelector(`[data-tool="${mode === 'flow' ? 'flow-text' : 'pen'}"]`).click();
if (mode === 'flow') target = host.querySelector('.canvas-flow-layer p');
const content = editor.content();
expect(wheel({ deltaX: 120 }).defaultPrevented).toBe(true);
expect([viewport.scrollLeft, viewport.scrollTop]).toEqual([120, 0]);
wheel({ deltaX: -30, deltaY: 40 });
expect([viewport.scrollLeft, viewport.scrollTop]).toEqual([90, 40]);
wheel({ deltaY: 50, shiftKey: true });
expect([viewport.scrollLeft, viewport.scrollTop]).toEqual([140, 40]);
wheel({ deltaX: -60, shiftKey: true });
expect([viewport.scrollLeft, viewport.scrollTop]).toEqual([80, 40]);
wheel({ deltaY: -20 });
expect([viewport.scrollLeft, viewport.scrollTop]).toEqual([80, 20]);
expect(editor.content()).toEqual(content);
});
it('converts line and page wheel units on the destination axis', () => {
wheel({ deltaX: 2, deltaY: 3, deltaMode: 1 });
expect([viewport.scrollLeft, viewport.scrollTop]).toEqual([32, 48]);
wheel({ deltaY: 1, deltaMode: 2, shiftKey: true });
expect([viewport.scrollLeft, viewport.scrollTop]).toEqual([532, 48]);
wheel({ deltaY: 1, deltaMode: 2 });
expect([viewport.scrollLeft, viewport.scrollTop]).toEqual([532, 448]);
});
it('clamps at edges and only consumes events that can move the viewport', () => {
expect(wheel({ deltaX: -100, deltaY: -100 }).defaultPrevented).toBe(false);
expect(wheel({ deltaX: 2000, deltaY: 2000 }).defaultPrevented).toBe(true);
expect([viewport.scrollLeft, viewport.scrollTop]).toEqual([700, 1000]);
expect(wheel({ deltaX: 100 }).defaultPrevented).toBe(false);
expect(wheel({ deltaY: 100 }).defaultPrevented).toBe(false);
expect(wheel({ deltaY: 100, shiftKey: true }).defaultPrevented).toBe(false);
expect(wheel({ deltaX: 100, deltaY: -20 }).defaultPrevented).toBe(true);
expect([viewport.scrollLeft, viewport.scrollTop]).toEqual([700, 980]);
expect(wheel().defaultPrevented).toBe(false);
});
it('leaves unscrollable axes, Ctrl zoom and previously handled events alone', () => {
Object.defineProperty(viewport, 'scrollWidth', { value: 500 });
expect(wheel({ deltaX: 100 }).defaultPrevented).toBe(false);
expect(wheel({ deltaY: 100, shiftKey: true }).defaultPrevented).toBe(false);
expect(wheel({ deltaY: 100, ctrlKey: true }).defaultPrevented).toBe(false);
target.addEventListener('wheel', event => event.preventDefault(), { once: true });
wheel({ deltaY: 100 });
expect([viewport.scrollLeft, viewport.scrollTop]).toEqual([0, 0]);
Object.defineProperty(viewport, 'scrollHeight', { value: 400 });
expect(wheel({ deltaY: 100 }).defaultPrevented).toBe(false);
});
it('removes the wheel listener on destroy', async () => {
await editor.destroy();
expect(wheel({ deltaX: 100, deltaY: 100 }).defaultPrevented).toBe(false);
expect([viewport.scrollLeft, viewport.scrollTop]).toEqual([0, 0]);
});
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { canvasHasContent, createEmptyCanvasContent, fromCoreContent, toCoreContent } from './model'
import { magicNoteCanvasContentSchema } from '../../../shared/magic-notes-contracts'
describe('canvas document adapter', () => {
it('round trips all templates and page identity through the PeopleLib model', () => {
const content = createEmptyCanvasContent()
content.pages = ['blank', 'ruled', 'grid', 'dot'].map((template, index) => ({
...content.pages[0]!, id: `page-${index}`, background: { type: 'template', template: template as 'blank' | 'ruled' | 'grid' | 'dot' }
}))
expect(fromCoreContent(toCoreContent(content), [])).toEqual(content)
expect(canvasHasContent(content)).toBe(true)
expect(canvasHasContent(createEmptyCanvasContent())).toBe(false)
})
it('deduplicates image assets and restores src only in the renderer model', () => {
const core = toCoreContent(createEmptyCanvasContent())
const image = { type: 'Image', canvasKind: 'image', src: 'data:image/png;base64,AAAA', left: 10, scaleX: 2 }
core.pages[0]!.objects = [image, { ...image, left: 50 }]
const assets: ReturnType<typeof createEmptyCanvasContent>['assets'] = []
const content = fromCoreContent(core, assets)
expect(content.assets).toHaveLength(1)
expect(content.pages[0]!.objects[0]).not.toHaveProperty('src')
expect(content.pages[0]!.objects[0]!.assetId).toBe(content.pages[0]!.objects[1]!.assetId)
expect(toCoreContent(content)).toEqual(core)
expect(fromCoreContent(core, assets)).toEqual(content)
expect(magicNoteCanvasContentSchema.parse(content)).toEqual(content)
})
it('preserves PDF native text, page number and flow breaks in the shared contract', () => {
const core = toCoreContent(createEmptyCanvasContent())
core.pages[0]!.background = { type: 'pdf', assetId: 'pdf-1', page: 3 }
core.flow = { version: 1, ops: [{ insert: '正文\n' }, { insert: { canvasPageBreak: 'next-page' } }] }
const assets = [{ id: 'pdf-1', name: 'test.pdf', mimeType: 'application/pdf', dataUrl: 'data:application/pdf;base64,JVBERg==' }]
const result = fromCoreContent(core, assets, [{ assetId: 'pdf-1', pageNumber: 3, text: 'Native PDF text' }])
expect(result.pages[0]!.background).toEqual({ type: 'pdf', assetId: 'pdf-1', pageNumber: 3, text: 'Native PDF text' })
expect(magicNoteCanvasContentSchema.parse(result)).toEqual(result)
expect(toCoreContent(result)).toEqual(core)
})
it('removes unreferenced assets only from output, retaining undo resources', () => {
const assets = [{ id: 'old', name: 'old.png', mimeType: 'image/png', dataUrl: 'data:image/png;base64,AAAA' }]
expect(fromCoreContent(toCoreContent(createEmptyCanvasContent()), assets).assets).toEqual([])
expect(assets).toHaveLength(1)
})
})
+84
View File
@@ -0,0 +1,84 @@
import type { MagicNoteCanvasContent } from '../../../shared/magic-notes-contracts'
export type { MagicNoteCanvasContent } from '../../../shared/magic-notes-contracts'
export type CanvasFlow = NonNullable<MagicNoteCanvasContent['flow']>
export type CanvasAsset = MagicNoteCanvasContent['assets'][number]
export type PdfPageText = { assetId: string; pageNumber: number; text: string }
export type CoreContent = {
version: 2
pages: {
id: string
width: number
height: number
background: { type: 'template'; template: string } | { type: 'pdf'; assetId: string; page: number }
objects: Record<string, unknown>[]
flowAuto?: boolean
}[]
flow?: CanvasFlow
}
export function createEmptyCanvasContent(): MagicNoteCanvasContent {
return {
version: 2, kind: 'paged-canvas', assets: [],
pages: [{ id: crypto.randomUUID(), width: 794, height: 1123, background: { type: 'template', template: 'blank' }, objects: [] }]
}
}
export function canvasHasContent(content?: MagicNoteCanvasContent): boolean {
return !!content && (content.pages.length > 1 || content.pages.some((page) =>
page.objects.length > 0 || page.background.type === 'pdf' || page.background.template !== 'blank'
) || !!content.flow?.ops.some((op) => typeof op.insert === 'string' && op.insert.trim()))
}
export function toCoreContent(content: MagicNoteCanvasContent): CoreContent {
return {
version: 2,
pages: content.pages.map((page) => ({
...page,
background: page.background.type === 'pdf'
? { type: 'pdf', assetId: page.background.assetId, page: page.background.pageNumber }
: { type: 'template', template: page.background.template === 'ruled' ? 'lined' : page.background.template === 'dot' ? 'dots' : page.background.template },
objects: page.objects.map((object) => {
if (object.canvasKind !== 'image') return structuredClone(object)
const asset = content.assets.find((item) => item.id === object.assetId)
if (!asset && typeof object.src === 'string') return structuredClone(object)
if (!asset) throw new Error(`图片资源不可用:${String(object.assetId)}`)
const rest = { ...object }
delete rest.assetId
return { ...rest, src: asset.dataUrl }
})
})),
...(content.flow ? { flow: { ...content.flow, version: 1 } } : {})
}
}
export function fromCoreContent(core: CoreContent, assets: CanvasAsset[], pdfText: PdfPageText[] = []): MagicNoteCanvasContent {
const used = new Set<string>()
const pages = core.pages.map((page): MagicNoteCanvasContent['pages'][number] => {
if (page.background.type === 'pdf') used.add(page.background.assetId)
return {
...page,
background: page.background.type === 'pdf'
? { type: 'pdf', assetId: page.background.assetId, pageNumber: page.background.page,
text: pdfText.find((item) => page.background.type === 'pdf' && item.assetId === page.background.assetId && item.pageNumber === page.background.page)?.text }
: { type: 'template', template: page.background.template === 'lined' ? 'ruled' : page.background.template === 'dots' ? 'dot' : page.background.template as 'blank' | 'grid' },
objects: page.objects.map((object) => {
if (object.canvasKind !== 'image' || typeof object.src !== 'string') return structuredClone(object)
let asset = assets.find((item) => item.dataUrl === object.src)
if (!asset) {
asset = { id: crypto.randomUUID(), name: '画布图片', mimeType: object.src.slice(5, object.src.indexOf(';')), dataUrl: object.src }
assets.push(asset)
}
used.add(asset.id)
const rest = { ...object }
delete rest.src
return { ...rest, assetId: asset.id }
})
}
})
return {
version: 2, kind: 'paged-canvas', pages,
...(core.flow ? { flow: structuredClone(core.flow) } : {}),
assets: structuredClone(assets.filter((asset) => used.has(asset.id)))
}
}
@@ -0,0 +1,19 @@
/* global fetch, atob */
const urls = import.meta.glob('../../../../node_modules/pdfjs-dist/{cmaps,standard_fonts,wasm}/*', {
query: '?url', import: 'default', eager: true
});
const directories = { cMapUrl: 'cmaps', standardFontDataUrl: 'standard_fonts', wasmUrl: 'wasm' };
export class CanvasPdfBinaryDataFactory {
async fetch({ kind, filename }) {
const url = urls[`../../../../node_modules/pdfjs-dist/${directories[kind]}/${filename}`];
if (!url) throw new Error(`PDF 资源不可用:${filename}`);
if (url.startsWith('data:')) {
const binary = atob(url.slice(url.indexOf(',') + 1));
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
}
const response = await fetch(url);
if (!response.ok) throw new Error(`PDF 资源加载失败:${filename}`);
return new Uint8Array(await response.arrayBuffer());
}
}
+152 -8
View File
@@ -1247,6 +1247,7 @@
}
.magic-notes-page {
color-scheme: light;
display: flex;
min-width: 0;
min-height: 0;
@@ -1256,7 +1257,13 @@
container: magic-notes-page / inline-size;
}
:root[data-theme='dark'] .magic-notes-page {
color-scheme: dark;
}
.magic-notes-layout {
position: relative;
--magic-notes-index-width: 168px;
display: grid;
flex: 1;
min-width: 0;
@@ -1266,11 +1273,22 @@
border-radius: var(--radius-card);
background: var(--surface-raised);
grid-template-columns:
minmax(300px, 1fr) 9px
var(--magic-notes-index-width) minmax(300px, 1fr) 9px
var(--magic-notes-ai-width, 280px);
}
.magic-notes-layout--ai-hidden,
.magic-notes-layout--index-hidden {
grid-template-columns: minmax(300px, 1fr) 9px var(--magic-notes-ai-width, 280px);
}
.magic-notes-layout--ai-hidden {
grid-template-columns: var(--magic-notes-index-width) minmax(0, 1fr);
}
.magic-notes-layout--index-hidden.magic-notes-layout--ai-hidden {
grid-template-columns: minmax(0, 1fr);
}
.magic-notes-layout--overview {
grid-template-columns: minmax(0, 1fr);
}
@@ -1336,6 +1354,45 @@
background: var(--surface-subtle);
}
.magic-notes-ai-pane > .magic-notes-pane-heading {
position: sticky;
top: 0;
background: var(--surface-subtle);
z-index: 1;
padding-bottom: var(--space-3);
}
.magic-notes-index-pane {
min-width: 0;
min-height: 0;
overflow: auto;
background: var(--surface-subtle);
border-right: 1px solid var(--border-subtle);
}
.magic-notes-index-pane[hidden] { display: none; }
.magic-note-records { display: grid; gap: var(--space-3); padding: var(--space-2); }
.magic-note-records[hidden] { display: none; }
.magic-note-record {
display: grid;
gap: var(--space-2);
width: 100%;
padding: var(--space-3);
border: 1px solid var(--border-control);
border-radius: var(--radius-control);
background: var(--surface-raised);
color: var(--text-primary);
text-align: left;
cursor: pointer;
}
.magic-note-record[aria-current="true"] { border-color: var(--accent); background: var(--accent-subtle); }
.magic-note-record:hover { border-color: var(--accent); }
.magic-note-record:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.magic-note-record time { color: var(--text-secondary); font-size: var(--font-caption); }
.magic-note-record__summary { display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; }
.magic-note-record__image { display: block; text-align: center; }
.magic-note-record__image img { display: block; width: 100%; max-height: 180px; object-fit: contain; }
.magic-note-thumbnail-renderer { position: fixed; left: -10000px; top: 0; width: 850px; height: 1200px; visibility: hidden; pointer-events: none; }
.magic-notes-overview[hidden],
.magic-notes-ai-pane[hidden] {
display: none;
@@ -1348,7 +1405,7 @@
gap: var(--space-3);
}
.magic-notes-page > .page-header .page-tabs {
.magic-notes-page #magic-library-switch {
flex: 0 0 auto;
align-self: flex-start;
width: fit-content;
@@ -1840,12 +1897,18 @@
}
.magic-notes-stream-pane {
padding: var(--space-4) var(--space-6) var(--space-6);
padding: 0;
background: var(--surface-raised);
}
.magic-notes-layout--detail {
border: 0;
border-radius: 0;
}
.magic-note-detail-header {
display: flex;
min-height: var(--control-height);
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-3);
@@ -1953,6 +2016,7 @@
.magic-note-entry > header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
padding: var(--space-2) var(--space-3);
@@ -1965,6 +2029,7 @@
.magic-note-entry > header > div {
align-items: center;
flex-wrap: wrap;
margin-left: auto;
}
.magic-note-entry > header button {
@@ -1975,6 +2040,56 @@
line-height: 1;
}
.magic-note-entry-type {
display: flex;
gap: var(--space-2);
}
.magic-note-entry-type [aria-pressed='true'] {
border-color: var(--accent);
}
.magic-note-composer__header,
.magic-note-entry--canvas-editing > header {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2);
}
.magic-note-composer__header {
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--border-subtle);
}
.magic-note-composer > .magic-canvas-editor,
.magic-note-entry > .magic-canvas-content {
padding: var(--space-3);
}
.magic-note-canvas-actions {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
margin-left: auto;
gap: var(--space-2);
}
.magic-note-canvas-actions > button {
flex-shrink: 0;
white-space: nowrap;
}
.magic-note-entry--canvas-editing {
min-width: 0;
}
.magic-note-composer > footer {
flex-wrap: wrap;
gap: 8px;
}
.magic-note-entry__editor {
min-width: 0;
padding: var(--space-3);
@@ -2036,6 +2151,20 @@
color: var(--text-secondary);
}
.magic-note-editor__toolbar.ql-toolbar .ql-stroke {
stroke: currentColor;
}
.magic-note-editor__toolbar.ql-toolbar .ql-fill {
fill: currentColor;
}
.magic-note-editor__toolbar.ql-toolbar .ql-picker-options {
color: var(--text-primary);
background: var(--surface-raised);
border-color: var(--border-control);
}
.magic-note-editor__toolbar.ql-toolbar
.magic-note-editor__attachment-button {
display: inline-flex;
@@ -2044,6 +2173,7 @@
}
.magic-note-editor__content.ql-container.ql-snow {
height: auto;
min-height: 178px;
overflow: hidden;
border: 0;
@@ -2054,6 +2184,7 @@
}
.magic-note-editor__content .ql-editor {
height: auto;
min-height: 178px;
line-height: 1.65;
}
@@ -2243,6 +2374,21 @@
grid-template-rows: minmax(0, 1fr);
}
.magic-notes-layout--overview { grid-template-columns: minmax(0, 1fr); }
.magic-notes-index-pane--drawer-open {
position: absolute;
z-index: 3;
left: 0;
top: calc(var(--control-height) + var(--space-3));
bottom: 0;
width: 168px;
overflow: auto;
align-content: start;
background: var(--surface-subtle);
border-right: 1px solid var(--border-default);
box-shadow: var(--shadow-card);
}
.magic-notes-ai-resize-handle {
display: none;
}
@@ -2251,18 +2397,16 @@
max-height: 280px;
border-top: 1px solid var(--border-subtle);
border-left: 0;
grid-column: 1 / -1;
grid-column: 1;
}
}
@container magic-notes-page (max-width: 720px) {
.magic-note-composer > footer,
.magic-note-entry > header {
.magic-note-composer > footer {
align-items: stretch;
flex-direction: column;
}
.magic-note-entry > header > div,
.magic-note-entry__delete {
align-items: stretch;
flex-direction: column;