@hyperframes/studio 0.8.32 → 0.8.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/dist/assets/{hyperframes-player-CroojEG1.js → hyperframes-player-D4aSRr_M.js} +1 -1
  2. package/dist/assets/{index-PVOehm7h.js → index-CkxPQxLJ.js} +182 -172
  3. package/dist/assets/{index-CSyaU1m2.js → index-aNyQBoJv.js} +1 -1
  4. package/dist/assets/{index-D0AjUHNT.js → index-hch4ktcC.js} +1 -1
  5. package/dist/{chunk-A7S5SGNA.js → chunk-7BP32G62.js} +294 -53
  6. package/dist/chunk-7BP32G62.js.map +1 -0
  7. package/dist/{domEditingLayers-JMMVOGTO.js → domEditingLayers-UKMVYXT6.js} +2 -2
  8. package/dist/index.html +1 -1
  9. package/dist/index.js +354 -206
  10. package/dist/index.js.map +1 -1
  11. package/package.json +8 -8
  12. package/src/App.tsx +6 -4
  13. package/src/captions/components/CaptionOverlay.sender.test.tsx +47 -0
  14. package/src/captions/components/CaptionOverlay.tsx +1 -0
  15. package/src/captions/generator.test.ts +30 -0
  16. package/src/captions/generator.ts +5 -3
  17. package/src/captions/hooks/useCaptionSync.ts +3 -2
  18. package/src/components/MediaPreview.tsx +2 -1
  19. package/src/components/editor/AnimationCard.test.tsx +3 -3
  20. package/src/components/editor/PropertyPanel.test.tsx +18 -14
  21. package/src/components/editor/SnapGuideOverlay.tsx +2 -6
  22. package/src/components/editor/domEditLayerWalkCache.test.ts +284 -0
  23. package/src/components/editor/domEditLayerWalkCache.ts +327 -0
  24. package/src/components/editor/domEditOverlayBasis.ts +72 -0
  25. package/src/components/editor/domEditOverlayGeometry.ts +78 -83
  26. package/src/components/editor/domEditOverlayMeasurePass.ts +60 -0
  27. package/src/components/editor/domEditOverlayTransform.ts +47 -9
  28. package/src/components/editor/domEditingDom.ts +63 -14
  29. package/src/components/editor/domEditingElement.ts +56 -14
  30. package/src/components/editor/domEditingLayers.test.ts +48 -0
  31. package/src/components/editor/domEditingLayers.ts +22 -35
  32. package/src/components/editor/offCanvasIndicatorGeometry.complexity.test.ts +373 -0
  33. package/src/components/editor/offCanvasIndicatorGeometry.ts +50 -11
  34. package/src/components/editor/offCanvasIndicatorRefresh.ts +43 -16
  35. package/src/components/editor/overlayFrameLoop.test.ts +246 -0
  36. package/src/components/editor/overlayFrameLoop.ts +204 -0
  37. package/src/components/editor/probeSourceElement.ts +29 -0
  38. package/src/components/editor/propertyPanelFill.tsx +2 -1
  39. package/src/components/editor/propertyPanelFlatEffectsSection.test.tsx +2 -2
  40. package/src/components/editor/useDomEditCompositionRect.ts +2 -4
  41. package/src/components/editor/useDomEditOverlayRects.ts +15 -9
  42. package/src/components/editor/useMotionPathData.ts +2 -4
  43. package/src/components/feedback/projectProvenance.ts +4 -1
  44. package/src/components/nle/NLEContext.tsx +2 -1
  45. package/src/components/nle/PreviewOverlays.tsx +1 -1
  46. package/src/components/nle/useCompositionStack.ts +11 -4
  47. package/src/components/renders/RenderQueue.test.tsx +2 -2
  48. package/src/components/renders/RenderQueueItem.tsx +2 -1
  49. package/src/components/renders/useRenderQueue.ts +3 -2
  50. package/src/components/sidebar/AssetsTab.tsx +2 -1
  51. package/src/components/sidebar/CompositionsTab.tsx +2 -1
  52. package/src/components/storyboard/FramePoster.tsx +2 -1
  53. package/src/hooks/useAskAgentModal.ts +2 -1
  54. package/src/hooks/useCaptionDetection.sender.test.tsx +54 -0
  55. package/src/hooks/useCaptionDetection.ts +3 -1
  56. package/src/hooks/useCompositionContentLoader.ts +3 -1
  57. package/src/hooks/useCompositionDimensions.test.tsx +47 -0
  58. package/src/hooks/useCompositionDimensions.ts +3 -2
  59. package/src/hooks/useDomEditCommits.ts +5 -4
  60. package/src/hooks/useElementLifecycleOps.ts +5 -1
  61. package/src/hooks/useFileTree.ts +5 -4
  62. package/src/hooks/useGroupCommits.ts +2 -1
  63. package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +2 -2
  64. package/src/hooks/useLintModal.ts +2 -1
  65. package/src/hooks/useRenderClipContent.ts +6 -5
  66. package/src/hooks/useTimelineDeleteOps.ts +5 -1
  67. package/src/player/components/Player.tsx +6 -4
  68. package/src/player/components/TimelineAutomationLane.test.tsx +9 -9
  69. package/src/player/components/thumbnailUtils.ts +6 -2
  70. package/src/player/components/timelineDragDrop.test.tsx +3 -3
  71. package/src/player/lib/timelineIframeHelpers.test.ts +36 -0
  72. package/src/player/lib/timelineIframeHelpers.ts +17 -1
  73. package/src/test-setup.ts +8 -0
  74. package/src/utils/blockInstaller.ts +2 -1
  75. package/src/utils/projectRouting.test.ts +9 -0
  76. package/src/utils/projectRouting.ts +18 -1
  77. package/src/utils/razorSplitTransaction.test.ts +3 -3
  78. package/src/utils/sourceScopedSelectorIndex.test.ts +148 -0
  79. package/src/utils/sourceScopedSelectorIndex.ts +64 -6
  80. package/src/utils/studioFileHistory.ts +2 -1
  81. package/src/utils/studioHelpers.test.ts +5 -1
  82. package/src/utils/studioHelpers.ts +3 -2
  83. package/src/utils/timelineCompositionInsert.test.ts +3 -3
  84. package/dist/chunk-A7S5SGNA.js.map +0 -1
  85. /package/dist/{domEditingLayers-JMMVOGTO.js.map → domEditingLayers-UKMVYXT6.js.map} +0 -0
@@ -0,0 +1,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
- let acc = ops.identity();
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
- const style = getStyle(node);
78
- if (!style) return null;
79
- const transform = style.transform;
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
- export function isElementVisibleThroughAncestors(el: HTMLElement): boolean {
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
- let current: HTMLElement | null = el;
37
- while (current) {
38
- const computed = win.getComputedStyle(current);
39
- if (computed.display === "none" || computed.visibility === "hidden") return false;
40
- const opacity = Number.parseFloat(computed.opacity);
41
- if (
42
- Number.isFinite(opacity) &&
43
- opacity <= 0.01 &&
44
- !current.hasAttribute(COLOR_GRADING_SOURCE_HIDDEN_ATTR)
45
- )
46
- return false;
47
- current = current.parentElement;
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
- return true;
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(el: HTMLElement): boolean {
26
- return isElementVisibleThroughAncestors(el);
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
- function isInspectableLayerElement(el: HTMLElement): boolean {
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
- export function getDomLayerPatchTarget(
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
- if (!isInspectableLayerElement(el)) return null;
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
- export function getDirectLayerChildren(
327
- el: HTMLElement,
328
- options: DomEditContextOptions,
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 (getDomLayerPatchTarget(child, options.activeCompositionPath)) {
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 target = getDomLayerPatchTarget(el, options.activeCompositionPath);
478
- if (target) {
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: buildElementLabel(el),
462
+ label: entry.label,
483
463
  tagName: el.tagName.toLowerCase(),
484
464
  depth,
485
- childCount: getDirectLayerChildren(el, options).length,
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 = target ? depth + 1 : depth;
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
- // Drilled into a group show only its members; otherwise the whole tree.
503
- for (const el of groupScopedLayerRoots(root, options.activeGroupElement ?? null)) visit(el, 0);
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