@hyperframes/studio 0.8.33 → 0.8.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/dist/assets/{hyperframes-player-CNhkujVy.js → hyperframes-player-lPOqAC3l.js} +1 -1
  2. package/dist/assets/{index-DOoo96Tb.js → index-BRr1JoHX.js} +182 -172
  3. package/dist/assets/{index-CslAQ3JV.js → index-RPEcu_bT.js} +1 -1
  4. package/dist/assets/{index-DiStlnx7.js → index-ywAULfGT.js} +1 -1
  5. package/dist/{chunk-A7S5SGNA.js → chunk-7BP32G62.js} +294 -53
  6. package/dist/chunk-7BP32G62.js.map +1 -0
  7. package/dist/{domEditingLayers-JMMVOGTO.js → domEditingLayers-UKMVYXT6.js} +2 -2
  8. package/dist/index.html +1 -1
  9. package/dist/index.js +369 -220
  10. package/dist/index.js.map +1 -1
  11. package/package.json +8 -8
  12. package/src/App.tsx +6 -4
  13. package/src/captions/components/CaptionOverlay.sender.test.tsx +47 -0
  14. package/src/captions/components/CaptionOverlay.tsx +1 -0
  15. package/src/captions/generator.test.ts +30 -0
  16. package/src/captions/generator.ts +5 -3
  17. package/src/captions/hooks/useCaptionSync.ts +3 -2
  18. package/src/components/MediaPreview.tsx +2 -1
  19. package/src/components/editor/AnimationCard.test.tsx +3 -3
  20. package/src/components/editor/PropertyPanel.test.tsx +18 -14
  21. package/src/components/editor/SnapGuideOverlay.tsx +2 -6
  22. package/src/components/editor/domEditLayerWalkCache.test.ts +284 -0
  23. package/src/components/editor/domEditLayerWalkCache.ts +327 -0
  24. package/src/components/editor/domEditOverlayBasis.ts +72 -0
  25. package/src/components/editor/domEditOverlayGeometry.ts +78 -83
  26. package/src/components/editor/domEditOverlayMeasurePass.ts +60 -0
  27. package/src/components/editor/domEditOverlayTransform.ts +47 -9
  28. package/src/components/editor/domEditingDom.ts +63 -14
  29. package/src/components/editor/domEditingElement.ts +56 -14
  30. package/src/components/editor/domEditingLayers.test.ts +48 -0
  31. package/src/components/editor/domEditingLayers.ts +22 -35
  32. package/src/components/editor/offCanvasIndicatorGeometry.complexity.test.ts +373 -0
  33. package/src/components/editor/offCanvasIndicatorGeometry.ts +50 -11
  34. package/src/components/editor/offCanvasIndicatorRefresh.ts +43 -16
  35. package/src/components/editor/overlayFrameLoop.test.ts +246 -0
  36. package/src/components/editor/overlayFrameLoop.ts +204 -0
  37. package/src/components/editor/probeSourceElement.ts +29 -0
  38. package/src/components/editor/propertyPanelFill.tsx +2 -1
  39. package/src/components/editor/propertyPanelFlatEffectsSection.test.tsx +2 -2
  40. package/src/components/editor/useDomEditCompositionRect.ts +2 -4
  41. package/src/components/editor/useDomEditOverlayRects.ts +15 -9
  42. package/src/components/editor/useMotionPathData.ts +2 -4
  43. package/src/components/feedback/projectProvenance.ts +4 -1
  44. package/src/components/nle/NLEContext.tsx +2 -1
  45. package/src/components/nle/PreviewOverlays.tsx +1 -1
  46. package/src/components/nle/useCompositionStack.ts +11 -4
  47. package/src/components/renders/RenderQueue.test.tsx +2 -2
  48. package/src/components/renders/RenderQueueItem.tsx +2 -1
  49. package/src/components/renders/useRenderQueue.ts +3 -2
  50. package/src/components/sidebar/AssetsTab.tsx +2 -1
  51. package/src/components/sidebar/CompositionsTab.tsx +2 -1
  52. package/src/components/storyboard/FramePoster.tsx +2 -1
  53. package/src/hooks/useAskAgentModal.ts +2 -1
  54. package/src/hooks/useCaptionDetection.sender.test.tsx +54 -0
  55. package/src/hooks/useCaptionDetection.ts +3 -1
  56. package/src/hooks/useCompositionContentLoader.ts +3 -1
  57. package/src/hooks/useCompositionDimensions.test.tsx +47 -0
  58. package/src/hooks/useCompositionDimensions.ts +3 -2
  59. package/src/hooks/useDomEditCommits.ts +5 -4
  60. package/src/hooks/useElementLifecycleOps.ts +5 -1
  61. package/src/hooks/useFileTree.ts +5 -4
  62. package/src/hooks/useGroupCommits.ts +2 -1
  63. package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +2 -2
  64. package/src/hooks/useLintModal.ts +2 -1
  65. package/src/hooks/useRenderClipContent.ts +6 -5
  66. package/src/hooks/useTimelineDeleteOps.ts +5 -1
  67. package/src/player/components/Player.tsx +6 -4
  68. package/src/player/components/TimelineAutomationLane.test.tsx +9 -9
  69. package/src/player/components/thumbnailUtils.ts +6 -2
  70. package/src/player/components/timelineDragDrop.test.tsx +3 -3
  71. package/src/player/lib/timelineIframeHelpers.test.ts +36 -0
  72. package/src/player/lib/timelineIframeHelpers.ts +17 -1
  73. package/src/test-setup.ts +8 -0
  74. package/src/utils/blockInstaller.ts +2 -1
  75. package/src/utils/projectRouting.test.ts +9 -0
  76. package/src/utils/projectRouting.ts +18 -1
  77. package/src/utils/razorSplitTransaction.test.ts +3 -3
  78. package/src/utils/sourceScopedSelectorIndex.test.ts +148 -0
  79. package/src/utils/sourceScopedSelectorIndex.ts +64 -6
  80. package/src/utils/studioFileHistory.ts +2 -1
  81. package/src/utils/studioHelpers.test.ts +5 -1
  82. package/src/utils/studioHelpers.ts +3 -2
  83. package/src/utils/timelineCompositionInsert.test.ts +3 -3
  84. package/src/webmcp/types.ts +5 -1
  85. package/src/webmcp/useStudioAgentTools.polyfill.test.tsx +219 -0
  86. package/src/webmcp/useStudioAgentTools.test.tsx +81 -67
  87. package/src/webmcp/useStudioAgentTools.ts +49 -28
  88. package/src/webmcp/webmcpTestUtils.ts +56 -0
  89. package/dist/chunk-A7S5SGNA.js.map +0 -1
  90. /package/dist/{domEditingLayers-JMMVOGTO.js.map → domEditingLayers-UKMVYXT6.js.map} +0 -0
@@ -0,0 +1,327 @@
1
+ /**
2
+ * Incremental memory for `collectDomEditLayerItems`.
3
+ *
4
+ * The layer walk is re-run from scratch every time a MutationObserver says the
5
+ * preview changed, and the observer's loudest source is inline `style` — which
6
+ * is exactly what animation writes. So a paused-looking editor re-derived every
7
+ * element's patch target, label and child count several times a second, and each
8
+ * derivation costs two `getComputedStyle` reads (its own plus one per direct
9
+ * child), two ancestor walks for the source file, and a `textContent` read whose
10
+ * cost is the element's whole subtree.
11
+ *
12
+ * None of that depends on the traversal — only on the element. So the traversal
13
+ * stays live (it is pointer-chasing, and `depth` genuinely is positional) and the
14
+ * per-element half is memoized, with the mutation records deciding what to drop.
15
+ *
16
+ * WHAT MAKES A CACHED ENTRY WRONG, and how each is caught:
17
+ *
18
+ * - the element's own attributes (`style` -> computed display, `class`/`id`/
19
+ * `data-hf-group` -> selector and label) -> attribute record on it
20
+ * - an ancestor's `visibility`, the ONLY inherited input to the
21
+ * display/visibility gate (`display:none` on an ancestor does not change a
22
+ * descendant's own computed `display`) -> attribute record on the
23
+ * ancestor, whose subtree is
24
+ * dropped only when the
25
+ * style declares visibility
26
+ * - a direct child appearing or disappearing from the layer list, which moves
27
+ * the parent's `childCount` -> the parent is dropped
28
+ * alongside every element
29
+ * - descendant text, which moves an ancestor's label -> characterData record,
30
+ * ancestors dropped
31
+ * - selector occurrence indices, which shift for EVERY element sharing a
32
+ * selector when one element's identity changes -> whole cache dropped
33
+ * - nodes added or removed, which shift indices, order, labels and counts at
34
+ * once and cannot be attributed to one element -> whole cache dropped
35
+ * - a different active composition path, or a new composition-id -> source-file
36
+ * map, both of which re-scope every source file -> checked per walk
37
+ *
38
+ * The bias is deliberate and matches the duration cache in the runtime: a
39
+ * redundant recomputation is a missed optimisation, a missed one is a wrong
40
+ * layer tree.
41
+ */
42
+
43
+ import { buildElementLabel, getCompositionSourceMapRevision, isHtmlElement } from "./domEditingDom";
44
+ import {
45
+ getDirectLayerChildren,
46
+ isInspectableLayerElement,
47
+ resolveDomLayerIdentity,
48
+ } from "./domEditingElement";
49
+ import type { DomEditSelection } from "./domEditingTypes";
50
+
51
+ /** How an element is addressed. Survives style writes; see `readIdentity`. */
52
+ export type DomEditLayerIdentity = Pick<
53
+ DomEditSelection,
54
+ "id" | "hfId" | "selector" | "selectorIndex" | "sourceFile"
55
+ >;
56
+
57
+ /** Whether the element renders, and how many of its direct children are layers.
58
+ * Both are computed style reads, and both a style write can flip. Filled
59
+ * lazily: `childCount` is only asked for once an element is known to be one. */
60
+ interface PresenceMemo {
61
+ inspectable?: boolean;
62
+ childCount?: number;
63
+ }
64
+
65
+ interface IdentityMemo {
66
+ identity?: DomEditLayerIdentity | null;
67
+ label?: string;
68
+ }
69
+
70
+ /**
71
+ * Attributes that decide an element's SELECTOR, and therefore the occurrence
72
+ * index of every other element sharing that selector. One of these changing
73
+ * renumbers elements the record does not mention, so the only sound response is
74
+ * to drop everything. `class` is here because `buildStableSelector` falls back
75
+ * to the preferred class.
76
+ */
77
+ const SELECTOR_IDENTITY_ATTRIBUTES = new Set([
78
+ "id",
79
+ "class",
80
+ "data-hf-group",
81
+ "data-composition-id",
82
+ "data-composition-file",
83
+ "data-composition-src",
84
+ "data-hf-original-composition-id",
85
+ ]);
86
+
87
+ /**
88
+ * The observer configuration the cache's invalidation is written against.
89
+ * Exported so the refresh loop and the tests cannot drift from it.
90
+ *
91
+ * No `attributeFilter`: an unlisted attribute is one the cache would never learn
92
+ * about, and serving a stale entry is worse than the extra records.
93
+ */
94
+ export const DOM_EDIT_LAYER_OBSERVER_INIT: MutationObserverInit = {
95
+ attributes: true,
96
+ characterData: true,
97
+ childList: true,
98
+ subtree: true,
99
+ };
100
+
101
+ /**
102
+ * The inline `visibility` each element last presented. Deliberately NOT cleared
103
+ * by `invalidateAll`: it records what the DOM said, not a value derived from it.
104
+ */
105
+ const lastInlineVisibility = new WeakMap<HTMLElement, string>();
106
+
107
+ /**
108
+ * Did this style write move the element's inline `visibility`?
109
+ *
110
+ * `visibility` is inherited, so changing it changes the layer-list membership of
111
+ * the element's whole subtree, and only then is the subtree worth dropping.
112
+ *
113
+ * The comparison is on the PARSED value, remembered per element. Testing the
114
+ * attribute TEXT for "visibility" looks equivalent and is not: the standard
115
+ * GSAP fade (`autoAlpha`) writes `visibility: visible` once and leaves it in the
116
+ * attribute for the rest of the clip, so a text test stays true for every
117
+ * transform tick afterwards. That is a whole-subtree walk per animation frame,
118
+ * in the observer callback rather than behind the rebuild throttle, on exactly
119
+ * the elements a composition animates most.
120
+ *
121
+ * An element seen for the first time reports changed, which costs one drop and
122
+ * is the safe answer: its entries were derived before this write.
123
+ */
124
+ function inlineVisibilityChanged(el: HTMLElement): boolean {
125
+ const next = el.style.visibility;
126
+ if (lastInlineVisibility.get(el) === next) return false;
127
+ lastInlineVisibility.set(el, next);
128
+ return true;
129
+ }
130
+
131
+ export interface DomEditLayerWalkCache {
132
+ /**
133
+ * Start a walk. Drops everything when the scoping inputs the entries were
134
+ * built under no longer hold. Call once per `collectDomEditLayerItems`.
135
+ */
136
+ beginWalk(activeCompositionPath: string | null): void;
137
+ /**
138
+ * Does `el` render? Cheap to invalidate and invalidated often: any attribute
139
+ * write on the element, on its parent, or a `visibility` write on an ancestor
140
+ * drops it, because all three can flip the answer.
141
+ */
142
+ readInspectable(el: HTMLElement, compute: () => boolean): boolean;
143
+ /** How many of `el`'s direct children are layers. Same lifetime as
144
+ * `readInspectable`: it is a sum over their answers. */
145
+ readChildCount(el: HTMLElement, compute: () => number): number;
146
+ /**
147
+ * `el`'s selector, occurrence index and source file. A SEPARATE, much longer
148
+ * lifetime than presence: a style write cannot renumber a selector, so this
149
+ * survives one, and it is the only part of the walk that queries the
150
+ * document. That split is what makes an ordinary animation frame cost no
151
+ * document queries at all.
152
+ */
153
+ readIdentity(
154
+ el: HTMLElement,
155
+ compute: () => DomEditLayerIdentity | null,
156
+ ): DomEditLayerIdentity | null;
157
+ /** `el`'s display label. Shares the identity lifetime, plus descendant text. */
158
+ readLabel(el: HTMLElement, compute: () => string): string;
159
+ /** Apply mutation records. Safe to call with an empty array. */
160
+ ingest(records: MutationRecord[]): void;
161
+ /** Forget every entry — the fallback for anything not attributable. */
162
+ invalidateAll(): void;
163
+ }
164
+
165
+ export function createDomEditLayerWalkCache(): DomEditLayerWalkCache {
166
+ let presence = new WeakMap<HTMLElement, PresenceMemo>();
167
+ let identities = new WeakMap<HTMLElement, IdentityMemo>();
168
+ let scope: string | null = null;
169
+
170
+ const invalidateAll = () => {
171
+ presence = new WeakMap();
172
+ identities = new WeakMap();
173
+ };
174
+
175
+ const drop = (el: HTMLElement | null) => {
176
+ if (el) presence.delete(el);
177
+ };
178
+
179
+ const dropSubtree = (el: HTMLElement) => {
180
+ for (const descendant of el.querySelectorAll("*")) {
181
+ if (isHtmlElement(descendant)) presence.delete(descendant);
182
+ }
183
+ };
184
+
185
+ const ingestOne = (record: MutationRecord): boolean => {
186
+ // Structural change: order, occurrence indices, labels and child counts all
187
+ // move at once, and the record names the parent rather than everything
188
+ // affected. This is the documented full-rebuild fallback.
189
+ if (record.type === "childList") return false;
190
+
191
+ if (record.type === "characterData") {
192
+ // `buildElementLabel` falls back to `textContent`, so edited text changes
193
+ // the label of every ancestor that contains it.
194
+ for (
195
+ let ancestor = record.target.parentElement;
196
+ ancestor;
197
+ ancestor = ancestor.parentElement
198
+ ) {
199
+ identities.delete(ancestor);
200
+ }
201
+ return true;
202
+ }
203
+
204
+ const target = record.target;
205
+ if (!isHtmlElement(target)) return true;
206
+ if (record.attributeName && SELECTOR_IDENTITY_ATTRIBUTES.has(record.attributeName))
207
+ return false;
208
+
209
+ drop(target);
210
+ // The parent counts its layer children, and this element may have just
211
+ // joined or left that count.
212
+ drop(target.parentElement);
213
+ if (record.attributeName === "style" && inlineVisibilityChanged(target)) dropSubtree(target);
214
+ return true;
215
+ };
216
+
217
+ return {
218
+ beginWalk(activeCompositionPath) {
219
+ const nextScope = `${activeCompositionPath ?? ""}|${getCompositionSourceMapRevision()}`;
220
+ if (nextScope === scope) return;
221
+ scope = nextScope;
222
+ invalidateAll();
223
+ },
224
+ readInspectable(el, compute) {
225
+ const memo = presence.get(el) ?? {};
226
+ if (memo.inspectable === undefined) {
227
+ memo.inspectable = compute();
228
+ presence.set(el, memo);
229
+ }
230
+ return memo.inspectable;
231
+ },
232
+ readChildCount(el, compute) {
233
+ const memo = presence.get(el) ?? {};
234
+ if (memo.childCount === undefined) {
235
+ memo.childCount = compute();
236
+ presence.set(el, memo);
237
+ }
238
+ return memo.childCount;
239
+ },
240
+ readIdentity(el, compute) {
241
+ const memo = identities.get(el) ?? {};
242
+ if (memo.identity === undefined) {
243
+ memo.identity = compute();
244
+ identities.set(el, memo);
245
+ }
246
+ return memo.identity;
247
+ },
248
+ readLabel(el, compute) {
249
+ const memo = identities.get(el) ?? {};
250
+ if (memo.label === undefined) {
251
+ memo.label = compute();
252
+ identities.set(el, memo);
253
+ }
254
+ return memo.label;
255
+ },
256
+ ingest(records) {
257
+ for (const record of records) {
258
+ if (!ingestOne(record)) {
259
+ invalidateAll();
260
+ return;
261
+ }
262
+ }
263
+ },
264
+ invalidateAll,
265
+ };
266
+ }
267
+
268
+ /** One element's contribution to a layer walk. `depth` is absent on purpose: it
269
+ * belongs to the traversal, not to the element. */
270
+ export interface DomEditLayerWalkEntry {
271
+ target: DomEditLayerIdentity;
272
+ label: string;
273
+ childCount: number;
274
+ }
275
+
276
+ /**
277
+ * Everything `collectDomEditLayerItems` needs about ONE element, served from
278
+ * `cache` where it is still valid. Null when the element is not a layer.
279
+ *
280
+ * The four reads go through the cache separately rather than as one record
281
+ * because they do not go stale together: a style write flips whether an element
282
+ * renders without touching how it is addressed. Without a cache this is exactly
283
+ * the original per-element derivation, which is what every other caller of the
284
+ * walk still gets.
285
+ *
286
+ * Lives here rather than in the walk so the two halves of the memoization —
287
+ * what is stored and what is read — sit in one file.
288
+ */
289
+ export function readDomEditLayerWalkEntry(
290
+ el: HTMLElement,
291
+ activeCompositionPath: string | null,
292
+ cache?: DomEditLayerWalkCache,
293
+ ): DomEditLayerWalkEntry | null {
294
+ const inspectable = cache
295
+ ? cache.readInspectable(el, () => isInspectableLayerElement(el))
296
+ : isInspectableLayerElement(el);
297
+ if (!inspectable) return null;
298
+
299
+ const identity = () => resolveDomLayerIdentity(el, activeCompositionPath);
300
+ const target = cache ? cache.readIdentity(el, identity) : identity();
301
+ if (!target) return null;
302
+
303
+ const label = () => buildElementLabel(el);
304
+ const childCount = () => getDirectLayerChildren(el).length;
305
+ return {
306
+ target,
307
+ label: cache ? cache.readLabel(el, label) : label(),
308
+ childCount: cache ? cache.readChildCount(el, childCount) : childCount(),
309
+ };
310
+ }
311
+
312
+ /**
313
+ * Apply the records the observer has taken in but not yet delivered.
314
+ *
315
+ * Records arrive in a microtask, so an edit made earlier in THIS task would
316
+ * otherwise be read back against entries that predate it. Taking them suppresses
317
+ * the observer's own callback for them, which is equivalent here: that callback
318
+ * only ingests and marks a rebuild owed, and a caller draining is rebuilding
319
+ * regardless. A caller with no observer yet has nothing pending.
320
+ */
321
+ export function drainPendingLayerMutations(
322
+ observer: MutationObserver | null,
323
+ cache: DomEditLayerWalkCache,
324
+ ): void {
325
+ if (!observer) return;
326
+ cache.ingest(observer.takeRecords());
327
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The iframe→overlay coordinate basis: everything needed to map a rect measured
3
+ * inside the preview document into the Studio overlay's own coordinates.
4
+ *
5
+ * It is a property of the COMPOSITION and the canvas zoom, not of any element,
6
+ * so a caller measuring many elements in one synchronous pass resolves it once
7
+ * and threads it through the geometry functions in `domEditOverlayGeometry`.
8
+ * Resolved per element it costs a `querySelector("[data-composition-id]")` plus
9
+ * three layout reads each.
10
+ *
11
+ * Its own module because it is the one piece of that file every other piece
12
+ * depends on and nothing in it is about a single element's geometry.
13
+ */
14
+
15
+ /** iframe→overlay mapping basis shared by every overlay-geometry function. */
16
+ export interface OverlayRootScale {
17
+ iframeRect: DOMRect;
18
+ overlayRect: DOMRect;
19
+ rootScaleX: number;
20
+ rootScaleY: number;
21
+ }
22
+
23
+ export function readPositiveDimension(value: string | null): number | null {
24
+ if (!value) return null;
25
+ const parsed = Number.parseFloat(value);
26
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
27
+ }
28
+
29
+ /** The composition root element inside the preview doc (or null when absent). */
30
+ function findOverlayRootElement(doc: Document | null): HTMLElement | null {
31
+ return doc?.querySelector<HTMLElement>("[data-composition-id]") ?? doc?.documentElement ?? null;
32
+ }
33
+
34
+ /**
35
+ * The root's effective width/height for scaling: prefer the composition's
36
+ * declared dimensions (data-width/data-height), which stay fixed while GSAP
37
+ * transforms mutate the measured rect; fall back to the measured rect. Null when
38
+ * unmeasurable.
39
+ */
40
+ function resolveRootDimensions(root: HTMLElement | null): { width: number; height: number } | null {
41
+ if (!root) return null;
42
+ const rootRect = root.getBoundingClientRect();
43
+ const width = readPositiveDimension(root.getAttribute("data-width")) ?? rootRect.width;
44
+ const height = readPositiveDimension(root.getAttribute("data-height")) ?? rootRect.height;
45
+ if (!width || !height) return null;
46
+ return { width, height };
47
+ }
48
+
49
+ /**
50
+ * The iframe/overlay client rects and the iframe→root scale factors. Uses the
51
+ * composition's declared dimensions (data-width/data-height) for the scale
52
+ * instead of rootRect.width/height: when GSAP applies transforms (scale,
53
+ * translate) to the root, rootRect dimensions change but the composition's
54
+ * canonical size stays fixed, and using rootRect misaligns the overlay during
55
+ * animated playback. Returns null when the geometry is unmeasurable.
56
+ */
57
+ export function computeOverlayRootScale(
58
+ overlayEl: HTMLDivElement,
59
+ iframe: HTMLIFrameElement,
60
+ doc: Document | null,
61
+ ): OverlayRootScale | null {
62
+ const iframeRect = iframe.getBoundingClientRect();
63
+ const overlayRect = overlayEl.getBoundingClientRect();
64
+ const dims = resolveRootDimensions(findOverlayRootElement(doc));
65
+ if (!dims) return null;
66
+ return {
67
+ iframeRect,
68
+ overlayRect,
69
+ rootScaleX: iframeRect.width / dims.width,
70
+ rootScaleY: iframeRect.height / dims.height,
71
+ };
72
+ }
@@ -1,7 +1,13 @@
1
1
  import { type DomEditSelection, findElementForSelection } from "./domEditing";
2
+ import {
3
+ computeOverlayRootScale,
4
+ type OverlayRootScale,
5
+ readPositiveDimension,
6
+ } from "./domEditOverlayBasis";
2
7
  import { isElementVisibleThroughAncestors } from "./domEditingDom";
3
8
  import { hugRectForElement } from "./domEditOverlayCrop";
4
9
  import { composeElementTransform, type PlanarTransformOps } from "./domEditOverlayTransform";
10
+ import { type OverlayMeasurePass, readThroughPass } from "./domEditOverlayMeasurePass";
5
11
 
6
12
  export interface OverlayRect {
7
13
  left: number;
@@ -47,24 +53,28 @@ export function isElementVisibleForOverlay(el: HTMLElement): boolean {
47
53
  // shapes (rectangular cards, text, full-bleed media) don't have interior holes, so this
48
54
  // doesn't bite. If ring/cutout shapes become editable targets, sample more densely or
49
55
  // hit-test against the element's actual painted geometry instead of its bounding box.
50
- function readPositiveDimension(value: string | null): number | null {
51
- if (!value) return null;
52
- const parsed = Number.parseFloat(value);
53
- return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
54
- }
55
-
56
- function findSourceBoundary(element: HTMLElement): HTMLElement | null {
57
- let current: HTMLElement | null = element;
58
- while (current) {
59
- if (
60
- current.hasAttribute("data-composition-file") ||
61
- current.hasAttribute("data-composition-src")
62
- ) {
63
- return current;
56
+ const isSourceBoundary = (node: HTMLElement): boolean =>
57
+ node.hasAttribute("data-composition-file") || node.hasAttribute("data-composition-src");
58
+
59
+ /** With a `pass`, every node on the way up is memoized rather than only the
60
+ * element asked about: an element's boundary IS its parent's unless it is one
61
+ * itself, so siblings share the walk instead of each repeating it. */
62
+ function findSourceBoundary(element: HTMLElement, pass?: OverlayMeasurePass): HTMLElement | null {
63
+ const pending: HTMLElement[] = [];
64
+ let boundary: HTMLElement | null | undefined;
65
+ for (let node: HTMLElement | null = element; node; node = node.parentElement) {
66
+ boundary = pass?.sourceBoundary.get(node);
67
+ if (boundary !== undefined) break;
68
+ if (isSourceBoundary(node)) {
69
+ boundary = node;
70
+ pass?.sourceBoundary.set(node, node);
71
+ break;
64
72
  }
65
- current = current.parentElement;
73
+ pending.push(node);
66
74
  }
67
- return null;
75
+ const answer = boundary ?? null;
76
+ if (pass) for (const node of pending) pass.sourceBoundary.set(node, answer);
77
+ return answer;
68
78
  }
69
79
 
70
80
  export function resolveDomEditCoordinateScale(input: {
@@ -102,8 +112,9 @@ export function toVisibleOverlayRect(
102
112
  overlayEl: HTMLDivElement,
103
113
  iframe: HTMLIFrameElement,
104
114
  element: HTMLElement,
115
+ precomputedScale?: OverlayRootScale | null,
105
116
  ): OverlayRect | null {
106
- const rect = toOverlayRect(overlayEl, iframe, element);
117
+ const rect = toOverlayRect(overlayEl, iframe, element, precomputedScale);
107
118
  return rect ? { ...rect, ...hugRectForElement(rect, element) } : null;
108
119
  }
109
120
 
@@ -157,6 +168,7 @@ interface ElementTransformSnapshot {
157
168
  function readElementTransformSnapshot(
158
169
  win: Window,
159
170
  element: HTMLElement,
171
+ pass?: OverlayMeasurePass,
160
172
  ): ElementTransformSnapshot | null {
161
173
  const DOMMatrixCtor = (win as Window & typeof globalThis).DOMMatrix;
162
174
  if (!DOMMatrixCtor) return null;
@@ -170,8 +182,11 @@ function readElementTransformSnapshot(
170
182
  compose: (outer, inner) => outer.multiply(inner),
171
183
  };
172
184
  try {
173
- const matrix = composeElementTransform(element, ops, (node) =>
174
- node === element ? cs : win.getComputedStyle(node),
185
+ const matrix = composeElementTransform(
186
+ element,
187
+ ops,
188
+ (node) => (node === element ? cs : win.getComputedStyle(node)),
189
+ pass?.transform,
175
190
  );
176
191
  return matrix ? { matrix, cs } : null;
177
192
  } catch {
@@ -206,64 +221,12 @@ function rotationDegreesFromMatrix(matrix: DOMMatrix): number {
206
221
  * matrix-decomposition floating-point noise, never an actual rotation. */
207
222
  const ROTATION_GATE_EPSILON_DEG = 1e-4;
208
223
 
209
- /** iframe→overlay mapping basis shared by every overlay-geometry function. */
210
- interface OverlayRootScale {
211
- iframeRect: DOMRect;
212
- overlayRect: DOMRect;
213
- rootScaleX: number;
214
- rootScaleY: number;
215
- }
216
-
217
- /** The composition root element inside the preview doc (or null when absent). */
218
- function findOverlayRootElement(doc: Document | null): HTMLElement | null {
219
- return doc?.querySelector<HTMLElement>("[data-composition-id]") ?? doc?.documentElement ?? null;
220
- }
221
-
222
- /**
223
- * The root's effective width/height for scaling: prefer the composition's
224
- * declared dimensions (data-width/data-height), which stay fixed while GSAP
225
- * transforms mutate the measured rect; fall back to the measured rect. Null when
226
- * unmeasurable.
227
- */
228
- function resolveRootDimensions(root: HTMLElement | null): { width: number; height: number } | null {
229
- if (!root) return null;
230
- const rootRect = root.getBoundingClientRect();
231
- const width = readPositiveDimension(root.getAttribute("data-width")) ?? rootRect.width;
232
- const height = readPositiveDimension(root.getAttribute("data-height")) ?? rootRect.height;
233
- if (!width || !height) return null;
234
- return { width, height };
235
- }
236
-
237
- /**
238
- * The iframe/overlay client rects and the iframe→root scale factors. Uses the
239
- * composition's declared dimensions (data-width/data-height) for the scale
240
- * instead of rootRect.width/height: when GSAP applies transforms (scale,
241
- * translate) to the root, rootRect dimensions change but the composition's
242
- * canonical size stays fixed, and using rootRect misaligns the overlay during
243
- * animated playback. Returns null when the geometry is unmeasurable.
244
- */
245
- function computeOverlayRootScale(
246
- overlayEl: HTMLDivElement,
247
- iframe: HTMLIFrameElement,
248
- doc: Document | null,
249
- ): OverlayRootScale | null {
250
- const iframeRect = iframe.getBoundingClientRect();
251
- const overlayRect = overlayEl.getBoundingClientRect();
252
- const dims = resolveRootDimensions(findOverlayRootElement(doc));
253
- if (!dims) return null;
254
- return {
255
- iframeRect,
256
- overlayRect,
257
- rootScaleX: iframeRect.width / dims.width,
258
- rootScaleY: iframeRect.height / dims.height,
259
- };
260
- }
261
-
262
224
  function toOverlayRect(
263
225
  overlayEl: HTMLDivElement,
264
226
  iframe: HTMLIFrameElement,
265
227
  element: HTMLElement,
266
228
  precomputedScale?: OverlayRootScale | null,
229
+ pass?: OverlayMeasurePass,
267
230
  ): OverlayRect | null {
268
231
  const scale =
269
232
  precomputedScale ?? computeOverlayRootScale(overlayEl, iframe, iframe.contentDocument);
@@ -271,8 +234,15 @@ function toOverlayRect(
271
234
  const { iframeRect, overlayRect, rootScaleX, rootScaleY } = scale;
272
235
 
273
236
  const elementRect = element.getBoundingClientRect();
274
- const sourceBoundary = findSourceBoundary(element);
275
- const sourceBoundaryRect = sourceBoundary?.getBoundingClientRect();
237
+ const sourceBoundary = findSourceBoundary(element, pass);
238
+ // Every element inside one sub-composition shares this boundary, so its rect
239
+ // is one layout read per boundary rather than one per element.
240
+ const sourceBoundaryRect =
241
+ sourceBoundary && pass
242
+ ? readThroughPass(pass.sourceBoundaryRect, sourceBoundary, () =>
243
+ sourceBoundary.getBoundingClientRect(),
244
+ )
245
+ : sourceBoundary?.getBoundingClientRect();
276
246
  const editScale = resolveDomEditCoordinateScale({
277
247
  rootScaleX,
278
248
  rootScaleY,
@@ -415,14 +385,17 @@ export function orientedOverlayRect(
415
385
  overlayEl: HTMLDivElement,
416
386
  iframe: HTMLIFrameElement,
417
387
  element: HTMLElement,
388
+ precomputedScale?: OverlayRootScale | null,
389
+ pass?: OverlayMeasurePass,
418
390
  ): OverlayRect | null {
419
- const scale = computeOverlayRootScale(overlayEl, iframe, iframe.contentDocument);
391
+ const scale =
392
+ precomputedScale ?? computeOverlayRootScale(overlayEl, iframe, iframe.contentDocument);
420
393
  if (!scale) return null;
421
- const base = toOverlayRect(overlayEl, iframe, element, scale);
394
+ const base = toOverlayRect(overlayEl, iframe, element, scale, pass);
422
395
  if (!base) return null;
423
396
 
424
397
  const win = iframe.contentWindow;
425
- const transform = win ? readElementTransformSnapshot(win, element) : null;
398
+ const transform = win ? readElementTransformSnapshot(win, element, pass) : null;
426
399
  const angle = transform ? rotationDegreesFromMatrix(transform.matrix) : 0;
427
400
  if (Math.abs(angle) < ROTATION_GATE_EPSILON_DEG) return base;
428
401
 
@@ -460,8 +433,9 @@ export function orientedVisibleOverlayRect(
460
433
  overlayEl: HTMLDivElement,
461
434
  iframe: HTMLIFrameElement,
462
435
  element: HTMLElement,
436
+ precomputedScale?: OverlayRootScale | null,
463
437
  ): OverlayRect | null {
464
- const rect = orientedOverlayRect(overlayEl, iframe, element);
438
+ const rect = orientedOverlayRect(overlayEl, iframe, element, precomputedScale);
465
439
  return rect ? { ...rect, ...hugRectForElement(rect, element) } : null;
466
440
  }
467
441
 
@@ -531,8 +505,10 @@ export function groupAwareOverlayRect(
531
505
  overlayEl: HTMLDivElement,
532
506
  iframe: HTMLIFrameElement,
533
507
  el: HTMLElement,
508
+ precomputedScale?: OverlayRootScale | null,
509
+ pass?: OverlayMeasurePass,
534
510
  ): OverlayRect | null {
535
- const rect = toOverlayRect(overlayEl, iframe, el);
511
+ const rect = toOverlayRect(overlayEl, iframe, el, precomputedScale, pass);
536
512
  if (!rect || !el.hasAttribute("data-hf-group")) return rect;
537
513
  // Union the MEMBERS' rendered rects — where the content actually is — not the
538
514
  // wrapper's own box. The wrapper is invisible and its box can sit apart from the
@@ -540,7 +516,13 @@ export function groupAwareOverlayRect(
540
516
  // group's bounds (and its off-canvas marker) off to a stale position.
541
517
  const rects: OverlayRect[] = [];
542
518
  for (const child of Array.from(el.children)) {
543
- const childRect = toOverlayRect(overlayEl, iframe, child as HTMLElement);
519
+ const childRect = toOverlayRect(
520
+ overlayEl,
521
+ iframe,
522
+ child as HTMLElement,
523
+ precomputedScale,
524
+ pass,
525
+ );
544
526
  if (childRect) rects.push(childRect);
545
527
  }
546
528
  const union = rects.length > 0 ? resolveDomEditGroupOverlayRect(rects) : null;
@@ -551,15 +533,28 @@ export function groupAwareOverlayRect(
551
533
  return { ...union, editScaleX: rect.editScaleX, editScaleY: rect.editScaleY };
552
534
  }
553
535
 
554
- /** Groups stay axis-aligned unions; ordinary elements keep their oriented box. */
536
+ /**
537
+ * Groups stay axis-aligned unions; ordinary elements keep their oriented box.
538
+ *
539
+ * `precomputedScale` is the iframe→overlay basis from `computeOverlayRootScale`.
540
+ * Without it every call resolves the composition root itself — one
541
+ * `querySelector("[data-composition-id]")` plus three `getBoundingClientRect`
542
+ * reads PER ELEMENT — and a caller measuring a whole preview therefore pays that
543
+ * once per element rather than once per composition. The basis is a property of
544
+ * the composition and the canvas zoom, not of the element, so a caller that
545
+ * measures many elements in one synchronous pass resolves it once and threads it
546
+ * through. See `toVisibleOverlayRects` for the same batching in miniature.
547
+ */
555
548
  export function orientedGroupAwareOverlayRect(
556
549
  overlayEl: HTMLDivElement,
557
550
  iframe: HTMLIFrameElement,
558
551
  el: HTMLElement,
552
+ precomputedScale?: OverlayRootScale | null,
553
+ pass?: OverlayMeasurePass,
559
554
  ): OverlayRect | null {
560
555
  return el.hasAttribute("data-hf-group")
561
- ? groupAwareOverlayRect(overlayEl, iframe, el)
562
- : orientedOverlayRect(overlayEl, iframe, el);
556
+ ? groupAwareOverlayRect(overlayEl, iframe, el, precomputedScale, pass)
557
+ : orientedOverlayRect(overlayEl, iframe, el, precomputedScale, pass);
563
558
  }
564
559
 
565
560
  export function filterNestedDomEditGroupItems<T extends { element: HTMLElement }>(items: T[]): T[] {