@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.
Files changed (54) hide show
  1. package/README.md +165 -0
  2. package/package.json +46 -0
  3. package/src/__tests__/adapter-contract.test.ts +123 -0
  4. package/src/__tests__/adapter.test.ts +185 -0
  5. package/src/__tests__/schema.test.ts +104 -0
  6. package/src/carousel/AddElementMenu.tsx +211 -0
  7. package/src/carousel/CarouselEditor.tsx +529 -0
  8. package/src/carousel/CarouselRenderModal.tsx +243 -0
  9. package/src/carousel/OverlayErrorBoundary.tsx +99 -0
  10. package/src/carousel/OverlayPicker.tsx +145 -0
  11. package/src/carousel/SlideCanvas.tsx +588 -0
  12. package/src/carousel/SlidePropertyPanel.tsx +349 -0
  13. package/src/carousel/__tests__/CarouselEditor.test.tsx +235 -0
  14. package/src/crop/CanvasCropOverlay.tsx +193 -0
  15. package/src/crop/__tests__/crop-math.test.ts +174 -0
  16. package/src/crop/crop-math.ts +125 -0
  17. package/src/gestures/helpers/__tests__/element-transform.test.ts +30 -0
  18. package/src/gestures/helpers/drag.ts +24 -0
  19. package/src/gestures/helpers/element-transform.ts +15 -0
  20. package/src/gestures/helpers/resize.ts +60 -0
  21. package/src/gestures/helpers/rotate.ts +44 -0
  22. package/src/gestures/helpers/snap.ts +64 -0
  23. package/src/gestures/hooks/useOverlayDrag.ts +106 -0
  24. package/src/gestures/hooks/useOverlayResize.ts +67 -0
  25. package/src/gestures/hooks/useOverlayRotate.ts +64 -0
  26. package/src/gestures/index.ts +16 -0
  27. package/src/index.ts +112 -0
  28. package/src/overlays/contract.ts +41 -0
  29. package/src/preview/OverlayPreview.tsx +196 -0
  30. package/src/preview/__tests__/OverlayPreview.test.tsx +169 -0
  31. package/src/schema.ts +194 -0
  32. package/src/state/__tests__/project-reducer.test.ts +957 -0
  33. package/src/state/__tests__/use-project-state.test.tsx +258 -0
  34. package/src/state/mutation-queue.ts +62 -0
  35. package/src/state/project-reducer.ts +328 -0
  36. package/src/state/use-project-state.ts +442 -0
  37. package/src/test-setup.ts +1 -0
  38. package/src/text/FontPicker.tsx +218 -0
  39. package/src/text/InlineTextEditor.tsx +92 -0
  40. package/src/text/TextFormattingToolbar.tsx +248 -0
  41. package/src/text/__tests__/InlineTextEditor.test.tsx +139 -0
  42. package/src/text/__tests__/TextFormattingToolbar.test.tsx +416 -0
  43. package/src/theme.ts +93 -0
  44. package/src/types.ts +325 -0
  45. package/src/ui/__tests__/button.test.tsx +17 -0
  46. package/src/ui/badge.tsx +32 -0
  47. package/src/ui/button.tsx +32 -0
  48. package/src/ui/index.ts +16 -0
  49. package/src/ui/input.tsx +15 -0
  50. package/src/ui/label.tsx +10 -0
  51. package/src/ui/select.tsx +23 -0
  52. package/src/ui/switch.tsx +31 -0
  53. package/src/ui/textarea.tsx +15 -0
  54. 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
+ }