@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,529 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import { RefreshCw, AlertCircle, Download } from 'lucide-react'
3
+ import type { Project, Slide, CarouselElement, ImageElement, CarouselEditorProps, OverlayFactory } from '../types'
4
+ import { applyTheme, defaultMontajTheme } from '../theme'
5
+ import { useProjectState } from '../state/use-project-state'
6
+ import SlideCanvas from './SlideCanvas'
7
+ import SlidePropertyPanel from './SlidePropertyPanel'
8
+ import AddElementMenu from './AddElementMenu'
9
+ import CarouselRenderModal from './CarouselRenderModal'
10
+ import { Button } from '../ui'
11
+
12
+ // Generic over the host's concrete project type `P` (default = the package's
13
+ // own `Project`). Montaj passes its richer Project; the index signature on
14
+ // EditorProject absorbs the host-only pipeline fields, so a full host Project
15
+ // round-trips through load→edit→save (and `onProjectChange`) without casts.
16
+ type Props<P extends Project = Project> = CarouselEditorProps<P>
17
+
18
+ // ── SlideGrid (inline sub-component) ─────────────────────────────────────────
19
+
20
+ interface SlideGridProps {
21
+ project: Project
22
+ slides: Slide[]
23
+ selectedSlideId: string | null
24
+ onSelect: (id: string) => void
25
+ onAdd: () => void
26
+ onDuplicate: (id: string) => void
27
+ onDelete: (id: string) => void
28
+ onReorder: (fromIdx: number, toIdx: number) => void
29
+ resolveImageSrc?: (element: ImageElement) => string
30
+ compileOverlay?: (template: string) => Promise<OverlayFactory>
31
+ }
32
+
33
+ function SlideGrid({
34
+ project,
35
+ slides,
36
+ selectedSlideId,
37
+ onSelect,
38
+ onAdd,
39
+ onDuplicate,
40
+ onDelete,
41
+ onReorder,
42
+ resolveImageSrc,
43
+ compileOverlay,
44
+ }: SlideGridProps) {
45
+ const [w, h] = project.settings.resolution
46
+ const THUMB_W = 200
47
+ const scale = THUMB_W / w
48
+ const thumbH = Math.round(h * scale)
49
+
50
+ const dragIdx = useRef<number | null>(null)
51
+ const [dragOverIdx, setDragOverIdx] = useState<number | null>(null)
52
+
53
+ function handleDragStart(idx: number) {
54
+ dragIdx.current = idx
55
+ }
56
+ function handleDragOver(e: React.DragEvent, idx: number) {
57
+ e.preventDefault()
58
+ setDragOverIdx(idx)
59
+ }
60
+ function handleDrop(toIdx: number) {
61
+ if (dragIdx.current !== null && dragIdx.current !== toIdx) {
62
+ onReorder(dragIdx.current, toIdx)
63
+ }
64
+ dragIdx.current = null
65
+ setDragOverIdx(null)
66
+ }
67
+ function handleDragEnd() {
68
+ dragIdx.current = null
69
+ setDragOverIdx(null)
70
+ }
71
+
72
+ return (
73
+ <div className="w-56 flex-shrink-0 flex flex-col border-r border-gray-800 bg-gray-950 overflow-y-auto">
74
+ <div className="px-3 py-2 border-b border-gray-800">
75
+ <span className="text-xs font-semibold text-gray-400 uppercase tracking-wider">Slides</span>
76
+ </div>
77
+ <div className="flex-1 overflow-y-auto py-2 flex flex-col gap-2 px-2">
78
+ {slides.map((slide, idx) => (
79
+ <div
80
+ key={slide.id}
81
+ draggable
82
+ onDragStart={() => handleDragStart(idx)}
83
+ onDragOver={e => handleDragOver(e, idx)}
84
+ onDrop={() => handleDrop(idx)}
85
+ onDragEnd={handleDragEnd}
86
+ onClick={() => onSelect(slide.id)}
87
+ className={`group relative cursor-pointer rounded overflow-hidden border transition-colors ${
88
+ selectedSlideId === slide.id
89
+ ? 'border-blue-500'
90
+ : dragOverIdx === idx
91
+ ? 'border-blue-400 opacity-70'
92
+ : 'border-gray-700 hover:border-gray-500'
93
+ }`}
94
+ style={{ width: THUMB_W, height: thumbH }}
95
+ >
96
+ <SlideCanvas slide={slide} width={w} height={h} interactive={false} scale={scale} resolveImageSrc={resolveImageSrc} compileOverlay={compileOverlay} />
97
+ <div className="absolute bottom-1 left-1 text-xs text-white bg-black/50 px-1 rounded">
98
+ {idx + 1}
99
+ </div>
100
+ <div className="absolute top-1 right-1 hidden group-hover:flex gap-1">
101
+ <button
102
+ onClick={e => { e.stopPropagation(); onDuplicate(slide.id) }}
103
+ className="text-xs bg-black/60 text-white px-1 py-0.5 rounded hover:bg-black/80"
104
+ title="Duplicate slide"
105
+ >
106
+
107
+ </button>
108
+ <button
109
+ onClick={e => { e.stopPropagation(); onDelete(slide.id) }}
110
+ className="text-xs bg-black/60 text-red-400 px-1 py-0.5 rounded hover:bg-black/80"
111
+ title="Delete slide"
112
+ >
113
+ ×
114
+ </button>
115
+ </div>
116
+ </div>
117
+ ))}
118
+ </div>
119
+ <div className="p-2 border-t border-gray-800">
120
+ <Button size="sm" variant="outline" onClick={onAdd} className="w-full text-xs">
121
+ + Add Slide
122
+ </Button>
123
+ </div>
124
+ </div>
125
+ )
126
+ }
127
+
128
+ // ── helpers ──
129
+
130
+ function deepCloneElement(el: CarouselElement): CarouselElement {
131
+ if (el.type === 'overlay') {
132
+ return {
133
+ ...el,
134
+ id: crypto.randomUUID(),
135
+ overlay: { template: el.overlay.template, props: { ...el.overlay.props } },
136
+ }
137
+ }
138
+ return { ...el, id: crypto.randomUUID() }
139
+ }
140
+
141
+ function makeSlide(): Slide {
142
+ return { id: crypto.randomUUID(), base_color: '#ffffff', elements: [] }
143
+ }
144
+
145
+ function isTypingTarget(t: EventTarget | null): boolean {
146
+ const el = t as HTMLElement | null
147
+ if (!el) return false
148
+ const tag = el.tagName
149
+ return tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable
150
+ }
151
+
152
+ // ── CarouselEditor ────────────────────────────────────────────────────────────
153
+
154
+ export default function CarouselEditor<P extends Project = Project>({ project: initialProject, adapter, onProjectChange, theme, slots }: Props<P>) {
155
+ const state = useProjectState(adapter, initialProject.id, initialProject)
156
+ const project = state.project
157
+ const slides = project.slides ?? []
158
+
159
+ // Keep the host's project state in sync with the hook's authoritative state.
160
+ useEffect(() => {
161
+ onProjectChange?.(project)
162
+ }, [project, onProjectChange])
163
+
164
+ const [selectedSlideId, setSelectedSlideId] = useState<string | null>(slides[0]?.id ?? null)
165
+ const [selectedElementId, setSelectedElementId] = useState<string | null>(null)
166
+ const [cropElementId, setCropElementId] = useState<string | null>(null)
167
+
168
+ const [skillPath, setSkillPath] = useState<string | null>(null)
169
+ const [copied, setCopied] = useState(false)
170
+ const [refreshing, setRefreshing] = useState(false)
171
+ const [refreshState, setRefreshState] = useState<'idle' | 'err'>('idle')
172
+ const [rendering, setRendering] = useState(false)
173
+ const [renderOpen, setRenderOpen] = useState(false)
174
+
175
+ // ── Theme: apply tokens onto the editor container. ──
176
+ const containerRef = useRef<HTMLDivElement>(null)
177
+ useEffect(() => {
178
+ if (containerRef.current) applyTheme(containerRef.current, theme ?? defaultMontajTheme)
179
+ }, [theme])
180
+
181
+ // ── Keyboard shortcuts: undo / redo. Guarded against text inputs. ──
182
+ useEffect(() => {
183
+ const onKey = (e: KeyboardEvent) => {
184
+ if (isTypingTarget(e.target)) return
185
+ const mod = e.metaKey || e.ctrlKey
186
+ if (!mod) return
187
+ const key = e.key.toLowerCase()
188
+ if (key === 'z' && !e.shiftKey) {
189
+ e.preventDefault()
190
+ state.undo()
191
+ } else if ((key === 'z' && e.shiftKey) || key === 'y') {
192
+ e.preventDefault()
193
+ state.redo()
194
+ }
195
+ }
196
+ window.addEventListener('keydown', onKey)
197
+ return () => window.removeEventListener('keydown', onKey)
198
+ }, [state])
199
+
200
+ async function handleRender() {
201
+ setRendering(true)
202
+ try {
203
+ await state.setStatus('final')
204
+ setRenderOpen(true)
205
+ } catch (e) {
206
+ alert(`Failed to start render: ${e instanceof Error ? e.message : String(e)}`)
207
+ } finally {
208
+ setRendering(false)
209
+ }
210
+ }
211
+
212
+ async function handleRefresh() {
213
+ setRefreshing(true)
214
+ setRefreshState('idle')
215
+ const [result] = await Promise.allSettled([
216
+ state.refetch(),
217
+ new Promise(r => setTimeout(r, 1000)),
218
+ ])
219
+ setRefreshing(false)
220
+ if (result.status === 'rejected') {
221
+ console.error(result.reason)
222
+ setRefreshState('err')
223
+ setTimeout(() => setRefreshState('idle'), 2500)
224
+ }
225
+ }
226
+
227
+ useEffect(() => {
228
+ adapter.getInfo?.().then(info => setSkillPath(info.root_skill_path ?? null)).catch(() => {})
229
+ }, [adapter])
230
+
231
+ // Auto-select first slide, or re-select when the current one disappears.
232
+ useEffect(() => {
233
+ if (slides.length === 0) return
234
+ const stillExists = selectedSlideId && slides.some(s => s.id === selectedSlideId)
235
+ if (!stillExists) {
236
+ setSelectedSlideId(slides[0].id)
237
+ setSelectedElementId(null)
238
+ setCropElementId(null)
239
+ }
240
+ }, [slides, selectedSlideId])
241
+
242
+ // Auto-create a starter slide for a non-pending project that ended up empty.
243
+ const initialSlideCreatedRef = useRef(false)
244
+ useEffect(() => {
245
+ if (initialSlideCreatedRef.current) return
246
+ if (project.status === 'pending') return
247
+ if (slides.length === 0) {
248
+ initialSlideCreatedRef.current = true
249
+ const slide = makeSlide()
250
+ void state.addSlide(slide)
251
+ setSelectedSlideId(slide.id)
252
+ }
253
+ }, [project.status, slides.length])
254
+
255
+ // ── Slide handlers (via project-state mutators) ──
256
+ function handleAddSlide() {
257
+ const slide = makeSlide()
258
+ void state.addSlide(slide, selectedSlideId ?? undefined)
259
+ setSelectedSlideId(slide.id)
260
+ setSelectedElementId(null)
261
+ }
262
+ function handleDuplicateSlide(id: string) {
263
+ const src = slides.find(s => s.id === id)
264
+ if (!src) return
265
+ const clone: Slide = { ...src, id: crypto.randomUUID(), elements: src.elements.map(deepCloneElement) }
266
+ void state.duplicateSlide(id, clone)
267
+ setSelectedSlideId(clone.id)
268
+ setSelectedElementId(null)
269
+ }
270
+ function handleDeleteSlide(id: string) {
271
+ void state.removeSlide(id)
272
+ if (selectedSlideId === id) {
273
+ setSelectedElementId(null)
274
+ setCropElementId(null)
275
+ }
276
+ }
277
+ function handleReorderSlides(fromIdx: number, toIdx: number) {
278
+ void state.reorderSlides(fromIdx, toIdx)
279
+ }
280
+
281
+ // ── Element handlers ──
282
+ function handleAddElement(slideId: string, element: CarouselElement) {
283
+ void state.addElement(slideId, element)
284
+ setSelectedElementId(element.id)
285
+ }
286
+ function handleDeleteElement(slideId: string, elementId: string) {
287
+ void state.removeElement(slideId, elementId)
288
+ setSelectedElementId(null)
289
+ if (cropElementId === elementId) setCropElementId(null)
290
+ }
291
+ function handleDuplicateElement(slideId: string, elementId: string) {
292
+ const slide = slides.find(s => s.id === slideId)
293
+ const src = slide?.elements.find(el => el.id === elementId)
294
+ if (!src) return
295
+ const clone = { ...deepCloneElement(src), x: src.x + 20, y: src.y + 20 }
296
+ void state.duplicateElement(slideId, elementId, clone)
297
+ setSelectedElementId(clone.id)
298
+ }
299
+ function handleReorderElement(slideId: string, elementId: string, direction: 'forward' | 'backward') {
300
+ void state.reorderElement(slideId, elementId, direction)
301
+ }
302
+
303
+ // Property-panel transform/frame edits → committed mutators.
304
+ function handlePanelElementChange(patch: Partial<CarouselElement>) {
305
+ if (!selectedSlideId || !selectedElementId) return
306
+ const slide = slides.find(s => s.id === selectedSlideId)
307
+ const el = slide?.elements.find(e => e.id === selectedElementId)
308
+ if (!el) return
309
+ if ('x' in patch || 'y' in patch || 'w' in patch || 'h' in patch) {
310
+ const box = {
311
+ x: patch.x ?? el.x,
312
+ y: patch.y ?? el.y,
313
+ w: patch.w ?? el.w,
314
+ h: patch.h ?? el.h,
315
+ }
316
+ void state.resizeElement(selectedSlideId, selectedElementId, box).then(() => state.commit())
317
+ }
318
+ if ('rotation' in patch && typeof patch.rotation === 'number') {
319
+ void state.rotateElement(selectedSlideId, selectedElementId, patch.rotation).then(() => state.commit())
320
+ }
321
+ if (el.type === 'overlay' && 'overlay' in patch && patch.overlay) {
322
+ // Prop edits from the generic PropEditor — diff and write per key.
323
+ const nextProps = (patch.overlay as { props: Record<string, unknown> }).props
324
+ for (const [k, v] of Object.entries(nextProps)) {
325
+ if (el.overlay.props[k] !== v) {
326
+ void state.updateOverlayProp(selectedSlideId, selectedElementId, k, String(v))
327
+ }
328
+ }
329
+ }
330
+ if (el.type === 'overlay' && 'frame' in patch && typeof patch.frame === 'number') {
331
+ void state.setOverlayFrame(selectedSlideId, selectedElementId, patch.frame)
332
+ }
333
+ }
334
+
335
+ function handleSlideChange(patch: Partial<Slide>) {
336
+ if (selectedSlideId) void state.updateSlide(selectedSlideId, patch)
337
+ }
338
+
339
+ const selectedSlide = slides.find(s => s.id === selectedSlideId)
340
+ const selectedElement = selectedSlide?.elements.find(el => el.id === selectedElementId)
341
+
342
+ const [w, h] = project.settings.resolution
343
+ const canvasContainerRef = useRef<HTMLDivElement>(null)
344
+ const [canvasContainerSize, setCanvasContainerSize] = useState<{ w: number; h: number }>({ w: 600, h: 700 })
345
+ useEffect(() => {
346
+ const el = canvasContainerRef.current
347
+ if (!el) return
348
+ const obs = new ResizeObserver(([entry]) => {
349
+ setCanvasContainerSize({ w: entry.contentRect.width, h: entry.contentRect.height })
350
+ })
351
+ obs.observe(el)
352
+ return () => obs.disconnect()
353
+ }, [])
354
+ const PADDING = 48
355
+ const HINT_RESERVE = 36
356
+ const availW = Math.max(0, canvasContainerSize.w - PADDING)
357
+ const availH = Math.max(0, canvasContainerSize.h - PADDING - HINT_RESERVE)
358
+ const canvasScale = Math.min(availW / w, availH / h, 1)
359
+
360
+ return (
361
+ <div ref={containerRef} className="flex h-full overflow-hidden bg-gray-950">
362
+ <SlideGrid
363
+ project={project}
364
+ slides={slides}
365
+ selectedSlideId={selectedSlideId}
366
+ onSelect={id => { setSelectedSlideId(id); setSelectedElementId(null); setCropElementId(null) }}
367
+ onAdd={handleAddSlide}
368
+ onDuplicate={handleDuplicateSlide}
369
+ onDelete={handleDeleteSlide}
370
+ onReorder={handleReorderSlides}
371
+ resolveImageSrc={adapter.resolveImageSrc}
372
+ compileOverlay={(t) => adapter.compileOverlay(t)}
373
+ />
374
+
375
+ <div ref={canvasContainerRef} className="relative flex-1 flex flex-col items-center justify-center gap-4 overflow-hidden p-6">
376
+ <button
377
+ onClick={handleRefresh}
378
+ disabled={refreshing}
379
+ className={`absolute top-3 left-3 z-30 flex items-center gap-2 px-3 py-2 rounded-md border transition-colors ${
380
+ refreshState === 'err'
381
+ ? 'text-red-300 border-red-500/40 bg-red-950/60 hover:bg-red-900/70'
382
+ : 'text-gray-200 border-gray-700 bg-gray-900/80 hover:text-white hover:border-gray-500 hover:bg-gray-800'
383
+ }`}
384
+ title={refreshState === 'err' ? 'Refresh failed — check connection' : 'Refresh project'}
385
+ >
386
+ {refreshState === 'err' ? <AlertCircle size={18} /> : <RefreshCw size={18} className={refreshing ? 'animate-spin' : ''} />}
387
+ <span className="text-xs font-medium">Refresh</span>
388
+ </button>
389
+
390
+ <button
391
+ onClick={handleRender}
392
+ disabled={rendering || project.status === 'pending' || slides.length === 0}
393
+ className="absolute top-3 right-3 z-30 flex items-center gap-2 px-3 py-2 rounded-md border border-blue-500/50 bg-blue-600/80 text-white hover:bg-blue-600 hover:border-blue-400 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
394
+ title={
395
+ project.status === 'pending'
396
+ ? 'Wait for the agent to finish before rendering'
397
+ : slides.length === 0
398
+ ? 'Add slides before rendering'
399
+ : 'Render all slides as PNGs'
400
+ }
401
+ >
402
+ <Download size={18} />
403
+ <span className="text-xs font-medium">{rendering ? 'Starting…' : 'Render'}</span>
404
+ </button>
405
+
406
+ {project.status === 'pending' ? (
407
+ <div className="flex flex-col items-center gap-6 text-center max-w-lg w-full">
408
+ {slots?.pendingStatus ?? (
409
+ <div className="flex flex-col items-center gap-2">
410
+ <p className="text-white text-lg font-semibold">Message your agent to start</p>
411
+ <p className="text-gray-400 text-sm">Nothing will happen automatically. Copy this and send it to your agent.</p>
412
+ </div>
413
+ )}
414
+ {!slots?.pendingStatus && skillPath && (
415
+ <div className="w-full rounded-xl border-2 border-blue-400/50 bg-gray-900 p-5 flex flex-col gap-3 text-left shadow-lg shadow-blue-400/10">
416
+ <p className="text-blue-400 text-xs font-bold uppercase tracking-widest">Send this to your agent</p>
417
+ <div className="flex items-start justify-between bg-black/60 border border-transparent rounded-lg px-3 py-3 font-mono gap-3">
418
+ <span className="text-gray-200 text-[12px] leading-relaxed break-all">
419
+ There is a new project pending: &quot;{project.name ?? project.id}&quot;. Please see @{skillPath} and start. Talk to me if you run into questions.
420
+ </span>
421
+ <button
422
+ onClick={() => {
423
+ navigator.clipboard.writeText(
424
+ `There is a new project pending: "${project.name ?? project.id}". Please see @${skillPath} and start. Talk to me if you run into questions.`
425
+ )
426
+ setCopied(true)
427
+ setTimeout(() => setCopied(false), 2000)
428
+ }}
429
+ className={`shrink-0 flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-md transition-colors ${
430
+ copied ? 'bg-green-700 text-green-200' : 'bg-white/10 text-gray-300 hover:bg-white/20 hover:text-white'
431
+ }`}
432
+ title="Copy prompt"
433
+ >
434
+ {copied ? '✓ Copied' : 'Copy'}
435
+ </button>
436
+ </div>
437
+ </div>
438
+ )}
439
+ <p className="text-gray-600 text-xs font-mono">project id: {project.id}</p>
440
+ </div>
441
+ ) : selectedSlide ? (
442
+ <>
443
+ <div className="flex-shrink-0" style={{ boxShadow: '0 0 0 1px rgba(255,255,255,0.08)' }}>
444
+ <SlideCanvas
445
+ slide={selectedSlide}
446
+ slideId={selectedSlide.id}
447
+ width={w}
448
+ height={h}
449
+ interactive
450
+ selectedElementId={selectedElementId}
451
+ onSelect={id => { setSelectedElementId(id); if (id !== cropElementId) setCropElementId(null) }}
452
+ scale={canvasScale}
453
+ resolveImageSrc={adapter.resolveImageSrc}
454
+ compileOverlay={(t) => adapter.compileOverlay(t)}
455
+ watchFile={adapter.watchFile}
456
+ moveElement={state.moveElement}
457
+ resizeElement={state.resizeElement}
458
+ rotateElement={state.rotateElement}
459
+ commit={state.commit}
460
+ updateOverlayProp={state.updateOverlayProp}
461
+ updateImageCrop={state.updateImageCrop}
462
+ cropElementId={cropElementId}
463
+ onExitCrop={() => setCropElementId(null)}
464
+ />
465
+ </div>
466
+ <p className="flex-shrink-0 text-xs text-gray-500 text-center max-w-md">
467
+ Drag to reposition, resize/rotate via handles, double-click text to edit. Cmd/Ctrl+Z to undo.
468
+ </p>
469
+ </>
470
+ ) : (
471
+ <div className="text-gray-600 text-sm">No slides yet. Add one in the left panel.</div>
472
+ )}
473
+
474
+ {state.lastError && (
475
+ <div className="absolute bottom-3 left-1/2 -translate-x-1/2 z-30 flex items-center gap-2 px-3 py-2 rounded-md border border-red-500/40 bg-red-950/80 text-red-200 text-xs">
476
+ <AlertCircle size={14} />
477
+ <span>{state.lastError}</span>
478
+ <button onClick={state.clearError} className="ml-2 underline">dismiss</button>
479
+ </div>
480
+ )}
481
+ </div>
482
+
483
+ <div className="flex flex-col overflow-hidden">
484
+ {selectedSlide && project.status !== 'pending' && (
485
+ <div className="px-4 py-2 border-l border-b border-gray-800 bg-gray-950">
486
+ <AddElementMenu
487
+ project={project}
488
+ selectedSlideId={selectedSlideId}
489
+ adapter={adapter}
490
+ onAddElement={handleAddElement}
491
+ />
492
+ </div>
493
+ )}
494
+ <SlidePropertyPanel
495
+ project={project}
496
+ slide={selectedSlide}
497
+ element={selectedElement}
498
+ adapter={adapter}
499
+ onSlideChange={handleSlideChange}
500
+ onElementChange={handlePanelElementChange}
501
+ onDeleteSlide={handleDeleteSlide}
502
+ onDuplicateSlide={handleDuplicateSlide}
503
+ onDeleteElement={handleDeleteElement}
504
+ onDuplicateElement={handleDuplicateElement}
505
+ onReorderElement={handleReorderElement}
506
+ onEnterCrop={(_slideId, elementId) => { setSelectedElementId(elementId); setCropElementId(elementId) }}
507
+ updateOverlayProp={state.updateOverlayProp}
508
+ />
509
+ {slots?.assetsPanel && (
510
+ <div className="border-t border-gray-800 flex flex-col overflow-hidden" style={{ minHeight: 180 }}>
511
+ {slots.assetsPanel}
512
+ </div>
513
+ )}
514
+ </div>
515
+
516
+ {renderOpen && (
517
+ <CarouselRenderModal
518
+ projectId={project.id}
519
+ adapter={adapter}
520
+ slidesCount={slides.length}
521
+ resolution={project.settings.resolution as [number, number]}
522
+ exportActions={slots?.exportActions}
523
+ onClose={() => setRenderOpen(false)}
524
+ onCancel={() => setRenderOpen(false)}
525
+ />
526
+ )}
527
+ </div>
528
+ )
529
+ }