@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 +1 -1
- package/src/schema.ts +6 -0
- package/src/state/__tests__/use-project-sync.test.tsx +315 -0
- package/src/state/use-project-state.ts +47 -222
- package/src/state/use-project-sync.ts +310 -0
- package/src/video/VideoEditor.tsx +260 -124
- package/src/video/__tests__/VideoEditor.test.tsx +245 -3
- package/src/video/__tests__/backfillCaptionIds.test.ts +70 -0
- package/src/video/__tests__/captionPositioning.test.tsx +435 -0
- package/src/video/__tests__/captionRepair.test.ts +26 -0
- package/src/video/captionRepair.ts +13 -1
- package/src/video/preview/CaptionPreview.tsx +298 -4
- package/src/video/preview/PreviewPlayer.tsx +13 -0
- package/src/video/preview/__tests__/captionDragState.test.ts +163 -0
- package/src/video/preview/captionDragState.ts +175 -0
- package/src/video/timeline/CaptionTrackRow.tsx +235 -0
- package/src/video/timeline/Timeline.tsx +31 -2
- package/src/video/timeline/TranscriptModal.tsx +10 -3
- package/src/video/timeline/TranscriptPanel.tsx +7 -1
- package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +241 -0
- package/src/video/timeline/__tests__/TranscriptModal.test.tsx +41 -0
- package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +22 -0
- package/src/video/timeline/__tests__/makeCaptionEdit.test.ts +123 -0
- package/src/video/timeline/makeCaptionEdit.ts +33 -10
|
@@ -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
|
+
}
|