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 ( +
+ +
+ {chain.map((page, i) => { + const isLast = i === chain.length - 1; + return ( + + {i > 0 ? : null} + + + ); + })} +
+
+ ); +}; + +// 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.`, + ); + } +};