@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.
- package/LICENSE +133 -0
- package/README.md +160 -127
- package/dist/editor/AssetPanel/AssetPanel.d.ts +4 -1
- package/dist/editor/AssetPanel/AssetPanel.js +109 -138
- package/dist/editor/ElementsPanel/ElementsPanel.d.ts +4 -1
- package/dist/editor/ElementsPanel/ElementsPanel.js +87 -46
- package/dist/editor/Preview/MediaTransformOverlay.js +4 -4
- package/dist/editor/Preview/Preview.d.ts +2 -0
- package/dist/editor/Preview/Preview.js +38 -12
- package/dist/editor/Preview/ShapeOverlay.d.ts +1 -0
- package/dist/editor/Preview/ShapeOverlay.js +178 -0
- package/dist/editor/Preview/StageBorder.js +3 -3
- package/dist/editor/Preview/TextOverlay.js +5 -5
- package/dist/editor/Preview/TransitionOverlay.js +1 -5
- package/dist/editor/SourcePanel/SourcePanel.d.ts +37 -0
- package/dist/editor/SourcePanel/SourcePanel.js +381 -0
- package/dist/editor/SourcePanel/index.d.ts +2 -0
- package/dist/editor/SourcePanel/index.js +1 -0
- package/dist/editor/activation.d.ts +26 -0
- package/dist/editor/activation.js +53 -0
- package/dist/index.d.ts +9 -2
- package/dist/index.js +5 -1
- package/dist/styles.css +1 -0
- package/package.json +75 -69
- package/src/styles/index.css +3 -0
- package/src/styles/tokens.css +204 -0
|
@@ -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
|
|
11
|
-
video: { label: 'VIDEO',
|
|
12
|
-
audio: { label: 'AUDIO',
|
|
13
|
-
image: { label: 'IMAGE',
|
|
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
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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: '
|
|
124
|
-
border: '1px solid
|
|
125
|
-
borderRadius:
|
|
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: '
|
|
130
|
-
}, children: _jsx("button", { type: "button", onMouseDown: (e) => e.stopPropagation(), onClick: handleDelete,
|
|
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 = '
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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:
|
|
227
|
-
color: importing ? '
|
|
228
|
-
border: '1px solid
|
|
229
|
-
borderRadius:
|
|
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:
|
|
232
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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", {
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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,
|
|
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
|
|
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;
|