Web frame preview ignores canvas layer ordering and always paints on top #207
Labels
No labels
prio_critical
prio_low
type_bug
type_contact
type_issue
type_lead
type_question
type_story
type_task
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
lhumina_code/hero_whiteboard#207
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
The web frame object renders as a DOM iframe wrapped in an absolutely-positioned overlay (
crates/hero_whiteboard_admin/static/web/js/whiteboard/webframe.js, thewrapper/iframecreated around the overlay'sposition:absolutemarkup). Because HTML elements always paint above a canvas, the iframe sits visually on top of every Konva object regardless ofz_index, bring-to-front / send-to-back actions, frame containment, or whatever other object the user expects to be in front. The web frame's own preview/screenshot card is also part of that DOM overlay, so it has the same problem before the iframe has loaded.This is not a stacking bug in
object.update; it's a fundamental DOM-over-canvas limitation. The web frame cannot honour the canvas's layer order while it is rendered as a live iframe overlay.Reproduction
Same effect when a web frame is inside (or behind) a frame: the iframe overlay still covers items the user expects to be in front of it.
Scope
Make the web frame respect canvas layer ordering. The recommended approach is to stop rendering a live iframe in-canvas by default and instead:
This matches how other in-canvas embeddings handle the same constraint (Miro and similar tools render a preview card by default and open the live page on demand).
Requirements
z_index, send-to-back / bring-to-front, and frame containment exactly like any other object — its preview is part of the Konva object layer.Acceptance Criteria
Notes
The whiteboard frontend is vanilla JS modules under
crates/hero_whiteboard_admin/static/web/js/whiteboard/, embedded via rust-embed. The current overlay machinery is inwebframe.js(overlay creation, hide-on-drag, hide-on-pan/zoom logic). The replacement preview should reuse the existingobjects.jsfactory pattern so undo/redo, selection toolbar, sync, and persistence keep working unchanged.Implementation Spec for Issue #207
Objective
Eliminate the on-canvas HTML
<iframe>overlay used for web-frame previews so web-frame objects honor Konva layer ordering (z_index,bringToFront/sendToBack, frame clipping). Render every web-frame as a pure Konva card by default; expose the live<iframe>only inside a transient, centered modal that the user opens explicitly (double-click on the card or an Open button in the selection toolbar) and dismisses (X / Escape / backdrop click).Requirements
bringToFront/sendToBack, frame containment, and presentation mode apply uniformly.Double-click to openhint. The server's existing/api/url-check(web_url_checkinroutes.rs:437) returns only{ embeddable, reason? }— no favicon/title — so the card uses URL + icon only. No new server endpoint.{ url }insidedata(sync.js:365-366) — no schema migration; pre-existing webframes load as the new card./api/url-checksays non-embeddable, the existing "site doesn't allow embedding" card is rendered into the body instead.iframe.src.body.wb-presentingis set; auto-closes on entering presentation.board_view.htmlrenders the new card and allows opening the modal (read-only is cosmetic; opening is a view-only action).objects.js:1657) — no webframe-specific overlay reposition code remains.wheel, stagedragstart/dragend, groupdragstart/dragendoverlay-reposition logic is deleted.createWebframe,updateUrl,applyNewUrl,submitUrlModal,cancelUrlModal; addopenOverlay,closeOverlay. The dead overlay APIs (destroyOverlay,remapOverlay,refreshOverlay,setAllInteractive,hideOverlay,showOverlay,hideAllOverlays,showAllOverlays,refreshAllOverlays) become no-ops so the call sites inobjects.js,sync.js,app.js,canvas.js,history.js,tools.js,frames.jskeep working without per-file edits. (Latent fix:applyNewUrlis currently called byselection_toolbar.js:1727but missing from the public return — added explicitly.)Files to Modify/Create
crates/hero_whiteboard_admin/static/web/js/whiteboard/webframe.js— rewrite factory to Konva-only; move iframe code into a transient shared modal.crates/hero_whiteboard_admin/templates/web/board.html— add#wb-webframe-modalmarkup (near line 403).crates/hero_whiteboard_admin/templates/web/board_view.html— mirror the same modal markup.crates/hero_whiteboard_admin/static/web/css/whiteboard.css— add.wb-webframe-modal*styles using the existing--wb-*theme vars; includebody.wb-presenting .wb-webframe-modal { display:none !important; }.crates/hero_whiteboard_admin/static/web/js/whiteboard/selection_toolbar.js—_renderWebframe(line 1713) appends an Open icon button (_buildIconBtn 'bi bi-box-arrow-up-right') callingWhiteboardWebframe.openOverlay(node).crates/hero_whiteboard_admin/static/web/js/whiteboard/frames.js— drop the webframe-specific show/hide loops (lines 268-269 and 384-407); onstartPresentationcallWhiteboardWebframe.closeOverlay().No new JS file; no new server endpoint.
Implementation Plan
Step 1: Modal markup
Files:
templates/web/board.html,templates/web/board_view.html<div id="wb-webframe-modal" class="wb-webframe-modal" role="dialog" aria-modal="true" style="display:none;">containing__dialog/__header(url, newtab link, close X)/__body(iframe + hidden non-embeddable card slot).Dependencies: none (parallelizable with Step 2)
Step 2: Modal CSS
Files:
static/web/css/whiteboard.css.wb-webframe-modal(fixed inset:0, backdrop, z-index above chrome),__dialog(max-width:90vw, max-height:85vh),__header(flex, themed),__url(mono, truncate,--wb-text-muted),__close,__body(flex:1, position:relative). Addbody.wb-presenting .wb-webframe-modal { display:none !important; }.Dependencies: none (parallelizable with Step 1)
Step 3: Rewrite webframe.js
Files:
static/web/js/whiteboard/webframe.jscreateWebframe: keep theKonva.Groupwith bg/header/label/placeholder; removecreateIframeOverlay(id, ...)call and the stagedragstart.wf_*/dragend.wf_*/wheel.wf_*listeners andsetTimeout(updateOverlayPosition, 50). Placeholder label readsDouble-click to open\n<url>.dblclick/dbltap: open the modal viaopenOverlay(group)(notpromptUrl); URL editing remains in the selection toolbar popover.recomputeParentFrame; drophideOverlay/showOverlay.overlaysmap and the helpers:createIframeOverlay,updateOverlayPosition,destroyOverlay,remapOverlay,refreshOverlay,setAllInteractive,hideOverlay,showOverlay,hideAllOverlays,showAllOverlays,refreshAllOverlays. Replace each with a no-op return-undefinedfunction exported from the module.openOverlay(group): read_wfState.url(orgroup.findOne('.label').text()), set the modal's URL text + iframesrc(after the existing_checkEmbeddable; non-embeddable → render the existing not-embeddable card markup into#wb-webframe-modal-cardand hide the iframe). Wire Escape + backdrop + X. If overlay already open, callcloseOverlay()first.closeOverlay(): hide the modal, cleariframe.src, detach listeners.applyNewUrl(id, url)/updateUrl(id, url): drop theoverlays[id]branches; update the.labeland.placeholderKonva text.{ createWebframe, openOverlay, closeOverlay, applyNewUrl, updateUrl, submitUrlModal, cancelUrlModal, destroyOverlay: noop, remapOverlay: noop, refreshOverlay: noop, setAllInteractive: noop, hideOverlay: noop, showOverlay: noop, hideAllOverlays: noop, showAllOverlays: noop, refreshAllOverlays: noop }.Dependencies: Steps 1 and 2
Step 4: Selection-toolbar Open button
Files:
static/web/js/whiteboard/selection_toolbar.js_renderWebframe(line 1713), append after the URL popover:propsEl.appendChild(_buildIconBtn('bi bi-box-arrow-up-right', 'Open web frame', function(){ if (WhiteboardWebframe && WhiteboardWebframe.openOverlay) WhiteboardWebframe.openOverlay(node); }));.Dependencies: Step 3
Step 5: Presentation mode close
Files:
static/web/js/whiteboard/frames.jsstartPresentationcallWhiteboardWebframe.closeOverlay()once.Dependencies: Step 3
Step 6: Build + verify
touch crates/hero_whiteboard_admin/src/assets.rs(CSS/template + JS changed),cargo build --release -p hero_whiteboard_admin. Verify the served assets show the new card markup and the modal HTML. Manual check: stack a sticky in front of a webframe; rubber-band select crosses; open via double-click and via toolbar; Escape closes; presentation mode closes the modal.Parallelization: Steps 1, 2 in parallel. Step 3 after both. Steps 4, 5 in parallel after Step 3.
Acceptance Criteria
<div id="wf-overlay-...">exists in the DOM.bringToFront/sendToBackon a web-frame stacks correctly relative to other objects.iframe.srcso the page unloads.data.urlonly) load as the card; no migration.body.wb-presentingis set.board_view.htmlrenders the card and opens correctly.destroyOverlay,refreshOverlay, etc.).Notes
web_url_checkreturns{ embeddable, reason? }only. Card stays URL + globe icon.{ url }insidedata); only client rendering changes.applyNewUrlwas missing from the public return today; the rewrite adds it back.selection_toolbar.js:1727will resolve it correctly post-change.WhiteboardPromptModal(#205) and the existing#webframe-url-modalmarkup stay as-is; URL editing path is unchanged.cargo build --release -p hero_whiteboard_admin; no new file is added, soassets.rsstrictly does NOT need touching (but touching is harmless).Implementation Summary
Web frames no longer use an on-canvas DOM iframe overlay. They render as a normal Konva card and honour canvas layer ordering; the live iframe is shown in a transient shared modal opened on demand.
Changes
crates/hero_whiteboard_admin/static/web/js/whiteboard/webframe.js:Double-click to open).overlaysmap,createIframeOverlay,updateOverlayPosition, stagedragstart.wf_*/dragend.wf_*/wheel.wf_*listeners, and all hide/show/refresh overlay helpers (along with their per-webframe HTML wrappers).openOverlay(group)/closeOverlay()that drive a single shared modal (#wb-webframe-modal): pre-flights via the existing/api/url-check, shows the iframe when embeddable or renders the existing fallback card when not, closes on X / Escape / backdrop click and clearsiframe.srcon close.applyNewUrl/updateUrlnow also refresh the Konva placeholder text; iframe-overlay branches dropped.destroyOverlay,remapOverlay,refreshOverlay,setAllInteractive,hideOverlay,showOverlay,hideAllOverlays,showAllOverlays,refreshAllOverlays) so existing call sites inobjects.js,sync.js,app.js,canvas.js,history.js,tools.js,frames.jscontinue to work without per-file edits.crates/hero_whiteboard_admin/templates/web/board.htmlandcrates/hero_whiteboard_admin/templates/web/board_view.html: added the shared#wb-webframe-modalmarkup (header with URL, "open in new tab" link, close X; body with iframe + a fallback card slot).crates/hero_whiteboard_admin/static/web/css/whiteboard.css: added.wb-webframe-modal*rules using the existing--wb-*theme variables;body.wb-presenting .wb-webframe-modal { display:none !important; }so the modal cannot appear during presentation.crates/hero_whiteboard_admin/static/web/js/whiteboard/selection_toolbar.js:_renderWebframeappends an Open icon button (bi-box-arrow-up-right) that callsWhiteboardWebframe.openOverlay(node).crates/hero_whiteboard_admin/static/web/js/whiteboard/frames.js: replaced the per-webframe overlay show/hide loops with a singleWhiteboardWebframe.closeOverlay()onstartPresentation(the overlay is now a transient modal and cannot leak into presentation).Behavior
z_index/bringToFront/sendToBacklike any other object, and frame containment applies.{ url }insidedata) is unchanged; existing boards load as the new card with no migration.iframe.srcso the inner page is unloaded.Test results
cargo test --workspace --lib: compiled cleanly, no failures (change is JS/HTML/CSS-only; regression guard).node --checkpassed forwebframe.js,selection_toolbar.js,frames.js. File encodings clean (UTF-8, zero control bytes).webframe.jscontainsopenOverlay/closeOverlay/no-op stubs, served CSS contains the new.wb-webframe-modal*rules.Notes
/api/url-check(web_url_check) returns only{ embeddable, reason? }— there is no favicon/title available without a new server endpoint, so the on-canvas card uses URL + globe icon only. Out of scope to add a thumbnailing endpoint here.#webframe-url-modal) and the selection-toolbar URL popover are unchanged.applyNewUrlwas being called by the toolbar but was missing from the previous public return — it is now exported.Implementation Spec for Issue #207 (v2 — revised approach)
The previous v1 approach (replace the live iframe with a Konva card + a transient open-modal) removed the live preview the user wants to keep. This v2 keeps the existing in-canvas iframe overlay and makes it respect canvas layer order through CSS clipping and occlusion-based show/hide.
Objective
The existing live
<iframe>overlay stays as the on-canvas preview, but it now:z_indexoverlaps it — sobringToFront,sendToBack, frame containment, and object stacking all behave as expected.Requirements
<iframe>overlay machinery inwebframe.js(overlays[id], the wrapper element,updateOverlayPosition, pan/zoom/drag listeners) is preserved — no rewrite to a Konva-only card.updateOverlayLayering(id)step runs whenever an event could change the visibility result and:updateOverlayPositionalready does this).parent_frame_id, computes the parent frame group's screen rect and sets the wrapper'sclip-path: inset(top right bottom left)so the iframe is clipped to the frame. If the webframe is fully outside the frame's visible area, hide the wrapper.zIndex() > webframe.zIndex()whosegetClientRect()intersects the webframe'sgetClientRect(), mark the webframe as occluded. If occluded, hide the wrapper (the user sees the Konva placeholder underneath the now-hidden iframe). Otherwise show.bringToFront/sendToBack(KonvamoveToTop/moveToBottom) on a webframe or its overlappers must immediately reflect in the iframe visibility.wheel(pan/zoom), stage drag, any Konvadragmove/dragendon the object layer, and an explicitWhiteboardWebframe.refreshAllLayering()call after object create / delete / z-index change (called byobjects.js/tools.jswhere stacking changes happen).requestAnimationFrame.dragendwe run the new layering check rather than always-restore.Files to Modify/Create
crates/hero_whiteboard_admin/static/web/js/whiteboard/webframe.js— addupdateOverlayLayering(id),refreshAllLayering(), and_computeOcclusion(group); hook into stagedragend/wheel-end and into the existingupdateOverlayPositionpath so layering updates whenever position updates. Add new exports.crates/hero_whiteboard_admin/static/web/js/whiteboard/objects.js— after the stacking helpers (bringToFront/sendToBack/moveUp/moveDowncallers incontextmenu.js/tools.js) callWhiteboardWebframe.refreshAllLayering()once. Also call it from the create/delete object code paths.crates/hero_whiteboard_admin/static/web/js/whiteboard/tools.js— at the end of objectdragendon the object layer, callWhiteboardWebframe.refreshAllLayering()(cheap, rAF-debounced).crates/hero_whiteboard_admin/static/web/js/whiteboard/frames.js— when a parent frame moves or resizes, callWhiteboardWebframe.refreshAllLayering()so the frame-containment clip updates.No new files, no template/CSS changes (the clip is set inline via JS).
Implementation Plan
Step 1: Add layering helpers in webframe.js
Files:
webframe.jsfunction _getClientRectScreen(node)— returns the node's bounding box in screen pixels (stage.container() coords). Use Konva'sgetClientRect({ relativeTo: stage })then map bystage.scaleX()/Y()andstage.x()/y(). (Mirror whatupdateOverlayPositionalready does.)function _findParentFrameGroup(group)— readobj.parent_frame_idfrom the objects registry; return the frame's Konva group or null.function _computeOcclusion(group)— get webframerect = _getClientRectScreen(group); get object layer children; for eachnwithn.zIndex() > group.zIndex()ANDn !== group, computenRect; ifintersects(rect, nRect)return true; return false.function _computeFrameClipInset(group, wrapper)— if_findParentFrameGroup(group)returns a framef, computefRect = _getClientRectScreen(f)and the wrapper's screen rect; returntop/right/bottom/leftinset values in CSS pixels relative to the wrapper origin soinset(top right bottom left)clips the wrapper to the frame; return null if no parent frame.function updateOverlayLayering(id)— combines the above: appliesclip-path: inset(...)/clears it, and toggles wrapperdisplay: none/blockbased on occlusion + outside-frame.function refreshAllLayering()— debounced via rAF; iteratesoverlaysmap and callsupdateOverlayLayeringfor each.updateOverlayLayeringandrefreshAllLayeringfrom the module.Dependencies: none
Step 2: Wire layering updates into existing overlay updates
Files:
webframe.jsupdateOverlayPosition(id)(existing), callupdateOverlayLayering(id)at the end so layering is recomputed every time the position is recomputed.wheel/dragstart/dragendlisteners that update position will automatically pick up layering.Dependencies: Step 1
Step 3: Call refreshAllLayering on layer/z mutations
Files:
objects.js,tools.js,frames.jsobjects.jsbringToFront/sendToBack/moveUp/moveDownhelpers (find them; if the code usesnode.moveToTop()etc. inline, wrap or add a hook): after the Konva stacking call,WhiteboardWebframe.refreshAllLayering().tools.jsglobaldragendhandler on the object layer (find it; the one that fires after any object drag ends), callWhiteboardWebframe.refreshAllLayering().frames.jsframedragend/transform-end handlers, callWhiteboardWebframe.refreshAllLayering().objects.js/sync.jsobject create and delete paths, callWhiteboardWebframe.refreshAllLayering()once after the registry mutation settles.Dependencies: Step 2
Step 4: Build + verify
touch crates/hero_whiteboard_admin/src/assets.rs && cargo build --release -p hero_whiteboard_admin.Acceptance Criteria
<iframe>preview still appears on the canvas by default (no Konva-only card replacement).Notes
node.zIndex()returns the runtime layer order, which is what bring-to-front / send-to-back manipulate. The unfixedz_indexpersistence gap (V2 audit High) is orthogonal — Konva's runtime order is what matters for visibility.