@devbycrux/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 +165 -0
- package/package.json +46 -0
- package/src/__tests__/adapter-contract.test.ts +123 -0
- package/src/__tests__/adapter.test.ts +185 -0
- package/src/__tests__/schema.test.ts +104 -0
- package/src/carousel/AddElementMenu.tsx +211 -0
- package/src/carousel/CarouselEditor.tsx +529 -0
- package/src/carousel/CarouselRenderModal.tsx +243 -0
- package/src/carousel/OverlayErrorBoundary.tsx +99 -0
- package/src/carousel/OverlayPicker.tsx +145 -0
- package/src/carousel/SlideCanvas.tsx +588 -0
- package/src/carousel/SlidePropertyPanel.tsx +349 -0
- package/src/carousel/__tests__/CarouselEditor.test.tsx +235 -0
- package/src/crop/CanvasCropOverlay.tsx +193 -0
- package/src/crop/__tests__/crop-math.test.ts +174 -0
- package/src/crop/crop-math.ts +125 -0
- package/src/gestures/helpers/__tests__/element-transform.test.ts +30 -0
- package/src/gestures/helpers/drag.ts +24 -0
- package/src/gestures/helpers/element-transform.ts +15 -0
- package/src/gestures/helpers/resize.ts +60 -0
- package/src/gestures/helpers/rotate.ts +44 -0
- package/src/gestures/helpers/snap.ts +64 -0
- package/src/gestures/hooks/useOverlayDrag.ts +106 -0
- package/src/gestures/hooks/useOverlayResize.ts +67 -0
- package/src/gestures/hooks/useOverlayRotate.ts +64 -0
- package/src/gestures/index.ts +16 -0
- package/src/index.ts +112 -0
- package/src/overlays/contract.ts +41 -0
- package/src/preview/OverlayPreview.tsx +196 -0
- package/src/preview/__tests__/OverlayPreview.test.tsx +169 -0
- package/src/schema.ts +194 -0
- package/src/state/__tests__/project-reducer.test.ts +957 -0
- package/src/state/__tests__/use-project-state.test.tsx +258 -0
- package/src/state/mutation-queue.ts +62 -0
- package/src/state/project-reducer.ts +328 -0
- package/src/state/use-project-state.ts +442 -0
- package/src/test-setup.ts +1 -0
- package/src/text/FontPicker.tsx +218 -0
- package/src/text/InlineTextEditor.tsx +92 -0
- package/src/text/TextFormattingToolbar.tsx +248 -0
- package/src/text/__tests__/InlineTextEditor.test.tsx +139 -0
- package/src/text/__tests__/TextFormattingToolbar.test.tsx +416 -0
- package/src/theme.ts +93 -0
- package/src/types.ts +325 -0
- package/src/ui/__tests__/button.test.tsx +17 -0
- package/src/ui/badge.tsx +32 -0
- package/src/ui/button.tsx +32 -0
- package/src/ui/index.ts +16 -0
- package/src/ui/input.tsx +15 -0
- package/src/ui/label.tsx +10 -0
- package/src/ui/select.tsx +23 -0
- package/src/ui/switch.tsx +31 -0
- package/src/ui/textarea.tsx +15 -0
- package/src/ui/utils.ts +7 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
|
2
|
+
import type { EditorAdapter, Project } from '../types'
|
|
3
|
+
|
|
4
|
+
interface CarouselRenderModalProps {
|
|
5
|
+
projectId: string
|
|
6
|
+
/** Adapter drives the render stream and path→URL resolution. */
|
|
7
|
+
adapter: EditorAdapter<Project>
|
|
8
|
+
/** Number of slides in the project — drives the gallery row count. */
|
|
9
|
+
slidesCount: number
|
|
10
|
+
/** Slide resolution [width, height] — drives thumbnail aspect ratio. */
|
|
11
|
+
resolution: [number, number]
|
|
12
|
+
/** Fired when the modal closes from a finished or errored state. */
|
|
13
|
+
onClose: () => void
|
|
14
|
+
/** Fired when the user cancels an in-progress render. Falls back to onClose. */
|
|
15
|
+
onCancel?: () => void
|
|
16
|
+
/**
|
|
17
|
+
* Host-supplied export controls (e.g. a "Download all (.zip)" link). Rendered
|
|
18
|
+
* in the done-state info panel. The package no longer hardcodes host URLs.
|
|
19
|
+
*/
|
|
20
|
+
exportActions?: ReactNode
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function slideFile(index: number): string {
|
|
24
|
+
return `slide_${String(index + 1).padStart(2, '0')}.png`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function LogLine({ text }: { text: string }) {
|
|
28
|
+
const t = text.replace(/^\[render\]\s*/, '')
|
|
29
|
+
let color = 'text-gray-400'
|
|
30
|
+
if (/done|complete|→/i.test(t)) color = 'text-green-400'
|
|
31
|
+
else if (/rendering|launching|bundling/i.test(t)) color = 'text-sky-400'
|
|
32
|
+
else if (/error|fail/i.test(t)) color = 'text-red-400'
|
|
33
|
+
|
|
34
|
+
const prefix = text.startsWith('[render]')
|
|
35
|
+
? <span className="text-gray-600">[render] </span>
|
|
36
|
+
: null
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<span className={`leading-relaxed whitespace-pre-wrap break-all ${color}`}>
|
|
40
|
+
{prefix}{t}
|
|
41
|
+
</span>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export default function CarouselRenderModal({ projectId, adapter, slidesCount, resolution, onClose, onCancel, exportActions }: CarouselRenderModalProps) {
|
|
46
|
+
const [logs, setLogs] = useState<string[]>([])
|
|
47
|
+
const [status, setStatus] = useState<'running' | 'done' | 'error'>('running')
|
|
48
|
+
const [outputDir, setOutDir] = useState<string | null>(null)
|
|
49
|
+
const [errorMsg, setError] = useState<string | null>(null)
|
|
50
|
+
const logRef = useRef<HTMLDivElement>(null)
|
|
51
|
+
const cancelledRef = useRef(false)
|
|
52
|
+
const unmountedRef = useRef(false)
|
|
53
|
+
const cleanupTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
// StrictMode-safe render trigger — see RenderModal.tsx for the long-form
|
|
57
|
+
// comment.
|
|
58
|
+
if (cleanupTimerRef.current !== null) {
|
|
59
|
+
clearTimeout(cleanupTimerRef.current)
|
|
60
|
+
cleanupTimerRef.current = null
|
|
61
|
+
unmountedRef.current = false
|
|
62
|
+
return scheduleCleanup
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
unmountedRef.current = false
|
|
66
|
+
cancelledRef.current = false
|
|
67
|
+
;(async () => {
|
|
68
|
+
try {
|
|
69
|
+
for await (const ev of adapter.render(projectId)) {
|
|
70
|
+
if (cancelledRef.current || unmountedRef.current) break
|
|
71
|
+
if (ev.type === 'log') setLogs(l => [...l, ev.message])
|
|
72
|
+
else if (ev.type === 'done') { setOutDir(ev.outputPath); setStatus('done') }
|
|
73
|
+
else if (ev.type === 'error') { setError(ev.message); setStatus('error') }
|
|
74
|
+
}
|
|
75
|
+
} catch (e) {
|
|
76
|
+
if (!cancelledRef.current && !unmountedRef.current) {
|
|
77
|
+
setError(String(e)); setStatus('error')
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
})()
|
|
81
|
+
return scheduleCleanup
|
|
82
|
+
|
|
83
|
+
function scheduleCleanup() {
|
|
84
|
+
cleanupTimerRef.current = setTimeout(() => {
|
|
85
|
+
cleanupTimerRef.current = null
|
|
86
|
+
unmountedRef.current = true
|
|
87
|
+
cancelledRef.current = true
|
|
88
|
+
}, 0)
|
|
89
|
+
}
|
|
90
|
+
}, [projectId, adapter])
|
|
91
|
+
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight
|
|
94
|
+
}, [logs])
|
|
95
|
+
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
const onKey = (e: KeyboardEvent) => {
|
|
98
|
+
if (e.key === 'Escape' && status !== 'running') onClose()
|
|
99
|
+
}
|
|
100
|
+
document.addEventListener('keydown', onKey)
|
|
101
|
+
return () => document.removeEventListener('keydown', onKey)
|
|
102
|
+
}, [status, onClose])
|
|
103
|
+
|
|
104
|
+
function handleCancel() {
|
|
105
|
+
cancelledRef.current = true
|
|
106
|
+
;(onCancel ?? onClose)()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── Done state — gallery + zip download ─────────────────────────────────
|
|
110
|
+
if (status === 'done' && outputDir) {
|
|
111
|
+
return (
|
|
112
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 backdrop-blur-md">
|
|
113
|
+
<div className="w-[96vw] h-[96vh] bg-gray-950 border border-gray-800 rounded-2xl shadow-2xl flex overflow-hidden">
|
|
114
|
+
|
|
115
|
+
{/* Left — slide gallery */}
|
|
116
|
+
<div className="flex-1 bg-black flex items-center justify-center overflow-auto p-8">
|
|
117
|
+
<div
|
|
118
|
+
className="grid gap-4 w-full max-w-6xl"
|
|
119
|
+
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))' }}
|
|
120
|
+
>
|
|
121
|
+
{Array.from({ length: slidesCount }).map((_, i) => {
|
|
122
|
+
const file = slideFile(i)
|
|
123
|
+
const url = adapter.fileUrl(`${outputDir}/${file}`)
|
|
124
|
+
return (
|
|
125
|
+
<a
|
|
126
|
+
key={i}
|
|
127
|
+
href={url}
|
|
128
|
+
target="_blank"
|
|
129
|
+
rel="noreferrer"
|
|
130
|
+
className="group relative block rounded-lg overflow-hidden border border-gray-800 hover:border-gray-600 transition-colors bg-gray-900"
|
|
131
|
+
>
|
|
132
|
+
<img
|
|
133
|
+
src={url}
|
|
134
|
+
alt={file}
|
|
135
|
+
className="block w-full h-auto"
|
|
136
|
+
style={{ aspectRatio: `${resolution[0]} / ${resolution[1]}` }}
|
|
137
|
+
/>
|
|
138
|
+
<div className="absolute bottom-0 left-0 right-0 px-2 py-1.5 bg-black/70 backdrop-blur-sm text-[11px] text-gray-300 font-mono flex justify-between">
|
|
139
|
+
<span>#{String(i + 1).padStart(2, '0')}</span>
|
|
140
|
+
<span className="text-gray-500">{file}</span>
|
|
141
|
+
</div>
|
|
142
|
+
</a>
|
|
143
|
+
)
|
|
144
|
+
})}
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
{/* Right — info panel */}
|
|
149
|
+
<div className="w-72 shrink-0 flex flex-col border-l border-gray-800">
|
|
150
|
+
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-800">
|
|
151
|
+
<div className="flex items-center gap-2.5">
|
|
152
|
+
<span className="w-2 h-2 rounded-full bg-green-400" />
|
|
153
|
+
<div>
|
|
154
|
+
<p className="text-sm font-semibold text-white">Render complete</p>
|
|
155
|
+
<p className="text-xs text-gray-400">
|
|
156
|
+
{slidesCount} slide{slidesCount === 1 ? '' : 's'} ready.
|
|
157
|
+
</p>
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
<button onClick={onClose} className="text-gray-500 hover:text-white transition-colors text-lg leading-none">×</button>
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
<div className="flex flex-col gap-3 p-5 flex-1">
|
|
164
|
+
<p className="text-xs font-mono text-gray-500 break-all leading-relaxed">{outputDir}</p>
|
|
165
|
+
{exportActions}
|
|
166
|
+
<button
|
|
167
|
+
onClick={onClose}
|
|
168
|
+
className="w-full text-center text-sm px-4 py-2.5 rounded-lg bg-gray-800 border border-gray-700 text-gray-300 hover:bg-gray-700 transition-colors"
|
|
169
|
+
>
|
|
170
|
+
Close
|
|
171
|
+
</button>
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── Running / error state — log readout ─────────────────────────────────
|
|
180
|
+
return (
|
|
181
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
|
182
|
+
<div className="w-full max-w-3xl bg-gray-900 border border-gray-700 rounded-xl shadow-2xl flex flex-col overflow-hidden">
|
|
183
|
+
|
|
184
|
+
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-800">
|
|
185
|
+
<div className="flex items-center gap-2.5">
|
|
186
|
+
{status === 'running' && <span className="w-2 h-2 rounded-full bg-amber-400 animate-pulse" />}
|
|
187
|
+
{status === 'error' && <span className="w-2 h-2 rounded-full bg-red-400" />}
|
|
188
|
+
<div className="flex flex-col gap-0.5">
|
|
189
|
+
<h2 className="text-sm font-semibold text-white">
|
|
190
|
+
{status === 'running' ? 'Rendering slides…' : 'Render failed'}
|
|
191
|
+
</h2>
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
{status !== 'running' && (
|
|
195
|
+
<button onClick={onClose} className="text-gray-500 hover:text-white transition-colors text-lg leading-none">×</button>
|
|
196
|
+
)}
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
<div className="relative">
|
|
200
|
+
<button
|
|
201
|
+
onClick={() => navigator.clipboard.writeText(logs.join('\n') + (errorMsg ? '\n' + errorMsg : ''))}
|
|
202
|
+
className="absolute top-2 right-2 z-10 text-[10px] px-2 py-0.5 rounded bg-gray-800 border border-gray-700 text-gray-400 hover:text-white hover:border-gray-500 transition-colors"
|
|
203
|
+
title="Copy logs"
|
|
204
|
+
>
|
|
205
|
+
Copy
|
|
206
|
+
</button>
|
|
207
|
+
<div
|
|
208
|
+
ref={logRef}
|
|
209
|
+
className="h-96 overflow-y-auto px-4 py-3 font-mono text-[11px] text-gray-300 bg-gray-950 flex flex-col gap-0.5"
|
|
210
|
+
>
|
|
211
|
+
{logs.length === 0 && status === 'running' && (
|
|
212
|
+
<span className="text-gray-600 italic">Starting render engine…</span>
|
|
213
|
+
)}
|
|
214
|
+
{logs.map((line, i) => (
|
|
215
|
+
<LogLine key={i} text={line} />
|
|
216
|
+
))}
|
|
217
|
+
{status === 'error' && errorMsg && (
|
|
218
|
+
<span className="text-red-400 mt-1">{errorMsg}</span>
|
|
219
|
+
)}
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
|
|
223
|
+
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-gray-800">
|
|
224
|
+
{status === 'running' ? (
|
|
225
|
+
<button
|
|
226
|
+
onClick={handleCancel}
|
|
227
|
+
className="text-sm px-4 py-1.5 rounded-md bg-gray-800 border border-gray-700 text-gray-300 hover:bg-red-900/40 hover:border-red-700 hover:text-red-300 transition-colors"
|
|
228
|
+
>
|
|
229
|
+
Cancel
|
|
230
|
+
</button>
|
|
231
|
+
) : (
|
|
232
|
+
<button
|
|
233
|
+
onClick={onClose}
|
|
234
|
+
className="text-sm px-4 py-1.5 rounded-md bg-gray-800 border border-gray-700 text-white hover:bg-gray-700 transition-colors"
|
|
235
|
+
>
|
|
236
|
+
Close
|
|
237
|
+
</button>
|
|
238
|
+
)}
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
)
|
|
243
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OverlayErrorBoundary — catches render-phase errors thrown by compiled overlay JSX
|
|
3
|
+
* so a single broken overlay can't crash the whole editor.
|
|
4
|
+
*
|
|
5
|
+
* overlay-eval's try/catch only wraps the synchronous factory call. If the factory
|
|
6
|
+
* returns an element whose component is undefined (e.g. `Ph.CircuitBoard` — not a real
|
|
7
|
+
* Phosphor name), React throws minified error #130 during reconciliation, escaping
|
|
8
|
+
* the factory's try/catch and unmounting the entire app tree. This boundary contains
|
|
9
|
+
* that fault and shows the broken overlay's filename instead.
|
|
10
|
+
*
|
|
11
|
+
* Auto-recovery:
|
|
12
|
+
* - `watchPath` + `watchFile`: when the host injects a `watchFile` watcher, the
|
|
13
|
+
* boundary subscribes to `watchPath` and clears the error on file change, so
|
|
14
|
+
* editing the overlay's source recovers the preview without a page reload.
|
|
15
|
+
* The package opens NO transport itself — without `watchFile` it simply
|
|
16
|
+
* doesn't watch (a non-Montaj host gets a no-op, not an `/api/files/stream`
|
|
17
|
+
* EventSource).
|
|
18
|
+
* - `resetKey`: clears the error whenever this value changes between renders. Use
|
|
19
|
+
* for non-path-backed sources like caption styles or template ids.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { Component, type ErrorInfo, type ReactNode } from 'react'
|
|
23
|
+
|
|
24
|
+
interface Props {
|
|
25
|
+
/** Filename or label shown in the fallback UI. */
|
|
26
|
+
label: string
|
|
27
|
+
/** Optional host path to watch — clears the error on file change (needs `watchFile`). */
|
|
28
|
+
watchPath?: string
|
|
29
|
+
/**
|
|
30
|
+
* Optional host file watcher (from the adapter). Subscribes to `watchPath` and
|
|
31
|
+
* returns an unsubscribe. Absent → no watch is opened.
|
|
32
|
+
*/
|
|
33
|
+
watchFile?: (path: string, onChange: () => void) => () => void
|
|
34
|
+
/** Optional reset key — clears the error when its value changes between renders. */
|
|
35
|
+
resetKey?: string | number
|
|
36
|
+
children: ReactNode
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface State {
|
|
40
|
+
error: Error | null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export default class OverlayErrorBoundary extends Component<Props, State> {
|
|
44
|
+
private unwatch: (() => void) | null = null
|
|
45
|
+
state: State = { error: null }
|
|
46
|
+
|
|
47
|
+
static getDerivedStateFromError(error: Error): State {
|
|
48
|
+
return { error }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
componentDidCatch(error: Error, info: ErrorInfo) {
|
|
52
|
+
console.warn(`[OverlayErrorBoundary] ${this.props.label} (render):`, error, info.componentStack)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
componentDidMount() {
|
|
56
|
+
this.openWatcher()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
componentDidUpdate(prev: Props) {
|
|
60
|
+
if (prev.watchPath !== this.props.watchPath || prev.watchFile !== this.props.watchFile) {
|
|
61
|
+
this.closeWatcher()
|
|
62
|
+
this.openWatcher()
|
|
63
|
+
}
|
|
64
|
+
if (this.state.error && prev.resetKey !== this.props.resetKey) {
|
|
65
|
+
this.setState({ error: null })
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
componentWillUnmount() {
|
|
70
|
+
this.closeWatcher()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private openWatcher() {
|
|
74
|
+
const { watchPath, watchFile } = this.props
|
|
75
|
+
if (!watchPath || !watchFile) return
|
|
76
|
+
this.unwatch = watchFile(watchPath, () => {
|
|
77
|
+
if (this.state.error) this.setState({ error: null })
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private closeWatcher() {
|
|
82
|
+
this.unwatch?.()
|
|
83
|
+
this.unwatch = null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
render() {
|
|
87
|
+
if (this.state.error) {
|
|
88
|
+
return (
|
|
89
|
+
<div className="absolute inset-0 flex items-end justify-center p-2 pointer-events-none z-50">
|
|
90
|
+
<div className="bg-red-950/85 border border-red-700 text-red-300 text-[11px] px-3 py-2 rounded font-mono max-w-full">
|
|
91
|
+
<div className="truncate">overlay error: {this.props.label}</div>
|
|
92
|
+
<div className="truncate opacity-75">{this.state.error.message}</div>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
return this.props.children
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import type { Project, OverlayElement, GlobalOverlay, EditorAdapter } from '../types'
|
|
3
|
+
|
|
4
|
+
// TODO (v2): live thumbnail rendering for each overlay card
|
|
5
|
+
// TODO (v2): respect overlay staticFrame when rendering thumbnails — requires API extension
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
open: boolean
|
|
9
|
+
onClose: () => void
|
|
10
|
+
project: Project
|
|
11
|
+
adapter: EditorAdapter<Project>
|
|
12
|
+
onPick: (element: OverlayElement) => void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default function OverlayPicker({ open, onClose, project, adapter, onPick }: Props) {
|
|
16
|
+
const [overlays, setOverlays] = useState<GlobalOverlay[]>([])
|
|
17
|
+
const [loaded, setLoaded] = useState(false)
|
|
18
|
+
const [loading, setLoading] = useState(false)
|
|
19
|
+
const [error, setError] = useState<string | null>(null)
|
|
20
|
+
// fetchingRef prevents concurrent fetches; loaded state prevents re-fetch after success
|
|
21
|
+
const fetchingRef = useRef(false)
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (!open || loaded || fetchingRef.current) return
|
|
25
|
+
fetchingRef.current = true
|
|
26
|
+
setLoading(true)
|
|
27
|
+
setError(null)
|
|
28
|
+
|
|
29
|
+
const promises: Promise<GlobalOverlay[]>[] = [adapter.listGlobalOverlays()]
|
|
30
|
+
if (project.profile) {
|
|
31
|
+
promises.push(adapter.listProfileOverlays?.(project.profile) ?? Promise.resolve([]))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
Promise.all(promises)
|
|
35
|
+
.then(results => {
|
|
36
|
+
// Combine and dedupe by jsxPath
|
|
37
|
+
const seen = new Set<string>()
|
|
38
|
+
const combined: GlobalOverlay[] = []
|
|
39
|
+
for (const list of results) {
|
|
40
|
+
for (const o of list) {
|
|
41
|
+
if (!seen.has(o.jsxPath)) {
|
|
42
|
+
seen.add(o.jsxPath)
|
|
43
|
+
combined.push(o)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
setOverlays(combined)
|
|
48
|
+
setLoaded(true)
|
|
49
|
+
})
|
|
50
|
+
.catch(e => setError(e instanceof Error ? e.message : String(e)))
|
|
51
|
+
// fetchingRef reset in finally so a failed fetch can be retried on next open
|
|
52
|
+
.finally(() => { fetchingRef.current = false; setLoading(false) })
|
|
53
|
+
}, [open, loaded, project.profile])
|
|
54
|
+
|
|
55
|
+
// Reset loaded state when closed so next open re-fetches fresh data
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (!open) setLoaded(false)
|
|
58
|
+
}, [open])
|
|
59
|
+
|
|
60
|
+
// Escape key to close
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (!open) return
|
|
63
|
+
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
|
64
|
+
document.addEventListener('keydown', onKey)
|
|
65
|
+
return () => document.removeEventListener('keydown', onKey)
|
|
66
|
+
}, [open, onClose])
|
|
67
|
+
|
|
68
|
+
if (!open) return null
|
|
69
|
+
|
|
70
|
+
function handlePick(overlay: GlobalOverlay) {
|
|
71
|
+
const [w, h] = project.settings.resolution
|
|
72
|
+
const elementW = 800
|
|
73
|
+
const elementH = 200
|
|
74
|
+
const durationProp = overlay.props.find(p => p.name === 'duration')
|
|
75
|
+
const duration = typeof durationProp?.default === 'number' ? durationProp.default : 60
|
|
76
|
+
const props = Object.fromEntries(
|
|
77
|
+
overlay.props
|
|
78
|
+
.filter(p => p.default !== undefined)
|
|
79
|
+
.map(p => [p.name, p.default])
|
|
80
|
+
)
|
|
81
|
+
const element: OverlayElement = {
|
|
82
|
+
id: crypto.randomUUID(),
|
|
83
|
+
type: 'overlay',
|
|
84
|
+
overlay: { template: overlay.jsxPath, props },
|
|
85
|
+
frame: Math.max(0, duration - 1),
|
|
86
|
+
x: Math.round(w / 2 - elementW / 2),
|
|
87
|
+
y: Math.round(h / 2 - elementH / 2),
|
|
88
|
+
w: elementW,
|
|
89
|
+
h: elementH,
|
|
90
|
+
rotation: 0,
|
|
91
|
+
}
|
|
92
|
+
onPick(element)
|
|
93
|
+
onClose()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<div
|
|
98
|
+
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
|
99
|
+
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
|
|
100
|
+
>
|
|
101
|
+
<div className="bg-gray-900 border border-gray-700 rounded-xl shadow-2xl w-full max-w-2xl max-h-[80vh] flex flex-col overflow-hidden">
|
|
102
|
+
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-800">
|
|
103
|
+
<h2 className="text-sm font-semibold text-white">Add Overlay</h2>
|
|
104
|
+
<button
|
|
105
|
+
onClick={onClose}
|
|
106
|
+
className="text-gray-500 hover:text-white transition-colors text-lg leading-none"
|
|
107
|
+
>
|
|
108
|
+
×
|
|
109
|
+
</button>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<div className="flex-1 overflow-y-auto p-4">
|
|
113
|
+
{loading && (
|
|
114
|
+
<div className="text-center text-gray-500 text-sm py-8">Loading overlays…</div>
|
|
115
|
+
)}
|
|
116
|
+
{error && (
|
|
117
|
+
<div className="text-center text-red-400 text-sm py-8">{error}</div>
|
|
118
|
+
)}
|
|
119
|
+
{!loading && !error && overlays.length === 0 && (
|
|
120
|
+
<div className="text-center text-gray-500 text-sm py-8">No overlays available</div>
|
|
121
|
+
)}
|
|
122
|
+
{!loading && !error && overlays.length > 0 && (
|
|
123
|
+
<div className="grid grid-cols-3 gap-3">
|
|
124
|
+
{overlays.map(overlay => (
|
|
125
|
+
<button
|
|
126
|
+
key={overlay.jsxPath}
|
|
127
|
+
onClick={() => handlePick(overlay)}
|
|
128
|
+
className="text-left p-3 bg-gray-800 hover:bg-gray-700 border border-gray-700 hover:border-gray-500 rounded-lg transition-colors"
|
|
129
|
+
>
|
|
130
|
+
<div className="text-sm font-medium text-white truncate">{overlay.name}</div>
|
|
131
|
+
{overlay.group && (
|
|
132
|
+
<div className="text-xs text-blue-400 mt-0.5 truncate">{overlay.group}</div>
|
|
133
|
+
)}
|
|
134
|
+
{overlay.description && (
|
|
135
|
+
<div className="text-xs text-gray-400 mt-1 line-clamp-2">{overlay.description}</div>
|
|
136
|
+
)}
|
|
137
|
+
</button>
|
|
138
|
+
))}
|
|
139
|
+
</div>
|
|
140
|
+
)}
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
)
|
|
145
|
+
}
|