Files
hyperframes/packages/studio/src/components/editor/offCanvasIndicatorRefresh.ts
T
Miguel Ángel 574da2b215 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.
2026-07-07 04:39:46 -04:00

100 lines
3.6 KiB
TypeScript

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;
};
}