diff --git a/apps/roam/src/components/canvas/DgSubpageBreadcrumb.tsx b/apps/roam/src/components/canvas/DgSubpageBreadcrumb.tsx
new file mode 100644
index 000000000..c1283d574
--- /dev/null
+++ b/apps/roam/src/components/canvas/DgSubpageBreadcrumb.tsx
@@ -0,0 +1,114 @@
+// Screen-fixed breadcrumb + back bar for nested sub-pages. Real UI chrome, not
+// a drawn shape: registered as the tldraw `HelperButtons` UI component (the
+// slot under the page menu, verified visible in 2.4.6), composed with the
+// default helper buttons rather than replacing them.
+import React from "react";
+import { DefaultHelperButtons, useEditor, useValue } from "tldraw";
+import { enterPage, getLineage } from "./nestedPageNavigation";
+
+const DgSubpageBreadcrumb = () => {
+ const editor = useEditor();
+ const chain = useValue("dg-subpage-breadcrumb", () => getLineage(editor), [
+ editor,
+ ]);
+ // Root page (no dgNested.parentPageId): render nothing.
+ if (chain.length <= 1) return null;
+
+ const go = (id: string) => {
+ if (id !== editor.getCurrentPageId()) enterPage(editor, id);
+ };
+ const parent = chain[chain.length - 2];
+
+ return (
+
+
{
+ e.stopPropagation();
+ go(parent.id);
+ }}
+ style={{
+ border: "1px solid #dfe1e6",
+ background: "#f7f8fa",
+ borderRadius: 7,
+ padding: "3px 9px",
+ cursor: "pointer",
+ font: "inherit",
+ fontWeight: 600,
+ color: "#3a3d42",
+ whiteSpace: "nowrap",
+ }}
+ >
+ ⬅ back
+
+
+ {chain.map((page, i) => {
+ const isLast = i === chain.length - 1;
+ return (
+
+ {i > 0 ? ▸ : null}
+ {
+ e.stopPropagation();
+ if (!isLast) go(page.id);
+ }}
+ style={{
+ border: "none",
+ background: "transparent",
+ padding: "2px 4px",
+ font: "inherit",
+ cursor: isLast ? "default" : "pointer",
+ color: isLast ? "#1d1d1f" : "#5b6bd6",
+ fontWeight: isLast ? 600 : 500,
+ maxWidth: 220,
+ whiteSpace: "nowrap",
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+ }}
+ >
+ {page.name}
+
+
+ );
+ })}
+
+
+ );
+};
+
+// The HelperButtons slot override: keep the default content (back-to-content
+// etc.) and add the breadcrumb under it.
+export const NestedPageHelperButtons = () => (
+ <>
+
+
+ >
+);
diff --git a/apps/roam/src/components/canvas/DgSubpageUtil.tsx b/apps/roam/src/components/canvas/DgSubpageUtil.tsx
new file mode 100644
index 000000000..ccc284032
--- /dev/null
+++ b/apps/roam/src/components/canvas/DgSubpageUtil.tsx
@@ -0,0 +1,737 @@
+// Nested sub-page portals, rendered by a portal-aware NATIVE geo util. A portal
+// is a plain `geo` rectangle whose `meta.dgSubpage` names a target page; this
+// util renders those as a framed sub-canvas — a colored title bar (click =
+// enter the target page) over a live spatial preview of that page — and defers
+// to the stock geo behavior for every other geo shape.
+//
+// Why meta on a native shape instead of a custom shape type: backward
+// compatibility. A plugin version without this feature loads the same board and
+// shows a labeled rectangle (movable, bindable, page menu still navigates the
+// flat page list); an unknown custom shape type would instead make
+// loadSnapshot throw and blank the whole canvas there. Verified against
+// tldraw 2.4.6: unknown meta and unknown migration sequences are tolerated,
+// unknown shape types are not (see utils/__tests__/nestedPagesCompat.test.ts).
+//
+// Ported from the tldraw-offline prototype; see
+// dg-prototypes/nested-pages/SPEC.md (§4 shape contract, §5 preview spec).
+import {
+ FileHelpers,
+ GeoShapeUtil,
+ HTMLContainer,
+ SvgExportContext,
+ TLAssetId,
+ TLGeoShape,
+ TLPageId,
+ toDomPrecision,
+ useValue,
+} from "tldraw";
+import React from "react";
+import { discourseContext } from "./Tldraw";
+import { COLOR_PALETTE } from "./DiscourseNodeUtil";
+import { getDiscourseNodeColors } from "~/utils/getDiscourseNodeColors";
+import {
+ buildPreviewModel,
+ buildPrefixMatchers,
+ getBoxLabel,
+ getSubpageMeta,
+ layoutPreview,
+ PrefixMatcher,
+ PreviewBox,
+ PreviewModel,
+ PreviewShapeDescriptor,
+ SubpageMeta,
+ SUBPAGE_HEADER_HEIGHT,
+ SUBPAGE_SHAPE_TYPE,
+} from "~/utils/nestedPages";
+import { DEFAULT_PORTAL_ACCENT, enterPage } from "./nestedPageNavigation";
+
+// Tier-2 classification derives its prefix alternation from the live grammar's
+// node formats — never a hardcoded code list. Cached per nodes object (the
+// discourseContext.nodes record is replaced wholesale when settings load).
+const matcherCache = new WeakMap();
+const getPrefixMatchers = (): PrefixMatcher[] => {
+ const nodes = discourseContext.nodes;
+ let matchers = matcherCache.get(nodes);
+ if (!matchers) {
+ matchers = buildPrefixMatchers(Object.values(nodes));
+ matcherCache.set(nodes, matchers);
+ }
+ return matchers;
+};
+
+const codeForNodeType = (nodeType?: string): string | undefined =>
+ nodeType
+ ? getPrefixMatchers().find((m) => m.nodeType === nodeType)?.prefix
+ : undefined;
+
+const tint = (hex: string, alpha: number): string => {
+ const m = /^#?([0-9a-f]{6})$/i.exec(hex || "");
+ if (!m) return `rgba(150,150,150,${alpha})`;
+ const n = parseInt(m[1], 16);
+ return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${alpha})`;
+};
+
+type BoxPaint = {
+ fill: string;
+ stroke: string;
+ strokeWidth: number;
+ dashed?: boolean;
+ labelColor: string;
+ labelWeight: number;
+};
+
+// One paint table shared by the live (HTML) and export (SVG) renderers so the
+// two pictures cannot drift. Discourse nodes use the host's own node colors —
+// the same background the real node renders with on the target page.
+const paintBox = (box: PreviewBox): BoxPaint => {
+ switch (box.kind) {
+ case "frame":
+ return {
+ fill: "transparent",
+ stroke: "#b6bcc2",
+ strokeWidth: 1,
+ dashed: true,
+ labelColor: "#6b7178",
+ labelWeight: 400,
+ };
+ case "image":
+ return {
+ fill: "#eef2f7",
+ stroke: "#cfd8e6",
+ strokeWidth: 1,
+ labelColor: "#2b2f33",
+ labelWeight: 400,
+ };
+ case "node": {
+ const { backgroundColor, textColor } = getDiscourseNodeColors({
+ nodeType: box.nodeType,
+ discourseNodes: Object.values(discourseContext.nodes),
+ });
+ return {
+ fill: backgroundColor,
+ stroke: "rgba(0,0,0,0.12)",
+ strokeWidth: 1,
+ labelColor: textColor,
+ labelWeight: 600,
+ };
+ }
+ case "portal": {
+ const accent = box.color ?? DEFAULT_PORTAL_ACCENT;
+ return {
+ fill: tint(accent, 0.1),
+ stroke: accent,
+ strokeWidth: 1.5,
+ labelColor: "#2b2f33",
+ labelWeight: 600,
+ };
+ }
+ case "text":
+ return {
+ fill: "transparent",
+ stroke: "none",
+ strokeWidth: 0,
+ labelColor: "#495057",
+ labelWeight: 500,
+ };
+ default: {
+ const hex = COLOR_PALETTE[box.colorStyle ?? ""] ?? "#adb5bd";
+ return {
+ fill: tint(hex, 0.15),
+ stroke: tint(hex, 0.55),
+ strokeWidth: 1,
+ labelColor: "#2b2f33",
+ labelWeight: 400,
+ };
+ }
+ }
+};
+
+const trunc = (s: string, px: number, fontSize: number): string => {
+ const max = Math.max(3, Math.floor(px / (fontSize * 0.55)));
+ return s.length > max ? `${s.slice(0, max - 1)}…` : s;
+};
+
+// Distinct component types per branch so that when a shape flips between
+// portal and plain geo (linkSubpagePortal on an existing shape), React
+// remounts instead of tripping over a changed hook order.
+const NativeGeo = ({
+ render,
+}: {
+ render: () => React.ReactNode;
+}): JSX.Element => <>{render()}>;
+
+const SubpagePortal = ({
+ util,
+ shape,
+ portal,
+}: {
+ util: DgSubpageGeoUtil;
+ shape: TLGeoShape;
+ portal: SubpageMeta;
+}): JSX.Element => {
+ const { w, h } = shape.props;
+ const accent = portal.accent ?? DEFAULT_PORTAL_ACCENT;
+ const subtitle = portal.subtitle ?? "";
+ const targetPageId = portal.targetPageId;
+ // Reactive live read: recomputes when the target page's shapes change.
+ const model = useValue(
+ `dg-subpage-preview-${shape.id}`,
+ () => util.readPreviewModel(targetPageId),
+ [util, targetPageId],
+ );
+ // The header shows the live page name, so renaming the target page is
+ // reflected immediately; meta.title is only the missing-page fallback.
+ const livePageName = useValue(
+ `dg-subpage-title-${shape.id}`,
+ () => util.editor.getPage(targetPageId as TLPageId)?.name ?? null,
+ [util, targetPageId],
+ );
+ const headerTitle = livePageName ?? portal.title ?? "Sub-canvas";
+
+ const boxes: JSX.Element[] = [];
+ if (model?.bounds) {
+ const layout = layoutPreview({
+ shape: { w, h },
+ hasSubtitle: !!subtitle,
+ bounds: model.bounds,
+ });
+ model.boxes.forEach((box, i) => {
+ const bw = Math.max(2, box.w * layout.scale);
+ const bh = Math.max(2, box.h * layout.scale);
+ const left =
+ layout.offX +
+ (box.x - model.bounds!.minX) * layout.scale -
+ layout.area.x;
+ const top =
+ layout.offY +
+ (box.y - model.bounds!.minY) * layout.scale -
+ (SUBPAGE_HEADER_HEIGHT + layout.subH);
+ const paint = paintBox(box);
+ const label = getBoxLabel(
+ { ...box, code: box.code ?? codeForNodeType(box.nodeType) },
+ bw,
+ bh,
+ );
+ const style: React.CSSProperties = {
+ position: "absolute",
+ left,
+ top,
+ width: bw,
+ height: bh,
+ borderRadius: Math.min(4, bh / 4),
+ boxSizing: "border-box",
+ overflow: box.kind === "text" ? "visible" : "hidden",
+ background: paint.fill,
+ border:
+ paint.stroke === "none"
+ ? "none"
+ : `${paint.strokeWidth}px ${paint.dashed ? "dashed" : "solid"} ${paint.stroke}`,
+ };
+ boxes.push(
+
+ {box.img ? (
+
+ ) : null}
+ {label?.mode === "text" ? (
+
+ {label.text}
+
+ ) : label?.mode === "title" ? (
+
+ {label.text}
+
+ ) : label?.mode === "code" ? (
+
+ {label.text}
+
+ ) : null}
+
,
+ );
+ });
+ }
+
+ return (
+
+ {
+ e.stopPropagation();
+ enterPage(util.editor, targetPageId);
+ }}
+ style={{
+ flex: "0 0 auto",
+ height: SUBPAGE_HEADER_HEIGHT,
+ display: "flex",
+ alignItems: "center",
+ gap: 8,
+ padding: "0 10px",
+ background: accent,
+ color: "#fff",
+ cursor: "pointer",
+ pointerEvents: "all",
+ userSelect: "none",
+ }}
+ >
+
+ {headerTitle}
+
+
+ {model?.count ?? 0} items
+
+ ↗
+
+ {subtitle ? (
+
+ {subtitle}
+
+ ) : null}
+
+ {!model ? (
+
+ target page not found
+
+ ) : model.boxes.length === 0 ? (
+
+ empty page
+
+ ) : (
+ boxes
+ )}
+
+
+ );
+};
+
+export class DgSubpageGeoUtil extends GeoShapeUtil {
+ // Portals are not label-editable; plain geos keep the stock behavior. The
+ // cast bridges GeoShapeUtil's zero-arg narrowing of canEdit — the editor
+ // calls it with the shape either way.
+ override canEdit = ((shape: TLGeoShape) =>
+ !getSubpageMeta(shape.meta)) as unknown as () => boolean;
+
+ // One descriptor per shape on the target page, in page coordinates (via
+ // getShapePageBounds so auto-sized text shapes get their rendered height).
+ // Returns null exactly when the target page is missing.
+ readTargetPageDescriptors(
+ targetPageId: string,
+ ): PreviewShapeDescriptor[] | null {
+ const editor = this.editor;
+ const pageId = targetPageId as TLPageId;
+ if (!targetPageId || !editor.getPage(pageId)) return null;
+ const descriptors: PreviewShapeDescriptor[] = [];
+ for (const id of editor.getPageShapeIds(pageId)) {
+ const shape = editor.getShape(id);
+ if (!shape) continue;
+ const pageBounds = editor.getShapePageBounds(id);
+ const props = shape.props as {
+ title?: unknown;
+ text?: unknown;
+ name?: unknown;
+ nodeTypeId?: unknown;
+ imageUrl?: unknown;
+ assetId?: unknown;
+ color?: unknown;
+ };
+ // A nested portal is any shape carrying dgSubpage meta.
+ const sub = getSubpageMeta(shape.meta);
+ // Tier 1 covers both the modern discourse-node shape and legacy per-type
+ // node shapes (whose type is itself a node type id).
+ const nodeTypeId =
+ typeof props.nodeTypeId === "string" && props.nodeTypeId
+ ? props.nodeTypeId
+ : discourseContext.nodes[shape.type]
+ ? shape.type
+ : undefined;
+ const assetSrc =
+ shape.type === "image" && typeof props.assetId === "string"
+ ? (this.editor.getAsset(props.assetId as TLAssetId)?.props.src ??
+ undefined)
+ : undefined;
+ descriptors.push({
+ id: shape.id,
+ type: sub
+ ? SUBPAGE_SHAPE_TYPE
+ : nodeTypeId
+ ? "discourse-node"
+ : shape.type,
+ bounds: pageBounds
+ ? {
+ x: pageBounds.x,
+ y: pageBounds.y,
+ w: pageBounds.w,
+ h: pageBounds.h,
+ }
+ : null,
+ text:
+ typeof props.title === "string"
+ ? props.title
+ : typeof props.text === "string"
+ ? props.text
+ : undefined,
+ nodeTypeId,
+ imageUrl:
+ typeof props.imageUrl === "string" ? props.imageUrl : assetSrc,
+ // Nested portals preview under their live target-page name, matching
+ // the live header (stored title is only the missing-page fallback).
+ portalTitle: sub
+ ? (editor.getPage(sub.targetPageId as TLPageId)?.name ??
+ sub.title ??
+ "Sub-canvas")
+ : undefined,
+ portalAccent: sub?.accent,
+ frameName: typeof props.name === "string" ? props.name : undefined,
+ colorStyle: typeof props.color === "string" ? props.color : undefined,
+ });
+ }
+ return descriptors;
+ }
+
+ readPreviewModel(targetPageId: string): PreviewModel | null {
+ const descriptors = this.readTargetPageDescriptors(targetPageId);
+ if (!descriptors) return null;
+ return buildPreviewModel(descriptors, getPrefixMatchers());
+ }
+
+ // The `ReturnType` casts below bridge tldraw's bundled
+ // React-18 JSX element type against this repo's React 19 types — the
+ // elements are the same at runtime.
+ override component(shape: TLGeoShape): ReturnType {
+ const portal = getSubpageMeta(shape.meta);
+ const element = portal ? (
+
+ ) : (
+ super.component(shape)} />
+ );
+ return element as ReturnType;
+ }
+
+ override indicator(shape: TLGeoShape): ReturnType {
+ if (!getSubpageMeta(shape.meta)) return super.indicator(shape);
+ return (
+
+ ) as ReturnType;
+ }
+
+ override toSvg(
+ shape: TLGeoShape,
+ ctx: SvgExportContext,
+ ): ReturnType {
+ const portal = getSubpageMeta(shape.meta);
+ if (!portal) return super.toSvg(shape, ctx);
+ // Async is fine at runtime (the exporter awaits toSvg results); the cast
+ // bridges GeoShapeUtil's sync narrowing of the base signature.
+ return this.portalToSvg(shape, portal) as unknown as ReturnType<
+ GeoShapeUtil["toSvg"]
+ >;
+ }
+
+ // Export renderer. Shares the classifier (readPreviewModel), projection
+ // (layoutPreview), paint table (paintBox), and label thresholds (getBoxLabel)
+ // with the live render — SPEC §5 export parity.
+ private async portalToSvg(
+ shape: TLGeoShape,
+ portal: SubpageMeta,
+ ): Promise {
+ const { w, h } = shape.props;
+ const accent = portal.accent ?? DEFAULT_PORTAL_ACCENT;
+ const subtitle = portal.subtitle ?? "";
+ const model = this.readPreviewModel(portal.targetPageId);
+ // Same live-name rule as the live render: meta.title only when the page is gone.
+ const headerTitle =
+ this.editor.getPage(portal.targetPageId as TLPageId)?.name ??
+ portal.title ??
+ "Sub-canvas";
+ const clipId = `dgm-${shape.id.replace(/[^a-zA-Z0-9]/g, "")}-h`;
+
+ const children: JSX.Element[] = [];
+ if (model?.bounds) {
+ const layout = layoutPreview({
+ shape: { w, h },
+ hasSubtitle: !!subtitle,
+ bounds: model.bounds,
+ });
+ const images = await Promise.all(
+ model.boxes.map(async (box) => {
+ if (!box.img) return null;
+ try {
+ const response = await fetch(box.img);
+ const blob = await response.blob();
+ return await FileHelpers.blobToDataUrl(blob);
+ } catch {
+ return null;
+ }
+ }),
+ );
+ model.boxes.forEach((box, i) => {
+ const bw = Math.max(2, box.w * layout.scale);
+ const bh = Math.max(2, box.h * layout.scale);
+ const x = layout.offX + (box.x - model.bounds!.minX) * layout.scale;
+ const y = layout.offY + (box.y - model.bounds!.minY) * layout.scale;
+ const paint = paintBox(box);
+ const label = getBoxLabel(
+ { ...box, code: box.code ?? codeForNodeType(box.nodeType) },
+ bw,
+ bh,
+ );
+ if (box.kind !== "text") {
+ children.push(
+ ,
+ );
+ }
+ const img = images[i];
+ if (img && bw > 8 && bh > 8) {
+ children.push(
+ ,
+ );
+ }
+ if (label) {
+ // Image boxes keep the full image and put the label at the bottom,
+ // same as the live render.
+ const textY =
+ label.mode === "text"
+ ? y + label.fontSize
+ : img
+ ? y + bh - 3
+ : y + label.fontSize + 2;
+ if (img && label.mode === "title") {
+ children.push(
+ ,
+ );
+ }
+ children.push(
+
+ {trunc(label.text, bw - 6, label.fontSize)}
+ ,
+ );
+ }
+ });
+ }
+
+ return (
+
+
+
+
+
+
+
+ {trunc(headerTitle, w - 90, 14)}
+
+
+ ↗
+
+ {subtitle ? (
+
+ {trunc(subtitle, w - 20, 11)}
+
+ ) : null}
+ {!model ? (
+
+ target page not found
+
+ ) : null}
+ {children}
+
+ );
+ }
+}
diff --git a/apps/roam/src/components/canvas/Tldraw.tsx b/apps/roam/src/components/canvas/Tldraw.tsx
index 505833d02..f47eae8b4 100644
--- a/apps/roam/src/components/canvas/Tldraw.tsx
+++ b/apps/roam/src/components/canvas/Tldraw.tsx
@@ -24,7 +24,6 @@ import {
TldrawUi,
defaultBindingUtils,
defaultShapeTools,
- defaultShapeUtils,
defaultTools,
useEditor,
VecModel,
@@ -113,6 +112,7 @@ import {
} from "./canvasSyncMode";
import {
CanvasStoreAdapterArgs,
+ combineShapeUtilsWithDefaults,
useCanvasStoreAdapterArgs,
} from "./useCanvasStoreAdapterArgs";
import { shouldCreateAutoCanvasRelations } from "./autoCanvasRelationsSuppression";
@@ -1339,7 +1339,7 @@ const TldrawCanvasShared = ({
// instanceId={initialState.instanceId}
autoFocus={false}
initialState="select"
- shapeUtils={[...defaultShapeUtils, ...customShapeUtils]}
+ shapeUtils={combineShapeUtilsWithDefaults(customShapeUtils)}
tools={[...defaultTools, ...defaultShapeTools, ...customTools]}
bindingUtils={[...defaultBindingUtils, ...customBindingUtils]}
components={editorComponents}
diff --git a/apps/roam/src/components/canvas/TldrawCanvasCloudflareSync.tsx b/apps/roam/src/components/canvas/TldrawCanvasCloudflareSync.tsx
index 3bddf46ae..648d82fd6 100644
--- a/apps/roam/src/components/canvas/TldrawCanvasCloudflareSync.tsx
+++ b/apps/roam/src/components/canvas/TldrawCanvasCloudflareSync.tsx
@@ -5,11 +5,11 @@ import {
TLAssetStore,
TLStoreWithStatus,
defaultBindingUtils,
- defaultShapeUtils,
MigrationSequence,
} from "tldraw";
import { useMemo } from "react";
import { getCurrentRoamTldrawUserInfo } from "~/utils/roamTldrawUserInfo";
+import { combineShapeUtilsWithDefaults } from "./useCanvasStoreAdapterArgs";
/** Base URL for tldraw-sync-cloudflare worker. Use https (not wss) - useSync upgrades to WebSocket. */
export const TLDRAW_CLOUDFLARE_SYNC_WS_BASE_URL =
@@ -68,7 +68,7 @@ export const useCloudflareSyncStore = ({
}): CloudflareCanvasStoreAdapterResult => {
const assets = useMemo(() => createRoamAssetStore(), []);
const shapeUtils = useMemo(
- () => [...defaultShapeUtils, ...customShapeUtils],
+ () => combineShapeUtilsWithDefaults(customShapeUtils),
[customShapeUtils],
);
const bindingUtils = useMemo(
diff --git a/apps/roam/src/components/canvas/nestedPageNavigation.ts b/apps/roam/src/components/canvas/nestedPageNavigation.ts
new file mode 100644
index 000000000..220084b06
--- /dev/null
+++ b/apps/roam/src/components/canvas/nestedPageNavigation.ts
@@ -0,0 +1,183 @@
+// Navigation + creation for nested sub-page portals. `enterPage` is the one
+// navigation gesture — the portal header, breadcrumb segments, and back button
+// all funnel through it so they cannot drift apart.
+//
+// A portal is a NATIVE geo rectangle whose meta.dgSubpage names the target
+// page (see DgSubpageUtil.tsx for why this is meta on a native shape and not a
+// custom shape type). The geo label text mirrors the page name so a plugin
+// version without this feature still shows a readable, movable rectangle.
+import {
+ createShapeId,
+ Editor,
+ PageRecordType,
+ TLGeoShape,
+ TLPageId,
+ TLShape,
+ TLShapeId,
+} from "tldraw";
+import {
+ assertCanCreateSubpage,
+ getNestedPageMeta,
+ getSubpageMeta,
+ LineagePage,
+ walkLineage,
+} from "~/utils/nestedPages";
+
+export const DEFAULT_PORTAL_ACCENT = "#6d5ae0";
+const DEFAULT_PORTAL_WIDTH = 460;
+const DEFAULT_PORTAL_HEIGHT = 340;
+
+// The label old clients see on the portal rectangle.
+const portalLabel = (pageName: string): string => `⤵ ${pageName}`;
+
+export const enterPage = (editor: Editor, pageId: string) => {
+ if (!pageId || !editor.getPage(pageId as TLPageId)) return;
+ editor.setCurrentPage(pageId as TLPageId);
+ const bounds = editor.getCurrentPageBounds();
+ if (bounds) {
+ editor.zoomToBounds(bounds, { inset: 80, animation: { duration: 200 } });
+ }
+};
+
+export const getLineage = (editor: Editor): LineagePage[] =>
+ walkLineage((id) => {
+ const page = editor.getPage(id as TLPageId);
+ if (!page) return null;
+ return {
+ id: page.id,
+ name: page.name,
+ parentPageId: getNestedPageMeta(page.meta)?.parentPageId,
+ };
+ }, editor.getCurrentPageId());
+
+const getShapeText = (shape: TLShape): string => {
+ const props = shape.props as { title?: unknown; text?: unknown };
+ if (typeof props.title === "string" && props.title) return props.title;
+ if (typeof props.text === "string" && props.text) return props.text;
+ return "";
+};
+
+/**
+ * Create a child page plus a portal into it on the current page, in one batch
+ * (SPEC §7 `createSubpage`, eager creation). Throws before touching the store
+ * when the page cap is reached — tldraw's `createPage` silently no-ops there,
+ * which would strand an orphan portal.
+ *
+ * If exactly one shape is selected, the portal is titled from its text and
+ * placed just below it; otherwise it lands centered in the viewport.
+ */
+export const createSubpagePortal = ({
+ editor,
+ title,
+ accent = DEFAULT_PORTAL_ACCENT,
+}: {
+ editor: Editor;
+ title?: string;
+ accent?: string;
+}): { pageId: TLPageId; portalId: TLShapeId } => {
+ assertCanCreateSubpage({
+ pageCount: editor.getPages().length,
+ maxPages: editor.options.maxPages,
+ });
+
+ const owner = editor.getOnlySelectedShape();
+ const here = editor.getCurrentPageId();
+ const name = (title || (owner && getShapeText(owner)) || "Sub-canvas").slice(
+ 0,
+ 60,
+ );
+
+ // owner.x/y are parent-local — for a card inside a frame they would place the
+ // portal near the page origin. Page bounds are what we mean.
+ const ownerBounds = owner ? editor.getShapePageBounds(owner.id) : undefined;
+ const viewport = editor.getViewportPageBounds();
+ const x = ownerBounds
+ ? ownerBounds.x
+ : viewport.center.x - DEFAULT_PORTAL_WIDTH / 2;
+ const y = ownerBounds
+ ? ownerBounds.y + ownerBounds.h + 24
+ : viewport.center.y - DEFAULT_PORTAL_HEIGHT / 2;
+
+ const pageId = PageRecordType.createId();
+ const portalId = createShapeId();
+ editor.batch(() => {
+ editor.createPage({
+ id: pageId,
+ name,
+ meta: { dgNested: { parentPageId: here, ownerShapeId: portalId } },
+ });
+ // createPage increments duplicate names ("Study A" → "Study A 1"); keep the
+ // portal title in sync with what the page actually got.
+ const finalName = editor.getPage(pageId)?.name ?? name;
+ editor.createShape({
+ id: portalId,
+ type: "geo",
+ x,
+ y,
+ // explicit parentId: a portal created over a frame must not be
+ // auto-parented into that frame
+ parentId: here,
+ meta: {
+ dgSubpage: {
+ targetPageId: pageId,
+ accent,
+ title: finalName,
+ subtitle: "",
+ },
+ },
+ props: {
+ geo: "rectangle",
+ w: DEFAULT_PORTAL_WIDTH,
+ h: DEFAULT_PORTAL_HEIGHT,
+ color: "violet",
+ fill: "semi",
+ font: "sans",
+ size: "m",
+ text: portalLabel(finalName),
+ },
+ });
+ editor.select(portalId);
+ });
+ return { pageId, portalId };
+};
+
+/** Point an existing geo shape at a page, making it a portal and recording
+ * lineage on the target. The recorded parent is the page the portal LIVES ON,
+ * not whichever page the session happens to be viewing. */
+export const linkSubpagePortal = (
+ editor: Editor,
+ portalId: TLShapeId,
+ targetPageId: TLPageId,
+): boolean => {
+ const portal = editor.getShape(portalId);
+ if (!portal || portal.type !== "geo") return false;
+ const targetPage = editor.getPage(targetPageId);
+ if (!targetPage) return false;
+ const parentPageId =
+ editor.getAncestorPageId(portal) ?? editor.getCurrentPageId();
+ const existing = getSubpageMeta(portal.meta);
+ editor.batch(() => {
+ editor.updateShape({
+ id: portalId,
+ type: "geo",
+ meta: {
+ ...portal.meta,
+ dgSubpage: {
+ targetPageId,
+ accent: existing?.accent ?? DEFAULT_PORTAL_ACCENT,
+ title: targetPage.name,
+ subtitle: existing?.subtitle ?? "",
+ },
+ },
+ props: { text: portalLabel(targetPage.name) },
+ });
+ editor.updatePage({
+ id: targetPageId,
+ meta: {
+ ...targetPage.meta,
+ dgNested: { parentPageId, ownerShapeId: portalId },
+ },
+ });
+ });
+ return true;
+};
diff --git a/apps/roam/src/components/canvas/uiOverrides.tsx b/apps/roam/src/components/canvas/uiOverrides.tsx
index 111ebae41..84506e50b 100644
--- a/apps/roam/src/components/canvas/uiOverrides.tsx
+++ b/apps/roam/src/components/canvas/uiOverrides.tsx
@@ -71,6 +71,8 @@ import { createOrUpdateArrowBinding } from "./DiscourseRelationShape/helpers";
import DiscourseGraphPanel from "./DiscourseToolPanel";
import type { CanvasNodeShortcuts } from "~/components/settings/utils/zodSchema";
import { CustomDefaultToolbar } from "./CustomDefaultToolbar";
+import { NestedPageHelperButtons } from "./DgSubpageBreadcrumb";
+import { createSubpagePortal } from "./nestedPageNavigation";
import { renderModifyNodeDialog } from "~/components/ModifyNodeDialog";
import { CanvasSyncMode } from "./canvasSyncMode";
import { getPersonalSetting } from "~/components/settings/utils/accessors";
@@ -422,6 +424,7 @@ export const CustomContextMenu = ({
allNodes: DiscourseNode[];
}) => {
const editor = useEditor();
+ const actions = useActions();
const selectedShape = useValue(
"selectedShape",
() => editor.getOnlySelectedShape(),
@@ -457,6 +460,9 @@ export const CustomContextMenu = ({
return (
+
+
+
{shareableResults.length > 0 && (
);
},
+ // Default helper buttons plus the nested sub-page breadcrumb/back bar.
+ HelperButtons: NestedPageHelperButtons,
};
};
export const createUiOverrides = ({
@@ -724,8 +732,26 @@ export const createUiOverrides = ({
return tools;
},
- actions: (_editor, actions, helpers) => {
+ actions: (editor, actions, helpers) => {
const { addToast, addDialog } = helpers;
+ actions["create-subpage-portal"] = {
+ id: "create-subpage-portal",
+ label: "action.create-subpage-portal" as TLUiTranslationKey,
+ kbd: "",
+ onSelect: () => {
+ try {
+ createSubpagePortal({ editor });
+ posthog.capture("Canvas: Create Subpage Portal");
+ } catch (error) {
+ addToast({
+ title: "Cannot create sub-canvas",
+ description: error instanceof Error ? error.message : String(error),
+ severity: "error",
+ });
+ }
+ },
+ readonlyOk: false,
+ };
actions["convert-to"] = {
id: "convert-to",
label: "action.convert-to" as TLUiTranslationKey,
@@ -797,6 +823,7 @@ export const createUiOverrides = ({
...Object.fromEntries(
allNodes.map((node) => [`shape.node.${node.type}`, node.text]),
),
+ "action.create-subpage-portal": "Create sub-canvas portal",
"action.toggle-cloud-sync": "Toggle cloud canvas sync",
"action.toggle-full-screen": "Toggle Full Screen",
"tool.discourse-tool": "Discourse Graph",
diff --git a/apps/roam/src/components/canvas/useCanvasStoreAdapterArgs.ts b/apps/roam/src/components/canvas/useCanvasStoreAdapterArgs.ts
index aa9fcbb3c..38a0e0e84 100644
--- a/apps/roam/src/components/canvas/useCanvasStoreAdapterArgs.ts
+++ b/apps/roam/src/components/canvas/useCanvasStoreAdapterArgs.ts
@@ -19,6 +19,28 @@ import {
createAllRelationBindings,
} from "./DiscourseRelationShape/DiscourseRelationBindings";
import { createMigrations } from "./DiscourseRelationShape/discourseRelationMigrations";
+import { DgSubpageGeoUtil } from "./DgSubpageUtil";
+import { defaultShapeUtils } from "tldraw";
+
+const DEFAULT_SHAPE_TYPES = new Set(
+ defaultShapeUtils.map((u) => u.type),
+);
+
+/**
+ * Combine the default shape utils with ours, letting a custom util that
+ * declares a DEFAULT type (e.g. DgSubpageGeoUtil's "geo") REPLACE the stock
+ * util — registering the same type twice throws. Every store construction site
+ * must use this instead of spreading `[...defaultShapeUtils, ...custom]`.
+ */
+export const combineShapeUtilsWithDefaults = (
+ customShapeUtils: readonly TLAnyShapeUtilConstructor[],
+): TLAnyShapeUtilConstructor[] => {
+ const customTypes = new Set(customShapeUtils.map((u) => u.type));
+ return [
+ ...defaultShapeUtils.filter((d) => !customTypes.has(d.type)),
+ ...customShapeUtils,
+ ];
+};
/**
* Cloudflare sync needs stable adapter arg identities, but local Roam
@@ -71,6 +93,7 @@ const createShapeUtils = ({
}): TLAnyShapeUtilConstructor[] => {
return [
DiscourseNodeUtil,
+ DgSubpageGeoUtil,
...(includeLegacyNodeTypes
? createLegacyDiscourseNodeShapeUtils(allNodes)
: []),
@@ -114,9 +137,11 @@ export const useCanvasStoreAdapterArgs = ({
allRelationIds,
allAddReferencedNodeByAction,
});
+ // Overridden DEFAULT types (geo) are not "custom" to the sync layer — the
+ // server already knows them; advertising them as custom would double-define.
const customShapeTypes = getUtilTypes({
utils: customShapeUtils,
- });
+ }).filter((t) => !DEFAULT_SHAPE_TYPES.has(t));
const customBindingTypes = getUtilTypes({
utils: customBindingUtils,
});
@@ -159,7 +184,7 @@ export const useCanvasStoreAdapterArgs = ({
pageUid,
value: getUtilTypes({
utils: stableCustomShapeUtils,
- }),
+ }).filter((t) => !DEFAULT_SHAPE_TYPES.has(t)),
}),
[pageUid, stableCustomShapeUtils],
).value;
diff --git a/apps/roam/src/components/canvas/useRoamStore.ts b/apps/roam/src/components/canvas/useRoamStore.ts
index 78989afaa..fd02636cf 100644
--- a/apps/roam/src/components/canvas/useRoamStore.ts
+++ b/apps/roam/src/components/canvas/useRoamStore.ts
@@ -14,7 +14,6 @@ import {
import { SerializedStore, StoreSnapshot } from "@tldraw/store";
import {
defaultBindingUtils,
- defaultShapeUtils,
getIndices,
loadSnapshot,
MigrationSequence,
@@ -24,6 +23,7 @@ import {
TLStore,
} from "tldraw";
import { AddPullWatch } from "roamjs-components/types";
+import { combineShapeUtilsWithDefaults } from "./useCanvasStoreAdapterArgs";
import { LEGACY_SCHEMA } from "~/data/legacyTldrawSchema";
import internalError from "~/utils/internalError";
@@ -91,7 +91,7 @@ const createCanvasStore = ({
}): TLStore =>
createTLStore({
migrations,
- shapeUtils: [...defaultShapeUtils, ...customShapeUtils],
+ shapeUtils: combineShapeUtilsWithDefaults(customShapeUtils),
bindingUtils: [...defaultBindingUtils, ...customBindingUtils],
});
diff --git a/apps/roam/src/utils/__tests__/nestedPages.test.ts b/apps/roam/src/utils/__tests__/nestedPages.test.ts
new file mode 100644
index 000000000..a67ada3df
--- /dev/null
+++ b/apps/roam/src/utils/__tests__/nestedPages.test.ts
@@ -0,0 +1,387 @@
+import { describe, expect, it } from "vitest";
+import {
+ assertCanCreateSubpage,
+ buildPreviewModel,
+ buildPrefixMatchers,
+ getBoxLabel,
+ getNestedPageMeta,
+ getSubpageMeta,
+ layoutPreview,
+ walkLineage,
+ type PreviewShapeDescriptor,
+} from "~/utils/nestedPages";
+
+const geo = (
+ overrides: Partial = {},
+): PreviewShapeDescriptor => ({
+ id: "shape:a",
+ type: "geo",
+ bounds: { x: 0, y: 0, w: 100, h: 50 },
+ ...overrides,
+});
+
+describe("buildPrefixMatchers", () => {
+ const nodes = [
+ { type: "que-node", format: "[[QUE]] - {content}" },
+ { type: "evd-node", format: "[[EVD]] - {content} - {Source}" },
+ { type: "page-node", format: "{content}" },
+ { type: "blck-node", format: "{content}" },
+ ];
+
+ it("derives a prefix from the text before the first placeholder", () => {
+ const matchers = buildPrefixMatchers(nodes);
+ expect(matchers.map((m) => m.prefix)).toEqual(["QUE", "EVD"]);
+ expect(matchers.map((m) => m.nodeType)).toEqual(["que-node", "evd-node"]);
+ });
+
+ it("yields no matcher for formats that start with the placeholder", () => {
+ const matchers = buildPrefixMatchers(nodes);
+ expect(matchers.some((m) => m.nodeType === "page-node")).toBe(false);
+ expect(matchers.some((m) => m.nodeType === "blck-node")).toBe(false);
+ });
+
+ it("matches prefixed labels case-insensitively across separators and newlines", () => {
+ const matchers = buildPrefixMatchers(nodes);
+ const match = (text: string) =>
+ matchers.find((m) => m.regex.test(text))?.nodeType;
+ expect(match("QUE - How fast does actin polymerize?")).toBe("que-node");
+ expect(match("que: lowercase works")).toBe("que-node");
+ expect(match("EVD\nobserved X in Y")).toBe("evd-node");
+ expect(match("QUESTIONS are not a prefix")).toBeUndefined();
+ expect(match("unprefixed text")).toBeUndefined();
+ });
+
+ it("strips the matched prefix from the remaining title", () => {
+ const matchers = buildPrefixMatchers(nodes);
+ const m = matchers.find((x) => x.nodeType === "que-node");
+ expect("QUE - How fast?".replace(m!.regex, "")).toBe("How fast?");
+ });
+
+ it("matches and strips bracketed prefixes as Roam titles literally contain them", () => {
+ const matchers = buildPrefixMatchers(nodes);
+ const m = matchers.find((x) => x.regex.test("[[EVD]] - observed X"));
+ expect(m?.nodeType).toBe("evd-node");
+ expect("[[EVD]] - observed X".replace(m!.regex, "")).toBe("observed X");
+ });
+});
+
+describe("walkLineage", () => {
+ type Page = { id: string; name: string; parentPageId?: string };
+ const lookup =
+ (pages: Page[]) =>
+ (id: string): Page | undefined =>
+ pages.find((p) => p.id === id);
+
+ it("returns the chain root-first including the current page", () => {
+ const pages = [
+ { id: "root", name: "Root" },
+ { id: "mid", name: "Mid", parentPageId: "root" },
+ { id: "leaf", name: "Leaf", parentPageId: "mid" },
+ ];
+ expect(walkLineage(lookup(pages), "leaf")).toEqual([
+ { id: "root", name: "Root" },
+ { id: "mid", name: "Mid" },
+ { id: "leaf", name: "Leaf" },
+ ]);
+ });
+
+ it("returns a single entry for a root page", () => {
+ expect(walkLineage(lookup([{ id: "root", name: "Root" }]), "root")).toEqual(
+ [{ id: "root", name: "Root" }],
+ );
+ });
+
+ it("survives a parentPageId cycle", () => {
+ const pages = [
+ { id: "a", name: "A", parentPageId: "b" },
+ { id: "b", name: "B", parentPageId: "a" },
+ ];
+ const chain = walkLineage(lookup(pages), "a");
+ expect(chain.map((p) => p.id)).toEqual(["b", "a"]);
+ });
+
+ it("caps the walk at 16 pages", () => {
+ const pages = Array.from({ length: 40 }, (_, i) => ({
+ id: `p${i}`,
+ name: `P${i}`,
+ parentPageId: i < 39 ? `p${i + 1}` : undefined,
+ }));
+ expect(walkLineage(lookup(pages), "p0")).toHaveLength(16);
+ });
+
+ it("stops when a parent page is missing", () => {
+ const pages = [{ id: "leaf", name: "Leaf", parentPageId: "gone" }];
+ expect(walkLineage(lookup(pages), "leaf")).toEqual([
+ { id: "leaf", name: "Leaf" },
+ ]);
+ });
+});
+
+describe("getNestedPageMeta", () => {
+ it("reads dgNested from page meta", () => {
+ expect(
+ getNestedPageMeta({
+ dgNested: { parentPageId: "page:x", ownerShapeId: "shape:y" },
+ }),
+ ).toEqual({ parentPageId: "page:x", ownerShapeId: "shape:y" });
+ });
+
+ it("returns null for absent or malformed meta", () => {
+ expect(getNestedPageMeta(undefined)).toBeNull();
+ expect(getNestedPageMeta({})).toBeNull();
+ expect(getNestedPageMeta({ dgNested: { parentPageId: 7 } })).toBeNull();
+ });
+});
+
+describe("getSubpageMeta", () => {
+ it("reads dgSubpage from shape meta", () => {
+ expect(
+ getSubpageMeta({
+ dgSubpage: {
+ targetPageId: "page:x",
+ accent: "#6d5ae0",
+ title: "Study A",
+ },
+ }),
+ ).toEqual({ targetPageId: "page:x", accent: "#6d5ae0", title: "Study A" });
+ });
+
+ it("requires only targetPageId", () => {
+ expect(getSubpageMeta({ dgSubpage: { targetPageId: "page:x" } })).toEqual({
+ targetPageId: "page:x",
+ });
+ });
+
+ it("returns null for absent or malformed meta", () => {
+ expect(getSubpageMeta(undefined)).toBeNull();
+ expect(getSubpageMeta({})).toBeNull();
+ expect(getSubpageMeta({ dgSubpage: { targetPageId: 3 } })).toBeNull();
+ });
+});
+
+describe("buildPreviewModel", () => {
+ const matchers = buildPrefixMatchers([
+ { type: "que-node", format: "[[QUE]] - {content}" },
+ ]);
+
+ it("returns an empty model for no shapes", () => {
+ const model = buildPreviewModel([], matchers);
+ expect(model).toEqual({ count: 0, boxes: [], bounds: null });
+ });
+
+ it("classifies discourse-node shapes as nodes with their type id", () => {
+ const model = buildPreviewModel(
+ [
+ geo({
+ type: "discourse-node",
+ nodeTypeId: "que-node",
+ text: "How fast?",
+ imageUrl: "http://img",
+ }),
+ ],
+ matchers,
+ );
+ expect(model.boxes[0]).toMatchObject({
+ kind: "node",
+ nodeType: "que-node",
+ title: "How fast?",
+ img: "http://img",
+ });
+ });
+
+ it("strips the format prefix from discourse-node titles too", () => {
+ const evdMatchers = buildPrefixMatchers([
+ { type: "evd-node", format: "[[EVD]] - {content}" },
+ ]);
+ const model = buildPreviewModel(
+ [
+ geo({
+ type: "discourse-node",
+ nodeTypeId: "evd-node",
+ text: "[[EVD]] - Cortactin assembled with Arp3",
+ }),
+ ],
+ evdMatchers,
+ );
+ expect(model.boxes[0]).toMatchObject({
+ kind: "node",
+ nodeType: "evd-node",
+ title: "Cortactin assembled with Arp3",
+ });
+ });
+
+ it("classifies grammar-prefixed labels as nodes of the matched type", () => {
+ const model = buildPreviewModel(
+ [geo({ text: "QUE - How fast does actin polymerize?" })],
+ matchers,
+ );
+ expect(model.boxes[0]).toMatchObject({
+ kind: "node",
+ nodeType: "que-node",
+ title: "How fast does actin polymerize?",
+ });
+ });
+
+ it("classifies frames, images, portals, text, and plain geo", () => {
+ const model = buildPreviewModel(
+ [
+ geo({ id: "s1", type: "frame", frameName: "Study A" }),
+ geo({ id: "s2", type: "image", imageUrl: "http://img" }),
+ geo({
+ id: "s3",
+ type: "dg-subpage",
+ portalTitle: "Inner",
+ portalAccent: "#123456",
+ }),
+ geo({ id: "s4", type: "text", text: "a caption" }),
+ geo({ id: "s5", text: "plain card", colorStyle: "yellow" }),
+ ],
+ matchers,
+ );
+ expect(model.boxes.map((b) => b.kind).sort()).toEqual([
+ "frame",
+ "geo",
+ "image",
+ "portal",
+ "text",
+ ]);
+ const portal = model.boxes.find((b) => b.kind === "portal");
+ expect(portal).toMatchObject({ title: "Inner", color: "#123456" });
+ });
+
+ it("skips arrows, groups, and zero-extent shapes; count equals boxes drawn", () => {
+ const model = buildPreviewModel(
+ [
+ geo({ id: "s1" }),
+ geo({ id: "s2", type: "arrow" }),
+ geo({ id: "s3", type: "group" }),
+ geo({ id: "s4", bounds: { x: 10, y: 10, w: 0, h: 40 } }),
+ geo({ id: "s5", bounds: null }),
+ ],
+ matchers,
+ );
+ expect(model.count).toBe(1);
+ expect(model.count).toBe(model.boxes.length);
+ });
+
+ it("computes union bounds and stacks frames below nodes below images", () => {
+ const model = buildPreviewModel(
+ [
+ geo({
+ id: "s1",
+ type: "image",
+ bounds: { x: 200, y: 200, w: 100, h: 100 },
+ }),
+ geo({
+ id: "s2",
+ type: "discourse-node",
+ nodeTypeId: "que-node",
+ text: "t",
+ bounds: { x: 50, y: 60, w: 100, h: 40 },
+ }),
+ geo({
+ id: "s3",
+ type: "frame",
+ frameName: "F",
+ bounds: { x: 0, y: 0, w: 400, h: 300 },
+ }),
+ ],
+ matchers,
+ );
+ expect(model.bounds).toEqual({ minX: 0, minY: 0, maxX: 400, maxY: 300 });
+ expect(model.boxes.map((b) => b.kind)).toEqual(["frame", "node", "image"]);
+ });
+});
+
+describe("layoutPreview", () => {
+ it("scale-to-fits the page bounds into the body, centered", () => {
+ const layout = layoutPreview({
+ shape: { w: 460, h: 340 },
+ hasSubtitle: false,
+ bounds: { minX: 0, minY: 0, maxX: 1000, maxY: 500 },
+ });
+ expect(layout.area).toEqual({ x: 8, y: 48, w: 444, h: 284 });
+ expect(layout.scale).toBeCloseTo(0.444);
+ expect(layout.offX).toBeCloseTo(8);
+ expect(layout.offY).toBeCloseTo(48 + (284 - 500 * 0.444) / 2);
+ });
+
+ it("reserves a strip for the subtitle", () => {
+ const layout = layoutPreview({
+ shape: { w: 460, h: 340 },
+ hasSubtitle: true,
+ bounds: { minX: 0, minY: 0, maxX: 100, maxY: 100 },
+ });
+ expect(layout.area.y).toBe(48 + 18);
+ });
+});
+
+describe("getBoxLabel", () => {
+ it("shows the full title when the scaled box is large enough", () => {
+ const label = getBoxLabel(
+ { kind: "node", title: "How fast?", code: "QUE" },
+ 120,
+ 40,
+ );
+ expect(label).toMatchObject({ mode: "title", text: "QUE How fast?" });
+ });
+
+ it("falls back to the type code in small boxes", () => {
+ const label = getBoxLabel(
+ { kind: "node", title: "How fast?", code: "QUE" },
+ 30,
+ 12,
+ );
+ expect(label).toMatchObject({ mode: "code", text: "QUE" });
+ });
+
+ it("shows nothing when the box is tiny", () => {
+ expect(
+ getBoxLabel({ kind: "node", title: "t", code: "QUE" }, 10, 6),
+ ).toBeNull();
+ });
+
+ it("labels bare text whenever it fits horizontally", () => {
+ const label = getBoxLabel({ kind: "text", title: "a caption" }, 60, 10);
+ expect(label).toMatchObject({ mode: "text", text: "a caption" });
+ });
+
+ it("caps long titles at 90 characters with an ellipsis", () => {
+ const label = getBoxLabel(
+ { kind: "node", title: "x".repeat(200), code: "EVD" },
+ 200,
+ 100,
+ );
+ expect(label?.text).toHaveLength(90);
+ expect(label?.text.endsWith("…")).toBe(true);
+ });
+
+ it("clamps the label to 2 lines when the box has an image, so the image stays visible", () => {
+ const withImage = getBoxLabel(
+ { kind: "node", title: "long title ".repeat(8), code: "EVD", img: "u" },
+ 200,
+ 200,
+ );
+ expect(withImage?.maxLines).toBe(2);
+ const withoutImage = getBoxLabel(
+ { kind: "node", title: "long title ".repeat(8), code: "EVD" },
+ 200,
+ 200,
+ );
+ expect(withoutImage?.maxLines).toBeGreaterThan(2);
+ });
+});
+
+describe("assertCanCreateSubpage", () => {
+ it("passes below the page cap", () => {
+ expect(() =>
+ assertCanCreateSubpage({ pageCount: 3, maxPages: 40 }),
+ ).not.toThrow();
+ });
+
+ it("throws a loud error at the cap instead of stranding an orphan portal", () => {
+ expect(() =>
+ assertCanCreateSubpage({ pageCount: 40, maxPages: 40 }),
+ ).toThrow(/40/);
+ });
+});
diff --git a/apps/roam/src/utils/__tests__/nestedPagesCompat.test.ts b/apps/roam/src/utils/__tests__/nestedPagesCompat.test.ts
new file mode 100644
index 000000000..7cb052db1
--- /dev/null
+++ b/apps/roam/src/utils/__tests__/nestedPagesCompat.test.ts
@@ -0,0 +1,144 @@
+// Backward compatibility: a canvas containing nested sub-page portals must
+// still load on a plugin version that predates the feature. Portals persist as
+// NATIVE geo shapes carrying meta.dgSubpage, and the page hierarchy as page
+// meta — never as a custom shape type, because an unknown shape type makes
+// tldraw's loadSnapshot throw and blanks the ENTIRE canvas for old clients.
+// These tests simulate an old client (default shape utils only) loading a
+// board written by a new client.
+import { describe, expect, it } from "vitest";
+import {
+ createTLStore,
+ defaultBindingUtils,
+ defaultShapeUtils,
+ loadSnapshot,
+ type TLGeoShape,
+ type TLPage,
+ type TLPageId,
+ type TLRecord,
+ type TLShapeId,
+ type TLStore,
+ type TLStoreSnapshot,
+} from "tldraw";
+
+const PORTAL_ID = "shape:portal" as TLShapeId;
+const CHILD_PAGE_ID = "page:child" as TLPageId;
+
+const createOldClientStore = (): TLStore =>
+ createTLStore({
+ shapeUtils: defaultShapeUtils,
+ bindingUtils: defaultBindingUtils,
+ });
+
+const newClientRecords = [
+ { typeName: "page", id: "page:root", name: "Root", index: "a1", meta: {} },
+ {
+ typeName: "page",
+ id: CHILD_PAGE_ID,
+ name: "Child",
+ index: "a2",
+ meta: {
+ dgNested: { parentPageId: "page:root", ownerShapeId: PORTAL_ID },
+ },
+ },
+ {
+ typeName: "shape",
+ id: PORTAL_ID,
+ type: "geo",
+ x: 0,
+ y: 0,
+ rotation: 0,
+ index: "a1",
+ parentId: "page:root",
+ isLocked: false,
+ opacity: 1,
+ meta: {
+ dgSubpage: {
+ targetPageId: CHILD_PAGE_ID,
+ accent: "#6d5ae0",
+ title: "Child",
+ },
+ },
+ props: {
+ geo: "rectangle",
+ w: 460,
+ h: 340,
+ color: "violet",
+ labelColor: "black",
+ fill: "semi",
+ dash: "draw",
+ size: "m",
+ font: "sans",
+ text: "⤵ Child",
+ align: "middle",
+ verticalAlign: "middle",
+ growY: 0,
+ url: "",
+ scale: 1,
+ },
+ },
+] as unknown as TLRecord[];
+
+const buildNewClientSnapshot = (): TLStoreSnapshot => {
+ const store = createOldClientStore();
+ store.put(newClientRecords);
+ const snapshot = store.getStoreSnapshot();
+ // New clients stamp migration sequences old clients have never heard of;
+ // simulate the worst case explicitly.
+ const schema = snapshot.schema as unknown as {
+ sequences?: Record;
+ };
+ return {
+ ...snapshot,
+ schema: {
+ ...snapshot.schema,
+ sequences: {
+ ...(schema.sequences ?? {}),
+ "com.roam-research.discourse-graphs.future-feature": 0,
+ },
+ },
+ } as TLStoreSnapshot;
+};
+
+describe("old clients reading a nested-pages canvas", () => {
+ it("loads a board whose portals are geo shapes with dgSubpage meta", () => {
+ const snapshot = buildNewClientSnapshot();
+ const oldClient = createOldClientStore();
+ expect(() => loadSnapshot(oldClient, snapshot)).not.toThrow();
+ const portal = oldClient.get(PORTAL_ID) as TLGeoShape;
+ expect(portal.type).toBe("geo");
+ expect(portal.meta.dgSubpage).toMatchObject({
+ targetPageId: CHILD_PAGE_ID,
+ });
+ const childPage = oldClient.get(CHILD_PAGE_ID) as TLPage;
+ expect(childPage.meta.dgNested).toMatchObject({
+ parentPageId: "page:root",
+ });
+ });
+
+ it("would fail the whole board if portals were a custom shape type (the rejected design)", () => {
+ const snapshot = buildNewClientSnapshot();
+ const customTypeRecord = {
+ typeName: "shape",
+ id: "shape:custom",
+ type: "dg-subpage",
+ x: 0,
+ y: 0,
+ rotation: 0,
+ index: "a2",
+ parentId: "page:root",
+ isLocked: false,
+ opacity: 1,
+ meta: {},
+ props: { w: 460, h: 340 },
+ };
+ const withCustomType = {
+ ...snapshot,
+ store: {
+ ...snapshot.store,
+ "shape:custom": customTypeRecord,
+ },
+ } as unknown as TLStoreSnapshot;
+ const oldClient = createOldClientStore();
+ expect(() => loadSnapshot(oldClient, withCustomType)).toThrow();
+ });
+});
diff --git a/apps/roam/src/utils/nestedPages.ts b/apps/roam/src/utils/nestedPages.ts
new file mode 100644
index 000000000..b70d4640a
--- /dev/null
+++ b/apps/roam/src/utils/nestedPages.ts
@@ -0,0 +1,410 @@
+// Pure logic for nested sub-page portals (dg-subpage): tier-2 prefix derivation,
+// lineage walking, the preview classifier, scale-to-fit layout, and label
+// thresholds. Everything here is editor-free and node-testable; the shape util
+// (DgSubpageUtil.tsx) maps live editor records into the plain descriptors below.
+//
+// Schema (see dg-prototypes/nested-pages/SPEC.md §3): pages stay flat siblings;
+// a target page records `meta.dgNested.parentPageId` (the page its portal lives
+// on) and the portal shape holds the forward pointer in `props.targetPageId`.
+
+// Classifier tag for portal boxes inside previews. NOT a persisted tldraw shape
+// type: portals persist as native `geo` shapes carrying `meta.dgSubpage`, so a
+// plugin version without this feature still loads the board (an unknown shape
+// type would make loadSnapshot throw and blank the whole canvas there).
+export const SUBPAGE_SHAPE_TYPE = "dg-subpage";
+
+export const SUBPAGE_HEADER_HEIGHT = 40;
+export const SUBPAGE_SUBTITLE_HEIGHT = 18;
+export const SUBPAGE_BODY_PADDING = 8;
+// Smallest scaled box that still shows its full title / its type code.
+export const TITLE_MIN_W = 44;
+export const TITLE_MIN_H = 13;
+export const CODE_MIN_W = 20;
+export const CODE_MIN_H = 10;
+export const MAX_LINEAGE_DEPTH = 16;
+
+export type NestedPageMeta = {
+ parentPageId: string;
+ ownerShapeId?: string;
+};
+
+// `ownerShapeId` is informational only — nothing reads it, and a back-pointer is
+// always recoverable by scanning portals for props.targetPageId === page.id.
+export const getNestedPageMeta = (meta: unknown): NestedPageMeta | null => {
+ if (typeof meta !== "object" || meta === null) return null;
+ const dgNested = (meta as { dgNested?: unknown }).dgNested;
+ if (typeof dgNested !== "object" || dgNested === null) return null;
+ const { parentPageId, ownerShapeId } = dgNested as {
+ parentPageId?: unknown;
+ ownerShapeId?: unknown;
+ };
+ if (typeof parentPageId !== "string" || !parentPageId) return null;
+ return {
+ parentPageId,
+ ...(typeof ownerShapeId === "string" ? { ownerShapeId } : {}),
+ };
+};
+
+export type SubpageMeta = {
+ /** Forward pointer to the page this portal opens. */
+ targetPageId: string;
+ /** Header color (hex). */
+ accent?: string;
+ /** Title fallback for when the target page is missing; the live render
+ * prefers the current page name. Also mirrored into the geo label text so
+ * old clients see a named rectangle. */
+ title?: string;
+ subtitle?: string;
+};
+
+// A shape is a portal exactly when its meta carries dgSubpage. Kept meta-based
+// (not a custom shape type) for backward compatibility — see SUBPAGE_SHAPE_TYPE.
+export const getSubpageMeta = (meta: unknown): SubpageMeta | null => {
+ if (typeof meta !== "object" || meta === null) return null;
+ const dgSubpage = (meta as { dgSubpage?: unknown }).dgSubpage;
+ if (typeof dgSubpage !== "object" || dgSubpage === null) return null;
+ const { targetPageId, accent, title, subtitle } = dgSubpage as {
+ targetPageId?: unknown;
+ accent?: unknown;
+ title?: unknown;
+ subtitle?: unknown;
+ };
+ if (typeof targetPageId !== "string" || !targetPageId) return null;
+ return {
+ targetPageId,
+ ...(typeof accent === "string" ? { accent } : {}),
+ ...(typeof title === "string" ? { title } : {}),
+ ...(typeof subtitle === "string" ? { subtitle } : {}),
+ };
+};
+
+export type PrefixMatcher = {
+ prefix: string;
+ nodeType: string;
+ regex: RegExp;
+};
+
+const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+
+// A node format like `[[QUE]] - {content}` yields the prefix "QUE": everything
+// before the first placeholder, with page brackets and trailing separator
+// punctuation removed. Formats that begin with the placeholder (Page, Block)
+// yield nothing — those nodes are untyped from a label's point of view.
+const derivePrefix = (format: string): string | null => {
+ const head = format.split("{")[0] ?? "";
+ const prefix = head
+ .replace(/\[\[|\]\]/g, "")
+ .replace(/[\s:\-–—]+$/g, "")
+ .trim();
+ return prefix.length > 0 ? prefix : null;
+};
+
+export const buildPrefixMatchers = (
+ nodes: { type: string; format?: string }[],
+): PrefixMatcher[] => {
+ const matchers = nodes.flatMap((node) => {
+ const prefix = node.format ? derivePrefix(node.format) : null;
+ if (!prefix) return [];
+ // \b only makes sense when the prefix ends in a word character ("QUE" must
+ // not match "QUESTIONS", but a prefix like "@" has no word boundary).
+ const boundary = /\w$/.test(prefix) ? "\\b" : "";
+ // Roam titles literally contain the bracketed format ("[[EVD]] - …"), so
+ // the brackets are optional parts of the match and get stripped with it.
+ return [
+ {
+ prefix,
+ nodeType: node.type,
+ regex: new RegExp(
+ `^\\s*(?:\\[\\[\\s*)?${escapeRegExp(prefix)}${boundary}(?:\\s*\\]\\])?[\\s:\\-–—]*`,
+ "i",
+ ),
+ },
+ ];
+ });
+ // Longest prefix first so e.g. "EVD-X" can never be shadowed by "EVD".
+ return matchers.sort((a, b) => b.prefix.length - a.prefix.length);
+};
+
+export type LineagePage = { id: string; name: string };
+
+// Root-first chain ending at `currentPageId`. Cycles cannot be prevented at
+// write time (programmatic meta writes can't be policed), so the walk carries a
+// visited-set and a depth cap.
+export const walkLineage = (
+ getPage: (
+ id: string,
+ ) => { id: string; name: string; parentPageId?: string } | null | undefined,
+ currentPageId: string,
+): LineagePage[] => {
+ const chain: LineagePage[] = [];
+ const seen = new Set();
+ let page = getPage(currentPageId);
+ while (page && !seen.has(page.id) && chain.length < MAX_LINEAGE_DEPTH) {
+ seen.add(page.id);
+ chain.unshift({ id: page.id, name: page.name });
+ page = page.parentPageId ? getPage(page.parentPageId) : null;
+ }
+ return chain;
+};
+
+export type PreviewBoxKind =
+ | "node"
+ | "portal"
+ | "frame"
+ | "image"
+ | "text"
+ | "geo";
+
+export type PreviewShapeDescriptor = {
+ id: string;
+ type: string;
+ /** Page-space bounds (getShapePageBounds); null when unavailable. */
+ bounds: { x: number; y: number; w: number; h: number } | null;
+ /** Plain text: node title, geo/text label. */
+ text?: string;
+ /** discourse-node shapes: props.nodeTypeId. */
+ nodeTypeId?: string;
+ /** discourse-node imageUrl, or the resolved asset src of an image shape. */
+ imageUrl?: string;
+ portalTitle?: string;
+ portalAccent?: string;
+ frameName?: string;
+ /** tldraw color style name for plain geo shapes. */
+ colorStyle?: string;
+};
+
+export type PreviewBox = {
+ id?: string;
+ x: number;
+ y: number;
+ w: number;
+ h: number;
+ kind: PreviewBoxKind;
+ z: number;
+ title?: string;
+ /** Type code shown when the box is too small for its title. */
+ code?: string;
+ /** Discourse node type id (tier 1 from props, tier 2 from the label prefix). */
+ nodeType?: string;
+ /** Explicit color (portal accent). */
+ color?: string;
+ img?: string;
+ colorStyle?: string;
+};
+
+export type PreviewModel = {
+ count: number;
+ boxes: PreviewBox[];
+ bounds: { minX: number; minY: number; maxX: number; maxY: number } | null;
+};
+
+// Preview stacking: frames behind, then boxes/portals, then nodes and text,
+// images on top. Within a class, input order is kept (sort is stable).
+const Z: Record = {
+ frame: 0,
+ geo: 1,
+ portal: 1,
+ node: 2,
+ text: 2,
+ image: 3,
+};
+
+const collapse = (s: string) => s.replace(/\s+/g, " ").trim();
+
+// One box per shape, in page coordinates. Arrows are skipped (their bounds span
+// between endpoints and would inflate the fitted bounds — relations don't
+// appear in previews). Groups are skipped (the page shape listing already
+// includes their children). Zero-extent shapes don't map.
+export const buildPreviewModel = (
+ shapes: PreviewShapeDescriptor[],
+ prefixMatchers: PrefixMatcher[],
+): PreviewModel => {
+ const boxes: PreviewBox[] = [];
+ let minX = Infinity;
+ let minY = Infinity;
+ let maxX = -Infinity;
+ let maxY = -Infinity;
+
+ for (const s of shapes) {
+ if (s.type === "arrow" || s.type === "group") continue;
+ if (!s.bounds || !s.bounds.w || !s.bounds.h) continue;
+ const { x, y, w, h } = s.bounds;
+ minX = Math.min(minX, x);
+ minY = Math.min(minY, y);
+ maxX = Math.max(maxX, x + w);
+ maxY = Math.max(maxY, y + h);
+
+ const base = { id: s.id, x, y, w, h, img: s.imageUrl };
+ if (s.type === "discourse-node" && s.nodeTypeId) {
+ // The node's own title carries its format prefix ("[[EVD]] - …"); strip
+ // it — the label already shows the type code, and the pixels are better
+ // spent on the content (and the key image).
+ const raw = s.text ?? "";
+ const matcher = prefixMatchers.find((m) => m.regex.test(raw));
+ boxes.push({
+ ...base,
+ kind: "node",
+ z: Z.node,
+ nodeType: s.nodeTypeId,
+ title: collapse(matcher ? raw.replace(matcher.regex, "") : raw),
+ });
+ } else if (s.type === SUBPAGE_SHAPE_TYPE) {
+ boxes.push({
+ ...base,
+ kind: "portal",
+ z: Z.portal,
+ color: s.portalAccent,
+ title: s.portalTitle ?? "Sub-canvas",
+ });
+ } else if (s.type === "frame") {
+ boxes.push({
+ ...base,
+ kind: "frame",
+ z: Z.frame,
+ title: s.frameName ?? "Frame",
+ });
+ } else if (s.type === "image") {
+ boxes.push({ ...base, kind: "image", z: Z.image });
+ } else if (s.type === "text") {
+ boxes.push({
+ ...base,
+ kind: "text",
+ z: Z.text,
+ title: collapse(s.text ?? ""),
+ });
+ } else {
+ const text = s.text ?? "";
+ const matcher = prefixMatchers.find((m) => m.regex.test(text));
+ if (matcher) {
+ boxes.push({
+ ...base,
+ kind: "node",
+ z: Z.node,
+ nodeType: matcher.nodeType,
+ code: matcher.prefix,
+ title: collapse(text.replace(matcher.regex, "")),
+ });
+ } else {
+ boxes.push({
+ ...base,
+ kind: "geo",
+ z: Z.geo,
+ colorStyle: s.colorStyle,
+ title: collapse(text),
+ });
+ }
+ }
+ }
+
+ if (boxes.length === 0) return { count: 0, boxes: [], bounds: null };
+ boxes.sort((a, b) => a.z - b.z);
+ // count = boxes actually drawn, so the header count agrees with the map (a
+ // page holding only arrows shows 0 and an "empty page" body).
+ return { count: boxes.length, boxes, bounds: { minX, minY, maxX, maxY } };
+};
+
+export type PreviewLayout = {
+ area: { x: number; y: number; w: number; h: number };
+ scale: number;
+ offX: number;
+ offY: number;
+ subH: number;
+};
+
+// Scale-to-fit projection of the page bounds into the portal body (the shape
+// minus header, optional subtitle strip, and padding), aspect-preserving,
+// centered. Only call with a non-empty model's bounds.
+export const layoutPreview = ({
+ shape,
+ hasSubtitle,
+ bounds,
+}: {
+ shape: { w: number; h: number };
+ hasSubtitle: boolean;
+ bounds: { minX: number; minY: number; maxX: number; maxY: number };
+}): PreviewLayout => {
+ const subH = hasSubtitle ? SUBPAGE_SUBTITLE_HEIGHT : 0;
+ const pad = SUBPAGE_BODY_PADDING;
+ const area = {
+ x: pad,
+ y: SUBPAGE_HEADER_HEIGHT + subH + pad,
+ w: shape.w - pad * 2,
+ h: shape.h - SUBPAGE_HEADER_HEIGHT - subH - pad * 2,
+ };
+ const pw = Math.max(1, bounds.maxX - bounds.minX);
+ const ph = Math.max(1, bounds.maxY - bounds.minY);
+ const scale = Math.min(area.w / pw, area.h / ph);
+ return {
+ area,
+ scale,
+ offX: area.x + (area.w - pw * scale) / 2,
+ offY: area.y + (area.h - ph * scale) / 2,
+ subH,
+ };
+};
+
+export type BoxLabel = {
+ mode: "title" | "code" | "text";
+ text: string;
+ fontSize: number;
+ /** Line clamp for title mode; 2 when the box has an image so it stays visible. */
+ maxLines?: number;
+};
+
+export const MAX_LABEL_CHARS = 90;
+
+// Shared by the live (HTML) and export (SVG) renderers so their label
+// decisions cannot drift. `bw`/`bh` are the scaled box dimensions.
+export const getBoxLabel = (
+ box: Pick,
+ bw: number,
+ bh: number,
+): BoxLabel | null => {
+ if (box.kind === "text") {
+ if (!box.title || bw <= CODE_MIN_W) return null;
+ return {
+ mode: "text",
+ text: box.title,
+ fontSize: Math.max(7, Math.min(13, bh * 0.9)),
+ };
+ }
+ if (box.title && bw > TITLE_MIN_W && bh > TITLE_MIN_H) {
+ const fontSize = Math.max(6.5, Math.min(11, bh * 0.32));
+ const full = (box.code ? `${box.code} ` : "") + box.title;
+ const text =
+ full.length > MAX_LABEL_CHARS
+ ? `${full.slice(0, MAX_LABEL_CHARS - 1)}…`
+ : full;
+ const fit = Math.max(1, Math.floor(bh / (fontSize * 1.3)));
+ return {
+ mode: "title",
+ text,
+ fontSize,
+ maxLines: box.img ? Math.min(2, fit) : fit,
+ };
+ }
+ if (box.code && bw > CODE_MIN_W && bh > CODE_MIN_H) {
+ return {
+ mode: "code",
+ text: box.code,
+ fontSize: Math.max(6, Math.min(9, bh * 0.5)),
+ };
+ }
+ return null;
+};
+
+// tldraw's createPage silently no-ops at the cap, which would strand an orphan
+// portal pointing at a page that never got created — so guard loudly first.
+export const assertCanCreateSubpage = ({
+ pageCount,
+ maxPages,
+}: {
+ pageCount: number;
+ maxPages: number;
+}): void => {
+ if (pageCount >= maxPages) {
+ throw new Error(
+ `Cannot create a sub-page: this canvas already has the maximum of ${maxPages} pages. Delete unused pages first.`,
+ );
+ }
+};