@dfosco/storyboard-react 3.10.0 → 3.11.0-beta.1

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.
@@ -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
+ }
@@ -56,6 +56,7 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate }, ref) {
56
56
  const inputRef = useRef(null)
57
57
  const filterRef = useRef(null)
58
58
  const embedRef = useRef(null)
59
+ const iframeRef = useRef(null)
59
60
 
60
61
  const iframeSrc = useMemo(() => {
61
62
  if (!rawSrc) return ''
@@ -177,6 +178,21 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate }, ref) {
177
178
  return () => document.removeEventListener('storyboard:theme:changed', readToolbarTheme)
178
179
  }, [])
179
180
 
181
+ // Listen for navigation events from the embedded prototype iframe
182
+ useEffect(() => {
183
+ function handleMessage(e) {
184
+ if (e.source !== iframeRef.current?.contentWindow) return
185
+ if (e.data?.type !== 'storyboard:embed:navigate') return
186
+ const newSrc = e.data.src
187
+ if (newSrc && newSrc !== src) {
188
+ const originalSrc = readProp(props, 'originalSrc', prototypeEmbedSchema)
189
+ onUpdate?.({ src: newSrc, originalSrc: originalSrc || src })
190
+ }
191
+ }
192
+ window.addEventListener('message', handleMessage)
193
+ return () => window.removeEventListener('message', handleMessage)
194
+ }, [src, props, onUpdate])
195
+
180
196
  const chromeVars = useMemo(() => getEmbedChromeVars(canvasTheme), [canvasTheme])
181
197
 
182
198
  const enterInteractive = useCallback(() => setInteractive(true), [])
@@ -186,6 +202,8 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate }, ref) {
186
202
  handleAction(actionId) {
187
203
  if (actionId === 'edit') {
188
204
  setEditing(true)
205
+ } else if (actionId === 'open-external') {
206
+ if (rawSrc) window.open(rawSrc, '_blank', 'noopener')
189
207
  } else if (actionId === 'zoom-in') {
190
208
  const step = zoom < 75 ? 5 : 25
191
209
  onUpdate?.({ zoom: Math.min(200, zoom + step) })
@@ -194,7 +212,7 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate }, ref) {
194
212
  onUpdate?.({ zoom: Math.max(25, zoom - step) })
195
213
  }
196
214
  },
197
- }), [zoom, onUpdate])
215
+ }), [rawSrc, zoom, onUpdate])
198
216
 
199
217
  function handlePickRoute(route) {
200
218
  onUpdate?.({ src: route })
@@ -307,6 +325,7 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate }, ref) {
307
325
  <>
308
326
  <div className={styles.iframeContainer}>
309
327
  <iframe
328
+ ref={iframeRef}
310
329
  src={iframeSrc}
311
330
  className={styles.iframe}
312
331
  style={{
@@ -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,39 @@ 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
+
63
+ function CopyIcon() {
64
+ return (
65
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
66
+ <path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z" />
67
+ <path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z" />
68
+ </svg>
69
+ )
70
+ }
71
+
45
72
  const ACTION_ICONS = {
46
73
  'delete': DeleteIcon,
47
74
  'zoom-in': ZoomInIcon,
48
75
  'zoom-out': ZoomOutIcon,
49
76
  'edit': EditIcon,
77
+ 'open-external': OpenExternalIcon,
78
+ 'toggle-private': EyeIcon,
79
+ 'copy': CopyIcon,
50
80
  }
51
81
 
52
82
  const ACTION_LABELS = {
@@ -54,6 +84,9 @@ const ACTION_LABELS = {
54
84
  'zoom-in': 'Zoom in',
55
85
  'zoom-out': 'Zoom out',
56
86
  'edit': 'Edit',
87
+ 'open-external': 'Open in new tab',
88
+ 'toggle-private': 'Make private',
89
+ 'copy': 'Copy widget',
57
90
  }
58
91
 
59
92
  /**
@@ -145,21 +178,16 @@ export default function WidgetChrome({
145
178
  if (!pointerStartPos.current) return
146
179
  const start = pointerStartPos.current
147
180
  pointerStartPos.current = null
148
- // Only toggle selection if the pointer stayed close (click, not drag)
149
181
  const dist = Math.hypot(e.clientX - start.x, e.clientY - start.y)
150
182
  if (dist > 10) return
151
183
  e.stopPropagation()
152
- if (selected) {
153
- onDeselect?.()
154
- } else {
155
- onSelect?.()
156
- }
157
- }, [selected, onSelect, onDeselect])
184
+ onSelect?.()
185
+ }, [onSelect])
158
186
 
159
187
  const handleActionClick = useCallback((actionId, e) => {
160
188
  e.stopPropagation()
161
189
  // Standard actions go through onAction (handled by CanvasPage)
162
- if (actionId === 'delete') {
190
+ if (actionId === 'delete' || actionId === 'copy') {
163
191
  onAction?.(actionId)
164
192
  return
165
193
  }
@@ -211,17 +239,29 @@ export default function WidgetChrome({
211
239
  }
212
240
 
213
241
  if (feature.type === 'action') {
214
- const Icon = ACTION_ICONS[feature.action]
242
+ let Icon = ACTION_ICONS[feature.action]
243
+ let label = ACTION_LABELS[feature.action] || feature.action
244
+
245
+ // Toggle-private: swap icon/label based on current state
246
+ if (feature.action === 'toggle-private') {
247
+ if (widgetProps?.private) {
248
+ Icon = EyeClosedIcon
249
+ label = 'Private image — only visible locally'
250
+ } else {
251
+ label = 'Published image — deployed with canvas'
252
+ }
253
+ }
254
+
215
255
  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>
256
+ <Tooltip key={feature.id} text={label} direction="n">
257
+ <button
258
+ className={styles.featureBtn}
259
+ onClick={(e) => handleActionClick(feature.action, e)}
260
+ aria-label={label}
261
+ >
262
+ {Icon ? <Icon /> : feature.action}
263
+ </button>
264
+ </Tooltip>
225
265
  )
226
266
  }
227
267
 
@@ -229,14 +269,15 @@ export default function WidgetChrome({
229
269
  })}
230
270
  </div>
231
271
 
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
- />
272
+ <Tooltip text="Select" direction="n">
273
+ <button
274
+ className={`tc-drag-handle ${styles.selectHandle} ${selected ? styles.selectHandleActive : ''}`}
275
+ onPointerDown={handleHandlePointerDown}
276
+ onPointerUp={handleHandlePointerUp}
277
+ aria-label="Select widget"
278
+ aria-pressed={selected}
279
+ />
280
+ </Tooltip>
240
281
  </div>
241
282
  </div>
242
283
  </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']