@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.
Files changed (41) hide show
  1. package/package.json +1 -1
  2. package/src/index.ts +4 -0
  3. package/src/schema.ts +6 -0
  4. package/src/state/__tests__/use-project-sync.test.tsx +315 -0
  5. package/src/state/use-project-state.ts +47 -222
  6. package/src/state/use-project-sync.ts +310 -0
  7. package/src/video/VideoEditor.tsx +331 -111
  8. package/src/video/__tests__/VideoEditor.test.tsx +279 -3
  9. package/src/video/__tests__/backfillCaptionIds.test.ts +70 -0
  10. package/src/video/__tests__/captionPositioning.test.tsx +435 -0
  11. package/src/video/__tests__/captionRepair.test.ts +26 -0
  12. package/src/video/__tests__/playback-clock.test.tsx +45 -0
  13. package/src/video/captionRepair.ts +13 -1
  14. package/src/video/playback-clock.ts +31 -0
  15. package/src/video/preview/CaptionPreview.tsx +298 -4
  16. package/src/video/preview/OverlayItemsLayer.tsx +36 -13
  17. package/src/video/preview/OverlayPropsModal.tsx +292 -0
  18. package/src/video/preview/PreviewPlayer.tsx +27 -6
  19. package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +106 -0
  20. package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +32 -0
  21. package/src/video/preview/__tests__/captionDragState.test.ts +163 -0
  22. package/src/video/preview/__tests__/overlay-prop-fields.test.ts +44 -0
  23. package/src/video/preview/captionDragState.ts +175 -0
  24. package/src/video/preview/overlay-prop-fields.ts +39 -0
  25. package/src/video/preview/useDragOverlay.ts +21 -1
  26. package/src/video/preview/useVideoPlayback.ts +12 -3
  27. package/src/video/timeline/AudioTrackRow.tsx +6 -8
  28. package/src/video/timeline/CaptionTrackRow.tsx +235 -0
  29. package/src/video/timeline/PlayheadLine.tsx +18 -0
  30. package/src/video/timeline/Scrubber.tsx +7 -5
  31. package/src/video/timeline/Timeline.tsx +94 -30
  32. package/src/video/timeline/TimelineContext.ts +2 -2
  33. package/src/video/timeline/TranscriptModal.tsx +10 -3
  34. package/src/video/timeline/TranscriptPanel.tsx +7 -1
  35. package/src/video/timeline/VisualTrackRow.tsx +21 -14
  36. package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +241 -0
  37. package/src/video/timeline/__tests__/PlayheadLine.test.tsx +60 -0
  38. package/src/video/timeline/__tests__/TranscriptModal.test.tsx +41 -0
  39. package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +22 -0
  40. package/src/video/timeline/__tests__/makeCaptionEdit.test.ts +123 -0
  41. package/src/video/timeline/makeCaptionEdit.ts +33 -10
@@ -1,28 +1,25 @@
1
1
  /**
2
- * editor-core / state / use-project-state — optimistic, host-agnostic project
3
- * state with undo/redo and SSE reconciliation.
2
+ * editor-core / state / use-project-state — the carousel editor's typed,
3
+ * slide/element-addressed project state.
4
4
  *
5
- * Ported from mission-control's
6
- * `src/app/admin/projects/hooks/use-project-state.ts`. The MC version hardcoded
7
- * `/api/hub/projects/:id/montaj` fetch + EventSource. Here, ALL transport goes
8
- * through the injected `EditorAdapter`:
9
- * - persistence → `adapter.saveProject(id, project)`
10
- * - live frames → `adapter.subscribe(id, onFrame)`
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
- * Everything else is preserved: optimistic mutations, the transient-vs-committed
14
- * distinction, mutation-queue serialisation, SSE deferral while a save is in
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 { useEffect, useReducer, useRef, useState, useCallback } from 'react'
15
+ import { useCallback } from 'react'
18
16
  import { projectReducer, type Action, type ProjectStatus } from './project-reducer'
19
- import { createMutationQueue } from './mutation-queue'
17
+ import { useProjectSync } from './use-project-sync'
20
18
  import type { Project, Slide, CarouselElement, EditorAdapter } from '../types'
21
19
 
22
- // Connection lifecycle: 'connecting' from mount until the first SSE frame
23
- // arrives, then 'live'. The adapter's subscribe auto-reconnects on drop —
24
- // the editor stays 'live' and simply receives the next frame when it comes.
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: 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
- const [project, dispatch] = useReducer(
78
- projectReducer as (state: P, action: Action<P>) => P,
79
- initial,
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
- // Subscription lifecycle. The adapter owns the transport (SSE, websocket,
123
- // poll); we just receive fresh frames and reconcile them.
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
- // Internal: snapshot, optimistically reduce, dispatch, and enqueue the save.
162
- // Gates on edit-allowed status and target-exists; silent no-ops are visible
163
- // via console.warn so the regen→slide-deleted race surfaces in the dev
164
- // console. `next` is computed synchronously via the same reducer so the save
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
- const snapshot = base
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
- [save, pushUndo],
109
+ [syncMutate, projectRef],
203
110
  )
204
111
 
205
- // Internal: dispatch a transient (local-only) action — no save, no queue.
206
- // Records the pre-gesture baseline on the first call so commit() can roll
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
- // Capture baseline before the first transient change in this gesture.
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
- // Force a fresh load of the project via the adapter and replace local state.
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
+ }