@devbycrux/editor 0.1.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/README.md +165 -0
- package/package.json +46 -0
- package/src/__tests__/adapter-contract.test.ts +123 -0
- package/src/__tests__/adapter.test.ts +185 -0
- package/src/__tests__/schema.test.ts +104 -0
- package/src/carousel/AddElementMenu.tsx +211 -0
- package/src/carousel/CarouselEditor.tsx +529 -0
- package/src/carousel/CarouselRenderModal.tsx +243 -0
- package/src/carousel/OverlayErrorBoundary.tsx +99 -0
- package/src/carousel/OverlayPicker.tsx +145 -0
- package/src/carousel/SlideCanvas.tsx +588 -0
- package/src/carousel/SlidePropertyPanel.tsx +349 -0
- package/src/carousel/__tests__/CarouselEditor.test.tsx +235 -0
- package/src/crop/CanvasCropOverlay.tsx +193 -0
- package/src/crop/__tests__/crop-math.test.ts +174 -0
- package/src/crop/crop-math.ts +125 -0
- package/src/gestures/helpers/__tests__/element-transform.test.ts +30 -0
- package/src/gestures/helpers/drag.ts +24 -0
- package/src/gestures/helpers/element-transform.ts +15 -0
- package/src/gestures/helpers/resize.ts +60 -0
- package/src/gestures/helpers/rotate.ts +44 -0
- package/src/gestures/helpers/snap.ts +64 -0
- package/src/gestures/hooks/useOverlayDrag.ts +106 -0
- package/src/gestures/hooks/useOverlayResize.ts +67 -0
- package/src/gestures/hooks/useOverlayRotate.ts +64 -0
- package/src/gestures/index.ts +16 -0
- package/src/index.ts +112 -0
- package/src/overlays/contract.ts +41 -0
- package/src/preview/OverlayPreview.tsx +196 -0
- package/src/preview/__tests__/OverlayPreview.test.tsx +169 -0
- package/src/schema.ts +194 -0
- package/src/state/__tests__/project-reducer.test.ts +957 -0
- package/src/state/__tests__/use-project-state.test.tsx +258 -0
- package/src/state/mutation-queue.ts +62 -0
- package/src/state/project-reducer.ts +328 -0
- package/src/state/use-project-state.ts +442 -0
- package/src/test-setup.ts +1 -0
- package/src/text/FontPicker.tsx +218 -0
- package/src/text/InlineTextEditor.tsx +92 -0
- package/src/text/TextFormattingToolbar.tsx +248 -0
- package/src/text/__tests__/InlineTextEditor.test.tsx +139 -0
- package/src/text/__tests__/TextFormattingToolbar.test.tsx +416 -0
- package/src/theme.ts +93 -0
- package/src/types.ts +325 -0
- package/src/ui/__tests__/button.test.tsx +17 -0
- package/src/ui/badge.tsx +32 -0
- package/src/ui/button.tsx +32 -0
- package/src/ui/index.ts +16 -0
- package/src/ui/input.tsx +15 -0
- package/src/ui/label.tsx +10 -0
- package/src/ui/select.tsx +23 -0
- package/src/ui/switch.tsx +31 -0
- package/src/ui/textarea.tsx +15 -0
- package/src/ui/utils.ts +7 -0
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* editor-core / state / use-project-state — optimistic, host-agnostic project
|
|
3
|
+
* state with undo/redo and SSE reconciliation.
|
|
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)`
|
|
12
|
+
*
|
|
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.
|
|
16
|
+
*/
|
|
17
|
+
import { useEffect, useReducer, useRef, useState, useCallback } from 'react'
|
|
18
|
+
import { projectReducer, type Action, type ProjectStatus } from './project-reducer'
|
|
19
|
+
import { createMutationQueue } from './mutation-queue'
|
|
20
|
+
import type { Project, Slide, CarouselElement, EditorAdapter } from '../types'
|
|
21
|
+
|
|
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'
|
|
26
|
+
|
|
27
|
+
function isEditable(status: ProjectStatus): boolean {
|
|
28
|
+
return status === 'draft' || status === 'final'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function findElementType(
|
|
32
|
+
state: Project,
|
|
33
|
+
slideId: string,
|
|
34
|
+
elementId: string,
|
|
35
|
+
): 'overlay' | 'image' | null {
|
|
36
|
+
const slide: Slide | undefined = state.slides?.find((s) => s.id === slideId)
|
|
37
|
+
const el = slide?.elements.find((e) => e.id === elementId)
|
|
38
|
+
return el?.type ?? null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface UseProjectState<P extends Project = Project> {
|
|
42
|
+
project: P
|
|
43
|
+
connection: Connection
|
|
44
|
+
isEditingAllowed: boolean
|
|
45
|
+
lastError: string | null
|
|
46
|
+
clearError: () => void
|
|
47
|
+
updateOverlayProp: (slideId: string, elementId: string, key: string, value: string) => Promise<void>
|
|
48
|
+
updateImageCrop: (slideId: string, elementId: string, crop: { x: number; y: number; w: number; h: number } | undefined) => Promise<void>
|
|
49
|
+
setStatus: (status: ProjectStatus) => Promise<void>
|
|
50
|
+
setName: (name: string) => Promise<void>
|
|
51
|
+
moveElement: (slideId: string, elementId: string, x: number, y: number) => Promise<void>
|
|
52
|
+
resizeElement: (slideId: string, elementId: string, box: { x: number; y: number; w: number; h: number }) => Promise<void>
|
|
53
|
+
rotateElement: (slideId: string, elementId: string, rotation: number) => Promise<void>
|
|
54
|
+
addElement: (slideId: string, element: CarouselElement) => Promise<void>
|
|
55
|
+
removeElement: (slideId: string, elementId: string) => Promise<void>
|
|
56
|
+
duplicateElement: (slideId: string, elementId: string, newElement: CarouselElement) => Promise<void>
|
|
57
|
+
reorderElement: (slideId: string, elementId: string, direction: 'forward' | 'backward') => Promise<void>
|
|
58
|
+
addSlide: (slide: Slide, afterSlideId?: string) => Promise<void>
|
|
59
|
+
removeSlide: (slideId: string) => Promise<void>
|
|
60
|
+
duplicateSlide: (slideId: string, newSlide: Slide) => Promise<void>
|
|
61
|
+
reorderSlides: (fromIndex: number, toIndex: number) => Promise<void>
|
|
62
|
+
updateSlide: (slideId: string, patch: Partial<Slide>) => Promise<void>
|
|
63
|
+
setOverlayFrame: (slideId: string, elementId: string, frame: number) => Promise<void>
|
|
64
|
+
commit: () => Promise<void>
|
|
65
|
+
refetch: () => Promise<void>
|
|
66
|
+
undo: () => void
|
|
67
|
+
redo: () => void
|
|
68
|
+
canUndo: boolean
|
|
69
|
+
canRedo: boolean
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function useProjectState<P extends Project = Project>(
|
|
73
|
+
adapter: EditorAdapter<P>,
|
|
74
|
+
projectId: string,
|
|
75
|
+
initial: P,
|
|
76
|
+
): UseProjectState<P> {
|
|
77
|
+
const [project, dispatch] = useReducer(
|
|
78
|
+
projectReducer as (state: P, action: Action<P>) => P,
|
|
79
|
+
initial,
|
|
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
|
+
|
|
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
|
+
)
|
|
160
|
+
|
|
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.
|
|
166
|
+
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).
|
|
171
|
+
const base = projectRef.current
|
|
172
|
+
const editGated = new Set(['updateOverlayProp', 'updateImageCrop', 'setStatus', 'setName', 'moveElement', 'resizeElement', 'rotateElement', 'addElement', 'removeElement', 'addSlide', 'removeSlide', 'duplicateSlide', 'reorderSlides', 'updateSlide', 'duplicateElement', 'reorderElement', 'setOverlayFrame'])
|
|
173
|
+
if (editGated.has(action.type) && !isEditable(base.status)) {
|
|
174
|
+
console.warn(`[useProjectState] dropped ${action.type}: status="${base.status}" not editable`)
|
|
175
|
+
return Promise.resolve()
|
|
176
|
+
}
|
|
177
|
+
if (action.type === 'updateOverlayProp' || action.type === 'updateImageCrop') {
|
|
178
|
+
const found = findElementType(base, action.slideId, action.elementId)
|
|
179
|
+
const expected = action.type === 'updateOverlayProp' ? 'overlay' : 'image'
|
|
180
|
+
if (found !== expected) {
|
|
181
|
+
console.warn(
|
|
182
|
+
`[useProjectState] dropped ${action.type}: target ${action.slideId}/${action.elementId} ${found ? `is ${found}, expected ${expected}` : 'no longer exists'}`,
|
|
183
|
+
)
|
|
184
|
+
return Promise.resolve()
|
|
185
|
+
}
|
|
186
|
+
}
|
|
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
|
+
)
|
|
201
|
+
},
|
|
202
|
+
[save, pushUndo],
|
|
203
|
+
)
|
|
204
|
+
|
|
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.
|
|
208
|
+
const mutateTransient = useCallback(
|
|
209
|
+
(action: Action<P>) => {
|
|
210
|
+
const base = projectRef.current
|
|
211
|
+
const editGated = new Set(['moveElement', 'resizeElement', 'rotateElement'])
|
|
212
|
+
if (!editGated.has(action.type) || !isEditable(base.status)) {
|
|
213
|
+
console.warn(`[useProjectState] dropped transient ${action.type}: status="${base.status}" not editable`)
|
|
214
|
+
return
|
|
215
|
+
}
|
|
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)
|
|
223
|
+
},
|
|
224
|
+
[],
|
|
225
|
+
)
|
|
226
|
+
|
|
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
|
+
const updateOverlayProp = useCallback(
|
|
291
|
+
(slideId: string, elementId: string, key: string, value: string) =>
|
|
292
|
+
mutate({ type: 'updateOverlayProp', slideId, elementId, key, value }),
|
|
293
|
+
[mutate],
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
const updateImageCrop = useCallback(
|
|
297
|
+
(slideId: string, elementId: string, crop: { x: number; y: number; w: number; h: number } | undefined) =>
|
|
298
|
+
mutate({ type: 'updateImageCrop', slideId, elementId, crop }),
|
|
299
|
+
[mutate],
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
const setStatus = useCallback(
|
|
303
|
+
(status: ProjectStatus) =>
|
|
304
|
+
mutate({ type: 'setStatus', status }),
|
|
305
|
+
[mutate],
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
const setName = useCallback(
|
|
309
|
+
(name: string) =>
|
|
310
|
+
mutate({ type: 'setName', name }),
|
|
311
|
+
[mutate],
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
const moveElement = useCallback(
|
|
315
|
+
(slideId: string, elementId: string, x: number, y: number): Promise<void> => {
|
|
316
|
+
mutateTransient({ type: 'moveElement', slideId, elementId, x, y })
|
|
317
|
+
return Promise.resolve()
|
|
318
|
+
},
|
|
319
|
+
[mutateTransient],
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
const resizeElement = useCallback(
|
|
323
|
+
(slideId: string, elementId: string, box: { x: number; y: number; w: number; h: number }): Promise<void> => {
|
|
324
|
+
mutateTransient({ type: 'resizeElement', slideId, elementId, ...box })
|
|
325
|
+
return Promise.resolve()
|
|
326
|
+
},
|
|
327
|
+
[mutateTransient],
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
const rotateElement = useCallback(
|
|
331
|
+
(slideId: string, elementId: string, rotation: number): Promise<void> => {
|
|
332
|
+
mutateTransient({ type: 'rotateElement', slideId, elementId, rotation })
|
|
333
|
+
return Promise.resolve()
|
|
334
|
+
},
|
|
335
|
+
[mutateTransient],
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
const addElement = useCallback(
|
|
339
|
+
(slideId: string, element: CarouselElement) =>
|
|
340
|
+
mutate({ type: 'addElement', slideId, element }),
|
|
341
|
+
[mutate],
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
const removeElement = useCallback(
|
|
345
|
+
(slideId: string, elementId: string) =>
|
|
346
|
+
mutate({ type: 'removeElement', slideId, elementId }),
|
|
347
|
+
[mutate],
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
const duplicateElement = useCallback(
|
|
351
|
+
(slideId: string, elementId: string, newElement: CarouselElement) =>
|
|
352
|
+
mutate({ type: 'duplicateElement', slideId, elementId, newElement }),
|
|
353
|
+
[mutate],
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
const reorderElement = useCallback(
|
|
357
|
+
(slideId: string, elementId: string, direction: 'forward' | 'backward') =>
|
|
358
|
+
mutate({ type: 'reorderElement', slideId, elementId, direction }),
|
|
359
|
+
[mutate],
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
const addSlide = useCallback(
|
|
363
|
+
(slide: Slide, afterSlideId?: string) =>
|
|
364
|
+
mutate({ type: 'addSlide', slide, afterSlideId }),
|
|
365
|
+
[mutate],
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
const removeSlide = useCallback(
|
|
369
|
+
(slideId: string) =>
|
|
370
|
+
mutate({ type: 'removeSlide', slideId }),
|
|
371
|
+
[mutate],
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
const duplicateSlide = useCallback(
|
|
375
|
+
(slideId: string, newSlide: Slide) =>
|
|
376
|
+
mutate({ type: 'duplicateSlide', slideId, newSlide }),
|
|
377
|
+
[mutate],
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
const reorderSlides = useCallback(
|
|
381
|
+
(fromIndex: number, toIndex: number) =>
|
|
382
|
+
mutate({ type: 'reorderSlides', fromIndex, toIndex }),
|
|
383
|
+
[mutate],
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
const updateSlide = useCallback(
|
|
387
|
+
(slideId: string, patch: Partial<Slide>) =>
|
|
388
|
+
mutate({ type: 'updateSlide', slideId, patch }),
|
|
389
|
+
[mutate],
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
const setOverlayFrame = useCallback(
|
|
393
|
+
(slideId: string, elementId: string, frame: number) =>
|
|
394
|
+
mutate({ type: 'setOverlayFrame', slideId, elementId, frame }),
|
|
395
|
+
[mutate],
|
|
396
|
+
)
|
|
397
|
+
|
|
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)
|
|
411
|
+
|
|
412
|
+
return {
|
|
413
|
+
project,
|
|
414
|
+
connection,
|
|
415
|
+
isEditingAllowed,
|
|
416
|
+
lastError,
|
|
417
|
+
clearError,
|
|
418
|
+
updateOverlayProp,
|
|
419
|
+
updateImageCrop,
|
|
420
|
+
setStatus,
|
|
421
|
+
setName,
|
|
422
|
+
moveElement,
|
|
423
|
+
resizeElement,
|
|
424
|
+
rotateElement,
|
|
425
|
+
addElement,
|
|
426
|
+
removeElement,
|
|
427
|
+
duplicateElement,
|
|
428
|
+
reorderElement,
|
|
429
|
+
addSlide,
|
|
430
|
+
removeSlide,
|
|
431
|
+
duplicateSlide,
|
|
432
|
+
reorderSlides,
|
|
433
|
+
updateSlide,
|
|
434
|
+
setOverlayFrame,
|
|
435
|
+
commit,
|
|
436
|
+
refetch,
|
|
437
|
+
undo,
|
|
438
|
+
redo,
|
|
439
|
+
canUndo,
|
|
440
|
+
canRedo,
|
|
441
|
+
}
|
|
442
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import '@testing-library/jest-dom'
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { ChevronDown } from 'lucide-react'
|
|
3
|
+
|
|
4
|
+
export type FontOption = {
|
|
5
|
+
label: string
|
|
6
|
+
value: string
|
|
7
|
+
isGoogleFont: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const FONT_OPTIONS: FontOption[] = [
|
|
11
|
+
{ label: 'System', value: 'system-ui, -apple-system, "Helvetica Neue", sans-serif', isGoogleFont: false },
|
|
12
|
+
{ label: 'Inter', value: '"Inter", system-ui, sans-serif', isGoogleFont: true },
|
|
13
|
+
{ label: 'Roboto', value: '"Roboto", system-ui, sans-serif', isGoogleFont: true },
|
|
14
|
+
{ label: 'Open Sans', value: '"Open Sans", system-ui, sans-serif', isGoogleFont: true },
|
|
15
|
+
{ label: 'Lato', value: '"Lato", system-ui, sans-serif', isGoogleFont: true },
|
|
16
|
+
{ label: 'Montserrat', value: '"Montserrat", system-ui, sans-serif', isGoogleFont: true },
|
|
17
|
+
{ label: 'Poppins', value: '"Poppins", system-ui, sans-serif', isGoogleFont: true },
|
|
18
|
+
{ label: 'Raleway', value: '"Raleway", system-ui, sans-serif', isGoogleFont: true },
|
|
19
|
+
{ label: 'Nunito', value: '"Nunito", system-ui, sans-serif', isGoogleFont: true },
|
|
20
|
+
{ label: 'Work Sans', value: '"Work Sans", system-ui, sans-serif', isGoogleFont: true },
|
|
21
|
+
{ label: 'DM Sans', value: '"DM Sans", system-ui, sans-serif', isGoogleFont: true },
|
|
22
|
+
{ label: 'Rubik', value: '"Rubik", system-ui, sans-serif', isGoogleFont: true },
|
|
23
|
+
{ label: 'Oswald', value: '"Oswald", system-ui, sans-serif', isGoogleFont: true },
|
|
24
|
+
{ label: 'Bebas Neue', value: '"Bebas Neue", system-ui, sans-serif', isGoogleFont: true },
|
|
25
|
+
{ label: 'Playfair Display', value: '"Playfair Display", Georgia, serif', isGoogleFont: true },
|
|
26
|
+
{ label: 'Merriweather', value: '"Merriweather", Georgia, serif', isGoogleFont: true },
|
|
27
|
+
{ label: 'Source Serif 4', value: '"Source Serif 4", Georgia, serif', isGoogleFont: true },
|
|
28
|
+
{ label: 'JetBrains Mono', value: '"JetBrains Mono", ui-monospace, monospace', isGoogleFont: true },
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
const GOOGLE_FONTS_URL = (() => {
|
|
32
|
+
const params = FONT_OPTIONS
|
|
33
|
+
.filter((f) => f.isGoogleFont)
|
|
34
|
+
.map((f) => `family=${f.label.replace(/ /g, '+')}:wght@400;700`)
|
|
35
|
+
.join('&')
|
|
36
|
+
return `https://fonts.googleapis.com/css2?${params}&display=swap`
|
|
37
|
+
})()
|
|
38
|
+
|
|
39
|
+
let fontsInjected = false
|
|
40
|
+
function ensureGoogleFontsLoaded(): void {
|
|
41
|
+
if (fontsInjected || typeof document === 'undefined') return
|
|
42
|
+
fontsInjected = true
|
|
43
|
+
const link = document.createElement('link')
|
|
44
|
+
link.rel = 'stylesheet'
|
|
45
|
+
link.href = GOOGLE_FONTS_URL
|
|
46
|
+
document.head.appendChild(link)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function firstFontToken(value: string): string {
|
|
50
|
+
return value.split(',')[0].trim().replace(/^["']|["']$/g, '').toLowerCase()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function findFontOption(cssValue: string): FontOption | null {
|
|
54
|
+
if (!cssValue) return null
|
|
55
|
+
const key = firstFontToken(cssValue)
|
|
56
|
+
return FONT_OPTIONS.find((opt) => firstFontToken(opt.value) === key) ?? null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type FontFamilyPickerProps = {
|
|
60
|
+
value: string
|
|
61
|
+
onChange: (value: string) => void
|
|
62
|
+
disabled?: boolean
|
|
63
|
+
className?: string
|
|
64
|
+
buttonClassName?: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function FontFamilyPicker({ value, onChange, disabled, className, buttonClassName }: FontFamilyPickerProps) {
|
|
68
|
+
const [open, setOpen] = useState(false)
|
|
69
|
+
const wrapperRef = useRef<HTMLDivElement | null>(null)
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
ensureGoogleFontsLoaded()
|
|
73
|
+
}, [])
|
|
74
|
+
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (!open) return
|
|
77
|
+
function onDocDown(e: MouseEvent) {
|
|
78
|
+
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
|
|
79
|
+
setOpen(false)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
document.addEventListener('mousedown', onDocDown)
|
|
83
|
+
return () => document.removeEventListener('mousedown', onDocDown)
|
|
84
|
+
}, [open])
|
|
85
|
+
|
|
86
|
+
const current = findFontOption(value)
|
|
87
|
+
const displayLabel = current?.label ?? (value ? 'Custom' : 'Default')
|
|
88
|
+
const displayStyle = current ? { fontFamily: current.value } : undefined
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<div ref={wrapperRef} className={`relative ${className ?? ''}`}>
|
|
92
|
+
<button
|
|
93
|
+
type="button"
|
|
94
|
+
disabled={disabled}
|
|
95
|
+
onClick={() => setOpen((o) => !o)}
|
|
96
|
+
className={
|
|
97
|
+
buttonClassName ??
|
|
98
|
+
'flex w-full items-center gap-1 rounded-md border border-gray-600 bg-gray-800 px-2 py-1 text-sm text-gray-100 hover:bg-gray-700 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50'
|
|
99
|
+
}
|
|
100
|
+
style={displayStyle}
|
|
101
|
+
aria-haspopup="listbox"
|
|
102
|
+
aria-expanded={open}
|
|
103
|
+
aria-label="Font family"
|
|
104
|
+
title={value || 'Default font'}
|
|
105
|
+
>
|
|
106
|
+
<span className="flex-1 truncate text-left">{displayLabel}</span>
|
|
107
|
+
<ChevronDown className="h-3 w-3 shrink-0 opacity-70" />
|
|
108
|
+
</button>
|
|
109
|
+
{open && (
|
|
110
|
+
<div
|
|
111
|
+
role="listbox"
|
|
112
|
+
className="absolute right-0 z-50 mt-1 max-h-80 w-60 overflow-y-auto rounded-md border border-gray-600 bg-gray-800 shadow-xl ring-1 ring-black/20"
|
|
113
|
+
>
|
|
114
|
+
<ul className="py-1">
|
|
115
|
+
{FONT_OPTIONS.map((opt) => {
|
|
116
|
+
const isActive = opt.value === current?.value
|
|
117
|
+
return (
|
|
118
|
+
<li key={opt.label}>
|
|
119
|
+
<button
|
|
120
|
+
type="button"
|
|
121
|
+
role="option"
|
|
122
|
+
aria-selected={isActive}
|
|
123
|
+
onClick={() => {
|
|
124
|
+
onChange(opt.value)
|
|
125
|
+
setOpen(false)
|
|
126
|
+
}}
|
|
127
|
+
style={{ fontFamily: opt.value }}
|
|
128
|
+
className={`flex w-full items-center justify-between px-3 py-2 text-left text-[15px] leading-tight text-gray-100 hover:bg-gray-700 focus:bg-gray-700 focus:outline-none ${
|
|
129
|
+
isActive ? 'bg-gray-700 font-medium' : ''
|
|
130
|
+
}`}
|
|
131
|
+
>
|
|
132
|
+
<span className="truncate">{opt.label}</span>
|
|
133
|
+
{isActive && (
|
|
134
|
+
<span
|
|
135
|
+
className="ml-2 shrink-0 text-xs text-gray-400"
|
|
136
|
+
style={{ fontFamily: 'system-ui, sans-serif' }}
|
|
137
|
+
>
|
|
138
|
+
✓
|
|
139
|
+
</span>
|
|
140
|
+
)}
|
|
141
|
+
</button>
|
|
142
|
+
</li>
|
|
143
|
+
)
|
|
144
|
+
})}
|
|
145
|
+
</ul>
|
|
146
|
+
</div>
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
type FontSizePickerProps = {
|
|
153
|
+
value: string
|
|
154
|
+
onChange: (value: string) => void
|
|
155
|
+
disabled?: boolean
|
|
156
|
+
min?: number
|
|
157
|
+
max?: number
|
|
158
|
+
className?: string
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Extract the numeric portion of a stored fontSize. Tolerates "25", "25px",
|
|
162
|
+
// " 25px ", "25 px", or a bare number. Returns '' when no numeric prefix is
|
|
163
|
+
// present so the <input type="number"> can render empty instead of NaN-empty.
|
|
164
|
+
function parseFontSizeNumeric(value: string): string {
|
|
165
|
+
const m = /^\s*(-?\d+(?:\.\d+)?)/.exec(value)
|
|
166
|
+
return m ? m[1] : ''
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function FontSizePicker({ value, onChange, disabled, min = 8, max = 9999, className }: FontSizePickerProps) {
|
|
170
|
+
const [local, setLocal] = useState(parseFontSizeNumeric(value))
|
|
171
|
+
const isFocused = useRef(false)
|
|
172
|
+
const onChangeRef = useRef(onChange)
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
onChangeRef.current = onChange
|
|
175
|
+
}, [onChange])
|
|
176
|
+
|
|
177
|
+
useEffect(() => {
|
|
178
|
+
if (!isFocused.current) setLocal(parseFontSizeNumeric(value))
|
|
179
|
+
}, [value])
|
|
180
|
+
|
|
181
|
+
// Commit immediately on every keystroke. Debouncing here is the wrong call
|
|
182
|
+
// for typography — the rendered text is the live preview, so delaying the
|
|
183
|
+
// PUT delays user feedback.
|
|
184
|
+
//
|
|
185
|
+
// CSS font-size requires a unit; a unitless value is invalid and the
|
|
186
|
+
// overlay template falls back to its default. The picker is number-only
|
|
187
|
+
// for the operator, so we attach `px` here before writing — never asking
|
|
188
|
+
// the user to type the unit themselves.
|
|
189
|
+
function commit(next: string): void {
|
|
190
|
+
setLocal(next)
|
|
191
|
+
if (next === '') return
|
|
192
|
+
const outgoing = `${next}px`
|
|
193
|
+
if (outgoing !== value) onChangeRef.current(outgoing)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return (
|
|
197
|
+
<input
|
|
198
|
+
type="number"
|
|
199
|
+
min={min}
|
|
200
|
+
max={max}
|
|
201
|
+
disabled={disabled}
|
|
202
|
+
value={local}
|
|
203
|
+
placeholder="size"
|
|
204
|
+
onChange={(e) => commit(e.target.value)}
|
|
205
|
+
onFocus={() => {
|
|
206
|
+
isFocused.current = true
|
|
207
|
+
}}
|
|
208
|
+
onBlur={() => {
|
|
209
|
+
isFocused.current = false
|
|
210
|
+
}}
|
|
211
|
+
className={
|
|
212
|
+
className ??
|
|
213
|
+
'w-14 rounded-md border border-gray-600 bg-gray-800 px-2 py-1 text-sm text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50'
|
|
214
|
+
}
|
|
215
|
+
aria-label="Font size"
|
|
216
|
+
/>
|
|
217
|
+
)
|
|
218
|
+
}
|