From c2263b30bc9e17b8944876acdbd4dd1e08c56f7b Mon Sep 17 00:00:00 2001 From: mattakamatsu Date: Sat, 11 Jul 2026 22:49:28 -0700 Subject: [PATCH 1/2] Render frame embeds as static snapshots by default (v1) Frame-anchored {{dg-canvas}} embeds now render a read-only SVG image of the frame exported from the persisted canvas data, instead of mounting a full interactive tldraw editor per embed. A trailing `live` token in the block text opts back into the live editor; sync-mode canvases always fall back to the live embed (their block-props snapshot is frozen at switch time) with an explanatory badge. Frameless embeds are unchanged. Why not tldraw's : in 2.4.6 it builds its internal store without custom binding utils or migrations, so discourse-graph canvases (per-relation binding records, legacy node-uid shape types) throw during store creation. CanvasFrameSnapshot replicates its export flow (temporary headless Editor -> getSvgString -> blob URL -> ) on top of a store built with the exact live-mount recipe (buildFrameExportStore), so legacy records migrate on load exactly as they do in the live embed. The export uses tldraw's single-frame semantics: descendants included and clipped to the frame, neighbors excluded, no frame border/label, bounds = frame page bounds - no store filtering, no hand-rolled transforms, rotated/nested frames included. Snapshot chrome: "Open canvas" navigates to the canvas page and zooms to the frame once on arrival (one-shot sessionStorage hint consumed by the full-page canvas mount); "Edit here" swaps the live embed in place until the block re-renders. Snapshots recompute on every mount, which is the feature's whole refresh model - no watchers. Co-Authored-By: Claude Fable 5 --- .../src/components/canvas/CanvasEmbed.tsx | 10 +- .../components/canvas/CanvasFrameEmbed.tsx | 76 ++--- .../components/canvas/CanvasFrameSnapshot.tsx | 319 ++++++++++++++++++ apps/roam/src/components/canvas/Tldraw.tsx | 54 ++- .../canvas/canvasFrameExportStore.ts | 40 +++ .../src/components/canvas/canvasFrameRef.ts | 53 +++ .../canvas/useCanvasStoreAdapterArgs.ts | 4 +- .../buildAllAddReferencedNodeByAction.test.ts | 77 +++++ .../__tests__/canvasFrameExportStore.test.ts | 228 +++++++++++++ .../__tests__/canvasFrameZoomHint.test.ts | 124 +++++++ .../src/utils/__tests__/dgCanvasEmbed.test.ts | 84 +++++ .../buildAllAddReferencedNodeByAction.ts | 39 +++ apps/roam/src/utils/canvasFrameZoomHint.ts | 79 +++++ apps/roam/src/utils/dgCanvasEmbed.ts | 34 +- 14 files changed, 1134 insertions(+), 87 deletions(-) create mode 100644 apps/roam/src/components/canvas/CanvasFrameSnapshot.tsx create mode 100644 apps/roam/src/components/canvas/canvasFrameExportStore.ts create mode 100644 apps/roam/src/components/canvas/canvasFrameRef.ts create mode 100644 apps/roam/src/utils/__tests__/buildAllAddReferencedNodeByAction.test.ts create mode 100644 apps/roam/src/utils/__tests__/canvasFrameExportStore.test.ts create mode 100644 apps/roam/src/utils/__tests__/canvasFrameZoomHint.test.ts create mode 100644 apps/roam/src/utils/buildAllAddReferencedNodeByAction.ts create mode 100644 apps/roam/src/utils/canvasFrameZoomHint.ts diff --git a/apps/roam/src/components/canvas/CanvasEmbed.tsx b/apps/roam/src/components/canvas/CanvasEmbed.tsx index 589a1c619..d67c877df 100644 --- a/apps/roam/src/components/canvas/CanvasEmbed.tsx +++ b/apps/roam/src/components/canvas/CanvasEmbed.tsx @@ -9,7 +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"; -import { CanvasFrameEmbed, findCanvasFrameRef } from "./CanvasFrameEmbed"; +import { findCanvasFrameRef } from "./CanvasFrameEmbed"; +import { CanvasFrameEmbedRouter } from "./CanvasFrameSnapshot"; import { parseDgCanvasEmbed } from "~/utils/dgCanvasEmbed"; const CanvasEmbedPlaceholder = ({ message }: { message: string }) => ( @@ -101,7 +102,12 @@ export const renderCanvasEmbed = ( renderWithUnmount( {frame ? ( - + ) : ( )} diff --git a/apps/roam/src/components/canvas/CanvasFrameEmbed.tsx b/apps/roam/src/components/canvas/CanvasFrameEmbed.tsx index 994950d6a..de38ec1dd 100644 --- a/apps/roam/src/components/canvas/CanvasFrameEmbed.tsx +++ b/apps/roam/src/components/canvas/CanvasFrameEmbed.tsx @@ -1,12 +1,11 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; -import { Button, Card, Elevation } from "@blueprintjs/core"; -import type { Editor, TLFrameShape, TLShapeId } from "tldraw"; +import { Button, Card, Elevation, Icon } from "@blueprintjs/core"; +import type { Editor } from "tldraw"; import { TldrawCanvas, type CanvasEmbedOptions } from "./Tldraw"; import { getCanvasFrameShapes } from "./useRoamStore"; +import { zoomToFrame, type FrameRef } from "./canvasFrameRef"; -const FRAME_ZOOM_INSET = 16; - -export type FrameRef = { name?: string; shapeId?: string }; +export type { FrameRef } from "./canvasFrameRef"; // 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 @@ -42,59 +41,18 @@ export const findCanvasFrameRef = ({ } }; -// 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. +// when the block's frame argument maps to a real frame on the canvas. The +// optional notice explains a forced live render (e.g. the sync-mode fallback +// from the snapshot renderer). export const CanvasFrameEmbed = ({ title, frame, + notice, }: { title: string; frame: FrameRef; + notice?: string; }) => { const editorRef = useRef(null); const rafRef = useRef(null); @@ -149,6 +107,22 @@ export const CanvasFrameEmbed = ({ > ⌖ + {notice && ( + + + + )} {frameMissing && ( : in tldraw 2.4.6 it builds its internal store +// without custom binding utils or migrations, so discourse-graph canvases +// (per-relation binding records, legacy node-uid shape types) throw during +// store creation. This component replicates its export flow (temporary +// headless Editor → getSvgString → blob URL → ) on top of a +// full-fidelity store built exactly like the live mount's. +// +// The export uses tldraw's single-frame semantics (getSvgString([frameId])): +// descendants are included and clipped to the frame, neighbors excluded, the +// frame's own border/label is not drawn, and the image bounds are the frame's +// page bounds with no padding — rotated and nested frames included, no +// hand-rolled transform math. +import React, { useEffect, useState } from "react"; +import { Button, Spinner } from "@blueprintjs/core"; +import { + defaultBindingUtils, + defaultEditorAssetUrls, + defaultShapeUtils, + Editor, + usePreloadAssets, +} from "tldraw"; +import getDiscourseNodes from "~/utils/getDiscourseNodes"; +import getDiscourseRelations, { + DiscourseRelation, +} from "~/utils/getDiscourseRelations"; +import { buildAllAddReferencedNodeByAction } from "~/utils/buildAllAddReferencedNodeByAction"; +import { getFrameEmbedMode } from "~/utils/dgCanvasEmbed"; +import { writeFrameZoomHint } from "~/utils/canvasFrameZoomHint"; +import { discourseContext } from "./Tldraw"; +import { + createBindingUtils, + createShapeUtils, +} from "./useCanvasStoreAdapterArgs"; +import { createMigrations } from "./DiscourseRelationShape/discourseRelationMigrations"; +import { getRoamCanvasSnapshot } from "./useRoamStore"; +import { getEffectiveCanvasSyncMode } from "./canvasSyncMode"; +import { buildFrameExportStore } from "./canvasFrameExportStore"; +import { resolveFrameShape, type FrameRef } from "./canvasFrameRef"; +import { CanvasFrameEmbed } from "./CanvasFrameEmbed"; + +// The same schema inputs the live canvas derives inside TldrawCanvas, built +// headlessly. Also populates the discourseContext singleton (node colors / +// relation lookups read by the shape utils) in case no canvas has mounted +// yet this session. +const buildFrameExportDeps = () => { + const allNodes = getDiscourseNodes(); + const allRelations = getDiscourseRelations(); + + discourseContext.nodes = Object.fromEntries( + allNodes.map((n, index) => [n.type, { ...n, index }]), + ); + discourseContext.relations = allRelations.reduce( + (acc, r) => { + if (acc[r.label]) { + acc[r.label].push(r); + } else { + acc[r.label] = [r]; + } + return acc; + }, + {} as Record, + ); + + const allRelationIds = Array.from(new Set(allRelations.map((r) => r.id))); + const allAddReferencedNodeByAction = + buildAllAddReferencedNodeByAction(allNodes); + + return { + migrations: [ + createMigrations({ + allRelationIds, + allAddReferencedNodeActions: Object.keys(allAddReferencedNodeByAction), + allNodeTypes: allNodes.map((n) => n.type), + }), + ], + customShapeUtils: createShapeUtils({ + allNodes, + allRelationIds, + allAddReferencedNodeByAction, + }), + customBindingUtils: createBindingUtils({ + allRelationIds, + allAddReferencedNodeByAction, + }), + }; +}; + +type SnapshotStatus = + | { kind: "loading" } + | { kind: "ready"; url: string } + | { kind: "frame-missing" } + | { kind: "empty-canvas" } + | { kind: "error" }; + +export const CanvasFrameSnapshot = ({ + title, + pageUid, + frame, + onEditHere, +}: { + title: string; + pageUid: string; + frame: FrameRef; + onEditHere: () => void; +}): JSX.Element => { + const [container, setContainer] = useState(null); + const [status, setStatus] = useState({ kind: "loading" }); + const [hovered, setHovered] = useState(false); + // Fonts must be loaded before the headless editor measures text, or node + // titles come out mispositioned (same preload the live canvas performs). + const assetLoading = usePreloadAssets(defaultEditorAssetUrls); + + useEffect(() => { + if (!container || !assetLoading.done) return; + let cancelled = false; + let objectUrl: string | null = null; + + const exportFrame = async () => { + const deps = buildFrameExportDeps(); + // Personal records (camera/instance/presence) are irrelevant to an + // export and excluded so stale session state can't affect it. + const snapshot = getRoamCanvasSnapshot({ + pageUid, + ...deps, + includePersonalRecords: false, + }); + if (!snapshot) { + if (!cancelled) setStatus({ kind: "empty-canvas" }); + return; + } + + const store = buildFrameExportStore({ snapshot, ...deps }); + const shapeUtils = [...defaultShapeUtils, ...deps.customShapeUtils]; + const bindingUtils = [...defaultBindingUtils, ...deps.customBindingUtils]; + + // tldraw's text measurement needs a real container with the tl-container + // theme classes; both are cleaned up as soon as the export resolves. + const tempElm = document.createElement("div"); + container.appendChild(tempElm); + container.classList.add("tl-container", "tl-theme__light"); + const editor = new Editor({ + store, + shapeUtils, + bindingUtils, + tools: [], + getContainer: () => tempElm, + }); + try { + const frameShape = resolveFrameShape(editor, frame); + if (!frameShape) { + if (!cancelled) setStatus({ kind: "frame-missing" }); + return; + } + const framePageId = editor.getAncestorPageId(frameShape); + if (framePageId && framePageId !== editor.getCurrentPageId()) { + editor.setCurrentPage(framePageId); + } + const result = await editor.getSvgString([frameShape.id], { + background: true, + scale: 1, + }); + if (cancelled) return; + if (!result) { + setStatus({ kind: "frame-missing" }); + return; + } + objectUrl = URL.createObjectURL( + new Blob([result.svg], { type: "image/svg+xml" }), + ); + setStatus({ kind: "ready", url: objectUrl }); + } finally { + editor.dispose(); + tempElm.remove(); + } + }; + + exportFrame().catch((error) => { + // eslint-disable-next-line no-console + console.warn("dg-canvas: frame snapshot export failed", error); + if (!cancelled) setStatus({ kind: "error" }); + }); + + return () => { + cancelled = true; + if (objectUrl) URL.revokeObjectURL(objectUrl); + }; + // `frame` is created once per embed render by renderCanvasEmbed, so its + // identity is stable for the lifetime of this mount. + }, [container, assetLoading.done, pageUid, frame]); + + const handleOpenCanvas = () => { + writeFrameZoomHint({ pageUid, frame }); + void window.roamAlphaAPI.ui.mainWindow.openPage({ + page: { uid: pageUid }, + }); + }; + + const frameLabel = frame.name ? `“${frame.name}” ` : ""; + const placeholderMessage = + status.kind === "frame-missing" + ? `Frame ${frameLabel}not found on [[${title}]]` + : status.kind === "empty-canvas" + ? `[[${title}]] has no drawing yet` + : status.kind === "error" + ? "Couldn't render a snapshot of this frame — use “Edit here” for the live view" + : null; + + return ( +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + {status.kind === "ready" && ( + {`Canvas + )} + {status.kind === "loading" && ( +
+ +
+ )} + {placeholderMessage && ( +
+ {placeholderMessage} +
+ )} +
+ + +
+
+ ); +}; + +// Decides how a frame-anchored embed renders: static snapshot by default, the +// live editor when the block opts in with `live`, when the canvas is a +// sync-mode (multiplayer) canvas whose block-props snapshot could be stale, +// or when the user clicks "Edit here" (local state only — reverts to the +// snapshot when the block re-renders). +export const CanvasFrameEmbedRouter = ({ + title, + pageUid, + frame, + live, +}: { + title: string; + pageUid: string; + frame: FrameRef; + live?: boolean; +}): JSX.Element => { + const [editHere, setEditHere] = useState(false); + const mode = getFrameEmbedMode({ + live, + canvasSyncMode: getEffectiveCanvasSyncMode({ pageUid }), + }); + + if (mode === "live" || editHere) { + return ; + } + if (mode === "live-sync-fallback") { + return ( + + ); + } + return ( + setEditHere(true)} + /> + ); +}; diff --git a/apps/roam/src/components/canvas/Tldraw.tsx b/apps/roam/src/components/canvas/Tldraw.tsx index 2f22fcabd..4d502653b 100644 --- a/apps/roam/src/components/canvas/Tldraw.tsx +++ b/apps/roam/src/components/canvas/Tldraw.tsx @@ -85,10 +85,10 @@ import openBlockInSidebar from "roamjs-components/writes/openBlockInSidebar"; import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageUid"; import renderToast from "roamjs-components/components/Toast"; import { - AddReferencedNodeType, createAllReferencedNodeTools, createAllRelationShapeTools, } from "./DiscourseRelationShape/DiscourseRelationTool"; +import { buildAllAddReferencedNodeByAction } from "~/utils/buildAllAddReferencedNodeByAction"; import ConvertToDialog from "./ConvertToDialog"; import ToastListener, { dispatchToastEvent } from "./ToastListener"; import { CanvasDrawerPanel } from "./CanvasDrawer"; @@ -96,6 +96,8 @@ import { ClipboardPanel, ClipboardProvider } from "./Clipboard"; import internalError from "~/utils/internalError"; import { syncCanvasNodeTitlesOnLoad } from "~/utils/syncCanvasNodeTitlesOnLoad"; import { registerCanvasSessionStatePersistence } from "~/utils/canvasSessionState"; +import { consumeFrameZoomHint } from "~/utils/canvasFrameZoomHint"; +import { zoomToFrame } from "./canvasFrameRef"; import { isPluginTimerReady, waitForPluginTimer } from "~/utils/pluginTimer"; import { HistoryEntry } from "@tldraw/store"; import { TLRecord } from "@tldraw/tlschema"; @@ -528,35 +530,10 @@ const TldrawCanvasShared = ({ return allNodes; }, []); - const allAddReferencedNodeByAction = useMemo(() => { - const obj: AddReferencedNodeType = {}; - - // TODO: support multiple referenced node - // with migration from format to specification - allNodes.forEach((n) => { - const referencedNodes = [...n.format.matchAll(/{([\w\d-]+)}/g)].filter( - (match) => match[1] !== "content", - ); - - if (referencedNodes.length > 0) { - const sourceName = referencedNodes[0][1]; - const sourceType = allNodes.find((node) => node.text === sourceName) - ?.type as string; - - if (!obj[`Add ${sourceName}`]) obj[`Add ${sourceName}`] = []; - - obj[`Add ${sourceName}`].push({ - format: n.format, - sourceName, - sourceType, - destinationType: n.type, - destinationName: n.text, - }); - } - }); - - return obj; - }, [allNodes]); + const allAddReferencedNodeByAction = useMemo( + () => buildAllAddReferencedNodeByAction(allNodes), + [allNodes], + ); const allAddReferencedNodeActions = useMemo(() => { return Object.keys(allAddReferencedNodeByAction); }, [allAddReferencedNodeByAction]); @@ -1085,6 +1062,22 @@ const TldrawCanvasShared = ({ pageUid, }); + // A frame snapshot's "Open canvas" button leaves a one-shot hint + // to land zoomed on its frame. Consumed after session persistence + // restored the remembered camera, so the hint wins; deferred one + // frame so the viewport is measured (same pattern as the frame + // embed's zoom-on-mount). Main-page/sidebar mounts only. + let zoomHintRaf: number | null = null; + if (!embedOptions) { + const zoomHint = consumeFrameZoomHint({ pageUid }); + if (zoomHint) { + zoomHintRaf = requestAnimationFrame(() => { + zoomHintRaf = null; + zoomToFrame(app, zoomHint); + }); + } + } + if (process.env.NODE_ENV !== "production") { if (!window.tldrawApps) window.tldrawApps = {}; const { tldrawApps } = window; @@ -1185,6 +1178,7 @@ const TldrawCanvasShared = ({ embedOptions?.onEditorMount?.(app); return () => { + if (zoomHintRaf !== null) cancelAnimationFrame(zoomHintRaf); unregisterCanvasSessionStatePersistence(); }; }} diff --git a/apps/roam/src/components/canvas/canvasFrameExportStore.ts b/apps/roam/src/components/canvas/canvasFrameExportStore.ts new file mode 100644 index 000000000..a77df912e --- /dev/null +++ b/apps/roam/src/components/canvas/canvasFrameExportStore.ts @@ -0,0 +1,40 @@ +// Store construction for the static frame snapshot renderer. This must be +// byte-for-byte the recipe the live mount uses (useRoamStore's +// createCanvasStore + loadSnapshot): same default+custom utils, same +// migration sequences, so legacy pre-migration records upgrade during load +// exactly as they do when the live embed mounts, and the snapshot renders +// the same data the editor would show. +// +// Kept tldraw-only (no Roam imports) so unit tests can exercise the exact +// recipe in a node environment. +import { + createTLStore, + defaultBindingUtils, + defaultShapeUtils, + loadSnapshot, + MigrationSequence, + TLAnyBindingUtilConstructor, + TLAnyShapeUtilConstructor, + TLStore, + TLStoreSnapshot, +} from "tldraw"; + +export const buildFrameExportStore = ({ + snapshot, + migrations, + customShapeUtils, + customBindingUtils, +}: { + snapshot: TLStoreSnapshot; + migrations: MigrationSequence[]; + customShapeUtils: readonly TLAnyShapeUtilConstructor[]; + customBindingUtils: readonly TLAnyBindingUtilConstructor[]; +}): TLStore => { + const store = createTLStore({ + migrations, + shapeUtils: [...defaultShapeUtils, ...customShapeUtils], + bindingUtils: [...defaultBindingUtils, ...customBindingUtils], + }); + loadSnapshot(store, snapshot); + return store; +}; diff --git a/apps/roam/src/components/canvas/canvasFrameRef.ts b/apps/roam/src/components/canvas/canvasFrameRef.ts new file mode 100644 index 000000000..e62343ee0 --- /dev/null +++ b/apps/roam/src/components/canvas/canvasFrameRef.ts @@ -0,0 +1,53 @@ +// Frame resolution and zoom helpers shared by the frame-anchored embeds and +// the full-page canvas (zoom-on-arrival). Lives in its own tldraw-only module +// so Tldraw.tsx can import it without a cycle through CanvasFrameEmbed.tsx. +import type { Editor, TLFrameShape, TLShapeId } from "tldraw"; + +export type FrameRef = { name?: string; shapeId?: string }; + +export const FRAME_ZOOM_INSET = 16; + +// 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. +export 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. +export 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; +}; diff --git a/apps/roam/src/components/canvas/useCanvasStoreAdapterArgs.ts b/apps/roam/src/components/canvas/useCanvasStoreAdapterArgs.ts index aa9fcbb3c..47ba4bcee 100644 --- a/apps/roam/src/components/canvas/useCanvasStoreAdapterArgs.ts +++ b/apps/roam/src/components/canvas/useCanvasStoreAdapterArgs.ts @@ -58,7 +58,7 @@ const getUtilTypes = ({ return utils.map((u) => u.type); }; -const createShapeUtils = ({ +export const createShapeUtils = ({ allNodes, allRelationIds, allAddReferencedNodeByAction, @@ -79,7 +79,7 @@ const createShapeUtils = ({ ]; }; -const createBindingUtils = ({ +export const createBindingUtils = ({ allRelationIds, allAddReferencedNodeByAction, }: { diff --git a/apps/roam/src/utils/__tests__/buildAllAddReferencedNodeByAction.test.ts b/apps/roam/src/utils/__tests__/buildAllAddReferencedNodeByAction.test.ts new file mode 100644 index 000000000..186dbfffc --- /dev/null +++ b/apps/roam/src/utils/__tests__/buildAllAddReferencedNodeByAction.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { buildAllAddReferencedNodeByAction } from "~/utils/buildAllAddReferencedNodeByAction"; +import type { DiscourseNode } from "~/utils/getDiscourseNodes"; + +const makeNode = (overrides: Partial): DiscourseNode => + ({ + text: "Node", + type: "node-type", + shortcut: "N", + specification: [], + backedBy: "user", + canvasSettings: {}, + format: "[[NOD]] - {content}", + ...overrides, + }) as DiscourseNode; + +describe("buildAllAddReferencedNodeByAction", () => { + it("maps formats referencing another node to an Add action", () => { + const source = makeNode({ + text: "Source", + type: "source-type", + format: "[[SRC]] - {content}", + }); + const claim = makeNode({ + text: "Claim", + type: "claim-type", + format: "[[CLM]] - {Source} - {content}", + }); + + const actions = buildAllAddReferencedNodeByAction([source, claim]); + + expect(Object.keys(actions)).toEqual(["Add Source"]); + expect(actions["Add Source"]).toEqual([ + { + format: claim.format, + sourceName: "Source", + sourceType: "source-type", + destinationType: "claim-type", + destinationName: "Claim", + }, + ]); + }); + + it("ignores {content} placeholders and formats with no reference", () => { + const plain = makeNode({ format: "[[EVD]] - {content}" }); + expect(buildAllAddReferencedNodeByAction([plain])).toEqual({}); + }); + + it("groups multiple destinations under one shared source action", () => { + const source = makeNode({ + text: "Source", + type: "source-type", + format: "[[SRC]] - {content}", + }); + const claim = makeNode({ + text: "Claim", + type: "claim-type", + format: "[[CLM]] - {Source} - {content}", + }); + const question = makeNode({ + text: "Question", + type: "question-type", + format: "[[QUE]] - {Source} - {content}", + }); + + const actions = buildAllAddReferencedNodeByAction([ + source, + claim, + question, + ]); + + expect(actions["Add Source"]).toHaveLength(2); + expect( + actions["Add Source"].map((action) => action.destinationName), + ).toEqual(["Claim", "Question"]); + }); +}); diff --git a/apps/roam/src/utils/__tests__/canvasFrameExportStore.test.ts b/apps/roam/src/utils/__tests__/canvasFrameExportStore.test.ts new file mode 100644 index 000000000..3cb8503b6 --- /dev/null +++ b/apps/roam/src/utils/__tests__/canvasFrameExportStore.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it } from "vitest"; +import { + BaseBoxShapeUtil, + DocumentRecordType, + PageRecordType, + IndexKey, + T, + TLBaseShape, + TLDOCUMENT_ID, + TLFrameShape, + TLStoreSnapshot, + createMigrationIds, + createMigrationSequence, + createTLStore, + defaultBindingUtils, + defaultShapeUtils, +} from "tldraw"; +import { buildFrameExportStore } from "~/components/canvas/canvasFrameExportStore"; + +// The frame snapshot renderer builds its export store with the exact recipe +// the live mount uses (default + custom utils + migration sequences). These +// tests pin the property that makes the static path safe on old data: a +// persisted store holding a legacy node-uid-typed shape (the record family +// behind the 2026-07 blank-page bug) must migrate during load rather than +// throw, and frame parent/child structure must survive so single-frame export +// picks up descendants. Scaffolding mirrors canvasRemoteMerge.test.ts. +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, +}); + +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 OUTER_FRAME_ID = "shape:frameOuter"; +const INNER_FRAME_ID = "shape:frameInner"; +const LEGACY_SHAPE_ID = "shape:legacy1"; +const NEIGHBOR_SHAPE_ID = "shape:neighbor1"; + +const baseShape = ( + overrides: Partial>, +): Record => ({ + typeName: "shape", + x: 0, + y: 0, + rotation: 0, + isLocked: false, + opacity: 1, + meta: {}, + parentId: PAGE_ID, + ...overrides, +}); + +const makeFrameShape = ( + id: string, + overrides: Partial> = {}, +): Record => + baseShape({ + id, + type: "frame", + index: "a2", + props: { w: 400, h: 300, name: "Frame" }, + ...overrides, + }); + +const makeLegacyShape = ( + overrides: Partial> = {}, +): Record => + baseShape({ + id: LEGACY_SHAPE_ID, + type: LEGACY_NODE_TYPE, + index: "a1", + props: { w: 100, h: 50 }, + ...overrides, + }); + +// A persisted snapshot the way Roam block props hold it: records saved before +// the discourse migration sequence existed, so legacy-typed shapes are raw. +const makePersistedSnapshot = ( + records: 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 records) { + store[record.id as string] = record; + } + return { + store: store as TLStoreSnapshot["store"], + schema: snapshot.schema, + }; +}; + +const exportDeps = { + migrations: [testMigrations], + customShapeUtils: [TestDiscourseNodeUtil], + customBindingUtils: [], +}; + +describe("buildFrameExportStore", () => { + it("migrates a legacy node-uid-typed child of a frame during load", () => { + const snapshot = makePersistedSnapshot([ + makeFrameShape(OUTER_FRAME_ID), + makeLegacyShape({ parentId: OUTER_FRAME_ID }), + ]); + + const store = buildFrameExportStore({ snapshot, ...exportDeps }); + + const migrated = store.get( + LEGACY_SHAPE_ID as Parameters[0], + ) as unknown as { + type: string; + parentId: string; + props: { nodeTypeId: string }; + }; + expect(migrated.type).toBe(MIGRATED_NODE_TYPE); + expect(migrated.props.nodeTypeId).toBe(LEGACY_NODE_TYPE); + expect(migrated.parentId).toBe(OUTER_FRAME_ID); + }); + + it("preserves a nested-frame parentId chain (what single-frame export walks)", () => { + const snapshot = makePersistedSnapshot([ + makeFrameShape(OUTER_FRAME_ID), + makeFrameShape(INNER_FRAME_ID, { + parentId: OUTER_FRAME_ID, + index: "a1", + props: { w: 200, h: 150, name: "Inner" }, + }), + makeLegacyShape({ parentId: INNER_FRAME_ID }), + makeFrameShape(NEIGHBOR_SHAPE_ID, { + index: "a3", + props: { w: 100, h: 100, name: "Neighbor" }, + }), + ]); + + const store = buildFrameExportStore({ snapshot, ...exportDeps }); + + const shapeAt = (id: string) => + store.get(id as Parameters[0]) as unknown as { + parentId: string; + }; + expect(shapeAt(INNER_FRAME_ID).parentId).toBe(OUTER_FRAME_ID); + expect(shapeAt(LEGACY_SHAPE_ID).parentId).toBe(INNER_FRAME_ID); + // The out-of-frame neighbor stays in the store (arrows bound to it keep + // rendering); export excludes it by walking the frame's descendants, not + // by store filtering. + expect(shapeAt(NEIGHBOR_SHAPE_ID).parentId).toBe(PAGE_ID); + const outer = store.get( + OUTER_FRAME_ID as Parameters[0], + ) as TLFrameShape | undefined; + expect(outer?.type).toBe("frame"); + }); + + // Documents why buildFrameExportDeps must supply real migrations/utils: the + // same persisted data without them fails schema validation on load — the + // pre-fix blank-page failure mode. + it("throws on legacy data when the migration sequence is missing", () => { + const snapshot = makePersistedSnapshot([ + makeFrameShape(OUTER_FRAME_ID), + makeLegacyShape({ parentId: OUTER_FRAME_ID }), + ]); + + expect(() => + buildFrameExportStore({ + snapshot, + migrations: [], + customShapeUtils: [], + customBindingUtils: [], + }), + ).toThrow(); + }); +}); diff --git a/apps/roam/src/utils/__tests__/canvasFrameZoomHint.test.ts b/apps/roam/src/utils/__tests__/canvasFrameZoomHint.test.ts new file mode 100644 index 000000000..31c951ac1 --- /dev/null +++ b/apps/roam/src/utils/__tests__/canvasFrameZoomHint.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; +import { + consumeFrameZoomHint, + writeFrameZoomHint, +} from "~/utils/canvasFrameZoomHint"; + +const makeStorage = () => { + const map = new Map(); + return { + getItem: (key: string) => (map.has(key) ? map.get(key)! : null), + setItem: (key: string, value: string) => { + map.set(key, value); + }, + removeItem: (key: string) => { + map.delete(key); + }, + size: () => map.size, + raw: map, + }; +}; + +const PAGE_UID = "abc123def"; +const KEY = `dg-canvas:zoom-to-frame:${PAGE_UID}`; + +describe("canvasFrameZoomHint", () => { + it("round-trips a frame ref through write and consume", () => { + const storage = makeStorage(); + writeFrameZoomHint({ + pageUid: PAGE_UID, + frame: { name: "Frame A", shapeId: "shape:x1" }, + storage, + now: 1000, + }); + expect( + consumeFrameZoomHint({ pageUid: PAGE_UID, storage, now: 2000 }), + ).toEqual({ name: "Frame A", shapeId: "shape:x1" }); + }); + + it("is one-shot: the second consume returns null", () => { + const storage = makeStorage(); + writeFrameZoomHint({ + pageUid: PAGE_UID, + frame: { shapeId: "shape:x1" }, + storage, + now: 1000, + }); + expect( + consumeFrameZoomHint({ pageUid: PAGE_UID, storage, now: 1001 }), + ).toEqual({ shapeId: "shape:x1" }); + expect( + consumeFrameZoomHint({ pageUid: PAGE_UID, storage, now: 1002 }), + ).toBeNull(); + expect(storage.size()).toBe(0); + }); + + it("is scoped per canvas page", () => { + const storage = makeStorage(); + writeFrameZoomHint({ + pageUid: PAGE_UID, + frame: { shapeId: "shape:x1" }, + storage, + now: 1000, + }); + expect( + consumeFrameZoomHint({ pageUid: "otherPage", storage, now: 1001 }), + ).toBeNull(); + // The other page's consume must not have destroyed this page's hint. + expect( + consumeFrameZoomHint({ pageUid: PAGE_UID, storage, now: 1002 }), + ).toEqual({ shapeId: "shape:x1" }); + }); + + it("expires stale hints instead of zooming long after the click", () => { + const storage = makeStorage(); + writeFrameZoomHint({ + pageUid: PAGE_UID, + frame: { name: "Frame A" }, + storage, + now: 1000, + }); + expect( + consumeFrameZoomHint({ + pageUid: PAGE_UID, + storage, + now: 1000 + 2 * 60 * 1000 + 1, + }), + ).toBeNull(); + }); + + it("clears and ignores corrupt or shapeless stored values", () => { + const storage = makeStorage(); + storage.setItem(KEY, "not json {"); + expect( + consumeFrameZoomHint({ pageUid: PAGE_UID, storage, now: 1000 }), + ).toBeNull(); + expect(storage.size()).toBe(0); + + storage.setItem(KEY, JSON.stringify({ savedAt: 999 })); + expect( + consumeFrameZoomHint({ pageUid: PAGE_UID, storage, now: 1000 }), + ).toBeNull(); + expect(storage.size()).toBe(0); + }); + + it("skips writing a hint with no name or shape id", () => { + const storage = makeStorage(); + writeFrameZoomHint({ pageUid: PAGE_UID, frame: {}, storage, now: 1000 }); + expect(storage.size()).toBe(0); + }); + + it("never throws when storage is unavailable", () => { + expect(() => + writeFrameZoomHint({ + pageUid: PAGE_UID, + frame: { name: "Frame A" }, + storage: null, + now: 1000, + }), + ).not.toThrow(); + expect( + consumeFrameZoomHint({ pageUid: PAGE_UID, storage: null, now: 1000 }), + ).toBeNull(); + }); +}); diff --git a/apps/roam/src/utils/__tests__/dgCanvasEmbed.test.ts b/apps/roam/src/utils/__tests__/dgCanvasEmbed.test.ts index 7172a9c2c..ded087c1c 100644 --- a/apps/roam/src/utils/__tests__/dgCanvasEmbed.test.ts +++ b/apps/roam/src/utils/__tests__/dgCanvasEmbed.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + getFrameEmbedMode, parseDgCanvasEmbed, serializeDgCanvasEmbed, } from "~/utils/dgCanvasEmbed"; @@ -85,6 +86,75 @@ describe("parseDgCanvasEmbed", () => { expect(parseDgCanvasEmbed("{{dg-query: [[My Canvas]]}}")).toBeNull(); expect(parseDgCanvasEmbed("just some text")).toBeNull(); }); + + it("parses the trailing `live` modifier in every frame-argument shape", () => { + expect( + parseDgCanvasEmbed( + '{{dg-canvas: [[My Canvas]] "Frame A" shape:aB1_c-2 live}}', + ), + ).toEqual({ + title: "My Canvas", + frameName: "Frame A", + frameShapeId: "shape:aB1_c-2", + live: true, + }); + expect( + parseDgCanvasEmbed("{{dg-canvas: [[My Canvas]] shape:aB1_c-2 live}}"), + ).toEqual({ + title: "My Canvas", + frameShapeId: "shape:aB1_c-2", + live: true, + }); + expect( + parseDgCanvasEmbed('{{dg-canvas: [[My Canvas]] "Frame A" live}}'), + ).toEqual({ + title: "My Canvas", + frameName: "Frame A", + live: true, + }); + }); + + it("parses a bare `live` with no frame argument (frameless embeds are live anyway)", () => { + expect(parseDgCanvasEmbed("{{dg-canvas: [[My Canvas]] live}}")).toEqual({ + title: "My Canvas", + live: true, + }); + }); + + // `live` is canonical: lowercase, always last. Anything else falls under the + // existing degradation contract (frame args ignored, whole-canvas embed). + it("degrades when `live` is misplaced, cased differently, or followed by junk", () => { + expect( + parseDgCanvasEmbed("{{dg-canvas: [[My Canvas]] live shape:aB1_c-2}}"), + ).toEqual({ title: "My Canvas" }); + expect( + parseDgCanvasEmbed('{{dg-canvas: [[My Canvas]] "Frame A" Live}}'), + ).toEqual({ title: "My Canvas" }); + expect( + parseDgCanvasEmbed('{{dg-canvas: [[My Canvas]] "Frame A" live junk}}'), + ).toEqual({ title: "My Canvas" }); + }); +}); + +describe("getFrameEmbedMode", () => { + it("defaults to snapshot for local-mode canvases", () => { + expect(getFrameEmbedMode({ canvasSyncMode: "local" })).toBe("snapshot"); + }); + + it("honors an explicit live request regardless of sync mode", () => { + expect(getFrameEmbedMode({ live: true, canvasSyncMode: "local" })).toBe( + "live", + ); + expect(getFrameEmbedMode({ live: true, canvasSyncMode: "sync" })).toBe( + "live", + ); + }); + + it("falls back to a labeled live embed for sync-mode canvases", () => { + expect(getFrameEmbedMode({ canvasSyncMode: "sync" })).toBe( + "live-sync-fallback", + ); + }); }); describe("serializeDgCanvasEmbed", () => { @@ -105,6 +175,20 @@ describe("serializeDgCanvasEmbed", () => { expect(parseDgCanvasEmbed(text)).toEqual(embed); }); + it("round-trips the live modifier", () => { + const embed = { + title: "My Canvas", + frameName: "Frame A", + frameShapeId: "shape:aB1_c-2", + live: true, + }; + const text = serializeDgCanvasEmbed(embed); + expect(text).toBe( + '{{dg-canvas: [[My Canvas]] "Frame A" shape:aB1_c-2 live}}', + ); + 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", diff --git a/apps/roam/src/utils/buildAllAddReferencedNodeByAction.ts b/apps/roam/src/utils/buildAllAddReferencedNodeByAction.ts new file mode 100644 index 000000000..728756214 --- /dev/null +++ b/apps/roam/src/utils/buildAllAddReferencedNodeByAction.ts @@ -0,0 +1,39 @@ +// Maps "Add " tool actions to the node formats that reference that +// source (e.g. a Claim format containing {Source}). Extracted from the +// TldrawCanvas component so headless canvas consumers (the frame snapshot +// renderer) can build the same custom shape/binding utils without mounting +// the editor. +import type { DiscourseNode } from "~/utils/getDiscourseNodes"; +import type { AddReferencedNodeType } from "~/components/canvas/DiscourseRelationShape/DiscourseRelationTool"; + +export const buildAllAddReferencedNodeByAction = ( + allNodes: DiscourseNode[], +): AddReferencedNodeType => { + const obj: AddReferencedNodeType = {}; + + // TODO: support multiple referenced node + // with migration from format to specification + allNodes.forEach((n) => { + const referencedNodes = [...n.format.matchAll(/{([\w\d-]+)}/g)].filter( + (match) => match[1] !== "content", + ); + + if (referencedNodes.length > 0) { + const sourceName = referencedNodes[0][1]; + const sourceType = allNodes.find((node) => node.text === sourceName) + ?.type as string; + + if (!obj[`Add ${sourceName}`]) obj[`Add ${sourceName}`] = []; + + obj[`Add ${sourceName}`].push({ + format: n.format, + sourceName, + sourceType, + destinationType: n.type, + destinationName: n.text, + }); + } + }); + + return obj; +}; diff --git a/apps/roam/src/utils/canvasFrameZoomHint.ts b/apps/roam/src/utils/canvasFrameZoomHint.ts new file mode 100644 index 000000000..23728afb6 --- /dev/null +++ b/apps/roam/src/utils/canvasFrameZoomHint.ts @@ -0,0 +1,79 @@ +// One-shot "zoom to this frame when the canvas page next opens" hint, written +// by a frame snapshot's "Open canvas" button and consumed by the full-page +// canvas on mount. sessionStorage scopes it to the tab; the TTL guards against +// a hint written just before the user navigated somewhere else entirely and +// only hitting the canvas much later. + +export type FrameZoomHint = { name?: string; shapeId?: string }; + +type HintStorage = Pick; + +const MAX_AGE_MS = 2 * 60 * 1000; + +const keyFor = (pageUid: string): string => + `dg-canvas:zoom-to-frame:${pageUid}`; + +const defaultStorage = (): HintStorage | null => { + try { + return typeof window === "undefined" ? null : window.sessionStorage; + } catch { + // Storage access can throw in sandboxed/embedded contexts. + return null; + } +}; + +export const writeFrameZoomHint = ({ + pageUid, + frame, + storage = defaultStorage(), + now = Date.now(), +}: { + pageUid: string; + frame: FrameZoomHint; + storage?: HintStorage | null; + now?: number; +}): void => { + if (!storage) return; + if (!frame.name && !frame.shapeId) return; + try { + storage.setItem( + keyFor(pageUid), + JSON.stringify({ ...frame, savedAt: now }), + ); + } catch { + // Quota/security errors just mean no zoom-on-arrival — never break the click. + } +}; + +export const consumeFrameZoomHint = ({ + pageUid, + storage = defaultStorage(), + now = Date.now(), +}: { + pageUid: string; + storage?: HintStorage | null; + now?: number; +}): FrameZoomHint | null => { + if (!storage) return null; + try { + const raw = storage.getItem(keyFor(pageUid)); + // One-shot: clear before parsing so a corrupt value can't stick around. + if (raw !== null) storage.removeItem(keyFor(pageUid)); + if (!raw) return null; + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null) return null; + const { name, shapeId, savedAt } = parsed as { + name?: unknown; + shapeId?: unknown; + savedAt?: unknown; + }; + if (typeof savedAt !== "number" || now - savedAt > MAX_AGE_MS) return null; + const hint: FrameZoomHint = { + ...(typeof name === "string" && name ? { name } : {}), + ...(typeof shapeId === "string" && shapeId ? { shapeId } : {}), + }; + return hint.name || hint.shapeId ? hint : null; + } catch { + return null; + } +}; diff --git a/apps/roam/src/utils/dgCanvasEmbed.ts b/apps/roam/src/utils/dgCanvasEmbed.ts index 4e5c63b5f..46ccd8ee2 100644 --- a/apps/roam/src/utils/dgCanvasEmbed.ts +++ b/apps/roam/src/utils/dgCanvasEmbed.ts @@ -6,6 +6,10 @@ // {{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) +// {{dg-canvas: [[Canvas Page Title]] "Frame Name" shape:abc live}} frame, live editor (see below) +// +// Frame-anchored embeds render as a static snapshot by default; a trailing +// `live` token (lowercase, always last) opts back into the interactive editor. // // Frames are referenced by tldraw shape id (stable across renames and moves); // the display name is carried alongside for human readability and as a @@ -24,6 +28,9 @@ export type DgCanvasEmbed = { // Full tldraw shape id, e.g. "shape:abc123" (tldraw ids use the nanoid // alphabet: A-Z a-z 0-9 _ -). frameShapeId?: string; + // Frame embeds only: force the interactive editor instead of the default + // static snapshot. + live?: boolean; }; // Group 1: page title. Group 2: everything between the closing `]]` and the @@ -37,8 +44,12 @@ 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*$/; +// quoted frame name followed by an optional shape-id token, optionally +// followed by the `live` modifier. Kept case-sensitive: shape ids are +// case-sensitive, and a canonical lowercase `live` keeps the degradation +// rule simple (anything else in the tail → whole-canvas embed). +const FRAME_ARGS_REGEX = + /^\s*(?:"([^"]*)"\s*)?(shape:[A-Za-z0-9_-]+)?\s*(live)?\s*$/; export const parseDgCanvasEmbed = (blockText: string): DgCanvasEmbed | null => { const match = blockText.match(DG_CANVAS_EMBED_REGEX); @@ -55,6 +66,7 @@ export const parseDgCanvasEmbed = (blockText: string): DgCanvasEmbed | null => { title, ...(frameName ? { frameName } : {}), ...(frameShapeId ? { frameShapeId } : {}), + ...(frameArgs?.[3] ? { live: true } : {}), }; }; @@ -62,6 +74,7 @@ export const serializeDgCanvasEmbed = ({ title, frameName, frameShapeId, + live, }: DgCanvasEmbed): string => { let inner = `dg-canvas: [[${title}]]`; // The name is only ever a readability/fallback hint (the id is authoritative), @@ -71,5 +84,22 @@ export const serializeDgCanvasEmbed = ({ const safeName = frameName?.replace(/"/g, "'").replace(/[{}]/g, ""); if (safeName) inner += ` "${safeName}"`; if (frameShapeId) inner += ` ${frameShapeId}`; + if (live) inner += " live"; return `{{${inner}}}`; }; + +// How a frame-anchored embed should render. Pure so it is unit-testable: the +// caller supplies the canvas's effective sync mode. Sync-mode canvases fall +// back to the live embed because their block-props snapshot may lag the +// multiplayer room — an explicit `live` request is already the live editor, +// so the fallback distinction only matters for labeling. +export type FrameEmbedMode = "snapshot" | "live" | "live-sync-fallback"; + +export const getFrameEmbedMode = ({ + live, + canvasSyncMode, +}: { + live?: boolean; + canvasSyncMode: "local" | "sync"; +}): FrameEmbedMode => + live ? "live" : canvasSyncMode === "sync" ? "live-sync-fallback" : "snapshot"; From 8c66e4df6ec913a4580ab271672eb753c41f7f88 Mon Sep 17 00:00:00 2001 From: mattakamatsu Date: Sat, 11 Jul 2026 23:46:15 -0700 Subject: [PATCH 2/2] Retry zoom-on-arrival until the canvas viewport is measured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Open-canvas zoom-to-frame relied on a single requestAnimationFrame after onMount. On a warm canvas load that works, but on a cold load (first navigation to the canvas page) the editor's viewport is not measured yet one frame in, and zoomToBounds is a no-op against a zero-size viewport — so the camera was left at the default origin instead of the frame. Verified live in plugin-testing-akamatsulab2: warm loads framed the target correctly; a genuine cold load (session camera cleared) ended at the default {0,0} camera even though the hint was consumed and the frame resolved. Manual zoomToBounds after the viewport measured always worked. Fix: retry across frames until getViewportScreenBounds reports a measured (non-zero) viewport and zoomToFrame succeeds, then zoom exactly once. Bounded to ~90 frames so a deleted frame or never-sized container gives up instead of spinning; the existing cleanup already cancels the pending frame on unmount. Co-Authored-By: Claude Fable 5 --- apps/roam/src/components/canvas/Tldraw.tsx | 28 +++++++++++++++++----- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/apps/roam/src/components/canvas/Tldraw.tsx b/apps/roam/src/components/canvas/Tldraw.tsx index 4d502653b..3714eb2fe 100644 --- a/apps/roam/src/components/canvas/Tldraw.tsx +++ b/apps/roam/src/components/canvas/Tldraw.tsx @@ -1064,17 +1064,33 @@ const TldrawCanvasShared = ({ // A frame snapshot's "Open canvas" button leaves a one-shot hint // to land zoomed on its frame. Consumed after session persistence - // restored the remembered camera, so the hint wins; deferred one - // frame so the viewport is measured (same pattern as the frame - // embed's zoom-on-mount). Main-page/sidebar mounts only. + // restored the remembered camera, so the hint wins. Main-page / + // sidebar mounts only. + // + // zoomToBounds is a no-op against an unmeasured (zero-size) + // viewport, and on a cold page load the editor's viewport is not + // measured yet at onMount — a single deferred frame lands too + // early and leaves the camera at the default origin. Retry across + // frames until the viewport is measured and the frame resolves, + // then zoom exactly once. Bounded so a deleted/missing frame or a + // never-sized container gives up instead of spinning. let zoomHintRaf: number | null = null; if (!embedOptions) { const zoomHint = consumeFrameZoomHint({ pageUid }); if (zoomHint) { - zoomHintRaf = requestAnimationFrame(() => { + let zoomHintAttempts = 0; + const applyZoomHint = () => { zoomHintRaf = null; - zoomToFrame(app, zoomHint); - }); + const screen = app.getViewportScreenBounds(); + const viewportMeasured = + !!screen && screen.w > 1 && screen.h > 1; + if (viewportMeasured && zoomToFrame(app, zoomHint)) return; + // ~90 frames ≈ 1.5s: long enough for a cold canvas to size + // its container, short enough to stop if the frame is gone. + if (zoomHintAttempts++ < 90) + zoomHintRaf = requestAnimationFrame(applyZoomHint); + }; + zoomHintRaf = requestAnimationFrame(applyZoomHint); } }