@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,349 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import { Crop } from 'lucide-react'
|
|
3
|
+
import type {
|
|
4
|
+
Project,
|
|
5
|
+
Slide,
|
|
6
|
+
CarouselElement,
|
|
7
|
+
OverlayElement,
|
|
8
|
+
GlobalOverlay,
|
|
9
|
+
GlobalOverlayProp,
|
|
10
|
+
EditorAdapter,
|
|
11
|
+
} from '../types'
|
|
12
|
+
import { Button } from '../ui'
|
|
13
|
+
import { TextFormattingToolbar } from '../text/TextFormattingToolbar'
|
|
14
|
+
|
|
15
|
+
function parseNumber(v: string): number | null {
|
|
16
|
+
const n = Number(v)
|
|
17
|
+
return Number.isFinite(n) ? n : null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface Props {
|
|
21
|
+
project: Project
|
|
22
|
+
slide?: Slide
|
|
23
|
+
element?: CarouselElement
|
|
24
|
+
onSlideChange: (patch: Partial<Slide>) => void
|
|
25
|
+
onElementChange: (patch: Partial<CarouselElement>) => void
|
|
26
|
+
onDeleteSlide: (id: string) => void
|
|
27
|
+
onDuplicateSlide: (id: string) => void
|
|
28
|
+
onDeleteElement: (slideId: string, elementId: string) => void
|
|
29
|
+
onDuplicateElement: (slideId: string, elementId: string) => void
|
|
30
|
+
onReorderElement: (slideId: string, elementId: string, direction: 'forward' | 'backward') => void
|
|
31
|
+
// Crop entry: enabled only for unrotated image elements.
|
|
32
|
+
onEnterCrop?: (slideId: string, elementId: string) => void
|
|
33
|
+
// editor-core text mutator for the formatting toolbar.
|
|
34
|
+
updateOverlayProp?: (slideId: string, elementId: string, key: string, value: string) => Promise<void>
|
|
35
|
+
// Adapter supplies overlay-schema listing (global + profile-scoped).
|
|
36
|
+
adapter: EditorAdapter<Project>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function numInput(
|
|
40
|
+
label: string,
|
|
41
|
+
value: number,
|
|
42
|
+
onChange: (v: number) => void,
|
|
43
|
+
opts?: { min?: number; max?: number; step?: number }
|
|
44
|
+
) {
|
|
45
|
+
return (
|
|
46
|
+
<label className="flex flex-col gap-0.5">
|
|
47
|
+
<span className="text-xs text-gray-500">{label}</span>
|
|
48
|
+
<input
|
|
49
|
+
type="number"
|
|
50
|
+
value={value}
|
|
51
|
+
min={opts?.min}
|
|
52
|
+
max={opts?.max}
|
|
53
|
+
step={opts?.step ?? 1}
|
|
54
|
+
onChange={e => {
|
|
55
|
+
const parsed = parseNumber(e.target.value)
|
|
56
|
+
if (parsed !== null) onChange(parsed)
|
|
57
|
+
}}
|
|
58
|
+
className="bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-gray-500 w-full"
|
|
59
|
+
/>
|
|
60
|
+
</label>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function PropEditor({
|
|
65
|
+
prop,
|
|
66
|
+
value,
|
|
67
|
+
onChange,
|
|
68
|
+
}: {
|
|
69
|
+
prop: GlobalOverlayProp
|
|
70
|
+
value: unknown
|
|
71
|
+
onChange: (v: unknown) => void
|
|
72
|
+
}) {
|
|
73
|
+
const { name, type, description } = prop
|
|
74
|
+
|
|
75
|
+
if (type === 'bool') {
|
|
76
|
+
return (
|
|
77
|
+
<label className="flex items-center gap-2 cursor-pointer" title={description}>
|
|
78
|
+
<input
|
|
79
|
+
type="checkbox"
|
|
80
|
+
checked={Boolean(value)}
|
|
81
|
+
onChange={e => onChange(e.target.checked)}
|
|
82
|
+
className="accent-blue-500"
|
|
83
|
+
/>
|
|
84
|
+
<span className="text-xs text-gray-300">{name}</span>
|
|
85
|
+
</label>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (type === 'color') {
|
|
90
|
+
return (
|
|
91
|
+
<label className="flex flex-col gap-0.5" title={description}>
|
|
92
|
+
<span className="text-xs text-gray-500">{name}</span>
|
|
93
|
+
<input
|
|
94
|
+
type="color"
|
|
95
|
+
value={String(value ?? '#000000')}
|
|
96
|
+
onChange={e => onChange(e.target.value)}
|
|
97
|
+
className="w-full h-7 bg-gray-800 border border-gray-700 rounded cursor-pointer"
|
|
98
|
+
/>
|
|
99
|
+
</label>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (type === 'int' || type === 'float') {
|
|
104
|
+
return (
|
|
105
|
+
<label className="flex flex-col gap-0.5" title={description}>
|
|
106
|
+
<span className="text-xs text-gray-500">{name}</span>
|
|
107
|
+
<input
|
|
108
|
+
type="number"
|
|
109
|
+
value={Number(value ?? 0)}
|
|
110
|
+
step={type === 'float' ? 0.1 : 1}
|
|
111
|
+
onChange={e => {
|
|
112
|
+
const parsed = parseNumber(e.target.value)
|
|
113
|
+
if (parsed !== null) onChange(parsed)
|
|
114
|
+
}}
|
|
115
|
+
className="bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-gray-500 w-full"
|
|
116
|
+
/>
|
|
117
|
+
</label>
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// string fallback
|
|
122
|
+
return (
|
|
123
|
+
<label className="flex flex-col gap-0.5" title={description}>
|
|
124
|
+
<span className="text-xs text-gray-500">{name}</span>
|
|
125
|
+
<input
|
|
126
|
+
type="text"
|
|
127
|
+
value={String(value ?? '')}
|
|
128
|
+
onChange={e => onChange(e.target.value)}
|
|
129
|
+
className="bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-gray-500 w-full"
|
|
130
|
+
/>
|
|
131
|
+
</label>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export default function SlidePropertyPanel({
|
|
136
|
+
project,
|
|
137
|
+
slide,
|
|
138
|
+
element,
|
|
139
|
+
onSlideChange,
|
|
140
|
+
onElementChange,
|
|
141
|
+
onDeleteSlide,
|
|
142
|
+
onDuplicateSlide,
|
|
143
|
+
onDeleteElement,
|
|
144
|
+
onDuplicateElement,
|
|
145
|
+
onReorderElement,
|
|
146
|
+
onEnterCrop,
|
|
147
|
+
updateOverlayProp,
|
|
148
|
+
adapter,
|
|
149
|
+
}: Props) {
|
|
150
|
+
// Map of jsxPath → GlobalOverlay for overlay prop schemas
|
|
151
|
+
const [overlaySchemas, setOverlaySchemas] = useState<Map<string, GlobalOverlay>>(new Map())
|
|
152
|
+
const [schemasLoading, setSchemasLoading] = useState(false)
|
|
153
|
+
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
setSchemasLoading(true)
|
|
156
|
+
const promises: Promise<GlobalOverlay[]>[] = [adapter.listGlobalOverlays()]
|
|
157
|
+
if (project.profile) {
|
|
158
|
+
promises.push(adapter.listProfileOverlays?.(project.profile) ?? Promise.resolve([]))
|
|
159
|
+
}
|
|
160
|
+
Promise.all(promises)
|
|
161
|
+
.then(results => {
|
|
162
|
+
const map = new Map<string, GlobalOverlay>()
|
|
163
|
+
for (const list of results) {
|
|
164
|
+
for (const o of list) {
|
|
165
|
+
map.set(o.jsxPath, o)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
setOverlaySchemas(map)
|
|
169
|
+
})
|
|
170
|
+
.catch(() => {})
|
|
171
|
+
.finally(() => setSchemasLoading(false))
|
|
172
|
+
}, [project.profile])
|
|
173
|
+
|
|
174
|
+
if (!slide) {
|
|
175
|
+
return (
|
|
176
|
+
<div className="w-80 flex-shrink-0 flex items-center justify-center text-gray-600 text-xs p-4">
|
|
177
|
+
Select a slide
|
|
178
|
+
</div>
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const overlayEl = element?.type === 'overlay' ? (element as OverlayElement) : null
|
|
183
|
+
const overlaySchema = overlayEl ? overlaySchemas.get(overlayEl.overlay.template) : null
|
|
184
|
+
|
|
185
|
+
return (
|
|
186
|
+
<div className="w-80 flex-shrink-0 border-l border-gray-800 flex flex-col overflow-y-auto bg-gray-950">
|
|
187
|
+
{/* Slide header */}
|
|
188
|
+
<div className="px-4 py-3 border-b border-gray-800">
|
|
189
|
+
<div className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">Slide</div>
|
|
190
|
+
<div className="flex flex-col gap-2">
|
|
191
|
+
<label className="flex flex-col gap-0.5">
|
|
192
|
+
<span className="text-xs text-gray-500">Background color</span>
|
|
193
|
+
<input
|
|
194
|
+
type="color"
|
|
195
|
+
value={slide.base_color || '#ffffff'}
|
|
196
|
+
onChange={e => onSlideChange({ base_color: e.target.value })}
|
|
197
|
+
className="w-full h-7 bg-gray-800 border border-gray-700 rounded cursor-pointer"
|
|
198
|
+
/>
|
|
199
|
+
</label>
|
|
200
|
+
<div className="flex gap-2">
|
|
201
|
+
<Button
|
|
202
|
+
size="sm"
|
|
203
|
+
variant="outline"
|
|
204
|
+
className="flex-1 text-xs"
|
|
205
|
+
onClick={() => onDuplicateSlide(slide.id)}
|
|
206
|
+
>
|
|
207
|
+
Duplicate
|
|
208
|
+
</Button>
|
|
209
|
+
<Button
|
|
210
|
+
size="sm"
|
|
211
|
+
variant="outline"
|
|
212
|
+
className="flex-1 text-xs text-red-400 hover:text-red-300"
|
|
213
|
+
onClick={() => onDeleteSlide(slide.id)}
|
|
214
|
+
>
|
|
215
|
+
Delete
|
|
216
|
+
</Button>
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
|
|
221
|
+
{/* Element section */}
|
|
222
|
+
{element && (
|
|
223
|
+
<div className="px-4 py-3 flex flex-col gap-3">
|
|
224
|
+
<div className="flex items-center justify-between">
|
|
225
|
+
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
|
226
|
+
{element.type === 'image' ? 'Image' : 'Overlay'}
|
|
227
|
+
</span>
|
|
228
|
+
<div className="flex gap-1">
|
|
229
|
+
<button
|
|
230
|
+
onClick={() => onReorderElement(slide.id, element.id, 'forward')}
|
|
231
|
+
className="text-xs text-gray-500 hover:text-white px-1"
|
|
232
|
+
title="Bring forward"
|
|
233
|
+
>
|
|
234
|
+
↑
|
|
235
|
+
</button>
|
|
236
|
+
<button
|
|
237
|
+
onClick={() => onReorderElement(slide.id, element.id, 'backward')}
|
|
238
|
+
className="text-xs text-gray-500 hover:text-white px-1"
|
|
239
|
+
title="Send backward"
|
|
240
|
+
>
|
|
241
|
+
↓
|
|
242
|
+
</button>
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
{/* Transform */}
|
|
247
|
+
<div className="grid grid-cols-2 gap-2">
|
|
248
|
+
{numInput('X', element.x, v => onElementChange({ x: v }))}
|
|
249
|
+
{numInput('Y', element.y, v => onElementChange({ y: v }))}
|
|
250
|
+
{numInput('W', element.w, v => onElementChange({ w: v }), { min: 1 })}
|
|
251
|
+
{numInput('H', element.h, v => onElementChange({ h: v }), { min: 1 })}
|
|
252
|
+
{numInput('Rotation', element.rotation ?? 0, v => onElementChange({ rotation: v }), { min: -360, max: 360 })}
|
|
253
|
+
</div>
|
|
254
|
+
|
|
255
|
+
{/* Image-specific */}
|
|
256
|
+
{element.type === 'image' && (
|
|
257
|
+
<div className="flex flex-col gap-1.5">
|
|
258
|
+
<div className="flex flex-col gap-0.5">
|
|
259
|
+
<span className="text-xs text-gray-500">Source</span>
|
|
260
|
+
<span className="text-xs text-gray-300 truncate" title={element.src}>
|
|
261
|
+
{element.src.split('/').pop() || element.src}
|
|
262
|
+
</span>
|
|
263
|
+
</div>
|
|
264
|
+
<Button
|
|
265
|
+
size="sm"
|
|
266
|
+
variant="outline"
|
|
267
|
+
className="text-xs flex items-center gap-1.5"
|
|
268
|
+
disabled={(element.rotation ?? 0) !== 0 || !onEnterCrop}
|
|
269
|
+
title={
|
|
270
|
+
(element.rotation ?? 0) !== 0
|
|
271
|
+
? 'Reset rotation to 0 before cropping'
|
|
272
|
+
: 'Crop image'
|
|
273
|
+
}
|
|
274
|
+
onClick={() => onEnterCrop?.(slide.id, element.id)}
|
|
275
|
+
>
|
|
276
|
+
<Crop className="h-3.5 w-3.5" />
|
|
277
|
+
Crop
|
|
278
|
+
</Button>
|
|
279
|
+
</div>
|
|
280
|
+
)}
|
|
281
|
+
|
|
282
|
+
{/* Overlay-specific */}
|
|
283
|
+
{overlayEl && (
|
|
284
|
+
<div className="flex flex-col gap-2">
|
|
285
|
+
{/* Rich-text formatting (bold/italic/case/color/align + font family
|
|
286
|
+
& size pickers) for overlays exposing the standard text contract. */}
|
|
287
|
+
{updateOverlayProp && (
|
|
288
|
+
<TextFormattingToolbar
|
|
289
|
+
slideId={slide.id}
|
|
290
|
+
element={overlayEl}
|
|
291
|
+
updateOverlayProp={updateOverlayProp}
|
|
292
|
+
/>
|
|
293
|
+
)}
|
|
294
|
+
|
|
295
|
+
<div className="grid grid-cols-2 gap-2">
|
|
296
|
+
{numInput('Frame', overlayEl.frame, v => onElementChange({ frame: v }), { min: 0 })}
|
|
297
|
+
</div>
|
|
298
|
+
|
|
299
|
+
{schemasLoading && (
|
|
300
|
+
<div className="text-xs text-gray-500">Loading overlay props…</div>
|
|
301
|
+
)}
|
|
302
|
+
|
|
303
|
+
{!schemasLoading && overlaySchema && overlaySchema.props.length > 0 && (
|
|
304
|
+
<div className="flex flex-col gap-2">
|
|
305
|
+
<span className="text-xs text-gray-500 font-medium">Props</span>
|
|
306
|
+
{overlaySchema.props.map(prop => (
|
|
307
|
+
<PropEditor
|
|
308
|
+
key={prop.name}
|
|
309
|
+
prop={prop}
|
|
310
|
+
value={overlayEl.overlay.props[prop.name]}
|
|
311
|
+
onChange={v => {
|
|
312
|
+
onElementChange({
|
|
313
|
+
overlay: {
|
|
314
|
+
...overlayEl.overlay,
|
|
315
|
+
props: { ...overlayEl.overlay.props, [prop.name]: v },
|
|
316
|
+
},
|
|
317
|
+
} as Partial<CarouselElement>)
|
|
318
|
+
}}
|
|
319
|
+
/>
|
|
320
|
+
))}
|
|
321
|
+
</div>
|
|
322
|
+
)}
|
|
323
|
+
</div>
|
|
324
|
+
)}
|
|
325
|
+
|
|
326
|
+
{/* Element actions */}
|
|
327
|
+
<div className="flex gap-2 pt-1">
|
|
328
|
+
<Button
|
|
329
|
+
size="sm"
|
|
330
|
+
variant="outline"
|
|
331
|
+
className="flex-1 text-xs"
|
|
332
|
+
onClick={() => onDuplicateElement(slide.id, element.id)}
|
|
333
|
+
>
|
|
334
|
+
Duplicate
|
|
335
|
+
</Button>
|
|
336
|
+
<Button
|
|
337
|
+
size="sm"
|
|
338
|
+
variant="outline"
|
|
339
|
+
className="flex-1 text-xs text-red-400 hover:text-red-300"
|
|
340
|
+
onClick={() => onDeleteElement(slide.id, element.id)}
|
|
341
|
+
>
|
|
342
|
+
Delete
|
|
343
|
+
</Button>
|
|
344
|
+
</div>
|
|
345
|
+
</div>
|
|
346
|
+
)}
|
|
347
|
+
</div>
|
|
348
|
+
)
|
|
349
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { render, act, waitFor, fireEvent } from '@testing-library/react'
|
|
3
|
+
import type { EditorAdapter, ImageElement, Project, RenderEvent } from '../../types'
|
|
4
|
+
import CarouselEditor from '../CarouselEditor'
|
|
5
|
+
|
|
6
|
+
// ── Fake adapter (mirrors editor-core's use-project-state test pattern) ───────
|
|
7
|
+
// The package owns the assembled editor now: no host (`@/`) modules are mocked.
|
|
8
|
+
// A full fake `EditorAdapter` drives load/save/render and the overlay-list /
|
|
9
|
+
// upload / fileUrl primitives the assembled editor consumes.
|
|
10
|
+
|
|
11
|
+
function makeProject(overrides: Partial<Project> = {}): Project {
|
|
12
|
+
return {
|
|
13
|
+
version: '1',
|
|
14
|
+
id: 'proj-1',
|
|
15
|
+
name: 'Test',
|
|
16
|
+
workflow: 'carousel',
|
|
17
|
+
status: 'draft',
|
|
18
|
+
editingPrompt: '',
|
|
19
|
+
projectType: 'carousel',
|
|
20
|
+
settings: { resolution: [1080, 1080] },
|
|
21
|
+
assets: [],
|
|
22
|
+
slides: [
|
|
23
|
+
{
|
|
24
|
+
id: 'slide-0',
|
|
25
|
+
base_color: '#ffffff',
|
|
26
|
+
elements: [
|
|
27
|
+
{
|
|
28
|
+
id: 'el-img',
|
|
29
|
+
type: 'image',
|
|
30
|
+
src: 'a.png',
|
|
31
|
+
x: 100,
|
|
32
|
+
y: 100,
|
|
33
|
+
w: 200,
|
|
34
|
+
h: 200,
|
|
35
|
+
rotation: 0,
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
...overrides,
|
|
41
|
+
} as Project
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface FakeAdapter extends EditorAdapter<Project> {
|
|
45
|
+
saveCalls: Array<{ id: string; project: Project }>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function makeFakeAdapter(): FakeAdapter {
|
|
49
|
+
const saveCalls: Array<{ id: string; project: Project }> = []
|
|
50
|
+
return {
|
|
51
|
+
loadProject: vi.fn(async () => makeProject()),
|
|
52
|
+
saveProject: vi.fn(async (id: string, project: Project) => { saveCalls.push({ id, project }) }),
|
|
53
|
+
subscribe: () => () => {},
|
|
54
|
+
render: async function* (): AsyncIterable<RenderEvent> {
|
|
55
|
+
yield { type: 'done', outputPath: '/out.png' }
|
|
56
|
+
},
|
|
57
|
+
resolveImageSrc: (el: ImageElement) => el.src,
|
|
58
|
+
compileOverlay: vi.fn(async () => () => null),
|
|
59
|
+
listGlobalOverlays: vi.fn(async () => []),
|
|
60
|
+
listSystemOverlays: vi.fn(async () => []),
|
|
61
|
+
uploadFile: vi.fn(async () => '/path'),
|
|
62
|
+
fileUrl: (path: string) => path,
|
|
63
|
+
saveCalls,
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
beforeEach(() => {
|
|
68
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
69
|
+
// ResizeObserver isn't in jsdom.
|
|
70
|
+
;(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class {
|
|
71
|
+
observe() {}
|
|
72
|
+
unobserve() {}
|
|
73
|
+
disconnect() {}
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
afterEach(() => vi.restoreAllMocks())
|
|
77
|
+
|
|
78
|
+
// The element id appears twice in the DOM: once in the left-rail thumbnail
|
|
79
|
+
// (non-interactive SlideCanvas) and once in the main interactive canvas. The
|
|
80
|
+
// interactive canvas renders last, so take the final match.
|
|
81
|
+
function findInteractiveWrapper(elementId: string): HTMLElement {
|
|
82
|
+
const els = document.querySelectorAll(`[data-element-id="${elementId}"]`)
|
|
83
|
+
if (els.length === 0) throw new Error(`element wrapper ${elementId} not found`)
|
|
84
|
+
return els[els.length - 1] as HTMLElement
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
describe('CarouselEditor — editor-core integration', () => {
|
|
88
|
+
it('renders the host-supplied assetsPanel slot', async () => {
|
|
89
|
+
const adapter = makeFakeAdapter()
|
|
90
|
+
const initial = makeProject()
|
|
91
|
+
const { getByTestId } = render(
|
|
92
|
+
<CarouselEditor
|
|
93
|
+
project={initial}
|
|
94
|
+
adapter={adapter}
|
|
95
|
+
onProjectChange={vi.fn()}
|
|
96
|
+
slots={{ assetsPanel: <div data-testid="assets" /> }}
|
|
97
|
+
/>,
|
|
98
|
+
)
|
|
99
|
+
await waitFor(() => getByTestId('assets'))
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
// Regression: SlideGrid thumbnails must receive `compileOverlay` so overlay
|
|
103
|
+
// elements render in the left rail. Before the fix the thumbnail used a
|
|
104
|
+
// noopCompiler that always rejected → a red "overlay error" badge on every
|
|
105
|
+
// overlay; adapter.compileOverlay was called ONCE (main canvas only). With the
|
|
106
|
+
// fix the thumbnail (non-interactive SlideCanvas) routes through
|
|
107
|
+
// adapter.compileOverlay too, so the SAME overlay is compiled twice
|
|
108
|
+
// (thumbnail + main). We assert the compiler is threaded to the thumbnail
|
|
109
|
+
// (call count ≥ 2) — the precise fix. (We don't assert the rendered overlay
|
|
110
|
+
// output: the fake factory can't render in jsdom, which is orthogonal to this
|
|
111
|
+
// bug; the real render is verified in the browser.)
|
|
112
|
+
it('threads compileOverlay into slide thumbnails (compiles overlay for thumbnail + main)', async () => {
|
|
113
|
+
const adapter = makeFakeAdapter()
|
|
114
|
+
const initial = makeProject({
|
|
115
|
+
slides: [
|
|
116
|
+
{
|
|
117
|
+
id: 'slide-ov',
|
|
118
|
+
base_color: '#ffffff',
|
|
119
|
+
elements: [
|
|
120
|
+
{
|
|
121
|
+
id: 'el-ov',
|
|
122
|
+
type: 'overlay',
|
|
123
|
+
overlay: { template: '/overlays/lp-text.jsx', props: { text: 'Puerta' } },
|
|
124
|
+
frame: 0,
|
|
125
|
+
x: 100, y: 800, w: 880, h: 160, rotation: 0,
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
})
|
|
131
|
+
render(<CarouselEditor project={initial} adapter={adapter} onProjectChange={vi.fn()} />)
|
|
132
|
+
// ≥2 calls proves the thumbnail uses adapter.compileOverlay (not noopCompiler).
|
|
133
|
+
// Under the bug this is exactly 1 (main canvas only) and this times out.
|
|
134
|
+
await waitFor(() =>
|
|
135
|
+
expect((adapter.compileOverlay as ReturnType<typeof vi.fn>).mock.calls.length).toBeGreaterThanOrEqual(2),
|
|
136
|
+
)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('renders the host-supplied pendingStatus slot in the pending view', async () => {
|
|
140
|
+
const adapter = makeFakeAdapter()
|
|
141
|
+
const initial = makeProject({ status: 'pending', slides: [] })
|
|
142
|
+
const { getByTestId, queryByText } = render(
|
|
143
|
+
<CarouselEditor
|
|
144
|
+
project={initial}
|
|
145
|
+
adapter={adapter}
|
|
146
|
+
onProjectChange={vi.fn()}
|
|
147
|
+
slots={{ pendingStatus: <div data-testid="pending-status">Agent is working: → step 2</div> }}
|
|
148
|
+
/>,
|
|
149
|
+
)
|
|
150
|
+
await waitFor(() => getByTestId('pending-status'))
|
|
151
|
+
// The slot replaces the default empty-state copy.
|
|
152
|
+
expect(queryByText('Message your agent to start')).toBeNull()
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('shows the default empty-state copy when pendingStatus slot is absent', async () => {
|
|
156
|
+
const adapter = makeFakeAdapter()
|
|
157
|
+
const initial = makeProject({ status: 'pending', slides: [] })
|
|
158
|
+
const { getByText } = render(
|
|
159
|
+
<CarouselEditor project={initial} adapter={adapter} onProjectChange={vi.fn()} />,
|
|
160
|
+
)
|
|
161
|
+
await waitFor(() => getByText('Message your agent to start'))
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('selecting an element, moving it, then undo reverts the position', async () => {
|
|
165
|
+
const adapter = makeFakeAdapter()
|
|
166
|
+
const initial = makeProject()
|
|
167
|
+
|
|
168
|
+
render(
|
|
169
|
+
<CarouselEditor
|
|
170
|
+
project={initial}
|
|
171
|
+
adapter={adapter}
|
|
172
|
+
onProjectChange={vi.fn()}
|
|
173
|
+
slots={{ assetsPanel: <div data-testid="assets" /> }}
|
|
174
|
+
/>,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
// The interactive slide canvas renders the image element wrapper.
|
|
178
|
+
const wrapper = await waitFor(() => findInteractiveWrapper('el-img'))
|
|
179
|
+
|
|
180
|
+
// Select the element (click).
|
|
181
|
+
await act(async () => { fireEvent.click(wrapper) })
|
|
182
|
+
|
|
183
|
+
// Perform a drag: pointer-down on the wrapper, move on window, up on window.
|
|
184
|
+
await act(async () => {
|
|
185
|
+
fireEvent.pointerDown(wrapper, { clientX: 150, clientY: 150 })
|
|
186
|
+
fireEvent.pointerMove(window, { clientX: 250, clientY: 250 })
|
|
187
|
+
fireEvent.pointerUp(window)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
// The move + commit persisted a new position via the adapter.
|
|
191
|
+
await waitFor(() => {
|
|
192
|
+
expect(adapter.saveCalls.length).toBeGreaterThan(0)
|
|
193
|
+
})
|
|
194
|
+
const movedSave = adapter.saveCalls[adapter.saveCalls.length - 1].project
|
|
195
|
+
const movedEl = movedSave.slides![0].elements[0]
|
|
196
|
+
expect(movedEl.x).not.toBe(100)
|
|
197
|
+
|
|
198
|
+
const savesBeforeUndo = adapter.saveCalls.length
|
|
199
|
+
|
|
200
|
+
// Undo via keyboard shortcut (Cmd/Ctrl+Z). Guarded paths require the target
|
|
201
|
+
// not be a text input — fire on document.body.
|
|
202
|
+
await act(async () => {
|
|
203
|
+
fireEvent.keyDown(window, { key: 'z', ctrlKey: true })
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
// Undo enqueues a save that restores the original position.
|
|
207
|
+
await waitFor(() => {
|
|
208
|
+
expect(adapter.saveCalls.length).toBeGreaterThan(savesBeforeUndo)
|
|
209
|
+
})
|
|
210
|
+
const undoneSave = adapter.saveCalls[adapter.saveCalls.length - 1].project
|
|
211
|
+
const undoneEl = undoneSave.slides![0].elements[0]
|
|
212
|
+
expect(undoneEl.x).toBe(100)
|
|
213
|
+
expect(undoneEl.y).toBe(100)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('does not fire undo while typing in an input', async () => {
|
|
217
|
+
const adapter = makeFakeAdapter()
|
|
218
|
+
const initial = makeProject()
|
|
219
|
+
render(<CarouselEditor project={initial} adapter={adapter} onProjectChange={vi.fn()} />)
|
|
220
|
+
|
|
221
|
+
await waitFor(() => findInteractiveWrapper('el-img'))
|
|
222
|
+
|
|
223
|
+
const input = document.createElement('input')
|
|
224
|
+
document.body.appendChild(input)
|
|
225
|
+
input.focus()
|
|
226
|
+
|
|
227
|
+
const before = adapter.saveCalls.length
|
|
228
|
+
await act(async () => {
|
|
229
|
+
fireEvent.keyDown(input, { key: 'z', ctrlKey: true })
|
|
230
|
+
})
|
|
231
|
+
// No undo save should have been enqueued.
|
|
232
|
+
expect(adapter.saveCalls.length).toBe(before)
|
|
233
|
+
document.body.removeChild(input)
|
|
234
|
+
})
|
|
235
|
+
})
|