@bycrux/editor 0.9.0 → 0.11.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/package.json +1 -1
- package/src/index.ts +4 -0
- 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 +331 -111
- package/src/video/__tests__/VideoEditor.test.tsx +279 -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__/playback-clock.test.tsx +45 -0
- package/src/video/captionRepair.ts +13 -1
- package/src/video/playback-clock.ts +31 -0
- package/src/video/preview/CaptionPreview.tsx +298 -4
- package/src/video/preview/OverlayItemsLayer.tsx +36 -13
- package/src/video/preview/OverlayPropsModal.tsx +292 -0
- package/src/video/preview/PreviewPlayer.tsx +27 -6
- package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +106 -0
- package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +32 -0
- package/src/video/preview/__tests__/captionDragState.test.ts +163 -0
- package/src/video/preview/__tests__/overlay-prop-fields.test.ts +44 -0
- package/src/video/preview/captionDragState.ts +175 -0
- package/src/video/preview/overlay-prop-fields.ts +39 -0
- package/src/video/preview/useDragOverlay.ts +21 -1
- package/src/video/preview/useVideoPlayback.ts +12 -3
- package/src/video/timeline/AudioTrackRow.tsx +6 -8
- package/src/video/timeline/CaptionTrackRow.tsx +235 -0
- package/src/video/timeline/PlayheadLine.tsx +18 -0
- package/src/video/timeline/Scrubber.tsx +7 -5
- package/src/video/timeline/Timeline.tsx +94 -30
- package/src/video/timeline/TimelineContext.ts +2 -2
- package/src/video/timeline/TranscriptModal.tsx +10 -3
- package/src/video/timeline/TranscriptPanel.tsx +7 -1
- package/src/video/timeline/VisualTrackRow.tsx +21 -14
- package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +241 -0
- package/src/video/timeline/__tests__/PlayheadLine.test.tsx +60 -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
|
@@ -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
|
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* editor-core / state / use-project-sync — the shape-agnostic save/undo core.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from `use-project-state.ts` so both editors (carousel + video) can
|
|
5
|
+
* share one save model. This hook knows NOTHING about slides, elements, or the
|
|
6
|
+
* typed action vocabulary — it operates over opaque project values `P` and
|
|
7
|
+
* function-shaped mutations `(p: P) => P`. Everything shape-specific (the typed
|
|
8
|
+
* `Action` union, `projectReducer`, edit-gating) stays in the layer above.
|
|
9
|
+
*
|
|
10
|
+
* Mechanics carried over verbatim from `use-project-state.ts`:
|
|
11
|
+
* - mutation-queue serialisation (`createMutationQueue`)
|
|
12
|
+
* - SSE subscribe + deferral: hold echoes while a save is in flight, then
|
|
13
|
+
* apply only the most recent one on drain (last-write-wins)
|
|
14
|
+
* - optimistic apply with rollback-on-save-failure
|
|
15
|
+
* - undo/redo snapshot stacks capped at MAX_HISTORY=50
|
|
16
|
+
* - `projectRef` same-tick mirror so a caller reading it immediately after a
|
|
17
|
+
* mutation sees post-mutation state without waiting for a re-render
|
|
18
|
+
*
|
|
19
|
+
* The only shape-aware seam is the optional `reconcile` option: how an external
|
|
20
|
+
* frame (SSE / refetch / `applyExternal`) is folded into current state. It
|
|
21
|
+
* defaults to a plain replace; the carousel layer passes its reference-
|
|
22
|
+
* preserving structural merge so echoes don't churn the canvas. The core never
|
|
23
|
+
* looks inside `P` to do this — it just calls the supplied function.
|
|
24
|
+
*/
|
|
25
|
+
import { useCallback, useEffect, useReducer, useRef, useState } from 'react'
|
|
26
|
+
import type { RefObject } from 'react'
|
|
27
|
+
import { createMutationQueue } from './mutation-queue'
|
|
28
|
+
import type { Project, EditorAdapter } from '../types'
|
|
29
|
+
|
|
30
|
+
// Connection lifecycle: 'connecting' from mount until the first frame arrives,
|
|
31
|
+
// then 'live'. The adapter's subscribe auto-reconnects on drop — the editor
|
|
32
|
+
// stays 'live' and simply receives the next frame when it comes.
|
|
33
|
+
export type Connection = 'connecting' | 'live'
|
|
34
|
+
|
|
35
|
+
export interface UseProjectSyncOptions<P extends Project> {
|
|
36
|
+
/**
|
|
37
|
+
* Fold an external (server-authored) frame into current state. Receives the
|
|
38
|
+
* current project and the incoming frame; returns the state to apply. Used by
|
|
39
|
+
* the SSE path, `refetch`, and `applyExternal`. Defaults to a plain replace
|
|
40
|
+
* (`(_prev, next) => next`). Hosts that want reference-preserving merges pass
|
|
41
|
+
* one here — it must be a pure function of its two arguments.
|
|
42
|
+
*/
|
|
43
|
+
reconcile?: (prev: P, next: P) => P
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface UseProjectSync<P extends Project = Project> {
|
|
47
|
+
project: P
|
|
48
|
+
connection: Connection
|
|
49
|
+
/** pushUndo + optimistic apply + queued save + rollback-on-failure. */
|
|
50
|
+
mutate: (fn: (p: P) => P) => Promise<void>
|
|
51
|
+
/** Local-only apply (gesture previews) — no save, no undo push. */
|
|
52
|
+
mutateTransient: (fn: (p: P) => P) => void
|
|
53
|
+
/** One queued save for the accumulated transient state; one undo step. */
|
|
54
|
+
commit: () => Promise<void>
|
|
55
|
+
/** Apply server-authored state — no save, no undo push (e.g. caption regen). */
|
|
56
|
+
applyExternal: (p: P) => void
|
|
57
|
+
undo: () => void
|
|
58
|
+
redo: () => void
|
|
59
|
+
canUndo: boolean
|
|
60
|
+
canRedo: boolean
|
|
61
|
+
refetch: () => Promise<void>
|
|
62
|
+
lastError: string | null
|
|
63
|
+
clearError: () => void
|
|
64
|
+
projectRef: RefObject<P>
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function useProjectSync<P extends Project = Project>(
|
|
68
|
+
adapter: EditorAdapter<P>,
|
|
69
|
+
projectId: string,
|
|
70
|
+
initial: P,
|
|
71
|
+
options?: UseProjectSyncOptions<P>,
|
|
72
|
+
): UseProjectSync<P> {
|
|
73
|
+
// Replace-only reducer: every transition computes the next project value
|
|
74
|
+
// externally and dispatches it. Keeps the core shape-agnostic (no typed
|
|
75
|
+
// action vocabulary) while dodging useState's function-arg ambiguity.
|
|
76
|
+
const [project, setProject] = useReducer((_prev: P, next: P) => next, initial)
|
|
77
|
+
const [connection, setConnection] = useState<Connection>('connecting')
|
|
78
|
+
const [lastError, setLastError] = useState<string | null>(null)
|
|
79
|
+
const queue = useRef(createMutationQueue())
|
|
80
|
+
|
|
81
|
+
// Snapshot taken before the first transient mutation in the current gesture.
|
|
82
|
+
// Reset to null after a successful commit or a non-transient mutation.
|
|
83
|
+
const transientBaseline = useRef<P | null>(null)
|
|
84
|
+
|
|
85
|
+
// Synchronously-updated mirror of reducer state. Written in the render phase
|
|
86
|
+
// (below) AND inside every mutation path after computing `next`. The same-tick
|
|
87
|
+
// writes are critical: a caller (e.g. `commit()` invoked immediately after a
|
|
88
|
+
// transient move from a gesture's onCommit handler) reads this ref to get
|
|
89
|
+
// post-dispatch state without waiting for a re-render.
|
|
90
|
+
const projectRef = useRef<P>(project)
|
|
91
|
+
projectRef.current = project
|
|
92
|
+
|
|
93
|
+
// `reconcile` stashed in a ref so the subscribe effect and apply path stay
|
|
94
|
+
// referentially stable regardless of whether the host memoises the option.
|
|
95
|
+
const reconcileRef = useRef(options?.reconcile)
|
|
96
|
+
reconcileRef.current = options?.reconcile
|
|
97
|
+
|
|
98
|
+
// Latest deferred external frame. Held while saves are in flight because an
|
|
99
|
+
// echo for an earlier save can arrive while a later save is still mid-flight —
|
|
100
|
+
// applying it would regress the optimistic state to the older value (visible
|
|
101
|
+
// as jitter on the canvas while the operator is typing). Last-write-wins:
|
|
102
|
+
// only the most recent frame is kept.
|
|
103
|
+
const deferredSseRef = useRef<P | null>(null)
|
|
104
|
+
|
|
105
|
+
// Fold an external frame into current state (reconcile, or plain replace) and
|
|
106
|
+
// apply it. No save, no undo push. Stable — reads reconcile via ref.
|
|
107
|
+
//
|
|
108
|
+
// Clears transientBaseline: an external frame invalidates any in-progress
|
|
109
|
+
// gesture's pre-gesture snapshot (it predates this frame), so a subsequent
|
|
110
|
+
// commit() must not roll back to it and undo() must not resurrect it. Without
|
|
111
|
+
// this, a stale baseline can silently swallow an external change on undo —
|
|
112
|
+
// see the regression test for the full sequence.
|
|
113
|
+
const applyExternal = useCallback((incoming: P) => {
|
|
114
|
+
const base = projectRef.current
|
|
115
|
+
const reconcile = reconcileRef.current
|
|
116
|
+
const next = reconcile ? reconcile(base, incoming) : incoming
|
|
117
|
+
projectRef.current = next
|
|
118
|
+
transientBaseline.current = null
|
|
119
|
+
setProject(next)
|
|
120
|
+
}, [])
|
|
121
|
+
|
|
122
|
+
// Undo/redo: snapshot-based stacks of full project state. Each committed local
|
|
123
|
+
// mutation pushes the pre-mutation snapshot to undoStack and clears redoStack.
|
|
124
|
+
// undo() pops undo→redo; redo() pops redo→undo. External frames do NOT touch
|
|
125
|
+
// the stacks — server changes stay opaque to local history.
|
|
126
|
+
const MAX_HISTORY = 50
|
|
127
|
+
const undoStackRef = useRef<P[]>([])
|
|
128
|
+
const redoStackRef = useRef<P[]>([])
|
|
129
|
+
const [historyVersion, setHistoryVersion] = useState(0)
|
|
130
|
+
const bumpHistory = useCallback(() => setHistoryVersion((v) => v + 1), [])
|
|
131
|
+
const pushUndo = useCallback((snapshot: P) => {
|
|
132
|
+
undoStackRef.current.push(snapshot)
|
|
133
|
+
if (undoStackRef.current.length > MAX_HISTORY) undoStackRef.current.shift()
|
|
134
|
+
redoStackRef.current = []
|
|
135
|
+
bumpHistory()
|
|
136
|
+
}, [bumpHistory])
|
|
137
|
+
|
|
138
|
+
// Subscription lifecycle. The adapter owns the transport (SSE, websocket,
|
|
139
|
+
// poll); we just receive fresh frames and reconcile them. `applyExternal` is
|
|
140
|
+
// stable so this only re-subscribes on adapter/projectId change.
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
setConnection('connecting')
|
|
143
|
+
let active = true
|
|
144
|
+
const unsubscribe = adapter.subscribe(projectId, (next) => {
|
|
145
|
+
if (!active) return
|
|
146
|
+
setConnection('live')
|
|
147
|
+
if (queue.current.isPending()) {
|
|
148
|
+
// Hold the frame; apply it once the queue drains.
|
|
149
|
+
deferredSseRef.current = next
|
|
150
|
+
queue.current.onceDrained(() => {
|
|
151
|
+
const held = deferredSseRef.current
|
|
152
|
+
deferredSseRef.current = null
|
|
153
|
+
if (held) applyExternal(held)
|
|
154
|
+
})
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
applyExternal(next)
|
|
158
|
+
})
|
|
159
|
+
return () => {
|
|
160
|
+
active = false
|
|
161
|
+
unsubscribe()
|
|
162
|
+
}
|
|
163
|
+
}, [adapter, projectId, applyExternal])
|
|
164
|
+
|
|
165
|
+
// Internal: persist the full project via the adapter; rollback on failure.
|
|
166
|
+
const save = useCallback(
|
|
167
|
+
async (next: P, snapshot: P) => {
|
|
168
|
+
try {
|
|
169
|
+
await adapter.saveProject(projectId, next)
|
|
170
|
+
} catch (err) {
|
|
171
|
+
projectRef.current = snapshot
|
|
172
|
+
setProject(snapshot)
|
|
173
|
+
throw err instanceof Error ? err : new Error(String(err))
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
[adapter, projectId],
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
// Snapshot, optimistically apply, and enqueue the save. `next` is computed
|
|
180
|
+
// synchronously from the live ref (not the `project` closure) so a sequence of
|
|
181
|
+
// mutate calls in the same tick chains correctly (call N's next becomes call
|
|
182
|
+
// N+1's base) and the save body carries the correct shape without a re-render.
|
|
183
|
+
const mutate = useCallback(
|
|
184
|
+
(fn: (p: P) => P): Promise<void> => {
|
|
185
|
+
const base = projectRef.current
|
|
186
|
+
const snapshot = base
|
|
187
|
+
pushUndo(snapshot)
|
|
188
|
+
const next = fn(base)
|
|
189
|
+
projectRef.current = next
|
|
190
|
+
setProject(next)
|
|
191
|
+
// Non-transient mutations reset the baseline so any subsequent gesture
|
|
192
|
+
// starts from the freshly committed state.
|
|
193
|
+
transientBaseline.current = null
|
|
194
|
+
return queue.current.enqueue(() =>
|
|
195
|
+
save(next, snapshot).catch((err) => {
|
|
196
|
+
setLastError(err instanceof Error ? err.message : String(err))
|
|
197
|
+
throw err
|
|
198
|
+
}),
|
|
199
|
+
)
|
|
200
|
+
},
|
|
201
|
+
[save, pushUndo],
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
// Local-only apply — no save, no queue. Records the pre-gesture baseline on the
|
|
205
|
+
// first call so commit() can roll back to it on failure.
|
|
206
|
+
const mutateTransient = useCallback((fn: (p: P) => P): void => {
|
|
207
|
+
const base = projectRef.current
|
|
208
|
+
if (transientBaseline.current === null) {
|
|
209
|
+
transientBaseline.current = base
|
|
210
|
+
}
|
|
211
|
+
const next = fn(base)
|
|
212
|
+
projectRef.current = next
|
|
213
|
+
setProject(next)
|
|
214
|
+
}, [])
|
|
215
|
+
|
|
216
|
+
// commit() — enqueues ONE save with the current (post-gesture) state. On
|
|
217
|
+
// failure, rolls back to the pre-gesture baseline. One undo step per gesture:
|
|
218
|
+
// only push the baseline if the gesture actually produced transient changes.
|
|
219
|
+
const commit = useCallback((): Promise<void> => {
|
|
220
|
+
const current = projectRef.current
|
|
221
|
+
const baseline = transientBaseline.current
|
|
222
|
+
transientBaseline.current = null
|
|
223
|
+
if (baseline !== null) pushUndo(baseline)
|
|
224
|
+
const rollbackTo = baseline ?? current
|
|
225
|
+
return queue.current.enqueue(() =>
|
|
226
|
+
save(current, rollbackTo).catch((err) => {
|
|
227
|
+
setLastError(err instanceof Error ? err.message : String(err))
|
|
228
|
+
throw err
|
|
229
|
+
}),
|
|
230
|
+
)
|
|
231
|
+
}, [save, pushUndo])
|
|
232
|
+
|
|
233
|
+
// undo()/redo() — snapshot swap. Pops the target stack, pushes current state
|
|
234
|
+
// to the opposite stack, replaces the entire state, and enqueues a save so the
|
|
235
|
+
// host persists the swap. Also clears transientBaseline: it replaces state
|
|
236
|
+
// wholesale, so any in-progress gesture's pre-gesture snapshot is stale after
|
|
237
|
+
// this and must not be resurrected by a later commit()/undo() (see applyExternal).
|
|
238
|
+
const undo = useCallback((): void => {
|
|
239
|
+
const prev = undoStackRef.current.pop()
|
|
240
|
+
if (!prev) return
|
|
241
|
+
const current = projectRef.current
|
|
242
|
+
redoStackRef.current.push(current)
|
|
243
|
+
if (redoStackRef.current.length > MAX_HISTORY) redoStackRef.current.shift()
|
|
244
|
+
bumpHistory()
|
|
245
|
+
projectRef.current = prev
|
|
246
|
+
transientBaseline.current = null
|
|
247
|
+
setProject(prev)
|
|
248
|
+
void queue.current.enqueue(() =>
|
|
249
|
+
save(prev, current).catch((err) => {
|
|
250
|
+
setLastError(err instanceof Error ? err.message : String(err))
|
|
251
|
+
throw err
|
|
252
|
+
}),
|
|
253
|
+
)
|
|
254
|
+
}, [save, bumpHistory])
|
|
255
|
+
|
|
256
|
+
const redo = useCallback((): void => {
|
|
257
|
+
const next = redoStackRef.current.pop()
|
|
258
|
+
if (!next) return
|
|
259
|
+
const current = projectRef.current
|
|
260
|
+
undoStackRef.current.push(current)
|
|
261
|
+
if (undoStackRef.current.length > MAX_HISTORY) undoStackRef.current.shift()
|
|
262
|
+
bumpHistory()
|
|
263
|
+
projectRef.current = next
|
|
264
|
+
transientBaseline.current = null
|
|
265
|
+
setProject(next)
|
|
266
|
+
void queue.current.enqueue(() =>
|
|
267
|
+
save(next, current).catch((err) => {
|
|
268
|
+
setLastError(err instanceof Error ? err.message : String(err))
|
|
269
|
+
throw err
|
|
270
|
+
}),
|
|
271
|
+
)
|
|
272
|
+
}, [save, bumpHistory])
|
|
273
|
+
|
|
274
|
+
const canUndo = undoStackRef.current.length > 0
|
|
275
|
+
const canRedo = redoStackRef.current.length > 0
|
|
276
|
+
// Touch historyVersion so dependent components re-render when the stacks
|
|
277
|
+
// change. Without this, canUndo/canRedo would be evaluated on stale renders.
|
|
278
|
+
void historyVersion
|
|
279
|
+
|
|
280
|
+
const clearError = useCallback(() => setLastError(null), [])
|
|
281
|
+
|
|
282
|
+
// Force a fresh load of the project via the adapter and reconcile it in.
|
|
283
|
+
// Useful when local state has drifted from the server (e.g. after a gap).
|
|
284
|
+
const refetch = useCallback(async () => {
|
|
285
|
+
try {
|
|
286
|
+
const next = await adapter.loadProject(projectId)
|
|
287
|
+
applyExternal(next)
|
|
288
|
+
} catch (err) {
|
|
289
|
+
setLastError(err instanceof Error ? err.message : String(err))
|
|
290
|
+
throw err
|
|
291
|
+
}
|
|
292
|
+
}, [adapter, projectId, applyExternal])
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
project,
|
|
296
|
+
connection,
|
|
297
|
+
mutate,
|
|
298
|
+
mutateTransient,
|
|
299
|
+
commit,
|
|
300
|
+
applyExternal,
|
|
301
|
+
undo,
|
|
302
|
+
redo,
|
|
303
|
+
canUndo,
|
|
304
|
+
canRedo,
|
|
305
|
+
refetch,
|
|
306
|
+
lastError,
|
|
307
|
+
clearError,
|
|
308
|
+
projectRef,
|
|
309
|
+
}
|
|
310
|
+
}
|