@dfosco/storyboard-react 3.10.0 → 3.11.0-beta.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,10 +1,10 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-react",
3
- "version": "3.10.0",
3
+ "version": "3.11.0-beta.0",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@dfosco/storyboard-core": "3.10.0",
7
- "@dfosco/tiny-canvas": "3.10.0",
6
+ "@dfosco/storyboard-core": "3.11.0-beta.0",
7
+ "@dfosco/tiny-canvas": "3.11.0-beta.0",
8
8
  "@neodrag/react": "^2.3.1",
9
9
  "glob": "^11.0.0",
10
10
  "jsonc-parser": "^3.3.1"
@@ -54,6 +54,10 @@ vi.mock('./widgets/index.js', () => ({
54
54
  getWidgetComponent: () => function MockWidget() { return <div>mock widget</div> },
55
55
  }))
56
56
 
57
+ vi.mock('./widgets/WidgetChrome.jsx', () => ({
58
+ default: ({ children }) => <div data-testid="widget-chrome">{children}</div>,
59
+ }))
60
+
57
61
  vi.mock('./widgets/widgetProps.js', () => ({
58
62
  schemas: {},
59
63
  getDefaults: () => ({}),
@@ -8,9 +8,10 @@ import { getCanvasThemeVars, getCanvasPrimerAttrs } from './canvasTheme.js'
8
8
  import { getWidgetComponent } from './widgets/index.js'
9
9
  import { schemas, getDefaults } from './widgets/widgetProps.js'
10
10
  import { getFeatures } from './widgets/widgetConfig.js'
11
+ import { isFigmaUrl, sanitizeFigmaUrl } from './widgets/figmaUrl.js'
11
12
  import WidgetChrome from './widgets/WidgetChrome.jsx'
12
13
  import ComponentWidget from './widgets/ComponentWidget.jsx'
13
- import { addWidget as addWidgetApi, updateCanvas, removeWidget as removeWidgetApi } from './canvasApi.js'
14
+ import { addWidget as addWidgetApi, updateCanvas, removeWidget as removeWidgetApi, uploadImage } from './canvasApi.js'
14
15
  import styles from './CanvasPage.module.css'
15
16
 
16
17
  const ZOOM_MIN = 25
@@ -56,6 +57,30 @@ function debounce(fn, ms) {
56
57
  }
57
58
  }
58
59
 
60
+ /** Per-canvas viewport state persistence (zoom + scroll position). */
61
+ function getViewportStorageKey(canvasName) {
62
+ return `sb-canvas-viewport:${canvasName}`
63
+ }
64
+
65
+ function loadViewportState(canvasName) {
66
+ try {
67
+ const raw = localStorage.getItem(getViewportStorageKey(canvasName))
68
+ if (!raw) return null
69
+ const state = JSON.parse(raw)
70
+ return {
71
+ zoom: typeof state.zoom === 'number' ? Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, state.zoom)) : null,
72
+ scrollLeft: typeof state.scrollLeft === 'number' ? state.scrollLeft : null,
73
+ scrollTop: typeof state.scrollTop === 'number' ? state.scrollTop : null,
74
+ }
75
+ } catch { return null }
76
+ }
77
+
78
+ function saveViewportState(canvasName, state) {
79
+ try {
80
+ localStorage.setItem(getViewportStorageKey(canvasName), JSON.stringify(state))
81
+ } catch { /* quota exceeded — non-critical */ }
82
+ }
83
+
59
84
  /**
60
85
  * Get viewport-center coordinates in canvas space for placing a new widget.
61
86
  * Converts the visible center of the scroll container to unscaled canvas coordinates.
@@ -78,7 +103,9 @@ const WIDGET_FALLBACK_SIZES = {
78
103
  'markdown': { width: 360, height: 200 },
79
104
  'prototype': { width: 800, height: 600 },
80
105
  'link-preview': { width: 320, height: 120 },
106
+ 'figma-embed': { width: 800, height: 450 },
81
107
  'component': { width: 200, height: 150 },
108
+ 'image': { width: 400, height: 300 },
82
109
  }
83
110
 
84
111
  /**
@@ -170,9 +197,11 @@ export default function CanvasPage({ name }) {
170
197
  const [localWidgets, setLocalWidgets] = useState(canvas?.widgets ?? null)
171
198
  const [trackedCanvas, setTrackedCanvas] = useState(canvas)
172
199
  const [selectedWidgetId, setSelectedWidgetId] = useState(null)
173
- const [zoom, setZoom] = useState(100)
174
- const zoomRef = useRef(100)
200
+ const initialViewport = loadViewportState(name)
201
+ const [zoom, setZoom] = useState(initialViewport?.zoom ?? 100)
202
+ const zoomRef = useRef(initialViewport?.zoom ?? 100)
175
203
  const scrollRef = useRef(null)
204
+ const pendingScrollRestore = useRef(initialViewport)
176
205
  const [canvasTitle, setCanvasTitle] = useState(canvas?.title || name)
177
206
  const titleInputRef = useRef(null)
178
207
  const [localSources, setLocalSources] = useState(canvas?.sources ?? [])
@@ -287,6 +316,55 @@ export default function CanvasPage({ name }) {
287
316
  zoomRef.current = zoom
288
317
  }, [zoom])
289
318
 
319
+ // Restore scroll position from localStorage after first render
320
+ useEffect(() => {
321
+ const el = scrollRef.current
322
+ const saved = pendingScrollRestore.current
323
+ if (el && saved) {
324
+ if (saved.scrollLeft != null) el.scrollLeft = saved.scrollLeft
325
+ if (saved.scrollTop != null) el.scrollTop = saved.scrollTop
326
+ pendingScrollRestore.current = null
327
+ }
328
+ }, [name, loading])
329
+
330
+ // Persist viewport state (zoom + scroll) to localStorage on changes
331
+ useEffect(() => {
332
+ const el = scrollRef.current
333
+ saveViewportState(name, {
334
+ zoom,
335
+ scrollLeft: el?.scrollLeft ?? 0,
336
+ scrollTop: el?.scrollTop ?? 0,
337
+ })
338
+ }, [name, zoom])
339
+
340
+ useEffect(() => {
341
+ const el = scrollRef.current
342
+ if (!el) return
343
+ function handleScroll() {
344
+ saveViewportState(name, {
345
+ zoom: zoomRef.current,
346
+ scrollLeft: el.scrollLeft,
347
+ scrollTop: el.scrollTop,
348
+ })
349
+ }
350
+ el.addEventListener('scroll', handleScroll, { passive: true })
351
+
352
+ // Flush viewport state on page unload so a refresh never misses it
353
+ function handleBeforeUnload() {
354
+ saveViewportState(name, {
355
+ zoom: zoomRef.current,
356
+ scrollLeft: el.scrollLeft,
357
+ scrollTop: el.scrollTop,
358
+ })
359
+ }
360
+ window.addEventListener('beforeunload', handleBeforeUnload)
361
+
362
+ return () => {
363
+ el.removeEventListener('scroll', handleScroll)
364
+ window.removeEventListener('beforeunload', handleBeforeUnload)
365
+ }
366
+ }, [name, loading])
367
+
290
368
  /**
291
369
  * Zoom to a new level, anchoring on an optional client-space point.
292
370
  * When a cursor position is provided (e.g. from a wheel event), the
@@ -345,6 +423,26 @@ export default function CanvasPage({ name }) {
345
423
  }
346
424
  }, [name])
347
425
 
426
+ // Tell the Vite dev server to suppress full-reloads while this canvas is active.
427
+ // The ?canvas-hmr URL param opts out of the guard for canvas UI development.
428
+ // Sends a heartbeat every 3s so the guard auto-expires if the tab closes.
429
+ useEffect(() => {
430
+ if (!import.meta.hot) return
431
+ const hmrEnabled = new URLSearchParams(window.location.search).has('canvas-hmr')
432
+ if (hmrEnabled) return
433
+
434
+ const msg = { active: true, hmrEnabled: false }
435
+ import.meta.hot.send('storyboard:canvas-hmr-guard', msg)
436
+ const interval = setInterval(() => {
437
+ import.meta.hot.send('storyboard:canvas-hmr-guard', msg)
438
+ }, 3000)
439
+
440
+ return () => {
441
+ clearInterval(interval)
442
+ import.meta.hot.send('storyboard:canvas-hmr-guard', { active: false, hmrEnabled: true })
443
+ }
444
+ }, [name])
445
+
348
446
  // Add a widget by type — used by CanvasControls and CoreUIBar event
349
447
  const addWidget = useCallback(async (type) => {
350
448
  const defaultProps = schemas[type] ? getDefaults(schemas[type]) : {}
@@ -420,6 +518,10 @@ export default function CanvasPage({ name }) {
420
518
  if (!selectedWidgetId) return
421
519
  const tag = e.target.tagName
422
520
  if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
521
+ if (e.key === 'Escape') {
522
+ e.preventDefault()
523
+ setSelectedWidgetId(null)
524
+ }
423
525
  if (e.key === 'Delete' || e.key === 'Backspace') {
424
526
  e.preventDefault()
425
527
  handleWidgetRemove(selectedWidgetId)
@@ -430,7 +532,8 @@ export default function CanvasPage({ name }) {
430
532
  return () => document.removeEventListener('keydown', handleKeyDown)
431
533
  }, [selectedWidgetId, handleWidgetRemove])
432
534
 
433
- // Paste handler — same-origin URLs become prototypes, other URLs become link previews, text becomes markdown
535
+ // Paste handler — images become image widgets, same-origin URLs become prototypes,
536
+ // other URLs become link previews, text becomes markdown
434
537
  useEffect(() => {
435
538
  const origin = window.location.origin
436
539
  const basePath = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
@@ -461,10 +564,81 @@ export default function CanvasPage({ name }) {
461
564
  return pathname
462
565
  }
463
566
 
567
+ function blobToDataUrl(blob) {
568
+ return new Promise((resolve, reject) => {
569
+ const reader = new FileReader()
570
+ reader.onload = () => resolve(reader.result)
571
+ reader.onerror = reject
572
+ reader.readAsDataURL(blob)
573
+ })
574
+ }
575
+
576
+ function getImageDimensions(dataUrl) {
577
+ return new Promise((resolve) => {
578
+ const img = new Image()
579
+ img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight })
580
+ img.onerror = () => resolve({ width: 400, height: 300 })
581
+ img.src = dataUrl
582
+ })
583
+ }
584
+
585
+ async function handleImagePaste(e) {
586
+ const items = e.clipboardData?.items
587
+ if (!items) return false
588
+
589
+ for (const item of items) {
590
+ if (!item.type.startsWith('image/')) continue
591
+
592
+ const blob = item.getAsFile()
593
+ if (!blob) continue
594
+
595
+ e.preventDefault()
596
+
597
+ try {
598
+ const dataUrl = await blobToDataUrl(blob)
599
+ const { width: natW, height: natH } = await getImageDimensions(dataUrl)
600
+
601
+ // Display at 2x retina: halve natural dimensions, then cap at 600px
602
+ const maxWidth = 600
603
+ let displayW = Math.round(natW / 2)
604
+ let displayH = Math.round(natH / 2)
605
+ if (displayW > maxWidth) {
606
+ displayH = Math.round(displayH * (maxWidth / displayW))
607
+ displayW = maxWidth
608
+ }
609
+
610
+ const uploadResult = await uploadImage(dataUrl, name)
611
+ if (!uploadResult.success) {
612
+ console.error('[canvas] Image upload failed:', uploadResult.error)
613
+ return true
614
+ }
615
+
616
+ const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
617
+ const pos = centerPositionForWidget(center, 'image', { width: displayW, height: displayH })
618
+ const result = await addWidgetApi(name, {
619
+ type: 'image',
620
+ props: { src: uploadResult.filename, private: false, width: displayW, height: displayH },
621
+ position: pos,
622
+ })
623
+ if (result.success && result.widget) {
624
+ setLocalWidgets((prev) => [...(prev || []), result.widget])
625
+ }
626
+ } catch (err) {
627
+ console.error('[canvas] Failed to paste image:', err)
628
+ }
629
+ return true
630
+ }
631
+ return false
632
+ }
633
+
464
634
  async function handlePaste(e) {
465
635
  const tag = e.target.tagName
466
636
  if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
467
637
 
638
+ // Image paste takes priority
639
+ const handledImage = await handleImagePaste(e)
640
+ if (handledImage) return
641
+
468
642
  const text = e.clipboardData?.getData('text/plain')?.trim()
469
643
  if (!text) return
470
644
 
@@ -473,7 +647,10 @@ export default function CanvasPage({ name }) {
473
647
  let type, props
474
648
  try {
475
649
  const parsed = new URL(text)
476
- if (isSameOriginPrototype(text)) {
650
+ if (isFigmaUrl(text)) {
651
+ type = 'figma-embed'
652
+ props = { url: sanitizeFigmaUrl(text), width: 800, height: 450 }
653
+ } else if (isSameOriginPrototype(text)) {
477
654
  const pathPortion = parsed.pathname + parsed.search + parsed.hash
478
655
  const src = extractPrototypeSrc(pathPortion)
479
656
  type = 'prototype'
@@ -39,3 +39,11 @@ export function addWidget(name, { type, props, position }) {
39
39
  export function removeWidget(name, widgetId) {
40
40
  return request('/widget', 'DELETE', { name, widgetId })
41
41
  }
42
+
43
+ export function uploadImage(dataUrl, canvasName) {
44
+ return request('/image', 'POST', { dataUrl, canvasName })
45
+ }
46
+
47
+ export function toggleImagePrivacy(filename) {
48
+ return request('/image/toggle-private', 'POST', { filename })
49
+ }
@@ -0,0 +1,106 @@
1
+ import { forwardRef, useImperativeHandle, useMemo, useCallback, useState } from 'react'
2
+ import WidgetWrapper from './WidgetWrapper.jsx'
3
+ import { readProp } from './widgetProps.js'
4
+ import { schemas } from './widgetConfig.js'
5
+ import { toFigmaEmbedUrl, getFigmaTitle, getFigmaType, isFigmaUrl } from './figmaUrl.js'
6
+ import styles from './FigmaEmbed.module.css'
7
+
8
+ const figmaEmbedSchema = schemas['figma-embed']
9
+
10
+ /** Inline Figma logo SVG */
11
+ function FigmaLogo() {
12
+ return (
13
+ <svg className={styles.figmaLogo} viewBox="0 0 38 57" fill="none" aria-hidden="true">
14
+ <path d="M19 28.5a9.5 9.5 0 1 1 19 0 9.5 9.5 0 0 1-19 0z" fill="#1ABCFE" />
15
+ <path d="M0 47.5A9.5 9.5 0 0 1 9.5 38H19v9.5a9.5 9.5 0 1 1-19 0z" fill="#0ACF83" />
16
+ <path d="M19 0v19h9.5a9.5 9.5 0 1 0 0-19H19z" fill="#FF7262" />
17
+ <path d="M0 9.5A9.5 9.5 0 0 0 9.5 19H19V0H9.5A9.5 9.5 0 0 0 0 9.5z" fill="#F24E1E" />
18
+ <path d="M0 28.5A9.5 9.5 0 0 0 9.5 38H19V19H9.5A9.5 9.5 0 0 0 0 28.5z" fill="#A259FF" />
19
+ </svg>
20
+ )
21
+ }
22
+
23
+ const TYPE_LABELS = { board: 'Board', design: 'Design', proto: 'Prototype' }
24
+
25
+ export default forwardRef(function FigmaEmbed({ props, onUpdate }, ref) {
26
+ const url = readProp(props, 'url', figmaEmbedSchema)
27
+ const width = readProp(props, 'width', figmaEmbedSchema)
28
+ const height = readProp(props, 'height', figmaEmbedSchema)
29
+
30
+ const [interactive, setInteractive] = useState(false)
31
+
32
+ // Validate URL at render time — only embed known Figma URLs
33
+ const isValid = useMemo(() => isFigmaUrl(url), [url])
34
+ const embedUrl = useMemo(() => (isValid ? toFigmaEmbedUrl(url) : ''), [url, isValid])
35
+ const title = useMemo(() => (url ? getFigmaTitle(url) : 'Figma'), [url])
36
+ const figmaType = useMemo(() => getFigmaType(url), [url])
37
+ const typeLabel = figmaType ? TYPE_LABELS[figmaType] : 'Figma'
38
+
39
+ const enterInteractive = useCallback(() => setInteractive(true), [])
40
+
41
+ useImperativeHandle(ref, () => ({
42
+ handleAction(actionId) {
43
+ if (actionId === 'open-external') {
44
+ if (url) window.open(url, '_blank', 'noopener')
45
+ }
46
+ },
47
+ }), [url])
48
+
49
+ return (
50
+ <WidgetWrapper>
51
+ <div className={styles.embed} style={{ width, height }}>
52
+ <div className={styles.header}>
53
+ <FigmaLogo />
54
+ <span className={styles.headerTitle}>{title}</span>
55
+ </div>
56
+ {embedUrl ? (
57
+ <>
58
+ <div className={styles.iframeContainer}>
59
+ <iframe
60
+ src={embedUrl}
61
+ className={styles.iframe}
62
+ title={`Figma ${typeLabel}: ${title}`}
63
+ allowFullScreen
64
+ />
65
+ </div>
66
+ {!interactive && (
67
+ <div
68
+ className={styles.dragOverlay}
69
+ onDoubleClick={enterInteractive}
70
+ />
71
+ )}
72
+ </>
73
+ ) : (
74
+ <div className={styles.iframeContainer} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
75
+ <p style={{ color: 'var(--fgColor-muted, #656d76)', fontSize: 14, fontStyle: 'italic' }}>
76
+ No Figma URL
77
+ </p>
78
+ </div>
79
+ )}
80
+ </div>
81
+ <div
82
+ className={styles.resizeHandle}
83
+ onMouseDown={(e) => {
84
+ e.stopPropagation()
85
+ e.preventDefault()
86
+ const startX = e.clientX
87
+ const startY = e.clientY
88
+ const startW = width
89
+ const startH = height
90
+ function onMove(ev) {
91
+ const newW = Math.max(200, startW + ev.clientX - startX)
92
+ const newH = Math.max(150, startH + ev.clientY - startY)
93
+ onUpdate?.({ width: newW, height: newH })
94
+ }
95
+ function onUp() {
96
+ document.removeEventListener('mousemove', onMove)
97
+ document.removeEventListener('mouseup', onUp)
98
+ }
99
+ document.addEventListener('mousemove', onMove)
100
+ document.addEventListener('mouseup', onUp)
101
+ }}
102
+ onPointerDown={(e) => e.stopPropagation()}
103
+ />
104
+ </WidgetWrapper>
105
+ )
106
+ })
@@ -0,0 +1,83 @@
1
+ .embed {
2
+ position: relative;
3
+ overflow: hidden;
4
+ background: var(--bgColor-default, #ffffff);
5
+ border: 3px solid var(--borderColor-default, #d0d7de);
6
+ border-radius: 8px;
7
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
8
+ }
9
+
10
+ .header {
11
+ display: flex;
12
+ align-items: center;
13
+ gap: 6px;
14
+ padding: 6px 10px;
15
+ font-size: 12px;
16
+ font-weight: 500;
17
+ color: var(--fgColor-muted, #656d76);
18
+ background: var(--bgColor-muted, #f6f8fa);
19
+ border-bottom: 1px solid var(--borderColor-muted, #d8dee4);
20
+ white-space: nowrap;
21
+ overflow: hidden;
22
+ text-overflow: ellipsis;
23
+ user-select: none;
24
+ }
25
+
26
+ .figmaLogo {
27
+ width: 14px;
28
+ height: 14px;
29
+ flex-shrink: 0;
30
+ }
31
+
32
+ .headerTitle {
33
+ overflow: hidden;
34
+ text-overflow: ellipsis;
35
+ }
36
+
37
+ .iframeContainer {
38
+ width: 100%;
39
+ height: calc(100% - 31px); /* subtract header height */
40
+ overflow: hidden;
41
+ }
42
+
43
+ .iframe {
44
+ width: 100%;
45
+ height: calc(100% + 24px); /* clip Figma's built-in bottom toolbar */
46
+ border: none;
47
+ display: block;
48
+ }
49
+
50
+ .dragOverlay {
51
+ position: absolute;
52
+ inset: 0;
53
+ z-index: 1;
54
+ cursor: grab;
55
+ }
56
+
57
+ .resizeHandle {
58
+ position: absolute;
59
+ bottom: 0;
60
+ right: 0;
61
+ width: 16px;
62
+ height: 16px;
63
+ cursor: nwse-resize;
64
+ background: linear-gradient(
65
+ 135deg,
66
+ transparent 40%,
67
+ var(--borderColor-muted, rgba(0, 0, 0, 0.15)) 40%,
68
+ var(--borderColor-muted, rgba(0, 0, 0, 0.15)) 50%,
69
+ transparent 50%,
70
+ transparent 65%,
71
+ var(--borderColor-muted, rgba(0, 0, 0, 0.15)) 65%,
72
+ var(--borderColor-muted, rgba(0, 0, 0, 0.15)) 75%,
73
+ transparent 75%
74
+ );
75
+ opacity: 0;
76
+ transition: opacity 150ms;
77
+ z-index: 2;
78
+ }
79
+
80
+ .embed:hover ~ .resizeHandle,
81
+ .resizeHandle:hover {
82
+ opacity: 1;
83
+ }
@@ -0,0 +1,91 @@
1
+ import { useRef, useCallback, useState, forwardRef, useImperativeHandle } from 'react'
2
+ import WidgetWrapper from './WidgetWrapper.jsx'
3
+ import ResizeHandle from './ResizeHandle.jsx'
4
+ import { readProp } from './widgetProps.js'
5
+ import { schemas } from './widgetConfig.js'
6
+ import { toggleImagePrivacy } from '../canvasApi.js'
7
+ import styles from './ImageWidget.module.css'
8
+
9
+ const imageSchema = schemas['image']
10
+
11
+ function getImageUrl(src) {
12
+ if (!src) return ''
13
+ const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
14
+ return `${base}/_storyboard/canvas/images/${src}`
15
+ }
16
+
17
+ /**
18
+ * Canvas widget that displays a pasted image.
19
+ * Supports aspect-ratio locked resize and privacy toggle.
20
+ */
21
+ const ImageWidget = forwardRef(function ImageWidget({ props, onUpdate }, ref) {
22
+ const containerRef = useRef(null)
23
+ const [naturalRatio, setNaturalRatio] = useState(null)
24
+
25
+ const src = readProp(props, 'src', imageSchema)
26
+ const isPrivate = readProp(props, 'private', imageSchema)
27
+ const width = readProp(props, 'width', imageSchema)
28
+ const height = readProp(props, 'height', imageSchema)
29
+
30
+ const handleImageLoad = useCallback((e) => {
31
+ const img = e.target
32
+ if (img.naturalWidth && img.naturalHeight) {
33
+ setNaturalRatio(img.naturalWidth / img.naturalHeight)
34
+ }
35
+ }, [])
36
+
37
+ const handleResize = useCallback((newWidth) => {
38
+ const ratio = naturalRatio || (width && height ? width / height : 4 / 3)
39
+ const newHeight = Math.round(newWidth / ratio)
40
+ onUpdate?.({ width: newWidth, height: newHeight })
41
+ }, [naturalRatio, width, height, onUpdate])
42
+
43
+ useImperativeHandle(ref, () => ({
44
+ handleAction(actionId) {
45
+ if (actionId === 'toggle-private') {
46
+ if (!src) return
47
+ toggleImagePrivacy(src).then((result) => {
48
+ if (result.success) {
49
+ onUpdate?.({ src: result.filename, private: result.private })
50
+ }
51
+ }).catch((err) => {
52
+ console.error('[canvas] Failed to toggle image privacy:', err)
53
+ })
54
+ }
55
+ }
56
+ }), [src, onUpdate])
57
+
58
+ if (!src) return null
59
+
60
+ const sizeStyle = {}
61
+ if (typeof width === 'number') sizeStyle.width = `${width}px`
62
+
63
+ return (
64
+ <WidgetWrapper className={styles.imageWrapper}>
65
+ <div ref={containerRef} className={styles.container} style={sizeStyle}>
66
+ <div className={styles.frame}>
67
+ <img
68
+ src={getImageUrl(src)}
69
+ alt=""
70
+ className={styles.image}
71
+ onLoad={handleImageLoad}
72
+ draggable={false}
73
+ />
74
+ {isPrivate && (
75
+ <span className={styles.privateBadge} title="Private — not committed to git">
76
+ Private
77
+ </span>
78
+ )}
79
+ </div>
80
+ <ResizeHandle
81
+ targetRef={containerRef}
82
+ minWidth={100}
83
+ minHeight={60}
84
+ onResize={(w) => handleResize(w)}
85
+ />
86
+ </div>
87
+ </WidgetWrapper>
88
+ )
89
+ })
90
+
91
+ export default ImageWidget
@@ -0,0 +1,39 @@
1
+ .imageWrapper {
2
+ min-width: unset;
3
+ }
4
+
5
+ .container {
6
+ position: relative;
7
+ overflow: hidden;
8
+ min-width: 100px;
9
+ }
10
+
11
+ .frame {
12
+ position: relative;
13
+ width: 100%;
14
+ box-sizing: border-box;
15
+ }
16
+
17
+ .image {
18
+ display: block;
19
+ width: 100%;
20
+ height: auto;
21
+ border-radius: 4px;
22
+ user-select: none;
23
+ pointer-events: none;
24
+ }
25
+
26
+ .privateBadge {
27
+ position: absolute;
28
+ top: 20px;
29
+ right: 20px;
30
+ padding: 2px 6px;
31
+ border-radius: 4px;
32
+ font-size: 10px;
33
+ font-weight: 600;
34
+ line-height: 1.4;
35
+ letter-spacing: 0.02em;
36
+ color: var(--fgColor-onEmphasis, #fff);
37
+ background: var(--bgColor-neutral-emphasis, rgba(0, 0, 0, 0.55));
38
+ pointer-events: none;
39
+ }
@@ -186,6 +186,8 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate }, ref) {
186
186
  handleAction(actionId) {
187
187
  if (actionId === 'edit') {
188
188
  setEditing(true)
189
+ } else if (actionId === 'open-external') {
190
+ if (rawSrc) window.open(rawSrc, '_blank', 'noopener')
189
191
  } else if (actionId === 'zoom-in') {
190
192
  const step = zoom < 75 ? 5 : 25
191
193
  onUpdate?.({ zoom: Math.min(200, zoom + step) })
@@ -194,7 +196,7 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate }, ref) {
194
196
  onUpdate?.({ zoom: Math.max(25, zoom - step) })
195
197
  }
196
198
  },
197
- }), [zoom, onUpdate])
199
+ }), [rawSrc, zoom, onUpdate])
198
200
 
199
201
  function handlePickRoute(route) {
200
202
  onUpdate?.({ src: route })
@@ -1,4 +1,6 @@
1
1
  import { useState, useCallback, useRef } from 'react'
2
+ import { Tooltip } from '@primer/react'
3
+ import { EyeIcon as OcticonEye, EyeClosedIcon as OcticonEyeClosed } from '@primer/octicons-react'
2
4
  import styles from './WidgetChrome.module.css'
3
5
 
4
6
  const STICKY_NOTE_COLORS = {
@@ -42,11 +44,29 @@ function EditIcon() {
42
44
  )
43
45
  }
44
46
 
47
+ function OpenExternalIcon() {
48
+ return (
49
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
50
+ <path d="M3.75 2h3.5a.75.75 0 0 1 0 1.5h-3.5a.25.25 0 0 0-.25.25v8.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25v-3.5a.75.75 0 0 1 1.5 0v3.5A1.75 1.75 0 0 1 12.25 14h-8.5A1.75 1.75 0 0 1 2 12.25v-8.5C2 2.784 2.784 2 3.75 2Zm6.854-1h4.146a.25.25 0 0 1 .25.25v4.146a.25.25 0 0 1-.427.177L13.03 4.03 9.28 7.78a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042l3.75-3.75-1.543-1.543A.25.25 0 0 1 10.604 1Z" />
51
+ </svg>
52
+ )
53
+ }
54
+
55
+ function EyeIcon() {
56
+ return <OcticonEye size={12} />
57
+ }
58
+
59
+ function EyeClosedIcon() {
60
+ return <OcticonEyeClosed size={12} />
61
+ }
62
+
45
63
  const ACTION_ICONS = {
46
64
  'delete': DeleteIcon,
47
65
  'zoom-in': ZoomInIcon,
48
66
  'zoom-out': ZoomOutIcon,
49
67
  'edit': EditIcon,
68
+ 'open-external': OpenExternalIcon,
69
+ 'toggle-private': EyeIcon,
50
70
  }
51
71
 
52
72
  const ACTION_LABELS = {
@@ -54,6 +74,8 @@ const ACTION_LABELS = {
54
74
  'zoom-in': 'Zoom in',
55
75
  'zoom-out': 'Zoom out',
56
76
  'edit': 'Edit',
77
+ 'open-external': 'Open in new tab',
78
+ 'toggle-private': 'Make private',
57
79
  }
58
80
 
59
81
  /**
@@ -145,16 +167,11 @@ export default function WidgetChrome({
145
167
  if (!pointerStartPos.current) return
146
168
  const start = pointerStartPos.current
147
169
  pointerStartPos.current = null
148
- // Only toggle selection if the pointer stayed close (click, not drag)
149
170
  const dist = Math.hypot(e.clientX - start.x, e.clientY - start.y)
150
171
  if (dist > 10) return
151
172
  e.stopPropagation()
152
- if (selected) {
153
- onDeselect?.()
154
- } else {
155
- onSelect?.()
156
- }
157
- }, [selected, onSelect, onDeselect])
173
+ onSelect?.()
174
+ }, [onSelect])
158
175
 
159
176
  const handleActionClick = useCallback((actionId, e) => {
160
177
  e.stopPropagation()
@@ -211,17 +228,29 @@ export default function WidgetChrome({
211
228
  }
212
229
 
213
230
  if (feature.type === 'action') {
214
- const Icon = ACTION_ICONS[feature.action]
231
+ let Icon = ACTION_ICONS[feature.action]
232
+ let label = ACTION_LABELS[feature.action] || feature.action
233
+
234
+ // Toggle-private: swap icon/label based on current state
235
+ if (feature.action === 'toggle-private') {
236
+ if (widgetProps?.private) {
237
+ Icon = EyeClosedIcon
238
+ label = 'Private image — only visible locally'
239
+ } else {
240
+ label = 'Published image — deployed with canvas'
241
+ }
242
+ }
243
+
215
244
  return (
216
- <button
217
- key={feature.id}
218
- className={styles.featureBtn}
219
- onClick={(e) => handleActionClick(feature.action, e)}
220
- title={ACTION_LABELS[feature.action] || feature.action}
221
- aria-label={ACTION_LABELS[feature.action] || feature.action}
222
- >
223
- {Icon ? <Icon /> : feature.action}
224
- </button>
245
+ <Tooltip key={feature.id} text={label} direction="n">
246
+ <button
247
+ className={styles.featureBtn}
248
+ onClick={(e) => handleActionClick(feature.action, e)}
249
+ aria-label={label}
250
+ >
251
+ {Icon ? <Icon /> : feature.action}
252
+ </button>
253
+ </Tooltip>
225
254
  )
226
255
  }
227
256
 
@@ -229,14 +258,15 @@ export default function WidgetChrome({
229
258
  })}
230
259
  </div>
231
260
 
232
- <button
233
- className={`tc-drag-handle ${styles.selectHandle} ${selected ? styles.selectHandleActive : ''}`}
234
- onPointerDown={handleHandlePointerDown}
235
- onPointerUp={handleHandlePointerUp}
236
- title={selected ? 'Deselect' : 'Select'}
237
- aria-label={selected ? 'Deselect widget' : 'Select widget'}
238
- aria-pressed={selected}
239
- />
261
+ <Tooltip text="Select" direction="n">
262
+ <button
263
+ className={`tc-drag-handle ${styles.selectHandle} ${selected ? styles.selectHandleActive : ''}`}
264
+ onPointerDown={handleHandlePointerDown}
265
+ onPointerUp={handleHandlePointerUp}
266
+ aria-label="Select widget"
267
+ aria-pressed={selected}
268
+ />
269
+ </Tooltip>
240
270
  </div>
241
271
  </div>
242
272
  </div>
@@ -115,8 +115,8 @@
115
115
  .selectHandle {
116
116
  all: unset;
117
117
  cursor: grab;
118
- width: 18px;
119
- height: 12px;
118
+ width: 16px;
119
+ height: 16px;
120
120
  border-radius: 4px;
121
121
  border: 1.6px solid var(--borderColor-muted, #d0d7de);
122
122
  background: var(--bgColor-default, #ffffff);
@@ -159,9 +159,8 @@
159
159
 
160
160
  .colorPopup {
161
161
  position: absolute;
162
- bottom: calc(100% + 6px);
163
- left: 50%;
164
- transform: translateX(-50%);
162
+ top: calc(100% + 2px);
163
+ left: -4px;
165
164
  display: flex;
166
165
  gap: 5px;
167
166
  padding: 6px 10px;
@@ -177,6 +176,17 @@
177
176
  white-space: nowrap;
178
177
  }
179
178
 
179
+ /* Invisible bridge from the trigger button to the popup so mouse
180
+ travel doesn't create a gap that closes the picker. */
181
+ .colorPopup::before {
182
+ content: '';
183
+ position: absolute;
184
+ bottom: 100%;
185
+ left: 0;
186
+ right: 0;
187
+ height: 8px;
188
+ }
189
+
180
190
  :global([data-sb-canvas-theme^='dark']) .colorPopup {
181
191
  background: var(--bgColor-muted, #161b22);
182
192
  box-shadow:
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Figma URL utilities — detection, sanitization, and embed URL transformation.
3
+ *
4
+ * Supports three Figma link types:
5
+ * - Board: figma.com/board/{key}/{name}
6
+ * - Design: figma.com/design/{key}/{name}
7
+ * - Proto: figma.com/proto/{key}/{name}
8
+ */
9
+
10
+ const FIGMA_HOST_RE = /^(www\.)?figma\.com$/
11
+ const FIGMA_PATH_RE = /^\/(board|design|proto)\/[A-Za-z0-9]+/
12
+
13
+ /** Params to strip from stored/embed URLs (session/tracking tokens). */
14
+ const STRIP_PARAMS = new Set(['t'])
15
+
16
+ /**
17
+ * Check whether a URL string is a Figma board, design, or prototype link.
18
+ * @param {string} url
19
+ * @returns {boolean}
20
+ */
21
+ export function isFigmaUrl(url) {
22
+ try {
23
+ const parsed = new URL(url)
24
+ return FIGMA_HOST_RE.test(parsed.hostname) && FIGMA_PATH_RE.test(parsed.pathname)
25
+ } catch {
26
+ return false
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Return the Figma link type: 'board', 'design', or 'proto'.
32
+ * Returns null for non-Figma URLs.
33
+ * @param {string} url
34
+ * @returns {'board' | 'design' | 'proto' | null}
35
+ */
36
+ export function getFigmaType(url) {
37
+ try {
38
+ const parsed = new URL(url)
39
+ if (!FIGMA_HOST_RE.test(parsed.hostname)) return null
40
+ const match = parsed.pathname.match(FIGMA_PATH_RE)
41
+ if (!match) return null
42
+ return match[1]
43
+ } catch {
44
+ return null
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Sanitize a Figma URL for storage — strips tracking params like `t`.
50
+ * Returns a canonical www.figma.com URL safe to persist in canvas data.
51
+ * @param {string} url — raw pasted Figma URL
52
+ * @returns {string} sanitized URL
53
+ */
54
+ export function sanitizeFigmaUrl(url) {
55
+ try {
56
+ const parsed = new URL(url)
57
+ if (!FIGMA_HOST_RE.test(parsed.hostname)) return url
58
+ // Normalize to www.figma.com
59
+ parsed.hostname = 'www.figma.com'
60
+ for (const key of STRIP_PARAMS) {
61
+ parsed.searchParams.delete(key)
62
+ }
63
+ return parsed.toString()
64
+ } catch {
65
+ return url
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Transform a Figma URL into its embed counterpart.
71
+ *
72
+ * - Replaces host with `embed.figma.com`
73
+ * - Strips tracking params (`t`)
74
+ * - Appends `embed-host=share`
75
+ *
76
+ * @param {string} url — original Figma URL
77
+ * @returns {string} embed URL, or the original URL if it can't be transformed
78
+ */
79
+ export function toFigmaEmbedUrl(url) {
80
+ try {
81
+ const parsed = new URL(url)
82
+ if (!FIGMA_HOST_RE.test(parsed.hostname)) return url
83
+
84
+ parsed.hostname = 'embed.figma.com'
85
+
86
+ // Strip tracking/session params
87
+ for (const key of STRIP_PARAMS) {
88
+ parsed.searchParams.delete(key)
89
+ }
90
+
91
+ // Ensure embed-host is set
92
+ parsed.searchParams.set('embed-host', 'share')
93
+
94
+ return parsed.toString()
95
+ } catch {
96
+ return url
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Extract a human-readable title from a Figma URL.
102
+ * Uses the name segment from the path (e.g. "Security-Products-HQ").
103
+ * @param {string} url
104
+ * @returns {string}
105
+ */
106
+ export function getFigmaTitle(url) {
107
+ try {
108
+ const parsed = new URL(url)
109
+ // Path: /board|design|proto/{key}/{name}
110
+ const segments = parsed.pathname.split('/').filter(Boolean)
111
+ if (segments.length >= 3) {
112
+ return segments[2].replace(/-/g, ' ')
113
+ }
114
+ return 'Figma'
115
+ } catch {
116
+ return 'Figma'
117
+ }
118
+ }
@@ -0,0 +1,139 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { isFigmaUrl, getFigmaType, toFigmaEmbedUrl, getFigmaTitle, sanitizeFigmaUrl } from './figmaUrl.js'
3
+
4
+ describe('isFigmaUrl', () => {
5
+ it('detects board URLs', () => {
6
+ expect(isFigmaUrl('https://www.figma.com/board/QlwxSiYxYQsHmnLNYpQRtR/Security-Products-HQ?node-id=0-1&t=XBF45Am0VgicAITG-0')).toBe(true)
7
+ })
8
+
9
+ it('detects design URLs', () => {
10
+ expect(isFigmaUrl('https://www.figma.com/design/i8jI8RuxPnGQGp2QllfAr7/Darby-s-copilot-metric-sandbox?node-id=103-4739')).toBe(true)
11
+ })
12
+
13
+ it('detects proto URLs', () => {
14
+ expect(isFigmaUrl('https://www.figma.com/proto/i8jI8RuxPnGQGp2QllfAr7/Darby-s-copilot-metric-sandbox?node-id=122-9632&p=f&t=9XSi047pSbt81sZS-0&scaling=min-zoom')).toBe(true)
15
+ })
16
+
17
+ it('works without www prefix', () => {
18
+ expect(isFigmaUrl('https://figma.com/board/abc123/My-Board')).toBe(true)
19
+ })
20
+
21
+ it('rejects non-Figma URLs', () => {
22
+ expect(isFigmaUrl('https://example.com/board/abc')).toBe(false)
23
+ expect(isFigmaUrl('https://www.figma.com/file/abc')).toBe(false)
24
+ expect(isFigmaUrl('not a url')).toBe(false)
25
+ expect(isFigmaUrl('')).toBe(false)
26
+ })
27
+ })
28
+
29
+ describe('getFigmaType', () => {
30
+ it('returns board for board URLs', () => {
31
+ expect(getFigmaType('https://www.figma.com/board/QlwxSiYxYQsHmnLNYpQRtR/Name')).toBe('board')
32
+ })
33
+
34
+ it('returns design for design URLs', () => {
35
+ expect(getFigmaType('https://www.figma.com/design/i8jI8RuxPnGQGp2QllfAr7/Name')).toBe('design')
36
+ })
37
+
38
+ it('returns proto for proto URLs', () => {
39
+ expect(getFigmaType('https://www.figma.com/proto/i8jI8RuxPnGQGp2QllfAr7/Name')).toBe('proto')
40
+ })
41
+
42
+ it('returns null for non-Figma URLs', () => {
43
+ expect(getFigmaType('https://example.com')).toBeNull()
44
+ })
45
+ })
46
+
47
+ describe('toFigmaEmbedUrl', () => {
48
+ it('transforms board URL', () => {
49
+ const input = 'https://www.figma.com/board/QlwxSiYxYQsHmnLNYpQRtR/Security-Products-HQ?node-id=0-1&t=XBF45Am0VgicAITG-0'
50
+ const result = toFigmaEmbedUrl(input)
51
+ const parsed = new URL(result)
52
+
53
+ expect(parsed.hostname).toBe('embed.figma.com')
54
+ expect(parsed.pathname).toBe('/board/QlwxSiYxYQsHmnLNYpQRtR/Security-Products-HQ')
55
+ expect(parsed.searchParams.get('node-id')).toBe('0-1')
56
+ expect(parsed.searchParams.get('embed-host')).toBe('share')
57
+ expect(parsed.searchParams.has('t')).toBe(false)
58
+ })
59
+
60
+ it('transforms design URL', () => {
61
+ const input = 'https://www.figma.com/design/i8jI8RuxPnGQGp2QllfAr7/Darby-s-copilot-metric-sandbox?node-id=103-4739'
62
+ const result = toFigmaEmbedUrl(input)
63
+ const parsed = new URL(result)
64
+
65
+ expect(parsed.hostname).toBe('embed.figma.com')
66
+ expect(parsed.pathname).toBe('/design/i8jI8RuxPnGQGp2QllfAr7/Darby-s-copilot-metric-sandbox')
67
+ expect(parsed.searchParams.get('node-id')).toBe('103-4739')
68
+ expect(parsed.searchParams.get('embed-host')).toBe('share')
69
+ })
70
+
71
+ it('transforms proto URL and preserves relevant params', () => {
72
+ const input = 'https://www.figma.com/proto/i8jI8RuxPnGQGp2QllfAr7/Darby-s-copilot-metric-sandbox?node-id=122-9632&p=f&t=9XSi047pSbt81sZS-0&scaling=min-zoom&content-scaling=fixed&page-id=103%3A4739&starting-point-node-id=140%3A5949'
73
+ const result = toFigmaEmbedUrl(input)
74
+ const parsed = new URL(result)
75
+
76
+ expect(parsed.hostname).toBe('embed.figma.com')
77
+ expect(parsed.pathname).toBe('/proto/i8jI8RuxPnGQGp2QllfAr7/Darby-s-copilot-metric-sandbox')
78
+ expect(parsed.searchParams.get('node-id')).toBe('122-9632')
79
+ expect(parsed.searchParams.get('p')).toBe('f')
80
+ expect(parsed.searchParams.get('scaling')).toBe('min-zoom')
81
+ expect(parsed.searchParams.get('content-scaling')).toBe('fixed')
82
+ expect(parsed.searchParams.get('page-id')).toBe('103:4739')
83
+ expect(parsed.searchParams.get('starting-point-node-id')).toBe('140:5949')
84
+ expect(parsed.searchParams.get('embed-host')).toBe('share')
85
+ expect(parsed.searchParams.has('t')).toBe(false)
86
+ })
87
+
88
+ it('returns original URL for non-Figma URLs', () => {
89
+ expect(toFigmaEmbedUrl('https://example.com')).toBe('https://example.com')
90
+ })
91
+ })
92
+
93
+ describe('getFigmaTitle', () => {
94
+ it('extracts title from board URL', () => {
95
+ expect(getFigmaTitle('https://www.figma.com/board/QlwxSiYxYQsHmnLNYpQRtR/Security-Products-HQ')).toBe('Security Products HQ')
96
+ })
97
+
98
+ it('extracts title from design URL', () => {
99
+ expect(getFigmaTitle('https://www.figma.com/design/i8jI8RuxPnGQGp2QllfAr7/Darby-s-copilot-metric-sandbox')).toBe("Darby s copilot metric sandbox")
100
+ })
101
+
102
+ it('returns Figma for URLs without name segment', () => {
103
+ expect(getFigmaTitle('https://www.figma.com/board/abc')).toBe('Figma')
104
+ })
105
+ })
106
+
107
+ describe('sanitizeFigmaUrl', () => {
108
+ it('strips tracking param and normalizes to www.figma.com', () => {
109
+ const input = 'https://www.figma.com/board/QlwxSiYxYQsHmnLNYpQRtR/Security-Products-HQ?node-id=0-1&t=XBF45Am0VgicAITG-0'
110
+ const result = sanitizeFigmaUrl(input)
111
+ const parsed = new URL(result)
112
+
113
+ expect(parsed.hostname).toBe('www.figma.com')
114
+ expect(parsed.searchParams.get('node-id')).toBe('0-1')
115
+ expect(parsed.searchParams.has('t')).toBe(false)
116
+ })
117
+
118
+ it('normalizes figma.com to www.figma.com', () => {
119
+ const input = 'https://figma.com/board/abc/Name?node-id=0-1'
120
+ const result = sanitizeFigmaUrl(input)
121
+ expect(new URL(result).hostname).toBe('www.figma.com')
122
+ })
123
+
124
+ it('preserves all non-tracking params for proto URLs', () => {
125
+ const input = 'https://www.figma.com/proto/abc/Name?node-id=1-2&p=f&t=TOKEN&scaling=min-zoom&page-id=103%3A4739'
126
+ const result = sanitizeFigmaUrl(input)
127
+ const parsed = new URL(result)
128
+
129
+ expect(parsed.searchParams.get('node-id')).toBe('1-2')
130
+ expect(parsed.searchParams.get('p')).toBe('f')
131
+ expect(parsed.searchParams.get('scaling')).toBe('min-zoom')
132
+ expect(parsed.searchParams.get('page-id')).toBe('103:4739')
133
+ expect(parsed.searchParams.has('t')).toBe(false)
134
+ })
135
+
136
+ it('returns non-Figma URLs unchanged', () => {
137
+ expect(sanitizeFigmaUrl('https://example.com')).toBe('https://example.com')
138
+ })
139
+ })
@@ -2,6 +2,8 @@ import StickyNote from './StickyNote.jsx'
2
2
  import MarkdownBlock from './MarkdownBlock.jsx'
3
3
  import PrototypeEmbed from './PrototypeEmbed.jsx'
4
4
  import LinkPreview from './LinkPreview.jsx'
5
+ import ImageWidget from './ImageWidget.jsx'
6
+ import FigmaEmbed from './FigmaEmbed.jsx'
5
7
 
6
8
  /**
7
9
  * Maps widget type strings to their React components.
@@ -12,6 +14,8 @@ export const widgetRegistry = {
12
14
  'markdown': MarkdownBlock,
13
15
  'prototype': PrototypeEmbed,
14
16
  'link-preview': LinkPreview,
17
+ 'image': ImageWidget,
18
+ 'figma-embed': FigmaEmbed,
15
19
  }
16
20
 
17
21
  /**
@@ -70,10 +70,10 @@ export function getWidgetMeta(type) {
70
70
 
71
71
  /**
72
72
  * Get all widget types as an array of { type, label, icon } for menus.
73
- * Excludes link-preview which is created via paste only.
73
+ * Excludes link-preview, image, and figma-embed which are created via paste only.
74
74
  */
75
75
  export function getMenuWidgetTypes() {
76
76
  return Object.entries(widgetTypes)
77
- .filter(([type]) => type !== 'link-preview')
77
+ .filter(([type]) => type !== 'link-preview' && type !== 'image' && type !== 'figma-embed')
78
78
  .map(([type, def]) => ({ type, label: def.label, icon: def.icon }))
79
79
  }
@@ -127,3 +127,5 @@ export const stickyNoteSchema = schemas['sticky-note']
127
127
  export const markdownSchema = schemas['markdown']
128
128
  export const prototypeEmbedSchema = schemas['prototype']
129
129
  export const linkPreviewSchema = schemas['link-preview']
130
+ export const imageSchema = schemas['image']
131
+ export const figmaEmbedSchema = schemas['figma-embed']