@hyperframes/studio 0.6.48 → 0.6.50

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/index.html CHANGED
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6
6
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
7
7
  <title>HyperFrames Studio</title>
8
- <script type="module" crossorigin src="/assets/index-B2QGnquo.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-B4Cr7MVx.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/assets/index-SKRp8mGz.css">
10
10
  </head>
11
11
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.6.48",
3
+ "version": "0.6.50",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -31,8 +31,8 @@
31
31
  "@codemirror/view": "6.40.0",
32
32
  "@phosphor-icons/react": "^2.1.10",
33
33
  "mediabunny": "^1.45.3",
34
- "@hyperframes/player": "0.6.48",
35
- "@hyperframes/core": "0.6.48"
34
+ "@hyperframes/core": "0.6.50",
35
+ "@hyperframes/player": "0.6.50"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@types/react": "19",
@@ -46,7 +46,7 @@
46
46
  "vite": "^6.4.2",
47
47
  "vitest": "^3.2.4",
48
48
  "zustand": "^5.0.0",
49
- "@hyperframes/producer": "0.6.48"
49
+ "@hyperframes/producer": "0.6.50"
50
50
  },
51
51
  "peerDependencies": {
52
52
  "react": "19",
@@ -143,10 +143,11 @@ describe("DomEditOverlay", () => {
143
143
  iframeRef,
144
144
  activeCompositionPath: null,
145
145
  selection: selected,
146
- hoverSelection: null,
146
+ // Simulate the element being hovered before pointer-down (real users always hover first)
147
+ hoverSelection: selection,
147
148
  groupSelections: [],
148
149
  onCanvasMouseDown: () => {},
149
- onCanvasPointerMove: () => selection,
150
+ onCanvasPointerMove: () => Promise.resolve(selection),
150
151
  onCanvasPointerLeave: () => {},
151
152
  onSelectionChange: (next: DomEditSelection) => setSelected(next),
152
153
  onBlockedMove: () => {},
@@ -43,7 +43,7 @@ interface DomEditOverlayProps {
43
43
  onCanvasPointerMove: (
44
44
  event: React.PointerEvent<HTMLDivElement>,
45
45
  options?: { preferClipAncestor?: boolean },
46
- ) => DomEditSelection | null;
46
+ ) => Promise<DomEditSelection | null>;
47
47
  onCanvasPointerLeave: () => void;
48
48
  onSelectionChange: (
49
49
  selection: DomEditSelection,
@@ -195,9 +195,8 @@ export const DomEditOverlay = memo(function DomEditOverlay({
195
195
  const handleOverlayPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
196
196
  if (!allowCanvasMovement || event.button !== 0) return;
197
197
  if (event.shiftKey) {
198
- const candidate =
199
- onCanvasPointerMoveRef.current(event, { preferClipAncestor: false }) ??
200
- hoverSelectionRef.current;
198
+ // Use the already-updated hover selection rather than re-resolving async
199
+ const candidate = hoverSelectionRef.current;
201
200
  if (!candidate) return;
202
201
  event.preventDefault();
203
202
  event.stopPropagation();
@@ -211,9 +210,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
211
210
  const target = event.target as HTMLElement | null;
212
211
  if (target?.closest('[data-dom-edit-selection-box="true"]')) return;
213
212
 
214
- const candidate =
215
- onCanvasPointerMoveRef.current(event, { preferClipAncestor: false }) ??
216
- hoverSelectionRef.current;
213
+ const candidate = hoverSelectionRef.current;
217
214
  if (!candidate?.capabilities.canApplyManualOffset) return;
218
215
 
219
216
  const overlayEl = overlayRef.current;
@@ -119,12 +119,13 @@ export const LayersPanel = memo(function LayersPanel() {
119
119
  isMasterView,
120
120
  preferClipAncestor: false,
121
121
  }),
122
+ // LayersPanel has no projectId; probe is skipped when projectId is absent
122
123
  [activeCompPath, isMasterView],
123
124
  );
124
125
 
125
126
  const seekToLayer = useCallback(
126
- (layer: DomEditLayerItem) => {
127
- const selection = resolveSelection(layer);
127
+ async (layer: DomEditLayerItem) => {
128
+ const selection = await resolveSelection(layer);
128
129
  if (!selection) return;
129
130
 
130
131
  let matchedId = findMatchingTimelineElementId(selection, timelineElements);
@@ -158,22 +159,22 @@ export const LayersPanel = memo(function LayersPanel() {
158
159
  );
159
160
 
160
161
  const handleSelectLayer = useCallback(
161
- (layer: DomEditLayerItem) => {
162
- const selection = resolveSelection(layer);
162
+ async (layer: DomEditLayerItem) => {
163
+ const selection = await resolveSelection(layer);
163
164
  if (!selection) return;
164
165
  applyDomSelection(selection);
165
- seekToLayer(layer);
166
+ await seekToLayer(layer);
166
167
  },
167
168
  [resolveSelection, applyDomSelection, seekToLayer],
168
169
  );
169
170
 
170
171
  const handleLayerHover = useCallback(
171
- (layer: DomEditLayerItem | null) => {
172
+ async (layer: DomEditLayerItem | null) => {
172
173
  if (!layer) {
173
174
  updateDomEditHoverSelection(null);
174
175
  return;
175
176
  }
176
- const selection = resolveSelection(layer);
177
+ const selection = await resolveSelection(layer);
177
178
  updateDomEditHoverSelection(selection);
178
179
  },
179
180
  [resolveSelection, updateDomEditHoverSelection],
@@ -226,6 +226,7 @@ describe("resolveDomEditCapabilities", () => {
226
226
  });
227
227
 
228
228
  describe("resolveVisualDomEditSelectionTarget", () => {
229
+ // fallow-ignore-next-line code-duplication
229
230
  it("prefers the visible leaf under the pointer over an oversized container", () => {
230
231
  const document = createDocument(`
231
232
  <section id="container" class="hero-shell">
@@ -299,7 +300,7 @@ describe("resolveVisualDomEditSelectionTarget", () => {
299
300
  ).toBe(card);
300
301
  });
301
302
 
302
- it("keeps explicit layer selection able to target containers", () => {
303
+ it("keeps explicit layer selection able to target containers", async () => {
303
304
  const document = createDocument(`
304
305
  <section id="container" class="hero-shell">
305
306
  <span id="headline" class="headline">Launch faster</span>
@@ -313,7 +314,7 @@ describe("resolveVisualDomEditSelectionTarget", () => {
313
314
  const visualTarget = resolveVisualDomEditSelectionTarget([container, headline], {
314
315
  activeCompositionPath: "index.html",
315
316
  });
316
- const explicitSelection = resolveDomEditSelection(container, {
317
+ const explicitSelection = await resolveDomEditSelection(container, {
317
318
  activeCompositionPath: "index.html",
318
319
  isMasterView: false,
319
320
  });
@@ -430,7 +431,7 @@ describe("resolveDomEditSelection", () => {
430
431
  });
431
432
  });
432
433
 
433
- it("resolves child clicks inside a composition host to the child in master view", () => {
434
+ it("resolves child clicks inside a composition host to the child in master view", async () => {
434
435
  const document = createDocument(`
435
436
  <div data-composition-id="main">
436
437
  <div
@@ -445,7 +446,7 @@ describe("resolveDomEditSelection", () => {
445
446
  `);
446
447
 
447
448
  const child = document.getElementById("inner-copy") as HTMLElement;
448
- const selection = resolveDomEditSelection(child, {
449
+ const selection = await resolveDomEditSelection(child, {
449
450
  activeCompositionPath: null,
450
451
  isMasterView: true,
451
452
  });
@@ -457,7 +458,8 @@ describe("resolveDomEditSelection", () => {
457
458
  expect(selection?.capabilities.canEditStyles).toBe(true);
458
459
  });
459
460
 
460
- it("does not prefer a scene host clip ancestor when selecting inside it", () => {
461
+ // fallow-ignore-next-line code-duplication
462
+ it("does not prefer a scene host clip ancestor when selecting inside it", async () => {
461
463
  const document = createDocument(`
462
464
  <div data-composition-id="main">
463
465
  <div
@@ -472,7 +474,7 @@ describe("resolveDomEditSelection", () => {
472
474
  `);
473
475
 
474
476
  const child = document.getElementById("inner-copy") as HTMLElement;
475
- const selection = resolveDomEditSelection(child, {
477
+ const selection = await resolveDomEditSelection(child, {
476
478
  activeCompositionPath: null,
477
479
  isMasterView: true,
478
480
  preferClipAncestor: true,
@@ -483,7 +485,7 @@ describe("resolveDomEditSelection", () => {
483
485
  expect(selection?.isCompositionHost).toBe(false);
484
486
  });
485
487
 
486
- it("still prefers an internal clip ancestor inside a scene", () => {
488
+ it("still prefers an internal clip ancestor inside a scene", async () => {
487
489
  const document = createDocument(`
488
490
  <div data-composition-id="main">
489
491
  <div
@@ -500,7 +502,7 @@ describe("resolveDomEditSelection", () => {
500
502
  `);
501
503
 
502
504
  const child = document.getElementById("inner-copy") as HTMLElement;
503
- const selection = resolveDomEditSelection(child, {
505
+ const selection = await resolveDomEditSelection(child, {
504
506
  activeCompositionPath: null,
505
507
  isMasterView: true,
506
508
  preferClipAncestor: true,
@@ -511,7 +513,7 @@ describe("resolveDomEditSelection", () => {
511
513
  expect(selection?.isCompositionHost).toBe(false);
512
514
  });
513
515
 
514
- it("scopes class selector indexing to the same source file", () => {
516
+ it("scopes class selector indexing to the same source file", async () => {
515
517
  const document = createDocument(`
516
518
  <div data-composition-id="main">
517
519
  <div class="chip">Root chip</div>
@@ -522,7 +524,7 @@ describe("resolveDomEditSelection", () => {
522
524
  `);
523
525
 
524
526
  const rootChip = document.getElementsByClassName("chip")[0] as HTMLElement;
525
- const selection = resolveDomEditSelection(rootChip, {
527
+ const selection = await resolveDomEditSelection(rootChip, {
526
528
  activeCompositionPath: null,
527
529
  isMasterView: true,
528
530
  });
@@ -533,7 +535,7 @@ describe("resolveDomEditSelection", () => {
533
535
  expect(findElementForSelection(document, selection!, null)).toBe(rootChip);
534
536
  });
535
537
 
536
- it("resolves nested duplicate ids from master view without treating root as the nested source", () => {
538
+ it("resolves nested duplicate ids from master view without treating root as the nested source", async () => {
537
539
  const document = createDocument(`
538
540
  <div data-composition-id="main">
539
541
  <div id="card">Root card</div>
@@ -546,7 +548,7 @@ describe("resolveDomEditSelection", () => {
546
548
  const nestedCard = document.querySelector(
547
549
  '[data-composition-file="scenes/nested.html"] #card',
548
550
  ) as HTMLElement;
549
- const selection = resolveDomEditSelection(nestedCard, {
551
+ const selection = await resolveDomEditSelection(nestedCard, {
550
552
  activeCompositionPath: null,
551
553
  isMasterView: true,
552
554
  });
@@ -588,7 +590,7 @@ describe("resolveDomEditSelection", () => {
588
590
  ).toBeNull();
589
591
  });
590
592
 
591
- it("escapes ids and composition ids when creating stable selectors", () => {
593
+ it("escapes ids and composition ids when creating stable selectors", async () => {
592
594
  const document = createDocument(`
593
595
  <div data-composition-id="main">
594
596
  <div id="logo:light">Logo</div>
@@ -600,11 +602,11 @@ describe("resolveDomEditSelection", () => {
600
602
  (element) => element.getAttribute("data-composition-id") === "scene:one",
601
603
  ) as HTMLElement;
602
604
 
603
- const logoSelection = resolveDomEditSelection(logo, {
605
+ const logoSelection = await resolveDomEditSelection(logo, {
604
606
  activeCompositionPath: null,
605
607
  isMasterView: true,
606
608
  });
607
- const sceneSelection = resolveDomEditSelection(scene, {
609
+ const sceneSelection = await resolveDomEditSelection(scene, {
608
610
  activeCompositionPath: null,
609
611
  isMasterView: true,
610
612
  });
@@ -615,7 +617,7 @@ describe("resolveDomEditSelection", () => {
615
617
  expect(findElementForSelection(document, sceneSelection!, null)).toBe(scene);
616
618
  });
617
619
 
618
- it("prefers the nearest clip ancestor on single-click style selection", () => {
620
+ it("prefers the nearest clip ancestor on single-click style selection", async () => {
619
621
  const document = createDocument(`
620
622
  <section id="card" class="clip" style="left: 10px; top: 20px; width: 200px; height: 100px; position: absolute;">
621
623
  <p id="copy">Hello</p>
@@ -623,7 +625,7 @@ describe("resolveDomEditSelection", () => {
623
625
  `);
624
626
 
625
627
  const child = document.getElementById("copy") as HTMLElement;
626
- const selection = resolveDomEditSelection(child, {
628
+ const selection = await resolveDomEditSelection(child, {
627
629
  activeCompositionPath: null,
628
630
  isMasterView: false,
629
631
  preferClipAncestor: true,
@@ -633,7 +635,7 @@ describe("resolveDomEditSelection", () => {
633
635
  expect(selection?.selector).toBe("#card");
634
636
  });
635
637
 
636
- it("can resolve the exact child when clip-ancestor preference is disabled", () => {
638
+ it("can resolve the exact child when clip-ancestor preference is disabled", async () => {
637
639
  const document = createDocument(`
638
640
  <section id="card" class="clip" style="left: 10px; top: 20px; width: 200px; height: 100px; position: absolute;">
639
641
  <p id="copy">Hello</p>
@@ -641,7 +643,7 @@ describe("resolveDomEditSelection", () => {
641
643
  `);
642
644
 
643
645
  const child = document.getElementById("copy") as HTMLElement;
644
- const selection = resolveDomEditSelection(child, {
646
+ const selection = await resolveDomEditSelection(child, {
645
647
  activeCompositionPath: null,
646
648
  isMasterView: false,
647
649
  preferClipAncestor: false,
@@ -651,7 +653,8 @@ describe("resolveDomEditSelection", () => {
651
653
  expect(selection?.selector).toBe("#copy");
652
654
  });
653
655
 
654
- it("collects simple child text blocks as separate editable fields", () => {
656
+ // fallow-ignore-next-line code-duplication
657
+ it("collects simple child text blocks as separate editable fields", async () => {
655
658
  const document = createDocument(`
656
659
  <section id="card" class="clip" style="left: 10px; top: 20px; width: 200px; height: 100px; position: absolute;">
657
660
  <strong>Headline</strong>
@@ -659,10 +662,13 @@ describe("resolveDomEditSelection", () => {
659
662
  </section>
660
663
  `);
661
664
 
662
- const selection = resolveDomEditSelection(document.getElementById("card") as HTMLElement, {
663
- activeCompositionPath: null,
664
- isMasterView: false,
665
- });
665
+ const selection = await resolveDomEditSelection(
666
+ document.getElementById("card") as HTMLElement,
667
+ {
668
+ activeCompositionPath: null,
669
+ isMasterView: false,
670
+ },
671
+ );
666
672
 
667
673
  expect(selection?.textFields.map((field) => field.label)).toEqual(["Text 1", "Text 2"]);
668
674
  expect(selection?.textFields.map((field) => field.value)).toEqual([
@@ -671,30 +677,36 @@ describe("resolveDomEditSelection", () => {
671
677
  ]);
672
678
  });
673
679
 
674
- it("preserves user-entered text spacing in editable text fields", () => {
680
+ it("preserves user-entered text spacing in editable text fields", async () => {
675
681
  const document = createDocument(`
676
682
  <section id="card" class="clip" style="position: absolute;">
677
683
  <strong>Headline with trailing space </strong>
678
684
  </section>
679
685
  `);
680
686
 
681
- const selection = resolveDomEditSelection(document.getElementById("card") as HTMLElement, {
682
- activeCompositionPath: null,
683
- isMasterView: false,
684
- });
687
+ const selection = await resolveDomEditSelection(
688
+ document.getElementById("card") as HTMLElement,
689
+ {
690
+ activeCompositionPath: null,
691
+ isMasterView: false,
692
+ },
693
+ );
685
694
 
686
695
  expect(selection?.textFields[0]?.value).toBe("Headline with trailing space ");
687
696
  });
688
697
 
689
- it("keeps an emptied text layer editable so users can type into it again", () => {
698
+ it("keeps an emptied text layer editable so users can type into it again", async () => {
690
699
  const document = createDocument(`
691
700
  <div id="card" class="clip" style="position: absolute;"></div>
692
701
  `);
693
702
 
694
- const selection = resolveDomEditSelection(document.getElementById("card") as HTMLElement, {
695
- activeCompositionPath: null,
696
- isMasterView: false,
697
- });
703
+ const selection = await resolveDomEditSelection(
704
+ document.getElementById("card") as HTMLElement,
705
+ {
706
+ activeCompositionPath: null,
707
+ isMasterView: false,
708
+ },
709
+ );
698
710
 
699
711
  expect(selection?.textFields).toMatchObject([
700
712
  {
@@ -707,7 +719,7 @@ describe("resolveDomEditSelection", () => {
707
719
  expect(selection ? isTextEditableSelection(selection) : false).toBe(true);
708
720
  });
709
721
 
710
- it("keeps emptied child text layers editable after their content is cleared", () => {
722
+ it("keeps emptied child text layers editable after their content is cleared", async () => {
711
723
  const document = createDocument(`
712
724
  <div id="card" class="clip" style="position: absolute;">
713
725
  <strong></strong>
@@ -715,16 +727,19 @@ describe("resolveDomEditSelection", () => {
715
727
  </div>
716
728
  `);
717
729
 
718
- const selection = resolveDomEditSelection(document.getElementById("card") as HTMLElement, {
719
- activeCompositionPath: null,
720
- isMasterView: false,
721
- });
730
+ const selection = await resolveDomEditSelection(
731
+ document.getElementById("card") as HTMLElement,
732
+ {
733
+ activeCompositionPath: null,
734
+ isMasterView: false,
735
+ },
736
+ );
722
737
 
723
738
  expect(selection?.textFields.map((field) => field.tagName)).toEqual(["strong", "span"]);
724
739
  expect(selection?.textFields.map((field) => field.value)).toEqual(["", ""]);
725
740
  });
726
741
 
727
- it("explains anonymous child elements that resolve to an editable parent", () => {
742
+ it("explains anonymous child elements that resolve to an editable parent", async () => {
728
743
  const document = createDocument(`
729
744
  <div data-composition-id="main">
730
745
  <div id="card">
@@ -734,7 +749,7 @@ describe("resolveDomEditSelection", () => {
734
749
  `);
735
750
 
736
751
  const child = document.querySelector("strong") as HTMLElement;
737
- const selection = resolveDomEditSelection(child, {
752
+ const selection = await resolveDomEditSelection(child, {
738
753
  activeCompositionPath: null,
739
754
  isMasterView: false,
740
755
  preferClipAncestor: false,
@@ -744,7 +759,7 @@ describe("resolveDomEditSelection", () => {
744
759
  expect(getDomEditNonEditableReason(child, selection)).toBe("Selection resolves to Card");
745
760
  });
746
761
 
747
- it("does not mark an element as non-editable when Studio can edit it directly", () => {
762
+ it("does not mark an element as non-editable when Studio can edit it directly", async () => {
748
763
  const document = createDocument(`
749
764
  <div data-composition-id="main">
750
765
  <div id="card">Editable</div>
@@ -752,7 +767,7 @@ describe("resolveDomEditSelection", () => {
752
767
  `);
753
768
 
754
769
  const element = document.getElementById("card") as HTMLElement;
755
- const selection = resolveDomEditSelection(element, {
770
+ const selection = await resolveDomEditSelection(element, {
756
771
  activeCompositionPath: null,
757
772
  isMasterView: false,
758
773
  });
@@ -73,6 +73,7 @@ function buildTextField(
73
73
  };
74
74
  }
75
75
 
76
+ // fallow-ignore-next-line complexity
76
77
  export function collectDomEditTextFields(el: HTMLElement): DomEditTextField[] {
77
78
  const childElements = Array.from(el.children).filter(isHtmlElement).filter(isEditableTextLeaf);
78
79
 
@@ -169,6 +170,7 @@ export function buildDefaultDomEditTextField(base?: Partial<DomEditTextField>):
169
170
 
170
171
  // ─── Capabilities ────────────────────────────────────────────────────────────
171
172
 
173
+ // fallow-ignore-next-line complexity
172
174
  export function resolveDomEditCapabilities(args: {
173
175
  selector?: string;
174
176
  tagName?: string;
@@ -178,6 +180,7 @@ export function resolveDomEditCapabilities(args: {
178
180
  isCompositionHost: boolean;
179
181
  isInsideLockedComposition: boolean;
180
182
  isMasterView: boolean;
183
+ existsInSource?: boolean;
181
184
  }): DomEditCapabilities {
182
185
  if (!args.selector || args.isInsideLockedComposition) {
183
186
  return {
@@ -194,6 +197,19 @@ export function resolveDomEditCapabilities(args: {
194
197
  };
195
198
  }
196
199
 
200
+ if (args.existsInSource === false) {
201
+ return {
202
+ canSelect: true,
203
+ canEditStyles: false,
204
+ canMove: false,
205
+ canResize: false,
206
+ canApplyManualOffset: false,
207
+ canApplyManualSize: false,
208
+ canApplyManualRotation: false,
209
+ reasonIfDisabled: "This element is generated by a script and cannot be edited visually.",
210
+ };
211
+ }
212
+
197
213
  const position = args.computedStyles.position;
198
214
  const left = parsePx(args.inlineStyles.left) ?? parsePx(args.computedStyles.left);
199
215
  const top = parsePx(args.inlineStyles.top) ?? parsePx(args.computedStyles.top);
@@ -243,6 +259,7 @@ export function resolveDomEditCapabilities(args: {
243
259
 
244
260
  // ─── Element label ────────────────────────────────────────────────────────────
245
261
 
262
+ // fallow-ignore-next-line complexity
246
263
  export function buildElementLabel(el: HTMLElement): string {
247
264
  const compositionId = el.getAttribute("data-composition-id");
248
265
  if (compositionId && compositionId !== "main") {
@@ -267,12 +284,37 @@ export function buildElementLabel(el: HTMLElement): string {
267
284
  return el.tagName.toLowerCase();
268
285
  }
269
286
 
287
+ // ─── Source probe ────────────────────────────────────────────────────────────
288
+
289
+ async function probeSourceElement(
290
+ projectId: string,
291
+ sourceFile: string,
292
+ target: { id?: string; selector?: string; selectorIndex?: number },
293
+ ): Promise<boolean> {
294
+ try {
295
+ const response = await fetch(
296
+ `/api/projects/${projectId}/file-mutations/probe-element/${encodeURIComponent(sourceFile)}`,
297
+ {
298
+ method: "POST",
299
+ headers: { "Content-Type": "application/json" },
300
+ body: JSON.stringify({ target }),
301
+ },
302
+ );
303
+ if (!response.ok) return true;
304
+ const data = (await response.json()) as { exists?: boolean };
305
+ return data.exists !== false;
306
+ } catch {
307
+ return true;
308
+ }
309
+ }
310
+
270
311
  // ─── Selection resolution ────────────────────────────────────────────────────
271
312
 
272
- export function resolveDomEditSelection(
313
+ // fallow-ignore-next-line complexity
314
+ export async function resolveDomEditSelection(
273
315
  startEl: HTMLElement | null,
274
- options: DomEditContextOptions,
275
- ): DomEditSelection | null {
316
+ options: DomEditContextOptions & { projectId?: string | null; skipSourceProbe?: boolean },
317
+ ): Promise<DomEditSelection | null> {
276
318
  if (!startEl) return null;
277
319
  const doc = startEl.ownerDocument;
278
320
 
@@ -303,6 +345,14 @@ export function resolveDomEditSelection(
303
345
  const computedStyles = getCuratedComputedStyles(current);
304
346
  const textFields = collectDomEditTextFields(current);
305
347
  const isInsideLocked = Boolean(findClosestByAttribute(current, ["data-timeline-locked"]));
348
+ let existsInSource: boolean | undefined;
349
+ if (!options.skipSourceProbe && options.projectId && (current.id || selector)) {
350
+ const probeTarget: { id?: string; selector?: string; selectorIndex?: number } = {};
351
+ if (current.id) probeTarget.id = current.id;
352
+ if (selector) probeTarget.selector = selector;
353
+ if (selectorIndex != null) probeTarget.selectorIndex = selectorIndex;
354
+ existsInSource = await probeSourceElement(options.projectId, sourceFile, probeTarget);
355
+ }
306
356
  const capabilities = resolveDomEditCapabilities({
307
357
  selector,
308
358
  tagName: current.tagName.toLowerCase(),
@@ -312,6 +362,7 @@ export function resolveDomEditSelection(
312
362
  isCompositionHost: Boolean(compositionSrc),
313
363
  isInsideLockedComposition: isInsideLocked,
314
364
  isMasterView: options.isMasterView,
365
+ existsInSource,
315
366
  });
316
367
  const rect = current.getBoundingClientRect();
317
368
 
@@ -345,10 +396,10 @@ export function resolveDomEditSelection(
345
396
  return null;
346
397
  }
347
398
 
348
- export function refreshDomEditSelection(
399
+ export async function refreshDomEditSelection(
349
400
  selection: DomEditSelection,
350
401
  activeCompositionPath: string | null,
351
- ): DomEditSelection | null {
402
+ ): Promise<DomEditSelection | null> {
352
403
  const doc = selection.element.ownerDocument;
353
404
  const nextElement = findElementForSelection(doc, selection, activeCompositionPath);
354
405
  return nextElement
@@ -73,7 +73,7 @@ export type UseDomEditOverlayGesturesOptions = {
73
73
  (
74
74
  e: React.PointerEvent<HTMLDivElement>,
75
75
  o?: { preferClipAncestor?: boolean },
76
- ) => DomEditSelection | null
76
+ ) => Promise<DomEditSelection | null>
77
77
  >;
78
78
  onCanvasMouseDown: (
79
79
  e: React.MouseEvent<HTMLDivElement>,
@@ -81,7 +81,7 @@ export interface UseDomEditCommitsParams {
81
81
  buildDomSelectionFromTarget: (
82
82
  target: HTMLElement,
83
83
  options?: { preferClipAncestor?: boolean },
84
- ) => DomEditSelection | null;
84
+ ) => Promise<DomEditSelection | null>;
85
85
  }
86
86
 
87
87
  // ── Hook ──
@@ -128,6 +128,7 @@ export function useDomEditCommits({
128
128
  [fileTree, projectId, importedFontAssetsRef],
129
129
  );
130
130
 
131
+ // fallow-ignore-next-line complexity
131
132
  const persistDomEditOperations: PersistDomEditOperations = useCallback(
132
133
  async (selection, operations, options) => {
133
134
  const pid = projectIdRef.current;
@@ -232,6 +233,7 @@ export function useDomEditCommits({
232
233
 
233
234
  // ── Position patch helper ──
234
235
 
236
+ // fallow-ignore-next-line complexity
235
237
  const commitPositionPatchToHtml = useCallback(
236
238
  (
237
239
  selection: DomEditSelection,
@@ -244,6 +246,7 @@ export function useDomEditCommits({
244
246
  coalesceKey: options.coalesceKey,
245
247
  skipRefresh: options.skipRefresh ?? true,
246
248
  });
249
+ // fallow-ignore-next-line complexity
247
250
  }).catch((error) => {
248
251
  const message = error instanceof Error ? error.message : "Failed to save position";
249
252
  showToast(message);
@@ -251,6 +254,9 @@ export function useDomEditCommits({
251
254
  source: "dom_edit",
252
255
  label: options.label,
253
256
  error_message: message,
257
+ target_id: selection.id ?? undefined,
258
+ target_selector: selection.selector ?? undefined,
259
+ target_source_file: selection.sourceFile ?? undefined,
254
260
  });
255
261
  });
256
262
  },
@@ -333,6 +339,7 @@ export function useDomEditCommits({
333
339
 
334
340
  // ── Motion commits (HTML-attribute–backed) ──
335
341
 
342
+ // fallow-ignore-next-line complexity
336
343
  const handleDomMotionCommit = useCallback(
337
344
  (
338
345
  selection: DomEditSelection,
@@ -359,6 +366,7 @@ export function useDomEditCommits({
359
366
  [commitPositionPatchToHtml, previewIframeRef, refreshDomEditSelectionFromPreview],
360
367
  );
361
368
 
369
+ // fallow-ignore-next-line complexity
362
370
  const handleDomMotionClear = useCallback(
363
371
  (selection: DomEditSelection) => {
364
372
  const clearPatches = buildClearMotionPatches(selection.element);
@@ -387,6 +395,7 @@ export function useDomEditCommits({
387
395
  [commitPositionPatchToHtml, previewIframeRef, refreshDomEditSelectionFromPreview],
388
396
  );
389
397
 
398
+ // fallow-ignore-next-line complexity
390
399
  const handleDomEditElementDelete = useCallback(
391
400
  async (selection: DomEditSelection) => {
392
401
  const pid = projectIdRef.current;
@@ -231,7 +231,7 @@ export function useDomEditSession({
231
231
  useEffect(() => {
232
232
  if (!previewIframe) return;
233
233
 
234
- const syncSelectionFromDocument = () => {
234
+ const syncSelectionFromDocument = async () => {
235
235
  if (!STUDIO_INSPECTOR_PANELS_ENABLED || captionEditMode) return;
236
236
  const currentSelection = domEditSelectionRef.current;
237
237
  if (!currentSelection) return;
@@ -249,7 +249,7 @@ export function useDomEditSession({
249
249
  return;
250
250
  }
251
251
 
252
- const nextSelection = buildDomSelectionFromTarget(nextElement);
252
+ const nextSelection = await buildDomSelectionFromTarget(nextElement);
253
253
  if (nextSelection) {
254
254
  applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
255
255
  }
@@ -257,13 +257,13 @@ export function useDomEditSession({
257
257
 
258
258
  syncPreviewHistoryHotkey(previewIframe);
259
259
  void applyStudioManualEditsToPreviewRef.current(previewIframe);
260
- syncSelectionFromDocument();
260
+ void syncSelectionFromDocument();
261
261
  refreshPreviewDocumentVersion();
262
262
 
263
263
  const handleLoad = () => {
264
264
  syncPreviewHistoryHotkey(previewIframe);
265
265
  void applyStudioManualEditsToPreviewRef.current(previewIframe);
266
- syncSelectionFromDocument();
266
+ void syncSelectionFromDocument();
267
267
  refreshPreviewDocumentVersion();
268
268
  };
269
269