feat: render the canvas in a shadow root - #702
Draft
stravo1 wants to merge 12 commits into
Draft
Conversation
Renders each breakpoint inside a shadow root instead of an iframe, for much less code than full DOM isolation via iframes: CSS and DOM queries stay encapsulated, coordinates and events need no cross-frame translation, and fonts need no extra handling. Includes emulation of page client scripts (CSS applied per breakpoint, JavaScript run against a document/window facade routed at the shadow root, since script isolation is not something shadow DOM provides). Behind a localStorage flag (builderShadowCanvas) until the trade-offs against the iframe approach are settled.
Removes the localStorage prototype flag: every breakpoint now renders in a shadow root unconditionally, no opt-in needed. The block-style selector no longer needs a canvas-id prefix, since each canvas's own shadow root already scopes its styles. Adds Run/Stop/Refresh controls to the canvas toolbar, mirroring the iframe PR's canvas controls. Client scripts default to stopped: they run in the editor's own realm (shadow DOM does not sandbox script execution), so an editor left open should not run arbitrary page or block JavaScript until the user asks for it. Refresh restarts already- running scripts to pick up edits without a stop/start round trip.
Removes the canvas toolbar buttons in favor of a single command
palette entry ("Run/Stop Client Scripts"), following the existing
toggle-theme and toggle-panel command pattern. Also drops the Refresh
control and its refresh nonce — the page and block script watchers
already re-apply on script content changes, so a manual restart added
little.
Scripts still default to stopped and only start on command.
A new breakpoint's shadow root exists before Vue teleports its block tree in, so a script run at that exact moment found no root block and the document.body fallback picked the first child instead — an internal style-sync element unrelated to the page. A script that touched document.body then mutated that element, not the real page. Defer the first script run to the next tick, after the teleport has landed, and drop the fallback: better to fail loudly with no body than silently mutate the wrong node.
Stopping a script cleans up its timers and listeners, but not any raw DOM change it made, such as replacing document.body's content. Vue has no way to detect that change on its own. Bump a nonce on stop and fold it into the block tree's key, so Vue unmounts and rebuilds it from the Block model, discarding whatever the script left behind.
Rewrites comments added in the last five commits to plain, short sentences: split run-ons, cut filler words, drop a contraction, and switch a couple of passive constructions to active. Also corrects one comment left stale by the block-tree remount fix — stop() no longer leaves DOM restoration to Vue on its own. No logic changes.
CSS from page and block client scripts applied unconditionally, only JavaScript waited for Run. CSS now applies and clears together with the JavaScript, under the same Run/Stop and execution-mode gate.
Cmd+Shift+P toggles Run/Stop permanently, matching the command palette entry. Holding Opt+Shift+P forces scripts on for a quick look and restores whatever state they were in before, on release.
…tcut Cmd+Shift+P now covers both: a tap toggles Run/Stop, a hold forces scripts on and restores the prior state on release. Replaces the separate Opt+Shift+P / Ctrl+Shift+L combos with one, matching the tap-vs-hold disambiguation added to frappe-ui's useShortcut. Depends on that frappe-ui change to actually distinguish a tap from a hold; without it, handler and onHold both fire on every keydown.
Each breakpoint shadow root cloned every document <style>/<link> and watched all of document.head to re-clone them, so N breakpoints parsed N copies of the editor CSS. Build one constructable CSSStyleSheet per source instead and adopt it into every root by reference. Mutating a sheet in place updates all adopters, so only a stylesheet entering or leaving the document reassigns adoptedStyleSheets, and the head observer narrows to childList with a per-element observer for hot reload. Also scope block client script CSS to the breakpoint that registered it, rather than injecting the combined string into every root, and cache the @media to @container rewrite, which depends on the CSS text alone and ran once per breakpoint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
State styles were authored and stored, but the canvas dropped them: a block renders its regular styles inline, which cannot carry a pseudo class, so BuilderBlock deleted every hover: key before binding. Pages with hover styles looked flat in the editor and only came alive once published. Emit them as real rules into the breakpoint's canvas stylesheet instead, mirroring append_state_style in builder_page.py. An inline declaration outranks any rule, so each state declaration carries !important to take effect while the state holds. The rules apply while previewing only. Editing means hovering blocks to select them, and a block that restyles itself under the pointer is unusable. Blocks register their rules whatever the mode, so toggling preview flips one computed in the canvas rather than re-running every block's watcher. Also announce the preview toggle with a toast, since preview leaves no lasting mark on the canvas to read the state from. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## develop #702 +/- ##
========================================
Coverage 57.59% 57.59%
========================================
Files 35 35
Lines 4271 4271
========================================
Hits 2460 2460
Misses 1811 1811 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Renders each canvas breakpoint inside a shadow root, so page CSS cannot reach the editor UI.
This is an alternative to the iframe approach on #656
Why a shadow root
A shadow root shares the viewport, the coordinate space, and the event loop with the editor. Rects, listeners, and drag events therefore need no translation across a frame boundary. Fonts need no extra handling, because
@font-faceis document scoped.Two boundary rules need care, and
canvasShadowDom.tsholds the helpers for both:event.targetretargets to the host element. UsegetEventTarget.document.querySelectordoes not descend into a shadow root. UsequeryCanvas.Client scripts
A shadow root gives no JavaScript isolation, so page and block scripts run against a facade in
canvasScriptFacade.ts. The facade pointsdocumentandwindowat the breakpoint's root.Run and Stop gate the scripts, from the command palette or
Ctrl+Shift+P. A tap toggles the state. A hold previews and restores the earlier state on release. Stopping remounts the block tree, because a script can mutate the DOM in ways Vue cannot undo.A shadow root resolves
@mediaagainst the editor viewport, so every breakpoint would get the same answer.pageScriptEmulation.tsrewrites width-only queries to container queries, and the canvas host becomes a size container only when the CSS needs one.Styles
Each breakpoint adopts the editor stylesheets by reference, as constructable
CSSStyleSheetobjects. The document parses each sheet once, and every root shares it. A change to a sheet updates all adopters, so only a stylesheet that enters or leaves the document reassignsadoptedStyleSheets.Block client script CSS goes to the breakpoint that registered it, not to every root.
State styles
Hover, active and focus styles now render during preview. Before this branch the canvas dropped them. A block renders its regular styles inline, and an inline style cannot carry a pseudo class, so
BuilderBlockdeleted everyhover:key.blockStateStyles.tsemits them as real rules instead, which mirrorsappend_state_styleinbuilder_page.py. An inline declaration outranks any rule, so each state declaration carries!important.The rules apply during preview only. To select a block the user hovers it, and a block that restyles itself under the pointer is unusable.
🤖 Generated with Claude Code