@hyperframes/studio 0.8.125 → 0.8.127

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 (61) hide show
  1. package/dist/assets/{hyperframes-player-CmIh-wq9.js → hyperframes-player-uqzDaxVL.js} +1 -1
  2. package/dist/assets/{index-4LRR6o2F.js → index-CLBNYlDY.js} +1 -1
  3. package/dist/assets/{index-CrYoEZ5p.js → index-COhbL1xq.js} +177 -177
  4. package/dist/assets/{index-yCK8osBg.js → index-DXSYlqmV.js} +1 -1
  5. package/dist/index.html +1 -1
  6. package/dist/index.js +5368 -4986
  7. package/dist/index.js.map +1 -1
  8. package/package.json +9 -8
  9. package/src/components/editor/DomEditSelectionChrome.test.tsx +5 -0
  10. package/src/components/editor/DomEditSelectionChrome.tsx +1 -0
  11. package/src/components/editor/MotionPathNode.tsx +56 -0
  12. package/src/components/editor/MotionPathOverlay.layerPress.test.tsx +167 -33
  13. package/src/components/editor/MotionPathOverlay.test.tsx +45 -0
  14. package/src/components/editor/MotionPathOverlay.tsx +49 -86
  15. package/src/components/editor/canvasContextMenuZOrder.ts +1 -10
  16. package/src/components/editor/manualEditsSeekReapply.ts +3 -6
  17. package/src/components/editor/manualOffsetDrag.ts +2 -3
  18. package/src/components/editor/motionPathCommit.nodeDrop.test.ts +212 -0
  19. package/src/components/editor/motionPathCommit.ts +58 -0
  20. package/src/components/editor/motionPathGeometry.test.ts +61 -1
  21. package/src/components/editor/motionPathGeometry.ts +50 -10
  22. package/src/components/editor/motionPathHome.ts +75 -0
  23. package/src/components/editor/motionPathLayerNode.test.ts +43 -35
  24. package/src/components/editor/motionPathLayerNode.ts +20 -16
  25. package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +20 -0
  26. package/src/components/editor/studioMotion.ts +7 -19
  27. package/src/components/editor/useMotionPathData.test.tsx +236 -0
  28. package/src/components/editor/useMotionPathData.ts +23 -61
  29. package/src/components/sidebar/CompositionsTab.thumbnails.test.tsx +1 -0
  30. package/src/hooks/gsapDragCommit.ts +33 -1
  31. package/src/hooks/gsapEditOutcome.ts +27 -2
  32. package/src/hooks/gsapParsedTween.test-helpers.ts +28 -1
  33. package/src/hooks/gsapParsedTween.ts +39 -2
  34. package/src/hooks/gsapRuntimeBridge.ts +7 -1
  35. package/src/hooks/gsapRuntimeKeyframes.realGsap.test.ts +48 -0
  36. package/src/hooks/gsapRuntimeKeyframes.ts +11 -4
  37. package/src/hooks/gsapShared.test.ts +1 -0
  38. package/src/hooks/gsapShared.ts +8 -3
  39. package/src/hooks/gsapShared.writeTarget.test.ts +10 -0
  40. package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +107 -5
  41. package/src/hooks/gsapValueAtPlayhead.test.ts +129 -27
  42. package/src/hooks/gsapValueAtPlayhead.ts +46 -6
  43. package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +72 -1
  44. package/src/hooks/gsapWholePropertyOffsetCommit.ts +6 -1
  45. package/src/hooks/intersectionObserverTestUtils.ts +16 -0
  46. package/src/hooks/resizeObserverTestUtils.ts +9 -2
  47. package/src/hooks/timelineEditingHelpers.ts +2 -9
  48. package/src/hooks/useCommitPreflightCapabilities.ts +2 -2
  49. package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +25 -0
  50. package/src/hooks/useThumbnailStripSize.test.tsx +236 -1
  51. package/src/hooks/useThumbnailStripSize.ts +206 -17
  52. package/src/noWindowBoundDomChecks.test.ts +31 -0
  53. package/src/player/components/CompositionThumbnail.tsx +10 -6
  54. package/src/player/components/ImageThumbnail.tsx +11 -5
  55. package/src/player/components/ThumbnailTiles.test.tsx +30 -0
  56. package/src/player/components/ThumbnailTiles.tsx +41 -0
  57. package/src/player/components/VideoThumbnail.test.tsx +116 -9
  58. package/src/player/components/VideoThumbnail.tsx +11 -5
  59. package/src/player/lib/timelineGroupInfo.realm.test.ts +20 -0
  60. package/src/player/lib/timelineGroupInfo.ts +2 -1
  61. package/src/webmcp/webmcpTestUtils.ts +2 -2
@@ -1,18 +1,18 @@
1
- import { observeGsapGesture } from "../../hooks/gsapGestureOutcome";
2
- import { trackPreviewEditResult } from "../../utils/previewFeatureUsage";
3
1
  import { scopedElementKey } from "../../hooks/gsapKeyframeCacheHelpers";
4
2
  import { memo, useEffect, useRef, useState, type RefObject } from "react";
5
3
  import type { DomEditSelection } from "./domEditing";
6
4
  import { useDomEditContext } from "../../contexts/DomEditContext";
5
+ import { useStudioShellContextOptional } from "../../contexts/StudioContext";
6
+ import { useGsapInteractionFailureTelemetry } from "../../hooks/useGsapInteractionFailureTelemetry";
7
7
  import { usePlayerStore } from "../../player/store/playerStore";
8
8
  import { useLivePreviewIframe } from "../../player/store/previewIframeStore";
9
9
  import { parkPlayheadOnKeyframe } from "../../hooks/gsapDragCommit";
10
- import { commitWholePropertyOffset } from "../../hooks/gsapWholePropertyOffsetCommit";
11
- import { nearestPointOnPath, type MotionNodeRef } from "./motionPathGeometry";
10
+ import { nearestPointOnPath, nodeCentre, type MotionNodeRef } from "./motionPathGeometry";
12
11
  import { editableAnimationId, selectorFor } from "./motionPathSelection";
13
- import { dotRadius, pressBelongsToLayer, pressSelectedLayer } from "./motionPathLayerNode";
12
+ import { controlForNode, controlUnder, pressControl } from "./motionPathLayerNode";
14
13
  import { readGsapPositionFromIframe } from "../../hooks/gsapPositionDetection";
15
- import { ACCENT, MotionPathNode } from "./MotionPathNode";
14
+ import { isHtmlElement } from "@hyperframes/core/runtime/dom-realm";
15
+ import { ACCENT, MotionPathLine, MotionPathNode } from "./MotionPathNode";
16
16
  import {
17
17
  KeyframeDiamondContextMenu,
18
18
  type KeyframeDiamondContextMenuState,
@@ -22,16 +22,12 @@ import {
22
22
  commitAddKeyframe,
23
23
  commitAddWaypoint,
24
24
  commitCreatePath,
25
- commitNode,
25
+ commitNodeDrop,
26
26
  commitRemoveWaypoint,
27
+ nodeDropLabel,
27
28
  } from "./motionPathCommit";
28
- import {
29
- elementHome,
30
- hasMotionPathPlugin,
31
- isPreviewHtmlElement,
32
- transformWDivisor,
33
- useMotionPathData,
34
- } from "./useMotionPathData";
29
+ import { elementHome } from "./motionPathHome";
30
+ import { hasMotionPathPlugin, transformWDivisor, useMotionPathData } from "./useMotionPathData";
35
31
 
36
32
  interface MotionPathOverlayProps {
37
33
  iframeRef: RefObject<HTMLIFrameElement | null>;
@@ -70,6 +66,7 @@ const NODE_PX = 6; // node radius in screen pixels (kept constant across zoom)
70
66
  // (select the keyframe); at or above it the gesture commits a move. Screen-space
71
67
  // (not composition px) so it behaves identically at any zoom.
72
68
  const DRAG_THRESHOLD_PX = 3;
69
+ const noToast = () => {};
73
70
 
74
71
  /**
75
72
  * Draws the selected element's GSAP motion path over the canvas — a dashed
@@ -95,12 +92,18 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
95
92
  handleGsapRemoveAllKeyframes,
96
93
  handleGsapMoveKeyframeToPlayhead,
97
94
  } = useDomEditContext();
95
+ const shell = useStudioShellContextOptional();
96
+ const reportFailure = useGsapInteractionFailureTelemetry(
97
+ shell?.activeCompPath ?? null,
98
+ shell?.showToast ?? noToast,
99
+ );
98
100
  const { rect, geometry, geometryResolved, visibleInPreview, home, pScale } = useMotionPathData(
99
101
  iframeRef,
100
102
  selectorFor(selection),
101
103
  );
102
104
  const [draft, setDraft] = useState<Draft | null>(null);
103
105
  const [ghost, setGhost] = useState<{ x: number; y: number; segIndex: number } | null>(null);
106
+ const [lineCursor, setLineCursor] = useState("copy");
104
107
  const [hoverNode, setHoverNode] = useState<number | null>(null);
105
108
  // Right-click context menu on a path node — same delete actions as the
106
109
  // timeline keyframe diamond. The node it was opened on rides along, because
@@ -194,7 +197,7 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
194
197
  // Resolve the element LIVE from the current iframe document — the selected
195
198
  // node may be detached after a soft-reload, which would skew home.
196
199
  const live = frame.contentDocument?.querySelector(createSelector);
197
- if (!isPreviewHtmlElement(live, frame)) return;
200
+ if (!isHtmlElement(live)) return;
198
201
  e.stopPropagation();
199
202
  e.preventDefault();
200
203
  const sc = r.width / compW;
@@ -284,12 +287,12 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
284
287
  // pivot (transform-origin), so it stays put; only the offsets foreshorten. 2D
285
288
  // elements have pScale = 1 (no change). Inverse (de-magnify) applied wherever a
286
289
  // pointer position is mapped back to a stored offset (create + node drag).
287
- const abs = nodes.map((n) => ({
288
- ...n,
289
- ax: home.x + n.x * pScale,
290
- ay: home.y + n.y * pScale,
291
- }));
290
+ const abs = nodes.map((n) => {
291
+ const c = nodeCentre(n, home, pScale);
292
+ return { ...n, ax: c.x, ay: c.y };
293
+ });
292
294
  const points = abs.map((p) => `${p.ax},${p.ay}`).join(" ");
295
+ const startAt = geometry.start && nodeCentre(geometry.start, home, pScale);
293
296
  // Map a VIEWPORT pointer to composition space. Use the iframe's LIVE viewport
294
297
  // rect, not `rect` — `rect.left/top` are stored pan-surface-relative (for the
295
298
  // absolute-positioned SVG), so subtracting them from a viewport clientX/Y would
@@ -313,9 +316,7 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
313
316
  if (e.button !== 0) return;
314
317
  e.stopPropagation();
315
318
  const live = readGsapPositionFromIframe(iframeRef.current, selectorFor(selection) ?? "");
316
- const dotR = dotRadius(nodeR, ref.type === "keyframe" && ref.pct === activeKeyframePct);
317
- if (pressBelongsToLayer(e, clientToComp(e), abs[index]!, dotR, live) && pressSelectedLayer(e))
318
- return;
319
+ if (pressControl(e, controlForNode(e, abs[index]!, live))) return;
319
320
  (e.target as Element).setPointerCapture(e.pointerId);
320
321
  dragRef.current = {
321
322
  index,
@@ -378,36 +379,19 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
378
379
  // high zoom) would commit an identical value — a no-op undo entry. Skip the
379
380
  // commit, but don't treat it as a click either (the user did drag).
380
381
  if (x === Math.round(d.initX) && y === Math.round(d.initY)) return;
381
- // With auto-keyframe off (#1808), dragging a keyframe's node on the motion
382
- // path (the common way to nudge a KEYFRAMED element's position on canvas,
383
- // since the element renders exactly at its current keyframe) shifts the
384
- // whole path instead of moving just that one keyframe.
385
382
  const anim =
386
383
  d.ref.type === "keyframe" ? selectedGsapAnimations?.find((a) => a.id === animId) : undefined;
387
- if (
388
- d.ref.type === "keyframe" &&
389
- anim &&
390
- selection &&
391
- !usePlayerStore.getState().autoKeyframeEnabled
392
- ) {
393
- const writes = observeGsapGesture((_sel, mutation, options) =>
394
- commitMutation(mutation, options),
395
- );
396
- void commitWholePropertyOffset(
397
- selection,
398
- anim,
399
- { x, y },
400
- d.ref.pct,
401
- iframeRef.current,
402
- { commitMutation: writes.commit! },
403
- "Move animation path",
404
- ).then(() => trackPreviewEditResult("motion_path", "drag", writes.finish()));
405
- } else {
406
- void commitNode(d.ref, x, y, animId, commitMutation);
407
- }
408
- // Park the playhead on the edited keyframe's time so the element previews AT
409
- // that keyframe. Without it, a playhead sitting before the tween renders the
410
- // element's base pose — the edit (correct on the path) looks like it vanished.
384
+ const label = nodeDropLabel(d.ref);
385
+ commitNodeDrop({
386
+ ref: d.ref,
387
+ at: { x, y },
388
+ animId,
389
+ anim,
390
+ selection,
391
+ iframe: iframeRef.current,
392
+ commitMutation,
393
+ }).catch((error: unknown) => reportFailure(error, selection, "drag", label));
394
+ // Park on the edited keyframe, or a playhead before the tween hides the edit.
411
395
  if (d.ref.type === "keyframe" && anim) {
412
396
  parkPlayheadOnKeyframe(anim, d.ref.pct);
413
397
  }
@@ -415,15 +399,21 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
415
399
 
416
400
  // Ghost "add" affordance: project the cursor onto the path; click inserts.
417
401
  const onPathHover = (e: React.PointerEvent) => {
402
+ const control = controlUnder(e);
403
+ setLineCursor(control ? getComputedStyle(control).cursor : "copy");
418
404
  const c = clientToComp(e);
419
405
  const np = nearestPointOnPath(
420
406
  c.x,
421
407
  c.y,
422
408
  abs.map((p) => ({ x: p.ax, y: p.ay })),
423
409
  );
424
- setGhost(np ? { x: np.x, y: np.y, segIndex: np.segIndex } : null);
410
+ setGhost(np && !control ? { x: np.x, y: np.y, segIndex: np.segIndex } : null);
425
411
  };
426
412
  const onPathDown = (e: React.PointerEvent) => {
413
+ if (pressControl(e, controlUnder(e))) {
414
+ e.stopPropagation();
415
+ return;
416
+ }
427
417
  if (!animId) return;
428
418
  // Compute the insertion point from the event directly so a click works
429
419
  // without (or faster than) a preceding hover.
@@ -434,8 +424,9 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
434
424
  abs.map((p) => ({ x: p.ax, y: p.ay })),
435
425
  );
436
426
  if (!np) return;
437
- const x = Math.round(np.x - home.x);
438
- const y = Math.round(np.y - home.y);
427
+ const [a, b] = [abs[np.segIndex]!, abs[np.segIndex + 1]!];
428
+ const x = Math.round(a.x + np.t * (b.x - a.x));
429
+ const y = Math.round(a.y + np.t * (b.y - a.y));
439
430
  if (geometry.kind === "arc") {
440
431
  e.stopPropagation();
441
432
  void commitAddWaypoint(animId, np.segIndex + 1, x, y, commitMutation);
@@ -458,10 +449,8 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
458
449
  };
459
450
 
460
451
  const elementId = selection?.id ?? null;
461
- // Right-click any path node → the timeline's keyframe context menu (delete this
462
- // one / delete all), so path nodes are removable in place. Waypoints open it
463
- // too: returning early for them let the browser's own context menu open over
464
- // the editor overlay, which is never what a right-click on a node should do.
452
+ // Right-click any path node → the timeline's keyframe menu. Waypoints too, or the
453
+ // browser's own context menu opens over the editor.
465
454
  const onNodeContextMenu = (e: React.MouseEvent, ref: MotionNodeRef) => {
466
455
  if (!animId || !elementId || !timelineElement) return;
467
456
  e.preventDefault();
@@ -525,39 +514,13 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
525
514
  stroke="transparent"
526
515
  strokeWidth={14 / scale}
527
516
  className="pointer-events-auto"
528
- style={{ cursor: "copy" }}
517
+ style={{ cursor: lineCursor }}
529
518
  onPointerMove={onPathHover}
530
519
  onPointerLeave={() => setGhost(null)}
531
520
  onPointerDown={onPathDown}
532
521
  />
533
522
  )}
534
- <polyline
535
- points={points}
536
- fill="none"
537
- style={{ stroke: ACCENT }}
538
- strokeWidth={1.5}
539
- strokeDasharray="5 5"
540
- strokeLinejoin="round"
541
- strokeLinecap="round"
542
- vectorEffect="non-scaling-stroke"
543
- opacity={0.85}
544
- />
545
- {ghost && (
546
- <rect
547
- x={ghost.x - nodeR * 0.707}
548
- y={ghost.y - nodeR * 0.707}
549
- width={nodeR * 1.414}
550
- height={nodeR * 1.414}
551
- rx={nodeR * 0.24}
552
- transform={`rotate(45 ${ghost.x} ${ghost.y})`}
553
- fill="none"
554
- strokeWidth={1.5}
555
- strokeDasharray="2 2"
556
- vectorEffect="non-scaling-stroke"
557
- className="pointer-events-none"
558
- style={{ stroke: ACCENT }}
559
- />
560
- )}
523
+ <MotionPathLine points={points} start={startAt} ghost={ghost} r={nodeR} />
561
524
  {abs.map((p, i) => (
562
525
  <MotionPathNode
563
526
  key={i}
@@ -124,17 +124,8 @@ export function readEffectiveZIndex(el: HTMLElement): number {
124
124
  return 0;
125
125
  }
126
126
 
127
- /**
128
- * Realm-safe HTMLElement check. The target lives in the preview IFRAME's
129
- * document, but this module runs in the top window, so `child instanceof
130
- * HTMLElement` (top-window constructor) is ALWAYS false for iframe elements —
131
- * which silently emptied the sibling list and left every z-order action
132
- * permanently disabled. Compare against the element's own realm instead, with
133
- * a nodeType fallback for detached / cross-realm edge cases.
134
- */
127
+ /** Any element, asked structurally: the preview's nodes are not this window's. */
135
128
  function isElementNode(node: Node): node is HTMLElement {
136
- const view = node.ownerDocument?.defaultView;
137
- if (view && node instanceof view.HTMLElement) return true;
138
129
  return node.nodeType === 1;
139
130
  }
140
131
 
@@ -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,
@@ -131,9 +132,7 @@ function getFrameElement(win: Window): HTMLElement | null {
131
132
  try {
132
133
  const frameElement = win.frameElement;
133
134
  if (!frameElement) return null;
134
- const ownerWin = frameElement.ownerDocument.defaultView;
135
- const htmlElement = ownerWin?.HTMLElement;
136
- return htmlElement && frameElement instanceof htmlElement ? frameElement : null;
135
+ return isHtmlElement(frameElement) ? frameElement : null;
137
136
  } catch {
138
137
  return null;
139
138
  }
@@ -0,0 +1,212 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, expect, it, onTestFinished, vi } from "vitest";
3
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
4
+ import type { DomEditSelection } from "./domEditing";
5
+ import { boxSteps, previewWith, tween } from "../../hooks/gsapParsedTween.test-helpers";
6
+ import { usePlayerStore } from "../../player/store/playerStore";
7
+ import { GsapEditBlockedError } from "../../hooks/gsapEditOutcome";
8
+ import { commitNodeDrop, nodeDropLabel, type CommitFn } from "./motionPathCommit";
9
+
10
+ it("names a node drop for its undo entry by what it moved", () => {
11
+ const { autoKeyframeEnabled } = usePlayerStore.getState();
12
+ onTestFinished(() => usePlayerStore.setState({ autoKeyframeEnabled }));
13
+ usePlayerStore.setState({ autoKeyframeEnabled: true });
14
+ expect(nodeDropLabel({ type: "keyframe", pct: 50 })).toBe("Move keyframe");
15
+ usePlayerStore.setState({ autoKeyframeEnabled: false });
16
+ expect(nodeDropLabel({ type: "keyframe", pct: 50 })).toBe("Move animation path");
17
+ expect(nodeDropLabel({ type: "waypoint", index: 0 })).toBe("Move waypoint");
18
+ });
19
+
20
+ afterEach(() => {
21
+ usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null, requestedSeekTime: null });
22
+ document.body.innerHTML = "";
23
+ });
24
+
25
+ function box(id = "box") {
26
+ const element = document.body.appendChild(document.createElement("div"));
27
+ element.id = id;
28
+ return { element, selection: { id, selector: `#${id}`, element } as DomEditSelection };
29
+ }
30
+
31
+ type Drop = Parameters<typeof commitNodeDrop>[0];
32
+
33
+ function drop(fields: Omit<Drop, "animId" | "commitMutation"> & { anim: GsapAnimation }) {
34
+ const commitMutation = vi.fn<CommitFn>(async () => {});
35
+ const done = commitNodeDrop({ ...fields, animId: fields.anim.id, commitMutation });
36
+ const written = () =>
37
+ (commitMutation.mock.calls[0]![0] as { keyframes: { properties: object }[] }).keyframes.map(
38
+ (kf) => kf.properties,
39
+ );
40
+ return { done, commitMutation, written };
41
+ }
42
+
43
+ it("a keyframe node's drop changes that keyframe and keeps a newly animated channel on the others", async () => {
44
+ const { element, selection } = box();
45
+ // Over a CSS translate GSAP reads as y 30.
46
+ const { keys, live } = boxSteps([
47
+ [2, { x: 60 }],
48
+ [1, { x: 120 }],
49
+ ]);
50
+ usePlayerStore.setState({ currentTime: 2, autoKeyframeEnabled: true });
51
+
52
+ const { done, commitMutation, written } = drop({
53
+ ref: { type: "keyframe", pct: 100 },
54
+ at: { x: 150, y: 90 },
55
+ anim: keys,
56
+ selection,
57
+ iframe: previewWith(element, [live(element)], { x: 60, y: 30 }),
58
+ });
59
+ await done;
60
+
61
+ expect(commitMutation.mock.calls[0]![0].type).toBe("replace-with-keyframes");
62
+ expect(written()).toEqual([
63
+ { x: 60, y: 30 },
64
+ { x: 150, y: 90 },
65
+ ]);
66
+ });
67
+
68
+ it("a drop on an array step's node edits that step, though the path spaces nodes evenly", async () => {
69
+ const { element, selection } = box();
70
+ // Step ends at 2 s and 3 s; the path draws the first node at 0%, the parser and lane at 66.7%.
71
+ const { keys, live } = boxSteps([
72
+ [2, { x: 60, y: 30 }],
73
+ [1, { x: 120, y: 30 }],
74
+ ]);
75
+ usePlayerStore.setState({ currentTime: 1, autoKeyframeEnabled: true, activeKeyframePct: null });
76
+
77
+ const { done, written } = drop({
78
+ ref: { type: "keyframe", pct: 0, step: 0 },
79
+ at: { x: 40, y: 90 },
80
+ anim: keys,
81
+ selection,
82
+ iframe: previewWith(element, [live(element)], { x: 60, y: 30 }),
83
+ });
84
+ await done;
85
+
86
+ expect(written()).toEqual([
87
+ { x: 40, y: 90 },
88
+ { x: 120, y: 30 },
89
+ ]);
90
+ expect(usePlayerStore.getState().requestedSeekTime).toBe(2);
91
+ });
92
+
93
+ it("a drop the writer refuses rejects with the reason, writes nothing and selects nothing", async () => {
94
+ const [a, b] = [box("a"), box("b")];
95
+ for (const { element } of [a, b]) element.className = "card";
96
+ const shared = tween({
97
+ targetSelector: ".card",
98
+ method: "to",
99
+ properties: {},
100
+ resolvedStart: 0,
101
+ duration: 1,
102
+ keyframes: { format: "percentage", keyframes: [{ percentage: 100, properties: { x: 20 } }] },
103
+ });
104
+ usePlayerStore.setState({ autoKeyframeEnabled: true, activeKeyframePct: null });
105
+
106
+ const { done, commitMutation } = drop({
107
+ ref: { type: "keyframe", pct: 100 },
108
+ at: { x: 5, y: 0 },
109
+ anim: shared,
110
+ selection: a.selection,
111
+ iframe: previewWith(b.element, [], { x: 0, y: 0 }),
112
+ });
113
+
114
+ await expect(done).rejects.toBeInstanceOf(GsapEditBlockedError);
115
+ expect(commitMutation).not.toHaveBeenCalled();
116
+ expect(usePlayerStore.getState().activeKeyframePct).toBeNull();
117
+ });
118
+
119
+ it.each([true, false])(
120
+ "a drop on a step list with a step delay says the delay blocks it and writes nothing (auto-keyframe %s)",
121
+ async (autoKeyframeEnabled) => {
122
+ const { element, selection } = box();
123
+ const { keys } = boxSteps([
124
+ [1.5, { x: 100, delay: 0.5 }],
125
+ [1.5, { x: 200, delay: 0.5 }],
126
+ ]);
127
+ usePlayerStore.setState({ autoKeyframeEnabled, activeKeyframePct: null });
128
+
129
+ const { done, commitMutation } = drop({
130
+ ref: { type: "keyframe", pct: 100 },
131
+ at: { x: 150, y: 0 },
132
+ anim: keys,
133
+ selection,
134
+ iframe: previewWith(element, [], { x: 0, y: 0 }),
135
+ });
136
+
137
+ await expect(done).rejects.toThrow(/its own delay.*Code tab/);
138
+ expect(commitMutation).not.toHaveBeenCalled();
139
+ },
140
+ );
141
+
142
+ it("with auto-keyframe off, a step's node shifts the path by its own keyframe's move", async () => {
143
+ const { selection } = box();
144
+ // Steps of 4 s, 1 s and 1 s: drawn at 0/50/100%, parsed at their step ends.
145
+ const { keys } = boxSteps([
146
+ [4, { x: 60, y: 0 }],
147
+ [1, { x: 120, y: 0 }],
148
+ [1, { x: 180, y: 0 }],
149
+ ]);
150
+ usePlayerStore.setState({ autoKeyframeEnabled: false, activeKeyframePct: null });
151
+
152
+ const { done, written } = drop({
153
+ ref: { type: "keyframe", pct: 50, step: 1 },
154
+ at: { x: 157, y: 0 },
155
+ anim: keys,
156
+ selection,
157
+ iframe: null,
158
+ });
159
+ await done;
160
+
161
+ expect(written().map((props) => (props as { x: number }).x)).toEqual([97, 157, 217]);
162
+ });
163
+
164
+ it("a drop on an inner keyframe of a tween eased as a whole changes that keyframe", async () => {
165
+ const { element, selection } = box();
166
+ const keys = tween({
167
+ id: "#box-to-0-position",
168
+ method: "to",
169
+ properties: {},
170
+ resolvedStart: 0,
171
+ duration: 2,
172
+ ease: "power1.inOut",
173
+ keyframes: {
174
+ format: "percentage",
175
+ keyframes: [0, 100, 200].map((x, i) => ({ percentage: i * 50, properties: { x, y: 0 } })),
176
+ },
177
+ });
178
+ usePlayerStore.setState({ autoKeyframeEnabled: true, activeKeyframePct: null });
179
+
180
+ const { done, written } = drop({
181
+ ref: { type: "keyframe", pct: 50 },
182
+ at: { x: 150, y: 0 },
183
+ anim: keys,
184
+ selection,
185
+ iframe: previewWith(element, [], { x: 0, y: 0 }),
186
+ });
187
+ await done;
188
+
189
+ expect(written().map((props) => (props as { x: number }).x)).toEqual([0, 150, 200]);
190
+ });
191
+
192
+ it("a drop on a short step under 1% from the one before edits the dragged step", async () => {
193
+ const { element, selection } = box();
194
+ // Parsed at 50, 50.5 and 100%: a 0.05 s pop step in a 10 s list.
195
+ const { keys, live } = boxSteps([
196
+ [5, { x: 0, y: 0 }],
197
+ [0.05, { x: 100, y: 0 }],
198
+ [4.95, { x: 100, y: 0 }],
199
+ ]);
200
+ usePlayerStore.setState({ autoKeyframeEnabled: true, activeKeyframePct: null });
201
+
202
+ const { done, written } = drop({
203
+ ref: { type: "keyframe", pct: 50, step: 1 },
204
+ at: { x: 150, y: 0 },
205
+ anim: keys,
206
+ selection,
207
+ iframe: previewWith(element, [live(element)], { x: 0, y: 0 }),
208
+ });
209
+ await done;
210
+
211
+ expect(written().map((props) => (props as { x: number }).x)).toEqual([0, 150, 100]);
212
+ });
@@ -5,7 +5,17 @@ import { trackPreviewFeatureUsed, type PreviewMethod } from "../../utils/preview
5
5
  * GSAP source mutation routed through the (selection-bound) commit facade, which
6
6
  * handles the soft reload, undo snapshot, and save-failure feedback.
7
7
  */
8
+ import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser";
9
+ import { assertGsapEditPersisted } from "../../hooks/gsapEditOutcome";
10
+ import { observeGsapGesture } from "../../hooks/gsapGestureOutcome";
11
+ import { readGsapPositionFromIframe } from "../../hooks/gsapPositionDetection";
12
+ import { commitValueAtPlayhead } from "../../hooks/gsapValueAtPlayhead";
13
+ import { commitWholePropertyOffset } from "../../hooks/gsapWholePropertyOffsetCommit";
14
+ import { usePlayerStore } from "../../player/store/playerStore";
15
+ import { trackPreviewEditResult } from "../../utils/previewFeatureUsage";
16
+ import type { DomEditSelection } from "./domEditing";
8
17
  import type { MotionNodeRef } from "./motionPathGeometry";
18
+ import { selectorFor } from "./motionPathSelection";
9
19
 
10
20
  export type CommitFn = (
11
21
  mutation: Record<string, unknown>,
@@ -41,6 +51,54 @@ export function commitNode(
41
51
  );
42
52
  }
43
53
 
54
+ type NodeDrop = {
55
+ ref: MotionNodeRef;
56
+ at: { x: number; y: number };
57
+ animId: string;
58
+ anim: GsapAnimation | undefined;
59
+ selection: DomEditSelection | null;
60
+ iframe: HTMLIFrameElement | null;
61
+ commitMutation: CommitFn;
62
+ };
63
+
64
+ /** What a node drop does, for its undo entry and its failure report. */
65
+ export function nodeDropLabel(ref: MotionNodeRef): string {
66
+ if (ref.type !== "keyframe") return "Move waypoint";
67
+ return usePlayerStore.getState().autoKeyframeEnabled ? "Move keyframe" : "Move animation path";
68
+ }
69
+
70
+ /** A dropped keyframe goes through the layer drag's writer, GSAP's live values backfilling others
71
+ * (auto-keyframe off, #1808: the whole path shifts); a waypoint moves in place. */
72
+ export function commitNodeDrop(drop: NodeDrop): Promise<void> {
73
+ const { ref, at, anim, selection, iframe, commitMutation } = drop;
74
+ if (ref.type !== "keyframe" || !anim || !selection)
75
+ return commitNode(ref, at.x, at.y, drop.animId, commitMutation);
76
+ const writes = observeGsapGesture((_sel, mutation, options) => commitMutation(mutation, options));
77
+ const callbacks = { commitMutation: writes.commit! };
78
+ let done: Promise<unknown>;
79
+ const store = usePlayerStore.getState();
80
+ const step = ref.step == null ? undefined : anim.keyframes?.keyframes[ref.step];
81
+ const pct = step?.percentage ?? ref.pct;
82
+ if (store.autoKeyframeEnabled) {
83
+ const selected = store.activeKeyframePct;
84
+ store.setActiveKeyframePct(pct);
85
+ const live = readGsapPositionFromIframe(iframe, selectorFor(selection) ?? "");
86
+ done = commitValueAtPlayhead(selection, anim, at, iframe, callbacks, {
87
+ label: nodeDropLabel(ref),
88
+ backfill: live ?? undefined,
89
+ })
90
+ .then(assertGsapEditPersisted)
91
+ .catch((error: unknown) => {
92
+ usePlayerStore.getState().setActiveKeyframePct(selected);
93
+ throw error;
94
+ });
95
+ } else {
96
+ const label = nodeDropLabel(ref);
97
+ done = commitWholePropertyOffset(selection, anim, at, pct, iframe, callbacks, label);
98
+ }
99
+ return done.then(() => trackPreviewEditResult("motion_path", "drag", writes.finish()));
100
+ }
101
+
44
102
  export function commitAddWaypoint(
45
103
  animationId: string,
46
104
  index: number,
@@ -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,63 @@ 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
+ });
185
+
186
+ it("an array step's node carries its step, so a drop finds that keyframe whatever its time", () => {
187
+ const read = {
188
+ keyframes: [
189
+ { percentage: 0, properties: { x: 60 }, step: 0 },
190
+ { percentage: 100, properties: { x: 120 }, step: 1 },
191
+ ],
192
+ };
193
+ expect(buildMotionPathGeometry(read)!.nodes.map((n) => n.ref)).toEqual([
194
+ { type: "keyframe", pct: 0, step: 0 },
195
+ { type: "keyframe", pct: 100, step: 1 },
196
+ ]);
197
+ });