mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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:
@@ -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;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user