@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,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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
function findSourceBoundary(element: HTMLElement): HTMLElement | null {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
) {
|
|
63
|
-
|
|
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
|
-
|
|
73
|
+
pending.push(node);
|
|
66
74
|
}
|
|
67
|
-
|
|
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(
|
|
174
|
-
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
/**
|
|
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[] {
|