ENG-2027: See individual Canvas frame in a block (dg-canvas frame embed)#1222
ENG-2027: See individual Canvas frame in a block (dg-canvas frame embed)#1222mattakamatsu wants to merge 10 commits into
Conversation
…erge fixes Feature (per canvas-frame-embed spec v0): a dg-frame block embed that mounts the live canvas anchored to a named tldraw frame, referenced by shape id with name fallback. Frame-anchored embeds re-zoom to the frame on every mount (with a re-center button); frameless embeds remember a per-instance viewport via an instanceKey suffix on the canvas session storage key. Includes a two-step insert dialog (canvas page -> frame) wired to a slash command, and embeds default to tldraw focus mode (cmd+. restores the controls). Fixes found while testing the embed on real canvases: - tldrawStyles.ts hid ALL Roam blocks on any page hosting a tldraw container unless it was inside .dg-canvas-embed, so mounting a dg-frame embed blanked the whole page (silently - pure CSS). The exclusion list now covers every embed wrapper class. - useRoamStore's pull-watch handler applied the raw persisted store via applyDiff, which validates against the live schema without migrating, so any record predating MigrateNodeTypeToDiscourseNode (legacy node-uid shape types) threw an uncaught ValidationError inside mergeRemoteChanges and broke the canvas - for dg-canvas embeds too, triggered by any watched change on the canvas page (including its own first-mount State-block write). The merge logic now lives in canvasRemoteMerge.ts: incoming snapshots run through store.schema.migrateStoreSnapshot (same machinery as loadSnapshot), still-invalid records are dropped individually instead of aborting, failed merges are reported instead of thrown, bare legacy-format props no longer TypeError, and a stale merge timer can no longer outlive the stateId guard and clobber just-saved local edits. - refreshConfigTree now tolerates phantom null children in config-page pulls, which previously aborted the entire extension load. Verified live in plugin-testing-akamatsulab2 (repro pages seeded with a raw legacy-typed shape reproduced the exact ValidationError pre-fix; post-fix the same triggers merge cleanly, migrating records in flight). Unit tests cover parsing, session-key instancing, and the remote merge against real tldraw stores. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…attribute
Per product decision: one block syntax, {{dg-canvas: [[Canvas]]}}, drives both
behaviors. A plain reference renders the original whole-canvas embed unchanged
(the classic code path, .dg-canvas-embed wrapper). An optional frame argument
- "Frame Name" and/or shape:ID - routes to the frame-anchored embed instead,
but only when it maps to a real frame on the canvas.
Degradation contract (a broken frame reference must never cost the user their
canvas): the parser ignores any tail that is not a clean "name"/shape:id
argument, and the renderer resolves the frame against the persisted store
before mounting - an unmatched name or id silently falls back to the
whole-canvas embed rather than showing a not-found notice.
- dgFrameEmbed.ts -> dgCanvasEmbed.ts: parse/serialize {{dg-canvas}} with an
optional frame argument; a malformed tail parses as whole-canvas.
- CanvasFrameEmbed.tsx exports findCanvasFrameRef (editor-less frame existence
check via getRoamCanvasSnapshot) and the CanvasFrameEmbed component;
renderCanvasEmbed routes plain vs frame and picks the wrapper class.
- Removed the dg-frame button observer and renderCanvasFrameEmbed; the frame
picker slash command now writes {{dg-canvas: ... "Frame" shape:ID}}.
Tests rewritten for the merged syntax including the degradation cases; verified
live in plugin-testing-akamatsulab2 that plain / valid-frame / unknown-frame /
malformed-tail each route to the expected embed with no errors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A `}` inside a serialized frame name aborted the whole-embed regex match, so parseDgCanvasEmbed returned null and renderCanvasEmbed rendered nothing — violating the contract that a bad frame reference always degrades to the whole canvas. Make the tail lazy (`[\s\S]*?` to the `}}` terminator) so a stray `}` is handled by FRAME_ARGS_REGEX instead, and strip curly braces in the serializer as defense-in-depth for picker-written blocks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The requestAnimationFrame scheduled at editor mount was never cancelled, so if the embed unmounted within a frame the callback ran zoomToFrame against a disposed tldraw editor and set state on an unmounted component. Track the handle and cancel it in an unmount effect. Also have the re-center (⌖) handler re-resolve the frame so a delete/rename after mount clears or raises the "not found" notice instead of leaving it stale. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
findCanvasFrameRef and the frame picker both called getRoamCanvasSnapshot, which builds and migrates a full throwaway TLStore just to list frame shapes — duplicating the parse the editor mount does moments later, and throwing (then swallowing) on legacy canvases whose node shapes need the discourse migration. Add getPersistedCanvasStore / getCanvasFrameShapes that read frame records straight from block props (frames are default shapes, present in both formats and untouched by migration), and route both callers through them. One source of frame enumeration, no throwaway editor, and legacy canvases list frames. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
instanceKey was threaded through CanvasEmbedOptions, the session storage key,
and the persistence registration to give frameless embeds an independent
viewport — but merging the frame embed into {{dg-canvas}} left the plain path
on the original TldrawCanvas with no embedOptions, so no caller ever set it.
Remove the dead parameter, its storage-key branch, and its tests, and correct
the CanvasEmbedOptions doc comment that still referenced the removed
{{dg-frame}} syntax. (Two same-canvas embeds share one viewport — the original
{{dg-canvas}} behavior, intentionally unchanged.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The ⌖ re-center button and the frame-not-found notice backed their readability with an inline rgba(255,255,255,0.9) — a new hardcoded shading color (AGENTS.md: don't introduce new shading colors). Use a default (non-minimal) Blueprint Button and a Blueprint Card, whose themed surfaces provide the background and elevation in both light and dark themes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AGENTS.md asks for explicit return types. Annotate the new exports that were missing them: renderCanvasFrameEmbedDialog and filterUserRecords. (calculateDiff is relocated code with any-typed internals that resist a precise return type without a rewrite — left as-is.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
|
This pull request has been ignored for the connected project Preview Branches by Supabase. |
Resolves the CanvasEmbed.tsx conflict with the "Edit Block" chrome that landed on main (#—, commit 69f7d22). Both sides rewrote renderCanvasEmbed's tail; the combined version keeps main's handleEditBlock/CanvasEmbedChrome for the whole-canvas path and this branch's frame routing for the frame path: frame ? <CanvasFrameEmbed> : <CanvasEmbedChrome title location> Title/frame parsing stays on parseDgCanvasEmbed (supersedes the removed BLOCK_TEXT_REGEX/extractCanvasTitle); both `frame` and `location` are computed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…f any Clears the 12 lint warnings CI flagged on canvasRemoteMerge.ts (the file reads as newly added, so the diff-helper code relocated from useRoamStore.ts is re-linted). diffObjects/calculateDiff now use `unknown` with narrowed casts and typed filter predicates; pruneState drops the unused destructured key. Behavior unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PR size/scope checkThis PR is over our review-size guideline.
Please split this into smaller PRs unless there is a clear reason the changes need to land together. If keeping it as one PR, please add a brief justification covering:
|
|
it also picked up a couple of pre-existing bugs and linting issues; those can be ignored/rejected if desired |
|
@mdroidian is this still on your radar or should I pick this up? |
mdroidian
left a comment
There was a problem hiding this comment.
it also picked up a couple of pre-existing bugs and linting issues; those can be ignored/rejected if desired
@mattakamatsu Yeah, generally speaking we want each PR/Ticket to solve a single problem and be less than 500 lines (before tests). For the sake of a quicker review merge, let's drop the cavasRemoteMerge/useRoamStore and the refreshConfigTree fixes.
I also did a quick once over.
| }; | ||
|
|
||
| return ( | ||
| <div style={{ position: "relative", height: "100%", width: "100%" }}> |
There was a problem hiding this comment.
use tailwind styles instead of inline
|
|
||
| export type FrameRef = { name?: string; shapeId?: string }; | ||
|
|
||
| // Decide whether a parsed frame argument actually maps to a frame on the canvas |
There was a problem hiding this comment.
Let's remind the agent to view the agents.md re: comments
Add comments only when necessary; descriptive names should minimize the need for comments
| ); | ||
| if (matches.length > 1) { | ||
| // eslint-disable-next-line no-console | ||
| console.warn( |
There was a problem hiding this comment.
Generally, only use console for debugging
Use a toast if we need to communicate to the user, posthog if want to record the error
| @@ -0,0 +1,198 @@ | |||
| import React, { useCallback, useEffect, useMemo, useState } from "react"; | |||
There was a problem hiding this comment.
Let's tie this into the existing embed dialog rather than creating a new dialog/separate slash command
| const renderPageStep = () => { | ||
| if (canvasPages === null) | ||
| return ( | ||
| <div className="text-sm text-[#5c7080]">Loading canvas pages...</div> |
There was a problem hiding this comment.
Does roam recognize text-[#5c7080]?
Regardless, why are we using a custom color here?
| /* Hide Roam Blocks only when a full-page canvas is present (not embedded) */ | ||
| .roam-article:has(.roamjs-tldraw-canvas-container:not(.dg-canvas-embed *)) .rm-block-children { | ||
| /* Hide Roam Blocks only when a full-page canvas is present (not embedded). | ||
| Every block-embed wrapper class must be excluded here, or mounting that |
There was a problem hiding this comment.
The original comment captured the intent just fine.
| // the worst case is falling back to the whole-canvas embed. | ||
| export const findCanvasFrameRef = ({ | ||
| pageUid, | ||
| frameName, |
There was a problem hiding this comment.
Let's not store/search for frameName (which is subject to change), only use frameShapeId which is stable.
frameName should be part of the UI when choosing, but not part of the saved data
| }; | ||
| }; | ||
|
|
||
| // The raw persisted record map from block props, without constructing or |
| (after?.[":block/props"] || {}) as json, | ||
| ) as Record<string, json>; | ||
| const rjsqb = props["roamjs-query-builder"] as Record<string, unknown>; | ||
| // Any pending merge was computed from an older props state than this |
There was a problem hiding this comment.
What's the use case for a "pending merge"? When is this likely to happen?
| @@ -1,13 +1,46 @@ | |||
| import getBasicTreeByParentUid from "roamjs-components/queries/getBasicTreeByParentUid"; | |||
| import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle"; | |||
There was a problem hiding this comment.
This change should be it's own ticket where the problem is clearly documented/repeated. The fix should also be in roamjs-components, not here.
Closes ENG-2027. See the issue for the product framing; this is the implementation + self-review notes.
What
One block syntax,
{{dg-canvas:}}, now drives both the whole-canvas embed and a frame-anchored one:{{dg-canvas: [[Canvas]]}}→ the original whole-canvas embed, unchanged.{{dg-canvas: [[Canvas]] "Frame" shape:ID}}→ live embed zoomed to that frame; tracks it as it moves/resizes, ⌖ re-center button, tldraw chrome hidden by default (⌘. restores it).The
{{dg-frame}}attribute from the first draft is gone — merged into{{dg-canvas}}.Shared-code bugs fixed along the way
Both pre-existing, surfaced by exercising the embed on real canvases:
ValidationErrorthat blanked canvas pages. The pull-watch handler applied the raw persisted store viaapplyDiff, which validates but never migrates — so any record predatingMigrateNodeTypeToDiscourseNode(a legacy node-uid shape) threw insidemergeRemoteChangesand tore the page down, for plain{{dg-canvas}}too. Extracted tocanvasRemoteMerge.ts: migrate the incoming snapshot first (same machinery asloadSnapshot), drop still-invalid records individually, report failures instead of throwing. Unit-tested against real tldraw stores.:has()rule only excluded.dg-canvas-embed; any new embed wrapper blanked the host page.Self-review (10-angle pass, findings addressed in the fix commits)
fix: a}in a frame name no longer parses to null and blanks the block (lazy tail + serializer sanitize).fix: cancel the zoom-on-mountrequestAnimationFrameon unmount; re-center refreshes the not-found state.perf: resolve frames from the raw persisted store instead of building a throwaway migrated editor (also dedupes with the picker and stops a legacy-store throw).chore: remove the unusedinstanceKeysession-key plumbing left dead by the merge.style: use Blueprint surfaces for the embed chrome instead of a hardcodedrgba(...)(AGENTS.md).chore: explicit return types on the new exports.For reviewers:
refreshConfigTree.tsgained a null-guard (a phantom null child was aborting extension load). It's unrelated to this feature and only hardens one of the ~16 callers of that helper — happy to split it into its own PR if preferred. Canvas concurrency remains last-write-wins (a remote edit in the same 350 ms window as your own save can be dropped until the next sync), unchanged in spirit from today.Verification
check-types,lint(0 errors), andtest(62) green.plugin-testing-akamatsulab2: all four routing cases (plain / valid-frame / unknown-frame / malformed-tail) route as expected with no errors, the frame embed zooms to its frame, and the ⌖ button + degradation paths work.🤖 Generated with Claude Code