@bycrux/editor 0.4.1

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