@dfosco/storyboard-react 3.10.0-beta.1 → 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-beta.1",
3
+ "version": "3.11.0-beta.0",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@dfosco/storyboard-core": "3.10.0-beta.1",
7
- "@dfosco/tiny-canvas": "3.10.0-beta.1",
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: () => ({}),
@@ -1,4 +1,5 @@
1
1
  import { createElement, useCallback, useEffect, useRef, useState } from 'react'
2
+ import { flushSync } from 'react-dom'
2
3
  import { Canvas } from '@dfosco/tiny-canvas'
3
4
  import '@dfosco/tiny-canvas/style.css'
4
5
  import { useCanvas } from './useCanvas.js'
@@ -7,9 +8,10 @@ import { getCanvasThemeVars, getCanvasPrimerAttrs } from './canvasTheme.js'
7
8
  import { getWidgetComponent } from './widgets/index.js'
8
9
  import { schemas, getDefaults } from './widgets/widgetProps.js'
9
10
  import { getFeatures } from './widgets/widgetConfig.js'
11
+ import { isFigmaUrl, sanitizeFigmaUrl } from './widgets/figmaUrl.js'
10
12
  import WidgetChrome from './widgets/WidgetChrome.jsx'
11
13
  import ComponentWidget from './widgets/ComponentWidget.jsx'
12
- import { addWidget as addWidgetApi, updateCanvas, removeWidget as removeWidgetApi } from './canvasApi.js'
14
+ import { addWidget as addWidgetApi, updateCanvas, removeWidget as removeWidgetApi, uploadImage } from './canvasApi.js'
13
15
  import styles from './CanvasPage.module.css'
14
16
 
15
17
  const ZOOM_MIN = 25
@@ -55,13 +57,68 @@ function debounce(fn, ms) {
55
57
  }
56
58
  }
57
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
+
58
84
  /**
59
- * Get viewport-center coordinates for placing a new widget.
85
+ * Get viewport-center coordinates in canvas space for placing a new widget.
86
+ * Converts the visible center of the scroll container to unscaled canvas coordinates.
60
87
  */
61
- function getViewportCenter() {
88
+ function getViewportCenter(scrollEl, scale) {
89
+ if (!scrollEl) {
90
+ return { x: 0, y: 0 }
91
+ }
92
+ const cx = scrollEl.scrollLeft + scrollEl.clientWidth / 2
93
+ const cy = scrollEl.scrollTop + scrollEl.clientHeight / 2
62
94
  return {
63
- x: Math.round(window.innerWidth / 2 - 120),
64
- y: Math.round(window.innerHeight / 2 - 80),
95
+ x: Math.round(cx / scale),
96
+ y: Math.round(cy / scale),
97
+ }
98
+ }
99
+
100
+ /** Fallback sizes for widget types without explicit width/height defaults. */
101
+ const WIDGET_FALLBACK_SIZES = {
102
+ 'sticky-note': { width: 180, height: 60 },
103
+ 'markdown': { width: 360, height: 200 },
104
+ 'prototype': { width: 800, height: 600 },
105
+ 'link-preview': { width: 320, height: 120 },
106
+ 'figma-embed': { width: 800, height: 450 },
107
+ 'component': { width: 200, height: 150 },
108
+ 'image': { width: 400, height: 300 },
109
+ }
110
+
111
+ /**
112
+ * Offset a position so the widget's center (not its top-left corner)
113
+ * lands on the given point.
114
+ */
115
+ function centerPositionForWidget(pos, type, props) {
116
+ const fallback = WIDGET_FALLBACK_SIZES[type] || { width: 200, height: 150 }
117
+ const w = props?.width ?? fallback.width
118
+ const h = props?.height ?? fallback.height
119
+ return {
120
+ x: Math.round(pos.x - w / 2),
121
+ y: Math.round(pos.y - h / 2),
65
122
  }
66
123
  }
67
124
 
@@ -140,9 +197,11 @@ export default function CanvasPage({ name }) {
140
197
  const [localWidgets, setLocalWidgets] = useState(canvas?.widgets ?? null)
141
198
  const [trackedCanvas, setTrackedCanvas] = useState(canvas)
142
199
  const [selectedWidgetId, setSelectedWidgetId] = useState(null)
143
- const [zoom, setZoom] = useState(100)
144
- const zoomRef = useRef(100)
200
+ const initialViewport = loadViewportState(name)
201
+ const [zoom, setZoom] = useState(initialViewport?.zoom ?? 100)
202
+ const zoomRef = useRef(initialViewport?.zoom ?? 100)
145
203
  const scrollRef = useRef(null)
204
+ const pendingScrollRestore = useRef(initialViewport)
146
205
  const [canvasTitle, setCanvasTitle] = useState(canvas?.title || name)
147
206
  const titleInputRef = useRef(null)
148
207
  const [localSources, setLocalSources] = useState(canvas?.sources ?? [])
@@ -257,6 +316,92 @@ export default function CanvasPage({ name }) {
257
316
  zoomRef.current = zoom
258
317
  }, [zoom])
259
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
+
368
+ /**
369
+ * Zoom to a new level, anchoring on an optional client-space point.
370
+ * When a cursor position is provided (e.g. from a wheel event), the
371
+ * canvas point under the cursor stays fixed. Otherwise falls back to
372
+ * the viewport center.
373
+ */
374
+ function applyZoom(newZoom, clientX, clientY) {
375
+ const el = scrollRef.current
376
+ const clampedZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, newZoom))
377
+
378
+ if (!el) {
379
+ setZoom(clampedZoom)
380
+ return
381
+ }
382
+
383
+ const oldScale = zoomRef.current / 100
384
+ const newScale = clampedZoom / 100
385
+
386
+ // Anchor point in scroll-container space
387
+ const rect = el.getBoundingClientRect()
388
+ const useViewportCenter = clientX == null || clientY == null
389
+ const anchorX = useViewportCenter ? el.clientWidth / 2 : clientX - rect.left
390
+ const anchorY = useViewportCenter ? el.clientHeight / 2 : clientY - rect.top
391
+
392
+ // Anchor → canvas coordinate
393
+ const canvasX = (el.scrollLeft + anchorX) / oldScale
394
+ const canvasY = (el.scrollTop + anchorY) / oldScale
395
+
396
+ // Synchronous render so the DOM has the new transform before we adjust scroll
397
+ zoomRef.current = clampedZoom
398
+ flushSync(() => setZoom(clampedZoom))
399
+
400
+ // Scroll so the same canvas point stays under the anchor
401
+ el.scrollLeft = canvasX * newScale - anchorX
402
+ el.scrollTop = canvasY * newScale - anchorY
403
+ }
404
+
260
405
  // Signal canvas mount/unmount to CoreUIBar
261
406
  useEffect(() => {
262
407
  window[CANVAS_BRIDGE_STATE_KEY] = { active: true, name, zoom: zoomRef.current }
@@ -278,10 +423,31 @@ export default function CanvasPage({ name }) {
278
423
  }
279
424
  }, [name])
280
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
+
281
446
  // Add a widget by type — used by CanvasControls and CoreUIBar event
282
447
  const addWidget = useCallback(async (type) => {
283
448
  const defaultProps = schemas[type] ? getDefaults(schemas[type]) : {}
284
- const pos = getViewportCenter()
449
+ const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
450
+ const pos = centerPositionForWidget(center, type, defaultProps)
285
451
  try {
286
452
  const result = await addWidgetApi(name, {
287
453
  type,
@@ -310,7 +476,7 @@ export default function CanvasPage({ name }) {
310
476
  function handleZoom(e) {
311
477
  const { zoom: newZoom } = e.detail
312
478
  if (typeof newZoom === 'number') {
313
- setZoom(Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, newZoom)))
479
+ applyZoom(newZoom)
314
480
  }
315
481
  }
316
482
  document.addEventListener('storyboard:canvas:set-zoom', handleZoom)
@@ -352,6 +518,10 @@ export default function CanvasPage({ name }) {
352
518
  if (!selectedWidgetId) return
353
519
  const tag = e.target.tagName
354
520
  if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
521
+ if (e.key === 'Escape') {
522
+ e.preventDefault()
523
+ setSelectedWidgetId(null)
524
+ }
355
525
  if (e.key === 'Delete' || e.key === 'Backspace') {
356
526
  e.preventDefault()
357
527
  handleWidgetRemove(selectedWidgetId)
@@ -362,7 +532,8 @@ export default function CanvasPage({ name }) {
362
532
  return () => document.removeEventListener('keydown', handleKeyDown)
363
533
  }, [selectedWidgetId, handleWidgetRemove])
364
534
 
365
- // 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
366
537
  useEffect(() => {
367
538
  const origin = window.location.origin
368
539
  const basePath = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
@@ -393,10 +564,81 @@ export default function CanvasPage({ name }) {
393
564
  return pathname
394
565
  }
395
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
+
396
634
  async function handlePaste(e) {
397
635
  const tag = e.target.tagName
398
636
  if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
399
637
 
638
+ // Image paste takes priority
639
+ const handledImage = await handleImagePaste(e)
640
+ if (handledImage) return
641
+
400
642
  const text = e.clipboardData?.getData('text/plain')?.trim()
401
643
  if (!text) return
402
644
 
@@ -405,7 +647,10 @@ export default function CanvasPage({ name }) {
405
647
  let type, props
406
648
  try {
407
649
  const parsed = new URL(text)
408
- 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)) {
409
654
  const pathPortion = parsed.pathname + parsed.search + parsed.hash
410
655
  const src = extractPrototypeSrc(pathPortion)
411
656
  type = 'prototype'
@@ -419,7 +664,8 @@ export default function CanvasPage({ name }) {
419
664
  props = { content: text }
420
665
  }
421
666
 
422
- const pos = getViewportCenter()
667
+ const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
668
+ const pos = centerPositionForWidget(center, type, props)
423
669
  try {
424
670
  const result = await addWidgetApi(name, {
425
671
  type,
@@ -449,7 +695,7 @@ export default function CanvasPage({ name }) {
449
695
  const step = Math.trunc(zoomAccum.current)
450
696
  if (step === 0) return
451
697
  zoomAccum.current -= step
452
- setZoom((z) => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z + step)))
698
+ applyZoom(zoomRef.current + step, e.clientX, e.clientY)
453
699
  }
454
700
  document.addEventListener('wheel', handleWheel, { passive: false })
455
701
  return () => document.removeEventListener('wheel', handleWheel)
@@ -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']