@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,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
|
+
}
|