@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
package/dist/Timeline.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef, } from 'react';
|
|
3
|
+
import { useTracksStore } from '@elah/core';
|
|
4
|
+
import { usePlaybackStore } from '@elah/core';
|
|
5
|
+
import { useSelectionStore } from '@elah/core';
|
|
6
|
+
import { splitClipAtPlayhead } from '@elah/core';
|
|
7
|
+
import { useEditor } from '@elah/core';
|
|
8
|
+
import { Ruler } from './Ruler';
|
|
9
|
+
import { Playhead } from './Playhead';
|
|
10
|
+
import { TrackRow } from './TrackRow';
|
|
11
|
+
import { AudioDropDialog } from './AudioDropDialog';
|
|
12
|
+
const SIDEBAR_WIDTH = 160;
|
|
13
|
+
function buildPasteOptions(clip, startFrame) {
|
|
14
|
+
const base = {
|
|
15
|
+
trackId: clip.trackId,
|
|
16
|
+
name: clip.name,
|
|
17
|
+
startFrame,
|
|
18
|
+
durationFrames: clip.durationFrames,
|
|
19
|
+
volume: clip.volume,
|
|
20
|
+
opacity: clip.opacity,
|
|
21
|
+
transform: clip.transform,
|
|
22
|
+
};
|
|
23
|
+
if (clip.type === 'text') {
|
|
24
|
+
return {
|
|
25
|
+
...base,
|
|
26
|
+
type: 'text',
|
|
27
|
+
text: {
|
|
28
|
+
content: clip.content ?? '',
|
|
29
|
+
fontSize: clip.fontSize,
|
|
30
|
+
color: clip.color,
|
|
31
|
+
fontFamily: clip.fontFamily,
|
|
32
|
+
fontWeight: clip.fontWeight,
|
|
33
|
+
textAlign: clip.textAlign,
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
...base,
|
|
39
|
+
type: clip.type,
|
|
40
|
+
src: clip.src ?? '',
|
|
41
|
+
assetId: clip.assetId,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
export const Timeline = memo(forwardRef(function Timeline({ fps = 30, className, style }, ref) {
|
|
45
|
+
const { engine, playback } = useEditor();
|
|
46
|
+
const scrollRef = useRef(null);
|
|
47
|
+
const rulerWrapRef = useRef(null);
|
|
48
|
+
const clipboardRef = useRef([]);
|
|
49
|
+
const fitToWindow = useCallback(() => {
|
|
50
|
+
const el = scrollRef.current;
|
|
51
|
+
if (!el)
|
|
52
|
+
return;
|
|
53
|
+
const available = el.clientWidth - SIDEBAR_WIDTH;
|
|
54
|
+
if (available <= 0)
|
|
55
|
+
return;
|
|
56
|
+
const totalFrames = useTracksStore.getState().totalFrames;
|
|
57
|
+
const frames = Math.max(totalFrames, fps * 10);
|
|
58
|
+
usePlaybackStore.getState().setZoom(available / frames);
|
|
59
|
+
}, [fps]);
|
|
60
|
+
useImperativeHandle(ref, () => ({ engine, playback, fitToWindow }), [engine, playback, fitToWindow]);
|
|
61
|
+
const tracks = useTracksStore((s) => s.tracks);
|
|
62
|
+
const totalFrames = useTracksStore((s) => s.totalFrames);
|
|
63
|
+
const zoom = usePlaybackStore((s) => s.zoom);
|
|
64
|
+
const setZoom = usePlaybackStore((s) => s.setZoom);
|
|
65
|
+
const setCurrentFrame = usePlaybackStore((s) => s.setCurrentFrame);
|
|
66
|
+
const syncRulerScroll = useCallback(() => {
|
|
67
|
+
if (rulerWrapRef.current && scrollRef.current) {
|
|
68
|
+
rulerWrapRef.current.scrollLeft = scrollRef.current.scrollLeft;
|
|
69
|
+
}
|
|
70
|
+
}, []);
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
const el = scrollRef.current;
|
|
73
|
+
if (!el)
|
|
74
|
+
return;
|
|
75
|
+
const handleWheel = (e) => {
|
|
76
|
+
if (!e.ctrlKey && !e.metaKey)
|
|
77
|
+
return;
|
|
78
|
+
e.preventDefault();
|
|
79
|
+
const direction = e.deltaY > 0 ? -0.5 : 0.5;
|
|
80
|
+
setZoom(usePlaybackStore.getState().zoom + direction);
|
|
81
|
+
};
|
|
82
|
+
el.addEventListener('wheel', handleWheel, { passive: false });
|
|
83
|
+
return () => el.removeEventListener('wheel', handleWheel);
|
|
84
|
+
}, [setZoom]);
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
const handleKey = (e) => {
|
|
87
|
+
const target = e.target;
|
|
88
|
+
if (target.tagName === 'INPUT' ||
|
|
89
|
+
target.tagName === 'TEXTAREA' ||
|
|
90
|
+
target.isContentEditable)
|
|
91
|
+
return;
|
|
92
|
+
if (e.code === 'Space') {
|
|
93
|
+
e.preventDefault();
|
|
94
|
+
usePlaybackStore.getState().togglePlayPause();
|
|
95
|
+
}
|
|
96
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
|
|
97
|
+
e.preventDefault();
|
|
98
|
+
engine.undo();
|
|
99
|
+
}
|
|
100
|
+
if ((e.ctrlKey || e.metaKey) &&
|
|
101
|
+
(e.key === 'y' || (e.key === 'z' && e.shiftKey))) {
|
|
102
|
+
e.preventDefault();
|
|
103
|
+
engine.redo();
|
|
104
|
+
}
|
|
105
|
+
if (e.key === 's' &&
|
|
106
|
+
!e.ctrlKey &&
|
|
107
|
+
!e.metaKey &&
|
|
108
|
+
!e.shiftKey &&
|
|
109
|
+
!e.altKey) {
|
|
110
|
+
e.preventDefault();
|
|
111
|
+
const result = splitClipAtPlayhead(engine);
|
|
112
|
+
if (!result.ok) {
|
|
113
|
+
console.warn('[timeline] split-at-playhead failed:', result.reason);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
117
|
+
const selected = useSelectionStore.getState().selectedClipIds;
|
|
118
|
+
if (selected.size === 0)
|
|
119
|
+
return;
|
|
120
|
+
e.preventDefault();
|
|
121
|
+
engine.batch(() => {
|
|
122
|
+
for (const clipId of selected) {
|
|
123
|
+
const found = engine.findClip(clipId);
|
|
124
|
+
if (found)
|
|
125
|
+
engine.removeClip(clipId, found.trackId);
|
|
126
|
+
}
|
|
127
|
+
}, 'Delete clip');
|
|
128
|
+
useSelectionStore.getState().clearSelection();
|
|
129
|
+
}
|
|
130
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 'c' && !e.shiftKey) {
|
|
131
|
+
const selected = useSelectionStore.getState().selectedClipIds;
|
|
132
|
+
if (selected.size === 0)
|
|
133
|
+
return;
|
|
134
|
+
e.preventDefault();
|
|
135
|
+
const snaps = [];
|
|
136
|
+
for (const clipId of selected) {
|
|
137
|
+
const found = engine.findClip(clipId);
|
|
138
|
+
if (found)
|
|
139
|
+
snaps.push({ ...found.clip });
|
|
140
|
+
}
|
|
141
|
+
clipboardRef.current = snaps;
|
|
142
|
+
}
|
|
143
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 'v' && !e.shiftKey) {
|
|
144
|
+
const clips = clipboardRef.current;
|
|
145
|
+
if (clips.length === 0)
|
|
146
|
+
return;
|
|
147
|
+
e.preventDefault();
|
|
148
|
+
const minStart = Math.min(...clips.map((c) => c.startFrame));
|
|
149
|
+
const pasteFrame = Math.max(...clips.map((c) => c.startFrame + c.durationFrames));
|
|
150
|
+
const newIds = [];
|
|
151
|
+
engine.batch(() => {
|
|
152
|
+
for (const src of clips) {
|
|
153
|
+
const offset = src.startFrame - minStart;
|
|
154
|
+
const options = buildPasteOptions(src, pasteFrame + offset);
|
|
155
|
+
const newClip = engine.addClip(options);
|
|
156
|
+
if (src.sourceStartFrame !== 0 || src.sourceDurationFrames !== src.durationFrames) {
|
|
157
|
+
engine.updateClip(newClip.id, newClip.trackId, {
|
|
158
|
+
sourceStartFrame: src.sourceStartFrame,
|
|
159
|
+
sourceDurationFrames: src.sourceDurationFrames,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
newIds.push(newClip.id);
|
|
163
|
+
}
|
|
164
|
+
}, 'Paste clip');
|
|
165
|
+
useSelectionStore.getState().selectClips(newIds);
|
|
166
|
+
}
|
|
167
|
+
if (e.code === 'ArrowRight' && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
|
168
|
+
e.preventDefault();
|
|
169
|
+
const { currentFrame, setCurrentFrame } = usePlaybackStore.getState();
|
|
170
|
+
setCurrentFrame(currentFrame + 1);
|
|
171
|
+
}
|
|
172
|
+
if (e.code === 'ArrowLeft' && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
|
173
|
+
e.preventDefault();
|
|
174
|
+
const { currentFrame, setCurrentFrame } = usePlaybackStore.getState();
|
|
175
|
+
setCurrentFrame(Math.max(0, currentFrame - 1));
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
window.addEventListener('keydown', handleKey);
|
|
179
|
+
return () => window.removeEventListener('keydown', handleKey);
|
|
180
|
+
}, [engine]);
|
|
181
|
+
const rulerHeight = 24;
|
|
182
|
+
const totalHeight = tracks.reduce((sum, t) => sum + t.height, 0);
|
|
183
|
+
return (_jsxs("div", { className: className, style: {
|
|
184
|
+
display: 'flex',
|
|
185
|
+
flexDirection: 'column',
|
|
186
|
+
background: '#0A0D14',
|
|
187
|
+
color: '#F3F4F6',
|
|
188
|
+
overflow: 'hidden',
|
|
189
|
+
position: 'relative',
|
|
190
|
+
fontFamily: 'sans-serif',
|
|
191
|
+
...style,
|
|
192
|
+
}, children: [_jsxs("div", { style: { display: 'flex' }, children: [_jsx("div", { style: {
|
|
193
|
+
width: SIDEBAR_WIDTH,
|
|
194
|
+
flexShrink: 0,
|
|
195
|
+
height: rulerHeight,
|
|
196
|
+
background: '#121722',
|
|
197
|
+
borderRight: '1px solid #232938',
|
|
198
|
+
borderBottom: '1px solid #1A1F2B',
|
|
199
|
+
} }), _jsx("div", { ref: rulerWrapRef, style: { flex: 1, overflow: 'hidden' }, children: _jsx(Ruler, { fps: fps, totalFrames: Math.max(totalFrames, fps * 10), zoom: zoom, height: rulerHeight, onSeek: setCurrentFrame }) })] }), _jsxs("div", { ref: scrollRef, onScroll: syncRulerScroll, style: {
|
|
200
|
+
flex: 1,
|
|
201
|
+
overflow: 'auto',
|
|
202
|
+
position: 'relative',
|
|
203
|
+
minHeight: 0,
|
|
204
|
+
}, children: [tracks.map((track) => (_jsx(TrackRow, { track: track, totalFrames: Math.max(totalFrames, fps * 10), zoom: zoom, fps: fps }, track.id))), tracks.length === 0 && (_jsx("div", { style: {
|
|
205
|
+
padding: 24,
|
|
206
|
+
color: '#555',
|
|
207
|
+
fontSize: 13,
|
|
208
|
+
textAlign: 'center',
|
|
209
|
+
}, children: "No tracks yet. Add a track to get started." }))] }), _jsx(Playhead, { zoom: zoom, height: "100%", scrollContainerRef: scrollRef, sidebarWidth: SIDEBAR_WIDTH }), _jsx(AudioDropDialog, {})] }));
|
|
210
|
+
}));
|
package/dist/TrackRow.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { memo, useState, useMemo } from 'react';
|
|
3
|
+
import { useTracksStore } from '@elah/core';
|
|
4
|
+
import { useSelectionStore } from '@elah/core';
|
|
5
|
+
import { ClipBlock } from './ClipBlock';
|
|
6
|
+
import { TransitionChip } from './TransitionChip';
|
|
7
|
+
import { useTimelineDrop } from './useTimelineDrop';
|
|
8
|
+
export const TrackRow = memo(function TrackRow({ track, totalFrames, zoom, fps, }) {
|
|
9
|
+
const rawClips = useTracksStore((s) => s.clips[track.id]) ?? [];
|
|
10
|
+
const clips = useMemo(() => [...rawClips].sort((a, b) => a.startFrame - b.startFrame), [rawClips]);
|
|
11
|
+
const adjacentPairs = useMemo(() => {
|
|
12
|
+
if (track.kind === 'audio' || track.kind === 'text')
|
|
13
|
+
return [];
|
|
14
|
+
const pairs = [];
|
|
15
|
+
for (let i = 0; i < clips.length - 1; i++) {
|
|
16
|
+
const a = clips[i];
|
|
17
|
+
const b = clips[i + 1];
|
|
18
|
+
const gap = b.startFrame - (a.startFrame + a.durationFrames);
|
|
19
|
+
if (gap >= 0 && gap <= 2) {
|
|
20
|
+
pairs.push({ from: a, to: b });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return pairs;
|
|
24
|
+
}, [clips, track.kind]);
|
|
25
|
+
const isActive = useSelectionStore((s) => s.activeTrackId === track.id);
|
|
26
|
+
const setActiveTrack = useSelectionStore((s) => s.setActiveTrack);
|
|
27
|
+
const [laneEl, setLaneEl] = useState(null);
|
|
28
|
+
useTimelineDrop(track.id, laneEl);
|
|
29
|
+
const rowMinWidth = Math.max(totalFrames * zoom, 800);
|
|
30
|
+
const kindAccent = track.kind === 'video'
|
|
31
|
+
? '#2563EB'
|
|
32
|
+
: track.kind === 'audio'
|
|
33
|
+
? '#16A34A'
|
|
34
|
+
: '#9333EA';
|
|
35
|
+
return (_jsxs("div", { style: { display: 'flex', height: track.height }, children: [_jsx("div", { onClick: () => setActiveTrack(track.id), style: {
|
|
36
|
+
position: 'sticky',
|
|
37
|
+
left: 0,
|
|
38
|
+
zIndex: 6,
|
|
39
|
+
width: 160,
|
|
40
|
+
flexShrink: 0,
|
|
41
|
+
borderLeft: `3px solid ${kindAccent}`,
|
|
42
|
+
borderRight: '1px solid #232938',
|
|
43
|
+
borderBottom: '1px solid #1A1F2B',
|
|
44
|
+
background: isActive ? '#171D2B' : '#121722',
|
|
45
|
+
display: 'flex',
|
|
46
|
+
alignItems: 'center',
|
|
47
|
+
paddingLeft: 12,
|
|
48
|
+
cursor: 'pointer',
|
|
49
|
+
userSelect: 'none',
|
|
50
|
+
}, children: _jsx("span", { style: {
|
|
51
|
+
fontSize: 11,
|
|
52
|
+
color: isActive ? '#F3F4F6' : '#A7AFBF',
|
|
53
|
+
fontWeight: isActive ? 600 : 500,
|
|
54
|
+
overflow: 'hidden',
|
|
55
|
+
whiteSpace: 'nowrap',
|
|
56
|
+
textOverflow: 'ellipsis',
|
|
57
|
+
}, children: track.name }) }), _jsxs("div", { ref: setLaneEl, style: {
|
|
58
|
+
position: 'relative',
|
|
59
|
+
flex: 1,
|
|
60
|
+
minWidth: rowMinWidth,
|
|
61
|
+
borderBottom: '1px solid #1A1F2B',
|
|
62
|
+
background: isActive ? '#0D1017' : '#0A0D14',
|
|
63
|
+
overflow: 'visible',
|
|
64
|
+
}, children: [clips.map((clip) => (_jsx(ClipBlock, { clip: clip, zoom: zoom, trackHeight: track.height }, clip.id))), adjacentPairs.map(({ from, to }) => (_jsx(TransitionChip, { fromClip: from, toClip: to, zoom: zoom, trackHeight: track.height, fps: fps }, `${from.id}-${to.id}`)))] })] }));
|
|
65
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Clip } from '@elah/core';
|
|
2
|
+
interface TransitionChipProps {
|
|
3
|
+
fromClip: Clip;
|
|
4
|
+
toClip: Clip;
|
|
5
|
+
zoom: number;
|
|
6
|
+
trackHeight: number;
|
|
7
|
+
fps: number;
|
|
8
|
+
}
|
|
9
|
+
export declare const TransitionChip: import("react").NamedExoticComponent<TransitionChipProps>;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { memo, useState, useCallback } from 'react';
|
|
3
|
+
import { useTimeline } from './engine-context';
|
|
4
|
+
import { useTransitionsStore } from '@elah/core';
|
|
5
|
+
import { TransitionPicker } from './TransitionPicker';
|
|
6
|
+
const HIT_W = 24;
|
|
7
|
+
const DIAMOND = 16;
|
|
8
|
+
export const TransitionChip = memo(function TransitionChip({ fromClip, toClip, zoom, trackHeight, fps }) {
|
|
9
|
+
const engine = useTimeline();
|
|
10
|
+
const [pickerPos, setPickerPos] = useState(null);
|
|
11
|
+
const pickerOpen = pickerPos !== null;
|
|
12
|
+
const [hovered, setHovered] = useState(false);
|
|
13
|
+
const transition = useTransitionsStore((s) => s.transitions.find((t) => t.fromClipId === fromClip.id && t.toClipId === toClip.id));
|
|
14
|
+
const cutX = toClip.startFrame * zoom;
|
|
15
|
+
const hasTransition = Boolean(transition);
|
|
16
|
+
const lineColor = hasTransition
|
|
17
|
+
? 'rgba(107, 140, 255, 0.9)'
|
|
18
|
+
: hovered
|
|
19
|
+
? 'rgba(255,255,255,0.55)'
|
|
20
|
+
: 'rgba(255,255,255,0.18)';
|
|
21
|
+
const handleAdd = useCallback((kind, durationFrames, offsetFrames) => {
|
|
22
|
+
engine.addTransition({
|
|
23
|
+
fromClipId: fromClip.id,
|
|
24
|
+
toClipId: toClip.id,
|
|
25
|
+
trackId: fromClip.trackId,
|
|
26
|
+
kind,
|
|
27
|
+
durationFrames,
|
|
28
|
+
offsetFrames,
|
|
29
|
+
});
|
|
30
|
+
}, [engine, fromClip, toClip]);
|
|
31
|
+
const handleUpdate = useCallback((patch) => {
|
|
32
|
+
if (transition)
|
|
33
|
+
engine.updateTransition(transition.id, patch);
|
|
34
|
+
}, [engine, transition]);
|
|
35
|
+
const handleRemove = useCallback(() => {
|
|
36
|
+
if (transition)
|
|
37
|
+
engine.removeTransition(transition.id);
|
|
38
|
+
}, [engine, transition]);
|
|
39
|
+
const diamondTop = (trackHeight - DIAMOND) / 2;
|
|
40
|
+
return (_jsxs(_Fragment, { children: [_jsx("div", { style: {
|
|
41
|
+
position: 'absolute',
|
|
42
|
+
left: cutX - 0.5,
|
|
43
|
+
top: 5,
|
|
44
|
+
width: 1,
|
|
45
|
+
height: trackHeight - 10,
|
|
46
|
+
background: lineColor,
|
|
47
|
+
transition: 'background 0.12s',
|
|
48
|
+
pointerEvents: 'none',
|
|
49
|
+
zIndex: 8,
|
|
50
|
+
} }), _jsx("div", { onMouseEnter: () => setHovered(true), onMouseLeave: () => setHovered(false), onClick: (e) => {
|
|
51
|
+
e.stopPropagation();
|
|
52
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
53
|
+
setPickerPos({ x: rect.left + rect.width / 2, y: rect.top });
|
|
54
|
+
}, title: hasTransition ? `${transition.kind} — click to change` : 'Add transition', style: {
|
|
55
|
+
position: 'absolute',
|
|
56
|
+
left: cutX - HIT_W / 2,
|
|
57
|
+
top: 0,
|
|
58
|
+
width: HIT_W,
|
|
59
|
+
height: trackHeight,
|
|
60
|
+
zIndex: 11,
|
|
61
|
+
cursor: 'pointer',
|
|
62
|
+
display: 'flex',
|
|
63
|
+
alignItems: 'center',
|
|
64
|
+
justifyContent: 'center',
|
|
65
|
+
}, children: _jsx("div", { style: {
|
|
66
|
+
position: 'absolute',
|
|
67
|
+
top: diamondTop,
|
|
68
|
+
width: DIAMOND,
|
|
69
|
+
height: DIAMOND,
|
|
70
|
+
display: 'flex',
|
|
71
|
+
alignItems: 'center',
|
|
72
|
+
justifyContent: 'center',
|
|
73
|
+
opacity: hasTransition ? 1 : hovered ? 1 : 0,
|
|
74
|
+
transition: 'opacity 0.12s',
|
|
75
|
+
}, children: hasTransition ? (_jsx("svg", { width: DIAMOND, height: DIAMOND, viewBox: "0 0 16 16", children: _jsx("rect", { x: 2, y: 2, width: 12, height: 12, rx: 2, transform: "rotate(45 8 8)", fill: "#6B8CFF", stroke: "#A5B4FC", strokeWidth: 1 }) })) : (_jsxs("svg", { width: DIAMOND, height: DIAMOND, viewBox: "0 0 16 16", children: [_jsx("rect", { x: 3, y: 3, width: 10, height: 10, rx: 1.5, transform: "rotate(45 8 8)", fill: "rgba(255,255,255,0.12)", stroke: "rgba(255,255,255,0.7)", strokeWidth: 1.5 }), _jsx("line", { x1: "8", y1: "5", x2: "8", y2: "11", stroke: "rgba(255,255,255,0.7)", strokeWidth: 1.2 }), _jsx("line", { x1: "5", y1: "8", x2: "11", y2: "8", stroke: "rgba(255,255,255,0.7)", strokeWidth: 1.2 })] })) }) }), pickerOpen && pickerPos && (_jsx(TransitionPicker, { anchorX: pickerPos.x, anchorY: pickerPos.y, cutFrame: toClip.startFrame, fps: fps, existing: transition, onAdd: handleAdd, onUpdate: handleUpdate, onRemove: handleRemove, onClose: () => setPickerPos(null) }))] }));
|
|
76
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { TransitionKind, Transition } from '@elah/core';
|
|
2
|
+
interface TransitionPickerProps {
|
|
3
|
+
anchorX: number;
|
|
4
|
+
anchorY: number;
|
|
5
|
+
cutFrame: number;
|
|
6
|
+
fps: number;
|
|
7
|
+
existing?: Transition;
|
|
8
|
+
onAdd: (kind: TransitionKind, durationFrames: number, offsetFrames: number) => void;
|
|
9
|
+
onUpdate: (patch: {
|
|
10
|
+
durationFrames?: number;
|
|
11
|
+
offsetFrames?: number;
|
|
12
|
+
kind?: TransitionKind;
|
|
13
|
+
}) => void;
|
|
14
|
+
onRemove: () => void;
|
|
15
|
+
onClose: () => void;
|
|
16
|
+
}
|
|
17
|
+
export declare function TransitionPicker({ anchorX, anchorY, cutFrame, fps, existing, onAdd, onUpdate, onRemove, onClose, }: TransitionPickerProps): import("react/jsx-runtime").JSX.Element;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
const OPTIONS = [
|
|
4
|
+
{
|
|
5
|
+
kind: 'fade',
|
|
6
|
+
label: 'Fade',
|
|
7
|
+
icon: 'M4 12 Q12 4 20 12 Q12 20 4 12Z',
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
kind: 'slide',
|
|
11
|
+
label: 'Slide',
|
|
12
|
+
icon: 'M4 8h16M4 12h10M4 16h7',
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
kind: 'wipe',
|
|
16
|
+
label: 'Wipe',
|
|
17
|
+
icon: 'M4 4h16v16H4z M12 4v16',
|
|
18
|
+
},
|
|
19
|
+
];
|
|
20
|
+
const DEFAULT_DURATION = 15;
|
|
21
|
+
const MIN_DURATION = 2;
|
|
22
|
+
const MAX_DURATION = 120;
|
|
23
|
+
function deriveOffset(existing, cutFrame) {
|
|
24
|
+
const half = Math.floor(existing.durationFrames / 2);
|
|
25
|
+
const center = existing.startFrame + half;
|
|
26
|
+
return center - cutFrame;
|
|
27
|
+
}
|
|
28
|
+
export function TransitionPicker({ anchorX, anchorY, cutFrame, fps, existing, onAdd, onUpdate, onRemove, onClose, }) {
|
|
29
|
+
const ref = useRef(null);
|
|
30
|
+
const [durationFrames, setDurationFrames] = useState(existing?.durationFrames ?? DEFAULT_DURATION);
|
|
31
|
+
const [offsetFrames, setOffsetFrames] = useState(existing ? deriveOffset(existing, cutFrame) : 0);
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
const handleClick = (e) => {
|
|
34
|
+
if (ref.current && !ref.current.contains(e.target))
|
|
35
|
+
onClose();
|
|
36
|
+
};
|
|
37
|
+
document.addEventListener('mousedown', handleClick);
|
|
38
|
+
return () => document.removeEventListener('mousedown', handleClick);
|
|
39
|
+
}, [onClose]);
|
|
40
|
+
const half = Math.max(1, Math.floor(durationFrames / 2));
|
|
41
|
+
const before = half - offsetFrames;
|
|
42
|
+
const after = half + offsetFrames;
|
|
43
|
+
const maxOffset = half - 1;
|
|
44
|
+
const durationSec = (durationFrames / fps).toFixed(2);
|
|
45
|
+
const handleDurationChange = (val) => {
|
|
46
|
+
const clamped = Math.max(MIN_DURATION, Math.min(MAX_DURATION, val));
|
|
47
|
+
setDurationFrames(clamped);
|
|
48
|
+
const newHalf = Math.max(1, Math.floor(clamped / 2));
|
|
49
|
+
const clampedOffset = Math.max(-(newHalf - 1), Math.min(newHalf - 1, offsetFrames));
|
|
50
|
+
setOffsetFrames(clampedOffset);
|
|
51
|
+
if (existing)
|
|
52
|
+
onUpdate({ durationFrames: clamped, offsetFrames: clampedOffset });
|
|
53
|
+
};
|
|
54
|
+
const handleOffsetChange = (val) => {
|
|
55
|
+
const clamped = Math.max(-maxOffset, Math.min(maxOffset, val));
|
|
56
|
+
setOffsetFrames(clamped);
|
|
57
|
+
if (existing)
|
|
58
|
+
onUpdate({ offsetFrames: clamped });
|
|
59
|
+
};
|
|
60
|
+
return (_jsxs("div", { ref: ref, style: {
|
|
61
|
+
position: 'fixed',
|
|
62
|
+
left: anchorX,
|
|
63
|
+
top: anchorY - 200,
|
|
64
|
+
zIndex: 1000,
|
|
65
|
+
background: '#1A1F2B',
|
|
66
|
+
border: '1px solid #2D3548',
|
|
67
|
+
borderRadius: 10,
|
|
68
|
+
padding: 10,
|
|
69
|
+
boxShadow: '0 8px 32px rgba(0,0,0,0.6)',
|
|
70
|
+
width: 184,
|
|
71
|
+
}, children: [_jsx("div", { style: {
|
|
72
|
+
fontSize: 10,
|
|
73
|
+
color: '#6B7280',
|
|
74
|
+
fontWeight: 600,
|
|
75
|
+
letterSpacing: '0.08em',
|
|
76
|
+
textTransform: 'uppercase',
|
|
77
|
+
marginBottom: 8,
|
|
78
|
+
paddingLeft: 2,
|
|
79
|
+
}, children: "Transition" }), _jsx("div", { style: { display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 6 }, children: OPTIONS.map((opt) => {
|
|
80
|
+
const isActive = existing?.kind === opt.kind;
|
|
81
|
+
return (_jsxs("button", { type: "button", onClick: () => {
|
|
82
|
+
if (existing) {
|
|
83
|
+
onUpdate({ kind: opt.kind });
|
|
84
|
+
onClose();
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
onAdd(opt.kind, durationFrames, offsetFrames);
|
|
88
|
+
onClose();
|
|
89
|
+
}
|
|
90
|
+
}, style: {
|
|
91
|
+
display: 'flex',
|
|
92
|
+
flexDirection: 'column',
|
|
93
|
+
alignItems: 'center',
|
|
94
|
+
gap: 4,
|
|
95
|
+
padding: '8px 6px',
|
|
96
|
+
background: isActive ? '#3B4A6B' : '#232938',
|
|
97
|
+
border: isActive ? '1px solid #6B8CFF' : '1px solid #2D3548',
|
|
98
|
+
borderRadius: 7,
|
|
99
|
+
cursor: 'pointer',
|
|
100
|
+
transition: 'background 0.12s',
|
|
101
|
+
}, onMouseEnter: (e) => {
|
|
102
|
+
if (!isActive)
|
|
103
|
+
e.currentTarget.style.background = '#2D3548';
|
|
104
|
+
}, onMouseLeave: (e) => {
|
|
105
|
+
if (!isActive)
|
|
106
|
+
e.currentTarget.style.background = '#232938';
|
|
107
|
+
}, children: [_jsx("svg", { width: 24, height: 24, viewBox: "0 0 24 24", fill: "none", stroke: isActive ? '#6B8CFF' : '#9CA3AF', strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("path", { d: opt.icon }) }), _jsx("span", { style: { fontSize: 9, color: isActive ? '#A5B4FC' : '#9CA3AF', fontWeight: 500 }, children: opt.label })] }, opt.kind));
|
|
108
|
+
}) }), _jsxs("div", { style: { marginTop: 12 }, children: [_jsxs("div", { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }, children: [_jsx("span", { style: { fontSize: 10, color: '#9CA3AF', fontWeight: 500 }, children: "Duration" }), _jsxs("span", { style: { fontSize: 10, color: '#E5E7EB', fontFamily: 'monospace' }, children: [durationFrames, "f \u00A0", durationSec, "s"] })] }), _jsx("input", { type: "range", min: MIN_DURATION, max: MAX_DURATION, value: durationFrames, onChange: (e) => handleDurationChange(Number(e.target.value)), style: { width: '100%', accentColor: '#6B8CFF', cursor: 'pointer' } })] }), _jsxs("div", { style: { marginTop: 10 }, children: [_jsxs("div", { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }, children: [_jsx("span", { style: { fontSize: 10, color: '#9CA3AF', fontWeight: 500 }, children: "Position" }), _jsxs("span", { style: { fontSize: 10, color: '#E5E7EB', fontFamily: 'monospace' }, children: [before, "f \u25C6 ", after, "f"] })] }), _jsx("input", { type: "range", min: -maxOffset, max: maxOffset, value: offsetFrames, onChange: (e) => handleOffsetChange(Number(e.target.value)), style: { width: '100%', accentColor: '#6B8CFF', cursor: 'pointer' } }), _jsxs("div", { style: { display: 'flex', justifyContent: 'space-between', marginTop: 2 }, children: [_jsx("span", { style: { fontSize: 8, color: '#4B5563' }, children: "\u2190 before cut" }), _jsx("span", { style: { fontSize: 8, color: '#4B5563' }, children: "after cut \u2192" })] })] }), existing && (_jsx("button", { type: "button", onClick: () => { onRemove(); onClose(); }, style: {
|
|
109
|
+
marginTop: 10,
|
|
110
|
+
width: '100%',
|
|
111
|
+
padding: '5px 0',
|
|
112
|
+
fontSize: 10,
|
|
113
|
+
color: '#F87171',
|
|
114
|
+
background: 'transparent',
|
|
115
|
+
border: '1px solid #3F2A2A',
|
|
116
|
+
borderRadius: 6,
|
|
117
|
+
cursor: 'pointer',
|
|
118
|
+
}, children: "Remove transition" }))] }));
|
|
119
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type AudioDropChoice = 'both' | 'video-only' | 'audio-only';
|
|
2
|
+
interface AudioDropDialogState {
|
|
3
|
+
open: boolean;
|
|
4
|
+
assetName: string;
|
|
5
|
+
resolve: ((choice: AudioDropChoice | null) => void) | null;
|
|
6
|
+
}
|
|
7
|
+
interface AudioDropDialogActions {
|
|
8
|
+
request: (assetName: string) => Promise<AudioDropChoice | null>;
|
|
9
|
+
respond: (choice: AudioDropChoice) => void;
|
|
10
|
+
}
|
|
11
|
+
export declare const useAudioDropDialogStore: import("zustand").UseBoundStore<import("zustand").StoreApi<AudioDropDialogState & AudioDropDialogActions>>;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { create } from 'zustand';
|
|
2
|
+
export const useAudioDropDialogStore = create((set, get) => ({
|
|
3
|
+
open: false,
|
|
4
|
+
assetName: '',
|
|
5
|
+
resolve: null,
|
|
6
|
+
request: (assetName) => {
|
|
7
|
+
get().resolve?.(null);
|
|
8
|
+
return new Promise((resolve) => {
|
|
9
|
+
set({ open: true, assetName, resolve });
|
|
10
|
+
});
|
|
11
|
+
},
|
|
12
|
+
respond: (choice) => {
|
|
13
|
+
get().resolve?.(choice);
|
|
14
|
+
set({ open: false, assetName: '', resolve: null });
|
|
15
|
+
},
|
|
16
|
+
}));
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const ELEMENT_DRAG_MIME = 'application/x-elah-element';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const useTimeline: () => import("@elah/core").TimelineEngine;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export declare const usePlayback: import("zustand").UseBoundStore<Omit<import("zustand").StoreApi<import("@elah/core").PlaybackState & import("@elah/core").PlaybackActions>, "setState" | "persist"> & {
|
|
2
|
+
setState(partial: (import("@elah/core").PlaybackState & import("@elah/core").PlaybackActions) | Partial<import("@elah/core").PlaybackState & import("@elah/core").PlaybackActions> | ((state: import("@elah/core").PlaybackState & import("@elah/core").PlaybackActions) => (import("@elah/core").PlaybackState & import("@elah/core").PlaybackActions) | Partial<import("@elah/core").PlaybackState & import("@elah/core").PlaybackActions>), replace?: false | undefined): unknown;
|
|
3
|
+
setState(state: (import("@elah/core").PlaybackState & import("@elah/core").PlaybackActions) | ((state: import("@elah/core").PlaybackState & import("@elah/core").PlaybackActions) => import("@elah/core").PlaybackState & import("@elah/core").PlaybackActions), replace: true): unknown;
|
|
4
|
+
persist: {
|
|
5
|
+
setOptions: (options: Partial<import("zustand/middleware").PersistOptions<import("@elah/core").PlaybackState & import("@elah/core").PlaybackActions, {
|
|
6
|
+
zoom: number;
|
|
7
|
+
volume: number;
|
|
8
|
+
muted: boolean;
|
|
9
|
+
playbackRate: number;
|
|
10
|
+
loop: boolean;
|
|
11
|
+
snapEnabled: boolean;
|
|
12
|
+
}, unknown>>) => void;
|
|
13
|
+
clearStorage: () => void;
|
|
14
|
+
rehydrate: () => Promise<void> | void;
|
|
15
|
+
hasHydrated: () => boolean;
|
|
16
|
+
onHydrate: (fn: (state: import("@elah/core").PlaybackState & import("@elah/core").PlaybackActions) => void) => () => void;
|
|
17
|
+
onFinishHydration: (fn: (state: import("@elah/core").PlaybackState & import("@elah/core").PlaybackActions) => void) => () => void;
|
|
18
|
+
getOptions: () => Partial<import("zustand/middleware").PersistOptions<import("@elah/core").PlaybackState & import("@elah/core").PlaybackActions, {
|
|
19
|
+
zoom: number;
|
|
20
|
+
volume: number;
|
|
21
|
+
muted: boolean;
|
|
22
|
+
playbackRate: number;
|
|
23
|
+
loop: boolean;
|
|
24
|
+
snapEnabled: boolean;
|
|
25
|
+
}, unknown>>;
|
|
26
|
+
};
|
|
27
|
+
}>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const useSelection: import("zustand").UseBoundStore<import("zustand").StoreApi<import("@elah/core").SelectionState & import("@elah/core").SelectionActions>>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const useTracks: import("zustand").UseBoundStore<import("zustand").StoreApi<import("@elah/core").TracksState & import("@elah/core").TracksActions>>;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { Timeline } from './Timeline';
|
|
2
|
+
export type { TimelineProps, TimelineRef } from './Timeline';
|
|
3
|
+
export { useTimeline } from './engine-context';
|
|
4
|
+
export { useTracks } from './hooks/useTracks';
|
|
5
|
+
export { usePlayback } from './hooks/usePlayback';
|
|
6
|
+
export { useSelection } from './hooks/useSelection';
|
|
7
|
+
export { useTimelineDrop } from './useTimelineDrop';
|
|
8
|
+
export { ELEMENT_DRAG_MIME } from './elementDrag';
|
|
9
|
+
export type { DragElementPayload, ElementKind } from './elementDrag';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { Timeline } from './Timeline';
|
|
2
|
+
export { useTimeline } from './engine-context';
|
|
3
|
+
export { useTracks } from './hooks/useTracks';
|
|
4
|
+
export { usePlayback } from './hooks/usePlayback';
|
|
5
|
+
export { useSelection } from './hooks/useSelection';
|
|
6
|
+
export { useTimelineDrop } from './useTimelineDrop';
|
|
7
|
+
export { ELEMENT_DRAG_MIME } from './elementDrag';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function useTimelineDrop(trackId: string, lane: HTMLElement | null): void;
|