@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.
Files changed (54) hide show
  1. package/README.md +165 -0
  2. package/package.json +46 -0
  3. package/src/__tests__/adapter-contract.test.ts +123 -0
  4. package/src/__tests__/adapter.test.ts +185 -0
  5. package/src/__tests__/schema.test.ts +104 -0
  6. package/src/carousel/AddElementMenu.tsx +211 -0
  7. package/src/carousel/CarouselEditor.tsx +529 -0
  8. package/src/carousel/CarouselRenderModal.tsx +243 -0
  9. package/src/carousel/OverlayErrorBoundary.tsx +99 -0
  10. package/src/carousel/OverlayPicker.tsx +145 -0
  11. package/src/carousel/SlideCanvas.tsx +588 -0
  12. package/src/carousel/SlidePropertyPanel.tsx +349 -0
  13. package/src/carousel/__tests__/CarouselEditor.test.tsx +235 -0
  14. package/src/crop/CanvasCropOverlay.tsx +193 -0
  15. package/src/crop/__tests__/crop-math.test.ts +174 -0
  16. package/src/crop/crop-math.ts +125 -0
  17. package/src/gestures/helpers/__tests__/element-transform.test.ts +30 -0
  18. package/src/gestures/helpers/drag.ts +24 -0
  19. package/src/gestures/helpers/element-transform.ts +15 -0
  20. package/src/gestures/helpers/resize.ts +60 -0
  21. package/src/gestures/helpers/rotate.ts +44 -0
  22. package/src/gestures/helpers/snap.ts +64 -0
  23. package/src/gestures/hooks/useOverlayDrag.ts +106 -0
  24. package/src/gestures/hooks/useOverlayResize.ts +67 -0
  25. package/src/gestures/hooks/useOverlayRotate.ts +64 -0
  26. package/src/gestures/index.ts +16 -0
  27. package/src/index.ts +112 -0
  28. package/src/overlays/contract.ts +41 -0
  29. package/src/preview/OverlayPreview.tsx +196 -0
  30. package/src/preview/__tests__/OverlayPreview.test.tsx +169 -0
  31. package/src/schema.ts +194 -0
  32. package/src/state/__tests__/project-reducer.test.ts +957 -0
  33. package/src/state/__tests__/use-project-state.test.tsx +258 -0
  34. package/src/state/mutation-queue.ts +62 -0
  35. package/src/state/project-reducer.ts +328 -0
  36. package/src/state/use-project-state.ts +442 -0
  37. package/src/test-setup.ts +1 -0
  38. package/src/text/FontPicker.tsx +218 -0
  39. package/src/text/InlineTextEditor.tsx +92 -0
  40. package/src/text/TextFormattingToolbar.tsx +248 -0
  41. package/src/text/__tests__/InlineTextEditor.test.tsx +139 -0
  42. package/src/text/__tests__/TextFormattingToolbar.test.tsx +416 -0
  43. package/src/theme.ts +93 -0
  44. package/src/types.ts +325 -0
  45. package/src/ui/__tests__/button.test.tsx +17 -0
  46. package/src/ui/badge.tsx +32 -0
  47. package/src/ui/button.tsx +32 -0
  48. package/src/ui/index.ts +16 -0
  49. package/src/ui/input.tsx +15 -0
  50. package/src/ui/label.tsx +10 -0
  51. package/src/ui/select.tsx +23 -0
  52. package/src/ui/switch.tsx +31 -0
  53. package/src/ui/textarea.tsx +15 -0
  54. 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
+ }