@hyperframes/studio 0.8.120 → 0.8.122

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 (54) hide show
  1. package/dist/assets/{hyperframes-player-i4gqkhjz.js → hyperframes-player-DMzBHhbH.js} +1 -1
  2. package/dist/assets/{index-Ce3Lr-Gt.js → index-B-YDp8hN.js} +174 -174
  3. package/dist/assets/{index-uWOhRqxe.js → index-CBRX2eZX.js} +1 -1
  4. package/dist/assets/{index-C7LHDM18.js → index-lyjhSEzi.js} +1 -1
  5. package/dist/{chunk-J6Q572FA.js → chunk-246VB2G3.js} +3 -1
  6. package/dist/chunk-246VB2G3.js.map +1 -0
  7. package/dist/index.d.ts +10 -2
  8. package/dist/index.html +1 -1
  9. package/dist/index.js +416 -301
  10. package/dist/index.js.map +1 -1
  11. package/dist/player.js +1 -1
  12. package/package.json +8 -8
  13. package/src/components/TimelineToolbar.test.tsx +25 -0
  14. package/src/components/TimelineToolbar.tsx +5 -1
  15. package/src/components/editor/MotionPathNode.tsx +2 -1
  16. package/src/components/editor/MotionPathOverlay.layerPress.test.tsx +99 -0
  17. package/src/components/editor/MotionPathOverlay.tsx +6 -0
  18. package/src/components/editor/domEditOverlayStartGesture.blocked.test.ts +57 -0
  19. package/src/components/editor/domEditOverlayStartGesture.ts +1 -1
  20. package/src/components/editor/motionPathGeometry.test.ts +10 -0
  21. package/src/components/editor/motionPathGeometry.ts +8 -5
  22. package/src/components/editor/motionPathLayerNode.test.ts +82 -0
  23. package/src/components/editor/motionPathLayerNode.ts +28 -0
  24. package/src/components/editor/useDomEditNudge.test.tsx +53 -1
  25. package/src/components/editor/useDomEditNudge.ts +2 -2
  26. package/src/components/editor/useMotionPathData.test.tsx +30 -0
  27. package/src/components/editor/useMotionPathData.ts +3 -1
  28. package/src/hooks/gsapValueAtPlayhead.test.ts +46 -3
  29. package/src/hooks/gsapValueAtPlayhead.ts +12 -2
  30. package/src/hooks/linkShortcuts.test.ts +16 -0
  31. package/src/hooks/linkShortcuts.ts +2 -0
  32. package/src/hooks/useAppHotkeys.ts +1 -2
  33. package/src/index.ts +2 -0
  34. package/src/player/components/TimelineClipFades.tsx +1 -0
  35. package/src/player/components/timelineClipDragPreview.test.ts +68 -0
  36. package/src/player/components/timelineClipDragPreview.ts +39 -14
  37. package/src/player/components/timelineLayout.test.ts +24 -1
  38. package/src/player/components/timelineRulerGeometry.ts +24 -3
  39. package/src/player/components/timelineSnapping.test.ts +43 -12
  40. package/src/player/components/timelineSnapping.ts +33 -15
  41. package/src/player/components/useTimelineClipDrag.resize.test.tsx +54 -54
  42. package/src/player/components/useTimelineClipDrag.ts +17 -29
  43. package/src/player/components/useTimelineProviderState.tsx +0 -1
  44. package/src/player/components/useTimelineTicks.ts +6 -3
  45. package/src/player/hooks/timelinePlayerTestHarness.ts +2 -0
  46. package/src/player/hooks/useTimelinePlayer.seek.test.ts +66 -0
  47. package/src/player/hooks/useTimelinePlayer.ts +6 -2
  48. package/src/player/lib/time.ts +3 -0
  49. package/src/player/store/previewFrameStore.ts +48 -0
  50. package/src/player/store/timelineElement.ts +12 -0
  51. package/src/timelineChromeExports.test.tsx +6 -0
  52. package/src/utils/gsapSoftReload.test.ts +50 -0
  53. package/src/utils/gsapSoftReload.ts +22 -2
  54. package/dist/chunk-J6Q572FA.js.map +0 -1
package/dist/player.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  Player
3
- } from "./chunk-J6Q572FA.js";
3
+ } from "./chunk-246VB2G3.js";
4
4
  import "./chunk-5VSXRGIX.js";
5
5
  import "./chunk-5EKWJZLM.js";
6
6
  import "./chunk-PZ5AY32C.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.120",
3
+ "version": "0.8.122",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -60,12 +60,12 @@
60
60
  "dockview-react": "8.3.1",
61
61
  "gsap": "^3.13.0",
62
62
  "mediabunny": "^1.45.3",
63
- "@hyperframes/core": "0.8.120",
64
- "@hyperframes/parsers": "0.8.120",
65
- "@hyperframes/sdk": "0.8.120",
66
- "@hyperframes/shader-transitions": "0.8.120",
67
- "@hyperframes/studio-server": "0.8.120",
68
- "@hyperframes/player": "0.8.120"
63
+ "@hyperframes/parsers": "0.8.122",
64
+ "@hyperframes/sdk": "0.8.122",
65
+ "@hyperframes/core": "0.8.122",
66
+ "@hyperframes/shader-transitions": "0.8.122",
67
+ "@hyperframes/studio-server": "0.8.122",
68
+ "@hyperframes/player": "0.8.122"
69
69
  },
70
70
  "devDependencies": {
71
71
  "@tailwindcss/vite": "^4.3.3",
@@ -81,7 +81,7 @@
81
81
  "vite": "^6.4.2",
82
82
  "vitest": "^4.1.11",
83
83
  "zustand": "^5.0.0",
84
- "@hyperframes/producer": "0.8.120"
84
+ "@hyperframes/producer": "0.8.122"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "react": "19",
@@ -436,3 +436,28 @@ describe("TimelineToolbar — host right actions", () => {
436
436
  act(() => root.unmount());
437
437
  });
438
438
  });
439
+
440
+ describe("TimelineToolbar split hint", () => {
441
+ async function splitTooltip(props: Partial<React.ComponentProps<typeof TimelineToolbar>>) {
442
+ usePlayerStore.setState({
443
+ elements: [{ id: "a", domId: "a", tag: "div", start: 0, duration: 4, track: 0 }],
444
+ selectedElementId: "a",
445
+ currentTime: 2,
446
+ });
447
+ const { host, root } = renderToolbar(undefined, { onSplitElement: vi.fn(), ...props });
448
+ const button = host.querySelector<HTMLButtonElement>('button[aria-label="Split at playhead"]');
449
+ act(() => button?.focus());
450
+ await act(async () => {
451
+ await new Promise((resolve) => setTimeout(resolve, 0));
452
+ });
453
+ const label = document.querySelector('[role="tooltip"]')?.textContent;
454
+ act(() => root.unmount());
455
+ usePlayerStore.setState({ elements: [], selectedElementId: null, currentTime: 0 });
456
+ return label;
457
+ }
458
+
459
+ it("names S unless the host binds Split to another key", async () => {
460
+ expect(await splitTooltip({})).toBe("Split at playhead (S)");
461
+ expect(await splitTooltip({ splitShortcut: "⌘B" })).toBe("Split at playhead (⌘B)");
462
+ });
463
+ });
@@ -35,6 +35,8 @@ import { LinkedSelectionToggle } from "./LinkedSelectionToggle";
35
35
  import { useProjectHasAudio } from "../utils/audioMeterMath";
36
36
  import { canAddBeatAt, addBeatAtCompositionTime } from "../utils/beatEditActions";
37
37
  import { isTypingTarget } from "../utils/typingTarget";
38
+ import { SPLIT_SHORTCUT_HINT } from "../player/components/studioShortcuts";
39
+ import type { TimelineProps } from "../player/components/TimelineTypes";
38
40
 
39
41
  interface DomEditSessionSlice extends EnableKeyframesSession {
40
42
  domEditSelection: DomEditSelection | null;
@@ -44,6 +46,7 @@ interface DomEditSessionSlice extends EnableKeyframesSession {
44
46
  export interface TimelineToolbarProps {
45
47
  domEditSession?: DomEditSessionSlice;
46
48
  onSplitElement?: (element: TimelineElement, splitTime: number) => void;
49
+ splitShortcut?: TimelineProps["splitShortcut"];
47
50
  history?: TimelineHistoryButtonsProps;
48
51
  showHistory?: boolean;
49
52
  showSelectAroundPlayhead?: boolean;
@@ -145,6 +148,7 @@ function useKeyframeToggle(session?: DomEditSessionSlice) {
145
148
  export const TimelineToolbar = memo(function TimelineToolbar({
146
149
  domEditSession,
147
150
  onSplitElement,
151
+ splitShortcut = SPLIT_SHORTCUT_HINT,
148
152
  history,
149
153
  showHistory = true,
150
154
  showSelectAroundPlayhead = true,
@@ -382,7 +386,7 @@ export const TimelineToolbar = memo(function TimelineToolbar({
382
386
  <Tooltip
383
387
  label={
384
388
  canSplit
385
- ? "Split at playhead (S)"
389
+ ? `Split at playhead (${splitShortcut})`
386
390
  : splittable
387
391
  ? "Move the playhead inside the clip to split"
388
392
  : "Select a clip to split"
@@ -1,4 +1,5 @@
1
1
  import type React from "react";
2
+ import { dotRadius } from "./motionPathLayerNode";
2
3
 
3
4
  // Editor primary color (themeable via --hf-accent). Applied through inline
4
5
  // style because CSS var() isn't valid in SVG presentation attributes.
@@ -29,7 +30,7 @@ export function MotionPathNode(props: {
29
30
  // Diamond matching the timeline keyframe (a 45°-rotated rounded square).
30
31
  // `side` is chosen so the diamond's points reach ~`r` from center, matching the
31
32
  // old dot's footprint; selection is shown by enlarging it (no extra shape).
32
- const side = (selected ? r * 1.5 : r) * 1.414;
33
+ const side = dotRadius(r, selected) * 1.414;
33
34
  return (
34
35
  <g onPointerEnter={props.onEnter} onPointerLeave={props.onLeave}>
35
36
  <rect
@@ -0,0 +1,99 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { expect, it, vi } from "vitest";
5
+ import { usePlayerStore } from "../../player/store/playerStore";
6
+ import { MotionPathOverlay } from "./MotionPathOverlay";
7
+ import type { DomEditSelection } from "./domEditing";
8
+
9
+ Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
10
+
11
+ vi.mock("../../contexts/DomEditContext", () => ({
12
+ useDomEditContext: () => ({ selectedGsapAnimations: [], commitMutation: vi.fn() }),
13
+ }));
14
+ vi.mock("./motionPathSelection", () => ({
15
+ selectorFor: () => "#box",
16
+ editableAnimationId: () => "a1",
17
+ }));
18
+ // GSAP renders the layer at the first keyframe: the playhead is on it.
19
+ vi.mock("../../hooks/gsapPositionDetection", () => ({
20
+ readGsapPositionFromIframe: () => ({ x: 60, y: 30 }),
21
+ }));
22
+ vi.mock("./useMotionPathData", async (importOriginal) => ({
23
+ ...(await importOriginal<typeof import("./useMotionPathData")>()),
24
+ useMotionPathData: () => ({
25
+ rect: { left: 0, top: 0, width: 1920, height: 1080 },
26
+ geometry: {
27
+ kind: "linear",
28
+ points: "60,30 120,30",
29
+ nodes: [
30
+ { x: 60, y: 30, ref: { type: "keyframe", pct: 66.667 } },
31
+ { x: 120, y: 30, ref: { type: "keyframe", pct: 100 } },
32
+ ],
33
+ },
34
+ geometryResolved: true,
35
+ visibleInPreview: true,
36
+ home: { x: 0, y: 0 },
37
+ pScale: 1,
38
+ }),
39
+ }));
40
+
41
+ it("the layer's node and another node's ring inside the layer's box press the layer; a dot keeps its node", () => {
42
+ const box = document.createElement("div");
43
+ box.setAttribute("data-dom-edit-selection-box", "true");
44
+ const host = document.createElement("div");
45
+ document.body.append(box, host);
46
+ const boxPresses: number[] = [];
47
+ let boxStarts = true;
48
+ box.addEventListener("pointerdown", (e) => {
49
+ boxPresses.push((e as PointerEvent).clientX);
50
+ if (boxStarts) e.preventDefault();
51
+ });
52
+ const captured = vi.spyOn(Element.prototype, "setPointerCapture").mockImplementation(() => {});
53
+ const root = createRoot(host);
54
+ const selection = { element: document.createElement("div") } as unknown as DomEditSelection;
55
+ // Scale 1: client px are composition px. `inBox`: the layer's box is under the press.
56
+ const press = (node: number, x: number, inBox: boolean) => {
57
+ const hit = [...host.querySelectorAll("circle.pointer-events-auto")].find(
58
+ (c) => c.getAttribute("cx") === String(node),
59
+ )!;
60
+ document.elementsFromPoint = () => (inBox ? [hit, box] : [hit]);
61
+ const down = { bubbles: true, cancelable: true, button: 0, clientX: x, clientY: 30 };
62
+ act(() => void hit.dispatchEvent(new PointerEvent("pointerdown", down)));
63
+ };
64
+ try {
65
+ act(() =>
66
+ root.render(
67
+ <MotionPathOverlay
68
+ iframeRef={{ current: null }}
69
+ selection={selection}
70
+ compositionSize={{ width: 1920, height: 1080 }}
71
+ isPlaying={false}
72
+ />,
73
+ ),
74
+ );
75
+ press(60, 63, false);
76
+ press(120, 110, true);
77
+ expect(boxPresses).toEqual([63, 110]);
78
+ press(120, 118, true);
79
+ press(120, 110, false);
80
+ expect(boxPresses).toEqual([63, 110]);
81
+ // A selected node draws its dot 1.5x as large, and the larger dot is the node's.
82
+ act(() => usePlayerStore.setState({ activeKeyframePct: 100 }));
83
+ press(120, 112, true);
84
+ expect(boxPresses).toEqual([63, 110]);
85
+ act(() => usePlayerStore.setState({ activeKeyframePct: null }));
86
+ press(120, 112, true);
87
+ expect(boxPresses).toEqual([63, 110, 112]);
88
+ // A box that starts no gesture leaves the press to the node.
89
+ boxStarts = false;
90
+ captured.mockClear();
91
+ press(60, 63, false);
92
+ expect(captured).toHaveBeenCalledTimes(1);
93
+ } finally {
94
+ captured.mockRestore();
95
+ act(() => root.unmount());
96
+ box.remove();
97
+ host.remove();
98
+ }
99
+ });
@@ -10,6 +10,8 @@ import { parkPlayheadOnKeyframe } from "../../hooks/gsapDragCommit";
10
10
  import { commitWholePropertyOffset } from "../../hooks/gsapWholePropertyOffsetCommit";
11
11
  import { nearestPointOnPath, type MotionNodeRef } from "./motionPathGeometry";
12
12
  import { editableAnimationId, selectorFor } from "./motionPathSelection";
13
+ import { dotRadius, pressBelongsToLayer, pressSelectedLayer } from "./motionPathLayerNode";
14
+ import { readGsapPositionFromIframe } from "../../hooks/gsapPositionDetection";
13
15
  import { ACCENT, MotionPathNode } from "./MotionPathNode";
14
16
  import {
15
17
  KeyframeDiamondContextMenu,
@@ -310,6 +312,10 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
310
312
  if (!interactive) return;
311
313
  if (e.button !== 0) return;
312
314
  e.stopPropagation();
315
+ 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;
313
319
  (e.target as Element).setPointerCapture(e.pointerId);
314
320
  dragRef.current = {
315
321
  index,
@@ -0,0 +1,57 @@
1
+ // @vitest-environment happy-dom
2
+ import { expect, it, vi } from "vitest";
3
+ import type { DomEditSelection } from "./domEditing";
4
+ import { readTranslatePx } from "./plainTranslate";
5
+ import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
6
+
7
+ vi.mock("./plainTranslate", async (importOriginal) => ({
8
+ ...(await importOriginal<typeof import("./plainTranslate")>()),
9
+ readTranslatePx: vi.fn(() => ({ x: Number.NaN, y: 0 })),
10
+ }));
11
+
12
+ it("a drag press the layer cannot take gets the notice, counts as handled, and leaves the box live", () => {
13
+ const element = document.body.appendChild(document.createElement("div"));
14
+ const ref = <T>(current: T) => ({ current });
15
+ const onBlockedMove = vi.fn();
16
+ const opts = {
17
+ selectionRef: ref({
18
+ element,
19
+ capabilities: { canApplyManualOffset: true },
20
+ } as unknown as DomEditSelection),
21
+ overlayRectRef: ref({ left: 0, top: 0, width: 240, height: 160, editScaleX: 1, editScaleY: 1 }),
22
+ boxRef: ref(document.createElement("div")),
23
+ overlayRef: ref(null),
24
+ iframeRef: ref(null),
25
+ gestureRef: ref(null),
26
+ rafPausedRef: ref(false),
27
+ onManualDragStartRef: ref(vi.fn()),
28
+ onBlockedMoveRef: ref(onBlockedMove),
29
+ onPathOffsetCommitRef: ref(vi.fn()),
30
+ snapGuidesRef: ref(null),
31
+ groupGestureRef: ref(null),
32
+ blockedMoveRef: ref(null),
33
+ setOverlayRect: vi.fn(),
34
+ suppressNextBoxClickRef: ref(false),
35
+ hoverSelectionRef: ref(null),
36
+ onCanvasMouseDown: vi.fn(),
37
+ };
38
+ const press = new PointerEvent("pointerdown", { cancelable: true });
39
+ const e = {
40
+ clientX: 10,
41
+ clientY: 10,
42
+ pointerId: 1,
43
+ button: 0,
44
+ preventDefault: () => press.preventDefault(),
45
+ stopPropagation() {},
46
+ currentTarget: { setPointerCapture() {} },
47
+ };
48
+ const handlers = createDomEditOverlayGestureHandlers(opts as never);
49
+ expect(handlers.startGesture("drag", e as never)).toBe(false);
50
+ expect(onBlockedMove).toHaveBeenCalledTimes(1);
51
+ expect(press.defaultPrevented).toBe(true);
52
+ // The release lands outside the box, so nothing would ever resume it.
53
+ expect(opts.rafPausedRef.current).toBe(false);
54
+ // Playback pauses before setup reads which timelines are running.
55
+ const [paused] = opts.onManualDragStartRef.current.mock.invocationCallOrder;
56
+ expect(paused).toBeLessThan(vi.mocked(readTranslatePx).mock.invocationCallOrder[0]!);
57
+ });
@@ -185,7 +185,6 @@ export function startGesture(
185
185
 
186
186
  if (kind === "drag") {
187
187
  opts.onManualDragStartRef.current?.();
188
- opts.rafPausedRef.current = true;
189
188
  const result = createManualOffsetDragMember({
190
189
  key: selectionCacheKey(sel),
191
190
  selection: sel,
@@ -195,6 +194,7 @@ export function startGesture(
195
194
  });
196
195
  if (!result.ok) {
197
196
  opts.onBlockedMoveRef.current(result.selection, result.reason);
197
+ e.preventDefault();
198
198
  return false;
199
199
  }
200
200
  pathOffsetMember = result.member;
@@ -17,6 +17,16 @@ describe("buildMotionPathGeometry", () => {
17
17
  ]);
18
18
  });
19
19
 
20
+ it("puts an axis the tween does not animate where GSAP renders it, a folded CSS translate", () => {
21
+ const read: ReadTween = {
22
+ keyframes: [
23
+ { percentage: 66.667, properties: { x: 60 } },
24
+ { percentage: 100, properties: { x: 120 } },
25
+ ],
26
+ };
27
+ expect(buildMotionPathGeometry(read, { x: 40, y: 30 })!.points).toBe("60,30 120,30");
28
+ });
29
+
20
30
  it("preserves order and percentages for intermediate keyframes", () => {
21
31
  const read: ReadTween = { keyframes: [kf(0, 0, 0), kf(50, 50, 90), kf(100, 100, 0)] };
22
32
  const geo = buildMotionPathGeometry(read);
@@ -78,7 +78,10 @@ export function nearestPointOnPath(
78
78
  return best;
79
79
  }
80
80
 
81
- export function buildMotionPathGeometry(read: ReadTween | null): MotionPathGeometry | null {
81
+ export function buildMotionPathGeometry(
82
+ read: ReadTween | null,
83
+ base: { x: number; y: number } = { x: 0, y: 0 },
84
+ ): MotionPathGeometry | null {
82
85
  if (!read) return null;
83
86
  const isArc = Boolean(read.arcPath);
84
87
  const nodes: MotionPathNode[] = [];
@@ -87,8 +90,8 @@ export function buildMotionPathGeometry(read: ReadTween | null): MotionPathGeome
87
90
  // anchor. Arc waypoints always carry x/y (never filtered), so source index
88
91
  // and node order stay aligned.
89
92
  // Which axes does the tween animate at all? A single-axis tween (e.g.
90
- // `to({ x: -260 })`) only carries x; its y stays at the base (0, the GSAP
91
- // transform identity), so we default it and still draw a path. But if the tween
93
+ // `to({ x: -260 })`) only carries x; its y stays at `base`, GSAP's live y (a CSS
94
+ // translate it folded in, else 0), so we default it and still draw a path. But if the tween
92
95
  // DOES animate an axis and a given keyframe omits it, that value is interpolated
93
96
  // (not 0) and can't be placed here → skip that node (the prior behavior).
94
97
  const finite = (v: unknown): v is number => typeof v === "number" && isFinite(v);
@@ -100,8 +103,8 @@ export function buildMotionPathGeometry(read: ReadTween | null): MotionPathGeome
100
103
  if (tweenHasX && !finite(kf.properties.x)) return;
101
104
  if (tweenHasY && !finite(kf.properties.y)) return;
102
105
  nodes.push({
103
- x: tweenHasX ? (kf.properties.x as number) : 0,
104
- y: tweenHasY ? (kf.properties.y as number) : 0,
106
+ x: tweenHasX ? (kf.properties.x as number) : base.x,
107
+ y: tweenHasY ? (kf.properties.y as number) : base.y,
105
108
  ref: isArc ? { type: "waypoint", index: i } : { type: "keyframe", pct: kf.percentage },
106
109
  });
107
110
  });
@@ -0,0 +1,82 @@
1
+ // @vitest-environment happy-dom
2
+ import { expect, it } from "vitest";
3
+ import { pressBelongsToLayer, pressSelectedLayer } from "./motionPathLayerNode";
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 };
8
+ const live = { x: 60, y: 30 };
9
+
10
+ const box = document.createElement("div");
11
+ box.setAttribute("data-dom-edit-selection-box", "true");
12
+ const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
13
+ document.body.append(box, circle);
14
+
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;
19
+ }
20
+
21
+ 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);
26
+ });
27
+
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
+ );
35
+ });
36
+
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
+ });
43
+
44
+ it("hands a press to the selected layer's box with its pointer and position", () => {
45
+ const got: number[][] = [];
46
+ const listen = (e: Event) => {
47
+ const p = e as PointerEvent;
48
+ got.push([p.pointerId, p.clientX, p.clientY, p.button]);
49
+ e.preventDefault();
50
+ };
51
+ box.addEventListener("pointerdown", listen);
52
+ const nativeEvent = new PointerEvent("pointerdown", {
53
+ pointerId: 7,
54
+ clientX: 12,
55
+ clientY: 34,
56
+ button: 0,
57
+ bubbles: true,
58
+ cancelable: true,
59
+ });
60
+ const down = { currentTarget: circle, nativeEvent } as unknown as React.PointerEvent;
61
+ expect(pressSelectedLayer(down)).toBe(true);
62
+ 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);
67
+ });
68
+
69
+ it("keeps the press when the box does not start a gesture with it, or takes no pointer", () => {
70
+ let got = 0;
71
+ const listen = () => void got++;
72
+ box.addEventListener("pointerdown", listen);
73
+ const nativeEvent = new PointerEvent("pointerdown", { bubbles: true, cancelable: true });
74
+ const down = { currentTarget: circle, nativeEvent } as unknown as React.PointerEvent;
75
+ expect(pressSelectedLayer(down)).toBe(false);
76
+ expect(got).toBe(1);
77
+ box.style.pointerEvents = "none";
78
+ expect(pressSelectedLayer(down)).toBe(false);
79
+ expect(got).toBe(1);
80
+ box.style.pointerEvents = "";
81
+ box.removeEventListener("pointerdown", listen);
82
+ });
@@ -0,0 +1,28 @@
1
+ type Point = { x: number; y: number };
2
+ type DrawnNode = Point & { ax: number; ay: number };
3
+
4
+ const LAYER_BOX = '[data-dom-edit-selection-box="true"]';
5
+
6
+ /** How far a node's dot reaches; a selected node draws it larger. */
7
+ export const dotRadius = (r: number, selected: boolean) => (selected ? r * 1.5 : r);
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(
11
+ e: React.PointerEvent,
12
+ point: Point,
13
+ pressed: DrawnNode,
14
+ dotR: number,
15
+ 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
+ }
22
+
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
+ }
@@ -34,9 +34,11 @@ let flushNudge = () => {};
34
34
  function Harness({
35
35
  selection,
36
36
  onPathOffsetCommit,
37
+ onManualDragStart = () => {},
37
38
  }: {
38
39
  selection: DomEditSelection | null;
39
40
  onPathOffsetCommit: UseDomEditNudgeParams["onPathOffsetCommitRef"]["current"];
41
+ onManualDragStart?: () => void;
40
42
  }) {
41
43
  flushNudge = useDomEditNudge({
42
44
  selection,
@@ -48,7 +50,7 @@ function Harness({
48
50
  gestureRef: makeRef(null),
49
51
  groupGestureRef: makeRef(null),
50
52
  blockedMoveRef: makeRef(null),
51
- onManualDragStartRef: makeRef(() => {}),
53
+ onManualDragStartRef: makeRef(onManualDragStart),
52
54
  onBlockedMoveRef: makeRef(() => {}),
53
55
  onPathOffsetCommitRef: makeRef(onPathOffsetCommit),
54
56
  onGroupPathOffsetCommitRef: makeRef(async () => {}),
@@ -340,6 +342,56 @@ describe("useDomEditNudge carries the route its press chose", () => {
340
342
  });
341
343
  });
342
344
 
345
+ describe("useDomEditNudge pauses playback before it snapshots the timelines", () => {
346
+ let reactRoot: ReturnType<typeof createRoot> | null = null;
347
+ const pause = vi.fn();
348
+ const mount = (selection: DomEditSelection | null) => {
349
+ reactRoot = createRoot(document.createElement("div"));
350
+ act(() => {
351
+ reactRoot!.render(
352
+ React.createElement(Harness, {
353
+ selection,
354
+ onPathOffsetCommit: vi.fn(),
355
+ onManualDragStart: pause,
356
+ }),
357
+ );
358
+ });
359
+ };
360
+ beforeEach(() => {
361
+ vi.useFakeTimers();
362
+ __resetForTests();
363
+ pause.mockReset();
364
+ });
365
+ afterEach(() => {
366
+ act(() => reactRoot?.unmount());
367
+ reactRoot = null;
368
+ document.body.innerHTML = "";
369
+ vi.useRealTimers();
370
+ delete (window as { __timelines?: unknown }).__timelines;
371
+ });
372
+
373
+ it("does not resume a timeline the playback pause stopped", () => {
374
+ let playing = true;
375
+ const root = { pause: () => void (playing = false), paused: () => !playing };
376
+ (window as { __timelines?: unknown }).__timelines = { root };
377
+ pause.mockImplementation(root.pause);
378
+ const element = document.body.appendChild(document.createElement("div"));
379
+ mount(makeSelection("Dot", element));
380
+
381
+ act(() => dispatchArrowRight());
382
+
383
+ expect(element.hasAttribute("data-hf-drag-paused-timelines")).toBe(false);
384
+ });
385
+
386
+ it("leaves playback alone when no layer can move", () => {
387
+ mount(null);
388
+
389
+ act(() => dispatchArrowRight());
390
+
391
+ expect(pause).not.toHaveBeenCalled();
392
+ });
393
+ });
394
+
343
395
  describe("useDomEditNudge — undo right after a burst", () => {
344
396
  it("undo's drain commits a burst still inside its debounce and waits for its save", async () => {
345
397
  __resetForTests();
@@ -212,6 +212,8 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
212
212
  ? resolveGroupNudgeTargets(groupItems)
213
213
  : resolveSingleNudgeTarget(p.selectionRef.current, p.overlayRectRef.current);
214
214
  if (!targets) return null;
215
+ // Same side effect a drag start has (pauses preview playback).
216
+ p.onManualDragStartRef.current?.();
215
217
  const members: ManualOffsetDragMember[] = [];
216
218
  for (const target of targets) {
217
219
  const result = createManualOffsetDragMember({ ...target, gesture: "nudge" });
@@ -223,8 +225,6 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
223
225
  members.push(result.member);
224
226
  }
225
227
  if (members.length === 0) return null;
226
- // Same side effect a drag start has (pauses preview playback).
227
- p.onManualDragStartRef.current?.();
228
228
  const edit = beginStudioPendingEdit(manualOffsetMoveRevert(members));
229
229
  return { members, isGroup, accum: { x: 0, y: 0 }, timer: null, edit };
230
230
  };
@@ -8,6 +8,12 @@ import { usePlayerStore } from "../../player/store/playerStore";
8
8
 
9
9
  Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
10
10
 
11
+ const runtime = vi.hoisted(() => ({ read: null as unknown, gsapPosition: null as unknown }));
12
+ vi.mock("../../hooks/gsapRuntimeKeyframes", () => ({ readRuntimeKeyframes: () => runtime.read }));
13
+ vi.mock("../../hooks/gsapPositionDetection", () => ({
14
+ readGsapPositionFromIframe: () => runtime.gsapPosition,
15
+ }));
16
+
11
17
  const originalRaf = window.requestAnimationFrame;
12
18
  let frames: Array<() => void> = [];
13
19
  const runFrames = (count: number) => {
@@ -36,6 +42,30 @@ afterEach(() => {
36
42
  vi.useRealTimers();
37
43
  usePlayerStore.setState({ previewBooted: false, motionPathArmed: false });
38
44
  document.body.innerHTML = "";
45
+ runtime.read = runtime.gsapPosition = null;
46
+ });
47
+
48
+ it("draws an axis the tween leaves alone where GSAP renders it, so the playhead node sits on the layer", () => {
49
+ runtime.read = {
50
+ keyframes: [
51
+ { percentage: 66.667, properties: { x: 60 } },
52
+ { percentage: 100, properties: { x: 120 } },
53
+ ],
54
+ };
55
+ runtime.gsapPosition = { x: 60, y: 30 };
56
+ let points: string | undefined;
57
+ function Probe() {
58
+ const ref = useRef(document.createElement("iframe"));
59
+ points = useMotionPathData(ref, "#box").geometry?.points;
60
+ return null;
61
+ }
62
+ const root = createRoot(document.body.appendChild(document.createElement("div")));
63
+ try {
64
+ act(() => root.render(<Probe />));
65
+ expect(points).toBe("60,30 120,30");
66
+ } finally {
67
+ act(() => root.unmount());
68
+ }
39
69
  });
40
70
 
41
71
  it("reads no layout per frame until there is a path or a create ring to draw", () => {
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useRef, useState, type RefObject } from "react";
2
2
  import { readRuntimeKeyframes } from "../../hooks/gsapRuntimeKeyframes";
3
+ import { readGsapPositionFromIframe } from "../../hooks/gsapPositionDetection";
3
4
  import { isElementVisibleForOverlay } from "./domEditOverlayGeometry";
4
5
  import { buildMotionPathGeometry, type MotionPathGeometry } from "./motionPathGeometry";
5
6
  import { subscribeOverlayFrame } from "./overlayFrameLoop";
@@ -169,7 +170,8 @@ export function useMotionPathData(
169
170
  const recompute = () => {
170
171
  // Position-only: never let a co-located size/scale tween shadow the path.
171
172
  const read = readRuntimeKeyframes(iframeRef.current, selector, undefined, ["x", "y"]);
172
- const next = buildMotionPathGeometry(read);
173
+ const base = read ? readGsapPositionFromIframe(iframeRef.current, selector) : null;
174
+ const next = buildMotionPathGeometry(read, base ?? undefined);
173
175
  setGeometry((prev) =>
174
176
  prev?.points === next?.points && prev?.kind === next?.kind ? prev : next,
175
177
  );