@hyperframes/studio 0.8.33 → 0.8.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-CNhkujVy.js → hyperframes-player-lPOqAC3l.js} +1 -1
- package/dist/assets/{index-DOoo96Tb.js → index-BRr1JoHX.js} +182 -172
- package/dist/assets/{index-CslAQ3JV.js → index-RPEcu_bT.js} +1 -1
- package/dist/assets/{index-DiStlnx7.js → index-ywAULfGT.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 +369 -220
- 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/src/webmcp/types.ts +5 -1
- package/src/webmcp/useStudioAgentTools.polyfill.test.tsx +219 -0
- package/src/webmcp/useStudioAgentTools.test.tsx +81 -67
- package/src/webmcp/useStudioAgentTools.ts +49 -28
- package/src/webmcp/webmcpTestUtils.ts +56 -0
- package/dist/chunk-A7S5SGNA.js.map +0 -1
- /package/dist/{domEditingLayers-JMMVOGTO.js.map → domEditingLayers-UKMVYXT6.js.map} +0 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared memory for ONE synchronous pass that measures many elements.
|
|
3
|
+
*
|
|
4
|
+
* The off-canvas indicator overlay re-measures every element in the preview
|
|
5
|
+
* several times a second, and almost all of that work is per-ANCESTOR, not
|
|
6
|
+
* per-element: a visibility read for every node up to the root, a transform
|
|
7
|
+
* composed over the same nodes, and a walk for the source-file boundary. Two
|
|
8
|
+
* siblings share their entire chain above themselves, so a preview of a
|
|
9
|
+
* thousand elements asked the platform the same questions about the same
|
|
10
|
+
* ancestors a thousand times.
|
|
11
|
+
*
|
|
12
|
+
* WHY THIS IS A PASS AND NOT A CACHE. A cache has to answer "what could have
|
|
13
|
+
* changed since last time", and for a MEASUREMENT the honest answer is
|
|
14
|
+
* "anything": an `<img>` finishing decode, a web font swapping in, a CSS
|
|
15
|
+
* transition frame, a container query, a `CSSStyleSheet.insertRule` — each one
|
|
16
|
+
* moves an element's box with nothing written to the DOM, so no mutation
|
|
17
|
+
* record exists to invalidate on and no observer reports all of them. A pass
|
|
18
|
+
* sidesteps the question instead of answering it wrong: it lives inside one
|
|
19
|
+
* synchronous measurement that only READS, so nothing can move under it, and
|
|
20
|
+
* it is dropped when the pass ends. Every rebuild still measures every
|
|
21
|
+
* element, exactly as it did before; it just stops asking the same question
|
|
22
|
+
* about the same ancestor once per descendant.
|
|
23
|
+
*
|
|
24
|
+
* Never store one of these across a rebuild, an await, or a frame.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** The composed transform type the overlay's corner math uses. */
|
|
28
|
+
type OverlayTransform = DOMMatrix;
|
|
29
|
+
|
|
30
|
+
export interface OverlayMeasurePass {
|
|
31
|
+
/** Does this node render, given everything above it? */
|
|
32
|
+
visible: Map<HTMLElement, boolean>;
|
|
33
|
+
/** This node's transform composed with every ancestor's, up to the
|
|
34
|
+
* composition root. `null` is an answer: some node's transform is
|
|
35
|
+
* unusable. */
|
|
36
|
+
transform: Map<HTMLElement, OverlayTransform | null>;
|
|
37
|
+
/** The nearest ancestor carrying a source-file boundary, or null. */
|
|
38
|
+
sourceBoundary: Map<HTMLElement, HTMLElement | null>;
|
|
39
|
+
/** That boundary's client rect, which is shared by everything inside it. */
|
|
40
|
+
sourceBoundaryRect: Map<HTMLElement, DOMRect>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createOverlayMeasurePass(): OverlayMeasurePass {
|
|
44
|
+
return {
|
|
45
|
+
visible: new Map(),
|
|
46
|
+
transform: new Map(),
|
|
47
|
+
sourceBoundary: new Map(),
|
|
48
|
+
sourceBoundaryRect: new Map(),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Read through a pass's map, filling it on the way. `undefined` is the only
|
|
53
|
+
* miss, so a memoized `null` stays an answer. */
|
|
54
|
+
export function readThroughPass<K, V>(memo: Map<K, V>, key: K, compute: () => V): V {
|
|
55
|
+
const answered = memo.get(key);
|
|
56
|
+
if (answered !== undefined) return answered;
|
|
57
|
+
const value = compute();
|
|
58
|
+
memo.set(key, value);
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
@@ -58,6 +58,22 @@ export function individualRotateDegrees(value: string | undefined): number {
|
|
|
58
58
|
return Number.isFinite(deg) ? deg : 0;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/** One node's own contribution, with the individual properties applied before
|
|
62
|
+
* `transform` the way CSS does. Null when the node's transform is unusable. */
|
|
63
|
+
function ownNodeTransform<M>(
|
|
64
|
+
node: HTMLElement,
|
|
65
|
+
ops: PlanarTransformOps<M>,
|
|
66
|
+
getStyle: (node: HTMLElement) => CSSStyleDeclaration | null,
|
|
67
|
+
): M | null {
|
|
68
|
+
const style = getStyle(node);
|
|
69
|
+
if (!style) return null;
|
|
70
|
+
const transform = style.transform;
|
|
71
|
+
const own = transform && transform !== "none" ? ops.fromTransform(transform) : ops.identity();
|
|
72
|
+
if (!own) return null;
|
|
73
|
+
const spin = individualRotateDegrees(style.rotate);
|
|
74
|
+
return spin === 0 ? own : ops.compose(ops.fromRotate(spin), own);
|
|
75
|
+
}
|
|
76
|
+
|
|
61
77
|
/**
|
|
62
78
|
* The element's transform composed with every ancestor's, up to the composition
|
|
63
79
|
* root.
|
|
@@ -66,23 +82,45 @@ export function individualRotateDegrees(value: string | undefined): number {
|
|
|
66
82
|
* `rotate` composes on the left of it. Between nodes, an ancestor applies
|
|
67
83
|
* outside its child. Null means some node's transform was unusable and the
|
|
68
84
|
* caller should fall back rather than guess.
|
|
85
|
+
*
|
|
86
|
+
* `memo` holds each node's COMPOSED chain, for a caller walking many elements
|
|
87
|
+
* in one synchronous pass.
|
|
88
|
+
*
|
|
89
|
+
* A chain is `chain(parent)` composed with the node's own, so siblings share
|
|
90
|
+
* everything above them and the whole tree costs one style read and one
|
|
91
|
+
* compose per node instead of one per node PER DESCENDANT. Valid only for the
|
|
92
|
+
* length of one pass, which is why the caller owns it: nothing here writes to
|
|
93
|
+
* the DOM, so nothing can move under it, and it is dropped before anything
|
|
94
|
+
* else runs. Omitted, every call composes its own chain from scratch.
|
|
69
95
|
*/
|
|
70
96
|
export function composeElementTransform<M>(
|
|
71
97
|
element: HTMLElement,
|
|
72
98
|
ops: PlanarTransformOps<M>,
|
|
73
99
|
getStyle: (node: HTMLElement) => CSSStyleDeclaration | null,
|
|
100
|
+
memo?: Map<HTMLElement, M | null>,
|
|
74
101
|
): M | null {
|
|
75
|
-
|
|
102
|
+
const pending: HTMLElement[] = [];
|
|
103
|
+
// `undefined` means nothing on the way up was already composed, so the chain
|
|
104
|
+
// starts from identity. A memoized `null` is an answer, not a miss: some node
|
|
105
|
+
// above carries a transform this algebra cannot represent.
|
|
106
|
+
let above: M | null | undefined;
|
|
76
107
|
for (let node: HTMLElement | null = element; node; node = node.parentElement) {
|
|
77
|
-
|
|
78
|
-
if (
|
|
79
|
-
|
|
80
|
-
let own = transform && transform !== "none" ? ops.fromTransform(transform) : ops.identity();
|
|
81
|
-
if (!own) return null;
|
|
82
|
-
const spin = individualRotateDegrees(style.rotate);
|
|
83
|
-
if (spin !== 0) own = ops.compose(ops.fromRotate(spin), own);
|
|
84
|
-
acc = ops.compose(own, acc);
|
|
108
|
+
above = memo?.get(node);
|
|
109
|
+
if (above !== undefined) break;
|
|
110
|
+
pending.push(node);
|
|
85
111
|
if (node.hasAttribute(COMPOSITION_ROOT_ATTR)) break;
|
|
86
112
|
}
|
|
113
|
+
|
|
114
|
+
let acc: M | null = above === undefined ? ops.identity() : above;
|
|
115
|
+
for (let i = pending.length - 1; i >= 0; i -= 1) {
|
|
116
|
+
const node = pending[i]!;
|
|
117
|
+
if (acc !== null) {
|
|
118
|
+
const own = ownNodeTransform(node, ops, getStyle);
|
|
119
|
+
// The ancestors' chain is the OUTER of the pair, as an ancestor applies
|
|
120
|
+
// around its child.
|
|
121
|
+
acc = own === null ? null : ops.compose(acc, own);
|
|
122
|
+
}
|
|
123
|
+
memo?.set(node, acc);
|
|
124
|
+
}
|
|
87
125
|
return acc;
|
|
88
126
|
}
|
|
@@ -30,23 +30,63 @@ export function isTextBearingTag(tagName: string): boolean {
|
|
|
30
30
|
return ["div", "span", "p", "strong", "h1", "h2", "h3", "h4", "h5", "h6"].includes(tagName);
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
/** Does this node render AT ALL, ignoring what it inherits? Sole owner of the
|
|
34
|
+
* rule; the walk below only decides which nodes to ask it about. */
|
|
35
|
+
function elementRendersItself(win: Window, el: HTMLElement): boolean {
|
|
36
|
+
const computed = win.getComputedStyle(el);
|
|
37
|
+
if (computed.display === "none" || computed.visibility === "hidden") return false;
|
|
38
|
+
const opacity = Number.parseFloat(computed.opacity);
|
|
39
|
+
return !(
|
|
40
|
+
Number.isFinite(opacity) &&
|
|
41
|
+
opacity <= 0.01 &&
|
|
42
|
+
!el.hasAttribute(COLOR_GRADING_SOURCE_HIDDEN_ATTR)
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Does `el` render, given everything above it?
|
|
48
|
+
*
|
|
49
|
+
* `memo` is for a caller asking this about MANY elements in one synchronous
|
|
50
|
+
* pass. Answers are a function of the node and its ancestors, and siblings
|
|
51
|
+
* share almost all of their chain, so memoizing per node turns a walk per
|
|
52
|
+
* element into one style read per node in the tree. It is only ever valid for
|
|
53
|
+
* the length of one pass — the DOM cannot change under a pass, and every read
|
|
54
|
+
* here is a read — so the caller creates it and drops it, and nothing survives
|
|
55
|
+
* to be invalidated. Omitted, every call walks the chain itself.
|
|
56
|
+
*
|
|
57
|
+
* The ANSWER is what it always was. Which nodes get a style read is not: this
|
|
58
|
+
* resolves top-down and stops at the first node that is out, where the previous
|
|
59
|
+
* version resolved bottom-up and stopped at the first one that is out going the
|
|
60
|
+
* other way. Same boolean for every input, a different (and, for a subtree
|
|
61
|
+
* hidden near the root, smaller) set of reads.
|
|
62
|
+
*/
|
|
63
|
+
export function isElementVisibleThroughAncestors(
|
|
64
|
+
el: HTMLElement,
|
|
65
|
+
memo?: Map<HTMLElement, boolean>,
|
|
66
|
+
): boolean {
|
|
34
67
|
const win = el.ownerDocument.defaultView;
|
|
35
68
|
if (!win) return true;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
69
|
+
// Up to the first node already answered for, then back down: a node's answer
|
|
70
|
+
// needs its ancestors' first, and the topmost unanswered node is where the
|
|
71
|
+
// chain of unknowns starts.
|
|
72
|
+
const pending: HTMLElement[] = [];
|
|
73
|
+
let inherited = true;
|
|
74
|
+
for (let node: HTMLElement | null = el; node; node = node.parentElement) {
|
|
75
|
+
const answered = memo?.get(node);
|
|
76
|
+
if (answered !== undefined) {
|
|
77
|
+
inherited = answered;
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
pending.push(node);
|
|
48
81
|
}
|
|
49
|
-
|
|
82
|
+
for (let i = pending.length - 1; i >= 0; i -= 1) {
|
|
83
|
+
const node = pending[i]!;
|
|
84
|
+
// Once an ancestor is out, its descendants are out with it, and asking the
|
|
85
|
+
// platform about them would be a style read for an answer already known.
|
|
86
|
+
inherited = inherited && elementRendersItself(win, node);
|
|
87
|
+
memo?.set(node, inherited);
|
|
88
|
+
}
|
|
89
|
+
return inherited;
|
|
50
90
|
}
|
|
51
91
|
|
|
52
92
|
// ─── Style accessors ──────────────────────────────────────────────────────────
|
|
@@ -109,9 +149,18 @@ export function findClosestByAttribute(
|
|
|
109
149
|
// time, so module scope is the right lifetime; it's empty until set, in which case
|
|
110
150
|
// resolution falls back to the historical attribute-only behavior.
|
|
111
151
|
let compositionSourceMap: Map<string, string> = new Map();
|
|
152
|
+
// Bumped on every replacement so a consumer that memoizes a resolved source file
|
|
153
|
+
// can tell that the map it resolved against is gone. The map is module state
|
|
154
|
+
// with no DOM footprint, so nothing else can observe the change.
|
|
155
|
+
let compositionSourceMapRevision = 0;
|
|
112
156
|
|
|
113
157
|
export function setCompositionSourceMap(map: Map<string, string>): void {
|
|
114
158
|
compositionSourceMap = map;
|
|
159
|
+
compositionSourceMapRevision += 1;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function getCompositionSourceMapRevision(): number {
|
|
163
|
+
return compositionSourceMapRevision;
|
|
115
164
|
}
|
|
116
165
|
|
|
117
166
|
function sourceFromCompositionId(ownerRoot: HTMLElement | null): string | undefined {
|
|
@@ -22,8 +22,11 @@ import {
|
|
|
22
22
|
|
|
23
23
|
// ─── Visibility ──────────────────────────────────────────────────────────────
|
|
24
24
|
|
|
25
|
-
export function isElementComputedVisible(
|
|
26
|
-
|
|
25
|
+
export function isElementComputedVisible(
|
|
26
|
+
el: HTMLElement,
|
|
27
|
+
memo?: Map<HTMLElement, boolean>,
|
|
28
|
+
): boolean {
|
|
29
|
+
return isElementVisibleThroughAncestors(el, memo);
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
const VISUAL_LEAF_TAGS = new Set(["img", "video", "canvas", "svg", "audio"]);
|
|
@@ -91,7 +94,13 @@ const DOM_LAYER_IGNORED_TAGS = new Set([
|
|
|
91
94
|
"wbr",
|
|
92
95
|
]);
|
|
93
96
|
|
|
94
|
-
|
|
97
|
+
/**
|
|
98
|
+
* Is the element rendered at all? The half of `getDomLayerPatchTarget` that a
|
|
99
|
+
* plain style write can flip, and the only half that reads computed style.
|
|
100
|
+
* Exported so a caller memoizing a layer walk can give it its own (short)
|
|
101
|
+
* cache lifetime — see `domEditLayerWalkCache`.
|
|
102
|
+
*/
|
|
103
|
+
export function isInspectableLayerElement(el: HTMLElement): boolean {
|
|
95
104
|
const tagName = el.tagName.toLowerCase();
|
|
96
105
|
if (DOM_LAYER_IGNORED_TAGS.has(tagName)) return false;
|
|
97
106
|
|
|
@@ -101,14 +110,29 @@ function isInspectableLayerElement(el: HTMLElement): boolean {
|
|
|
101
110
|
return true;
|
|
102
111
|
}
|
|
103
112
|
|
|
104
|
-
|
|
113
|
+
/**
|
|
114
|
+
* How the element is ADDRESSED — its selector, that selector's occurrence index
|
|
115
|
+
* within the source file, and the file itself. Independent of whether the
|
|
116
|
+
* element currently renders, so it survives every style write and moves only
|
|
117
|
+
* when an identity attribute changes or the document's population of elements
|
|
118
|
+
* sharing the selector does.
|
|
119
|
+
*/
|
|
120
|
+
/**
|
|
121
|
+
* The selector an element is addressed by, or undefined when it cannot be a
|
|
122
|
+
* layer at all. Sole owner of that rule: `isDomLayerElement` answers the same
|
|
123
|
+
* question without paying for the occurrence index, and a second copy of the
|
|
124
|
+
* conditions would go stale the first time one of them changed.
|
|
125
|
+
*/
|
|
126
|
+
function resolveLayerSelector(el: HTMLElement): string | undefined {
|
|
127
|
+
if (el.hasAttribute("data-composition-id")) return undefined;
|
|
128
|
+
return buildStableSelector(el);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function resolveDomLayerIdentity(
|
|
105
132
|
el: HTMLElement,
|
|
106
133
|
activeCompositionPath: string | null,
|
|
107
134
|
): Pick<DomEditSelection, "id" | "hfId" | "selector" | "selectorIndex" | "sourceFile"> | null {
|
|
108
|
-
|
|
109
|
-
if (el.hasAttribute("data-composition-id")) return null;
|
|
110
|
-
|
|
111
|
-
const selector = buildStableSelector(el);
|
|
135
|
+
const selector = resolveLayerSelector(el);
|
|
112
136
|
if (!selector) return null;
|
|
113
137
|
|
|
114
138
|
const { sourceFile } = getSourceFileForElement(el, activeCompositionPath);
|
|
@@ -127,6 +151,26 @@ export function getDomLayerPatchTarget(
|
|
|
127
151
|
};
|
|
128
152
|
}
|
|
129
153
|
|
|
154
|
+
export function getDomLayerPatchTarget(
|
|
155
|
+
el: HTMLElement,
|
|
156
|
+
activeCompositionPath: string | null,
|
|
157
|
+
): Pick<DomEditSelection, "id" | "hfId" | "selector" | "selectorIndex" | "sourceFile"> | null {
|
|
158
|
+
return isInspectableLayerElement(el) ? resolveDomLayerIdentity(el, activeCompositionPath) : null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Is `el` a layer at all? Exactly the condition under which
|
|
163
|
+
* `getDomLayerPatchTarget` returns non-null, for callers that want the yes/no
|
|
164
|
+
* and throw the target away.
|
|
165
|
+
*
|
|
166
|
+
* Worth its own function because the target carries the selector's OCCURRENCE
|
|
167
|
+
* INDEX, and resolving that is a whole-document query — which the yes/no does
|
|
168
|
+
* not depend on. Counting an element's layer children asked for one per child.
|
|
169
|
+
*/
|
|
170
|
+
export function isDomLayerElement(el: HTMLElement): boolean {
|
|
171
|
+
return isInspectableLayerElement(el) && resolveLayerSelector(el) !== undefined;
|
|
172
|
+
}
|
|
173
|
+
|
|
130
174
|
// ─── Clip ancestor / selection candidate ─────────────────────────────────────
|
|
131
175
|
|
|
132
176
|
function getPreferredClipAncestor(startEl: HTMLElement): HTMLElement | null {
|
|
@@ -323,12 +367,10 @@ export function findElementForTimelineElement(
|
|
|
323
367
|
|
|
324
368
|
// ─── Layer children ───────────────────────────────────────────────────────────
|
|
325
369
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
): HTMLElement[] {
|
|
370
|
+
/** `el`'s direct children that are layers. No longer takes the context options:
|
|
371
|
+
* layer-ness does not depend on the active composition path. */
|
|
372
|
+
export function getDirectLayerChildren(el: HTMLElement): HTMLElement[] {
|
|
330
373
|
return Array.from(el.children).filter(
|
|
331
|
-
(child): child is HTMLElement =>
|
|
332
|
-
isHtmlElement(child) && getDomLayerPatchTarget(child, options.activeCompositionPath) !== null,
|
|
374
|
+
(child): child is HTMLElement => isHtmlElement(child) && isDomLayerElement(child),
|
|
333
375
|
);
|
|
334
376
|
}
|
|
@@ -257,3 +257,51 @@ describe("collectDomEditLayerItems item budget", () => {
|
|
|
257
257
|
expect(collectDomEditLayerItems(documentWith(200), opts, 80)).toHaveLength(80);
|
|
258
258
|
});
|
|
259
259
|
});
|
|
260
|
+
|
|
261
|
+
describe("collectDomEditLayerItems selector-index cost", () => {
|
|
262
|
+
// Attached, unlike the fixture above: a detached subtree is invisible to
|
|
263
|
+
// document.querySelectorAll, so the occurrence lookup would find nothing.
|
|
264
|
+
function attachedRootWithSharedClass(count: number): HTMLElement {
|
|
265
|
+
const root = document.createElement("div");
|
|
266
|
+
root.setAttribute("data-composition-id", "index.html");
|
|
267
|
+
for (let i = 0; i < count; i++) {
|
|
268
|
+
const child = document.createElement("div");
|
|
269
|
+
child.className = "box";
|
|
270
|
+
root.append(child);
|
|
271
|
+
}
|
|
272
|
+
document.body.append(root);
|
|
273
|
+
return root;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Class-selector document queries made by one walk over `count` sibling cards. */
|
|
277
|
+
function classSelectorQueries(count: number): number {
|
|
278
|
+
const root = attachedRootWithSharedClass(count);
|
|
279
|
+
const doc = root.ownerDocument;
|
|
280
|
+
const real = doc.querySelectorAll.bind(doc);
|
|
281
|
+
let calls = 0;
|
|
282
|
+
Object.defineProperty(doc, "querySelectorAll", {
|
|
283
|
+
configurable: true,
|
|
284
|
+
value: (selector: string) => {
|
|
285
|
+
if (selector.startsWith(".")) calls += 1;
|
|
286
|
+
return real(selector);
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
try {
|
|
290
|
+
expect(collectDomEditLayerItems(root, opts)).toHaveLength(count);
|
|
291
|
+
return calls;
|
|
292
|
+
} finally {
|
|
293
|
+
delete (doc as Partial<Document>).querySelectorAll;
|
|
294
|
+
root.remove();
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// The occurrence index is resolved here, for every item, so an unshared index
|
|
299
|
+
// costs one whole-document query per element — quadratic once a composition
|
|
300
|
+
// repeats a card or tile class. Owning the pass here rather than at each call
|
|
301
|
+
// site is what keeps the layers panel, the marquee and the agent's look tool
|
|
302
|
+
// linear too; invariance across a 4x fixture fails for any per-element term.
|
|
303
|
+
it("resolves a shared selector once per walk, not once per element", () => {
|
|
304
|
+
expect(classSelectorQueries(48)).toBe(classSelectorQueries(12));
|
|
305
|
+
expect(classSelectorQueries(12)).toBe(1);
|
|
306
|
+
});
|
|
307
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { probeSourceElement } from "./probeSourceElement";
|
|
1
2
|
import type { PatchOperation } from "../../utils/sourcePatcher";
|
|
2
3
|
import {
|
|
3
4
|
resolveEditingAffordances,
|
|
@@ -26,11 +27,12 @@ import {
|
|
|
26
27
|
} from "./domEditingDom";
|
|
27
28
|
import {
|
|
28
29
|
findElementForSelection,
|
|
29
|
-
getDomLayerPatchTarget,
|
|
30
|
-
getDirectLayerChildren,
|
|
31
30
|
getSelectionCandidate,
|
|
31
|
+
isDomLayerElement,
|
|
32
32
|
} from "./domEditingElement";
|
|
33
33
|
import { isCompositionRootLayer } from "./domEditingRootLayer";
|
|
34
|
+
import { withSelectorIndexPass } from "../../utils/sourceScopedSelectorIndex";
|
|
35
|
+
import { type DomEditLayerWalkCache, readDomEditLayerWalkEntry } from "./domEditLayerWalkCache";
|
|
34
36
|
|
|
35
37
|
export function isEditableTextLeaf(el: HTMLElement): boolean {
|
|
36
38
|
return isTextBearingTag(el.tagName.toLowerCase()) && el.children.length === 0;
|
|
@@ -281,31 +283,6 @@ export function resolveDomEditCapabilities(args: {
|
|
|
281
283
|
).capabilities;
|
|
282
284
|
}
|
|
283
285
|
|
|
284
|
-
async function probeSourceElement(
|
|
285
|
-
projectId: string,
|
|
286
|
-
sourceFile: string,
|
|
287
|
-
target: { id?: string; hfId?: string; selector?: string; selectorIndex?: number },
|
|
288
|
-
): Promise<boolean> {
|
|
289
|
-
try {
|
|
290
|
-
const response = await fetch(
|
|
291
|
-
`/api/projects/${projectId}/file-mutations/probe-element/${encodeURIComponent(sourceFile)}`,
|
|
292
|
-
{
|
|
293
|
-
method: "POST",
|
|
294
|
-
headers: { "Content-Type": "application/json" },
|
|
295
|
-
body: JSON.stringify({ target }),
|
|
296
|
-
},
|
|
297
|
-
);
|
|
298
|
-
if (!response.ok) return true;
|
|
299
|
-
const data = await response.json();
|
|
300
|
-
if (data && typeof data === "object" && "exists" in data && data.exists === false) {
|
|
301
|
-
return false;
|
|
302
|
-
}
|
|
303
|
-
return true;
|
|
304
|
-
} catch {
|
|
305
|
-
return true;
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
|
|
309
286
|
// fallow-ignore-next-line complexity
|
|
310
287
|
export async function resolveDomEditSelection(
|
|
311
288
|
startEl: HTMLElement | null,
|
|
@@ -445,7 +422,7 @@ export function countDomEditChildLayers(
|
|
|
445
422
|
const visit = (el: HTMLElement) => {
|
|
446
423
|
for (const child of Array.from(el.children)) {
|
|
447
424
|
if (!isHtmlElement(child)) continue;
|
|
448
|
-
if (
|
|
425
|
+
if (isDomLayerElement(child)) {
|
|
449
426
|
count += 1;
|
|
450
427
|
if (count >= maxCount) return;
|
|
451
428
|
}
|
|
@@ -466,23 +443,26 @@ export function collectDomEditLayerItems(
|
|
|
466
443
|
root: HTMLElement | null | undefined,
|
|
467
444
|
options: DomEditContextOptions,
|
|
468
445
|
maxItems = Number.POSITIVE_INFINITY,
|
|
446
|
+
cache?: DomEditLayerWalkCache,
|
|
469
447
|
): DomEditLayerItem[] {
|
|
470
448
|
if (!root) return [];
|
|
449
|
+
cache?.beginWalk(options.activeCompositionPath);
|
|
471
450
|
|
|
472
451
|
const items: DomEditLayerItem[] = [];
|
|
473
452
|
// fallow-ignore-next-line complexity
|
|
474
453
|
const visit = (el: HTMLElement, depth: number) => {
|
|
475
454
|
if (items.length >= maxItems) return;
|
|
476
455
|
|
|
477
|
-
const
|
|
478
|
-
if (
|
|
456
|
+
const entry = readDomEditLayerWalkEntry(el, options.activeCompositionPath, cache);
|
|
457
|
+
if (entry) {
|
|
458
|
+
const { target } = entry;
|
|
479
459
|
items.push({
|
|
480
460
|
key: getDomEditLayerKey(target),
|
|
481
461
|
element: el,
|
|
482
|
-
label:
|
|
462
|
+
label: entry.label,
|
|
483
463
|
tagName: el.tagName.toLowerCase(),
|
|
484
464
|
depth,
|
|
485
|
-
childCount:
|
|
465
|
+
childCount: entry.childCount,
|
|
486
466
|
id: target.id ?? undefined,
|
|
487
467
|
hfId: target.hfId ?? undefined,
|
|
488
468
|
selector: target.selector ?? undefined,
|
|
@@ -491,7 +471,7 @@ export function collectDomEditLayerItems(
|
|
|
491
471
|
});
|
|
492
472
|
}
|
|
493
473
|
|
|
494
|
-
const nextDepth =
|
|
474
|
+
const nextDepth = entry ? depth + 1 : depth;
|
|
495
475
|
for (const child of Array.from(el.children)) {
|
|
496
476
|
if (!isHtmlElement(child)) continue;
|
|
497
477
|
visit(child, nextDepth);
|
|
@@ -499,8 +479,15 @@ export function collectDomEditLayerItems(
|
|
|
499
479
|
}
|
|
500
480
|
};
|
|
501
481
|
|
|
502
|
-
//
|
|
503
|
-
|
|
482
|
+
// Every item resolves its selector's occurrence index, and unshared that is a
|
|
483
|
+
// whole-document query per element — quadratic once a composition repeats a
|
|
484
|
+
// card or tile class. The walk is one synchronous read of a document it does
|
|
485
|
+
// not mutate, so one index per selector serves the whole of it. The pass lives
|
|
486
|
+
// here rather than in each caller because this function owns the loop.
|
|
487
|
+
withSelectorIndexPass(root.ownerDocument, () => {
|
|
488
|
+
// Drilled into a group → show only its members; otherwise the whole tree.
|
|
489
|
+
for (const el of groupScopedLayerRoots(root, options.activeGroupElement ?? null)) visit(el, 0);
|
|
490
|
+
});
|
|
504
491
|
return items;
|
|
505
492
|
}
|
|
506
493
|
|