@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.
- package/dist/assets/{hyperframes-player-CroojEG1.js → hyperframes-player-D4aSRr_M.js} +1 -1
- package/dist/assets/{index-PVOehm7h.js → index-CkxPQxLJ.js} +182 -172
- package/dist/assets/{index-CSyaU1m2.js → index-aNyQBoJv.js} +1 -1
- package/dist/assets/{index-D0AjUHNT.js → index-hch4ktcC.js} +1 -1
- package/dist/{chunk-A7S5SGNA.js → chunk-7BP32G62.js} +294 -53
- package/dist/chunk-7BP32G62.js.map +1 -0
- package/dist/{domEditingLayers-JMMVOGTO.js → domEditingLayers-UKMVYXT6.js} +2 -2
- package/dist/index.html +1 -1
- package/dist/index.js +354 -206
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/App.tsx +6 -4
- package/src/captions/components/CaptionOverlay.sender.test.tsx +47 -0
- package/src/captions/components/CaptionOverlay.tsx +1 -0
- package/src/captions/generator.test.ts +30 -0
- package/src/captions/generator.ts +5 -3
- package/src/captions/hooks/useCaptionSync.ts +3 -2
- package/src/components/MediaPreview.tsx +2 -1
- package/src/components/editor/AnimationCard.test.tsx +3 -3
- package/src/components/editor/PropertyPanel.test.tsx +18 -14
- package/src/components/editor/SnapGuideOverlay.tsx +2 -6
- package/src/components/editor/domEditLayerWalkCache.test.ts +284 -0
- package/src/components/editor/domEditLayerWalkCache.ts +327 -0
- package/src/components/editor/domEditOverlayBasis.ts +72 -0
- package/src/components/editor/domEditOverlayGeometry.ts +78 -83
- package/src/components/editor/domEditOverlayMeasurePass.ts +60 -0
- package/src/components/editor/domEditOverlayTransform.ts +47 -9
- package/src/components/editor/domEditingDom.ts +63 -14
- package/src/components/editor/domEditingElement.ts +56 -14
- package/src/components/editor/domEditingLayers.test.ts +48 -0
- package/src/components/editor/domEditingLayers.ts +22 -35
- package/src/components/editor/offCanvasIndicatorGeometry.complexity.test.ts +373 -0
- package/src/components/editor/offCanvasIndicatorGeometry.ts +50 -11
- package/src/components/editor/offCanvasIndicatorRefresh.ts +43 -16
- package/src/components/editor/overlayFrameLoop.test.ts +246 -0
- package/src/components/editor/overlayFrameLoop.ts +204 -0
- package/src/components/editor/probeSourceElement.ts +29 -0
- package/src/components/editor/propertyPanelFill.tsx +2 -1
- package/src/components/editor/propertyPanelFlatEffectsSection.test.tsx +2 -2
- package/src/components/editor/useDomEditCompositionRect.ts +2 -4
- package/src/components/editor/useDomEditOverlayRects.ts +15 -9
- package/src/components/editor/useMotionPathData.ts +2 -4
- package/src/components/feedback/projectProvenance.ts +4 -1
- package/src/components/nle/NLEContext.tsx +2 -1
- package/src/components/nle/PreviewOverlays.tsx +1 -1
- package/src/components/nle/useCompositionStack.ts +11 -4
- package/src/components/renders/RenderQueue.test.tsx +2 -2
- package/src/components/renders/RenderQueueItem.tsx +2 -1
- package/src/components/renders/useRenderQueue.ts +3 -2
- package/src/components/sidebar/AssetsTab.tsx +2 -1
- package/src/components/sidebar/CompositionsTab.tsx +2 -1
- package/src/components/storyboard/FramePoster.tsx +2 -1
- package/src/hooks/useAskAgentModal.ts +2 -1
- package/src/hooks/useCaptionDetection.sender.test.tsx +54 -0
- package/src/hooks/useCaptionDetection.ts +3 -1
- package/src/hooks/useCompositionContentLoader.ts +3 -1
- package/src/hooks/useCompositionDimensions.test.tsx +47 -0
- package/src/hooks/useCompositionDimensions.ts +3 -2
- package/src/hooks/useDomEditCommits.ts +5 -4
- package/src/hooks/useElementLifecycleOps.ts +5 -1
- package/src/hooks/useFileTree.ts +5 -4
- package/src/hooks/useGroupCommits.ts +2 -1
- package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +2 -2
- package/src/hooks/useLintModal.ts +2 -1
- package/src/hooks/useRenderClipContent.ts +6 -5
- package/src/hooks/useTimelineDeleteOps.ts +5 -1
- package/src/player/components/Player.tsx +6 -4
- package/src/player/components/TimelineAutomationLane.test.tsx +9 -9
- package/src/player/components/thumbnailUtils.ts +6 -2
- package/src/player/components/timelineDragDrop.test.tsx +3 -3
- package/src/player/lib/timelineIframeHelpers.test.ts +36 -0
- package/src/player/lib/timelineIframeHelpers.ts +17 -1
- package/src/test-setup.ts +8 -0
- package/src/utils/blockInstaller.ts +2 -1
- package/src/utils/projectRouting.test.ts +9 -0
- package/src/utils/projectRouting.ts +18 -1
- package/src/utils/razorSplitTransaction.test.ts +3 -3
- package/src/utils/sourceScopedSelectorIndex.test.ts +148 -0
- package/src/utils/sourceScopedSelectorIndex.ts +64 -6
- package/src/utils/studioFileHistory.ts +2 -1
- package/src/utils/studioHelpers.test.ts +5 -1
- package/src/utils/studioHelpers.ts +3 -2
- package/src/utils/timelineCompositionInsert.test.ts +3 -3
- package/dist/chunk-A7S5SGNA.js.map +0 -1
- /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(
|
|
61
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
140
|
+
const unsubscribe = subscribeOverlayFrame(update);
|
|
114
141
|
return () => {
|
|
115
|
-
|
|
142
|
+
unsubscribe();
|
|
116
143
|
options.observerRef.current?.disconnect();
|
|
117
144
|
options.observerRef.current = null;
|
|
118
145
|
options.observedDocRef.current = null;
|