fix(studio): off-canvas indicators track live layout instead of going stale (#2018)

The dashed indicators for elements outside the canvas stayed pinned at an element's
old position after a move or a seek: they were recomputed by an effect keyed on
compRect/activeCompositionPath, neither of which changes on an in-place soft-reload
edit or a playhead seek.

They now recompute via a MutationObserver on the preview document (coalesced to one
recompute per frame), so they follow the element live. Crop-hugging is preserved.
Adds a regression test that mutates an element in place and asserts the indicator moves.
This commit is contained in:
Miguel Ángel
2026-07-07 04:39:46 -04:00
committed by GitHub
parent 037266e72b
commit 574da2b215
4 changed files with 360 additions and 47 deletions
@@ -3,9 +3,7 @@ import { type DomEditSelection } from "./domEditing";
import type { PreviewMouseDownOptions } from "../../hooks/usePreviewInteraction";
import { useMarqueeGestures } from "./marqueeCommit";
import { MarqueeOverlay } from "./MarqueeOverlay";
import { groupAwareOverlayRect, resolveDomEditGroupOverlayRect } from "./domEditOverlayGeometry";
import { collectDomEditLayerItems } from "./domEditingLayers";
import { isElementComputedVisible } from "./domEditingElement";
import { resolveDomEditGroupOverlayRect } from "./domEditOverlayGeometry";
import {
type BlockedMoveState,
type DomEditGroupPathOffsetCommit,
@@ -26,6 +24,8 @@ import { hugRectForElement } from "./domEditOverlayCrop";
import { useCropOverlay } from "../../hooks/useCropMode";
import { readDomEditSelectionShapeStyles, resolveBoxChromeClass } from "./domEditOverlayShape";
import { useDomEditCompositionRect } from "./useDomEditCompositionRect";
import { useMountEffect } from "../../hooks/useMountEffect";
import { startOffCanvasIndicatorRefresh } from "./offCanvasIndicatorRefresh";
// Re-exports for external consumers — preserving existing import paths.
export {
@@ -173,6 +173,8 @@ export const DomEditOverlay = memo(function DomEditOverlay({
});
const compRect = useDomEditCompositionRect({ iframeRef, overlayRef });
const compRectRef = useRef(compRect);
compRectRef.current = compRect;
const { hasCropInsets, cropOutlineInsetPx } = useCropOverlay({
selection,
@@ -189,51 +191,35 @@ export const DomEditOverlay = memo(function DomEditOverlay({
// outside the composition bounds so users can find them.
const offCanvasElementsRef = useRef<Map<string, HTMLElement>>(new Map());
const [offCanvasRects, setOffCanvasRects] = useState<OffCanvasRect[]>([]);
// fallow-ignore-next-line complexity
const offCanvasDirtyRef = useRef(true);
const offCanvasSigRef = useRef("");
const offCanvasObserverRef = useRef<MutationObserver | null>(null);
const offCanvasObservedDocRef = useRef<Document | null>(null);
// Positions depend on live iframe layout, not selection — the selected-element
// suppression is a render-time filter, so selection/groupSelections stay out
// of the geometry walk.
useMountEffect(() =>
startOffCanvasIndicatorRefresh({
iframeRef,
overlayRef,
compRectRef,
activeCompositionPathRef,
dirtyRef: offCanvasDirtyRef,
sigRef: offCanvasSigRef,
observerRef: offCanvasObserverRef,
observedDocRef: offCanvasObservedDocRef,
elementsRef: offCanvasElementsRef,
setRects: setOffCanvasRects,
}),
);
// Switching compositions may not swap the iframe document (so the observer's
// doc-swap detection wouldn't fire) yet changes which elements are off-canvas.
// Force a recompute explicitly on comp change.
useEffect(() => {
const iframe = iframeRef.current;
const overlay = overlayRef.current;
if (!iframe || !overlay || compRect.width <= 0) {
setOffCanvasRects([]);
return;
}
const doc = iframe.contentDocument;
if (!doc) return;
const root = doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.body;
const acp = activeCompositionPath ?? "index.html";
const items = collectDomEditLayerItems(root, {
activeCompositionPath: acp,
isMasterView: !acp || acp === "index.html",
});
const rects: typeof offCanvasRects = [];
const elMap = new Map<string, HTMLElement>();
for (const item of items) {
if (!isElementComputedVisible(item.element)) continue;
// Groups use their members' union (where they actually render), so a group
// whose members sit inside the canvas isn't flagged off-canvas by a stale
// wrapper box. Crop-hug the result so an inset crop that keeps the visible
// part on-canvas doesn't flag the element either.
const base = groupAwareOverlayRect(overlay, iframe, item.element);
const r = base ? { ...base, ...hugRectForElement(base, item.element) } : null;
if (!r) continue;
// Any edge crossing the composition border → gray-zone indicator (the
// in-canvas portion is clipped away below, so only the sliver shows).
const extendsOutsideComp =
r.left < compRect.left ||
r.left + r.width > compRect.left + compRect.width ||
r.top < compRect.top ||
r.top + r.height > compRect.top + compRect.height;
if (extendsOutsideComp) {
rects.push({ key: item.key, left: r.left, top: r.top, width: r.width, height: r.height });
elMap.set(item.key, item.element);
}
}
offCanvasElementsRef.current = elMap;
setOffCanvasRects(rects);
// Positions depend on layout, not selection — the selected-element
// suppression is a render-time filter, so selection/groupSelections stay
// out of the deps to avoid re-walking geometry on each selection change.
}, [iframeRef, compRect, activeCompositionPath]);
offCanvasDirtyRef.current = true;
}, [activeCompositionPath]);
const gestures = createDomEditOverlayGestureHandlers({
overlayRef,
@@ -0,0 +1,74 @@
import type React from "react";
import type { OffCanvasRect } from "./OffCanvasIndicators";
import { hugRectForElement } from "./domEditOverlayCrop";
import { groupAwareOverlayRect } from "./domEditOverlayGeometry";
import { isElementComputedVisible } from "./domEditingElement";
import { collectDomEditLayerItems } from "./domEditingLayers";
function rounded(value: number): number {
return Math.round(value * 100) / 100;
}
function offCanvasSignature(rects: OffCanvasRect[]): string {
return rects
.map(
(rect) =>
`${rect.key}:${rounded(rect.left)},${rounded(rect.top)},${rounded(rect.width)},${rounded(rect.height)}`,
)
.join("|");
}
// fallow-ignore-next-line complexity
export function recomputeOffCanvasIndicators(
iframe: HTMLIFrameElement,
overlay: HTMLDivElement,
doc: Document | null | undefined,
comp: { left: number; top: number; width: number; height: number },
activeCompositionPath: string | null,
sigRef: React.MutableRefObject<string>,
elementsRef: React.MutableRefObject<Map<string, HTMLElement>>,
setRects: (rects: OffCanvasRect[]) => void,
): void {
if (comp.width <= 0 || !doc) {
sigRef.current = "";
elementsRef.current = new Map();
setRects([]);
return;
}
const root = doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.body;
const acp = activeCompositionPath ?? "index.html";
const items = collectDomEditLayerItems(root, {
activeCompositionPath: acp,
isMasterView: !acp || acp === "index.html",
});
const rects: OffCanvasRect[] = [];
const elMap = new Map<string, HTMLElement>();
for (const item of items) {
if (!isElementComputedVisible(item.element)) continue;
// Groups use their members' union (where they actually render), so a group
// whose members sit inside the canvas isn't flagged off-canvas by a stale
// wrapper box. Crop-hug the result so an inset crop that keeps the visible
// part on-canvas doesn't flag the element either.
const base = groupAwareOverlayRect(overlay, iframe, item.element);
const r = base ? { ...base, ...hugRectForElement(base, item.element) } : null;
if (!r) continue;
// Any edge crossing the composition border → gray-zone indicator (the
// in-canvas portion is clipped away below, so only the sliver shows).
const extendsOutsideComp =
r.left < comp.left ||
r.left + r.width > comp.left + comp.width ||
r.top < comp.top ||
r.top + r.height > comp.top + comp.height;
if (extendsOutsideComp) {
rects.push({ key: item.key, left: r.left, top: r.top, width: r.width, height: r.height });
elMap.set(item.key, item.element);
}
}
const nextSig = offCanvasSignature(rects);
if (nextSig === sigRef.current) return;
sigRef.current = nextSig;
elementsRef.current = elMap;
setRects(rects);
}
@@ -0,0 +1,154 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { describe, expect, it } from "vitest";
import { DomEditOverlay } from "./DomEditOverlay";
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
const INDICATOR = '[aria-label="Select off-canvas element index.html:headline:0"]';
function domRect(left: number, top: number, width: number, height: number): DOMRect {
return {
left,
top,
right: left + width,
bottom: top + height,
width,
height,
x: left,
y: top,
toJSON: () => ({}),
};
}
async function flushAnimationFrames(): Promise<void> {
await new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
});
}
interface OverlayHarness {
host: HTMLElement;
movedElement: HTMLElement;
cleanup: () => void;
}
// Mount DomEditOverlay over an iframe whose #headline sits at `initialLeft`, with a
// getBoundingClientRect stub that reads the element's live inline geometry (so a
// style mutation moves it) and reports the composition/overlay as 800x450 at origin.
function mountOverlayWithHeadline(initialLeft: number): OverlayHarness {
const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect;
const host = document.createElement("div");
document.body.append(host);
const root: Root = createRoot(host);
const iframe = document.createElement("iframe");
document.body.append(iframe);
const doc = iframe.contentDocument;
if (!doc) throw new Error("Expected iframe content document");
doc.body.innerHTML = `
<div data-composition-id="root" data-width="800" data-height="450">
<div id="headline" style="position:absolute; left:${initialLeft}px; top:40px; width:100px; height:40px;">Headline</div>
</div>
`;
const movedElement = doc.getElementById("headline");
if (!movedElement) throw new Error("Expected test element");
Element.prototype.getBoundingClientRect = function (): DOMRect {
if (this === movedElement) {
return domRect(
Number.parseFloat(movedElement.style.left),
Number.parseFloat(movedElement.style.top),
Number.parseFloat(movedElement.style.width),
Number.parseFloat(movedElement.style.height),
);
}
return domRect(0, 0, 800, 450);
};
act(() => {
root.render(
<DomEditOverlay
iframeRef={{ current: iframe }}
activeCompositionPath={null}
selection={null}
hoverSelection={null}
groupSelections={[]}
onCanvasMouseDown={() => {}}
onCanvasPointerMove={() => Promise.resolve(null)}
onCanvasPointerLeave={() => {}}
onSelectionChange={() => {}}
onBlockedMove={() => {}}
onPathOffsetCommit={() => {}}
onGroupPathOffsetCommit={() => {}}
onBoxSizeCommit={() => {}}
onRotationCommit={() => {}}
/>,
);
});
return {
host,
movedElement,
cleanup: () => {
act(() => root.unmount());
Element.prototype.getBoundingClientRect = originalGetBoundingClientRect;
iframe.remove();
host.remove();
},
};
}
describe("off-canvas indicator refresh", () => {
it("removes the indicator when an off-canvas element moves in-canvas (off->on)", async () => {
const h = mountOverlayWithHeadline(760);
try {
await act(async () => {
await flushAnimationFrames();
});
expect(h.host.querySelector(INDICATOR)).toBeTruthy();
act(() => {
h.movedElement.style.left = "120px";
});
await act(async () => {
await Promise.resolve();
await flushAnimationFrames();
});
expect(h.host.querySelector(INDICATOR)).toBeNull();
} finally {
h.cleanup();
}
});
it("tracks the indicator to the new position when it stays off-canvas (off->off)", async () => {
const h = mountOverlayWithHeadline(760);
try {
await act(async () => {
await flushAnimationFrames();
});
const before = h.host.querySelector(INDICATOR);
expect(before).toBeTruthy();
const leftBefore = (before!.parentElement as HTMLElement).style.left;
// Move further off-canvas (still outside the 800px-wide composition).
act(() => {
h.movedElement.style.left = "1200px";
});
await act(async () => {
await Promise.resolve();
await flushAnimationFrames();
});
const after = h.host.querySelector(INDICATOR);
expect(after).toBeTruthy();
const leftAfter = (after!.parentElement as HTMLElement).style.left;
expect(leftAfter).not.toEqual(leftBefore);
} finally {
h.cleanup();
}
});
});
@@ -0,0 +1,99 @@
import type React from "react";
import type { OffCanvasRect } from "./OffCanvasIndicators";
import { recomputeOffCanvasIndicators } from "./offCanvasIndicatorGeometry";
interface OffCanvasIndicatorRefreshOptions {
iframeRef: React.RefObject<HTMLIFrameElement | null>;
overlayRef: React.RefObject<HTMLDivElement | null>;
compRectRef: React.MutableRefObject<{ left: number; top: number; width: number; height: number }>;
activeCompositionPathRef: React.MutableRefObject<string | null>;
dirtyRef: React.MutableRefObject<boolean>;
sigRef: React.MutableRefObject<string>;
observerRef: React.MutableRefObject<MutationObserver | null>;
observedDocRef: React.MutableRefObject<Document | null>;
elementsRef: React.MutableRefObject<Map<string, HTMLElement>>;
setRects: (rects: OffCanvasRect[]) => void;
}
function compSignature(comp: { left: number; top: number; width: number; height: number }): string {
return `${Math.round(comp.left)}:${Math.round(comp.top)}:${Math.round(comp.width)}:${Math.round(comp.height)}`;
}
function clearIndicators(options: OffCanvasIndicatorRefreshOptions): void {
options.dirtyRef.current = false;
options.sigRef.current = "";
options.elementsRef.current = new Map();
options.setRects([]);
}
function observeDoc(doc: Document, markDirty: () => void): MutationObserver | null {
const Observer = doc.defaultView?.MutationObserver ?? globalThis.MutationObserver;
if (!Observer) return null;
const observer = new Observer(markDirty);
observer.observe(doc.documentElement, {
attributes: true,
// data-hidden is included explicitly: hiding an element writes data-hidden, and
// although the runtime honoring also writes display:"none" (a style mutation we'd
// catch anyway), keying on the attribute directly makes the coupling robust to any
// future throttling of that runtime sync.
attributeFilter: ["style", "class", "transform", "width", "height", "data-hidden"],
childList: true,
subtree: true,
});
return observer;
}
export function startOffCanvasIndicatorRefresh(
options: OffCanvasIndicatorRefreshOptions,
): () => void {
let frame = 0;
let lastCompSig = "";
const markDirty = () => {
options.dirtyRef.current = true;
};
const attachObserver = (doc: Document | null) => {
options.observerRef.current?.disconnect();
options.observerRef.current = doc?.documentElement ? observeDoc(doc, markDirty) : null;
options.observedDocRef.current = doc;
options.sigRef.current = "";
};
const update = () => {
frame = requestAnimationFrame(update);
const iframe = options.iframeRef.current;
const overlayEl = options.overlayRef.current;
const doc = iframe?.contentDocument ?? null;
if (doc !== options.observedDocRef.current) {
attachObserver(doc);
markDirty();
}
const comp = options.compRectRef.current;
const nextCompSig = compSignature(comp);
if (nextCompSig !== lastCompSig) {
lastCompSig = nextCompSig;
markDirty();
}
if (!iframe || !overlayEl) {
if (options.dirtyRef.current) clearIndicators(options);
return;
}
if (!options.dirtyRef.current) return;
options.dirtyRef.current = false;
recomputeOffCanvasIndicators(
iframe,
overlayEl,
doc,
comp,
options.activeCompositionPathRef.current,
options.sigRef,
options.elementsRef,
options.setRects,
);
};
frame = requestAnimationFrame(update);
return () => {
cancelAnimationFrame(frame);
options.observerRef.current?.disconnect();
options.observerRef.current = null;
options.observedDocRef.current = null;
};
}