@hyperframes/studio 0.7.39 → 0.7.40

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 (76) hide show
  1. package/dist/assets/{index-312a3Ceu.js → index-89kPtC4s.js} +1 -1
  2. package/dist/assets/{index-B9YvRJz1.js → index-B2Utv-2b.js} +195 -195
  3. package/dist/assets/index-BpM6cnfP.css +1 -0
  4. package/dist/assets/{index-CQHiecE7.js → index-CjX8Ljc8.js} +1 -1
  5. package/dist/{chunk-RCLGSZ7C.js → chunk-BA66NM4L.js} +1 -1
  6. package/dist/{chunk-RCLGSZ7C.js.map → chunk-BA66NM4L.js.map} +1 -1
  7. package/dist/{domEditingLayers-S6YOLVRG.js → domEditingLayers-H7LDFIJ7.js} +2 -2
  8. package/dist/index.d.ts +34 -29
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +2749 -2075
  11. package/dist/index.js.map +1 -1
  12. package/package.json +7 -7
  13. package/src/App.tsx +2 -1
  14. package/src/components/StudioPreviewArea.tsx +6 -0
  15. package/src/components/StudioRightPanel.tsx +3 -0
  16. package/src/components/editor/DomEditOverlay.tsx +33 -47
  17. package/src/components/editor/PropertyPanel.test.ts +25 -0
  18. package/src/components/editor/PropertyPanel.tsx +33 -7
  19. package/src/components/editor/domEditingElement.ts +8 -1
  20. package/src/components/editor/manualEditsDom.ts +1 -18
  21. package/src/components/editor/manualEditsStyleHelpers.ts +18 -0
  22. package/src/components/editor/offCanvasIndicatorGeometry.ts +74 -0
  23. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +154 -0
  24. package/src/components/editor/offCanvasIndicatorRefresh.ts +99 -0
  25. package/src/components/editor/propertyPanelHelpers.ts +11 -0
  26. package/src/components/editor/propertyPanelTypes.ts +1 -0
  27. package/src/contexts/TimelineEditContext.tsx +1 -0
  28. package/src/hooks/gsapDragCommit.test.ts +56 -0
  29. package/src/hooks/gsapDragCommit.ts +41 -109
  30. package/src/hooks/gsapDragPositionCommit.ts +3 -0
  31. package/src/hooks/gsapDragStaticSetHelpers.ts +109 -0
  32. package/src/hooks/gsapRuntimeBridge.ts +23 -2
  33. package/src/hooks/gsapRuntimeKeyframes.test.ts +34 -0
  34. package/src/hooks/gsapRuntimeKeyframes.ts +13 -1
  35. package/src/hooks/gsapWholePropertyOffsetCommit.ts +1 -0
  36. package/src/hooks/timelineEditingHelpers.ts +17 -5
  37. package/src/hooks/timelineTrackVisibility.test.ts +202 -0
  38. package/src/hooks/timelineTrackVisibility.ts +371 -0
  39. package/src/hooks/useRenderClipContent.test.ts +68 -2
  40. package/src/hooks/useRenderClipContent.ts +5 -6
  41. package/src/hooks/useTimelineEditing.ts +35 -30
  42. package/src/hooks/useTimelineEditingTypes.ts +30 -0
  43. package/src/icons/SystemIcons.tsx +12 -4
  44. package/src/player/components/AudioWaveform.tsx +11 -8
  45. package/src/player/components/CompositionThumbnail.tsx +14 -11
  46. package/src/player/components/PlayheadIndicator.tsx +21 -11
  47. package/src/player/components/Timeline.test.ts +105 -5
  48. package/src/player/components/Timeline.tsx +37 -47
  49. package/src/player/components/TimelineCanvas.tsx +275 -256
  50. package/src/player/components/TimelineClip.test.tsx +105 -0
  51. package/src/player/components/TimelineClip.tsx +44 -57
  52. package/src/player/components/TimelineRuler.tsx +17 -23
  53. package/src/player/components/TimelineShortcutHint.tsx +26 -0
  54. package/src/player/components/TimelineTypes.ts +18 -0
  55. package/src/player/components/VideoThumbnail.tsx +16 -13
  56. package/src/player/components/timelineCallbacks.ts +1 -0
  57. package/src/player/components/timelineIcons.tsx +2 -44
  58. package/src/player/components/timelineLayout.ts +18 -22
  59. package/src/player/components/timelineMotionStyles.test.ts +101 -0
  60. package/src/player/components/timelineTheme.test.ts +21 -4
  61. package/src/player/components/timelineTheme.ts +15 -15
  62. package/src/player/components/useTimelineActiveClips.test.ts +93 -0
  63. package/src/player/components/useTimelineActiveClips.ts +125 -0
  64. package/src/player/components/useTimelinePlayhead.ts +6 -0
  65. package/src/player/hooks/useExpandedTimelineElements.test.ts +110 -3
  66. package/src/player/hooks/useExpandedTimelineElements.ts +106 -19
  67. package/src/player/lib/time.test.ts +1 -0
  68. package/src/player/lib/timelineDOM.test.ts +48 -1
  69. package/src/player/lib/timelineDOM.ts +7 -0
  70. package/src/player/lib/timelineIframeHelpers.ts +1 -0
  71. package/src/player/store/playerStore.ts +5 -1
  72. package/src/styles/studio.css +108 -0
  73. package/src/utils/sourcePatcher.test.ts +11 -0
  74. package/src/utils/sourcePatcher.ts +6 -4
  75. package/dist/assets/index-0P10SwC_.css +0 -1
  76. /package/dist/{domEditingLayers-S6YOLVRG.js.map → domEditingLayers-H7LDFIJ7.js.map} +0 -0
@@ -32,6 +32,7 @@ function extractPeaks(channelData: Float32Array, barCount: number): number[] {
32
32
  const start = i * samplesPerBar;
33
33
  const end = Math.min(start + samplesPerBar, channelData.length);
34
34
  for (let j = start; j < end; j++) {
35
+ // fallow-ignore-next-line code-duplication
35
36
  const abs = Math.abs(channelData[j] ?? 0);
36
37
  if (abs > max) max = abs;
37
38
  }
@@ -195,14 +196,16 @@ export const AudioWaveform = memo(function AudioWaveform({
195
196
  }}
196
197
  />
197
198
  )}
198
- <div className="absolute top-0 left-0 right-0 px-1.5 py-0.5 z-10">
199
- <span
200
- className="text-[9px] font-semibold truncate block leading-tight"
201
- style={{ color: labelColor, textShadow: "0 1px 3px rgba(0,0,0,0.9)" }}
202
- >
203
- {label}
204
- </span>
205
- </div>
199
+ {label && (
200
+ <div className="absolute top-0 left-0 right-0 px-1.5 py-0.5 z-10">
201
+ <span
202
+ className="text-[9px] font-semibold truncate block leading-tight"
203
+ style={{ color: labelColor, textShadow: "0 1px 3px rgba(0,0,0,0.9)" }}
204
+ >
205
+ {label}
206
+ </span>
207
+ </div>
208
+ )}
206
209
  </div>
207
210
  );
208
211
  });
@@ -66,6 +66,7 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
66
66
  if (!el) return;
67
67
 
68
68
  const measured = el.parentElement?.clientWidth || el.clientWidth;
69
+ // fallow-ignore-next-line code-duplication
69
70
  setContainerWidth(measured);
70
71
 
71
72
  const target = el.parentElement || el;
@@ -130,17 +131,19 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
130
131
  </div>
131
132
  )}
132
133
 
133
- <div className="absolute left-3 top-0 bottom-0 flex items-center" style={{ zIndex: 10 }}>
134
- <span
135
- className="block max-w-full truncate text-[10px] font-semibold leading-none"
136
- style={{
137
- color: labelColor,
138
- textShadow: loaded ? "0 1px 4px rgba(0,0,0,0.9), 0 0 8px rgba(0,0,0,0.6)" : "none",
139
- }}
140
- >
141
- {label}
142
- </span>
143
- </div>
134
+ {label && (
135
+ <div className="absolute left-3 top-0 bottom-0 flex items-center" style={{ zIndex: 10 }}>
136
+ <span
137
+ className="block max-w-full truncate text-[10px] font-semibold leading-none"
138
+ style={{
139
+ color: labelColor,
140
+ textShadow: loaded ? "0 1px 4px rgba(0,0,0,0.9), 0 0 8px rgba(0,0,0,0.6)" : "none",
141
+ }}
142
+ >
143
+ {label}
144
+ </span>
145
+ </div>
146
+ )}
144
147
  </div>
145
148
  );
146
149
  });
@@ -12,29 +12,39 @@ interface PlayheadIndicatorProps {
12
12
 
13
13
  export function PlayheadIndicator({
14
14
  color = "var(--hf-accent, #3CE6AC)",
15
- glowColor = "rgba(60,230,172,0.5)",
15
+ glowColor = "rgba(60,230,172,0.14)",
16
16
  }: PlayheadIndicatorProps) {
17
17
  return (
18
18
  <>
19
19
  <div
20
+ aria-hidden="true"
20
21
  className="absolute top-0 bottom-0"
21
22
  style={{
22
23
  left: "50%",
23
- width: 2,
24
- marginLeft: -1,
24
+ width: 13,
25
+ transform: "translateX(-50%)",
26
+ background: `radial-gradient(closest-side, ${glowColor}, transparent)`,
27
+ }}
28
+ />
29
+ <div
30
+ className="absolute top-0 bottom-0"
31
+ style={{
32
+ left: "50%",
33
+ width: 1,
34
+ marginLeft: -0.5,
25
35
  background: color,
26
- boxShadow: `0 0 8px ${glowColor}`,
36
+ boxShadow: `0 0 6px ${glowColor}`,
27
37
  }}
28
38
  />
29
- <div className="absolute" style={{ left: "50%", top: 0, transform: "translateX(-50%)" }}>
39
+ <div className="absolute" style={{ left: "50%", top: 1, transform: "translateX(-50%)" }}>
30
40
  <div
31
41
  style={{
32
- width: 0,
33
- height: 0,
34
- borderLeft: "6px solid transparent",
35
- borderRight: "6px solid transparent",
36
- borderTop: `8px solid ${color}`,
37
- filter: "drop-shadow(0 1px 3px rgba(0,0,0,0.6))",
42
+ width: 9,
43
+ height: 9,
44
+ borderRadius: 2,
45
+ background: color,
46
+ boxShadow: `0 1px 3px rgba(0,0,0,0.55), 0 0 5px ${glowColor}`,
47
+ transform: "rotate(45deg)",
38
48
  }}
39
49
  />
40
50
  </div>
@@ -21,6 +21,7 @@ import {
21
21
  import { RULER_H, TRACK_H } from "./timelineLayout";
22
22
  import { formatTime } from "../lib/time";
23
23
  import { usePlayerStore } from "../store/playerStore";
24
+ import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
24
25
 
25
26
  globalThis.IS_REACT_ACT_ENVIRONMENT = true;
26
27
 
@@ -30,6 +31,7 @@ afterEach(() => {
30
31
  });
31
32
 
32
33
  describe("Timeline provider boundary", () => {
34
+ // fallow-ignore-next-line code-duplication
33
35
  it("renders the public Timeline export without TimelineEditProvider", () => {
34
36
  const host = document.createElement("div");
35
37
  document.body.append(host);
@@ -55,6 +57,99 @@ describe("Timeline provider boundary", () => {
55
57
  act(() => root.unmount());
56
58
  });
57
59
 
60
+ // fallow-ignore-next-line code-duplication
61
+ it("renders the gutter without legacy icons or hue dots", () => {
62
+ const host = document.createElement("div");
63
+ document.body.append(host);
64
+ Object.defineProperty(host, "clientWidth", {
65
+ configurable: true,
66
+ value: 640,
67
+ });
68
+
69
+ usePlayerStore.setState({
70
+ duration: 4,
71
+ timelineReady: true,
72
+ elements: [{ id: "clip-1", tag: "div", start: 0, duration: 2, track: 0 }],
73
+ });
74
+
75
+ const root = createRoot(host);
76
+ act(() => {
77
+ root.render(React.createElement(Timeline));
78
+ });
79
+
80
+ const hueDot = Array.from(host.querySelectorAll("div")).find(
81
+ (node) =>
82
+ node.style.width === "6px" &&
83
+ node.style.height === "6px" &&
84
+ node.style.borderRadius === "9999px",
85
+ );
86
+
87
+ expect(host.querySelector('img[src^="/icons/timeline/"]')).toBeNull();
88
+ expect(hueDot).toBeUndefined();
89
+ act(() => root.unmount());
90
+ });
91
+
92
+ // fallow-ignore-next-line code-duplication
93
+ it("requests persisted track visibility from the gutter without seeking", () => {
94
+ const host = document.createElement("div");
95
+ document.body.append(host);
96
+ Object.defineProperty(host, "clientWidth", {
97
+ configurable: true,
98
+ value: 640,
99
+ });
100
+
101
+ usePlayerStore.setState({
102
+ duration: 4,
103
+ timelineReady: true,
104
+ elements: [{ id: "clip-1", tag: "div", start: 0, duration: 2, track: 0, hidden: true }],
105
+ });
106
+
107
+ const onSeek = vi.fn();
108
+ const onToggleTrackHidden = vi.fn();
109
+ const root = createRoot(host);
110
+ act(() => {
111
+ root.render(
112
+ React.createElement(
113
+ TimelineEditProvider,
114
+ { value: { onToggleTrackHidden } },
115
+ React.createElement(Timeline, { onSeek }),
116
+ ),
117
+ );
118
+ });
119
+
120
+ const button = host.querySelector<HTMLButtonElement>('button[aria-label="Show track 0"]');
121
+ expect(button).not.toBeNull();
122
+ if (!button) throw new Error("Expected a track visibility toggle");
123
+
124
+ act(() => {
125
+ button.dispatchEvent(
126
+ new MouseEvent("pointerdown", {
127
+ bubbles: true,
128
+ cancelable: true,
129
+ button: 0,
130
+ clientX: 120,
131
+ clientY: 40,
132
+ }),
133
+ );
134
+ });
135
+ expect(onSeek).not.toHaveBeenCalled();
136
+
137
+ act(() => {
138
+ button.click();
139
+ });
140
+
141
+ const row = button.parentElement?.parentElement;
142
+ const trackContent = row?.children.item(1);
143
+ expect(onToggleTrackHidden).toHaveBeenCalledWith(0, false);
144
+ expect(trackContent).toBeInstanceOf(HTMLElement);
145
+ if (!(trackContent instanceof HTMLElement)) {
146
+ throw new Error("Expected track content element");
147
+ }
148
+ expect(trackContent.style.opacity).toBe("0.35");
149
+
150
+ act(() => root.unmount());
151
+ });
152
+
58
153
  it("opens the keyframe context menu without seeking to that keyframe", () => {
59
154
  const host = document.createElement("div");
60
155
  document.body.append(host);
@@ -177,10 +272,10 @@ describe("generateTicks", () => {
177
272
  it("uses denser major labels as timeline zoom increases", () => {
178
273
  const fitTicks = generateTicks(180, 10);
179
274
  const zoomedTicks = generateTicks(180, 48);
180
- expect(fitTicks.major[1] - fitTicks.major[0]).toBe(15);
181
- expect(zoomedTicks.major[1] - zoomedTicks.major[0]).toBe(5);
275
+ expect(fitTicks.major[1] - fitTicks.major[0]).toBe(10);
276
+ expect(fitTicks.minor).toContain(5);
277
+ expect(zoomedTicks.major[1] - zoomedTicks.major[0]).toBe(2);
182
278
  expect(zoomedTicks.minor).toContain(1);
183
- expect(zoomedTicks.minor).toContain(4);
184
279
  });
185
280
 
186
281
  it("keeps labels readable instead of placing one at every tiny tick", () => {
@@ -194,6 +289,7 @@ describe("formatTime", () => {
194
289
  expect(formatTime(0)).toBe("0:00");
195
290
  });
196
291
 
292
+ // fallow-ignore-next-line code-duplication
197
293
  it("formats seconds below a minute", () => {
198
294
  expect(formatTime(5)).toBe("0:05");
199
295
  expect(formatTime(30)).toBe("0:30");
@@ -261,8 +357,12 @@ describe("getTimelineScrollLeftForZoomTransition", () => {
261
357
  expect(getTimelineScrollLeftForZoomTransition("manual", "fit", 480)).toBe(0);
262
358
  });
263
359
 
264
- it("preserves the current scroll offset for other zoom transitions", () => {
265
- expect(getTimelineScrollLeftForZoomTransition("fit", "fit", 480)).toBe(480);
360
+ it("resets horizontal scroll whenever the next zoom mode is fit", () => {
361
+ expect(getTimelineScrollLeftForZoomTransition("fit", "fit", 480)).toBe(0);
362
+ expect(getTimelineScrollLeftForZoomTransition(null, "fit", 480)).toBe(0);
363
+ });
364
+
365
+ it("preserves the current scroll offset for manual zoom transitions", () => {
266
366
  expect(getTimelineScrollLeftForZoomTransition("fit", "manual", 480)).toBe(480);
267
367
  expect(getTimelineScrollLeftForZoomTransition("manual", "manual", 480)).toBe(480);
268
368
  });
@@ -1,4 +1,4 @@
1
- import { useRef, useMemo, useCallback, useState, useEffect, memo, type ReactNode } from "react";
1
+ import { useRef, useMemo, useCallback, useState, useEffect, memo } from "react";
2
2
  import { useMusicBeatAnalysis } from "../../hooks/useMusicBeatAnalysis";
3
3
  import { isMusicTrack } from "../../utils/timelineInspector";
4
4
  import { remapBeatAnalysisToComposition } from "../../utils/beatEditActions";
@@ -6,9 +6,10 @@ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
6
6
  import { useExpandedTimelineElements } from "../hooks/useExpandedTimelineElements";
7
7
  import { useMountEffect } from "../../hooks/useMountEffect";
8
8
  import { EditPopover } from "./EditModal";
9
- import { defaultTimelineTheme, type TimelineTheme } from "./timelineTheme";
9
+ import { defaultTimelineTheme } from "./timelineTheme";
10
10
  import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
11
11
  import { useTimelinePlayhead } from "./useTimelinePlayhead";
12
+ import { useTimelineActiveClips } from "./useTimelineActiveClips";
12
13
  import { type TrackVisualStyle, getTrackStyle } from "./timelineIcons";
13
14
  import { getTimelinePixelsPerSecond } from "./timelineZoom";
14
15
  import { useTimelineZoom } from "./useTimelineZoom";
@@ -21,17 +22,15 @@ import {
21
22
  } from "./KeyframeDiamondContextMenu";
22
23
  import { useTimelineClipDrag } from "./useTimelineClipDrag";
23
24
  import { ClipContextMenu } from "./ClipContextMenu";
25
+ import { TimelineShortcutHint } from "./TimelineShortcutHint";
24
26
  import {
25
27
  GUTTER,
26
28
  generateTicks,
27
29
  getTimelineCanvasHeight,
28
30
  shouldShowTimelineShortcutHint,
29
31
  } from "./timelineLayout";
30
- import type { TimelineDropCallbacks } from "./timelineCallbacks";
31
- import {
32
- useResolvedTimelineEditCallbacks,
33
- type TimelineEditOverrides,
34
- } from "./useResolvedTimelineEditCallbacks";
32
+ import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks";
33
+ import type { TimelineProps } from "./TimelineTypes";
35
34
 
36
35
  // Re-export pure utilities so existing imports from "./Timeline" still resolve.
37
36
  export {
@@ -48,19 +47,6 @@ export {
48
47
  getDefaultDroppedTrack,
49
48
  } from "./timelineLayout";
50
49
 
51
- interface TimelineProps extends TimelineDropCallbacks, TimelineEditOverrides {
52
- onSeek?: (time: number) => void;
53
- onDrillDown?: (element: TimelineElement) => void;
54
- renderClipContent?: (
55
- element: TimelineElement,
56
- style: { clip: string; label: string },
57
- ) => ReactNode;
58
- renderClipOverlay?: (element: TimelineElement) => ReactNode;
59
- onDeleteElement?: (element: TimelineElement) => Promise<void> | void;
60
- onSelectElement?: (element: TimelineElement | null) => void;
61
- theme?: Partial<TimelineTheme>;
62
- }
63
-
64
50
  export const Timeline = memo(function Timeline({
65
51
  onSeek,
66
52
  onDrillDown,
@@ -162,20 +148,26 @@ export const Timeline = memo(function Timeline({
162
148
  });
163
149
  }, [syncShortcutHintVisibility]);
164
150
 
165
- const setContainerRef = useCallback(
151
+ const setContainerRef = useCallback((el: HTMLDivElement | null) => {
152
+ containerRef.current = el;
153
+ }, []);
154
+
155
+ const setScrollRef = useCallback(
166
156
  (el: HTMLDivElement | null) => {
167
157
  if (roRef.current) {
168
158
  roRef.current.disconnect();
169
159
  roRef.current = null;
170
160
  }
171
- containerRef.current = el;
161
+ scrollRef.current = el;
172
162
  if (!el) return;
173
- setViewportWidth(el.clientWidth);
174
- scheduleShortcutHintVisibilitySync();
175
- roRef.current = new ResizeObserver(([entry]) => {
176
- setViewportWidth(entry.contentRect.width);
163
+
164
+ const syncScrollViewport = () => {
165
+ setViewportWidth(el.clientWidth);
177
166
  scheduleShortcutHintVisibilitySync();
178
- });
167
+ };
168
+
169
+ syncScrollViewport();
170
+ roRef.current = new ResizeObserver(syncScrollViewport);
179
171
  roRef.current.observe(el);
180
172
  },
181
173
  [scheduleShortcutHintVisibilitySync],
@@ -273,6 +265,13 @@ export const Timeline = memo(function Timeline({
273
265
  const pps = getTimelinePixelsPerSecond(fitPps, zoomMode, manualZoomPercent);
274
266
  ppsRef.current = pps;
275
267
  const trackContentWidth = Math.max(0, effectiveDuration * pps);
268
+ const clipStateVersion = useMemo(
269
+ () =>
270
+ expandedElements
271
+ .map((el) => `${el.key ?? el.id}:${el.start}:${el.duration}:${el.track}`)
272
+ .join("|"),
273
+ [expandedElements],
274
+ );
276
275
  const zoomModeRef = useRef(zoomMode);
277
276
  zoomModeRef.current = zoomMode;
278
277
  const manualZoomPercentRef = useRef(manualZoomPercent);
@@ -301,6 +300,11 @@ export const Timeline = memo(function Timeline({
301
300
  setManualZoomPercent,
302
301
  onSeek,
303
302
  });
303
+ useTimelineActiveClips({
304
+ scrollRef,
305
+ currentTime,
306
+ clipStateVersion,
307
+ });
304
308
 
305
309
  const {
306
310
  rangeSelection,
@@ -340,8 +344,7 @@ export const Timeline = memo(function Timeline({
340
344
  () => generateTicks(effectiveDuration, pps),
341
345
  [effectiveDuration, pps],
342
346
  );
343
- const majorTickInterval =
344
- major.length >= 2 ? Math.max(0.25, major[1] - major[0]) : effectiveDuration;
347
+ const majorTickInterval = major.length >= 2 ? major[1] - major[0] : effectiveDuration;
345
348
 
346
349
  useEffect(() => {
347
350
  syncShortcutHintVisibility();
@@ -406,7 +409,7 @@ export const Timeline = memo(function Timeline({
406
409
  }}
407
410
  >
408
411
  <div
409
- ref={scrollRef}
412
+ ref={setScrollRef}
410
413
  tabIndex={-1}
411
414
  className={`${zoomMode === "fit" ? "overflow-x-hidden" : "overflow-x-auto"} overflow-y-auto h-full outline-none`}
412
415
  onDragOver={handleAssetDragOver}
@@ -434,7 +437,6 @@ export const Timeline = memo(function Timeline({
434
437
  totalH={totalH}
435
438
  effectiveDuration={effectiveDuration}
436
439
  majorTickInterval={majorTickInterval}
437
- shiftHeld={shiftHeld}
438
440
  rangeSelection={rangeSelection}
439
441
  theme={theme}
440
442
  displayTrackOrder={displayTrackOrder}
@@ -472,6 +474,9 @@ export const Timeline = memo(function Timeline({
472
474
  const elKey = el.key ?? el.id;
473
475
  setSelectedElementId(elKey);
474
476
  onSelectElement?.(el);
477
+ // Visually select the clicked diamond (matches shift-click / motion-path
478
+ // selection); cleared above so this single-selects it.
479
+ toggleSelectedKeyframe(`${elKey}:${pct}`);
475
480
  const absTime = el.start + (pct / 100) * el.duration;
476
481
  onSeek?.(absTime);
477
482
  const kfData = keyframeCache?.get(elKey);
@@ -519,22 +524,7 @@ export const Timeline = memo(function Timeline({
519
524
  </div>
520
525
 
521
526
  {showShortcutHint && !showPopover && !rangeSelection && (
522
- <div className="absolute bottom-2 right-3 pointer-events-none z-20">
523
- <div
524
- className="flex items-center gap-1.5 px-2 py-1 rounded-md border"
525
- style={{ background: "rgba(17,23,35,0.84)", borderColor: theme.gutterBorder }}
526
- >
527
- <kbd
528
- className="text-[9px] font-mono px-1 py-0.5 rounded"
529
- style={{ color: theme.textSecondary, background: "rgba(255,255,255,0.06)" }}
530
- >
531
- Shift
532
- </kbd>
533
- <span className="text-[9px]" style={{ color: theme.textSecondary }}>
534
- + drag/click to edit range
535
- </span>
536
- </div>
537
- </div>
527
+ <TimelineShortcutHint theme={theme} />
538
528
  )}
539
529
 
540
530
  {showPopover && rangeSelection && (