@elah/editor 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/README.md ADDED
@@ -0,0 +1,169 @@
1
+ # @elah/editor
2
+
3
+ Engine-first video timeline SDK for React. Internally layered as `core/` → `timeline/` → `editor/`.
4
+
5
+ See the repo root [README](../../README.md) for project overview and the [core architecture reference](src/core/Architecture.md) for a cold-start guide to the engine.
6
+
7
+ ## Install
8
+
9
+ This package is part of the monorepo workspace:
10
+
11
+ ```bash
12
+ npm install
13
+ ```
14
+
15
+ Peer dependencies: `react`, `react-dom` (>= 18).
16
+
17
+ ## Quick start
18
+
19
+ ```tsx
20
+ import { EditorProvider, Timeline } from '@elah/editor'
21
+
22
+ function App() {
23
+ return (
24
+ <EditorProvider fps={30}>
25
+ <Timeline style={{ height: 300 }} />
26
+ </EditorProvider>
27
+ )
28
+ }
29
+ ```
30
+
31
+ ## Import media files
32
+
33
+ Register local files into the media library from a file input or drop handler:
34
+
35
+ ```ts
36
+ import { importFiles, useMediaLibraryStore } from '@elah/editor'
37
+
38
+ async function onFilesSelected(files: FileList | File[]) {
39
+ const list = Array.from(files)
40
+ const assets = await importFiles(list)
41
+
42
+ // Assets are in the store immediately; thumbnails arrive shortly after.
43
+ console.log(useMediaLibraryStore.getState().assets)
44
+
45
+ // Subscribe in React via useMediaLibrary() for UI updates.
46
+ return assets
47
+ }
48
+ ```
49
+
50
+ `importFiles`:
51
+
52
+ - Creates object URLs and probes duration/dimensions via DOM media elements
53
+ - Skips unsupported MIME types with a console warning
54
+ - Registers assets in `useMediaLibraryStore` synchronously
55
+ - Generates JPEG thumbnails on the main thread and patches `thumbnailUrl` asynchronously
56
+
57
+ ## AssetPanel
58
+
59
+ Browse, drop, and drag media assets from a sidebar panel. Render as a sibling of `<Timeline>` inside `<EditorProvider>`:
60
+
61
+ ```tsx
62
+ import { EditorProvider, Timeline, AssetPanel } from '@elah/editor'
63
+
64
+ function App() {
65
+ return (
66
+ <EditorProvider fps={30}>
67
+ <div style={{ display: 'flex', height: '100vh' }}>
68
+ <AssetPanel style={{ width: 220 }} />
69
+ <Timeline style={{ flex: 1 }} />
70
+ </div>
71
+ </EditorProvider>
72
+ )
73
+ }
74
+ ```
75
+
76
+ - **Add** opens a file picker; **drop** onto the panel imports via `importFiles`
77
+ - Thumbnails appear asynchronously after import
78
+ - Drag a thumbnail onto a timeline track lane to create a clip (see Timeline drop below)
79
+
80
+ ## Timeline drop
81
+
82
+ With `<AssetPanel>` and `<Timeline>` as siblings inside `<EditorProvider>`, drag a thumbnail onto any track lane:
83
+
84
+ - Drop position becomes the clip `startFrame` (respects timeline zoom)
85
+ - Clip duration comes from the asset (`durationSec × project.fps`; images default to 5 seconds)
86
+ - Video/image assets go on video tracks; audio on audio tracks
87
+ - When snap is enabled (`usePlaybackStore.snapEnabled`), the drop snaps to the playhead and nearby clip edges
88
+
89
+ No extra wiring beyond `TrackRow` — `useTimelineDrop` is attached automatically per lane.
90
+
91
+ ## Render pixels with `<Preview>`
92
+
93
+ `<Preview>` mounts the WebGL2 `GpuRenderer`, drives the RAF loop, and paints
94
+ interactive transform overlays — drag / uniform-scale for video & image clips
95
+ (`MediaTransformOverlay`), and drag / resize / inline-edit for text clips
96
+ (`TextOverlay`) — plus the project's audio.
97
+ Inject a **demuxer factory** so the SDK never hard-depends on a decode backend:
98
+
99
+ ```tsx
100
+ import { EditorProvider, Preview, createMediabunnyBackend } from '@elah/editor'
101
+ import * as mediabunny from 'mediabunny'
102
+
103
+ const demuxerFactory = () =>
104
+ createMediabunnyBackend(mediabunny, { blobResolver: (src) => fetch(src).then((r) => r.blob()) })
105
+
106
+ function App() {
107
+ return (
108
+ <EditorProvider fps={30}>
109
+ <Preview demuxerFactory={demuxerFactory} style={{ height: 480 }} />
110
+ </EditorProvider>
111
+ )
112
+ }
113
+ ```
114
+
115
+ Omit `demuxerFactory` for a synthetic dev preview (no media files, no mediabunny).
116
+ For a lower-level renderer handle, `GpuRenderer` is exported directly. See
117
+ [`src/core/renderer/README.md`](src/core/renderer/README.md).
118
+
119
+ ## Export to MP4
120
+
121
+ ```ts
122
+ import { exportVideo } from '@elah/editor'
123
+
124
+ const blob = await exportVideo(engine.getProject(), {
125
+ videoBitrate: 8_000_000,
126
+ onProgress: ({ frame, totalFrames }) => setPct(Math.round((frame / totalFrames) * 100)),
127
+ })
128
+ ```
129
+
130
+ Runs in a worker; reuses `resolveTimeline` + the renderer's placement math. See
131
+ [`src/core/export/README.md`](src/core/export/README.md).
132
+
133
+ ## Package layout
134
+
135
+ ```
136
+ src/
137
+ core/ types, engine, playback, resolver, stores, assets, media, export, debug, actions
138
+ timeline/ Timeline UI + hooks
139
+ editor/ EditorProvider, AssetPanel, Preview, useResolvedScene
140
+ ```
141
+
142
+ ## Keyboard shortcuts
143
+
144
+ The `<Timeline>` component registers these global keyboard shortcuts when focused:
145
+
146
+ | Key | Action |
147
+ |---|---|
148
+ | **Space** | Play / pause |
149
+ | **S** | Split selected clip at playhead |
150
+ | **Delete** / **Backspace** | Delete selected clip(s) |
151
+ | **Ctrl/Cmd + C** | Copy selected clip(s) to clipboard |
152
+ | **Ctrl/Cmd + V** | Paste copied clip(s) — placed at current playhead position, same track |
153
+ | **Ctrl/Cmd + Z** | Undo |
154
+ | **Ctrl/Cmd + Shift + Z** / **Ctrl/Cmd + Y** | Redo |
155
+ | **Ctrl/Cmd + scroll** | Zoom in / out |
156
+ | **← / →** | Step one frame back / forward |
157
+
158
+ **Right-click** any clip to open a context menu with a **Delete** option.
159
+
160
+ ## Scripts
161
+
162
+ ```bash
163
+ npm run typecheck # from packages/editor
164
+ npm run test
165
+ ```
166
+
167
+ ## License
168
+
169
+ To be decided — see root README.
@@ -0,0 +1,6 @@
1
+ import { type CSSProperties } from 'react';
2
+ export interface AssetPanelProps {
3
+ style?: CSSProperties;
4
+ className?: string;
5
+ }
6
+ export declare function AssetPanel({ style, className }: AssetPanelProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,272 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useRef, useState, } from 'react';
3
+ import { createPortal } from 'react-dom';
4
+ import { importFiles, MEDIA_DRAG_MIME, useMediaLibrary, useMediaLibraryStore, } from '@elah/core';
5
+ const KIND_ICONS = {
6
+ video: '▶',
7
+ audio: '♪',
8
+ image: '◻',
9
+ };
10
+ const KIND_TAG = {
11
+ video: { label: 'VIDEO', color: '#93C5FD', bg: 'rgba(37, 99, 235, 0.2)' },
12
+ audio: { label: 'AUDIO', color: '#86EFAC', bg: 'rgba(22, 163, 74, 0.2)' },
13
+ image: { label: 'IMAGE', color: '#FCD34D', bg: 'rgba(245, 158, 11, 0.2)' },
14
+ };
15
+ function formatDuration(sec) {
16
+ if (!Number.isFinite(sec) || sec <= 0)
17
+ return '—';
18
+ const m = Math.floor(sec / 60);
19
+ const s = Math.floor(sec % 60);
20
+ return m > 0 ? `${m}:${s.toString().padStart(2, '0')}` : `${s}s`;
21
+ }
22
+ const THUMB_SIZE = 52;
23
+ const TOAST_DISMISS_MS = 3000;
24
+ function formatFileNames(files, maxNames = 3) {
25
+ const names = files.map((file) => file.name);
26
+ if (names.length <= maxNames)
27
+ return names.join(', ');
28
+ const shown = names.slice(0, maxNames).join(', ');
29
+ return `${shown} +${names.length - maxNames} more`;
30
+ }
31
+ function buildImportToast(skipped) {
32
+ if (skipped.length === 0)
33
+ return null;
34
+ const duplicates = skipped.filter((entry) => entry.reason === 'duplicate');
35
+ const unsupported = skipped.filter((entry) => entry.reason === 'unsupported');
36
+ const lines = [];
37
+ if (duplicates.length > 0) {
38
+ lines.push(`Skipped ${duplicates.length} duplicate file${duplicates.length === 1 ? '' : 's'}: ${formatFileNames(duplicates.map((entry) => entry.file))}`);
39
+ }
40
+ if (unsupported.length > 0) {
41
+ lines.push(`Skipped ${unsupported.length} unsupported file${unsupported.length === 1 ? '' : 's'}: ${formatFileNames(unsupported.map((entry) => entry.file))}`);
42
+ }
43
+ return {
44
+ message: lines.join('\n'),
45
+ tone: unsupported.length > 0 ? 'warn' : 'info',
46
+ };
47
+ }
48
+ function AssetThumbnail({ asset, onDelete }) {
49
+ const [ctxMenu, setCtxMenu] = useState(null);
50
+ const onDragStart = useCallback((e) => {
51
+ const payload = { kind: 'media-asset', assetId: asset.id };
52
+ e.dataTransfer.setData(MEDIA_DRAG_MIME, JSON.stringify(payload));
53
+ e.dataTransfer.effectAllowed = 'copy';
54
+ }, [asset.id]);
55
+ const handleContextMenu = useCallback((e) => {
56
+ e.preventDefault();
57
+ e.stopPropagation();
58
+ setCtxMenu({ x: e.clientX, y: e.clientY });
59
+ }, []);
60
+ const closeCtxMenu = useCallback(() => setCtxMenu(null), []);
61
+ const handleDelete = useCallback(() => {
62
+ onDelete(asset.id);
63
+ setCtxMenu(null);
64
+ }, [asset.id, onDelete]);
65
+ const tag = KIND_TAG[asset.kind];
66
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { draggable: true, className: "elah-media-card", onDragStart: onDragStart, onContextMenu: handleContextMenu, title: asset.name, style: {
67
+ display: 'flex',
68
+ alignItems: 'center',
69
+ gap: 10,
70
+ padding: '8px 10px',
71
+ borderRadius: 8,
72
+ cursor: 'grab',
73
+ userSelect: 'none',
74
+ background: '#171D2B',
75
+ border: '1px solid #232938',
76
+ transition: 'background 0.15s, border-color 0.15s',
77
+ }, children: [_jsx("div", { style: {
78
+ position: 'relative',
79
+ width: THUMB_SIZE,
80
+ height: THUMB_SIZE,
81
+ flexShrink: 0,
82
+ background: '#06070A',
83
+ borderRadius: 6,
84
+ border: '1px solid #1A1F2B',
85
+ overflow: 'hidden',
86
+ }, children: asset.thumbnailUrl ? (_jsx("img", { src: asset.thumbnailUrl, alt: "", draggable: false, style: {
87
+ width: '100%',
88
+ height: '100%',
89
+ objectFit: 'cover',
90
+ display: 'block',
91
+ } })) : (_jsx("div", { style: {
92
+ width: '100%',
93
+ height: '100%',
94
+ display: 'flex',
95
+ alignItems: 'center',
96
+ justifyContent: 'center',
97
+ fontSize: 20,
98
+ color: '#555',
99
+ }, children: KIND_ICONS[asset.kind] })) }), _jsxs("div", { style: {
100
+ display: 'flex',
101
+ flexDirection: 'column',
102
+ gap: 3,
103
+ minWidth: 0,
104
+ }, children: [_jsx("span", { style: {
105
+ fontSize: 11,
106
+ color: '#F3F4F6',
107
+ overflow: 'hidden',
108
+ textOverflow: 'ellipsis',
109
+ whiteSpace: 'nowrap',
110
+ }, children: asset.name }), _jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: 6 }, children: [_jsx("span", { style: {
111
+ fontSize: 8,
112
+ fontWeight: 700,
113
+ letterSpacing: '0.06em',
114
+ padding: '2px 5px',
115
+ borderRadius: 3,
116
+ color: tag.color,
117
+ background: tag.bg,
118
+ }, children: tag.label }), _jsx("span", { style: { fontSize: 10, color: '#6B7280', fontFamily: 'ui-monospace, monospace' }, children: formatDuration(asset.durationSec) })] })] })] }), ctxMenu && createPortal(_jsxs(_Fragment, { children: [_jsx("div", { style: { position: 'fixed', inset: 0, zIndex: 9998 }, onMouseDown: closeCtxMenu }), _jsx("div", { style: {
119
+ position: 'fixed',
120
+ top: ctxMenu.y,
121
+ left: ctxMenu.x,
122
+ zIndex: 9999,
123
+ background: '#1E2433',
124
+ border: '1px solid #2D3548',
125
+ borderRadius: 6,
126
+ padding: '4px 0',
127
+ minWidth: 140,
128
+ boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
129
+ fontFamily: 'sans-serif',
130
+ }, children: _jsx("button", { type: "button", onMouseDown: (e) => e.stopPropagation(), onClick: handleDelete, style: {
131
+ display: 'block',
132
+ width: '100%',
133
+ padding: '7px 14px',
134
+ textAlign: 'left',
135
+ background: 'none',
136
+ border: 'none',
137
+ color: '#FF6B6B',
138
+ fontSize: 13,
139
+ cursor: 'pointer',
140
+ letterSpacing: '0.01em',
141
+ }, onMouseEnter: (e) => {
142
+ ;
143
+ e.currentTarget.style.background = 'rgba(255,107,107,0.12)';
144
+ }, onMouseLeave: (e) => {
145
+ ;
146
+ e.currentTarget.style.background = 'none';
147
+ }, children: "Delete" }) })] }), document.body)] }));
148
+ }
149
+ export function AssetPanel({ style, className }) {
150
+ const { assets } = useMediaLibrary();
151
+ const removeAsset = useMediaLibraryStore((s) => s.removeAsset);
152
+ const fileInputRef = useRef(null);
153
+ const [isDragOver, setIsDragOver] = useState(false);
154
+ const [importing, setImporting] = useState(false);
155
+ const [toast, setToast] = useState(null);
156
+ const handleDeleteAsset = useCallback((id) => {
157
+ removeAsset(id);
158
+ }, [removeAsset]);
159
+ useEffect(() => {
160
+ if (!toast)
161
+ return;
162
+ const timer = globalThis.setTimeout(() => setToast(null), TOAST_DISMISS_MS);
163
+ return () => globalThis.clearTimeout(timer);
164
+ }, [toast]);
165
+ const handleFiles = useCallback(async (files) => {
166
+ const list = Array.from(files);
167
+ if (list.length === 0)
168
+ return;
169
+ setImporting(true);
170
+ try {
171
+ const { skipped } = await importFiles(list);
172
+ setToast(buildImportToast(skipped));
173
+ }
174
+ finally {
175
+ setImporting(false);
176
+ }
177
+ }, []);
178
+ const onBrowseClick = useCallback(() => {
179
+ fileInputRef.current?.click();
180
+ }, []);
181
+ const onFileInputChange = useCallback((e) => {
182
+ const files = e.target.files;
183
+ if (files && files.length > 0) {
184
+ void handleFiles(files);
185
+ }
186
+ e.target.value = '';
187
+ }, [handleFiles]);
188
+ const onDragOver = useCallback((e) => {
189
+ if (e.dataTransfer.types.includes('Files')) {
190
+ e.preventDefault();
191
+ e.dataTransfer.dropEffect = 'copy';
192
+ setIsDragOver(true);
193
+ }
194
+ }, []);
195
+ const onDragLeave = useCallback((e) => {
196
+ if (!e.currentTarget.contains(e.relatedTarget)) {
197
+ setIsDragOver(false);
198
+ }
199
+ }, []);
200
+ const onDrop = useCallback((e) => {
201
+ e.preventDefault();
202
+ setIsDragOver(false);
203
+ const files = e.dataTransfer.files;
204
+ if (files.length > 0) {
205
+ void handleFiles(files);
206
+ }
207
+ }, [handleFiles]);
208
+ return (_jsxs("div", { className: className, onDragOver: onDragOver, onDragLeave: onDragLeave, onDrop: onDrop, style: {
209
+ display: 'flex',
210
+ flexDirection: 'column',
211
+ height: '100%',
212
+ background: 'transparent',
213
+ borderRight: 'none',
214
+ ...style,
215
+ }, children: [_jsx("input", { ref: fileInputRef, type: "file", multiple: true, accept: "video/*,audio/*,image/*", style: { display: 'none' }, onChange: onFileInputChange, "data-testid": "asset-file-input" }), _jsxs("div", { style: {
216
+ display: 'flex',
217
+ alignItems: 'center',
218
+ justifyContent: 'space-between',
219
+ padding: '10px 12px',
220
+ borderBottom: '1px solid #232938',
221
+ flexShrink: 0,
222
+ }, children: [_jsx("span", { style: { fontSize: 10, fontWeight: 700, color: '#6B7280', letterSpacing: '0.08em' }, children: "MEDIA" }), _jsx("button", { type: "button", onClick: onBrowseClick, disabled: importing, style: {
223
+ padding: '4px 12px',
224
+ fontSize: 11,
225
+ fontWeight: 600,
226
+ background: importing ? '#121722' : '#171D2B',
227
+ color: importing ? '#6B7280' : '#E11D48',
228
+ border: '1px solid #232938',
229
+ borderRadius: 6,
230
+ cursor: importing ? 'wait' : 'pointer',
231
+ }, children: importing ? '…' : '+ Add' })] }), _jsxs("div", { style: {
232
+ flex: 1,
233
+ overflow: 'auto',
234
+ padding: 8,
235
+ outline: isDragOver ? '2px dashed #E11D48' : 'none',
236
+ outlineOffset: -4,
237
+ borderRadius: 4,
238
+ position: 'relative',
239
+ }, children: [toast && (_jsx("div", { role: "status", style: {
240
+ position: 'absolute',
241
+ top: 8,
242
+ left: 8,
243
+ right: 8,
244
+ zIndex: 2,
245
+ padding: '8px 10px',
246
+ borderRadius: 6,
247
+ fontSize: 10,
248
+ fontFamily: 'monospace',
249
+ lineHeight: 1.4,
250
+ whiteSpace: 'pre-line',
251
+ color: toast.tone === 'warn' ? '#f5d0a9' : '#c8d8f0',
252
+ background: toast.tone === 'warn' ? '#3a2418' : '#1a2433',
253
+ border: `1px solid ${toast.tone === 'warn' ? '#7a4a2a' : '#355070'}`,
254
+ boxShadow: '0 4px 12px rgba(0, 0, 0, 0.35)',
255
+ }, children: toast.message })), assets.length === 0 ? (_jsxs("div", { style: {
256
+ display: 'flex',
257
+ flexDirection: 'column',
258
+ alignItems: 'center',
259
+ justifyContent: 'center',
260
+ minHeight: 120,
261
+ padding: 16,
262
+ textAlign: 'center',
263
+ color: '#6B7280',
264
+ fontSize: 11,
265
+ border: '1px dashed #232938',
266
+ borderRadius: 8,
267
+ }, children: [_jsx("span", { style: { marginBottom: 8, fontSize: 24, opacity: 0.5 }, children: "\u2193" }), "Drop files here", _jsx("br", {}), "or click Add"] })) : (_jsx("div", { style: {
268
+ display: 'flex',
269
+ flexDirection: 'column',
270
+ gap: 6,
271
+ }, children: assets.map((asset) => (_jsx(AssetThumbnail, { asset: asset, onDelete: handleDeleteAsset }, asset.id))) }))] })] }));
272
+ }
@@ -0,0 +1,2 @@
1
+ export { AssetPanel } from './AssetPanel';
2
+ export type { AssetPanelProps } from './AssetPanel';
@@ -0,0 +1 @@
1
+ export { AssetPanel } from './AssetPanel';
@@ -0,0 +1,14 @@
1
+ import { type ReactNode } from 'react';
2
+ import type { InitialTrackConfig } from '@elah/core';
3
+ export interface EditorProviderProps {
4
+ fps: number;
5
+ stage?: {
6
+ width: number;
7
+ height: number;
8
+ };
9
+ defaultTrackHeight?: number;
10
+ maxHistorySize?: number;
11
+ initialTracks?: InitialTrackConfig[];
12
+ children: ReactNode;
13
+ }
14
+ export declare function EditorProvider({ fps, stage, defaultTrackHeight, maxHistorySize, initialTracks, children, }: EditorProviderProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,80 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useMemo } from 'react';
3
+ import { TimelineEngine, PlaybackEngine, useTracksStore, usePlaybackStore, useTransitionsStore, EditorContext, installTraceGlobal, trace, } from '@elah/core';
4
+ export function EditorProvider({ fps, stage, defaultTrackHeight, maxHistorySize, initialTracks, children, }) {
5
+ const engine = useMemo(() => new TimelineEngine({
6
+ fps,
7
+ stage,
8
+ defaultTrackHeight,
9
+ maxHistorySize,
10
+ initialTracks,
11
+ }), []);
12
+ const playback = useMemo(() => new PlaybackEngine({
13
+ fps,
14
+ getTotalFrames: () => useTracksStore.getState().totalFrames,
15
+ }), []);
16
+ useEffect(() => {
17
+ const syncAll = () => {
18
+ const project = engine.getProject();
19
+ useTracksStore.getState().sync(project, {
20
+ canUndo: engine.canUndo(),
21
+ canRedo: engine.canRedo(),
22
+ });
23
+ useTransitionsStore.getState().sync(project);
24
+ };
25
+ engine.on('change', syncAll);
26
+ engine.on('history:change', syncAll);
27
+ syncAll();
28
+ return () => {
29
+ engine.off('change', syncAll);
30
+ engine.off('history:change', syncAll);
31
+ };
32
+ }, [engine]);
33
+ useEffect(() => {
34
+ return playback.subscribe((snapshot) => {
35
+ const pb = usePlaybackStore.getState();
36
+ if (snapshot.currentFrame !== pb.currentFrame) {
37
+ pb.setCurrentFrame(snapshot.currentFrame);
38
+ }
39
+ if (snapshot.isPlaying && !pb.isPlaying)
40
+ pb.play();
41
+ else if (!snapshot.isPlaying && pb.isPlaying)
42
+ pb.pause();
43
+ });
44
+ }, [playback]);
45
+ useEffect(() => {
46
+ installTraceGlobal();
47
+ const s0 = usePlaybackStore.getState();
48
+ playback.setPlaybackRate(s0.playbackRate);
49
+ playback.setLoop(s0.loop);
50
+ if (s0.isPlaying)
51
+ playback.play();
52
+ return usePlaybackStore.subscribe((state, prev) => {
53
+ if (state.isPlaying !== prev.isPlaying) {
54
+ if (state.isPlaying)
55
+ playback.play();
56
+ else
57
+ playback.pause();
58
+ }
59
+ if (state.currentFrameEpoch !== prev.currentFrameEpoch) {
60
+ const willSeek = state.currentFrame !== playback.currentFrame;
61
+ trace('SEEK_GATE', {
62
+ storeFrame: state.currentFrame,
63
+ engineFrame: playback.currentFrame,
64
+ willSeek,
65
+ });
66
+ if (willSeek)
67
+ playback.seek(state.currentFrame);
68
+ }
69
+ if (state.playbackRate !== prev.playbackRate) {
70
+ playback.setPlaybackRate(state.playbackRate);
71
+ }
72
+ if (state.loop !== prev.loop) {
73
+ playback.setLoop(state.loop);
74
+ }
75
+ });
76
+ }, [playback]);
77
+ useEffect(() => () => playback.destroy(), [playback]);
78
+ const value = useMemo(() => ({ engine, playback }), [engine, playback]);
79
+ return (_jsx(EditorContext.Provider, { value: value, children: children }));
80
+ }
@@ -0,0 +1,6 @@
1
+ import { type CSSProperties } from 'react';
2
+ export interface ElementsPanelProps {
3
+ style?: CSSProperties;
4
+ className?: string;
5
+ }
6
+ export declare function ElementsPanel({ style, className }: ElementsPanelProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,51 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback } from 'react';
3
+ import { ELEMENT_DRAG_MIME } from '@elah/timeline';
4
+ export function ElementsPanel({ style, className }) {
5
+ const onTextDragStart = useCallback((e) => {
6
+ const payload = { kind: 'element', element: 'text' };
7
+ e.dataTransfer.setData(ELEMENT_DRAG_MIME, JSON.stringify(payload));
8
+ e.dataTransfer.effectAllowed = 'copy';
9
+ }, []);
10
+ return (_jsxs("div", { className: className, style: {
11
+ display: 'flex',
12
+ flexDirection: 'column',
13
+ background: 'transparent',
14
+ borderBottom: '1px solid #232938',
15
+ ...style,
16
+ }, children: [_jsx("div", { style: {
17
+ padding: '10px 12px',
18
+ borderBottom: '1px solid #232938',
19
+ flexShrink: 0,
20
+ }, children: _jsx("span", { style: {
21
+ fontSize: 10,
22
+ fontWeight: 700,
23
+ color: '#6B7280',
24
+ letterSpacing: '0.08em',
25
+ }, children: "ELEMENTS" }) }), _jsx("div", { style: { padding: 10 }, children: _jsxs("div", { draggable: true, className: "elah-element-card", onDragStart: onTextDragStart, title: "Drag onto the Text track", style: {
26
+ display: 'flex',
27
+ alignItems: 'center',
28
+ gap: 10,
29
+ padding: '10px 12px',
30
+ borderRadius: 8,
31
+ cursor: 'grab',
32
+ userSelect: 'none',
33
+ background: '#171D2B',
34
+ border: '1px solid #232938',
35
+ transition: 'background 0.15s, border-color 0.15s',
36
+ }, children: [_jsx("span", { style: {
37
+ width: 32,
38
+ height: 32,
39
+ flexShrink: 0,
40
+ display: 'flex',
41
+ alignItems: 'center',
42
+ justifyContent: 'center',
43
+ borderRadius: 6,
44
+ background: 'rgba(147, 51, 234, 0.25)',
45
+ border: '1px solid rgba(147, 51, 234, 0.4)',
46
+ color: '#C4B5FD',
47
+ fontWeight: 700,
48
+ fontFamily: 'Georgia, serif',
49
+ fontSize: 17,
50
+ }, children: "T" }), _jsxs("div", { style: { display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 }, children: [_jsx("span", { style: { fontSize: 12, color: '#F3F4F6', fontWeight: 500 }, children: "Text" }), _jsx("span", { style: { fontSize: 10, color: '#6B7280' }, children: "+ Drag onto timeline" })] })] }) })] }));
51
+ }
@@ -0,0 +1,2 @@
1
+ export { ElementsPanel } from './ElementsPanel';
2
+ export type { ElementsPanelProps } from './ElementsPanel';
@@ -0,0 +1 @@
1
+ export { ElementsPanel } from './ElementsPanel';
@@ -0,0 +1 @@
1
+ export declare function MediaTransformOverlay(): import("react/jsx-runtime").JSX.Element;