@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.
@@ -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
+ }));
@@ -0,0 +1,9 @@
1
+ import type { Track } from '@elah/core';
2
+ interface TrackRowProps {
3
+ track: Track;
4
+ totalFrames: number;
5
+ zoom: number;
6
+ fps: number;
7
+ }
8
+ export declare const TrackRow: import("react").NamedExoticComponent<TrackRowProps>;
9
+ export {};
@@ -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,6 @@
1
+ export declare const ELEMENT_DRAG_MIME = "application/x-elah-element";
2
+ export type ElementKind = 'text';
3
+ export interface DragElementPayload {
4
+ kind: 'element';
5
+ element: ElementKind;
6
+ }
@@ -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,2 @@
1
+ import { useTimelineEngine } from '@elah/core';
2
+ export const useTimeline = useTimelineEngine;
@@ -0,0 +1,3 @@
1
+ export { useTracks } from './useTracks';
2
+ export { usePlayback } from './usePlayback';
3
+ export { useSelection } from './useSelection';
@@ -0,0 +1,3 @@
1
+ export { useTracks } from './useTracks';
2
+ export { usePlayback } from './usePlayback';
3
+ export { useSelection } from './useSelection';
@@ -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,2 @@
1
+ import { usePlaybackStore } from '@elah/core';
2
+ export const usePlayback = usePlaybackStore;
@@ -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,2 @@
1
+ import { useSelectionStore } from '@elah/core';
2
+ export const useSelection = useSelectionStore;
@@ -0,0 +1 @@
1
+ export declare const useTracks: import("zustand").UseBoundStore<import("zustand").StoreApi<import("@elah/core").TracksState & import("@elah/core").TracksActions>>;
@@ -0,0 +1,2 @@
1
+ import { useTracksStore } from '@elah/core';
2
+ export const useTracks = useTracksStore;
@@ -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;