@elah/timeline 0.1.0
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/AudioDropDialog.d.ts +1 -0
- package/dist/AudioDropDialog.js +71 -0
- package/dist/ClipBlock.d.ts +8 -0
- package/dist/ClipBlock.js +372 -0
- package/dist/Playhead.d.ts +9 -0
- package/dist/Playhead.js +89 -0
- package/dist/Ruler.d.ts +12 -0
- package/dist/Ruler.js +56 -0
- package/dist/Timeline.d.ts +13 -0
- package/dist/Timeline.js +210 -0
- package/dist/TrackRow.d.ts +9 -0
- package/dist/TrackRow.js +65 -0
- package/dist/TransitionChip.d.ts +10 -0
- package/dist/TransitionChip.js +76 -0
- package/dist/TransitionPicker.d.ts +18 -0
- package/dist/TransitionPicker.js +119 -0
- package/dist/audioDropDialog.store.d.ts +12 -0
- package/dist/audioDropDialog.store.js +16 -0
- package/dist/elementDrag.d.ts +6 -0
- package/dist/elementDrag.js +1 -0
- package/dist/engine-context.d.ts +1 -0
- package/dist/engine-context.js +2 -0
- package/dist/hooks/index.d.ts +3 -0
- package/dist/hooks/index.js +3 -0
- package/dist/hooks/usePlayback.d.ts +27 -0
- package/dist/hooks/usePlayback.js +2 -0
- package/dist/hooks/useSelection.d.ts +1 -0
- package/dist/hooks/useSelection.js +2 -0
- package/dist/hooks/useTracks.d.ts +1 -0
- package/dist/hooks/useTracks.js +2 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +7 -0
- package/dist/useTimelineDrop.d.ts +1 -0
- package/dist/useTimelineDrop.js +187 -0
- package/package.json +37 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function AudioDropDialog(): import("react/jsx-runtime").JSX.Element | null;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useAudioDropDialogStore } from './audioDropDialog.store';
|
|
3
|
+
const CHOICES = [
|
|
4
|
+
{
|
|
5
|
+
choice: 'both',
|
|
6
|
+
label: 'Video + Audio',
|
|
7
|
+
hint: 'Add the video and its audio on a separate audio track',
|
|
8
|
+
primary: true,
|
|
9
|
+
},
|
|
10
|
+
{ choice: 'video-only', label: 'Video only', hint: 'Drop the audio track' },
|
|
11
|
+
{ choice: 'audio-only', label: 'Audio only', hint: 'Add just the audio, no video' },
|
|
12
|
+
];
|
|
13
|
+
export function AudioDropDialog() {
|
|
14
|
+
const open = useAudioDropDialogStore((s) => s.open);
|
|
15
|
+
const assetName = useAudioDropDialogStore((s) => s.assetName);
|
|
16
|
+
const respond = useAudioDropDialogStore((s) => s.respond);
|
|
17
|
+
if (!open)
|
|
18
|
+
return null;
|
|
19
|
+
return (_jsx("div", { role: "dialog", "aria-modal": "true", "aria-label": "Choose how to add this media", style: {
|
|
20
|
+
position: 'fixed',
|
|
21
|
+
inset: 0,
|
|
22
|
+
zIndex: 1000,
|
|
23
|
+
display: 'flex',
|
|
24
|
+
alignItems: 'center',
|
|
25
|
+
justifyContent: 'center',
|
|
26
|
+
background: 'rgba(5, 7, 12, 0.6)',
|
|
27
|
+
backdropFilter: 'blur(2px)',
|
|
28
|
+
fontFamily: 'sans-serif',
|
|
29
|
+
}, children: _jsxs("div", { style: {
|
|
30
|
+
width: 380,
|
|
31
|
+
maxWidth: '90vw',
|
|
32
|
+
background: '#171D2B',
|
|
33
|
+
border: '1px solid #232938',
|
|
34
|
+
borderRadius: 12,
|
|
35
|
+
boxShadow: '0 24px 60px rgba(0,0,0,0.55)',
|
|
36
|
+
padding: 22,
|
|
37
|
+
}, children: [_jsx("h2", { style: {
|
|
38
|
+
margin: 0,
|
|
39
|
+
fontSize: 15,
|
|
40
|
+
fontWeight: 700,
|
|
41
|
+
color: '#F3F4F6',
|
|
42
|
+
letterSpacing: '-0.01em',
|
|
43
|
+
}, children: "This video has audio" }), _jsxs("p", { style: {
|
|
44
|
+
margin: '6px 0 18px',
|
|
45
|
+
fontSize: 12,
|
|
46
|
+
lineHeight: 1.5,
|
|
47
|
+
color: '#9CA3AF',
|
|
48
|
+
}, children: ["How should", ' ', _jsx("span", { style: { color: '#E5E7EB', fontWeight: 600 }, children: assetName }), ' ', "be added to the timeline?"] }), _jsx("div", { style: { display: 'flex', flexDirection: 'column', gap: 8 }, children: CHOICES.map(({ choice, label, hint, primary }) => (_jsxs("button", { type: "button", onClick: () => respond(choice), style: {
|
|
49
|
+
display: 'flex',
|
|
50
|
+
flexDirection: 'column',
|
|
51
|
+
alignItems: 'flex-start',
|
|
52
|
+
gap: 2,
|
|
53
|
+
width: '100%',
|
|
54
|
+
padding: '10px 14px',
|
|
55
|
+
textAlign: 'left',
|
|
56
|
+
cursor: 'pointer',
|
|
57
|
+
borderRadius: 8,
|
|
58
|
+
border: primary ? '1px solid #2563EB' : '1px solid #2A3142',
|
|
59
|
+
background: primary ? 'rgba(37, 99, 235, 0.16)' : '#1B2230',
|
|
60
|
+
color: '#F3F4F6',
|
|
61
|
+
transition: 'background 0.12s, border-color 0.12s',
|
|
62
|
+
}, onMouseEnter: (e) => {
|
|
63
|
+
e.currentTarget.style.background = primary
|
|
64
|
+
? 'rgba(37, 99, 235, 0.28)'
|
|
65
|
+
: '#222B3C';
|
|
66
|
+
}, onMouseLeave: (e) => {
|
|
67
|
+
e.currentTarget.style.background = primary
|
|
68
|
+
? 'rgba(37, 99, 235, 0.16)'
|
|
69
|
+
: '#1B2230';
|
|
70
|
+
}, children: [_jsx("span", { style: { fontSize: 13, fontWeight: 600 }, children: label }), _jsx("span", { style: { fontSize: 11, color: '#9CA3AF' }, children: hint })] }, choice))) })] }) }));
|
|
71
|
+
}
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { memo, useCallback, useRef, useState } from 'react';
|
|
3
|
+
import { createPortal } from 'react-dom';
|
|
4
|
+
import { useTimeline } from './engine-context';
|
|
5
|
+
import { useSelectionStore } from '@elah/core';
|
|
6
|
+
import { usePlaybackStore } from '@elah/core';
|
|
7
|
+
import { buildSnapPoints, DEFAULT_OVERLAP_TOLERANCE, resolveOverlapEdgeSnap, snapFrame, } from '@elah/core';
|
|
8
|
+
import { useTracksStore } from '@elah/core';
|
|
9
|
+
import { useMediaLibraryStore } from '@elah/core';
|
|
10
|
+
const CLIP_STYLES = {
|
|
11
|
+
video: {
|
|
12
|
+
top: '#3B82F6',
|
|
13
|
+
mid: '#2563EB',
|
|
14
|
+
bottom: '#1D4ED8',
|
|
15
|
+
accent: '#60A5FA',
|
|
16
|
+
},
|
|
17
|
+
audio: {
|
|
18
|
+
top: '#22C55E',
|
|
19
|
+
mid: '#16A34A',
|
|
20
|
+
bottom: '#15803D',
|
|
21
|
+
accent: '#4ADE80',
|
|
22
|
+
},
|
|
23
|
+
text: {
|
|
24
|
+
top: '#A855F7',
|
|
25
|
+
mid: '#9333EA',
|
|
26
|
+
bottom: '#7E22CE',
|
|
27
|
+
accent: '#C084FC',
|
|
28
|
+
},
|
|
29
|
+
image: {
|
|
30
|
+
top: '#FBBF24',
|
|
31
|
+
mid: '#D97706',
|
|
32
|
+
bottom: '#B45309',
|
|
33
|
+
accent: '#FCD34D',
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
const WAVE_BARS = [
|
|
37
|
+
0.35, 0.55, 0.75, 0.45, 0.9, 0.6, 0.8, 0.5, 0.7, 0.4, 0.85, 0.55, 0.65, 0.45, 0.75,
|
|
38
|
+
0.5, 0.95, 0.6, 0.8, 0.45, 0.7, 0.55, 0.85, 0.4, 0.65, 0.75, 0.5, 0.9, 0.6, 0.45,
|
|
39
|
+
];
|
|
40
|
+
const TRIM_HANDLE_WIDTH = 8;
|
|
41
|
+
export const ClipBlock = memo(function ClipBlock({ clip, zoom, trackHeight }) {
|
|
42
|
+
const engine = useTimeline();
|
|
43
|
+
const isSelected = useSelectionStore((s) => s.selectedClipIds.has(clip.id));
|
|
44
|
+
const selectClip = useSelectionStore((s) => s.selectClip);
|
|
45
|
+
const clearSelection = useSelectionStore((s) => s.clearSelection);
|
|
46
|
+
const snapEnabled = usePlaybackStore((s) => s.snapEnabled);
|
|
47
|
+
const asset = useMediaLibraryStore((s) => clip.assetId ? s.assets[clip.assetId] : undefined);
|
|
48
|
+
const blockRef = useRef(null);
|
|
49
|
+
const isDragging = useRef(false);
|
|
50
|
+
const [ctxMenu, setCtxMenu] = useState(null);
|
|
51
|
+
const left = clip.startFrame * zoom;
|
|
52
|
+
const width = Math.max(clip.durationFrames * zoom, 4);
|
|
53
|
+
const blockHeight = trackHeight - 10;
|
|
54
|
+
const palette = CLIP_STYLES[clip.type] ?? CLIP_STYLES.video;
|
|
55
|
+
const stripFrames = asset?.thumbnailStrip ?? (asset?.thumbnailUrl ? [asset.thumbnailUrl] : []);
|
|
56
|
+
const tileAspect = asset?.width && asset?.height ? asset.width / asset.height : 16 / 9;
|
|
57
|
+
const tileWidth = Math.max(12, blockHeight * tileAspect);
|
|
58
|
+
const tileCount = Math.min(40, Math.max(1, Math.ceil(width / tileWidth)));
|
|
59
|
+
const waveform = asset?.waveform;
|
|
60
|
+
let waveBars = WAVE_BARS;
|
|
61
|
+
if (waveform && waveform.length > 0) {
|
|
62
|
+
const count = Math.min(160, Math.max(8, Math.floor(width / 3)));
|
|
63
|
+
const sampled = new Array(count);
|
|
64
|
+
for (let i = 0; i < count; i++) {
|
|
65
|
+
sampled[i] = waveform[Math.floor((i / count) * waveform.length)];
|
|
66
|
+
}
|
|
67
|
+
waveBars = sampled;
|
|
68
|
+
}
|
|
69
|
+
const handleBodyMouseDown = useCallback((e) => {
|
|
70
|
+
if (e.button !== 0)
|
|
71
|
+
return;
|
|
72
|
+
e.stopPropagation();
|
|
73
|
+
selectClip(clip.id);
|
|
74
|
+
const startX = e.clientX;
|
|
75
|
+
const originalStart = clip.startFrame;
|
|
76
|
+
let currentStart = originalStart;
|
|
77
|
+
isDragging.current = false;
|
|
78
|
+
const allClips = useTracksStore.getState().clips;
|
|
79
|
+
const handleMove = (moveEvent) => {
|
|
80
|
+
isDragging.current = true;
|
|
81
|
+
if (blockRef.current) {
|
|
82
|
+
blockRef.current.style.zIndex = '30';
|
|
83
|
+
}
|
|
84
|
+
const deltaX = moveEvent.clientX - startX;
|
|
85
|
+
const deltaFrames = Math.round(deltaX / zoom);
|
|
86
|
+
let nextStart = Math.max(0, originalStart + deltaFrames);
|
|
87
|
+
if (snapEnabled) {
|
|
88
|
+
const snapPoints = buildSnapPoints(allClips, clip.id);
|
|
89
|
+
nextStart = snapFrame(nextStart, snapPoints, Math.max(1, Math.round(5 / zoom)));
|
|
90
|
+
}
|
|
91
|
+
currentStart = nextStart;
|
|
92
|
+
if (blockRef.current) {
|
|
93
|
+
blockRef.current.style.transform = `translateX(${nextStart * zoom - left}px)`;
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
const handleUp = () => {
|
|
97
|
+
window.removeEventListener('mousemove', handleMove);
|
|
98
|
+
window.removeEventListener('mouseup', handleUp);
|
|
99
|
+
if (isDragging.current && currentStart !== originalStart) {
|
|
100
|
+
const trackClips = useTracksStore.getState().clips[clip.trackId] ?? [];
|
|
101
|
+
const settledStart = resolveOverlapEdgeSnap(currentStart, clip, trackClips, DEFAULT_OVERLAP_TOLERANCE);
|
|
102
|
+
engine.moveClip(clip.id, clip.trackId, clip.trackId, settledStart);
|
|
103
|
+
}
|
|
104
|
+
if (blockRef.current) {
|
|
105
|
+
blockRef.current.style.transform = '';
|
|
106
|
+
blockRef.current.style.zIndex = '';
|
|
107
|
+
}
|
|
108
|
+
isDragging.current = false;
|
|
109
|
+
};
|
|
110
|
+
window.addEventListener('mousemove', handleMove);
|
|
111
|
+
window.addEventListener('mouseup', handleUp);
|
|
112
|
+
}, [clip, zoom, engine, selectClip, left, snapEnabled]);
|
|
113
|
+
const handleLeftTrimMouseDown = useCallback((e) => {
|
|
114
|
+
e.stopPropagation();
|
|
115
|
+
selectClip(clip.id);
|
|
116
|
+
const startX = e.clientX;
|
|
117
|
+
const originalStart = clip.startFrame;
|
|
118
|
+
const originalDuration = clip.durationFrames;
|
|
119
|
+
const anchorEnd = originalStart + originalDuration;
|
|
120
|
+
const maxDuration = clip.type === 'text' ? Infinity : clip.sourceDurationFrames;
|
|
121
|
+
const minDuration = Math.max(1, Math.ceil((TRIM_HANDLE_WIDTH * 2) / zoom));
|
|
122
|
+
const calcLeftTrim = (clientX) => {
|
|
123
|
+
const deltaFrames = Math.round((clientX - startX) / zoom);
|
|
124
|
+
let newStart = Math.max(0, originalStart + deltaFrames);
|
|
125
|
+
newStart = Math.min(newStart, anchorEnd - minDuration);
|
|
126
|
+
let newDuration = anchorEnd - newStart;
|
|
127
|
+
if (newDuration > maxDuration) {
|
|
128
|
+
newDuration = maxDuration;
|
|
129
|
+
newStart = anchorEnd - maxDuration;
|
|
130
|
+
}
|
|
131
|
+
return { newStart, newDuration };
|
|
132
|
+
};
|
|
133
|
+
const handleMove = (moveEvent) => {
|
|
134
|
+
const { newStart, newDuration } = calcLeftTrim(moveEvent.clientX);
|
|
135
|
+
if (blockRef.current) {
|
|
136
|
+
blockRef.current.style.left = `${newStart * zoom}px`;
|
|
137
|
+
blockRef.current.style.width = `${newDuration * zoom}px`;
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
const handleUp = (upEvent) => {
|
|
141
|
+
window.removeEventListener('mousemove', handleMove);
|
|
142
|
+
window.removeEventListener('mouseup', handleUp);
|
|
143
|
+
const { newStart, newDuration } = calcLeftTrim(upEvent.clientX);
|
|
144
|
+
if (newStart !== originalStart || newDuration !== originalDuration) {
|
|
145
|
+
engine.trimClip(clip.id, clip.trackId, newStart, newDuration);
|
|
146
|
+
}
|
|
147
|
+
if (blockRef.current) {
|
|
148
|
+
const live = engine.findClip(clip.id)?.clip;
|
|
149
|
+
const liveStart = live?.startFrame ?? originalStart;
|
|
150
|
+
const liveDuration = live?.durationFrames ?? originalDuration;
|
|
151
|
+
blockRef.current.style.left = `${liveStart * zoom}px`;
|
|
152
|
+
blockRef.current.style.width = `${Math.max(liveDuration * zoom, 4)}px`;
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
window.addEventListener('mousemove', handleMove);
|
|
156
|
+
window.addEventListener('mouseup', handleUp);
|
|
157
|
+
}, [clip, zoom, engine, selectClip]);
|
|
158
|
+
const handleRightTrimMouseDown = useCallback((e) => {
|
|
159
|
+
e.stopPropagation();
|
|
160
|
+
selectClip(clip.id);
|
|
161
|
+
const startX = e.clientX;
|
|
162
|
+
const originalDuration = clip.durationFrames;
|
|
163
|
+
const maxDuration = clip.type === 'text' ? Infinity : clip.sourceDurationFrames;
|
|
164
|
+
const minDuration = Math.max(1, Math.ceil((TRIM_HANDLE_WIDTH * 2) / zoom));
|
|
165
|
+
const calcRightTrim = (clientX) => {
|
|
166
|
+
const deltaFrames = Math.round((clientX - startX) / zoom);
|
|
167
|
+
return Math.min(maxDuration, Math.max(minDuration, originalDuration + deltaFrames));
|
|
168
|
+
};
|
|
169
|
+
const handleMove = (moveEvent) => {
|
|
170
|
+
const newDuration = calcRightTrim(moveEvent.clientX);
|
|
171
|
+
if (blockRef.current) {
|
|
172
|
+
blockRef.current.style.width = `${newDuration * zoom}px`;
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
const handleUp = (upEvent) => {
|
|
176
|
+
window.removeEventListener('mousemove', handleMove);
|
|
177
|
+
window.removeEventListener('mouseup', handleUp);
|
|
178
|
+
const newDuration = calcRightTrim(upEvent.clientX);
|
|
179
|
+
if (newDuration !== originalDuration) {
|
|
180
|
+
engine.trimClip(clip.id, clip.trackId, clip.startFrame, newDuration);
|
|
181
|
+
}
|
|
182
|
+
if (blockRef.current) {
|
|
183
|
+
const live = engine.findClip(clip.id)?.clip;
|
|
184
|
+
const liveDuration = live?.durationFrames ?? originalDuration;
|
|
185
|
+
blockRef.current.style.width = `${Math.max(liveDuration * zoom, 4)}px`;
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
window.addEventListener('mousemove', handleMove);
|
|
189
|
+
window.addEventListener('mouseup', handleUp);
|
|
190
|
+
}, [clip, zoom, engine, selectClip]);
|
|
191
|
+
const handleDelete = useCallback((e) => {
|
|
192
|
+
e.stopPropagation();
|
|
193
|
+
engine.removeClip(clip.id, clip.trackId);
|
|
194
|
+
clearSelection();
|
|
195
|
+
}, [clip.id, clip.trackId, engine, clearSelection]);
|
|
196
|
+
const handleContextMenu = useCallback((e) => {
|
|
197
|
+
e.preventDefault();
|
|
198
|
+
e.stopPropagation();
|
|
199
|
+
selectClip(clip.id);
|
|
200
|
+
setCtxMenu({ x: e.clientX, y: e.clientY });
|
|
201
|
+
}, [clip.id, selectClip]);
|
|
202
|
+
const closeCtxMenu = useCallback(() => setCtxMenu(null), []);
|
|
203
|
+
const handleCtxDelete = useCallback(() => {
|
|
204
|
+
engine.removeClip(clip.id, clip.trackId);
|
|
205
|
+
clearSelection();
|
|
206
|
+
setCtxMenu(null);
|
|
207
|
+
}, [clip.id, clip.trackId, engine, clearSelection]);
|
|
208
|
+
return (_jsxs("div", { ref: blockRef, onMouseDown: handleBodyMouseDown, onContextMenu: handleContextMenu, style: {
|
|
209
|
+
position: 'absolute',
|
|
210
|
+
top: 5,
|
|
211
|
+
left,
|
|
212
|
+
width,
|
|
213
|
+
height: blockHeight,
|
|
214
|
+
borderRadius: 7,
|
|
215
|
+
boxSizing: 'border-box',
|
|
216
|
+
background: `linear-gradient(180deg, ${palette.top} 0%, ${palette.mid} 42%, ${palette.bottom} 100%)`,
|
|
217
|
+
border: isSelected
|
|
218
|
+
? '2px solid #FF2D55'
|
|
219
|
+
: `1px solid ${palette.accent}55`,
|
|
220
|
+
boxShadow: isSelected
|
|
221
|
+
? '0 0 14px rgba(255, 45, 85, 0.4), inset 0 1px 0 rgba(255,255,255,0.15)'
|
|
222
|
+
: 'inset 0 1px 0 rgba(255,255,255,0.12), 0 2px 6px rgba(0,0,0,0.35)',
|
|
223
|
+
cursor: 'grab',
|
|
224
|
+
overflow: 'hidden',
|
|
225
|
+
userSelect: 'none',
|
|
226
|
+
willChange: 'transform',
|
|
227
|
+
zIndex: isSelected ? 5 : 1,
|
|
228
|
+
}, children: [(clip.type === 'video' || clip.type === 'image') && stripFrames.length > 0 && (_jsx("div", { style: {
|
|
229
|
+
position: 'absolute',
|
|
230
|
+
inset: 0,
|
|
231
|
+
display: 'flex',
|
|
232
|
+
overflow: 'hidden',
|
|
233
|
+
pointerEvents: 'none',
|
|
234
|
+
borderRadius: 7,
|
|
235
|
+
}, children: Array.from({ length: tileCount }).map((_, i) => {
|
|
236
|
+
const frameIdx = Math.min(stripFrames.length - 1, Math.floor((i / tileCount) * stripFrames.length));
|
|
237
|
+
return (_jsx("div", { style: {
|
|
238
|
+
flex: '1 1 0',
|
|
239
|
+
minWidth: 0,
|
|
240
|
+
height: '100%',
|
|
241
|
+
backgroundImage: `url(${stripFrames[frameIdx]})`,
|
|
242
|
+
backgroundSize: 'cover',
|
|
243
|
+
backgroundPosition: 'center',
|
|
244
|
+
boxShadow: 'inset -1px 0 0 rgba(0,0,0,0.28)',
|
|
245
|
+
} }, i));
|
|
246
|
+
}) })), _jsx("div", { style: {
|
|
247
|
+
position: 'absolute',
|
|
248
|
+
left: 0,
|
|
249
|
+
top: 0,
|
|
250
|
+
bottom: 0,
|
|
251
|
+
width: 3,
|
|
252
|
+
background: palette.accent,
|
|
253
|
+
opacity: 0.85,
|
|
254
|
+
pointerEvents: 'none',
|
|
255
|
+
} }), _jsx("div", { style: {
|
|
256
|
+
position: 'absolute',
|
|
257
|
+
inset: '0 0 55%',
|
|
258
|
+
background: 'linear-gradient(180deg, rgba(255,255,255,0.14) 0%, transparent 100%)',
|
|
259
|
+
pointerEvents: 'none',
|
|
260
|
+
} }), clip.type === 'audio' && (_jsx("div", { style: {
|
|
261
|
+
position: 'absolute',
|
|
262
|
+
left: TRIM_HANDLE_WIDTH,
|
|
263
|
+
right: TRIM_HANDLE_WIDTH,
|
|
264
|
+
bottom: 4,
|
|
265
|
+
height: '46%',
|
|
266
|
+
display: 'flex',
|
|
267
|
+
alignItems: 'flex-end',
|
|
268
|
+
gap: 1,
|
|
269
|
+
opacity: 0.55,
|
|
270
|
+
pointerEvents: 'none',
|
|
271
|
+
}, children: waveBars.map((h, i) => (_jsx("div", { style: {
|
|
272
|
+
flex: '1 1 2px',
|
|
273
|
+
minWidth: 1,
|
|
274
|
+
maxWidth: 3,
|
|
275
|
+
height: `${Math.max(6, h * 100)}%`,
|
|
276
|
+
background: 'rgba(255,255,255,0.85)',
|
|
277
|
+
borderRadius: 1,
|
|
278
|
+
} }, i))) })), (clip.type === 'video' || clip.type === 'image') &&
|
|
279
|
+
stripFrames.length === 0 &&
|
|
280
|
+
width > 28 && (_jsx("div", { style: {
|
|
281
|
+
position: 'absolute',
|
|
282
|
+
left: TRIM_HANDLE_WIDTH + 4,
|
|
283
|
+
top: 4,
|
|
284
|
+
bottom: 4,
|
|
285
|
+
width: 14,
|
|
286
|
+
borderRadius: 3,
|
|
287
|
+
background: 'rgba(0,0,0,0.22)',
|
|
288
|
+
border: '1px solid rgba(255,255,255,0.08)',
|
|
289
|
+
pointerEvents: 'none',
|
|
290
|
+
} })), _jsx("div", { onMouseDown: handleLeftTrimMouseDown, style: {
|
|
291
|
+
position: 'absolute',
|
|
292
|
+
left: 0,
|
|
293
|
+
top: 0,
|
|
294
|
+
width: TRIM_HANDLE_WIDTH,
|
|
295
|
+
height: '100%',
|
|
296
|
+
cursor: 'ew-resize',
|
|
297
|
+
background: 'linear-gradient(90deg, rgba(0,0,0,0.35) 0%, transparent 100%)',
|
|
298
|
+
zIndex: 2,
|
|
299
|
+
} }), _jsx("span", { style: {
|
|
300
|
+
position: 'relative',
|
|
301
|
+
zIndex: 1,
|
|
302
|
+
display: 'block',
|
|
303
|
+
paddingLeft: clip.type === 'video' || clip.type === 'image' ? TRIM_HANDLE_WIDTH + 22 : TRIM_HANDLE_WIDTH + 6,
|
|
304
|
+
paddingRight: TRIM_HANDLE_WIDTH + 6,
|
|
305
|
+
paddingTop: 5,
|
|
306
|
+
fontSize: 10,
|
|
307
|
+
color: 'rgba(255,255,255,0.95)',
|
|
308
|
+
whiteSpace: 'nowrap',
|
|
309
|
+
overflow: 'hidden',
|
|
310
|
+
textOverflow: 'ellipsis',
|
|
311
|
+
fontWeight: 600,
|
|
312
|
+
letterSpacing: '0.01em',
|
|
313
|
+
textShadow: '0 1px 2px rgba(0,0,0,0.45)',
|
|
314
|
+
pointerEvents: 'none',
|
|
315
|
+
}, children: clip.type === 'text' ? (clip.content?.trim() || clip.name) : clip.name }), _jsx("div", { onMouseDown: handleRightTrimMouseDown, style: {
|
|
316
|
+
position: 'absolute',
|
|
317
|
+
right: 0,
|
|
318
|
+
top: 0,
|
|
319
|
+
width: TRIM_HANDLE_WIDTH,
|
|
320
|
+
height: '100%',
|
|
321
|
+
cursor: 'ew-resize',
|
|
322
|
+
background: 'linear-gradient(270deg, rgba(0,0,0,0.35) 0%, transparent 100%)',
|
|
323
|
+
zIndex: 2,
|
|
324
|
+
} }), isSelected && (_jsx("button", { type: "button", onMouseDown: (e) => e.stopPropagation(), onClick: handleDelete, title: "Delete clip (Del)", "aria-label": "Delete clip", style: {
|
|
325
|
+
position: 'absolute',
|
|
326
|
+
top: 3,
|
|
327
|
+
right: TRIM_HANDLE_WIDTH + 3,
|
|
328
|
+
width: 16,
|
|
329
|
+
height: 16,
|
|
330
|
+
padding: 0,
|
|
331
|
+
lineHeight: '14px',
|
|
332
|
+
fontSize: 11,
|
|
333
|
+
color: '#fff',
|
|
334
|
+
background: 'rgba(0,0,0,0.5)',
|
|
335
|
+
border: '1px solid rgba(255,255,255,0.35)',
|
|
336
|
+
borderRadius: 4,
|
|
337
|
+
cursor: 'pointer',
|
|
338
|
+
zIndex: 4,
|
|
339
|
+
display: 'flex',
|
|
340
|
+
alignItems: 'center',
|
|
341
|
+
justifyContent: 'center',
|
|
342
|
+
}, children: "\u00D7" })), ctxMenu && createPortal(_jsxs(_Fragment, { children: [_jsx("div", { style: { position: 'fixed', inset: 0, zIndex: 9998 }, onMouseDown: closeCtxMenu }), _jsx("div", { style: {
|
|
343
|
+
position: 'fixed',
|
|
344
|
+
top: ctxMenu.y,
|
|
345
|
+
left: ctxMenu.x,
|
|
346
|
+
zIndex: 9999,
|
|
347
|
+
background: '#1E2433',
|
|
348
|
+
border: '1px solid #2D3548',
|
|
349
|
+
borderRadius: 6,
|
|
350
|
+
padding: '4px 0',
|
|
351
|
+
minWidth: 140,
|
|
352
|
+
boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
|
|
353
|
+
fontFamily: 'sans-serif',
|
|
354
|
+
}, children: _jsx("button", { type: "button", onMouseDown: (e) => e.stopPropagation(), onClick: handleCtxDelete, style: {
|
|
355
|
+
display: 'block',
|
|
356
|
+
width: '100%',
|
|
357
|
+
padding: '7px 14px',
|
|
358
|
+
textAlign: 'left',
|
|
359
|
+
background: 'none',
|
|
360
|
+
border: 'none',
|
|
361
|
+
color: '#FF6B6B',
|
|
362
|
+
fontSize: 13,
|
|
363
|
+
cursor: 'pointer',
|
|
364
|
+
letterSpacing: '0.01em',
|
|
365
|
+
}, onMouseEnter: (e) => {
|
|
366
|
+
;
|
|
367
|
+
e.currentTarget.style.background = 'rgba(255,107,107,0.12)';
|
|
368
|
+
}, onMouseLeave: (e) => {
|
|
369
|
+
;
|
|
370
|
+
e.currentTarget.style.background = 'none';
|
|
371
|
+
}, children: "Delete" }) })] }), document.body)] }));
|
|
372
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
interface PlayheadProps {
|
|
2
|
+
zoom: number;
|
|
3
|
+
height: number | string;
|
|
4
|
+
color?: string;
|
|
5
|
+
scrollContainerRef?: React.RefObject<HTMLDivElement | null>;
|
|
6
|
+
sidebarWidth?: number;
|
|
7
|
+
}
|
|
8
|
+
export declare function Playhead({ zoom, height, color, scrollContainerRef, sidebarWidth, }: PlayheadProps): import("react/jsx-runtime").JSX.Element;
|
|
9
|
+
export {};
|
package/dist/Playhead.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useRef } from 'react';
|
|
3
|
+
import { usePlaybackStore } from '@elah/core';
|
|
4
|
+
export function Playhead({ zoom, height, color = '#FF2D55', scrollContainerRef, sidebarWidth = 0, }) {
|
|
5
|
+
const needleRef = useRef(null);
|
|
6
|
+
const zoomRef = useRef(zoom);
|
|
7
|
+
const sidebarWidthRef = useRef(sidebarWidth);
|
|
8
|
+
zoomRef.current = zoom;
|
|
9
|
+
sidebarWidthRef.current = sidebarWidth;
|
|
10
|
+
const applyPosition = (frame, scrollLeft) => {
|
|
11
|
+
if (needleRef.current) {
|
|
12
|
+
const left = sidebarWidthRef.current + frame * zoomRef.current - scrollLeft;
|
|
13
|
+
needleRef.current.style.left = `${left}px`;
|
|
14
|
+
needleRef.current.style.visibility =
|
|
15
|
+
left < sidebarWidthRef.current ? 'hidden' : 'visible';
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
return usePlaybackStore.subscribe((state, prev) => {
|
|
20
|
+
if (state.currentFrame === prev.currentFrame)
|
|
21
|
+
return;
|
|
22
|
+
const scrollLeft = scrollContainerRef?.current?.scrollLeft ?? 0;
|
|
23
|
+
applyPosition(state.currentFrame, scrollLeft);
|
|
24
|
+
});
|
|
25
|
+
}, [scrollContainerRef]);
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
const scrollLeft = scrollContainerRef?.current?.scrollLeft ?? 0;
|
|
28
|
+
applyPosition(usePlaybackStore.getState().currentFrame, scrollLeft);
|
|
29
|
+
}, [zoom, sidebarWidth, scrollContainerRef]);
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
const el = scrollContainerRef?.current;
|
|
32
|
+
if (!el)
|
|
33
|
+
return;
|
|
34
|
+
const handleScroll = () => {
|
|
35
|
+
applyPosition(usePlaybackStore.getState().currentFrame, el.scrollLeft);
|
|
36
|
+
};
|
|
37
|
+
el.addEventListener('scroll', handleScroll, { passive: true });
|
|
38
|
+
return () => el.removeEventListener('scroll', handleScroll);
|
|
39
|
+
}, []);
|
|
40
|
+
const handleMouseDown = useCallback((e) => {
|
|
41
|
+
e.preventDefault();
|
|
42
|
+
const store = usePlaybackStore.getState();
|
|
43
|
+
const { setCurrentFrame } = store;
|
|
44
|
+
const wasPlaying = store.isPlaying;
|
|
45
|
+
if (wasPlaying)
|
|
46
|
+
store.pause();
|
|
47
|
+
const handleMove = (moveEvent) => {
|
|
48
|
+
const container = scrollContainerRef?.current;
|
|
49
|
+
const scrollLeft = container?.scrollLeft ?? 0;
|
|
50
|
+
const parent = needleRef.current?.parentElement;
|
|
51
|
+
if (!parent)
|
|
52
|
+
return;
|
|
53
|
+
const rect = parent.getBoundingClientRect();
|
|
54
|
+
const x = moveEvent.clientX - rect.left - sidebarWidthRef.current + scrollLeft;
|
|
55
|
+
setCurrentFrame(Math.max(0, Math.round(x / zoomRef.current)));
|
|
56
|
+
};
|
|
57
|
+
const handleUp = () => {
|
|
58
|
+
window.removeEventListener('mousemove', handleMove);
|
|
59
|
+
window.removeEventListener('mouseup', handleUp);
|
|
60
|
+
if (wasPlaying)
|
|
61
|
+
usePlaybackStore.getState().play();
|
|
62
|
+
};
|
|
63
|
+
window.addEventListener('mousemove', handleMove);
|
|
64
|
+
window.addEventListener('mouseup', handleUp);
|
|
65
|
+
}, [scrollContainerRef]);
|
|
66
|
+
return (_jsx("div", { ref: needleRef, onMouseDown: handleMouseDown, style: {
|
|
67
|
+
position: 'absolute',
|
|
68
|
+
top: 0,
|
|
69
|
+
left: 0,
|
|
70
|
+
width: 2,
|
|
71
|
+
height,
|
|
72
|
+
background: color,
|
|
73
|
+
boxShadow: `0 0 8px ${color}`,
|
|
74
|
+
zIndex: 50,
|
|
75
|
+
cursor: 'col-resize',
|
|
76
|
+
willChange: 'left',
|
|
77
|
+
pointerEvents: 'all',
|
|
78
|
+
}, children: _jsx("div", { style: {
|
|
79
|
+
position: 'absolute',
|
|
80
|
+
top: -2,
|
|
81
|
+
left: -7,
|
|
82
|
+
width: 16,
|
|
83
|
+
height: 14,
|
|
84
|
+
background: color,
|
|
85
|
+
borderRadius: '3px 3px 0 0',
|
|
86
|
+
clipPath: 'polygon(50% 100%, 0 0, 100% 0)',
|
|
87
|
+
boxShadow: `0 0 8px ${color}`,
|
|
88
|
+
} }) }));
|
|
89
|
+
}
|
package/dist/Ruler.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
interface RulerProps {
|
|
2
|
+
fps: number;
|
|
3
|
+
totalFrames: number;
|
|
4
|
+
zoom: number;
|
|
5
|
+
height?: number;
|
|
6
|
+
color?: string;
|
|
7
|
+
tickColor?: string;
|
|
8
|
+
labelColor?: string;
|
|
9
|
+
onSeek?: (frame: number) => void;
|
|
10
|
+
}
|
|
11
|
+
export declare const Ruler: import("react").NamedExoticComponent<RulerProps>;
|
|
12
|
+
export {};
|
package/dist/Ruler.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { memo, useMemo } from 'react';
|
|
3
|
+
import { framesToTimecode } from '@elah/core';
|
|
4
|
+
export const Ruler = memo(function Ruler({ fps, totalFrames, zoom, height = 24, color = '#121722', tickColor = '#232938', labelColor = '#6B7280', onSeek, }) {
|
|
5
|
+
const contentWidth = totalFrames * zoom;
|
|
6
|
+
const ticks = useMemo(() => {
|
|
7
|
+
const pixelsPerFrame = zoom;
|
|
8
|
+
const pixelsPerSecond = fps * pixelsPerFrame;
|
|
9
|
+
const rawSeconds = 80 / pixelsPerSecond;
|
|
10
|
+
const intervals = [
|
|
11
|
+
1 / fps,
|
|
12
|
+
0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 1200, 1800,
|
|
13
|
+
];
|
|
14
|
+
const secondsPerTick = intervals.find((i) => i >= rawSeconds) ?? intervals[intervals.length - 1];
|
|
15
|
+
const framesPerTick = Math.max(1, Math.round(secondsPerTick * fps));
|
|
16
|
+
const result = [];
|
|
17
|
+
for (let frame = 0; frame <= totalFrames + framesPerTick; frame += framesPerTick) {
|
|
18
|
+
result.push({ frame, label: framesToTimecode(frame, fps) });
|
|
19
|
+
}
|
|
20
|
+
return result;
|
|
21
|
+
}, [fps, totalFrames, zoom]);
|
|
22
|
+
const handleClick = (e) => {
|
|
23
|
+
if (!onSeek)
|
|
24
|
+
return;
|
|
25
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
26
|
+
const x = e.clientX - rect.left;
|
|
27
|
+
onSeek(Math.max(0, Math.round(x / zoom)));
|
|
28
|
+
};
|
|
29
|
+
return (_jsx("div", { style: {
|
|
30
|
+
position: 'relative',
|
|
31
|
+
width: contentWidth,
|
|
32
|
+
minWidth: '100%',
|
|
33
|
+
height,
|
|
34
|
+
background: color,
|
|
35
|
+
flexShrink: 0,
|
|
36
|
+
cursor: onSeek ? 'pointer' : 'default',
|
|
37
|
+
userSelect: 'none',
|
|
38
|
+
}, onClick: handleClick, children: ticks.map(({ frame, label }) => (_jsxs("div", { style: {
|
|
39
|
+
position: 'absolute',
|
|
40
|
+
left: frame * zoom,
|
|
41
|
+
top: 0,
|
|
42
|
+
display: 'flex',
|
|
43
|
+
flexDirection: 'column',
|
|
44
|
+
alignItems: 'flex-start',
|
|
45
|
+
}, children: [_jsx("div", { style: {
|
|
46
|
+
width: 1,
|
|
47
|
+
height: height * 0.5,
|
|
48
|
+
background: tickColor,
|
|
49
|
+
} }), _jsx("span", { style: {
|
|
50
|
+
fontSize: 9,
|
|
51
|
+
color: labelColor,
|
|
52
|
+
whiteSpace: 'nowrap',
|
|
53
|
+
transform: 'translateX(3px)',
|
|
54
|
+
fontFamily: 'monospace',
|
|
55
|
+
}, children: label })] }, frame))) }));
|
|
56
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { TimelineEngine } from '@elah/core';
|
|
2
|
+
import type { PlaybackEngine } from '@elah/core';
|
|
3
|
+
export interface TimelineRef {
|
|
4
|
+
engine: TimelineEngine;
|
|
5
|
+
playback: PlaybackEngine;
|
|
6
|
+
fitToWindow: () => void;
|
|
7
|
+
}
|
|
8
|
+
export interface TimelineProps {
|
|
9
|
+
fps?: number;
|
|
10
|
+
className?: string;
|
|
11
|
+
style?: React.CSSProperties;
|
|
12
|
+
}
|
|
13
|
+
export declare const Timeline: import("react").MemoExoticComponent<import("react").ForwardRefExoticComponent<TimelineProps & import("react").RefAttributes<TimelineRef>>>;
|