@bycrux/editor 0.10.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.
@@ -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
+ }