@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.
- 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/__tests__/video-adapter-contract.test.ts +89 -0
- package/src/carousel/AddElementMenu.tsx +211 -0
- package/src/carousel/CarouselEditor.tsx +545 -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/ReadOnlySlide.tsx +90 -0
- package/src/carousel/SlideCanvas.tsx +637 -0
- package/src/carousel/SlidePropertyPanel.tsx +387 -0
- package/src/carousel/__tests__/CarouselEditor.test.tsx +291 -0
- package/src/carousel/__tests__/ReadOnlySlide.test.tsx +139 -0
- package/src/carousel/__tests__/SlideCanvasCrop.test.tsx +95 -0
- package/src/carousel/__tests__/SlideCanvasFonts.test.tsx +82 -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 +136 -0
- package/src/lib/google-fonts.ts +28 -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 +201 -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 +486 -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
- package/src/video/RenderModal.tsx +252 -0
- package/src/video/VersionPanel.tsx +83 -0
- package/src/video/VideoEditor.tsx +508 -0
- package/src/video/__tests__/VideoEditor.test.tsx +213 -0
- package/src/video/__tests__/captionRepair.test.ts +134 -0
- package/src/video/__tests__/cuts.test.ts +198 -0
- package/src/video/captionRepair.ts +41 -0
- package/src/video/cuts.ts +369 -0
- package/src/video/design-canvas.ts +11 -0
- package/src/video/preview/CaptionPreview.tsx +83 -0
- package/src/video/preview/CarouselPreview.tsx +35 -0
- package/src/video/preview/OverlayItemsLayer.tsx +584 -0
- package/src/video/preview/PreviewPlayer.tsx +178 -0
- package/src/video/preview/useDragOverlay.ts +167 -0
- package/src/video/preview/useVideoPlayback.ts +761 -0
- package/src/video/timeline/AudioTrackRow.tsx +406 -0
- package/src/video/timeline/AudioWaveformLayer.tsx +117 -0
- package/src/video/timeline/EditableSegment.tsx +30 -0
- package/src/video/timeline/Scrubber.tsx +184 -0
- package/src/video/timeline/Timeline.tsx +375 -0
- package/src/video/timeline/TimelineContext.ts +25 -0
- package/src/video/timeline/TranscriptModal.tsx +63 -0
- package/src/video/timeline/TranscriptPanel.tsx +86 -0
- package/src/video/timeline/VisualTrackRow.tsx +293 -0
- package/src/video/timeline/makeCaptionEdit.ts +32 -0
- package/src/video/timeline/multiSelectOps.ts +157 -0
- package/src/video/timeline/useItemDragDrop.ts +190 -0
- package/src/video/timeline/useTimelineZoom.ts +48 -0
- 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 }
|