diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 49759d87..19c16acf 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -569,6 +569,7 @@ Defined in `src/utils/durableMarkdownBlocks.ts`, `src/utils/editorDurableMarkdow - Fenced `tldraw` blocks become `tldrawBlock` schema nodes before BlockNote sees the Markdown body. - Each `tldrawBlock` stores a stable `boardId` plus the tldraw document snapshot JSON. Session state such as camera, selected tool, and current selection is not persisted into the note. - The rich editor renders the block with the `tldraw` package and saves debounced document snapshot changes back into the block props, so normal Tolaria autosave writes the board into the `.md` file. +- Whiteboard prop writes re-resolve the live BlockNote block by id before mutating it, and disappear as no-ops if a note reload or mode switch has already removed that block. - Mermaid and tldraw both register small codecs with the shared durable fenced-block pipeline; scanner, token, block injection, and mixed serialization mechanics live in one owner. - The `/whiteboard` slash command inserts an empty tldraw block using the same Markdown-durable storage path. Preview images are intentionally omitted; thumbnails can be added later as derived cache artifacts. diff --git a/src/components/editorSchema.tsx b/src/components/editorSchema.tsx index 7cf64c49..6b1a0aa7 100644 --- a/src/components/editorSchema.tsx +++ b/src/components/editorSchema.tsx @@ -16,6 +16,7 @@ import { createTolariaCodeBlockOptions } from './codeBlockOptions' import { NoteTitleIcon } from './NoteTitleIcon' import { MermaidDiagram } from './MermaidDiagram' import { SafeHtmlSpan } from './SafeMarkup' +import { updateTldrawBlockPropsSafely } from './tldrawBlockProps' const TldrawWhiteboard = lazy(() => import('./TldrawWhiteboard').then(module => ({ default: module.TldrawWhiteboard, @@ -186,25 +187,24 @@ const TldrawBlock = createReactBlockSpec( snapshot={props.block.props.snapshot} width={props.block.props.width} onSnapshotChange={(snapshot) => { - props.editor.updateBlock(props.block, { - type: TLDRAW_BLOCK_TYPE, - props: { - boardId: props.block.props.boardId, - height: props.block.props.height, + updateTldrawBlockPropsSafely({ + blockId: props.block.id, + editor: props.editor, + nextProps: (currentProps) => ({ + ...currentProps, snapshot, - width: props.block.props.width, - }, + }), }) }} onSizeChange={(size) => { - props.editor.updateBlock(props.block, { - type: TLDRAW_BLOCK_TYPE, - props: { - boardId: props.block.props.boardId, + updateTldrawBlockPropsSafely({ + blockId: props.block.id, + editor: props.editor, + nextProps: (currentProps) => ({ + ...currentProps, height: size.height, - snapshot: props.block.props.snapshot, width: size.width, - }, + }), }) }} /> diff --git a/src/components/tldrawBlockProps.test.ts b/src/components/tldrawBlockProps.test.ts new file mode 100644 index 00000000..9d4e5940 --- /dev/null +++ b/src/components/tldrawBlockProps.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from 'vitest' +import { TLDRAW_BLOCK_TYPE } from '../utils/tldrawMarkdown' +import { + updateTldrawBlockPropsSafely, + type TldrawBlockMutationEditor, +} from './tldrawBlockProps' + +function tldrawBlock(props: { + boardId?: string + height?: string + id?: string + snapshot?: string + width?: string +}) { + return { + id: props.id ?? 'whiteboard-block', + props: { + boardId: props.boardId ?? 'planning-map', + height: props.height ?? '520', + snapshot: props.snapshot ?? '{}', + width: props.width ?? '', + }, + type: TLDRAW_BLOCK_TYPE, + } +} + +describe('tldraw block prop updates', () => { + it('ignores whiteboard callbacks after the owning BlockNote block disappears', () => { + const editor: TldrawBlockMutationEditor = { + getBlock: vi.fn(() => undefined), + updateBlock: vi.fn(), + } + + expect(updateTldrawBlockPropsSafely({ + blockId: 'whiteboard-block', + editor, + nextProps: (props) => ({ ...props, snapshot: '{ "store": {} }' }), + })).toBe(false) + expect(editor.updateBlock).not.toHaveBeenCalled() + }) + + it('resolves live whiteboard props before writing a debounced snapshot', () => { + const editor: TldrawBlockMutationEditor = { + getBlock: vi.fn(() => tldrawBlock({ + boardId: 'fresh-board', + height: '640', + snapshot: '{ "store": "old" }', + width: '900', + })), + updateBlock: vi.fn(), + } + + expect(updateTldrawBlockPropsSafely({ + blockId: 'whiteboard-block', + editor, + nextProps: (props) => ({ ...props, snapshot: '{ "store": "next" }' }), + })).toBe(true) + expect(editor.updateBlock).toHaveBeenCalledWith('whiteboard-block', { + props: { + boardId: 'fresh-board', + height: '640', + snapshot: '{ "store": "next" }', + width: '900', + }, + type: TLDRAW_BLOCK_TYPE, + }) + }) + + it('turns a final missing-block race into a no-op', () => { + const missingBlockError = new Error('Block with ID whiteboard-block not found') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const editor: TldrawBlockMutationEditor = { + getBlock: vi.fn(() => tldrawBlock({})), + updateBlock: vi.fn(() => { + throw missingBlockError + }), + } + + expect(updateTldrawBlockPropsSafely({ + blockId: 'whiteboard-block', + editor, + nextProps: (props) => ({ ...props, height: '720' }), + })).toBe(false) + expect(warn).toHaveBeenCalledWith('[editor] Ignored stale whiteboard block update:', missingBlockError) + + warn.mockRestore() + }) +}) diff --git a/src/components/tldrawBlockProps.ts b/src/components/tldrawBlockProps.ts new file mode 100644 index 00000000..dbeaec56 --- /dev/null +++ b/src/components/tldrawBlockProps.ts @@ -0,0 +1,81 @@ +import { TLDRAW_BLOCK_TYPE, TLDRAW_DEFAULT_HEIGHT } from '../utils/tldrawMarkdown' + +export interface TldrawBlockProps { + boardId: string + height: string + snapshot: string + width: string +} + +export interface TldrawBlockMutationEditor { + getBlock: (blockId: string) => unknown + updateBlock: (blockId: string, update: TldrawBlockUpdate) => unknown +} + +interface TldrawBlockUpdate { + props: TldrawBlockProps + type: typeof TLDRAW_BLOCK_TYPE +} + +interface LiveTldrawBlock { + id: string + props: TldrawBlockProps +} + +interface TldrawBlockMutation { + blockId: string + editor: TldrawBlockMutationEditor + nextProps: (props: TldrawBlockProps) => TldrawBlockProps +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function stringProp(value: unknown, fallback: string) { + return typeof value === 'string' ? value : fallback +} + +function tldrawBlockProps(value: unknown): TldrawBlockProps | null { + if (!isRecord(value)) return null + if (typeof value.boardId !== 'string' || typeof value.snapshot !== 'string') return null + + return { + boardId: value.boardId, + height: stringProp(value.height, TLDRAW_DEFAULT_HEIGHT), + snapshot: value.snapshot, + width: stringProp(value.width, ''), + } +} + +function liveTldrawBlock(value: unknown): LiveTldrawBlock | null { + if (!isRecord(value)) return null + if (value.type !== TLDRAW_BLOCK_TYPE || typeof value.id !== 'string') return null + + const props = tldrawBlockProps(value.props) + return props ? { id: value.id, props } : null +} + +function isMissingBlockError(error: unknown) { + return error instanceof Error + && error.message.includes('Block with ID') + && error.message.includes('not found') +} + +export function updateTldrawBlockPropsSafely({ blockId, editor, nextProps }: TldrawBlockMutation) { + const liveBlock = liveTldrawBlock(editor.getBlock(blockId)) + if (!liveBlock) return false + + try { + editor.updateBlock(liveBlock.id, { + props: nextProps(liveBlock.props), + type: TLDRAW_BLOCK_TYPE, + }) + return true + } catch (error) { + if (!isMissingBlockError(error)) throw error + + console.warn('[editor] Ignored stale whiteboard block update:', error) + return false + } +}