@dfosco/storyboard-react 3.10.0 → 3.11.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/canvas/CanvasPage.bridge.test.jsx +4 -0
- package/src/canvas/CanvasPage.jsx +182 -5
- package/src/canvas/canvasApi.js +8 -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 +3 -1
- package/src/canvas/widgets/WidgetChrome.jsx +55 -25
- 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
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dfosco/storyboard-react",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.11.0-beta.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"dependencies": {
|
|
6
|
-
"@dfosco/storyboard-core": "3.
|
|
7
|
-
"@dfosco/tiny-canvas": "3.
|
|
6
|
+
"@dfosco/storyboard-core": "3.11.0-beta.0",
|
|
7
|
+
"@dfosco/tiny-canvas": "3.11.0-beta.0",
|
|
8
8
|
"@neodrag/react": "^2.3.1",
|
|
9
9
|
"glob": "^11.0.0",
|
|
10
10
|
"jsonc-parser": "^3.3.1"
|
|
@@ -54,6 +54,10 @@ vi.mock('./widgets/index.js', () => ({
|
|
|
54
54
|
getWidgetComponent: () => function MockWidget() { return <div>mock widget</div> },
|
|
55
55
|
}))
|
|
56
56
|
|
|
57
|
+
vi.mock('./widgets/WidgetChrome.jsx', () => ({
|
|
58
|
+
default: ({ children }) => <div data-testid="widget-chrome">{children}</div>,
|
|
59
|
+
}))
|
|
60
|
+
|
|
57
61
|
vi.mock('./widgets/widgetProps.js', () => ({
|
|
58
62
|
schemas: {},
|
|
59
63
|
getDefaults: () => ({}),
|
|
@@ -8,9 +8,10 @@ import { getCanvasThemeVars, getCanvasPrimerAttrs } from './canvasTheme.js'
|
|
|
8
8
|
import { getWidgetComponent } from './widgets/index.js'
|
|
9
9
|
import { schemas, getDefaults } from './widgets/widgetProps.js'
|
|
10
10
|
import { getFeatures } from './widgets/widgetConfig.js'
|
|
11
|
+
import { isFigmaUrl, sanitizeFigmaUrl } from './widgets/figmaUrl.js'
|
|
11
12
|
import WidgetChrome from './widgets/WidgetChrome.jsx'
|
|
12
13
|
import ComponentWidget from './widgets/ComponentWidget.jsx'
|
|
13
|
-
import { addWidget as addWidgetApi, updateCanvas, removeWidget as removeWidgetApi } from './canvasApi.js'
|
|
14
|
+
import { addWidget as addWidgetApi, updateCanvas, removeWidget as removeWidgetApi, uploadImage } from './canvasApi.js'
|
|
14
15
|
import styles from './CanvasPage.module.css'
|
|
15
16
|
|
|
16
17
|
const ZOOM_MIN = 25
|
|
@@ -56,6 +57,30 @@ function debounce(fn, ms) {
|
|
|
56
57
|
}
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
/** Per-canvas viewport state persistence (zoom + scroll position). */
|
|
61
|
+
function getViewportStorageKey(canvasName) {
|
|
62
|
+
return `sb-canvas-viewport:${canvasName}`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function loadViewportState(canvasName) {
|
|
66
|
+
try {
|
|
67
|
+
const raw = localStorage.getItem(getViewportStorageKey(canvasName))
|
|
68
|
+
if (!raw) return null
|
|
69
|
+
const state = JSON.parse(raw)
|
|
70
|
+
return {
|
|
71
|
+
zoom: typeof state.zoom === 'number' ? Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, state.zoom)) : null,
|
|
72
|
+
scrollLeft: typeof state.scrollLeft === 'number' ? state.scrollLeft : null,
|
|
73
|
+
scrollTop: typeof state.scrollTop === 'number' ? state.scrollTop : null,
|
|
74
|
+
}
|
|
75
|
+
} catch { return null }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function saveViewportState(canvasName, state) {
|
|
79
|
+
try {
|
|
80
|
+
localStorage.setItem(getViewportStorageKey(canvasName), JSON.stringify(state))
|
|
81
|
+
} catch { /* quota exceeded — non-critical */ }
|
|
82
|
+
}
|
|
83
|
+
|
|
59
84
|
/**
|
|
60
85
|
* Get viewport-center coordinates in canvas space for placing a new widget.
|
|
61
86
|
* Converts the visible center of the scroll container to unscaled canvas coordinates.
|
|
@@ -78,7 +103,9 @@ const WIDGET_FALLBACK_SIZES = {
|
|
|
78
103
|
'markdown': { width: 360, height: 200 },
|
|
79
104
|
'prototype': { width: 800, height: 600 },
|
|
80
105
|
'link-preview': { width: 320, height: 120 },
|
|
106
|
+
'figma-embed': { width: 800, height: 450 },
|
|
81
107
|
'component': { width: 200, height: 150 },
|
|
108
|
+
'image': { width: 400, height: 300 },
|
|
82
109
|
}
|
|
83
110
|
|
|
84
111
|
/**
|
|
@@ -170,9 +197,11 @@ export default function CanvasPage({ name }) {
|
|
|
170
197
|
const [localWidgets, setLocalWidgets] = useState(canvas?.widgets ?? null)
|
|
171
198
|
const [trackedCanvas, setTrackedCanvas] = useState(canvas)
|
|
172
199
|
const [selectedWidgetId, setSelectedWidgetId] = useState(null)
|
|
173
|
-
const
|
|
174
|
-
const
|
|
200
|
+
const initialViewport = loadViewportState(name)
|
|
201
|
+
const [zoom, setZoom] = useState(initialViewport?.zoom ?? 100)
|
|
202
|
+
const zoomRef = useRef(initialViewport?.zoom ?? 100)
|
|
175
203
|
const scrollRef = useRef(null)
|
|
204
|
+
const pendingScrollRestore = useRef(initialViewport)
|
|
176
205
|
const [canvasTitle, setCanvasTitle] = useState(canvas?.title || name)
|
|
177
206
|
const titleInputRef = useRef(null)
|
|
178
207
|
const [localSources, setLocalSources] = useState(canvas?.sources ?? [])
|
|
@@ -287,6 +316,55 @@ export default function CanvasPage({ name }) {
|
|
|
287
316
|
zoomRef.current = zoom
|
|
288
317
|
}, [zoom])
|
|
289
318
|
|
|
319
|
+
// Restore scroll position from localStorage after first render
|
|
320
|
+
useEffect(() => {
|
|
321
|
+
const el = scrollRef.current
|
|
322
|
+
const saved = pendingScrollRestore.current
|
|
323
|
+
if (el && saved) {
|
|
324
|
+
if (saved.scrollLeft != null) el.scrollLeft = saved.scrollLeft
|
|
325
|
+
if (saved.scrollTop != null) el.scrollTop = saved.scrollTop
|
|
326
|
+
pendingScrollRestore.current = null
|
|
327
|
+
}
|
|
328
|
+
}, [name, loading])
|
|
329
|
+
|
|
330
|
+
// Persist viewport state (zoom + scroll) to localStorage on changes
|
|
331
|
+
useEffect(() => {
|
|
332
|
+
const el = scrollRef.current
|
|
333
|
+
saveViewportState(name, {
|
|
334
|
+
zoom,
|
|
335
|
+
scrollLeft: el?.scrollLeft ?? 0,
|
|
336
|
+
scrollTop: el?.scrollTop ?? 0,
|
|
337
|
+
})
|
|
338
|
+
}, [name, zoom])
|
|
339
|
+
|
|
340
|
+
useEffect(() => {
|
|
341
|
+
const el = scrollRef.current
|
|
342
|
+
if (!el) return
|
|
343
|
+
function handleScroll() {
|
|
344
|
+
saveViewportState(name, {
|
|
345
|
+
zoom: zoomRef.current,
|
|
346
|
+
scrollLeft: el.scrollLeft,
|
|
347
|
+
scrollTop: el.scrollTop,
|
|
348
|
+
})
|
|
349
|
+
}
|
|
350
|
+
el.addEventListener('scroll', handleScroll, { passive: true })
|
|
351
|
+
|
|
352
|
+
// Flush viewport state on page unload so a refresh never misses it
|
|
353
|
+
function handleBeforeUnload() {
|
|
354
|
+
saveViewportState(name, {
|
|
355
|
+
zoom: zoomRef.current,
|
|
356
|
+
scrollLeft: el.scrollLeft,
|
|
357
|
+
scrollTop: el.scrollTop,
|
|
358
|
+
})
|
|
359
|
+
}
|
|
360
|
+
window.addEventListener('beforeunload', handleBeforeUnload)
|
|
361
|
+
|
|
362
|
+
return () => {
|
|
363
|
+
el.removeEventListener('scroll', handleScroll)
|
|
364
|
+
window.removeEventListener('beforeunload', handleBeforeUnload)
|
|
365
|
+
}
|
|
366
|
+
}, [name, loading])
|
|
367
|
+
|
|
290
368
|
/**
|
|
291
369
|
* Zoom to a new level, anchoring on an optional client-space point.
|
|
292
370
|
* When a cursor position is provided (e.g. from a wheel event), the
|
|
@@ -345,6 +423,26 @@ export default function CanvasPage({ name }) {
|
|
|
345
423
|
}
|
|
346
424
|
}, [name])
|
|
347
425
|
|
|
426
|
+
// Tell the Vite dev server to suppress full-reloads while this canvas is active.
|
|
427
|
+
// The ?canvas-hmr URL param opts out of the guard for canvas UI development.
|
|
428
|
+
// Sends a heartbeat every 3s so the guard auto-expires if the tab closes.
|
|
429
|
+
useEffect(() => {
|
|
430
|
+
if (!import.meta.hot) return
|
|
431
|
+
const hmrEnabled = new URLSearchParams(window.location.search).has('canvas-hmr')
|
|
432
|
+
if (hmrEnabled) return
|
|
433
|
+
|
|
434
|
+
const msg = { active: true, hmrEnabled: false }
|
|
435
|
+
import.meta.hot.send('storyboard:canvas-hmr-guard', msg)
|
|
436
|
+
const interval = setInterval(() => {
|
|
437
|
+
import.meta.hot.send('storyboard:canvas-hmr-guard', msg)
|
|
438
|
+
}, 3000)
|
|
439
|
+
|
|
440
|
+
return () => {
|
|
441
|
+
clearInterval(interval)
|
|
442
|
+
import.meta.hot.send('storyboard:canvas-hmr-guard', { active: false, hmrEnabled: true })
|
|
443
|
+
}
|
|
444
|
+
}, [name])
|
|
445
|
+
|
|
348
446
|
// Add a widget by type — used by CanvasControls and CoreUIBar event
|
|
349
447
|
const addWidget = useCallback(async (type) => {
|
|
350
448
|
const defaultProps = schemas[type] ? getDefaults(schemas[type]) : {}
|
|
@@ -420,6 +518,10 @@ export default function CanvasPage({ name }) {
|
|
|
420
518
|
if (!selectedWidgetId) return
|
|
421
519
|
const tag = e.target.tagName
|
|
422
520
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
|
|
521
|
+
if (e.key === 'Escape') {
|
|
522
|
+
e.preventDefault()
|
|
523
|
+
setSelectedWidgetId(null)
|
|
524
|
+
}
|
|
423
525
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
424
526
|
e.preventDefault()
|
|
425
527
|
handleWidgetRemove(selectedWidgetId)
|
|
@@ -430,7 +532,8 @@ export default function CanvasPage({ name }) {
|
|
|
430
532
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
431
533
|
}, [selectedWidgetId, handleWidgetRemove])
|
|
432
534
|
|
|
433
|
-
// Paste handler —
|
|
535
|
+
// Paste handler — images become image widgets, same-origin URLs become prototypes,
|
|
536
|
+
// other URLs become link previews, text becomes markdown
|
|
434
537
|
useEffect(() => {
|
|
435
538
|
const origin = window.location.origin
|
|
436
539
|
const basePath = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
|
|
@@ -461,10 +564,81 @@ export default function CanvasPage({ name }) {
|
|
|
461
564
|
return pathname
|
|
462
565
|
}
|
|
463
566
|
|
|
567
|
+
function blobToDataUrl(blob) {
|
|
568
|
+
return new Promise((resolve, reject) => {
|
|
569
|
+
const reader = new FileReader()
|
|
570
|
+
reader.onload = () => resolve(reader.result)
|
|
571
|
+
reader.onerror = reject
|
|
572
|
+
reader.readAsDataURL(blob)
|
|
573
|
+
})
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function getImageDimensions(dataUrl) {
|
|
577
|
+
return new Promise((resolve) => {
|
|
578
|
+
const img = new Image()
|
|
579
|
+
img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight })
|
|
580
|
+
img.onerror = () => resolve({ width: 400, height: 300 })
|
|
581
|
+
img.src = dataUrl
|
|
582
|
+
})
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
async function handleImagePaste(e) {
|
|
586
|
+
const items = e.clipboardData?.items
|
|
587
|
+
if (!items) return false
|
|
588
|
+
|
|
589
|
+
for (const item of items) {
|
|
590
|
+
if (!item.type.startsWith('image/')) continue
|
|
591
|
+
|
|
592
|
+
const blob = item.getAsFile()
|
|
593
|
+
if (!blob) continue
|
|
594
|
+
|
|
595
|
+
e.preventDefault()
|
|
596
|
+
|
|
597
|
+
try {
|
|
598
|
+
const dataUrl = await blobToDataUrl(blob)
|
|
599
|
+
const { width: natW, height: natH } = await getImageDimensions(dataUrl)
|
|
600
|
+
|
|
601
|
+
// Display at 2x retina: halve natural dimensions, then cap at 600px
|
|
602
|
+
const maxWidth = 600
|
|
603
|
+
let displayW = Math.round(natW / 2)
|
|
604
|
+
let displayH = Math.round(natH / 2)
|
|
605
|
+
if (displayW > maxWidth) {
|
|
606
|
+
displayH = Math.round(displayH * (maxWidth / displayW))
|
|
607
|
+
displayW = maxWidth
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const uploadResult = await uploadImage(dataUrl, name)
|
|
611
|
+
if (!uploadResult.success) {
|
|
612
|
+
console.error('[canvas] Image upload failed:', uploadResult.error)
|
|
613
|
+
return true
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
|
|
617
|
+
const pos = centerPositionForWidget(center, 'image', { width: displayW, height: displayH })
|
|
618
|
+
const result = await addWidgetApi(name, {
|
|
619
|
+
type: 'image',
|
|
620
|
+
props: { src: uploadResult.filename, private: false, width: displayW, height: displayH },
|
|
621
|
+
position: pos,
|
|
622
|
+
})
|
|
623
|
+
if (result.success && result.widget) {
|
|
624
|
+
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
625
|
+
}
|
|
626
|
+
} catch (err) {
|
|
627
|
+
console.error('[canvas] Failed to paste image:', err)
|
|
628
|
+
}
|
|
629
|
+
return true
|
|
630
|
+
}
|
|
631
|
+
return false
|
|
632
|
+
}
|
|
633
|
+
|
|
464
634
|
async function handlePaste(e) {
|
|
465
635
|
const tag = e.target.tagName
|
|
466
636
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
|
|
467
637
|
|
|
638
|
+
// Image paste takes priority
|
|
639
|
+
const handledImage = await handleImagePaste(e)
|
|
640
|
+
if (handledImage) return
|
|
641
|
+
|
|
468
642
|
const text = e.clipboardData?.getData('text/plain')?.trim()
|
|
469
643
|
if (!text) return
|
|
470
644
|
|
|
@@ -473,7 +647,10 @@ export default function CanvasPage({ name }) {
|
|
|
473
647
|
let type, props
|
|
474
648
|
try {
|
|
475
649
|
const parsed = new URL(text)
|
|
476
|
-
if (
|
|
650
|
+
if (isFigmaUrl(text)) {
|
|
651
|
+
type = 'figma-embed'
|
|
652
|
+
props = { url: sanitizeFigmaUrl(text), width: 800, height: 450 }
|
|
653
|
+
} else if (isSameOriginPrototype(text)) {
|
|
477
654
|
const pathPortion = parsed.pathname + parsed.search + parsed.hash
|
|
478
655
|
const src = extractPrototypeSrc(pathPortion)
|
|
479
656
|
type = 'prototype'
|
package/src/canvas/canvasApi.js
CHANGED
|
@@ -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
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
</
|
|
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
|
-
<
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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:
|
|
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']
|