@dfosco/storyboard-react 3.10.0 → 3.11.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/canvas/CanvasControls.jsx +2 -59
- package/src/canvas/CanvasControls.module.css +0 -29
- package/src/canvas/CanvasPage.bridge.test.jsx +4 -0
- package/src/canvas/CanvasPage.jsx +416 -22
- package/src/canvas/canvasApi.js +8 -0
- package/src/canvas/computeCanvasBounds.test.js +121 -0
- package/src/canvas/useUndoRedo.js +86 -0
- package/src/canvas/useUndoRedo.test.js +231 -0
- package/src/canvas/widgets/FigmaEmbed.jsx +106 -0
- package/src/canvas/widgets/FigmaEmbed.module.css +83 -0
- package/src/canvas/widgets/ImageWidget.jsx +91 -0
- package/src/canvas/widgets/ImageWidget.module.css +39 -0
- package/src/canvas/widgets/PrototypeEmbed.jsx +20 -1
- package/src/canvas/widgets/WidgetChrome.jsx +67 -26
- package/src/canvas/widgets/WidgetChrome.module.css +15 -5
- package/src/canvas/widgets/figmaUrl.js +118 -0
- package/src/canvas/widgets/figmaUrl.test.js +139 -0
- package/src/canvas/widgets/index.js +4 -0
- package/src/canvas/widgets/widgetConfig.js +2 -2
- package/src/canvas/widgets/widgetProps.js +2 -0
|
@@ -8,9 +8,11 @@ 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
|
|
14
|
+
import useUndoRedo from './useUndoRedo.js'
|
|
15
|
+
import { addWidget as addWidgetApi, updateCanvas, removeWidget as removeWidgetApi, uploadImage } from './canvasApi.js'
|
|
14
16
|
import styles from './CanvasPage.module.css'
|
|
15
17
|
|
|
16
18
|
const ZOOM_MIN = 25
|
|
@@ -47,13 +49,40 @@ function resolveCanvasThemeFromStorage() {
|
|
|
47
49
|
|
|
48
50
|
/**
|
|
49
51
|
* Debounce helper — returns a function that delays invocation.
|
|
52
|
+
* Exposes `.cancel()` to abort pending calls (used by undo/redo).
|
|
50
53
|
*/
|
|
51
54
|
function debounce(fn, ms) {
|
|
52
55
|
let timer
|
|
53
|
-
|
|
56
|
+
const debounced = (...args) => {
|
|
54
57
|
clearTimeout(timer)
|
|
55
58
|
timer = setTimeout(() => fn(...args), ms)
|
|
56
59
|
}
|
|
60
|
+
debounced.cancel = () => clearTimeout(timer)
|
|
61
|
+
return debounced
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Per-canvas viewport state persistence (zoom + scroll position). */
|
|
65
|
+
function getViewportStorageKey(canvasName) {
|
|
66
|
+
return `sb-canvas-viewport:${canvasName}`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function loadViewportState(canvasName) {
|
|
70
|
+
try {
|
|
71
|
+
const raw = localStorage.getItem(getViewportStorageKey(canvasName))
|
|
72
|
+
if (!raw) return null
|
|
73
|
+
const state = JSON.parse(raw)
|
|
74
|
+
return {
|
|
75
|
+
zoom: typeof state.zoom === 'number' ? Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, state.zoom)) : null,
|
|
76
|
+
scrollLeft: typeof state.scrollLeft === 'number' ? state.scrollLeft : null,
|
|
77
|
+
scrollTop: typeof state.scrollTop === 'number' ? state.scrollTop : null,
|
|
78
|
+
}
|
|
79
|
+
} catch { return null }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function saveViewportState(canvasName, state) {
|
|
83
|
+
try {
|
|
84
|
+
localStorage.setItem(getViewportStorageKey(canvasName), JSON.stringify(state))
|
|
85
|
+
} catch { /* quota exceeded — non-critical */ }
|
|
57
86
|
}
|
|
58
87
|
|
|
59
88
|
/**
|
|
@@ -74,11 +103,13 @@ function getViewportCenter(scrollEl, scale) {
|
|
|
74
103
|
|
|
75
104
|
/** Fallback sizes for widget types without explicit width/height defaults. */
|
|
76
105
|
const WIDGET_FALLBACK_SIZES = {
|
|
77
|
-
'sticky-note': { width:
|
|
78
|
-
'markdown': { width:
|
|
106
|
+
'sticky-note': { width: 270, height: 170 },
|
|
107
|
+
'markdown': { width: 530, height: 240 },
|
|
79
108
|
'prototype': { width: 800, height: 600 },
|
|
80
109
|
'link-preview': { width: 320, height: 120 },
|
|
110
|
+
'figma-embed': { width: 800, height: 450 },
|
|
81
111
|
'component': { width: 200, height: 150 },
|
|
112
|
+
'image': { width: 400, height: 300 },
|
|
82
113
|
}
|
|
83
114
|
|
|
84
115
|
/**
|
|
@@ -99,6 +130,57 @@ function roundPosition(value) {
|
|
|
99
130
|
return Math.round(value)
|
|
100
131
|
}
|
|
101
132
|
|
|
133
|
+
/** Padding (canvas-space pixels) around bounding box for zoom-to-fit. */
|
|
134
|
+
const FIT_PADDING = 48
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Compute the axis-aligned bounding box that contains every widget and source.
|
|
138
|
+
* Returns { minX, minY, maxX, maxY } in canvas-space coordinates, or null if empty.
|
|
139
|
+
*/
|
|
140
|
+
function computeCanvasBounds(widgets, sources, jsxExports) {
|
|
141
|
+
let minX = Infinity
|
|
142
|
+
let minY = Infinity
|
|
143
|
+
let maxX = -Infinity
|
|
144
|
+
let maxY = -Infinity
|
|
145
|
+
let hasItems = false
|
|
146
|
+
|
|
147
|
+
// JSON widgets
|
|
148
|
+
for (const w of (widgets ?? [])) {
|
|
149
|
+
const x = w?.position?.x ?? 0
|
|
150
|
+
const y = w?.position?.y ?? 0
|
|
151
|
+
const fallback = WIDGET_FALLBACK_SIZES[w.type] || { width: 200, height: 150 }
|
|
152
|
+
const width = w.props?.width ?? fallback.width
|
|
153
|
+
const height = w.props?.height ?? fallback.height
|
|
154
|
+
minX = Math.min(minX, x)
|
|
155
|
+
minY = Math.min(minY, y)
|
|
156
|
+
maxX = Math.max(maxX, x + width)
|
|
157
|
+
maxY = Math.max(maxY, y + height)
|
|
158
|
+
hasItems = true
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// JSX sources
|
|
162
|
+
const sourceMap = Object.fromEntries(
|
|
163
|
+
(sources || []).filter((s) => s?.export).map((s) => [s.export, s])
|
|
164
|
+
)
|
|
165
|
+
if (jsxExports) {
|
|
166
|
+
for (const exportName of Object.keys(jsxExports)) {
|
|
167
|
+
const sourceData = sourceMap[exportName] || {}
|
|
168
|
+
const x = sourceData.position?.x ?? 0
|
|
169
|
+
const y = sourceData.position?.y ?? 0
|
|
170
|
+
const fallback = WIDGET_FALLBACK_SIZES['component']
|
|
171
|
+
const width = sourceData.width ?? fallback.width
|
|
172
|
+
const height = sourceData.height ?? fallback.height
|
|
173
|
+
minX = Math.min(minX, x)
|
|
174
|
+
minY = Math.min(minY, y)
|
|
175
|
+
maxX = Math.max(maxX, x + width)
|
|
176
|
+
maxY = Math.max(maxY, y + height)
|
|
177
|
+
hasItems = true
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return hasItems ? { minX, minY, maxX, maxY } : null
|
|
182
|
+
}
|
|
183
|
+
|
|
102
184
|
/** Renders a single JSON-defined widget by type lookup. */
|
|
103
185
|
function WidgetRenderer({ widget, onUpdate, widgetRef }) {
|
|
104
186
|
const Component = getWidgetComponent(widget.type)
|
|
@@ -125,6 +207,7 @@ function ChromeWrappedWidget({
|
|
|
125
207
|
onDeselect,
|
|
126
208
|
onUpdate,
|
|
127
209
|
onRemove,
|
|
210
|
+
onCopy,
|
|
128
211
|
}) {
|
|
129
212
|
const widgetRef = useRef(null)
|
|
130
213
|
const features = getFeatures(widget.type)
|
|
@@ -132,8 +215,10 @@ function ChromeWrappedWidget({
|
|
|
132
215
|
const handleAction = useCallback((actionId) => {
|
|
133
216
|
if (actionId === 'delete') {
|
|
134
217
|
onRemove(widget.id)
|
|
218
|
+
} else if (actionId === 'copy') {
|
|
219
|
+
onCopy(widget)
|
|
135
220
|
}
|
|
136
|
-
}, [widget
|
|
221
|
+
}, [widget, onRemove, onCopy])
|
|
137
222
|
|
|
138
223
|
return (
|
|
139
224
|
<WidgetChrome
|
|
@@ -170,19 +255,38 @@ export default function CanvasPage({ name }) {
|
|
|
170
255
|
const [localWidgets, setLocalWidgets] = useState(canvas?.widgets ?? null)
|
|
171
256
|
const [trackedCanvas, setTrackedCanvas] = useState(canvas)
|
|
172
257
|
const [selectedWidgetId, setSelectedWidgetId] = useState(null)
|
|
173
|
-
const
|
|
174
|
-
const
|
|
258
|
+
const initialViewport = loadViewportState(name)
|
|
259
|
+
const [zoom, setZoom] = useState(initialViewport?.zoom ?? 100)
|
|
260
|
+
const zoomRef = useRef(initialViewport?.zoom ?? 100)
|
|
175
261
|
const scrollRef = useRef(null)
|
|
262
|
+
const pendingScrollRestore = useRef(initialViewport)
|
|
176
263
|
const [canvasTitle, setCanvasTitle] = useState(canvas?.title || name)
|
|
177
264
|
const titleInputRef = useRef(null)
|
|
178
265
|
const [localSources, setLocalSources] = useState(canvas?.sources ?? [])
|
|
179
266
|
const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
|
|
180
267
|
|
|
268
|
+
// Undo/redo history — tracks both widgets and sources as a combined snapshot
|
|
269
|
+
const undoRedo = useUndoRedo()
|
|
270
|
+
const stateRef = useRef({ widgets: localWidgets, sources: localSources })
|
|
271
|
+
useEffect(() => {
|
|
272
|
+
stateRef.current = { widgets: localWidgets, sources: localSources }
|
|
273
|
+
}, [localWidgets, localSources])
|
|
274
|
+
|
|
275
|
+
// Serialized write queue — ensures JSONL events land in the right order
|
|
276
|
+
const writeQueueRef = useRef(Promise.resolve())
|
|
277
|
+
function queueWrite(fn) {
|
|
278
|
+
writeQueueRef.current = writeQueueRef.current.then(fn).catch((err) =>
|
|
279
|
+
console.error('[canvas] Write queue error:', err)
|
|
280
|
+
)
|
|
281
|
+
return writeQueueRef.current
|
|
282
|
+
}
|
|
283
|
+
|
|
181
284
|
if (canvas !== trackedCanvas) {
|
|
182
285
|
setTrackedCanvas(canvas)
|
|
183
286
|
setLocalWidgets(canvas?.widgets ?? null)
|
|
184
287
|
setLocalSources(canvas?.sources ?? [])
|
|
185
288
|
setCanvasTitle(canvas?.title || name)
|
|
289
|
+
undoRedo.reset()
|
|
186
290
|
}
|
|
187
291
|
|
|
188
292
|
// Debounced save to server
|
|
@@ -216,6 +320,7 @@ export default function CanvasPage({ name }) {
|
|
|
216
320
|
}, [])
|
|
217
321
|
|
|
218
322
|
const handleWidgetUpdate = useCallback((widgetId, updates) => {
|
|
323
|
+
undoRedo.snapshot(stateRef.current, 'edit', widgetId)
|
|
219
324
|
setLocalWidgets((prev) => {
|
|
220
325
|
if (!prev) return prev
|
|
221
326
|
const next = prev.map((w) =>
|
|
@@ -224,14 +329,44 @@ export default function CanvasPage({ name }) {
|
|
|
224
329
|
debouncedSave(name, next)
|
|
225
330
|
return next
|
|
226
331
|
})
|
|
227
|
-
}, [name, debouncedSave])
|
|
332
|
+
}, [name, debouncedSave, undoRedo])
|
|
228
333
|
|
|
229
334
|
const handleWidgetRemove = useCallback((widgetId) => {
|
|
335
|
+
undoRedo.snapshot(stateRef.current, 'remove', widgetId)
|
|
230
336
|
setLocalWidgets((prev) => prev ? prev.filter((w) => w.id !== widgetId) : prev)
|
|
231
|
-
|
|
232
|
-
|
|
337
|
+
queueWrite(() =>
|
|
338
|
+
removeWidgetApi(name, widgetId).catch((err) =>
|
|
339
|
+
console.error('[canvas] Failed to remove widget:', err)
|
|
340
|
+
)
|
|
233
341
|
)
|
|
234
|
-
}, [name])
|
|
342
|
+
}, [name, undoRedo])
|
|
343
|
+
|
|
344
|
+
const handleWidgetCopy = useCallback(async (widget) => {
|
|
345
|
+
// Find the next free offset — check how many copies already exist at +n*40
|
|
346
|
+
const baseX = widget.position?.x ?? 0
|
|
347
|
+
const baseY = widget.position?.y ?? 0
|
|
348
|
+
const occupied = new Set(
|
|
349
|
+
(localWidgets ?? []).map((w) => `${w.position?.x ?? 0},${w.position?.y ?? 0}`)
|
|
350
|
+
)
|
|
351
|
+
let n = 1
|
|
352
|
+
while (occupied.has(`${baseX + n * 40},${baseY + n * 40}`)) {
|
|
353
|
+
n++
|
|
354
|
+
}
|
|
355
|
+
const position = { x: baseX + n * 40, y: baseY + n * 40 }
|
|
356
|
+
try {
|
|
357
|
+
undoRedo.snapshot(stateRef.current, 'add')
|
|
358
|
+
const result = await addWidgetApi(name, {
|
|
359
|
+
type: widget.type,
|
|
360
|
+
props: { ...widget.props },
|
|
361
|
+
position,
|
|
362
|
+
})
|
|
363
|
+
if (result.success && result.widget) {
|
|
364
|
+
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
365
|
+
}
|
|
366
|
+
} catch (err) {
|
|
367
|
+
console.error('[canvas] Failed to copy widget:', err)
|
|
368
|
+
}
|
|
369
|
+
}, [name, localWidgets, undoRedo])
|
|
235
370
|
|
|
236
371
|
const debouncedSourceSave = useRef(
|
|
237
372
|
debounce((canvasName, sources) => {
|
|
@@ -242,6 +377,7 @@ export default function CanvasPage({ name }) {
|
|
|
242
377
|
).current
|
|
243
378
|
|
|
244
379
|
const handleSourceUpdate = useCallback((exportName, updates) => {
|
|
380
|
+
undoRedo.snapshot(stateRef.current, 'edit', `jsx-${exportName}`)
|
|
245
381
|
setLocalSources((prev) => {
|
|
246
382
|
const current = Array.isArray(prev) ? prev : []
|
|
247
383
|
const next = current.some((s) => s?.export === exportName)
|
|
@@ -250,43 +386,98 @@ export default function CanvasPage({ name }) {
|
|
|
250
386
|
debouncedSourceSave(name, next)
|
|
251
387
|
return next
|
|
252
388
|
})
|
|
253
|
-
}, [name, debouncedSourceSave])
|
|
389
|
+
}, [name, debouncedSourceSave, undoRedo])
|
|
254
390
|
|
|
255
391
|
const handleItemDragEnd = useCallback((dragId, position) => {
|
|
256
392
|
if (!dragId || !position) return
|
|
257
393
|
const rounded = { x: Math.max(0, roundPosition(position.x)), y: Math.max(0, roundPosition(position.y)) }
|
|
258
394
|
|
|
259
395
|
if (dragId.startsWith('jsx-')) {
|
|
396
|
+
undoRedo.snapshot(stateRef.current, 'move', dragId)
|
|
260
397
|
const sourceExport = dragId.replace(/^jsx-/, '')
|
|
261
398
|
setLocalSources((prev) => {
|
|
262
399
|
const current = Array.isArray(prev) ? prev : []
|
|
263
400
|
const next = current.some((s) => s?.export === sourceExport)
|
|
264
401
|
? current.map((s) => (s?.export === sourceExport ? { ...s, position: rounded } : s))
|
|
265
402
|
: [...current, { export: sourceExport, position: rounded }]
|
|
266
|
-
|
|
267
|
-
|
|
403
|
+
queueWrite(() =>
|
|
404
|
+
updateCanvas(name, { sources: next }).catch((err) =>
|
|
405
|
+
console.error('[canvas] Failed to save source position:', err)
|
|
406
|
+
)
|
|
268
407
|
)
|
|
269
408
|
return next
|
|
270
409
|
})
|
|
271
410
|
return
|
|
272
411
|
}
|
|
273
412
|
|
|
413
|
+
undoRedo.snapshot(stateRef.current, 'move', dragId)
|
|
274
414
|
setLocalWidgets((prev) => {
|
|
275
415
|
if (!prev) return prev
|
|
276
416
|
const next = prev.map((w) =>
|
|
277
417
|
w.id === dragId ? { ...w, position: rounded } : w
|
|
278
418
|
)
|
|
279
|
-
|
|
280
|
-
|
|
419
|
+
queueWrite(() =>
|
|
420
|
+
updateCanvas(name, { widgets: next }).catch((err) =>
|
|
421
|
+
console.error('[canvas] Failed to save widget position:', err)
|
|
422
|
+
)
|
|
281
423
|
)
|
|
282
424
|
return next
|
|
283
425
|
})
|
|
284
|
-
}, [name])
|
|
426
|
+
}, [name, undoRedo])
|
|
285
427
|
|
|
286
428
|
useEffect(() => {
|
|
287
429
|
zoomRef.current = zoom
|
|
288
430
|
}, [zoom])
|
|
289
431
|
|
|
432
|
+
// Restore scroll position from localStorage after first render
|
|
433
|
+
useEffect(() => {
|
|
434
|
+
const el = scrollRef.current
|
|
435
|
+
const saved = pendingScrollRestore.current
|
|
436
|
+
if (el && saved) {
|
|
437
|
+
if (saved.scrollLeft != null) el.scrollLeft = saved.scrollLeft
|
|
438
|
+
if (saved.scrollTop != null) el.scrollTop = saved.scrollTop
|
|
439
|
+
pendingScrollRestore.current = null
|
|
440
|
+
}
|
|
441
|
+
}, [name, loading])
|
|
442
|
+
|
|
443
|
+
// Persist viewport state (zoom + scroll) to localStorage on changes
|
|
444
|
+
useEffect(() => {
|
|
445
|
+
const el = scrollRef.current
|
|
446
|
+
saveViewportState(name, {
|
|
447
|
+
zoom,
|
|
448
|
+
scrollLeft: el?.scrollLeft ?? 0,
|
|
449
|
+
scrollTop: el?.scrollTop ?? 0,
|
|
450
|
+
})
|
|
451
|
+
}, [name, zoom])
|
|
452
|
+
|
|
453
|
+
useEffect(() => {
|
|
454
|
+
const el = scrollRef.current
|
|
455
|
+
if (!el) return
|
|
456
|
+
function handleScroll() {
|
|
457
|
+
saveViewportState(name, {
|
|
458
|
+
zoom: zoomRef.current,
|
|
459
|
+
scrollLeft: el.scrollLeft,
|
|
460
|
+
scrollTop: el.scrollTop,
|
|
461
|
+
})
|
|
462
|
+
}
|
|
463
|
+
el.addEventListener('scroll', handleScroll, { passive: true })
|
|
464
|
+
|
|
465
|
+
// Flush viewport state on page unload so a refresh never misses it
|
|
466
|
+
function handleBeforeUnload() {
|
|
467
|
+
saveViewportState(name, {
|
|
468
|
+
zoom: zoomRef.current,
|
|
469
|
+
scrollLeft: el.scrollLeft,
|
|
470
|
+
scrollTop: el.scrollTop,
|
|
471
|
+
})
|
|
472
|
+
}
|
|
473
|
+
window.addEventListener('beforeunload', handleBeforeUnload)
|
|
474
|
+
|
|
475
|
+
return () => {
|
|
476
|
+
el.removeEventListener('scroll', handleScroll)
|
|
477
|
+
window.removeEventListener('beforeunload', handleBeforeUnload)
|
|
478
|
+
}
|
|
479
|
+
}, [name, loading])
|
|
480
|
+
|
|
290
481
|
/**
|
|
291
482
|
* Zoom to a new level, anchoring on an optional client-space point.
|
|
292
483
|
* When a cursor position is provided (e.g. from a wheel event), the
|
|
@@ -345,6 +536,26 @@ export default function CanvasPage({ name }) {
|
|
|
345
536
|
}
|
|
346
537
|
}, [name])
|
|
347
538
|
|
|
539
|
+
// Tell the Vite dev server to suppress full-reloads while this canvas is active.
|
|
540
|
+
// The ?canvas-hmr URL param opts out of the guard for canvas UI development.
|
|
541
|
+
// Sends a heartbeat every 3s so the guard auto-expires if the tab closes.
|
|
542
|
+
useEffect(() => {
|
|
543
|
+
if (!import.meta.hot) return
|
|
544
|
+
const hmrEnabled = new URLSearchParams(window.location.search).has('canvas-hmr')
|
|
545
|
+
if (hmrEnabled) return
|
|
546
|
+
|
|
547
|
+
const msg = { active: true, hmrEnabled: false }
|
|
548
|
+
import.meta.hot.send('storyboard:canvas-hmr-guard', msg)
|
|
549
|
+
const interval = setInterval(() => {
|
|
550
|
+
import.meta.hot.send('storyboard:canvas-hmr-guard', msg)
|
|
551
|
+
}, 3000)
|
|
552
|
+
|
|
553
|
+
return () => {
|
|
554
|
+
clearInterval(interval)
|
|
555
|
+
import.meta.hot.send('storyboard:canvas-hmr-guard', { active: false, hmrEnabled: true })
|
|
556
|
+
}
|
|
557
|
+
}, [name])
|
|
558
|
+
|
|
348
559
|
// Add a widget by type — used by CanvasControls and CoreUIBar event
|
|
349
560
|
const addWidget = useCallback(async (type) => {
|
|
350
561
|
const defaultProps = schemas[type] ? getDefaults(schemas[type]) : {}
|
|
@@ -357,12 +568,13 @@ export default function CanvasPage({ name }) {
|
|
|
357
568
|
position: pos,
|
|
358
569
|
})
|
|
359
570
|
if (result.success && result.widget) {
|
|
571
|
+
undoRedo.snapshot(stateRef.current, 'add')
|
|
360
572
|
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
361
573
|
}
|
|
362
574
|
} catch (err) {
|
|
363
575
|
console.error('[canvas] Failed to add widget:', err)
|
|
364
576
|
}
|
|
365
|
-
}, [name])
|
|
577
|
+
}, [name, undoRedo])
|
|
366
578
|
|
|
367
579
|
// Listen for CoreUIBar add-widget events
|
|
368
580
|
useEffect(() => {
|
|
@@ -385,6 +597,38 @@ export default function CanvasPage({ name }) {
|
|
|
385
597
|
return () => document.removeEventListener('storyboard:canvas:set-zoom', handleZoom)
|
|
386
598
|
}, [])
|
|
387
599
|
|
|
600
|
+
// Listen for zoom-to-fit from CoreUIBar
|
|
601
|
+
useEffect(() => {
|
|
602
|
+
function handleZoomToFit() {
|
|
603
|
+
const el = scrollRef.current
|
|
604
|
+
if (!el) return
|
|
605
|
+
|
|
606
|
+
const bounds = computeCanvasBounds(localWidgets, localSources, jsxExports)
|
|
607
|
+
if (!bounds) return
|
|
608
|
+
|
|
609
|
+
const boxW = bounds.maxX - bounds.minX + FIT_PADDING * 2
|
|
610
|
+
const boxH = bounds.maxY - bounds.minY + FIT_PADDING * 2
|
|
611
|
+
|
|
612
|
+
const viewW = el.clientWidth
|
|
613
|
+
const viewH = el.clientHeight
|
|
614
|
+
|
|
615
|
+
// Find the zoom level that fits the bounding box in the viewport
|
|
616
|
+
const fitScale = Math.min(viewW / boxW, viewH / boxH)
|
|
617
|
+
const fitZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(fitScale * 100)))
|
|
618
|
+
const newScale = fitZoom / 100
|
|
619
|
+
|
|
620
|
+
// Apply zoom synchronously so DOM updates before we scroll
|
|
621
|
+
zoomRef.current = fitZoom
|
|
622
|
+
flushSync(() => setZoom(fitZoom))
|
|
623
|
+
|
|
624
|
+
// Scroll so the bounding box top-left (with padding) is at viewport top-left
|
|
625
|
+
el.scrollLeft = (bounds.minX - FIT_PADDING) * newScale
|
|
626
|
+
el.scrollTop = (bounds.minY - FIT_PADDING) * newScale
|
|
627
|
+
}
|
|
628
|
+
document.addEventListener('storyboard:canvas:zoom-to-fit', handleZoomToFit)
|
|
629
|
+
return () => document.removeEventListener('storyboard:canvas:zoom-to-fit', handleZoomToFit)
|
|
630
|
+
}, [localWidgets, localSources, jsxExports])
|
|
631
|
+
|
|
388
632
|
// Canvas background should follow toolbar theme target.
|
|
389
633
|
useEffect(() => {
|
|
390
634
|
function readMode() {
|
|
@@ -420,6 +664,10 @@ export default function CanvasPage({ name }) {
|
|
|
420
664
|
if (!selectedWidgetId) return
|
|
421
665
|
const tag = e.target.tagName
|
|
422
666
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
|
|
667
|
+
if (e.key === 'Escape') {
|
|
668
|
+
e.preventDefault()
|
|
669
|
+
setSelectedWidgetId(null)
|
|
670
|
+
}
|
|
423
671
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
424
672
|
e.preventDefault()
|
|
425
673
|
handleWidgetRemove(selectedWidgetId)
|
|
@@ -430,7 +678,8 @@ export default function CanvasPage({ name }) {
|
|
|
430
678
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
431
679
|
}, [selectedWidgetId, handleWidgetRemove])
|
|
432
680
|
|
|
433
|
-
// Paste handler —
|
|
681
|
+
// Paste handler — images become image widgets, same-origin URLs become prototypes,
|
|
682
|
+
// other URLs become link previews, text becomes markdown
|
|
434
683
|
useEffect(() => {
|
|
435
684
|
const origin = window.location.origin
|
|
436
685
|
const basePath = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
|
|
@@ -461,10 +710,82 @@ export default function CanvasPage({ name }) {
|
|
|
461
710
|
return pathname
|
|
462
711
|
}
|
|
463
712
|
|
|
713
|
+
function blobToDataUrl(blob) {
|
|
714
|
+
return new Promise((resolve, reject) => {
|
|
715
|
+
const reader = new FileReader()
|
|
716
|
+
reader.onload = () => resolve(reader.result)
|
|
717
|
+
reader.onerror = reject
|
|
718
|
+
reader.readAsDataURL(blob)
|
|
719
|
+
})
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function getImageDimensions(dataUrl) {
|
|
723
|
+
return new Promise((resolve) => {
|
|
724
|
+
const img = new Image()
|
|
725
|
+
img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight })
|
|
726
|
+
img.onerror = () => resolve({ width: 400, height: 300 })
|
|
727
|
+
img.src = dataUrl
|
|
728
|
+
})
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
async function handleImagePaste(e) {
|
|
732
|
+
const items = e.clipboardData?.items
|
|
733
|
+
if (!items) return false
|
|
734
|
+
|
|
735
|
+
for (const item of items) {
|
|
736
|
+
if (!item.type.startsWith('image/')) continue
|
|
737
|
+
|
|
738
|
+
const blob = item.getAsFile()
|
|
739
|
+
if (!blob) continue
|
|
740
|
+
|
|
741
|
+
e.preventDefault()
|
|
742
|
+
|
|
743
|
+
try {
|
|
744
|
+
const dataUrl = await blobToDataUrl(blob)
|
|
745
|
+
const { width: natW, height: natH } = await getImageDimensions(dataUrl)
|
|
746
|
+
|
|
747
|
+
// Display at 2x retina: halve natural dimensions, then cap at 600px
|
|
748
|
+
const maxWidth = 600
|
|
749
|
+
let displayW = Math.round(natW / 2)
|
|
750
|
+
let displayH = Math.round(natH / 2)
|
|
751
|
+
if (displayW > maxWidth) {
|
|
752
|
+
displayH = Math.round(displayH * (maxWidth / displayW))
|
|
753
|
+
displayW = maxWidth
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
const uploadResult = await uploadImage(dataUrl, name)
|
|
757
|
+
if (!uploadResult.success) {
|
|
758
|
+
console.error('[canvas] Image upload failed:', uploadResult.error)
|
|
759
|
+
return true
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
|
|
763
|
+
const pos = centerPositionForWidget(center, 'image', { width: displayW, height: displayH })
|
|
764
|
+
const result = await addWidgetApi(name, {
|
|
765
|
+
type: 'image',
|
|
766
|
+
props: { src: uploadResult.filename, private: false, width: displayW, height: displayH },
|
|
767
|
+
position: pos,
|
|
768
|
+
})
|
|
769
|
+
if (result.success && result.widget) {
|
|
770
|
+
undoRedo.snapshot(stateRef.current, 'add')
|
|
771
|
+
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
772
|
+
}
|
|
773
|
+
} catch (err) {
|
|
774
|
+
console.error('[canvas] Failed to paste image:', err)
|
|
775
|
+
}
|
|
776
|
+
return true
|
|
777
|
+
}
|
|
778
|
+
return false
|
|
779
|
+
}
|
|
780
|
+
|
|
464
781
|
async function handlePaste(e) {
|
|
465
782
|
const tag = e.target.tagName
|
|
466
783
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
|
|
467
784
|
|
|
785
|
+
// Image paste takes priority
|
|
786
|
+
const handledImage = await handleImagePaste(e)
|
|
787
|
+
if (handledImage) return
|
|
788
|
+
|
|
468
789
|
const text = e.clipboardData?.getData('text/plain')?.trim()
|
|
469
790
|
if (!text) return
|
|
470
791
|
|
|
@@ -473,11 +794,14 @@ export default function CanvasPage({ name }) {
|
|
|
473
794
|
let type, props
|
|
474
795
|
try {
|
|
475
796
|
const parsed = new URL(text)
|
|
476
|
-
if (
|
|
797
|
+
if (isFigmaUrl(text)) {
|
|
798
|
+
type = 'figma-embed'
|
|
799
|
+
props = { url: sanitizeFigmaUrl(text), width: 800, height: 450 }
|
|
800
|
+
} else if (isSameOriginPrototype(text)) {
|
|
477
801
|
const pathPortion = parsed.pathname + parsed.search + parsed.hash
|
|
478
802
|
const src = extractPrototypeSrc(pathPortion)
|
|
479
803
|
type = 'prototype'
|
|
480
|
-
props = { src: src || '/', label: '', width: 800, height: 600 }
|
|
804
|
+
props = { src: src || '/', originalSrc: src || '/', label: '', width: 800, height: 600 }
|
|
481
805
|
} else {
|
|
482
806
|
type = 'link-preview'
|
|
483
807
|
props = { url: text, title: '' }
|
|
@@ -496,6 +820,7 @@ export default function CanvasPage({ name }) {
|
|
|
496
820
|
position: pos,
|
|
497
821
|
})
|
|
498
822
|
if (result.success && result.widget) {
|
|
823
|
+
undoRedo.snapshot(stateRef.current, 'add')
|
|
499
824
|
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
500
825
|
}
|
|
501
826
|
} catch (err) {
|
|
@@ -504,7 +829,75 @@ export default function CanvasPage({ name }) {
|
|
|
504
829
|
}
|
|
505
830
|
document.addEventListener('paste', handlePaste)
|
|
506
831
|
return () => document.removeEventListener('paste', handlePaste)
|
|
507
|
-
}, [name])
|
|
832
|
+
}, [name, undoRedo])
|
|
833
|
+
|
|
834
|
+
// --- Undo / Redo ---
|
|
835
|
+
const handleUndo = useCallback(() => {
|
|
836
|
+
const previous = undoRedo.undo(stateRef.current)
|
|
837
|
+
if (!previous) return
|
|
838
|
+
debouncedSave.cancel()
|
|
839
|
+
debouncedSourceSave.cancel()
|
|
840
|
+
setLocalWidgets(previous.widgets)
|
|
841
|
+
setLocalSources(previous.sources)
|
|
842
|
+
queueWrite(() =>
|
|
843
|
+
updateCanvas(name, { widgets: previous.widgets, sources: previous.sources }).catch((err) =>
|
|
844
|
+
console.error('[canvas] Failed to persist undo:', err)
|
|
845
|
+
)
|
|
846
|
+
)
|
|
847
|
+
}, [name, debouncedSave, debouncedSourceSave, undoRedo])
|
|
848
|
+
|
|
849
|
+
const handleRedo = useCallback(() => {
|
|
850
|
+
const next = undoRedo.redo(stateRef.current)
|
|
851
|
+
if (!next) return
|
|
852
|
+
debouncedSave.cancel()
|
|
853
|
+
debouncedSourceSave.cancel()
|
|
854
|
+
setLocalWidgets(next.widgets)
|
|
855
|
+
setLocalSources(next.sources)
|
|
856
|
+
queueWrite(() =>
|
|
857
|
+
updateCanvas(name, { widgets: next.widgets, sources: next.sources }).catch((err) =>
|
|
858
|
+
console.error('[canvas] Failed to persist redo:', err)
|
|
859
|
+
)
|
|
860
|
+
)
|
|
861
|
+
}, [name, debouncedSave, debouncedSourceSave, undoRedo])
|
|
862
|
+
|
|
863
|
+
// Keyboard shortcuts — dev-only (Cmd+Z / Cmd+Shift+Z)
|
|
864
|
+
useEffect(() => {
|
|
865
|
+
if (!import.meta.hot) return
|
|
866
|
+
function handleKeyDown(e) {
|
|
867
|
+
const tag = e.target.tagName
|
|
868
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
|
|
869
|
+
const mod = e.metaKey || e.ctrlKey
|
|
870
|
+
if (mod && e.key === 'z' && !e.shiftKey) {
|
|
871
|
+
e.preventDefault()
|
|
872
|
+
handleUndo()
|
|
873
|
+
}
|
|
874
|
+
if (mod && e.key === 'z' && e.shiftKey) {
|
|
875
|
+
e.preventDefault()
|
|
876
|
+
handleRedo()
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
document.addEventListener('keydown', handleKeyDown)
|
|
880
|
+
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
881
|
+
}, [handleUndo, handleRedo])
|
|
882
|
+
|
|
883
|
+
// Listen for undo/redo from CoreUIBar (Svelte toolbar)
|
|
884
|
+
useEffect(() => {
|
|
885
|
+
function handleUndoEvent() { handleUndo() }
|
|
886
|
+
function handleRedoEvent() { handleRedo() }
|
|
887
|
+
document.addEventListener('storyboard:canvas:undo', handleUndoEvent)
|
|
888
|
+
document.addEventListener('storyboard:canvas:redo', handleRedoEvent)
|
|
889
|
+
return () => {
|
|
890
|
+
document.removeEventListener('storyboard:canvas:undo', handleUndoEvent)
|
|
891
|
+
document.removeEventListener('storyboard:canvas:redo', handleRedoEvent)
|
|
892
|
+
}
|
|
893
|
+
}, [handleUndo, handleRedo])
|
|
894
|
+
|
|
895
|
+
// Broadcast undo/redo availability to Svelte toolbar
|
|
896
|
+
useEffect(() => {
|
|
897
|
+
document.dispatchEvent(new CustomEvent('storyboard:canvas:undo-redo-state', {
|
|
898
|
+
detail: { canUndo: undoRedo.canUndo, canRedo: undoRedo.canRedo }
|
|
899
|
+
}))
|
|
900
|
+
}, [undoRedo.canUndo, undoRedo.canRedo])
|
|
508
901
|
|
|
509
902
|
// Cmd+scroll / trackpad pinch to smooth-zoom the canvas
|
|
510
903
|
// On macOS, pinch-to-zoom fires wheel events with ctrlKey: true and small
|
|
@@ -681,6 +1074,7 @@ export default function CanvasPage({ name }) {
|
|
|
681
1074
|
onSelect={() => setSelectedWidgetId(widget.id)}
|
|
682
1075
|
onDeselect={() => setSelectedWidgetId(null)}
|
|
683
1076
|
onUpdate={handleWidgetUpdate}
|
|
1077
|
+
onCopy={handleWidgetCopy}
|
|
684
1078
|
onRemove={(id) => {
|
|
685
1079
|
handleWidgetRemove(id)
|
|
686
1080
|
setSelectedWidgetId(null)
|
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
|
+
}
|