@hyperframes/studio 0.7.28 → 0.7.30

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 (46) hide show
  1. package/dist/assets/{index-BGUJ2C2G.js → index-BhSyGx7y.js} +1 -1
  2. package/dist/assets/{index-Bz6Eqd_G.js → index-D0yNztV_.js} +1 -1
  3. package/dist/assets/{index-CLlPjdPl.js → index-kbACg3_I.js} +139 -139
  4. package/dist/{chunk-AN2EWWK3.js → chunk-JND3XUJL.js} +38 -10
  5. package/dist/chunk-JND3XUJL.js.map +1 -0
  6. package/dist/{domEditingLayers-EK7R7R4G.js → domEditingLayers-UIQZJCOA.js} +4 -2
  7. package/dist/index.d.ts +3 -0
  8. package/dist/index.html +1 -1
  9. package/dist/index.js +1145 -675
  10. package/dist/index.js.map +1 -1
  11. package/package.json +7 -7
  12. package/src/components/editor/DomEditOverlay.test.ts +95 -0
  13. package/src/components/editor/DomEditOverlay.tsx +5 -3
  14. package/src/components/editor/domEditOverlayGestures.ts +3 -4
  15. package/src/components/editor/domEditing.ts +1 -0
  16. package/src/components/editor/domEditingLayers.test.ts +52 -0
  17. package/src/components/editor/domEditingLayers.ts +55 -19
  18. package/src/components/editor/domEditingTypes.ts +1 -0
  19. package/src/components/editor/persistSeam.integration.test.ts +264 -0
  20. package/src/components/editor/propertyPanelPrimitives.tsx +15 -1
  21. package/src/components/editor/useDomEditOverlayGestures.ts +1 -0
  22. package/src/hooks/domEditCommitRunner.ts +46 -0
  23. package/src/hooks/domEditPersistFailure.test.ts +123 -0
  24. package/src/hooks/domEditPersistFailure.ts +89 -0
  25. package/src/hooks/domEditTextFieldCommitOps.test.ts +111 -0
  26. package/src/hooks/domEditTextFieldCommitOps.ts +63 -0
  27. package/src/hooks/domSelectionTestHarness.ts +40 -0
  28. package/src/hooks/useDomEditAttributeCommits.ts +227 -0
  29. package/src/hooks/useDomEditCommits.test.tsx +775 -0
  30. package/src/hooks/useDomEditCommits.ts +33 -5
  31. package/src/hooks/useDomEditTextCommits.ts +243 -220
  32. package/src/hooks/useDomSelection.test.ts +134 -0
  33. package/src/hooks/useDomSelection.ts +29 -15
  34. package/src/hooks/usePreviewInteraction.test.ts +260 -0
  35. package/src/hooks/usePreviewInteraction.ts +60 -19
  36. package/src/utils/sdkCutoverEligibility.test.ts +17 -0
  37. package/src/utils/sdkCutoverEligibility.ts +8 -2
  38. package/src/utils/sdkResolverShadow.test.ts +180 -1
  39. package/src/utils/sdkResolverShadow.ts +94 -1
  40. package/src/utils/sourcePatcher.ts +2 -0
  41. package/src/utils/studioPreviewHelpers.test.ts +95 -1
  42. package/src/utils/studioPreviewHelpers.ts +109 -10
  43. package/src/utils/studioSaveDiagnostics.ts +5 -2
  44. package/src/utils/studioTelemetry.ts +27 -20
  45. package/dist/chunk-AN2EWWK3.js.map +0 -1
  46. /package/dist/{domEditingLayers-EK7R7R4G.js.map → domEditingLayers-UIQZJCOA.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.7.28",
3
+ "version": "0.7.30",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -46,11 +46,11 @@
46
46
  "gsap": "^3.13.0",
47
47
  "marked": "^14.1.4",
48
48
  "mediabunny": "^1.45.3",
49
- "@hyperframes/core": "0.7.28",
50
- "@hyperframes/player": "0.7.28",
51
- "@hyperframes/sdk": "0.7.28",
52
- "@hyperframes/studio-server": "0.7.28",
53
- "@hyperframes/parsers": "0.7.28"
49
+ "@hyperframes/core": "0.7.30",
50
+ "@hyperframes/player": "0.7.30",
51
+ "@hyperframes/parsers": "0.7.30",
52
+ "@hyperframes/sdk": "0.7.30",
53
+ "@hyperframes/studio-server": "0.7.30"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@types/react": "19",
@@ -65,7 +65,7 @@
65
65
  "vite": "^6.4.2",
66
66
  "vitest": "^3.2.4",
67
67
  "zustand": "^5.0.0",
68
- "@hyperframes/producer": "0.7.28"
68
+ "@hyperframes/producer": "0.7.30"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "react": "19",
@@ -345,6 +345,101 @@ describe("DomEditOverlay", () => {
345
345
  Element.prototype.getBoundingClientRect = originalGetBoundingClientRect;
346
346
  host.remove();
347
347
  });
348
+
349
+ it("passes the tracked hover selection when clicking the existing selection box", async () => {
350
+ const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect;
351
+ Element.prototype.getBoundingClientRect = function (): DOMRect {
352
+ return {
353
+ left: 0,
354
+ top: 0,
355
+ right: 800,
356
+ bottom: 450,
357
+ width: 800,
358
+ height: 450,
359
+ x: 0,
360
+ y: 0,
361
+ toJSON: () => ({}),
362
+ };
363
+ };
364
+
365
+ const host = document.createElement("div");
366
+ document.body.append(host);
367
+ const root = createRoot(host);
368
+ const selection: DomEditSelection = {
369
+ element: document.createElement("div"),
370
+ id: "hero-title",
371
+ selector: ".hero-title",
372
+ selectorIndex: 0,
373
+ sourceFile: "index.html",
374
+ tagName: "div",
375
+ label: "Hero Title",
376
+ textContent: "Hello",
377
+ textFields: [],
378
+ capabilities: {
379
+ canEditText: true,
380
+ canEditLayout: true,
381
+ canMove: false,
382
+ canApplyManualOffset: false,
383
+ canApplyManualSize: false,
384
+ canApplyManualRotation: false,
385
+ canAdjustOpacity: true,
386
+ canAdjustFill: true,
387
+ canAdjustBorderRadius: true,
388
+ canAdjustStroke: true,
389
+ canAdjustShadow: true,
390
+ canAdjustZIndex: true,
391
+ },
392
+ computedStyle: {
393
+ display: "block",
394
+ position: "absolute",
395
+ },
396
+ };
397
+ const hoverSelection: DomEditSelection = { ...selection, id: "hovered-sibling" };
398
+ const onCanvasMouseDown = vi.fn();
399
+ const iframeRef = { current: document.createElement("iframe") as HTMLIFrameElement | null };
400
+
401
+ function Harness() {
402
+ return React.createElement(DomEditOverlay, {
403
+ ...createOverlayProps({
404
+ iframeRef,
405
+ selection,
406
+ hoverSelection,
407
+ onSelectionChange: () => {},
408
+ }),
409
+ onCanvasMouseDown,
410
+ });
411
+ }
412
+
413
+ act(() => {
414
+ root.render(React.createElement(Harness));
415
+ });
416
+
417
+ await act(async () => {
418
+ await new Promise<void>((resolve) => {
419
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
420
+ });
421
+ });
422
+
423
+ const selectionBox = host.querySelector(
424
+ '[data-dom-edit-selection-box="true"]',
425
+ ) as HTMLDivElement;
426
+ expect(selectionBox).toBeTruthy();
427
+
428
+ act(() => {
429
+ selectionBox.dispatchEvent(new MouseEvent("click", { bubbles: true }));
430
+ });
431
+
432
+ expect(onCanvasMouseDown).toHaveBeenCalledWith(
433
+ expect.anything(),
434
+ expect.objectContaining({ hoverSelection }),
435
+ );
436
+
437
+ act(() => {
438
+ root.unmount();
439
+ });
440
+ Element.prototype.getBoundingClientRect = originalGetBoundingClientRect;
441
+ host.remove();
442
+ });
348
443
  });
349
444
 
350
445
  describe("resolveDomEditCoordinateScale", () => {
@@ -1,6 +1,7 @@
1
1
  import { memo, useEffect, useMemo, useRef, useState, type RefObject } from "react";
2
2
  import { useMountEffect } from "../../hooks/useMountEffect";
3
3
  import { type DomEditSelection } from "./domEditing";
4
+ import type { PreviewMouseDownOptions } from "../../hooks/usePreviewInteraction";
4
5
  import { useMarqueeGestures } from "./marqueeCommit";
5
6
  import { MarqueeOverlay } from "./MarqueeOverlay";
6
7
  import { groupAwareOverlayRect, resolveDomEditGroupOverlayRect } from "./domEditOverlayGeometry";
@@ -44,7 +45,7 @@ interface DomEditOverlayProps {
44
45
  allowCanvasMovement?: boolean;
45
46
  onCanvasMouseDown: (
46
47
  event: React.MouseEvent<HTMLDivElement>,
47
- options?: { preferClipAncestor?: boolean },
48
+ options?: PreviewMouseDownOptions,
48
49
  ) => void;
49
50
  onCanvasPointerMove: (
50
51
  event: React.PointerEvent<HTMLDivElement>,
@@ -277,6 +278,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
277
278
  iframeRef,
278
279
  boxRef,
279
280
  selectionRef,
281
+ hoverSelectionRef,
280
282
  overlayRectRef,
281
283
  groupOverlayItemsRef,
282
284
  gestureRef,
@@ -336,7 +338,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
336
338
  // Allow clicks anywhere on the overlay — GSAP-translated elements can
337
339
  // extend beyond the composition rect into the gray zone, and users need
338
340
  // to select/deselect them by clicking there.
339
- onCanvasMouseDown(event, { preferClipAncestor: false });
341
+ onCanvasMouseDown(event, { hoverSelection: hoverSelectionRef.current });
340
342
  if (event.shiftKey) {
341
343
  suppressNextBoxMouseDownRef.current = true;
342
344
  suppressNextBoxClickRef.current = true;
@@ -401,7 +403,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
401
403
  event.stopPropagation();
402
404
  return;
403
405
  }
404
- onCanvasMouseDown(event, { preferClipAncestor: false });
406
+ onCanvasMouseDown(event, { hoverSelection: hoverSelectionRef.current });
405
407
  };
406
408
 
407
409
  const suppressBoxMouseDown = (e: React.MouseEvent) => {
@@ -9,6 +9,7 @@ import type { ManualOffsetDragMember } from "./manualOffsetDrag";
9
9
  import type { GroupOverlayItem, OverlayRect } from "./domEditOverlayGeometry";
10
10
  import type { SnapContext } from "./snapTargetCollection";
11
11
  import type { SnapGuidesState } from "./SnapGuideOverlay";
12
+ import type { PreviewMouseDownOptions } from "../../hooks/usePreviewInteraction";
12
13
 
13
14
  export type GestureKind = "drag" | "resize" | "rotate";
14
15
 
@@ -161,6 +162,7 @@ export type UseDomEditOverlayGesturesOptions = {
161
162
  iframeRef: RefObject<HTMLIFrameElement | null>;
162
163
  boxRef: RefObject<HTMLDivElement | null>;
163
164
  selectionRef: RefObject<DomEditSelection | null>;
165
+ hoverSelectionRef: RefObject<DomEditSelection | null>;
164
166
  overlayRectRef: RefObject<OverlayRect | null>;
165
167
  groupOverlayItemsRef: RefObject<GroupOverlayItem[]>;
166
168
  gestureRef: RefObject<GestureState | null>;
@@ -194,9 +196,6 @@ export type UseDomEditOverlayGesturesOptions = {
194
196
  o?: { preferClipAncestor?: boolean },
195
197
  ) => Promise<DomEditSelection | null>
196
198
  >;
197
- onCanvasMouseDown: (
198
- e: React.MouseEvent<HTMLDivElement>,
199
- o?: { preferClipAncestor?: boolean },
200
- ) => void;
199
+ onCanvasMouseDown: (e: React.MouseEvent<HTMLDivElement>, o?: PreviewMouseDownOptions) => void;
201
200
  snapGuidesRef: RefObject<SnapGuidesState | null>;
202
201
  };
@@ -31,6 +31,7 @@ export {
31
31
  buildDomEditTextPatchOperation,
32
32
  collectDomEditLayerItems,
33
33
  countDomEditChildLayers,
34
+ buildTextFieldChildLocator,
34
35
  getDomEditLayerKey,
35
36
  getDomEditNonEditableReason,
36
37
  getDomEditTargetKey,
@@ -4,11 +4,27 @@ import {
4
4
  collectDomEditLayerItems,
5
5
  resolveDomEditSelection,
6
6
  buildDomEditPatchTarget,
7
+ buildTextFieldChildLocator,
7
8
  readHfId,
8
9
  } from "./domEditingLayers";
10
+ import type { DomEditTextField } from "./domEditingTypes";
9
11
 
10
12
  const opts = { activeCompositionPath: "index.html", isMasterView: true, skipSourceProbe: true };
11
13
 
14
+ function textField(overrides: Partial<DomEditTextField> = {}): DomEditTextField {
15
+ return {
16
+ key: "child:0:span",
17
+ label: "Text 1",
18
+ value: "Hello",
19
+ tagName: "span",
20
+ attributes: [],
21
+ inlineStyles: {},
22
+ computedStyles: {},
23
+ source: "child",
24
+ ...overrides,
25
+ };
26
+ }
27
+
12
28
  describe("buildDomEditPatchTarget", () => {
13
29
  it("includes hfId when selection has hfId", () => {
14
30
  const target = buildDomEditPatchTarget({
@@ -172,3 +188,39 @@ describe("resolveDomEditSelection — data-hf-group capture", () => {
172
188
  expect(selection?.id).toBe("outside");
173
189
  });
174
190
  });
191
+
192
+ describe("buildTextFieldChildLocator", () => {
193
+ it("locates a child field using its DOM-derived sourceChildIndex", () => {
194
+ const fields = [textField({ key: "child:0:span", sourceChildIndex: 0 })];
195
+
196
+ expect(buildTextFieldChildLocator(fields, "child:0:span")).toEqual({
197
+ childSelector: ":scope > span",
198
+ childIndex: 0,
199
+ });
200
+ });
201
+
202
+ it("fails closed for a synthetic child field with no sourceChildIndex", () => {
203
+ // A field built by buildDefaultDomEditTextField (e.g. "add text field")
204
+ // has never been read back from the live DOM, so its true position among
205
+ // same-tag siblings is unknown. Guessing it by counting same-tag "child"
206
+ // fields elsewhere in the array can silently point at the wrong element.
207
+ const fields = [
208
+ textField({ key: "child:0:span", sourceChildIndex: 0 }),
209
+ textField({ key: "child:new:1", tagName: "span" }),
210
+ ];
211
+
212
+ expect(buildTextFieldChildLocator(fields, "child:new:1")).toBeNull();
213
+ });
214
+
215
+ it("returns null for a self-sourced field", () => {
216
+ const fields = [textField({ key: "self:0:div", source: "self", sourceChildIndex: 0 })];
217
+
218
+ expect(buildTextFieldChildLocator(fields, "self:0:div")).toBeNull();
219
+ });
220
+
221
+ it("returns null for an unknown field key", () => {
222
+ const fields = [textField({ key: "child:0:span", sourceChildIndex: 0 })];
223
+
224
+ expect(buildTextFieldChildLocator(fields, "missing")).toBeNull();
225
+ });
226
+ });
@@ -1,7 +1,3 @@
1
- /**
2
- * Layer items, text fields, capabilities, selection resolution, and patch operations
3
- * for dom editing.
4
- */
5
1
  import type { PatchOperation } from "../../utils/sourcePatcher";
6
2
  import {
7
3
  resolveEditingAffordances,
@@ -36,12 +32,20 @@ import {
36
32
  } from "./domEditingElement";
37
33
  import { isCompositionRootLayer } from "./domEditingRootLayer";
38
34
 
39
- // ─── Text fields ────────────────────────────────────────────────────────────
40
-
41
35
  export function isEditableTextLeaf(el: HTMLElement): boolean {
42
36
  return isTextBearingTag(el.tagName.toLowerCase()) && el.children.length === 0;
43
37
  }
44
38
 
39
+ function sameTagChildIndex(el: HTMLElement): number {
40
+ let index = 0;
41
+ let sibling = el.previousElementSibling;
42
+ while (sibling) {
43
+ if (sibling.tagName === el.tagName) index += 1;
44
+ sibling = sibling.previousElementSibling;
45
+ }
46
+ return index;
47
+ }
48
+
45
49
  function getTextFieldLabel(
46
50
  _tagName: string,
47
51
  index: number,
@@ -57,6 +61,7 @@ function buildTextField(
57
61
  index: number,
58
62
  total: number,
59
63
  source: "self" | "child",
64
+ sourceChildIndex?: number,
60
65
  ): DomEditTextField {
61
66
  const tagName = el.tagName.toLowerCase();
62
67
  const key = el.getAttribute("data-hf-text-key") ?? `${source}:${index}:${tagName}`;
@@ -74,6 +79,7 @@ function buildTextField(
74
79
  inlineStyles: getInlineStyles(el),
75
80
  computedStyles: getCuratedComputedStyles(el),
76
81
  source,
82
+ ...(sourceChildIndex == null ? {} : { sourceChildIndex }),
77
83
  };
78
84
  }
79
85
 
@@ -105,7 +111,9 @@ export function collectDomEditTextFields(el: HTMLElement): DomEditTextField[] {
105
111
  });
106
112
  childIdx++;
107
113
  } else if (isHtmlElement(node) && isEditableTextLeaf(node)) {
108
- fields.push(buildTextField(node, childIdx, childElements.length, "child"));
114
+ fields.push(
115
+ buildTextField(node, childIdx, childElements.length, "child", sameTagChildIndex(node)),
116
+ );
109
117
  childIdx++;
110
118
  }
111
119
  }
@@ -113,7 +121,7 @@ export function collectDomEditTextFields(el: HTMLElement): DomEditTextField[] {
113
121
  }
114
122
 
115
123
  return childElements.map((child, index) =>
116
- buildTextField(child, index, childElements.length, "child"),
124
+ buildTextField(child, index, childElements.length, "child", sameTagChildIndex(child)),
117
125
  );
118
126
  }
119
127
 
@@ -172,14 +180,30 @@ export function buildDefaultDomEditTextField(base?: Partial<DomEditTextField>):
172
180
  };
173
181
  }
174
182
 
175
- // ─── Capabilities ────────────────────────────────────────────────────────────
183
+ export interface DomEditChildLocator {
184
+ childSelector: string;
185
+ childIndex: number;
186
+ }
187
+
188
+ export function buildTextFieldChildLocator(
189
+ fields: DomEditTextField[],
190
+ fieldKey: string,
191
+ ): DomEditChildLocator | null {
192
+ const field = fields.find((candidate) => candidate.key === fieldKey);
193
+ if (!field || field.source !== "child") return null;
194
+ // sourceChildIndex is only absent for a synthetic field that was never read
195
+ // back from the live DOM (e.g. one built by buildDefaultDomEditTextField).
196
+ // Guessing its position by counting same-tag "child" fields elsewhere in
197
+ // the array is unreliable and can silently locate the wrong element — fail
198
+ // closed instead so the caller falls back to the unsupported-structure path.
199
+ if (field.sourceChildIndex == null) return null;
200
+
201
+ return {
202
+ childSelector: `:scope > ${field.tagName}`,
203
+ childIndex: field.sourceChildIndex,
204
+ };
205
+ }
176
206
 
177
- /**
178
- * Build the geometry/capability half of EditableElementFacts. Section inputs
179
- * (text/timing/animation) are irrelevant to capability resolution, so they are
180
- * zeroed here. Shared by the wrapper and the live-selection path so the two
181
- * fact-construction sites can't disagree.
182
- */
183
207
  function capabilityFacts(geometry: {
184
208
  hasStableTarget: boolean;
185
209
  tag: string;
@@ -276,8 +300,11 @@ async function probeSourceElement(
276
300
  },
277
301
  );
278
302
  if (!response.ok) return true;
279
- const data = (await response.json()) as { exists?: boolean };
280
- return data.exists !== false;
303
+ const data = await response.json();
304
+ if (data && typeof data === "object" && "exists" in data && data.exists === false) {
305
+ return false;
306
+ }
307
+ return true;
281
308
  } catch {
282
309
  return true;
283
310
  }
@@ -475,19 +502,28 @@ export function collectDomEditLayerItems(
475
502
 
476
503
  // ─── Patch operations ────────────────────────────────────────────────────────
477
504
 
478
- export function buildDomEditStylePatchOperation(property: string, value: string): PatchOperation {
505
+ export function buildDomEditStylePatchOperation(
506
+ property: string,
507
+ value: string | null,
508
+ childLocator?: DomEditChildLocator,
509
+ ): PatchOperation {
479
510
  return {
480
511
  type: "inline-style",
481
512
  property,
482
513
  value,
514
+ ...childLocator,
483
515
  };
484
516
  }
485
517
 
486
- export function buildDomEditTextPatchOperation(value: string): PatchOperation {
518
+ export function buildDomEditTextPatchOperation(
519
+ value: string,
520
+ childLocator?: DomEditChildLocator,
521
+ ): PatchOperation {
487
522
  return {
488
523
  type: "text-content",
489
524
  property: "text",
490
525
  value,
526
+ ...childLocator,
491
527
  };
492
528
  }
493
529
 
@@ -69,6 +69,7 @@ export interface DomEditTextField {
69
69
  inlineStyles: Record<string, string>;
70
70
  computedStyles: Record<string, string>;
71
71
  source: "self" | "child" | "text-node";
72
+ sourceChildIndex?: number;
72
73
  }
73
74
 
74
75
  export interface DomEditSelection extends PatchTarget {