@hyperframes/studio 0.8.121 → 0.8.123

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 (63) hide show
  1. package/dist/assets/{hyperframes-player-GkN9yzzq.js → hyperframes-player-yABdb6yk.js} +1 -1
  2. package/dist/assets/{index-ByV72rWx.js → index-D77MIjSJ.js} +1 -1
  3. package/dist/assets/{index-BpKhiuzT.js → index-DWjF2Kxw.js} +1 -1
  4. package/dist/assets/{index-CyFqMHGS.js → index-Dt7lWtY7.js} +184 -184
  5. package/dist/assets/{index-t1LH6H5H.css → index-LrNlT5Tg.css} +1 -1
  6. package/dist/{chunk-J6Q572FA.js → chunk-246VB2G3.js} +3 -1
  7. package/dist/chunk-246VB2G3.js.map +1 -0
  8. package/dist/index.d.ts +10 -2
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +1611 -1310
  11. package/dist/index.js.map +1 -1
  12. package/dist/player.js +1 -1
  13. package/dist/styles.css +1 -1
  14. package/package.json +8 -8
  15. package/src/components/FrameyGlyph.tsx +52 -0
  16. package/src/components/OpenInDesktopButton.dom.test.tsx +113 -0
  17. package/src/components/OpenInDesktopButton.tsx +153 -0
  18. package/src/components/StudioHeader.dom.test.tsx +2 -0
  19. package/src/components/StudioHeader.tsx +2 -0
  20. package/src/components/TimelineToolbar.test.tsx +25 -0
  21. package/src/components/TimelineToolbar.tsx +5 -1
  22. package/src/components/editor/MotionPathNode.tsx +2 -1
  23. package/src/components/editor/MotionPathOverlay.layerPress.test.tsx +99 -0
  24. package/src/components/editor/MotionPathOverlay.tsx +6 -0
  25. package/src/components/editor/domEditOverlayStartGesture.blocked.test.ts +57 -0
  26. package/src/components/editor/domEditOverlayStartGesture.ts +1 -1
  27. package/src/components/editor/motionPathGeometry.test.ts +10 -0
  28. package/src/components/editor/motionPathGeometry.ts +8 -5
  29. package/src/components/editor/motionPathLayerNode.test.ts +82 -0
  30. package/src/components/editor/motionPathLayerNode.ts +28 -0
  31. package/src/components/editor/useDomEditNudge.test.tsx +53 -1
  32. package/src/components/editor/useDomEditNudge.ts +2 -2
  33. package/src/components/editor/useMotionPathData.test.tsx +30 -0
  34. package/src/components/editor/useMotionPathData.ts +3 -1
  35. package/src/hooks/gsapValueAtPlayhead.test.ts +46 -3
  36. package/src/hooks/gsapValueAtPlayhead.ts +12 -2
  37. package/src/hooks/linkShortcuts.test.ts +16 -0
  38. package/src/hooks/linkShortcuts.ts +2 -0
  39. package/src/hooks/useAppHotkeys.ts +1 -2
  40. package/src/index.ts +2 -0
  41. package/src/player/components/TimelineClipFades.tsx +1 -0
  42. package/src/player/components/timelineClipDragPreview.test.ts +68 -0
  43. package/src/player/components/timelineClipDragPreview.ts +39 -14
  44. package/src/player/components/timelineLayout.test.ts +24 -1
  45. package/src/player/components/timelineRulerGeometry.ts +24 -3
  46. package/src/player/components/timelineSnapping.test.ts +43 -12
  47. package/src/player/components/timelineSnapping.ts +33 -15
  48. package/src/player/components/useTimelineClipDrag.resize.test.tsx +54 -54
  49. package/src/player/components/useTimelineClipDrag.ts +17 -29
  50. package/src/player/components/useTimelineProviderState.tsx +0 -1
  51. package/src/player/components/useTimelineTicks.ts +6 -3
  52. package/src/player/hooks/timelinePlayerTestHarness.ts +2 -0
  53. package/src/player/hooks/useTimelinePlayer.seek.test.ts +66 -0
  54. package/src/player/hooks/useTimelinePlayer.ts +6 -2
  55. package/src/player/lib/time.ts +3 -0
  56. package/src/player/store/previewFrameStore.ts +48 -0
  57. package/src/player/store/timelineElement.ts +12 -0
  58. package/src/styles/components.css +51 -0
  59. package/src/styles/theme.css +3 -0
  60. package/src/timelineChromeExports.test.tsx +6 -0
  61. package/src/utils/gsapSoftReload.test.ts +50 -0
  62. package/src/utils/gsapSoftReload.ts +22 -2
  63. package/dist/chunk-J6Q572FA.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.121",
3
+ "version": "0.8.123",
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.121",
64
- "@hyperframes/sdk": "0.8.121",
65
- "@hyperframes/shader-transitions": "0.8.121",
66
- "@hyperframes/player": "0.8.121",
67
- "@hyperframes/studio-server": "0.8.121",
68
- "@hyperframes/parsers": "0.8.121"
63
+ "@hyperframes/core": "0.8.123",
64
+ "@hyperframes/parsers": "0.8.123",
65
+ "@hyperframes/player": "0.8.123",
66
+ "@hyperframes/sdk": "0.8.123",
67
+ "@hyperframes/shader-transitions": "0.8.123",
68
+ "@hyperframes/studio-server": "0.8.123"
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.121"
84
+ "@hyperframes/producer": "0.8.123"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "react": "19",
@@ -0,0 +1,52 @@
1
+ import type { Ref } from "react";
2
+
3
+ // Framey, the desktop app's cursor mascot, as hyperframes-internal packages/nib draws it: a rounded triangle with a
4
+ // white rim and one eye. The viewBox frames the body with its rim; the tip points up and left like a cursor.
5
+ const BODY = "M77.55 48.44 L122.77 65.85 L99.3 89.41 Z";
6
+ const EYE = { cx: 111.6, cy: 68.2, r: 11.5 };
7
+
8
+ /** `eyeRef` moves the pupils (a translate of a few px) without re-rendering. */
9
+ export function FrameyGlyph({
10
+ size,
11
+ className,
12
+ eyeRef,
13
+ }: {
14
+ size: number;
15
+ className?: string;
16
+ eyeRef?: Ref<SVGGElement>;
17
+ }) {
18
+ return (
19
+ <svg
20
+ width={size}
21
+ height={size}
22
+ viewBox="58 30 82 76"
23
+ aria-hidden="true"
24
+ className={className}
25
+ overflow="visible"
26
+ >
27
+ <path
28
+ d={BODY}
29
+ fill="var(--color-white)"
30
+ stroke="var(--color-white)"
31
+ strokeWidth={24}
32
+ strokeLinejoin="round"
33
+ />
34
+ <path
35
+ d={BODY}
36
+ fill="var(--color-framey)"
37
+ stroke="var(--color-framey)"
38
+ strokeWidth={19}
39
+ strokeLinejoin="round"
40
+ />
41
+ <circle cx={EYE.cx} cy={EYE.cy} r={EYE.r} fill="var(--color-white)" />
42
+ <g ref={eyeRef} className="hf-framey-eye">
43
+ <path
44
+ d={`M${EYE.cx - 2.6} ${EYE.cy - 2} V${EYE.cy + 2} M${EYE.cx + 2.6} ${EYE.cy - 2} V${EYE.cy + 2}`}
45
+ stroke="var(--color-framey-ink)"
46
+ strokeWidth={2.4}
47
+ strokeLinecap="round"
48
+ />
49
+ </g>
50
+ </svg>
51
+ );
52
+ }
@@ -0,0 +1,113 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ /**
4
+ * "Edit with Framey": shown only where the preview server answers on macOS; a press opens the project in the app,
5
+ * or introduces Framey with the download while the app cannot take it or is missing.
6
+ */
7
+ import React, { act } from "react";
8
+ import { createRoot, type Root } from "react-dom/client";
9
+ import { afterEach, beforeEach, expect, it, vi } from "vitest";
10
+
11
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
12
+
13
+ const showToast = vi.fn();
14
+ vi.mock("../contexts/StudioContext", () => ({ useStudioShellContext: () => ({ showToast }) }));
15
+ vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
16
+
17
+ const { OpenInDesktopButton } = await import("./OpenInDesktopButton");
18
+
19
+ const DOWNLOAD = "https://hyperframes.dev/studio/download";
20
+ let mounted: { root: Root; host: HTMLElement } | null = null;
21
+ let posts: number;
22
+
23
+ function serve(get: Response | null, post?: unknown) {
24
+ posts = 0;
25
+ vi.stubGlobal(
26
+ "fetch",
27
+ vi.fn(async (_url: string, init?: RequestInit) => {
28
+ if (init?.method === "POST") {
29
+ posts += 1;
30
+ return Response.json(post);
31
+ }
32
+ return get ?? new Response("Not found", { status: 404 });
33
+ }),
34
+ );
35
+ }
36
+
37
+ async function mount(): Promise<HTMLElement> {
38
+ const host = document.createElement("div");
39
+ document.body.append(host);
40
+ const root = createRoot(host);
41
+ mounted = { root, host };
42
+ await act(async () => root.render(<OpenInDesktopButton />));
43
+ return host;
44
+ }
45
+
46
+ const downloadHref = () =>
47
+ [...document.querySelectorAll("a")]
48
+ .find((a) => a.textContent?.includes("Get HyperFrames Studio"))
49
+ ?.getAttribute("href");
50
+
51
+ const button = () =>
52
+ document.querySelector<HTMLButtonElement>('[data-testid="header-open-in-desktop"]');
53
+
54
+ beforeEach(() => showToast.mockReset());
55
+
56
+ afterEach(() => {
57
+ vi.unstubAllGlobals();
58
+ if (!mounted) return;
59
+ const { root, host } = mounted;
60
+ mounted = null;
61
+ act(() => root.unmount());
62
+ host.remove();
63
+ });
64
+
65
+ it("stays hidden where the server has no hand-off (the desktop's own Studio)", async () => {
66
+ serve(null);
67
+ await mount();
68
+ expect(button()).toBeNull();
69
+ });
70
+
71
+ it("stays hidden when the server says it cannot open the app (not macOS)", async () => {
72
+ serve(Response.json({ available: false }));
73
+ await mount();
74
+ expect(button()).toBeNull();
75
+ });
76
+
77
+ it("while the app cannot take a project yet, introduces Framey with the download and opens nothing", async () => {
78
+ serve(Response.json({ available: true, handoff: false }), { opened: true });
79
+ await mount();
80
+ await act(async () => button()!.click());
81
+ expect(posts).toBe(0);
82
+ expect(document.body.textContent).toContain("Meet Framey");
83
+ expect(downloadHref()).toBe(DOWNLOAD);
84
+ });
85
+
86
+ it("opens the project in the app and says so", async () => {
87
+ serve(Response.json({ available: true, handoff: true }), {
88
+ opened: true,
89
+ bundleId: "dev.hyperframes.desktop",
90
+ });
91
+ await mount();
92
+ await act(async () => button()!.click());
93
+ expect(posts).toBe(1);
94
+ expect(showToast).toHaveBeenCalledWith(
95
+ "Framey is opening this project in HyperFrames Studio",
96
+ "info",
97
+ );
98
+ expect(button()!.dataset.opening).toBe("true");
99
+ expect(document.body.textContent).not.toContain("Meet Framey");
100
+ });
101
+
102
+ it("introduces Framey and offers the download when the app is missing", async () => {
103
+ serve(Response.json({ available: true, handoff: true }), {
104
+ opened: false,
105
+ reason: "not-installed",
106
+ downloadUrl: DOWNLOAD,
107
+ });
108
+ await mount();
109
+ await act(async () => button()!.click());
110
+ expect(showToast).not.toHaveBeenCalled();
111
+ expect(document.body.textContent).toContain("Meet Framey");
112
+ expect(downloadHref()).toBe(DOWNLOAD);
113
+ });
@@ -0,0 +1,153 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { useStudioShellContext } from "../contexts/StudioContext";
3
+ import { studioApiFetch } from "../utils/studioApiFetch";
4
+ import { trackStudioEvent } from "../utils/studioTelemetry";
5
+ import { FrameyGlyph } from "./FrameyGlyph";
6
+ import { Button, buttonBase, buttonSizes, buttonVariants, cn, Popover } from "./ui";
7
+
8
+ // "Edit with Framey": the preview server (`/api/open-in-desktop`) says whether to show it and whether the app can
9
+ // take the project yet (its gate is the CLI's HANDOFF_READY); until then a press introduces Framey with the download.
10
+ const ROUTE = "/api/open-in-desktop";
11
+ const DOWNLOAD_URL = "https://hyperframes.dev/studio/download";
12
+ /** How far the pupils travel towards the pointer, in the glyph's own units. */
13
+ const GAZE = 2.2;
14
+ /** Long enough for the flight to finish before the label comes back. */
15
+ const OPENING_MS = 1600;
16
+
17
+ type OpenResult = { opened: true } | { opened: false; reason: string; downloadUrl: string };
18
+
19
+ /** Null hides the button: no such route (the app's own Studio), or not macOS. */
20
+ function useDesktopRoute(): { handoff: boolean } | null {
21
+ const [route, setRoute] = useState<{ handoff: boolean } | null>(null);
22
+ useEffect(() => {
23
+ let live = true;
24
+ studioApiFetch(ROUTE)
25
+ .then((res) => (res.ok ? res.json() : null))
26
+ .then((body: { available?: unknown; handoff?: unknown } | null) => {
27
+ if (live && body?.available === true) setRoute({ handoff: body.handoff === true });
28
+ })
29
+ .catch(() => {});
30
+ return () => {
31
+ live = false;
32
+ };
33
+ }, []);
34
+ return route;
35
+ }
36
+
37
+ /** Framey looks at the pointer: one listener, one write per frame, no re-render. */
38
+ function useGaze(
39
+ anchor: React.RefObject<HTMLElement | null>,
40
+ eye: React.RefObject<SVGGElement | null>,
41
+ ) {
42
+ useEffect(() => {
43
+ let frame = 0;
44
+ const look = (event: PointerEvent) => {
45
+ cancelAnimationFrame(frame);
46
+ frame = requestAnimationFrame(() => {
47
+ const box = anchor.current?.getBoundingClientRect();
48
+ if (!box || !eye.current) return;
49
+ const dx = event.clientX - (box.left + box.height / 2);
50
+ const dy = event.clientY - (box.top + box.height / 2);
51
+ const distance = Math.hypot(dx, dy) || 1;
52
+ eye.current.style.transform = `translate(${(dx / distance) * GAZE}px, ${(dy / distance) * GAZE}px)`;
53
+ });
54
+ };
55
+ window.addEventListener("pointermove", look);
56
+ return () => {
57
+ cancelAnimationFrame(frame);
58
+ window.removeEventListener("pointermove", look);
59
+ };
60
+ }, [anchor, eye]);
61
+ }
62
+
63
+ export function OpenInDesktopButton() {
64
+ const { showToast } = useStudioShellContext();
65
+ const route = useDesktopRoute();
66
+ const [opening, setOpening] = useState(false);
67
+ // The card under the button, introducing Framey with the app's download.
68
+ const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
69
+ const trigger = useRef<HTMLButtonElement>(null);
70
+ const eye = useRef<SVGGElement>(null);
71
+ useGaze(trigger, eye);
72
+ if (!route) return null;
73
+
74
+ const open = async () => {
75
+ if (!route.handoff) {
76
+ trackStudioEvent("toolbar_action", { action: "open_in_desktop" });
77
+ setDownloadUrl(DOWNLOAD_URL);
78
+ return;
79
+ }
80
+ if (opening) return;
81
+ setOpening(true);
82
+ trackStudioEvent("toolbar_action", { action: "open_in_desktop" });
83
+ try {
84
+ const res = await studioApiFetch(ROUTE, { method: "POST" });
85
+ const result = (await res.json()) as OpenResult;
86
+ if (result.opened) {
87
+ showToast("Framey is opening this project in HyperFrames Studio", "info");
88
+ await new Promise((done) => setTimeout(done, OPENING_MS));
89
+ } else setDownloadUrl(result.downloadUrl);
90
+ } catch {
91
+ showToast("Couldn't reach the preview server to open the app", "error");
92
+ } finally {
93
+ setOpening(false);
94
+ }
95
+ };
96
+
97
+ return (
98
+ <Popover
99
+ open={downloadUrl !== null}
100
+ // The popover owns the trigger's press: a press runs the hand-off, and the card shows only when there is
101
+ // something to say (the download). A second handler on the button raced this one.
102
+ onOpenChange={(next) => {
103
+ if (next) void open();
104
+ else setDownloadUrl(null);
105
+ }}
106
+ side="bottom"
107
+ align="end"
108
+ arrow
109
+ aria-label="Meet Framey"
110
+ className="w-72"
111
+ trigger={
112
+ <Button
113
+ ref={trigger}
114
+ variant="ghost"
115
+ data-testid="header-open-in-desktop"
116
+ data-opening={opening || undefined}
117
+ title="Open this project in HyperFrames Studio, the desktop app, and edit it with Framey"
118
+ className="hf-framey-trigger gap-1.5 pl-1.5"
119
+ icon={<FrameyGlyph size={20} className="hf-framey" eyeRef={eye} />}
120
+ >
121
+ {opening ? "Opening in the app…" : "Edit with Framey"}
122
+ </Button>
123
+ }
124
+ >
125
+ <div className="flex items-center gap-2.5">
126
+ <FrameyGlyph size={36} />
127
+ <p className="text-step-12 font-medium text-text-0">Meet Framey</p>
128
+ </div>
129
+ <p className="mt-2 text-text-3">
130
+ I live in HyperFrames Studio, the free desktop app for macOS:
131
+ </p>
132
+ <ul className="mt-1.5 grid gap-1 text-text-1">
133
+ <li>Chat to change anything in the video</li>
134
+ <li>Draw on a frame to point at it</li>
135
+ <li>Watch me act out each edit</li>
136
+ </ul>
137
+ <div className="mt-3 flex items-center justify-end gap-2 whitespace-nowrap">
138
+ <Button variant="ghost" onClick={() => setDownloadUrl(null)}>
139
+ Not now
140
+ </Button>
141
+ <a
142
+ href={downloadUrl ?? undefined}
143
+ target="_blank"
144
+ rel="noopener noreferrer"
145
+ className={cn(buttonBase, buttonVariants.primary, buttonSizes.md)}
146
+ onClick={() => trackStudioEvent("toolbar_action", { action: "get_desktop_app" })}
147
+ >
148
+ Get HyperFrames Studio
149
+ </a>
150
+ </div>
151
+ </Popover>
152
+ );
153
+ }
@@ -40,6 +40,8 @@ vi.mock("../contexts/PanelLayoutContext", () => ({
40
40
  }));
41
41
 
42
42
  vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
43
+ // Probes the preview server on mount; covered in OpenInDesktopButton.dom.test.tsx.
44
+ vi.mock("./OpenInDesktopButton", () => ({ OpenInDesktopButton: () => null }));
43
45
 
44
46
  const { StudioHeader } = await import("./StudioHeader");
45
47
  const { ShowThemeToggle } = await import("./ThemeToggle");
@@ -6,6 +6,7 @@ import { trackStudioEvent } from "../utils/studioTelemetry";
6
6
  import { Button, buttonBase, buttonSizes, buttonVariants, cn, Tooltip } from "./ui";
7
7
  import { Dock } from "./dock/Dock";
8
8
  import { InspectorIcon } from "./icons/InspectorIcon";
9
+ import { OpenInDesktopButton } from "./OpenInDesktopButton";
9
10
  import { HyperframesLogo } from "./ui/HyperframesLogo";
10
11
  import { ShowThemeToggle, ThemeToggle } from "./ThemeToggle";
11
12
 
@@ -151,6 +152,7 @@ export const StudioHeader = memo(function StudioHeader({
151
152
  </div>
152
153
  {showThemeToggle && <ThemeToggle />}
153
154
  <Dock.WindowMenu />
155
+ <OpenInDesktopButton />
154
156
  <Tooltip
155
157
  label={
156
158
  ffmpegMissing
@@ -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);