@hyperframes/studio 0.6.72 → 0.6.74
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/index-BcJO6Ej5.js +140 -0
- package/dist/assets/index-C2gBZ2km.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +30 -24
- package/src/components/StudioPreviewArea.tsx +101 -26
- package/src/components/StudioRightPanel.tsx +3 -0
- package/src/components/StudioToast.tsx +18 -0
- package/src/components/TimelineToolbar.tsx +230 -4
- package/src/components/editor/AnimationCard.tsx +68 -4
- package/src/components/editor/DomEditOverlay.tsx +70 -1
- package/src/components/editor/GridOverlay.tsx +50 -0
- package/src/components/editor/KeyframeDiamond.tsx +49 -0
- package/src/components/editor/KeyframeNavigation.tsx +139 -0
- package/src/components/editor/PropertyPanel.tsx +293 -140
- package/src/components/editor/SnapGuideOverlay.tsx +166 -0
- package/src/components/editor/SnapToolbar.tsx +163 -0
- package/src/components/editor/SpringEaseEditor.tsx +256 -0
- package/src/components/editor/domEditOverlayGestures.ts +7 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +28 -0
- package/src/components/editor/gsapAnimationConstants.ts +42 -0
- package/src/components/editor/gsapAnimationHelpers.ts +2 -1
- package/src/components/editor/manualEditingAvailability.ts +6 -0
- package/src/components/editor/manualEditsDom.ts +56 -2
- package/src/components/editor/manualOffsetDrag.ts +19 -3
- package/src/components/editor/propertyPanelHelpers.ts +90 -0
- package/src/components/editor/propertyPanelTimingSection.tsx +64 -0
- package/src/components/editor/snapEngine.test.ts +657 -0
- package/src/components/editor/snapEngine.ts +575 -0
- package/src/components/editor/snapTargetCollection.ts +147 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +137 -10
- package/src/components/nle/NLELayout.tsx +18 -0
- package/src/contexts/DomEditContext.tsx +24 -0
- package/src/hooks/gsapRuntimeBridge.ts +585 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +170 -0
- package/src/hooks/useAnimatedPropertyCommit.ts +131 -0
- package/src/hooks/useAppHotkeys.ts +63 -1
- package/src/hooks/useDomEditCommits.ts +39 -4
- package/src/hooks/useDomEditSession.ts +177 -63
- package/src/hooks/useGsapScriptCommits.ts +144 -7
- package/src/hooks/useGsapSelectionHandlers.ts +202 -0
- package/src/hooks/useGsapTweenCache.ts +174 -3
- package/src/hooks/useTimelineEditing.ts +93 -0
- package/src/icons/SystemIcons.tsx +2 -0
- package/src/player/components/ClipContextMenu.tsx +99 -0
- package/src/player/components/KeyframeDiamondContextMenu.tsx +164 -0
- package/src/player/components/Timeline.test.ts +2 -1
- package/src/player/components/Timeline.tsx +108 -68
- package/src/player/components/TimelineCanvas.tsx +47 -1
- package/src/player/components/TimelineClip.tsx +8 -3
- package/src/player/components/TimelineClipDiamonds.tsx +174 -0
- package/src/player/components/timelineDragDrop.ts +103 -0
- package/src/player/components/timelineLayout.ts +1 -1
- package/src/player/store/playerStore.ts +42 -0
- package/src/utils/editHistory.ts +1 -1
- package/src/utils/optimisticUpdate.test.ts +53 -0
- package/src/utils/optimisticUpdate.ts +18 -0
- package/src/utils/studioUiPreferences.ts +17 -0
- package/dist/assets/index-CrxThtSJ.css +0 -1
- package/dist/assets/index-CveQve6o.js +0 -140
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { memo, useCallback, useEffect, useRef } from "react";
|
|
2
|
+
import { EASE_LABELS } from "../../components/editor/gsapAnimationConstants";
|
|
3
|
+
|
|
4
|
+
export interface KeyframeDiamondContextMenuState {
|
|
5
|
+
x: number;
|
|
6
|
+
y: number;
|
|
7
|
+
elementId: string;
|
|
8
|
+
percentage: number;
|
|
9
|
+
currentEase?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface KeyframeDiamondContextMenuProps {
|
|
13
|
+
state: KeyframeDiamondContextMenuState;
|
|
14
|
+
onClose: () => void;
|
|
15
|
+
onDelete: (elementId: string, percentage: number) => void;
|
|
16
|
+
onDeleteAll: (elementId: string) => void;
|
|
17
|
+
onChangeEase: (elementId: string, percentage: number, ease: string) => void;
|
|
18
|
+
onCopyProperties: (elementId: string, percentage: number) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const EASE_PRESETS = [
|
|
22
|
+
"none",
|
|
23
|
+
"power1.out",
|
|
24
|
+
"power2.out",
|
|
25
|
+
"power3.out",
|
|
26
|
+
"power1.in",
|
|
27
|
+
"power2.in",
|
|
28
|
+
"power1.inOut",
|
|
29
|
+
"power2.inOut",
|
|
30
|
+
"back.out",
|
|
31
|
+
"elastic.out",
|
|
32
|
+
"bounce.out",
|
|
33
|
+
"expo.out",
|
|
34
|
+
] as const;
|
|
35
|
+
|
|
36
|
+
export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMenu({
|
|
37
|
+
state,
|
|
38
|
+
onClose,
|
|
39
|
+
onDelete,
|
|
40
|
+
onDeleteAll,
|
|
41
|
+
onChangeEase,
|
|
42
|
+
onCopyProperties,
|
|
43
|
+
}: KeyframeDiamondContextMenuProps) {
|
|
44
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
45
|
+
const easeSubmenuRef = useRef<HTMLDivElement>(null);
|
|
46
|
+
|
|
47
|
+
const dismiss = useCallback(
|
|
48
|
+
(e: MouseEvent | KeyboardEvent) => {
|
|
49
|
+
if (e instanceof KeyboardEvent && e.key !== "Escape") return;
|
|
50
|
+
if (e instanceof MouseEvent && menuRef.current?.contains(e.target as Node)) return;
|
|
51
|
+
onClose();
|
|
52
|
+
},
|
|
53
|
+
[onClose],
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
document.addEventListener("mousedown", dismiss);
|
|
58
|
+
document.addEventListener("keydown", dismiss);
|
|
59
|
+
return () => {
|
|
60
|
+
document.removeEventListener("mousedown", dismiss);
|
|
61
|
+
document.removeEventListener("keydown", dismiss);
|
|
62
|
+
};
|
|
63
|
+
}, [dismiss]);
|
|
64
|
+
|
|
65
|
+
const adjustedX = Math.min(state.x, window.innerWidth - 200);
|
|
66
|
+
const adjustedY = Math.min(state.y, window.innerHeight - 300);
|
|
67
|
+
|
|
68
|
+
const currentEaseLabel = state.currentEase
|
|
69
|
+
? (EASE_LABELS[state.currentEase] ?? state.currentEase)
|
|
70
|
+
: "Default";
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div
|
|
74
|
+
ref={menuRef}
|
|
75
|
+
className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
|
|
76
|
+
style={{ left: adjustedX, top: adjustedY }}
|
|
77
|
+
>
|
|
78
|
+
{/* Ease submenu */}
|
|
79
|
+
<div className="relative group">
|
|
80
|
+
<button
|
|
81
|
+
type="button"
|
|
82
|
+
className="w-full flex items-center justify-between px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 cursor-pointer text-left"
|
|
83
|
+
>
|
|
84
|
+
<span>
|
|
85
|
+
Ease: <span className="text-neutral-500">{currentEaseLabel}</span>
|
|
86
|
+
</span>
|
|
87
|
+
<svg width="8" height="8" viewBox="0 0 8 8" className="text-neutral-500 ml-2">
|
|
88
|
+
<path d="M3 1l4 3-4 3" fill="none" stroke="currentColor" strokeWidth="1.2" />
|
|
89
|
+
</svg>
|
|
90
|
+
</button>
|
|
91
|
+
<div
|
|
92
|
+
ref={easeSubmenuRef}
|
|
93
|
+
className="absolute left-full top-0 ml-0.5 hidden group-hover:block bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[160px] max-h-[300px] overflow-y-auto"
|
|
94
|
+
>
|
|
95
|
+
{EASE_PRESETS.map((ease) => (
|
|
96
|
+
<button
|
|
97
|
+
key={ease}
|
|
98
|
+
type="button"
|
|
99
|
+
className={`w-full flex items-center gap-2 px-3 py-1.5 text-xs hover:bg-neutral-800 cursor-pointer text-left ${
|
|
100
|
+
ease === state.currentEase ? "text-white font-medium" : "text-neutral-300"
|
|
101
|
+
}`}
|
|
102
|
+
onClick={() => {
|
|
103
|
+
onChangeEase(state.elementId, state.percentage, ease);
|
|
104
|
+
onClose();
|
|
105
|
+
}}
|
|
106
|
+
>
|
|
107
|
+
{ease === state.currentEase && (
|
|
108
|
+
<svg
|
|
109
|
+
width="8"
|
|
110
|
+
height="8"
|
|
111
|
+
viewBox="0 0 8 8"
|
|
112
|
+
className="text-green-400 flex-shrink-0"
|
|
113
|
+
>
|
|
114
|
+
<path d="M1 4l2 2 4-4" fill="none" stroke="currentColor" strokeWidth="1.5" />
|
|
115
|
+
</svg>
|
|
116
|
+
)}
|
|
117
|
+
<span className={ease === state.currentEase ? "" : "ml-[16px]"}>
|
|
118
|
+
{EASE_LABELS[ease] ?? ease}
|
|
119
|
+
</span>
|
|
120
|
+
</button>
|
|
121
|
+
))}
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
{/* Separator */}
|
|
126
|
+
<div className="my-1 border-t border-neutral-700/60" />
|
|
127
|
+
|
|
128
|
+
{/* Delete */}
|
|
129
|
+
<button
|
|
130
|
+
type="button"
|
|
131
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left"
|
|
132
|
+
onClick={() => {
|
|
133
|
+
onDelete(state.elementId, state.percentage);
|
|
134
|
+
onClose();
|
|
135
|
+
}}
|
|
136
|
+
>
|
|
137
|
+
Delete Keyframe
|
|
138
|
+
</button>
|
|
139
|
+
|
|
140
|
+
<button
|
|
141
|
+
type="button"
|
|
142
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left"
|
|
143
|
+
onClick={() => {
|
|
144
|
+
onDeleteAll(state.elementId);
|
|
145
|
+
onClose();
|
|
146
|
+
}}
|
|
147
|
+
>
|
|
148
|
+
Delete All Keyframes
|
|
149
|
+
</button>
|
|
150
|
+
|
|
151
|
+
{/* Copy Properties */}
|
|
152
|
+
<button
|
|
153
|
+
type="button"
|
|
154
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 cursor-pointer text-left"
|
|
155
|
+
onClick={() => {
|
|
156
|
+
onCopyProperties(state.elementId, state.percentage);
|
|
157
|
+
onClose();
|
|
158
|
+
}}
|
|
159
|
+
>
|
|
160
|
+
Copy Properties
|
|
161
|
+
</button>
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
});
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
shouldHandleTimelineDeleteKey,
|
|
13
13
|
shouldAutoScrollTimeline,
|
|
14
14
|
} from "./Timeline";
|
|
15
|
+
import { RULER_H, TRACK_H } from "./timelineLayout";
|
|
15
16
|
import { formatTime } from "../lib/time";
|
|
16
17
|
|
|
17
18
|
describe("generateTicks", () => {
|
|
@@ -230,7 +231,7 @@ describe("getTimelinePlayheadLeft", () => {
|
|
|
230
231
|
|
|
231
232
|
describe("getTimelineCanvasHeight", () => {
|
|
232
233
|
it("includes bottom scroll buffer below the last track", () => {
|
|
233
|
-
expect(getTimelineCanvasHeight(3)).toBeGreaterThan(
|
|
234
|
+
expect(getTimelineCanvasHeight(3)).toBeGreaterThan(RULER_H + 3 * TRACK_H);
|
|
234
235
|
});
|
|
235
236
|
|
|
236
237
|
it("still keeps ruler space when there are no tracks", () => {
|
|
@@ -8,17 +8,20 @@ import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
|
|
|
8
8
|
import { useTimelinePlayhead } from "./useTimelinePlayhead";
|
|
9
9
|
import { type TrackVisualStyle, getTrackStyle } from "./timelineIcons";
|
|
10
10
|
import { getTimelinePixelsPerSecond } from "./timelineZoom";
|
|
11
|
-
import {
|
|
11
|
+
import { useTimelineAssetDrop } from "./timelineDragDrop";
|
|
12
12
|
import { TimelineEmptyState } from "./TimelineEmptyState";
|
|
13
13
|
import { TimelineCanvas } from "./TimelineCanvas";
|
|
14
|
+
import {
|
|
15
|
+
KeyframeDiamondContextMenu,
|
|
16
|
+
type KeyframeDiamondContextMenuState,
|
|
17
|
+
} from "./KeyframeDiamondContextMenu";
|
|
14
18
|
import { useTimelineClipDrag } from "./useTimelineClipDrag";
|
|
19
|
+
import { ClipContextMenu } from "./ClipContextMenu";
|
|
15
20
|
import {
|
|
16
21
|
GUTTER,
|
|
17
|
-
TRACK_H,
|
|
18
22
|
generateTicks,
|
|
19
23
|
getTimelineCanvasHeight,
|
|
20
24
|
shouldShowTimelineShortcutHint,
|
|
21
|
-
resolveTimelineAssetDrop,
|
|
22
25
|
} from "./timelineLayout";
|
|
23
26
|
|
|
24
27
|
// Re-export pure utilities so existing imports from "./Timeline" still resolve.
|
|
@@ -66,7 +69,13 @@ interface TimelineProps {
|
|
|
66
69
|
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
|
67
70
|
) => Promise<void> | void;
|
|
68
71
|
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
|
72
|
+
onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
|
69
73
|
onSelectElement?: (element: TimelineElement | null) => void;
|
|
74
|
+
onDeleteKeyframe?: (elementId: string, percentage: number) => void;
|
|
75
|
+
onDeleteAllKeyframes?: (elementId: string) => void;
|
|
76
|
+
onChangeKeyframeEase?: (elementId: string, percentage: number, ease: string) => void;
|
|
77
|
+
onMoveKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void;
|
|
78
|
+
onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void;
|
|
70
79
|
theme?: Partial<TimelineTheme>;
|
|
71
80
|
}
|
|
72
81
|
|
|
@@ -82,7 +91,13 @@ export const Timeline = memo(function Timeline({
|
|
|
82
91
|
onMoveElement,
|
|
83
92
|
onResizeElement,
|
|
84
93
|
onBlockedEditAttempt,
|
|
94
|
+
onSplitElement,
|
|
85
95
|
onSelectElement,
|
|
96
|
+
onDeleteKeyframe,
|
|
97
|
+
onDeleteAllKeyframes,
|
|
98
|
+
onChangeKeyframeEase,
|
|
99
|
+
onMoveKeyframe,
|
|
100
|
+
onToggleKeyframeAtPlayhead,
|
|
86
101
|
theme: themeOverrides,
|
|
87
102
|
}: TimelineProps = {}) {
|
|
88
103
|
const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]);
|
|
@@ -120,6 +135,12 @@ export const Timeline = memo(function Timeline({
|
|
|
120
135
|
|
|
121
136
|
const [showPopover, setShowPopover] = useState(false);
|
|
122
137
|
const [showShortcutHint, setShowShortcutHint] = useState(true);
|
|
138
|
+
const [kfContextMenu, setKfContextMenu] = useState<KeyframeDiamondContextMenuState | null>(null);
|
|
139
|
+
const [clipContextMenu, setClipContextMenu] = useState<{
|
|
140
|
+
x: number;
|
|
141
|
+
y: number;
|
|
142
|
+
element: TimelineElement;
|
|
143
|
+
} | null>(null);
|
|
123
144
|
const [viewportWidth, setViewportWidth] = useState(0);
|
|
124
145
|
const roRef = useRef<ResizeObserver | null>(null);
|
|
125
146
|
const shortcutHintRafRef = useRef(0);
|
|
@@ -231,6 +252,10 @@ export const Timeline = memo(function Timeline({
|
|
|
231
252
|
}, [draggedClip, trackOrder]);
|
|
232
253
|
|
|
233
254
|
const totalH = getTimelineCanvasHeight(displayTrackOrder.length);
|
|
255
|
+
const keyframeCache = usePlayerStore((s) => s.keyframeCache);
|
|
256
|
+
const selectedKeyframes = usePlayerStore((s) => s.selectedKeyframes);
|
|
257
|
+
const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe);
|
|
258
|
+
|
|
234
259
|
const selectedElement = useMemo(
|
|
235
260
|
() => elements.find((element) => (element.key ?? element.id) === selectedElementId) ?? null,
|
|
236
261
|
[elements, selectedElementId],
|
|
@@ -337,71 +362,15 @@ export const Timeline = memo(function Timeline({
|
|
|
337
362
|
[resizingClip],
|
|
338
363
|
);
|
|
339
364
|
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
setIsDragOver(true);
|
|
350
|
-
}, []);
|
|
351
|
-
|
|
352
|
-
const handleAssetDrop = useCallback(
|
|
353
|
-
(e: React.DragEvent) => {
|
|
354
|
-
e.preventDefault();
|
|
355
|
-
setIsDragOver(false);
|
|
356
|
-
const scroll = scrollRef.current;
|
|
357
|
-
const rect = scroll?.getBoundingClientRect();
|
|
358
|
-
const dropInput = {
|
|
359
|
-
rectLeft: rect?.left ?? 0,
|
|
360
|
-
rectTop: rect?.top ?? 0,
|
|
361
|
-
scrollLeft: scroll?.scrollLeft ?? 0,
|
|
362
|
-
scrollTop: scroll?.scrollTop ?? 0,
|
|
363
|
-
pixelsPerSecond: ppsRef.current,
|
|
364
|
-
duration: durationRef.current,
|
|
365
|
-
trackHeight: TRACK_H,
|
|
366
|
-
trackOrder: trackOrderRef.current,
|
|
367
|
-
};
|
|
368
|
-
if (onFileDrop && e.dataTransfer.files.length > 0) {
|
|
369
|
-
void onFileDrop(
|
|
370
|
-
Array.from(e.dataTransfer.files),
|
|
371
|
-
scroll && rect ? resolveTimelineAssetDrop(dropInput, e.clientX, e.clientY) : undefined,
|
|
372
|
-
);
|
|
373
|
-
return;
|
|
374
|
-
}
|
|
375
|
-
const assetPayload = e.dataTransfer.getData(TIMELINE_ASSET_MIME);
|
|
376
|
-
if (assetPayload && onAssetDrop && scroll && rect) {
|
|
377
|
-
try {
|
|
378
|
-
const parsed = JSON.parse(assetPayload) as { path?: string };
|
|
379
|
-
if (parsed.path)
|
|
380
|
-
void onAssetDrop(
|
|
381
|
-
parsed.path,
|
|
382
|
-
resolveTimelineAssetDrop(dropInput, e.clientX, e.clientY),
|
|
383
|
-
);
|
|
384
|
-
} catch {
|
|
385
|
-
/* ignore malformed drag payloads */
|
|
386
|
-
}
|
|
387
|
-
return;
|
|
388
|
-
}
|
|
389
|
-
const blockPayload = e.dataTransfer.getData(TIMELINE_BLOCK_MIME);
|
|
390
|
-
if (blockPayload && onBlockDrop && scroll && rect) {
|
|
391
|
-
try {
|
|
392
|
-
const parsed = JSON.parse(blockPayload) as { name?: string };
|
|
393
|
-
if (parsed.name)
|
|
394
|
-
void onBlockDrop(
|
|
395
|
-
parsed.name,
|
|
396
|
-
resolveTimelineAssetDrop(dropInput, e.clientX, e.clientY),
|
|
397
|
-
);
|
|
398
|
-
} catch {
|
|
399
|
-
/* ignore malformed drag payloads */
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
},
|
|
403
|
-
[onAssetDrop, onBlockDrop, onFileDrop],
|
|
404
|
-
);
|
|
365
|
+
const { isDragOver, setIsDragOver, handleAssetDragOver, handleAssetDrop } = useTimelineAssetDrop({
|
|
366
|
+
scrollRef,
|
|
367
|
+
ppsRef,
|
|
368
|
+
durationRef,
|
|
369
|
+
trackOrderRef,
|
|
370
|
+
onFileDrop,
|
|
371
|
+
onAssetDrop,
|
|
372
|
+
onBlockDrop,
|
|
373
|
+
});
|
|
405
374
|
|
|
406
375
|
if (!timelineReady || elements.length === 0) {
|
|
407
376
|
return (
|
|
@@ -477,6 +446,48 @@ export const Timeline = memo(function Timeline({
|
|
|
477
446
|
shiftClickClipRef={shiftClickClipRef}
|
|
478
447
|
getPreviewElement={getPreviewElement}
|
|
479
448
|
getTrackStyle={getTrackStyle}
|
|
449
|
+
keyframeCache={keyframeCache}
|
|
450
|
+
selectedKeyframes={selectedKeyframes}
|
|
451
|
+
currentTime={currentTime}
|
|
452
|
+
onToggleKeyframeAtPlayhead={onToggleKeyframeAtPlayhead}
|
|
453
|
+
onClickKeyframe={(el, pct) => {
|
|
454
|
+
usePlayerStore.getState().clearSelectedKeyframes();
|
|
455
|
+
const elKey = el.key ?? el.id;
|
|
456
|
+
setSelectedElementId(elKey);
|
|
457
|
+
onSelectElement?.(el);
|
|
458
|
+
const absTime = el.start + (pct / 100) * el.duration;
|
|
459
|
+
onSeek?.(absTime);
|
|
460
|
+
}}
|
|
461
|
+
onShiftClickKeyframe={(elId, pct) => {
|
|
462
|
+
toggleSelectedKeyframe(`${elId}:${pct}`);
|
|
463
|
+
}}
|
|
464
|
+
onDragKeyframe={(el, oldPct, newPct) => {
|
|
465
|
+
onMoveKeyframe?.(el, oldPct, newPct);
|
|
466
|
+
}}
|
|
467
|
+
onContextMenuKeyframe={(e, elId, pct) => {
|
|
468
|
+
const el = elements.find((x) => (x.key ?? x.id) === elId);
|
|
469
|
+
if (el) {
|
|
470
|
+
setSelectedElementId(elId);
|
|
471
|
+
onSelectElement?.(el);
|
|
472
|
+
const absTime = el.start + (pct / 100) * el.duration;
|
|
473
|
+
onSeek?.(absTime);
|
|
474
|
+
}
|
|
475
|
+
const kfData = keyframeCache.get(elId);
|
|
476
|
+
const kf = kfData?.keyframes.find((k) => k.percentage === pct);
|
|
477
|
+
setKfContextMenu({
|
|
478
|
+
x: e.clientX,
|
|
479
|
+
y: e.clientY,
|
|
480
|
+
elementId: elId,
|
|
481
|
+
percentage: pct,
|
|
482
|
+
currentEase: kf?.ease ?? kfData?.ease,
|
|
483
|
+
});
|
|
484
|
+
}}
|
|
485
|
+
onContextMenuClip={(e, el) => {
|
|
486
|
+
e.preventDefault();
|
|
487
|
+
setSelectedElementId(el.key ?? el.id);
|
|
488
|
+
onSelectElement?.(el);
|
|
489
|
+
setClipContextMenu({ x: e.clientX, y: e.clientY, element: el });
|
|
490
|
+
}}
|
|
480
491
|
/>
|
|
481
492
|
</div>
|
|
482
493
|
|
|
@@ -511,6 +522,35 @@ export const Timeline = memo(function Timeline({
|
|
|
511
522
|
}}
|
|
512
523
|
/>
|
|
513
524
|
)}
|
|
525
|
+
|
|
526
|
+
{kfContextMenu && (
|
|
527
|
+
<KeyframeDiamondContextMenu
|
|
528
|
+
state={kfContextMenu}
|
|
529
|
+
onClose={() => setKfContextMenu(null)}
|
|
530
|
+
onDelete={(elId, pct) => onDeleteKeyframe?.(elId, pct)}
|
|
531
|
+
onDeleteAll={(elId) => onDeleteAllKeyframes?.(elId)}
|
|
532
|
+
onChangeEase={(elId, pct, ease) => onChangeKeyframeEase?.(elId, pct, ease)}
|
|
533
|
+
onCopyProperties={(elId, pct) => {
|
|
534
|
+
const kfData = keyframeCache.get(elId);
|
|
535
|
+
const kf = kfData?.keyframes.find((k) => k.percentage === pct);
|
|
536
|
+
if (kf) {
|
|
537
|
+
void navigator.clipboard.writeText(JSON.stringify(kf.properties, null, 2));
|
|
538
|
+
}
|
|
539
|
+
}}
|
|
540
|
+
/>
|
|
541
|
+
)}
|
|
542
|
+
|
|
543
|
+
{clipContextMenu && (
|
|
544
|
+
<ClipContextMenu
|
|
545
|
+
x={clipContextMenu.x}
|
|
546
|
+
y={clipContextMenu.y}
|
|
547
|
+
element={clipContextMenu.element}
|
|
548
|
+
currentTime={currentTime}
|
|
549
|
+
onClose={() => setClipContextMenu(null)}
|
|
550
|
+
onSplit={(el, time) => onSplitElement?.(el, time)}
|
|
551
|
+
onDelete={(el) => _onDeleteElement?.(el)}
|
|
552
|
+
/>
|
|
553
|
+
)}
|
|
514
554
|
</div>
|
|
515
555
|
);
|
|
516
556
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { memo, type ReactNode } from "react";
|
|
2
2
|
import { TimelineClip } from "./TimelineClip";
|
|
3
|
+
import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
|
|
3
4
|
import { TimelineRuler } from "./TimelineRuler";
|
|
4
5
|
import {
|
|
5
6
|
getTimelineEditCapabilities,
|
|
@@ -8,9 +9,10 @@ import {
|
|
|
8
9
|
} from "./timelineEditing";
|
|
9
10
|
import { getRenderedTimelineElement, type TimelineTheme } from "./timelineTheme";
|
|
10
11
|
import { GUTTER, TRACK_H, RULER_H, CLIP_Y, CLIP_HANDLE_W } from "./timelineLayout";
|
|
11
|
-
import type { TimelineElement } from "../store/playerStore";
|
|
12
|
+
import type { TimelineElement, KeyframeCacheEntry } from "../store/playerStore";
|
|
12
13
|
import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./useTimelineClipDrag";
|
|
13
14
|
import type { TrackVisualStyle } from "./timelineIcons";
|
|
15
|
+
import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability";
|
|
14
16
|
|
|
15
17
|
interface TimelineCanvasProps {
|
|
16
18
|
major: number[];
|
|
@@ -58,6 +60,15 @@ interface TimelineCanvasProps {
|
|
|
58
60
|
} | null>;
|
|
59
61
|
getPreviewElement: (element: TimelineElement) => TimelineElement;
|
|
60
62
|
getTrackStyle: (tag: string) => TrackVisualStyle;
|
|
63
|
+
keyframeCache?: Map<string, KeyframeCacheEntry>;
|
|
64
|
+
selectedKeyframes: Set<string>;
|
|
65
|
+
currentTime: number;
|
|
66
|
+
onClickKeyframe?: (element: TimelineElement, percentage: number) => void;
|
|
67
|
+
onShiftClickKeyframe?: (elementId: string, percentage: number) => void;
|
|
68
|
+
onDragKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void;
|
|
69
|
+
onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void;
|
|
70
|
+
onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void;
|
|
71
|
+
onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void;
|
|
61
72
|
}
|
|
62
73
|
|
|
63
74
|
export const TimelineCanvas = memo(function TimelineCanvas({
|
|
@@ -99,6 +110,15 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
|
|
99
110
|
shiftClickClipRef,
|
|
100
111
|
getPreviewElement,
|
|
101
112
|
getTrackStyle,
|
|
113
|
+
keyframeCache,
|
|
114
|
+
selectedKeyframes,
|
|
115
|
+
currentTime,
|
|
116
|
+
onClickKeyframe,
|
|
117
|
+
onShiftClickKeyframe,
|
|
118
|
+
onDragKeyframe,
|
|
119
|
+
onContextMenuKeyframe,
|
|
120
|
+
onContextMenuClip,
|
|
121
|
+
onToggleKeyframeAtPlayhead: _onToggleKeyframeAtPlayhead,
|
|
102
122
|
}: TimelineCanvasProps) {
|
|
103
123
|
const draggedElement = draggedClip?.element ?? null;
|
|
104
124
|
const activeDraggedElement =
|
|
@@ -231,6 +251,10 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
|
|
231
251
|
return (
|
|
232
252
|
<TimelineClip
|
|
233
253
|
key={clipKey}
|
|
254
|
+
onContextMenu={(e: React.MouseEvent) => {
|
|
255
|
+
e.preventDefault();
|
|
256
|
+
onContextMenuClip?.(e, el);
|
|
257
|
+
}}
|
|
234
258
|
el={previewElement}
|
|
235
259
|
pps={pps}
|
|
236
260
|
clipY={CLIP_Y}
|
|
@@ -328,6 +352,28 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
|
|
328
352
|
}}
|
|
329
353
|
>
|
|
330
354
|
{renderClipChildren(previewElement, clipStyle)}
|
|
355
|
+
{STUDIO_KEYFRAMES_ENABLED && keyframeCache?.get(elementKey) && (
|
|
356
|
+
<TimelineClipDiamonds
|
|
357
|
+
keyframesData={keyframeCache.get(elementKey)!}
|
|
358
|
+
clipWidthPx={Math.max(previewElement.duration * pps, 4)}
|
|
359
|
+
clipHeightPx={TRACK_H - 2 * CLIP_Y}
|
|
360
|
+
accentColor={clipStyle.accent}
|
|
361
|
+
isSelected={isSelected}
|
|
362
|
+
currentPercentage={
|
|
363
|
+
previewElement.duration > 0
|
|
364
|
+
? ((currentTime - previewElement.start) / previewElement.duration) * 100
|
|
365
|
+
: 0
|
|
366
|
+
}
|
|
367
|
+
elementId={elementKey}
|
|
368
|
+
selectedKeyframes={selectedKeyframes}
|
|
369
|
+
onClickKeyframe={(pct) => onClickKeyframe?.(previewElement, pct)}
|
|
370
|
+
onShiftClickKeyframe={onShiftClickKeyframe}
|
|
371
|
+
onDragKeyframe={(oldPct, newPct) =>
|
|
372
|
+
onDragKeyframe?.(previewElement, oldPct, newPct)
|
|
373
|
+
}
|
|
374
|
+
onContextMenuKeyframe={onContextMenuKeyframe}
|
|
375
|
+
/>
|
|
376
|
+
)}
|
|
331
377
|
</TimelineClip>
|
|
332
378
|
);
|
|
333
379
|
})}
|
|
@@ -23,6 +23,7 @@ interface TimelineClipProps {
|
|
|
23
23
|
onResizeStart?: (edge: "start" | "end", e: React.PointerEvent) => void;
|
|
24
24
|
onClick: (e: React.MouseEvent) => void;
|
|
25
25
|
onDoubleClick: (e: React.MouseEvent) => void;
|
|
26
|
+
onContextMenu?: (e: React.MouseEvent) => void;
|
|
26
27
|
children?: ReactNode;
|
|
27
28
|
}
|
|
28
29
|
|
|
@@ -44,6 +45,7 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
44
45
|
onResizeStart,
|
|
45
46
|
onClick,
|
|
46
47
|
onDoubleClick,
|
|
48
|
+
onContextMenu,
|
|
47
49
|
children,
|
|
48
50
|
}: TimelineClipProps) {
|
|
49
51
|
const leftPx = el.start * pps;
|
|
@@ -51,14 +53,14 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
51
53
|
const handleOpacity = getClipHandleOpacity({ isHovered, isSelected, isDragging });
|
|
52
54
|
|
|
53
55
|
const borderColor = isSelected
|
|
54
|
-
? trackStyle.accent
|
|
56
|
+
? trackStyle.accent
|
|
55
57
|
: isHovered
|
|
56
58
|
? theme.clipBorderHover
|
|
57
59
|
: theme.clipBorder;
|
|
58
60
|
const boxShadow = isDragging
|
|
59
61
|
? theme.clipShadowDragging
|
|
60
62
|
: isSelected
|
|
61
|
-
? `0 0 0 1px ${trackStyle.accent}
|
|
63
|
+
? `0 0 0 1px ${trackStyle.accent}80, 0 0 8px ${trackStyle.accent}25`
|
|
62
64
|
: isHovered
|
|
63
65
|
? theme.clipShadowHover
|
|
64
66
|
: theme.clipShadow;
|
|
@@ -69,7 +71,9 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
69
71
|
<div
|
|
70
72
|
data-clip="true"
|
|
71
73
|
className={
|
|
72
|
-
hasCustomContent
|
|
74
|
+
hasCustomContent
|
|
75
|
+
? "absolute overflow-visible"
|
|
76
|
+
: "absolute flex items-center overflow-visible"
|
|
73
77
|
}
|
|
74
78
|
style={{
|
|
75
79
|
left: leftPx,
|
|
@@ -96,6 +100,7 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
96
100
|
onPointerDown={onPointerDown}
|
|
97
101
|
onClick={onClick}
|
|
98
102
|
onDoubleClick={onDoubleClick}
|
|
103
|
+
onContextMenu={onContextMenu}
|
|
99
104
|
>
|
|
100
105
|
{/* Left accent stripe */}
|
|
101
106
|
<div
|