@dfosco/storyboard-react 3.11.1-beta.0 → 3.11.2

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.
Files changed (35) hide show
  1. package/package.json +3 -3
  2. package/src/Viewfinder.jsx +5 -3
  3. package/src/__mocks__/virtual-storyboard-data-index.js +1 -0
  4. package/src/canvas/CanvasControls.jsx +2 -59
  5. package/src/canvas/CanvasControls.module.css +0 -29
  6. package/src/canvas/CanvasPage.bridge.test.jsx +68 -42
  7. package/src/canvas/CanvasPage.jsx +801 -68
  8. package/src/canvas/CanvasPage.module.css +47 -2
  9. package/src/canvas/CanvasPage.multiselect.test.jsx +345 -0
  10. package/src/canvas/canvasApi.js +8 -0
  11. package/src/canvas/computeCanvasBounds.test.js +121 -0
  12. package/src/canvas/useCanvas.js +2 -1
  13. package/src/canvas/useUndoRedo.js +86 -0
  14. package/src/canvas/useUndoRedo.test.js +231 -0
  15. package/src/canvas/widgets/ComponentWidget.jsx +9 -7
  16. package/src/canvas/widgets/FigmaEmbed.jsx +195 -0
  17. package/src/canvas/widgets/FigmaEmbed.module.css +147 -0
  18. package/src/canvas/widgets/ImageWidget.jsx +115 -0
  19. package/src/canvas/widgets/ImageWidget.module.css +39 -0
  20. package/src/canvas/widgets/MarkdownBlock.jsx +25 -8
  21. package/src/canvas/widgets/MarkdownBlock.test.jsx +53 -0
  22. package/src/canvas/widgets/PrototypeEmbed.jsx +132 -26
  23. package/src/canvas/widgets/PrototypeEmbed.module.css +66 -2
  24. package/src/canvas/widgets/StickyNote.jsx +21 -16
  25. package/src/canvas/widgets/StickyNote.test.jsx +24 -4
  26. package/src/canvas/widgets/WidgetChrome.jsx +276 -50
  27. package/src/canvas/widgets/WidgetChrome.module.css +91 -10
  28. package/src/canvas/widgets/figmaUrl.js +118 -0
  29. package/src/canvas/widgets/figmaUrl.test.js +139 -0
  30. package/src/canvas/widgets/index.js +4 -0
  31. package/src/canvas/widgets/widgetConfig.js +74 -6
  32. package/src/canvas/widgets/widgetConfig.test.js +46 -0
  33. package/src/canvas/widgets/widgetProps.js +2 -0
  34. package/src/context.jsx +34 -4
  35. package/src/context.test.jsx +13 -0
@@ -7,10 +7,12 @@ import { shouldPreventCanvasTextSelection } from './textSelection.js'
7
7
  import { getCanvasThemeVars, getCanvasPrimerAttrs } from './canvasTheme.js'
8
8
  import { getWidgetComponent } from './widgets/index.js'
9
9
  import { schemas, getDefaults } from './widgets/widgetProps.js'
10
- import { getFeatures } from './widgets/widgetConfig.js'
10
+ import { getFeatures, isResizable } from './widgets/widgetConfig.js'
11
+ import { isFigmaUrl, sanitizeFigmaUrl } from './widgets/figmaUrl.js'
11
12
  import WidgetChrome from './widgets/WidgetChrome.jsx'
12
13
  import ComponentWidget from './widgets/ComponentWidget.jsx'
13
- import { addWidget as addWidgetApi, updateCanvas, removeWidget as removeWidgetApi } from './canvasApi.js'
14
+ import 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
- return (...args) => {
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: 180, height: 60 },
78
- 'markdown': { width: 360, height: 200 },
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,78 @@ function roundPosition(value) {
99
130
  return Math.round(value)
100
131
  }
101
132
 
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
+ // eslint-disable-next-line no-unused-vars
140
+ function snapPosition(pos, gridSize, enabled) {
141
+ if (!enabled || !gridSize) return pos
142
+ return {
143
+ x: Math.max(0, snapValue(pos.x, gridSize)),
144
+ y: Math.max(0, snapValue(pos.y, gridSize)),
145
+ }
146
+ }
147
+
148
+ /** Snap a dimension to the grid if snapping is enabled. */
149
+ function snapDimension(value, gridSize, enabled, min = 0) {
150
+ if (!enabled || !gridSize) return value
151
+ return Math.max(min, snapValue(value, gridSize))
152
+ }
153
+
154
+ /** Padding (canvas-space pixels) around bounding box for zoom-to-fit. */
155
+ const FIT_PADDING = 48
156
+
157
+ /**
158
+ * Compute the axis-aligned bounding box that contains every widget and source.
159
+ * Returns { minX, minY, maxX, maxY } in canvas-space coordinates, or null if empty.
160
+ */
161
+ function computeCanvasBounds(widgets, sources, jsxExports) {
162
+ let minX = Infinity
163
+ let minY = Infinity
164
+ let maxX = -Infinity
165
+ let maxY = -Infinity
166
+ let hasItems = false
167
+
168
+ // JSON widgets
169
+ for (const w of (widgets ?? [])) {
170
+ const x = w?.position?.x ?? 0
171
+ const y = w?.position?.y ?? 0
172
+ const fallback = WIDGET_FALLBACK_SIZES[w.type] || { width: 200, height: 150 }
173
+ const width = w.props?.width ?? fallback.width
174
+ const height = w.props?.height ?? fallback.height
175
+ minX = Math.min(minX, x)
176
+ minY = Math.min(minY, y)
177
+ maxX = Math.max(maxX, x + width)
178
+ maxY = Math.max(maxY, y + height)
179
+ hasItems = true
180
+ }
181
+
182
+ // JSX sources
183
+ const sourceMap = Object.fromEntries(
184
+ (sources || []).filter((s) => s?.export).map((s) => [s.export, s])
185
+ )
186
+ if (jsxExports) {
187
+ for (const exportName of Object.keys(jsxExports)) {
188
+ const sourceData = sourceMap[exportName] || {}
189
+ const x = sourceData.position?.x ?? 0
190
+ const y = sourceData.position?.y ?? 0
191
+ const fallback = WIDGET_FALLBACK_SIZES['component']
192
+ const width = sourceData.width ?? fallback.width
193
+ const height = sourceData.height ?? fallback.height
194
+ minX = Math.min(minX, x)
195
+ minY = Math.min(minY, y)
196
+ maxX = Math.max(maxX, x + width)
197
+ maxY = Math.max(maxY, y + height)
198
+ hasItems = true
199
+ }
200
+ }
201
+
202
+ return hasItems ? { minX, minY, maxX, maxY } : null
203
+ }
204
+
102
205
  /** Renders a single JSON-defined widget by type lookup. */
103
206
  function WidgetRenderer({ widget, onUpdate, widgetRef }) {
104
207
  const Component = getWidgetComponent(widget.type)
@@ -106,8 +209,9 @@ function WidgetRenderer({ widget, onUpdate, widgetRef }) {
106
209
  console.warn(`[canvas] Unknown widget type: ${widget.type}`)
107
210
  return null
108
211
  }
212
+ const resizable = isResizable(widget.type) && !!onUpdate
109
213
  // Only pass ref to forwardRef-wrapped components (e.g. PrototypeEmbed)
110
- const elementProps = { id: widget.id, props: widget.props, onUpdate }
214
+ const elementProps = { id: widget.id, props: widget.props, onUpdate, resizable }
111
215
  if (Component.$$typeof === Symbol.for('react.forward_ref')) {
112
216
  elementProps.ref = widgetRef
113
217
  }
@@ -121,19 +225,24 @@ function WidgetRenderer({ widget, onUpdate, widgetRef }) {
121
225
  function ChromeWrappedWidget({
122
226
  widget,
123
227
  selected,
228
+ multiSelected,
124
229
  onSelect,
125
230
  onDeselect,
126
231
  onUpdate,
127
232
  onRemove,
233
+ onCopy,
234
+ readOnly,
128
235
  }) {
129
236
  const widgetRef = useRef(null)
130
237
  const features = getFeatures(widget.type)
131
238
 
132
239
  const handleAction = useCallback((actionId) => {
133
240
  if (actionId === 'delete') {
134
- onRemove(widget.id)
241
+ onRemove?.(widget.id)
242
+ } else if (actionId === 'copy') {
243
+ onCopy?.(widget)
135
244
  }
136
- }, [widget.id, onRemove])
245
+ }, [widget, onRemove, onCopy])
137
246
 
138
247
  return (
139
248
  <WidgetChrome
@@ -141,16 +250,18 @@ function ChromeWrappedWidget({
141
250
  widgetType={widget.type}
142
251
  features={features}
143
252
  selected={selected}
253
+ multiSelected={multiSelected}
144
254
  widgetProps={widget.props}
145
255
  widgetRef={widgetRef}
146
256
  onSelect={onSelect}
147
257
  onDeselect={onDeselect}
148
258
  onAction={handleAction}
149
- onUpdate={(updates) => onUpdate(widget.id, updates)}
259
+ onUpdate={onUpdate ? (updates) => onUpdate(widget.id, updates) : undefined}
260
+ readOnly={readOnly}
150
261
  >
151
262
  <WidgetRenderer
152
263
  widget={widget}
153
- onUpdate={(updates) => onUpdate(widget.id, updates)}
264
+ onUpdate={onUpdate ? (updates) => onUpdate(widget.id, updates) : undefined}
154
265
  widgetRef={widgetRef}
155
266
  />
156
267
  </WidgetChrome>
@@ -165,24 +276,129 @@ function ChromeWrappedWidget({
165
276
  */
166
277
  export default function CanvasPage({ name }) {
167
278
  const { canvas, jsxExports, loading } = useCanvas(name)
279
+ const isLocalDev = typeof window !== 'undefined' && window.__SB_LOCAL_DEV__ === true && !new URLSearchParams(window.location.search).has('prodMode')
168
280
 
169
281
  // Local mutable copy of widgets for instant UI updates
170
282
  const [localWidgets, setLocalWidgets] = useState(canvas?.widgets ?? null)
171
283
  const [trackedCanvas, setTrackedCanvas] = useState(canvas)
172
- const [selectedWidgetId, setSelectedWidgetId] = useState(null)
173
- const [zoom, setZoom] = useState(100)
174
- const zoomRef = useRef(100)
284
+ const [selectedWidgetIds, setSelectedWidgetIds] = useState(() => new Set())
285
+ const initialViewport = loadViewportState(name)
286
+ const [zoom, setZoom] = useState(initialViewport?.zoom ?? 100)
287
+ const zoomRef = useRef(initialViewport?.zoom ?? 100)
175
288
  const scrollRef = useRef(null)
289
+ const pendingScrollRestore = useRef(initialViewport)
290
+ const initialWidgetParam = useRef(new URLSearchParams(window.location.search).has('widget'))
176
291
  const [canvasTitle, setCanvasTitle] = useState(canvas?.title || name)
177
292
  const titleInputRef = useRef(null)
178
293
  const [localSources, setLocalSources] = useState(canvas?.sources ?? [])
179
294
  const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
295
+ const [snapEnabled, setSnapEnabled] = useState(canvas?.snapToGrid ?? false)
296
+ const [snapGridSize, setSnapGridSize] = useState(canvas?.gridSize || 40)
297
+
298
+ // Undo/redo history — tracks both widgets and sources as a combined snapshot
299
+ const undoRedo = useUndoRedo()
300
+ const stateRef = useRef({ widgets: localWidgets, sources: localSources })
301
+ useEffect(() => {
302
+ stateRef.current = { widgets: localWidgets, sources: localSources }
303
+ }, [localWidgets, localSources])
304
+
305
+ // Serialized write queue — ensures JSONL events land in the right order
306
+ const writeQueueRef = useRef(Promise.resolve())
307
+ function queueWrite(fn) {
308
+ writeQueueRef.current = writeQueueRef.current.then(fn).catch((err) =>
309
+ console.error('[canvas] Write queue error:', err)
310
+ )
311
+ return writeQueueRef.current
312
+ }
313
+
314
+ // Ref for selectedWidgetIds to avoid stale closures in callbacks
315
+ const selectedIdsRef = useRef(selectedWidgetIds)
316
+ useEffect(() => {
317
+ selectedIdsRef.current = selectedWidgetIds
318
+ }, [selectedWidgetIds])
319
+
320
+ const isMultiSelected = selectedWidgetIds.size > 1
321
+
322
+ /**
323
+ * Selection handler — shift+click toggles in/out of multi-select set,
324
+ * plain click single-selects (clears others).
325
+ * Suppressed immediately after a multi-drag to prevent the post-drag
326
+ * click from collapsing the selection.
327
+ */
328
+ const handleWidgetSelect = useCallback((widgetId, shiftKey) => {
329
+ if (justDraggedRef.current) return
330
+ if (shiftKey) {
331
+ setSelectedWidgetIds(prev => {
332
+ const next = new Set(prev)
333
+ if (next.has(widgetId)) {
334
+ next.delete(widgetId)
335
+ } else {
336
+ next.add(widgetId)
337
+ }
338
+ return next
339
+ })
340
+ } else {
341
+ setSelectedWidgetIds(new Set([widgetId]))
342
+ }
343
+ }, [])
344
+
345
+ // --- Multi-select drag: peers animate to new positions on drag end ---
346
+ // During drag, only the dragged widget moves (via neodrag). On drag end,
347
+ // peer widget positions are updated via React state, and we add the
348
+ // tc-on-translation class so they animate smoothly to their new spots.
349
+ const peerArticlesRef = useRef(new Map())
350
+ // Flag to suppress the click-based selection reset that fires after a drag
351
+ const justDraggedRef = useRef(false)
352
+
353
+ const handleItemDragStart = useCallback((dragId, position) => {
354
+ const ids = selectedIdsRef.current
355
+ peerArticlesRef.current.clear()
356
+ if (ids.size <= 1 || !ids.has(dragId)) return
357
+
358
+ // Suppress selection changes for the duration of the drag
359
+ justDraggedRef.current = true
360
+
361
+ // Collect peer article elements for transition on drag end
362
+ for (const id of ids) {
363
+ if (id === dragId) continue
364
+ const widgetEl = document.getElementById(id)
365
+ const article = widgetEl?.closest('article')
366
+ if (!article) continue
367
+ peerArticlesRef.current.set(id, article)
368
+ }
369
+ }, [])
370
+
371
+ const handleItemDrag = useCallback(() => {
372
+ // Peers stay put during drag — they animate on drag end
373
+ }, [])
374
+
375
+ /** Add transition class to peer articles so they animate to new positions. */
376
+ const transitionPeers = useCallback(() => {
377
+ for (const [, article] of peerArticlesRef.current) {
378
+ article.classList.add('tc-on-translation')
379
+ }
380
+ // Remove class after animation completes
381
+ const articles = [...peerArticlesRef.current.values()]
382
+ setTimeout(() => {
383
+ for (const article of articles) {
384
+ article.classList.remove('tc-on-translation')
385
+ }
386
+ }, 150 + 50 + 200)
387
+ peerArticlesRef.current.clear()
388
+ }, [])
389
+
390
+ const clearDragPreview = useCallback(() => {
391
+ peerArticlesRef.current.clear()
392
+ }, [])
180
393
 
181
394
  if (canvas !== trackedCanvas) {
182
395
  setTrackedCanvas(canvas)
183
396
  setLocalWidgets(canvas?.widgets ?? null)
184
397
  setLocalSources(canvas?.sources ?? [])
185
398
  setCanvasTitle(canvas?.title || name)
399
+ setSnapEnabled(canvas?.snapToGrid ?? false)
400
+ setSnapGridSize(canvas?.gridSize || 40)
401
+ undoRedo.reset()
186
402
  }
187
403
 
188
404
  // Debounced save to server
@@ -216,22 +432,59 @@ export default function CanvasPage({ name }) {
216
432
  }, [])
217
433
 
218
434
  const handleWidgetUpdate = useCallback((widgetId, updates) => {
435
+ undoRedo.snapshot(stateRef.current, 'edit', widgetId)
436
+ // Snap width/height to grid when snap is enabled
437
+ const snapped = { ...updates }
438
+ if (snapEnabled && snapGridSize) {
439
+ if (snapped.width != null) snapped.width = snapDimension(snapped.width, snapGridSize, true, 60)
440
+ if (snapped.height != null) snapped.height = snapDimension(snapped.height, snapGridSize, true, 60)
441
+ }
219
442
  setLocalWidgets((prev) => {
220
443
  if (!prev) return prev
221
444
  const next = prev.map((w) =>
222
- w.id === widgetId ? { ...w, props: { ...w.props, ...updates } } : w
445
+ w.id === widgetId ? { ...w, props: { ...w.props, ...snapped } } : w
223
446
  )
224
447
  debouncedSave(name, next)
225
448
  return next
226
449
  })
227
- }, [name, debouncedSave])
450
+ }, [name, debouncedSave, undoRedo, snapEnabled, snapGridSize])
228
451
 
229
452
  const handleWidgetRemove = useCallback((widgetId) => {
453
+ undoRedo.snapshot(stateRef.current, 'remove', widgetId)
230
454
  setLocalWidgets((prev) => prev ? prev.filter((w) => w.id !== widgetId) : prev)
231
- removeWidgetApi(name, widgetId).catch((err) =>
232
- console.error('[canvas] Failed to remove widget:', err)
455
+ queueWrite(() =>
456
+ removeWidgetApi(name, widgetId).catch((err) =>
457
+ console.error('[canvas] Failed to remove widget:', err)
458
+ )
233
459
  )
234
- }, [name])
460
+ }, [name, undoRedo])
461
+
462
+ const handleWidgetCopy = useCallback(async (widget) => {
463
+ // Find the next free offset — check how many copies already exist at +n*40
464
+ const baseX = widget.position?.x ?? 0
465
+ const baseY = widget.position?.y ?? 0
466
+ const occupied = new Set(
467
+ (localWidgets ?? []).map((w) => `${w.position?.x ?? 0},${w.position?.y ?? 0}`)
468
+ )
469
+ let n = 1
470
+ while (occupied.has(`${baseX + n * 40},${baseY + n * 40}`)) {
471
+ n++
472
+ }
473
+ const position = { x: baseX + n * 40, y: baseY + n * 40 }
474
+ try {
475
+ undoRedo.snapshot(stateRef.current, 'add')
476
+ const result = await addWidgetApi(name, {
477
+ type: widget.type,
478
+ props: { ...widget.props },
479
+ position,
480
+ })
481
+ if (result.success && result.widget) {
482
+ setLocalWidgets((prev) => [...(prev || []), result.widget])
483
+ }
484
+ } catch (err) {
485
+ console.error('[canvas] Failed to copy widget:', err)
486
+ }
487
+ }, [name, localWidgets, undoRedo])
235
488
 
236
489
  const debouncedSourceSave = useRef(
237
490
  debounce((canvasName, sources) => {
@@ -242,51 +495,250 @@ export default function CanvasPage({ name }) {
242
495
  ).current
243
496
 
244
497
  const handleSourceUpdate = useCallback((exportName, updates) => {
498
+ undoRedo.snapshot(stateRef.current, 'edit', `jsx-${exportName}`)
499
+ const snapped = { ...updates }
500
+ if (snapEnabled && snapGridSize) {
501
+ if (snapped.width != null) snapped.width = snapDimension(snapped.width, snapGridSize, true, 100)
502
+ if (snapped.height != null) snapped.height = snapDimension(snapped.height, snapGridSize, true, 60)
503
+ }
245
504
  setLocalSources((prev) => {
246
505
  const current = Array.isArray(prev) ? prev : []
247
506
  const next = current.some((s) => s?.export === exportName)
248
- ? current.map((s) => (s?.export === exportName ? { ...s, ...updates } : s))
249
- : [...current, { export: exportName, ...updates }]
507
+ ? current.map((s) => (s?.export === exportName ? { ...s, ...snapped } : s))
508
+ : [...current, { export: exportName, ...snapped }]
250
509
  debouncedSourceSave(name, next)
251
510
  return next
252
511
  })
253
- }, [name, debouncedSourceSave])
512
+ }, [name, debouncedSourceSave, undoRedo, snapEnabled, snapGridSize])
254
513
 
255
514
  const handleItemDragEnd = useCallback((dragId, position) => {
256
- if (!dragId || !position) return
515
+ if (!dragId || !position) {
516
+ clearDragPreview()
517
+ return
518
+ }
257
519
  const rounded = { x: Math.max(0, roundPosition(position.x)), y: Math.max(0, roundPosition(position.y)) }
258
520
 
521
+ const ids = selectedIdsRef.current
522
+ // Multi-select move: apply same delta to all selected widgets
523
+ // Checked BEFORE the jsx- early return so mixed selections work
524
+ if (ids.size > 1 && ids.has(dragId)) {
525
+ transitionPeers()
526
+ // Suppress the click-based selection reset that fires after pointerup
527
+ justDraggedRef.current = true
528
+ requestAnimationFrame(() => { justDraggedRef.current = false })
529
+ undoRedo.snapshot(stateRef.current, 'multi-move')
530
+
531
+ // Compute delta from the dragged widget's old position
532
+ const isJsx = dragId.startsWith('jsx-')
533
+ let oldPos = { x: 0, y: 0 }
534
+ if (isJsx) {
535
+ const sourceExport = dragId.replace(/^jsx-/, '')
536
+ const source = (stateRef.current.sources ?? []).find(s => s?.export === sourceExport)
537
+ oldPos = source?.position || { x: 0, y: 0 }
538
+ } else {
539
+ const draggedWidget = (stateRef.current.widgets ?? []).find(w => w.id === dragId)
540
+ oldPos = draggedWidget?.position || { x: 0, y: 0 }
541
+ }
542
+ const dx = rounded.x - oldPos.x
543
+ const dy = rounded.y - oldPos.y
544
+
545
+ debouncedSave.cancel()
546
+
547
+ // Update JSON widget positions
548
+ setLocalWidgets((prev) => {
549
+ if (!prev) return prev
550
+ const next = prev.map((w) => {
551
+ if (w.id === dragId) return { ...w, position: rounded }
552
+ if (ids.has(w.id)) {
553
+ return {
554
+ ...w,
555
+ position: {
556
+ x: Math.max(0, roundPosition((w.position?.x ?? 0) + dx)),
557
+ y: Math.max(0, roundPosition((w.position?.y ?? 0) + dy)),
558
+ },
559
+ }
560
+ }
561
+ return w
562
+ })
563
+ queueWrite(() =>
564
+ updateCanvas(name, { widgets: next }).catch((err) =>
565
+ console.error('[canvas] Failed to save multi-move:', err)
566
+ )
567
+ )
568
+ return next
569
+ })
570
+
571
+ // Update JSX source positions
572
+ setLocalSources((prev) => {
573
+ const current = Array.isArray(prev) ? prev : []
574
+ let changed = false
575
+ const next = current.map((s) => {
576
+ if (!s?.export) return s
577
+ const sid = `jsx-${s.export}`
578
+ if (sid === dragId) {
579
+ changed = true
580
+ return { ...s, position: rounded }
581
+ }
582
+ if (ids.has(sid)) {
583
+ changed = true
584
+ return {
585
+ ...s,
586
+ position: {
587
+ x: Math.max(0, roundPosition((s.position?.x ?? 0) + dx)),
588
+ y: Math.max(0, roundPosition((s.position?.y ?? 0) + dy)),
589
+ },
590
+ }
591
+ }
592
+ return s
593
+ })
594
+ if (changed) {
595
+ queueWrite(() =>
596
+ updateCanvas(name, { sources: next }).catch((err) =>
597
+ console.error('[canvas] Failed to save multi-move sources:', err)
598
+ )
599
+ )
600
+ }
601
+ return changed ? next : current
602
+ })
603
+ return
604
+ }
605
+
259
606
  if (dragId.startsWith('jsx-')) {
607
+ undoRedo.snapshot(stateRef.current, 'move', dragId)
260
608
  const sourceExport = dragId.replace(/^jsx-/, '')
261
609
  setLocalSources((prev) => {
262
610
  const current = Array.isArray(prev) ? prev : []
263
611
  const next = current.some((s) => s?.export === sourceExport)
264
612
  ? current.map((s) => (s?.export === sourceExport ? { ...s, position: rounded } : s))
265
613
  : [...current, { export: sourceExport, position: rounded }]
266
- updateCanvas(name, { sources: next }).catch((err) =>
267
- console.error('[canvas] Failed to save source position:', err)
614
+ queueWrite(() =>
615
+ updateCanvas(name, { sources: next }).catch((err) =>
616
+ console.error('[canvas] Failed to save source position:', err)
617
+ )
268
618
  )
269
619
  return next
270
620
  })
271
621
  return
272
622
  }
273
623
 
624
+ undoRedo.snapshot(stateRef.current, 'move', dragId)
274
625
  setLocalWidgets((prev) => {
275
626
  if (!prev) return prev
276
627
  const next = prev.map((w) =>
277
628
  w.id === dragId ? { ...w, position: rounded } : w
278
629
  )
279
- updateCanvas(name, { widgets: next }).catch((err) =>
280
- console.error('[canvas] Failed to save widget position:', err)
630
+ queueWrite(() =>
631
+ updateCanvas(name, { widgets: next }).catch((err) =>
632
+ console.error('[canvas] Failed to save widget position:', err)
633
+ )
281
634
  )
282
635
  return next
283
636
  })
284
- }, [name])
637
+ }, [name, undoRedo, debouncedSave, transitionPeers, clearDragPreview])
285
638
 
286
639
  useEffect(() => {
287
640
  zoomRef.current = zoom
288
641
  }, [zoom])
289
642
 
643
+ // Restore scroll position from localStorage after first render
644
+ useEffect(() => {
645
+ const el = scrollRef.current
646
+ const saved = pendingScrollRestore.current
647
+ if (el && saved) {
648
+ if (saved.scrollLeft != null) el.scrollLeft = saved.scrollLeft
649
+ if (saved.scrollTop != null) el.scrollTop = saved.scrollTop
650
+ pendingScrollRestore.current = null
651
+ }
652
+ }, [name, loading])
653
+
654
+ // Center on a specific widget if `?widget=<id>` is in the URL
655
+ useEffect(() => {
656
+ const params = new URLSearchParams(window.location.search)
657
+ const targetId = params.get('widget')
658
+ if (!targetId || loading) return
659
+
660
+ const el = scrollRef.current
661
+ if (!el) return
662
+
663
+ let x, y, w, h
664
+
665
+ // Check JSON widgets first
666
+ const widgets = localWidgets ?? []
667
+ const widget = widgets.find((wgt) => wgt.id === targetId)
668
+ if (widget) {
669
+ const fallback = WIDGET_FALLBACK_SIZES[widget.type] || { width: 200, height: 150 }
670
+ x = widget.position?.x ?? 0
671
+ y = widget.position?.y ?? 0
672
+ w = widget.props?.width ?? fallback.width
673
+ h = widget.props?.height ?? fallback.height
674
+ }
675
+
676
+ // Check JSX sources (jsx-ExportName)
677
+ if (!widget && targetId.startsWith('jsx-')) {
678
+ const exportName = targetId.slice(4)
679
+ const sourceMap = Object.fromEntries(
680
+ (localSources || []).filter((s) => s?.export).map((s) => [s.export, s])
681
+ )
682
+ const sourceData = sourceMap[exportName]
683
+ if (sourceData || (jsxExports && exportName in jsxExports)) {
684
+ const fallback = WIDGET_FALLBACK_SIZES['component']
685
+ x = sourceData?.position?.x ?? 0
686
+ y = sourceData?.position?.y ?? 0
687
+ w = sourceData?.width ?? fallback.width
688
+ h = sourceData?.height ?? fallback.height
689
+ }
690
+ }
691
+
692
+ if (x == null) return
693
+
694
+ const scale = zoomRef.current / 100
695
+ el.scrollLeft = (x + w / 2) * scale - el.clientWidth / 2
696
+ el.scrollTop = (y + h / 2) * scale - el.clientHeight / 2
697
+
698
+ // Clean the URL param without triggering navigation
699
+ const url = new URL(window.location.href)
700
+ url.searchParams.delete('widget')
701
+ window.history.replaceState({}, '', url.toString())
702
+ }, [loading, localWidgets, localSources, jsxExports])
703
+
704
+ // Persist viewport state (zoom + scroll) to localStorage on changes
705
+ useEffect(() => {
706
+ const el = scrollRef.current
707
+ saveViewportState(name, {
708
+ zoom,
709
+ scrollLeft: el?.scrollLeft ?? 0,
710
+ scrollTop: el?.scrollTop ?? 0,
711
+ })
712
+ }, [name, zoom])
713
+
714
+ useEffect(() => {
715
+ const el = scrollRef.current
716
+ if (!el) return
717
+ function handleScroll() {
718
+ saveViewportState(name, {
719
+ zoom: zoomRef.current,
720
+ scrollLeft: el.scrollLeft,
721
+ scrollTop: el.scrollTop,
722
+ })
723
+ }
724
+ el.addEventListener('scroll', handleScroll, { passive: true })
725
+
726
+ // Flush viewport state on page unload so a refresh never misses it
727
+ function handleBeforeUnload() {
728
+ saveViewportState(name, {
729
+ zoom: zoomRef.current,
730
+ scrollLeft: el.scrollLeft,
731
+ scrollTop: el.scrollTop,
732
+ })
733
+ }
734
+ window.addEventListener('beforeunload', handleBeforeUnload)
735
+
736
+ return () => {
737
+ el.removeEventListener('scroll', handleScroll)
738
+ window.removeEventListener('beforeunload', handleBeforeUnload)
739
+ }
740
+ }, [name, loading])
741
+
290
742
  /**
291
743
  * Zoom to a new level, anchoring on an optional client-space point.
292
744
  * When a cursor position is provided (e.g. from a wheel event), the
@@ -345,6 +797,26 @@ export default function CanvasPage({ name }) {
345
797
  }
346
798
  }, [name])
347
799
 
800
+ // Tell the Vite dev server to suppress full-reloads while this canvas is active.
801
+ // The ?canvas-hmr URL param opts out of the guard for canvas UI development.
802
+ // Sends a heartbeat every 3s so the guard auto-expires if the tab closes.
803
+ useEffect(() => {
804
+ if (!import.meta.hot) return
805
+ const hmrEnabled = new URLSearchParams(window.location.search).has('canvas-hmr')
806
+ if (hmrEnabled) return
807
+
808
+ const msg = { active: true, hmrEnabled: false }
809
+ import.meta.hot.send('storyboard:canvas-hmr-guard', msg)
810
+ const interval = setInterval(() => {
811
+ import.meta.hot.send('storyboard:canvas-hmr-guard', msg)
812
+ }, 3000)
813
+
814
+ return () => {
815
+ clearInterval(interval)
816
+ import.meta.hot.send('storyboard:canvas-hmr-guard', { active: false, hmrEnabled: true })
817
+ }
818
+ }, [name])
819
+
348
820
  // Add a widget by type — used by CanvasControls and CoreUIBar event
349
821
  const addWidget = useCallback(async (type) => {
350
822
  const defaultProps = schemas[type] ? getDefaults(schemas[type]) : {}
@@ -357,12 +829,13 @@ export default function CanvasPage({ name }) {
357
829
  position: pos,
358
830
  })
359
831
  if (result.success && result.widget) {
832
+ undoRedo.snapshot(stateRef.current, 'add')
360
833
  setLocalWidgets((prev) => [...(prev || []), result.widget])
361
834
  }
362
835
  } catch (err) {
363
836
  console.error('[canvas] Failed to add widget:', err)
364
837
  }
365
- }, [name])
838
+ }, [name, undoRedo])
366
839
 
367
840
  // Listen for CoreUIBar add-widget events
368
841
  useEffect(() => {
@@ -385,6 +858,77 @@ export default function CanvasPage({ name }) {
385
858
  return () => document.removeEventListener('storyboard:canvas:set-zoom', handleZoom)
386
859
  }, [])
387
860
 
861
+ // Listen for snap-to-grid toggle from CoreUIBar
862
+ useEffect(() => {
863
+ function handleSnapToggle() {
864
+ setSnapEnabled((prev) => {
865
+ const next = !prev
866
+ updateCanvas(name, { snapToGrid: next }).catch((err) =>
867
+ console.error('[canvas] Failed to persist snap setting:', err)
868
+ )
869
+ return next
870
+ })
871
+ }
872
+ document.addEventListener('storyboard:canvas:toggle-snap', handleSnapToggle)
873
+ return () => document.removeEventListener('storyboard:canvas:toggle-snap', handleSnapToggle)
874
+ }, [name])
875
+
876
+ // Broadcast snap state to Svelte toolbar
877
+ useEffect(() => {
878
+ document.dispatchEvent(new CustomEvent('storyboard:canvas:snap-state', {
879
+ detail: { snapEnabled }
880
+ }))
881
+ }, [snapEnabled])
882
+
883
+ // Listen for gridSize from Svelte toolbar config
884
+ useEffect(() => {
885
+ function handleGridSize(e) {
886
+ const size = e.detail?.gridSize
887
+ if (typeof size === 'number' && size > 0) setSnapGridSize(size)
888
+ }
889
+ document.addEventListener('storyboard:canvas:grid-size', handleGridSize)
890
+ return () => document.removeEventListener('storyboard:canvas:grid-size', handleGridSize)
891
+ }, [])
892
+
893
+ // Listen for zoom-to-fit from CoreUIBar
894
+ useEffect(() => {
895
+ function handleZoomToFit() {
896
+ const el = scrollRef.current
897
+ if (!el) return
898
+
899
+ const bounds = computeCanvasBounds(localWidgets, localSources, jsxExports)
900
+ if (!bounds) return
901
+
902
+ const boxW = bounds.maxX - bounds.minX + FIT_PADDING * 2
903
+ const boxH = bounds.maxY - bounds.minY + FIT_PADDING * 2
904
+
905
+ const viewW = el.clientWidth
906
+ const viewH = el.clientHeight
907
+
908
+ // Find the zoom level that fits the bounding box in the viewport
909
+ const fitScale = Math.min(viewW / boxW, viewH / boxH)
910
+ const fitZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(fitScale * 100)))
911
+ const newScale = fitZoom / 100
912
+
913
+ // Apply zoom synchronously so DOM updates before we scroll
914
+ zoomRef.current = fitZoom
915
+ flushSync(() => setZoom(fitZoom))
916
+
917
+ // Scroll so the bounding box top-left (with padding) is at viewport top-left
918
+ el.scrollLeft = (bounds.minX - FIT_PADDING) * newScale
919
+ el.scrollTop = (bounds.minY - FIT_PADDING) * newScale
920
+ }
921
+ document.addEventListener('storyboard:canvas:zoom-to-fit', handleZoomToFit)
922
+ return () => document.removeEventListener('storyboard:canvas:zoom-to-fit', handleZoomToFit)
923
+ }, [localWidgets, localSources, jsxExports])
924
+
925
+ // On initial load without a ?widget= deep link, zoom to fit all objects
926
+ useEffect(() => {
927
+ if (loading || initialWidgetParam.current) return
928
+ initialWidgetParam.current = true // only once
929
+ document.dispatchEvent(new CustomEvent('storyboard:canvas:zoom-to-fit'))
930
+ }, [loading])
931
+
388
932
  // Canvas background should follow toolbar theme target.
389
933
  useEffect(() => {
390
934
  function readMode() {
@@ -417,20 +961,42 @@ export default function CanvasPage({ name }) {
417
961
 
418
962
  useEffect(() => {
419
963
  function handleKeyDown(e) {
420
- if (!selectedWidgetId) return
964
+ if (selectedWidgetIds.size === 0) return
421
965
  const tag = e.target.tagName
422
966
  if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
967
+ if (e.key === 'Escape') {
968
+ e.preventDefault()
969
+ setSelectedWidgetIds(new Set())
970
+ }
423
971
  if (e.key === 'Delete' || e.key === 'Backspace') {
424
972
  e.preventDefault()
425
- handleWidgetRemove(selectedWidgetId)
426
- setSelectedWidgetId(null)
973
+ if (selectedWidgetIds.size > 1) {
974
+ // Multi-delete — snapshot once, remove all, persist via updateCanvas
975
+ undoRedo.snapshot(stateRef.current, 'multi-remove')
976
+ debouncedSave.cancel()
977
+ setLocalWidgets((prev) => {
978
+ if (!prev) return prev
979
+ const next = prev.filter(w => !selectedWidgetIds.has(w.id))
980
+ queueWrite(() =>
981
+ updateCanvas(name, { widgets: next }).catch(err =>
982
+ console.error('[canvas] Failed to save multi-delete:', err)
983
+ )
984
+ )
985
+ return next
986
+ })
987
+ } else {
988
+ const widgetId = [...selectedWidgetIds][0]
989
+ if (widgetId) handleWidgetRemove(widgetId)
990
+ }
991
+ setSelectedWidgetIds(new Set())
427
992
  }
428
993
  }
429
994
  document.addEventListener('keydown', handleKeyDown)
430
995
  return () => document.removeEventListener('keydown', handleKeyDown)
431
- }, [selectedWidgetId, handleWidgetRemove])
996
+ }, [selectedWidgetIds, handleWidgetRemove, undoRedo, name, debouncedSave])
432
997
 
433
- // Paste handler — same-origin URLs become prototypes, other URLs become link previews, text becomes markdown
998
+ // Paste handler — images become image widgets, same-origin URLs become prototypes,
999
+ // other URLs become link previews, text becomes markdown
434
1000
  useEffect(() => {
435
1001
  const origin = window.location.origin
436
1002
  const basePath = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
@@ -461,10 +1027,82 @@ export default function CanvasPage({ name }) {
461
1027
  return pathname
462
1028
  }
463
1029
 
1030
+ function blobToDataUrl(blob) {
1031
+ return new Promise((resolve, reject) => {
1032
+ const reader = new FileReader()
1033
+ reader.onload = () => resolve(reader.result)
1034
+ reader.onerror = reject
1035
+ reader.readAsDataURL(blob)
1036
+ })
1037
+ }
1038
+
1039
+ function getImageDimensions(dataUrl) {
1040
+ return new Promise((resolve) => {
1041
+ const img = new Image()
1042
+ img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight })
1043
+ img.onerror = () => resolve({ width: 400, height: 300 })
1044
+ img.src = dataUrl
1045
+ })
1046
+ }
1047
+
1048
+ async function handleImagePaste(e) {
1049
+ const items = e.clipboardData?.items
1050
+ if (!items) return false
1051
+
1052
+ for (const item of items) {
1053
+ if (!item.type.startsWith('image/')) continue
1054
+
1055
+ const blob = item.getAsFile()
1056
+ if (!blob) continue
1057
+
1058
+ e.preventDefault()
1059
+
1060
+ try {
1061
+ const dataUrl = await blobToDataUrl(blob)
1062
+ const { width: natW, height: natH } = await getImageDimensions(dataUrl)
1063
+
1064
+ // Display at 2x retina: halve natural dimensions, then cap at 600px
1065
+ const maxWidth = 600
1066
+ let displayW = Math.round(natW / 2)
1067
+ let displayH = Math.round(natH / 2)
1068
+ if (displayW > maxWidth) {
1069
+ displayH = Math.round(displayH * (maxWidth / displayW))
1070
+ displayW = maxWidth
1071
+ }
1072
+
1073
+ const uploadResult = await uploadImage(dataUrl, name)
1074
+ if (!uploadResult.success) {
1075
+ console.error('[canvas] Image upload failed:', uploadResult.error)
1076
+ return true
1077
+ }
1078
+
1079
+ const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
1080
+ const pos = centerPositionForWidget(center, 'image', { width: displayW, height: displayH })
1081
+ const result = await addWidgetApi(name, {
1082
+ type: 'image',
1083
+ props: { src: uploadResult.filename, private: false, width: displayW, height: displayH },
1084
+ position: pos,
1085
+ })
1086
+ if (result.success && result.widget) {
1087
+ undoRedo.snapshot(stateRef.current, 'add')
1088
+ setLocalWidgets((prev) => [...(prev || []), result.widget])
1089
+ }
1090
+ } catch (err) {
1091
+ console.error('[canvas] Failed to paste image:', err)
1092
+ }
1093
+ return true
1094
+ }
1095
+ return false
1096
+ }
1097
+
464
1098
  async function handlePaste(e) {
465
1099
  const tag = e.target.tagName
466
1100
  if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
467
1101
 
1102
+ // Image paste takes priority
1103
+ const handledImage = await handleImagePaste(e)
1104
+ if (handledImage) return
1105
+
468
1106
  const text = e.clipboardData?.getData('text/plain')?.trim()
469
1107
  if (!text) return
470
1108
 
@@ -473,11 +1111,14 @@ export default function CanvasPage({ name }) {
473
1111
  let type, props
474
1112
  try {
475
1113
  const parsed = new URL(text)
476
- if (isSameOriginPrototype(text)) {
1114
+ if (isFigmaUrl(text)) {
1115
+ type = 'figma-embed'
1116
+ props = { url: sanitizeFigmaUrl(text), width: 800, height: 450 }
1117
+ } else if (isSameOriginPrototype(text)) {
477
1118
  const pathPortion = parsed.pathname + parsed.search + parsed.hash
478
1119
  const src = extractPrototypeSrc(pathPortion)
479
1120
  type = 'prototype'
480
- props = { src: src || '/', label: '', width: 800, height: 600 }
1121
+ props = { src: src || '/', originalSrc: src || '/', label: '', width: 800, height: 600 }
481
1122
  } else {
482
1123
  type = 'link-preview'
483
1124
  props = { url: text, title: '' }
@@ -496,6 +1137,7 @@ export default function CanvasPage({ name }) {
496
1137
  position: pos,
497
1138
  })
498
1139
  if (result.success && result.widget) {
1140
+ undoRedo.snapshot(stateRef.current, 'add')
499
1141
  setLocalWidgets((prev) => [...(prev || []), result.widget])
500
1142
  }
501
1143
  } catch (err) {
@@ -504,7 +1146,75 @@ export default function CanvasPage({ name }) {
504
1146
  }
505
1147
  document.addEventListener('paste', handlePaste)
506
1148
  return () => document.removeEventListener('paste', handlePaste)
507
- }, [name])
1149
+ }, [name, undoRedo])
1150
+
1151
+ // --- Undo / Redo ---
1152
+ const handleUndo = useCallback(() => {
1153
+ const previous = undoRedo.undo(stateRef.current)
1154
+ if (!previous) return
1155
+ debouncedSave.cancel()
1156
+ debouncedSourceSave.cancel()
1157
+ setLocalWidgets(previous.widgets)
1158
+ setLocalSources(previous.sources)
1159
+ queueWrite(() =>
1160
+ updateCanvas(name, { widgets: previous.widgets, sources: previous.sources }).catch((err) =>
1161
+ console.error('[canvas] Failed to persist undo:', err)
1162
+ )
1163
+ )
1164
+ }, [name, debouncedSave, debouncedSourceSave, undoRedo])
1165
+
1166
+ const handleRedo = useCallback(() => {
1167
+ const next = undoRedo.redo(stateRef.current)
1168
+ if (!next) return
1169
+ debouncedSave.cancel()
1170
+ debouncedSourceSave.cancel()
1171
+ setLocalWidgets(next.widgets)
1172
+ setLocalSources(next.sources)
1173
+ queueWrite(() =>
1174
+ updateCanvas(name, { widgets: next.widgets, sources: next.sources }).catch((err) =>
1175
+ console.error('[canvas] Failed to persist redo:', err)
1176
+ )
1177
+ )
1178
+ }, [name, debouncedSave, debouncedSourceSave, undoRedo])
1179
+
1180
+ // Keyboard shortcuts — dev-only (Cmd+Z / Cmd+Shift+Z)
1181
+ useEffect(() => {
1182
+ if (!import.meta.hot) return
1183
+ function handleKeyDown(e) {
1184
+ const tag = e.target.tagName
1185
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
1186
+ const mod = e.metaKey || e.ctrlKey
1187
+ if (mod && e.key === 'z' && !e.shiftKey) {
1188
+ e.preventDefault()
1189
+ handleUndo()
1190
+ }
1191
+ if (mod && e.key === 'z' && e.shiftKey) {
1192
+ e.preventDefault()
1193
+ handleRedo()
1194
+ }
1195
+ }
1196
+ document.addEventListener('keydown', handleKeyDown)
1197
+ return () => document.removeEventListener('keydown', handleKeyDown)
1198
+ }, [handleUndo, handleRedo])
1199
+
1200
+ // Listen for undo/redo from CoreUIBar (Svelte toolbar)
1201
+ useEffect(() => {
1202
+ function handleUndoEvent() { handleUndo() }
1203
+ function handleRedoEvent() { handleRedo() }
1204
+ document.addEventListener('storyboard:canvas:undo', handleUndoEvent)
1205
+ document.addEventListener('storyboard:canvas:redo', handleRedoEvent)
1206
+ return () => {
1207
+ document.removeEventListener('storyboard:canvas:undo', handleUndoEvent)
1208
+ document.removeEventListener('storyboard:canvas:redo', handleRedoEvent)
1209
+ }
1210
+ }, [handleUndo, handleRedo])
1211
+
1212
+ // Broadcast undo/redo availability to Svelte toolbar
1213
+ useEffect(() => {
1214
+ document.dispatchEvent(new CustomEvent('storyboard:canvas:undo-redo-state', {
1215
+ detail: { canUndo: undoRedo.canUndo, canRedo: undoRedo.canRedo }
1216
+ }))
1217
+ }, [undoRedo.canUndo, undoRedo.canRedo])
508
1218
 
509
1219
  // Cmd+scroll / trackpad pinch to smooth-zoom the canvas
510
1220
  // On macOS, pinch-to-zoom fires wheel events with ctrlKey: true and small
@@ -604,9 +1314,11 @@ export default function CanvasPage({ name }) {
604
1314
  dotted: canvas.dotted ?? false,
605
1315
  grid: canvas.grid ?? false,
606
1316
  gridSize: canvas.gridSize ?? 18,
1317
+ snapGrid: snapEnabled ? [snapGridSize, snapGridSize] : undefined,
607
1318
  colorMode: canvas.colorMode === 'auto'
608
1319
  ? getToolbarColorMode(canvasTheme)
609
1320
  : (canvas.colorMode ?? 'auto'),
1321
+ locked: !isLocalDev,
610
1322
  }
611
1323
 
612
1324
  const canvasThemeVars = getCanvasThemeVars(canvasTheme)
@@ -633,25 +1345,31 @@ export default function CanvasPage({ name }) {
633
1345
  id={`jsx-${exportName}`}
634
1346
  data-tc-x={sourcePosition.x}
635
1347
  data-tc-y={sourcePosition.y}
636
- data-tc-handle=".tc-drag-handle"
1348
+ {...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle, .tc-drag-surface' } : {})}
637
1349
  {...canvasPrimerAttrs}
638
1350
  style={canvasThemeVars}
639
- onClick={(e) => {
1351
+ onClick={isLocalDev ? (e) => {
640
1352
  e.stopPropagation()
641
- setSelectedWidgetId(`jsx-${exportName}`)
642
- }}
1353
+ if (!e.target.closest('.tc-drag-handle')) {
1354
+ handleWidgetSelect(`jsx-${exportName}`, e.shiftKey)
1355
+ }
1356
+ } : undefined}
643
1357
  >
644
1358
  <WidgetChrome
1359
+ widgetId={`jsx-${exportName}`}
645
1360
  features={componentFeatures}
646
- selected={selectedWidgetId === `jsx-${exportName}`}
647
- onSelect={() => setSelectedWidgetId(`jsx-${exportName}`)}
648
- onDeselect={() => setSelectedWidgetId(null)}
1361
+ selected={selectedWidgetIds.has(`jsx-${exportName}`)}
1362
+ multiSelected={isMultiSelected && selectedWidgetIds.has(`jsx-${exportName}`)}
1363
+ onSelect={(shiftKey) => handleWidgetSelect(`jsx-${exportName}`, shiftKey)}
1364
+ onDeselect={() => setSelectedWidgetIds(new Set())}
1365
+ readOnly={!isLocalDev}
649
1366
  >
650
1367
  <ComponentWidget
651
1368
  component={Component}
652
1369
  width={sourceData.width}
653
1370
  height={sourceData.height}
654
- onUpdate={(updates) => handleSourceUpdate(exportName, updates)}
1371
+ onUpdate={isLocalDev ? (updates) => handleSourceUpdate(exportName, updates) : undefined}
1372
+ resizable={isResizable('component') && isLocalDev}
655
1373
  />
656
1374
  </WidgetChrome>
657
1375
  </div>
@@ -667,24 +1385,29 @@ export default function CanvasPage({ name }) {
667
1385
  id={widget.id}
668
1386
  data-tc-x={widget?.position?.x ?? 0}
669
1387
  data-tc-y={widget?.position?.y ?? 0}
670
- data-tc-handle=".tc-drag-handle"
1388
+ {...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle, .tc-drag-surface' } : {})}
671
1389
  {...canvasPrimerAttrs}
672
1390
  style={canvasThemeVars}
673
- onClick={(e) => {
1391
+ onClick={isLocalDev ? (e) => {
674
1392
  e.stopPropagation()
675
- setSelectedWidgetId(widget.id)
676
- }}
1393
+ if (!e.target.closest('.tc-drag-handle')) {
1394
+ handleWidgetSelect(widget.id, e.shiftKey)
1395
+ }
1396
+ } : undefined}
677
1397
  >
678
1398
  <ChromeWrappedWidget
679
1399
  widget={widget}
680
- selected={selectedWidgetId === widget.id}
681
- onSelect={() => setSelectedWidgetId(widget.id)}
682
- onDeselect={() => setSelectedWidgetId(null)}
683
- onUpdate={handleWidgetUpdate}
684
- onRemove={(id) => {
1400
+ selected={selectedWidgetIds.has(widget.id)}
1401
+ multiSelected={isMultiSelected && selectedWidgetIds.has(widget.id)}
1402
+ onSelect={(shiftKey) => handleWidgetSelect(widget.id, shiftKey)}
1403
+ onDeselect={() => setSelectedWidgetIds(new Set())}
1404
+ onUpdate={isLocalDev ? handleWidgetUpdate : undefined}
1405
+ onCopy={isLocalDev ? handleWidgetCopy : undefined}
1406
+ onRemove={isLocalDev ? (id) => {
685
1407
  handleWidgetRemove(id)
686
- setSelectedWidgetId(null)
687
- }}
1408
+ setSelectedWidgetIds(new Set())
1409
+ } : undefined}
1410
+ readOnly={!isLocalDev}
688
1411
  />
689
1412
  </div>
690
1413
  )
@@ -695,17 +1418,27 @@ export default function CanvasPage({ name }) {
695
1418
  return (
696
1419
  <>
697
1420
  <div className={styles.canvasTitle}>
698
- <input
699
- ref={titleInputRef}
700
- className={styles.canvasTitleInput}
701
- value={canvasTitle}
702
- onChange={handleTitleChange}
703
- onKeyDown={handleTitleKeyDown}
704
- onMouseDown={(e) => e.stopPropagation()}
705
- spellCheck={false}
706
- aria-label="Canvas title"
707
- style={{ width: `${Math.max(80, canvasTitle.length * 8.5 + 20)}px` }}
708
- />
1421
+ <div className={styles.canvasTitleWrap}>
1422
+ <span className={styles.canvasTitleMeasure} aria-hidden="true">{canvasTitle || ' '}</span>
1423
+ {isLocalDev ? (
1424
+ <input
1425
+ ref={titleInputRef}
1426
+ className={styles.canvasTitleInput}
1427
+ value={canvasTitle}
1428
+ size={1}
1429
+ onChange={handleTitleChange}
1430
+ onKeyDown={handleTitleKeyDown}
1431
+ onMouseDown={(e) => e.stopPropagation()}
1432
+ spellCheck={false}
1433
+ aria-label="Canvas title"
1434
+ />
1435
+ ) : (
1436
+ <h1 className={styles.canvasTitleStatic}>{canvasTitle}</h1>
1437
+ )}
1438
+ </div>
1439
+ {isLocalDev && (
1440
+ <span className={styles.localEditingLabel}>Local editing</span>
1441
+ )}
709
1442
  </div>
710
1443
  <div
711
1444
  ref={scrollRef}
@@ -717,7 +1450,7 @@ export default function CanvasPage({ name }) {
717
1450
  ...canvasThemeVars,
718
1451
  ...(spaceHeld ? { cursor: panningActive ? 'grabbing' : 'grab' } : {}),
719
1452
  }}
720
- onClick={() => setSelectedWidgetId(null)}
1453
+ onClick={() => setSelectedWidgetIds(new Set())}
721
1454
  onMouseDown={handlePanStart}
722
1455
  >
723
1456
  <div
@@ -732,7 +1465,7 @@ export default function CanvasPage({ name }) {
732
1465
  ...(spaceHeld ? { pointerEvents: 'none' } : {}),
733
1466
  }}
734
1467
  >
735
- <Canvas {...canvasProps} onDragEnd={handleItemDragEnd}>
1468
+ <Canvas {...canvasProps} onDragStart={isLocalDev ? handleItemDragStart : undefined} onDrag={isLocalDev ? handleItemDrag : undefined} onDragEnd={isLocalDev ? handleItemDragEnd : undefined}>
736
1469
  {allChildren}
737
1470
  </Canvas>
738
1471
  </div>