@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bycrux/editor",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
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 — 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
  }