@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devbycrux/editor",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -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
- <img
402
- src={resolveSrc(element)}
403
- draggable={false}
404
- style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
405
- alt=""
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,