@elah/editor 0.2.1 → 0.3.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.
@@ -1,16 +1,18 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useRef, useState, } from 'react';
3
3
  import { createPortal } from 'react-dom';
4
- import { importFiles, MEDIA_DRAG_MIME, useMediaLibrary, useMediaLibraryStore, } from '@elah/core';
4
+ import { importFiles, importUrl, MEDIA_DRAG_MIME, mediaDragKindMime, useMediaLibrary, useMediaLibraryStore, } from '@elah/core';
5
+ import { cn } from '@elah/timeline';
6
+ import { isActivationKey, useAssetActivation, } from '../activation';
5
7
  const KIND_ICONS = {
6
8
  video: '▶',
7
9
  audio: '♪',
8
10
  image: '◻',
9
11
  };
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)' },
12
+ const KIND_TOKEN = {
13
+ video: { label: 'VIDEO', fg: 'var(--elah-tag-video-fg)', bg: 'var(--elah-tag-video-bg)' },
14
+ audio: { label: 'AUDIO', fg: 'var(--elah-tag-audio-fg)', bg: 'var(--elah-tag-audio-bg)' },
15
+ image: { label: 'IMAGE', fg: 'var(--elah-tag-image-fg)', bg: 'var(--elah-tag-image-bg)' },
14
16
  };
15
17
  function formatDuration(sec) {
16
18
  if (!Number.isFinite(sec) || sec <= 0)
@@ -45,11 +47,12 @@ function buildImportToast(skipped) {
45
47
  tone: unsupported.length > 0 ? 'warn' : 'info',
46
48
  };
47
49
  }
48
- function AssetThumbnail({ asset, onDelete }) {
50
+ function AssetThumbnail({ asset, onDelete, onActivate, }) {
49
51
  const [ctxMenu, setCtxMenu] = useState(null);
50
52
  const onDragStart = useCallback((e) => {
51
53
  const payload = { kind: 'media-asset', assetId: asset.id };
52
54
  e.dataTransfer.setData(MEDIA_DRAG_MIME, JSON.stringify(payload));
55
+ e.dataTransfer.setData(mediaDragKindMime(asset.kind), '');
53
56
  e.dataTransfer.effectAllowed = 'copy';
54
57
  }, [asset.id]);
55
58
  const handleContextMenu = useCallback((e) => {
@@ -62,100 +65,55 @@ function AssetThumbnail({ asset, onDelete }) {
62
65
  onDelete(asset.id);
63
66
  setCtxMenu(null);
64
67
  }, [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: {
68
+ const handleActivate = useCallback(() => onActivate?.(asset), [asset, onActivate]);
69
+ const handleActivateKey = useCallback((e) => {
70
+ if (!onActivate || !isActivationKey(e.key))
71
+ return;
72
+ e.preventDefault();
73
+ onActivate(asset);
74
+ }, [asset, onActivate]);
75
+ const tag = KIND_TOKEN[asset.kind];
76
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { draggable: true, role: onActivate ? 'button' : undefined, tabIndex: onActivate ? 0 : undefined, className: "elah-media-card flex items-center gap-[10px] px-[10px] py-2 rounded-md cursor-grab select-none bg-ed-card border border-ed-border transition-[background,border-color] duration-[150ms]", onDragStart: onDragStart, onClick: onActivate ? handleActivate : undefined, onKeyDown: onActivate ? handleActivateKey : undefined, onContextMenu: handleContextMenu, title: asset.name, children: [_jsx("div", { className: "relative shrink-0 bg-ed-bg rounded-sm border border-ed-border-subtle overflow-hidden", style: { width: THUMB_SIZE, height: THUMB_SIZE }, children: asset.thumbnailUrl ? (_jsx("img", { src: asset.thumbnailUrl, alt: "", draggable: false, className: "w-full h-full object-cover block" })) : (_jsx("div", { className: "w-full h-full flex items-center justify-center text-xl text-ed-text-muted", children: KIND_ICONS[asset.kind] })) }), _jsxs("div", { className: "flex flex-col gap-[3px] min-w-0", children: [_jsx("span", { className: "text-[11px] text-ed-text overflow-hidden text-ellipsis whitespace-nowrap", children: asset.name }), _jsxs("div", { className: "flex items-center gap-[6px]", children: [_jsx("span", { className: "text-[8px] font-bold tracking-[0.06em] px-[5px] py-[2px] rounded-sm", style: { color: tag.fg, background: tag.bg }, children: tag.label }), _jsx("span", { className: "text-[10px] text-ed-text-muted font-mono", children: formatDuration(asset.durationSec) })] })] })] }), ctxMenu && createPortal(_jsxs(_Fragment, { children: [_jsx("div", { className: "fixed inset-0 z-[9998]", onMouseDown: closeCtxMenu }), _jsx("div", { style: {
119
77
  position: 'fixed',
120
78
  top: ctxMenu.y,
121
79
  left: ctxMenu.x,
122
80
  zIndex: 9999,
123
- background: '#1E2433',
124
- border: '1px solid #2D3548',
125
- borderRadius: 6,
81
+ background: 'var(--elah-bg-elevated)',
82
+ border: '1px solid var(--elah-outline)',
83
+ borderRadius: 'var(--elah-radius-sm)',
126
84
  padding: '4px 0',
127
85
  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) => {
86
+ boxShadow: 'var(--elah-menu-shadow, 0 8px 24px rgba(0,0,0,0.5))',
87
+ fontFamily: 'var(--elah-font-ui)',
88
+ }, children: _jsx("button", { type: "button", onMouseDown: (e) => e.stopPropagation(), onClick: handleDelete, className: "block w-full px-[14px] py-[7px] text-left bg-transparent border-none text-ed-error text-[13px] cursor-pointer tracking-[0.01em]", onMouseEnter: (e) => {
142
89
  ;
143
- e.currentTarget.style.background = 'rgba(255,107,107,0.12)';
90
+ e.currentTarget.style.background = 'color-mix(in srgb, var(--elah-color-error) 12%, transparent)';
144
91
  }, onMouseLeave: (e) => {
145
92
  ;
146
93
  e.currentTarget.style.background = 'none';
147
94
  }, children: "Delete" }) })] }), document.body)] }));
148
95
  }
149
- export function AssetPanel({ style, className }) {
96
+ export function AssetPanel({ style, className, activateOnTap, onAssetActivate, }) {
150
97
  const { assets } = useMediaLibrary();
151
98
  const removeAsset = useMediaLibraryStore((s) => s.removeAsset);
152
99
  const fileInputRef = useRef(null);
153
100
  const [isDragOver, setIsDragOver] = useState(false);
154
101
  const [importing, setImporting] = useState(false);
155
102
  const [toast, setToast] = useState(null);
103
+ const [urlInputOpen, setUrlInputOpen] = useState(false);
104
+ const [urlValue, setUrlValue] = useState('');
105
+ const activationEnabled = activateOnTap === true || Boolean(onAssetActivate);
106
+ const activateAsset = useAssetActivation({
107
+ activateOnTap,
108
+ onAssetActivate,
109
+ setToast,
110
+ });
156
111
  const handleDeleteAsset = useCallback((id) => {
157
112
  removeAsset(id);
158
113
  }, [removeAsset]);
114
+ const handleAssetActivate = useCallback((asset) => {
115
+ void activateAsset({ kind: 'media-asset', asset });
116
+ }, [activateAsset]);
159
117
  useEffect(() => {
160
118
  if (!toast)
161
119
  return;
@@ -175,6 +133,36 @@ export function AssetPanel({ style, className }) {
175
133
  setImporting(false);
176
134
  }
177
135
  }, []);
136
+ const handleImportUrl = useCallback(async () => {
137
+ const url = urlValue.trim();
138
+ if (!url)
139
+ return;
140
+ setImporting(true);
141
+ try {
142
+ await importUrl(url);
143
+ setUrlValue('');
144
+ setUrlInputOpen(false);
145
+ }
146
+ catch (err) {
147
+ setToast({
148
+ message: `Failed to import URL: ${err instanceof Error ? err.message : String(err)}`,
149
+ tone: 'warn',
150
+ });
151
+ }
152
+ finally {
153
+ setImporting(false);
154
+ }
155
+ }, [urlValue]);
156
+ const onUrlKeyDown = useCallback((e) => {
157
+ if (e.key === 'Enter') {
158
+ e.preventDefault();
159
+ void handleImportUrl();
160
+ }
161
+ else if (e.key === 'Escape') {
162
+ setUrlInputOpen(false);
163
+ setUrlValue('');
164
+ }
165
+ }, [handleImportUrl]);
178
166
  const onBrowseClick = useCallback(() => {
179
167
  fileInputRef.current?.click();
180
168
  }, []);
@@ -205,68 +193,51 @@ export function AssetPanel({ style, className }) {
205
193
  void handleFiles(files);
206
194
  }
207
195
  }, [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',
196
+ return (_jsxs("div", { className: cn('flex flex-col h-full bg-transparent', className), onDragOver: onDragOver, onDragLeave: onDragLeave, onDrop: onDrop, style: style, children: [_jsx("input", { ref: fileInputRef, type: "file", multiple: true, accept: "video/*,audio/*,image/*", className: "hidden", onChange: onFileInputChange, "data-testid": "asset-file-input" }), _jsxs("div", { className: "flex items-center justify-between px-3 py-[10px] border-b border-ed-border shrink-0", children: [_jsx("span", { className: "text-[10px] font-bold text-ed-text-muted tracking-[0.08em]", children: "MEDIA" }), _jsxs("div", { className: "flex gap-[6px]", children: [_jsx("button", { type: "button", onClick: () => setUrlInputOpen((open) => !open), disabled: importing, "data-testid": "asset-url-toggle", style: {
197
+ padding: '4px 12px',
198
+ fontSize: 11,
199
+ fontWeight: 600,
200
+ background: urlInputOpen ? 'var(--elah-bg-elevated)' : 'var(--elah-bg-card)',
201
+ color: importing ? 'var(--elah-text-muted)' : 'var(--elah-accent)',
202
+ border: '1px solid var(--elah-border)',
203
+ borderRadius: 'var(--elah-radius-sm)',
204
+ cursor: importing ? 'wait' : 'pointer',
205
+ }, children: "+ URL" }), _jsx("button", { type: "button", onClick: onBrowseClick, disabled: importing, style: {
206
+ padding: '4px 12px',
207
+ fontSize: 11,
208
+ fontWeight: 600,
209
+ background: importing ? 'var(--elah-bg-panel)' : 'var(--elah-bg-card)',
210
+ color: importing ? 'var(--elah-text-muted)' : 'var(--elah-accent)',
211
+ border: '1px solid var(--elah-border)',
212
+ borderRadius: 'var(--elah-radius-sm)',
213
+ cursor: importing ? 'wait' : 'pointer',
214
+ }, children: importing ? '…' : '+ Add' })] })] }), urlInputOpen && (_jsxs("div", { className: "flex gap-[6px] px-3 py-2 border-b border-ed-border shrink-0", children: [_jsx("input", { type: "url", value: urlValue, onChange: (e) => setUrlValue(e.target.value), onKeyDown: onUrlKeyDown, placeholder: "https://\u2026/media.mp4", autoFocus: true, "data-testid": "asset-url-input", style: {
215
+ flex: 1,
216
+ minWidth: 0,
217
+ padding: '5px 8px',
218
+ fontSize: 11,
219
+ fontFamily: 'var(--elah-font-mono)',
220
+ color: 'var(--elah-text)',
221
+ background: 'var(--elah-bg)',
222
+ border: '1px solid var(--elah-border)',
223
+ borderRadius: 'var(--elah-radius-sm)',
224
+ outline: 'none',
225
+ } }), _jsx("button", { type: "button", onClick: () => void handleImportUrl(), disabled: importing || urlValue.trim().length === 0, style: {
226
+ padding: '5px 12px',
224
227
  fontSize: 11,
225
228
  fontWeight: 600,
226
- background: importing ? '#121722' : '#171D2B',
227
- color: importing ? '#6B7280' : '#E11D48',
228
- border: '1px solid #232938',
229
- borderRadius: 6,
229
+ background: 'var(--elah-bg-card)',
230
+ color: importing || urlValue.trim().length === 0 ? 'var(--elah-text-muted)' : 'var(--elah-accent)',
231
+ border: '1px solid var(--elah-border)',
232
+ borderRadius: 'var(--elah-radius-sm)',
230
233
  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',
234
+ }, children: "Add" })] })), _jsxs("div", { className: "flex-1 overflow-auto p-2 relative rounded", style: {
235
+ outline: isDragOver ? '2px dashed var(--elah-accent)' : 'none',
236
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'}`,
237
+ }, children: [toast && (_jsx("div", { role: "status", className: "absolute top-2 left-2 right-2 z-[2] px-[10px] py-2 rounded-sm text-[10px] font-mono leading-[1.4] whitespace-pre-line", style: {
238
+ color: toast.tone === 'warn' ? 'var(--elah-danger-text, #f5d0a9)' : 'var(--elah-info-text, #c8d8f0)',
239
+ background: toast.tone === 'warn' ? 'var(--elah-danger-bg, #3a2418)' : 'var(--elah-info-bg, #1a2433)',
240
+ border: `1px solid ${toast.tone === 'warn' ? 'var(--elah-danger-border, #7a4a2a)' : 'var(--elah-info-border, #355070)'}`,
254
241
  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))) }))] })] }));
242
+ }, children: toast.message })), assets.length === 0 ? (_jsxs("div", { className: "flex flex-col items-center justify-center min-h-[120px] p-4 text-center text-ed-text-muted text-[11px] border border-dashed border-ed-border rounded-md", children: [_jsx("span", { className: "mb-2 text-2xl opacity-50", children: "\u2193" }), "Drop files here", _jsx("br", {}), "or click Add"] })) : (_jsx("div", { className: "flex flex-col gap-[6px]", children: assets.map((asset) => (_jsx(AssetThumbnail, { asset: asset, onDelete: handleDeleteAsset, onActivate: activationEnabled ? handleAssetActivate : undefined }, asset.id))) }))] })] }));
272
243
  }
@@ -1,6 +1,9 @@
1
1
  import { type CSSProperties } from 'react';
2
+ import { type AssetActivationHandler } from '../activation';
2
3
  export interface ElementsPanelProps {
3
4
  style?: CSSProperties;
4
5
  className?: string;
6
+ activateOnTap?: boolean;
7
+ onAssetActivate?: AssetActivationHandler;
5
8
  }
6
- export declare function ElementsPanel({ style, className }: ElementsPanelProps): import("react/jsx-runtime").JSX.Element;
9
+ export declare function ElementsPanel({ style, className, activateOnTap, onAssetActivate, }: ElementsPanelProps): import("react/jsx-runtime").JSX.Element;
@@ -1,51 +1,92 @@
1
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' };
2
+ import { useCallback, useEffect, useState } from 'react';
3
+ import { ELEMENT_DRAG_MIME, cn } from '@elah/timeline';
4
+ import { isActivationKey, useAssetActivation, } from '../activation';
5
+ const ShapeRect = () => (_jsx("svg", { viewBox: "0 0 24 24", width: 20, height: 20, fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinejoin: "round", children: _jsx("rect", { x: "3", y: "5", width: "18", height: "14", rx: "2" }) }));
6
+ const ShapeCircle = () => (_jsx("svg", { viewBox: "0 0 24 24", width: 20, height: 20, fill: "none", stroke: "currentColor", strokeWidth: "2", children: _jsx("circle", { cx: "12", cy: "12", r: "9" }) }));
7
+ const ShapeTriangle = () => (_jsx("svg", { viewBox: "0 0 24 24", width: 20, height: 20, fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinejoin: "round", children: _jsx("polygon", { points: "12,3 22,21 2,21" }) }));
8
+ const FreehandIcon = () => (_jsxs("svg", { viewBox: "0 0 24 24", width: 20, height: 20, fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("path", { d: "M3 17c3-3 5-7 8-7s4 4 7 1" }), _jsx("path", { d: "M17 11l2 2-2 2" })] }));
9
+ const TILES = [
10
+ {
11
+ element: 'text',
12
+ label: 'Text',
13
+ icon: 'T',
14
+ iconStyle: {
15
+ background: 'var(--elah-tag-text-bg)',
16
+ border: '1px solid var(--elah-tag-text-border)',
17
+ color: 'var(--elah-tag-text-fg)',
18
+ fontFamily: 'Georgia, serif',
19
+ fontWeight: 700,
20
+ fontSize: 17,
21
+ },
22
+ },
23
+ {
24
+ element: 'shape',
25
+ shapeVariant: 'rect',
26
+ label: 'Rectangle',
27
+ icon: _jsx(ShapeRect, {}),
28
+ iconStyle: {
29
+ background: 'var(--elah-tag-shape-bg)',
30
+ border: '1px solid var(--elah-tag-shape-border)',
31
+ color: 'var(--elah-tag-shape-fg)',
32
+ },
33
+ },
34
+ {
35
+ element: 'shape',
36
+ shapeVariant: 'circle',
37
+ label: 'Circle',
38
+ icon: _jsx(ShapeCircle, {}),
39
+ iconStyle: {
40
+ background: 'var(--elah-tag-shape-bg)',
41
+ border: '1px solid var(--elah-tag-shape-border)',
42
+ color: 'var(--elah-tag-shape-fg)',
43
+ },
44
+ },
45
+ {
46
+ element: 'shape',
47
+ shapeVariant: 'triangle',
48
+ label: 'Triangle',
49
+ icon: _jsx(ShapeTriangle, {}),
50
+ iconStyle: {
51
+ background: 'var(--elah-tag-shape-bg)',
52
+ border: '1px solid var(--elah-tag-shape-border)',
53
+ color: 'var(--elah-tag-shape-fg)',
54
+ },
55
+ },
56
+ ];
57
+ const TOAST_DISMISS_MS = 3000;
58
+ export function ElementsPanel({ style, className, activateOnTap, onAssetActivate, }) {
59
+ const [toast, setToast] = useState(null);
60
+ const activationEnabled = activateOnTap === true || Boolean(onAssetActivate);
61
+ const activateAsset = useAssetActivation({
62
+ activateOnTap,
63
+ onAssetActivate,
64
+ setToast,
65
+ });
66
+ useEffect(() => {
67
+ if (!toast)
68
+ return;
69
+ const timer = globalThis.setTimeout(() => setToast(null), TOAST_DISMISS_MS);
70
+ return () => globalThis.clearTimeout(timer);
71
+ }, [toast]);
72
+ const makeDragStart = useCallback((element, shapeVariant) => (e) => {
73
+ const payload = { kind: 'element', element, shapeVariant };
7
74
  e.dataTransfer.setData(ELEMENT_DRAG_MIME, JSON.stringify(payload));
8
75
  e.dataTransfer.effectAllowed = 'copy';
9
76
  }, []);
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" })] })] }) })] }));
77
+ const handleElementActivate = useCallback((element, shapeVariant, label) => {
78
+ void activateAsset({ kind: 'element', element, shapeVariant, label });
79
+ }, [activateAsset]);
80
+ return (_jsxs("div", { className: cn('flex flex-col bg-transparent', className), style: style, children: [_jsx("div", { className: "px-3 py-[10px] border-b border-ed-border shrink-0", children: _jsx("span", { className: "text-[10px] font-bold text-ed-text-muted tracking-[0.08em]", children: "ELEMENTS" }) }), toast && (_jsx("div", { role: "status", className: "mx-[10px] mt-[10px] rounded-sm px-[10px] py-2 text-[10px] font-mono leading-[1.4]", style: {
81
+ color: toast.tone === 'warn' ? 'var(--elah-danger-text, #f5d0a9)' : 'var(--elah-info-text, #c8d8f0)',
82
+ background: toast.tone === 'warn' ? 'var(--elah-danger-bg, #3a2418)' : 'var(--elah-info-bg, #1a2433)',
83
+ border: `1px solid ${toast.tone === 'warn' ? 'var(--elah-danger-border, #7a4a2a)' : 'var(--elah-info-border, #355070)'}`,
84
+ }, children: toast.message })), _jsx("div", { className: "p-[10px] grid grid-cols-2 gap-[6px]", children: TILES.map(({ element, shapeVariant, label, icon, iconStyle }) => (_jsxs("div", { draggable: true, role: activationEnabled ? 'button' : undefined, tabIndex: activationEnabled ? 0 : undefined, className: "elah-element-card flex flex-col items-center justify-center gap-[6px] px-2 py-3 rounded-md cursor-grab select-none bg-ed-card border border-ed-border transition-[background,border-color] duration-[150ms] min-h-[72px]", onDragStart: makeDragStart(element, shapeVariant), onClick: activationEnabled ? () => handleElementActivate(element, shapeVariant, label) : undefined, onKeyDown: activationEnabled
85
+ ? (e) => {
86
+ if (!isActivationKey(e.key))
87
+ return;
88
+ e.preventDefault();
89
+ handleElementActivate(element, shapeVariant, label);
90
+ }
91
+ : undefined, title: `Drag ${label} onto the elements track`, children: [_jsx("span", { className: "w-8 h-8 shrink-0 flex items-center justify-center rounded-sm", style: iconStyle, children: icon }), _jsx("span", { className: "text-[11px] text-ed-text font-medium", children: label })] }, shapeVariant ? `${element}-${shapeVariant}` : element))) })] }));
51
92
  }
@@ -140,7 +140,7 @@ export function MediaTransformOverlay() {
140
140
  startDist: Math.hypot(e.clientX - centerClientX, e.clientY - centerClientY),
141
141
  };
142
142
  }, [selectedClipIds, selectClip, baseTransformFor, stage.width, stage.height]);
143
- return (_jsxs("div", { ref: rootRef, style: { position: 'absolute', inset: 0, zIndex: 2, pointerEvents: 'none', overflow: 'hidden' }, children: [!isPlaying && ownsSelection && (_jsx("div", { style: { position: 'absolute', inset: 0, pointerEvents: 'auto' }, onPointerDown: () => clearSelection() })), !isPlaying &&
143
+ return (_jsxs("div", { ref: rootRef, className: "absolute inset-0 z-[2] pointer-events-none overflow-hidden", children: [!isPlaying && ownsSelection && (_jsx("div", { className: "absolute inset-0 pointer-events-auto", onPointerDown: () => clearSelection() })), !isPlaying &&
144
144
  items.map((item) => {
145
145
  const { clip, rect } = item;
146
146
  const selected = selectedClipIds.has(clip.id);
@@ -151,7 +151,7 @@ export function MediaTransformOverlay() {
151
151
  width: rect.width + BOX_PAD * 2,
152
152
  height: rect.height + BOX_PAD * 2,
153
153
  boxSizing: 'border-box',
154
- border: selected ? '1px solid #4c9aff' : '1px solid transparent',
154
+ border: selected ? '1px solid var(--elah-selection-color, #4c9aff)' : '1px solid transparent',
155
155
  borderRadius: 2,
156
156
  cursor: 'move',
157
157
  pointerEvents: 'auto',
@@ -163,8 +163,8 @@ export function MediaTransformOverlay() {
163
163
  ...corner.pos,
164
164
  width: 10,
165
165
  height: 10,
166
- background: '#fff',
167
- border: '1px solid #4c9aff',
166
+ background: 'var(--elah-selection-handle, #fff)',
167
+ border: '1px solid var(--elah-selection-color, #4c9aff)',
168
168
  borderRadius: 2,
169
169
  cursor: corner.cursor,
170
170
  pointerEvents: 'auto',
@@ -1,6 +1,7 @@
1
1
  import { type CSSProperties } from 'react';
2
2
  import { GpuRenderer } from '@elah/core';
3
3
  import type { DemuxerFactory } from '@elah/core';
4
+ import type { AudioResolver } from '@elah/core';
4
5
  export interface PreviewHandle {
5
6
  getCanvas(): HTMLCanvasElement | null;
6
7
  getRenderer(): GpuRenderer | null;
@@ -12,6 +13,7 @@ export interface PreviewProps {
12
13
  clearColor?: [number, number, number, number];
13
14
  preserveDrawingBuffer?: boolean;
14
15
  enableAudio?: boolean;
16
+ audioResolver?: AudioResolver;
15
17
  style?: CSSProperties;
16
18
  className?: string;
17
19
  }
@@ -3,12 +3,14 @@ import { forwardRef, useEffect, useImperativeHandle, useRef, } from 'react';
3
3
  import { GpuRenderer } from '@elah/core';
4
4
  import { resolveTimeline } from '@elah/core';
5
5
  import { useTimelineEngine, usePlaybackEngine } from '@elah/core';
6
- import { AudioPlaybackController } from '@elah/core';
6
+ import { AudioPlaybackController, useMediaLibraryStore, preloadProjectImages, warmImageSrc } from '@elah/core';
7
+ import { cn } from '@elah/timeline';
7
8
  import { TextOverlay } from './TextOverlay';
9
+ import { ShapeOverlay } from './ShapeOverlay';
8
10
  import { MediaTransformOverlay } from './MediaTransformOverlay';
9
11
  import { TransitionOverlay } from './TransitionOverlay';
10
12
  import { StageBorder } from './StageBorder';
11
- export const Preview = forwardRef(function Preview({ demuxerFactory, debug = false, probeLayer = false, clearColor, preserveDrawingBuffer, enableAudio = true, style, className, }, ref) {
13
+ export const Preview = forwardRef(function Preview({ demuxerFactory, debug = false, probeLayer = false, clearColor, preserveDrawingBuffer, enableAudio = true, audioResolver, style, className, }, ref) {
12
14
  const containerRef = useRef(null);
13
15
  const rendererRef = useRef(null);
14
16
  const transitionOverlayRef = useRef(null);
@@ -39,34 +41,58 @@ export const Preview = forwardRef(function Preview({ demuxerFactory, debug = fal
39
41
  observer.observe(container);
40
42
  resize();
41
43
  const audio = enableAudio
42
- ? new AudioPlaybackController(playback, () => engine.getProject())
44
+ ? new AudioPlaybackController(playback, () => engine.getProject(), { audioResolver })
43
45
  : null;
44
46
  audio?.start();
47
+ let warmAudio = null;
48
+ const warmImages = () => preloadProjectImages(engine.getProject());
49
+ warmImages();
50
+ engine.on('change', warmImages);
51
+ if (audio) {
52
+ warmAudio = () => audio.preloadProjectAudio();
53
+ warmAudio();
54
+ engine.on('change', warmAudio);
55
+ }
56
+ const warmedAssetIds = new Set();
57
+ const unsubMediaLibrary = useMediaLibraryStore.subscribe((state) => {
58
+ for (const id of state.order) {
59
+ if (warmedAssetIds.has(id))
60
+ continue;
61
+ warmedAssetIds.add(id);
62
+ const asset = state.assets[id];
63
+ if (asset?.kind === 'audio')
64
+ audio?.warmAudioSrc(asset.src);
65
+ else if (asset?.kind === 'image')
66
+ warmImageSrc(asset.src);
67
+ }
68
+ });
45
69
  let rafId = 0;
70
+ const PREWARM_HORIZON_FRAMES = 30;
46
71
  const tick = () => {
47
72
  const frame = Math.floor(playback.getFrameAt());
48
- const scene = resolveTimeline(frame, engine.getProject());
73
+ const project = engine.getProject();
74
+ const scene = resolveTimeline(frame, project);
49
75
  const canvas = renderer.getCanvas();
50
76
  if (canvas)
51
77
  transitionOverlayRef.current?.captureIfNewTransition(scene, canvas);
52
78
  renderer.render(scene);
53
79
  transitionOverlayRef.current?.update(scene);
80
+ const prewarmScene = resolveTimeline(frame + PREWARM_HORIZON_FRAMES, project);
81
+ renderer.prewarm(prewarmScene);
54
82
  rafId = requestAnimationFrame(tick);
55
83
  };
56
84
  rafId = requestAnimationFrame(tick);
57
85
  return () => {
58
86
  cancelAnimationFrame(rafId);
59
87
  observer.disconnect();
88
+ if (warmAudio)
89
+ engine.off('change', warmAudio);
90
+ engine.off('change', warmImages);
91
+ unsubMediaLibrary();
60
92
  audio?.destroy();
61
93
  renderer.dispose();
62
94
  rendererRef.current = null;
63
95
  };
64
- }, [engine, playback, demuxerFactory, debug, probeLayer, preserveDrawingBuffer, enableAudio]);
65
- return (_jsxs("div", { ref: containerRef, className: className, style: {
66
- position: 'relative',
67
- width: '100%',
68
- height: '100%',
69
- background: '#06070A',
70
- ...style,
71
- }, children: [_jsx(StageBorder, {}), _jsx(TransitionOverlay, { ref: transitionOverlayRef }), _jsx(MediaTransformOverlay, {}), _jsx(TextOverlay, {})] }));
96
+ }, [engine, playback, demuxerFactory, debug, probeLayer, preserveDrawingBuffer, enableAudio, audioResolver]);
97
+ return (_jsxs("div", { ref: containerRef, className: cn('relative w-full h-full bg-[var(--elah-preview-bg,#06070A)]', className), style: style, children: [_jsx(StageBorder, {}), _jsx(TransitionOverlay, { ref: transitionOverlayRef }), _jsx(MediaTransformOverlay, {}), _jsx(ShapeOverlay, {}), _jsx(TextOverlay, {})] }));
72
98
  });
@@ -0,0 +1 @@
1
+ export declare function ShapeOverlay(): import("react/jsx-runtime").JSX.Element;