@hyperframes/studio 0.8.87 → 0.8.88
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-CtNVImMi.js → hyperframes-player-w3Bbv0kd.js} +1 -1
- package/dist/assets/{index-B_MF86RE.js → index-B6PGJTzp.js} +1 -1
- package/dist/assets/{index-DZKcDAtf.js → index-DE6cZYR5.js} +175 -175
- package/dist/assets/{index-DJ-Ol7YS.js → index-DldknZPp.js} +1 -1
- package/dist/{chunk-7PLTC6SV.js → chunk-KU4F7QZ3.js} +9 -1
- package/dist/chunk-KU4F7QZ3.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.html +1 -1
- package/dist/index.js +164 -92
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/package.json +8 -8
- package/src/components/TimelineHistoryButtons.dom.test.tsx +14 -3
- package/src/components/TimelineToolPicker.tsx +90 -0
- package/src/components/TimelineToolbar.tsx +2 -27
- package/src/hooks/appHotkeysDispatch.ts +8 -0
- package/src/hooks/useAppHotkeys.test.ts +58 -1
- package/src/player/components/Timeline.showKeyframes.test.tsx +91 -0
- package/src/player/components/TimelineTypes.ts +1 -0
- package/src/player/components/studioShortcuts.ts +2 -0
- package/src/player/components/useTimelineKeyframeData.ts +14 -0
- package/src/player/components/useTimelineProviderState.tsx +5 -3
- package/src/player/store/playerStore.ts +16 -0
- package/dist/chunk-7PLTC6SV.js.map +0 -1
package/dist/player.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.88",
|
|
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/
|
|
64
|
-
"@hyperframes/
|
|
65
|
-
"@hyperframes/player": "0.8.
|
|
66
|
-
"@hyperframes/
|
|
67
|
-
"@hyperframes/
|
|
68
|
-
"@hyperframes/
|
|
63
|
+
"@hyperframes/parsers": "0.8.88",
|
|
64
|
+
"@hyperframes/core": "0.8.88",
|
|
65
|
+
"@hyperframes/player": "0.8.88",
|
|
66
|
+
"@hyperframes/studio-server": "0.8.88",
|
|
67
|
+
"@hyperframes/sdk": "0.8.88",
|
|
68
|
+
"@hyperframes/shader-transitions": "0.8.88"
|
|
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.88"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
87
|
"react": "19",
|
|
@@ -96,14 +96,25 @@ it("leaves enabled buttons without the disabled attribute", () => {
|
|
|
96
96
|
expect(el.className).not.toContain("cursor-not-allowed");
|
|
97
97
|
});
|
|
98
98
|
|
|
99
|
-
it("orders the toolbar Undo, Redo,
|
|
99
|
+
it("orders the toolbar Undo, Redo, then the tool picker showing the active tool", () => {
|
|
100
100
|
const host = mount(<TimelineToolbar />);
|
|
101
101
|
const labels = Array.from(host.querySelectorAll("button"))
|
|
102
102
|
.map((b) => b.getAttribute("aria-label"))
|
|
103
103
|
.filter((l) => l !== null)
|
|
104
|
-
.slice(0,
|
|
104
|
+
.slice(0, 3);
|
|
105
105
|
|
|
106
|
-
expect(labels).toEqual(["Undo", "Redo", "
|
|
106
|
+
expect(labels).toEqual(["Undo", "Redo", "Timeline tool: Select"]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("shows the Split tool on the picker while the razor is active", async () => {
|
|
110
|
+
const { usePlayerStore } = await import("../player");
|
|
111
|
+
usePlayerStore.setState({ activeTool: "razor" });
|
|
112
|
+
try {
|
|
113
|
+
const host = mount(<TimelineToolbar />);
|
|
114
|
+
expect(host.querySelector('button[aria-label="Timeline tool: Split"]')).not.toBeNull();
|
|
115
|
+
} finally {
|
|
116
|
+
usePlayerStore.setState({ activeTool: "select" });
|
|
117
|
+
}
|
|
107
118
|
});
|
|
108
119
|
|
|
109
120
|
it("classifies Undo and Redo for the hotkey filters at their new location (KTD13)", () => {
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { ArrowLineLeft, ArrowLineRight, CaretDown } from "@phosphor-icons/react";
|
|
3
|
+
import { usePlayerStore } from "../player";
|
|
4
|
+
import { Scissors } from "../icons/SystemIcons";
|
|
5
|
+
import { Menu, MenuItem } from "./ui";
|
|
6
|
+
import { cn } from "./ui/cn";
|
|
7
|
+
import { flatIdle } from "./timelineToolbarStyles";
|
|
8
|
+
|
|
9
|
+
function SelectIcon() {
|
|
10
|
+
return (
|
|
11
|
+
<svg width="16" height="16" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true">
|
|
12
|
+
<path d="M2 0.5L10 6L6.5 6.5L8.5 11L6.5 11.5L4.5 7L2 9Z" />
|
|
13
|
+
</svg>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ToolRow {
|
|
18
|
+
label: string;
|
|
19
|
+
shortcut: string;
|
|
20
|
+
icon: ReactNode;
|
|
21
|
+
active: boolean;
|
|
22
|
+
run: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** One button for the active timeline tool; its menu switches tools or selects clips around the playhead. */
|
|
26
|
+
export function TimelineToolPicker() {
|
|
27
|
+
const activeTool = usePlayerStore((s) => s.activeTool);
|
|
28
|
+
const store = () => usePlayerStore.getState();
|
|
29
|
+
const rows: ToolRow[] = [
|
|
30
|
+
{
|
|
31
|
+
label: "Select",
|
|
32
|
+
shortcut: "V",
|
|
33
|
+
icon: <SelectIcon />,
|
|
34
|
+
active: activeTool === "select",
|
|
35
|
+
run: () => store().setActiveTool("select"),
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
label: "Split",
|
|
39
|
+
shortcut: "B",
|
|
40
|
+
icon: <Scissors size={16} />,
|
|
41
|
+
active: activeTool === "razor",
|
|
42
|
+
run: () => store().setActiveTool("razor"),
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
label: "Select leftward",
|
|
46
|
+
shortcut: "[",
|
|
47
|
+
icon: <ArrowLineLeft size={16} aria-hidden="true" />,
|
|
48
|
+
active: false,
|
|
49
|
+
run: () => store().selectLeftward(),
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
label: "Select rightward",
|
|
53
|
+
shortcut: "]",
|
|
54
|
+
icon: <ArrowLineRight size={16} aria-hidden="true" />,
|
|
55
|
+
active: false,
|
|
56
|
+
run: () => store().selectRightward(),
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
const current = rows.find((row) => row.active) ?? rows[0];
|
|
60
|
+
return (
|
|
61
|
+
<Menu
|
|
62
|
+
aria-label="Timeline tools"
|
|
63
|
+
trigger={
|
|
64
|
+
<button
|
|
65
|
+
type="button"
|
|
66
|
+
aria-label={`Timeline tool: ${current.label}`}
|
|
67
|
+
className={cn(flatIdle, "gap-0.5")}
|
|
68
|
+
>
|
|
69
|
+
{current.icon}
|
|
70
|
+
<CaretDown size={10} weight="bold" aria-hidden="true" />
|
|
71
|
+
</button>
|
|
72
|
+
}
|
|
73
|
+
>
|
|
74
|
+
{rows.map((row) => (
|
|
75
|
+
<MenuItem
|
|
76
|
+
key={row.label}
|
|
77
|
+
shortcut={row.shortcut}
|
|
78
|
+
onClick={row.run}
|
|
79
|
+
data-active={row.active || undefined}
|
|
80
|
+
className={row.active ? "bg-neutral-800 text-white" : undefined}
|
|
81
|
+
>
|
|
82
|
+
<span className="flex items-center gap-2">
|
|
83
|
+
{row.icon}
|
|
84
|
+
{row.label}
|
|
85
|
+
</span>
|
|
86
|
+
</MenuItem>
|
|
87
|
+
))}
|
|
88
|
+
</Menu>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -26,7 +26,7 @@ import { AudioMetersIcon } from "./icons/AudioMetersIcon";
|
|
|
26
26
|
import { RippleEditIcon } from "./icons/RippleEditIcon";
|
|
27
27
|
import { flatActive, flatBtn, flatDisabled, flatIdle } from "./timelineToolbarStyles";
|
|
28
28
|
import { TimelineHistoryButtons, type TimelineHistoryButtonsProps } from "./TimelineHistoryButtons";
|
|
29
|
-
import {
|
|
29
|
+
import { TimelineToolPicker } from "./TimelineToolPicker";
|
|
30
30
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
31
31
|
import type { DomEditSelection } from "./editor/domEditingTypes";
|
|
32
32
|
import { canSplitElement } from "../utils/timelineElementSplit";
|
|
@@ -144,8 +144,6 @@ export function TimelineToolbar({
|
|
|
144
144
|
showAddBeat = true,
|
|
145
145
|
showKeyframes = true,
|
|
146
146
|
}: TimelineToolbarProps) {
|
|
147
|
-
const activeTool = usePlayerStore((s) => s.activeTool);
|
|
148
|
-
const setActiveTool = usePlayerStore((s) => s.setActiveTool);
|
|
149
147
|
const timelineSnapEnabled = usePlayerStore((s) => s.timelineSnapEnabled);
|
|
150
148
|
const setTimelineSnapEnabled = usePlayerStore((s) => s.setTimelineSnapEnabled);
|
|
151
149
|
const rippleEditEnabled = usePlayerStore((s) => s.rippleEditEnabled);
|
|
@@ -216,30 +214,7 @@ export function TimelineToolbar({
|
|
|
216
214
|
<div className="flex items-center justify-between px-2 py-0.5">
|
|
217
215
|
<div className="flex items-center gap-0.5">
|
|
218
216
|
<TimelineHistoryButtons {...history} />
|
|
219
|
-
<
|
|
220
|
-
<button
|
|
221
|
-
type="button"
|
|
222
|
-
onClick={() => setActiveTool("select")}
|
|
223
|
-
aria-label="Selection tool"
|
|
224
|
-
aria-pressed={activeTool === "select"}
|
|
225
|
-
className={activeTool === "select" ? flatActive : flatIdle}
|
|
226
|
-
>
|
|
227
|
-
<svg width="16" height="16" viewBox="0 0 12 12" fill="currentColor">
|
|
228
|
-
<path d="M2 0.5L10 6L6.5 6.5L8.5 11L6.5 11.5L4.5 7L2 9Z" />
|
|
229
|
-
</svg>
|
|
230
|
-
</button>
|
|
231
|
-
</Tooltip>
|
|
232
|
-
<Tooltip label="Razor tool (B) — Shift+click splits all tracks">
|
|
233
|
-
<button
|
|
234
|
-
type="button"
|
|
235
|
-
onClick={() => setActiveTool("razor")}
|
|
236
|
-
aria-label="Razor tool"
|
|
237
|
-
aria-pressed={activeTool === "razor"}
|
|
238
|
-
className={activeTool === "razor" ? flatActive : flatIdle}
|
|
239
|
-
>
|
|
240
|
-
<Scissors size={16} />
|
|
241
|
-
</button>
|
|
242
|
-
</Tooltip>
|
|
217
|
+
<TimelineToolPicker />
|
|
243
218
|
{/* Divider: tool-mode | editing-actions */}
|
|
244
219
|
<div aria-hidden="true" className="mx-1 h-4 w-px bg-neutral-800" />
|
|
245
220
|
<Tooltip label={timelineSnapEnabled ? "Snapping on (N)" : "Snapping off (N)"}>
|
|
@@ -207,6 +207,14 @@ export function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCa
|
|
|
207
207
|
return;
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
+
if ((key === "[" || key === "]") && !event.shiftKey && !event.altKey) {
|
|
211
|
+
event.preventDefault();
|
|
212
|
+
const store = usePlayerStore.getState();
|
|
213
|
+
if (key === "[") store.selectLeftward();
|
|
214
|
+
else store.selectRightward();
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
210
218
|
// CapCut returns to the select tool with "A". Scoped to the razor being
|
|
211
219
|
// armed; usePlaybackKeyboard.ts's own "A" (seek to in-point) checks the
|
|
212
220
|
// same activeTool to stay out of the way.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
3
|
import { dispatchModifierKey, dispatchPlainKey, type HotkeyCallbacks } from "./appHotkeysDispatch";
|
|
4
|
-
import { usePlayerStore } from "../player/store/playerStore";
|
|
4
|
+
import { liveTime, usePlayerStore } from "../player/store/playerStore";
|
|
5
5
|
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
6
6
|
import { clearAutomationClipboard, copyRange } from "../player/components/automationClipboard";
|
|
7
7
|
import { VOLUME_RANGE } from "@hyperframes/core/audio-automation";
|
|
@@ -60,6 +60,63 @@ afterEach(() => {
|
|
|
60
60
|
});
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
describe("dispatchPlainKey — select leftward / rightward", () => {
|
|
64
|
+
const clips = [
|
|
65
|
+
{ ...bgmElement, id: "early", key: "early", start: 0, track: 0 },
|
|
66
|
+
{ ...bgmElement, id: "at", key: "at", start: 4, track: 1 },
|
|
67
|
+
{ ...bgmElement, id: "late", key: "late", start: 7, track: 2 },
|
|
68
|
+
];
|
|
69
|
+
beforeEach(() => usePlayerStore.setState({ elements: clips, currentTime: 4 }));
|
|
70
|
+
|
|
71
|
+
it("[ selects every clip starting before the playhead, on every track", () => {
|
|
72
|
+
const event = press("[");
|
|
73
|
+
dispatchPlainKey(event, "[", callbacks());
|
|
74
|
+
expect([...usePlayerStore.getState().selectedElementIds]).toEqual(["early"]);
|
|
75
|
+
expect(usePlayerStore.getState().selectedElementId).toBe("early");
|
|
76
|
+
expect(event.defaultPrevented).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("uses the live playhead while playing, not the time stored at play start", () => {
|
|
80
|
+
usePlayerStore.setState({ isPlaying: true, currentTime: 0 });
|
|
81
|
+
liveTime.notify(8);
|
|
82
|
+
try {
|
|
83
|
+
dispatchPlainKey(press("["), "[", callbacks());
|
|
84
|
+
expect([...usePlayerStore.getState().selectedElementIds].sort()).toEqual([
|
|
85
|
+
"at",
|
|
86
|
+
"early",
|
|
87
|
+
"late",
|
|
88
|
+
]);
|
|
89
|
+
} finally {
|
|
90
|
+
usePlayerStore.setState({ isPlaying: false });
|
|
91
|
+
liveTime.notify(0);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("clears a clicked keyframe like any other selection change", () => {
|
|
96
|
+
usePlayerStore.setState({ activeKeyframePct: 50 });
|
|
97
|
+
dispatchPlainKey(press("]"), "]", callbacks());
|
|
98
|
+
expect(usePlayerStore.getState().activeKeyframePct).toBeNull();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("] selects every clip starting at or after the playhead, on every track", () => {
|
|
102
|
+
dispatchPlainKey(press("]"), "]", callbacks());
|
|
103
|
+
const { selectedElementIds, selectedElementId } = usePlayerStore.getState();
|
|
104
|
+
expect([...selectedElementIds].sort()).toEqual(["at", "late"]);
|
|
105
|
+
expect(selectedElementId).toBe("at");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("selects nothing when no clip is on that side", () => {
|
|
109
|
+
usePlayerStore.setState({
|
|
110
|
+
currentTime: 0,
|
|
111
|
+
selectedElementId: "late",
|
|
112
|
+
selectedElementIds: new Set(["late"]),
|
|
113
|
+
});
|
|
114
|
+
dispatchPlainKey(press("["), "[", callbacks());
|
|
115
|
+
expect(usePlayerStore.getState().selectedElementIds.size).toBe(0);
|
|
116
|
+
expect(usePlayerStore.getState().selectedElementId).toBeNull();
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
63
120
|
describe("dispatchPlainKey — Delete arbitration", () => {
|
|
64
121
|
const selectBgm = () =>
|
|
65
122
|
usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { Timeline } from "./Timeline";
|
|
7
|
+
import { installTimelineMountEnv } from "./timelineMountTestEnv";
|
|
8
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
9
|
+
|
|
10
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
11
|
+
|
|
12
|
+
installTimelineMountEnv();
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
document.body.innerHTML = "";
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
async function mountKeyframed(showKeyframes?: boolean, withLanes = true) {
|
|
19
|
+
usePlayerStore.setState({
|
|
20
|
+
duration: 10,
|
|
21
|
+
currentTime: 0,
|
|
22
|
+
timelineReady: true,
|
|
23
|
+
selectedElementId: "card",
|
|
24
|
+
elements: [{ id: "card", label: "Hero card", tag: "div", start: 0, duration: 4, track: 0 }],
|
|
25
|
+
gsapAnimations: withLanes
|
|
26
|
+
? new Map([
|
|
27
|
+
[
|
|
28
|
+
"card",
|
|
29
|
+
[
|
|
30
|
+
{
|
|
31
|
+
id: "card-position",
|
|
32
|
+
targetSelector: "#card",
|
|
33
|
+
method: "to",
|
|
34
|
+
position: 0,
|
|
35
|
+
duration: 2,
|
|
36
|
+
properties: {},
|
|
37
|
+
propertyGroup: "position",
|
|
38
|
+
keyframes: {
|
|
39
|
+
format: "percentage",
|
|
40
|
+
keyframes: [
|
|
41
|
+
{ percentage: 0, properties: { x: 0 } },
|
|
42
|
+
{ percentage: 50, properties: { x: 100 } },
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
],
|
|
48
|
+
])
|
|
49
|
+
: new Map(),
|
|
50
|
+
keyframeCache: new Map([
|
|
51
|
+
[
|
|
52
|
+
"card",
|
|
53
|
+
{
|
|
54
|
+
format: "percentage",
|
|
55
|
+
keyframes: [{ percentage: 50, properties: { x: 100 }, tweenPercentage: 50 }],
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
]),
|
|
59
|
+
});
|
|
60
|
+
const host = document.createElement("div");
|
|
61
|
+
document.body.append(host);
|
|
62
|
+
const root = createRoot(host);
|
|
63
|
+
await act(async () => root.render(<Timeline showKeyframes={showKeyframes} />));
|
|
64
|
+
const diamonds = host.querySelectorAll('button[title="50%"]').length;
|
|
65
|
+
const named = Array.from(host.querySelectorAll("[role=rowheader]")).some((h) =>
|
|
66
|
+
h.textContent?.includes("Hero card"),
|
|
67
|
+
);
|
|
68
|
+
const lanes = host.textContent?.includes("Position") ?? false;
|
|
69
|
+
act(() => root.unmount());
|
|
70
|
+
return { diamonds, named, lanes };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe("Timeline showKeyframes", () => {
|
|
74
|
+
it("draws a keyframed clip's diamonds by default", async () => {
|
|
75
|
+
expect((await mountKeyframed(undefined, false)).diamonds).toBeGreaterThan(0);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("draws no keyframe diamonds when the host turns keyframes off", async () => {
|
|
79
|
+
expect((await mountKeyframed(false, false)).diamonds).toBe(0);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("draws the keyframe lanes by default and none when keyframes are off", async () => {
|
|
83
|
+
expect((await mountKeyframed()).lanes).toBe(true);
|
|
84
|
+
expect((await mountKeyframed(false)).lanes).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("keeps each track's name in the header when keyframes are off", async () => {
|
|
88
|
+
expect((await mountKeyframed()).named).toBe(true);
|
|
89
|
+
expect((await mountKeyframed(false)).named).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -52,6 +52,8 @@ export const DEFAULT_SHORTCUT_SECTIONS: readonly ShortcutSection[] = [
|
|
|
52
52
|
{ key: "⌘X", label: "Cut element" },
|
|
53
53
|
{ key: hintKey(STUDIO_PLAIN_KEYS.split), label: "Split clip at playhead" },
|
|
54
54
|
{ key: "⇧Click", label: "Razor tool: split all tracks" },
|
|
55
|
+
{ key: "[", label: "Select clips starting before the playhead" },
|
|
56
|
+
{ key: "]", label: "Select clips starting at or after the playhead" },
|
|
55
57
|
{ key: "⌘G", label: "Group elements" },
|
|
56
58
|
{ key: "⌘⇧G", label: "Ungroup" },
|
|
57
59
|
{ key: "Del", label: "Delete selected element (no keyframe selected)" },
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { usePlayerStore, type KeyframeCacheEntry } from "../store/playerStore";
|
|
2
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
3
|
+
|
|
4
|
+
const HIDDEN = {
|
|
5
|
+
gsapAnimations: new Map<string, GsapAnimation[]>(),
|
|
6
|
+
keyframeCache: new Map<string, KeyframeCacheEntry>(),
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/** The keyframe data the timeline draws, or none when the host hides keyframes. */
|
|
10
|
+
export function useTimelineKeyframeData(showKeyframes: boolean) {
|
|
11
|
+
const gsapAnimations = usePlayerStore((s) => s.gsapAnimations);
|
|
12
|
+
const keyframeCache = usePlayerStore((s) => s.keyframeCache);
|
|
13
|
+
return showKeyframes ? { gsapAnimations, keyframeCache } : HIDDEN;
|
|
14
|
+
}
|
|
@@ -21,6 +21,7 @@ import { useTimelineGeometry } from "./useTimelineGeometry";
|
|
|
21
21
|
import { useAutoExpandKeyframedClips } from "./useAutoExpandKeyframedClips";
|
|
22
22
|
import { GUTTER, LABEL_COL_W } from "./timelineLayout";
|
|
23
23
|
import { useTimelineLabelColumn } from "./useTimelineLabelColumn";
|
|
24
|
+
import { useTimelineKeyframeData } from "./useTimelineKeyframeData";
|
|
24
25
|
import { useTimelineScrollViewport } from "./useTimelineScrollViewport";
|
|
25
26
|
import { ClipContentOnceShown } from "./timelineClipChildren";
|
|
26
27
|
import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks";
|
|
@@ -68,6 +69,7 @@ export function useTimelineProviderState({
|
|
|
68
69
|
canPasteClip,
|
|
69
70
|
theme: themeOverrides,
|
|
70
71
|
showAudioEffects = true,
|
|
72
|
+
showKeyframes = true,
|
|
71
73
|
sessionEpoch = 0,
|
|
72
74
|
previewIframeRef,
|
|
73
75
|
onZIndexReorder,
|
|
@@ -104,8 +106,9 @@ export function useTimelineProviderState({
|
|
|
104
106
|
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
|
|
105
107
|
const selectedElementIds = usePlayerStore((s) => s.selectedElementIds);
|
|
106
108
|
const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
|
|
107
|
-
const gsapAnimations =
|
|
108
|
-
const
|
|
109
|
+
const { gsapAnimations, keyframeCache } = useTimelineKeyframeData(showKeyframes);
|
|
110
|
+
const namedAnimations = usePlayerStore((s) => s.gsapAnimations);
|
|
111
|
+
const { labelMode, contentOrigin } = useTimelineLabelColumn(namedAnimations, timelineElements);
|
|
109
112
|
const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
|
|
110
113
|
const currentTime = usePlayerStore((s) => s.currentTime);
|
|
111
114
|
const beatDragging = usePlayerStore((s) => s.beatDragging);
|
|
@@ -130,7 +133,6 @@ export function useTimelineProviderState({
|
|
|
130
133
|
() => getEffectiveTimelineDuration(duration, timelineElements),
|
|
131
134
|
[duration, timelineElements],
|
|
132
135
|
);
|
|
133
|
-
const keyframeCache = usePlayerStore((s) => s.keyframeCache);
|
|
134
136
|
useAutoExpandKeyframedClips(gsapAnimations);
|
|
135
137
|
const {
|
|
136
138
|
tracks,
|
|
@@ -23,6 +23,7 @@ import { createRangeSelectionSlice, type RangeSelectionSlice } from "./rangeSele
|
|
|
23
23
|
import { createTimelineResetState } from "./timelineResetState";
|
|
24
24
|
export type { KeyframeCacheEntry } from "./keyframeSlice";
|
|
25
25
|
export { liveTime } from "./liveTime";
|
|
26
|
+
import { liveTime } from "./liveTime";
|
|
26
27
|
export { createTimelineResetState };
|
|
27
28
|
|
|
28
29
|
import type {
|
|
@@ -96,6 +97,8 @@ interface PlayerState extends PlayerStoreSlices {
|
|
|
96
97
|
|
|
97
98
|
activeTool: TimelineTool;
|
|
98
99
|
setActiveTool: (tool: TimelineTool) => void;
|
|
100
|
+
selectLeftward: () => void;
|
|
101
|
+
selectRightward: () => void;
|
|
99
102
|
|
|
100
103
|
/** Tween-relative percentage of the last-clicked keyframe diamond. Operations
|
|
101
104
|
* (drag, resize, rotate) target this instead of recomputing from playhead. */
|
|
@@ -250,6 +253,17 @@ interface BeatHistoryEntry {
|
|
|
250
253
|
label: string;
|
|
251
254
|
}
|
|
252
255
|
|
|
256
|
+
/** Selects like the marquee: the primary first, so its resets run, then the whole set. */
|
|
257
|
+
function selectAroundPlayhead(
|
|
258
|
+
state: PlayerState,
|
|
259
|
+
keep: (start: number, playhead: number) => boolean,
|
|
260
|
+
): void {
|
|
261
|
+
const playhead = state.isPlaying ? liveTime.latest() : state.currentTime;
|
|
262
|
+
const ids = state.elements.filter((el) => keep(el.start, playhead)).map((el) => el.key ?? el.id);
|
|
263
|
+
state.setSelectedElementId(ids[0] ?? null);
|
|
264
|
+
state.setSelectedElementIds(new Set(ids));
|
|
265
|
+
}
|
|
266
|
+
|
|
253
267
|
export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
254
268
|
isPlaying: false,
|
|
255
269
|
currentTime: 0,
|
|
@@ -273,6 +287,8 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
273
287
|
|
|
274
288
|
activeTool: "select",
|
|
275
289
|
setActiveTool: (tool) => set({ activeTool: tool }),
|
|
290
|
+
selectLeftward: () => selectAroundPlayhead(get(), (start, playhead) => start < playhead),
|
|
291
|
+
selectRightward: () => selectAroundPlayhead(get(), (start, playhead) => start >= playhead),
|
|
276
292
|
|
|
277
293
|
...createKeyframeSlice(set, () => ({
|
|
278
294
|
timelineProjectId: get().timelineProjectId,
|