@bycrux/editor 0.10.0 → 0.11.1
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/package.json +1 -1
- package/src/schema.ts +6 -0
- package/src/state/__tests__/use-project-sync.test.tsx +315 -0
- package/src/state/use-project-state.ts +47 -222
- package/src/state/use-project-sync.ts +310 -0
- package/src/video/VideoEditor.tsx +260 -124
- package/src/video/__tests__/VideoEditor.test.tsx +245 -3
- package/src/video/__tests__/backfillCaptionIds.test.ts +70 -0
- package/src/video/__tests__/captionPositioning.test.tsx +435 -0
- package/src/video/__tests__/captionRepair.test.ts +26 -0
- package/src/video/__tests__/captionSnap.test.ts +87 -0
- package/src/video/captionRepair.ts +13 -1
- package/src/video/preview/CaptionPreview.tsx +310 -4
- package/src/video/preview/PreviewPlayer.tsx +13 -0
- package/src/video/preview/__tests__/captionDragState.test.ts +168 -0
- package/src/video/preview/captionDragState.ts +201 -0
- package/src/video/timeline/CaptionTrackRow.tsx +235 -0
- package/src/video/timeline/Timeline.tsx +31 -2
- package/src/video/timeline/TranscriptModal.tsx +10 -3
- package/src/video/timeline/TranscriptPanel.tsx +7 -1
- package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +241 -0
- package/src/video/timeline/__tests__/TranscriptModal.test.tsx +41 -0
- package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +22 -0
- package/src/video/timeline/__tests__/makeCaptionEdit.test.ts +123 -0
- package/src/video/timeline/makeCaptionEdit.ts +33 -10
package/package.json
CHANGED
package/src/schema.ts
CHANGED
|
@@ -40,6 +40,12 @@ export interface CaptionSegment {
|
|
|
40
40
|
start: number
|
|
41
41
|
end: number
|
|
42
42
|
words?: Word[]
|
|
43
|
+
offsetX?: number // percent of frame width, 0/absent = default anchor
|
|
44
|
+
offsetY?: number // percent of frame height, 0/absent = default anchor
|
|
45
|
+
// Visual scale of the whole caption block about its own centre — a CSS
|
|
46
|
+
// transform, not a font-size change, so it scales the background box and
|
|
47
|
+
// text stroke too and does NOT re-wrap the text. Default 1.
|
|
48
|
+
scale?: number
|
|
43
49
|
}
|
|
44
50
|
|
|
45
51
|
export interface Captions {
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { act, renderHook, waitFor } from '@testing-library/react'
|
|
3
|
+
import { useProjectSync } from '../use-project-sync'
|
|
4
|
+
import type { EditorAdapter, Project, ImageElement, RenderEvent } from '../../types'
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Fixtures — mirrors the use-project-state test's FakeAdapter, but drives the
|
|
8
|
+
// shape-agnostic core directly with `(p) => p` mutations instead of the typed
|
|
9
|
+
// action vocabulary.
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
function makeProject(overrides: Partial<Project> = {}): Project {
|
|
13
|
+
return {
|
|
14
|
+
version: '1',
|
|
15
|
+
id: 'proj-1',
|
|
16
|
+
name: 'Test Project',
|
|
17
|
+
workflow: 'carousel',
|
|
18
|
+
status: 'draft',
|
|
19
|
+
editingPrompt: '',
|
|
20
|
+
settings: { resolution: [1080, 1080] },
|
|
21
|
+
assets: [],
|
|
22
|
+
slides: [
|
|
23
|
+
{
|
|
24
|
+
id: 'slide-0',
|
|
25
|
+
base_color: '#ffffff',
|
|
26
|
+
elements: [
|
|
27
|
+
{ id: 'el-0', type: 'image', src: 'a.png', x: 0, y: 0, w: 100, h: 100, rotation: 0 },
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
...overrides,
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface FakeAdapter extends EditorAdapter {
|
|
36
|
+
emit: (p: Project) => void
|
|
37
|
+
saveCalls: Array<{ id: string; project: Project }>
|
|
38
|
+
unsub: ReturnType<typeof vi.fn>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function makeFakeAdapter(opts: {
|
|
42
|
+
failSave?: boolean
|
|
43
|
+
/** When set, saveProject blocks on this until `release` is called. */
|
|
44
|
+
blockSave?: boolean
|
|
45
|
+
} = {}): FakeAdapter & { release: () => void } {
|
|
46
|
+
let onFrame: ((p: Project) => void) | null = null
|
|
47
|
+
const saveCalls: Array<{ id: string; project: Project }> = []
|
|
48
|
+
const unsub = vi.fn(() => { onFrame = null })
|
|
49
|
+
let releaseSave: (() => void) | null = null
|
|
50
|
+
const adapter: FakeAdapter & { release: () => void } = {
|
|
51
|
+
loadProject: vi.fn(async () => makeProject()),
|
|
52
|
+
saveProject: vi.fn(async (id: string, project: Project) => {
|
|
53
|
+
saveCalls.push({ id, project })
|
|
54
|
+
if (opts.blockSave) await new Promise<void>((res) => { releaseSave = res })
|
|
55
|
+
if (opts.failSave) throw new Error('save boom')
|
|
56
|
+
}),
|
|
57
|
+
subscribe: (_id: string, cb: (p: Project) => void) => {
|
|
58
|
+
onFrame = cb
|
|
59
|
+
return unsub
|
|
60
|
+
},
|
|
61
|
+
render: async function* (): AsyncIterable<RenderEvent> {
|
|
62
|
+
yield { type: 'done', outputPath: '/out.png' }
|
|
63
|
+
},
|
|
64
|
+
resolveImageSrc: (el: ImageElement) => el.src,
|
|
65
|
+
compileOverlay: vi.fn(async () => () => null),
|
|
66
|
+
listGlobalOverlays: vi.fn(async () => []),
|
|
67
|
+
listSystemOverlays: vi.fn(async () => []),
|
|
68
|
+
uploadFile: vi.fn(async () => '/path'),
|
|
69
|
+
fileUrl: (path: string) => path,
|
|
70
|
+
emit: (p: Project) => onFrame?.(p),
|
|
71
|
+
saveCalls,
|
|
72
|
+
unsub,
|
|
73
|
+
release: () => releaseSave?.(),
|
|
74
|
+
}
|
|
75
|
+
return adapter
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
beforeEach(() => { vi.spyOn(console, 'warn').mockImplementation(() => {}) })
|
|
79
|
+
afterEach(() => { vi.restoreAllMocks() })
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// Tests
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
describe('useProjectSync — optimistic save', () => {
|
|
86
|
+
it('optimistically applies a mutation and persists via adapter.saveProject', async () => {
|
|
87
|
+
const adapter = makeFakeAdapter()
|
|
88
|
+
const initial = makeProject()
|
|
89
|
+
const { result } = renderHook(() => useProjectSync(adapter, initial.id, initial))
|
|
90
|
+
|
|
91
|
+
await act(async () => {
|
|
92
|
+
await result.current.mutate((p) => ({ ...p, name: 'Renamed' }))
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
expect(result.current.project.name).toBe('Renamed')
|
|
96
|
+
expect(adapter.saveProject).toHaveBeenCalledTimes(1)
|
|
97
|
+
expect(adapter.saveCalls[0].id).toBe('proj-1')
|
|
98
|
+
expect(adapter.saveCalls[0].project.name).toBe('Renamed')
|
|
99
|
+
expect(result.current.canUndo).toBe(true)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('rolls back optimistic state and surfaces lastError when saveProject fails', async () => {
|
|
103
|
+
const adapter = makeFakeAdapter({ failSave: true })
|
|
104
|
+
const initial = makeProject()
|
|
105
|
+
const { result } = renderHook(() => useProjectSync(adapter, initial.id, initial))
|
|
106
|
+
|
|
107
|
+
await act(async () => {
|
|
108
|
+
await result.current.mutate((p) => ({ ...p, name: 'Renamed' })).catch(() => {})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
// Rolled back to the pre-mutation snapshot.
|
|
112
|
+
expect(result.current.project.name).toBe('Test Project')
|
|
113
|
+
expect(result.current.lastError).toMatch(/save boom/)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('clearError resets lastError', async () => {
|
|
117
|
+
const adapter = makeFakeAdapter({ failSave: true })
|
|
118
|
+
const initial = makeProject()
|
|
119
|
+
const { result } = renderHook(() => useProjectSync(adapter, initial.id, initial))
|
|
120
|
+
|
|
121
|
+
await act(async () => {
|
|
122
|
+
await result.current.mutate((p) => ({ ...p, name: 'X' })).catch(() => {})
|
|
123
|
+
})
|
|
124
|
+
expect(result.current.lastError).toMatch(/save boom/)
|
|
125
|
+
|
|
126
|
+
act(() => { result.current.clearError() })
|
|
127
|
+
expect(result.current.lastError).toBeNull()
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
describe('useProjectSync — external frames', () => {
|
|
132
|
+
it('applies SSE frames pushed through adapter.subscribe and goes live', async () => {
|
|
133
|
+
const adapter = makeFakeAdapter()
|
|
134
|
+
const initial = makeProject()
|
|
135
|
+
const { result } = renderHook(() => useProjectSync(adapter, initial.id, initial))
|
|
136
|
+
|
|
137
|
+
expect(result.current.connection).toBe('connecting')
|
|
138
|
+
|
|
139
|
+
act(() => { adapter.emit(makeProject({ name: 'From Server' })) })
|
|
140
|
+
|
|
141
|
+
await waitFor(() => {
|
|
142
|
+
expect(result.current.project.name).toBe('From Server')
|
|
143
|
+
expect(result.current.connection).toBe('live')
|
|
144
|
+
})
|
|
145
|
+
// External frames do not save or push undo history.
|
|
146
|
+
expect(adapter.saveProject).not.toHaveBeenCalled()
|
|
147
|
+
expect(result.current.canUndo).toBe(false)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('applyExternal replaces state without saving or pushing undo', async () => {
|
|
151
|
+
const adapter = makeFakeAdapter()
|
|
152
|
+
const initial = makeProject()
|
|
153
|
+
const { result } = renderHook(() => useProjectSync(adapter, initial.id, initial))
|
|
154
|
+
|
|
155
|
+
act(() => { result.current.applyExternal(makeProject({ name: 'Server Authored' })) })
|
|
156
|
+
|
|
157
|
+
expect(result.current.project.name).toBe('Server Authored')
|
|
158
|
+
expect(adapter.saveProject).not.toHaveBeenCalled()
|
|
159
|
+
expect(result.current.canUndo).toBe(false)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('routes external frames through a supplied reconcile function', async () => {
|
|
163
|
+
const adapter = makeFakeAdapter()
|
|
164
|
+
const initial = makeProject({ name: 'Local' })
|
|
165
|
+
// reconcile that ignores the incoming frame (keeps prev).
|
|
166
|
+
const reconcile = vi.fn((prev: Project, _next: Project) => prev)
|
|
167
|
+
const { result } = renderHook(() =>
|
|
168
|
+
useProjectSync(adapter, initial.id, initial, { reconcile }),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
act(() => { result.current.applyExternal(makeProject({ name: 'Incoming' })) })
|
|
172
|
+
|
|
173
|
+
expect(reconcile).toHaveBeenCalledTimes(1)
|
|
174
|
+
expect(result.current.project.name).toBe('Local')
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('defers an SSE frame while a save is in flight, then applies it on drain', async () => {
|
|
178
|
+
const adapter = makeFakeAdapter({ blockSave: true })
|
|
179
|
+
const initial = makeProject()
|
|
180
|
+
const { result } = renderHook(() => useProjectSync(adapter, initial.id, initial))
|
|
181
|
+
|
|
182
|
+
// Kick a mutation whose save blocks — the queue is now pending.
|
|
183
|
+
let mutatePromise: Promise<void> = Promise.resolve()
|
|
184
|
+
act(() => {
|
|
185
|
+
mutatePromise = result.current.mutate((p) => ({ ...p, name: 'Optimistic' }))
|
|
186
|
+
})
|
|
187
|
+
expect(result.current.project.name).toBe('Optimistic')
|
|
188
|
+
await waitFor(() => expect(adapter.saveProject).toHaveBeenCalledTimes(1))
|
|
189
|
+
|
|
190
|
+
// An echo arrives mid-save — it must be HELD, not applied (no clobber).
|
|
191
|
+
act(() => { adapter.emit(makeProject({ name: 'Echo While Saving' })) })
|
|
192
|
+
expect(result.current.project.name).toBe('Optimistic')
|
|
193
|
+
|
|
194
|
+
// Release the save; the queue drains and the deferred frame is applied.
|
|
195
|
+
await act(async () => {
|
|
196
|
+
adapter.release()
|
|
197
|
+
await mutatePromise
|
|
198
|
+
})
|
|
199
|
+
await waitFor(() => expect(result.current.project.name).toBe('Echo While Saving'))
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
describe('useProjectSync — undo/redo', () => {
|
|
204
|
+
it('undo/redo swap state and re-persist via adapter', async () => {
|
|
205
|
+
const adapter = makeFakeAdapter()
|
|
206
|
+
const initial = makeProject()
|
|
207
|
+
const { result } = renderHook(() => useProjectSync(adapter, initial.id, initial))
|
|
208
|
+
|
|
209
|
+
await act(async () => {
|
|
210
|
+
await result.current.mutate((p) => ({ ...p, name: 'Renamed' }))
|
|
211
|
+
})
|
|
212
|
+
expect(result.current.project.name).toBe('Renamed')
|
|
213
|
+
expect(result.current.canUndo).toBe(true)
|
|
214
|
+
expect(result.current.canRedo).toBe(false)
|
|
215
|
+
|
|
216
|
+
await act(async () => { result.current.undo() })
|
|
217
|
+
expect(result.current.project.name).toBe('Test Project')
|
|
218
|
+
expect(result.current.canRedo).toBe(true)
|
|
219
|
+
|
|
220
|
+
await act(async () => { result.current.redo() })
|
|
221
|
+
expect(result.current.project.name).toBe('Renamed')
|
|
222
|
+
|
|
223
|
+
// mutate save + undo save + redo save
|
|
224
|
+
await waitFor(() => expect(adapter.saveProject).toHaveBeenCalledTimes(3))
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('undo is a no-op with an empty history', async () => {
|
|
228
|
+
const adapter = makeFakeAdapter()
|
|
229
|
+
const initial = makeProject()
|
|
230
|
+
const { result } = renderHook(() => useProjectSync(adapter, initial.id, initial))
|
|
231
|
+
|
|
232
|
+
act(() => { result.current.undo() })
|
|
233
|
+
expect(result.current.project.name).toBe('Test Project')
|
|
234
|
+
expect(adapter.saveProject).not.toHaveBeenCalled()
|
|
235
|
+
})
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
describe('useProjectSync — transient gestures', () => {
|
|
239
|
+
it('commit persists one save after transient mutations, with one undo step', async () => {
|
|
240
|
+
const adapter = makeFakeAdapter()
|
|
241
|
+
const initial = makeProject()
|
|
242
|
+
const { result } = renderHook(() => useProjectSync(adapter, initial.id, initial))
|
|
243
|
+
|
|
244
|
+
await act(async () => {
|
|
245
|
+
result.current.mutateTransient((p) => ({ ...p, name: 'Drag 1' }))
|
|
246
|
+
result.current.mutateTransient((p) => ({ ...p, name: 'Drag 2' }))
|
|
247
|
+
})
|
|
248
|
+
// Transient mutations do not save.
|
|
249
|
+
expect(adapter.saveProject).not.toHaveBeenCalled()
|
|
250
|
+
expect(result.current.project.name).toBe('Drag 2')
|
|
251
|
+
|
|
252
|
+
await act(async () => { await result.current.commit() })
|
|
253
|
+
expect(adapter.saveProject).toHaveBeenCalledTimes(1)
|
|
254
|
+
expect(adapter.saveCalls[0].project.name).toBe('Drag 2')
|
|
255
|
+
// One undo step for the whole gesture.
|
|
256
|
+
expect(result.current.canUndo).toBe(true)
|
|
257
|
+
|
|
258
|
+
await act(async () => { result.current.undo() })
|
|
259
|
+
expect(result.current.project.name).toBe('Test Project')
|
|
260
|
+
})
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
describe('useProjectSync — stale baseline regression', () => {
|
|
264
|
+
// Regression for a data-loss bug: applyExternal (and undo/redo) replaced
|
|
265
|
+
// state but left `transientBaseline` pointing at a now-stale pre-gesture
|
|
266
|
+
// snapshot. A second gesture would then see a non-null baseline and skip
|
|
267
|
+
// re-baselining, so commit() pushed the STALE snapshot as the undo/rollback
|
|
268
|
+
// target — a later undo silently discarded the external change in between.
|
|
269
|
+
// Sequence: gesture 1 (baseline = initial) → external frame arrives →
|
|
270
|
+
// gesture 2 (must re-baseline to the external frame, not reuse the stale
|
|
271
|
+
// one) → commit → undo. Asserts undo lands on the EXTERNAL state, not the
|
|
272
|
+
// pre-gesture-1 state.
|
|
273
|
+
it('undo after a gesture that follows an external frame restores the external state, not the stale pre-gesture baseline', async () => {
|
|
274
|
+
const adapter = makeFakeAdapter()
|
|
275
|
+
const initial = makeProject({ name: 'Test Project' })
|
|
276
|
+
const { result } = renderHook(() => useProjectSync(adapter, initial.id, initial))
|
|
277
|
+
|
|
278
|
+
// Gesture 1 — baselines against the initial state.
|
|
279
|
+
act(() => {
|
|
280
|
+
result.current.mutateTransient((p) => ({ ...p, name: 'Gesture 1' }))
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
// External frame arrives mid-gesture (e.g. cancelOverlayEdit routing through
|
|
284
|
+
// applyExternal, or an SSE echo / caption regen / restoreVersion).
|
|
285
|
+
act(() => {
|
|
286
|
+
result.current.applyExternal(makeProject({ name: 'External' }))
|
|
287
|
+
})
|
|
288
|
+
expect(result.current.project.name).toBe('External')
|
|
289
|
+
|
|
290
|
+
// Gesture 2 — must baseline against 'External' (the current state), not the
|
|
291
|
+
// stale 'Test Project' baseline from gesture 1.
|
|
292
|
+
act(() => {
|
|
293
|
+
result.current.mutateTransient((p) => ({ ...p, name: 'Gesture 2' }))
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
await act(async () => { await result.current.commit() })
|
|
297
|
+
|
|
298
|
+
await act(async () => { result.current.undo() })
|
|
299
|
+
|
|
300
|
+
// Undo should remove only gesture 2, landing back on the external state —
|
|
301
|
+
// NOT the stale pre-gesture-1 snapshot ('Test Project'), which would
|
|
302
|
+
// silently discard the external change.
|
|
303
|
+
expect(result.current.project.name).toBe('External')
|
|
304
|
+
})
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
describe('useProjectSync — subscription lifecycle', () => {
|
|
308
|
+
it('unsubscribes on unmount', () => {
|
|
309
|
+
const adapter = makeFakeAdapter()
|
|
310
|
+
const initial = makeProject()
|
|
311
|
+
const { unmount } = renderHook(() => useProjectSync(adapter, initial.id, initial))
|
|
312
|
+
unmount()
|
|
313
|
+
expect(adapter.unsub).toHaveBeenCalledTimes(1)
|
|
314
|
+
})
|
|
315
|
+
})
|
|
@@ -1,28 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* editor-core / state / use-project-state —
|
|
3
|
-
*
|
|
2
|
+
* editor-core / state / use-project-state — the carousel editor's typed,
|
|
3
|
+
* slide/element-addressed project state.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* - refetch → `adapter.loadProject(id)`
|
|
5
|
+
* A thin typed layer over the shape-agnostic `useProjectSync` core. This module
|
|
6
|
+
* owns everything carousel-specific: the typed `Action` vocabulary (via
|
|
7
|
+
* `projectReducer`), edit-gating by project status, and the target-exists guards
|
|
8
|
+
* on overlay/image prop edits. All save/undo/SSE machinery lives in
|
|
9
|
+
* `useProjectSync`; each typed mutation is expressed as
|
|
10
|
+
* `sync.mutate(p => projectReducer(p, action))` behind its existing gates.
|
|
12
11
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* flight, rollback on save failure, and the MAX_HISTORY=50 undo/redo stacks.
|
|
12
|
+
* The public surface (the `UseProjectState` interface and behavior) is
|
|
13
|
+
* unchanged from when the machinery lived here inline.
|
|
16
14
|
*/
|
|
17
|
-
import {
|
|
15
|
+
import { useCallback } from 'react'
|
|
18
16
|
import { projectReducer, type Action, type ProjectStatus } from './project-reducer'
|
|
19
|
-
import {
|
|
17
|
+
import { useProjectSync } from './use-project-sync'
|
|
20
18
|
import type { Project, Slide, CarouselElement, EditorAdapter } from '../types'
|
|
21
19
|
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
export type Connection = 'connecting' | 'live'
|
|
20
|
+
// Re-export so existing consumers (index.ts barrel) keep importing `Connection`
|
|
21
|
+
// from here; the type is now owned by the sync core.
|
|
22
|
+
export type { Connection } from './use-project-sync'
|
|
26
23
|
|
|
27
24
|
function isEditable(status: ProjectStatus): boolean {
|
|
28
25
|
return status === 'draft' || status === 'final'
|
|
@@ -40,7 +37,7 @@ function findElementType(
|
|
|
40
37
|
|
|
41
38
|
export interface UseProjectState<P extends Project = Project> {
|
|
42
39
|
project: P
|
|
43
|
-
connection:
|
|
40
|
+
connection: 'connecting' | 'live'
|
|
44
41
|
isEditingAllowed: boolean
|
|
45
42
|
lastError: string | null
|
|
46
43
|
clearError: () => void
|
|
@@ -74,100 +71,23 @@ export function useProjectState<P extends Project = Project>(
|
|
|
74
71
|
projectId: string,
|
|
75
72
|
initial: P,
|
|
76
73
|
): UseProjectState<P> {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
74
|
+
// Reference-preserving structural merge for external frames (SSE / refetch),
|
|
75
|
+
// expressed through the reducer's `sse` case so echoes don't churn the canvas.
|
|
76
|
+
const reconcile = useCallback(
|
|
77
|
+
(prev: P, next: P): P =>
|
|
78
|
+
(projectReducer as (state: P, action: Action<P>) => P)(prev, { type: 'sse', project: next }),
|
|
79
|
+
[],
|
|
80
80
|
)
|
|
81
|
-
const [connection, setConnection] = useState<Connection>('connecting')
|
|
82
|
-
const [lastError, setLastError] = useState<string | null>(null)
|
|
83
|
-
const queue = useRef(createMutationQueue())
|
|
84
|
-
// Snapshot taken before the first transient mutation in the current gesture.
|
|
85
|
-
// Reset to null after a successful commit or rollback.
|
|
86
|
-
const transientBaseline = useRef<P | null>(null)
|
|
87
|
-
// Synchronously-updated mirror of the reducer state. Written in three places:
|
|
88
|
-
// 1. Render phase, from `project` (covers SSE, rollback, refetch — paths
|
|
89
|
-
// that go through dispatch directly without computing `next` here).
|
|
90
|
-
// 2. Inside `mutate`, after computing `next` synchronously from the reducer.
|
|
91
|
-
// 3. Inside `mutateTransient`, after computing `next` synchronously.
|
|
92
|
-
// (2) and (3) are critical: a same-tick caller (e.g. `commit()` invoked
|
|
93
|
-
// immediately after `moveElement` from the gesture's onCommit handler) reads
|
|
94
|
-
// this ref to get the post-dispatch state without waiting for a re-render.
|
|
95
|
-
// Without (2)/(3), the ref lags by one render and save bodies are stale.
|
|
96
|
-
const projectRef = useRef<P>(project)
|
|
97
|
-
projectRef.current = project
|
|
98
|
-
|
|
99
|
-
// Latest deferred SSE payload. Held while there are in-flight saves because
|
|
100
|
-
// SSE echoes for an earlier save can arrive while a later save is still
|
|
101
|
-
// mid-flight — applying them would regress the optimistic state to the older
|
|
102
|
-
// value (visible as jitter on the canvas while the operator is typing).
|
|
103
|
-
// Last-write-wins: only the most recent SSE is kept.
|
|
104
|
-
const deferredSseRef = useRef<P | null>(null)
|
|
105
|
-
|
|
106
|
-
// Undo/redo: snapshot-based stacks of full project state. Each committed
|
|
107
|
-
// local action pushes the pre-action snapshot to undoStack and clears the
|
|
108
|
-
// redoStack. undo() pops undo→redo; redo() pops redo→undo. SSE updates do
|
|
109
|
-
// NOT touch the stacks — external changes stay opaque to local history.
|
|
110
|
-
const MAX_HISTORY = 50
|
|
111
|
-
const undoStackRef = useRef<P[]>([])
|
|
112
|
-
const redoStackRef = useRef<P[]>([])
|
|
113
|
-
const [historyVersion, setHistoryVersion] = useState(0)
|
|
114
|
-
const bumpHistory = useCallback(() => setHistoryVersion((v) => v + 1), [])
|
|
115
|
-
const pushUndo = useCallback((snapshot: P) => {
|
|
116
|
-
undoStackRef.current.push(snapshot)
|
|
117
|
-
if (undoStackRef.current.length > MAX_HISTORY) undoStackRef.current.shift()
|
|
118
|
-
redoStackRef.current = []
|
|
119
|
-
bumpHistory()
|
|
120
|
-
}, [bumpHistory])
|
|
121
81
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
useEffect(() => {
|
|
125
|
-
setConnection('connecting')
|
|
126
|
-
let active = true
|
|
127
|
-
const unsubscribe = adapter.subscribe(projectId, (next) => {
|
|
128
|
-
if (!active) return
|
|
129
|
-
setConnection('live')
|
|
130
|
-
if (queue.current.isPending()) {
|
|
131
|
-
// Hold the frame; dispatch it once the queue drains.
|
|
132
|
-
deferredSseRef.current = next
|
|
133
|
-
queue.current.onceDrained(() => {
|
|
134
|
-
const held = deferredSseRef.current
|
|
135
|
-
deferredSseRef.current = null
|
|
136
|
-
if (held) dispatch({ type: 'sse', project: held })
|
|
137
|
-
})
|
|
138
|
-
return
|
|
139
|
-
}
|
|
140
|
-
dispatch({ type: 'sse', project: next })
|
|
141
|
-
})
|
|
142
|
-
return () => {
|
|
143
|
-
active = false
|
|
144
|
-
unsubscribe()
|
|
145
|
-
}
|
|
146
|
-
}, [adapter, projectId])
|
|
147
|
-
|
|
148
|
-
// Internal: persist the full project via the adapter; rollback on failure.
|
|
149
|
-
const save = useCallback(
|
|
150
|
-
async (next: P, snapshot: P) => {
|
|
151
|
-
try {
|
|
152
|
-
await adapter.saveProject(projectId, next)
|
|
153
|
-
} catch (err) {
|
|
154
|
-
dispatch({ type: 'rollback', snapshot })
|
|
155
|
-
throw err instanceof Error ? err : new Error(String(err))
|
|
156
|
-
}
|
|
157
|
-
},
|
|
158
|
-
[adapter, projectId],
|
|
159
|
-
)
|
|
82
|
+
const sync = useProjectSync<P>(adapter, projectId, initial, { reconcile })
|
|
83
|
+
const { mutate: syncMutate, mutateTransient: syncMutateTransient, projectRef } = sync
|
|
160
84
|
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
// gets the correct shape without waiting for a re-render.
|
|
85
|
+
// Typed, gated mutation. Gates on edit-allowed status and target-exists; silent
|
|
86
|
+
// no-ops surface via console.warn so the regen→slide-deleted race shows up in
|
|
87
|
+
// the dev console. Non-gated actions flow straight to the core, which handles
|
|
88
|
+
// the optimistic apply + queued save.
|
|
166
89
|
const mutate = useCallback(
|
|
167
|
-
(action: Action<P>) => {
|
|
168
|
-
// Read base state from the live ref, not the `project` closure, so a
|
|
169
|
-
// sequence of mutate calls in the same event tick chain correctly
|
|
170
|
-
// (call N's `next` becomes call N+1's base).
|
|
90
|
+
(action: Action<P>): Promise<void> => {
|
|
171
91
|
const base = projectRef.current
|
|
172
92
|
const editGated = new Set(['updateOverlayProp', 'updateImageCrop', 'setStatus', 'setName', 'moveElement', 'resizeElement', 'rotateElement', 'addElement', 'removeElement', 'addSlide', 'removeSlide', 'duplicateSlide', 'reorderSlides', 'updateSlide', 'duplicateElement', 'reorderElement', 'setOverlayFrame'])
|
|
173
93
|
if (editGated.has(action.type) && !isEditable(base.status)) {
|
|
@@ -184,109 +104,26 @@ export function useProjectState<P extends Project = Project>(
|
|
|
184
104
|
return Promise.resolve()
|
|
185
105
|
}
|
|
186
106
|
}
|
|
187
|
-
|
|
188
|
-
pushUndo(snapshot)
|
|
189
|
-
const next = projectReducer(base, action)
|
|
190
|
-
projectRef.current = next
|
|
191
|
-
dispatch(action)
|
|
192
|
-
// Non-transient mutations reset the baseline so any subsequent gesture
|
|
193
|
-
// starts from the freshly committed state.
|
|
194
|
-
transientBaseline.current = null
|
|
195
|
-
return queue.current.enqueue(() =>
|
|
196
|
-
save(next, snapshot).catch((err) => {
|
|
197
|
-
setLastError(err instanceof Error ? err.message : String(err))
|
|
198
|
-
throw err
|
|
199
|
-
}),
|
|
200
|
-
)
|
|
107
|
+
return syncMutate((p) => projectReducer(p, action))
|
|
201
108
|
},
|
|
202
|
-
[
|
|
109
|
+
[syncMutate, projectRef],
|
|
203
110
|
)
|
|
204
111
|
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
// back to it on failure.
|
|
112
|
+
// Typed transient dispatch (gesture previews) — local only, gated to the
|
|
113
|
+
// transform actions and edit-allowed status.
|
|
208
114
|
const mutateTransient = useCallback(
|
|
209
|
-
(action: Action<P>) => {
|
|
115
|
+
(action: Action<P>): void => {
|
|
210
116
|
const base = projectRef.current
|
|
211
117
|
const editGated = new Set(['moveElement', 'resizeElement', 'rotateElement'])
|
|
212
118
|
if (!editGated.has(action.type) || !isEditable(base.status)) {
|
|
213
119
|
console.warn(`[useProjectState] dropped transient ${action.type}: status="${base.status}" not editable`)
|
|
214
120
|
return
|
|
215
121
|
}
|
|
216
|
-
|
|
217
|
-
if (transientBaseline.current === null) {
|
|
218
|
-
transientBaseline.current = base
|
|
219
|
-
}
|
|
220
|
-
const next = projectReducer(base, action)
|
|
221
|
-
projectRef.current = next
|
|
222
|
-
dispatch(action)
|
|
122
|
+
syncMutateTransient((p) => projectReducer(p, action))
|
|
223
123
|
},
|
|
224
|
-
[],
|
|
124
|
+
[syncMutateTransient, projectRef],
|
|
225
125
|
)
|
|
226
126
|
|
|
227
|
-
// commit() — enqueues ONE save with the current (post-drag) state.
|
|
228
|
-
// On failure, rolls back to the pre-gesture baseline.
|
|
229
|
-
const commit = useCallback((): Promise<void> => {
|
|
230
|
-
const current = projectRef.current
|
|
231
|
-
const baseline = transientBaseline.current
|
|
232
|
-
transientBaseline.current = null
|
|
233
|
-
// One undo step per gesture: only push the baseline if the gesture
|
|
234
|
-
// actually produced transient changes (baseline was captured).
|
|
235
|
-
if (baseline !== null) pushUndo(baseline)
|
|
236
|
-
const rollbackTo = baseline ?? current
|
|
237
|
-
return queue.current.enqueue(() =>
|
|
238
|
-
save(current, rollbackTo).catch((err) => {
|
|
239
|
-
setLastError(err instanceof Error ? err.message : String(err))
|
|
240
|
-
throw err
|
|
241
|
-
}),
|
|
242
|
-
)
|
|
243
|
-
}, [save, pushUndo])
|
|
244
|
-
|
|
245
|
-
// undo()/redo() — snapshot swap. Pops the target stack, pushes current
|
|
246
|
-
// state to the opposite stack, dispatches `rollback` (which replaces the
|
|
247
|
-
// entire state), and enqueues a save so the host persists the swap.
|
|
248
|
-
const undo = useCallback((): void => {
|
|
249
|
-
const prev = undoStackRef.current.pop()
|
|
250
|
-
if (!prev) return
|
|
251
|
-
const current = projectRef.current
|
|
252
|
-
redoStackRef.current.push(current)
|
|
253
|
-
if (redoStackRef.current.length > MAX_HISTORY) redoStackRef.current.shift()
|
|
254
|
-
bumpHistory()
|
|
255
|
-
projectRef.current = prev
|
|
256
|
-
dispatch({ type: 'rollback', snapshot: prev })
|
|
257
|
-
void queue.current.enqueue(() =>
|
|
258
|
-
save(prev, current).catch((err) => {
|
|
259
|
-
setLastError(err instanceof Error ? err.message : String(err))
|
|
260
|
-
throw err
|
|
261
|
-
}),
|
|
262
|
-
)
|
|
263
|
-
}, [save, bumpHistory])
|
|
264
|
-
|
|
265
|
-
const redo = useCallback((): void => {
|
|
266
|
-
const next = redoStackRef.current.pop()
|
|
267
|
-
if (!next) return
|
|
268
|
-
const current = projectRef.current
|
|
269
|
-
undoStackRef.current.push(current)
|
|
270
|
-
if (undoStackRef.current.length > MAX_HISTORY) undoStackRef.current.shift()
|
|
271
|
-
bumpHistory()
|
|
272
|
-
projectRef.current = next
|
|
273
|
-
dispatch({ type: 'rollback', snapshot: next })
|
|
274
|
-
void queue.current.enqueue(() =>
|
|
275
|
-
save(next, current).catch((err) => {
|
|
276
|
-
setLastError(err instanceof Error ? err.message : String(err))
|
|
277
|
-
throw err
|
|
278
|
-
}),
|
|
279
|
-
)
|
|
280
|
-
}, [save, bumpHistory])
|
|
281
|
-
|
|
282
|
-
const canUndo = undoStackRef.current.length > 0
|
|
283
|
-
const canRedo = redoStackRef.current.length > 0
|
|
284
|
-
// Touch historyVersion so dependent components re-render when the stacks
|
|
285
|
-
// change. Without this, canUndo/canRedo would be evaluated on stale renders.
|
|
286
|
-
void historyVersion
|
|
287
|
-
|
|
288
|
-
const clearError = useCallback(() => setLastError(null), [])
|
|
289
|
-
|
|
290
127
|
const updateOverlayProp = useCallback(
|
|
291
128
|
(slideId: string, elementId: string, key: string, value: string) =>
|
|
292
129
|
mutate({ type: 'updateOverlayProp', slideId, elementId, key, value }),
|
|
@@ -395,26 +232,14 @@ export function useProjectState<P extends Project = Project>(
|
|
|
395
232
|
[mutate],
|
|
396
233
|
)
|
|
397
234
|
|
|
398
|
-
|
|
399
|
-
// Useful when local state has drifted from the server (e.g. after a network gap).
|
|
400
|
-
const refetch = useCallback(async () => {
|
|
401
|
-
try {
|
|
402
|
-
const next = await adapter.loadProject(projectId)
|
|
403
|
-
dispatch({ type: 'sse', project: next })
|
|
404
|
-
} catch (err) {
|
|
405
|
-
setLastError(err instanceof Error ? err.message : String(err))
|
|
406
|
-
throw err
|
|
407
|
-
}
|
|
408
|
-
}, [adapter, projectId])
|
|
409
|
-
|
|
410
|
-
const isEditingAllowed = isEditable(project.status)
|
|
235
|
+
const isEditingAllowed = isEditable(sync.project.status)
|
|
411
236
|
|
|
412
237
|
return {
|
|
413
|
-
project,
|
|
414
|
-
connection,
|
|
238
|
+
project: sync.project,
|
|
239
|
+
connection: sync.connection,
|
|
415
240
|
isEditingAllowed,
|
|
416
|
-
lastError,
|
|
417
|
-
clearError,
|
|
241
|
+
lastError: sync.lastError,
|
|
242
|
+
clearError: sync.clearError,
|
|
418
243
|
updateOverlayProp,
|
|
419
244
|
updateImageCrop,
|
|
420
245
|
setStatus,
|
|
@@ -432,11 +257,11 @@ export function useProjectState<P extends Project = Project>(
|
|
|
432
257
|
reorderSlides,
|
|
433
258
|
updateSlide,
|
|
434
259
|
setOverlayFrame,
|
|
435
|
-
commit,
|
|
436
|
-
refetch,
|
|
437
|
-
undo,
|
|
438
|
-
redo,
|
|
439
|
-
canUndo,
|
|
440
|
-
canRedo,
|
|
260
|
+
commit: sync.commit,
|
|
261
|
+
refetch: sync.refetch,
|
|
262
|
+
undo: sync.undo,
|
|
263
|
+
redo: sync.redo,
|
|
264
|
+
canUndo: sync.canUndo,
|
|
265
|
+
canRedo: sync.canRedo,
|
|
441
266
|
}
|
|
442
267
|
}
|