refactor(uve): explicit iframe sizing with DevTools-style canvas controls#35539
Open
refactor(uve): explicit iframe sizing with DevTools-style canvas controls#35539
Conversation
Phase 1 of #35514. Removes the ResizeObserver/MutationObserver pipeline that derived the canvas height from the iframe document. Pages with viewport-relative units on layout containers (e.g. min-height: 100dvh) caused an infinite growth loop because the iframe's height was both an input to and an output of layout. The iframe now has explicit dimensions (viewIframeWidth, viewIframeHeight) that are user-controlled inputs, defaulting to 1520 x 1080. Content taller than the iframe scrolls inside the iframe. Inside the iframe, vh/dvh resolve against a fixed viewport, so there is no feedback loop. Overlays will glitch on iframe scroll until phase 2 wires scroll-aware clearing of contentlet bounds.
The iframe element used h-full but its host (<dot-uve-iframe>) had no height, so 100% resolved against zero. Add w-full h-full to the host class so it fills .iframe-wrapper.
…-sizing WIP — more tweaks pending. Adds DevTools-style explicit iframe sizing: - viewIframeWidth / viewIframeHeight are now device-preset and user-driven (resize handles, numeric inputs), not derived from page content. - New $viewIsResponsiveMode computed (true when no specific device preset). - viewSetDevice / viewSetOrientation now set explicit pixel dimensions. - viewSetIframeSize clamps to MIN_IFRAME_WIDTH / MIN_IFRAME_HEIGHT. Editor: - ResizeObserver on .canvas-viewport syncs iframe size to viewport in responsive mode. Effect resyncs when user exits a device preset. - Removed legacy $iframeWrapperStyles override; wrapper always fills the store-sized .canvas-inner. - Initial state width/height = 0; ngAfterViewInit applies real size before first paint to avoid scrollbar flash on load. New components (Tailwind, no SCSS): - <dot-uve-iframe-resize-handles>: full-length right/bottom bars + corner ball. Hidden in device mode. Drag delta divided by zoom. - <dot-uve-iframe-size-input>: width × height numeric inputs in toolbar. Editing while a device is active flips back to responsive mode.
Previously the iframe could grow beyond what the canvas could show at the current zoom — e.g. drag to 2000px at 50% zoom (visually fits in 1000px), then zoom back to 100% and the canvas had to scroll. Now in responsive mode the iframe size is clamped so iframe * zoom never exceeds the canvas viewport. The clamp re-applies on every zoom change (viewZoomSetLevel, in/out/reset all delegate to it) and on every drag / numeric-input update. Device-mode keeps preset dimensions unchanged and lets the canvas scroll if needed. Adds viewCanvasAvailableWidth/Height to UVEState and viewSetCanvasAvailableSize action; the editor's ResizeObserver feeds the real (padding/gutter-excluded) canvas size into the store.
Reuses the existing scroll plumbing — updateEditorScrollState on pointer down, updateEditorOnScrollEnd on pointer up — so editorBounds and editorContentArea clear during the drag and restore on release.
The canvas row uses margin: 0 auto, so growing the iframe shifted the right/bottom edge by only half the size delta — the cursor visibly outpaced the handle. Switch the drag math to measure the handle's current edge each frame and grow the iframe by the cursor's distance from that edge. The handle stays under the cursor whether the canvas is centered, left-anchored after overflow, or transitioning between the two.
Drag and numeric-input updates now cap the iframe so its zoomed size never exceeds the available canvas — no horizontal/vertical canvas scrollbars in responsive mode. Device mode is unaffected (preset sizes keep their dimensions; canvas scrolls if needed).
Switch .browser-url-bar-container from align-items: center to align-items: stretch so all toolbar items match the tallest one. Drop the redundant h-full on dot-uve-iframe-size-input — flex stretch handles it.
- viewZoomSetLevel no longer reshapes the iframe — zooming in past the canvas just makes the canvas scroll (DevTools behavior). Size inputs stay stable while zooming. - viewZoomReset now also snaps the iframe to the canvas viewport in responsive mode (device mode keeps its preset size). Reset becomes a one-click 'fit and zoom 100%'.
The iframe's on-screen size is now always the user-set dimensions (handles never move with zoom). Zoom changes only the iframe's internal viewport via inverse scaling: at 70% zoom the iframe is laid out at dimensions/0.7 CSS pixels and scaled by 0.7 to fill the same on-screen box. The page inside the iframe sees a larger or smaller viewport depending on zoom; canvas never scrolls.
viewIframeWidth/Height now represent on-screen size (since zoom adjusts the iframe's internal viewport, not its on-screen extent). Update both the resize-handle drag math and the responsive-mode canvas clamp to work directly in on-screen pixels — no more dividing by zoom — so the iframe stays inside the canvas at any zoom.
…mode - Picking a device computes a fit zoom so device dims × zoom fit the canvas; iframe gets the on-screen size and viewZoomLevel is set to match. Same logic on orientation flip. - Resize handles now render in device mode too. Dragging from a device preset switches back to responsive (clears the device) so the canvas clamp and user-driven sizing take over.
The default device left zoom and size untouched, so it inherited values from a previously-selected device. Picking it now snaps the iframe to the available canvas viewport at 100% zoom — the expected responsive default.
The zoom controls now expose a borderless p-select (50/75/100/150/200%) instead of the discrete +/- buttons. Borders, background, padding and focus ring are zeroed via [dt] design-token overrides so only the value and chevron icon remain in the toolbar.
Add size="small" and zero the label's right padding so the value sits flush against the chevron.
The size input and zoom select share a single rounded gray container with thin dividers between sections. The reset button moved to the left of the pill (inside it) since it now resets both zoom and canvas size.
Device presets compute an auto-fit zoom (e.g. 67%) that isn't in the predefined list, so p-select rendered an empty label. The options list now appends the current zoom when it isn't a preset, and the bound value is rounded to match the option label exactly.
- dot-uve-device-controls now renders the device buttons inside p-buttongroup; the active device is filled and the rest are outlined. - Drop the gray pill background from dot-uve-device-controls so the group sits flat on the toolbar. - Tighten left padding on the merged size+zoom pill (the reset button brings its own padding).
Lock .browser-toolbar to flex-wrap: nowrap and prevent the start/end slots from shrinking. The center slot, the URL container, and the URL pill all get min-width: 0 so the URL bar can compress and ellipsis its text on narrow toolbars instead of wrapping to a second row.
- Toolbar center now flex+gap and never wraps; uses auto-margin centering so its overflow scrolls all the way to the start. - Browser-toolbar contains its scroll instead of bleeding the layout. - :host grid uses minmax(0, 1fr) and .editor-content min-width: 0 so the editor track shrinks under both side panels at narrow widths. - Device-controls reverts to plain rounded buttons in a gray pill (active = bg-gray-200), no host padding so buttons sit edge-to-edge. - Round all toolbar p-button icons to text-gray-900 (copy-url, zoom reset, orientation, device buttons). - Browser URL pill gets pr-3; size+zoom select padding zeroed via dt and label !p-0.
Use a container query on .p-toolbar-center: under 48rem the URL text hides and the pill drops its padding/max-width so only the copy-link icon button remains. Move the pill's right padding from HTML utility class into SCSS so the query can clear it.
Switch .browser-url-pill from flex: 1 1 0 (always grow) to flex: 0 1 auto so it sits at its content width up to max-width: 28rem and only shrinks under toolbar pressure.
Restructure the copy-URL popover so each row stacks the (lighter gray-700) label above the URL with a small gap, and places the copy button to the right vertically centered with both. List items get more breathing room and a divider line beneath each row.
Inject GlobalStore in the editor and append a third entry in $pageURLS using the current site's hostname (from globalStore.siteDetails) plus the current page path. Uses the i18n key uve.toolbar.page.current.site.url.
Move the progress bar out of the toolbar's center template into a zero-height sibling wrapper. The bar inside is absolutely positioned just above its slot so it overlays the bottom edge of the toolbar without pushing the toolbar contents around.
viewSetDevice(DEFAULT_DEVICE) snapped the iframe back to canvas at 100% zoom, causing a visible jump the moment the user grabbed a resize handle on a device preview. Add viewExitDevicePreset that just clears the device flag without changing size or zoom, and call it from the resize handles after flipping editorState to SCROLLING — the responsive-mode sync effect now skips its canvas-snap while scrolling, so the iframe stays exactly where the user grabbed it.
The active-state check only matched on inode equality, so after exiting a device preset (viewDevice = null) no button highlighted. Treat a null device as the desktop preset so the desktop button reflects 'responsive mode' both on explicit selection and on auto-exit.
Switch handle elements from divs to <button> for semantics and a11y, bump the bar thickness to 1rem (w-4 / h-4), and use a quieter gray palette: base bg-gray-300, hover bg-gray-500, active bg-gray-600 for all three handles.
Drop the .edit-panel-wrapper clamp min from 400px to 360px so the sidebar fits more comfortably alongside the iframe canvas on smaller displays.
Drop the per-tab utility classes and styleClass overrides on both the palette and the right sidebar tablist; the design tokens already size and lay out the tabs. Add a tablist background design token override (var(--gray-100)) for both.
The canvas viewport is overflow:hidden after the iframe-sizing refactor, so canvasViewport.nativeElement.scrollTo was a no-op and clicking a section node in the palette didn't move the editor. Move the scroll target to the iframe's contentWindow (the iframe is what scrolls now) and drop the zoom-factor multiplication — offsetTop is already in the iframe's CSS coordinate space. Extract the inline onSectionOffset callback into a protected handleSectionOffset method and add three tests covering the iframe target, negative-clamp, and missing-iframe paths.
If the component was destroyed while the user was actively dragging a resize handle (e.g. navigation away mid-drag), the pointermove/up/cancel listeners stayed attached to the handle and editorState stayed RESIZING. Switch to AbortController-scoped listeners and tie aborting to DestroyRef.onDestroy so the in-flight drag is torn down cleanly: listeners detach via the signal, pointer capture is released if still held, and editorState flips back to IDLE through updateEditorOnResizeEnd. Adds a component spec covering pointerdown ordering, pointermove math, pointerup release, and the destroy-mid-drag cleanup path.
…zoom computeDeviceFit clamped the zoom to 10% but kept the on-screen size at device * rawFit. With raw fit < 0.1 (canvas much smaller than the device) the iframe ended up at e.g. device * 0.05 with zoom = 10, so the page inside saw iframe / zoom = device * 0.5 — half of the device's CSS dimensions. Device previews were silently broken on tiny canvases. Re-derive the on-screen size from the *clamped* fit so iframe / zoom always equals the device's CSS dimensions exactly. The trade-off: the iframe may visually exceed the canvas (clipped by overflow:hidden) when the canvas is too small to fit the device at the minimum zoom — better than rendering the wrong page layout. Adds a regression test for the clamped-zoom case.
The interface declared device, orientation and seo as non-nullable
strings/Orientation, but viewExitDevicePreset, viewSetSEO, and
viewClearDeviceAndSocialMedia all set them to null at runtime to clear
the corresponding preview mode. The mismatch was masked because no
strict-null check is enforced project-wide, but it was a real type
contract bug that could surface on any future strictness bump.
Make all three fields nullable and update the actions to:
- coerce undefined orientation to null in viewSetDevice
- read viewParams() once per action and produce a fully-typed object
- drop the '...store.viewParams() || {}' fallback that produced
partial objects which couldn't satisfy the new type.
The hook only ran a self-contradicting null-check on uveStore — inject() throws before lifecycle hooks fire, so the branch was unreachable. Drop the method and the OnInit interface implementation.
Add a spec for DotUveZoomControlsComponent covering: - Standard preset list when current zoom matches a preset - Off-preset (e.g. 67% from device auto-fit) appended sorted - Reactivity to zoom signal changes - onZoomChange forwards to viewZoomSetLevel - $viewZoomLevelPct passes through unmultiplied
When a side panel opened or closed the canvas viewport changed width, which fired the ResizeObserver — the editor unconditionally called viewSetIframeSize(canvasSize) and clobbered any custom size the user had typed in the inputs or dragged via the resize handles. Snapshot the previous canvas size before updating it. If the iframe size matches the previous canvas, treat it as auto-filling and follow the new canvas. Otherwise the user has resized — keep their size, but re-apply it through viewSetIframeSize so the canvas-clamp can shrink it when the canvas shrinks below the user's size.
fmontes
commented
May 2, 2026
The size-input component had to call viewExitDevicePreset and viewSetIframeSize back-to-back to handle the user-types-while-device-active case. That orchestration belongs in the store, not the caller. Add viewResizeIframe(size) that delegates to the existing actions internally — exits the device preset (size-preserving) and applies the new dimension. Keep viewSetIframeSize as the low-level setter for paths that legitimately should not exit the device preset (canvas-viewport sync, resize-handles drag where exit already happened on pointerdown). Update the size input to use the new method and add two tests covering the exit-and-set flow and the responsive-mode no-op.
The editor no longer derives canvas size from the iframe document (phase 1 replaced that pipeline with explicit user-controlled iframe sizing). The SDK was still observing the document height and posting IFRAME_HEIGHT messages that the editor now ignores — pure overhead. Remove the SDK side of the contract: - Delete reportIframeHeight, shouldReportIframeHeightToParent, and the observeDocumentHeight observer (+ its spec). - Drop the call sites in sdk-editor.ts and lib/editor/public.ts. - Drop the document-height-observer barrel re-export in internal.ts. - Update script/utils.spec.ts and lib/editor/public.spec.ts. The editor keeps its IFRAME_HEIGHT no-op postMessage handler so any external SDK consumers still emitting this action remain harmless.
Contributor
|
Pull Request Unsafe to Rollback!!!
|
When a side panel toggled, the canvas changed size and the iframe reflowed internally — but the floating contentlet-tools stayed pinned to the old coordinates. The user's quick-edit selection (preserved in the store as editorActiveContentlet) was fine, but the visible tools sat at the wrong place. Two changes: - Editor's canvas observer now flips editorState to RESIZING when the canvas size actually changes, then to IDLE on the next animation frame and re-requests bounds — so containers/dropzone re-anchor. - DotUveContentletToolsComponent listens to editorState and clears its local selectedContentletArea on RESIZING/SCROLLING. The tools disappear during the transition and re-anchor on the next hover or click; the quick-edit panel data is untouched.
…-through The contentlet hover overlay used to capture clicks (and wheels), which blocked iframe scrolling and the page's native click behavior. Move selection to the SDK side instead: - New CONTENTLET_CLICKED UVE event in @dotcms/types + handler in the SDK (capture phase, preventDefault) that posts SET_SELECTED_CONTENTLET to the editor. - New editorSelectedContentletArea state + setSelectedContentletArea store action; the action handler also calls setActiveContentlet so the quick-edit panel opens. - DotUveContentletToolsComponent reads selectedContentletArea from the store; local signal + handleClick deleted. Editor's handleSelectedContentlet callback removed. - .bounds.hover gets pointer-events: none so wheel and clicks pass through to the iframe. - SDK suppresses the page's click on first selection but lets a second click on the SAME contentlet through, so users can interact with links/buttons inside the selected contentlet. - updateEditorScrollState / updateEditorResizeState also clear editorSelectedContentletArea since the bounds go stale. - Document SET_CONTENTLET (hover) vs SET_SELECTED_CONTENTLET (click). Includes a rebuilt dot-uve.js.
Selection used to be triggered by spectator.click(hoverBounds); the click flow moved to the SDK in the previous commit, so the spec now mocks UVEStore with a writable editorSelectedContentletArea signal and sets it directly to drive selected-state assertions. Drops the now-dead 'click hoverBounds to re-select' boilerplate from each test.
Hover overlay now shows a 2px primary outline with a small label tab in the top-left corner displaying the contentlet's content type, instead of the transparent-blue fill. The label is offset -2px to align with the outline's outer edge. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Switching device presets reflows the iframe and moves contentlets, but viewSetDevice patches state directly without flipping editorState. The selected/hover overlays were left pointing at stale coordinates. Mirror the canvas-resize behavior: an effect on viewDevice/orientation flips to RESIZING (hides overlays + clears editorSelectedContentletArea) then back to IDLE on the next frame so REQUEST_BOUNDS re-emits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move the stale-overlay cleanup for device/orientation switches into the store's atomic patchState calls (viewSetDevice, viewSetOrientation) instead of the editor component's effect. The store is the right place to mutate editor state; the component only needs to handle the IDLE recovery + REQUEST_BOUNDS round-trip on the next frame. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related defensive fixes around the click→select path: - $showContentletControls now also renders the contentlet-tools host when a selected area exists, not only when a hovered area does. This was breaking selection for headless contentlets that re-render their hovered DOM node on click — the click set the selection, but the host component was unmounted because editorContentArea was null. - $reloadEditorContent gets a structural equal so downstream effects don't fire when none of (code, pageType, enableInlineEdit) actually changed. Click handlers don't write any of these, but the upstream pageAsset signal version still bumps in some flows, which used to re-fire UVE_SET_PAGE_DATA on every selection. The follow-up bug — where that reload also produced a one-frame empty image identifier in the consumer's React tree — is tracked separately. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.
Summary
Closes #35514. Replaces the iframe height-observation pipeline with explicit, user-controllable iframe sizing modeled after Chrome DevTools' device toolbar.
The previous architecture derived the canvas height from a
ResizeObserver+MutationObserverpipeline running inside the iframe document. Pages with viewport-relative units on a layout container (e.g.min-height: 100dvh) entered an infinite growth loop because the iframe's height was both an input to and an output of layout.What changed
viewIframeWidth/viewIframeHeightare user-driven (resize handles, numeric inputs, device presets).transform: scale()adjusts what the page inside renders. Resize handles never move with zoom; the canvas never scrolls.CONTENTLET_CLICKED(capture-phase, withpreventDefaultto suppress page navigation) and the editor uses it to drive selection. The hover overlay is nowpointer-events: noneso wheel/scroll/click pass through to the iframe — fixes the "blue overlay blocks scroll" and "page link fires on click" bugs. Second click on the same selected contentlet passes through, so internal links remain usable.reportIframeHeight/observeDocumentHeight(no longer consumed). Editor keeps theIFRAME_HEIGHTno-op handler for backward compatibility with external SDK builds.minmax(0, 1fr)so it doesn't bleed when both sidebars are open.Test plan
min-height: 100dvhpage renders without runaway growth — iframe scrolls internallymin-height: 100vhrenders correctlyTests added
🤖 Generated with Claude Code