@devbycrux/editor 0.2.0 → 0.4.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/package.json +1 -1
- package/src/carousel/CarouselEditor.tsx +28 -16
- package/src/carousel/ReadOnlySlide.tsx +90 -0
- package/src/carousel/SlideCanvas.tsx +55 -6
- package/src/carousel/SlidePropertyPanel.tsx +40 -2
- package/src/carousel/__tests__/CarouselEditor.test.tsx +52 -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/index.ts +2 -0
- package/src/lib/google-fonts.ts +28 -0
- package/src/schema.ts +7 -0
- package/src/types.ts +20 -0
- package/src/video/preview/OverlayItemsLayer.tsx +1 -20
package/package.json
CHANGED
|
@@ -151,7 +151,7 @@ function isTypingTarget(t: EventTarget | null): boolean {
|
|
|
151
151
|
|
|
152
152
|
// ── CarouselEditor ────────────────────────────────────────────────────────────
|
|
153
153
|
|
|
154
|
-
export default function CarouselEditor<P extends Project = Project>({ project: initialProject, adapter, onProjectChange, theme, slots }: Props<P>) {
|
|
154
|
+
export default function CarouselEditor<P extends Project = Project>({ project: initialProject, adapter, onProjectChange, theme, slots, hiddenElementIds, onToggleElementVisibility, onSelectionChange }: Props<P>) {
|
|
155
155
|
const state = useProjectState(adapter, initialProject.id, initialProject)
|
|
156
156
|
const project = state.project
|
|
157
157
|
const slides = project.slides ?? []
|
|
@@ -339,6 +339,12 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
|
|
|
339
339
|
const selectedSlide = slides.find(s => s.id === selectedSlideId)
|
|
340
340
|
const selectedElement = selectedSlide?.elements.find(el => el.id === selectedElementId)
|
|
341
341
|
|
|
342
|
+
// Notify the host of selection changes so it can drive selection-aware chrome
|
|
343
|
+
// (e.g. a regen action in a toolbar slot). Fires with the element or null.
|
|
344
|
+
useEffect(() => {
|
|
345
|
+
onSelectionChange?.(selectedElement ?? null)
|
|
346
|
+
}, [selectedElement, onSelectionChange])
|
|
347
|
+
|
|
342
348
|
const [w, h] = project.settings.resolution
|
|
343
349
|
const canvasContainerRef = useRef<HTMLDivElement>(null)
|
|
344
350
|
const [canvasContainerSize, setCanvasContainerSize] = useState<{ w: number; h: number }>({ w: 600, h: 700 })
|
|
@@ -387,21 +393,24 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
|
|
|
387
393
|
<span className="text-xs font-medium">Refresh</span>
|
|
388
394
|
</button>
|
|
389
395
|
|
|
390
|
-
<
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
396
|
+
<div className="absolute top-3 right-3 z-30 flex items-center gap-2">
|
|
397
|
+
{slots?.toolbarActions}
|
|
398
|
+
<button
|
|
399
|
+
onClick={handleRender}
|
|
400
|
+
disabled={rendering || project.status === 'pending' || slides.length === 0}
|
|
401
|
+
className="flex items-center gap-2 px-3 py-2 rounded-md border border-blue-500/50 bg-blue-600/80 text-white hover:bg-blue-600 hover:border-blue-400 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
|
402
|
+
title={
|
|
403
|
+
project.status === 'pending'
|
|
404
|
+
? 'Wait for the agent to finish before rendering'
|
|
405
|
+
: slides.length === 0
|
|
406
|
+
? 'Add slides before rendering'
|
|
407
|
+
: 'Render all slides as PNGs'
|
|
408
|
+
}
|
|
409
|
+
>
|
|
410
|
+
<Download size={18} />
|
|
411
|
+
<span className="text-xs font-medium">{rendering ? 'Starting…' : 'Render'}</span>
|
|
412
|
+
</button>
|
|
413
|
+
</div>
|
|
405
414
|
|
|
406
415
|
{project.status === 'pending' ? (
|
|
407
416
|
<div className="flex flex-col items-center gap-6 text-center max-w-lg w-full">
|
|
@@ -461,6 +470,7 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
|
|
|
461
470
|
updateImageCrop={state.updateImageCrop}
|
|
462
471
|
cropElementId={cropElementId}
|
|
463
472
|
onExitCrop={() => setCropElementId(null)}
|
|
473
|
+
hiddenElementIds={hiddenElementIds}
|
|
464
474
|
/>
|
|
465
475
|
</div>
|
|
466
476
|
<p className="flex-shrink-0 text-xs text-gray-500 text-center max-w-md">
|
|
@@ -505,6 +515,8 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
|
|
|
505
515
|
onReorderElement={handleReorderElement}
|
|
506
516
|
onEnterCrop={(_slideId, elementId) => { setSelectedElementId(elementId); setCropElementId(elementId) }}
|
|
507
517
|
updateOverlayProp={state.updateOverlayProp}
|
|
518
|
+
hiddenElementIds={hiddenElementIds}
|
|
519
|
+
onToggleElementVisibility={onToggleElementVisibility}
|
|
508
520
|
/>
|
|
509
521
|
{slots?.assetsPanel && (
|
|
510
522
|
<div className="border-t border-gray-800 flex flex-col overflow-hidden" style={{ minHeight: 180 }}>
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef, useState } from 'react'
|
|
2
|
+
import type { Slide, Project, EditorAdapter } from '../types'
|
|
3
|
+
import SlideCanvas from './SlideCanvas'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Public read-only props for rendering a single carousel slide. This is the
|
|
7
|
+
* clean read-only surface over the internal (interactive-heavy) SlideCanvas
|
|
8
|
+
* Props: it exposes only the fields a non-editing consumer needs — geometry,
|
|
9
|
+
* an optional explicit scale or auto-fit, and the adapter capabilities the
|
|
10
|
+
* render path can use (image resolution, overlay compilation, file watching).
|
|
11
|
+
*/
|
|
12
|
+
export interface ReadOnlySlideProps {
|
|
13
|
+
slide: Slide
|
|
14
|
+
/** Native slide width (resolution[0]). */
|
|
15
|
+
width: number
|
|
16
|
+
/** Native slide height (resolution[1]). */
|
|
17
|
+
height: number
|
|
18
|
+
/** Explicit scale; if omitted and autoFit is on, the scale is measured. */
|
|
19
|
+
scale?: number
|
|
20
|
+
/**
|
|
21
|
+
* When true, measure the wrapper's box via a ResizeObserver and fit the slide
|
|
22
|
+
* with `min(parentW/width, parentH/height)`. When false (default), the
|
|
23
|
+
* effective scale is `scale ?? 1`.
|
|
24
|
+
*/
|
|
25
|
+
autoFit?: boolean
|
|
26
|
+
resolveImageSrc?: EditorAdapter<Project>['resolveImageSrc']
|
|
27
|
+
compileOverlay?: EditorAdapter<Project>['compileOverlay']
|
|
28
|
+
watchFile?: EditorAdapter<Project>['watchFile']
|
|
29
|
+
/** Element ids to omit from the render (non-persisted). */
|
|
30
|
+
hiddenElementIds?: string[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Thin read-only renderer for a single carousel slide. Renders a
|
|
35
|
+
* non-interactive SlideCanvas — no selection, drag, resize, rotate, crop, or
|
|
36
|
+
* text-edit chrome. The wrapper fills its sized parent (width/height: 100%) so
|
|
37
|
+
* the parent's `aspect-ratio` controls layout; with `autoFit` the slide is
|
|
38
|
+
* scaled to fit that box.
|
|
39
|
+
*/
|
|
40
|
+
export default function ReadOnlySlide({
|
|
41
|
+
slide,
|
|
42
|
+
width,
|
|
43
|
+
height,
|
|
44
|
+
scale: explicitScale,
|
|
45
|
+
autoFit = false,
|
|
46
|
+
resolveImageSrc,
|
|
47
|
+
compileOverlay,
|
|
48
|
+
watchFile,
|
|
49
|
+
hiddenElementIds,
|
|
50
|
+
}: ReadOnlySlideProps) {
|
|
51
|
+
// Auto-fit: measure the wrapper and pick the largest scale that preserves the
|
|
52
|
+
// slide's aspect ratio. Consumers just give the wrapper a sized parent (e.g. a
|
|
53
|
+
// box with `aspect-ratio: <w>/<h>; width: 100%`); no explicit scale needed.
|
|
54
|
+
const rootRef = useRef<HTMLDivElement | null>(null)
|
|
55
|
+
const [measuredScale, setMeasuredScale] = useState<number>(0)
|
|
56
|
+
useLayoutEffect(() => {
|
|
57
|
+
if (!autoFit || explicitScale !== undefined) return
|
|
58
|
+
const node = rootRef.current
|
|
59
|
+
if (!node) return
|
|
60
|
+
const compute = () => {
|
|
61
|
+
const w = node.clientWidth
|
|
62
|
+
const h = node.clientHeight
|
|
63
|
+
if (w === 0 || h === 0) return
|
|
64
|
+
const fit = Math.min(w / width, h / height)
|
|
65
|
+
if (Number.isFinite(fit) && fit > 0) setMeasuredScale(fit)
|
|
66
|
+
}
|
|
67
|
+
compute()
|
|
68
|
+
const ro = new ResizeObserver(compute)
|
|
69
|
+
ro.observe(node)
|
|
70
|
+
return () => ro.disconnect()
|
|
71
|
+
}, [autoFit, explicitScale, width, height])
|
|
72
|
+
|
|
73
|
+
const scale = explicitScale ?? (autoFit ? measuredScale : 1)
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<div ref={rootRef} style={{ width: '100%', height: '100%' }}>
|
|
77
|
+
<SlideCanvas
|
|
78
|
+
slide={slide}
|
|
79
|
+
width={width}
|
|
80
|
+
height={height}
|
|
81
|
+
interactive={false}
|
|
82
|
+
scale={scale}
|
|
83
|
+
resolveImageSrc={resolveImageSrc}
|
|
84
|
+
compileOverlay={compileOverlay}
|
|
85
|
+
watchFile={watchFile}
|
|
86
|
+
hiddenElementIds={hiddenElementIds}
|
|
87
|
+
/>
|
|
88
|
+
</div>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
import { OverlayPreview } from '../preview/OverlayPreview'
|
|
13
13
|
import { CanvasCropOverlay } from '../crop/CanvasCropOverlay'
|
|
14
14
|
import { InlineTextEditor } from '../text/InlineTextEditor'
|
|
15
|
+
import { ensureGoogleFontsLoaded } from '../lib/google-fonts'
|
|
15
16
|
|
|
16
17
|
// Neutral fallback used only when no host `resolveImageSrc` is injected. The
|
|
17
18
|
// package must not synthesize a host-shaped URL (e.g. Montaj's `/api/files`):
|
|
@@ -55,6 +56,12 @@ function OverlayElementView({
|
|
|
55
56
|
}) {
|
|
56
57
|
const duration = (element.overlay.props.duration as number | undefined) ?? 60
|
|
57
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])
|
|
58
65
|
return (
|
|
59
66
|
<OverlayPreview
|
|
60
67
|
compileOverlay={compileOverlay ?? noopCompiler}
|
|
@@ -106,6 +113,13 @@ interface Props {
|
|
|
106
113
|
// Crop mode is owned here but the entry trigger lives in the property panel.
|
|
107
114
|
cropElementId?: string | null
|
|
108
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[]
|
|
109
123
|
}
|
|
110
124
|
|
|
111
125
|
export default function SlideCanvas({
|
|
@@ -128,9 +142,11 @@ export default function SlideCanvas({
|
|
|
128
142
|
updateImageCrop,
|
|
129
143
|
cropElementId,
|
|
130
144
|
onExitCrop,
|
|
145
|
+
hiddenElementIds,
|
|
131
146
|
}: Props) {
|
|
132
147
|
const sid = slideId ?? slide.id
|
|
133
148
|
const resolveSrc = resolveImageSrc ?? ((el: ImageElement) => resolveAssetDefault(el.src))
|
|
149
|
+
const hiddenSet = hiddenElementIds && hiddenElementIds.length ? new Set(hiddenElementIds) : null
|
|
134
150
|
|
|
135
151
|
// Refs to each element wrapper so gesture previews can mutate DOM directly.
|
|
136
152
|
const wrapperRefs = useRef<Map<string, HTMLDivElement>>(new Map())
|
|
@@ -295,6 +311,7 @@ export default function SlideCanvas({
|
|
|
295
311
|
|
|
296
312
|
return (
|
|
297
313
|
<div
|
|
314
|
+
data-interactive={interactive ? 'true' : undefined}
|
|
298
315
|
style={{
|
|
299
316
|
width: displayW,
|
|
300
317
|
height: displayH,
|
|
@@ -353,6 +370,8 @@ export default function SlideCanvas({
|
|
|
353
370
|
)}
|
|
354
371
|
|
|
355
372
|
{slide.elements.map((element) => {
|
|
373
|
+
// Editor-only visibility: omit hidden elements from this canvas.
|
|
374
|
+
if (hiddenSet?.has(element.id)) return null
|
|
356
375
|
const isSelected = selectedElementId === element.id
|
|
357
376
|
const inCrop = cropState?.elementId === element.id
|
|
358
377
|
const isRotated = (element.rotation ?? 0) !== 0
|
|
@@ -386,12 +405,42 @@ export default function SlideCanvas({
|
|
|
386
405
|
|
|
387
406
|
const innerContent =
|
|
388
407
|
element.type === 'image' ? (
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
+
)
|
|
395
444
|
) : (
|
|
396
445
|
<OverlayErrorBoundary
|
|
397
446
|
label={element.overlay.template.split('/').pop() ?? element.overlay.template}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react'
|
|
2
|
-
import { Crop } from 'lucide-react'
|
|
2
|
+
import { Crop, Eye, EyeOff } from 'lucide-react'
|
|
3
3
|
import type {
|
|
4
4
|
Project,
|
|
5
5
|
Slide,
|
|
@@ -34,6 +34,37 @@ interface Props {
|
|
|
34
34
|
updateOverlayProp?: (slideId: string, elementId: string, key: string, value: string) => Promise<void>
|
|
35
35
|
// Adapter supplies overlay-schema listing (global + profile-scoped).
|
|
36
36
|
adapter: EditorAdapter<Project>
|
|
37
|
+
// Editor-only element visibility (host-owned, non-persisted). When
|
|
38
|
+
// `onToggleElementVisibility` is supplied, an eye toggle is shown for the
|
|
39
|
+
// selected element; `hiddenElementIds` reflects the current hidden set.
|
|
40
|
+
hiddenElementIds?: string[]
|
|
41
|
+
onToggleElementVisibility?: (elementId: string) => void
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Small eye toggle to hide/show the selected element in the editor preview only
|
|
45
|
+
// (never persisted). Absent host callback → not rendered.
|
|
46
|
+
function HideToggle({
|
|
47
|
+
elementId,
|
|
48
|
+
isHidden,
|
|
49
|
+
onToggle,
|
|
50
|
+
}: {
|
|
51
|
+
elementId: string
|
|
52
|
+
isHidden: boolean
|
|
53
|
+
onToggle?: (elementId: string) => void
|
|
54
|
+
}) {
|
|
55
|
+
if (!onToggle) return null
|
|
56
|
+
return (
|
|
57
|
+
<button
|
|
58
|
+
type="button"
|
|
59
|
+
onClick={e => { e.stopPropagation(); onToggle(elementId) }}
|
|
60
|
+
title={isHidden ? 'Show in editor' : 'Hide from editor'}
|
|
61
|
+
aria-label={isHidden ? 'Show in editor' : 'Hide from editor'}
|
|
62
|
+
aria-pressed={isHidden}
|
|
63
|
+
className="text-gray-500 hover:text-white px-1"
|
|
64
|
+
>
|
|
65
|
+
{isHidden ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
|
|
66
|
+
</button>
|
|
67
|
+
)
|
|
37
68
|
}
|
|
38
69
|
|
|
39
70
|
function numInput(
|
|
@@ -146,6 +177,8 @@ export default function SlidePropertyPanel({
|
|
|
146
177
|
onEnterCrop,
|
|
147
178
|
updateOverlayProp,
|
|
148
179
|
adapter,
|
|
180
|
+
hiddenElementIds,
|
|
181
|
+
onToggleElementVisibility,
|
|
149
182
|
}: Props) {
|
|
150
183
|
// Map of jsxPath → GlobalOverlay for overlay prop schemas
|
|
151
184
|
const [overlaySchemas, setOverlaySchemas] = useState<Map<string, GlobalOverlay>>(new Map())
|
|
@@ -225,7 +258,12 @@ export default function SlidePropertyPanel({
|
|
|
225
258
|
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
|
226
259
|
{element.type === 'image' ? 'Image' : 'Overlay'}
|
|
227
260
|
</span>
|
|
228
|
-
<div className="flex gap-1">
|
|
261
|
+
<div className="flex items-center gap-1">
|
|
262
|
+
<HideToggle
|
|
263
|
+
elementId={element.id}
|
|
264
|
+
isHidden={hiddenElementIds?.includes(element.id) ?? false}
|
|
265
|
+
onToggle={onToggleElementVisibility}
|
|
266
|
+
/>
|
|
229
267
|
<button
|
|
230
268
|
onClick={() => onReorderElement(slide.id, element.id, 'forward')}
|
|
231
269
|
className="text-xs text-gray-500 hover:text-white px-1"
|
|
@@ -232,4 +232,56 @@ describe('CarouselEditor — editor-core integration', () => {
|
|
|
232
232
|
expect(adapter.saveCalls.length).toBe(before)
|
|
233
233
|
document.body.removeChild(input)
|
|
234
234
|
})
|
|
235
|
+
|
|
236
|
+
// Visibility toggle: ids in `hiddenElementIds` are omitted from the interactive
|
|
237
|
+
// canvas (editor-only; the thumbnail and `saveProject` are untouched).
|
|
238
|
+
it('omits hidden elements from the interactive canvas', async () => {
|
|
239
|
+
const adapter = makeFakeAdapter()
|
|
240
|
+
const initial = makeProject({
|
|
241
|
+
slides: [
|
|
242
|
+
{
|
|
243
|
+
id: 'slide-0',
|
|
244
|
+
base_color: '#ffffff',
|
|
245
|
+
elements: [
|
|
246
|
+
{ id: 'el-a', type: 'image', src: 'a.png', x: 0, y: 0, w: 100, h: 100, rotation: 0 },
|
|
247
|
+
{ id: 'el-b', type: 'image', src: 'b.png', x: 10, y: 10, w: 100, h: 100, rotation: 0 },
|
|
248
|
+
],
|
|
249
|
+
},
|
|
250
|
+
],
|
|
251
|
+
})
|
|
252
|
+
const { container } = render(
|
|
253
|
+
<CarouselEditor project={initial} adapter={adapter} onProjectChange={vi.fn()} hiddenElementIds={['el-b']} />,
|
|
254
|
+
)
|
|
255
|
+
// Visible element renders in the interactive canvas; hidden one does not.
|
|
256
|
+
await waitFor(() => expect(container.querySelector('[data-interactive] [data-element-id="el-a"]')).not.toBeNull())
|
|
257
|
+
expect(container.querySelector('[data-interactive] [data-element-id="el-b"]')).toBeNull()
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
// onSelectionChange fires with the selected element, and with null on deselect.
|
|
261
|
+
it('fires onSelectionChange on select and deselect', async () => {
|
|
262
|
+
const adapter = makeFakeAdapter()
|
|
263
|
+
const initial = makeProject()
|
|
264
|
+
const onSelectionChange = vi.fn()
|
|
265
|
+
const { container } = render(
|
|
266
|
+
<CarouselEditor project={initial} adapter={adapter} onProjectChange={vi.fn()} onSelectionChange={onSelectionChange} />,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
const lastSelection = () => {
|
|
270
|
+
const calls = onSelectionChange.mock.calls
|
|
271
|
+
return calls[calls.length - 1]?.[0]
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const wrapper = await waitFor(() => findInteractiveWrapper('el-img'))
|
|
275
|
+
await act(async () => { fireEvent.click(wrapper) })
|
|
276
|
+
await waitFor(() => {
|
|
277
|
+
expect(lastSelection()?.id).toBe('el-img')
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
// Click the interactive canvas background to clear selection.
|
|
281
|
+
const root = container.querySelector('[data-interactive]') as HTMLElement
|
|
282
|
+
await act(async () => { fireEvent.click(root) })
|
|
283
|
+
await waitFor(() => {
|
|
284
|
+
expect(lastSelection()).toBeNull()
|
|
285
|
+
})
|
|
286
|
+
})
|
|
235
287
|
})
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { render, waitFor } from '@testing-library/react'
|
|
3
|
+
import type { ImageElement, OverlayElement, Slide } from '../../types'
|
|
4
|
+
import ReadOnlySlide from '../ReadOnlySlide'
|
|
5
|
+
|
|
6
|
+
// ── ResizeObserver mock plumbing ──────────────────────────────────────────────
|
|
7
|
+
// jsdom has no ResizeObserver. We capture the instances so autoFit tests can
|
|
8
|
+
// drive a callback with a controlled parent size.
|
|
9
|
+
let roInstances: Array<{ cb: ResizeObserverCallback; el: Element | null }> = []
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
roInstances = []
|
|
12
|
+
;(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class {
|
|
13
|
+
cb: ResizeObserverCallback
|
|
14
|
+
el: Element | null = null
|
|
15
|
+
constructor(cb: ResizeObserverCallback) {
|
|
16
|
+
this.cb = cb
|
|
17
|
+
roInstances.push(this)
|
|
18
|
+
}
|
|
19
|
+
observe(el: Element) {
|
|
20
|
+
this.el = el
|
|
21
|
+
}
|
|
22
|
+
unobserve() {}
|
|
23
|
+
disconnect() {}
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
afterEach(() => vi.restoreAllMocks())
|
|
27
|
+
|
|
28
|
+
const imgEl: ImageElement = {
|
|
29
|
+
id: 'el-img',
|
|
30
|
+
type: 'image',
|
|
31
|
+
src: 'a.png',
|
|
32
|
+
x: 100,
|
|
33
|
+
y: 100,
|
|
34
|
+
w: 200,
|
|
35
|
+
h: 200,
|
|
36
|
+
rotation: 0,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const overlayEl: OverlayElement = {
|
|
40
|
+
id: 'el-ov',
|
|
41
|
+
type: 'overlay',
|
|
42
|
+
overlay: { template: '/overlays/lp-text.jsx', props: { text: 'Hola' } },
|
|
43
|
+
frame: 0,
|
|
44
|
+
x: 50,
|
|
45
|
+
y: 800,
|
|
46
|
+
w: 400,
|
|
47
|
+
h: 120,
|
|
48
|
+
rotation: 0,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function makeSlide(elements: Slide['elements']): Slide {
|
|
52
|
+
return { id: 'slide-0', base_color: '#ffffff', elements }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('ReadOnlySlide', () => {
|
|
56
|
+
it("renders a slide's image + overlay non-interactively (no interactive chrome)", async () => {
|
|
57
|
+
const { container } = render(
|
|
58
|
+
<ReadOnlySlide
|
|
59
|
+
slide={makeSlide([imgEl, overlayEl])}
|
|
60
|
+
width={1080}
|
|
61
|
+
height={1080}
|
|
62
|
+
scale={0.5}
|
|
63
|
+
resolveImageSrc={(el) => el.src}
|
|
64
|
+
compileOverlay={vi.fn(async () => () => null)}
|
|
65
|
+
/>,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
// Image element renders.
|
|
69
|
+
const img = await waitFor(() => {
|
|
70
|
+
const el = container.querySelector('[data-element-id="el-img"]') as HTMLElement
|
|
71
|
+
if (!el) throw new Error('image wrapper not yet rendered')
|
|
72
|
+
return el
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
// Non-interactive: the canvas root must not advertise interactivity, and the
|
|
76
|
+
// element wrappers must not receive pointer events (no selection chrome).
|
|
77
|
+
expect(container.querySelector('[data-interactive]')).toBeNull()
|
|
78
|
+
expect(img.style.pointerEvents).toBe('none')
|
|
79
|
+
|
|
80
|
+
// No resize / rotate handles rendered in read-only mode.
|
|
81
|
+
expect(container.querySelector('[data-testid="rotate-handle"]')).toBeNull()
|
|
82
|
+
expect(container.querySelector('[data-testid="resize-handle-nw"]')).toBeNull()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('applies the explicit scale when provided', async () => {
|
|
86
|
+
const { container } = render(
|
|
87
|
+
<ReadOnlySlide
|
|
88
|
+
slide={makeSlide([imgEl])}
|
|
89
|
+
width={1080}
|
|
90
|
+
height={1080}
|
|
91
|
+
scale={0.5}
|
|
92
|
+
resolveImageSrc={(el) => el.src}
|
|
93
|
+
/>,
|
|
94
|
+
)
|
|
95
|
+
// displayW = width * scale = 1080 * 0.5 = 540.
|
|
96
|
+
await waitFor(() => {
|
|
97
|
+
const inner = container.querySelector('[data-element-id="el-img"]') as HTMLElement
|
|
98
|
+
if (!inner) throw new Error('not rendered')
|
|
99
|
+
})
|
|
100
|
+
const canvas = container.querySelector('div > div') as HTMLElement
|
|
101
|
+
// The SlideCanvas root sets width to displayW; find a node sized 540px.
|
|
102
|
+
const sized = Array.from(container.querySelectorAll('div')).some(
|
|
103
|
+
(d) => (d as HTMLElement).style.width === '540px',
|
|
104
|
+
)
|
|
105
|
+
expect(sized).toBe(true)
|
|
106
|
+
expect(canvas).toBeTruthy()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('measures the parent and applies a fitted scale when autoFit is on', async () => {
|
|
110
|
+
const { container } = render(
|
|
111
|
+
<ReadOnlySlide
|
|
112
|
+
slide={makeSlide([imgEl])}
|
|
113
|
+
width={1080}
|
|
114
|
+
height={1080}
|
|
115
|
+
autoFit
|
|
116
|
+
resolveImageSrc={(el) => el.src}
|
|
117
|
+
/>,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
// A ResizeObserver was created and is observing the root.
|
|
121
|
+
await waitFor(() => expect(roInstances.length).toBeGreaterThan(0))
|
|
122
|
+
const ro = roInstances[0]
|
|
123
|
+
expect(ro.el).not.toBeNull()
|
|
124
|
+
|
|
125
|
+
// Drive the observer: parent of the root is 540x540 → fit = 540/1080 = 0.5.
|
|
126
|
+
const root = ro.el as HTMLElement
|
|
127
|
+
Object.defineProperty(root, 'clientWidth', { value: 540, configurable: true })
|
|
128
|
+
Object.defineProperty(root, 'clientHeight', { value: 540, configurable: true })
|
|
129
|
+
ro.cb([], ro as unknown as ResizeObserver)
|
|
130
|
+
|
|
131
|
+
// After measuring, SlideCanvas renders at displayW = 1080 * 0.5 = 540.
|
|
132
|
+
await waitFor(() => {
|
|
133
|
+
const sized = Array.from(container.querySelectorAll('div')).some(
|
|
134
|
+
(d) => (d as HTMLElement).style.width === '540px',
|
|
135
|
+
)
|
|
136
|
+
expect(sized).toBe(true)
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
})
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
2
|
+
import { render, waitFor } from '@testing-library/react'
|
|
3
|
+
import type { ImageElement, Slide } from '../../types'
|
|
4
|
+
import SlideCanvas from '../SlideCanvas'
|
|
5
|
+
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
;(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class {
|
|
8
|
+
observe() {}
|
|
9
|
+
unobserve() {}
|
|
10
|
+
disconnect() {}
|
|
11
|
+
}
|
|
12
|
+
})
|
|
13
|
+
afterEach(() => vi.restoreAllMocks())
|
|
14
|
+
|
|
15
|
+
function makeSlide(el: ImageElement): Slide {
|
|
16
|
+
return { id: 'slide-0', base_color: '#ffffff', elements: [el] }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('SlideCanvas crop display', () => {
|
|
20
|
+
it('renders the oversized-cover crop variant when element.crop is present (non-interactive)', async () => {
|
|
21
|
+
const el: ImageElement = {
|
|
22
|
+
id: 'el-img',
|
|
23
|
+
type: 'image',
|
|
24
|
+
src: 'a.png',
|
|
25
|
+
x: 0,
|
|
26
|
+
y: 0,
|
|
27
|
+
w: 200,
|
|
28
|
+
h: 200,
|
|
29
|
+
rotation: 0,
|
|
30
|
+
crop: { x: 0.25, y: 0.1, w: 0.5, h: 0.5 },
|
|
31
|
+
}
|
|
32
|
+
const { container } = render(
|
|
33
|
+
<SlideCanvas
|
|
34
|
+
slide={makeSlide(el)}
|
|
35
|
+
width={1080}
|
|
36
|
+
height={1080}
|
|
37
|
+
interactive={false}
|
|
38
|
+
scale={1}
|
|
39
|
+
resolveImageSrc={(e) => e.src}
|
|
40
|
+
/>,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
const img = await waitFor(() => {
|
|
44
|
+
const found = container.querySelector('[data-element-id="el-img"] img') as HTMLImageElement
|
|
45
|
+
if (!found) throw new Error('img not rendered')
|
|
46
|
+
return found
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
// Oversized cover: width = 100/cw = 200%, height = 100/ch = 200%.
|
|
50
|
+
expect(img.style.width).toBe('200%')
|
|
51
|
+
expect(img.style.height).toBe('200%')
|
|
52
|
+
// marginLeft = -cx*100/cw = -0.25*100/0.5 = -50%; marginTop = -0.1*100/0.5 = -20%.
|
|
53
|
+
expect(img.style.marginLeft).toBe('-50%')
|
|
54
|
+
expect(img.style.marginTop).toBe('-20%')
|
|
55
|
+
// Tailwind-preflight defeat.
|
|
56
|
+
expect(img.style.maxWidth).toBe('none')
|
|
57
|
+
expect(img.style.maxHeight).toBe('none')
|
|
58
|
+
|
|
59
|
+
// The crop wrapper clips overflow.
|
|
60
|
+
const wrapper = img.parentElement as HTMLElement
|
|
61
|
+
expect(wrapper.style.overflow).toBe('hidden')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('renders plain cover (no crop wrapper) when element.crop is absent', async () => {
|
|
65
|
+
const el: ImageElement = {
|
|
66
|
+
id: 'el-img',
|
|
67
|
+
type: 'image',
|
|
68
|
+
src: 'a.png',
|
|
69
|
+
x: 0,
|
|
70
|
+
y: 0,
|
|
71
|
+
w: 200,
|
|
72
|
+
h: 200,
|
|
73
|
+
rotation: 0,
|
|
74
|
+
}
|
|
75
|
+
const { container } = render(
|
|
76
|
+
<SlideCanvas
|
|
77
|
+
slide={makeSlide(el)}
|
|
78
|
+
width={1080}
|
|
79
|
+
height={1080}
|
|
80
|
+
interactive={false}
|
|
81
|
+
scale={1}
|
|
82
|
+
resolveImageSrc={(e) => e.src}
|
|
83
|
+
/>,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
const img = await waitFor(() => {
|
|
87
|
+
const found = container.querySelector('[data-element-id="el-img"] img') as HTMLImageElement
|
|
88
|
+
if (!found) throw new Error('img not rendered')
|
|
89
|
+
return found
|
|
90
|
+
})
|
|
91
|
+
expect(img.style.objectFit).toBe('cover')
|
|
92
|
+
expect(img.style.width).toBe('100%')
|
|
93
|
+
expect(img.style.height).toBe('100%')
|
|
94
|
+
})
|
|
95
|
+
})
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
2
|
+
import { render, waitFor } from '@testing-library/react'
|
|
3
|
+
import type { OverlayElement, Slide } from '../../types'
|
|
4
|
+
|
|
5
|
+
// Spy on the shared font loader so we can assert the carousel overlay path
|
|
6
|
+
// invokes it with the element's googleFonts list (unit-level — we do not assert
|
|
7
|
+
// actual font bytes load in jsdom).
|
|
8
|
+
const { ensureGoogleFontsLoaded } = vi.hoisted(() => ({ ensureGoogleFontsLoaded: vi.fn() }))
|
|
9
|
+
vi.mock('../../lib/google-fonts', () => ({ ensureGoogleFontsLoaded }))
|
|
10
|
+
|
|
11
|
+
import SlideCanvas from '../SlideCanvas'
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
ensureGoogleFontsLoaded.mockClear()
|
|
15
|
+
;(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class {
|
|
16
|
+
observe() {}
|
|
17
|
+
unobserve() {}
|
|
18
|
+
disconnect() {}
|
|
19
|
+
}
|
|
20
|
+
})
|
|
21
|
+
afterEach(() => vi.restoreAllMocks())
|
|
22
|
+
|
|
23
|
+
function makeSlide(el: OverlayElement): Slide {
|
|
24
|
+
return { id: 'slide-0', base_color: '#ffffff', elements: [el] }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('SlideCanvas carousel overlay Google Fonts', () => {
|
|
28
|
+
it('loads an overlay element’s googleFonts via the shared helper', async () => {
|
|
29
|
+
const el: OverlayElement = {
|
|
30
|
+
id: 'el-ov',
|
|
31
|
+
type: 'overlay',
|
|
32
|
+
overlay: { template: '/overlays/lp-text.jsx', props: { text: 'Hola' } },
|
|
33
|
+
frame: 0,
|
|
34
|
+
x: 0,
|
|
35
|
+
y: 0,
|
|
36
|
+
w: 400,
|
|
37
|
+
h: 120,
|
|
38
|
+
rotation: 0,
|
|
39
|
+
googleFonts: ['Syne:wght@800', 'Inter:wght@400'],
|
|
40
|
+
}
|
|
41
|
+
render(
|
|
42
|
+
<SlideCanvas
|
|
43
|
+
slide={makeSlide(el)}
|
|
44
|
+
width={1080}
|
|
45
|
+
height={1080}
|
|
46
|
+
interactive={false}
|
|
47
|
+
scale={1}
|
|
48
|
+
compileOverlay={vi.fn(async () => () => null)}
|
|
49
|
+
/>,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
await waitFor(() =>
|
|
53
|
+
expect(ensureGoogleFontsLoaded).toHaveBeenCalledWith(['Syne:wght@800', 'Inter:wght@400']),
|
|
54
|
+
)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('does not throw when an overlay element has no googleFonts', async () => {
|
|
58
|
+
const el: OverlayElement = {
|
|
59
|
+
id: 'el-ov',
|
|
60
|
+
type: 'overlay',
|
|
61
|
+
overlay: { template: '/overlays/lp-text.jsx', props: { text: 'Hola' } },
|
|
62
|
+
frame: 0,
|
|
63
|
+
x: 0,
|
|
64
|
+
y: 0,
|
|
65
|
+
w: 400,
|
|
66
|
+
h: 120,
|
|
67
|
+
rotation: 0,
|
|
68
|
+
}
|
|
69
|
+
render(
|
|
70
|
+
<SlideCanvas
|
|
71
|
+
slide={makeSlide(el)}
|
|
72
|
+
width={1080}
|
|
73
|
+
height={1080}
|
|
74
|
+
interactive={false}
|
|
75
|
+
scale={1}
|
|
76
|
+
compileOverlay={vi.fn(async () => () => null)}
|
|
77
|
+
/>,
|
|
78
|
+
)
|
|
79
|
+
// Helper is still invoked (with undefined) — it no-ops internally.
|
|
80
|
+
await waitFor(() => expect(ensureGoogleFontsLoaded).toHaveBeenCalledWith(undefined))
|
|
81
|
+
})
|
|
82
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -132,3 +132,5 @@ export { default as VideoEditor } from './video/VideoEditor'
|
|
|
132
132
|
// components render SlideCanvas thumbnails and wrap overlays in the boundary.
|
|
133
133
|
export { default as SlideCanvas, resolveAsset } from './carousel/SlideCanvas'
|
|
134
134
|
export { default as OverlayErrorBoundary } from './carousel/OverlayErrorBoundary'
|
|
135
|
+
export { default as ReadOnlySlide } from './carousel/ReadOnlySlide'
|
|
136
|
+
export type { ReadOnlySlideProps } from './carousel/ReadOnlySlide'
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Shared Google Fonts loader.
|
|
2
|
+
//
|
|
3
|
+
// Injects a Google Fonts stylesheet <link> for the declared family specs so the
|
|
4
|
+
// editor's preview/render uses the same glyphs and metrics the renderer
|
|
5
|
+
// (bundle.js) fetches. Used by both the video overlay layer and the carousel
|
|
6
|
+
// overlay render path. Resilient by design: a font-load failure must never
|
|
7
|
+
// break the render (we only append a <link>; the browser handles the fetch).
|
|
8
|
+
|
|
9
|
+
// Track Google Fonts URLs already injected so we don't add the same <link>
|
|
10
|
+
// twice when multiple overlays declare overlapping fonts. Keyed by the full
|
|
11
|
+
// stylesheet URL — the same URL never produces a duplicate fetch from
|
|
12
|
+
// Chromium regardless, but the duplicate <link> tags would still clutter
|
|
13
|
+
// document.head across long editing sessions.
|
|
14
|
+
const __injectedFontUrls = new Set<string>()
|
|
15
|
+
|
|
16
|
+
export function ensureGoogleFontsLoaded(googleFonts: string[] | undefined): void {
|
|
17
|
+
if (!googleFonts?.length) return
|
|
18
|
+
// Match the format bundle.js uses for the render pipeline so preview and
|
|
19
|
+
// render fetch identical CSS (and identical glyphs / metrics).
|
|
20
|
+
const url = `https://fonts.googleapis.com/css2?${googleFonts.map((f) => `family=${f}`).join('&')}&display=swap`
|
|
21
|
+
if (__injectedFontUrls.has(url)) return
|
|
22
|
+
__injectedFontUrls.add(url)
|
|
23
|
+
if (typeof document === 'undefined') return
|
|
24
|
+
const link = document.createElement('link')
|
|
25
|
+
link.rel = 'stylesheet'
|
|
26
|
+
link.href = url
|
|
27
|
+
document.head.appendChild(link)
|
|
28
|
+
}
|
package/src/schema.ts
CHANGED
|
@@ -157,6 +157,13 @@ export interface OverlayElement {
|
|
|
157
157
|
w: number
|
|
158
158
|
h: number
|
|
159
159
|
rotation: number
|
|
160
|
+
/**
|
|
161
|
+
* Optional Google Fonts family specs (e.g. ["Syne:wght@800"]) the overlay
|
|
162
|
+
* renders with. Mirrors the video-side `VisualItem.googleFonts`. The carousel
|
|
163
|
+
* overlay render path injects these so the preview uses the same glyphs and
|
|
164
|
+
* metrics as the renderer; absent = no custom fonts loaded.
|
|
165
|
+
*/
|
|
166
|
+
googleFonts?: string[]
|
|
160
167
|
}
|
|
161
168
|
|
|
162
169
|
export type CarouselElement = ImageElement | OverlayElement
|
package/src/types.ts
CHANGED
|
@@ -406,6 +406,26 @@ export interface CarouselEditorProps<P extends Project = Project> {
|
|
|
406
406
|
theme?: EditorTheme
|
|
407
407
|
slots?: EditorSlots
|
|
408
408
|
readOnly?: boolean
|
|
409
|
+
/**
|
|
410
|
+
* Editor-only set of element ids to hide from the interactive canvas. The host
|
|
411
|
+
* owns this state; the package never persists it (hidden elements are omitted
|
|
412
|
+
* from the canvas render only, never from `saveProject`). Lets a host
|
|
413
|
+
* temporarily hide a scrim/background to position overlays beneath it.
|
|
414
|
+
*/
|
|
415
|
+
hiddenElementIds?: string[]
|
|
416
|
+
/**
|
|
417
|
+
* Invoked when the user toggles the selected element's editor-visibility via
|
|
418
|
+
* the property-panel eye button. The host updates its hidden-set and reflects
|
|
419
|
+
* it back through `hiddenElementIds`. Absent → no eye toggle is rendered.
|
|
420
|
+
*/
|
|
421
|
+
onToggleElementVisibility?: (elementId: string) => void
|
|
422
|
+
/**
|
|
423
|
+
* Invoked whenever the selected element changes — with the element, or `null`
|
|
424
|
+
* when selection clears. Lets a host drive selection-aware chrome (e.g. a
|
|
425
|
+
* "regenerate image" action in a toolbar slot that targets the current
|
|
426
|
+
* selection). The package keeps owning selection state.
|
|
427
|
+
*/
|
|
428
|
+
onSelectionChange?: (element: CarouselElement | null) => void
|
|
409
429
|
}
|
|
410
430
|
|
|
411
431
|
/**
|
|
@@ -3,6 +3,7 @@ import type { EditorProject as Project, VisualItem } from '../../schema'
|
|
|
3
3
|
import type { OverlayFactory } from '../../types'
|
|
4
4
|
import OverlayErrorBoundary from '../../carousel/OverlayErrorBoundary'
|
|
5
5
|
import { getOverlayDesignCanvas } from '../design-canvas'
|
|
6
|
+
import { ensureGoogleFontsLoaded } from '../../lib/google-fonts'
|
|
6
7
|
import type { Corner } from './useDragOverlay'
|
|
7
8
|
import type { useDragOverlay } from './useDragOverlay'
|
|
8
9
|
|
|
@@ -104,26 +105,6 @@ interface CustomOverlayProps {
|
|
|
104
105
|
fileUrl: (path: string) => string
|
|
105
106
|
}
|
|
106
107
|
|
|
107
|
-
// Track Google Fonts URLs already injected so we don't add the same <link>
|
|
108
|
-
// twice when multiple overlays declare overlapping fonts. Keyed by the full
|
|
109
|
-
// stylesheet URL — the same URL never produces a duplicate fetch from
|
|
110
|
-
// Chromium regardless, but the duplicate <link> tags would still clutter
|
|
111
|
-
// document.head across long editing sessions.
|
|
112
|
-
const __injectedFontUrls = new Set<string>()
|
|
113
|
-
|
|
114
|
-
function ensureGoogleFontsLoaded(googleFonts: string[] | undefined) {
|
|
115
|
-
if (!googleFonts?.length) return
|
|
116
|
-
// Match the format bundle.js uses for the render pipeline so preview and
|
|
117
|
-
// render fetch identical CSS (and identical glyphs / metrics).
|
|
118
|
-
const url = `https://fonts.googleapis.com/css2?${googleFonts.map(f => `family=${f}`).join('&')}&display=swap`
|
|
119
|
-
if (__injectedFontUrls.has(url)) return
|
|
120
|
-
__injectedFontUrls.add(url)
|
|
121
|
-
const link = document.createElement('link')
|
|
122
|
-
link.rel = 'stylesheet'
|
|
123
|
-
link.href = url
|
|
124
|
-
document.head.appendChild(link)
|
|
125
|
-
}
|
|
126
|
-
|
|
127
108
|
function CustomOverlay({
|
|
128
109
|
src,
|
|
129
110
|
props,
|