@hyperframes/studio 0.7.39 → 0.7.41
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-CQHiecE7.js → index-BCG-TnJO.js} +1 -1
- package/dist/assets/index-BpM6cnfP.css +1 -0
- package/dist/assets/{index-B9YvRJz1.js → index-DRrNf6k_.js} +195 -195
- package/dist/assets/{index-312a3Ceu.js → index-rnqIbAs_.js} +1 -1
- package/dist/{chunk-RCLGSZ7C.js → chunk-BA66NM4L.js} +1 -1
- package/dist/{chunk-RCLGSZ7C.js.map → chunk-BA66NM4L.js.map} +1 -1
- package/dist/{domEditingLayers-S6YOLVRG.js → domEditingLayers-H7LDFIJ7.js} +2 -2
- package/dist/index.d.ts +34 -29
- package/dist/index.html +2 -2
- package/dist/index.js +2749 -2075
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +2 -1
- package/src/components/StudioPreviewArea.tsx +6 -0
- package/src/components/StudioRightPanel.tsx +3 -0
- package/src/components/editor/DomEditOverlay.tsx +33 -47
- package/src/components/editor/PropertyPanel.test.ts +25 -0
- package/src/components/editor/PropertyPanel.tsx +33 -7
- package/src/components/editor/domEditingElement.ts +8 -1
- package/src/components/editor/manualEditsDom.ts +1 -18
- package/src/components/editor/manualEditsStyleHelpers.ts +18 -0
- package/src/components/editor/offCanvasIndicatorGeometry.ts +74 -0
- package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +154 -0
- package/src/components/editor/offCanvasIndicatorRefresh.ts +99 -0
- package/src/components/editor/propertyPanelHelpers.ts +11 -0
- package/src/components/editor/propertyPanelTypes.ts +1 -0
- package/src/contexts/TimelineEditContext.tsx +1 -0
- package/src/hooks/gsapDragCommit.test.ts +56 -0
- package/src/hooks/gsapDragCommit.ts +41 -109
- package/src/hooks/gsapDragPositionCommit.ts +3 -0
- package/src/hooks/gsapDragStaticSetHelpers.ts +109 -0
- package/src/hooks/gsapRuntimeBridge.ts +23 -2
- package/src/hooks/gsapRuntimeKeyframes.test.ts +34 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +13 -1
- package/src/hooks/gsapWholePropertyOffsetCommit.ts +1 -0
- package/src/hooks/timelineEditingHelpers.ts +17 -5
- package/src/hooks/timelineTrackVisibility.test.ts +202 -0
- package/src/hooks/timelineTrackVisibility.ts +371 -0
- package/src/hooks/useRenderClipContent.test.ts +68 -2
- package/src/hooks/useRenderClipContent.ts +5 -6
- package/src/hooks/useTimelineEditing.ts +35 -30
- package/src/hooks/useTimelineEditingTypes.ts +30 -0
- package/src/icons/SystemIcons.tsx +12 -4
- package/src/player/components/AudioWaveform.tsx +11 -8
- package/src/player/components/CompositionThumbnail.tsx +14 -11
- package/src/player/components/PlayheadIndicator.tsx +21 -11
- package/src/player/components/Timeline.test.ts +105 -5
- package/src/player/components/Timeline.tsx +37 -47
- package/src/player/components/TimelineCanvas.tsx +275 -256
- package/src/player/components/TimelineClip.test.tsx +105 -0
- package/src/player/components/TimelineClip.tsx +44 -57
- package/src/player/components/TimelineRuler.tsx +17 -23
- package/src/player/components/TimelineShortcutHint.tsx +26 -0
- package/src/player/components/TimelineTypes.ts +18 -0
- package/src/player/components/VideoThumbnail.tsx +16 -13
- package/src/player/components/timelineCallbacks.ts +1 -0
- package/src/player/components/timelineIcons.tsx +2 -44
- package/src/player/components/timelineLayout.ts +18 -22
- package/src/player/components/timelineMotionStyles.test.ts +101 -0
- package/src/player/components/timelineTheme.test.ts +21 -4
- package/src/player/components/timelineTheme.ts +15 -15
- package/src/player/components/useTimelineActiveClips.test.ts +93 -0
- package/src/player/components/useTimelineActiveClips.ts +125 -0
- package/src/player/components/useTimelinePlayhead.ts +6 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +110 -3
- package/src/player/hooks/useExpandedTimelineElements.ts +106 -19
- package/src/player/lib/time.test.ts +1 -0
- package/src/player/lib/timelineDOM.test.ts +48 -1
- package/src/player/lib/timelineDOM.ts +7 -0
- package/src/player/lib/timelineIframeHelpers.ts +1 -0
- package/src/player/store/playerStore.ts +5 -1
- package/src/styles/studio.css +108 -0
- package/src/utils/sourcePatcher.test.ts +11 -0
- package/src/utils/sourcePatcher.ts +6 -4
- package/dist/assets/index-0P10SwC_.css +0 -1
- /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
|
-
|
|
199
|
-
<
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
134
|
-
<
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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.
|
|
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:
|
|
24
|
-
|
|
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
|
|
36
|
+
boxShadow: `0 0 6px ${glowColor}`,
|
|
27
37
|
}}
|
|
28
38
|
/>
|
|
29
|
-
<div className="absolute" style={{ left: "50%", top:
|
|
39
|
+
<div className="absolute" style={{ left: "50%", top: 1, transform: "translateX(-50%)" }}>
|
|
30
40
|
<div
|
|
31
41
|
style={{
|
|
32
|
-
width:
|
|
33
|
-
height:
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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(
|
|
181
|
-
expect(
|
|
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("
|
|
265
|
-
expect(getTimelineScrollLeftForZoomTransition("fit", "fit", 480)).toBe(
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
161
|
+
scrollRef.current = el;
|
|
172
162
|
if (!el) return;
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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={
|
|
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
|
-
<
|
|
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 && (
|