@hyperframes/studio 0.8.124 → 0.8.126

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 (69) hide show
  1. package/dist/assets/{hyperframes-player-ColOZ_78.js → hyperframes-player-BbbN-wWN.js} +1 -1
  2. package/dist/assets/{index-BuSTrK-Z.js → index-B8kU2xXl.js} +177 -177
  3. package/dist/assets/{index-CVuUfURE.js → index-Cll7uTNy.js} +1 -1
  4. package/dist/assets/{index-jQEqFKMk.js → index-nvLF29ZH.js} +1 -1
  5. package/dist/index.d.ts +43 -35
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +4458 -4072
  8. package/dist/index.js.map +1 -1
  9. package/package.json +9 -8
  10. package/src/components/editor/DomEditSelectionChrome.test.tsx +5 -0
  11. package/src/components/editor/DomEditSelectionChrome.tsx +1 -0
  12. package/src/components/editor/MotionPathNode.tsx +56 -0
  13. package/src/components/editor/MotionPathOverlay.layerPress.test.tsx +140 -48
  14. package/src/components/editor/MotionPathOverlay.test.tsx +45 -0
  15. package/src/components/editor/MotionPathOverlay.tsx +30 -61
  16. package/src/components/editor/canvasContextMenuZOrder.ts +1 -10
  17. package/src/components/editor/domEditOverlayGestures.ts +2 -0
  18. package/src/components/editor/domEditOverlayStartGesture.ts +6 -0
  19. package/src/components/editor/dragUndoPaint.test.ts +170 -2
  20. package/src/components/editor/gestureUndoRevert.ts +104 -0
  21. package/src/components/editor/manualEditsSeekReapply.ts +3 -6
  22. package/src/components/editor/manualOffsetDrag.ts +3 -15
  23. package/src/components/editor/motionPathGeometry.test.ts +48 -1
  24. package/src/components/editor/motionPathGeometry.ts +46 -8
  25. package/src/components/editor/motionPathHome.ts +75 -0
  26. package/src/components/editor/motionPathLayerNode.test.ts +43 -35
  27. package/src/components/editor/motionPathLayerNode.ts +20 -16
  28. package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +20 -0
  29. package/src/components/editor/studioMotion.ts +7 -19
  30. package/src/components/editor/useDomEditNudge.ts +1 -1
  31. package/src/components/editor/useDomEditOverlayGestures.ts +9 -5
  32. package/src/components/editor/useMotionPathData.test.tsx +236 -0
  33. package/src/components/editor/useMotionPathData.ts +23 -61
  34. package/src/components/sidebar/CompositionsTab.thumbnails.test.tsx +1 -0
  35. package/src/hooks/gestureTransaction.test.ts +60 -0
  36. package/src/hooks/gestureTransaction.ts +5 -2
  37. package/src/hooks/gsapGestureOutcome.ts +9 -2
  38. package/src/hooks/gsapParsedTween.ts +16 -0
  39. package/src/hooks/gsapResizeDropPoint.test.ts +39 -1
  40. package/src/hooks/gsapResizeElementSize.test.tsx +27 -0
  41. package/src/hooks/gsapResizeIntercept.ts +55 -68
  42. package/src/hooks/gsapRuntimeKeyframes.realGsap.test.ts +48 -0
  43. package/src/hooks/gsapRuntimeKeyframes.ts +10 -3
  44. package/src/hooks/gsapScriptCommitTypes.ts +2 -0
  45. package/src/hooks/gsapShared.ts +2 -1
  46. package/src/hooks/gsapShared.writeTarget.test.ts +10 -0
  47. package/src/hooks/intersectionObserverTestUtils.ts +16 -0
  48. package/src/hooks/resizeObserverTestUtils.ts +9 -2
  49. package/src/hooks/timelineEditingHelpers.ts +2 -9
  50. package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +29 -1
  51. package/src/hooks/useGsapAwareEditing.test.tsx +1 -0
  52. package/src/hooks/useGsapAwareEditing.ts +21 -25
  53. package/src/hooks/useGsapAwareGroupMove.ts +4 -6
  54. package/src/hooks/useGsapScriptCommits.test.tsx +42 -1
  55. package/src/hooks/useGsapScriptCommits.ts +12 -3
  56. package/src/hooks/useThumbnailStripSize.test.tsx +236 -1
  57. package/src/hooks/useThumbnailStripSize.ts +206 -17
  58. package/src/noWindowBoundDomChecks.test.ts +31 -0
  59. package/src/player/components/CompositionThumbnail.tsx +10 -6
  60. package/src/player/components/ImageThumbnail.tsx +11 -5
  61. package/src/player/components/ThumbnailTiles.test.tsx +30 -0
  62. package/src/player/components/ThumbnailTiles.tsx +41 -0
  63. package/src/player/components/VideoThumbnail.test.tsx +116 -9
  64. package/src/player/components/VideoThumbnail.tsx +11 -5
  65. package/src/player/lib/timelineGroupInfo.realm.test.ts +20 -0
  66. package/src/player/lib/timelineGroupInfo.ts +2 -1
  67. package/src/utils/studioPendingEdits.test.ts +52 -0
  68. package/src/utils/studioPendingEdits.ts +55 -10
  69. package/src/webmcp/webmcpTestUtils.ts +2 -2
@@ -1,54 +1,64 @@
1
1
  // @vitest-environment happy-dom
2
2
  import { expect, it } from "vitest";
3
- import { pressBelongsToLayer, pressSelectedLayer } from "./motionPathLayerNode";
3
+ import { controlForNode, controlUnder, pressControl } from "./motionPathLayerNode";
4
4
 
5
- // Keyframe nodes at x 60 and 120 (y 30), drawn from home (500, 400); dots of radius 6.
6
- const atLayer = { x: 60, y: 30, ax: 560, ay: 430 };
7
- const other = { x: 120, y: 30, ax: 620, ay: 430 };
5
+ // Keyframe nodes at x 60 and 120 (y 30); GSAP renders the layer at the first.
6
+ const atLayer = { x: 60, y: 30 };
7
+ const other = { x: 120, y: 30 };
8
8
  const live = { x: 60, y: 30 };
9
9
 
10
+ // The editor overlay: the selection's chrome (box and a handle) and another layer's off-canvas marker.
11
+ const overlay = document.createElement("div");
12
+ const chrome = document.createElement("div");
13
+ chrome.setAttribute("data-dom-edit-chrome", "true");
10
14
  const box = document.createElement("div");
11
15
  box.setAttribute("data-dom-edit-selection-box", "true");
16
+ const handle = document.createElement("button");
17
+ const marker = document.createElement("div");
18
+ chrome.append(box, handle);
19
+ overlay.append(chrome, marker);
20
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
12
21
  const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
13
- document.body.append(box, circle);
22
+ const line = document.createElementNS("http://www.w3.org/2000/svg", "polyline");
23
+ svg.append(line, circle);
24
+ document.body.append(overlay, svg);
14
25
 
15
- /** A press on `circle` at (x, y), with `under` what the document hit-tests there. */
16
- function press(x: number, y: number, under: Element[]) {
17
- document.elementsFromPoint = () => [circle, ...under];
18
- return { currentTarget: circle, clientX: x, clientY: y } as unknown as React.PointerEvent;
26
+ /** A press on `circle`, with `under` what the document hit-tests below it. */
27
+ function press(under: Element[]) {
28
+ document.elementsFromPoint = () => [circle, line, ...under];
29
+ return { currentTarget: circle, clientX: 0, clientY: 0 } as unknown as React.PointerEvent;
19
30
  }
20
31
 
21
32
  it("the node GSAP renders the layer at is the layer, inside the box or out", () => {
22
- expect(pressBelongsToLayer(press(562, 431, [box]), { x: 562, y: 431 }, atLayer, 6, live)).toBe(
23
- true,
24
- );
25
- expect(pressBelongsToLayer(press(570, 440, []), { x: 570, y: 440 }, atLayer, 6, live)).toBe(true);
33
+ expect(controlForNode(press([box, overlay]), atLayer, live)).toBe(box);
34
+ expect(controlForNode(press([overlay]), atLayer, live)).toBe(box);
26
35
  });
27
36
 
28
- it("inside the layer's box another node's grab ring is the layer's; its dot stays the node's", () => {
29
- expect(pressBelongsToLayer(press(610, 430, [box]), { x: 610, y: 430 }, other, 6, live)).toBe(
30
- true,
31
- );
32
- expect(pressBelongsToLayer(press(617, 430, [box]), { x: 617, y: 430 }, other, 6, live)).toBe(
33
- false,
34
- );
37
+ it("inside the layer's box any other node is the layer's, so a drag from its middle moves it", () => {
38
+ expect(controlForNode(press([box, overlay]), other, live)).toBe(box);
39
+ expect(controlForNode(press([box, overlay]), other, null)).toBe(box);
35
40
  });
36
41
 
37
- it("outside the layer's box a node's whole grab ring is the node's", () => {
38
- expect(pressBelongsToLayer(press(610, 430, []), { x: 610, y: 430 }, other, 6, live)).toBe(false);
39
- expect(pressBelongsToLayer(press(610, 430, [box]), { x: 610, y: 430 }, other, 6, null)).toBe(
40
- true,
41
- );
42
+ it("a handle above the box under a node takes the press, not the box or the node", () => {
43
+ expect(controlForNode(press([handle, box, overlay]), other, live)).toBe(handle);
44
+ expect(controlForNode(press([handle, box, overlay]), atLayer, live)).toBe(handle);
45
+ expect(controlUnder(press([handle, overlay]))).toBe(handle);
42
46
  });
43
47
 
44
- it("hands a press to the selected layer's box with its pointer and position", () => {
48
+ it("outside the layer's box and its handles a node is the node's", () => {
49
+ expect(controlForNode(press([overlay]), other, live)).toBeNull();
50
+ expect(controlForNode(press([marker, overlay]), other, live)).toBeNull();
51
+ expect(controlForNode(press([document.body]), other, live)).toBeNull();
52
+ });
53
+
54
+ it("hands a press to the control with its pointer and position", () => {
45
55
  const got: number[][] = [];
46
56
  const listen = (e: Event) => {
47
57
  const p = e as PointerEvent;
48
58
  got.push([p.pointerId, p.clientX, p.clientY, p.button]);
49
59
  e.preventDefault();
50
60
  };
51
- box.addEventListener("pointerdown", listen);
61
+ handle.addEventListener("pointerdown", listen);
52
62
  const nativeEvent = new PointerEvent("pointerdown", {
53
63
  pointerId: 7,
54
64
  clientX: 12,
@@ -58,24 +68,22 @@ it("hands a press to the selected layer's box with its pointer and position", ()
58
68
  cancelable: true,
59
69
  });
60
70
  const down = { currentTarget: circle, nativeEvent } as unknown as React.PointerEvent;
61
- expect(pressSelectedLayer(down)).toBe(true);
71
+ expect(pressControl(down, handle)).toBe(true);
62
72
  expect(got).toEqual([[7, 12, 34, 0]]);
63
- box.removeEventListener("pointerdown", listen);
64
- box.remove();
65
- expect(pressSelectedLayer(down)).toBe(false);
66
- document.body.append(box);
73
+ expect(pressControl(down, null)).toBe(false);
74
+ handle.removeEventListener("pointerdown", listen);
67
75
  });
68
76
 
69
- it("keeps the press when the box does not start a gesture with it, or takes no pointer", () => {
77
+ it("keeps the press when the control does not start a gesture with it, or takes no pointer", () => {
70
78
  let got = 0;
71
79
  const listen = () => void got++;
72
80
  box.addEventListener("pointerdown", listen);
73
81
  const nativeEvent = new PointerEvent("pointerdown", { bubbles: true, cancelable: true });
74
82
  const down = { currentTarget: circle, nativeEvent } as unknown as React.PointerEvent;
75
- expect(pressSelectedLayer(down)).toBe(false);
83
+ expect(pressControl(down, box)).toBe(false);
76
84
  expect(got).toBe(1);
77
85
  box.style.pointerEvents = "none";
78
- expect(pressSelectedLayer(down)).toBe(false);
86
+ expect(pressControl(down, box)).toBe(false);
79
87
  expect(got).toBe(1);
80
88
  box.style.pointerEvents = "";
81
89
  box.removeEventListener("pointerdown", listen);
@@ -1,28 +1,32 @@
1
1
  type Point = { x: number; y: number };
2
- type DrawnNode = Point & { ax: number; ay: number };
3
2
 
4
3
  const LAYER_BOX = '[data-dom-edit-selection-box="true"]';
4
+ export const SELECTION_CHROME = `${LAYER_BOX}, [data-dom-edit-chrome="true"], [data-dom-edit-crop-frame="true"]`;
5
5
 
6
6
  /** How far a node's dot reaches; a selected node draws it larger. */
7
7
  export const dotRadius = (r: number, selected: boolean) => (selected ? r * 1.5 : r);
8
8
 
9
- /** A press on the layer's own node, or in its box but off a node's dot, belongs to the layer. */
10
- export function pressBelongsToLayer(
9
+ export function controlUnder(e: React.PointerEvent): Element | null {
10
+ const path = e.currentTarget.closest("svg");
11
+ const hits = e.currentTarget.ownerDocument.elementsFromPoint(e.clientX, e.clientY);
12
+ const hit = hits.find((el) => !path?.contains(el));
13
+ return hit?.closest(SELECTION_CHROME) ? hit : null;
14
+ }
15
+
16
+ /** The handle or box above a node, else the box if the layer sits at the node; null: the node. */
17
+ export function controlForNode(
11
18
  e: React.PointerEvent,
12
- point: Point,
13
- pressed: DrawnNode,
14
- dotR: number,
19
+ pressed: Point,
15
20
  live: Point | null,
16
- ): boolean {
17
- if (live && Math.abs(pressed.x - live.x) < 0.5 && Math.abs(pressed.y - live.y) < 0.5) return true;
18
- const hits = e.currentTarget.ownerDocument.elementsFromPoint(e.clientX, e.clientY);
19
- const inBox = hits.some((el) => el.closest(LAYER_BOX));
20
- return inBox && Math.hypot(point.x - pressed.ax, point.y - pressed.ay) > dotR;
21
+ ): Element | null {
22
+ const atLayer = live && Math.abs(pressed.x - live.x) < 0.5 && Math.abs(pressed.y - live.y) < 0.5;
23
+ return (
24
+ controlUnder(e) ?? (atLayer ? e.currentTarget.ownerDocument.querySelector(LAYER_BOX) : null)
25
+ );
21
26
  }
22
27
 
23
- /** Hands a press to the selected layer's box; true when the box started a gesture with it. */
24
- export function pressSelectedLayer(e: React.PointerEvent): boolean {
25
- const box = e.currentTarget.ownerDocument.querySelector(LAYER_BOX);
26
- if (!box || getComputedStyle(box).pointerEvents === "none") return false;
27
- return !box.dispatchEvent(new PointerEvent("pointerdown", e.nativeEvent));
28
+ /** Hands a press to `control`; true when it started a gesture with it. */
29
+ export function pressControl(e: React.PointerEvent, control: Element | null): boolean {
30
+ if (!control || getComputedStyle(control).pointerEvents === "none") return false;
31
+ return !control.dispatchEvent(new PointerEvent("pointerdown", e.nativeEvent));
28
32
  }
@@ -130,6 +130,26 @@ describe("reapplyPositionEditsAfterSeek", () => {
130
130
  delete win.__timelines;
131
131
  });
132
132
 
133
+ it("reapplies and migrates marks in the preview, whichever window built its node", () => {
134
+ const frame = document.body.appendChild(document.createElement("iframe"));
135
+ const doc = frame.contentDocument!;
136
+ // The frame builds one node; the editor builds the other, as on loads where they differ.
137
+ const nodes = [doc.createElement("div"), document.createElement("div")];
138
+ for (const el of nodes) {
139
+ el.setAttribute(`data-${STUDIO_PATH_OFFSET_ATTR}`, "true");
140
+ el.setAttribute(STUDIO_ROTATION_ATTR, "true");
141
+ el.style.setProperty(STUDIO_ROTATION_PROP, "8deg");
142
+ doc.body.append(el);
143
+ }
144
+
145
+ reapplyPositionEditsAfterSeek(doc);
146
+
147
+ for (const el of nodes) {
148
+ expect(el.getAttribute(STUDIO_PATH_OFFSET_ATTR)).toBe("true");
149
+ expect(el.style.getPropertyValue("rotate")).toContain(STUDIO_ROTATION_PROP);
150
+ }
151
+ });
152
+
133
153
  it("still migrates a legacy double-prefixed edit mark", () => {
134
154
  document.body.innerHTML = `<div id="a" data-${STUDIO_PATH_OFFSET_ATTR}="true"></div>`;
135
155
 
@@ -35,6 +35,7 @@ export {
35
35
  clearStudioMotionFromElement,
36
36
  } from "./studioMotionOps";
37
37
 
38
+ import { isHtmlElement } from "@hyperframes/core/runtime/dom-realm";
38
39
  import { readStudioMotionFromElement as readMotionAttr } from "./studioMotionOps";
39
40
  import {
40
41
  STUDIO_MOTION_ATTR,
@@ -71,21 +72,17 @@ function elementMatchesSourceFile(
71
72
  }
72
73
 
73
74
  function querySelectorCandidates(document: Document, selector: string): HTMLElement[] {
74
- const isCandidate = (element: Element): element is HTMLElement => {
75
- const HTMLElementCtor = element.ownerDocument.defaultView?.HTMLElement;
76
- return Boolean(HTMLElementCtor && element instanceof HTMLElementCtor);
77
- };
78
75
  const className = selector.match(/^\.([A-Za-z0-9_-]+)$/)?.[1];
79
76
  if (className) {
80
77
  return Array.from(document.getElementsByTagName("*")).filter(
81
78
  (element): element is HTMLElement =>
82
- isCandidate(element) && element.classList.contains(className),
79
+ isHtmlElement(element) && element.classList.contains(className),
83
80
  );
84
81
  }
85
82
  if (/^[A-Za-z][A-Za-z0-9-]*$/.test(selector)) {
86
- return Array.from(document.getElementsByTagName(selector)).filter(isCandidate);
83
+ return Array.from(document.getElementsByTagName(selector)).filter(isHtmlElement);
87
84
  }
88
- return Array.from(document.querySelectorAll(selector)).filter(isCandidate);
85
+ return Array.from(document.querySelectorAll(selector)).filter(isHtmlElement);
89
86
  }
90
87
 
91
88
  function resolveTarget(
@@ -93,12 +90,10 @@ function resolveTarget(
93
90
  target: StudioMotionTarget,
94
91
  activeCompositionPath: string | null,
95
92
  ): HTMLElement | null {
96
- const HTMLElementCtor = document.defaultView?.HTMLElement;
97
93
  if (target.id) {
98
94
  const byId = document.getElementById(target.id);
99
95
  if (
100
- HTMLElementCtor &&
101
- byId instanceof HTMLElementCtor &&
96
+ isHtmlElement(byId) &&
102
97
  elementMatchesSourceFile(byId, target.sourceFile, activeCompositionPath)
103
98
  ) {
104
99
  return byId;
@@ -135,10 +130,8 @@ function restoreStudioMotionElement(element: HTMLElement, gsap: StudioMotionWind
135
130
  }
136
131
 
137
132
  function restoreStudioMotionElements(document: Document, gsap: StudioMotionWindow["gsap"]): void {
138
- const HTMLElementCtor = document.defaultView?.HTMLElement;
139
- if (!HTMLElementCtor) return;
140
133
  for (const element of Array.from(document.querySelectorAll(`[${STUDIO_MOTION_ATTR}]`))) {
141
- if (element instanceof HTMLElementCtor) restoreStudioMotionElement(element, gsap);
134
+ if (isHtmlElement(element)) restoreStudioMotionElement(element, gsap);
142
135
  }
143
136
  }
144
137
 
@@ -238,11 +231,6 @@ export function applyStudioMotionFromDom(document: Document, currentTime?: numbe
238
231
  win.__timelines[STUDIO_MOTION_TIMELINE_ID]?.kill?.();
239
232
  delete win.__timelines[STUDIO_MOTION_TIMELINE_ID];
240
233
 
241
- // Restore elements that had GSAP motion applied previously but whose attribute
242
- // is now just the legacy marker "true" (i.e. they were restored/cleared).
243
- const HTMLElementCtor = document.defaultView?.HTMLElement;
244
- if (!HTMLElementCtor) return 0;
245
-
246
234
  // Collect elements that have JSON motion data in their attribute
247
235
  const motionElements: Array<{
248
236
  element: HTMLElement;
@@ -257,7 +245,7 @@ export function applyStudioMotionFromDom(document: Document, currentTime?: numbe
257
245
  }> = [];
258
246
 
259
247
  for (const el of Array.from(document.querySelectorAll(`[${STUDIO_MOTION_ATTR}]`))) {
260
- if (!(el instanceof HTMLElementCtor)) continue;
248
+ if (!isHtmlElement(el)) continue;
261
249
  const motionData = readMotionAttr(el);
262
250
  if (motionData) {
263
251
  motionElements.push({ element: el, motion: motionData });
@@ -32,10 +32,10 @@ import {
32
32
  applyManualOffsetNudgeDraft,
33
33
  createManualOffsetDragMember,
34
34
  endManualOffsetDragMembers,
35
- manualOffsetMoveRevert,
36
35
  restoreManualOffsetDragMembers,
37
36
  type ManualOffsetDragMember,
38
37
  } from "./manualOffsetDrag";
38
+ import { manualOffsetMoveRevert } from "./gestureUndoRevert";
39
39
  import { isStudioManualEditGestureCurrent, restoreStudioPathOffset } from "./manualEdits";
40
40
  import {
41
41
  CANVAS_NUDGE_COMMIT_DEBOUNCE_MS,
@@ -12,8 +12,8 @@ import {
12
12
  applyManualOffsetDragDraft,
13
13
  endManualOffsetDragMembers,
14
14
  restoreManualOffsetDragMembers,
15
- manualOffsetMoveRevert,
16
15
  } from "./manualOffsetDrag";
16
+ import { manualOffsetMoveRevert, elementLookRevert } from "./gestureUndoRevert";
17
17
  import { applyRotationDraft, restoreRotationDraft } from "./rotationDraft";
18
18
  import {
19
19
  applyStudioBoxSize,
@@ -443,7 +443,8 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
443
443
  // Hold the final angle while the commit lands.
444
444
  applyRotationDraft(sel.element, finalRotation.angle, g.plainRotation);
445
445
  const commit = { ...finalRotation, plain: g.plainRotation };
446
- void Promise.resolve(opts.onRotationCommitRef.current(sel, commit))
446
+ const edit = beginStudioPendingEdit(elementLookRevert(sel.element, g.initialLook));
447
+ const saved = Promise.resolve(edit.adopt(() => opts.onRotationCommitRef.current(sel, commit)))
447
448
  .then((result) => trackPreviewEditResult("rotate", "drag", result))
448
449
  .catch((error) => {
449
450
  logGestureCommitFailure("rotate commit failed", error);
@@ -454,6 +455,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
454
455
  restoreRotation();
455
456
  })
456
457
  .finally(() => endStudioManualEditGesture(sel.element, g.manualEditDragToken));
458
+ edit.settle(saved);
457
459
  } else if (g.kind === "drag") {
458
460
  // A moved drag (taps returned earlier) must not let the release click
459
461
  // re-select whatever now sits under the pointer — dropping over a
@@ -535,9 +537,10 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
535
537
  restoreStudioBoxSize(sel.element, g.initialBoxSize);
536
538
  if (finalOffset) restoreStudioPathOffset(sel.element, g.initialPathOffset);
537
539
  };
538
- void Promise.resolve(
539
- opts.onBoxSizeCommitRef.current(sel, finalSize, finalOffset ?? undefined, restore, member),
540
- )
540
+ const commitSize = () =>
541
+ opts.onBoxSizeCommitRef.current(sel, finalSize, finalOffset ?? undefined, restore, member);
542
+ const edit = beginStudioPendingEdit(elementLookRevert(sel.element, g.initialLook));
543
+ const saved = Promise.resolve(edit.adopt(commitSize))
541
544
  .then((result) => trackPreviewEditResult("resize", "drag", result))
542
545
  .catch((error) => {
543
546
  logGestureCommitFailure("resize commit failed", error);
@@ -546,6 +549,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
546
549
  if (member) endManualOffsetDragMembers([member]);
547
550
  else endStudioManualEditGesture(sel.element, g.manualEditDragToken);
548
551
  });
552
+ edit.settle(saved);
549
553
  logResizeSettle(sel.element, "post-release");
550
554
  }
551
555
  };
@@ -1,7 +1,9 @@
1
1
  // @vitest-environment happy-dom
2
+ import { gsap } from "gsap";
2
3
  import { act, useRef } from "react";
3
4
  import { createRoot } from "react-dom/client";
4
5
  import { afterEach, beforeEach, expect, it, vi } from "vitest";
6
+ import { elementHome } from "./motionPathHome";
5
7
  import { useMotionPathData } from "./useMotionPathData";
6
8
  import { resetOverlayFrameLoopForTests } from "./overlayFrameLoop";
7
9
  import { usePlayerStore } from "../../player/store/playerStore";
@@ -92,3 +94,237 @@ it("reads no layout per frame until there is a path or a create ring to draw", (
92
94
  act(() => root.unmount());
93
95
  }
94
96
  });
97
+
98
+ it("finds the layer whichever window built its node, so the path draws on every load", () => {
99
+ runtime.read = {
100
+ keyframes: [
101
+ { percentage: 0, properties: { x: 0, y: 0 } },
102
+ { percentage: 100, properties: { x: 100, y: 0 } },
103
+ ],
104
+ };
105
+ const iframe = document.createElement("iframe");
106
+ document.body.append(iframe);
107
+ const box = iframe.contentDocument!.createElement("div");
108
+ box.id = "box";
109
+ iframe.contentDocument!.body.append(box);
110
+ // happy-dom shares one realm, so the frame's own constructor stands in for another window's.
111
+ Object.defineProperty(iframe.contentWindow!, "HTMLElement", { value: class {} });
112
+ let home: unknown = null;
113
+ function Probe() {
114
+ const ref = useRef(iframe);
115
+ home = useMotionPathData(ref, "#box").home;
116
+ return null;
117
+ }
118
+ const root = createRoot(document.body.appendChild(document.createElement("div")));
119
+ try {
120
+ act(() => root.render(<Probe />));
121
+ runFrames(3);
122
+ expect(home).not.toBeNull();
123
+ } finally {
124
+ act(() => root.unmount());
125
+ }
126
+ });
127
+
128
+ it("redraws a node whose keyframe changes only its size", () => {
129
+ const read = (width: number) => ({
130
+ keyframes: [
131
+ { percentage: 66.667, properties: { x: 60, width } },
132
+ { percentage: 100, properties: { x: 120, width: 320 } },
133
+ ],
134
+ });
135
+ runtime.read = read(280);
136
+ runtime.gsapPosition = { x: 50, y: 30 };
137
+ let w: number | undefined;
138
+ function Probe() {
139
+ const ref = useRef(document.createElement("iframe"));
140
+ w = useMotionPathData(ref, "#box").geometry?.nodes[0]?.w;
141
+ return null;
142
+ }
143
+ const root = createRoot(document.body.appendChild(document.createElement("div")));
144
+ try {
145
+ act(() => root.render(<Probe />));
146
+ expect(w).toBe(280);
147
+ runtime.read = read(300);
148
+ act(() => vi.advanceTimersByTime(250));
149
+ expect(w).toBe(300);
150
+ } finally {
151
+ act(() => root.unmount());
152
+ }
153
+ });
154
+
155
+ /** A layer laid out at (960, 540), 240 x 160, with `css` inline and `cache` as GSAP's `_gsap`. */
156
+ function layer(css: Partial<CSSStyleDeclaration>, cache?: Record<string, number>) {
157
+ const el = document.body.appendChild(document.createElement("div"));
158
+ Object.assign(el.style, css);
159
+ const box = {
160
+ offsetLeft: 960,
161
+ offsetTop: 540,
162
+ offsetWidth: 240,
163
+ offsetHeight: 160,
164
+ offsetParent: null,
165
+ };
166
+ for (const [key, value] of Object.entries(box)) Object.defineProperty(el, key, { value });
167
+ return cache ? Object.assign(el, { _gsap: cache }) : el;
168
+ }
169
+
170
+ it("anchors a layer GSAP centres with xPercent/yPercent -50 on its centre", () => {
171
+ const el = layer(
172
+ { position: "absolute", left: "50%", top: "50%" },
173
+ { x: 0, y: 0, xPercent: -50, yPercent: -50 },
174
+ );
175
+ expect(elementHome(el)).toEqual({ x: 960, y: 540, w: 240, h: 160, ax: 0, ay: 0 });
176
+ });
177
+
178
+ it("moves a resized layer's centre by half the change from a set left, against it from a set right, and not in flow", () => {
179
+ const fromLeft = layer({ position: "absolute", left: "10px", top: "10px" });
180
+ expect(elementHome(fromLeft)).toMatchObject({ x: 1080, y: 620, ax: 0.5, ay: 0.5 });
181
+ const fromRight = layer({ position: "absolute", right: "10px", bottom: "10px" });
182
+ const auto = new Set(["left", "top"]);
183
+ fromRight.computedStyleMap = () =>
184
+ ({
185
+ get: (side: string) => (auto.has(side) ? "auto" : "10px"),
186
+ }) as unknown as StylePropertyMapReadOnly;
187
+ expect(elementHome(fromRight)).toMatchObject({ ax: -0.5, ay: -0.5 });
188
+ expect(elementHome(layer({}))).toMatchObject({ x: 1080, ax: 0, ay: 0 });
189
+ });
190
+
191
+ const resolved = (style: CSSStyleDeclaration) =>
192
+ (style.translate ?? "").replace(/var\((--[\w-]+)\)/g, (_, name) =>
193
+ style.getPropertyValue(name),
194
+ ) || "none";
195
+
196
+ /** Computes `translate` and `transform` as Chromium does (happy-dom does not), so GSAP's parse would fold them. */
197
+ function computeTranslate() {
198
+ const real = window.getComputedStyle.bind(window);
199
+ const computed = (node: Element, pseudo?: string | null) =>
200
+ new Proxy(real(node, pseudo), {
201
+ get(style, key) {
202
+ if (key === "translate") return resolved((node as HTMLElement).style);
203
+ if (key === "transform") return (node as HTMLElement).style.transform || "none";
204
+ if (key === "scale" || key === "rotate") return "none";
205
+ const value = Reflect.get(style, key);
206
+ return typeof value === "function" ? value.bind(style) : value;
207
+ },
208
+ });
209
+ vi.stubGlobal("getComputedStyle", computed);
210
+ vi.spyOn(window, "getComputedStyle").mockImplementation(
211
+ computed as typeof window.getComputedStyle,
212
+ );
213
+ }
214
+
215
+ it("leaves a layer's CSS translate alone and anchors where GSAP's parse will put it (real GSAP)", () => {
216
+ vi.stubGlobal("gsap", gsap);
217
+ computeTranslate();
218
+ try {
219
+ const offset = "var(--hf-studio-offset-x) var(--hf-studio-offset-y)";
220
+ const cases: [string, number, number][] = [
221
+ // GSAP folds these into x, which a created path sets.
222
+ ["40px 30px", 1080, 620],
223
+ [offset, 1080, 620],
224
+ // Minus half the size becomes xPercent/yPercent -50.
225
+ ["-50% -50%", 960, 540],
226
+ ["-120px -80px", 960, 540],
227
+ ];
228
+ // GSAP sums the layer's own transform with its translate: Chromium's matrix for translate(-50%, -50%).
229
+ const centred = layer({ position: "absolute", transform: "matrix(1, 0, 0, 1, -120, -80)" });
230
+ expect(elementHome(centred)).toMatchObject({ x: 960, y: 540 });
231
+ for (const [translate, x, y] of cases) {
232
+ const el = layer({ position: "absolute", left: "10px", top: "10px", translate });
233
+ el.style.setProperty("--hf-studio-offset-x", "40px");
234
+ el.style.setProperty("--hf-studio-offset-y", "30px");
235
+ expect(elementHome(el), translate).toMatchObject({ x, y });
236
+ expect([el.style.translate, el.style.transform]).toEqual([translate, ""]);
237
+ }
238
+ // A drag's offset on a layer GSAP already parsed composes with its transform.
239
+ const dragged = layer({ position: "absolute", left: "10px", top: "10px" });
240
+ gsap.set(dragged, { x: 20 });
241
+ dragged.style.setProperty("--hf-studio-offset-x", "40px");
242
+ dragged.style.setProperty("--hf-studio-offset-y", "30px");
243
+ dragged.style.translate = offset;
244
+ expect(elementHome(dragged)).toMatchObject({ x: 1120, y: 650 });
245
+ // After clearProps GSAP marks its cache uncache and its next parse folds the translate again.
246
+ const css = { position: "absolute", left: "10px", top: "10px", translate: "40px 30px" };
247
+ expect(elementHome(layer(css, { x: 0, y: 0, uncache: 1 }))).toMatchObject({ x: 1080, y: 620 });
248
+ // A fade gives GSAP a cache with no transform in it yet, so its next parse still folds.
249
+ expect(elementHome(layer(css, { opacity: 1 }))).toMatchObject({ x: 1080, y: 620 });
250
+ const owned = layer({ position: "absolute", left: "50%", top: "50%" });
251
+ gsap.set(owned, { xPercent: -50, yPercent: -50 });
252
+ expect(elementHome(owned)).toMatchObject({ x: 960, y: 540, ax: 0, ay: 0 });
253
+ } finally {
254
+ vi.unstubAllGlobals();
255
+ vi.restoreAllMocks();
256
+ }
257
+ });
258
+
259
+ it("redraws the start ring when only GSAP's start changes", () => {
260
+ const read = (start?: Record<string, number>) => ({
261
+ keyframes: [
262
+ { percentage: 50, properties: { x: 60 } },
263
+ { percentage: 100, properties: { x: 120 } },
264
+ ],
265
+ ...(start && { start }),
266
+ });
267
+ runtime.read = read();
268
+ runtime.gsapPosition = { x: 50, y: 30 };
269
+ let start: unknown;
270
+ function Probe() {
271
+ const ref = useRef(document.createElement("iframe"));
272
+ start = useMotionPathData(ref, "#box").geometry?.start;
273
+ return null;
274
+ }
275
+ const root = createRoot(document.body.appendChild(document.createElement("div")));
276
+ try {
277
+ act(() => root.render(<Probe />));
278
+ expect(start).toBeUndefined();
279
+ runtime.read = read({ x: 40 });
280
+ act(() => vi.advanceTimersByTime(250));
281
+ expect(start).toEqual({ x: 40, y: 30 });
282
+ } finally {
283
+ act(() => root.unmount());
284
+ }
285
+ });
286
+
287
+ it("updates the home when the layer's size or anchoring changes but its centre does not", () => {
288
+ runtime.read = {
289
+ keyframes: [
290
+ { percentage: 0, properties: { x: 0, width: 200 } },
291
+ { percentage: 100, properties: { x: 100, width: 240 } },
292
+ ],
293
+ };
294
+ const iframe = document.body.appendChild(document.createElement("iframe"));
295
+ const layout = { offsetLeft: 100, offsetWidth: 200 };
296
+ const box = iframe.contentDocument!.body.appendChild(
297
+ iframe.contentDocument!.createElement("div"),
298
+ );
299
+ box.id = "box";
300
+ for (const key of ["offsetLeft", "offsetWidth"] as const)
301
+ Object.defineProperty(box, key, { get: () => layout[key] });
302
+ let w: number | undefined;
303
+ let ax: number | undefined;
304
+ function Probe() {
305
+ const ref = useRef(iframe);
306
+ ({ w, ax } = useMotionPathData(ref, "#box").home ?? {});
307
+ return null;
308
+ }
309
+ const root = createRoot(document.body.appendChild(document.createElement("div")));
310
+ try {
311
+ act(() => root.render(<Probe />));
312
+ runFrames(2);
313
+ expect(w).toBe(200);
314
+ Object.assign(layout, { offsetLeft: 80, offsetWidth: 240 });
315
+ runFrames(2);
316
+ expect(w).toBe(240);
317
+ // Re-anchored from the left to the right edge without moving: the share a size change moves changes.
318
+ Object.assign(box.style, { position: "absolute", left: "10px" });
319
+ runFrames(2);
320
+ expect(ax).toBe(0.5);
321
+ box.computedStyleMap = () =>
322
+ ({
323
+ get: (side: string) => (side === "left" ? "auto" : "0px"),
324
+ }) as unknown as StylePropertyMapReadOnly;
325
+ runFrames(2);
326
+ expect(ax).toBe(-0.5);
327
+ } finally {
328
+ act(() => root.unmount());
329
+ }
330
+ });