Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 18 additions & 14 deletions packages/studio/src/components/editor/PropertyPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1001,20 +1001,24 @@ describe("PropertyPanel — Motion is for things that move", () => {
return element;
},
],
])("recognizes %s through the shared audio predicate", async (_label, makeElement) => {
const fixture = {
...audioClipElement(),
element: makeElement(),
tagName: "div",
};
const { host, root } = await renderPanel(true, fixture);
const titles = Array.from(
host.querySelectorAll<HTMLElement>("[data-flat-group-collapsed], [data-flat-group-open]"),
).map((node) => node.textContent ?? "");
expect(titles.some((title) => title.includes("Motion"))).toBe(false);
expect(titles.some((title) => title.includes("Timing"))).toBe(true);
act(() => root.unmount());
});
])(
"recognizes %s through the shared audio predicate",
async (_label, makeElement) => {
const fixture = {
...audioClipElement(),
element: makeElement(),
tagName: "div",
};
const { host, root } = await renderPanel(true, fixture);
const titles = Array.from(
host.querySelectorAll<HTMLElement>("[data-flat-group-collapsed], [data-flat-group-open]"),
).map((node) => node.textContent ?? "");
expect(titles.some((title) => title.includes("Motion"))).toBe(false);
expect(titles.some((title) => title.includes("Timing"))).toBe(true);
act(() => root.unmount());
},
RENDER_TIMEOUT_MS,
);

it(
"calls the section Timing on an audio clip, and offers no tween editor",
Expand Down
72 changes: 52 additions & 20 deletions packages/studio/src/components/editor/domEditOverlayGeometry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
import { isElementVisibleThroughAncestors } from "./domEditingDom";
import { hugRectForElement } from "./domEditOverlayCrop";
import { composeElementTransform, type PlanarTransformOps } from "./domEditOverlayTransform";
import { type OverlayMeasurePass, readThroughPass } from "./domEditOverlayMeasurePass";

export interface OverlayRect {
left: number;
Expand Down Expand Up @@ -52,18 +53,28 @@ export function isElementVisibleForOverlay(el: HTMLElement): boolean {
// shapes (rectangular cards, text, full-bleed media) don't have interior holes, so this
// doesn't bite. If ring/cutout shapes become editable targets, sample more densely or
// hit-test against the element's actual painted geometry instead of its bounding box.
function findSourceBoundary(element: HTMLElement): HTMLElement | null {
let current: HTMLElement | null = element;
while (current) {
if (
current.hasAttribute("data-composition-file") ||
current.hasAttribute("data-composition-src")
) {
return current;
const isSourceBoundary = (node: HTMLElement): boolean =>
node.hasAttribute("data-composition-file") || node.hasAttribute("data-composition-src");

/** With a `pass`, every node on the way up is memoized rather than only the
* element asked about: an element's boundary IS its parent's unless it is one
* itself, so siblings share the walk instead of each repeating it. */
function findSourceBoundary(element: HTMLElement, pass?: OverlayMeasurePass): HTMLElement | null {
const pending: HTMLElement[] = [];
let boundary: HTMLElement | null | undefined;
for (let node: HTMLElement | null = element; node; node = node.parentElement) {
boundary = pass?.sourceBoundary.get(node);
if (boundary !== undefined) break;
if (isSourceBoundary(node)) {
boundary = node;
pass?.sourceBoundary.set(node, node);
break;
}
current = current.parentElement;
pending.push(node);
}
return null;
const answer = boundary ?? null;
if (pass) for (const node of pending) pass.sourceBoundary.set(node, answer);
return answer;
}

export function resolveDomEditCoordinateScale(input: {
Expand Down Expand Up @@ -157,6 +168,7 @@ interface ElementTransformSnapshot {
function readElementTransformSnapshot(
win: Window,
element: HTMLElement,
pass?: OverlayMeasurePass,
): ElementTransformSnapshot | null {
const DOMMatrixCtor = (win as Window & typeof globalThis).DOMMatrix;
if (!DOMMatrixCtor) return null;
Expand All @@ -170,8 +182,11 @@ function readElementTransformSnapshot(
compose: (outer, inner) => outer.multiply(inner),
};
try {
const matrix = composeElementTransform(element, ops, (node) =>
node === element ? cs : win.getComputedStyle(node),
const matrix = composeElementTransform(
element,
ops,
(node) => (node === element ? cs : win.getComputedStyle(node)),
pass?.transform,
);
return matrix ? { matrix, cs } : null;
} catch {
Expand Down Expand Up @@ -211,15 +226,23 @@ function toOverlayRect(
iframe: HTMLIFrameElement,
element: HTMLElement,
precomputedScale?: OverlayRootScale | null,
pass?: OverlayMeasurePass,
): OverlayRect | null {
const scale =
precomputedScale ?? computeOverlayRootScale(overlayEl, iframe, iframe.contentDocument);
if (!scale) return null;
const { iframeRect, overlayRect, rootScaleX, rootScaleY } = scale;

const elementRect = element.getBoundingClientRect();
const sourceBoundary = findSourceBoundary(element);
const sourceBoundaryRect = sourceBoundary?.getBoundingClientRect();
const sourceBoundary = findSourceBoundary(element, pass);
// Every element inside one sub-composition shares this boundary, so its rect
// is one layout read per boundary rather than one per element.
const sourceBoundaryRect =
sourceBoundary && pass
? readThroughPass(pass.sourceBoundaryRect, sourceBoundary, () =>
sourceBoundary.getBoundingClientRect(),
)
: sourceBoundary?.getBoundingClientRect();
const editScale = resolveDomEditCoordinateScale({
rootScaleX,
rootScaleY,
Expand Down Expand Up @@ -363,15 +386,16 @@ export function orientedOverlayRect(
iframe: HTMLIFrameElement,
element: HTMLElement,
precomputedScale?: OverlayRootScale | null,
pass?: OverlayMeasurePass,
): OverlayRect | null {
const scale =
precomputedScale ?? computeOverlayRootScale(overlayEl, iframe, iframe.contentDocument);
if (!scale) return null;
const base = toOverlayRect(overlayEl, iframe, element, scale);
const base = toOverlayRect(overlayEl, iframe, element, scale, pass);
if (!base) return null;

const win = iframe.contentWindow;
const transform = win ? readElementTransformSnapshot(win, element) : null;
const transform = win ? readElementTransformSnapshot(win, element, pass) : null;
const angle = transform ? rotationDegreesFromMatrix(transform.matrix) : 0;
if (Math.abs(angle) < ROTATION_GATE_EPSILON_DEG) return base;

Expand Down Expand Up @@ -482,16 +506,23 @@ export function groupAwareOverlayRect(
iframe: HTMLIFrameElement,
el: HTMLElement,
precomputedScale?: OverlayRootScale | null,
pass?: OverlayMeasurePass,
): OverlayRect | null {
const rect = toOverlayRect(overlayEl, iframe, el, precomputedScale);
const rect = toOverlayRect(overlayEl, iframe, el, precomputedScale, pass);
if (!rect || !el.hasAttribute("data-hf-group")) return rect;
// Union the MEMBERS' rendered rects — where the content actually is — not the
// wrapper's own box. The wrapper is invisible and its box can sit apart from the
// members once they've been moved/transformed, which would otherwise drag the
// group's bounds (and its off-canvas marker) off to a stale position.
const rects: OverlayRect[] = [];
for (const child of Array.from(el.children)) {
const childRect = toOverlayRect(overlayEl, iframe, child as HTMLElement, precomputedScale);
const childRect = toOverlayRect(
overlayEl,
iframe,
child as HTMLElement,
precomputedScale,
pass,
);
if (childRect) rects.push(childRect);
}
const union = rects.length > 0 ? resolveDomEditGroupOverlayRect(rects) : null;
Expand Down Expand Up @@ -519,10 +550,11 @@ export function orientedGroupAwareOverlayRect(
iframe: HTMLIFrameElement,
el: HTMLElement,
precomputedScale?: OverlayRootScale | null,
pass?: OverlayMeasurePass,
): OverlayRect | null {
return el.hasAttribute("data-hf-group")
? groupAwareOverlayRect(overlayEl, iframe, el, precomputedScale)
: orientedOverlayRect(overlayEl, iframe, el, precomputedScale);
? groupAwareOverlayRect(overlayEl, iframe, el, precomputedScale, pass)
: orientedOverlayRect(overlayEl, iframe, el, precomputedScale, pass);
}

export function filterNestedDomEditGroupItems<T extends { element: HTMLElement }>(items: T[]): T[] {
Expand Down
60 changes: 60 additions & 0 deletions packages/studio/src/components/editor/domEditOverlayMeasurePass.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* Shared memory for ONE synchronous pass that measures many elements.
*
* The off-canvas indicator overlay re-measures every element in the preview
* several times a second, and almost all of that work is per-ANCESTOR, not
* per-element: a visibility read for every node up to the root, a transform
* composed over the same nodes, and a walk for the source-file boundary. Two
* siblings share their entire chain above themselves, so a preview of a
* thousand elements asked the platform the same questions about the same
* ancestors a thousand times.
*
* WHY THIS IS A PASS AND NOT A CACHE. A cache has to answer "what could have
* changed since last time", and for a MEASUREMENT the honest answer is
* "anything": an `<img>` finishing decode, a web font swapping in, a CSS
* transition frame, a container query, a `CSSStyleSheet.insertRule` — each one
* moves an element's box with nothing written to the DOM, so no mutation
* record exists to invalidate on and no observer reports all of them. A pass
* sidesteps the question instead of answering it wrong: it lives inside one
* synchronous measurement that only READS, so nothing can move under it, and
* it is dropped when the pass ends. Every rebuild still measures every
* element, exactly as it did before; it just stops asking the same question
* about the same ancestor once per descendant.
*
* Never store one of these across a rebuild, an await, or a frame.
*/

/** The composed transform type the overlay's corner math uses. */
type OverlayTransform = DOMMatrix;

export interface OverlayMeasurePass {
/** Does this node render, given everything above it? */
visible: Map<HTMLElement, boolean>;
/** This node's transform composed with every ancestor's, up to the
* composition root. `null` is an answer: some node's transform is
* unusable. */
transform: Map<HTMLElement, OverlayTransform | null>;
/** The nearest ancestor carrying a source-file boundary, or null. */
sourceBoundary: Map<HTMLElement, HTMLElement | null>;
/** That boundary's client rect, which is shared by everything inside it. */
sourceBoundaryRect: Map<HTMLElement, DOMRect>;
}

export function createOverlayMeasurePass(): OverlayMeasurePass {
return {
visible: new Map(),
transform: new Map(),
sourceBoundary: new Map(),
sourceBoundaryRect: new Map(),
};
}

/** Read through a pass's map, filling it on the way. `undefined` is the only
* miss, so a memoized `null` stays an answer. */
export function readThroughPass<K, V>(memo: Map<K, V>, key: K, compute: () => V): V {
const answered = memo.get(key);
if (answered !== undefined) return answered;
const value = compute();
memo.set(key, value);
return value;
}
56 changes: 47 additions & 9 deletions packages/studio/src/components/editor/domEditOverlayTransform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,22 @@ export function individualRotateDegrees(value: string | undefined): number {
return Number.isFinite(deg) ? deg : 0;
}

/** One node's own contribution, with the individual properties applied before
* `transform` the way CSS does. Null when the node's transform is unusable. */
function ownNodeTransform<M>(
node: HTMLElement,
ops: PlanarTransformOps<M>,
getStyle: (node: HTMLElement) => CSSStyleDeclaration | null,
): M | null {
const style = getStyle(node);
if (!style) return null;
const transform = style.transform;
const own = transform && transform !== "none" ? ops.fromTransform(transform) : ops.identity();
if (!own) return null;
const spin = individualRotateDegrees(style.rotate);
return spin === 0 ? own : ops.compose(ops.fromRotate(spin), own);
}

/**
* The element's transform composed with every ancestor's, up to the composition
* root.
Expand All @@ -66,23 +82,45 @@ export function individualRotateDegrees(value: string | undefined): number {
* `rotate` composes on the left of it. Between nodes, an ancestor applies
* outside its child. Null means some node's transform was unusable and the
* caller should fall back rather than guess.
*
* `memo` holds each node's COMPOSED chain, for a caller walking many elements
* in one synchronous pass.
*
* A chain is `chain(parent)` composed with the node's own, so siblings share
* everything above them and the whole tree costs one style read and one
* compose per node instead of one per node PER DESCENDANT. Valid only for the
* length of one pass, which is why the caller owns it: nothing here writes to
* the DOM, so nothing can move under it, and it is dropped before anything
* else runs. Omitted, every call composes its own chain from scratch.
*/
export function composeElementTransform<M>(
element: HTMLElement,
ops: PlanarTransformOps<M>,
getStyle: (node: HTMLElement) => CSSStyleDeclaration | null,
memo?: Map<HTMLElement, M | null>,
): M | null {
let acc = ops.identity();
const pending: HTMLElement[] = [];
// `undefined` means nothing on the way up was already composed, so the chain
// starts from identity. A memoized `null` is an answer, not a miss: some node
// above carries a transform this algebra cannot represent.
let above: M | null | undefined;
for (let node: HTMLElement | null = element; node; node = node.parentElement) {
const style = getStyle(node);
if (!style) return null;
const transform = style.transform;
let own = transform && transform !== "none" ? ops.fromTransform(transform) : ops.identity();
if (!own) return null;
const spin = individualRotateDegrees(style.rotate);
if (spin !== 0) own = ops.compose(ops.fromRotate(spin), own);
acc = ops.compose(own, acc);
above = memo?.get(node);
if (above !== undefined) break;
pending.push(node);
if (node.hasAttribute(COMPOSITION_ROOT_ATTR)) break;
}

let acc: M | null = above === undefined ? ops.identity() : above;
for (let i = pending.length - 1; i >= 0; i -= 1) {
const node = pending[i]!;
if (acc !== null) {
const own = ownNodeTransform(node, ops, getStyle);
// The ancestors' chain is the OUTER of the pair, as an ancestor applies
// around its child.
acc = own === null ? null : ops.compose(acc, own);
}
memo?.set(node, acc);
}
return acc;
}
Loading
Loading