mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
perf: sublinear Studio rebuilds and seeks (#3837)
* perf(studio): resolve the overlay coordinate basis once per composition
The iframe->overlay basis (composition root, root scale, iframe and overlay
rects) was resolved inside every geometry call, so measuring a preview cost one
querySelector("[data-composition-id]") plus three layout reads PER ELEMENT to
rediscover something that is a property of the composition and the canvas zoom.
It is now threaded through orientedGroupAwareOverlayRect, groupAwareOverlayRect,
orientedOverlayRect, orientedVisibleOverlayRect and toVisibleOverlayRect the way
toVisibleOverlayRects already batched it, and resolved once by the two callers
that measure many elements in one synchronous pass: the off-canvas indicator
rebuild and the overlay RAF loop.
Both passes only read the DOM, so nothing can move the canvas between two
measurements inside one of them.
* perf(studio): rebuild the layer walk from the mutation, not the document
A MutationObserver marked the off-canvas indicators dirty and the rebuild then
re-derived every element in the preview from scratch. The observer's loudest
source is inline style, which is what animation writes, so a composition just
sitting there re-derived the whole document several times a second, and each
element costs two getComputedStyle reads, two ancestor walks for its source
file, and a textContent read over its whole subtree.
The records now say WHAT to drop, not merely that something changed, and the
per-element derivations are memoized between rebuilds. Two lifetimes, because
they do not go stale together: whether an element renders (and how many layer
children it has) dies on any nearby attribute write, while how it is ADDRESSED
survives every style write and dies only on something that can renumber a
selector. That split is what makes an ordinary animation frame cost no document
queries at all.
Not attributable to specific elements, so still a full rebuild: nodes added or
removed, and any change to an identity attribute, which renumbers every element
sharing a selector.
Two supporting changes:
- getDirectLayerChildren asked getDomLayerPatchTarget for a full patch target
per child and used it as a boolean. The target carries the selector's
occurrence index, which is a whole-document query the yes/no does not depend
on. isDomLayerElement answers it without one, for every caller. Its unused
options parameter goes with it.
- The observer no longer filters attributes. An attribute it never hears about
is one the cache would answer stale for, and the old filter omitted id and the
data-composition-* attributes that decide a layer's identity. Widening only
makes indicators refresh sooner; a rebuild is a pure read and is throttled
either way.
Every other caller of collectDomEditLayerItems passes no cache and is unchanged.
* perf(runtime): visit only the clips a seek can flip
Every seek, and every frame of playback, rebuilt the window of every video and
audio element in the document and handed all of them to the per-clip sync loop.
On a composition of eighty videos that is eighty window derivations and eighty
per-clip passes to conclude that one clip is on screen.
A clip is active only while start <= t < end, so a clip whose window excludes
the new time is inactive there whatever its element state is. Sorting the
windows by each endpoint turns a seek into two binary searches: the clips whose
start or end lies between the old time and the new one, plus the ones that were
in window at the old time. Anything outside both was out of window before and is
out of window now, and was already paused and already evicted by the pass that
last saw it go out. A one-frame step visits the clip or two that flip; a jump
over a hundred clips still visits the hundred boundaries it crosses.
The index carries only the windows, and it rides the revision the duration
floors already ride: the same timing attributes, the same media metadata events,
the same timeline registry signature. Nothing else is cached. Every field handed
to syncRuntimeMedia is re-read from the element on every pass, because
el.duration can be reset under us by the load() retry, and a cached copy of it
would be exactly the stale duration the two-resolver-scope rule exists to
prevent.
The render/export path does not consult the index at all and visits every
element on every frame, and a render seek clears the sweep so a later live seek
cannot inherit a position it did not establish.
Also folds the duration floors' own invalidation into that shared revision.
Draining the observer is what detects a change, and only the first reader in a
task gets the records, so two caches each checking for themselves would have had
the second told nothing changed.
* refactor(studio): split the overlay basis and the layer-walk read into their own modules
Clears the 600-line file cap and the fallow audit on this branch. Behaviour is
unchanged: this moves code, it does not alter any of it.
File size. Both files were already near the cap on main and my three levers
pushed them over (615 vs 598, and 605 vs 584):
- domEditOverlayGeometry.ts -> 561. The iframe->overlay coordinate basis
(OverlayRootScale, computeOverlayRootScale and the root/dimension lookups it
owns) moves to domEditOverlayBasis.ts. It is the one piece of that file every
other piece depends on, and nothing in it is about a single element's
geometry. Its two callers now import it from there.
- domEditingLayers.ts -> 587. The per-element read that the walk performs moves
to readDomEditLayerWalkEntry in domEditLayerWalkCache.ts, the module that owns
the memoization it reads through. The walk keeps the traversal and the depth
bookkeeping, which are the parts that are actually about walking.
No unrelated code was trimmed to make room.
Duplication, all three clone groups fallow flagged:
- The runtime seek fixture (createMockTimeline, the synchronous animation-frame
clock, the CSS.escape shim, stubDuration) was copied between
init.timingResolver.test.ts and init.mediaClipIndex.test.ts. It moves to
runtimeSeekFixture.test-helpers.ts. Each suite keeps its own vi.mock calls,
which are file-scoped and cannot be shared. The file is excluded from
tsconfig.runtime.json alongside the test files it serves, for the same reason.
- domEditLayerWalkCache.test.ts repeated its mount/observe/first-walk setup in
three tests; that is now withWarmWalk.
Complexity. Only one of fallow's three findings is attributable to this branch,
and fallow agrees: it marks the other two inherited and excludes them from the
gate.
- offCanvasIndicatorRefresh.ts update was NEW (absent from main's report, 10
cyclomatic / 31.6 CRAP here). The optional-chain-and-default I added to drain
the observer becomes drainPendingLayerMutations, with its own unit tests, and
the function drops off the report entirely.
- useDomEditOverlayRects.ts update: 37 cyclomatic / 69 cognitive on main AND
here. My change added one statement and no branch; the function grew 139 -> 147
lines, which is what resurfaced it. Left alone.
- domEditingDom.ts escapeCssIdentifier: 24 cyclomatic / 19 cognitive / 148.4
CRAP on main AND here. Untouched by this branch; only its line number moved
(173 -> 182) because the composition-source-map revision counter sits above it.
Left alone.
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RuntimeTimelineLike } from "./types";
|
||||
import {
|
||||
createMockTimeline,
|
||||
installImmediateAnimationFrame,
|
||||
resetRuntimeFixtureDom,
|
||||
} from "./runtimeSeekFixture.test-helpers";
|
||||
|
||||
/**
|
||||
* `visits` counts the media elements handed to `syncRuntimeMedia` — the pass's
|
||||
* own definition of "visited", since every per-clip effect happens inside that
|
||||
* loop.
|
||||
*
|
||||
* `disableNarrowing` neutralises ONLY the index's element narrowing, leaving
|
||||
* every other line of the runtime identical. That is what makes the cross-check
|
||||
* below a comparison of the indexed path against the full-visit path, rather
|
||||
* than a comparison of two differently-configured runtimes.
|
||||
*/
|
||||
const mediaSpy = vi.hoisted(() => ({ visits: [] as number[], disableNarrowing: false }));
|
||||
|
||||
vi.mock("./media", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./media")>();
|
||||
return {
|
||||
...actual,
|
||||
refreshRuntimeMediaCache: (params?: Parameters<typeof actual.refreshRuntimeMediaCache>[0]) =>
|
||||
actual.refreshRuntimeMediaCache(
|
||||
mediaSpy.disableNarrowing && params ? { ...params, elements: undefined } : params,
|
||||
),
|
||||
syncRuntimeMedia: (params: Parameters<typeof actual.syncRuntimeMedia>[0]) => {
|
||||
mediaSpy.visits.push(params.clips.length);
|
||||
return actual.syncRuntimeMedia(params);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const { initSandboxRuntimeModular } = await import("./init");
|
||||
|
||||
/**
|
||||
* `count` back-to-back one-second clips, so the transport crosses exactly one
|
||||
* start and one end per second of travel and the flip set is knowable without
|
||||
* reproducing the index's arithmetic.
|
||||
*/
|
||||
function mountClips(count: number): void {
|
||||
const clips = Array.from(
|
||||
{ length: count },
|
||||
(_unused, i) => `<video id="clip${i}" data-start="${i}" data-duration="1"></video>`,
|
||||
).join("");
|
||||
document.body.innerHTML = `<div data-composition-id="main" data-root="true" data-start="0">${clips}</div>`;
|
||||
for (const video of document.querySelectorAll("video")) {
|
||||
Object.defineProperty(video, "duration", { value: 1, configurable: true });
|
||||
}
|
||||
window.__timelines = { main: createMockTimeline(count) };
|
||||
}
|
||||
|
||||
/** Every observable the media pass can write, for every element. Compared
|
||||
* between the two arms instead of re-deriving what each should have been. */
|
||||
function mediaState(): Array<Record<string, unknown>> {
|
||||
return Array.from(document.querySelectorAll("video, audio")).map((raw) => {
|
||||
const el = raw as HTMLMediaElement;
|
||||
return {
|
||||
id: el.id,
|
||||
paused: el.paused,
|
||||
currentTime: el.currentTime,
|
||||
volume: el.volume,
|
||||
muted: el.muted,
|
||||
visibility: (el as HTMLElement).style.visibility,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
describe("per-seek media clip index", () => {
|
||||
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||
const originalCancelAnimationFrame = window.cancelAnimationFrame;
|
||||
|
||||
beforeEach(() => {
|
||||
resetRuntimeFixtureDom();
|
||||
installImmediateAnimationFrame();
|
||||
mediaSpy.visits = [];
|
||||
mediaSpy.disableNarrowing = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.__hfRuntimeTeardown?.();
|
||||
window.requestAnimationFrame = originalRequestAnimationFrame;
|
||||
window.cancelAnimationFrame = originalCancelAnimationFrame;
|
||||
document.body.innerHTML = "";
|
||||
window.__timelines = {} as Record<string, RuntimeTimelineLike>;
|
||||
delete window.__player;
|
||||
delete window.__playerReady;
|
||||
delete window.__renderReady;
|
||||
mediaSpy.disableNarrowing = false;
|
||||
});
|
||||
|
||||
/** Elements visited by ONE single-frame seek, after the transport has already
|
||||
* settled at a time (so the sweep has a previous position to work from). */
|
||||
function visitsForOneFrameSeek(clipCount: number): number {
|
||||
mountClips(clipCount);
|
||||
initSandboxRuntimeModular();
|
||||
window.__player!.seek(3.0);
|
||||
mediaSpy.visits = [];
|
||||
window.__player!.seek(3.02);
|
||||
expect(mediaSpy.visits).toHaveLength(1);
|
||||
return mediaSpy.visits[0]!;
|
||||
}
|
||||
|
||||
/**
|
||||
* The load-bearing assertion, and it is INVARIANCE, not a threshold. A
|
||||
* threshold ("visits fewer than 20 elements") passes on a small fixture while
|
||||
* a real composition of eighty videos still visits eighty. Quadrupling the
|
||||
* clip count must not change what one frame costs at all.
|
||||
*
|
||||
* Before the index, both numbers were the clip count: every seek rebuilt the
|
||||
* window of every media element in the document and handed all of them to the
|
||||
* per-clip loop.
|
||||
*/
|
||||
it("visits the same number of clips for a one-frame seek at n clips and at 4n", () => {
|
||||
const few = visitsForOneFrameSeek(8);
|
||||
const many = visitsForOneFrameSeek(32);
|
||||
|
||||
expect(many).toBe(few);
|
||||
// A ceiling too: within one frame at t=3.0 only the clip covering [3,4) is
|
||||
// in window and no boundary is crossed, so this is the active set alone.
|
||||
expect(many).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("still visits every clip whose window a long jump crosses", () => {
|
||||
mountClips(32);
|
||||
initSandboxRuntimeModular();
|
||||
window.__player!.seek(1.0);
|
||||
mediaSpy.visits = [];
|
||||
// Twenty seconds of travel crosses twenty starts and twenty ends. The point
|
||||
// is that the index does not silently skip them — a sublinear seek that
|
||||
// under-visits leaves elements playing.
|
||||
window.__player!.seek(21.0);
|
||||
expect(mediaSpy.visits[0]!).toBeGreaterThanOrEqual(20);
|
||||
});
|
||||
|
||||
/**
|
||||
* The equivalence half, and the one that catches a wrong optimisation rather
|
||||
* than a missing one. Random seek sequences, and after every single seek the
|
||||
* indexed runtime's media state must equal the full-visit runtime's — the
|
||||
* same code with only the narrowing turned off.
|
||||
*
|
||||
* Deterministic: a fixed seed, so a failure is reproducible rather than a
|
||||
* flake someone reruns until it passes.
|
||||
*/
|
||||
it("leaves every media element in the same state as a full-visit pass", () => {
|
||||
let seed = 0x5eed;
|
||||
const random = () => {
|
||||
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
||||
return seed / 0x7fffffff;
|
||||
};
|
||||
|
||||
const runSequence = (times: number[], disableNarrowing: boolean) => {
|
||||
mediaSpy.disableNarrowing = disableNarrowing;
|
||||
mountClips(12);
|
||||
initSandboxRuntimeModular();
|
||||
const states: Array<Array<Record<string, unknown>>> = [];
|
||||
for (const time of times) {
|
||||
window.__player!.seek(time);
|
||||
states.push(mediaState());
|
||||
}
|
||||
window.__hfRuntimeTeardown?.();
|
||||
document.body.innerHTML = "";
|
||||
return states;
|
||||
};
|
||||
|
||||
for (let sequence = 0; sequence < 4; sequence += 1) {
|
||||
// A mix of single frames, mid-clip nudges and long jumps in both
|
||||
// directions — the shapes that decide whether the sweep is right.
|
||||
const times = Array.from({ length: 16 }, () => Math.round(random() * 1200) / 100);
|
||||
expect(runSequence(times, false), `seek sequence ${sequence}`).toEqual(
|
||||
runSequence(times, true),
|
||||
);
|
||||
}
|
||||
// Two full runtimes per sequence, and under coverage instrumentation that
|
||||
// runs well past the 5s default. An explicit budget, not a global bump.
|
||||
}, 30_000);
|
||||
|
||||
/**
|
||||
* The render path must not consult the index at all: capture depends on the
|
||||
* exact state of every element on every frame, and a frame captured against a
|
||||
* stale one cannot be recovered. Same latch, and the same reasoning, as the
|
||||
* duration-floor cache.
|
||||
*/
|
||||
it("visits every clip on a render-capture seek, however small the step", () => {
|
||||
mountClips(16);
|
||||
initSandboxRuntimeModular();
|
||||
window.__player!.renderSeek(3.0);
|
||||
mediaSpy.visits = [];
|
||||
window.__player!.renderSeek(3.02);
|
||||
|
||||
expect(mediaSpy.visits[0]!).toBe(16);
|
||||
});
|
||||
|
||||
it("reseeds after a render seek, so a later live seek cannot trust a stale sweep", () => {
|
||||
mountClips(16);
|
||||
initSandboxRuntimeModular();
|
||||
window.__player!.renderSeek(3.0);
|
||||
mediaSpy.visits = [];
|
||||
// The sweep has no trustworthy previous position, so this visits everything
|
||||
// rather than assuming the render pass left it one.
|
||||
window.__player!.seek(3.02);
|
||||
expect(mediaSpy.visits[0]!).toBe(16);
|
||||
});
|
||||
|
||||
it("re-derives the index when a clip is moved on the timeline", () => {
|
||||
mountClips(16);
|
||||
initSandboxRuntimeModular();
|
||||
window.__player!.seek(3.0);
|
||||
|
||||
// clip15 lived at [15,16). Moved onto the playhead it must become active,
|
||||
// which it cannot if the index still holds its old window.
|
||||
document.querySelector("#clip15")!.setAttribute("data-start", "3");
|
||||
mediaSpy.visits = [];
|
||||
window.__player!.seek(3.01);
|
||||
|
||||
expect(document.querySelector<HTMLVideoElement>("#clip15")!.style.visibility).not.toBe(
|
||||
"hidden",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RuntimeTimelineLike } from "./types";
|
||||
import {
|
||||
createMockTimeline,
|
||||
installImmediateAnimationFrame,
|
||||
resetRuntimeFixtureDom,
|
||||
stubDuration,
|
||||
} from "./runtimeSeekFixture.test-helpers";
|
||||
|
||||
const resolverSpy = vi.hoisted(() => ({ constructions: 0 }));
|
||||
const mediaSpy = vi.hoisted(() => ({ beforeSync: null as null | (() => void) }));
|
||||
@@ -30,49 +35,13 @@ vi.mock("./media", async (importOriginal) => {
|
||||
|
||||
const { initSandboxRuntimeModular } = await import("./init");
|
||||
|
||||
function createMockTimeline(duration: number): RuntimeTimelineLike {
|
||||
const state = { time: 0, paused: true };
|
||||
return {
|
||||
play: () => {
|
||||
state.paused = false;
|
||||
},
|
||||
pause: () => {
|
||||
state.paused = true;
|
||||
},
|
||||
seek: (time?: number) => (time === undefined ? state.time : (state.time = time)),
|
||||
totalTime: (time?: number) => (time === undefined ? state.time : (state.time = time)),
|
||||
time: () => state.time,
|
||||
duration: () => duration,
|
||||
add: () => {},
|
||||
paused: (value?: boolean) =>
|
||||
typeof value === "boolean" ? (state.paused = value) : state.paused,
|
||||
timeScale: () => {},
|
||||
set: () => {},
|
||||
getChildren: () => [],
|
||||
};
|
||||
}
|
||||
|
||||
/** jsdom leaves `duration` as NaN; a writable getter also lets a test mimic the
|
||||
* `el.load()` reset `syncRuntimeMedia` performs on the seek-retry path. */
|
||||
function stubDuration(el: HTMLMediaElement, initial: number): { set: (next: number) => void } {
|
||||
let value = initial;
|
||||
Object.defineProperty(el, "duration", { get: () => value, configurable: true });
|
||||
return { set: (next: number) => (value = next) };
|
||||
}
|
||||
|
||||
describe("runtime timing resolver scoping", () => {
|
||||
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||
const originalCancelAnimationFrame = window.cancelAnimationFrame;
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
(globalThis as typeof globalThis & { CSS?: { escape?: (value: string) => string } }).CSS ??= {};
|
||||
globalThis.CSS.escape ??= (value: string) => value;
|
||||
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
}) as typeof window.requestAnimationFrame;
|
||||
window.cancelAnimationFrame = (() => {}) as typeof window.cancelAnimationFrame;
|
||||
resetRuntimeFixtureDom();
|
||||
installImmediateAnimationFrame();
|
||||
resolverSpy.constructions = 0;
|
||||
mediaSpy.beforeSync = null;
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
refreshRuntimeMediaCache,
|
||||
resolveRuntimeMediaClipDuration,
|
||||
resolveNaturalMediaTimelineDuration,
|
||||
type RuntimeMediaClip,
|
||||
syncRuntimeMedia,
|
||||
} from "./media";
|
||||
import { handleErrorForProxy, handleMetadataForProxy, maybeProxyProactively } from "./mediaProxy";
|
||||
@@ -975,13 +976,21 @@ export function initSandboxRuntimeModular(): void {
|
||||
"id",
|
||||
"src",
|
||||
];
|
||||
const DURATION_FLOOR_MEDIA_EVENTS = ["loadedmetadata", "durationchange", "emptied"] as const;
|
||||
const COMPOSITION_TIMING_MEDIA_EVENTS = ["loadedmetadata", "durationchange", "emptied"] as const;
|
||||
let durationFloorsCache: DurationFloors | null = null;
|
||||
let durationFloorsRegistrySignature: string | null = null;
|
||||
let durationFloorsObserver: MutationObserver | null = null;
|
||||
let durationFloorsRevision = -1;
|
||||
let timelineRegistrySignature: string | null = null;
|
||||
let compositionTimingObserver: MutationObserver | null = null;
|
||||
|
||||
const invalidateDurationFloors = () => {
|
||||
durationFloorsCache = null;
|
||||
// Bumped whenever anything above can have moved a clip's window. Two caches
|
||||
// read it: the duration floors, and the media clip index below. It is a
|
||||
// counter rather than a per-cache flag because DRAINING the observer is what
|
||||
// detects a change, and only the first reader in a task gets the records — a
|
||||
// second cache checking for itself would be told nothing changed.
|
||||
let compositionTimingRevision = 0;
|
||||
|
||||
const invalidateCompositionTimingCaches = () => {
|
||||
compositionTimingRevision += 1;
|
||||
};
|
||||
|
||||
const deriveDurationFloors = (): DurationFloors => ({
|
||||
@@ -1000,10 +1009,10 @@ export function initSandboxRuntimeModular(): void {
|
||||
return signature;
|
||||
};
|
||||
|
||||
const watchDurationFloorInputs = () => {
|
||||
if (durationFloorsObserver || typeof MutationObserver === "undefined") return;
|
||||
durationFloorsObserver = new MutationObserver(invalidateDurationFloors);
|
||||
durationFloorsObserver.observe(document.documentElement, {
|
||||
const watchCompositionTimingInputs = () => {
|
||||
if (compositionTimingObserver || typeof MutationObserver === "undefined") return;
|
||||
compositionTimingObserver = new MutationObserver(invalidateCompositionTimingCaches);
|
||||
compositionTimingObserver.observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
@@ -1012,40 +1021,55 @@ export function initSandboxRuntimeModular(): void {
|
||||
// Media duration changes are published as events, not DOM mutations, and
|
||||
// they do not bubble — so listen in the capture phase, which reaches every
|
||||
// media element including ones added long after this binds.
|
||||
for (const eventType of DURATION_FLOOR_MEDIA_EVENTS) {
|
||||
document.addEventListener(eventType, invalidateDurationFloors, true);
|
||||
for (const eventType of COMPOSITION_TIMING_MEDIA_EVENTS) {
|
||||
document.addEventListener(eventType, invalidateCompositionTimingCaches, true);
|
||||
}
|
||||
runtimeCleanupCallbacks.push(() => {
|
||||
durationFloorsObserver?.disconnect();
|
||||
durationFloorsObserver = null;
|
||||
invalidateDurationFloors();
|
||||
for (const eventType of DURATION_FLOOR_MEDIA_EVENTS) {
|
||||
document.removeEventListener(eventType, invalidateDurationFloors, true);
|
||||
compositionTimingObserver?.disconnect();
|
||||
compositionTimingObserver = null;
|
||||
invalidateCompositionTimingCaches();
|
||||
for (const eventType of COMPOSITION_TIMING_MEDIA_EVENTS) {
|
||||
document.removeEventListener(eventType, invalidateCompositionTimingCaches, true);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* The current revision, after taking every signal that could have moved it.
|
||||
* Call this exactly once per read, and compare it with the revision a cache
|
||||
* was built at.
|
||||
*/
|
||||
const readCompositionTimingRevision = (): number => {
|
||||
watchCompositionTimingInputs();
|
||||
// MutationObserver records are delivered in a microtask, so a caller that
|
||||
// edits a timing attribute and reads a window back in the SAME synchronous
|
||||
// block would otherwise be served the pre-edit value. Draining the queue
|
||||
// here makes the caches correct within a task, not just across tasks.
|
||||
// Taking the records suppresses the callback for them, which is exactly
|
||||
// equivalent since the callback only bumps the revision.
|
||||
if (compositionTimingObserver && compositionTimingObserver.takeRecords().length > 0) {
|
||||
invalidateCompositionTimingCaches();
|
||||
}
|
||||
// `window.__timelines` is a plain object: no MutationObserver and no event
|
||||
// can see a composition script registering into it or lengthening what it
|
||||
// already registered, so its shape is compared on every read.
|
||||
const signature = readTimelineRegistrySignature();
|
||||
if (signature !== timelineRegistrySignature) {
|
||||
timelineRegistrySignature = signature;
|
||||
invalidateCompositionTimingCaches();
|
||||
}
|
||||
return compositionTimingRevision;
|
||||
};
|
||||
|
||||
const resolveDurationFloors = (): DurationFloors => {
|
||||
// The render path never reads a cached floor. Capture depends on the exact
|
||||
// duration and a frame that rendered against a wrong one cannot be
|
||||
// recovered, so it pays the full derivation on every frame exactly as
|
||||
// before — a correct slow render beats a fast wrong one.
|
||||
if (renderCaptureSeekStarted) return deriveDurationFloors();
|
||||
watchDurationFloorInputs();
|
||||
// MutationObserver records are delivered in a microtask, so a caller that
|
||||
// edits a timing attribute and reads the duration back in the SAME
|
||||
// synchronous block would otherwise be served the pre-edit value. Draining
|
||||
// the queue here makes the cache correct within a task, not just across
|
||||
// tasks. Taking the records suppresses the callback for them, which is
|
||||
// exactly equivalent since the callback only marks the cache stale.
|
||||
if (durationFloorsObserver && durationFloorsObserver.takeRecords().length > 0) {
|
||||
invalidateDurationFloors();
|
||||
}
|
||||
const registrySignature = readTimelineRegistrySignature();
|
||||
if (durationFloorsCache && registrySignature === durationFloorsRegistrySignature) {
|
||||
return durationFloorsCache;
|
||||
}
|
||||
durationFloorsRegistrySignature = registrySignature;
|
||||
const revision = readCompositionTimingRevision();
|
||||
if (durationFloorsCache && durationFloorsRevision === revision) return durationFloorsCache;
|
||||
durationFloorsRevision = revision;
|
||||
durationFloorsCache = deriveDurationFloors();
|
||||
return durationFloorsCache;
|
||||
};
|
||||
@@ -2304,48 +2328,162 @@ export function initSandboxRuntimeModular(): void {
|
||||
visibilityNodes: Element[] = Array.from(document.querySelectorAll("[data-start]")),
|
||||
) => withTimingResolver(() => applyTimedElementVisibility(currentTime, visibilityNodes));
|
||||
|
||||
/**
|
||||
* Clip windows sorted by each endpoint, so a seek can ask which windows it
|
||||
* crossed instead of asking every clip whether it is active.
|
||||
*
|
||||
* Rebuilt whenever the composition's timing inputs move — the same revision
|
||||
* the duration floors ride on, because the two are derived from the same
|
||||
* attributes, the same media metadata events and the same timeline registry.
|
||||
*/
|
||||
interface MediaClipIndex {
|
||||
revision: number;
|
||||
clips: RuntimeMediaClip[];
|
||||
byStart: RuntimeMediaClip[];
|
||||
byEnd: RuntimeMediaClip[];
|
||||
}
|
||||
let mediaClipIndex: MediaClipIndex | null = null;
|
||||
// The transport time the last sync ran at, and the clips whose authored window
|
||||
// contained it. `null` means "unknown": the next pass visits everything and
|
||||
// reseeds. Only ever advanced when `syncRuntimeMedia` actually ran, so a pass
|
||||
// skipped for any reason widens the next sweep rather than skipping the
|
||||
// boundaries crossed in between.
|
||||
let lastSyncedMediaTimeSeconds: number | null = null;
|
||||
let mediaClipsInWindow: RuntimeMediaClip[] = [];
|
||||
|
||||
const buildRuntimeMediaCache = (elements?: Array<HTMLVideoElement | HTMLAudioElement>) =>
|
||||
refreshRuntimeMediaCache({
|
||||
elements,
|
||||
shouldIncludeElement: (element) =>
|
||||
element.hasAttribute("data-start") ||
|
||||
Boolean(resolveMediaCompositionContext(element).compositionRoot),
|
||||
resolveStartSeconds: (element) => {
|
||||
return resolveAbsoluteMediaStartSeconds(element);
|
||||
},
|
||||
resolveDurationSeconds: (element) => {
|
||||
const context = resolveMediaCompositionContext(element);
|
||||
const start = resolveAbsoluteMediaStartSeconds(element);
|
||||
const hostRemaining =
|
||||
context.inheritedStart != null &&
|
||||
context.inheritedDuration != null &&
|
||||
context.inheritedDuration > 0
|
||||
? Math.max(0, context.inheritedStart + context.inheritedDuration - start)
|
||||
: null;
|
||||
const sourceDuration = Number.isFinite(element.duration)
|
||||
? resolveNaturalMediaTimelineDuration(element, element.duration)
|
||||
: null;
|
||||
// The element's own data-duration is an explicit clip-length trim
|
||||
// (the studio writes it when you drag the clip edge). It must bound
|
||||
// playback so a trimmed track stops at its edge instead of running on
|
||||
// to the source-file or host-composition end. Absent → no cap (an
|
||||
// untrimmed clip plays its natural source length).
|
||||
const ownDuration = parseStrictFiniteTimingNumber(element.dataset.duration);
|
||||
const explicitDuration = ownDuration != null && ownDuration > 0 ? ownDuration : null;
|
||||
return resolveRuntimeMediaClipDuration({
|
||||
isVideo: element.tagName === "VIDEO",
|
||||
sourceDuration,
|
||||
hostRemaining,
|
||||
explicitDuration,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const resolveMediaClipIndex = (): MediaClipIndex => {
|
||||
const revision = readCompositionTimingRevision();
|
||||
if (mediaClipIndex && mediaClipIndex.revision === revision) return mediaClipIndex;
|
||||
const clips = buildRuntimeMediaCache().mediaClips;
|
||||
mediaClipIndex = {
|
||||
revision,
|
||||
clips,
|
||||
byStart: [...clips].sort((a, b) => a.start - b.start),
|
||||
byEnd: [...clips].sort((a, b) => a.end - b.end),
|
||||
};
|
||||
// The windows moved, so what the sweep remembers being in-window is about
|
||||
// clips that no longer describe this composition. Reseed on the next pass.
|
||||
lastSyncedMediaTimeSeconds = null;
|
||||
mediaClipsInWindow = [];
|
||||
return mediaClipIndex;
|
||||
};
|
||||
|
||||
/** Clips whose `endpoint` lies in [lo, hi], via binary search on the sorted
|
||||
* array. Inclusive at both ends: over-visiting is free, under-visiting is a
|
||||
* clip left playing. */
|
||||
const clipsWithEndpointBetween = (
|
||||
sorted: RuntimeMediaClip[],
|
||||
endpoint: (clip: RuntimeMediaClip) => number,
|
||||
lo: number,
|
||||
hi: number,
|
||||
): RuntimeMediaClip[] => {
|
||||
let low = 0;
|
||||
let high = sorted.length;
|
||||
while (low < high) {
|
||||
const mid = (low + high) >> 1;
|
||||
if (endpoint(sorted[mid]!) < lo) low = mid + 1;
|
||||
else high = mid;
|
||||
}
|
||||
const crossed: RuntimeMediaClip[] = [];
|
||||
for (let i = low; i < sorted.length && endpoint(sorted[i]!) <= hi; i += 1)
|
||||
crossed.push(sorted[i]!);
|
||||
return crossed;
|
||||
};
|
||||
|
||||
/**
|
||||
* The media elements this pass must visit, and the argument that the rest can
|
||||
* be skipped.
|
||||
*
|
||||
* `isActive` requires `start <= t < end`, so a clip whose window excludes the
|
||||
* new time is inactive there no matter what its element state is. A clip that
|
||||
* is in neither the previous in-window set nor the set of windows whose
|
||||
* endpoint the transport just crossed was therefore out of window BEFORE and
|
||||
* is out of window NOW — the pass would only have evicted sync state that is
|
||||
* already empty and paused an element already paused, both of which it was
|
||||
* left in the last time it went out of window, by a pass that did visit it.
|
||||
*
|
||||
* The endpoint search is what makes a jump honest rather than lucky: seeking
|
||||
* over a hundred clips visits the hundred boundaries it crossed, and a
|
||||
* single-frame step visits the one or two that flip.
|
||||
*/
|
||||
const collectMediaElementsToVisit = (
|
||||
index: MediaClipIndex,
|
||||
toSeconds: number,
|
||||
): Array<HTMLVideoElement | HTMLAudioElement> => {
|
||||
const fromSeconds = lastSyncedMediaTimeSeconds;
|
||||
if (fromSeconds === null) return index.clips.map((clip) => clip.el);
|
||||
const lo = Math.min(fromSeconds, toSeconds);
|
||||
const hi = Math.max(fromSeconds, toSeconds);
|
||||
const visiting = new Set<HTMLVideoElement | HTMLAudioElement>();
|
||||
for (const clip of mediaClipsInWindow) visiting.add(clip.el);
|
||||
for (const clip of clipsWithEndpointBetween(index.byStart, (c) => c.start, lo, hi)) {
|
||||
visiting.add(clip.el);
|
||||
}
|
||||
for (const clip of clipsWithEndpointBetween(index.byEnd, (c) => c.end, lo, hi)) {
|
||||
visiting.add(clip.el);
|
||||
}
|
||||
return [...visiting];
|
||||
};
|
||||
|
||||
const syncMediaForCurrentState = () => {
|
||||
// Scope 1 of 3 (see `withTimingResolver`). Closes before `syncRuntimeMedia`,
|
||||
// which may call `el.load()` and invalidate every cached duration.
|
||||
const cache = withTimingResolver(() =>
|
||||
refreshRuntimeMediaCache({
|
||||
shouldIncludeElement: (element) =>
|
||||
element.hasAttribute("data-start") ||
|
||||
Boolean(resolveMediaCompositionContext(element).compositionRoot),
|
||||
resolveStartSeconds: (element) => {
|
||||
return resolveAbsoluteMediaStartSeconds(element);
|
||||
},
|
||||
resolveDurationSeconds: (element) => {
|
||||
const context = resolveMediaCompositionContext(element);
|
||||
const start = resolveAbsoluteMediaStartSeconds(element);
|
||||
const hostRemaining =
|
||||
context.inheritedStart != null &&
|
||||
context.inheritedDuration != null &&
|
||||
context.inheritedDuration > 0
|
||||
? Math.max(0, context.inheritedStart + context.inheritedDuration - start)
|
||||
: null;
|
||||
const sourceDuration = Number.isFinite(element.duration)
|
||||
? resolveNaturalMediaTimelineDuration(element, element.duration)
|
||||
: null;
|
||||
// The element's own data-duration is an explicit clip-length trim
|
||||
// (the studio writes it when you drag the clip edge). It must bound
|
||||
// playback so a trimmed track stops at its edge instead of running on
|
||||
// to the source-file or host-composition end. Absent → no cap (an
|
||||
// untrimmed clip plays its natural source length).
|
||||
const ownDuration = parseStrictFiniteTimingNumber(element.dataset.duration);
|
||||
const explicitDuration = ownDuration != null && ownDuration > 0 ? ownDuration : null;
|
||||
return resolveRuntimeMediaClipDuration({
|
||||
isVideo: element.tagName === "VIDEO",
|
||||
sourceDuration,
|
||||
hostRemaining,
|
||||
explicitDuration,
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
//
|
||||
// The render/export path never consults the index: capture depends on the
|
||||
// exact state of every element on every frame and a frame captured against a
|
||||
// stale one cannot be recovered, so it visits all of them exactly as before.
|
||||
// Same reasoning, and the same latch, as the duration floors above.
|
||||
const indexed = !renderCaptureSeekStarted;
|
||||
const mediaClips = withTimingResolver(() => {
|
||||
if (!indexed) return buildRuntimeMediaCache().mediaClips;
|
||||
const index = resolveMediaClipIndex();
|
||||
// Windows come from the index, but every field handed to `syncRuntimeMedia`
|
||||
// is re-read here: `el.duration` is reset by any `el.load()` the retry path
|
||||
// made last pass, and a cached copy of it would be exactly the stale
|
||||
// duration the two-scope rule exists to prevent.
|
||||
return buildRuntimeMediaCache(collectMediaElementsToVisit(index, state.currentTime))
|
||||
.mediaClips;
|
||||
});
|
||||
// Attach probed volume keyframes to clips so syncRuntimeMedia can use the
|
||||
// same envelope the renderer uses instead of tracking GSAP-change diffs.
|
||||
for (const clip of cache.mediaClips) {
|
||||
for (const clip of mediaClips) {
|
||||
const kf = volumeKeyframeCache.get(clip.el as HTMLMediaElement);
|
||||
if (kf) clip.volumeKeyframes = kf;
|
||||
}
|
||||
@@ -2354,7 +2492,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
if (forceSync) state.mediaForceSyncNextTick = false;
|
||||
if (!state.nativeMediaSyncDisabled) {
|
||||
syncRuntimeMedia({
|
||||
clips: cache.mediaClips,
|
||||
clips: mediaClips,
|
||||
timeSeconds: state.currentTime,
|
||||
playing: state.isPlaying,
|
||||
playbackRate: state.playbackRate,
|
||||
@@ -2372,6 +2510,17 @@ export function initSandboxRuntimeModular(): void {
|
||||
postRuntimeMessage({ source: "hf-preview", type: "media-autoplay-blocked" });
|
||||
},
|
||||
});
|
||||
if (indexed) {
|
||||
lastSyncedMediaTimeSeconds = state.currentTime;
|
||||
// Every clip not visited was out of window at both ends of this seek, so
|
||||
// the visited ones are the only possible members.
|
||||
mediaClipsInWindow = mediaClips.filter(
|
||||
(clip) => state.currentTime >= clip.start && state.currentTime < clip.end,
|
||||
);
|
||||
} else {
|
||||
lastSyncedMediaTimeSeconds = null;
|
||||
mediaClipsInWindow = [];
|
||||
}
|
||||
}
|
||||
syncTimedElementVisibility(state.currentTime);
|
||||
};
|
||||
|
||||
@@ -59,15 +59,28 @@ export function refreshRuntimeMediaCache(params?: {
|
||||
resolveStartSeconds?: (element: Element) => number;
|
||||
resolveDurationSeconds?: (element: HTMLVideoElement | HTMLAudioElement) => number | null;
|
||||
shouldIncludeElement?: (element: HTMLVideoElement | HTMLAudioElement) => boolean;
|
||||
/**
|
||||
* Build clips for exactly these elements instead of scanning the document for
|
||||
* them. For a caller that already knows which media it needs this pass — the
|
||||
* per-seek sync only touches the clips whose active state can change — and so
|
||||
* should not pay to re-derive the window of every clip in the composition.
|
||||
*
|
||||
* Every field is still read FRESH from the element. This narrows which
|
||||
* elements are visited, never how current the answer is: `el.duration` can be
|
||||
* reset under the caller by an `el.load()` it did not make.
|
||||
*/
|
||||
elements?: Array<HTMLVideoElement | HTMLAudioElement>;
|
||||
}): {
|
||||
timedMediaEls: Array<HTMLVideoElement | HTMLAudioElement>;
|
||||
mediaClips: RuntimeMediaClip[];
|
||||
videoClips: RuntimeMediaClip[];
|
||||
maxMediaEnd: number;
|
||||
} {
|
||||
const mediaEls = Array.from(document.querySelectorAll("video, audio")) as Array<
|
||||
HTMLVideoElement | HTMLAudioElement
|
||||
>;
|
||||
const mediaEls =
|
||||
params?.elements ??
|
||||
(Array.from(document.querySelectorAll("video, audio")) as Array<
|
||||
HTMLVideoElement | HTMLAudioElement
|
||||
>);
|
||||
const timedMediaEls = params?.shouldIncludeElement
|
||||
? mediaEls.filter((el) => params.shouldIncludeElement?.(el))
|
||||
: mediaEls.filter((el) => el.hasAttribute("data-start"));
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Shared fixture for the runtime suites that drive real seeks through
|
||||
* `initSandboxRuntimeModular` — the timing-resolver scoping suite and the media
|
||||
* clip index suite.
|
||||
*
|
||||
* Not a `.test.ts` file on purpose: vitest's `include` is `src/**\/*.test.ts`,
|
||||
* so this is imported, never collected. Each suite keeps its own `vi.mock`
|
||||
* calls, which are file-scoped and cannot be shared.
|
||||
*/
|
||||
import type { RuntimeTimelineLike } from "./types";
|
||||
|
||||
/** A paused timeline of a fixed duration, registered on `window.__timelines`.
|
||||
* The runtime only syncs a clock duration when a timeline is BOUND, so a
|
||||
* fixture without one never reaches the code these suites are about. */
|
||||
export function createMockTimeline(duration: number): RuntimeTimelineLike {
|
||||
const state = { time: 0, paused: true };
|
||||
return {
|
||||
play: () => {
|
||||
state.paused = false;
|
||||
},
|
||||
pause: () => {
|
||||
state.paused = true;
|
||||
},
|
||||
seek: (time?: number) => (time === undefined ? state.time : (state.time = time)),
|
||||
totalTime: (time?: number) => (time === undefined ? state.time : (state.time = time)),
|
||||
time: () => state.time,
|
||||
duration: () => duration,
|
||||
add: () => {},
|
||||
paused: (value?: boolean) =>
|
||||
typeof value === "boolean" ? (state.paused = value) : state.paused,
|
||||
timeScale: () => {},
|
||||
set: () => {},
|
||||
getChildren: () => [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Empty document plus the `CSS.escape` jsdom does not ship, which the runtime's
|
||||
* selector building needs. */
|
||||
export function resetRuntimeFixtureDom(): void {
|
||||
document.body.innerHTML = "";
|
||||
(globalThis as typeof globalThis & { CSS?: { escape?: (value: string) => string } }).CSS ??= {};
|
||||
globalThis.CSS.escape ??= (value: string) => value;
|
||||
}
|
||||
|
||||
/** Run every animation frame callback immediately, so a test can drive the
|
||||
* transport without waiting on a real frame clock. */
|
||||
export function installImmediateAnimationFrame(): void {
|
||||
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
}) as typeof window.requestAnimationFrame;
|
||||
window.cancelAnimationFrame = (() => {}) as typeof window.cancelAnimationFrame;
|
||||
}
|
||||
|
||||
/** jsdom leaves `duration` as NaN; a writable getter also lets a test mimic the
|
||||
* `el.load()` reset `syncRuntimeMedia` performs on the seek-retry path. */
|
||||
export function stubDuration(
|
||||
el: HTMLMediaElement,
|
||||
initial: number,
|
||||
): { set: (next: number) => void } {
|
||||
let value = initial;
|
||||
Object.defineProperty(el, "duration", { get: () => value, configurable: true });
|
||||
return { set: (next: number) => (value = next) };
|
||||
}
|
||||
@@ -5,5 +5,5 @@
|
||||
},
|
||||
"files": [],
|
||||
"include": ["src/runtime/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test-helpers.ts"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
DOM_EDIT_LAYER_OBSERVER_INIT,
|
||||
type DomEditLayerWalkCache,
|
||||
createDomEditLayerWalkCache,
|
||||
drainPendingLayerMutations,
|
||||
} from "./domEditLayerWalkCache";
|
||||
import { collectDomEditLayerItems } from "./domEditingLayers";
|
||||
import type { DomEditLayerItem } from "./domEditingTypes";
|
||||
|
||||
const opts = { activeCompositionPath: "index.html", isMasterView: true };
|
||||
|
||||
/** Attached, not detached: the occurrence index is resolved with a whole-document
|
||||
* query, so a detached fixture would number every element as a miss. */
|
||||
function mountPreview(cardCount: number): HTMLElement {
|
||||
const cards = Array.from(
|
||||
{ length: cardCount },
|
||||
(_unused, i) =>
|
||||
`<div class="box"><span class="label">card ${i}</span><b class="tag">t${i}</b></div>`,
|
||||
).join("");
|
||||
document.body.innerHTML = `<div data-composition-id="index.html">${cards}</div>`;
|
||||
return document.body.firstElementChild as HTMLElement;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
/** Everything a caller can observe about a walk. Compared field by field so a
|
||||
* divergence names itself instead of failing on object identity. */
|
||||
function shape(items: DomEditLayerItem[]): unknown {
|
||||
return items.map((item) => ({
|
||||
key: item.key,
|
||||
label: item.label,
|
||||
tagName: item.tagName,
|
||||
depth: item.depth,
|
||||
childCount: item.childCount,
|
||||
id: item.id,
|
||||
hfId: item.hfId,
|
||||
selector: item.selector,
|
||||
selectorIndex: item.selectorIndex,
|
||||
sourceFile: item.sourceFile,
|
||||
element: item.element,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Count `getComputedStyle` calls, which is the per-element cost this cache
|
||||
* exists to avoid: `isInspectableLayerElement` makes one for the element and
|
||||
* `getDirectLayerChildren` one more for each direct child. A count that does not
|
||||
* grow with the document is the whole claim.
|
||||
*/
|
||||
function countingComputedStyle(): { calls: () => number; restore: () => void } {
|
||||
const real = window.getComputedStyle.bind(window);
|
||||
let calls = 0;
|
||||
window.getComputedStyle = ((el: Element, pseudo?: string | null) => {
|
||||
calls += 1;
|
||||
return real(el, pseudo ?? undefined);
|
||||
}) as typeof window.getComputedStyle;
|
||||
return { calls: () => calls, restore: () => (window.getComputedStyle = real) };
|
||||
}
|
||||
|
||||
interface WarmWalk {
|
||||
root: HTMLElement;
|
||||
cache: DomEditLayerWalkCache;
|
||||
observer: MutationObserver;
|
||||
}
|
||||
|
||||
/**
|
||||
* A mounted preview whose cache has already done one full walk, with the
|
||||
* observer live. Every test here needs exactly this before it can measure what
|
||||
* a SECOND walk costs, so it lives in one place.
|
||||
*/
|
||||
function withWarmWalk<T>(cardCount: number, run: (ctx: WarmWalk) => T): T {
|
||||
const root = mountPreview(cardCount);
|
||||
const cache = createDomEditLayerWalkCache();
|
||||
const observer = new MutationObserver(() => {});
|
||||
observer.observe(document.documentElement, DOM_EDIT_LAYER_OBSERVER_INIT);
|
||||
try {
|
||||
collectDomEditLayerItems(root, opts, undefined, cache);
|
||||
return run({ root, cache, observer });
|
||||
} finally {
|
||||
observer.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
describe("collectDomEditLayerItems incremental rebuild", () => {
|
||||
/**
|
||||
* The load-bearing assertion, and it is INVARIANCE, not a threshold: a
|
||||
* threshold ("under 20 style reads") passes on a small fixture and still
|
||||
* degrades linearly on a real preview. Quadrupling the card count must not
|
||||
* change what one style edit costs at all.
|
||||
*
|
||||
* Before the cache both numbers tracked the document size (every element and
|
||||
* every direct child re-derived on every mutation, whatever the mutation was).
|
||||
*/
|
||||
function styleEditRebuildCost(cardCount: number): number {
|
||||
return withWarmWalk(cardCount, ({ root, cache, observer }) => {
|
||||
// What animation writes on a frame: one element's transform, nothing that
|
||||
// can change any element's selector, membership or label.
|
||||
root.querySelector<HTMLElement>(".box")!.style.transform = "translateX(4px)";
|
||||
cache.ingest(observer.takeRecords());
|
||||
|
||||
const probe = countingComputedStyle();
|
||||
try {
|
||||
collectDomEditLayerItems(root, opts, undefined, cache);
|
||||
return probe.calls();
|
||||
} finally {
|
||||
probe.restore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it("costs the same for one style edit at n cards and at 4n", () => {
|
||||
const small = styleEditRebuildCost(12);
|
||||
const large = styleEditRebuildCost(48);
|
||||
|
||||
expect(large).toBe(small);
|
||||
// A ceiling as well, so a future change that reintroduces a per-element term
|
||||
// fails here even if it happens to be document-size-flat. The edited element,
|
||||
// its parent, and each of their direct children is the whole budget.
|
||||
expect(small).toBeLessThanOrEqual(12);
|
||||
});
|
||||
|
||||
it("performs no document query at all for an edit that touches no selector", () => {
|
||||
withWarmWalk(12, ({ root, cache, observer }) => {
|
||||
root.querySelector<HTMLElement>(".tag")!.style.opacity = "0.5";
|
||||
cache.ingest(observer.takeRecords());
|
||||
|
||||
const real = document.querySelectorAll.bind(document);
|
||||
let queries = 0;
|
||||
Object.defineProperty(document, "querySelectorAll", {
|
||||
configurable: true,
|
||||
value: (selector: string) => {
|
||||
queries += 1;
|
||||
return real(selector);
|
||||
},
|
||||
});
|
||||
try {
|
||||
collectDomEditLayerItems(root, opts, undefined, cache);
|
||||
} finally {
|
||||
delete (document as Partial<Document>).querySelectorAll;
|
||||
}
|
||||
// The only document query in the walk resolves a selector's occurrence
|
||||
// index, and no cached selector was invalidated.
|
||||
expect(queries).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The equivalence half, and the one that would catch a wrong optimisation
|
||||
* rather than a missing one. Every mutation KIND the invalidation reasons
|
||||
* about is applied in turn, and after each the incremental walk must equal a
|
||||
* walk with no cache at all — which is what every other caller still gets.
|
||||
*/
|
||||
it("matches an uncached walk after every kind of mutation it reasons about", () => {
|
||||
const root = mountPreview(6);
|
||||
const cache = createDomEditLayerWalkCache();
|
||||
const observer = new MutationObserver(() => {});
|
||||
observer.observe(document.documentElement, DOM_EDIT_LAYER_OBSERVER_INIT);
|
||||
// Captured up front: later cases change class and id, so re-querying `.box`
|
||||
// would hand the next case a different element than it names.
|
||||
const cards = Array.from(root.querySelectorAll<HTMLElement>(".box"));
|
||||
const box = (i: number) => cards[i]!;
|
||||
|
||||
const mutations: Array<[string, () => void]> = [
|
||||
["a style write that cannot change membership", () => (box(0).style.opacity = "0.5")],
|
||||
// Inherited: the subtree's computed visibility moves with it.
|
||||
["a style write that hides a subtree", () => (box(1).style.visibility = "hidden")],
|
||||
["clearing that same subtree hide", () => (box(1).style.visibility = "")],
|
||||
// display:none removes the element from the list and shifts its
|
||||
// descendants' depth by one, without touching their own computed display.
|
||||
["display:none on a card", () => (box(2).style.display = "none")],
|
||||
// Renumbers `.box` for every card after it, and relabels this one.
|
||||
["a class change that renumbers a shared selector", () => (box(3).className = "crate")],
|
||||
["an id, which outranks the class selector", () => (box(4).id = "hero")],
|
||||
["edited text, which is a label input", () => (box(5).firstChild!.textContent = "renamed")],
|
||||
// The unattributable case: the documented full-rebuild fallback.
|
||||
["a node inserted mid-document", () => box(0).append(document.createElement("i"))],
|
||||
["a node removed", () => box(0).querySelector("b")!.remove()],
|
||||
["a data attribute nothing derives from", () => box(0).setAttribute("data-x", "1")],
|
||||
];
|
||||
|
||||
try {
|
||||
collectDomEditLayerItems(root, opts, undefined, cache);
|
||||
for (const [what, mutate] of mutations) {
|
||||
mutate();
|
||||
cache.ingest(observer.takeRecords());
|
||||
expect(
|
||||
shape(collectDomEditLayerItems(root, opts, undefined, cache)),
|
||||
`incremental walk diverged after ${what}`,
|
||||
).toEqual(shape(collectDomEditLayerItems(root, opts)));
|
||||
}
|
||||
} finally {
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The subtree drop is the one expensive branch in the ingest path, and it runs
|
||||
* in the raw observer callback — not behind the rebuild throttle. GSAP's
|
||||
* `autoAlpha` writes `visibility: visible` into inline style ONCE on fade-in
|
||||
* and leaves it there, so a rule that fires whenever the style attribute
|
||||
* mentions visibility fires on every transform tick for the rest of the clip:
|
||||
* a whole-subtree walk per animation frame, for the exact elements a
|
||||
* composition animates most.
|
||||
*/
|
||||
it("does not re-walk a subtree when a faded-in element merely animates", () => {
|
||||
const root = mountPreview(6);
|
||||
const cache = createDomEditLayerWalkCache();
|
||||
const observer = new MutationObserver(() => {});
|
||||
observer.observe(document.documentElement, DOM_EDIT_LAYER_OBSERVER_INIT);
|
||||
const card = root.querySelector<HTMLElement>(".box")!;
|
||||
try {
|
||||
collectDomEditLayerItems(root, opts, undefined, cache);
|
||||
// The fade-in itself. This one legitimately invalidates the subtree.
|
||||
card.style.visibility = "visible";
|
||||
cache.ingest(observer.takeRecords());
|
||||
|
||||
let subtreeWalks = 0;
|
||||
const real = Element.prototype.querySelectorAll;
|
||||
Element.prototype.querySelectorAll = function (this: Element, selector: string) {
|
||||
if (selector === "*") subtreeWalks += 1;
|
||||
return real.call(this, selector);
|
||||
} as typeof Element.prototype.querySelectorAll;
|
||||
try {
|
||||
// Ordinary animation frames. `visibility: visible` is still sitting in
|
||||
// the attribute, but the value has not moved.
|
||||
card.style.transform = "translateX(1px)";
|
||||
cache.ingest(observer.takeRecords());
|
||||
card.style.transform = "translateX(2px)";
|
||||
cache.ingest(observer.takeRecords());
|
||||
} finally {
|
||||
Element.prototype.querySelectorAll = real;
|
||||
}
|
||||
|
||||
expect(subtreeWalks).toBe(0);
|
||||
} finally {
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
it("drops everything when the walk is re-scoped to a different composition", () => {
|
||||
const root = mountPreview(3);
|
||||
const cache = createDomEditLayerWalkCache();
|
||||
collectDomEditLayerItems(root, opts, undefined, cache);
|
||||
|
||||
const rescoped = { ...opts, activeCompositionPath: "scenes/two.html" };
|
||||
expect(shape(collectDomEditLayerItems(root, rescoped, undefined, cache))).toEqual(
|
||||
shape(collectDomEditLayerItems(root, rescoped)),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("drainPendingLayerMutations", () => {
|
||||
it("applies records the observer has not delivered yet", () => {
|
||||
withWarmWalk(6, ({ root, cache, observer }) => {
|
||||
const card = root.querySelector<HTMLElement>(".box")!;
|
||||
// Not delivered: the callback runs in a microtask, and this test never
|
||||
// yields to one.
|
||||
card.id = "renamed";
|
||||
|
||||
drainPendingLayerMutations(observer, cache);
|
||||
|
||||
// An identity attribute drops the whole cache, so the walk after the drain
|
||||
// must agree with an uncached walk.
|
||||
expect(shape(collectDomEditLayerItems(root, opts, undefined, cache))).toEqual(
|
||||
shape(collectDomEditLayerItems(root, opts)),
|
||||
);
|
||||
// And the records really were consumed by the drain, not still queued.
|
||||
expect(observer.takeRecords()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("is a no-op when no observer is attached yet", () => {
|
||||
withWarmWalk(6, ({ root, cache }) => {
|
||||
expect(() => drainPendingLayerMutations(null, cache)).not.toThrow();
|
||||
expect(collectDomEditLayerItems(root, opts, undefined, cache)).toHaveLength(
|
||||
collectDomEditLayerItems(root, opts).length,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* Incremental memory for `collectDomEditLayerItems`.
|
||||
*
|
||||
* The layer walk is re-run from scratch every time a MutationObserver says the
|
||||
* preview changed, and the observer's loudest source is inline `style` — which
|
||||
* is exactly what animation writes. So a paused-looking editor re-derived every
|
||||
* element's patch target, label and child count several times a second, and each
|
||||
* derivation costs two `getComputedStyle` reads (its own plus one per direct
|
||||
* child), two ancestor walks for the source file, and a `textContent` read whose
|
||||
* cost is the element's whole subtree.
|
||||
*
|
||||
* None of that depends on the traversal — only on the element. So the traversal
|
||||
* stays live (it is pointer-chasing, and `depth` genuinely is positional) and the
|
||||
* per-element half is memoized, with the mutation records deciding what to drop.
|
||||
*
|
||||
* WHAT MAKES A CACHED ENTRY WRONG, and how each is caught:
|
||||
*
|
||||
* - the element's own attributes (`style` -> computed display, `class`/`id`/
|
||||
* `data-hf-group` -> selector and label) -> attribute record on it
|
||||
* - an ancestor's `visibility`, the ONLY inherited input to the
|
||||
* display/visibility gate (`display:none` on an ancestor does not change a
|
||||
* descendant's own computed `display`) -> attribute record on the
|
||||
* ancestor, whose subtree is
|
||||
* dropped only when the
|
||||
* style declares visibility
|
||||
* - a direct child appearing or disappearing from the layer list, which moves
|
||||
* the parent's `childCount` -> the parent is dropped
|
||||
* alongside every element
|
||||
* - descendant text, which moves an ancestor's label -> characterData record,
|
||||
* ancestors dropped
|
||||
* - selector occurrence indices, which shift for EVERY element sharing a
|
||||
* selector when one element's identity changes -> whole cache dropped
|
||||
* - nodes added or removed, which shift indices, order, labels and counts at
|
||||
* once and cannot be attributed to one element -> whole cache dropped
|
||||
* - a different active composition path, or a new composition-id -> source-file
|
||||
* map, both of which re-scope every source file -> checked per walk
|
||||
*
|
||||
* The bias is deliberate and matches the duration cache in the runtime: a
|
||||
* redundant recomputation is a missed optimisation, a missed one is a wrong
|
||||
* layer tree.
|
||||
*/
|
||||
|
||||
import { buildElementLabel, getCompositionSourceMapRevision, isHtmlElement } from "./domEditingDom";
|
||||
import {
|
||||
getDirectLayerChildren,
|
||||
isInspectableLayerElement,
|
||||
resolveDomLayerIdentity,
|
||||
} from "./domEditingElement";
|
||||
import type { DomEditSelection } from "./domEditingTypes";
|
||||
|
||||
/** How an element is addressed. Survives style writes; see `readIdentity`. */
|
||||
export type DomEditLayerIdentity = Pick<
|
||||
DomEditSelection,
|
||||
"id" | "hfId" | "selector" | "selectorIndex" | "sourceFile"
|
||||
>;
|
||||
|
||||
/** Whether the element renders, and how many of its direct children are layers.
|
||||
* Both are computed style reads, and both a style write can flip. Filled
|
||||
* lazily: `childCount` is only asked for once an element is known to be one. */
|
||||
interface PresenceMemo {
|
||||
inspectable?: boolean;
|
||||
childCount?: number;
|
||||
}
|
||||
|
||||
interface IdentityMemo {
|
||||
identity?: DomEditLayerIdentity | null;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attributes that decide an element's SELECTOR, and therefore the occurrence
|
||||
* index of every other element sharing that selector. One of these changing
|
||||
* renumbers elements the record does not mention, so the only sound response is
|
||||
* to drop everything. `class` is here because `buildStableSelector` falls back
|
||||
* to the preferred class.
|
||||
*/
|
||||
const SELECTOR_IDENTITY_ATTRIBUTES = new Set([
|
||||
"id",
|
||||
"class",
|
||||
"data-hf-group",
|
||||
"data-composition-id",
|
||||
"data-composition-file",
|
||||
"data-composition-src",
|
||||
"data-hf-original-composition-id",
|
||||
]);
|
||||
|
||||
/**
|
||||
* The observer configuration the cache's invalidation is written against.
|
||||
* Exported so the refresh loop and the tests cannot drift from it.
|
||||
*
|
||||
* No `attributeFilter`: an unlisted attribute is one the cache would never learn
|
||||
* about, and serving a stale entry is worse than the extra records.
|
||||
*/
|
||||
export const DOM_EDIT_LAYER_OBSERVER_INIT: MutationObserverInit = {
|
||||
attributes: true,
|
||||
characterData: true,
|
||||
childList: true,
|
||||
subtree: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* The inline `visibility` each element last presented. Deliberately NOT cleared
|
||||
* by `invalidateAll`: it records what the DOM said, not a value derived from it.
|
||||
*/
|
||||
const lastInlineVisibility = new WeakMap<HTMLElement, string>();
|
||||
|
||||
/**
|
||||
* Did this style write move the element's inline `visibility`?
|
||||
*
|
||||
* `visibility` is inherited, so changing it changes the layer-list membership of
|
||||
* the element's whole subtree, and only then is the subtree worth dropping.
|
||||
*
|
||||
* The comparison is on the PARSED value, remembered per element. Testing the
|
||||
* attribute TEXT for "visibility" looks equivalent and is not: the standard
|
||||
* GSAP fade (`autoAlpha`) writes `visibility: visible` once and leaves it in the
|
||||
* attribute for the rest of the clip, so a text test stays true for every
|
||||
* transform tick afterwards. That is a whole-subtree walk per animation frame,
|
||||
* in the observer callback rather than behind the rebuild throttle, on exactly
|
||||
* the elements a composition animates most.
|
||||
*
|
||||
* An element seen for the first time reports changed, which costs one drop and
|
||||
* is the safe answer: its entries were derived before this write.
|
||||
*/
|
||||
function inlineVisibilityChanged(el: HTMLElement): boolean {
|
||||
const next = el.style.visibility;
|
||||
if (lastInlineVisibility.get(el) === next) return false;
|
||||
lastInlineVisibility.set(el, next);
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface DomEditLayerWalkCache {
|
||||
/**
|
||||
* Start a walk. Drops everything when the scoping inputs the entries were
|
||||
* built under no longer hold. Call once per `collectDomEditLayerItems`.
|
||||
*/
|
||||
beginWalk(activeCompositionPath: string | null): void;
|
||||
/**
|
||||
* Does `el` render? Cheap to invalidate and invalidated often: any attribute
|
||||
* write on the element, on its parent, or a `visibility` write on an ancestor
|
||||
* drops it, because all three can flip the answer.
|
||||
*/
|
||||
readInspectable(el: HTMLElement, compute: () => boolean): boolean;
|
||||
/** How many of `el`'s direct children are layers. Same lifetime as
|
||||
* `readInspectable`: it is a sum over their answers. */
|
||||
readChildCount(el: HTMLElement, compute: () => number): number;
|
||||
/**
|
||||
* `el`'s selector, occurrence index and source file. A SEPARATE, much longer
|
||||
* lifetime than presence: a style write cannot renumber a selector, so this
|
||||
* survives one, and it is the only part of the walk that queries the
|
||||
* document. That split is what makes an ordinary animation frame cost no
|
||||
* document queries at all.
|
||||
*/
|
||||
readIdentity(
|
||||
el: HTMLElement,
|
||||
compute: () => DomEditLayerIdentity | null,
|
||||
): DomEditLayerIdentity | null;
|
||||
/** `el`'s display label. Shares the identity lifetime, plus descendant text. */
|
||||
readLabel(el: HTMLElement, compute: () => string): string;
|
||||
/** Apply mutation records. Safe to call with an empty array. */
|
||||
ingest(records: MutationRecord[]): void;
|
||||
/** Forget every entry — the fallback for anything not attributable. */
|
||||
invalidateAll(): void;
|
||||
}
|
||||
|
||||
export function createDomEditLayerWalkCache(): DomEditLayerWalkCache {
|
||||
let presence = new WeakMap<HTMLElement, PresenceMemo>();
|
||||
let identities = new WeakMap<HTMLElement, IdentityMemo>();
|
||||
let scope: string | null = null;
|
||||
|
||||
const invalidateAll = () => {
|
||||
presence = new WeakMap();
|
||||
identities = new WeakMap();
|
||||
};
|
||||
|
||||
const drop = (el: HTMLElement | null) => {
|
||||
if (el) presence.delete(el);
|
||||
};
|
||||
|
||||
const dropSubtree = (el: HTMLElement) => {
|
||||
for (const descendant of el.querySelectorAll("*")) {
|
||||
if (isHtmlElement(descendant)) presence.delete(descendant);
|
||||
}
|
||||
};
|
||||
|
||||
const ingestOne = (record: MutationRecord): boolean => {
|
||||
// Structural change: order, occurrence indices, labels and child counts all
|
||||
// move at once, and the record names the parent rather than everything
|
||||
// affected. This is the documented full-rebuild fallback.
|
||||
if (record.type === "childList") return false;
|
||||
|
||||
if (record.type === "characterData") {
|
||||
// `buildElementLabel` falls back to `textContent`, so edited text changes
|
||||
// the label of every ancestor that contains it.
|
||||
for (
|
||||
let ancestor = record.target.parentElement;
|
||||
ancestor;
|
||||
ancestor = ancestor.parentElement
|
||||
) {
|
||||
identities.delete(ancestor);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const target = record.target;
|
||||
if (!isHtmlElement(target)) return true;
|
||||
if (record.attributeName && SELECTOR_IDENTITY_ATTRIBUTES.has(record.attributeName))
|
||||
return false;
|
||||
|
||||
drop(target);
|
||||
// The parent counts its layer children, and this element may have just
|
||||
// joined or left that count.
|
||||
drop(target.parentElement);
|
||||
if (record.attributeName === "style" && inlineVisibilityChanged(target)) dropSubtree(target);
|
||||
return true;
|
||||
};
|
||||
|
||||
return {
|
||||
beginWalk(activeCompositionPath) {
|
||||
const nextScope = `${activeCompositionPath ?? ""}|${getCompositionSourceMapRevision()}`;
|
||||
if (nextScope === scope) return;
|
||||
scope = nextScope;
|
||||
invalidateAll();
|
||||
},
|
||||
readInspectable(el, compute) {
|
||||
const memo = presence.get(el) ?? {};
|
||||
if (memo.inspectable === undefined) {
|
||||
memo.inspectable = compute();
|
||||
presence.set(el, memo);
|
||||
}
|
||||
return memo.inspectable;
|
||||
},
|
||||
readChildCount(el, compute) {
|
||||
const memo = presence.get(el) ?? {};
|
||||
if (memo.childCount === undefined) {
|
||||
memo.childCount = compute();
|
||||
presence.set(el, memo);
|
||||
}
|
||||
return memo.childCount;
|
||||
},
|
||||
readIdentity(el, compute) {
|
||||
const memo = identities.get(el) ?? {};
|
||||
if (memo.identity === undefined) {
|
||||
memo.identity = compute();
|
||||
identities.set(el, memo);
|
||||
}
|
||||
return memo.identity;
|
||||
},
|
||||
readLabel(el, compute) {
|
||||
const memo = identities.get(el) ?? {};
|
||||
if (memo.label === undefined) {
|
||||
memo.label = compute();
|
||||
identities.set(el, memo);
|
||||
}
|
||||
return memo.label;
|
||||
},
|
||||
ingest(records) {
|
||||
for (const record of records) {
|
||||
if (!ingestOne(record)) {
|
||||
invalidateAll();
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
invalidateAll,
|
||||
};
|
||||
}
|
||||
|
||||
/** One element's contribution to a layer walk. `depth` is absent on purpose: it
|
||||
* belongs to the traversal, not to the element. */
|
||||
export interface DomEditLayerWalkEntry {
|
||||
target: DomEditLayerIdentity;
|
||||
label: string;
|
||||
childCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything `collectDomEditLayerItems` needs about ONE element, served from
|
||||
* `cache` where it is still valid. Null when the element is not a layer.
|
||||
*
|
||||
* The four reads go through the cache separately rather than as one record
|
||||
* because they do not go stale together: a style write flips whether an element
|
||||
* renders without touching how it is addressed. Without a cache this is exactly
|
||||
* the original per-element derivation, which is what every other caller of the
|
||||
* walk still gets.
|
||||
*
|
||||
* Lives here rather than in the walk so the two halves of the memoization —
|
||||
* what is stored and what is read — sit in one file.
|
||||
*/
|
||||
export function readDomEditLayerWalkEntry(
|
||||
el: HTMLElement,
|
||||
activeCompositionPath: string | null,
|
||||
cache?: DomEditLayerWalkCache,
|
||||
): DomEditLayerWalkEntry | null {
|
||||
const inspectable = cache
|
||||
? cache.readInspectable(el, () => isInspectableLayerElement(el))
|
||||
: isInspectableLayerElement(el);
|
||||
if (!inspectable) return null;
|
||||
|
||||
const identity = () => resolveDomLayerIdentity(el, activeCompositionPath);
|
||||
const target = cache ? cache.readIdentity(el, identity) : identity();
|
||||
if (!target) return null;
|
||||
|
||||
const label = () => buildElementLabel(el);
|
||||
const childCount = () => getDirectLayerChildren(el).length;
|
||||
return {
|
||||
target,
|
||||
label: cache ? cache.readLabel(el, label) : label(),
|
||||
childCount: cache ? cache.readChildCount(el, childCount) : childCount(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the records the observer has taken in but not yet delivered.
|
||||
*
|
||||
* Records arrive in a microtask, so an edit made earlier in THIS task would
|
||||
* otherwise be read back against entries that predate it. Taking them suppresses
|
||||
* the observer's own callback for them, which is equivalent here: that callback
|
||||
* only ingests and marks a rebuild owed, and a caller draining is rebuilding
|
||||
* regardless. A caller with no observer yet has nothing pending.
|
||||
*/
|
||||
export function drainPendingLayerMutations(
|
||||
observer: MutationObserver | null,
|
||||
cache: DomEditLayerWalkCache,
|
||||
): void {
|
||||
if (!observer) return;
|
||||
cache.ingest(observer.takeRecords());
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* The iframe→overlay coordinate basis: everything needed to map a rect measured
|
||||
* inside the preview document into the Studio overlay's own coordinates.
|
||||
*
|
||||
* It is a property of the COMPOSITION and the canvas zoom, not of any element,
|
||||
* so a caller measuring many elements in one synchronous pass resolves it once
|
||||
* and threads it through the geometry functions in `domEditOverlayGeometry`.
|
||||
* Resolved per element it costs a `querySelector("[data-composition-id]")` plus
|
||||
* three layout reads each.
|
||||
*
|
||||
* Its own module because it is the one piece of that file every other piece
|
||||
* depends on and nothing in it is about a single element's geometry.
|
||||
*/
|
||||
|
||||
/** iframe→overlay mapping basis shared by every overlay-geometry function. */
|
||||
export interface OverlayRootScale {
|
||||
iframeRect: DOMRect;
|
||||
overlayRect: DOMRect;
|
||||
rootScaleX: number;
|
||||
rootScaleY: number;
|
||||
}
|
||||
|
||||
export function readPositiveDimension(value: string | null): number | null {
|
||||
if (!value) return null;
|
||||
const parsed = Number.parseFloat(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
/** The composition root element inside the preview doc (or null when absent). */
|
||||
function findOverlayRootElement(doc: Document | null): HTMLElement | null {
|
||||
return doc?.querySelector<HTMLElement>("[data-composition-id]") ?? doc?.documentElement ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The root's effective width/height for scaling: prefer the composition's
|
||||
* declared dimensions (data-width/data-height), which stay fixed while GSAP
|
||||
* transforms mutate the measured rect; fall back to the measured rect. Null when
|
||||
* unmeasurable.
|
||||
*/
|
||||
function resolveRootDimensions(root: HTMLElement | null): { width: number; height: number } | null {
|
||||
if (!root) return null;
|
||||
const rootRect = root.getBoundingClientRect();
|
||||
const width = readPositiveDimension(root.getAttribute("data-width")) ?? rootRect.width;
|
||||
const height = readPositiveDimension(root.getAttribute("data-height")) ?? rootRect.height;
|
||||
if (!width || !height) return null;
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
/**
|
||||
* The iframe/overlay client rects and the iframe→root scale factors. Uses the
|
||||
* composition's declared dimensions (data-width/data-height) for the scale
|
||||
* instead of rootRect.width/height: when GSAP applies transforms (scale,
|
||||
* translate) to the root, rootRect dimensions change but the composition's
|
||||
* canonical size stays fixed, and using rootRect misaligns the overlay during
|
||||
* animated playback. Returns null when the geometry is unmeasurable.
|
||||
*/
|
||||
export function computeOverlayRootScale(
|
||||
overlayEl: HTMLDivElement,
|
||||
iframe: HTMLIFrameElement,
|
||||
doc: Document | null,
|
||||
): OverlayRootScale | null {
|
||||
const iframeRect = iframe.getBoundingClientRect();
|
||||
const overlayRect = overlayEl.getBoundingClientRect();
|
||||
const dims = resolveRootDimensions(findOverlayRootElement(doc));
|
||||
if (!dims) return null;
|
||||
return {
|
||||
iframeRect,
|
||||
overlayRect,
|
||||
rootScaleX: iframeRect.width / dims.width,
|
||||
rootScaleY: iframeRect.height / dims.height,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
import { type DomEditSelection, findElementForSelection } from "./domEditing";
|
||||
import {
|
||||
computeOverlayRootScale,
|
||||
type OverlayRootScale,
|
||||
readPositiveDimension,
|
||||
} from "./domEditOverlayBasis";
|
||||
import { isElementVisibleThroughAncestors } from "./domEditingDom";
|
||||
import { hugRectForElement } from "./domEditOverlayCrop";
|
||||
import { composeElementTransform, type PlanarTransformOps } from "./domEditOverlayTransform";
|
||||
@@ -47,12 +52,6 @@ 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 readPositiveDimension(value: string | null): number | null {
|
||||
if (!value) return null;
|
||||
const parsed = Number.parseFloat(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
function findSourceBoundary(element: HTMLElement): HTMLElement | null {
|
||||
let current: HTMLElement | null = element;
|
||||
while (current) {
|
||||
@@ -102,8 +101,9 @@ export function toVisibleOverlayRect(
|
||||
overlayEl: HTMLDivElement,
|
||||
iframe: HTMLIFrameElement,
|
||||
element: HTMLElement,
|
||||
precomputedScale?: OverlayRootScale | null,
|
||||
): OverlayRect | null {
|
||||
const rect = toOverlayRect(overlayEl, iframe, element);
|
||||
const rect = toOverlayRect(overlayEl, iframe, element, precomputedScale);
|
||||
return rect ? { ...rect, ...hugRectForElement(rect, element) } : null;
|
||||
}
|
||||
|
||||
@@ -206,59 +206,6 @@ function rotationDegreesFromMatrix(matrix: DOMMatrix): number {
|
||||
* matrix-decomposition floating-point noise, never an actual rotation. */
|
||||
const ROTATION_GATE_EPSILON_DEG = 1e-4;
|
||||
|
||||
/** iframe→overlay mapping basis shared by every overlay-geometry function. */
|
||||
interface OverlayRootScale {
|
||||
iframeRect: DOMRect;
|
||||
overlayRect: DOMRect;
|
||||
rootScaleX: number;
|
||||
rootScaleY: number;
|
||||
}
|
||||
|
||||
/** The composition root element inside the preview doc (or null when absent). */
|
||||
function findOverlayRootElement(doc: Document | null): HTMLElement | null {
|
||||
return doc?.querySelector<HTMLElement>("[data-composition-id]") ?? doc?.documentElement ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The root's effective width/height for scaling: prefer the composition's
|
||||
* declared dimensions (data-width/data-height), which stay fixed while GSAP
|
||||
* transforms mutate the measured rect; fall back to the measured rect. Null when
|
||||
* unmeasurable.
|
||||
*/
|
||||
function resolveRootDimensions(root: HTMLElement | null): { width: number; height: number } | null {
|
||||
if (!root) return null;
|
||||
const rootRect = root.getBoundingClientRect();
|
||||
const width = readPositiveDimension(root.getAttribute("data-width")) ?? rootRect.width;
|
||||
const height = readPositiveDimension(root.getAttribute("data-height")) ?? rootRect.height;
|
||||
if (!width || !height) return null;
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
/**
|
||||
* The iframe/overlay client rects and the iframe→root scale factors. Uses the
|
||||
* composition's declared dimensions (data-width/data-height) for the scale
|
||||
* instead of rootRect.width/height: when GSAP applies transforms (scale,
|
||||
* translate) to the root, rootRect dimensions change but the composition's
|
||||
* canonical size stays fixed, and using rootRect misaligns the overlay during
|
||||
* animated playback. Returns null when the geometry is unmeasurable.
|
||||
*/
|
||||
function computeOverlayRootScale(
|
||||
overlayEl: HTMLDivElement,
|
||||
iframe: HTMLIFrameElement,
|
||||
doc: Document | null,
|
||||
): OverlayRootScale | null {
|
||||
const iframeRect = iframe.getBoundingClientRect();
|
||||
const overlayRect = overlayEl.getBoundingClientRect();
|
||||
const dims = resolveRootDimensions(findOverlayRootElement(doc));
|
||||
if (!dims) return null;
|
||||
return {
|
||||
iframeRect,
|
||||
overlayRect,
|
||||
rootScaleX: iframeRect.width / dims.width,
|
||||
rootScaleY: iframeRect.height / dims.height,
|
||||
};
|
||||
}
|
||||
|
||||
function toOverlayRect(
|
||||
overlayEl: HTMLDivElement,
|
||||
iframe: HTMLIFrameElement,
|
||||
@@ -415,8 +362,10 @@ export function orientedOverlayRect(
|
||||
overlayEl: HTMLDivElement,
|
||||
iframe: HTMLIFrameElement,
|
||||
element: HTMLElement,
|
||||
precomputedScale?: OverlayRootScale | null,
|
||||
): OverlayRect | null {
|
||||
const scale = computeOverlayRootScale(overlayEl, iframe, iframe.contentDocument);
|
||||
const scale =
|
||||
precomputedScale ?? computeOverlayRootScale(overlayEl, iframe, iframe.contentDocument);
|
||||
if (!scale) return null;
|
||||
const base = toOverlayRect(overlayEl, iframe, element, scale);
|
||||
if (!base) return null;
|
||||
@@ -460,8 +409,9 @@ export function orientedVisibleOverlayRect(
|
||||
overlayEl: HTMLDivElement,
|
||||
iframe: HTMLIFrameElement,
|
||||
element: HTMLElement,
|
||||
precomputedScale?: OverlayRootScale | null,
|
||||
): OverlayRect | null {
|
||||
const rect = orientedOverlayRect(overlayEl, iframe, element);
|
||||
const rect = orientedOverlayRect(overlayEl, iframe, element, precomputedScale);
|
||||
return rect ? { ...rect, ...hugRectForElement(rect, element) } : null;
|
||||
}
|
||||
|
||||
@@ -531,8 +481,9 @@ export function groupAwareOverlayRect(
|
||||
overlayEl: HTMLDivElement,
|
||||
iframe: HTMLIFrameElement,
|
||||
el: HTMLElement,
|
||||
precomputedScale?: OverlayRootScale | null,
|
||||
): OverlayRect | null {
|
||||
const rect = toOverlayRect(overlayEl, iframe, el);
|
||||
const rect = toOverlayRect(overlayEl, iframe, el, precomputedScale);
|
||||
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
|
||||
@@ -540,7 +491,7 @@ export function groupAwareOverlayRect(
|
||||
// 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);
|
||||
const childRect = toOverlayRect(overlayEl, iframe, child as HTMLElement, precomputedScale);
|
||||
if (childRect) rects.push(childRect);
|
||||
}
|
||||
const union = rects.length > 0 ? resolveDomEditGroupOverlayRect(rects) : null;
|
||||
@@ -551,15 +502,27 @@ export function groupAwareOverlayRect(
|
||||
return { ...union, editScaleX: rect.editScaleX, editScaleY: rect.editScaleY };
|
||||
}
|
||||
|
||||
/** Groups stay axis-aligned unions; ordinary elements keep their oriented box. */
|
||||
/**
|
||||
* Groups stay axis-aligned unions; ordinary elements keep their oriented box.
|
||||
*
|
||||
* `precomputedScale` is the iframe→overlay basis from `computeOverlayRootScale`.
|
||||
* Without it every call resolves the composition root itself — one
|
||||
* `querySelector("[data-composition-id]")` plus three `getBoundingClientRect`
|
||||
* reads PER ELEMENT — and a caller measuring a whole preview therefore pays that
|
||||
* once per element rather than once per composition. The basis is a property of
|
||||
* the composition and the canvas zoom, not of the element, so a caller that
|
||||
* measures many elements in one synchronous pass resolves it once and threads it
|
||||
* through. See `toVisibleOverlayRects` for the same batching in miniature.
|
||||
*/
|
||||
export function orientedGroupAwareOverlayRect(
|
||||
overlayEl: HTMLDivElement,
|
||||
iframe: HTMLIFrameElement,
|
||||
el: HTMLElement,
|
||||
precomputedScale?: OverlayRootScale | null,
|
||||
): OverlayRect | null {
|
||||
return el.hasAttribute("data-hf-group")
|
||||
? groupAwareOverlayRect(overlayEl, iframe, el)
|
||||
: orientedOverlayRect(overlayEl, iframe, el);
|
||||
? groupAwareOverlayRect(overlayEl, iframe, el, precomputedScale)
|
||||
: orientedOverlayRect(overlayEl, iframe, el, precomputedScale);
|
||||
}
|
||||
|
||||
export function filterNestedDomEditGroupItems<T extends { element: HTMLElement }>(items: T[]): T[] {
|
||||
|
||||
@@ -109,9 +109,18 @@ export function findClosestByAttribute(
|
||||
// time, so module scope is the right lifetime; it's empty until set, in which case
|
||||
// resolution falls back to the historical attribute-only behavior.
|
||||
let compositionSourceMap: Map<string, string> = new Map();
|
||||
// Bumped on every replacement so a consumer that memoizes a resolved source file
|
||||
// can tell that the map it resolved against is gone. The map is module state
|
||||
// with no DOM footprint, so nothing else can observe the change.
|
||||
let compositionSourceMapRevision = 0;
|
||||
|
||||
export function setCompositionSourceMap(map: Map<string, string>): void {
|
||||
compositionSourceMap = map;
|
||||
compositionSourceMapRevision += 1;
|
||||
}
|
||||
|
||||
export function getCompositionSourceMapRevision(): number {
|
||||
return compositionSourceMapRevision;
|
||||
}
|
||||
|
||||
function sourceFromCompositionId(ownerRoot: HTMLElement | null): string | undefined {
|
||||
|
||||
@@ -91,7 +91,13 @@ const DOM_LAYER_IGNORED_TAGS = new Set([
|
||||
"wbr",
|
||||
]);
|
||||
|
||||
function isInspectableLayerElement(el: HTMLElement): boolean {
|
||||
/**
|
||||
* Is the element rendered at all? The half of `getDomLayerPatchTarget` that a
|
||||
* plain style write can flip, and the only half that reads computed style.
|
||||
* Exported so a caller memoizing a layer walk can give it its own (short)
|
||||
* cache lifetime — see `domEditLayerWalkCache`.
|
||||
*/
|
||||
export function isInspectableLayerElement(el: HTMLElement): boolean {
|
||||
const tagName = el.tagName.toLowerCase();
|
||||
if (DOM_LAYER_IGNORED_TAGS.has(tagName)) return false;
|
||||
|
||||
@@ -101,14 +107,29 @@ function isInspectableLayerElement(el: HTMLElement): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getDomLayerPatchTarget(
|
||||
/**
|
||||
* How the element is ADDRESSED — its selector, that selector's occurrence index
|
||||
* within the source file, and the file itself. Independent of whether the
|
||||
* element currently renders, so it survives every style write and moves only
|
||||
* when an identity attribute changes or the document's population of elements
|
||||
* sharing the selector does.
|
||||
*/
|
||||
/**
|
||||
* The selector an element is addressed by, or undefined when it cannot be a
|
||||
* layer at all. Sole owner of that rule: `isDomLayerElement` answers the same
|
||||
* question without paying for the occurrence index, and a second copy of the
|
||||
* conditions would go stale the first time one of them changed.
|
||||
*/
|
||||
function resolveLayerSelector(el: HTMLElement): string | undefined {
|
||||
if (el.hasAttribute("data-composition-id")) return undefined;
|
||||
return buildStableSelector(el);
|
||||
}
|
||||
|
||||
export function resolveDomLayerIdentity(
|
||||
el: HTMLElement,
|
||||
activeCompositionPath: string | null,
|
||||
): Pick<DomEditSelection, "id" | "hfId" | "selector" | "selectorIndex" | "sourceFile"> | null {
|
||||
if (!isInspectableLayerElement(el)) return null;
|
||||
if (el.hasAttribute("data-composition-id")) return null;
|
||||
|
||||
const selector = buildStableSelector(el);
|
||||
const selector = resolveLayerSelector(el);
|
||||
if (!selector) return null;
|
||||
|
||||
const { sourceFile } = getSourceFileForElement(el, activeCompositionPath);
|
||||
@@ -127,6 +148,26 @@ export function getDomLayerPatchTarget(
|
||||
};
|
||||
}
|
||||
|
||||
export function getDomLayerPatchTarget(
|
||||
el: HTMLElement,
|
||||
activeCompositionPath: string | null,
|
||||
): Pick<DomEditSelection, "id" | "hfId" | "selector" | "selectorIndex" | "sourceFile"> | null {
|
||||
return isInspectableLayerElement(el) ? resolveDomLayerIdentity(el, activeCompositionPath) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is `el` a layer at all? Exactly the condition under which
|
||||
* `getDomLayerPatchTarget` returns non-null, for callers that want the yes/no
|
||||
* and throw the target away.
|
||||
*
|
||||
* Worth its own function because the target carries the selector's OCCURRENCE
|
||||
* INDEX, and resolving that is a whole-document query — which the yes/no does
|
||||
* not depend on. Counting an element's layer children asked for one per child.
|
||||
*/
|
||||
export function isDomLayerElement(el: HTMLElement): boolean {
|
||||
return isInspectableLayerElement(el) && resolveLayerSelector(el) !== undefined;
|
||||
}
|
||||
|
||||
// ─── Clip ancestor / selection candidate ─────────────────────────────────────
|
||||
|
||||
function getPreferredClipAncestor(startEl: HTMLElement): HTMLElement | null {
|
||||
@@ -323,12 +364,10 @@ export function findElementForTimelineElement(
|
||||
|
||||
// ─── Layer children ───────────────────────────────────────────────────────────
|
||||
|
||||
export function getDirectLayerChildren(
|
||||
el: HTMLElement,
|
||||
options: DomEditContextOptions,
|
||||
): HTMLElement[] {
|
||||
/** `el`'s direct children that are layers. No longer takes the context options:
|
||||
* layer-ness does not depend on the active composition path. */
|
||||
export function getDirectLayerChildren(el: HTMLElement): HTMLElement[] {
|
||||
return Array.from(el.children).filter(
|
||||
(child): child is HTMLElement =>
|
||||
isHtmlElement(child) && getDomLayerPatchTarget(child, options.activeCompositionPath) !== null,
|
||||
(child): child is HTMLElement => isHtmlElement(child) && isDomLayerElement(child),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,12 +27,12 @@ import {
|
||||
} from "./domEditingDom";
|
||||
import {
|
||||
findElementForSelection,
|
||||
getDomLayerPatchTarget,
|
||||
getDirectLayerChildren,
|
||||
getSelectionCandidate,
|
||||
isDomLayerElement,
|
||||
} from "./domEditingElement";
|
||||
import { isCompositionRootLayer } from "./domEditingRootLayer";
|
||||
import { withSelectorIndexPass } from "../../utils/sourceScopedSelectorIndex";
|
||||
import { type DomEditLayerWalkCache, readDomEditLayerWalkEntry } from "./domEditLayerWalkCache";
|
||||
|
||||
export function isEditableTextLeaf(el: HTMLElement): boolean {
|
||||
return isTextBearingTag(el.tagName.toLowerCase()) && el.children.length === 0;
|
||||
@@ -422,7 +422,7 @@ export function countDomEditChildLayers(
|
||||
const visit = (el: HTMLElement) => {
|
||||
for (const child of Array.from(el.children)) {
|
||||
if (!isHtmlElement(child)) continue;
|
||||
if (getDomLayerPatchTarget(child, options.activeCompositionPath)) {
|
||||
if (isDomLayerElement(child)) {
|
||||
count += 1;
|
||||
if (count >= maxCount) return;
|
||||
}
|
||||
@@ -443,23 +443,26 @@ export function collectDomEditLayerItems(
|
||||
root: HTMLElement | null | undefined,
|
||||
options: DomEditContextOptions,
|
||||
maxItems = Number.POSITIVE_INFINITY,
|
||||
cache?: DomEditLayerWalkCache,
|
||||
): DomEditLayerItem[] {
|
||||
if (!root) return [];
|
||||
cache?.beginWalk(options.activeCompositionPath);
|
||||
|
||||
const items: DomEditLayerItem[] = [];
|
||||
// fallow-ignore-next-line complexity
|
||||
const visit = (el: HTMLElement, depth: number) => {
|
||||
if (items.length >= maxItems) return;
|
||||
|
||||
const target = getDomLayerPatchTarget(el, options.activeCompositionPath);
|
||||
if (target) {
|
||||
const entry = readDomEditLayerWalkEntry(el, options.activeCompositionPath, cache);
|
||||
if (entry) {
|
||||
const { target } = entry;
|
||||
items.push({
|
||||
key: getDomEditLayerKey(target),
|
||||
element: el,
|
||||
label: buildElementLabel(el),
|
||||
label: entry.label,
|
||||
tagName: el.tagName.toLowerCase(),
|
||||
depth,
|
||||
childCount: getDirectLayerChildren(el, options).length,
|
||||
childCount: entry.childCount,
|
||||
id: target.id ?? undefined,
|
||||
hfId: target.hfId ?? undefined,
|
||||
selector: target.selector ?? undefined,
|
||||
@@ -468,7 +471,7 @@ export function collectDomEditLayerItems(
|
||||
});
|
||||
}
|
||||
|
||||
const nextDepth = target ? depth + 1 : depth;
|
||||
const nextDepth = entry ? depth + 1 : depth;
|
||||
for (const child of Array.from(el.children)) {
|
||||
if (!isHtmlElement(child)) continue;
|
||||
visit(child, nextDepth);
|
||||
|
||||
+51
-1
@@ -13,6 +13,10 @@ afterEach(() => {
|
||||
interface Rebuild {
|
||||
/** querySelectorAll calls on the preview document, per selector. */
|
||||
queriesBySelector: Map<string, number>;
|
||||
/** querySelector (singular) calls on the preview document, per selector. This
|
||||
* is where the composition-root lookup that resolves the iframe→overlay
|
||||
* basis shows up. */
|
||||
singleQueriesBySelector: Map<string, number>;
|
||||
/** The indicator keys, which carry each element's selector occurrence index. */
|
||||
keys: string[];
|
||||
}
|
||||
@@ -58,6 +62,16 @@ function rebuildWithSharedSelector(cardCount: number): Rebuild {
|
||||
},
|
||||
});
|
||||
|
||||
const singleQueriesBySelector = new Map<string, number>();
|
||||
const realQuerySelector = doc.querySelector.bind(doc);
|
||||
Object.defineProperty(doc, "querySelector", {
|
||||
configurable: true,
|
||||
value: (selector: string) => {
|
||||
singleQueriesBySelector.set(selector, (singleQueriesBySelector.get(selector) ?? 0) + 1);
|
||||
return realQuerySelector(selector);
|
||||
},
|
||||
});
|
||||
|
||||
const sigRef = { current: "" } as React.MutableRefObject<string>;
|
||||
const elementsRef = { current: new Map<string, HTMLElement>() } as React.MutableRefObject<
|
||||
Map<string, HTMLElement>
|
||||
@@ -79,9 +93,14 @@ function rebuildWithSharedSelector(cardCount: number): Rebuild {
|
||||
|
||||
iframe.remove();
|
||||
overlay.remove();
|
||||
return { queriesBySelector, keys: rects.map((rect) => rect.key) };
|
||||
return { queriesBySelector, singleQueriesBySelector, keys: rects.map((rect) => rect.key) };
|
||||
}
|
||||
|
||||
/** The composition-root lookups a rebuild makes. `computeOverlayRootScale` and
|
||||
* `recomputeOffCanvasIndicators` each resolve the root once; nothing else in
|
||||
* the pass may. */
|
||||
const COMPOSITION_ROOT_SELECTOR = "[data-composition-id]";
|
||||
|
||||
/** The queries that resolve a class selector's occurrence index — the work this
|
||||
* guards. Excluded: the per-element `[data-composition-id]` ancestor lookup and
|
||||
* the stylesheet scan, which are linear per element and a separate seam. */
|
||||
@@ -115,3 +134,34 @@ describe("recomputeOffCanvasIndicators selector-index cost", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("recomputeOffCanvasIndicators composition-basis cost", () => {
|
||||
/**
|
||||
* The defect this guards: the iframe→overlay basis was resolved INSIDE
|
||||
* `orientedGroupAwareOverlayRect`, so every element in the preview paid its
|
||||
* own `querySelector("[data-composition-id]")` plus three layout reads to
|
||||
* rediscover a basis that is a property of the composition, not of the
|
||||
* element. On a real preview that is one lookup per element per rebuild.
|
||||
*
|
||||
* The basis is hoisted to the caller and threaded through, so the count is
|
||||
* a property of the COMPOSITION (one root, resolved twice: once for the walk
|
||||
* root, once for the basis) and cannot grow with the element count.
|
||||
*/
|
||||
it("resolves the composition root the same number of times at n and at 4n", () => {
|
||||
const small = rebuildWithSharedSelector(12);
|
||||
const large = rebuildWithSharedSelector(48);
|
||||
|
||||
expect(large.singleQueriesBySelector.get(COMPOSITION_ROOT_SELECTOR)).toEqual(
|
||||
small.singleQueriesBySelector.get(COMPOSITION_ROOT_SELECTOR),
|
||||
);
|
||||
// A ceiling as well as invariance, so a future caller that reintroduces a
|
||||
// per-element lookup fails here even if it happens to be element-count-flat.
|
||||
expect(small.singleQueriesBySelector.get(COMPOSITION_ROOT_SELECTOR)).toBe(2);
|
||||
});
|
||||
|
||||
// Non-vacuity guard for the assertion above: it only means something if the
|
||||
// fixture actually drives the per-element geometry path.
|
||||
it("measures a rebuild that really did resolve every card's rect", () => {
|
||||
expect(rebuildWithSharedSelector(12).keys).toHaveLength(12);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type React from "react";
|
||||
import type { OffCanvasRect } from "./OffCanvasIndicators";
|
||||
import { hugRectForElement } from "./domEditOverlayCrop";
|
||||
import { computeOverlayRootScale } from "./domEditOverlayBasis";
|
||||
import { orientedGroupAwareOverlayRect } from "./domEditOverlayGeometry";
|
||||
import { isElementComputedVisible } from "./domEditingElement";
|
||||
import type { DomEditLayerWalkCache } from "./domEditLayerWalkCache";
|
||||
import { collectDomEditLayerItems } from "./domEditingLayers";
|
||||
|
||||
function rounded(value: number): number {
|
||||
@@ -47,6 +49,9 @@ export function recomputeOffCanvasIndicators(
|
||||
sigRef: React.MutableRefObject<string>,
|
||||
elementsRef: React.MutableRefObject<Map<string, HTMLElement>>,
|
||||
setRects: (rects: OffCanvasRect[]) => void,
|
||||
/** Reuses the previous rebuild's per-element derivations for the elements no
|
||||
* mutation touched. Omitted (tests, one-off callers) => a full derivation. */
|
||||
walkCache?: DomEditLayerWalkCache,
|
||||
): void {
|
||||
if (comp.width <= 0 || !doc) {
|
||||
sigRef.current = "";
|
||||
@@ -57,10 +62,19 @@ export function recomputeOffCanvasIndicators(
|
||||
|
||||
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 items = collectDomEditLayerItems(
|
||||
root,
|
||||
{ activeCompositionPath: acp, isMasterView: !acp || acp === "index.html" },
|
||||
undefined,
|
||||
walkCache,
|
||||
);
|
||||
// The iframe→overlay basis is a property of the composition and the canvas
|
||||
// zoom, not of the element, and this loop neither writes to the DOM nor lets
|
||||
// the canvas move under it — so it is resolved ONCE here instead of inside
|
||||
// every `orientedGroupAwareOverlayRect` call. Unhoisted it cost a
|
||||
// `querySelector("[data-composition-id]")` plus three layout reads per item,
|
||||
// which on a preview of a few hundred elements is the bulk of the rebuild.
|
||||
const scale = computeOverlayRootScale(overlay, iframe, doc);
|
||||
const rects: OffCanvasRect[] = [];
|
||||
const elMap = new Map<string, HTMLElement>();
|
||||
for (const item of items) {
|
||||
@@ -69,7 +83,7 @@ export function recomputeOffCanvasIndicators(
|
||||
// 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 = orientedGroupAwareOverlayRect(overlay, iframe, item.element);
|
||||
const base = orientedGroupAwareOverlayRect(overlay, iframe, item.element, scale);
|
||||
const r = base ? { ...base, ...hugRectForElement(base, item.element) } : null;
|
||||
if (!r) continue;
|
||||
// Any edge crossing the composition border → gray-zone indicator (the
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import type React from "react";
|
||||
import type { OffCanvasRect } from "./OffCanvasIndicators";
|
||||
import {
|
||||
DOM_EDIT_LAYER_OBSERVER_INIT,
|
||||
type DomEditLayerWalkCache,
|
||||
createDomEditLayerWalkCache,
|
||||
drainPendingLayerMutations,
|
||||
} from "./domEditLayerWalkCache";
|
||||
import { recomputeOffCanvasIndicators } from "./offCanvasIndicatorGeometry";
|
||||
|
||||
interface OffCanvasIndicatorRefreshOptions {
|
||||
@@ -26,20 +32,29 @@ function clearIndicators(options: OffCanvasIndicatorRefreshOptions): void {
|
||||
options.setRects([]);
|
||||
}
|
||||
|
||||
function observeDoc(doc: Document, markDirty: () => void): MutationObserver | null {
|
||||
function observeDoc(
|
||||
doc: Document,
|
||||
cache: DomEditLayerWalkCache,
|
||||
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,
|
||||
// The same records do two jobs: they say a rebuild is owed, and they say which
|
||||
// elements the rebuild may not reuse. Reading them here rather than only
|
||||
// marking a boolean is what makes the rebuild cost the edit rather than the
|
||||
// document.
|
||||
const observer = new Observer((records) => {
|
||||
cache.ingest(records);
|
||||
markDirty();
|
||||
});
|
||||
// Unfiltered, unlike the boolean-dirty version this replaces, which watched
|
||||
// only ["style", "class", "transform", "width", "height", "data-hidden"]. An
|
||||
// attribute the cache never hears about is one it would answer stale for, and
|
||||
// the filter omitted `id` and the `data-composition-*` attributes that decide
|
||||
// a layer's identity. Widening only makes indicators refresh sooner: a rebuild
|
||||
// is a pure read of the DOM and is throttled by RECOMPUTE_INTERVAL_MS either
|
||||
// way. See DOM_EDIT_LAYER_OBSERVER_INIT for what each option is load-bearing for.
|
||||
observer.observe(doc.documentElement, DOM_EDIT_LAYER_OBSERVER_INIT);
|
||||
return observer;
|
||||
}
|
||||
|
||||
@@ -66,12 +81,18 @@ export function startOffCanvasIndicatorRefresh(
|
||||
let frame = 0;
|
||||
let lastCompSig = "";
|
||||
let lastRecomputeAt = Number.NEGATIVE_INFINITY;
|
||||
const walkCache = createDomEditLayerWalkCache();
|
||||
const markDirty = () => {
|
||||
options.dirtyRef.current = true;
|
||||
};
|
||||
const attachObserver = (doc: Document | null) => {
|
||||
options.observerRef.current?.disconnect();
|
||||
options.observerRef.current = doc?.documentElement ? observeDoc(doc, markDirty) : null;
|
||||
// A new preview document shares no elements with the old one, and nothing
|
||||
// reported the old one's teardown.
|
||||
walkCache.invalidateAll();
|
||||
options.observerRef.current = doc?.documentElement
|
||||
? observeDoc(doc, walkCache, markDirty)
|
||||
: null;
|
||||
options.observedDocRef.current = doc;
|
||||
options.sigRef.current = "";
|
||||
};
|
||||
@@ -99,6 +120,7 @@ export function startOffCanvasIndicatorRefresh(
|
||||
if (!rebuildDue(options.dirtyRef.current, lastRecomputeAt, now)) return;
|
||||
lastRecomputeAt = now;
|
||||
options.dirtyRef.current = false;
|
||||
drainPendingLayerMutations(options.observerRef.current, walkCache);
|
||||
recomputeOffCanvasIndicators(
|
||||
iframe,
|
||||
overlayEl,
|
||||
@@ -108,6 +130,7 @@ export function startOffCanvasIndicatorRefresh(
|
||||
options.sigRef,
|
||||
options.elementsRef,
|
||||
options.setRects,
|
||||
walkCache,
|
||||
);
|
||||
};
|
||||
frame = requestAnimationFrame(update);
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
selectionCacheKey,
|
||||
orientedVisibleOverlayRect,
|
||||
} from "./domEditOverlayGeometry";
|
||||
import { computeOverlayRootScale } from "./domEditOverlayBasis";
|
||||
|
||||
function childRectsEqual(a: OverlayRect[], b: OverlayRect[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
@@ -144,6 +145,14 @@ export function useDomEditOverlayRects({
|
||||
return;
|
||||
}
|
||||
|
||||
// One basis for the whole frame: the selection box, up to 60 child
|
||||
// outlines, every group box and the hover box all map through the same
|
||||
// iframe→overlay transform, and nothing below writes to the preview DOM
|
||||
// or moves the canvas. Resolved per call it was one
|
||||
// `querySelector("[data-composition-id]")` and three layout reads EACH,
|
||||
// every frame.
|
||||
const scale = computeOverlayRootScale(overlayEl, iframe, doc);
|
||||
|
||||
if (sel) {
|
||||
const el = resolveElementForOverlay(
|
||||
doc,
|
||||
@@ -164,7 +173,7 @@ export function useDomEditOverlayRects({
|
||||
// (a cheap per-call check) and only pays for the full corner-transform
|
||||
// measurement when the element is actually rotated — this RAF loop runs
|
||||
// every frame for any single selection, so that gate matters here most.
|
||||
const nextRect = orientedGroupAwareOverlayRect(overlayEl, iframe, el);
|
||||
const nextRect = orientedGroupAwareOverlayRect(overlayEl, iframe, el, scale);
|
||||
setOverlayRect(nextRect);
|
||||
const descendants = el.querySelectorAll("*");
|
||||
if (descendants.length > 0 && descendants.length <= 60) {
|
||||
@@ -174,7 +183,7 @@ export function useDomEditOverlayRects({
|
||||
if (!child.getBoundingClientRect) continue;
|
||||
// Oriented, not axis-aligned: a child of a rotated element drew its
|
||||
// outline square around the rotated glyphs instead of on them.
|
||||
const r = orientedVisibleOverlayRect(overlayEl, iframe, child);
|
||||
const r = orientedVisibleOverlayRect(overlayEl, iframe, child, scale);
|
||||
if (r && r.width > 2 && r.height > 2) nextChildRects.push(r);
|
||||
}
|
||||
if (!childRectsEqual(childRectsRef.current, nextChildRects)) {
|
||||
@@ -213,7 +222,7 @@ export function useDomEditOverlayRects({
|
||||
if (liveGroupKeys.has(key)) continue;
|
||||
liveGroupKeys.add(key);
|
||||
const el = resolveGroupElement(doc, groupSelection);
|
||||
const base = el ? groupAwareOverlayRect(overlayEl, iframe, el) : null;
|
||||
const base = el ? groupAwareOverlayRect(overlayEl, iframe, el, scale) : null;
|
||||
const rect = base && el ? { ...base, ...hugRectForElement(base, el) } : base;
|
||||
if (el && rect)
|
||||
nextGroupItems.push({ key, selection: groupSelection, element: el, rect });
|
||||
@@ -251,7 +260,7 @@ export function useDomEditOverlayRects({
|
||||
return;
|
||||
}
|
||||
|
||||
setHoverRect(orientedGroupAwareOverlayRect(overlayEl, iframe, hoverEl));
|
||||
setHoverRect(orientedGroupAwareOverlayRect(overlayEl, iframe, hoverEl, scale));
|
||||
};
|
||||
|
||||
frame = requestAnimationFrame(update);
|
||||
|
||||
Reference in New Issue
Block a user