@hyperframes/studio 0.8.32 → 0.8.34

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 (85) hide show
  1. package/dist/assets/{hyperframes-player-CroojEG1.js → hyperframes-player-D4aSRr_M.js} +1 -1
  2. package/dist/assets/{index-PVOehm7h.js → index-CkxPQxLJ.js} +182 -172
  3. package/dist/assets/{index-CSyaU1m2.js → index-aNyQBoJv.js} +1 -1
  4. package/dist/assets/{index-D0AjUHNT.js → index-hch4ktcC.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 +354 -206
  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/dist/chunk-A7S5SGNA.js.map +0 -1
  85. /package/dist/{domEditingLayers-JMMVOGTO.js.map → domEditingLayers-UKMVYXT6.js.map} +0 -0
@@ -0,0 +1,373 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import type React from "react";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import type { OffCanvasRect } from "./OffCanvasIndicators";
6
+ import { recomputeOffCanvasIndicators } from "./offCanvasIndicatorGeometry";
7
+ import { DOM_EDIT_LAYER_OBSERVER_INIT, createDomEditLayerWalkCache } from "./domEditLayerWalkCache";
8
+
9
+ const realGetBoundingClientRect = Element.prototype.getBoundingClientRect;
10
+ afterEach(() => {
11
+ Element.prototype.getBoundingClientRect = realGetBoundingClientRect;
12
+ });
13
+
14
+ /** The composition frame every fixture here is measured against. */
15
+ const COMP = { left: 0, top: 0, width: 800, height: 450 };
16
+
17
+ interface Preview {
18
+ doc: Document;
19
+ /** One real rebuild through the production entry point. Returns the markers
20
+ * it produced. `observe` opts the walk cache and its observer in, which is
21
+ * how `startOffCanvasIndicatorRefresh` calls it. */
22
+ rebuild: () => OffCanvasRect[];
23
+ dispose: () => void;
24
+ }
25
+
26
+ /**
27
+ * A mounted preview wired to `recomputeOffCanvasIndicators` exactly as the
28
+ * refresh loop wires it.
29
+ *
30
+ * `rectFor` stands in for layout, which happy-dom does not do: return the box
31
+ * an element should report, or null for "measures empty". Every test here
32
+ * varies only the markup and that function, so the scaffolding lives once.
33
+ */
34
+ function mountPreview(
35
+ bodyHtml: string,
36
+ rectFor: (el: Element) => DOMRect | null,
37
+ options: { observe?: boolean } = {},
38
+ ): Preview {
39
+ const iframe = document.createElement("iframe");
40
+ document.body.append(iframe);
41
+ const doc = iframe.contentDocument;
42
+ if (!doc) throw new Error("Expected iframe content document");
43
+ doc.body.innerHTML = `<div data-composition-id="root" data-width="800" data-height="450">${bodyHtml}</div>`;
44
+
45
+ const overlay = document.createElement("div");
46
+ document.body.append(overlay);
47
+ Element.prototype.getBoundingClientRect = function (): DOMRect {
48
+ if (this === iframe || this === overlay) return new DOMRect(0, 0, 800, 450);
49
+ return rectFor(this) ?? new DOMRect(0, 0, 0, 0);
50
+ };
51
+
52
+ const cache = options.observe ? createDomEditLayerWalkCache() : undefined;
53
+ const observer = options.observe ? new MutationObserver(() => {}) : null;
54
+ observer?.observe(doc.documentElement, DOM_EDIT_LAYER_OBSERVER_INIT);
55
+
56
+ const sigRef = { current: "" } as React.MutableRefObject<string>;
57
+ const elementsRef = { current: new Map<string, HTMLElement>() } as React.MutableRefObject<
58
+ Map<string, HTMLElement>
59
+ >;
60
+ let rects: OffCanvasRect[] = [];
61
+ return {
62
+ doc,
63
+ rebuild: () => {
64
+ if (cache && observer) cache.ingest(observer.takeRecords());
65
+ recomputeOffCanvasIndicators(
66
+ iframe,
67
+ overlay,
68
+ doc,
69
+ COMP,
70
+ "index.html",
71
+ sigRef,
72
+ elementsRef,
73
+ (next) => {
74
+ rects = next;
75
+ },
76
+ cache,
77
+ );
78
+ return rects;
79
+ },
80
+ dispose: () => {
81
+ observer?.disconnect();
82
+ iframe.remove();
83
+ overlay.remove();
84
+ },
85
+ };
86
+ }
87
+
88
+ /** `cardCount` cards sharing `.box`, all off-canvas to the left. */
89
+ function cardsMarkup(cardCount: number): string {
90
+ return Array.from(
91
+ { length: cardCount },
92
+ (_unused, i) => `<div class="box"><span class="label">card ${i}</span></div>`,
93
+ ).join("");
94
+ }
95
+
96
+ /** Counts computed-style reads on a preview document while `run` executes. */
97
+ function countStyleReads(doc: Document, run: () => void): number {
98
+ const win = doc.defaultView!;
99
+ const real = win.getComputedStyle.bind(win);
100
+ let reads = 0;
101
+ win.getComputedStyle = ((el: Element, pseudo?: string | null) => (
102
+ (reads += 1), real(el, pseudo ?? undefined)
103
+ )) as typeof win.getComputedStyle;
104
+ try {
105
+ run();
106
+ } finally {
107
+ win.getComputedStyle = real;
108
+ }
109
+ return reads;
110
+ }
111
+
112
+ interface Rebuild {
113
+ /** querySelectorAll calls on the preview document, per selector. */
114
+ queriesBySelector: Map<string, number>;
115
+ /** querySelector (singular) calls on the preview document, per selector. This
116
+ * is where the composition-root lookup that resolves the iframe→overlay
117
+ * basis shows up. */
118
+ singleQueriesBySelector: Map<string, number>;
119
+ /** The indicator keys, which carry each element's selector occurrence index. */
120
+ keys: string[];
121
+ }
122
+
123
+ /**
124
+ * One real rebuild over a preview whose `cardCount` cards all share `.box`,
125
+ * counting the preview document's queries. Everything below the entry point is
126
+ * production code: the layer walk, the patch targets, and the selector
127
+ * occurrence indices that end up in each indicator's key.
128
+ */
129
+ function rebuildWithSharedSelector(cardCount: number): Rebuild {
130
+ // Cards sit left of the composition, so every one of them is off-canvas and
131
+ // reaches the indicator list; everything else measures empty and does not.
132
+ const preview = mountPreview(cardsMarkup(cardCount), (el) =>
133
+ el.classList.contains("box") ? new DOMRect(-500, 40, 100, 40) : null,
134
+ );
135
+ const { doc } = preview;
136
+
137
+ const queriesBySelector = new Map<string, number>();
138
+ const realQuerySelectorAll = doc.querySelectorAll.bind(doc);
139
+ Object.defineProperty(doc, "querySelectorAll", {
140
+ configurable: true,
141
+ value: (selector: string) => {
142
+ queriesBySelector.set(selector, (queriesBySelector.get(selector) ?? 0) + 1);
143
+ return realQuerySelectorAll(selector);
144
+ },
145
+ });
146
+
147
+ const singleQueriesBySelector = new Map<string, number>();
148
+ const realQuerySelector = doc.querySelector.bind(doc);
149
+ Object.defineProperty(doc, "querySelector", {
150
+ configurable: true,
151
+ value: (selector: string) => {
152
+ singleQueriesBySelector.set(selector, (singleQueriesBySelector.get(selector) ?? 0) + 1);
153
+ return realQuerySelector(selector);
154
+ },
155
+ });
156
+
157
+ const keys = preview.rebuild().map((rect) => rect.key);
158
+ preview.dispose();
159
+ return { queriesBySelector, singleQueriesBySelector, keys };
160
+ }
161
+
162
+ /** The composition-root lookups a rebuild makes. `computeOverlayRootScale` and
163
+ * `recomputeOffCanvasIndicators` each resolve the root once; nothing else in
164
+ * the pass may. */
165
+ const COMPOSITION_ROOT_SELECTOR = "[data-composition-id]";
166
+
167
+ /** The queries that resolve a class selector's occurrence index — the work this
168
+ * guards. Excluded: the per-element `[data-composition-id]` ancestor lookup and
169
+ * the stylesheet scan, which are linear per element and a separate seam. */
170
+ function classSelectorQueries(rebuild: Rebuild): Array<[string, number]> {
171
+ return [...rebuild.queriesBySelector].filter(([selector]) => selector.startsWith(".")).sort();
172
+ }
173
+
174
+ describe("recomputeOffCanvasIndicators selector-index cost", () => {
175
+ // The defect this guards: the occurrence index used to be resolved with its
176
+ // own whole-document querySelectorAll PER element, so n cards sharing a class
177
+ // cost n queries and n² source-file resolutions. A threshold would pass on a
178
+ // small fixture while a real composition runs hundreds of cards. Invariance
179
+ // across a 4x fixture cannot — it fails for any per-element term at all.
180
+ it("resolves shared selectors with the same number of queries at n and at 4n", () => {
181
+ const small = rebuildWithSharedSelector(12);
182
+ const large = rebuildWithSharedSelector(48);
183
+
184
+ expect(classSelectorQueries(large)).toEqual(classSelectorQueries(small));
185
+ expect(classSelectorQueries(small)).toEqual([
186
+ [".box", 1],
187
+ [".label", 1],
188
+ ]);
189
+ });
190
+
191
+ it("still numbers every shared-selector element in document order", () => {
192
+ for (const cardCount of [12, 48]) {
193
+ const { keys } = rebuildWithSharedSelector(cardCount);
194
+ expect(keys).toEqual(
195
+ Array.from({ length: cardCount }, (_unused, i) => `index.html:.box:${i}`),
196
+ );
197
+ }
198
+ });
199
+ });
200
+
201
+ describe("recomputeOffCanvasIndicators composition-basis cost", () => {
202
+ /**
203
+ * The defect this guards: the iframe→overlay basis was resolved INSIDE
204
+ * `orientedGroupAwareOverlayRect`, so every element in the preview paid its
205
+ * own `querySelector("[data-composition-id]")` plus three layout reads to
206
+ * rediscover a basis that is a property of the composition, not of the
207
+ * element. On a real preview that is one lookup per element per rebuild.
208
+ *
209
+ * The basis is hoisted to the caller and threaded through, so the count is
210
+ * a property of the COMPOSITION (one root, resolved twice: once for the walk
211
+ * root, once for the basis) and cannot grow with the element count.
212
+ */
213
+ it("resolves the composition root the same number of times at n and at 4n", () => {
214
+ const small = rebuildWithSharedSelector(12);
215
+ const large = rebuildWithSharedSelector(48);
216
+
217
+ expect(large.singleQueriesBySelector.get(COMPOSITION_ROOT_SELECTOR)).toEqual(
218
+ small.singleQueriesBySelector.get(COMPOSITION_ROOT_SELECTOR),
219
+ );
220
+ // A ceiling as well as invariance, so a future caller that reintroduces a
221
+ // per-element lookup fails here even if it happens to be element-count-flat.
222
+ expect(small.singleQueriesBySelector.get(COMPOSITION_ROOT_SELECTOR)).toBe(2);
223
+ });
224
+
225
+ // Non-vacuity guard for the assertion above: it only means something if the
226
+ // fixture actually drives the per-element geometry path.
227
+ it("measures a rebuild that really did resolve every card's rect", () => {
228
+ expect(rebuildWithSharedSelector(12).keys).toHaveLength(12);
229
+ });
230
+ });
231
+
232
+ /**
233
+ * A group's box is the union of its MEMBERS' rects, not its own. That makes it
234
+ * the one item whose measurement depends on elements BELOW it, so any scheme
235
+ * that reuses a wrapper's rect while a member moves shows a marker frozen in
236
+ * the member's old place — and nothing ever writes to the wrapper, so it would
237
+ * never repair.
238
+ */
239
+ describe("recomputeOffCanvasIndicators group measurement", () => {
240
+ it("re-measures a group when a member moves and nothing writes to the wrapper", () => {
241
+ // Only the member has a box; the wrapper measures empty, which is exactly
242
+ // the case the union exists for.
243
+ let memberLeft = -500;
244
+ const preview = mountPreview(
245
+ `<div id="grp" data-hf-group="Group"><div id="member" class="member"></div></div>`,
246
+ (el) => (el.id === "member" ? new DOMRect(memberLeft, 40, 100, 40) : null),
247
+ { observe: true },
248
+ );
249
+ const groupBox = () => {
250
+ const group = preview.rebuild().find((rect) => rect.key.includes("grp"));
251
+ return group ? `${group.left},${group.top},${group.width},${group.height}` : "absent";
252
+ };
253
+
254
+ const first = groupBox();
255
+ // What an animation frame does: one inline style write on the member.
256
+ memberLeft = -300;
257
+ preview.doc.getElementById("member")!.style.transform = "translateX(200px)";
258
+ const second = groupBox();
259
+ preview.dispose();
260
+
261
+ expect(first).not.toBe("absent");
262
+ expect(second).not.toBe(first);
263
+ });
264
+ });
265
+
266
+ /**
267
+ * Layout changes that emit no mutation record at all.
268
+ *
269
+ * An `<img>` finishing decode, a web font swapping in, a CSS transition or
270
+ * `@keyframes` frame, a container query re-evaluating, a
271
+ * `CSSStyleSheet.insertRule` — every one of them moves an element's border box
272
+ * with nothing written to the DOM. A rebuild that measures everything picks
273
+ * them up for free; anything that reuses a previous measurement cannot see
274
+ * them at all, and there is no record to invalidate on and no observer that
275
+ * reports all of them. This is why the pass is scoped to one rebuild.
276
+ */
277
+ describe("recomputeOffCanvasIndicators layout changes with no mutation record", () => {
278
+ it("re-measures an element whose own box changed with no DOM write", () => {
279
+ // Before decode the image lays out small; after decode it takes its
280
+ // intrinsic size. No attribute is written, no node added, no text changed.
281
+ let heroRect = new DOMRect(-500, 40, 100, 40);
282
+ const preview = mountPreview(
283
+ `<img id="hero" class="hero">`,
284
+ (el) => (el.id === "hero" ? heroRect : null),
285
+ { observe: true },
286
+ );
287
+ const heroBox = () => {
288
+ const marker = preview.rebuild().find((rect) => rect.key.includes("hero"));
289
+ return marker ? `${marker.left},${marker.top},${marker.width},${marker.height}` : "absent";
290
+ };
291
+
292
+ const first = heroBox();
293
+ heroRect = new DOMRect(-500, 40, 320, 180); // decode finished
294
+ const second = heroBox();
295
+ preview.dispose();
296
+
297
+ expect(first).toBe("-500,40,100,40");
298
+ expect(second).toBe("-500,40,320,180");
299
+ });
300
+ });
301
+
302
+ /**
303
+ * The other half of "the pass is scoped to one rebuild".
304
+ *
305
+ * The no-mutation-record test above varies an element's own BOX, which the pass
306
+ * never memoizes — so it would still pass if the pass leaked across rebuilds.
307
+ * This one varies an ANCESTOR-derived input, which is exactly what the pass
308
+ * does hold: fade a wrapper out and every descendant's visibility answer has to
309
+ * be recomputed, not served from the previous rebuild's memo.
310
+ */
311
+ describe("recomputeOffCanvasIndicators ancestor-derived state across rebuilds", () => {
312
+ it("re-answers visibility when an ancestor fades between rebuilds", () => {
313
+ const preview = mountPreview(
314
+ `<div id="wrap"><div class="box"></div></div>`,
315
+ (el) => (el.classList.contains("box") ? new DOMRect(-500, 40, 100, 40) : null),
316
+ { observe: true },
317
+ );
318
+
319
+ const visible = preview.rebuild().length;
320
+ // Nothing about the box changes — only what it inherits from above it.
321
+ preview.doc.getElementById("wrap")!.style.opacity = "0";
322
+ const faded = preview.rebuild().length;
323
+ preview.dispose();
324
+
325
+ expect(visible).toBe(1);
326
+ expect(faded).toBe(0);
327
+ });
328
+ });
329
+
330
+ /**
331
+ * The ancestor work, measured through the production entry point.
332
+ *
333
+ * Everything the rebuild asks about an ancestor — does it render, what does it
334
+ * contribute to the composed transform, is it the source-file boundary — is the
335
+ * same question for every element underneath it. The guard is INVARIANCE in
336
+ * depth: burying the same cards under more shared wrappers may cost one style
337
+ * read per added wrapper, and must not cost one per wrapper PER CARD.
338
+ */
339
+ describe("recomputeOffCanvasIndicators ancestor cost", () => {
340
+ /** Computed-style reads for one rebuild of `cardCount` cards buried under
341
+ * `depth` shared wrappers. */
342
+ function styleReadsAtDepth(cardCount: number, depth: number): number {
343
+ const open = Array.from({ length: depth }, (_unused, i) => `<div class="w${i}">`).join("");
344
+ const preview = mountPreview(
345
+ `${open}${cardsMarkup(cardCount)}${"</div>".repeat(depth)}`,
346
+ (el) => (el.classList.contains("box") ? new DOMRect(-500, 40, 100, 40) : null),
347
+ );
348
+ let markers = 0;
349
+ const reads = countStyleReads(preview.doc, () => {
350
+ markers = preview.rebuild().length;
351
+ });
352
+ preview.dispose();
353
+ // Non-vacuity, checked on every measurement rather than in its own test:
354
+ // the count only means something if the rebuild really did resolve every
355
+ // card's rect.
356
+ expect(markers).toBe(cardCount);
357
+ return reads;
358
+ }
359
+
360
+ /** What burying the same cards 8 wrappers deeper costs, at `cardCount`. */
361
+ const depthSurcharge = (cardCount: number): number =>
362
+ styleReadsAtDepth(cardCount, 10) - styleReadsAtDepth(cardCount, 2);
363
+
364
+ // The load-bearing assertion, and it is INVARIANCE rather than a threshold: a
365
+ // ceiling ("under 40 reads") passes on a small fixture and still degrades on
366
+ // a real preview. Eight wrappers are shared by every card, so what they cost
367
+ // is a property of the WRAPPERS. Asking each card about them separately makes
368
+ // it a property of wrappers x cards, and that is the thing that has to stay
369
+ // flat when the card count moves.
370
+ it("pays for a deeper tree per added ancestor, not per ancestor per card", () => {
371
+ expect(depthSurcharge(48)).toBe(depthSurcharge(24));
372
+ });
373
+ });
@@ -1,8 +1,11 @@
1
1
  import type React from "react";
2
2
  import type { OffCanvasRect } from "./OffCanvasIndicators";
3
3
  import { hugRectForElement } from "./domEditOverlayCrop";
4
+ import { computeOverlayRootScale, type OverlayRootScale } from "./domEditOverlayBasis";
5
+ import { createOverlayMeasurePass, type OverlayMeasurePass } from "./domEditOverlayMeasurePass";
4
6
  import { orientedGroupAwareOverlayRect } from "./domEditOverlayGeometry";
5
7
  import { isElementComputedVisible } from "./domEditingElement";
8
+ import type { DomEditLayerWalkCache } from "./domEditLayerWalkCache";
6
9
  import { collectDomEditLayerItems } from "./domEditingLayers";
7
10
 
8
11
  function rounded(value: number): number {
@@ -37,6 +40,28 @@ function extendsOutside(
37
40
  );
38
41
  }
39
42
 
43
+ /**
44
+ * One item's box in overlay coordinates, or null when it does not render.
45
+ *
46
+ * Every layout read the rebuild makes about a single element is in here, which
47
+ * is what lets the loop below hand all of them one measure pass.
48
+ */
49
+ function measureItemRect(
50
+ overlay: HTMLDivElement,
51
+ iframe: HTMLIFrameElement,
52
+ element: HTMLElement,
53
+ scale: OverlayRootScale | null,
54
+ pass: OverlayMeasurePass,
55
+ ): Omit<OffCanvasRect, "key"> | null {
56
+ if (!isElementComputedVisible(element, pass.visible)) return null;
57
+ // Groups use their members' union (where they actually render), so a group
58
+ // whose members sit inside the canvas isn't flagged off-canvas by a stale
59
+ // wrapper box. Crop-hug the result so an inset crop that keeps the visible
60
+ // part on-canvas doesn't flag the element either.
61
+ const base = orientedGroupAwareOverlayRect(overlay, iframe, element, scale, pass);
62
+ return base ? { ...base, ...hugRectForElement(base, element) } : null;
63
+ }
64
+
40
65
  // fallow-ignore-next-line complexity
41
66
  export function recomputeOffCanvasIndicators(
42
67
  iframe: HTMLIFrameElement,
@@ -47,6 +72,9 @@ export function recomputeOffCanvasIndicators(
47
72
  sigRef: React.MutableRefObject<string>,
48
73
  elementsRef: React.MutableRefObject<Map<string, HTMLElement>>,
49
74
  setRects: (rects: OffCanvasRect[]) => void,
75
+ /** Reuses the previous rebuild's per-element derivations for the elements no
76
+ * mutation touched. Omitted (tests, one-off callers) => a full derivation. */
77
+ walkCache?: DomEditLayerWalkCache,
50
78
  ): void {
51
79
  if (comp.width <= 0 || !doc) {
52
80
  sigRef.current = "";
@@ -57,20 +85,31 @@ export function recomputeOffCanvasIndicators(
57
85
 
58
86
  const root = doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.body;
59
87
  const acp = activeCompositionPath ?? "index.html";
60
- const items = collectDomEditLayerItems(root, {
61
- activeCompositionPath: acp,
62
- isMasterView: !acp || acp === "index.html",
63
- });
88
+ const items = collectDomEditLayerItems(
89
+ root,
90
+ { activeCompositionPath: acp, isMasterView: !acp || acp === "index.html" },
91
+ undefined,
92
+ walkCache,
93
+ );
94
+ // The iframe→overlay basis is a property of the composition and the canvas
95
+ // zoom, not of the element, and this loop neither writes to the DOM nor lets
96
+ // the canvas move under it — so it is resolved ONCE here instead of inside
97
+ // every `orientedGroupAwareOverlayRect` call. Unhoisted it cost a
98
+ // `querySelector("[data-composition-id]")` plus three layout reads per item,
99
+ // which on a preview of a few hundred elements is the bulk of the rebuild.
100
+ const scale = computeOverlayRootScale(overlay, iframe, doc);
101
+ // Every element below is measured against the same ancestors: the same
102
+ // visibility chain, the same composed transforms, the same source-file
103
+ // boundary. One pass answers each of those questions once per NODE instead
104
+ // of once per node per descendant. It measures everything every time — it is
105
+ // not a cache and nothing in it survives this call — so a layout change that
106
+ // emits no mutation record (an image decoding, a font swapping, a CSS
107
+ // transition frame) is picked up here exactly as it was before.
108
+ const pass = createOverlayMeasurePass();
64
109
  const rects: OffCanvasRect[] = [];
65
110
  const elMap = new Map<string, HTMLElement>();
66
111
  for (const item of items) {
67
- if (!isElementComputedVisible(item.element)) continue;
68
- // Groups use their members' union (where they actually render), so a group
69
- // whose members sit inside the canvas isn't flagged off-canvas by a stale
70
- // wrapper box. Crop-hug the result so an inset crop that keeps the visible
71
- // part on-canvas doesn't flag the element either.
72
- const base = orientedGroupAwareOverlayRect(overlay, iframe, item.element);
73
- const r = base ? { ...base, ...hugRectForElement(base, item.element) } : null;
112
+ const r = measureItemRect(overlay, iframe, item.element, scale, pass);
74
113
  if (!r) continue;
75
114
  // Any edge crossing the composition border → gray-zone indicator (the
76
115
  // in-canvas portion is clipped away below, so only the sliver shows).
@@ -1,6 +1,13 @@
1
1
  import type React from "react";
2
2
  import type { OffCanvasRect } from "./OffCanvasIndicators";
3
+ import {
4
+ DOM_EDIT_LAYER_OBSERVER_INIT,
5
+ type DomEditLayerWalkCache,
6
+ createDomEditLayerWalkCache,
7
+ drainPendingLayerMutations,
8
+ } from "./domEditLayerWalkCache";
3
9
  import { recomputeOffCanvasIndicators } from "./offCanvasIndicatorGeometry";
10
+ import { requestOverlayFrames, subscribeOverlayFrame } from "./overlayFrameLoop";
4
11
 
5
12
  interface OffCanvasIndicatorRefreshOptions {
6
13
  iframeRef: React.RefObject<HTMLIFrameElement | null>;
@@ -26,20 +33,29 @@ function clearIndicators(options: OffCanvasIndicatorRefreshOptions): void {
26
33
  options.setRects([]);
27
34
  }
28
35
 
29
- function observeDoc(doc: Document, markDirty: () => void): MutationObserver | null {
36
+ function observeDoc(
37
+ doc: Document,
38
+ cache: DomEditLayerWalkCache,
39
+ markDirty: () => void,
40
+ ): MutationObserver | null {
30
41
  const Observer = doc.defaultView?.MutationObserver ?? globalThis.MutationObserver;
31
42
  if (!Observer) return null;
32
- const observer = new Observer(markDirty);
33
- observer.observe(doc.documentElement, {
34
- attributes: true,
35
- // data-hidden is included explicitly: hiding an element writes data-hidden, and
36
- // although the runtime honoring also writes display:"none" (a style mutation we'd
37
- // catch anyway), keying on the attribute directly makes the coupling robust to any
38
- // future throttling of that runtime sync.
39
- attributeFilter: ["style", "class", "transform", "width", "height", "data-hidden"],
40
- childList: true,
41
- subtree: true,
43
+ // The same records do two jobs: they say a rebuild is owed, and they say which
44
+ // elements the rebuild may not reuse. Reading them here rather than only
45
+ // marking a boolean is what makes the rebuild cost the edit rather than the
46
+ // document.
47
+ const observer = new Observer((records) => {
48
+ cache.ingest(records);
49
+ markDirty();
42
50
  });
51
+ // Unfiltered, unlike the boolean-dirty version this replaces, which watched
52
+ // only ["style", "class", "transform", "width", "height", "data-hidden"]. An
53
+ // attribute the cache never hears about is one it would answer stale for, and
54
+ // the filter omitted `id` and the `data-composition-*` attributes that decide
55
+ // a layer's identity. Widening only makes indicators refresh sooner: a rebuild
56
+ // is a pure read of the DOM and is throttled by RECOMPUTE_INTERVAL_MS either
57
+ // way. See DOM_EDIT_LAYER_OBSERVER_INIT for what each option is load-bearing for.
58
+ observer.observe(doc.documentElement, DOM_EDIT_LAYER_OBSERVER_INIT);
43
59
  return observer;
44
60
  }
45
61
 
@@ -63,20 +79,29 @@ export function rebuildDue(dirty: boolean, lastAt: number, now: number): boolean
63
79
  export function startOffCanvasIndicatorRefresh(
64
80
  options: OffCanvasIndicatorRefreshOptions,
65
81
  ): () => void {
66
- let frame = 0;
67
82
  let lastCompSig = "";
68
83
  let lastRecomputeAt = Number.NEGATIVE_INFINITY;
84
+ const walkCache = createDomEditLayerWalkCache();
69
85
  const markDirty = () => {
70
86
  options.dirtyRef.current = true;
87
+ // A rebuild is owed, so the shared loop has to be running to pay it. The
88
+ // preview's own MutationObserver is the wake source the top frame's
89
+ // pointer and message listeners cannot be: an edit inside the iframe
90
+ // reaches this callback and nothing else.
91
+ requestOverlayFrames();
71
92
  };
72
93
  const attachObserver = (doc: Document | null) => {
73
94
  options.observerRef.current?.disconnect();
74
- options.observerRef.current = doc?.documentElement ? observeDoc(doc, markDirty) : null;
95
+ // A new preview document shares no elements with the old one, and nothing
96
+ // reported the old one's teardown.
97
+ walkCache.invalidateAll();
98
+ options.observerRef.current = doc?.documentElement
99
+ ? observeDoc(doc, walkCache, markDirty)
100
+ : null;
75
101
  options.observedDocRef.current = doc;
76
102
  options.sigRef.current = "";
77
103
  };
78
104
  const update = () => {
79
- frame = requestAnimationFrame(update);
80
105
  const iframe = options.iframeRef.current;
81
106
  const overlayEl = options.overlayRef.current;
82
107
  const doc = iframe?.contentDocument ?? null;
@@ -99,6 +124,7 @@ export function startOffCanvasIndicatorRefresh(
99
124
  if (!rebuildDue(options.dirtyRef.current, lastRecomputeAt, now)) return;
100
125
  lastRecomputeAt = now;
101
126
  options.dirtyRef.current = false;
127
+ drainPendingLayerMutations(options.observerRef.current, walkCache);
102
128
  recomputeOffCanvasIndicators(
103
129
  iframe,
104
130
  overlayEl,
@@ -108,11 +134,12 @@ export function startOffCanvasIndicatorRefresh(
108
134
  options.sigRef,
109
135
  options.elementsRef,
110
136
  options.setRects,
137
+ walkCache,
111
138
  );
112
139
  };
113
- frame = requestAnimationFrame(update);
140
+ const unsubscribe = subscribeOverlayFrame(update);
114
141
  return () => {
115
- cancelAnimationFrame(frame);
142
+ unsubscribe();
116
143
  options.observerRef.current?.disconnect();
117
144
  options.observerRef.current = null;
118
145
  options.observedDocRef.current = null;