Skip to content

Render dg-canvas frame embeds as static snapshots by default (v1)#1223

Draft
mattakamatsu wants to merge 2 commits into
canvas-frame-embedfrom
canvas-frame-snapshot
Draft

Render dg-canvas frame embeds as static snapshots by default (v1)#1223
mattakamatsu wants to merge 2 commits into
canvas-frame-embedfrom
canvas-frame-snapshot

Conversation

@mattakamatsu

Copy link
Copy Markdown
Contributor

Stacked on #1222 (v0 frame embeds) — do not merge before it; retarget to main after v0 lands.

What

Frame-anchored {{dg-canvas: [[Page]] "Frame" shape:ID}} embeds now render a read-only SVG image of the frame exported from persisted canvas data, instead of mounting a full interactive tldraw editor per embed. Spec: dg-prototypes/canvas-frame-embed/README.md (v1 section, decisions resolved 2026-07-11).

  • Default = snapshot for frame embeds; a trailing live token ({{dg-canvas: [[Page]] "Frame" shape:ID live}}) opts back into the v0 live editor. Frameless embeds unchanged (always live).
  • Sync-mode canvases always fall back to the live embed with an info badge — their block-props snapshot is frozen at the local→sync switch, so a static render could be arbitrarily stale.
  • Hover chrome: Open canvas (navigates to the canvas page and zooms to the frame once on arrival, via a one-shot sessionStorage hint) and Edit here (swaps the live embed in place until the block re-renders).
  • Refresh model: recompute on mount — block re-render = fresh snapshot, no watchers.

Why not TldrawImage

In tldraw 2.4.6, TldrawImage builds its internal store with useTLStore({ snapshot, shapeUtils }) only — no bindingUtils, no migrations — so DG canvases (per-relation-id binding records, legacy node-uid shape types) throw during store creation. CanvasFrameSnapshot replicates its export flow (temporary headless EditorgetSvgString → blob URL → <img>) on top of a store built with the exact live-mount recipe (buildFrameExportStore), so legacy records migrate on load the same way they do in the live embed. tldraw's single-frame export handles the rest: descendants included and clipped to the frame, neighbors excluded, no frame border/label, bounds = frame page bounds — no store filtering, no hand-rolled transforms.

Tests

  • dgCanvasEmbed.test.ts: live token grammar (all arg shapes, degradation on misplaced/miscased/junk tails, serialize round-trip) + getFrameEmbedMode matrix
  • canvasFrameExportStore.test.ts: legacy node-uid-typed child of a frame migrates on load; nested-frame parentId chain survives; missing migrations throw (documents why the dep-builder matters)
  • canvasFrameZoomHint.test.ts: one-shot semantics, per-page scoping, TTL expiry, corrupt-value cleanup, null-storage safety
  • buildAllAddReferencedNodeByAction.test.ts: extraction from Tldraw.tsx pinned

npm test (82 passing), check-types, eslint, and npm run build all clean.

Not yet done

  • Live verification in plugin-testing-akamatsulab2 (the verification matrix is in the spec README — snapshot vs live rendering, legacy canvas, placeholders, Open-canvas zoom, Edit-here swap). The on-disk apps/roam/dist is built from this branch and ready to load.
  • Cosmetic: exported SVG text falls back to system fonts (DiscourseNodeUtil.toSvg doesn't embed font defs) — noted as an optional follow-up in the spec.

🤖 Generated with Claude Code

Frame-anchored {{dg-canvas}} embeds now render a read-only SVG image of
the frame exported from the persisted canvas data, instead of mounting a
full interactive tldraw editor per embed. A trailing `live` token in the
block text opts back into the live editor; sync-mode canvases always
fall back to the live embed (their block-props snapshot is frozen at
switch time) with an explanatory badge. Frameless embeds are unchanged.

Why not tldraw's <TldrawImage>: in 2.4.6 it builds its internal store
without custom binding utils or migrations, so discourse-graph canvases
(per-relation binding records, legacy node-uid shape types) throw during
store creation. CanvasFrameSnapshot replicates its export flow
(temporary headless Editor -> getSvgString -> blob URL -> <img>) on top
of a store built with the exact live-mount recipe (buildFrameExportStore),
so legacy records migrate on load exactly as they do in the live embed.
The export uses tldraw's single-frame semantics: descendants included
and clipped to the frame, neighbors excluded, no frame border/label,
bounds = frame page bounds - no store filtering, no hand-rolled
transforms, rotated/nested frames included.

Snapshot chrome: "Open canvas" navigates to the canvas page and zooms
to the frame once on arrival (one-shot sessionStorage hint consumed by
the full-page canvas mount); "Edit here" swaps the live embed in place
until the block re-renders. Snapshots recompute on every mount, which
is the feature's whole refresh model - no watchers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@supabase

supabase Bot commented Jul 12, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project zytfjzqyijgagqxrzbmz because there are no changes detected in packages/database/supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@vercel

vercel Bot commented Jul 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
discourse-graph Skipped Skipped Jul 12, 2026 6:46am

Request Review

Open-canvas zoom-to-frame relied on a single requestAnimationFrame after
onMount. On a warm canvas load that works, but on a cold load (first
navigation to the canvas page) the editor's viewport is not measured yet
one frame in, and zoomToBounds is a no-op against a zero-size viewport —
so the camera was left at the default origin instead of the frame.

Verified live in plugin-testing-akamatsulab2: warm loads framed the
target correctly; a genuine cold load (session camera cleared) ended at
the default {0,0} camera even though the hint was consumed and the frame
resolved. Manual zoomToBounds after the viewport measured always worked.

Fix: retry across frames until getViewportScreenBounds reports a measured
(non-zero) viewport and zoomToFrame succeeds, then zoom exactly once.
Bounded to ~90 frames so a deleted frame or never-sized container gives
up instead of spinning; the existing cleanup already cancels the pending
frame on unmount.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mattakamatsu

Copy link
Copy Markdown
Contributor Author

Live-verified in plugin-testing-akamatsulab2 (against Canvas/frame-testing, frames test1f/test2f, which includes discourse-node shapes and a legacy-typed record):

  • ✅ Frame snapshot renders the frame content only — velocity-plot image + red [[RES]] and green [[CLM]] discourse nodes with correct colors; neighbors excluded, no frame border/label. Renders headlessly on a page where the canvas was never opened.
  • ✅ Refresh-on-render: fresh blob image on every mount, no watchers.
  • live token → v0 live editor; frameless {{dg-canvas}} → live whole-canvas embed.
  • ✅ Hover chrome: Edit here swaps the live editor in place (zoomed to the frame, ⌖ button present); Open canvas navigates to the canvas page and zooms to the frame on arrival.
  • ✅ No blank-page regression; blocks stay visible.

One bug found and fixed during testing (commit 8c66e4d): Open-canvas zoom-on-arrival used a single requestAnimationFrame after onMount. That works on a warm canvas load but on a cold load the editor's viewport isn't measured yet one frame in, so zoomToBounds no-ops and the camera stays at the default origin. Reproduced deterministically (cleared session camera → cold load ended at {0,0} despite the hint being consumed and the frame resolving). Fixed by retrying across frames until getViewportScreenBounds is measured, then zooming once (bounded to ~90 frames). Re-verified live: cold full-page load now lands framed on test1f.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant