@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,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 }
|