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 (
+
+
+ e.stopPropagation()}
+ onClick={handleRecenter}
+ style={{ position: "absolute", top: 6, right: 6, zIndex: 300 }}
+ >
+ ⌖
+
+ {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}
+
+ setSelectedPage(null)}
+ >
+ Change
+
+
+
+
+ {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) => {