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:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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*\}/
|
||||
|
||||
@@ -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: '待办已恢复为未完成'
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user