@dfosco/storyboard-react 3.10.0-beta.1 → 3.11.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/canvas/CanvasPage.bridge.test.jsx +4 -0
- package/src/canvas/CanvasPage.jsx +259 -13
- 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: () => ({}),
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createElement, useCallback, useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { flushSync } from 'react-dom'
|
|
2
3
|
import { Canvas } from '@dfosco/tiny-canvas'
|
|
3
4
|
import '@dfosco/tiny-canvas/style.css'
|
|
4
5
|
import { useCanvas } from './useCanvas.js'
|
|
@@ -7,9 +8,10 @@ import { getCanvasThemeVars, getCanvasPrimerAttrs } from './canvasTheme.js'
|
|
|
7
8
|
import { getWidgetComponent } from './widgets/index.js'
|
|
8
9
|
import { schemas, getDefaults } from './widgets/widgetProps.js'
|
|
9
10
|
import { getFeatures } from './widgets/widgetConfig.js'
|
|
11
|
+
import { isFigmaUrl, sanitizeFigmaUrl } from './widgets/figmaUrl.js'
|
|
10
12
|
import WidgetChrome from './widgets/WidgetChrome.jsx'
|
|
11
13
|
import ComponentWidget from './widgets/ComponentWidget.jsx'
|
|
12
|
-
import { addWidget as addWidgetApi, updateCanvas, removeWidget as removeWidgetApi } from './canvasApi.js'
|
|
14
|
+
import { addWidget as addWidgetApi, updateCanvas, removeWidget as removeWidgetApi, uploadImage } from './canvasApi.js'
|
|
13
15
|
import styles from './CanvasPage.module.css'
|
|
14
16
|
|
|
15
17
|
const ZOOM_MIN = 25
|
|
@@ -55,13 +57,68 @@ function debounce(fn, ms) {
|
|
|
55
57
|
}
|
|
56
58
|
}
|
|
57
59
|
|
|
60
|
+
/** Per-canvas viewport state persistence (zoom + scroll position). */
|
|
61
|
+
function getViewportStorageKey(canvasName) {
|
|
62
|
+
return `sb-canvas-viewport:${canvasName}`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function loadViewportState(canvasName) {
|
|
66
|
+
try {
|
|
67
|
+
const raw = localStorage.getItem(getViewportStorageKey(canvasName))
|
|
68
|
+
if (!raw) return null
|
|
69
|
+
const state = JSON.parse(raw)
|
|
70
|
+
return {
|
|
71
|
+
zoom: typeof state.zoom === 'number' ? Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, state.zoom)) : null,
|
|
72
|
+
scrollLeft: typeof state.scrollLeft === 'number' ? state.scrollLeft : null,
|
|
73
|
+
scrollTop: typeof state.scrollTop === 'number' ? state.scrollTop : null,
|
|
74
|
+
}
|
|
75
|
+
} catch { return null }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function saveViewportState(canvasName, state) {
|
|
79
|
+
try {
|
|
80
|
+
localStorage.setItem(getViewportStorageKey(canvasName), JSON.stringify(state))
|
|
81
|
+
} catch { /* quota exceeded — non-critical */ }
|
|
82
|
+
}
|
|
83
|
+
|
|
58
84
|
/**
|
|
59
|
-
* Get viewport-center coordinates for placing a new widget.
|
|
85
|
+
* Get viewport-center coordinates in canvas space for placing a new widget.
|
|
86
|
+
* Converts the visible center of the scroll container to unscaled canvas coordinates.
|
|
60
87
|
*/
|
|
61
|
-
function getViewportCenter() {
|
|
88
|
+
function getViewportCenter(scrollEl, scale) {
|
|
89
|
+
if (!scrollEl) {
|
|
90
|
+
return { x: 0, y: 0 }
|
|
91
|
+
}
|
|
92
|
+
const cx = scrollEl.scrollLeft + scrollEl.clientWidth / 2
|
|
93
|
+
const cy = scrollEl.scrollTop + scrollEl.clientHeight / 2
|
|
62
94
|
return {
|
|
63
|
-
x: Math.round(
|
|
64
|
-
y: Math.round(
|
|
95
|
+
x: Math.round(cx / scale),
|
|
96
|
+
y: Math.round(cy / scale),
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Fallback sizes for widget types without explicit width/height defaults. */
|
|
101
|
+
const WIDGET_FALLBACK_SIZES = {
|
|
102
|
+
'sticky-note': { width: 180, height: 60 },
|
|
103
|
+
'markdown': { width: 360, height: 200 },
|
|
104
|
+
'prototype': { width: 800, height: 600 },
|
|
105
|
+
'link-preview': { width: 320, height: 120 },
|
|
106
|
+
'figma-embed': { width: 800, height: 450 },
|
|
107
|
+
'component': { width: 200, height: 150 },
|
|
108
|
+
'image': { width: 400, height: 300 },
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Offset a position so the widget's center (not its top-left corner)
|
|
113
|
+
* lands on the given point.
|
|
114
|
+
*/
|
|
115
|
+
function centerPositionForWidget(pos, type, props) {
|
|
116
|
+
const fallback = WIDGET_FALLBACK_SIZES[type] || { width: 200, height: 150 }
|
|
117
|
+
const w = props?.width ?? fallback.width
|
|
118
|
+
const h = props?.height ?? fallback.height
|
|
119
|
+
return {
|
|
120
|
+
x: Math.round(pos.x - w / 2),
|
|
121
|
+
y: Math.round(pos.y - h / 2),
|
|
65
122
|
}
|
|
66
123
|
}
|
|
67
124
|
|
|
@@ -140,9 +197,11 @@ export default function CanvasPage({ name }) {
|
|
|
140
197
|
const [localWidgets, setLocalWidgets] = useState(canvas?.widgets ?? null)
|
|
141
198
|
const [trackedCanvas, setTrackedCanvas] = useState(canvas)
|
|
142
199
|
const [selectedWidgetId, setSelectedWidgetId] = useState(null)
|
|
143
|
-
const
|
|
144
|
-
const
|
|
200
|
+
const initialViewport = loadViewportState(name)
|
|
201
|
+
const [zoom, setZoom] = useState(initialViewport?.zoom ?? 100)
|
|
202
|
+
const zoomRef = useRef(initialViewport?.zoom ?? 100)
|
|
145
203
|
const scrollRef = useRef(null)
|
|
204
|
+
const pendingScrollRestore = useRef(initialViewport)
|
|
146
205
|
const [canvasTitle, setCanvasTitle] = useState(canvas?.title || name)
|
|
147
206
|
const titleInputRef = useRef(null)
|
|
148
207
|
const [localSources, setLocalSources] = useState(canvas?.sources ?? [])
|
|
@@ -257,6 +316,92 @@ export default function CanvasPage({ name }) {
|
|
|
257
316
|
zoomRef.current = zoom
|
|
258
317
|
}, [zoom])
|
|
259
318
|
|
|
319
|
+
// Restore scroll position from localStorage after first render
|
|
320
|
+
useEffect(() => {
|
|
321
|
+
const el = scrollRef.current
|
|
322
|
+
const saved = pendingScrollRestore.current
|
|
323
|
+
if (el && saved) {
|
|
324
|
+
if (saved.scrollLeft != null) el.scrollLeft = saved.scrollLeft
|
|
325
|
+
if (saved.scrollTop != null) el.scrollTop = saved.scrollTop
|
|
326
|
+
pendingScrollRestore.current = null
|
|
327
|
+
}
|
|
328
|
+
}, [name, loading])
|
|
329
|
+
|
|
330
|
+
// Persist viewport state (zoom + scroll) to localStorage on changes
|
|
331
|
+
useEffect(() => {
|
|
332
|
+
const el = scrollRef.current
|
|
333
|
+
saveViewportState(name, {
|
|
334
|
+
zoom,
|
|
335
|
+
scrollLeft: el?.scrollLeft ?? 0,
|
|
336
|
+
scrollTop: el?.scrollTop ?? 0,
|
|
337
|
+
})
|
|
338
|
+
}, [name, zoom])
|
|
339
|
+
|
|
340
|
+
useEffect(() => {
|
|
341
|
+
const el = scrollRef.current
|
|
342
|
+
if (!el) return
|
|
343
|
+
function handleScroll() {
|
|
344
|
+
saveViewportState(name, {
|
|
345
|
+
zoom: zoomRef.current,
|
|
346
|
+
scrollLeft: el.scrollLeft,
|
|
347
|
+
scrollTop: el.scrollTop,
|
|
348
|
+
})
|
|
349
|
+
}
|
|
350
|
+
el.addEventListener('scroll', handleScroll, { passive: true })
|
|
351
|
+
|
|
352
|
+
// Flush viewport state on page unload so a refresh never misses it
|
|
353
|
+
function handleBeforeUnload() {
|
|
354
|
+
saveViewportState(name, {
|
|
355
|
+
zoom: zoomRef.current,
|
|
356
|
+
scrollLeft: el.scrollLeft,
|
|
357
|
+
scrollTop: el.scrollTop,
|
|
358
|
+
})
|
|
359
|
+
}
|
|
360
|
+
window.addEventListener('beforeunload', handleBeforeUnload)
|
|
361
|
+
|
|
362
|
+
return () => {
|
|
363
|
+
el.removeEventListener('scroll', handleScroll)
|
|
364
|
+
window.removeEventListener('beforeunload', handleBeforeUnload)
|
|
365
|
+
}
|
|
366
|
+
}, [name, loading])
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Zoom to a new level, anchoring on an optional client-space point.
|
|
370
|
+
* When a cursor position is provided (e.g. from a wheel event), the
|
|
371
|
+
* canvas point under the cursor stays fixed. Otherwise falls back to
|
|
372
|
+
* the viewport center.
|
|
373
|
+
*/
|
|
374
|
+
function applyZoom(newZoom, clientX, clientY) {
|
|
375
|
+
const el = scrollRef.current
|
|
376
|
+
const clampedZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, newZoom))
|
|
377
|
+
|
|
378
|
+
if (!el) {
|
|
379
|
+
setZoom(clampedZoom)
|
|
380
|
+
return
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const oldScale = zoomRef.current / 100
|
|
384
|
+
const newScale = clampedZoom / 100
|
|
385
|
+
|
|
386
|
+
// Anchor point in scroll-container space
|
|
387
|
+
const rect = el.getBoundingClientRect()
|
|
388
|
+
const useViewportCenter = clientX == null || clientY == null
|
|
389
|
+
const anchorX = useViewportCenter ? el.clientWidth / 2 : clientX - rect.left
|
|
390
|
+
const anchorY = useViewportCenter ? el.clientHeight / 2 : clientY - rect.top
|
|
391
|
+
|
|
392
|
+
// Anchor → canvas coordinate
|
|
393
|
+
const canvasX = (el.scrollLeft + anchorX) / oldScale
|
|
394
|
+
const canvasY = (el.scrollTop + anchorY) / oldScale
|
|
395
|
+
|
|
396
|
+
// Synchronous render so the DOM has the new transform before we adjust scroll
|
|
397
|
+
zoomRef.current = clampedZoom
|
|
398
|
+
flushSync(() => setZoom(clampedZoom))
|
|
399
|
+
|
|
400
|
+
// Scroll so the same canvas point stays under the anchor
|
|
401
|
+
el.scrollLeft = canvasX * newScale - anchorX
|
|
402
|
+
el.scrollTop = canvasY * newScale - anchorY
|
|
403
|
+
}
|
|
404
|
+
|
|
260
405
|
// Signal canvas mount/unmount to CoreUIBar
|
|
261
406
|
useEffect(() => {
|
|
262
407
|
window[CANVAS_BRIDGE_STATE_KEY] = { active: true, name, zoom: zoomRef.current }
|
|
@@ -278,10 +423,31 @@ export default function CanvasPage({ name }) {
|
|
|
278
423
|
}
|
|
279
424
|
}, [name])
|
|
280
425
|
|
|
426
|
+
// Tell the Vite dev server to suppress full-reloads while this canvas is active.
|
|
427
|
+
// The ?canvas-hmr URL param opts out of the guard for canvas UI development.
|
|
428
|
+
// Sends a heartbeat every 3s so the guard auto-expires if the tab closes.
|
|
429
|
+
useEffect(() => {
|
|
430
|
+
if (!import.meta.hot) return
|
|
431
|
+
const hmrEnabled = new URLSearchParams(window.location.search).has('canvas-hmr')
|
|
432
|
+
if (hmrEnabled) return
|
|
433
|
+
|
|
434
|
+
const msg = { active: true, hmrEnabled: false }
|
|
435
|
+
import.meta.hot.send('storyboard:canvas-hmr-guard', msg)
|
|
436
|
+
const interval = setInterval(() => {
|
|
437
|
+
import.meta.hot.send('storyboard:canvas-hmr-guard', msg)
|
|
438
|
+
}, 3000)
|
|
439
|
+
|
|
440
|
+
return () => {
|
|
441
|
+
clearInterval(interval)
|
|
442
|
+
import.meta.hot.send('storyboard:canvas-hmr-guard', { active: false, hmrEnabled: true })
|
|
443
|
+
}
|
|
444
|
+
}, [name])
|
|
445
|
+
|
|
281
446
|
// Add a widget by type — used by CanvasControls and CoreUIBar event
|
|
282
447
|
const addWidget = useCallback(async (type) => {
|
|
283
448
|
const defaultProps = schemas[type] ? getDefaults(schemas[type]) : {}
|
|
284
|
-
const
|
|
449
|
+
const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
|
|
450
|
+
const pos = centerPositionForWidget(center, type, defaultProps)
|
|
285
451
|
try {
|
|
286
452
|
const result = await addWidgetApi(name, {
|
|
287
453
|
type,
|
|
@@ -310,7 +476,7 @@ export default function CanvasPage({ name }) {
|
|
|
310
476
|
function handleZoom(e) {
|
|
311
477
|
const { zoom: newZoom } = e.detail
|
|
312
478
|
if (typeof newZoom === 'number') {
|
|
313
|
-
|
|
479
|
+
applyZoom(newZoom)
|
|
314
480
|
}
|
|
315
481
|
}
|
|
316
482
|
document.addEventListener('storyboard:canvas:set-zoom', handleZoom)
|
|
@@ -352,6 +518,10 @@ export default function CanvasPage({ name }) {
|
|
|
352
518
|
if (!selectedWidgetId) return
|
|
353
519
|
const tag = e.target.tagName
|
|
354
520
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
|
|
521
|
+
if (e.key === 'Escape') {
|
|
522
|
+
e.preventDefault()
|
|
523
|
+
setSelectedWidgetId(null)
|
|
524
|
+
}
|
|
355
525
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
356
526
|
e.preventDefault()
|
|
357
527
|
handleWidgetRemove(selectedWidgetId)
|
|
@@ -362,7 +532,8 @@ export default function CanvasPage({ name }) {
|
|
|
362
532
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
363
533
|
}, [selectedWidgetId, handleWidgetRemove])
|
|
364
534
|
|
|
365
|
-
// Paste handler —
|
|
535
|
+
// Paste handler — images become image widgets, same-origin URLs become prototypes,
|
|
536
|
+
// other URLs become link previews, text becomes markdown
|
|
366
537
|
useEffect(() => {
|
|
367
538
|
const origin = window.location.origin
|
|
368
539
|
const basePath = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
|
|
@@ -393,10 +564,81 @@ export default function CanvasPage({ name }) {
|
|
|
393
564
|
return pathname
|
|
394
565
|
}
|
|
395
566
|
|
|
567
|
+
function blobToDataUrl(blob) {
|
|
568
|
+
return new Promise((resolve, reject) => {
|
|
569
|
+
const reader = new FileReader()
|
|
570
|
+
reader.onload = () => resolve(reader.result)
|
|
571
|
+
reader.onerror = reject
|
|
572
|
+
reader.readAsDataURL(blob)
|
|
573
|
+
})
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function getImageDimensions(dataUrl) {
|
|
577
|
+
return new Promise((resolve) => {
|
|
578
|
+
const img = new Image()
|
|
579
|
+
img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight })
|
|
580
|
+
img.onerror = () => resolve({ width: 400, height: 300 })
|
|
581
|
+
img.src = dataUrl
|
|
582
|
+
})
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
async function handleImagePaste(e) {
|
|
586
|
+
const items = e.clipboardData?.items
|
|
587
|
+
if (!items) return false
|
|
588
|
+
|
|
589
|
+
for (const item of items) {
|
|
590
|
+
if (!item.type.startsWith('image/')) continue
|
|
591
|
+
|
|
592
|
+
const blob = item.getAsFile()
|
|
593
|
+
if (!blob) continue
|
|
594
|
+
|
|
595
|
+
e.preventDefault()
|
|
596
|
+
|
|
597
|
+
try {
|
|
598
|
+
const dataUrl = await blobToDataUrl(blob)
|
|
599
|
+
const { width: natW, height: natH } = await getImageDimensions(dataUrl)
|
|
600
|
+
|
|
601
|
+
// Display at 2x retina: halve natural dimensions, then cap at 600px
|
|
602
|
+
const maxWidth = 600
|
|
603
|
+
let displayW = Math.round(natW / 2)
|
|
604
|
+
let displayH = Math.round(natH / 2)
|
|
605
|
+
if (displayW > maxWidth) {
|
|
606
|
+
displayH = Math.round(displayH * (maxWidth / displayW))
|
|
607
|
+
displayW = maxWidth
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const uploadResult = await uploadImage(dataUrl, name)
|
|
611
|
+
if (!uploadResult.success) {
|
|
612
|
+
console.error('[canvas] Image upload failed:', uploadResult.error)
|
|
613
|
+
return true
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
|
|
617
|
+
const pos = centerPositionForWidget(center, 'image', { width: displayW, height: displayH })
|
|
618
|
+
const result = await addWidgetApi(name, {
|
|
619
|
+
type: 'image',
|
|
620
|
+
props: { src: uploadResult.filename, private: false, width: displayW, height: displayH },
|
|
621
|
+
position: pos,
|
|
622
|
+
})
|
|
623
|
+
if (result.success && result.widget) {
|
|
624
|
+
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
625
|
+
}
|
|
626
|
+
} catch (err) {
|
|
627
|
+
console.error('[canvas] Failed to paste image:', err)
|
|
628
|
+
}
|
|
629
|
+
return true
|
|
630
|
+
}
|
|
631
|
+
return false
|
|
632
|
+
}
|
|
633
|
+
|
|
396
634
|
async function handlePaste(e) {
|
|
397
635
|
const tag = e.target.tagName
|
|
398
636
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
|
|
399
637
|
|
|
638
|
+
// Image paste takes priority
|
|
639
|
+
const handledImage = await handleImagePaste(e)
|
|
640
|
+
if (handledImage) return
|
|
641
|
+
|
|
400
642
|
const text = e.clipboardData?.getData('text/plain')?.trim()
|
|
401
643
|
if (!text) return
|
|
402
644
|
|
|
@@ -405,7 +647,10 @@ export default function CanvasPage({ name }) {
|
|
|
405
647
|
let type, props
|
|
406
648
|
try {
|
|
407
649
|
const parsed = new URL(text)
|
|
408
|
-
if (
|
|
650
|
+
if (isFigmaUrl(text)) {
|
|
651
|
+
type = 'figma-embed'
|
|
652
|
+
props = { url: sanitizeFigmaUrl(text), width: 800, height: 450 }
|
|
653
|
+
} else if (isSameOriginPrototype(text)) {
|
|
409
654
|
const pathPortion = parsed.pathname + parsed.search + parsed.hash
|
|
410
655
|
const src = extractPrototypeSrc(pathPortion)
|
|
411
656
|
type = 'prototype'
|
|
@@ -419,7 +664,8 @@ export default function CanvasPage({ name }) {
|
|
|
419
664
|
props = { content: text }
|
|
420
665
|
}
|
|
421
666
|
|
|
422
|
-
const
|
|
667
|
+
const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
|
|
668
|
+
const pos = centerPositionForWidget(center, type, props)
|
|
423
669
|
try {
|
|
424
670
|
const result = await addWidgetApi(name, {
|
|
425
671
|
type,
|
|
@@ -449,7 +695,7 @@ export default function CanvasPage({ name }) {
|
|
|
449
695
|
const step = Math.trunc(zoomAccum.current)
|
|
450
696
|
if (step === 0) return
|
|
451
697
|
zoomAccum.current -= step
|
|
452
|
-
|
|
698
|
+
applyZoom(zoomRef.current + step, e.clientX, e.clientY)
|
|
453
699
|
}
|
|
454
700
|
document.addEventListener('wheel', handleWheel, { passive: false })
|
|
455
701
|
return () => document.removeEventListener('wheel', handleWheel)
|
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']
|