@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.
- package/package.json +3 -3
- package/src/canvas/CanvasControls.jsx +2 -59
- package/src/canvas/CanvasControls.module.css +0 -29
- package/src/canvas/CanvasPage.bridge.test.jsx +4 -0
- package/src/canvas/CanvasPage.jsx +416 -22
- package/src/canvas/canvasApi.js +8 -0
- package/src/canvas/computeCanvasBounds.test.js +121 -0
- package/src/canvas/useUndoRedo.js +86 -0
- package/src/canvas/useUndoRedo.test.js +231 -0
- package/src/canvas/widgets/FigmaEmbed.jsx +106 -0
- package/src/canvas/widgets/FigmaEmbed.module.css +83 -0
- package/src/canvas/widgets/ImageWidget.jsx +91 -0
- package/src/canvas/widgets/ImageWidget.module.css +39 -0
- package/src/canvas/widgets/PrototypeEmbed.jsx +20 -1
- package/src/canvas/widgets/WidgetChrome.jsx +67 -26
- package/src/canvas/widgets/WidgetChrome.module.css +15 -5
- package/src/canvas/widgets/figmaUrl.js +118 -0
- package/src/canvas/widgets/figmaUrl.test.js +139 -0
- package/src/canvas/widgets/index.js +4 -0
- package/src/canvas/widgets/widgetConfig.js +2 -2
- package/src/canvas/widgets/widgetProps.js +2 -0
|
@@ -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
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
</
|
|
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
|
-
<
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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:
|
|
119
|
-
height:
|
|
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
|
-
|
|
163
|
-
left:
|
|
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
|
|
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']
|