@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
@@ -9,6 +9,7 @@ import {
9
9
  restoreManualOffsetDragMembers,
10
10
  type ManualOffsetDragMember,
11
11
  } from "./manualOffsetDrag";
12
+ import { readElementLook } from "./gestureUndoRevert";
12
13
  import { readCssRotationTarget, readRotationBase } from "./rotationDraft";
13
14
  import {
14
15
  beginStudioManualEditGesture,
@@ -179,6 +180,10 @@ export function startGesture(
179
180
  Number.isFinite(rawContentScaleX) && rawContentScaleX > 0 ? rawContentScaleX : 1;
180
181
  const contentScaleY =
181
182
  Number.isFinite(rawContentScaleY) && rawContentScaleY > 0 ? rawContentScaleY : 1;
183
+ const initialLook = readElementLook(
184
+ sel.element,
185
+ kind !== "drag" && !editsPlainCss(sel.element, kind === "rotate" ? "rotate" : "resize"),
186
+ );
182
187
  let initialPathOffset = captureStudioPathOffset(sel.element);
183
188
  let manualEditDragToken: string | undefined;
184
189
  let pathOffsetMember: ManualOffsetDragMember | undefined;
@@ -274,6 +279,7 @@ export function startGesture(
274
279
  initialPathOffset,
275
280
  initialRotation: captureStudioRotation(sel.element),
276
281
  initialBoxSize: captureStudioBoxSize(sel.element),
282
+ initialLook,
277
283
  pathOffsetMember,
278
284
  originLeft: rect.left,
279
285
  originTop: rect.top,
@@ -1,15 +1,40 @@
1
1
  // @vitest-environment happy-dom
2
2
  import { afterEach, expect, it, vi } from "vitest";
3
+ import { savePlainRotation } from "../../hooks/plainRotation";
3
4
  import type { DomEditSelection } from "./domEditing";
4
5
  import type { GestureState } from "./domEditOverlayGestures";
5
6
  import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
6
7
  import {
8
+ adoptingStudioPendingEdit,
7
9
  hasStudioPendingEdits,
8
10
  paintBackNewestStudioPendingEdit,
11
+ type StudioEditInFlight,
9
12
  } from "../../utils/studioPendingEdits";
10
13
 
14
+ const gsapOwns = vi.hoisted(() => ({ on: false }));
15
+ vi.mock("../../hooks/gsapRuntimeKeyframes", async (importOriginal) => ({
16
+ ...(await importOriginal<typeof import("../../hooks/gsapRuntimeKeyframes")>()),
17
+ editsPlainCss: () => !gsapOwns.on,
18
+ }));
19
+
20
+ /** GSAP's own transform values per element, as `gsap.set` / `gsap.getProperty` see them. */
21
+ function fakeGsap(start: Record<string, number>) {
22
+ const values = new Map<Element, Record<string, number>>();
23
+ const of = (el: Element) => values.get(el) ?? values.set(el, { ...start }).get(el)!;
24
+ gsapOwns.on = true;
25
+ Object.assign(window, {
26
+ gsap: {
27
+ set: (el: Element, props: Record<string, number>) => Object.assign(of(el), props),
28
+ getProperty: (el: Element, prop: string) => of(el)[prop] ?? 0,
29
+ },
30
+ });
31
+ return of;
32
+ }
33
+
11
34
  afterEach(() => {
12
35
  document.body.innerHTML = "";
36
+ gsapOwns.on = false;
37
+ Reflect.deleteProperty(window, "gsap");
13
38
  });
14
39
 
15
40
  const ref = <T>(current: T) => ({ current });
@@ -24,7 +49,7 @@ const pointer = (x: number, y: number) => ({
24
49
  });
25
50
 
26
51
  /** Drags a box without GSAP 100 px right and 60 px down; its save waits for `save`. */
27
- function dragWithSaveRunning(save: Promise<void>) {
52
+ function dragWithSaveRunning(save: Promise<void> | (() => Promise<void>)) {
28
53
  const element = document.createElement("div");
29
54
  element.style.setProperty("translate", "40px 30px");
30
55
  document.body.append(element);
@@ -39,7 +64,7 @@ function dragWithSaveRunning(save: Promise<void>) {
39
64
  rafPausedRef: ref(false),
40
65
  onManualDragStartRef: ref(vi.fn()),
41
66
  onBlockedMoveRef: ref(vi.fn()),
42
- onPathOffsetCommitRef: ref(vi.fn(() => save)),
67
+ onPathOffsetCommitRef: ref(vi.fn(typeof save === "function" ? save : () => save)),
43
68
  snapGuidesRef: ref(null),
44
69
  groupGestureRef: ref(null),
45
70
  blockedMoveRef: ref(null),
@@ -69,3 +94,146 @@ it("a drag whose save is still running can be painted back at once, and shown ag
69
94
  await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
70
95
  expect(paintBackNewestStudioPendingEdit()).toBeNull();
71
96
  });
97
+
98
+ /** Resizes or rotates a plain 240x160 box; the commit draws what it saves at once and its save waits for `save`. */
99
+ function gestureWithSaveRunning(kind: "resize" | "rotate", save: Promise<void>) {
100
+ const element = document.createElement("div");
101
+ element.setAttribute(
102
+ "style",
103
+ "position: absolute; width: 240px; height: 160px; clip-path: inset(10px)",
104
+ );
105
+ document.body.append(element);
106
+ const selection = {
107
+ element,
108
+ capabilities: { canApplyManualSize: true, canApplyManualRotation: true },
109
+ };
110
+ const handlers = createDomEditOverlayGestureHandlers({
111
+ selectionRef: ref(selection as unknown as DomEditSelection),
112
+ overlayRectRef: ref({ left: 0, top: 0, width: 240, height: 160, editScaleX: 1, editScaleY: 1 }),
113
+ boxRef: ref(document.createElement("div")),
114
+ overlayRef: ref(null),
115
+ iframeRef: ref(null),
116
+ gestureRef: ref<GestureState | null>(null),
117
+ rafPausedRef: ref(false),
118
+ onBoxSizeCommitRef: ref(
119
+ vi.fn(() => {
120
+ element.style.setProperty("clip-path", "inset(15px)");
121
+ return save;
122
+ }),
123
+ ),
124
+ onRotationCommitRef: ref((sel: DomEditSelection, next: never) =>
125
+ savePlainRotation({ commitPositionPatchToHtml: () => save.then(() => undefined) }, sel, next),
126
+ ),
127
+ snapGuidesRef: ref(null),
128
+ groupGestureRef: ref(null),
129
+ blockedMoveRef: ref(null),
130
+ setOverlayRect: vi.fn(),
131
+ suppressNextBoxClickRef: ref(false),
132
+ hoverSelectionRef: ref(null),
133
+ onCanvasMouseDown: vi.fn(),
134
+ } as never);
135
+ const start = kind === "resize" ? pointer(240, 160) : pointer(240, 80);
136
+ expect(handlers.startGesture(kind, start as never, { resizeHandle: "se" })).toBe(true);
137
+ const end = kind === "resize" ? pointer(300, 200) : pointer(120, 200);
138
+ handlers.onPointerMove(end as never);
139
+ handlers.onPointerUp(end as never);
140
+ return element;
141
+ }
142
+
143
+ it.each(["resize", "rotate"] as const)(
144
+ "a %s whose save is still running can be painted back at once, and shown again",
145
+ async (kind) => {
146
+ let saved!: () => void;
147
+ const element = gestureWithSaveRunning(kind, new Promise<void>((resolve) => (saved = resolve)));
148
+ const before = "position: absolute; width: 240px; height: 160px; clip-path: inset(10px)";
149
+ const edited = element.getAttribute("style");
150
+ expect(edited).not.toBe(before);
151
+
152
+ const shown = paintBackNewestStudioPendingEdit();
153
+ expect(element.getAttribute("style")).toBe(before);
154
+ expect(paintBackNewestStudioPendingEdit()).toBeNull();
155
+ shown!.showAgain();
156
+ expect(element.getAttribute("style")).toBe(edited);
157
+
158
+ saved();
159
+ await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
160
+ },
161
+ );
162
+
163
+ it("a drag of a box GSAP positions is painted back to where GSAP had it, and shown again", async () => {
164
+ const gsapOf = fakeGsap({ x: 5, y: 7 });
165
+ let saved!: () => void;
166
+ const element = dragWithSaveRunning(new Promise<void>((resolve) => (saved = resolve)));
167
+ const moved = { ...gsapOf(element) };
168
+ expect(moved).not.toMatchObject({ x: 5, y: 7 });
169
+
170
+ const shown = paintBackNewestStudioPendingEdit();
171
+ expect(gsapOf(element)).toMatchObject({ x: 5, y: 7 });
172
+ shown!.showAgain();
173
+ expect(gsapOf(element)).toMatchObject({ x: moved.x, y: moved.y });
174
+
175
+ saved();
176
+ await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
177
+ });
178
+
179
+ it("a rotate of a box GSAP turns is painted back to GSAP's angle at press, and shown again", async () => {
180
+ const gsapOf = fakeGsap({ rotation: 10 });
181
+ let saved!: () => void;
182
+ const element = gestureWithSaveRunning(
183
+ "rotate",
184
+ new Promise<void>((resolve) => (saved = resolve)),
185
+ );
186
+ const turned = gsapOf(element).rotation;
187
+ expect(turned).not.toBe(10);
188
+
189
+ const shown = paintBackNewestStudioPendingEdit();
190
+ expect(gsapOf(element).rotation).toBe(10);
191
+ shown!.showAgain();
192
+ expect(gsapOf(element).rotation).toBe(turned);
193
+
194
+ saved();
195
+ await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
196
+ });
197
+
198
+ it("a GSAP drag saved as a left/top offset stays painted back while that offset is drawn", async () => {
199
+ fakeGsap({ x: 5, y: 7 });
200
+ let saved!: () => void;
201
+ let drawn!: Promise<void>;
202
+ const element = dragWithSaveRunning(() => {
203
+ const edit = adoptingStudioPendingEdit()!;
204
+ drawn = new Promise<void>((resolve) => (saved = resolve)).then(() =>
205
+ edit.drawKeepingUndone(() => void element.style.setProperty("left", "99px")),
206
+ );
207
+ return drawn;
208
+ });
209
+
210
+ const shown = paintBackNewestStudioPendingEdit();
211
+ saved();
212
+ await drawn;
213
+ expect(element.style.getPropertyValue("left")).toBe("");
214
+ shown!.showAgain();
215
+ expect(element.style.getPropertyValue("left")).toBe("99px");
216
+ await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
217
+ });
218
+
219
+ it("keeps a GSAP drag's base through every repaint of its undo, so its save adds the drag once", async () => {
220
+ const gsapOf = fakeGsap({ x: 5, y: 7 });
221
+ let edit!: StudioEditInFlight;
222
+ let release!: () => void;
223
+ const element = dragWithSaveRunning(() => {
224
+ edit = adoptingStudioPendingEdit()!;
225
+ return new Promise<void>((resolve) => (release = resolve));
226
+ });
227
+ const moved = { ...gsapOf(element) };
228
+
229
+ const shown = paintBackNewestStudioPendingEdit();
230
+ edit.drawKeepingUndone(() => undefined);
231
+ expect(gsapOf(element)).toMatchObject({ x: 5, y: 7 });
232
+ expect(element.getAttribute("data-hf-drag-gsap-base-x")).toBe("5");
233
+ shown!.showAgain();
234
+ expect(gsapOf(element)).toMatchObject({ x: moved.x, y: moved.y });
235
+ expect(element.getAttribute("data-hf-drag-gsap-base-x")).toBe("5");
236
+
237
+ release();
238
+ await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
239
+ });
@@ -0,0 +1,104 @@
1
+ import {
2
+ captureStudioPathOffset,
3
+ restoreStudioPathOffset,
4
+ type StudioPathOffsetSnapshot,
5
+ } from "./manualEdits";
6
+ import {
7
+ getOffsetDragGsap,
8
+ stampGestureBase,
9
+ type ManualOffsetDragMember,
10
+ } from "./manualOffsetDrag";
11
+ import type { StudioEditRevert } from "../../utils/studioPendingEdits";
12
+
13
+ interface MemberPosition {
14
+ offset: StudioPathOffsetSnapshot;
15
+ gsap: { x: number; y: number } | null;
16
+ placement: string[];
17
+ }
18
+
19
+ // Where an element-offset move draws the element when no transform channel can take it.
20
+ const PLACEMENT = ["position", "left", "top"];
21
+ const placementOf = (element: HTMLElement) =>
22
+ PLACEMENT.map((prop) => element.style.getPropertyValue(prop));
23
+
24
+ function showPlacement(element: HTMLElement, placement: string[]): void {
25
+ PLACEMENT.forEach((prop, i) => {
26
+ if (placement[i]) element.style.setProperty(prop, placement[i]!);
27
+ else element.style.removeProperty(prop);
28
+ });
29
+ }
30
+
31
+ function readMemberPosition(member: ManualOffsetDragMember): MemberPosition {
32
+ const gsap = member.plainTranslate ? null : getOffsetDragGsap(member.element);
33
+ return {
34
+ offset: captureStudioPathOffset(member.element),
35
+ gsap: gsap && {
36
+ x: Number(gsap.getProperty(member.element, "x")),
37
+ y: Number(gsap.getProperty(member.element, "y")),
38
+ },
39
+ placement: placementOf(member.element),
40
+ };
41
+ }
42
+
43
+ function showMemberPosition(member: ManualOffsetDragMember, position: MemberPosition): void {
44
+ restoreStudioPathOffset(member.element, position.offset);
45
+ showPlacement(member.element, position.placement);
46
+ if (position.gsap) getOffsetDragGsap(member.element)?.set(member.element, { ...position.gsap });
47
+ // The restore drops the gesture's base stamps; its save still reads them.
48
+ if (!member.plainTranslate)
49
+ stampGestureBase(member.element, member.initialOffset, member.baseGsap);
50
+ }
51
+
52
+ /** Undo's live revert of a move: its members at gesture start. */
53
+ export function manualOffsetMoveRevert(members: ManualOffsetDragMember[]): StudioEditRevert {
54
+ const startPlacement = members.map((member) => placementOf(member.element));
55
+ return () => {
56
+ const shown = members.map(readMemberPosition);
57
+ members.forEach((member, i) =>
58
+ showMemberPosition(member, {
59
+ offset: member.initialPathOffset,
60
+ gsap: member.plainTranslate ? null : member.baseGsap,
61
+ placement: startPlacement[i]!,
62
+ }),
63
+ );
64
+ return () => members.forEach((member, i) => showMemberPosition(member, shown[i]!));
65
+ };
66
+ }
67
+
68
+ export interface StudioElementLook {
69
+ style: string | null;
70
+ gsap: Record<string, number> | null;
71
+ }
72
+
73
+ const GSAP_LOOK_PROPS = ["x", "y", "rotation", "scaleX", "scaleY"];
74
+
75
+ /** Reads GSAP only for an element it owns: reading a plain one makes GSAP bake its CSS into a transform. */
76
+ export function readElementLook(element: HTMLElement, gsapOwned: boolean): StudioElementLook {
77
+ const gsap = gsapOwned ? getOffsetDragGsap(element) : null;
78
+ return {
79
+ style: element.getAttribute("style"),
80
+ gsap:
81
+ gsap &&
82
+ Object.fromEntries(
83
+ GSAP_LOOK_PROPS.map((prop) => [prop, Number(gsap.getProperty(element, prop))]),
84
+ ),
85
+ };
86
+ }
87
+
88
+ function showElementLook(element: HTMLElement, look: StudioElementLook): void {
89
+ if (look.style === null) element.removeAttribute("style");
90
+ else element.setAttribute("style", look.style);
91
+ if (look.gsap) getOffsetDragGsap(element)?.set(element, { ...look.gsap });
92
+ }
93
+
94
+ /** Undo's live revert of a resize or rotate: the element as it looked at press. */
95
+ export function elementLookRevert(
96
+ element: HTMLElement,
97
+ start: StudioElementLook,
98
+ ): StudioEditRevert {
99
+ return () => {
100
+ const shown = readElementLook(element, start.gsap !== null);
101
+ showElementLook(element, start);
102
+ return () => showElementLook(element, shown);
103
+ };
104
+ }
@@ -1,4 +1,5 @@
1
1
  // Puts Studio's committed edits back after a timeline seek has rendered over them.
2
+ import { isHtmlElement } from "@hyperframes/core/runtime/dom-realm";
2
3
  import {
3
4
  STUDIO_BOX_SIZE_ATTR,
4
5
  STUDIO_HEIGHT_PROP,
@@ -15,15 +16,11 @@ import { STUDIO_MOTION_ATTR, STUDIO_MOTION_TIMELINE_ID } from "./studioMotionTyp
15
16
  import { gsapWritesChannels } from "../../hooks/gsapRuntimeKeyframes";
16
17
 
17
18
  function queryStudioElements(doc: Document, attr: string): HTMLElement[] {
18
- const ctor = doc.defaultView?.HTMLElement;
19
- if (!ctor) return [];
20
- const elements = Array.from(doc.querySelectorAll(`[${attr}="true"]`)).filter(
21
- (el): el is HTMLElement => el instanceof ctor,
22
- );
19
+ const elements = Array.from(doc.querySelectorAll(`[${attr}="true"]`)).filter(isHtmlElement);
23
20
  // Handle legacy HTML files where attributes were persisted with a double data- prefix
24
21
  const legacyAttr = `data-${attr}`;
25
22
  for (const el of doc.querySelectorAll(`[${legacyAttr}="true"]`)) {
26
- if (el instanceof ctor && !el.hasAttribute(attr)) {
23
+ if (isHtmlElement(el) && !el.hasAttribute(attr)) {
27
24
  el.setAttribute(attr, "true");
28
25
  el.removeAttribute(legacyAttr);
29
26
  elements.push(el);
@@ -1,3 +1,4 @@
1
+ import { isHtmlElement } from "@hyperframes/core/runtime/dom-realm";
1
2
  import type { DomEditSelection } from "./domEditing";
2
3
  import {
3
4
  applyStudioPathOffset,
@@ -13,7 +14,6 @@ import {
13
14
  import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition";
14
15
  import { editsPlainCss } from "../../hooks/gsapRuntimeKeyframes";
15
16
  import { readTranslatePx, UNREADABLE_TRANSLATE, writeTranslatePx } from "./plainTranslate";
16
- import type { StudioEditRevert } from "../../utils/studioPendingEdits";
17
17
 
18
18
  interface OffsetDragGsap {
19
19
  set: (el: Element, vars: Record<string, number | string>) => void;
@@ -132,9 +132,7 @@ function getFrameElement(win: Window): HTMLElement | null {
132
132
  try {
133
133
  const frameElement = win.frameElement;
134
134
  if (!frameElement) return null;
135
- const ownerWin = frameElement.ownerDocument.defaultView;
136
- const htmlElement = ownerWin?.HTMLElement;
137
- return htmlElement && frameElement instanceof htmlElement ? frameElement : null;
135
+ return isHtmlElement(frameElement) ? frameElement : null;
138
136
  } catch {
139
137
  return null;
140
138
  }
@@ -448,7 +446,7 @@ export function applyManualOffsetDragDraft(
448
446
  * the element flies off-screen the instant you drop it. The member holds the
449
447
  * true gesture-start values in JS, immune to the re-render.
450
448
  */
451
- function stampGestureBase(el: HTMLElement, initialOffset: Point, baseGsap: Point): void {
449
+ export function stampGestureBase(el: HTMLElement, initialOffset: Point, baseGsap: Point): void {
452
450
  el.setAttribute("data-hf-drag-gsap-base-x", String(baseGsap.x));
453
451
  el.setAttribute("data-hf-drag-gsap-base-y", String(baseGsap.y));
454
452
  el.setAttribute("data-hf-drag-initial-offset-x", String(initialOffset.x));
@@ -510,16 +508,6 @@ function restoreManualOffsetDragMember(member: ManualOffsetDragMember): void {
510
508
  endStudioManualEditGesture(member.element, member.gestureToken);
511
509
  }
512
510
 
513
- /** Undo's live revert of a move: its members at gesture start. Null off the plain route, which GSAP draws. */
514
- export function manualOffsetMoveRevert(members: ManualOffsetDragMember[]): StudioEditRevert | null {
515
- if (!members.every((member) => member.plainTranslate)) return null;
516
- return () => {
517
- const shown = members.map((member) => captureStudioPathOffset(member.element));
518
- for (const member of members) restoreStudioPathOffset(member.element, member.initialPathOffset);
519
- return () => members.forEach((member, i) => restoreStudioPathOffset(member.element, shown[i]!));
520
- };
521
- }
522
-
523
511
  /** Roll back a FAILED drag to the exact gesture-start state. */
524
512
  export function restoreManualOffsetDragMembers(members: ManualOffsetDragMember[]): void {
525
513
  for (const member of members) {
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { buildMotionPathGeometry, nearestPointOnPath } from "./motionPathGeometry";
2
+ import { buildMotionPathGeometry, nearestPointOnPath, nodeCentre } from "./motionPathGeometry";
3
3
  import type { ReadTween } from "../../hooks/gsapRuntimeKeyframes";
4
4
 
5
5
  const kf = (percentage: number, x: number, y: number) => ({ percentage, properties: { x, y } });
@@ -135,3 +135,50 @@ describe("nearestPointOnPath", () => {
135
135
  expect(nearestPointOnPath(0, 0, [{ x: 0, y: 0 }])).toBeNull();
136
136
  });
137
137
  });
138
+
139
+ describe("nodeCentre", () => {
140
+ // The edit bench's `keys` layer at 1 s: left 560, 260 wide now, GSAP x 50; it reaches x 60 and
141
+ // width 280 at 2 s, x 120 and width 320 at 3 s, so its centre is then at 760 and 840.
142
+ const home = { x: 690, y: 380, w: 260, h: 160, ax: 0.5, ay: 0.5 };
143
+ const read: ReadTween = {
144
+ keyframes: [
145
+ { percentage: 66.667, properties: { x: 60, width: 280 } },
146
+ { percentage: 100, properties: { x: 120, width: 320 } },
147
+ ],
148
+ };
149
+
150
+ it("draws a keyframe that also sets the size where the layer's centre is at that keyframe", () => {
151
+ const geo = buildMotionPathGeometry(read, { x: 50, y: 30 })!;
152
+ expect(geo.nodes.map((n) => nodeCentre(n, home, 1))).toEqual([
153
+ { x: 760, y: 410 },
154
+ { x: 840, y: 410 },
155
+ ]);
156
+ });
157
+
158
+ it("keeps a layer centred by xPercent/yPercent -50 on its centre as a keyframe resizes it", () => {
159
+ const geo = buildMotionPathGeometry(read, { x: 50, y: 30 })!;
160
+ const centred = { ...home, ax: 0, ay: 0 };
161
+ expect(geo.nodes.map((n) => nodeCentre(n, centred, 1).x)).toEqual([750, 810]);
162
+ });
163
+
164
+ it("keeps a keyframe that sets no size at home plus its offset", () => {
165
+ const geo = buildMotionPathGeometry({ keyframes: [kf(0, 10, 20), kf(100, 200, 80)] })!;
166
+ expect(nodeCentre(geo.nodes[1]!, home, 1)).toEqual({ x: 890, y: 460 });
167
+ });
168
+ });
169
+
170
+ describe("start", () => {
171
+ it("keeps where GSAP started a tween whose first keyframe comes later, with the size it had", () => {
172
+ const read: ReadTween = {
173
+ keyframes: [kf(66.667, 60, 30), kf(100, 120, 30)],
174
+ start: { x: 40, y: 30, width: 260 },
175
+ };
176
+ expect(buildMotionPathGeometry(read)?.start).toEqual({ x: 40, y: 30, w: 260 });
177
+ });
178
+
179
+ it("draws no start when GSAP has not read one", () => {
180
+ expect(buildMotionPathGeometry({ keyframes: [kf(50, 60, 30), kf(100, 120, 30)] })?.start).toBe(
181
+ undefined,
182
+ );
183
+ });
184
+ });
@@ -11,18 +11,40 @@ export type MotionNodeRef =
11
11
  | { type: "keyframe"; pct: number } // x/y position keyframe at this tween-relative %
12
12
  | { type: "waypoint"; index: number }; // motionPath waypoint (anchor) at this index
13
13
 
14
- export interface MotionPathNode {
14
+ /** An offset on the path and the layout width/height set there, when one is. */
15
+ export interface MotionPathPoint {
15
16
  x: number;
16
17
  y: number;
18
+ w?: number;
19
+ h?: number;
20
+ }
21
+
22
+ export interface MotionPathNode extends MotionPathPoint {
17
23
  ref: MotionNodeRef;
18
24
  }
19
25
 
26
+ /** The live layer's centre without its x/y offset, its layout size now, and the share of a size
27
+ * change that moves the centre (0.5 from the left edge; 0 when xPercent -50 centres it). */
28
+ export type MotionPathHome = { x: number; y: number; w: number; h: number; ax: number; ay: number };
29
+
30
+ /** Where the layer's centre is at a node, with the size that keyframe sets. */
31
+ export function nodeCentre(n: MotionPathPoint, home: MotionPathHome, pScale: number) {
32
+ const grow = (to: number | undefined, now: number, share: number) =>
33
+ to === undefined ? 0 : (to - now) * share;
34
+ return {
35
+ x: home.x + (n.x + grow(n.w, home.w, home.ax)) * pScale,
36
+ y: home.y + (n.y + grow(n.h, home.h, home.ay)) * pScale,
37
+ };
38
+ }
39
+
20
40
  export interface MotionPathGeometry {
21
41
  /** "linear" = x/y keyframes; "arc" = motionPath tween. */
22
42
  kind: "linear" | "arc";
23
43
  /** SVG polyline points: "x,y x,y ...". */
24
44
  points: string;
25
45
  nodes: MotionPathNode[];
46
+ /** Where GSAP started the tween, when its first keyframe comes later: drawn, not draggable. */
47
+ start?: MotionPathPoint;
26
48
  }
27
49
 
28
50
  /**
@@ -78,6 +100,16 @@ export function nearestPointOnPath(
78
100
  return best;
79
101
  }
80
102
 
103
+ const finiteNumber = (v: unknown): v is number => typeof v === "number" && isFinite(v);
104
+
105
+ /** The layout width/height a keyframe sets, when it sets one. */
106
+ function sizeAt(p: Record<string, unknown>): { w?: number; h?: number } {
107
+ return {
108
+ ...(finiteNumber(p.width) && { w: p.width }),
109
+ ...(finiteNumber(p.height) && { h: p.height }),
110
+ };
111
+ }
112
+
81
113
  export function buildMotionPathGeometry(
82
114
  read: ReadTween | null,
83
115
  base: { x: number; y: number } = { x: 0, y: 0 },
@@ -94,26 +126,32 @@ export function buildMotionPathGeometry(
94
126
  // translate it folded in, else 0), so we default it and still draw a path. But if the tween
95
127
  // DOES animate an axis and a given keyframe omits it, that value is interpolated
96
128
  // (not 0) and can't be placed here → skip that node (the prior behavior).
97
- const finite = (v: unknown): v is number => typeof v === "number" && isFinite(v);
98
- const tweenHasX = read.keyframes.some((kf) => finite(kf.properties.x));
99
- const tweenHasY = read.keyframes.some((kf) => finite(kf.properties.y));
129
+ const tweenHasX = read.keyframes.some((kf) => finiteNumber(kf.properties.x));
130
+ const tweenHasY = read.keyframes.some((kf) => finiteNumber(kf.properties.y));
100
131
  if (!tweenHasX && !tweenHasY) return null; // no positional motion (opacity/scale only)
101
132
 
133
+ const pointAt = (p: Record<string, unknown>): MotionPathPoint | null => {
134
+ if ((tweenHasX && !finiteNumber(p.x)) || (tweenHasY && !finiteNumber(p.y))) return null;
135
+ const x = tweenHasX ? (p.x as number) : base.x;
136
+ const y = tweenHasY ? (p.y as number) : base.y;
137
+ return { x, y, ...sizeAt(p) };
138
+ };
102
139
  read.keyframes.forEach((kf, i) => {
103
- if (tweenHasX && !finite(kf.properties.x)) return;
104
- if (tweenHasY && !finite(kf.properties.y)) return;
140
+ const at = pointAt(kf.properties);
141
+ if (!at) return;
105
142
  nodes.push({
106
- x: tweenHasX ? (kf.properties.x as number) : base.x,
107
- y: tweenHasY ? (kf.properties.y as number) : base.y,
143
+ ...at,
108
144
  ref: isArc ? { type: "waypoint", index: i } : { type: "keyframe", pct: kf.percentage },
109
145
  });
110
146
  });
111
147
 
112
148
  if (nodes.length < 2) return null;
149
+ const start = !isArc && read.start ? pointAt(read.start) : null;
113
150
 
114
151
  return {
115
152
  kind: isArc ? "arc" : "linear",
116
153
  points: nodes.map((n) => `${n.x},${n.y}`).join(" "),
117
154
  nodes,
155
+ ...(start && { start }),
118
156
  };
119
157
  }
@@ -0,0 +1,75 @@
1
+ import type { MotionPathHome } from "./motionPathGeometry";
2
+
3
+ // An element's computed transform translate: a group wrapper GSAP moved carries its offset here.
4
+ function transformTranslate(el: HTMLElement): { x: number; y: number } {
5
+ const t = el.ownerDocument?.defaultView?.getComputedStyle(el).transform;
6
+ if (!t || t === "none") return { x: 0, y: 0 };
7
+ const m3 = t.match(/matrix3d\(([^)]+)\)/);
8
+ if (m3) {
9
+ const v = m3[1].split(",").map(Number);
10
+ return { x: v[12] || 0, y: v[13] || 0 };
11
+ }
12
+ const m = t.match(/matrix\(([^)]+)\)/);
13
+ if (m) {
14
+ const v = m[1].split(",").map(Number);
15
+ return { x: v[4] || 0, y: v[5] || 0 };
16
+ }
17
+ return { x: 0, y: 0 };
18
+ }
19
+
20
+ function cssTranslate(el: HTMLElement): [number, number] {
21
+ const raw = el.ownerDocument?.defaultView?.getComputedStyle(el).translate ?? "none";
22
+ const [x = "0", y = "0"] = raw === "none" ? [] : raw.split(" ");
23
+ const px = (v: string, size: number) =>
24
+ v.endsWith("%") ? (Number.parseFloat(v) / 100) * size : Number.parseFloat(v) || 0;
25
+ return [px(x, el.offsetWidth), px(y, el.offsetHeight)];
26
+ }
27
+
28
+ /** Centre shift per px grown: 0.5 from a set left/top, -0.5 from a set right/bottom, plus the percent.
29
+ * ponytail: in-flow layers get 0 (layout may centre them); read their anchor if one draws off. */
30
+ function growShares(el: HTMLElement, px: number, py: number): { ax: number; ay: number } {
31
+ const style = el.ownerDocument?.defaultView?.getComputedStyle(el);
32
+ if (!style || !/^(absolute|fixed)$/.test(style.position)) return { ax: 0, ay: 0 };
33
+ const map = el.computedStyleMap?.();
34
+ const auto = (side: string) => String(map?.get(side) ?? "") === "auto";
35
+ const share = (start: string, end: string) => (auto(start) && !auto(end) ? -0.5 : 0.5);
36
+ return { ax: share("left", "right") + px, ay: share("top", "bottom") + py };
37
+ }
38
+
39
+ export function elementHome(el: HTMLElement): MotionPathHome {
40
+ let left = 0;
41
+ let top = 0;
42
+ let node: HTMLElement | null = el;
43
+ while (node) {
44
+ left += node.offsetLeft;
45
+ top += node.offsetTop;
46
+ // Ancestor transforms (a group wrapper GSAP moved) shift where the layer renders; its own
47
+ // transform is the animated offset the path itself draws, so it is left out.
48
+ if (node !== el) {
49
+ const t = transformTranslate(node);
50
+ left += t.x;
51
+ top += t.y;
52
+ }
53
+ const parent = node.offsetParent as HTMLElement | null;
54
+ if (!parent || parent.hasAttribute("data-composition-id")) break;
55
+ node = parent;
56
+ }
57
+ // A translate composes once GSAP has parsed the layer; before, its parse sums translate and transform,
58
+ // makes minus half the size xPercent -50 and folds the rest into x, which a path sets (_parseTransform).
59
+ const cache = (
60
+ el as { _gsap?: { x?: unknown; uncache?: unknown; xPercent?: unknown; yPercent?: unknown } }
61
+ )._gsap;
62
+ const parsed = cache !== undefined && "x" in cache && !cache.uncache;
63
+ const [tx, ty] = cssTranslate(el);
64
+ const share = (cached: unknown, t: number, size: number) =>
65
+ parsed ? (Number(cached) || 0) / 100 : t && Math.round(size / 2) === Math.round(-t) ? -0.5 : 0;
66
+ const own = parsed ? { x: 0, y: 0 } : transformTranslate(el);
67
+ const [px, py] = [
68
+ share(cache?.xPercent, tx + own.x, el.offsetWidth),
69
+ share(cache?.yPercent, ty + own.y, el.offsetHeight),
70
+ ];
71
+ const x = left + el.offsetWidth * (0.5 + px) + (parsed ? tx : 0);
72
+ const y = top + el.offsetHeight * (0.5 + py) + (parsed ? ty : 0);
73
+ const { ax, ay } = growShares(el, px, py);
74
+ return { x, y, w: el.offsetWidth, h: el.offsetHeight, ax, ay };
75
+ }