@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,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: "{project.name ?? project.id}". 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
|
+
}
|