@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devbycrux/editor",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -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
- <button
391
- onClick={handleRender}
392
- disabled={rendering || project.status === 'pending' || slides.length === 0}
393
- className="absolute top-3 right-3 z-30 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"
394
- title={
395
- project.status === 'pending'
396
- ? 'Wait for the agent to finish before rendering'
397
- : slides.length === 0
398
- ? 'Add slides before rendering'
399
- : 'Render all slides as PNGs'
400
- }
401
- >
402
- <Download size={18} />
403
- <span className="text-xs font-medium">{rendering ? 'Starting…' : 'Render'}</span>
404
- </button>
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
- <img
390
- src={resolveSrc(element)}
391
- draggable={false}
392
- style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
393
- alt=""
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,