@hyperframes/studio 0.8.87 → 0.8.89
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-CVoS7kv8.js} +1 -1
- package/dist/assets/{index-DZKcDAtf.js → index-BM8PwYby.js} +187 -187
- package/dist/assets/{index-DJ-Ol7YS.js → index-D35-L_yn.js} +1 -1
- package/dist/assets/{index-B_MF86RE.js → index-DBub9qQY.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 +42 -5
- package/dist/index.html +1 -1
- package/dist/index.js +269 -94
- 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/components/editor/DomEditOverlay.tsx +7 -4
- package/src/components/editor/domEditOverlayGestures.ts +4 -4
- package/src/components/editor/useDomEditNudge.ts +2 -2
- package/src/hooks/appHotkeysDispatch.ts +8 -0
- package/src/hooks/useAppHotkeys.test.ts +58 -1
- package/src/hooks/useDomGeometryCommit.test.tsx +226 -0
- package/src/hooks/useDomGeometryCommit.ts +147 -0
- package/src/hooks/useGsapAwareEditing.ts +9 -4
- package/src/index.ts +3 -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
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { useCallback, useMemo, useRef } from "react";
|
|
2
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
3
|
+
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
4
|
+
import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
|
|
5
|
+
import { isPreviewBooted } from "../player/store/playerStore";
|
|
6
|
+
import type { DomEditCommitOutcome } from "./domEditCommitRunner";
|
|
7
|
+
import type { UseDomStyleCommitOptions } from "./useDomStyleCommit";
|
|
8
|
+
import { useGsapAnimationFetchFallback } from "./useGsapAnimationFetchFallback";
|
|
9
|
+
import { useGsapAwareEditing } from "./useGsapAwareEditing";
|
|
10
|
+
import { useGsapInteractionFailureTelemetry } from "./useGsapInteractionFailureTelemetry";
|
|
11
|
+
import { useGsapScriptCommits } from "./useGsapScriptCommits";
|
|
12
|
+
import { useGsapCacheVersion } from "./useGsapTweenCache";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Studio's `Player` must show the project, with `beginTimelineSession(projectId)` run before it
|
|
16
|
+
* mounts; drain `waitForPendingSaves` before switching projects.
|
|
17
|
+
*/
|
|
18
|
+
export interface UseDomGeometryCommitOptions extends UseDomStyleCommitOptions {
|
|
19
|
+
/** Called when a save cannot patch the preview in place; defaults to reloading the iframe. */
|
|
20
|
+
reloadPreview?: () => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface DomGeometryCommits {
|
|
24
|
+
commitPathOffset: (
|
|
25
|
+
selection: DomEditSelection,
|
|
26
|
+
next: { x: number; y: number },
|
|
27
|
+
modifiers?: { altKey?: boolean },
|
|
28
|
+
) => Promise<DomEditCommitOutcome>;
|
|
29
|
+
commitGroupPathOffset: (updates: DomEditGroupPathOffsetCommit[]) => Promise<DomEditCommitOutcome>;
|
|
30
|
+
commitBoxSize: (
|
|
31
|
+
selection: DomEditSelection,
|
|
32
|
+
next: { width: number; height: number },
|
|
33
|
+
offset?: { x: number; y: number },
|
|
34
|
+
restore?: () => void,
|
|
35
|
+
) => Promise<DomEditCommitOutcome>;
|
|
36
|
+
commitRotation: (
|
|
37
|
+
selection: DomEditSelection,
|
|
38
|
+
next: { angle: number },
|
|
39
|
+
) => Promise<DomEditCommitOutcome>;
|
|
40
|
+
waitForPendingSaves: () => Promise<void>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const noop = () => {};
|
|
44
|
+
const NO_SELECTED_ANIMATIONS: GsapAnimation[] = [];
|
|
45
|
+
// ponytail: unreachable, the GSAP writer always exists so a resize never takes the DOM route.
|
|
46
|
+
const noDomBoxSizeRoute = () => Promise.reject(new Error("Resize has no DOM route here"));
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Saves canvas moves, resizes and rotations through Studio's own GSAP-aware commits, for a host
|
|
50
|
+
* without the editor. A failed save rejects, so DomEditOverlay undoes the live gesture.
|
|
51
|
+
*/
|
|
52
|
+
export function useDomGeometryCommit({
|
|
53
|
+
projectId,
|
|
54
|
+
iframeRef,
|
|
55
|
+
writeProjectFile,
|
|
56
|
+
recordEdit,
|
|
57
|
+
activeCompPath = null,
|
|
58
|
+
showToast = noop,
|
|
59
|
+
reloadPreview,
|
|
60
|
+
}: UseDomGeometryCommitOptions): DomGeometryCommits {
|
|
61
|
+
const projectIdRef = useRef(projectId);
|
|
62
|
+
projectIdRef.current = projectId;
|
|
63
|
+
const pending = useRef(new Set<Promise<void>>()).current;
|
|
64
|
+
const editHistory = useMemo(() => ({ recordEdit }), [recordEdit]);
|
|
65
|
+
const reload = useCallback(
|
|
66
|
+
() => (reloadPreview ? reloadPreview() : iframeRef.current?.contentWindow?.location.reload()),
|
|
67
|
+
[reloadPreview, iframeRef],
|
|
68
|
+
);
|
|
69
|
+
const { bump: bumpGsapCache } = useGsapCacheVersion();
|
|
70
|
+
const gsap = useGsapScriptCommits({
|
|
71
|
+
projectIdRef,
|
|
72
|
+
activeCompPath,
|
|
73
|
+
previewIframeRef: iframeRef,
|
|
74
|
+
editHistory,
|
|
75
|
+
reloadPreview: reload,
|
|
76
|
+
onCacheInvalidate: bumpGsapCache,
|
|
77
|
+
showToast,
|
|
78
|
+
writeProjectFile,
|
|
79
|
+
});
|
|
80
|
+
const makeFetchFallback = useGsapAnimationFetchFallback(projectId);
|
|
81
|
+
const trackGsapInteractionFailure = useGsapInteractionFailureTelemetry(activeCompPath, showToast);
|
|
82
|
+
const {
|
|
83
|
+
handleGsapAwarePathOffsetCommit,
|
|
84
|
+
handleGsapAwareGroupPathOffsetCommit,
|
|
85
|
+
handleGsapAwareBoxSizeCommit,
|
|
86
|
+
handleGsapAwareRotationCommit,
|
|
87
|
+
} = useGsapAwareEditing({
|
|
88
|
+
// The host owns selection, so every gesture reads its element's animations from the server.
|
|
89
|
+
domEditSelection: null,
|
|
90
|
+
selectedGsapAnimations: NO_SELECTED_ANIMATIONS,
|
|
91
|
+
gsapCommitMutation: gsap.commitMutation,
|
|
92
|
+
previewIframeRef: iframeRef,
|
|
93
|
+
showToast,
|
|
94
|
+
bumpGsapCache,
|
|
95
|
+
makeFetchFallback,
|
|
96
|
+
trackGsapInteractionFailure,
|
|
97
|
+
handleDomBoxSizeCommit: noDomBoxSizeRoute,
|
|
98
|
+
addGsapAnimation: gsap.addGsapAnimation,
|
|
99
|
+
convertToKeyframes: gsap.convertToKeyframes,
|
|
100
|
+
setArcPath: gsap.setArcPath,
|
|
101
|
+
updateArcSegment: gsap.updateArcSegment,
|
|
102
|
+
});
|
|
103
|
+
const saved = useCallback(
|
|
104
|
+
async (commit: () => Promise<void>, restore = noop): Promise<DomEditCommitOutcome> => {
|
|
105
|
+
const refusal = !projectIdRef.current
|
|
106
|
+
? "No project is open"
|
|
107
|
+
: !isPreviewBooted(projectIdRef.current)
|
|
108
|
+
? "The preview for this project has not loaded yet"
|
|
109
|
+
: null;
|
|
110
|
+
if (refusal) {
|
|
111
|
+
restore();
|
|
112
|
+
showToast(refusal, "error");
|
|
113
|
+
throw new Error(refusal);
|
|
114
|
+
}
|
|
115
|
+
const run = commit();
|
|
116
|
+
pending.add(run);
|
|
117
|
+
try {
|
|
118
|
+
await run;
|
|
119
|
+
} finally {
|
|
120
|
+
pending.delete(run);
|
|
121
|
+
}
|
|
122
|
+
return { ok: true };
|
|
123
|
+
},
|
|
124
|
+
[pending, showToast],
|
|
125
|
+
);
|
|
126
|
+
return useMemo(
|
|
127
|
+
() => ({
|
|
128
|
+
commitPathOffset: (selection, next, modifiers) =>
|
|
129
|
+
saved(() => handleGsapAwarePathOffsetCommit(selection, next, modifiers)),
|
|
130
|
+
commitGroupPathOffset: (updates) =>
|
|
131
|
+
saved(() => handleGsapAwareGroupPathOffsetCommit(updates)),
|
|
132
|
+
commitBoxSize: (selection, next, offset, restore) =>
|
|
133
|
+
saved(() => handleGsapAwareBoxSizeCommit(selection, next, offset, restore), restore),
|
|
134
|
+
commitRotation: (selection, next) =>
|
|
135
|
+
saved(() => handleGsapAwareRotationCommit(selection, next)),
|
|
136
|
+
waitForPendingSaves: () => Promise.allSettled([...pending]).then(noop),
|
|
137
|
+
}),
|
|
138
|
+
[
|
|
139
|
+
saved,
|
|
140
|
+
pending,
|
|
141
|
+
handleGsapAwarePathOffsetCommit,
|
|
142
|
+
handleGsapAwareGroupPathOffsetCommit,
|
|
143
|
+
handleGsapAwareBoxSizeCommit,
|
|
144
|
+
handleGsapAwareRotationCommit,
|
|
145
|
+
],
|
|
146
|
+
);
|
|
147
|
+
}
|
|
@@ -298,10 +298,15 @@ export function useGsapAwareEditing({
|
|
|
298
298
|
offset?: { x: number; y: number },
|
|
299
299
|
restore: () => void = () => undefined,
|
|
300
300
|
) => {
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
: await ownedAnimations;
|
|
301
|
+
let targetAnimations: GsapAnimation[];
|
|
302
|
+
try {
|
|
303
|
+
const ownedAnimations = getGsapAnimationsForSelection(selection);
|
|
304
|
+
targetAnimations = Array.isArray(ownedAnimations) ? ownedAnimations : await ownedAnimations;
|
|
305
|
+
} catch (error) {
|
|
306
|
+
restore();
|
|
307
|
+
trackGsapInteractionFailure(error, selection, "resize", "Resize animated layer");
|
|
308
|
+
throw error;
|
|
309
|
+
}
|
|
305
310
|
const scaleRoute = targetAnimations.some((anim) => anim.propertyGroup === "scale");
|
|
306
311
|
const selector = selectorFromSelection(selection);
|
|
307
312
|
const hasLivePositionTween = selector
|
package/src/index.ts
CHANGED
|
@@ -167,7 +167,7 @@ export type { AudioMeterStripProps } from "./components/nle/AudioMeterStrip";
|
|
|
167
167
|
export { useClipboard } from "./hooks/useClipboard";
|
|
168
168
|
export type { UseClipboardOptions } from "./hooks/useClipboard";
|
|
169
169
|
|
|
170
|
-
// DOM editing for a host outside EditorShell;
|
|
170
|
+
// DOM editing for a host outside EditorShell; the Commit hooks save without useDomEditSession.
|
|
171
171
|
export { useDomEditSession } from "./hooks/useDomEditSession";
|
|
172
172
|
export type { UseDomEditSessionParams } from "./hooks/useDomEditSession";
|
|
173
173
|
export { usePreviewPersistence } from "./hooks/usePreviewPersistence";
|
|
@@ -181,6 +181,8 @@ export { useDomStyleCommit } from "./hooks/useDomStyleCommit";
|
|
|
181
181
|
export type { UseDomStyleCommitOptions } from "./hooks/useDomStyleCommit";
|
|
182
182
|
export type { DomEditCommitDeclineReason, DomEditCommitOutcome } from "./hooks/domEditCommitRunner";
|
|
183
183
|
export { resolveDomEditSelection } from "./components/editor/domEditingLayers";
|
|
184
|
+
export { useDomGeometryCommit } from "./hooks/useDomGeometryCommit";
|
|
185
|
+
export type { DomGeometryCommits, UseDomGeometryCommitOptions } from "./hooks/useDomGeometryCommit";
|
|
184
186
|
export { DomEditOverlay } from "./components/editor/DomEditOverlay";
|
|
185
187
|
export type {
|
|
186
188
|
DomEditGroupPathOffsetCommit,
|
|
@@ -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,
|