@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.
Files changed (60) hide show
  1. package/dist/assets/index-BcJO6Ej5.js +140 -0
  2. package/dist/assets/index-C2gBZ2km.css +1 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +4 -4
  5. package/src/App.tsx +30 -24
  6. package/src/components/StudioPreviewArea.tsx +101 -26
  7. package/src/components/StudioRightPanel.tsx +3 -0
  8. package/src/components/StudioToast.tsx +18 -0
  9. package/src/components/TimelineToolbar.tsx +230 -4
  10. package/src/components/editor/AnimationCard.tsx +68 -4
  11. package/src/components/editor/DomEditOverlay.tsx +70 -1
  12. package/src/components/editor/GridOverlay.tsx +50 -0
  13. package/src/components/editor/KeyframeDiamond.tsx +49 -0
  14. package/src/components/editor/KeyframeNavigation.tsx +139 -0
  15. package/src/components/editor/PropertyPanel.tsx +293 -140
  16. package/src/components/editor/SnapGuideOverlay.tsx +166 -0
  17. package/src/components/editor/SnapToolbar.tsx +163 -0
  18. package/src/components/editor/SpringEaseEditor.tsx +256 -0
  19. package/src/components/editor/domEditOverlayGestures.ts +7 -0
  20. package/src/components/editor/domEditOverlayStartGesture.ts +28 -0
  21. package/src/components/editor/gsapAnimationConstants.ts +42 -0
  22. package/src/components/editor/gsapAnimationHelpers.ts +2 -1
  23. package/src/components/editor/manualEditingAvailability.ts +6 -0
  24. package/src/components/editor/manualEditsDom.ts +56 -2
  25. package/src/components/editor/manualOffsetDrag.ts +19 -3
  26. package/src/components/editor/propertyPanelHelpers.ts +90 -0
  27. package/src/components/editor/propertyPanelTimingSection.tsx +64 -0
  28. package/src/components/editor/snapEngine.test.ts +657 -0
  29. package/src/components/editor/snapEngine.ts +575 -0
  30. package/src/components/editor/snapTargetCollection.ts +147 -0
  31. package/src/components/editor/useDomEditOverlayGestures.ts +137 -10
  32. package/src/components/nle/NLELayout.tsx +18 -0
  33. package/src/contexts/DomEditContext.tsx +24 -0
  34. package/src/hooks/gsapRuntimeBridge.ts +585 -0
  35. package/src/hooks/gsapRuntimeKeyframes.ts +170 -0
  36. package/src/hooks/useAnimatedPropertyCommit.ts +131 -0
  37. package/src/hooks/useAppHotkeys.ts +63 -1
  38. package/src/hooks/useDomEditCommits.ts +39 -4
  39. package/src/hooks/useDomEditSession.ts +177 -63
  40. package/src/hooks/useGsapScriptCommits.ts +144 -7
  41. package/src/hooks/useGsapSelectionHandlers.ts +202 -0
  42. package/src/hooks/useGsapTweenCache.ts +174 -3
  43. package/src/hooks/useTimelineEditing.ts +93 -0
  44. package/src/icons/SystemIcons.tsx +2 -0
  45. package/src/player/components/ClipContextMenu.tsx +99 -0
  46. package/src/player/components/KeyframeDiamondContextMenu.tsx +164 -0
  47. package/src/player/components/Timeline.test.ts +2 -1
  48. package/src/player/components/Timeline.tsx +108 -68
  49. package/src/player/components/TimelineCanvas.tsx +47 -1
  50. package/src/player/components/TimelineClip.tsx +8 -3
  51. package/src/player/components/TimelineClipDiamonds.tsx +174 -0
  52. package/src/player/components/timelineDragDrop.ts +103 -0
  53. package/src/player/components/timelineLayout.ts +1 -1
  54. package/src/player/store/playerStore.ts +42 -0
  55. package/src/utils/editHistory.ts +1 -1
  56. package/src/utils/optimisticUpdate.test.ts +53 -0
  57. package/src/utils/optimisticUpdate.ts +18 -0
  58. package/src/utils/studioUiPreferences.ts +17 -0
  59. package/dist/assets/index-CrxThtSJ.css +0 -1
  60. 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(24 + 3 * 72);
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 { TIMELINE_ASSET_MIME, TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
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 [isDragOver, setIsDragOver] = useState(false);
341
- const handleAssetDragOver = useCallback((e: React.DragEvent) => {
342
- const hasFiles = e.dataTransfer.files.length > 0;
343
- const types = Array.from(e.dataTransfer.types);
344
- const hasAsset = types.includes(TIMELINE_ASSET_MIME);
345
- const hasBlock = types.includes(TIMELINE_BLOCK_MIME);
346
- if (!hasFiles && !hasAsset && !hasBlock) return;
347
- e.preventDefault();
348
- if (hasAsset || hasBlock) e.dataTransfer.dropEffect = "copy";
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 + "60"
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}40`
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 ? "absolute overflow-hidden" : "absolute flex items-center overflow-hidden"
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