diff --git a/apps/roam/src/components/canvas/CanvasEmbed.tsx b/apps/roam/src/components/canvas/CanvasEmbed.tsx index c66c9662b..589a1c619 100644 --- a/apps/roam/src/components/canvas/CanvasEmbed.tsx +++ b/apps/roam/src/components/canvas/CanvasEmbed.tsx @@ -9,18 +9,8 @@ import getUids from "roamjs-components/dom/getUids"; import getTextByBlockUid from "roamjs-components/queries/getTextByBlockUid"; import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle"; import { TldrawCanvas } from "./Tldraw"; - -const BLOCK_TEXT_REGEX = /\{\{dg-canvas:\s*\[\[(.+?)\]\]\s*\}\}/i; - -const extractCanvasTitle = (button: HTMLElement): string | null => { - const blockUid = getBlockUidFromTarget(button); - if (!blockUid) return null; - const blockText = getTextByBlockUid(blockUid); - if (!blockText) return null; - const match = blockText.match(BLOCK_TEXT_REGEX); - if (!match) return null; - return match[1].trim(); -}; +import { CanvasFrameEmbed, findCanvasFrameRef } from "./CanvasFrameEmbed"; +import { parseDgCanvasEmbed } from "~/utils/dgCanvasEmbed"; const CanvasEmbedPlaceholder = ({ message }: { message: string }) => (
{ }); }; +// The whole-canvas embed, with an inline "Edit Block" button so the user can +// jump to the source block from the mounted canvas. const CanvasEmbedChrome = ({ title, location, @@ -61,6 +53,11 @@ const CanvasEmbedChrome = ({
); +// `{{dg-canvas: [[Title]]}}` renders the classic whole-canvas embed. An +// optional frame argument (`"Frame Name"` and/or `shape:ID`) routes to the +// frame-anchored embed instead — but only when it maps to a real frame on the +// canvas; malformed or unmatched frame arguments are ignored so the embed +// always degrades to just showing the canvas. export const renderCanvasEmbed = ( button: HTMLElement, onloadArgs: OnloadArgs, @@ -69,31 +66,45 @@ export const renderCanvasEmbed = ( if (!button.parentElement) return; - const title = extractCanvasTitle(button); - if (!title) return; + const blockUid = getBlockUidFromTarget(button); + if (!blockUid) return; + const blockText = getTextByBlockUid(blockUid); + const parsed = blockText ? parseDgCanvasEmbed(blockText) : null; + if (!parsed) return; - const pageUid = getPageUidByPageTitle(title); + const pageUid = getPageUidByPageTitle(parsed.title); if (!pageUid) { const wrapper = document.createElement("div"); button.parentElement.appendChild(wrapper); renderWithUnmount( - , + , wrapper, ); return; } + const frame = findCanvasFrameRef({ + pageUid, + frameName: parsed.frameName, + frameShapeId: parsed.frameShapeId, + }); const location = getUids(button.closest(".roam-block")); const wrapper = document.createElement("div"); - wrapper.className = "dg-canvas-embed my-2 w-full overflow-hidden rounded-md"; + wrapper.className = frame + ? "dg-frame-embed my-2 w-full overflow-hidden rounded-md" + : "dg-canvas-embed my-2 w-full overflow-hidden rounded-md"; wrapper.style.height = "400px"; wrapper.onmousedown = (e: MouseEvent) => e.stopPropagation(); button.parentElement.appendChild(wrapper); renderWithUnmount( - + {frame ? ( + + ) : ( + + )} , wrapper, ); diff --git a/apps/roam/src/components/canvas/CanvasFrameEmbed.tsx b/apps/roam/src/components/canvas/CanvasFrameEmbed.tsx new file mode 100644 index 000000000..994950d6a --- /dev/null +++ b/apps/roam/src/components/canvas/CanvasFrameEmbed.tsx @@ -0,0 +1,170 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { Button, Card, Elevation } from "@blueprintjs/core"; +import type { Editor, TLFrameShape, TLShapeId } from "tldraw"; +import { TldrawCanvas, type CanvasEmbedOptions } from "./Tldraw"; +import { getCanvasFrameShapes } from "./useRoamStore"; + +const FRAME_ZOOM_INSET = 16; + +export type FrameRef = { name?: string; shapeId?: string }; + +// Decide whether a parsed frame argument actually maps to a frame on the canvas +// by scanning the persisted frame shapes (no editor mounted). The classic embed +// renderer uses this to route: no match -> the frame argument is ignored and the +// whole canvas renders. Sync-mode canvases may have a slightly stale snapshot; +// the worst case is falling back to the whole-canvas embed. +export const findCanvasFrameRef = ({ + pageUid, + frameName, + frameShapeId, +}: { + pageUid: string; + frameName?: string; + frameShapeId?: string; +}): FrameRef | null => { + if (!frameName && !frameShapeId) return null; + try { + const frames = getCanvasFrameShapes(pageUid); + const matchesId = + !!frameShapeId && frames.some((frame) => frame.id === frameShapeId); + const target = frameName?.trim().toLowerCase(); + const matchesName = + !!target && + frames.some( + (frame) => (frame.props.name ?? "").trim().toLowerCase() === target, + ); + + return matchesId || matchesName + ? { name: frameName, shapeId: frameShapeId } + : null; + } catch { + return null; + } +}; + +// Resolve a frame by shape id first (stable across renames and moves), then fall +// back to a case-insensitive, trimmed name match. Duplicate names resolve to the +// first match with a warning. Returns null if neither path finds a frame. +const resolveFrameShape = ( + editor: Editor, + frame: FrameRef, +): TLFrameShape | null => { + if (frame.shapeId) { + const byId = editor.getShape(frame.shapeId as TLShapeId); + if (byId?.type === "frame") return byId; + } + + if (frame.name) { + const target = frame.name.trim().toLowerCase(); + const matches = editor + .getCurrentPageShapes() + .filter( + (shape): shape is TLFrameShape => + shape.type === "frame" && + ((shape as TLFrameShape).props.name ?? "").trim().toLowerCase() === + target, + ); + if (matches.length > 1) { + // eslint-disable-next-line no-console + console.warn( + `dg-canvas: multiple frames named "${frame.name}"; using the first match.`, + ); + } + if (matches[0]) return matches[0]; + } + + return null; +}; + +// Frame always wins: zoom to the frame's current bounds so the embed tracks the +// frame even when it has been moved or resized on the canvas since last mount. +const zoomToFrame = (editor: Editor, frame: FrameRef): boolean => { + const shape = resolveFrameShape(editor, frame); + if (!shape) return false; + const bounds = editor.getShapePageBounds(shape); + if (!bounds) return false; + editor.zoomToBounds(bounds, { inset: FRAME_ZOOM_INSET }); + return true; +}; + +// Frame-anchored variant of the canvas embed: mounted by renderCanvasEmbed only +// when the block's frame argument maps to a real frame on the canvas. +export const CanvasFrameEmbed = ({ + title, + frame, +}: { + title: string; + frame: FrameRef; +}) => { + const editorRef = useRef(null); + const rafRef = useRef(null); + const [frameMissing, setFrameMissing] = useState(false); + + const handleEditorMount = useCallback( + (editor: Editor) => { + editorRef.current = editor; + // Defer one frame so tldraw has measured the embed's viewport before we + // compute a viewport-relative zoom on first mount. Track the handle so the + // cleanup effect can cancel it if the embed unmounts within the frame — + // otherwise the callback runs against a disposed editor. + rafRef.current = requestAnimationFrame(() => { + rafRef.current = null; + setFrameMissing(!zoomToFrame(editor, frame)); + }); + }, + [frame], + ); + + useEffect( + () => () => { + if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); + }, + [], + ); + + const handleRecenter = useCallback(() => { + // Re-resolve on click so a frame deleted/renamed since mount clears (or + // raises) the not-found notice instead of leaving stale state. + if (editorRef.current) + setFrameMissing(!zoomToFrame(editorRef.current, frame)); + }, [frame]); + + // Manage the camera ourselves (zoom-to-frame on every mount, no session + // persistence) and start with the tldraw chrome hidden (cmd+. restores it). + const embedOptions: CanvasEmbedOptions = { + disableSessionPersistence: true, + onEditorMount: handleEditorMount, + defaultFocusMode: true, + }; + + return ( +
+ + + {frameMissing && ( + + Frame {frame.name ? `“${frame.name}” ` : ""}not found on [[ + {title}]] + + )} +
+ ); +}; diff --git a/apps/roam/src/components/canvas/CanvasFrameEmbedDialog.tsx b/apps/roam/src/components/canvas/CanvasFrameEmbedDialog.tsx new file mode 100644 index 000000000..9029459d3 --- /dev/null +++ b/apps/roam/src/components/canvas/CanvasFrameEmbedDialog.tsx @@ -0,0 +1,198 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Button, Dialog, MenuItem } from "@blueprintjs/core"; +import AutocompleteInput from "roamjs-components/components/AutocompleteInput"; +import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle"; +import renderOverlay, { + RoamOverlayProps, +} from "roamjs-components/util/renderOverlay"; +import type { TLFrameShape, TLShape } from "tldraw"; +import { getCanvasPageTitles } from "~/utils/isCanvasPage"; +import { getPersistedCanvasStore } from "./useRoamStore"; + +export type DgCanvasFrameSelection = { + title: string; + frameName?: string; + frameShapeId?: string; +}; + +type CanvasFrameEmbedDialogProps = { + onSelect: (selection: DgCanvasFrameSelection) => void; +}; + +type FrameOption = { id: string; name: string; childCount: number }; + +// Enumerate frames (with a child-shape count as a disambiguator) from the raw +// persisted store — no editor mounted, no migration. Sync-mode canvases may +// show a slightly stale list, acceptable for a picker; any read failure +// degrades to "whole canvas only". +const getCanvasFrames = (pageUid: string): FrameOption[] => { + try { + const records = Object.values(getPersistedCanvasStore(pageUid)); + + const childCounts = new Map(); + for (const record of records) { + if (record.typeName !== "shape") continue; + const { parentId } = record; + childCounts.set(parentId, (childCounts.get(parentId) ?? 0) + 1); + } + + return records + .filter( + (record): record is TLFrameShape => + record.typeName === "shape" && (record as TLShape).type === "frame", + ) + .map((frame) => ({ + id: frame.id, + name: (frame.props.name ?? "").trim(), + childCount: childCounts.get(frame.id) ?? 0, + })); + } catch { + return []; + } +}; + +const CanvasFrameEmbedDialog = ({ + isOpen, + onClose, + onSelect, +}: RoamOverlayProps) => { + const [canvasPages, setCanvasPages] = useState(null); + const [selectedPage, setSelectedPage] = useState(null); + + useEffect(() => { + void getCanvasPageTitles().then(setCanvasPages); + }, []); + + const frames = useMemo( + () => + selectedPage ? getCanvasFrames(getPageUidByPageTitle(selectedPage)) : [], + [selectedPage], + ); + + const handlePickPage = useCallback( + (title: string) => { + if (canvasPages?.includes(title)) setSelectedPage(title); + }, + [canvasPages], + ); + + const handleSelectWholeCanvas = useCallback(() => { + if (!selectedPage) return; + onSelect({ title: selectedPage }); + onClose(); + }, [selectedPage, onSelect, onClose]); + + const handleSelectFrame = useCallback( + (frame: FrameOption) => { + if (!selectedPage) return; + onSelect({ + title: selectedPage, + frameName: frame.name || undefined, + frameShapeId: frame.id, + }); + onClose(); + }, + [selectedPage, onSelect, onClose], + ); + + const renderPageStep = () => { + if (canvasPages === null) + return ( +
Loading canvas pages...
+ ); + if (canvasPages.length === 0) + return ( +
No canvas pages found
+ ); + return ( + ( + { + event.preventDefault(); + handlePickPage(item); + }} + text={item} + /> + )} + /> + ); + }; + + const renderFrameStep = () => ( +
+
+ + {selectedPage} + + +
+
+ + {frames.length === 0 ? ( +
+ No frames found on this canvas. +
+ ) : ( + frames.map((frame) => ( + handleSelectFrame(frame)} + /> + )) + )} +
+
+ ); + + return ( + +
+ {selectedPage ? renderFrameStep() : renderPageStep()} +
+
+ ); +}; + +export const renderCanvasFrameEmbedDialog = ( + props: CanvasFrameEmbedDialogProps, +): (() => void) | undefined => + renderOverlay({ + // eslint-disable-next-line @typescript-eslint/naming-convention + Overlay: CanvasFrameEmbedDialog, + props, + }); diff --git a/apps/roam/src/components/canvas/Tldraw.tsx b/apps/roam/src/components/canvas/Tldraw.tsx index c619370c4..2f22fcabd 100644 --- a/apps/roam/src/components/canvas/Tldraw.tsx +++ b/apps/roam/src/components/canvas/Tldraw.tsx @@ -190,7 +190,29 @@ export const isPageUid = (uid: string) => ":node/title" ]; -export const TldrawCanvas = ({ title }: { title: string }) => { +// Options for mounting the canvas inside a block embed (`{{dg-canvas: ...}}`). +// Left undefined for main-page and sidebar canvases, whose behavior is unchanged. +export type CanvasEmbedOptions = { + // Frame-anchored embeds manage their own camera (zoom-to-frame on every mount) + // and intentionally do not persist panning across remounts, so they opt out of + // the per-page session persistence entirely. + disableSessionPersistence?: boolean; + // Start the editor in focus mode (what cmd+. toggles), hiding the tldraw + // menus/toolbar chrome — block embeds are for viewing first, so they default + // to a clean surface and the user can cmd+. the controls back. + defaultFocusMode?: boolean; + // Invoked once the embedded editor has mounted; frame resolution + zoom lives + // in the caller (CanvasFrameEmbed) so this component stays frame-agnostic. + onEditorMount?: (editor: Editor) => void; +}; + +export const TldrawCanvas = ({ + title, + embedOptions, +}: { + title: string; + embedOptions?: CanvasEmbedOptions; +}) => { // In Roam, canvas identity is currently keyed by the page UID. // Room sync is graph/page encoded as an opaque base64url token. const pageUid = useMemo(() => getPageUidByPageTitle(title), [title]); @@ -255,6 +277,7 @@ export const TldrawCanvas = ({ title }: { title: string }) => { pageUid={pageUid} canvasSyncMode={canvasSyncMode} onCanvasSyncModeChange={onCanvasSyncModeChange} + embedOptions={embedOptions} /> ); } @@ -265,6 +288,7 @@ export const TldrawCanvas = ({ title }: { title: string }) => { pageUid={pageUid} canvasSyncMode={canvasSyncMode} onCanvasSyncModeChange={onCanvasSyncModeChange} + embedOptions={embedOptions} /> ); }; @@ -329,11 +353,13 @@ const TldrawCanvasRoam = ({ pageUid, canvasSyncMode, onCanvasSyncModeChange, + embedOptions, }: { title: string; pageUid: string; canvasSyncMode: CanvasSyncMode; onCanvasSyncModeChange: (mode: CanvasSyncMode) => void; + embedOptions?: CanvasEmbedOptions; }) => { return ( ); }; @@ -352,11 +379,13 @@ const TldrawCanvasCloudflare = ({ pageUid, canvasSyncMode, onCanvasSyncModeChange, + embedOptions, }: { title: string; pageUid: string; canvasSyncMode: CanvasSyncMode; onCanvasSyncModeChange: (mode: CanvasSyncMode) => void; + embedOptions?: CanvasEmbedOptions; }) => { return ( ); }; @@ -377,6 +407,7 @@ const TldrawCanvasShared = ({ isCloudflareSync, canvasSyncMode, onCanvasSyncModeChange, + embedOptions, }: { title: string; pageUid: string; @@ -384,6 +415,7 @@ const TldrawCanvasShared = ({ isCloudflareSync: boolean; canvasSyncMode: CanvasSyncMode; onCanvasSyncModeChange: (mode: CanvasSyncMode) => void; + embedOptions?: CanvasEmbedOptions; }) => { const appRef = useRef(null); const lastInsertRef = useRef(); @@ -1041,13 +1073,17 @@ const TldrawCanvasShared = ({ components={editorComponents} store={store} onMount={(app) => { + // Frame-anchored embeds re-zoom to their frame on every mount and + // do not persist panning, so they opt out of session persistence. const unregisterCanvasSessionStatePersistence = - registerCanvasSessionStatePersistence({ - editor: app, - graphName: window.roamAlphaAPI.graph.name, - userUid: window.roamAlphaAPI.user.uid() || "", - pageUid, - }); + embedOptions?.disableSessionPersistence + ? () => {} + : registerCanvasSessionStatePersistence({ + editor: app, + graphName: window.roamAlphaAPI.graph.name, + userUid: window.roamAlphaAPI.user.uid() || "", + pageUid, + }); if (process.env.NODE_ENV !== "production") { if (!window.tldrawApps) window.tldrawApps = {}; @@ -1136,6 +1172,18 @@ const TldrawCanvasShared = ({ } }); + if ( + embedOptions?.defaultFocusMode && + !app.getInstanceState().isFocusMode + ) { + app.updateInstanceState({ isFocusMode: true }); + } + + // Hand the mounted editor back to an embed wrapper (frame + // resolution + zoom-to-frame). Runs after the store is loaded and + // shapes are present, so frame bounds are resolvable. + embedOptions?.onEditorMount?.(app); + return () => { unregisterCanvasSessionStatePersistence(); }; diff --git a/apps/roam/src/components/canvas/canvasRemoteMerge.ts b/apps/roam/src/components/canvas/canvasRemoteMerge.ts new file mode 100644 index 000000000..007959a3e --- /dev/null +++ b/apps/roam/src/components/canvas/canvasRemoteMerge.ts @@ -0,0 +1,228 @@ +// Merging persisted canvas state from Roam block props into a live tldraw +// store (the pull-watch "remote changes" path of useRoamStore). +// +// The persisted store is raw: it may predate the live schema (e.g. legacy +// node-uid shape types that MigrateNodeTypeToDiscourseNode retypes to +// "discourse-node" on load). `store.applyDiff` validates records against the +// live schema but never migrates them, so the incoming state must run through +// the same migration machinery `loadSnapshot` uses on mount before diffing — +// otherwise a single stale record throws a ValidationError inside +// `mergeRemoteChanges` and tears the canvas down. +import { TLRecord } from "@tldraw/tlschema"; +import { SerializedStore } from "@tldraw/store"; +import { + getIndices, + sortByIndex, + TLShape, + TLStore, + TLStoreSnapshot, +} from "tldraw"; +import { LEGACY_SCHEMA } from "~/data/legacyTldrawSchema"; + +export const isTLStoreSnapshot = (value: unknown): value is TLStoreSnapshot => { + return ( + typeof value === "object" && + value !== null && + "store" in value && + "schema" in value + ); +}; + +export const fixShapeIndices = ( + data: SerializedStore, +): SerializedStore => { + const shapes = Object.values(data).filter( + (record): record is TLShape => record.typeName === "shape", + ); + + const sortedShapes = shapes.sort((a, b) => { + if (a.index !== undefined && b.index !== undefined) { + return sortByIndex(a, b); + } + return a.id.localeCompare(b.id); + }); + + const newIndices = getIndices(shapes.length); + + const fixedShapes = sortedShapes.map((shape, i) => ({ + ...shape, + index: newIndices[i], + })); + + return Object.fromEntries( + Object.entries(data).map(([key, value]) => { + if (value.typeName === "shape") { + const updatedShape = fixedShapes.find((s) => s.id === value.id); + return [key, (updatedShape || value) as TLRecord]; + } + return [key, value]; + }), + ); +}; + +export const filterUserRecords = ( + data: SerializedStore, +): SerializedStore => { + return Object.fromEntries( + Object.entries(data).filter(([key]) => { + return !/^(user_presence|camera|instance|instance_page_state|user|user_document):/.test( + key, + ); + }), + ); +}; + +const personalRecordTypes = new Set([ + "camera", + "instance", + "instance_page_state", +]); + +const pruneState = (state: SerializedStore) => + Object.fromEntries( + Object.entries(state).filter( + ([, record]) => !personalRecordTypes.has(record.typeName), + ), + ); + +const diffObjects = ( + oldRecord: Record, + newRecord: Record, +): Record => { + const allKeys = Array.from( + new Set(Object.keys(oldRecord).concat(Object.keys(newRecord))), + ); + return Object.fromEntries( + allKeys + .map((key): [string, unknown] | null => { + const oldValue = oldRecord[key]; + const newValue = newRecord[key]; + if (typeof oldValue !== typeof newValue) { + return [key, newValue]; + } + if ( + typeof oldValue === "object" && + oldValue !== null && + newValue !== null + ) { + // Both branches are non-null objects (their `typeof` matched above). + const diffed = diffObjects( + oldValue as Record, + newValue as Record, + ); + if (Object.keys(diffed).length) { + return [key, diffed]; + } + return null; + } + if (oldValue !== newValue) { + return [key, newValue]; + } + return null; + }) + .filter((e): e is [string, unknown] => !!e), + ); +}; + +export const calculateDiff = ( + _newState: SerializedStore, + _oldState: SerializedStore, +) => { + const newState = pruneState(_newState); + const oldState = pruneState(_oldState); + return { + added: Object.fromEntries( + Object.keys(newState) + .filter((id) => !oldState[id]) + .map((id) => [id, newState[id]]), + ), + removed: Object.fromEntries( + Object.keys(oldState) + .filter((id) => !newState[id]) + .map((key) => [key, oldState[key]]), + ), + updated: Object.fromEntries( + Object.keys(newState) + .map((id): [string, [TLRecord, TLRecord]] | null => { + const oldRecord = oldState[id]; + const newRecord = newState[id]; + if (!oldRecord || !newRecord) { + return null; + } + + const diffed = diffObjects( + oldRecord as unknown as Record, + newRecord as unknown as Record, + ); + if (Object.keys(diffed).length) { + return [id, [oldRecord, newRecord]]; + } + return null; + }) + .filter((e): e is [string, [TLRecord, TLRecord]] => !!e), + ), + }; +}; + +export type MergeRemoteCanvasStateResult = + | { type: "applied"; droppedRecordIds: string[] } + | { type: "migration-failed" } + | { type: "apply-failed"; error: unknown }; + +export const mergeRemoteCanvasState = ({ + store, + remoteTldraw, +}: { + store: TLStore; + // The raw `props["roamjs-query-builder"].tldraw` value: a modern + // `{ store, schema }` snapshot, or a bare legacy serialized store. + remoteTldraw: TLStoreSnapshot | SerializedStore; +}): MergeRemoteCanvasStateResult => { + // Legacy-format data gets the same pre-processing the mount path + // (upgradeLegacyStoreSnapshot) applies before migrating. + const snapshot: TLStoreSnapshot = isTLStoreSnapshot(remoteTldraw) + ? remoteTldraw + : { + store: fixShapeIndices(filterUserRecords(remoteTldraw)), + schema: LEGACY_SCHEMA, + }; + + const migration = store.schema.migrateStoreSnapshot(snapshot); + if (migration.type !== "success") { + return { type: "migration-failed" }; + } + + // Keep only document-scoped records (what loadSnapshot keeps) and drop any + // record the live schema still rejects after migration — e.g. a shape whose + // node type is missing from this graph's config, which no migration can + // retype. A dropped record must not be treated as remotely deleted, so its + // id is also excluded from the diff's `removed` set below. + const droppedRecordIds: string[] = []; + const incoming: SerializedStore = Object.fromEntries( + Object.entries(migration.value).filter(([id, record]) => { + if (!store.scopedTypes.document.has(record.typeName)) return false; + try { + store.schema.validateRecord(store, record, "createRecord", null); + return true; + } catch { + droppedRecordIds.push(id); + return false; + } + }), + ); + + try { + store.mergeRemoteChanges(() => { + const currentState = store.getSnapshot(); + const diff = calculateDiff(incoming, currentState.store); + droppedRecordIds.forEach((id) => { + delete diff.removed[id]; + }); + store.applyDiff(diff); + }); + } catch (error) { + return { type: "apply-failed", error }; + } + + return { type: "applied", droppedRecordIds }; +}; diff --git a/apps/roam/src/components/canvas/tldrawStyles.ts b/apps/roam/src/components/canvas/tldrawStyles.ts index 03a5265e7..81e021d3e 100644 --- a/apps/roam/src/components/canvas/tldrawStyles.ts +++ b/apps/roam/src/components/canvas/tldrawStyles.ts @@ -1,12 +1,14 @@ // tldrawStyles.ts because some of these styles need to be inlined export default /* css */ ` - /* Hide Roam Blocks only when a full-page canvas is present (not embedded) */ - .roam-article:has(.roamjs-tldraw-canvas-container:not(.dg-canvas-embed *)) .rm-block-children { + /* Hide Roam Blocks only when a full-page canvas is present (not embedded). + Every block-embed wrapper class must be excluded here, or mounting that + embed hides ALL blocks on the host page. */ + .roam-article:has(.roamjs-tldraw-canvas-container:not(.dg-canvas-embed *, .dg-frame-embed *)) .rm-block-children { display: none; } /* Hide Roam Blocks in sidebar when a full-page canvas is present (not embedded) */ - .rm-sidebar-outline:has(.roamjs-tldraw-canvas-container:not(.dg-canvas-embed *)) .rm-block-children { + .rm-sidebar-outline:has(.roamjs-tldraw-canvas-container:not(.dg-canvas-embed *, .dg-frame-embed *)) .rm-block-children { display: none; } diff --git a/apps/roam/src/components/canvas/useRoamStore.ts b/apps/roam/src/components/canvas/useRoamStore.ts index 78989afaa..91a26c7ae 100644 --- a/apps/roam/src/components/canvas/useRoamStore.ts +++ b/apps/roam/src/components/canvas/useRoamStore.ts @@ -11,14 +11,13 @@ import { TLAnyBindingUtilConstructor, TLAnyShapeUtilConstructor, } from "@tldraw/editor"; -import { SerializedStore, StoreSnapshot } from "@tldraw/store"; +import { SerializedStore } from "@tldraw/store"; import { defaultBindingUtils, defaultShapeUtils, - getIndices, loadSnapshot, MigrationSequence, - sortByIndex, + TLFrameShape, TLShape, TLStoreSnapshot, TLStore, @@ -26,59 +25,16 @@ import { import { AddPullWatch } from "roamjs-components/types"; import { LEGACY_SCHEMA } from "~/data/legacyTldrawSchema"; import internalError from "~/utils/internalError"; +import { + isTLStoreSnapshot, + filterUserRecords, + fixShapeIndices, + mergeRemoteCanvasState, +} from "./canvasRemoteMerge"; const THROTTLE = 350; -export const isTLStoreSnapshot = (value: unknown): value is TLStoreSnapshot => { - return ( - typeof value === "object" && - value !== null && - "store" in value && - "schema" in value - ); -}; - -const fixShapeIndices = ( - data: SerializedStore, -): SerializedStore => { - const shapes = Object.values(data).filter( - (record): record is TLShape => record.typeName === "shape", - ); - - const sortedShapes = shapes.sort((a, b) => { - if (a.index !== undefined && b.index !== undefined) { - return sortByIndex(a, b); - } - return a.id.localeCompare(b.id); - }); - - const newIndices = getIndices(shapes.length); - - const fixedShapes = sortedShapes.map((shape, i) => ({ - ...shape, - index: newIndices[i], - })); - - return Object.fromEntries( - Object.entries(data).map(([key, value]) => { - if (value.typeName === "shape") { - const updatedShape = fixedShapes.find((s) => s.id === value.id); - return [key, (updatedShape || value) as TLRecord]; - } - return [key, value]; - }), - ); -}; - -const filterUserRecords = (data: SerializedStore) => { - return Object.fromEntries( - Object.entries(data).filter(([key]) => { - return !/^(user_presence|camera|instance|instance_page_state|user|user_document):/.test( - key, - ); - }), - ); -}; +export { isTLStoreSnapshot } from "./canvasRemoteMerge"; const createCanvasStore = ({ migrations, @@ -182,6 +138,27 @@ export const getRoamCanvasSnapshot = ({ }; }; +// The raw persisted record map from block props, without constructing or +// migrating a TLStore (modern `{ store, schema }` store, or the legacy raw +// store, whichever is present). Cheap read for callers that only need to +// inspect records — no throwaway editor, and no ValidationError on an +// un-migrated legacy node shape the way loadSnapshot-without-utils would throw. +export const getPersistedCanvasStore = ( + pageUid: string, +): SerializedStore => { + const { initialSnapshot, oldData } = getPersistedRoamCanvasState({ pageUid }); + return initialSnapshot ? initialSnapshot.store : (oldData ?? {}); +}; + +// Frame shapes on a canvas. Frames are default tldraw shapes — present verbatim +// in both persisted formats and untouched by discourse-node migrations — so +// this raw scan needs no migration or shape utils. +export const getCanvasFrameShapes = (pageUid: string): TLFrameShape[] => + Object.values(getPersistedCanvasStore(pageUid)).filter( + (record): record is TLFrameShape => + record.typeName === "shape" && (record as TLShape).type === "frame", + ); + export const useRoamStore = ({ customShapeUtils, customBindingUtils, @@ -349,12 +326,6 @@ export const useRoamStore = ({ pageUid, ]); - const personalRecordTypes = new Set([ - "camera", - "instance", - "instance_page_state", - ]); - const performUpgrade = () => { if (!oldData) return; try { @@ -404,84 +375,6 @@ export const useRoamStore = ({ } }; - const pruneState = (state: SerializedStore) => - Object.fromEntries( - Object.entries(state).filter( - ([_, record]) => !personalRecordTypes.has(record.typeName), - ), - ); - - const diffObjects = ( - oldRecord: Record, - newRecord: Record, - ): Record => { - const allKeys = Array.from( - new Set(Object.keys(oldRecord).concat(Object.keys(newRecord))), - ); - return Object.fromEntries( - allKeys - .map((key) => { - const oldValue = oldRecord[key]; - const newValue = newRecord[key]; - if (typeof oldValue !== typeof newValue) { - return [key, newValue]; - } - if ( - typeof oldValue === "object" && - oldValue !== null && - newValue !== null - ) { - const diffed = diffObjects(oldValue, newValue); - if (Object.keys(diffed).length) { - return [key, diffed]; - } - return null; - } - if (oldValue !== newValue) { - return [key, newValue]; - } - return null; - }) - .filter((e): e is [string, any] => !!e), - ); - }; - const calculateDiff = ( - _newState: SerializedStore, - _oldState: SerializedStore, - ) => { - const newState = pruneState(_newState); - const oldState = pruneState(_oldState); - return { - added: Object.fromEntries( - Object.keys(newState) - .filter((id) => !oldState[id]) - .map((id) => [id, newState[id]]), - ), - removed: Object.fromEntries( - Object.keys(oldState) - .filter((id) => !newState[id]) - .map((key) => [key, oldState[key]]), - ), - updated: Object.fromEntries( - Object.keys(newState) - .map((id) => { - const oldRecord = oldState[id]; - const newRecord = newState[id]; - if (!oldRecord || !newRecord) { - return null; - } - - const diffed = diffObjects(oldRecord, newRecord); - if (Object.keys(diffed).length) { - return [id, [oldRecord, newRecord]]; - } - return null; - }) - .filter((e): e is [string, any] => !!e), - ), - }; - }; - // Remote Changes useEffect(() => { const pullWatchProps: Parameters = [ @@ -492,18 +385,42 @@ export const useRoamStore = ({ (after?.[":block/props"] || {}) as json, ) as Record; const rjsqb = props["roamjs-query-builder"] as Record; + // Any pending merge was computed from an older props state than this + // invocation, so always cancel it — including when the newest state is + // our own save. (Returning early while a stale timer kept running used + // to let an outdated snapshot clobber just-saved local edits.) + clearTimeout(deserializeRef.current); const propsStateId = rjsqb?.stateId as string; if (localStateIds.current.some((s) => s === propsStateId)) return; - const newState = rjsqb?.tldraw as StoreSnapshot; + const newState = rjsqb?.tldraw as + | TLStoreSnapshot + | SerializedStore; if (!newState) return; - clearTimeout(deserializeRef.current); deserializeRef.current = window.setTimeout(() => { if (!store) return; - store.mergeRemoteChanges(() => { - const currentState = store.getSnapshot(); - const diff = calculateDiff(newState.store, currentState.store); - store.applyDiff(diff); + const result = mergeRemoteCanvasState({ + store, + remoteTldraw: newState, }); + if (result.type === "applied") { + if (result.droppedRecordIds.length) { + console.warn( + "Canvas remote merge skipped records the current schema rejects:", + result.droppedRecordIds, + ); + } + } else { + // Skipping a merge leaves the canvas slightly stale until the next + // save; that beats letting a ValidationError tear down the page. + internalError({ + error: + result.type === "apply-failed" + ? result.error + : new Error("Failed to migrate remote canvas snapshot"), + type: "Failed to merge remote canvas changes", + context: { pageUid }, + }); + } }, THROTTLE); }, ]; diff --git a/apps/roam/src/utils/__tests__/canvasRemoteMerge.test.ts b/apps/roam/src/utils/__tests__/canvasRemoteMerge.test.ts new file mode 100644 index 000000000..6896a7e7f --- /dev/null +++ b/apps/roam/src/utils/__tests__/canvasRemoteMerge.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, it } from "vitest"; +import { + BaseBoxShapeUtil, + DocumentRecordType, + PageRecordType, + IndexKey, + T, + TLBaseShape, + TLDOCUMENT_ID, + TLStore, + TLStoreSnapshot, + createMigrationIds, + createMigrationSequence, + createTLStore, + defaultBindingUtils, + defaultShapeUtils, + loadSnapshot, +} from "tldraw"; +import type { SerializedStore } from "@tldraw/store"; +import type { TLRecord } from "@tldraw/tlschema"; +import { + calculateDiff, + mergeRemoteCanvasState, +} from "~/components/canvas/canvasRemoteMerge"; + +// Mirrors the production situation that produced the dg-frame blank-page bug: +// persisted canvas stores can hold shapes whose type is a legacy node uid +// (e.g. "lxCvhQ034") that a store-level migration retypes on load, but the +// live schema itself does not register. `loadSnapshot` migrates; a raw +// `applyDiff` of the persisted store does not, and throws a ValidationError. +const LEGACY_NODE_TYPE = "lxCvhQ034test"; +const MIGRATED_NODE_TYPE = "discourse-node-test"; + +type TestNodeShape = TLBaseShape< + typeof MIGRATED_NODE_TYPE, + { w: number; h: number; nodeTypeId: string } +>; + +class TestDiscourseNodeUtil extends BaseBoxShapeUtil { + static override type = MIGRATED_NODE_TYPE; + static override props = { + w: T.number, + h: T.number, + nodeTypeId: T.string, + }; + override getDefaultProps() { + return { w: 100, h: 50, nodeTypeId: "" }; + } + override component() { + return null; + } + override indicator() { + return null; + } +} + +const versions = createMigrationIds("com.test.discourse-node", { + MigrateNodeTypeToDiscourseNode: 1, +}); + +// Same shape as the real MigrateNodeTypeToDiscourseNode migration in +// discourseRelationMigrations.ts: retype a node-uid-typed shape to the +// registered discourse-node type, keeping the uid in props. +const testMigrations = createMigrationSequence({ + sequenceId: "com.test.discourse-node", + retroactive: true, + sequence: [ + { + id: versions.MigrateNodeTypeToDiscourseNode, + scope: "record", + filter: (r) => + r.typeName === "shape" && + (r as { type?: string }).type === LEGACY_NODE_TYPE, + up: (shape) => { + const s = shape as unknown as { + type: string; + props: Record; + }; + s.props.nodeTypeId = s.type; + s.type = MIGRATED_NODE_TYPE; + }, + }, + ], +}); + +const PAGE_ID = PageRecordType.createId("page"); +const LEGACY_SHAPE_ID = "shape:legacy1"; + +const makeLegacyShape = ( + overrides: Partial> = {}, +): Record => ({ + id: LEGACY_SHAPE_ID, + typeName: "shape", + type: LEGACY_NODE_TYPE, + x: 0, + y: 0, + rotation: 0, + index: "a2", + parentId: PAGE_ID, + isLocked: false, + opacity: 1, + meta: {}, + props: { w: 100, h: 50 }, + ...overrides, +}); + +// A persisted snapshot the way Roam block props hold it: records saved before +// the discourse migration sequence existed (schema without that sequence), so +// the legacy-typed shape is still present raw. +const makePersistedSnapshot = ( + extraRecords: Record[] = [], +): TLStoreSnapshot => { + const scratch = createTLStore({ + shapeUtils: defaultShapeUtils, + bindingUtils: defaultBindingUtils, + }); + scratch.put([ + DocumentRecordType.create({ id: TLDOCUMENT_ID }), + PageRecordType.create({ + id: PAGE_ID, + name: "Page 1", + index: "a1" as IndexKey, + }), + ]); + const snapshot = scratch.getStoreSnapshot(); + const store = { ...snapshot.store } as Record; + for (const record of [makeLegacyShape(), ...extraRecords]) { + store[record.id as string] = record; + } + return { + store: store as TLStoreSnapshot["store"], + schema: snapshot.schema, + }; +}; + +const makeLiveStore = (): TLStore => + createTLStore({ + shapeUtils: [...defaultShapeUtils, TestDiscourseNodeUtil], + bindingUtils: defaultBindingUtils, + migrations: [testMigrations], + }); + +const mountLiveStore = (snapshot: TLStoreSnapshot): TLStore => { + const store = makeLiveStore(); + loadSnapshot(store, snapshot); + return store; +}; + +const getShape = (store: TLStore, id: string) => + store.get(id as Parameters[0]) as unknown as + | { type: string; x: number; props: Record } + | undefined; + +describe("mergeRemoteCanvasState", () => { + it("mount path migrates the legacy-typed shape (sanity check)", () => { + const store = mountLiveStore(makePersistedSnapshot()); + const shape = getShape(store, LEGACY_SHAPE_ID); + expect(shape?.type).toBe(MIGRATED_NODE_TYPE); + expect(shape?.props.nodeTypeId).toBe(LEGACY_NODE_TYPE); + }); + + it("documents the original bug: raw applyDiff of the unmigrated store throws", () => { + const persisted = makePersistedSnapshot(); + const store = mountLiveStore(persisted); + expect(() => + store.mergeRemoteChanges(() => { + const diff = calculateDiff(persisted.store, store.getSnapshot().store); + store.applyDiff(diff); + }), + ).toThrow(); + }); + + it("applies an unmigrated remote snapshot by migrating it first", () => { + const persisted = makePersistedSnapshot(); + const store = mountLiveStore(persisted); + const result = mergeRemoteCanvasState({ store, remoteTldraw: persisted }); + expect(result).toEqual({ type: "applied", droppedRecordIds: [] }); + const shape = getShape(store, LEGACY_SHAPE_ID); + expect(shape?.type).toBe(MIGRATED_NODE_TYPE); + expect(shape?.props.nodeTypeId).toBe(LEGACY_NODE_TYPE); + }); + + it("propagates adds, moves, and removals from the remote snapshot", () => { + const store = mountLiveStore(makePersistedSnapshot()); + + const secondShape = makeLegacyShape({ id: "shape:legacy2", index: "a3" }); + const withChanges = makePersistedSnapshot([secondShape]); + (withChanges.store as unknown as Record>)[ + LEGACY_SHAPE_ID + ] = makeLegacyShape({ x: 250 }); + + const result = mergeRemoteCanvasState({ + store, + remoteTldraw: withChanges, + }); + expect(result.type).toBe("applied"); + expect(getShape(store, LEGACY_SHAPE_ID)?.x).toBe(250); + expect(getShape(store, "shape:legacy2")?.type).toBe(MIGRATED_NODE_TYPE); + + const withRemoval = makePersistedSnapshot(); + const removalResult = mergeRemoteCanvasState({ + store, + remoteTldraw: withRemoval, + }); + expect(removalResult.type).toBe("applied"); + expect(getShape(store, "shape:legacy2")).toBeUndefined(); + }); + + it("drops records the schema still rejects instead of failing the merge", () => { + const store = mountLiveStore(makePersistedSnapshot()); + + const hopeless = makeLegacyShape({ + id: "shape:unknown1", + type: "no-such-type-anywhere", + index: "a4", + }); + const moved = makeLegacyShape({ x: 99 }); + const snapshot = makePersistedSnapshot([hopeless]); + (snapshot.store as unknown as Record>)[ + LEGACY_SHAPE_ID + ] = moved; + + const result = mergeRemoteCanvasState({ store, remoteTldraw: snapshot }); + expect(result).toEqual({ + type: "applied", + droppedRecordIds: ["shape:unknown1"], + }); + // The good change still landed; the bad record never entered the store. + expect(getShape(store, LEGACY_SHAPE_ID)?.x).toBe(99); + expect(getShape(store, "shape:unknown1")).toBeUndefined(); + }); + + it("returns a result instead of throwing for bare legacy-format data", () => { + const store = mountLiveStore(makePersistedSnapshot()); + // Old code did `newState.store` on this and crashed with a TypeError. + const bareLegacyStore = {} as SerializedStore; + expect(() => + mergeRemoteCanvasState({ store, remoteTldraw: bareLegacyStore }), + ).not.toThrow(); + }); +}); diff --git a/apps/roam/src/utils/__tests__/dgCanvasEmbed.test.ts b/apps/roam/src/utils/__tests__/dgCanvasEmbed.test.ts new file mode 100644 index 000000000..7172a9c2c --- /dev/null +++ b/apps/roam/src/utils/__tests__/dgCanvasEmbed.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from "vitest"; +import { + parseDgCanvasEmbed, + serializeDgCanvasEmbed, +} from "~/utils/dgCanvasEmbed"; + +describe("parseDgCanvasEmbed", () => { + it("parses a whole-canvas embed (no frame argument)", () => { + expect(parseDgCanvasEmbed("{{dg-canvas: [[My Canvas]]}}")).toEqual({ + title: "My Canvas", + }); + }); + + it("parses a name-only frame argument", () => { + expect( + parseDgCanvasEmbed('{{dg-canvas: [[My Canvas]] "Frame A"}}'), + ).toEqual({ + title: "My Canvas", + frameName: "Frame A", + }); + }); + + it("parses a name + shape-id frame argument", () => { + expect( + parseDgCanvasEmbed( + '{{dg-canvas: [[My Canvas]] "Frame A" shape:aB1_c-2}}', + ), + ).toEqual({ + title: "My Canvas", + frameName: "Frame A", + frameShapeId: "shape:aB1_c-2", + }); + }); + + it("parses an id-only frame argument (name omitted)", () => { + expect( + parseDgCanvasEmbed("{{dg-canvas: [[My Canvas]] shape:aB1_c-2}}"), + ).toEqual({ + title: "My Canvas", + frameShapeId: "shape:aB1_c-2", + }); + }); + + it("is case-insensitive on the keyword and tolerates surrounding text", () => { + expect( + parseDgCanvasEmbed('prefix {{DG-Canvas: [[My Canvas]] "F"}} suffix'), + ).toMatchObject({ title: "My Canvas", frameName: "F" }); + }); + + // Degradation contract: a tail that is not a clean `"name"`/`shape:id` + // argument is ignored, so the block still resolves to a whole-canvas embed + // instead of failing to render. + it("ignores a malformed frame argument and degrades to whole canvas", () => { + expect( + parseDgCanvasEmbed("{{dg-canvas: [[My Canvas]] not a frame arg}}"), + ).toEqual({ title: "My Canvas" }); + expect( + parseDgCanvasEmbed('{{dg-canvas: [[My Canvas]] "unclosed}}'), + ).toEqual({ title: "My Canvas" }); + expect( + parseDgCanvasEmbed('{{dg-canvas: [[My Canvas]] "Frame A" extra junk}}'), + ).toEqual({ title: "My Canvas" }); + }); + + it("tolerates extra whitespace around a valid frame argument", () => { + expect( + parseDgCanvasEmbed('{{dg-canvas: [[My Canvas]] "Frame A" }}'), + ).toEqual({ title: "My Canvas", frameName: "Frame A" }); + }); + + // A lone `}` inside a frame name must not stop the match at the wrong place and + // blank the whole block — the embed still parses (title + id survive) rather + // than returning null. + it("keeps a `}` inside a frame name from blanking the embed", () => { + expect( + parseDgCanvasEmbed('{{dg-canvas: [[My Canvas]] "Fig }2" shape:aB1_c-2}}'), + ).toEqual({ + title: "My Canvas", + frameName: "Fig }2", + frameShapeId: "shape:aB1_c-2", + }); + }); + + it("returns null for a non-dg-canvas embed or unrelated text", () => { + expect(parseDgCanvasEmbed("{{dg-query: [[My Canvas]]}}")).toBeNull(); + expect(parseDgCanvasEmbed("just some text")).toBeNull(); + }); +}); + +describe("serializeDgCanvasEmbed", () => { + it("round-trips a whole-canvas embed", () => { + const text = serializeDgCanvasEmbed({ title: "My Canvas" }); + expect(text).toBe("{{dg-canvas: [[My Canvas]]}}"); + expect(parseDgCanvasEmbed(text)).toEqual({ title: "My Canvas" }); + }); + + it("round-trips a fully-specified frame embed", () => { + const embed = { + title: "My Canvas", + frameName: "Frame A", + frameShapeId: "shape:aB1_c-2", + }; + const text = serializeDgCanvasEmbed(embed); + expect(text).toBe('{{dg-canvas: [[My Canvas]] "Frame A" shape:aB1_c-2}}'); + expect(parseDgCanvasEmbed(text)).toEqual(embed); + }); + + it("strips curly braces from the frame name so the serialized token round-trips", () => { + const text = serializeDgCanvasEmbed({ + title: "My Canvas", + frameName: "Draft }v2{", + frameShapeId: "shape:x", + }); + // Braces gone; the token parses back to a whole, non-null embed. + expect(text).toBe('{{dg-canvas: [[My Canvas]] "Draft v2" shape:x}}'); + expect(parseDgCanvasEmbed(text)).toEqual({ + title: "My Canvas", + frameName: "Draft v2", + frameShapeId: "shape:x", + }); + }); + + it("collapses embedded double-quotes in the frame name so the token stays parseable", () => { + const text = serializeDgCanvasEmbed({ + title: "My Canvas", + frameName: 'A "quoted" name', + frameShapeId: "shape:x", + }); + expect(text).toBe( + "{{dg-canvas: [[My Canvas]] \"A 'quoted' name\" shape:x}}", + ); + expect(parseDgCanvasEmbed(text)).toMatchObject({ + title: "My Canvas", + frameShapeId: "shape:x", + }); + }); +}); diff --git a/apps/roam/src/utils/dgCanvasEmbed.ts b/apps/roam/src/utils/dgCanvasEmbed.ts new file mode 100644 index 000000000..4e5c63b5f --- /dev/null +++ b/apps/roam/src/utils/dgCanvasEmbed.ts @@ -0,0 +1,75 @@ +// Parsing/serialization for the `{{dg-canvas: ...}}` block embed. +// +// One syntax covers both the classic whole-canvas embed and the frame-anchored +// embed (the frame argument is optional): +// {{dg-canvas: [[Canvas Page Title]]}} whole canvas (classic embed) +// {{dg-canvas: [[Canvas Page Title]] "Frame Name"}} frame by name (hand-written) +// {{dg-canvas: [[Canvas Page Title]] "Frame Name" shape:abc}} frame by id (what the picker writes) +// {{dg-canvas: [[Canvas Page Title]] shape:abc}} frame by id (name omitted) +// +// Frames are referenced by tldraw shape id (stable across renames and moves); +// the display name is carried alongside for human readability and as a +// resolution fallback. `shape:` is a plain token — we deliberately avoid Roam's +// `((...))` block-ref syntax so the embed round-trips through block text +// unambiguously. +// +// Degradation contract: a tail that does not parse as `"name"`/`shape:id` is +// ignored (whole-canvas embed), and a frame argument that matches no frame on +// the canvas is likewise ignored by the renderer — a broken frame reference +// must never cost the user their canvas. + +export type DgCanvasEmbed = { + title: string; + frameName?: string; + // Full tldraw shape id, e.g. "shape:abc123" (tldraw ids use the nanoid + // alphabet: A-Z a-z 0-9 _ -). + frameShapeId?: string; +}; + +// Group 1: page title. Group 2: everything between the closing `]]` and the +// `}}` terminator, parsed separately so a malformed frame argument degrades to a +// whole-canvas embed instead of a dead button. The tail is lazy (`[\s\S]*?`, not +// `[^}]*`) on purpose: a lone `}` inside a frame name must not stop the match at +// the wrong place and make the whole embed fail to parse (which would blank the +// block rather than degrade) — the tail runs to the first `}}` and a stray `}` +// in the name is then handled by FRAME_ARGS_REGEX. +export const DG_CANVAS_EMBED_REGEX = + /\{\{dg-canvas:\s*\[\[(.+?)\]\]([\s\S]*?)\}\}/i; + +// The tail only counts as a frame reference if it is exactly an optional +// quoted frame name followed by an optional shape-id token. +const FRAME_ARGS_REGEX = /^\s*(?:"([^"]*)"\s*)?(shape:[A-Za-z0-9_-]+)?\s*$/; + +export const parseDgCanvasEmbed = (blockText: string): DgCanvasEmbed | null => { + const match = blockText.match(DG_CANVAS_EMBED_REGEX); + if (!match) return null; + + const title = match[1].trim(); + if (!title) return null; + + const frameArgs = match[2].match(FRAME_ARGS_REGEX); + const frameName = frameArgs?.[1]?.trim() || undefined; + const frameShapeId = frameArgs?.[2] || undefined; + + return { + title, + ...(frameName ? { frameName } : {}), + ...(frameShapeId ? { frameShapeId } : {}), + }; +}; + +export const serializeDgCanvasEmbed = ({ + title, + frameName, + frameShapeId, +}: DgCanvasEmbed): string => { + let inner = `dg-canvas: [[${title}]]`; + // The name is only ever a readability/fallback hint (the id is authoritative), + // so collapse characters that would break the token: double-quotes (which + // delimit the name) and curly braces (which would collide with Roam's + // `{{ }}` component syntax and the parser's `}}` terminator). + const safeName = frameName?.replace(/"/g, "'").replace(/[{}]/g, ""); + if (safeName) inner += ` "${safeName}"`; + if (frameShapeId) inner += ` ${frameShapeId}`; + return `{{${inner}}}`; +}; diff --git a/apps/roam/src/utils/refreshConfigTree.ts b/apps/roam/src/utils/refreshConfigTree.ts index 8d23401c8..8261eed94 100644 --- a/apps/roam/src/utils/refreshConfigTree.ts +++ b/apps/roam/src/utils/refreshConfigTree.ts @@ -1,13 +1,46 @@ -import getBasicTreeByParentUid from "roamjs-components/queries/getBasicTreeByParentUid"; import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle"; import getDiscourseRelationLabels from "./getDiscourseRelationLabels"; import discourseConfigRef from "./discourseConfigRef"; import registerDiscourseDatalogTranslators from "./registerDiscourseDatalogTranslators"; import { unregisterDatalogTranslator } from "./conditionToDatalog"; -import type { PullBlock } from "roamjs-components/types/native"; +import type { PullBlock, RoamBasicNode } from "roamjs-components/types/native"; import { DISCOURSE_CONFIG_PAGE_TITLE } from "~/data/constants"; import type { SettingsSnapshot } from "~/components/settings/utils/accessors"; +// Null-safe replacement for roamjs-components' getBasicTreeByParentUid. +// +// Some graphs carry a `:block/children` edge from a page to a "phantom" block +// whose pull returns null (no :block/uid / :block/order / :block/string). The +// upstream helper sorts the raw pull rows by `:block/order` without filtering, +// so a single dangling child throws `Cannot read properties of null` — and, +// because refreshConfigTree runs first in onload, that aborts the entire +// extension load (no commands, menus, or UI register). Dropping the nulls +// preserves every real config block and lets the plugin load. +const sortBasicNodesSafe = (nodes: (PullBlock | null)[]): RoamBasicNode[] => + nodes + .filter((n): n is PullBlock => n !== null) + .sort( + (a, b) => + ((a[":block/order"] as number) || 0) - + ((b[":block/order"] as number) || 0), + ) + .map((node) => ({ + children: sortBasicNodesSafe( + (node[":block/children"] as (PullBlock | null)[]) || [], + ), + uid: node[":block/uid"] || "", + text: node[":block/string"] || "", + })); + +const getBasicTreeByParentUidSafe = (uid: string): RoamBasicNode[] => + sortBasicNodesSafe( + ( + window.roamAlphaAPI.data.fast.q( + `[:find (pull ?c [:block/string :block/uid :block/order {:block/children ...}]) :where [?b :block/uid "${uid}"] [?b :block/children ?c]]`, + ) as [PullBlock | null][] + ).map((a) => a[0]), + ); + const getPagesStartingWithPrefix = (prefix: string) => ( window.roamAlphaAPI.data.fast.q( @@ -22,7 +55,7 @@ const refreshConfigTree = (snapshot?: SettingsSnapshot) => { getDiscourseRelationLabels(undefined, snapshot).forEach((key) => unregisterDatalogTranslator({ key }), ); - discourseConfigRef.tree = getBasicTreeByParentUid( + discourseConfigRef.tree = getBasicTreeByParentUidSafe( getPageUidByPageTitle(DISCOURSE_CONFIG_PAGE_TITLE), ); const pages = getPagesStartingWithPrefix("discourse-graph/nodes"); @@ -32,7 +65,7 @@ const refreshConfigTree = (snapshot?: SettingsSnapshot) => { uid, { text: title.substring("discourse-graph/nodes/".length), - children: getBasicTreeByParentUid(uid), + children: getBasicTreeByParentUidSafe(uid), }, ]; }), diff --git a/apps/roam/src/utils/registerSlashCommands.ts b/apps/roam/src/utils/registerSlashCommands.ts index c47549818..5b4f874bd 100644 --- a/apps/roam/src/utils/registerSlashCommands.ts +++ b/apps/roam/src/utils/registerSlashCommands.ts @@ -1,5 +1,7 @@ import { updateBlock } from "roamjs-components/writes"; import { renderCanvasEmbedDialog } from "~/components/canvas/CanvasEmbedDialog"; +import { renderCanvasFrameEmbedDialog } from "~/components/canvas/CanvasFrameEmbedDialog"; +import { serializeDgCanvasEmbed } from "~/utils/dgCanvasEmbed"; type SlashCommandContext = { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -37,6 +39,21 @@ const SLASH_COMMANDS: { }); }, }, + { + label: "DG: Embed canvas frame", + callback: (context) => { + const uid = context["block-uid"]; + if (!uid) return; + renderCanvasFrameEmbedDialog({ + onSelect: ({ title, frameName, frameShapeId }) => { + void updateBlock({ + uid, + text: serializeDgCanvasEmbed({ title, frameName, frameShapeId }), + }).then(() => document.body.click()); + }, + }); + }, + }, ]; export const registerSlashCommands = (): (() => void) => {