@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.
- package/dist/assets/{hyperframes-player-GkN9yzzq.js → hyperframes-player-yABdb6yk.js} +1 -1
- package/dist/assets/{index-ByV72rWx.js → index-D77MIjSJ.js} +1 -1
- package/dist/assets/{index-BpKhiuzT.js → index-DWjF2Kxw.js} +1 -1
- package/dist/assets/{index-CyFqMHGS.js → index-Dt7lWtY7.js} +184 -184
- package/dist/assets/{index-t1LH6H5H.css → index-LrNlT5Tg.css} +1 -1
- package/dist/{chunk-J6Q572FA.js → chunk-246VB2G3.js} +3 -1
- package/dist/chunk-246VB2G3.js.map +1 -0
- package/dist/index.d.ts +10 -2
- package/dist/index.html +2 -2
- package/dist/index.js +1611 -1310
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/dist/styles.css +1 -1
- package/package.json +8 -8
- package/src/components/FrameyGlyph.tsx +52 -0
- package/src/components/OpenInDesktopButton.dom.test.tsx +113 -0
- package/src/components/OpenInDesktopButton.tsx +153 -0
- package/src/components/StudioHeader.dom.test.tsx +2 -0
- package/src/components/StudioHeader.tsx +2 -0
- package/src/components/TimelineToolbar.test.tsx +25 -0
- package/src/components/TimelineToolbar.tsx +5 -1
- package/src/components/editor/MotionPathNode.tsx +2 -1
- package/src/components/editor/MotionPathOverlay.layerPress.test.tsx +99 -0
- package/src/components/editor/MotionPathOverlay.tsx +6 -0
- package/src/components/editor/domEditOverlayStartGesture.blocked.test.ts +57 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +1 -1
- package/src/components/editor/motionPathGeometry.test.ts +10 -0
- package/src/components/editor/motionPathGeometry.ts +8 -5
- package/src/components/editor/motionPathLayerNode.test.ts +82 -0
- package/src/components/editor/motionPathLayerNode.ts +28 -0
- package/src/components/editor/useDomEditNudge.test.tsx +53 -1
- package/src/components/editor/useDomEditNudge.ts +2 -2
- package/src/components/editor/useMotionPathData.test.tsx +30 -0
- package/src/components/editor/useMotionPathData.ts +3 -1
- package/src/hooks/gsapValueAtPlayhead.test.ts +46 -3
- package/src/hooks/gsapValueAtPlayhead.ts +12 -2
- package/src/hooks/linkShortcuts.test.ts +16 -0
- package/src/hooks/linkShortcuts.ts +2 -0
- package/src/hooks/useAppHotkeys.ts +1 -2
- package/src/index.ts +2 -0
- package/src/player/components/TimelineClipFades.tsx +1 -0
- package/src/player/components/timelineClipDragPreview.test.ts +68 -0
- package/src/player/components/timelineClipDragPreview.ts +39 -14
- package/src/player/components/timelineLayout.test.ts +24 -1
- package/src/player/components/timelineRulerGeometry.ts +24 -3
- package/src/player/components/timelineSnapping.test.ts +43 -12
- package/src/player/components/timelineSnapping.ts +33 -15
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +54 -54
- package/src/player/components/useTimelineClipDrag.ts +17 -29
- package/src/player/components/useTimelineProviderState.tsx +0 -1
- package/src/player/components/useTimelineTicks.ts +6 -3
- package/src/player/hooks/timelinePlayerTestHarness.ts +2 -0
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +66 -0
- package/src/player/hooks/useTimelinePlayer.ts +6 -2
- package/src/player/lib/time.ts +3 -0
- package/src/player/store/previewFrameStore.ts +48 -0
- package/src/player/store/timelineElement.ts +12 -0
- package/src/styles/components.css +51 -0
- package/src/styles/theme.css +3 -0
- package/src/timelineChromeExports.test.tsx +6 -0
- package/src/utils/gsapSoftReload.test.ts +50 -0
- package/src/utils/gsapSoftReload.ts +22 -2
- 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.
|
|
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.
|
|
64
|
-
"@hyperframes/
|
|
65
|
-
"@hyperframes/
|
|
66
|
-
"@hyperframes/
|
|
67
|
-
"@hyperframes/
|
|
68
|
-
"@hyperframes/
|
|
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.
|
|
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
|
-
?
|
|
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 = (
|
|
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);
|