@dfosco/storyboard-react 3.11.1-beta.0 → 3.11.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.
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 +791 -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,126 @@ 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)
176
290
  const [canvasTitle, setCanvasTitle] = useState(canvas?.title || name)
177
291
  const titleInputRef = useRef(null)
178
292
  const [localSources, setLocalSources] = useState(canvas?.sources ?? [])
179
293
  const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
294
+ const [snapEnabled, setSnapEnabled] = useState(canvas?.snapToGrid ?? false)
295
+ const [snapGridSize, setSnapGridSize] = useState(canvas?.gridSize || 40)
296
+
297
+ // Undo/redo history — tracks both widgets and sources as a combined snapshot
298
+ const undoRedo = useUndoRedo()
299
+ const stateRef = useRef({ widgets: localWidgets, sources: localSources })
300
+ useEffect(() => {
301
+ stateRef.current = { widgets: localWidgets, sources: localSources }
302
+ }, [localWidgets, localSources])
303
+
304
+ // Serialized write queue — ensures JSONL events land in the right order
305
+ const writeQueueRef = useRef(Promise.resolve())
306
+ function queueWrite(fn) {
307
+ writeQueueRef.current = writeQueueRef.current.then(fn).catch((err) =>
308
+ console.error('[canvas] Write queue error:', err)
309
+ )
310
+ return writeQueueRef.current
311
+ }
312
+
313
+ // Ref for selectedWidgetIds to avoid stale closures in callbacks
314
+ const selectedIdsRef = useRef(selectedWidgetIds)
315
+ useEffect(() => {
316
+ selectedIdsRef.current = selectedWidgetIds
317
+ }, [selectedWidgetIds])
318
+
319
+ const isMultiSelected = selectedWidgetIds.size > 1
320
+
321
+ /**
322
+ * Selection handler — shift+click toggles in/out of multi-select set,
323
+ * plain click single-selects (clears others).
324
+ * Suppressed immediately after a multi-drag to prevent the post-drag
325
+ * click from collapsing the selection.
326
+ */
327
+ const handleWidgetSelect = useCallback((widgetId, shiftKey) => {
328
+ if (justDraggedRef.current) return
329
+ if (shiftKey) {
330
+ setSelectedWidgetIds(prev => {
331
+ const next = new Set(prev)
332
+ if (next.has(widgetId)) {
333
+ next.delete(widgetId)
334
+ } else {
335
+ next.add(widgetId)
336
+ }
337
+ return next
338
+ })
339
+ } else {
340
+ setSelectedWidgetIds(new Set([widgetId]))
341
+ }
342
+ }, [])
343
+
344
+ // --- Multi-select drag: peers animate to new positions on drag end ---
345
+ // During drag, only the dragged widget moves (via neodrag). On drag end,
346
+ // peer widget positions are updated via React state, and we add the
347
+ // tc-on-translation class so they animate smoothly to their new spots.
348
+ const peerArticlesRef = useRef(new Map())
349
+ // Flag to suppress the click-based selection reset that fires after a drag
350
+ const justDraggedRef = useRef(false)
351
+
352
+ const handleItemDragStart = useCallback((dragId, position) => {
353
+ const ids = selectedIdsRef.current
354
+ peerArticlesRef.current.clear()
355
+ if (ids.size <= 1 || !ids.has(dragId)) return
356
+
357
+ // Suppress selection changes for the duration of the drag
358
+ justDraggedRef.current = true
359
+
360
+ // Collect peer article elements for transition on drag end
361
+ for (const id of ids) {
362
+ if (id === dragId) continue
363
+ const widgetEl = document.getElementById(id)
364
+ const article = widgetEl?.closest('article')
365
+ if (!article) continue
366
+ peerArticlesRef.current.set(id, article)
367
+ }
368
+ }, [])
369
+
370
+ const handleItemDrag = useCallback(() => {
371
+ // Peers stay put during drag — they animate on drag end
372
+ }, [])
373
+
374
+ /** Add transition class to peer articles so they animate to new positions. */
375
+ const transitionPeers = useCallback(() => {
376
+ for (const [, article] of peerArticlesRef.current) {
377
+ article.classList.add('tc-on-translation')
378
+ }
379
+ // Remove class after animation completes
380
+ const articles = [...peerArticlesRef.current.values()]
381
+ setTimeout(() => {
382
+ for (const article of articles) {
383
+ article.classList.remove('tc-on-translation')
384
+ }
385
+ }, 150 + 50 + 200)
386
+ peerArticlesRef.current.clear()
387
+ }, [])
388
+
389
+ const clearDragPreview = useCallback(() => {
390
+ peerArticlesRef.current.clear()
391
+ }, [])
180
392
 
181
393
  if (canvas !== trackedCanvas) {
182
394
  setTrackedCanvas(canvas)
183
395
  setLocalWidgets(canvas?.widgets ?? null)
184
396
  setLocalSources(canvas?.sources ?? [])
185
397
  setCanvasTitle(canvas?.title || name)
398
+ undoRedo.reset()
186
399
  }
187
400
 
188
401
  // Debounced save to server
@@ -216,22 +429,59 @@ export default function CanvasPage({ name }) {
216
429
  }, [])
217
430
 
218
431
  const handleWidgetUpdate = useCallback((widgetId, updates) => {
432
+ undoRedo.snapshot(stateRef.current, 'edit', widgetId)
433
+ // Snap width/height to grid when snap is enabled
434
+ const snapped = { ...updates }
435
+ if (snapEnabled && snapGridSize) {
436
+ if (snapped.width != null) snapped.width = snapDimension(snapped.width, snapGridSize, true, 60)
437
+ if (snapped.height != null) snapped.height = snapDimension(snapped.height, snapGridSize, true, 60)
438
+ }
219
439
  setLocalWidgets((prev) => {
220
440
  if (!prev) return prev
221
441
  const next = prev.map((w) =>
222
- w.id === widgetId ? { ...w, props: { ...w.props, ...updates } } : w
442
+ w.id === widgetId ? { ...w, props: { ...w.props, ...snapped } } : w
223
443
  )
224
444
  debouncedSave(name, next)
225
445
  return next
226
446
  })
227
- }, [name, debouncedSave])
447
+ }, [name, debouncedSave, undoRedo, snapEnabled, snapGridSize])
228
448
 
229
449
  const handleWidgetRemove = useCallback((widgetId) => {
450
+ undoRedo.snapshot(stateRef.current, 'remove', widgetId)
230
451
  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)
452
+ queueWrite(() =>
453
+ removeWidgetApi(name, widgetId).catch((err) =>
454
+ console.error('[canvas] Failed to remove widget:', err)
455
+ )
233
456
  )
234
- }, [name])
457
+ }, [name, undoRedo])
458
+
459
+ const handleWidgetCopy = useCallback(async (widget) => {
460
+ // Find the next free offset — check how many copies already exist at +n*40
461
+ const baseX = widget.position?.x ?? 0
462
+ const baseY = widget.position?.y ?? 0
463
+ const occupied = new Set(
464
+ (localWidgets ?? []).map((w) => `${w.position?.x ?? 0},${w.position?.y ?? 0}`)
465
+ )
466
+ let n = 1
467
+ while (occupied.has(`${baseX + n * 40},${baseY + n * 40}`)) {
468
+ n++
469
+ }
470
+ const position = { x: baseX + n * 40, y: baseY + n * 40 }
471
+ try {
472
+ undoRedo.snapshot(stateRef.current, 'add')
473
+ const result = await addWidgetApi(name, {
474
+ type: widget.type,
475
+ props: { ...widget.props },
476
+ position,
477
+ })
478
+ if (result.success && result.widget) {
479
+ setLocalWidgets((prev) => [...(prev || []), result.widget])
480
+ }
481
+ } catch (err) {
482
+ console.error('[canvas] Failed to copy widget:', err)
483
+ }
484
+ }, [name, localWidgets, undoRedo])
235
485
 
236
486
  const debouncedSourceSave = useRef(
237
487
  debounce((canvasName, sources) => {
@@ -242,51 +492,250 @@ export default function CanvasPage({ name }) {
242
492
  ).current
243
493
 
244
494
  const handleSourceUpdate = useCallback((exportName, updates) => {
495
+ undoRedo.snapshot(stateRef.current, 'edit', `jsx-${exportName}`)
496
+ const snapped = { ...updates }
497
+ if (snapEnabled && snapGridSize) {
498
+ if (snapped.width != null) snapped.width = snapDimension(snapped.width, snapGridSize, true, 100)
499
+ if (snapped.height != null) snapped.height = snapDimension(snapped.height, snapGridSize, true, 60)
500
+ }
245
501
  setLocalSources((prev) => {
246
502
  const current = Array.isArray(prev) ? prev : []
247
503
  const next = current.some((s) => s?.export === exportName)
248
- ? current.map((s) => (s?.export === exportName ? { ...s, ...updates } : s))
249
- : [...current, { export: exportName, ...updates }]
504
+ ? current.map((s) => (s?.export === exportName ? { ...s, ...snapped } : s))
505
+ : [...current, { export: exportName, ...snapped }]
250
506
  debouncedSourceSave(name, next)
251
507
  return next
252
508
  })
253
- }, [name, debouncedSourceSave])
509
+ }, [name, debouncedSourceSave, undoRedo, snapEnabled, snapGridSize])
254
510
 
255
511
  const handleItemDragEnd = useCallback((dragId, position) => {
256
- if (!dragId || !position) return
512
+ if (!dragId || !position) {
513
+ clearDragPreview()
514
+ return
515
+ }
257
516
  const rounded = { x: Math.max(0, roundPosition(position.x)), y: Math.max(0, roundPosition(position.y)) }
258
517
 
518
+ const ids = selectedIdsRef.current
519
+ // Multi-select move: apply same delta to all selected widgets
520
+ // Checked BEFORE the jsx- early return so mixed selections work
521
+ if (ids.size > 1 && ids.has(dragId)) {
522
+ transitionPeers()
523
+ // Suppress the click-based selection reset that fires after pointerup
524
+ justDraggedRef.current = true
525
+ requestAnimationFrame(() => { justDraggedRef.current = false })
526
+ undoRedo.snapshot(stateRef.current, 'multi-move')
527
+
528
+ // Compute delta from the dragged widget's old position
529
+ const isJsx = dragId.startsWith('jsx-')
530
+ let oldPos = { x: 0, y: 0 }
531
+ if (isJsx) {
532
+ const sourceExport = dragId.replace(/^jsx-/, '')
533
+ const source = (stateRef.current.sources ?? []).find(s => s?.export === sourceExport)
534
+ oldPos = source?.position || { x: 0, y: 0 }
535
+ } else {
536
+ const draggedWidget = (stateRef.current.widgets ?? []).find(w => w.id === dragId)
537
+ oldPos = draggedWidget?.position || { x: 0, y: 0 }
538
+ }
539
+ const dx = rounded.x - oldPos.x
540
+ const dy = rounded.y - oldPos.y
541
+
542
+ debouncedSave.cancel()
543
+
544
+ // Update JSON widget positions
545
+ setLocalWidgets((prev) => {
546
+ if (!prev) return prev
547
+ const next = prev.map((w) => {
548
+ if (w.id === dragId) return { ...w, position: rounded }
549
+ if (ids.has(w.id)) {
550
+ return {
551
+ ...w,
552
+ position: {
553
+ x: Math.max(0, roundPosition((w.position?.x ?? 0) + dx)),
554
+ y: Math.max(0, roundPosition((w.position?.y ?? 0) + dy)),
555
+ },
556
+ }
557
+ }
558
+ return w
559
+ })
560
+ queueWrite(() =>
561
+ updateCanvas(name, { widgets: next }).catch((err) =>
562
+ console.error('[canvas] Failed to save multi-move:', err)
563
+ )
564
+ )
565
+ return next
566
+ })
567
+
568
+ // Update JSX source positions
569
+ setLocalSources((prev) => {
570
+ const current = Array.isArray(prev) ? prev : []
571
+ let changed = false
572
+ const next = current.map((s) => {
573
+ if (!s?.export) return s
574
+ const sid = `jsx-${s.export}`
575
+ if (sid === dragId) {
576
+ changed = true
577
+ return { ...s, position: rounded }
578
+ }
579
+ if (ids.has(sid)) {
580
+ changed = true
581
+ return {
582
+ ...s,
583
+ position: {
584
+ x: Math.max(0, roundPosition((s.position?.x ?? 0) + dx)),
585
+ y: Math.max(0, roundPosition((s.position?.y ?? 0) + dy)),
586
+ },
587
+ }
588
+ }
589
+ return s
590
+ })
591
+ if (changed) {
592
+ queueWrite(() =>
593
+ updateCanvas(name, { sources: next }).catch((err) =>
594
+ console.error('[canvas] Failed to save multi-move sources:', err)
595
+ )
596
+ )
597
+ }
598
+ return changed ? next : current
599
+ })
600
+ return
601
+ }
602
+
259
603
  if (dragId.startsWith('jsx-')) {
604
+ undoRedo.snapshot(stateRef.current, 'move', dragId)
260
605
  const sourceExport = dragId.replace(/^jsx-/, '')
261
606
  setLocalSources((prev) => {
262
607
  const current = Array.isArray(prev) ? prev : []
263
608
  const next = current.some((s) => s?.export === sourceExport)
264
609
  ? current.map((s) => (s?.export === sourceExport ? { ...s, position: rounded } : s))
265
610
  : [...current, { export: sourceExport, position: rounded }]
266
- updateCanvas(name, { sources: next }).catch((err) =>
267
- console.error('[canvas] Failed to save source position:', err)
611
+ queueWrite(() =>
612
+ updateCanvas(name, { sources: next }).catch((err) =>
613
+ console.error('[canvas] Failed to save source position:', err)
614
+ )
268
615
  )
269
616
  return next
270
617
  })
271
618
  return
272
619
  }
273
620
 
621
+ undoRedo.snapshot(stateRef.current, 'move', dragId)
274
622
  setLocalWidgets((prev) => {
275
623
  if (!prev) return prev
276
624
  const next = prev.map((w) =>
277
625
  w.id === dragId ? { ...w, position: rounded } : w
278
626
  )
279
- updateCanvas(name, { widgets: next }).catch((err) =>
280
- console.error('[canvas] Failed to save widget position:', err)
627
+ queueWrite(() =>
628
+ updateCanvas(name, { widgets: next }).catch((err) =>
629
+ console.error('[canvas] Failed to save widget position:', err)
630
+ )
281
631
  )
282
632
  return next
283
633
  })
284
- }, [name])
634
+ }, [name, undoRedo, debouncedSave, transitionPeers, clearDragPreview])
285
635
 
286
636
  useEffect(() => {
287
637
  zoomRef.current = zoom
288
638
  }, [zoom])
289
639
 
640
+ // Restore scroll position from localStorage after first render
641
+ useEffect(() => {
642
+ const el = scrollRef.current
643
+ const saved = pendingScrollRestore.current
644
+ if (el && saved) {
645
+ if (saved.scrollLeft != null) el.scrollLeft = saved.scrollLeft
646
+ if (saved.scrollTop != null) el.scrollTop = saved.scrollTop
647
+ pendingScrollRestore.current = null
648
+ }
649
+ }, [name, loading])
650
+
651
+ // Center on a specific widget if `?widget=<id>` is in the URL
652
+ useEffect(() => {
653
+ const params = new URLSearchParams(window.location.search)
654
+ const targetId = params.get('widget')
655
+ if (!targetId || loading) return
656
+
657
+ const el = scrollRef.current
658
+ if (!el) return
659
+
660
+ let x, y, w, h
661
+
662
+ // Check JSON widgets first
663
+ const widgets = localWidgets ?? []
664
+ const widget = widgets.find((wgt) => wgt.id === targetId)
665
+ if (widget) {
666
+ const fallback = WIDGET_FALLBACK_SIZES[widget.type] || { width: 200, height: 150 }
667
+ x = widget.position?.x ?? 0
668
+ y = widget.position?.y ?? 0
669
+ w = widget.props?.width ?? fallback.width
670
+ h = widget.props?.height ?? fallback.height
671
+ }
672
+
673
+ // Check JSX sources (jsx-ExportName)
674
+ if (!widget && targetId.startsWith('jsx-')) {
675
+ const exportName = targetId.slice(4)
676
+ const sourceMap = Object.fromEntries(
677
+ (localSources || []).filter((s) => s?.export).map((s) => [s.export, s])
678
+ )
679
+ const sourceData = sourceMap[exportName]
680
+ if (sourceData || (jsxExports && exportName in jsxExports)) {
681
+ const fallback = WIDGET_FALLBACK_SIZES['component']
682
+ x = sourceData?.position?.x ?? 0
683
+ y = sourceData?.position?.y ?? 0
684
+ w = sourceData?.width ?? fallback.width
685
+ h = sourceData?.height ?? fallback.height
686
+ }
687
+ }
688
+
689
+ if (x == null) return
690
+
691
+ const scale = zoomRef.current / 100
692
+ el.scrollLeft = (x + w / 2) * scale - el.clientWidth / 2
693
+ el.scrollTop = (y + h / 2) * scale - el.clientHeight / 2
694
+
695
+ // Clean the URL param without triggering navigation
696
+ const url = new URL(window.location.href)
697
+ url.searchParams.delete('widget')
698
+ window.history.replaceState({}, '', url.toString())
699
+ }, [loading, localWidgets, localSources, jsxExports])
700
+
701
+ // Persist viewport state (zoom + scroll) to localStorage on changes
702
+ useEffect(() => {
703
+ const el = scrollRef.current
704
+ saveViewportState(name, {
705
+ zoom,
706
+ scrollLeft: el?.scrollLeft ?? 0,
707
+ scrollTop: el?.scrollTop ?? 0,
708
+ })
709
+ }, [name, zoom])
710
+
711
+ useEffect(() => {
712
+ const el = scrollRef.current
713
+ if (!el) return
714
+ function handleScroll() {
715
+ saveViewportState(name, {
716
+ zoom: zoomRef.current,
717
+ scrollLeft: el.scrollLeft,
718
+ scrollTop: el.scrollTop,
719
+ })
720
+ }
721
+ el.addEventListener('scroll', handleScroll, { passive: true })
722
+
723
+ // Flush viewport state on page unload so a refresh never misses it
724
+ function handleBeforeUnload() {
725
+ saveViewportState(name, {
726
+ zoom: zoomRef.current,
727
+ scrollLeft: el.scrollLeft,
728
+ scrollTop: el.scrollTop,
729
+ })
730
+ }
731
+ window.addEventListener('beforeunload', handleBeforeUnload)
732
+
733
+ return () => {
734
+ el.removeEventListener('scroll', handleScroll)
735
+ window.removeEventListener('beforeunload', handleBeforeUnload)
736
+ }
737
+ }, [name, loading])
738
+
290
739
  /**
291
740
  * Zoom to a new level, anchoring on an optional client-space point.
292
741
  * When a cursor position is provided (e.g. from a wheel event), the
@@ -345,6 +794,26 @@ export default function CanvasPage({ name }) {
345
794
  }
346
795
  }, [name])
347
796
 
797
+ // Tell the Vite dev server to suppress full-reloads while this canvas is active.
798
+ // The ?canvas-hmr URL param opts out of the guard for canvas UI development.
799
+ // Sends a heartbeat every 3s so the guard auto-expires if the tab closes.
800
+ useEffect(() => {
801
+ if (!import.meta.hot) return
802
+ const hmrEnabled = new URLSearchParams(window.location.search).has('canvas-hmr')
803
+ if (hmrEnabled) return
804
+
805
+ const msg = { active: true, hmrEnabled: false }
806
+ import.meta.hot.send('storyboard:canvas-hmr-guard', msg)
807
+ const interval = setInterval(() => {
808
+ import.meta.hot.send('storyboard:canvas-hmr-guard', msg)
809
+ }, 3000)
810
+
811
+ return () => {
812
+ clearInterval(interval)
813
+ import.meta.hot.send('storyboard:canvas-hmr-guard', { active: false, hmrEnabled: true })
814
+ }
815
+ }, [name])
816
+
348
817
  // Add a widget by type — used by CanvasControls and CoreUIBar event
349
818
  const addWidget = useCallback(async (type) => {
350
819
  const defaultProps = schemas[type] ? getDefaults(schemas[type]) : {}
@@ -357,12 +826,13 @@ export default function CanvasPage({ name }) {
357
826
  position: pos,
358
827
  })
359
828
  if (result.success && result.widget) {
829
+ undoRedo.snapshot(stateRef.current, 'add')
360
830
  setLocalWidgets((prev) => [...(prev || []), result.widget])
361
831
  }
362
832
  } catch (err) {
363
833
  console.error('[canvas] Failed to add widget:', err)
364
834
  }
365
- }, [name])
835
+ }, [name, undoRedo])
366
836
 
367
837
  // Listen for CoreUIBar add-widget events
368
838
  useEffect(() => {
@@ -385,6 +855,70 @@ export default function CanvasPage({ name }) {
385
855
  return () => document.removeEventListener('storyboard:canvas:set-zoom', handleZoom)
386
856
  }, [])
387
857
 
858
+ // Listen for snap-to-grid toggle from CoreUIBar
859
+ useEffect(() => {
860
+ function handleSnapToggle() {
861
+ setSnapEnabled((prev) => {
862
+ const next = !prev
863
+ updateCanvas(name, { snapToGrid: next }).catch((err) =>
864
+ console.error('[canvas] Failed to persist snap setting:', err)
865
+ )
866
+ return next
867
+ })
868
+ }
869
+ document.addEventListener('storyboard:canvas:toggle-snap', handleSnapToggle)
870
+ return () => document.removeEventListener('storyboard:canvas:toggle-snap', handleSnapToggle)
871
+ }, [name])
872
+
873
+ // Broadcast snap state to Svelte toolbar
874
+ useEffect(() => {
875
+ document.dispatchEvent(new CustomEvent('storyboard:canvas:snap-state', {
876
+ detail: { snapEnabled }
877
+ }))
878
+ }, [snapEnabled])
879
+
880
+ // Listen for gridSize from Svelte toolbar config
881
+ useEffect(() => {
882
+ function handleGridSize(e) {
883
+ const size = e.detail?.gridSize
884
+ if (typeof size === 'number' && size > 0) setSnapGridSize(size)
885
+ }
886
+ document.addEventListener('storyboard:canvas:grid-size', handleGridSize)
887
+ return () => document.removeEventListener('storyboard:canvas:grid-size', handleGridSize)
888
+ }, [])
889
+
890
+ // Listen for zoom-to-fit from CoreUIBar
891
+ useEffect(() => {
892
+ function handleZoomToFit() {
893
+ const el = scrollRef.current
894
+ if (!el) return
895
+
896
+ const bounds = computeCanvasBounds(localWidgets, localSources, jsxExports)
897
+ if (!bounds) return
898
+
899
+ const boxW = bounds.maxX - bounds.minX + FIT_PADDING * 2
900
+ const boxH = bounds.maxY - bounds.minY + FIT_PADDING * 2
901
+
902
+ const viewW = el.clientWidth
903
+ const viewH = el.clientHeight
904
+
905
+ // Find the zoom level that fits the bounding box in the viewport
906
+ const fitScale = Math.min(viewW / boxW, viewH / boxH)
907
+ const fitZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(fitScale * 100)))
908
+ const newScale = fitZoom / 100
909
+
910
+ // Apply zoom synchronously so DOM updates before we scroll
911
+ zoomRef.current = fitZoom
912
+ flushSync(() => setZoom(fitZoom))
913
+
914
+ // Scroll so the bounding box top-left (with padding) is at viewport top-left
915
+ el.scrollLeft = (bounds.minX - FIT_PADDING) * newScale
916
+ el.scrollTop = (bounds.minY - FIT_PADDING) * newScale
917
+ }
918
+ document.addEventListener('storyboard:canvas:zoom-to-fit', handleZoomToFit)
919
+ return () => document.removeEventListener('storyboard:canvas:zoom-to-fit', handleZoomToFit)
920
+ }, [localWidgets, localSources, jsxExports])
921
+
388
922
  // Canvas background should follow toolbar theme target.
389
923
  useEffect(() => {
390
924
  function readMode() {
@@ -417,20 +951,42 @@ export default function CanvasPage({ name }) {
417
951
 
418
952
  useEffect(() => {
419
953
  function handleKeyDown(e) {
420
- if (!selectedWidgetId) return
954
+ if (selectedWidgetIds.size === 0) return
421
955
  const tag = e.target.tagName
422
956
  if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
957
+ if (e.key === 'Escape') {
958
+ e.preventDefault()
959
+ setSelectedWidgetIds(new Set())
960
+ }
423
961
  if (e.key === 'Delete' || e.key === 'Backspace') {
424
962
  e.preventDefault()
425
- handleWidgetRemove(selectedWidgetId)
426
- setSelectedWidgetId(null)
963
+ if (selectedWidgetIds.size > 1) {
964
+ // Multi-delete — snapshot once, remove all, persist via updateCanvas
965
+ undoRedo.snapshot(stateRef.current, 'multi-remove')
966
+ debouncedSave.cancel()
967
+ setLocalWidgets((prev) => {
968
+ if (!prev) return prev
969
+ const next = prev.filter(w => !selectedWidgetIds.has(w.id))
970
+ queueWrite(() =>
971
+ updateCanvas(name, { widgets: next }).catch(err =>
972
+ console.error('[canvas] Failed to save multi-delete:', err)
973
+ )
974
+ )
975
+ return next
976
+ })
977
+ } else {
978
+ const widgetId = [...selectedWidgetIds][0]
979
+ if (widgetId) handleWidgetRemove(widgetId)
980
+ }
981
+ setSelectedWidgetIds(new Set())
427
982
  }
428
983
  }
429
984
  document.addEventListener('keydown', handleKeyDown)
430
985
  return () => document.removeEventListener('keydown', handleKeyDown)
431
- }, [selectedWidgetId, handleWidgetRemove])
986
+ }, [selectedWidgetIds, handleWidgetRemove, undoRedo, name, debouncedSave])
432
987
 
433
- // Paste handler — same-origin URLs become prototypes, other URLs become link previews, text becomes markdown
988
+ // Paste handler — images become image widgets, same-origin URLs become prototypes,
989
+ // other URLs become link previews, text becomes markdown
434
990
  useEffect(() => {
435
991
  const origin = window.location.origin
436
992
  const basePath = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
@@ -461,10 +1017,82 @@ export default function CanvasPage({ name }) {
461
1017
  return pathname
462
1018
  }
463
1019
 
1020
+ function blobToDataUrl(blob) {
1021
+ return new Promise((resolve, reject) => {
1022
+ const reader = new FileReader()
1023
+ reader.onload = () => resolve(reader.result)
1024
+ reader.onerror = reject
1025
+ reader.readAsDataURL(blob)
1026
+ })
1027
+ }
1028
+
1029
+ function getImageDimensions(dataUrl) {
1030
+ return new Promise((resolve) => {
1031
+ const img = new Image()
1032
+ img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight })
1033
+ img.onerror = () => resolve({ width: 400, height: 300 })
1034
+ img.src = dataUrl
1035
+ })
1036
+ }
1037
+
1038
+ async function handleImagePaste(e) {
1039
+ const items = e.clipboardData?.items
1040
+ if (!items) return false
1041
+
1042
+ for (const item of items) {
1043
+ if (!item.type.startsWith('image/')) continue
1044
+
1045
+ const blob = item.getAsFile()
1046
+ if (!blob) continue
1047
+
1048
+ e.preventDefault()
1049
+
1050
+ try {
1051
+ const dataUrl = await blobToDataUrl(blob)
1052
+ const { width: natW, height: natH } = await getImageDimensions(dataUrl)
1053
+
1054
+ // Display at 2x retina: halve natural dimensions, then cap at 600px
1055
+ const maxWidth = 600
1056
+ let displayW = Math.round(natW / 2)
1057
+ let displayH = Math.round(natH / 2)
1058
+ if (displayW > maxWidth) {
1059
+ displayH = Math.round(displayH * (maxWidth / displayW))
1060
+ displayW = maxWidth
1061
+ }
1062
+
1063
+ const uploadResult = await uploadImage(dataUrl, name)
1064
+ if (!uploadResult.success) {
1065
+ console.error('[canvas] Image upload failed:', uploadResult.error)
1066
+ return true
1067
+ }
1068
+
1069
+ const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
1070
+ const pos = centerPositionForWidget(center, 'image', { width: displayW, height: displayH })
1071
+ const result = await addWidgetApi(name, {
1072
+ type: 'image',
1073
+ props: { src: uploadResult.filename, private: false, width: displayW, height: displayH },
1074
+ position: pos,
1075
+ })
1076
+ if (result.success && result.widget) {
1077
+ undoRedo.snapshot(stateRef.current, 'add')
1078
+ setLocalWidgets((prev) => [...(prev || []), result.widget])
1079
+ }
1080
+ } catch (err) {
1081
+ console.error('[canvas] Failed to paste image:', err)
1082
+ }
1083
+ return true
1084
+ }
1085
+ return false
1086
+ }
1087
+
464
1088
  async function handlePaste(e) {
465
1089
  const tag = e.target.tagName
466
1090
  if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
467
1091
 
1092
+ // Image paste takes priority
1093
+ const handledImage = await handleImagePaste(e)
1094
+ if (handledImage) return
1095
+
468
1096
  const text = e.clipboardData?.getData('text/plain')?.trim()
469
1097
  if (!text) return
470
1098
 
@@ -473,11 +1101,14 @@ export default function CanvasPage({ name }) {
473
1101
  let type, props
474
1102
  try {
475
1103
  const parsed = new URL(text)
476
- if (isSameOriginPrototype(text)) {
1104
+ if (isFigmaUrl(text)) {
1105
+ type = 'figma-embed'
1106
+ props = { url: sanitizeFigmaUrl(text), width: 800, height: 450 }
1107
+ } else if (isSameOriginPrototype(text)) {
477
1108
  const pathPortion = parsed.pathname + parsed.search + parsed.hash
478
1109
  const src = extractPrototypeSrc(pathPortion)
479
1110
  type = 'prototype'
480
- props = { src: src || '/', label: '', width: 800, height: 600 }
1111
+ props = { src: src || '/', originalSrc: src || '/', label: '', width: 800, height: 600 }
481
1112
  } else {
482
1113
  type = 'link-preview'
483
1114
  props = { url: text, title: '' }
@@ -496,6 +1127,7 @@ export default function CanvasPage({ name }) {
496
1127
  position: pos,
497
1128
  })
498
1129
  if (result.success && result.widget) {
1130
+ undoRedo.snapshot(stateRef.current, 'add')
499
1131
  setLocalWidgets((prev) => [...(prev || []), result.widget])
500
1132
  }
501
1133
  } catch (err) {
@@ -504,7 +1136,75 @@ export default function CanvasPage({ name }) {
504
1136
  }
505
1137
  document.addEventListener('paste', handlePaste)
506
1138
  return () => document.removeEventListener('paste', handlePaste)
507
- }, [name])
1139
+ }, [name, undoRedo])
1140
+
1141
+ // --- Undo / Redo ---
1142
+ const handleUndo = useCallback(() => {
1143
+ const previous = undoRedo.undo(stateRef.current)
1144
+ if (!previous) return
1145
+ debouncedSave.cancel()
1146
+ debouncedSourceSave.cancel()
1147
+ setLocalWidgets(previous.widgets)
1148
+ setLocalSources(previous.sources)
1149
+ queueWrite(() =>
1150
+ updateCanvas(name, { widgets: previous.widgets, sources: previous.sources }).catch((err) =>
1151
+ console.error('[canvas] Failed to persist undo:', err)
1152
+ )
1153
+ )
1154
+ }, [name, debouncedSave, debouncedSourceSave, undoRedo])
1155
+
1156
+ const handleRedo = useCallback(() => {
1157
+ const next = undoRedo.redo(stateRef.current)
1158
+ if (!next) return
1159
+ debouncedSave.cancel()
1160
+ debouncedSourceSave.cancel()
1161
+ setLocalWidgets(next.widgets)
1162
+ setLocalSources(next.sources)
1163
+ queueWrite(() =>
1164
+ updateCanvas(name, { widgets: next.widgets, sources: next.sources }).catch((err) =>
1165
+ console.error('[canvas] Failed to persist redo:', err)
1166
+ )
1167
+ )
1168
+ }, [name, debouncedSave, debouncedSourceSave, undoRedo])
1169
+
1170
+ // Keyboard shortcuts — dev-only (Cmd+Z / Cmd+Shift+Z)
1171
+ useEffect(() => {
1172
+ if (!import.meta.hot) return
1173
+ function handleKeyDown(e) {
1174
+ const tag = e.target.tagName
1175
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
1176
+ const mod = e.metaKey || e.ctrlKey
1177
+ if (mod && e.key === 'z' && !e.shiftKey) {
1178
+ e.preventDefault()
1179
+ handleUndo()
1180
+ }
1181
+ if (mod && e.key === 'z' && e.shiftKey) {
1182
+ e.preventDefault()
1183
+ handleRedo()
1184
+ }
1185
+ }
1186
+ document.addEventListener('keydown', handleKeyDown)
1187
+ return () => document.removeEventListener('keydown', handleKeyDown)
1188
+ }, [handleUndo, handleRedo])
1189
+
1190
+ // Listen for undo/redo from CoreUIBar (Svelte toolbar)
1191
+ useEffect(() => {
1192
+ function handleUndoEvent() { handleUndo() }
1193
+ function handleRedoEvent() { handleRedo() }
1194
+ document.addEventListener('storyboard:canvas:undo', handleUndoEvent)
1195
+ document.addEventListener('storyboard:canvas:redo', handleRedoEvent)
1196
+ return () => {
1197
+ document.removeEventListener('storyboard:canvas:undo', handleUndoEvent)
1198
+ document.removeEventListener('storyboard:canvas:redo', handleRedoEvent)
1199
+ }
1200
+ }, [handleUndo, handleRedo])
1201
+
1202
+ // Broadcast undo/redo availability to Svelte toolbar
1203
+ useEffect(() => {
1204
+ document.dispatchEvent(new CustomEvent('storyboard:canvas:undo-redo-state', {
1205
+ detail: { canUndo: undoRedo.canUndo, canRedo: undoRedo.canRedo }
1206
+ }))
1207
+ }, [undoRedo.canUndo, undoRedo.canRedo])
508
1208
 
509
1209
  // Cmd+scroll / trackpad pinch to smooth-zoom the canvas
510
1210
  // On macOS, pinch-to-zoom fires wheel events with ctrlKey: true and small
@@ -604,9 +1304,11 @@ export default function CanvasPage({ name }) {
604
1304
  dotted: canvas.dotted ?? false,
605
1305
  grid: canvas.grid ?? false,
606
1306
  gridSize: canvas.gridSize ?? 18,
1307
+ snapGrid: snapEnabled ? [snapGridSize, snapGridSize] : undefined,
607
1308
  colorMode: canvas.colorMode === 'auto'
608
1309
  ? getToolbarColorMode(canvasTheme)
609
1310
  : (canvas.colorMode ?? 'auto'),
1311
+ locked: !isLocalDev,
610
1312
  }
611
1313
 
612
1314
  const canvasThemeVars = getCanvasThemeVars(canvasTheme)
@@ -633,25 +1335,31 @@ export default function CanvasPage({ name }) {
633
1335
  id={`jsx-${exportName}`}
634
1336
  data-tc-x={sourcePosition.x}
635
1337
  data-tc-y={sourcePosition.y}
636
- data-tc-handle=".tc-drag-handle"
1338
+ {...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle, .tc-drag-surface' } : {})}
637
1339
  {...canvasPrimerAttrs}
638
1340
  style={canvasThemeVars}
639
- onClick={(e) => {
1341
+ onClick={isLocalDev ? (e) => {
640
1342
  e.stopPropagation()
641
- setSelectedWidgetId(`jsx-${exportName}`)
642
- }}
1343
+ if (!e.target.closest('.tc-drag-handle')) {
1344
+ handleWidgetSelect(`jsx-${exportName}`, e.shiftKey)
1345
+ }
1346
+ } : undefined}
643
1347
  >
644
1348
  <WidgetChrome
1349
+ widgetId={`jsx-${exportName}`}
645
1350
  features={componentFeatures}
646
- selected={selectedWidgetId === `jsx-${exportName}`}
647
- onSelect={() => setSelectedWidgetId(`jsx-${exportName}`)}
648
- onDeselect={() => setSelectedWidgetId(null)}
1351
+ selected={selectedWidgetIds.has(`jsx-${exportName}`)}
1352
+ multiSelected={isMultiSelected && selectedWidgetIds.has(`jsx-${exportName}`)}
1353
+ onSelect={(shiftKey) => handleWidgetSelect(`jsx-${exportName}`, shiftKey)}
1354
+ onDeselect={() => setSelectedWidgetIds(new Set())}
1355
+ readOnly={!isLocalDev}
649
1356
  >
650
1357
  <ComponentWidget
651
1358
  component={Component}
652
1359
  width={sourceData.width}
653
1360
  height={sourceData.height}
654
- onUpdate={(updates) => handleSourceUpdate(exportName, updates)}
1361
+ onUpdate={isLocalDev ? (updates) => handleSourceUpdate(exportName, updates) : undefined}
1362
+ resizable={isResizable('component') && isLocalDev}
655
1363
  />
656
1364
  </WidgetChrome>
657
1365
  </div>
@@ -667,24 +1375,29 @@ export default function CanvasPage({ name }) {
667
1375
  id={widget.id}
668
1376
  data-tc-x={widget?.position?.x ?? 0}
669
1377
  data-tc-y={widget?.position?.y ?? 0}
670
- data-tc-handle=".tc-drag-handle"
1378
+ {...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle, .tc-drag-surface' } : {})}
671
1379
  {...canvasPrimerAttrs}
672
1380
  style={canvasThemeVars}
673
- onClick={(e) => {
1381
+ onClick={isLocalDev ? (e) => {
674
1382
  e.stopPropagation()
675
- setSelectedWidgetId(widget.id)
676
- }}
1383
+ if (!e.target.closest('.tc-drag-handle')) {
1384
+ handleWidgetSelect(widget.id, e.shiftKey)
1385
+ }
1386
+ } : undefined}
677
1387
  >
678
1388
  <ChromeWrappedWidget
679
1389
  widget={widget}
680
- selected={selectedWidgetId === widget.id}
681
- onSelect={() => setSelectedWidgetId(widget.id)}
682
- onDeselect={() => setSelectedWidgetId(null)}
683
- onUpdate={handleWidgetUpdate}
684
- onRemove={(id) => {
1390
+ selected={selectedWidgetIds.has(widget.id)}
1391
+ multiSelected={isMultiSelected && selectedWidgetIds.has(widget.id)}
1392
+ onSelect={(shiftKey) => handleWidgetSelect(widget.id, shiftKey)}
1393
+ onDeselect={() => setSelectedWidgetIds(new Set())}
1394
+ onUpdate={isLocalDev ? handleWidgetUpdate : undefined}
1395
+ onCopy={isLocalDev ? handleWidgetCopy : undefined}
1396
+ onRemove={isLocalDev ? (id) => {
685
1397
  handleWidgetRemove(id)
686
- setSelectedWidgetId(null)
687
- }}
1398
+ setSelectedWidgetIds(new Set())
1399
+ } : undefined}
1400
+ readOnly={!isLocalDev}
688
1401
  />
689
1402
  </div>
690
1403
  )
@@ -695,17 +1408,27 @@ export default function CanvasPage({ name }) {
695
1408
  return (
696
1409
  <>
697
1410
  <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
- />
1411
+ <div className={styles.canvasTitleWrap}>
1412
+ <span className={styles.canvasTitleMeasure} aria-hidden="true">{canvasTitle || ' '}</span>
1413
+ {isLocalDev ? (
1414
+ <input
1415
+ ref={titleInputRef}
1416
+ className={styles.canvasTitleInput}
1417
+ value={canvasTitle}
1418
+ size={1}
1419
+ onChange={handleTitleChange}
1420
+ onKeyDown={handleTitleKeyDown}
1421
+ onMouseDown={(e) => e.stopPropagation()}
1422
+ spellCheck={false}
1423
+ aria-label="Canvas title"
1424
+ />
1425
+ ) : (
1426
+ <h1 className={styles.canvasTitleStatic}>{canvasTitle}</h1>
1427
+ )}
1428
+ </div>
1429
+ {isLocalDev && (
1430
+ <span className={styles.localEditingLabel}>Local editing</span>
1431
+ )}
709
1432
  </div>
710
1433
  <div
711
1434
  ref={scrollRef}
@@ -717,7 +1440,7 @@ export default function CanvasPage({ name }) {
717
1440
  ...canvasThemeVars,
718
1441
  ...(spaceHeld ? { cursor: panningActive ? 'grabbing' : 'grab' } : {}),
719
1442
  }}
720
- onClick={() => setSelectedWidgetId(null)}
1443
+ onClick={() => setSelectedWidgetIds(new Set())}
721
1444
  onMouseDown={handlePanStart}
722
1445
  >
723
1446
  <div
@@ -732,7 +1455,7 @@ export default function CanvasPage({ name }) {
732
1455
  ...(spaceHeld ? { pointerEvents: 'none' } : {}),
733
1456
  }}
734
1457
  >
735
- <Canvas {...canvasProps} onDragEnd={handleItemDragEnd}>
1458
+ <Canvas {...canvasProps} onDragStart={isLocalDev ? handleItemDragStart : undefined} onDrag={isLocalDev ? handleItemDrag : undefined} onDragEnd={isLocalDev ? handleItemDragEnd : undefined}>
736
1459
  {allChildren}
737
1460
  </Canvas>
738
1461
  </div>