@dfosco/storyboard-react 3.11.0-beta.4 → 3.11.1-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/Viewfinder.jsx +10 -1
- package/src/canvas/CanvasControls.jsx +59 -2
- package/src/canvas/CanvasControls.module.css +29 -0
- package/src/canvas/CanvasPage.bridge.test.jsx +0 -4
- package/src/canvas/CanvasPage.jsx +24 -520
- package/src/canvas/canvasApi.js +0 -8
- package/src/canvas/widgets/PrototypeEmbed.jsx +1 -20
- package/src/canvas/widgets/WidgetChrome.jsx +33 -254
- package/src/canvas/widgets/WidgetChrome.module.css +5 -79
- package/src/canvas/widgets/index.js +0 -4
- package/src/canvas/widgets/widgetConfig.js +5 -54
- package/src/canvas/widgets/widgetProps.js +0 -2
- package/src/canvas/computeCanvasBounds.test.js +0 -121
- package/src/canvas/useUndoRedo.js +0 -86
- package/src/canvas/useUndoRedo.test.js +0 -231
- package/src/canvas/widgets/FigmaEmbed.jsx +0 -106
- package/src/canvas/widgets/FigmaEmbed.module.css +0 -83
- package/src/canvas/widgets/ImageWidget.jsx +0 -113
- package/src/canvas/widgets/ImageWidget.module.css +0 -39
- package/src/canvas/widgets/figmaUrl.js +0 -118
- package/src/canvas/widgets/figmaUrl.test.js +0 -139
|
@@ -8,11 +8,9 @@ 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'
|
|
12
11
|
import WidgetChrome from './widgets/WidgetChrome.jsx'
|
|
13
12
|
import ComponentWidget from './widgets/ComponentWidget.jsx'
|
|
14
|
-
import
|
|
15
|
-
import { addWidget as addWidgetApi, updateCanvas, removeWidget as removeWidgetApi, uploadImage } from './canvasApi.js'
|
|
13
|
+
import { addWidget as addWidgetApi, updateCanvas, removeWidget as removeWidgetApi } from './canvasApi.js'
|
|
16
14
|
import styles from './CanvasPage.module.css'
|
|
17
15
|
|
|
18
16
|
const ZOOM_MIN = 25
|
|
@@ -49,40 +47,13 @@ function resolveCanvasThemeFromStorage() {
|
|
|
49
47
|
|
|
50
48
|
/**
|
|
51
49
|
* Debounce helper — returns a function that delays invocation.
|
|
52
|
-
* Exposes `.cancel()` to abort pending calls (used by undo/redo).
|
|
53
50
|
*/
|
|
54
51
|
function debounce(fn, ms) {
|
|
55
52
|
let timer
|
|
56
|
-
|
|
53
|
+
return (...args) => {
|
|
57
54
|
clearTimeout(timer)
|
|
58
55
|
timer = setTimeout(() => fn(...args), ms)
|
|
59
56
|
}
|
|
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 */ }
|
|
86
57
|
}
|
|
87
58
|
|
|
88
59
|
/**
|
|
@@ -103,13 +74,11 @@ function getViewportCenter(scrollEl, scale) {
|
|
|
103
74
|
|
|
104
75
|
/** Fallback sizes for widget types without explicit width/height defaults. */
|
|
105
76
|
const WIDGET_FALLBACK_SIZES = {
|
|
106
|
-
'sticky-note': { width:
|
|
107
|
-
'markdown': { width:
|
|
77
|
+
'sticky-note': { width: 180, height: 60 },
|
|
78
|
+
'markdown': { width: 360, height: 200 },
|
|
108
79
|
'prototype': { width: 800, height: 600 },
|
|
109
80
|
'link-preview': { width: 320, height: 120 },
|
|
110
|
-
'figma-embed': { width: 800, height: 450 },
|
|
111
81
|
'component': { width: 200, height: 150 },
|
|
112
|
-
'image': { width: 400, height: 300 },
|
|
113
82
|
}
|
|
114
83
|
|
|
115
84
|
/**
|
|
@@ -130,77 +99,6 @@ function roundPosition(value) {
|
|
|
130
99
|
return Math.round(value)
|
|
131
100
|
}
|
|
132
101
|
|
|
133
|
-
/** Snap a value to the nearest grid line. */
|
|
134
|
-
function snapValue(value, gridSize) {
|
|
135
|
-
return Math.round(value / gridSize) * gridSize
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/** Snap a position to the grid if snapping is enabled. */
|
|
139
|
-
function snapPosition(pos, gridSize, enabled) {
|
|
140
|
-
if (!enabled || !gridSize) return pos
|
|
141
|
-
return {
|
|
142
|
-
x: Math.max(0, snapValue(pos.x, gridSize)),
|
|
143
|
-
y: Math.max(0, snapValue(pos.y, gridSize)),
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/** Snap a dimension to the grid if snapping is enabled. */
|
|
148
|
-
function snapDimension(value, gridSize, enabled, min = 0) {
|
|
149
|
-
if (!enabled || !gridSize) return value
|
|
150
|
-
return Math.max(min, snapValue(value, gridSize))
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/** Padding (canvas-space pixels) around bounding box for zoom-to-fit. */
|
|
154
|
-
const FIT_PADDING = 48
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* Compute the axis-aligned bounding box that contains every widget and source.
|
|
158
|
-
* Returns { minX, minY, maxX, maxY } in canvas-space coordinates, or null if empty.
|
|
159
|
-
*/
|
|
160
|
-
function computeCanvasBounds(widgets, sources, jsxExports) {
|
|
161
|
-
let minX = Infinity
|
|
162
|
-
let minY = Infinity
|
|
163
|
-
let maxX = -Infinity
|
|
164
|
-
let maxY = -Infinity
|
|
165
|
-
let hasItems = false
|
|
166
|
-
|
|
167
|
-
// JSON widgets
|
|
168
|
-
for (const w of (widgets ?? [])) {
|
|
169
|
-
const x = w?.position?.x ?? 0
|
|
170
|
-
const y = w?.position?.y ?? 0
|
|
171
|
-
const fallback = WIDGET_FALLBACK_SIZES[w.type] || { width: 200, height: 150 }
|
|
172
|
-
const width = w.props?.width ?? fallback.width
|
|
173
|
-
const height = w.props?.height ?? fallback.height
|
|
174
|
-
minX = Math.min(minX, x)
|
|
175
|
-
minY = Math.min(minY, y)
|
|
176
|
-
maxX = Math.max(maxX, x + width)
|
|
177
|
-
maxY = Math.max(maxY, y + height)
|
|
178
|
-
hasItems = true
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// JSX sources
|
|
182
|
-
const sourceMap = Object.fromEntries(
|
|
183
|
-
(sources || []).filter((s) => s?.export).map((s) => [s.export, s])
|
|
184
|
-
)
|
|
185
|
-
if (jsxExports) {
|
|
186
|
-
for (const exportName of Object.keys(jsxExports)) {
|
|
187
|
-
const sourceData = sourceMap[exportName] || {}
|
|
188
|
-
const x = sourceData.position?.x ?? 0
|
|
189
|
-
const y = sourceData.position?.y ?? 0
|
|
190
|
-
const fallback = WIDGET_FALLBACK_SIZES['component']
|
|
191
|
-
const width = sourceData.width ?? fallback.width
|
|
192
|
-
const height = sourceData.height ?? fallback.height
|
|
193
|
-
minX = Math.min(minX, x)
|
|
194
|
-
minY = Math.min(minY, y)
|
|
195
|
-
maxX = Math.max(maxX, x + width)
|
|
196
|
-
maxY = Math.max(maxY, y + height)
|
|
197
|
-
hasItems = true
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
return hasItems ? { minX, minY, maxX, maxY } : null
|
|
202
|
-
}
|
|
203
|
-
|
|
204
102
|
/** Renders a single JSON-defined widget by type lookup. */
|
|
205
103
|
function WidgetRenderer({ widget, onUpdate, widgetRef }) {
|
|
206
104
|
const Component = getWidgetComponent(widget.type)
|
|
@@ -227,7 +125,6 @@ function ChromeWrappedWidget({
|
|
|
227
125
|
onDeselect,
|
|
228
126
|
onUpdate,
|
|
229
127
|
onRemove,
|
|
230
|
-
onCopy,
|
|
231
128
|
}) {
|
|
232
129
|
const widgetRef = useRef(null)
|
|
233
130
|
const features = getFeatures(widget.type)
|
|
@@ -235,10 +132,8 @@ function ChromeWrappedWidget({
|
|
|
235
132
|
const handleAction = useCallback((actionId) => {
|
|
236
133
|
if (actionId === 'delete') {
|
|
237
134
|
onRemove(widget.id)
|
|
238
|
-
} else if (actionId === 'copy') {
|
|
239
|
-
onCopy(widget)
|
|
240
135
|
}
|
|
241
|
-
}, [widget, onRemove
|
|
136
|
+
}, [widget.id, onRemove])
|
|
242
137
|
|
|
243
138
|
return (
|
|
244
139
|
<WidgetChrome
|
|
@@ -275,40 +170,19 @@ export default function CanvasPage({ name }) {
|
|
|
275
170
|
const [localWidgets, setLocalWidgets] = useState(canvas?.widgets ?? null)
|
|
276
171
|
const [trackedCanvas, setTrackedCanvas] = useState(canvas)
|
|
277
172
|
const [selectedWidgetId, setSelectedWidgetId] = useState(null)
|
|
278
|
-
const
|
|
279
|
-
const
|
|
280
|
-
const zoomRef = useRef(initialViewport?.zoom ?? 100)
|
|
173
|
+
const [zoom, setZoom] = useState(100)
|
|
174
|
+
const zoomRef = useRef(100)
|
|
281
175
|
const scrollRef = useRef(null)
|
|
282
|
-
const pendingScrollRestore = useRef(initialViewport)
|
|
283
176
|
const [canvasTitle, setCanvasTitle] = useState(canvas?.title || name)
|
|
284
177
|
const titleInputRef = useRef(null)
|
|
285
178
|
const [localSources, setLocalSources] = useState(canvas?.sources ?? [])
|
|
286
179
|
const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
|
|
287
|
-
const [snapEnabled, setSnapEnabled] = useState(canvas?.snapToGrid ?? false)
|
|
288
|
-
const snapGridSize = canvas?.gridSize || 40
|
|
289
|
-
|
|
290
|
-
// Undo/redo history — tracks both widgets and sources as a combined snapshot
|
|
291
|
-
const undoRedo = useUndoRedo()
|
|
292
|
-
const stateRef = useRef({ widgets: localWidgets, sources: localSources })
|
|
293
|
-
useEffect(() => {
|
|
294
|
-
stateRef.current = { widgets: localWidgets, sources: localSources }
|
|
295
|
-
}, [localWidgets, localSources])
|
|
296
|
-
|
|
297
|
-
// Serialized write queue — ensures JSONL events land in the right order
|
|
298
|
-
const writeQueueRef = useRef(Promise.resolve())
|
|
299
|
-
function queueWrite(fn) {
|
|
300
|
-
writeQueueRef.current = writeQueueRef.current.then(fn).catch((err) =>
|
|
301
|
-
console.error('[canvas] Write queue error:', err)
|
|
302
|
-
)
|
|
303
|
-
return writeQueueRef.current
|
|
304
|
-
}
|
|
305
180
|
|
|
306
181
|
if (canvas !== trackedCanvas) {
|
|
307
182
|
setTrackedCanvas(canvas)
|
|
308
183
|
setLocalWidgets(canvas?.widgets ?? null)
|
|
309
184
|
setLocalSources(canvas?.sources ?? [])
|
|
310
185
|
setCanvasTitle(canvas?.title || name)
|
|
311
|
-
undoRedo.reset()
|
|
312
186
|
}
|
|
313
187
|
|
|
314
188
|
// Debounced save to server
|
|
@@ -342,59 +216,22 @@ export default function CanvasPage({ name }) {
|
|
|
342
216
|
}, [])
|
|
343
217
|
|
|
344
218
|
const handleWidgetUpdate = useCallback((widgetId, updates) => {
|
|
345
|
-
undoRedo.snapshot(stateRef.current, 'edit', widgetId)
|
|
346
|
-
// Snap width/height to grid when snap is enabled
|
|
347
|
-
const snapped = { ...updates }
|
|
348
|
-
if (snapEnabled && snapGridSize) {
|
|
349
|
-
if (snapped.width != null) snapped.width = snapDimension(snapped.width, snapGridSize, true, 60)
|
|
350
|
-
if (snapped.height != null) snapped.height = snapDimension(snapped.height, snapGridSize, true, 60)
|
|
351
|
-
}
|
|
352
219
|
setLocalWidgets((prev) => {
|
|
353
220
|
if (!prev) return prev
|
|
354
221
|
const next = prev.map((w) =>
|
|
355
|
-
w.id === widgetId ? { ...w, props: { ...w.props, ...
|
|
222
|
+
w.id === widgetId ? { ...w, props: { ...w.props, ...updates } } : w
|
|
356
223
|
)
|
|
357
224
|
debouncedSave(name, next)
|
|
358
225
|
return next
|
|
359
226
|
})
|
|
360
|
-
}, [name, debouncedSave
|
|
227
|
+
}, [name, debouncedSave])
|
|
361
228
|
|
|
362
229
|
const handleWidgetRemove = useCallback((widgetId) => {
|
|
363
|
-
undoRedo.snapshot(stateRef.current, 'remove', widgetId)
|
|
364
230
|
setLocalWidgets((prev) => prev ? prev.filter((w) => w.id !== widgetId) : prev)
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
console.error('[canvas] Failed to remove widget:', err)
|
|
368
|
-
)
|
|
369
|
-
)
|
|
370
|
-
}, [name, undoRedo])
|
|
371
|
-
|
|
372
|
-
const handleWidgetCopy = useCallback(async (widget) => {
|
|
373
|
-
// Find the next free offset — check how many copies already exist at +n*40
|
|
374
|
-
const baseX = widget.position?.x ?? 0
|
|
375
|
-
const baseY = widget.position?.y ?? 0
|
|
376
|
-
const occupied = new Set(
|
|
377
|
-
(localWidgets ?? []).map((w) => `${w.position?.x ?? 0},${w.position?.y ?? 0}`)
|
|
231
|
+
removeWidgetApi(name, widgetId).catch((err) =>
|
|
232
|
+
console.error('[canvas] Failed to remove widget:', err)
|
|
378
233
|
)
|
|
379
|
-
|
|
380
|
-
while (occupied.has(`${baseX + n * 40},${baseY + n * 40}`)) {
|
|
381
|
-
n++
|
|
382
|
-
}
|
|
383
|
-
const position = { x: baseX + n * 40, y: baseY + n * 40 }
|
|
384
|
-
try {
|
|
385
|
-
undoRedo.snapshot(stateRef.current, 'add')
|
|
386
|
-
const result = await addWidgetApi(name, {
|
|
387
|
-
type: widget.type,
|
|
388
|
-
props: { ...widget.props },
|
|
389
|
-
position,
|
|
390
|
-
})
|
|
391
|
-
if (result.success && result.widget) {
|
|
392
|
-
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
393
|
-
}
|
|
394
|
-
} catch (err) {
|
|
395
|
-
console.error('[canvas] Failed to copy widget:', err)
|
|
396
|
-
}
|
|
397
|
-
}, [name, localWidgets, undoRedo])
|
|
234
|
+
}, [name])
|
|
398
235
|
|
|
399
236
|
const debouncedSourceSave = useRef(
|
|
400
237
|
debounce((canvasName, sources) => {
|
|
@@ -405,7 +242,6 @@ export default function CanvasPage({ name }) {
|
|
|
405
242
|
).current
|
|
406
243
|
|
|
407
244
|
const handleSourceUpdate = useCallback((exportName, updates) => {
|
|
408
|
-
undoRedo.snapshot(stateRef.current, 'edit', `jsx-${exportName}`)
|
|
409
245
|
setLocalSources((prev) => {
|
|
410
246
|
const current = Array.isArray(prev) ? prev : []
|
|
411
247
|
const next = current.some((s) => s?.export === exportName)
|
|
@@ -414,149 +250,43 @@ export default function CanvasPage({ name }) {
|
|
|
414
250
|
debouncedSourceSave(name, next)
|
|
415
251
|
return next
|
|
416
252
|
})
|
|
417
|
-
}, [name, debouncedSourceSave
|
|
253
|
+
}, [name, debouncedSourceSave])
|
|
418
254
|
|
|
419
255
|
const handleItemDragEnd = useCallback((dragId, position) => {
|
|
420
256
|
if (!dragId || !position) return
|
|
421
|
-
const
|
|
422
|
-
const rounded = snapPosition(raw, snapGridSize, snapEnabled)
|
|
257
|
+
const rounded = { x: Math.max(0, roundPosition(position.x)), y: Math.max(0, roundPosition(position.y)) }
|
|
423
258
|
|
|
424
259
|
if (dragId.startsWith('jsx-')) {
|
|
425
|
-
undoRedo.snapshot(stateRef.current, 'move', dragId)
|
|
426
260
|
const sourceExport = dragId.replace(/^jsx-/, '')
|
|
427
261
|
setLocalSources((prev) => {
|
|
428
262
|
const current = Array.isArray(prev) ? prev : []
|
|
429
263
|
const next = current.some((s) => s?.export === sourceExport)
|
|
430
264
|
? current.map((s) => (s?.export === sourceExport ? { ...s, position: rounded } : s))
|
|
431
265
|
: [...current, { export: sourceExport, position: rounded }]
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
console.error('[canvas] Failed to save source position:', err)
|
|
435
|
-
)
|
|
266
|
+
updateCanvas(name, { sources: next }).catch((err) =>
|
|
267
|
+
console.error('[canvas] Failed to save source position:', err)
|
|
436
268
|
)
|
|
437
269
|
return next
|
|
438
270
|
})
|
|
439
271
|
return
|
|
440
272
|
}
|
|
441
273
|
|
|
442
|
-
undoRedo.snapshot(stateRef.current, 'move', dragId)
|
|
443
274
|
setLocalWidgets((prev) => {
|
|
444
275
|
if (!prev) return prev
|
|
445
276
|
const next = prev.map((w) =>
|
|
446
277
|
w.id === dragId ? { ...w, position: rounded } : w
|
|
447
278
|
)
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
console.error('[canvas] Failed to save widget position:', err)
|
|
451
|
-
)
|
|
279
|
+
updateCanvas(name, { widgets: next }).catch((err) =>
|
|
280
|
+
console.error('[canvas] Failed to save widget position:', err)
|
|
452
281
|
)
|
|
453
282
|
return next
|
|
454
283
|
})
|
|
455
|
-
}, [name
|
|
284
|
+
}, [name])
|
|
456
285
|
|
|
457
286
|
useEffect(() => {
|
|
458
287
|
zoomRef.current = zoom
|
|
459
288
|
}, [zoom])
|
|
460
289
|
|
|
461
|
-
// Restore scroll position from localStorage after first render
|
|
462
|
-
useEffect(() => {
|
|
463
|
-
const el = scrollRef.current
|
|
464
|
-
const saved = pendingScrollRestore.current
|
|
465
|
-
if (el && saved) {
|
|
466
|
-
if (saved.scrollLeft != null) el.scrollLeft = saved.scrollLeft
|
|
467
|
-
if (saved.scrollTop != null) el.scrollTop = saved.scrollTop
|
|
468
|
-
pendingScrollRestore.current = null
|
|
469
|
-
}
|
|
470
|
-
}, [name, loading])
|
|
471
|
-
|
|
472
|
-
// Center on a specific widget if `?widget=<id>` is in the URL
|
|
473
|
-
useEffect(() => {
|
|
474
|
-
const params = new URLSearchParams(window.location.search)
|
|
475
|
-
const targetId = params.get('widget')
|
|
476
|
-
if (!targetId || loading) return
|
|
477
|
-
|
|
478
|
-
const el = scrollRef.current
|
|
479
|
-
if (!el) return
|
|
480
|
-
|
|
481
|
-
let x, y, w, h
|
|
482
|
-
|
|
483
|
-
// Check JSON widgets first
|
|
484
|
-
const widgets = localWidgets ?? []
|
|
485
|
-
const widget = widgets.find((wgt) => wgt.id === targetId)
|
|
486
|
-
if (widget) {
|
|
487
|
-
const fallback = WIDGET_FALLBACK_SIZES[widget.type] || { width: 200, height: 150 }
|
|
488
|
-
x = widget.position?.x ?? 0
|
|
489
|
-
y = widget.position?.y ?? 0
|
|
490
|
-
w = widget.props?.width ?? fallback.width
|
|
491
|
-
h = widget.props?.height ?? fallback.height
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
// Check JSX sources (jsx-ExportName)
|
|
495
|
-
if (!widget && targetId.startsWith('jsx-')) {
|
|
496
|
-
const exportName = targetId.slice(4)
|
|
497
|
-
const sourceMap = Object.fromEntries(
|
|
498
|
-
(localSources || []).filter((s) => s?.export).map((s) => [s.export, s])
|
|
499
|
-
)
|
|
500
|
-
const sourceData = sourceMap[exportName]
|
|
501
|
-
if (sourceData || (jsxExports && exportName in jsxExports)) {
|
|
502
|
-
const fallback = WIDGET_FALLBACK_SIZES['component']
|
|
503
|
-
x = sourceData?.position?.x ?? 0
|
|
504
|
-
y = sourceData?.position?.y ?? 0
|
|
505
|
-
w = sourceData?.width ?? fallback.width
|
|
506
|
-
h = sourceData?.height ?? fallback.height
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
if (x == null) return
|
|
511
|
-
|
|
512
|
-
const scale = zoomRef.current / 100
|
|
513
|
-
el.scrollLeft = (x + w / 2) * scale - el.clientWidth / 2
|
|
514
|
-
el.scrollTop = (y + h / 2) * scale - el.clientHeight / 2
|
|
515
|
-
|
|
516
|
-
// Clean the URL param without triggering navigation
|
|
517
|
-
const url = new URL(window.location.href)
|
|
518
|
-
url.searchParams.delete('widget')
|
|
519
|
-
window.history.replaceState({}, '', url.toString())
|
|
520
|
-
}, [loading, localWidgets, localSources, jsxExports])
|
|
521
|
-
|
|
522
|
-
// Persist viewport state (zoom + scroll) to localStorage on changes
|
|
523
|
-
useEffect(() => {
|
|
524
|
-
const el = scrollRef.current
|
|
525
|
-
saveViewportState(name, {
|
|
526
|
-
zoom,
|
|
527
|
-
scrollLeft: el?.scrollLeft ?? 0,
|
|
528
|
-
scrollTop: el?.scrollTop ?? 0,
|
|
529
|
-
})
|
|
530
|
-
}, [name, zoom])
|
|
531
|
-
|
|
532
|
-
useEffect(() => {
|
|
533
|
-
const el = scrollRef.current
|
|
534
|
-
if (!el) return
|
|
535
|
-
function handleScroll() {
|
|
536
|
-
saveViewportState(name, {
|
|
537
|
-
zoom: zoomRef.current,
|
|
538
|
-
scrollLeft: el.scrollLeft,
|
|
539
|
-
scrollTop: el.scrollTop,
|
|
540
|
-
})
|
|
541
|
-
}
|
|
542
|
-
el.addEventListener('scroll', handleScroll, { passive: true })
|
|
543
|
-
|
|
544
|
-
// Flush viewport state on page unload so a refresh never misses it
|
|
545
|
-
function handleBeforeUnload() {
|
|
546
|
-
saveViewportState(name, {
|
|
547
|
-
zoom: zoomRef.current,
|
|
548
|
-
scrollLeft: el.scrollLeft,
|
|
549
|
-
scrollTop: el.scrollTop,
|
|
550
|
-
})
|
|
551
|
-
}
|
|
552
|
-
window.addEventListener('beforeunload', handleBeforeUnload)
|
|
553
|
-
|
|
554
|
-
return () => {
|
|
555
|
-
el.removeEventListener('scroll', handleScroll)
|
|
556
|
-
window.removeEventListener('beforeunload', handleBeforeUnload)
|
|
557
|
-
}
|
|
558
|
-
}, [name, loading])
|
|
559
|
-
|
|
560
290
|
/**
|
|
561
291
|
* Zoom to a new level, anchoring on an optional client-space point.
|
|
562
292
|
* When a cursor position is provided (e.g. from a wheel event), the
|
|
@@ -615,26 +345,6 @@ export default function CanvasPage({ name }) {
|
|
|
615
345
|
}
|
|
616
346
|
}, [name])
|
|
617
347
|
|
|
618
|
-
// Tell the Vite dev server to suppress full-reloads while this canvas is active.
|
|
619
|
-
// The ?canvas-hmr URL param opts out of the guard for canvas UI development.
|
|
620
|
-
// Sends a heartbeat every 3s so the guard auto-expires if the tab closes.
|
|
621
|
-
useEffect(() => {
|
|
622
|
-
if (!import.meta.hot) return
|
|
623
|
-
const hmrEnabled = new URLSearchParams(window.location.search).has('canvas-hmr')
|
|
624
|
-
if (hmrEnabled) return
|
|
625
|
-
|
|
626
|
-
const msg = { active: true, hmrEnabled: false }
|
|
627
|
-
import.meta.hot.send('storyboard:canvas-hmr-guard', msg)
|
|
628
|
-
const interval = setInterval(() => {
|
|
629
|
-
import.meta.hot.send('storyboard:canvas-hmr-guard', msg)
|
|
630
|
-
}, 3000)
|
|
631
|
-
|
|
632
|
-
return () => {
|
|
633
|
-
clearInterval(interval)
|
|
634
|
-
import.meta.hot.send('storyboard:canvas-hmr-guard', { active: false, hmrEnabled: true })
|
|
635
|
-
}
|
|
636
|
-
}, [name])
|
|
637
|
-
|
|
638
348
|
// Add a widget by type — used by CanvasControls and CoreUIBar event
|
|
639
349
|
const addWidget = useCallback(async (type) => {
|
|
640
350
|
const defaultProps = schemas[type] ? getDefaults(schemas[type]) : {}
|
|
@@ -647,13 +357,12 @@ export default function CanvasPage({ name }) {
|
|
|
647
357
|
position: pos,
|
|
648
358
|
})
|
|
649
359
|
if (result.success && result.widget) {
|
|
650
|
-
undoRedo.snapshot(stateRef.current, 'add')
|
|
651
360
|
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
652
361
|
}
|
|
653
362
|
} catch (err) {
|
|
654
363
|
console.error('[canvas] Failed to add widget:', err)
|
|
655
364
|
}
|
|
656
|
-
}, [name
|
|
365
|
+
}, [name])
|
|
657
366
|
|
|
658
367
|
// Listen for CoreUIBar add-widget events
|
|
659
368
|
useEffect(() => {
|
|
@@ -676,60 +385,6 @@ export default function CanvasPage({ name }) {
|
|
|
676
385
|
return () => document.removeEventListener('storyboard:canvas:set-zoom', handleZoom)
|
|
677
386
|
}, [])
|
|
678
387
|
|
|
679
|
-
// Listen for snap-to-grid toggle from CoreUIBar
|
|
680
|
-
useEffect(() => {
|
|
681
|
-
function handleSnapToggle() {
|
|
682
|
-
setSnapEnabled((prev) => {
|
|
683
|
-
const next = !prev
|
|
684
|
-
updateCanvas(name, { snapToGrid: next }).catch((err) =>
|
|
685
|
-
console.error('[canvas] Failed to persist snap setting:', err)
|
|
686
|
-
)
|
|
687
|
-
return next
|
|
688
|
-
})
|
|
689
|
-
}
|
|
690
|
-
document.addEventListener('storyboard:canvas:toggle-snap', handleSnapToggle)
|
|
691
|
-
return () => document.removeEventListener('storyboard:canvas:toggle-snap', handleSnapToggle)
|
|
692
|
-
}, [name])
|
|
693
|
-
|
|
694
|
-
// Broadcast snap state to Svelte toolbar
|
|
695
|
-
useEffect(() => {
|
|
696
|
-
document.dispatchEvent(new CustomEvent('storyboard:canvas:snap-state', {
|
|
697
|
-
detail: { snapEnabled }
|
|
698
|
-
}))
|
|
699
|
-
}, [snapEnabled])
|
|
700
|
-
|
|
701
|
-
// Listen for zoom-to-fit from CoreUIBar
|
|
702
|
-
useEffect(() => {
|
|
703
|
-
function handleZoomToFit() {
|
|
704
|
-
const el = scrollRef.current
|
|
705
|
-
if (!el) return
|
|
706
|
-
|
|
707
|
-
const bounds = computeCanvasBounds(localWidgets, localSources, jsxExports)
|
|
708
|
-
if (!bounds) return
|
|
709
|
-
|
|
710
|
-
const boxW = bounds.maxX - bounds.minX + FIT_PADDING * 2
|
|
711
|
-
const boxH = bounds.maxY - bounds.minY + FIT_PADDING * 2
|
|
712
|
-
|
|
713
|
-
const viewW = el.clientWidth
|
|
714
|
-
const viewH = el.clientHeight
|
|
715
|
-
|
|
716
|
-
// Find the zoom level that fits the bounding box in the viewport
|
|
717
|
-
const fitScale = Math.min(viewW / boxW, viewH / boxH)
|
|
718
|
-
const fitZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(fitScale * 100)))
|
|
719
|
-
const newScale = fitZoom / 100
|
|
720
|
-
|
|
721
|
-
// Apply zoom synchronously so DOM updates before we scroll
|
|
722
|
-
zoomRef.current = fitZoom
|
|
723
|
-
flushSync(() => setZoom(fitZoom))
|
|
724
|
-
|
|
725
|
-
// Scroll so the bounding box top-left (with padding) is at viewport top-left
|
|
726
|
-
el.scrollLeft = (bounds.minX - FIT_PADDING) * newScale
|
|
727
|
-
el.scrollTop = (bounds.minY - FIT_PADDING) * newScale
|
|
728
|
-
}
|
|
729
|
-
document.addEventListener('storyboard:canvas:zoom-to-fit', handleZoomToFit)
|
|
730
|
-
return () => document.removeEventListener('storyboard:canvas:zoom-to-fit', handleZoomToFit)
|
|
731
|
-
}, [localWidgets, localSources, jsxExports])
|
|
732
|
-
|
|
733
388
|
// Canvas background should follow toolbar theme target.
|
|
734
389
|
useEffect(() => {
|
|
735
390
|
function readMode() {
|
|
@@ -765,10 +420,6 @@ export default function CanvasPage({ name }) {
|
|
|
765
420
|
if (!selectedWidgetId) return
|
|
766
421
|
const tag = e.target.tagName
|
|
767
422
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
|
|
768
|
-
if (e.key === 'Escape') {
|
|
769
|
-
e.preventDefault()
|
|
770
|
-
setSelectedWidgetId(null)
|
|
771
|
-
}
|
|
772
423
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
773
424
|
e.preventDefault()
|
|
774
425
|
handleWidgetRemove(selectedWidgetId)
|
|
@@ -779,8 +430,7 @@ export default function CanvasPage({ name }) {
|
|
|
779
430
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
780
431
|
}, [selectedWidgetId, handleWidgetRemove])
|
|
781
432
|
|
|
782
|
-
// Paste handler —
|
|
783
|
-
// other URLs become link previews, text becomes markdown
|
|
433
|
+
// Paste handler — same-origin URLs become prototypes, other URLs become link previews, text becomes markdown
|
|
784
434
|
useEffect(() => {
|
|
785
435
|
const origin = window.location.origin
|
|
786
436
|
const basePath = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
|
|
@@ -811,82 +461,10 @@ export default function CanvasPage({ name }) {
|
|
|
811
461
|
return pathname
|
|
812
462
|
}
|
|
813
463
|
|
|
814
|
-
function blobToDataUrl(blob) {
|
|
815
|
-
return new Promise((resolve, reject) => {
|
|
816
|
-
const reader = new FileReader()
|
|
817
|
-
reader.onload = () => resolve(reader.result)
|
|
818
|
-
reader.onerror = reject
|
|
819
|
-
reader.readAsDataURL(blob)
|
|
820
|
-
})
|
|
821
|
-
}
|
|
822
|
-
|
|
823
|
-
function getImageDimensions(dataUrl) {
|
|
824
|
-
return new Promise((resolve) => {
|
|
825
|
-
const img = new Image()
|
|
826
|
-
img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight })
|
|
827
|
-
img.onerror = () => resolve({ width: 400, height: 300 })
|
|
828
|
-
img.src = dataUrl
|
|
829
|
-
})
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
async function handleImagePaste(e) {
|
|
833
|
-
const items = e.clipboardData?.items
|
|
834
|
-
if (!items) return false
|
|
835
|
-
|
|
836
|
-
for (const item of items) {
|
|
837
|
-
if (!item.type.startsWith('image/')) continue
|
|
838
|
-
|
|
839
|
-
const blob = item.getAsFile()
|
|
840
|
-
if (!blob) continue
|
|
841
|
-
|
|
842
|
-
e.preventDefault()
|
|
843
|
-
|
|
844
|
-
try {
|
|
845
|
-
const dataUrl = await blobToDataUrl(blob)
|
|
846
|
-
const { width: natW, height: natH } = await getImageDimensions(dataUrl)
|
|
847
|
-
|
|
848
|
-
// Display at 2x retina: halve natural dimensions, then cap at 600px
|
|
849
|
-
const maxWidth = 600
|
|
850
|
-
let displayW = Math.round(natW / 2)
|
|
851
|
-
let displayH = Math.round(natH / 2)
|
|
852
|
-
if (displayW > maxWidth) {
|
|
853
|
-
displayH = Math.round(displayH * (maxWidth / displayW))
|
|
854
|
-
displayW = maxWidth
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
const uploadResult = await uploadImage(dataUrl, name)
|
|
858
|
-
if (!uploadResult.success) {
|
|
859
|
-
console.error('[canvas] Image upload failed:', uploadResult.error)
|
|
860
|
-
return true
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
|
|
864
|
-
const pos = centerPositionForWidget(center, 'image', { width: displayW, height: displayH })
|
|
865
|
-
const result = await addWidgetApi(name, {
|
|
866
|
-
type: 'image',
|
|
867
|
-
props: { src: uploadResult.filename, private: false, width: displayW, height: displayH },
|
|
868
|
-
position: pos,
|
|
869
|
-
})
|
|
870
|
-
if (result.success && result.widget) {
|
|
871
|
-
undoRedo.snapshot(stateRef.current, 'add')
|
|
872
|
-
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
873
|
-
}
|
|
874
|
-
} catch (err) {
|
|
875
|
-
console.error('[canvas] Failed to paste image:', err)
|
|
876
|
-
}
|
|
877
|
-
return true
|
|
878
|
-
}
|
|
879
|
-
return false
|
|
880
|
-
}
|
|
881
|
-
|
|
882
464
|
async function handlePaste(e) {
|
|
883
465
|
const tag = e.target.tagName
|
|
884
466
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
|
|
885
467
|
|
|
886
|
-
// Image paste takes priority
|
|
887
|
-
const handledImage = await handleImagePaste(e)
|
|
888
|
-
if (handledImage) return
|
|
889
|
-
|
|
890
468
|
const text = e.clipboardData?.getData('text/plain')?.trim()
|
|
891
469
|
if (!text) return
|
|
892
470
|
|
|
@@ -895,14 +473,11 @@ export default function CanvasPage({ name }) {
|
|
|
895
473
|
let type, props
|
|
896
474
|
try {
|
|
897
475
|
const parsed = new URL(text)
|
|
898
|
-
if (
|
|
899
|
-
type = 'figma-embed'
|
|
900
|
-
props = { url: sanitizeFigmaUrl(text), width: 800, height: 450 }
|
|
901
|
-
} else if (isSameOriginPrototype(text)) {
|
|
476
|
+
if (isSameOriginPrototype(text)) {
|
|
902
477
|
const pathPortion = parsed.pathname + parsed.search + parsed.hash
|
|
903
478
|
const src = extractPrototypeSrc(pathPortion)
|
|
904
479
|
type = 'prototype'
|
|
905
|
-
props = { src: src || '/',
|
|
480
|
+
props = { src: src || '/', label: '', width: 800, height: 600 }
|
|
906
481
|
} else {
|
|
907
482
|
type = 'link-preview'
|
|
908
483
|
props = { url: text, title: '' }
|
|
@@ -921,7 +496,6 @@ export default function CanvasPage({ name }) {
|
|
|
921
496
|
position: pos,
|
|
922
497
|
})
|
|
923
498
|
if (result.success && result.widget) {
|
|
924
|
-
undoRedo.snapshot(stateRef.current, 'add')
|
|
925
499
|
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
926
500
|
}
|
|
927
501
|
} catch (err) {
|
|
@@ -930,75 +504,7 @@ export default function CanvasPage({ name }) {
|
|
|
930
504
|
}
|
|
931
505
|
document.addEventListener('paste', handlePaste)
|
|
932
506
|
return () => document.removeEventListener('paste', handlePaste)
|
|
933
|
-
}, [name
|
|
934
|
-
|
|
935
|
-
// --- Undo / Redo ---
|
|
936
|
-
const handleUndo = useCallback(() => {
|
|
937
|
-
const previous = undoRedo.undo(stateRef.current)
|
|
938
|
-
if (!previous) return
|
|
939
|
-
debouncedSave.cancel()
|
|
940
|
-
debouncedSourceSave.cancel()
|
|
941
|
-
setLocalWidgets(previous.widgets)
|
|
942
|
-
setLocalSources(previous.sources)
|
|
943
|
-
queueWrite(() =>
|
|
944
|
-
updateCanvas(name, { widgets: previous.widgets, sources: previous.sources }).catch((err) =>
|
|
945
|
-
console.error('[canvas] Failed to persist undo:', err)
|
|
946
|
-
)
|
|
947
|
-
)
|
|
948
|
-
}, [name, debouncedSave, debouncedSourceSave, undoRedo])
|
|
949
|
-
|
|
950
|
-
const handleRedo = useCallback(() => {
|
|
951
|
-
const next = undoRedo.redo(stateRef.current)
|
|
952
|
-
if (!next) return
|
|
953
|
-
debouncedSave.cancel()
|
|
954
|
-
debouncedSourceSave.cancel()
|
|
955
|
-
setLocalWidgets(next.widgets)
|
|
956
|
-
setLocalSources(next.sources)
|
|
957
|
-
queueWrite(() =>
|
|
958
|
-
updateCanvas(name, { widgets: next.widgets, sources: next.sources }).catch((err) =>
|
|
959
|
-
console.error('[canvas] Failed to persist redo:', err)
|
|
960
|
-
)
|
|
961
|
-
)
|
|
962
|
-
}, [name, debouncedSave, debouncedSourceSave, undoRedo])
|
|
963
|
-
|
|
964
|
-
// Keyboard shortcuts — dev-only (Cmd+Z / Cmd+Shift+Z)
|
|
965
|
-
useEffect(() => {
|
|
966
|
-
if (!import.meta.hot) return
|
|
967
|
-
function handleKeyDown(e) {
|
|
968
|
-
const tag = e.target.tagName
|
|
969
|
-
if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
|
|
970
|
-
const mod = e.metaKey || e.ctrlKey
|
|
971
|
-
if (mod && e.key === 'z' && !e.shiftKey) {
|
|
972
|
-
e.preventDefault()
|
|
973
|
-
handleUndo()
|
|
974
|
-
}
|
|
975
|
-
if (mod && e.key === 'z' && e.shiftKey) {
|
|
976
|
-
e.preventDefault()
|
|
977
|
-
handleRedo()
|
|
978
|
-
}
|
|
979
|
-
}
|
|
980
|
-
document.addEventListener('keydown', handleKeyDown)
|
|
981
|
-
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
982
|
-
}, [handleUndo, handleRedo])
|
|
983
|
-
|
|
984
|
-
// Listen for undo/redo from CoreUIBar (Svelte toolbar)
|
|
985
|
-
useEffect(() => {
|
|
986
|
-
function handleUndoEvent() { handleUndo() }
|
|
987
|
-
function handleRedoEvent() { handleRedo() }
|
|
988
|
-
document.addEventListener('storyboard:canvas:undo', handleUndoEvent)
|
|
989
|
-
document.addEventListener('storyboard:canvas:redo', handleRedoEvent)
|
|
990
|
-
return () => {
|
|
991
|
-
document.removeEventListener('storyboard:canvas:undo', handleUndoEvent)
|
|
992
|
-
document.removeEventListener('storyboard:canvas:redo', handleRedoEvent)
|
|
993
|
-
}
|
|
994
|
-
}, [handleUndo, handleRedo])
|
|
995
|
-
|
|
996
|
-
// Broadcast undo/redo availability to Svelte toolbar
|
|
997
|
-
useEffect(() => {
|
|
998
|
-
document.dispatchEvent(new CustomEvent('storyboard:canvas:undo-redo-state', {
|
|
999
|
-
detail: { canUndo: undoRedo.canUndo, canRedo: undoRedo.canRedo }
|
|
1000
|
-
}))
|
|
1001
|
-
}, [undoRedo.canUndo, undoRedo.canRedo])
|
|
507
|
+
}, [name])
|
|
1002
508
|
|
|
1003
509
|
// Cmd+scroll / trackpad pinch to smooth-zoom the canvas
|
|
1004
510
|
// On macOS, pinch-to-zoom fires wheel events with ctrlKey: true and small
|
|
@@ -1136,7 +642,6 @@ export default function CanvasPage({ name }) {
|
|
|
1136
642
|
}}
|
|
1137
643
|
>
|
|
1138
644
|
<WidgetChrome
|
|
1139
|
-
widgetId={`jsx-${exportName}`}
|
|
1140
645
|
features={componentFeatures}
|
|
1141
646
|
selected={selectedWidgetId === `jsx-${exportName}`}
|
|
1142
647
|
onSelect={() => setSelectedWidgetId(`jsx-${exportName}`)}
|
|
@@ -1176,7 +681,6 @@ export default function CanvasPage({ name }) {
|
|
|
1176
681
|
onSelect={() => setSelectedWidgetId(widget.id)}
|
|
1177
682
|
onDeselect={() => setSelectedWidgetId(null)}
|
|
1178
683
|
onUpdate={handleWidgetUpdate}
|
|
1179
|
-
onCopy={handleWidgetCopy}
|
|
1180
684
|
onRemove={(id) => {
|
|
1181
685
|
handleWidgetRemove(id)
|
|
1182
686
|
setSelectedWidgetId(null)
|