@devbycrux/editor 0.3.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/ReadOnlySlide.tsx +90 -0
- package/src/carousel/SlideCanvas.tsx +43 -6
- 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/video/preview/OverlayItemsLayer.tsx +1 -20
package/package.json
CHANGED
|
@@ -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}
|
|
@@ -398,12 +405,42 @@ export default function SlideCanvas({
|
|
|
398
405
|
|
|
399
406
|
const innerContent =
|
|
400
407
|
element.type === 'image' ? (
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
+
)
|
|
407
444
|
) : (
|
|
408
445
|
<OverlayErrorBoundary
|
|
409
446
|
label={element.overlay.template.split('/').pop() ?? element.overlay.template}
|
|
@@ -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
|
|
@@ -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,
|