@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,588 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import type { Slide, OverlayElement, ImageElement, OverlayFactory } from '../types'
3
+ import OverlayErrorBoundary from './OverlayErrorBoundary'
4
+ import {
5
+ useOverlayDrag,
6
+ useOverlayResize,
7
+ useOverlayRotate,
8
+ buildElementTransform,
9
+ type ResizeHandle as ResizeHandleId,
10
+ type SnapGuide,
11
+ } from '../gestures'
12
+ import { OverlayPreview } from '../preview/OverlayPreview'
13
+ import { CanvasCropOverlay } from '../crop/CanvasCropOverlay'
14
+ import { InlineTextEditor } from '../text/InlineTextEditor'
15
+
16
+ // Neutral fallback used only when no host `resolveImageSrc` is injected. The
17
+ // package must not synthesize a host-shaped URL (e.g. Montaj's `/api/files`):
18
+ // it returns the `src` unchanged so a host without a resolver gets a passthrough
19
+ // rather than a URL it can't serve. Hosts that need resolution always inject
20
+ // `resolveImageSrc` (Montaj's adapter does).
21
+ function resolveAssetDefault(src: string): string {
22
+ return src
23
+ }
24
+
25
+ // ── Resize / Rotate handle geometry ────────────────────────────────────────────
26
+
27
+ const HANDLE_SIZE = 8
28
+ const ROTATE_OFFSET = 20
29
+
30
+ const HANDLES: { id: ResizeHandleId; cursor: string; xPct: number; yPct: number }[] = [
31
+ { id: 'nw', cursor: 'nwse-resize', xPct: 0, yPct: 0 },
32
+ { id: 'n', cursor: 'ns-resize', xPct: 0.5, yPct: 0 },
33
+ { id: 'ne', cursor: 'nesw-resize', xPct: 1, yPct: 0 },
34
+ { id: 'e', cursor: 'ew-resize', xPct: 1, yPct: 0.5 },
35
+ { id: 'se', cursor: 'nwse-resize', xPct: 1, yPct: 1 },
36
+ { id: 's', cursor: 'ns-resize', xPct: 0.5, yPct: 1 },
37
+ { id: 'sw', cursor: 'nesw-resize', xPct: 0, yPct: 1 },
38
+ { id: 'w', cursor: 'ew-resize', xPct: 0, yPct: 0.5 },
39
+ ]
40
+
41
+ // ── OverlayElementView — shared OverlayPreview wrapper ─────────────────────────
42
+
43
+ // Fallback compiler used when no compiler is injected (e.g. thumbnail previews
44
+ // that don't need live overlay rendering). Always rejects so OverlayPreview
45
+ // shows its errorState rather than a spinner that never resolves.
46
+ const noopCompiler = (): Promise<OverlayFactory> =>
47
+ Promise.reject(new Error('No overlay compiler provided'))
48
+
49
+ function OverlayElementView({
50
+ element,
51
+ compileOverlay,
52
+ }: {
53
+ element: OverlayElement
54
+ compileOverlay?: (template: string) => Promise<OverlayFactory>
55
+ }) {
56
+ const duration = (element.overlay.props.duration as number | undefined) ?? 60
57
+ const mergedProps = { ...element.overlay.props, offsetX: 0, offsetY: 0, scale: 1 }
58
+ return (
59
+ <OverlayPreview
60
+ compileOverlay={compileOverlay ?? noopCompiler}
61
+ template={element.overlay.template}
62
+ props={mergedProps}
63
+ frame={element.frame}
64
+ fps={30}
65
+ duration={duration}
66
+ />
67
+ )
68
+ }
69
+
70
+ // ── SlideCanvas ───────────────────────────────────────────────────────────────
71
+
72
+ type CropMode = { elementId: string; localCrop: { x: number; y: number; w: number; h: number } } | null
73
+
74
+ interface Props {
75
+ slide: Slide
76
+ slideId?: string
77
+ width: number
78
+ height: number
79
+ interactive?: boolean
80
+ selectedElementId?: string | null
81
+ onSelect?: (id: string | null) => void
82
+ scale?: number
83
+ resolveImageSrc?: (element: ImageElement) => string
84
+ /**
85
+ * Host-supplied overlay compiler. Injected from the adapter so SlideCanvas
86
+ * (and OverlayPreview inside it) never import '@/lib/overlay-eval' directly.
87
+ * When absent, overlay elements render nothing (thumbnail / read-only paths).
88
+ */
89
+ compileOverlay?: (template: string) => Promise<OverlayFactory>
90
+
91
+ /**
92
+ * Host-supplied file watcher. Threaded into OverlayErrorBoundary so editing an
93
+ * overlay's source on disk auto-recovers its preview. When absent, no watch is
94
+ * opened — the package never reaches for a host transport directly.
95
+ */
96
+ watchFile?: (path: string, onChange: () => void) => () => void
97
+
98
+ // Project-state mutators (editor-core). Required for the interactive path.
99
+ moveElement?: (slideId: string, elementId: string, x: number, y: number) => Promise<void>
100
+ resizeElement?: (slideId: string, elementId: string, box: { x: number; y: number; w: number; h: number }) => Promise<void>
101
+ rotateElement?: (slideId: string, elementId: string, rotation: number) => Promise<void>
102
+ commit?: () => Promise<void>
103
+ updateOverlayProp?: (slideId: string, elementId: string, key: string, value: string) => Promise<void>
104
+ updateImageCrop?: (slideId: string, elementId: string, crop: { x: number; y: number; w: number; h: number } | undefined) => Promise<void>
105
+
106
+ // Crop mode is owned here but the entry trigger lives in the property panel.
107
+ cropElementId?: string | null
108
+ onExitCrop?: () => void
109
+ }
110
+
111
+ export default function SlideCanvas({
112
+ slide,
113
+ slideId,
114
+ width,
115
+ height,
116
+ interactive = false,
117
+ selectedElementId,
118
+ onSelect,
119
+ scale = 1,
120
+ resolveImageSrc,
121
+ compileOverlay,
122
+ watchFile,
123
+ moveElement,
124
+ resizeElement,
125
+ rotateElement,
126
+ commit,
127
+ updateOverlayProp,
128
+ updateImageCrop,
129
+ cropElementId,
130
+ onExitCrop,
131
+ }: Props) {
132
+ const sid = slideId ?? slide.id
133
+ const resolveSrc = resolveImageSrc ?? ((el: ImageElement) => resolveAssetDefault(el.src))
134
+
135
+ // Refs to each element wrapper so gesture previews can mutate DOM directly.
136
+ const wrapperRefs = useRef<Map<string, HTMLDivElement>>(new Map())
137
+ const setWrapperRef = (id: string) => (el: HTMLDivElement | null) => {
138
+ if (el) wrapperRefs.current.set(id, el)
139
+ else wrapperRefs.current.delete(id)
140
+ }
141
+
142
+ // Inline text edit state.
143
+ const [editingId, setEditingId] = useState<string | null>(null)
144
+ const [editRect, setEditRect] = useState<{ left: number; top: number; width: number; height: number } | null>(null)
145
+
146
+ // Crop mode local state — source fraction window + loaded natural dims.
147
+ const [cropState, setCropState] = useState<CropMode>(null)
148
+ const [cropSrcDims, setCropSrcDims] = useState<{ width: number; height: number } | undefined>(undefined)
149
+
150
+ // ── Gesture preview helper: write transform/size directly to the wrapper ──
151
+ const applyPreviewTransform = (id: string, x: number, y: number, rotation: number) => {
152
+ const el = wrapperRefs.current.get(id)
153
+ if (el) el.style.transform = buildElementTransform(x, y, scale, rotation)
154
+ }
155
+ const applyPreviewBox = (
156
+ id: string,
157
+ box: { x: number; y: number; w: number; h: number },
158
+ rotation: number,
159
+ ) => {
160
+ const el = wrapperRefs.current.get(id)
161
+ if (!el) return
162
+ el.style.transform = buildElementTransform(box.x, box.y, scale, rotation)
163
+ el.style.width = `${box.w * scale}px`
164
+ el.style.height = `${box.h * scale}px`
165
+ }
166
+
167
+ // ── Gesture hooks ──
168
+ const drag = useOverlayDrag({
169
+ scale,
170
+ slide: { w: width, h: height },
171
+ onPreview: applyPreviewTransform,
172
+ onCommit: async (id, x, y) => {
173
+ await moveElement?.(sid, id, Math.round(x), Math.round(y))
174
+ await commit?.()
175
+ },
176
+ })
177
+
178
+ const resize = useOverlayResize({
179
+ scale,
180
+ onPreview: applyPreviewBox,
181
+ onCommit: async (id, box) => {
182
+ await resizeElement?.(sid, id, {
183
+ x: Math.round(box.x),
184
+ y: Math.round(box.y),
185
+ w: Math.round(box.w),
186
+ h: Math.round(box.h),
187
+ })
188
+ await commit?.()
189
+ },
190
+ })
191
+
192
+ const rotate = useOverlayRotate({
193
+ onPreview: (id, rotation, x, y) => applyPreviewTransform(id, x, y, rotation),
194
+ onCommit: async (id, rotation) => {
195
+ await rotateElement?.(sid, id, Math.round(rotation))
196
+ await commit?.()
197
+ },
198
+ })
199
+
200
+ // ── Enter / exit crop mode when cropElementId changes ──
201
+ useEffect(() => {
202
+ if (!cropElementId) {
203
+ setCropState(null)
204
+ setCropSrcDims(undefined)
205
+ return
206
+ }
207
+ const el = slide.elements.find((e) => e.id === cropElementId)
208
+ if (!el || el.type !== 'image') {
209
+ setCropState(null)
210
+ return
211
+ }
212
+ setCropState({ elementId: cropElementId, localCrop: el.crop ?? { x: 0, y: 0, w: 1, h: 1 } })
213
+ setCropSrcDims(undefined)
214
+ }, [cropElementId, slide.elements])
215
+
216
+ // Commit crop (also resizes the element box to match the crop's aspect) and exit.
217
+ const commitCrop = async () => {
218
+ const cs = cropState
219
+ if (!cs) return
220
+ const el = slide.elements.find((e) => e.id === cs.elementId)
221
+ if (el && el.type === 'image' && cropSrcDims) {
222
+ // Resize the element box so the cropped pixel aspect matches the box aspect.
223
+ // The crop window is a sub-rect of the source in fractions; convert its
224
+ // pixel aspect into a new box height for the current box width.
225
+ const croppedAspect =
226
+ (cs.localCrop.w * cropSrcDims.width) / (cs.localCrop.h * cropSrcDims.height)
227
+ if (Number.isFinite(croppedAspect) && croppedAspect > 0) {
228
+ const newH = Math.round(el.w / croppedAspect)
229
+ await resizeElement?.(sid, cs.elementId, { x: el.x, y: el.y, w: el.w, h: newH })
230
+ }
231
+ await updateImageCrop?.(sid, cs.elementId, cs.localCrop)
232
+ await commit?.()
233
+ }
234
+ onExitCrop?.()
235
+ }
236
+
237
+ // Escape / click-outside commits the crop.
238
+ useEffect(() => {
239
+ if (!cropState) return
240
+ const onKey = (e: KeyboardEvent) => {
241
+ if (e.key === 'Escape') {
242
+ e.preventDefault()
243
+ void commitCrop()
244
+ }
245
+ }
246
+ window.addEventListener('keydown', onKey)
247
+ return () => window.removeEventListener('keydown', onKey)
248
+ }, [cropState, cropSrcDims])
249
+
250
+ // ── Inline text edit ──
251
+ function beginTextEdit(element: OverlayElement) {
252
+ if (!interactive || typeof element.overlay.props.text !== 'string') return
253
+ setEditRect({
254
+ left: element.x * scale,
255
+ top: element.y * scale,
256
+ width: element.w * scale,
257
+ height: element.h * scale,
258
+ })
259
+ setEditingId(element.id)
260
+ }
261
+
262
+ function commitTextEdit(element: OverlayElement, value: string) {
263
+ void updateOverlayProp?.(sid, element.id, 'text', value)
264
+ setEditingId(null)
265
+ setEditRect(null)
266
+ }
267
+
268
+ // ── Pointer wiring shared by drag/resize/rotate ──
269
+ // We attach window-level move/up listeners while a gesture is active so the
270
+ // gesture keeps tracking outside the element bounds.
271
+ const activeGesture = useRef<'drag' | 'resize' | 'rotate' | null>(null)
272
+ useEffect(() => {
273
+ const onMove = (e: PointerEvent) => {
274
+ const cursor = { x: e.clientX, y: e.clientY }
275
+ if (activeGesture.current === 'drag') drag.onPointerMove(cursor)
276
+ else if (activeGesture.current === 'resize') resize.onPointerMove(cursor)
277
+ else if (activeGesture.current === 'rotate') rotate.onPointerMove(cursor)
278
+ }
279
+ const onUp = () => {
280
+ if (activeGesture.current === 'drag') drag.onPointerUp()
281
+ else if (activeGesture.current === 'resize') resize.onPointerUp()
282
+ else if (activeGesture.current === 'rotate') rotate.onPointerUp()
283
+ activeGesture.current = null
284
+ }
285
+ window.addEventListener('pointermove', onMove)
286
+ window.addEventListener('pointerup', onUp)
287
+ return () => {
288
+ window.removeEventListener('pointermove', onMove)
289
+ window.removeEventListener('pointerup', onUp)
290
+ }
291
+ }, [drag, resize, rotate])
292
+
293
+ const displayW = width * scale
294
+ const displayH = height * scale
295
+
296
+ return (
297
+ <div
298
+ style={{
299
+ width: displayW,
300
+ height: displayH,
301
+ overflow: 'hidden',
302
+ position: 'relative',
303
+ flexShrink: 0,
304
+ }}
305
+ onClick={interactive ? () => onSelect?.(null) : undefined}
306
+ >
307
+ {/* Inner native-resolution layer. Elements are positioned at native coords
308
+ and the wrapper transform applies translate(...*scale) so gesture
309
+ previews can mutate the same transform string. */}
310
+ <div
311
+ style={{
312
+ width: displayW,
313
+ height: displayH,
314
+ position: 'absolute',
315
+ top: 0,
316
+ left: 0,
317
+ backgroundColor: slide.base_color || '#ffffff',
318
+ }}
319
+ >
320
+ {/* Snap guides — drawn at the drag hook's reported guide axes. */}
321
+ {interactive && drag.guides.map((g: SnapGuide, i) =>
322
+ g.axis === 'x' ? (
323
+ <div
324
+ key={`gx-${i}`}
325
+ data-testid="snap-guide-x"
326
+ style={{
327
+ position: 'absolute',
328
+ left: g.at * scale - 0.5,
329
+ top: 0,
330
+ width: 1,
331
+ height: displayH,
332
+ background: '#ec4899',
333
+ pointerEvents: 'none',
334
+ zIndex: 999,
335
+ }}
336
+ />
337
+ ) : (
338
+ <div
339
+ key={`gy-${i}`}
340
+ data-testid="snap-guide-y"
341
+ style={{
342
+ position: 'absolute',
343
+ left: 0,
344
+ top: g.at * scale - 0.5,
345
+ width: displayW,
346
+ height: 1,
347
+ background: '#ec4899',
348
+ pointerEvents: 'none',
349
+ zIndex: 999,
350
+ }}
351
+ />
352
+ ),
353
+ )}
354
+
355
+ {slide.elements.map((element) => {
356
+ const isSelected = selectedElementId === element.id
357
+ const inCrop = cropState?.elementId === element.id
358
+ const isRotated = (element.rotation ?? 0) !== 0
359
+
360
+ const wrapperStyle: React.CSSProperties = {
361
+ position: 'absolute',
362
+ left: 0,
363
+ top: 0,
364
+ width: element.w * scale,
365
+ height: element.h * scale,
366
+ transform: buildElementTransform(element.x, element.y, scale, element.rotation),
367
+ transformOrigin: 'center center',
368
+ pointerEvents: interactive ? 'auto' : 'none',
369
+ userSelect: 'none',
370
+ outline: isSelected ? '1px solid #3b82f6' : 'none',
371
+ cursor: interactive ? 'grab' : 'default',
372
+ }
373
+
374
+ const handlePointerDownDrag = (e: React.PointerEvent) => {
375
+ if (!interactive || inCrop || editingId === element.id) return
376
+ e.preventDefault()
377
+ e.stopPropagation()
378
+ onSelect?.(element.id)
379
+ activeGesture.current = 'drag'
380
+ drag.onPointerDown(
381
+ element.id,
382
+ { x: e.clientX, y: e.clientY },
383
+ { x: element.x, y: element.y, w: element.w, h: element.h, rotation: element.rotation },
384
+ )
385
+ }
386
+
387
+ const innerContent =
388
+ element.type === 'image' ? (
389
+ <img
390
+ src={resolveSrc(element)}
391
+ draggable={false}
392
+ style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
393
+ alt=""
394
+ />
395
+ ) : (
396
+ <OverlayErrorBoundary
397
+ label={element.overlay.template.split('/').pop() ?? element.overlay.template}
398
+ watchPath={element.overlay.template}
399
+ watchFile={watchFile}
400
+ >
401
+ <OverlayElementView element={element} compileOverlay={compileOverlay} />
402
+ </OverlayErrorBoundary>
403
+ )
404
+
405
+ return (
406
+ <div
407
+ key={element.id}
408
+ ref={setWrapperRef(element.id)}
409
+ data-element-wrapper
410
+ data-element-id={element.id}
411
+ style={wrapperStyle}
412
+ onClick={interactive ? (e) => { e.stopPropagation(); onSelect?.(element.id) } : undefined}
413
+ onPointerDown={handlePointerDownDrag}
414
+ onDoubleClick={
415
+ interactive && element.type === 'overlay'
416
+ ? (e) => { e.stopPropagation(); beginTextEdit(element) }
417
+ : undefined
418
+ }
419
+ >
420
+ {innerContent}
421
+
422
+ {/* In-canvas crop overlay for the image being cropped. */}
423
+ {inCrop && element.type === 'image' && cropState && (
424
+ <CanvasCropOverlay
425
+ element={element}
426
+ resolveImageSrc={resolveSrc}
427
+ scale={scale}
428
+ localCrop={cropState.localCrop}
429
+ onLocalCropChange={(next) =>
430
+ setCropState((cs) => (cs ? { ...cs, localCrop: next } : cs))
431
+ }
432
+ onSrcDimsLoaded={setCropSrcDims}
433
+ srcDims={cropSrcDims}
434
+ />
435
+ )}
436
+
437
+ {/* Selection handles (hidden while cropping or rotated-crop guard). */}
438
+ {isSelected && !inCrop && editingId !== element.id && interactive && (
439
+ <>
440
+ {HANDLES.map((h) => {
441
+ const left = h.xPct * element.w * scale - HANDLE_SIZE / 2
442
+ const top = h.yPct * element.h * scale - HANDLE_SIZE / 2
443
+ return (
444
+ <div
445
+ key={h.id}
446
+ data-testid={`resize-handle-${h.id}`}
447
+ onPointerDown={(e) => {
448
+ e.preventDefault()
449
+ e.stopPropagation()
450
+ activeGesture.current = 'resize'
451
+ resize.onPointerDown(
452
+ element.id,
453
+ h.id,
454
+ { x: e.clientX, y: e.clientY },
455
+ { x: element.x, y: element.y, w: element.w, h: element.h, rotation: element.rotation },
456
+ )
457
+ }}
458
+ style={{
459
+ position: 'absolute',
460
+ left,
461
+ top,
462
+ width: HANDLE_SIZE,
463
+ height: HANDLE_SIZE,
464
+ background: '#3b82f6',
465
+ border: '1px solid #fff',
466
+ borderRadius: 1,
467
+ cursor: h.cursor,
468
+ zIndex: 10,
469
+ boxSizing: 'border-box',
470
+ touchAction: 'none',
471
+ }}
472
+ />
473
+ )
474
+ })}
475
+ {/* Rotate handle */}
476
+ <div
477
+ style={{
478
+ position: 'absolute',
479
+ left: (element.w * scale) / 2 - 0.5,
480
+ top: -ROTATE_OFFSET,
481
+ width: 1,
482
+ height: ROTATE_OFFSET,
483
+ background: '#3b82f6',
484
+ pointerEvents: 'none',
485
+ zIndex: 9,
486
+ }}
487
+ />
488
+ <div
489
+ data-testid="rotate-handle"
490
+ title="Drag to rotate"
491
+ onPointerDown={(e) => {
492
+ e.preventDefault()
493
+ e.stopPropagation()
494
+ const rect = (e.currentTarget as HTMLElement)
495
+ .closest('[data-element-wrapper]')!
496
+ .getBoundingClientRect()
497
+ const center = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
498
+ activeGesture.current = 'rotate'
499
+ rotate.onPointerDown(
500
+ element.id,
501
+ center,
502
+ { x: e.clientX, y: e.clientY },
503
+ element.rotation ?? 0,
504
+ { x: element.x, y: element.y },
505
+ )
506
+ }}
507
+ style={{
508
+ position: 'absolute',
509
+ left: (element.w * scale) / 2 - 7,
510
+ top: -ROTATE_OFFSET - 14,
511
+ width: 14,
512
+ height: 14,
513
+ background: '#3b82f6',
514
+ border: '2px solid #fff',
515
+ borderRadius: '50%',
516
+ cursor: 'crosshair',
517
+ zIndex: 10,
518
+ boxSizing: 'border-box',
519
+ touchAction: 'none',
520
+ }}
521
+ />
522
+ {element.type === 'overlay' && typeof element.overlay.props.text === 'string' && (
523
+ <div
524
+ style={{
525
+ position: 'absolute',
526
+ bottom: -20,
527
+ left: 0,
528
+ fontSize: 10,
529
+ color: '#93c5fd',
530
+ pointerEvents: 'none',
531
+ whiteSpace: 'nowrap',
532
+ }}
533
+ >
534
+ double-click to edit text
535
+ </div>
536
+ )}
537
+ {/* Guard hint: crop disabled while rotated (enforced in panel). */}
538
+ {element.type === 'image' && isRotated && (
539
+ <div
540
+ style={{
541
+ position: 'absolute',
542
+ bottom: -20,
543
+ left: 0,
544
+ fontSize: 10,
545
+ color: '#fca5a5',
546
+ pointerEvents: 'none',
547
+ whiteSpace: 'nowrap',
548
+ }}
549
+ >
550
+ reset rotation to crop
551
+ </div>
552
+ )}
553
+ </>
554
+ )}
555
+ </div>
556
+ )
557
+ })}
558
+
559
+ {/* Inline text editor (positioned in display coords over the element). */}
560
+ {interactive && editingId && editRect && (() => {
561
+ const el = slide.elements.find((e) => e.id === editingId)
562
+ if (!el || el.type !== 'overlay') return null
563
+ const initial = typeof el.overlay.props.text === 'string' ? el.overlay.props.text : ''
564
+ return (
565
+ <InlineTextEditor
566
+ key={editingId}
567
+ initialValue={initial}
568
+ rect={editRect}
569
+ styleSnapshot={{
570
+ color: '#ffffff',
571
+ fontSize: '18px',
572
+ fontFamily: 'system-ui, sans-serif',
573
+ whiteSpace: 'pre-wrap',
574
+ wordBreak: 'break-word',
575
+ }}
576
+ onChange={() => {}}
577
+ onCommit={(value) => commitTextEdit(el, value)}
578
+ onCancel={() => { setEditingId(null); setEditRect(null) }}
579
+ />
580
+ )
581
+ })()}
582
+ </div>
583
+ </div>
584
+ )
585
+ }
586
+
587
+ // Re-export the default asset resolver so other modules can reuse it.
588
+ export { resolveAssetDefault as resolveAsset }