@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,258 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { act, renderHook, waitFor } from '@testing-library/react'
3
+ import { useProjectState } from '../use-project-state'
4
+ import type { EditorAdapter, Project, ImageElement, RenderEvent } from '../../types'
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Fixtures
8
+ // ---------------------------------------------------------------------------
9
+
10
+ function makeProject(overrides: Partial<Project> = {}): Project {
11
+ return {
12
+ version: '1',
13
+ id: 'proj-1',
14
+ name: 'Test Project',
15
+ workflow: 'carousel',
16
+ status: 'draft', // editable so mutations are not gated out
17
+ editingPrompt: '',
18
+ settings: { resolution: [1080, 1080] },
19
+ assets: [],
20
+ slides: [
21
+ {
22
+ id: 'slide-0',
23
+ base_color: '#ffffff',
24
+ elements: [
25
+ { id: 'el-0', type: 'image', src: 'a.png', x: 0, y: 0, w: 100, h: 100, rotation: 0 },
26
+ {
27
+ id: 'el-1',
28
+ type: 'overlay',
29
+ overlay: { template: 't', props: { text: 'hi' } },
30
+ frame: 0,
31
+ x: 0,
32
+ y: 0,
33
+ w: 50,
34
+ h: 50,
35
+ rotation: 0,
36
+ },
37
+ ],
38
+ },
39
+ ],
40
+ ...overrides,
41
+ }
42
+ }
43
+
44
+ interface FakeAdapter extends EditorAdapter {
45
+ emit: (p: Project) => void
46
+ saveCalls: Array<{ id: string; project: Project }>
47
+ }
48
+
49
+ function makeFakeAdapter(opts: { failSave?: boolean } = {}): FakeAdapter {
50
+ let onFrame: ((p: Project) => void) | null = null
51
+ const saveCalls: Array<{ id: string; project: Project }> = []
52
+ return {
53
+ loadProject: vi.fn(async () => makeProject()),
54
+ saveProject: vi.fn(async (id: string, project: Project) => {
55
+ saveCalls.push({ id, project })
56
+ if (opts.failSave) throw new Error('save boom')
57
+ }),
58
+ subscribe: (_id: string, cb: (p: Project) => void) => {
59
+ onFrame = cb
60
+ return () => { onFrame = null }
61
+ },
62
+ render: async function* (): AsyncIterable<RenderEvent> {
63
+ yield { type: 'done', outputPath: '/out.png' }
64
+ },
65
+ resolveImageSrc: (el: ImageElement) => el.src,
66
+ compileOverlay: vi.fn(async () => () => null),
67
+ listGlobalOverlays: vi.fn(async () => []),
68
+ listSystemOverlays: vi.fn(async () => []),
69
+ uploadFile: vi.fn(async () => '/path'),
70
+ fileUrl: (path: string) => path,
71
+ emit: (p: Project) => onFrame?.(p),
72
+ saveCalls,
73
+ }
74
+ }
75
+
76
+ beforeEach(() => { vi.spyOn(console, 'warn').mockImplementation(() => {}) })
77
+ afterEach(() => { vi.restoreAllMocks() })
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Tests
81
+ // ---------------------------------------------------------------------------
82
+
83
+ describe('useProjectState — adapter wiring', () => {
84
+ it('optimistically applies a mutation and persists via adapter.saveProject', async () => {
85
+ const adapter = makeFakeAdapter()
86
+ const initial = makeProject()
87
+ const { result } = renderHook(() => useProjectState(adapter, initial.id, initial))
88
+
89
+ await act(async () => {
90
+ await result.current.updateOverlayProp('slide-0', 'el-1', 'text', 'bye')
91
+ })
92
+
93
+ // Optimistic local state updated.
94
+ const el = result.current.project.slides![0].elements[1]
95
+ expect(el.type === 'overlay' && el.overlay.props.text).toBe('bye')
96
+ // Persisted through the adapter, not a hardcoded fetch.
97
+ expect(adapter.saveProject).toHaveBeenCalledTimes(1)
98
+ expect(adapter.saveCalls[0].id).toBe('proj-1')
99
+ })
100
+
101
+ it('rolls back optimistic state and surfaces lastError when saveProject fails', async () => {
102
+ const adapter = makeFakeAdapter({ failSave: true })
103
+ const initial = makeProject()
104
+ const { result } = renderHook(() => useProjectState(adapter, initial.id, initial))
105
+
106
+ await act(async () => {
107
+ await result.current.updateOverlayProp('slide-0', 'el-1', 'text', 'bye').catch(() => {})
108
+ })
109
+
110
+ const el = result.current.project.slides![0].elements[1]
111
+ // Rolled back to original value.
112
+ expect(el.type === 'overlay' && el.overlay.props.text).toBe('hi')
113
+ expect(result.current.lastError).toMatch(/save boom/)
114
+ })
115
+
116
+ it('applies SSE frames pushed through adapter.subscribe', async () => {
117
+ const adapter = makeFakeAdapter()
118
+ const initial = makeProject()
119
+ const { result } = renderHook(() => useProjectState(adapter, initial.id, initial))
120
+
121
+ const frame = makeProject({ name: 'From Server', status: 'final' })
122
+ act(() => { adapter.emit(frame) })
123
+
124
+ await waitFor(() => {
125
+ expect(result.current.project.name).toBe('From Server')
126
+ expect(result.current.connection).toBe('live')
127
+ })
128
+ })
129
+
130
+ it('undo/redo swap state and re-persist via adapter', async () => {
131
+ const adapter = makeFakeAdapter()
132
+ const initial = makeProject()
133
+ const { result } = renderHook(() => useProjectState(adapter, initial.id, initial))
134
+
135
+ await act(async () => {
136
+ await result.current.setName('Renamed')
137
+ })
138
+ expect(result.current.project.name).toBe('Renamed')
139
+ expect(result.current.canUndo).toBe(true)
140
+
141
+ await act(async () => { result.current.undo() })
142
+ expect(result.current.project.name).toBe('Test Project')
143
+ expect(result.current.canRedo).toBe(true)
144
+
145
+ await act(async () => { result.current.redo() })
146
+ expect(result.current.project.name).toBe('Renamed')
147
+
148
+ // mutate save + undo save + redo save
149
+ await waitFor(() => expect(adapter.saveProject).toHaveBeenCalledTimes(3))
150
+ })
151
+
152
+ it('commit persists one save after transient gesture mutations', async () => {
153
+ const adapter = makeFakeAdapter()
154
+ const initial = makeProject()
155
+ const { result } = renderHook(() => useProjectState(adapter, initial.id, initial))
156
+
157
+ await act(async () => {
158
+ await result.current.moveElement('slide-0', 'el-0', 10, 20)
159
+ await result.current.moveElement('slide-0', 'el-0', 30, 40)
160
+ })
161
+ // Transient moves do not save.
162
+ expect(adapter.saveProject).not.toHaveBeenCalled()
163
+ const moved = result.current.project.slides![0].elements[0]
164
+ expect(moved.x).toBe(30)
165
+ expect(moved.y).toBe(40)
166
+
167
+ await act(async () => { await result.current.commit() })
168
+ expect(adapter.saveProject).toHaveBeenCalledTimes(1)
169
+ expect(result.current.canUndo).toBe(true)
170
+ })
171
+
172
+ it('unsubscribes on unmount', () => {
173
+ const adapter = makeFakeAdapter()
174
+ const unsub = vi.fn()
175
+ adapter.subscribe = (_id, _cb) => unsub
176
+ const initial = makeProject()
177
+ const { unmount } = renderHook(() => useProjectState(adapter, initial.id, initial))
178
+ unmount()
179
+ expect(unsub).toHaveBeenCalledTimes(1)
180
+ })
181
+ })
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // Status gating — slide-CRUD actions are no-ops when project is not editable
185
+ // ---------------------------------------------------------------------------
186
+
187
+ describe('useProjectState — addSlide is status-gated', () => {
188
+ it('no-ops addSlide and does not save when project status is "pending"', async () => {
189
+ const adapter = makeFakeAdapter()
190
+ // Override loadProject to return a pending project, but we pass initial directly
191
+ const initial = makeProject({ status: 'pending' })
192
+ const { result } = renderHook(() => useProjectState(adapter, initial.id, initial))
193
+
194
+ await act(async () => {
195
+ await result.current.addSlide({
196
+ id: 'slide-new',
197
+ base_color: '#ff0000',
198
+ elements: [],
199
+ })
200
+ })
201
+
202
+ // State must not have gained a slide
203
+ expect(result.current.project.slides).toHaveLength(initial.slides!.length)
204
+ // Adapter must not have been asked to save
205
+ expect(adapter.saveProject).not.toHaveBeenCalled()
206
+ })
207
+
208
+ it('no-ops addSlide when project status is "storyboard_ready"', async () => {
209
+ const adapter = makeFakeAdapter()
210
+ const initial = makeProject({ status: 'storyboard_ready' })
211
+ const { result } = renderHook(() => useProjectState(adapter, initial.id, initial))
212
+
213
+ await act(async () => {
214
+ await result.current.addSlide({
215
+ id: 'slide-new',
216
+ base_color: '#0000ff',
217
+ elements: [],
218
+ })
219
+ })
220
+
221
+ expect(result.current.project.slides).toHaveLength(initial.slides!.length)
222
+ expect(adapter.saveProject).not.toHaveBeenCalled()
223
+ })
224
+
225
+ it('allows addSlide when project status is "draft"', async () => {
226
+ const adapter = makeFakeAdapter()
227
+ const initial = makeProject({ status: 'draft' })
228
+ const { result } = renderHook(() => useProjectState(adapter, initial.id, initial))
229
+
230
+ await act(async () => {
231
+ await result.current.addSlide({
232
+ id: 'slide-new',
233
+ base_color: '#00ff00',
234
+ elements: [],
235
+ })
236
+ })
237
+
238
+ expect(result.current.project.slides).toHaveLength(initial.slides!.length + 1)
239
+ expect(adapter.saveProject).toHaveBeenCalledTimes(1)
240
+ })
241
+
242
+ it('allows addSlide when project status is "final"', async () => {
243
+ const adapter = makeFakeAdapter()
244
+ const initial = makeProject({ status: 'final' })
245
+ const { result } = renderHook(() => useProjectState(adapter, initial.id, initial))
246
+
247
+ await act(async () => {
248
+ await result.current.addSlide({
249
+ id: 'slide-new',
250
+ base_color: '#00ff00',
251
+ elements: [],
252
+ })
253
+ })
254
+
255
+ expect(result.current.project.slides).toHaveLength(initial.slides!.length + 1)
256
+ expect(adapter.saveProject).toHaveBeenCalledTimes(1)
257
+ })
258
+ })
@@ -0,0 +1,62 @@
1
+ /**
2
+ * editor-core / state / mutation-queue — serialises async mutations.
3
+ *
4
+ * Ported from mission-control's inline `createMutationQueue`
5
+ * (src/app/admin/projects/hooks/project-reducer.ts). Extracted into its own
6
+ * module here so the optimistic project-state hook and the reducer stay
7
+ * separable.
8
+ *
9
+ * The queue guarantees that enqueued functions START in submission order
10
+ * (each waits for the previous to settle), while preserving the caller's own
11
+ * rejection handling on the returned promise. `onceDrained` lets the SSE
12
+ * handler defer applying server echoes until in-flight PUTs have all settled.
13
+ */
14
+ export interface MutationQueue {
15
+ /** Enqueue an async op; it starts only after all prior ops have settled. */
16
+ enqueue<T>(fn: () => Promise<T>): Promise<T>
17
+ /** True while one or more ops are in flight. */
18
+ isPending(): boolean
19
+ /**
20
+ * Run `cb` the next time the queue transitions busy → idle. If already idle,
21
+ * `cb` runs on the next microtask. Last-writer-wins: only the most recent
22
+ * caller is notified, so a burst of SSE frames coalesces into one dispatch.
23
+ */
24
+ onceDrained(cb: () => void): void
25
+ }
26
+
27
+ export function createMutationQueue(): MutationQueue {
28
+ let tail: Promise<unknown> = Promise.resolve()
29
+ let pending = 0
30
+ let onDrain: (() => void) | null = null
31
+ return {
32
+ enqueue<T>(fn: () => Promise<T>): Promise<T> {
33
+ pending++
34
+ const next = tail.catch(() => undefined).then(() => fn())
35
+ // Chain the decrement onto the swallowed `tail`, NOT onto `next`. The
36
+ // caller's rejection handling lives on `next`; adding a .finally() to
37
+ // `next` here would create a parallel promise chain that also receives
38
+ // the rejection — with no handler attached, that surfaces as an
39
+ // unhandled-rejection warning. Folding the decrement into `tail`
40
+ // preserves single-handler semantics for the caller.
41
+ tail = next.catch(() => undefined).then(() => {
42
+ pending--
43
+ if (pending === 0 && onDrain) {
44
+ const cb = onDrain
45
+ onDrain = null
46
+ cb()
47
+ }
48
+ })
49
+ return next
50
+ },
51
+ isPending(): boolean {
52
+ return pending > 0
53
+ },
54
+ onceDrained(cb: () => void): void {
55
+ if (pending === 0) {
56
+ Promise.resolve().then(cb)
57
+ return
58
+ }
59
+ onDrain = cb
60
+ },
61
+ }
62
+ }
@@ -0,0 +1,328 @@
1
+ /**
2
+ * editor-core / state / project-reducer — pure reducer over a carousel Project.
3
+ *
4
+ * Ported faithfully from mission-control's
5
+ * `src/app/admin/projects/hooks/project-reducer.ts`, adapted to import the
6
+ * canonical Project/Slide/CarouselElement/ImageElement/OverlayElement types
7
+ * from editor-core (which re-exports Montaj's `lib/types/schema.ts`) rather
8
+ * than from a hand-mirrored `montaj.ts`.
9
+ *
10
+ * All action types and snapshot semantics are preserved. The reducer is pure —
11
+ * it never touches transport; persistence is the hook's job via the adapter.
12
+ */
13
+ import type {
14
+ Project,
15
+ Slide,
16
+ ImageElement,
17
+ CarouselElement,
18
+ } from '../types'
19
+
20
+ // The reducer/Action are generic over the host's concrete project type `P`,
21
+ // constrained to the editor-facing `Project` (= EditorProject) slice. A host can
22
+ // pass its full project (e.g. Montaj's `Project` with pipeline fields) and get
23
+ // the same concrete type back: every reducer branch spreads `state`, so host-
24
+ // only fields round-trip at both the type level and at runtime. The editor only
25
+ // ever reads/writes the editor-facing fields.
26
+
27
+ // Status values from the editor-facing schema. Duplicated as a value here so
28
+ // the hook can gate edits without importing the host's project types directly.
29
+ export type ProjectStatus = Project['status']
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Action discriminated union
33
+ // ---------------------------------------------------------------------------
34
+
35
+ export type Action<P extends Project = Project> =
36
+ | { type: 'sse'; project: P }
37
+ | { type: 'updateOverlayProp'; slideId: string; elementId: string; key: string; value: string }
38
+ | { type: 'setStatus'; status: ProjectStatus }
39
+ | { type: 'setName'; name: string }
40
+ | { type: 'rollback'; snapshot: P }
41
+ | { type: 'moveElement'; slideId: string; elementId: string; x: number; y: number }
42
+ | { type: 'resizeElement'; slideId: string; elementId: string; x: number; y: number; w: number; h: number }
43
+ | { type: 'rotateElement'; slideId: string; elementId: string; rotation: number }
44
+ | { type: 'addElement'; slideId: string; element: CarouselElement }
45
+ | { type: 'removeElement'; slideId: string; elementId: string }
46
+ | { type: 'updateImageCrop'; slideId: string; elementId: string; crop: ImageElement['crop'] }
47
+ // Slide-level structural actions. Added for the Montaj-native editor, which —
48
+ // unlike mission-control's element-only inspector — owns full slide CRUD.
49
+ | { type: 'addSlide'; slide: Slide; afterSlideId?: string }
50
+ | { type: 'removeSlide'; slideId: string }
51
+ | { type: 'duplicateSlide'; slideId: string; newSlide: Slide }
52
+ | { type: 'reorderSlides'; fromIndex: number; toIndex: number }
53
+ | { type: 'updateSlide'; slideId: string; patch: Partial<Slide> }
54
+ // Element structural actions the editor needs beyond add/remove.
55
+ | { type: 'duplicateElement'; slideId: string; elementId: string; newElement: CarouselElement }
56
+ | { type: 'reorderElement'; slideId: string; elementId: string; direction: 'forward' | 'backward' }
57
+ | { type: 'setOverlayFrame'; slideId: string; elementId: string; frame: number }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // SSE merge helpers
61
+ // ---------------------------------------------------------------------------
62
+
63
+ // Structural equality. Used to decide whether to reuse a previous reference
64
+ // in the SSE merge. Tree shape (project.json) is small, no cycles, no
65
+ // special types — a recursive walk is fine and short-circuits early on mismatch.
66
+ function deepEqual(a: unknown, b: unknown): boolean {
67
+ if (a === b) return true
68
+ if (typeof a !== typeof b) return false
69
+ if (a === null || b === null) return false
70
+ if (typeof a !== 'object') return false
71
+ if (Array.isArray(a)) {
72
+ if (!Array.isArray(b) || a.length !== b.length) return false
73
+ for (let i = 0; i < a.length; i++) {
74
+ if (!deepEqual(a[i], b[i])) return false
75
+ }
76
+ return true
77
+ }
78
+ if (Array.isArray(b)) return false
79
+ // Compare objects treating `undefined`-valued properties as absent. Required
80
+ // because the reducer writes `{...el, crop: undefined}` to clear a field,
81
+ // but the SSE echo round-trip serialises through Montaj's `json.dumps`,
82
+ // which drops undefined-valued keys. Without this normalisation, the
83
+ // identity short-circuit in mergeProject silently misses on every echo
84
+ // following an image resize.
85
+ const ao = a as Record<string, unknown>
86
+ const bo = b as Record<string, unknown>
87
+ const keys = new Set<string>()
88
+ for (const k of Object.keys(ao)) if (ao[k] !== undefined) keys.add(k)
89
+ for (const k of Object.keys(bo)) if (bo[k] !== undefined) keys.add(k)
90
+ for (const k of keys) {
91
+ if (!deepEqual(ao[k], bo[k])) return false
92
+ }
93
+ return true
94
+ }
95
+
96
+ function mergeElement(prev: CarouselElement, next: CarouselElement): CarouselElement {
97
+ return deepEqual(prev, next) ? prev : next
98
+ }
99
+
100
+ function mergeSlide(prev: Slide, next: Slide): Slide {
101
+ if (deepEqual(prev, next)) return prev
102
+ const prevElsById = new Map(prev.elements.map((e) => [e.id, e]))
103
+ let elementsChanged = next.elements.length !== prev.elements.length
104
+ const mergedElements = next.elements.map((nextEl, i): CarouselElement => {
105
+ const prevEl = prevElsById.get(nextEl.id)
106
+ if (!prevEl) { elementsChanged = true; return nextEl }
107
+ const merged = mergeElement(prevEl, nextEl)
108
+ if (merged !== prev.elements[i]) elementsChanged = true
109
+ return merged
110
+ })
111
+ return {
112
+ ...next,
113
+ elements: elementsChanged ? mergedElements : prev.elements,
114
+ }
115
+ }
116
+
117
+ function mergeProject<P extends Project>(prev: P, next: P): P {
118
+ if (prev === next || deepEqual(prev, next)) return prev
119
+ const prevSlides = prev.slides ?? []
120
+ const nextSlides = next.slides ?? []
121
+ const prevSlidesById = new Map(prevSlides.map((s) => [s.id, s]))
122
+ let slidesChanged = nextSlides.length !== prevSlides.length
123
+ const mergedSlides = nextSlides.map((nextSlide, i): Slide => {
124
+ const prevSlide = prevSlidesById.get(nextSlide.id)
125
+ if (!prevSlide) { slidesChanged = true; return nextSlide }
126
+ const merged = mergeSlide(prevSlide, nextSlide)
127
+ if (merged !== prevSlides[i]) slidesChanged = true
128
+ return merged
129
+ })
130
+ return {
131
+ ...next,
132
+ slides: slidesChanged ? mergedSlides : prevSlides,
133
+ }
134
+ }
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // Pure reducer
138
+ // ---------------------------------------------------------------------------
139
+
140
+ export function projectReducer<P extends Project>(state: P, action: Action<P>): P {
141
+ switch (action.type) {
142
+ case 'sse':
143
+ return mergeProject(state, action.project)
144
+
145
+ case 'updateOverlayProp': {
146
+ const slides = (state.slides ?? []).map((slide): Slide => {
147
+ if (slide.id !== action.slideId) return slide
148
+ const elements = slide.elements.map((el): CarouselElement => {
149
+ if (el.id !== action.elementId) return el
150
+ if (el.type !== 'overlay') return el
151
+ return {
152
+ ...el,
153
+ overlay: {
154
+ ...el.overlay,
155
+ props: {
156
+ ...el.overlay.props,
157
+ [action.key]: action.value,
158
+ },
159
+ },
160
+ }
161
+ })
162
+ return { ...slide, elements }
163
+ })
164
+ return { ...state, slides }
165
+ }
166
+
167
+ case 'setStatus':
168
+ return { ...state, status: action.status }
169
+
170
+ case 'setName':
171
+ return { ...state, name: action.name }
172
+
173
+ case 'rollback':
174
+ return action.snapshot
175
+
176
+ case 'moveElement': {
177
+ const slides = (state.slides ?? []).map((slide): Slide => {
178
+ if (slide.id !== action.slideId) return slide
179
+ const elements = slide.elements.map((el): CarouselElement => {
180
+ if (el.id !== action.elementId) return el
181
+ return { ...el, x: action.x, y: action.y }
182
+ })
183
+ return { ...slide, elements }
184
+ })
185
+ return { ...state, slides }
186
+ }
187
+
188
+ case 'resizeElement': {
189
+ const slides = (state.slides ?? []).map((slide): Slide => {
190
+ if (slide.id !== action.slideId) return slide
191
+ const elements = slide.elements.map((el): CarouselElement => {
192
+ if (el.id !== action.elementId) return el
193
+ return { ...el, x: action.x, y: action.y, w: action.w, h: action.h }
194
+ })
195
+ return { ...slide, elements }
196
+ })
197
+ return { ...state, slides }
198
+ }
199
+
200
+ case 'rotateElement': {
201
+ const slides = (state.slides ?? []).map((slide): Slide => {
202
+ if (slide.id !== action.slideId) return slide
203
+ const elements = slide.elements.map((el): CarouselElement => {
204
+ if (el.id !== action.elementId) return el
205
+ return { ...el, rotation: action.rotation }
206
+ })
207
+ return { ...slide, elements }
208
+ })
209
+ return { ...state, slides }
210
+ }
211
+
212
+ case 'addElement': {
213
+ const slides = (state.slides ?? []).map((slide): Slide => {
214
+ if (slide.id !== action.slideId) return slide
215
+ return { ...slide, elements: [...slide.elements, action.element] }
216
+ })
217
+ return { ...state, slides }
218
+ }
219
+
220
+ case 'removeElement': {
221
+ const slides = (state.slides ?? []).map((slide): Slide => {
222
+ if (slide.id !== action.slideId) return slide
223
+ return { ...slide, elements: slide.elements.filter((el) => el.id !== action.elementId) }
224
+ })
225
+ return { ...state, slides }
226
+ }
227
+
228
+ case 'updateImageCrop': {
229
+ const slides = (state.slides ?? []).map((slide): Slide => {
230
+ if (slide.id !== action.slideId) return slide
231
+ const elements = slide.elements.map((el): CarouselElement => {
232
+ if (el.id !== action.elementId) return el
233
+ if (el.type !== 'image') return el
234
+ return { ...el, crop: action.crop }
235
+ })
236
+ return { ...slide, elements }
237
+ })
238
+ return { ...state, slides }
239
+ }
240
+
241
+ case 'addSlide': {
242
+ const slides = [...(state.slides ?? [])]
243
+ if (action.afterSlideId) {
244
+ const idx = slides.findIndex((s) => s.id === action.afterSlideId)
245
+ if (idx >= 0) slides.splice(idx + 1, 0, action.slide)
246
+ else slides.push(action.slide)
247
+ } else {
248
+ slides.push(action.slide)
249
+ }
250
+ return { ...state, slides }
251
+ }
252
+
253
+ case 'removeSlide': {
254
+ const slides = (state.slides ?? []).filter((s) => s.id !== action.slideId)
255
+ return { ...state, slides }
256
+ }
257
+
258
+ case 'duplicateSlide': {
259
+ const slides = [...(state.slides ?? [])]
260
+ const idx = slides.findIndex((s) => s.id === action.slideId)
261
+ if (idx < 0) return state
262
+ slides.splice(idx + 1, 0, action.newSlide)
263
+ return { ...state, slides }
264
+ }
265
+
266
+ case 'reorderSlides': {
267
+ const slides = [...(state.slides ?? [])]
268
+ if (
269
+ action.fromIndex < 0 || action.fromIndex >= slides.length ||
270
+ action.toIndex < 0 || action.toIndex >= slides.length
271
+ ) {
272
+ return state
273
+ }
274
+ const [moved] = slides.splice(action.fromIndex, 1)
275
+ slides.splice(action.toIndex, 0, moved)
276
+ return { ...state, slides }
277
+ }
278
+
279
+ case 'updateSlide': {
280
+ const slides = (state.slides ?? []).map((slide): Slide =>
281
+ slide.id === action.slideId ? { ...slide, ...action.patch } : slide,
282
+ )
283
+ return { ...state, slides }
284
+ }
285
+
286
+ case 'duplicateElement': {
287
+ const slides = (state.slides ?? []).map((slide): Slide => {
288
+ if (slide.id !== action.slideId) return slide
289
+ const idx = slide.elements.findIndex((el) => el.id === action.elementId)
290
+ if (idx < 0) return slide
291
+ const elements = [
292
+ ...slide.elements.slice(0, idx + 1),
293
+ action.newElement,
294
+ ...slide.elements.slice(idx + 1),
295
+ ]
296
+ return { ...slide, elements }
297
+ })
298
+ return { ...state, slides }
299
+ }
300
+
301
+ case 'reorderElement': {
302
+ const slides = (state.slides ?? []).map((slide): Slide => {
303
+ if (slide.id !== action.slideId) return slide
304
+ const elements = [...slide.elements]
305
+ const idx = elements.findIndex((el) => el.id === action.elementId)
306
+ if (idx < 0) return slide
307
+ const swapIdx = action.direction === 'forward' ? idx + 1 : idx - 1
308
+ if (swapIdx < 0 || swapIdx >= elements.length) return slide
309
+ ;[elements[idx], elements[swapIdx]] = [elements[swapIdx], elements[idx]]
310
+ return { ...slide, elements }
311
+ })
312
+ return { ...state, slides }
313
+ }
314
+
315
+ case 'setOverlayFrame': {
316
+ const slides = (state.slides ?? []).map((slide): Slide => {
317
+ if (slide.id !== action.slideId) return slide
318
+ const elements = slide.elements.map((el): CarouselElement => {
319
+ if (el.id !== action.elementId) return el
320
+ if (el.type !== 'overlay') return el
321
+ return { ...el, frame: action.frame }
322
+ })
323
+ return { ...slide, elements }
324
+ })
325
+ return { ...state, slides }
326
+ }
327
+ }
328
+ }