@dfosco/storyboard-react 4.0.0-beta.9 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/package.json +6 -3
  2. package/src/AuthModal/AuthModal.jsx +134 -0
  3. package/src/AuthModal/AuthModal.module.css +221 -0
  4. package/src/BranchBar/BranchBar.jsx +56 -0
  5. package/src/BranchBar/BranchBar.module.css +230 -0
  6. package/src/BranchBar/useBranches.js +79 -0
  7. package/src/CommandPalette/CommandPalette.jsx +936 -0
  8. package/src/CommandPalette/CreateDialog.jsx +219 -0
  9. package/src/CommandPalette/command-palette.css +111 -0
  10. package/src/Icon.jsx +180 -0
  11. package/src/Viewfinder.jsx +1104 -57
  12. package/src/Viewfinder.module.css +1107 -149
  13. package/src/canvas/CanvasControls.jsx +51 -2
  14. package/src/canvas/CanvasControls.module.css +31 -0
  15. package/src/canvas/CanvasPage.bridge.test.jsx +142 -19
  16. package/src/canvas/CanvasPage.dragdrop.test.jsx +346 -0
  17. package/src/canvas/CanvasPage.jsx +807 -251
  18. package/src/canvas/CanvasPage.module.css +98 -50
  19. package/src/canvas/CanvasPage.multiselect.test.jsx +13 -11
  20. package/src/canvas/CanvasToolbar.jsx +2 -2
  21. package/src/canvas/MarqueeOverlay.jsx +20 -0
  22. package/src/canvas/PageSelector.jsx +239 -0
  23. package/src/canvas/PageSelector.module.css +165 -0
  24. package/src/canvas/PageSelector.test.jsx +104 -0
  25. package/src/canvas/canvasApi.js +22 -8
  26. package/src/canvas/canvasTheme.js +96 -52
  27. package/src/canvas/componentIsolate.jsx +33 -7
  28. package/src/canvas/useCanvas.js +9 -8
  29. package/src/canvas/useCanvas.test.js +4 -4
  30. package/src/canvas/useMarqueeSelect.js +187 -0
  31. package/src/canvas/useMarqueeSelect.test.js +78 -0
  32. package/src/canvas/widgets/CodePenEmbed.jsx +292 -0
  33. package/src/canvas/widgets/CodePenEmbed.module.css +161 -0
  34. package/src/canvas/widgets/ComponentWidget.jsx +42 -10
  35. package/src/canvas/widgets/ComponentWidget.module.css +6 -5
  36. package/src/canvas/widgets/FigmaEmbed.jsx +110 -24
  37. package/src/canvas/widgets/FigmaEmbed.module.css +21 -7
  38. package/src/canvas/widgets/LinkPreview.jsx +297 -11
  39. package/src/canvas/widgets/LinkPreview.module.css +386 -18
  40. package/src/canvas/widgets/LinkPreview.test.jsx +193 -0
  41. package/src/canvas/widgets/MarkdownBlock.jsx +86 -5
  42. package/src/canvas/widgets/MarkdownBlock.module.css +64 -15
  43. package/src/canvas/widgets/PrototypeEmbed.jsx +96 -145
  44. package/src/canvas/widgets/PrototypeEmbed.module.css +74 -4
  45. package/src/canvas/widgets/StickyNote.module.css +5 -0
  46. package/src/canvas/widgets/StickyNote.test.jsx +9 -9
  47. package/src/canvas/widgets/StoryWidget.jsx +277 -0
  48. package/src/canvas/widgets/StoryWidget.module.css +211 -0
  49. package/src/canvas/widgets/WidgetChrome.jsx +76 -20
  50. package/src/canvas/widgets/WidgetChrome.module.css +2 -6
  51. package/src/canvas/widgets/WidgetWrapper.module.css +2 -0
  52. package/src/canvas/widgets/codepenUrl.js +75 -0
  53. package/src/canvas/widgets/codepenUrl.test.js +76 -0
  54. package/src/canvas/widgets/embedInteraction.test.jsx +235 -0
  55. package/src/canvas/widgets/embedOverlay.module.css +35 -0
  56. package/src/canvas/widgets/embedTheme.js +138 -39
  57. package/src/canvas/widgets/githubUrl.js +82 -0
  58. package/src/canvas/widgets/githubUrl.test.js +74 -0
  59. package/src/canvas/widgets/iframeDevLogs.js +49 -0
  60. package/src/canvas/widgets/iframeDevLogs.test.jsx +81 -0
  61. package/src/canvas/widgets/index.js +4 -0
  62. package/src/canvas/widgets/pasteRules.js +295 -0
  63. package/src/canvas/widgets/pasteRules.test.js +474 -0
  64. package/src/canvas/widgets/snapshotDisplay.test.jsx +259 -0
  65. package/src/canvas/widgets/widgetConfig.js +16 -5
  66. package/src/canvas/widgets/widgetConfig.test.js +34 -12
  67. package/src/context.jsx +145 -16
  68. package/src/hooks/useSceneData.js +4 -2
  69. package/src/hooks/useThemeState.js +61 -0
  70. package/src/hooks/useThemeState.test.js +66 -0
  71. package/src/index.js +10 -0
  72. package/src/story/StoryPage.jsx +117 -0
  73. package/src/story/StoryPage.module.css +18 -0
  74. package/src/vite/data-plugin.js +348 -66
  75. package/src/vite/data-plugin.test.js +405 -5
@@ -1,5 +1,4 @@
1
- import { createElement, useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
- import { flushSync } from 'react-dom'
1
+ import { createElement, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
3
2
  import { Canvas } from '@dfosco/tiny-canvas'
4
3
  import '@dfosco/tiny-canvas/style.css'
5
4
  import { useCanvas } from './useCanvas.js'
@@ -8,28 +7,58 @@ import { getCanvasThemeVars, getCanvasPrimerAttrs } from './canvasTheme.js'
8
7
  import { getWidgetComponent } from './widgets/index.js'
9
8
  import { schemas, getDefaults } from './widgets/widgetProps.js'
10
9
  import { getFeatures, isResizable } from './widgets/widgetConfig.js'
11
- import { isFigmaUrl, sanitizeFigmaUrl } from './widgets/figmaUrl.js'
10
+ import { createPasteContext, resolvePaste } from './widgets/pasteRules.js'
11
+ import { getPasteRules } from '@dfosco/storyboard-core'
12
+ import { registerSmoothCorners } from '@dfosco/storyboard-core/smooth-corners'
13
+ import { isGitHubEmbedUrl } from './widgets/githubUrl.js'
12
14
  import WidgetChrome from './widgets/WidgetChrome.jsx'
13
15
  import ComponentWidget from './widgets/ComponentWidget.jsx'
14
16
  import useUndoRedo from './useUndoRedo.js'
15
- import { addWidget as addWidgetApi, updateCanvas, removeWidget as removeWidgetApi, uploadImage } from './canvasApi.js'
17
+ import useMarqueeSelect from './useMarqueeSelect.js'
18
+ import MarqueeOverlay from './MarqueeOverlay.jsx'
19
+ import {
20
+ addWidget as addWidgetApi,
21
+ checkGitHubCliAvailable,
22
+ fetchGitHubEmbed,
23
+ getCanvas as getCanvasApi,
24
+ removeWidget as removeWidgetApi,
25
+ updateCanvas,
26
+ uploadImage,
27
+ } from './canvasApi.js'
28
+ import PageSelector from './PageSelector.jsx'
29
+ import Icon from '../Icon.jsx'
30
+ import { stories as storyIndex } from 'virtual:storyboard-data-index'
16
31
  import styles from './CanvasPage.module.css'
17
32
 
18
33
  const ZOOM_MIN = 25
19
34
  const ZOOM_MAX = 200
20
35
 
36
+ /** Saved viewport state older than this is considered stale — zoom-to-fit instead. */
37
+ const VIEWPORT_TTL_MS = 15 * 60 * 1000
38
+
21
39
  const CANVAS_BRIDGE_STATE_KEY = '__storyboardCanvasBridgeState'
40
+ const GH_INSTALL_URL = 'https://github.com/cli/cli'
41
+
42
+ registerSmoothCorners()
22
43
 
23
44
  /** Matches branch-deploy base path prefixes like /branch--my-feature/ */
24
45
  const BRANCH_PREFIX_RE = /^\/branch--[^/]+/
25
46
 
47
+ // Build a reverse map from story route paths → { storyId, route }
48
+ const storyRouteIndex = new Map()
49
+ for (const [storyId, data] of Object.entries(storyIndex || {})) {
50
+ if (data?._route) {
51
+ storyRouteIndex.set(data._route.replace(/\/+$/, ''), storyId)
52
+ }
53
+ }
54
+
26
55
  function getToolbarColorMode(theme) {
27
56
  return String(theme || 'light').startsWith('dark') ? 'dark' : 'light'
28
57
  }
29
58
 
30
59
  function resolveCanvasThemeFromStorage() {
31
60
  if (typeof localStorage === 'undefined') return 'light'
32
- let sync = { prototype: true, toolbar: false, codeBoxes: true, canvas: false }
61
+ let sync = { prototype: true, toolbar: false, codeBoxes: true, canvas: true }
33
62
  try {
34
63
  const rawSync = localStorage.getItem('sb-theme-sync')
35
64
  if (rawSync) sync = { ...sync, ...JSON.parse(rawSync) }
@@ -51,6 +80,7 @@ function resolveCanvasThemeFromStorage() {
51
80
  * Get the copyable URL for a widget based on its type.
52
81
  * Returns the most relevant URL/path for the widget content.
53
82
  */
83
+ // eslint-disable-next-line no-unused-vars
54
84
  function getWidgetCopyableUrl(widget) {
55
85
  const { type, props = {} } = widget
56
86
  const base = (typeof import.meta !== 'undefined' && import.meta.env?.BASE_URL) || '/'
@@ -91,15 +121,20 @@ function debounce(fn, ms) {
91
121
  }
92
122
 
93
123
  /** Per-canvas viewport state persistence (zoom + scroll position). */
94
- function getViewportStorageKey(canvasName) {
95
- return `sb-canvas-viewport:${canvasName}`
124
+ function getViewportStorageKey(canvasId) {
125
+ return `sb-canvas-viewport:${canvasId}`
96
126
  }
97
127
 
98
- function loadViewportState(canvasName) {
128
+ function loadViewportState(canvasId) {
99
129
  try {
100
- const raw = localStorage.getItem(getViewportStorageKey(canvasName))
130
+ const raw = localStorage.getItem(getViewportStorageKey(canvasId))
101
131
  if (!raw) return null
102
132
  const state = JSON.parse(raw)
133
+ const timestamp = typeof state.timestamp === 'number' ? state.timestamp : 0
134
+ if (Date.now() - timestamp > VIEWPORT_TTL_MS) {
135
+ localStorage.removeItem(getViewportStorageKey(canvasId))
136
+ return null
137
+ }
103
138
  return {
104
139
  zoom: typeof state.zoom === 'number' ? Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, state.zoom)) : null,
105
140
  scrollLeft: typeof state.scrollLeft === 'number' ? state.scrollLeft : null,
@@ -108,9 +143,12 @@ function loadViewportState(canvasName) {
108
143
  } catch { return null }
109
144
  }
110
145
 
111
- function saveViewportState(canvasName, state) {
146
+ function saveViewportState(canvasId, state) {
112
147
  try {
113
- localStorage.setItem(getViewportStorageKey(canvasName), JSON.stringify(state))
148
+ localStorage.setItem(getViewportStorageKey(canvasId), JSON.stringify({
149
+ ...state,
150
+ timestamp: Date.now(),
151
+ }))
114
152
  } catch { /* quota exceeded — non-critical */ }
115
153
  }
116
154
 
@@ -226,7 +264,7 @@ function computeCanvasBounds(widgets, componentEntries) {
226
264
  }
227
265
 
228
266
  /** Renders a single JSON-defined widget by type lookup. */
229
- function WidgetRenderer({ widget, onUpdate, widgetRef }) {
267
+ function WidgetRenderer({ widget, onUpdate, widgetRef, onRefreshGitHub, canRefreshGitHub }) {
230
268
  const Component = getWidgetComponent(widget.type)
231
269
  if (!Component) {
232
270
  console.warn(`[canvas] Unknown widget type: ${widget.type}`)
@@ -234,7 +272,14 @@ function WidgetRenderer({ widget, onUpdate, widgetRef }) {
234
272
  }
235
273
  const resizable = isResizable(widget.type) && !!onUpdate
236
274
  // Only pass ref to forwardRef-wrapped components (e.g. PrototypeEmbed)
237
- const elementProps = { id: widget.id, props: widget.props, onUpdate, resizable }
275
+ const elementProps = {
276
+ id: widget.id,
277
+ props: widget.props,
278
+ onUpdate,
279
+ resizable,
280
+ onRefreshGitHub,
281
+ canRefreshGitHub,
282
+ }
238
283
  if (Component.$$typeof === Symbol.for('react.forward_ref')) {
239
284
  elementProps.ref = widgetRef
240
285
  }
@@ -244,8 +289,10 @@ function WidgetRenderer({ widget, onUpdate, widgetRef }) {
244
289
  /**
245
290
  * Wrapper for each JSON widget that holds its own ref for imperative actions.
246
291
  * This allows WidgetChrome to dispatch actions to the widget via ref.
292
+ *
293
+ * Memoized to prevent re-renders during zoom and unrelated state changes.
247
294
  */
248
- function ChromeWrappedWidget({
295
+ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
249
296
  widget,
250
297
  selected,
251
298
  multiSelected,
@@ -254,18 +301,64 @@ function ChromeWrappedWidget({
254
301
  onUpdate,
255
302
  onRemove,
256
303
  onCopy,
304
+ onRefreshGitHub,
305
+ canRefreshGitHub,
257
306
  readOnly,
258
307
  }) {
259
308
  const widgetRef = useRef(null)
260
- const features = getFeatures(widget.type)
309
+ const rawFeatures = getFeatures(widget.type, { isLocalDev: !readOnly })
310
+
311
+ // Dynamically adjust features based on widget state
312
+ const features = useMemo(() => {
313
+ const isGitHub = !!widget.props?.github
314
+ return rawFeatures.map((f) => {
315
+ // Toggle collapse label and hide when content is short (no github = no collapse)
316
+ if (f.action === 'toggle-collapse') {
317
+ if (!isGitHub) return null
318
+ return {
319
+ ...f,
320
+ label: widget.props?.collapsed ? 'Expand height' : 'Collapse height',
321
+ icon: widget.props?.collapsed ? 'unfold' : 'fold',
322
+ }
323
+ }
324
+ // Hide refresh-github for non-GitHub link previews
325
+ if (f.action === 'refresh-github' && !isGitHub) return null
326
+ return f
327
+ }).filter(Boolean)
328
+ }, [rawFeatures, widget.props?.github, widget.props?.collapsed])
261
329
 
262
330
  const handleAction = useCallback((actionId) => {
263
331
  if (actionId === 'delete') {
264
332
  onRemove?.(widget.id)
265
333
  } else if (actionId === 'copy') {
266
334
  onCopy?.(widget)
335
+ } else if (actionId === 'copy-text') {
336
+ const title = widget.props?.title || ''
337
+ const body = widget.props?.text || widget.props?.content || widget.props?.github?.body || ''
338
+ const text = title && body ? `# ${title}\n\n${body}` : title || body
339
+ navigator.clipboard?.writeText(text).catch(() => {})
340
+ } else if (actionId === 'open-external') {
341
+ const url = widget.props?.url || widget.props?.src
342
+ if (url) window.open(url, '_blank', 'noopener,noreferrer')
343
+ } else if (actionId === 'refresh-github') {
344
+ const url = widget.props?.url
345
+ if (url && onRefreshGitHub) onRefreshGitHub(widget.id, url)
346
+ } else if (actionId === 'toggle-collapse') {
347
+ const wasCollapsed = !!widget.props?.collapsed
348
+ onUpdate?.(widget.id, { collapsed: !wasCollapsed })
349
+ // When collapsing, pan viewport to center the widget
350
+ if (!wasCollapsed) {
351
+ requestAnimationFrame(() => {
352
+ const el = document.getElementById(widget.id)
353
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
354
+ })
355
+ }
267
356
  }
268
- }, [widget, onRemove, onCopy])
357
+ }, [widget, onRemove, onCopy, onRefreshGitHub])
358
+
359
+ const handleWidgetFieldUpdate = useCallback((updates) => {
360
+ onUpdate?.(widget.id, updates)
361
+ }, [onUpdate, widget.id])
269
362
 
270
363
  return (
271
364
  <WidgetChrome
@@ -279,43 +372,68 @@ function ChromeWrappedWidget({
279
372
  onSelect={onSelect}
280
373
  onDeselect={onDeselect}
281
374
  onAction={handleAction}
282
- onUpdate={onUpdate ? (updates) => onUpdate(widget.id, updates) : undefined}
375
+ onUpdate={onUpdate ? handleWidgetFieldUpdate : undefined}
283
376
  readOnly={readOnly}
284
377
  >
285
378
  <WidgetRenderer
286
379
  widget={widget}
287
- onUpdate={onUpdate ? (updates) => onUpdate(widget.id, updates) : undefined}
380
+ onUpdate={onUpdate ? handleWidgetFieldUpdate : undefined}
288
381
  widgetRef={widgetRef}
382
+ onRefreshGitHub={onRefreshGitHub}
383
+ canRefreshGitHub={canRefreshGitHub}
289
384
  />
290
385
  </WidgetChrome>
291
386
  )
292
- }
387
+ }, function chromeWidgetAreEqual(prev, next) {
388
+ return (
389
+ prev.widget === next.widget &&
390
+ prev.selected === next.selected &&
391
+ prev.multiSelected === next.multiSelected &&
392
+ prev.readOnly === next.readOnly &&
393
+ prev.onSelect === next.onSelect &&
394
+ prev.onDeselect === next.onDeselect &&
395
+ prev.onUpdate === next.onUpdate &&
396
+ prev.onRemove === next.onRemove &&
397
+ prev.onCopy === next.onCopy
398
+ )
399
+ })
293
400
 
294
401
  /**
295
402
  * Generic canvas page component.
296
403
  * Reads canvas data from the index and renders all widgets on a draggable surface.
297
404
  *
298
- * @param {{ name: string }} props - Canvas name as indexed by the data plugin
405
+ * @param {{ canvasId: string }} props - Canvas name as indexed by the data plugin
299
406
  */
300
- export default function CanvasPage({ name }) {
301
- const { canvas, jsxExports, jsxError, loading } = useCanvas(name)
407
+ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages = [], canvasMeta = null }) {
408
+ const canvasId = canvasIdProp || name || ''
409
+ const { canvas, jsxExports, jsxError, loading } = useCanvas(canvasId)
302
410
  const isLocalDev = typeof window !== 'undefined' && window.__SB_LOCAL_DEV__ === true && !new URLSearchParams(window.location.search).has('prodMode')
303
411
 
304
412
  // Local mutable copy of widgets for instant UI updates
305
413
  const [localWidgets, setLocalWidgets] = useState(canvas?.widgets ?? null)
306
414
  const [trackedCanvas, setTrackedCanvas] = useState(canvas)
307
415
  const [selectedWidgetIds, setSelectedWidgetIds] = useState(() => new Set())
308
- const initialViewport = loadViewportState(name)
416
+ const initialViewport = loadViewportState(canvasId)
309
417
  const [zoom, setZoom] = useState(initialViewport?.zoom ?? 100)
310
418
  const zoomRef = useRef(initialViewport?.zoom ?? 100)
311
419
  const scrollRef = useRef(null)
420
+ const zoomElRef = useRef(null)
421
+ const zoomCommitTimer = useRef(null)
422
+ const zoomEventTimer = useRef(null)
312
423
  const pendingScrollRestore = useRef(initialViewport)
313
- const [canvasTitle, setCanvasTitle] = useState(canvas?.title || name)
314
- const titleInputRef = useRef(null)
424
+ // Gate viewport persistence until initial positioning is complete.
425
+ // Tracks which canvasId was last initialized — save effects only
426
+ // write when this matches `canvasId`, preventing cross-canvas corruption.
427
+ const viewportInitName = useRef(null)
315
428
  const [localSources, setLocalSources] = useState(canvas?.sources ?? [])
316
429
  const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
317
430
  const [snapEnabled, setSnapEnabled] = useState(canvas?.snapToGrid ?? false)
318
431
  const [snapGridSize, setSnapGridSize] = useState(canvas?.gridSize || 40)
432
+ const [showGhInstallBanner, setShowGhInstallBanner] = useState(false)
433
+
434
+ // Refs for snap settings (used by drop handler inside effect closure)
435
+ const snapEnabledRef = useRef(snapEnabled)
436
+ const snapGridSizeRef = useRef(snapGridSize)
319
437
 
320
438
  // Centralized list of component export names.
321
439
  // When jsxExports is available, use it (discovers new exports not yet in sources).
@@ -400,13 +518,13 @@ export default function CanvasPage({ name }) {
400
518
  // Flag to suppress the click-based selection reset that fires after a drag
401
519
  const justDraggedRef = useRef(false)
402
520
 
403
- const handleItemDragStart = useCallback((dragId, position) => {
521
+ const handleItemDragStart = useCallback((dragId) => {
404
522
  const ids = selectedIdsRef.current
405
523
  peerArticlesRef.current.clear()
406
524
  if (ids.size <= 1 || !ids.has(dragId)) return
407
525
 
408
526
  // Suppress selection changes for the duration of the drag
409
- justDraggedRef.current = true
527
+ justDraggedRef.current = true // eslint-disable-line react-hooks/immutability
410
528
 
411
529
  // Collect peer article elements for transition on drag end
412
530
  for (const id of ids) {
@@ -445,40 +563,28 @@ export default function CanvasPage({ name }) {
445
563
  setTrackedCanvas(canvas)
446
564
  setLocalWidgets(canvas?.widgets ?? null)
447
565
  setLocalSources(canvas?.sources ?? [])
448
- setCanvasTitle(canvas?.title || name)
566
+ setSnapEnabled(canvas?.snapToGrid ?? false)
567
+ setSnapGridSize(canvas?.gridSize || 40)
449
568
  undoRedo.reset()
569
+ // Block saves until the new canvas's viewport is fully restored.
570
+ viewportInitName.current = null
571
+ const newViewport = loadViewportState(canvasId)
572
+ pendingScrollRestore.current = newViewport
573
+ // Restore zoom from the new canvas's saved state
574
+ const newZoom = newViewport?.zoom ?? 100
575
+ zoomRef.current = newZoom
576
+ setZoom(newZoom)
450
577
  }
451
578
 
452
579
  // Debounced save to server
453
580
  const debouncedSave = useRef(
454
- debounce((canvasName, widgets) => {
455
- updateCanvas(canvasName, { widgets }).catch((err) =>
581
+ debounce((canvasId, widgets) => {
582
+ updateCanvas(canvasId, { widgets }).catch((err) =>
456
583
  console.error('[canvas] Failed to save:', err)
457
584
  )
458
585
  }, 2000)
459
586
  ).current
460
587
 
461
- const debouncedTitleSave = useRef(
462
- debounce((canvasName, title) => {
463
- updateCanvas(canvasName, { settings: { title } }).catch((err) =>
464
- console.error('[canvas] Failed to save title:', err)
465
- )
466
- }, 1000)
467
- ).current
468
-
469
- const handleTitleChange = useCallback((e) => {
470
- const newTitle = e.target.value
471
- setCanvasTitle(newTitle)
472
- debouncedTitleSave(name, newTitle)
473
- }, [name, debouncedTitleSave])
474
-
475
- const handleTitleKeyDown = useCallback((e) => {
476
- if (e.key === 'Enter') {
477
- e.target.blur()
478
- }
479
- e.stopPropagation()
480
- }, [])
481
-
482
588
  const handleWidgetUpdate = useCallback((widgetId, updates) => {
483
589
  undoRedo.snapshot(stateRef.current, 'edit', widgetId)
484
590
  // Snap width/height to grid when snap is enabled
@@ -492,20 +598,20 @@ export default function CanvasPage({ name }) {
492
598
  const next = prev.map((w) =>
493
599
  w.id === widgetId ? { ...w, props: { ...w.props, ...snapped } } : w
494
600
  )
495
- debouncedSave(name, next)
601
+ debouncedSave(canvasId, next)
496
602
  return next
497
603
  })
498
- }, [name, debouncedSave, undoRedo, snapEnabled, snapGridSize])
604
+ }, [canvasId, debouncedSave, undoRedo, snapEnabled, snapGridSize])
499
605
 
500
606
  const handleWidgetRemove = useCallback((widgetId) => {
501
607
  undoRedo.snapshot(stateRef.current, 'remove', widgetId)
502
608
  setLocalWidgets((prev) => prev ? prev.filter((w) => w.id !== widgetId) : prev)
503
609
  queueWrite(() =>
504
- removeWidgetApi(name, widgetId).catch((err) =>
610
+ removeWidgetApi(canvasId, widgetId).catch((err) =>
505
611
  console.error('[canvas] Failed to remove widget:', err)
506
612
  )
507
613
  )
508
- }, [name, undoRedo])
614
+ }, [canvasId, undoRedo])
509
615
 
510
616
  const handleWidgetCopy = useCallback(async (widget) => {
511
617
  // Find the next free offset — check how many copies already exist at +n*40
@@ -521,7 +627,7 @@ export default function CanvasPage({ name }) {
521
627
  const position = { x: baseX + n * 40, y: baseY + n * 40 }
522
628
  try {
523
629
  undoRedo.snapshot(stateRef.current, 'add')
524
- const result = await addWidgetApi(name, {
630
+ const result = await addWidgetApi(canvasId, {
525
631
  type: widget.type,
526
632
  props: { ...widget.props },
527
633
  position,
@@ -532,11 +638,63 @@ export default function CanvasPage({ name }) {
532
638
  } catch (err) {
533
639
  console.error('[canvas] Failed to copy widget:', err)
534
640
  }
535
- }, [name, localWidgets, undoRedo])
641
+ }, [canvasId, localWidgets, undoRedo])
642
+
643
+ const showMissingGhBanner = useCallback(() => {
644
+ setShowGhInstallBanner(true)
645
+ }, [])
646
+
647
+ const buildGitHubPreviewUpdates = useCallback(async (url) => {
648
+ try {
649
+ const availability = await checkGitHubCliAvailable()
650
+ if (!availability?.available) {
651
+ showMissingGhBanner()
652
+ return null
653
+ }
654
+
655
+ const result = await fetchGitHubEmbed(url)
656
+ if (result?.code === 'gh_unavailable') {
657
+ showMissingGhBanner()
658
+ return null
659
+ }
660
+ if (!result?.success || !result?.snapshot) return null
661
+
662
+ const snapshot = result.snapshot
663
+ return {
664
+ title: snapshot.title || '',
665
+ width: 580,
666
+ height: 400,
667
+ github: {
668
+ kind: snapshot.kind || 'issue',
669
+ parentKind: snapshot.parentKind || snapshot.kind || 'issue',
670
+ context: snapshot.context || '',
671
+ body: snapshot.body || '',
672
+ bodyHtml: snapshot.bodyHtml || '',
673
+ authors: Array.isArray(snapshot.authors)
674
+ ? snapshot.authors.filter((author) => typeof author === 'string' && author.trim())
675
+ : [],
676
+ createdAt: snapshot.createdAt ?? null,
677
+ updatedAt: snapshot.updatedAt ?? null,
678
+ fetchedAt: new Date().toISOString(),
679
+ },
680
+ }
681
+ } catch (err) {
682
+ console.error('[canvas] Failed to fetch GitHub embed metadata:', err)
683
+ return null
684
+ }
685
+ }, [showMissingGhBanner])
686
+
687
+ const handleRefreshGitHubWidget = useCallback(async (widgetId, url) => {
688
+ if (!widgetId || !url) return { updated: false }
689
+ const updates = await buildGitHubPreviewUpdates(url)
690
+ if (!updates) return { updated: false }
691
+ handleWidgetUpdate(widgetId, updates)
692
+ return { updated: true }
693
+ }, [buildGitHubPreviewUpdates, handleWidgetUpdate])
536
694
 
537
695
  const debouncedSourceSave = useRef(
538
- debounce((canvasName, sources) => {
539
- updateCanvas(canvasName, { sources }).catch((err) =>
696
+ debounce((canvasId, sources) => {
697
+ updateCanvas(canvasId, { sources }).catch((err) =>
540
698
  console.error('[canvas] Failed to save sources:', err)
541
699
  )
542
700
  }, 2000)
@@ -554,10 +712,10 @@ export default function CanvasPage({ name }) {
554
712
  const next = current.some((s) => s?.export === exportName)
555
713
  ? current.map((s) => (s?.export === exportName ? { ...s, ...snapped } : s))
556
714
  : [...current, { export: exportName, ...snapped }]
557
- debouncedSourceSave(name, next)
715
+ debouncedSourceSave(canvasId, next)
558
716
  return next
559
717
  })
560
- }, [name, debouncedSourceSave, undoRedo, snapEnabled, snapGridSize])
718
+ }, [canvasId, debouncedSourceSave, undoRedo, snapEnabled, snapGridSize])
561
719
 
562
720
  const handleItemDragEnd = useCallback((dragId, position) => {
563
721
  if (!dragId || !position) {
@@ -572,7 +730,7 @@ export default function CanvasPage({ name }) {
572
730
  if (ids.size > 1 && ids.has(dragId)) {
573
731
  transitionPeers()
574
732
  // Suppress the click-based selection reset that fires after pointerup
575
- justDraggedRef.current = true
733
+ justDraggedRef.current = true // eslint-disable-line react-hooks/immutability
576
734
  requestAnimationFrame(() => { justDraggedRef.current = false })
577
735
  undoRedo.snapshot(stateRef.current, 'multi-move')
578
736
 
@@ -609,7 +767,7 @@ export default function CanvasPage({ name }) {
609
767
  return w
610
768
  })
611
769
  queueWrite(() =>
612
- updateCanvas(name, { widgets: next }).catch((err) =>
770
+ updateCanvas(canvasId, { widgets: next }).catch((err) =>
613
771
  console.error('[canvas] Failed to save multi-move:', err)
614
772
  )
615
773
  )
@@ -641,7 +799,7 @@ export default function CanvasPage({ name }) {
641
799
  })
642
800
  if (changed) {
643
801
  queueWrite(() =>
644
- updateCanvas(name, { sources: next }).catch((err) =>
802
+ updateCanvas(canvasId, { sources: next }).catch((err) =>
645
803
  console.error('[canvas] Failed to save multi-move sources:', err)
646
804
  )
647
805
  )
@@ -660,7 +818,7 @@ export default function CanvasPage({ name }) {
660
818
  ? current.map((s) => (s?.export === sourceExport ? { ...s, position: rounded } : s))
661
819
  : [...current, { export: sourceExport, position: rounded }]
662
820
  queueWrite(() =>
663
- updateCanvas(name, { sources: next }).catch((err) =>
821
+ updateCanvas(canvasId, { sources: next }).catch((err) =>
664
822
  console.error('[canvas] Failed to save source position:', err)
665
823
  )
666
824
  )
@@ -676,28 +834,65 @@ export default function CanvasPage({ name }) {
676
834
  w.id === dragId ? { ...w, position: rounded } : w
677
835
  )
678
836
  queueWrite(() =>
679
- updateCanvas(name, { widgets: next }).catch((err) =>
837
+ updateCanvas(canvasId, { widgets: next }).catch((err) =>
680
838
  console.error('[canvas] Failed to save widget position:', err)
681
839
  )
682
840
  )
683
841
  return next
684
842
  })
685
- }, [name, undoRedo, debouncedSave, transitionPeers, clearDragPreview])
843
+ }, [canvasId, undoRedo, debouncedSave, transitionPeers, clearDragPreview])
686
844
 
845
+ // Keep zoomRef in sync when React state is set (e.g. by toolbar or zoom-to-fit)
687
846
  useEffect(() => {
688
847
  zoomRef.current = zoom
689
848
  }, [zoom])
690
849
 
691
- // Restore scroll position from localStorage after first render
850
+ // Cleanup zoom timers on unmount
851
+ useEffect(() => () => {
852
+ clearTimeout(zoomCommitTimer.current)
853
+ clearTimeout(zoomEventTimer.current)
854
+ }, [])
855
+
856
+ // Restore scroll position from localStorage after first render.
857
+ // When saved state is fresh (< 15 min), restore it. Otherwise zoom-to-fit
858
+ // all objects so the user sees a useful overview instead of stale coordinates.
692
859
  useEffect(() => {
693
860
  const el = scrollRef.current
861
+ if (!el || loading) return
694
862
  const saved = pendingScrollRestore.current
695
- if (el && saved) {
863
+ if (saved) {
864
+ // Fresh saved viewport — restore exactly
696
865
  if (saved.scrollLeft != null) el.scrollLeft = saved.scrollLeft
697
866
  if (saved.scrollTop != null) el.scrollTop = saved.scrollTop
698
867
  pendingScrollRestore.current = null
868
+ } else {
869
+ // No saved state or stale — zoom-to-fit all objects
870
+ const bounds = computeCanvasBounds(localWidgets, componentEntries)
871
+ if (bounds && el.clientWidth > 0 && el.clientHeight > 0) {
872
+ const boxW = bounds.maxX - bounds.minX + FIT_PADDING * 2
873
+ const boxH = bounds.maxY - bounds.minY + FIT_PADDING * 2
874
+ const fitScale = Math.min(el.clientWidth / boxW, el.clientHeight / boxH)
875
+ const fitZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(fitScale * 100)))
876
+ const newScale = fitZoom / 100
877
+ zoomRef.current = fitZoom
878
+ // Imperative DOM update for initial zoom-to-fit — same path as applyZoom
879
+ const zoomEl = zoomElRef.current
880
+ if (zoomEl) {
881
+ zoomEl.style.transform = `scale(${newScale})`
882
+ zoomEl.style.width = `${Math.max(10000, 100 / newScale)}vw`
883
+ zoomEl.style.height = `${Math.max(10000, 100 / newScale)}vh`
884
+ }
885
+ setZoom(fitZoom)
886
+ el.scrollLeft = (bounds.minX - FIT_PADDING) * newScale
887
+ el.scrollTop = (bounds.minY - FIT_PADDING) * newScale
888
+ } else {
889
+ el.scrollLeft = 0
890
+ el.scrollTop = 0
891
+ }
699
892
  }
700
- }, [name, loading])
893
+ // Allow save effects for this canvas now that positioning is settled.
894
+ viewportInitName.current = canvasId
895
+ }, [canvasId, loading])
701
896
 
702
897
  // Center on a specific widget if `?widget=<id>` is in the URL
703
898
  useEffect(() => {
@@ -746,55 +941,76 @@ export default function CanvasPage({ name }) {
746
941
  window.history.replaceState({}, '', url.toString())
747
942
  }, [loading, localWidgets, componentEntries])
748
943
 
749
- // Persist viewport state (zoom + scroll) to localStorage on changes
944
+ // Persist viewport state (zoom only) to localStorage on zoom changes.
945
+ // Scroll position is persisted separately by the debounced scroll handler,
946
+ // cleanup handler, and beforeunload — never here, because imperative zoom
947
+ // operations (applyZoom, zoom-to-fit) adjust scroll AFTER setZoom, so the
948
+ // scroll values would be stale at this point.
750
949
  useEffect(() => {
950
+ if (viewportInitName.current !== canvasId) return
751
951
  const el = scrollRef.current
752
- saveViewportState(name, {
952
+ // Read current scroll so the zoom entry doesn't zero-out position,
953
+ // but the authoritative scroll save comes from the scroll handler.
954
+ saveViewportState(canvasId, {
753
955
  zoom,
754
956
  scrollLeft: el?.scrollLeft ?? 0,
755
957
  scrollTop: el?.scrollTop ?? 0,
756
958
  })
757
- }, [name, zoom])
959
+ }, [canvasId, zoom])
758
960
 
759
961
  useEffect(() => {
760
962
  const el = scrollRef.current
761
963
  if (!el) return
762
- function handleScroll() {
763
- saveViewportState(name, {
964
+ const saveNow = () => {
965
+ if (viewportInitName.current !== canvasId) return
966
+ saveViewportState(canvasId, {
764
967
  zoom: zoomRef.current,
765
968
  scrollLeft: el.scrollLeft,
766
969
  scrollTop: el.scrollTop,
767
970
  })
768
971
  }
972
+ const debouncedScrollSave = debounce(saveNow, 150)
973
+ function handleScroll() {
974
+ if (viewportInitName.current !== canvasId) return
975
+ debouncedScrollSave()
976
+ }
769
977
  el.addEventListener('scroll', handleScroll, { passive: true })
770
978
 
771
979
  // Flush viewport state on page unload so a refresh never misses it
772
980
  function handleBeforeUnload() {
773
- saveViewportState(name, {
774
- zoom: zoomRef.current,
775
- scrollLeft: el.scrollLeft,
776
- scrollTop: el.scrollTop,
777
- })
981
+ debouncedScrollSave.cancel()
982
+ saveNow()
778
983
  }
779
984
  window.addEventListener('beforeunload', handleBeforeUnload)
780
985
 
781
986
  return () => {
987
+ debouncedScrollSave.cancel()
782
988
  el.removeEventListener('scroll', handleScroll)
783
989
  window.removeEventListener('beforeunload', handleBeforeUnload)
990
+ // Save final state on cleanup (covers SPA navigation where
991
+ // beforeunload doesn't fire).
992
+ saveNow()
784
993
  }
785
- }, [name, loading])
994
+ }, [canvasId, loading])
786
995
 
787
996
  /**
788
997
  * Zoom to a new level, anchoring on an optional client-space point.
789
998
  * When a cursor position is provided (e.g. from a wheel event), the
790
999
  * canvas point under the cursor stays fixed. Otherwise falls back to
791
1000
  * the viewport center.
1001
+ *
1002
+ * Performs an imperative DOM mutation instead of a React state update
1003
+ * to avoid triggering a full re-render of the widget tree on every
1004
+ * zoom tick. React state is committed after a debounce for toolbar
1005
+ * display updates.
792
1006
  */
793
1007
  function applyZoom(newZoom, clientX, clientY) {
794
1008
  const el = scrollRef.current
1009
+ const zoomEl = zoomElRef.current
795
1010
  const clampedZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, newZoom))
796
1011
 
797
- if (!el) {
1012
+ if (!el || !zoomEl) {
1013
+ zoomRef.current = clampedZoom
798
1014
  setZoom(clampedZoom)
799
1015
  return
800
1016
  }
@@ -812,24 +1028,48 @@ export default function CanvasPage({ name }) {
812
1028
  const canvasX = (el.scrollLeft + anchorX) / oldScale
813
1029
  const canvasY = (el.scrollTop + anchorY) / oldScale
814
1030
 
815
- // Synchronous render so the DOM has the new transform before we adjust scroll
1031
+ // Imperative DOM update no React re-render
816
1032
  zoomRef.current = clampedZoom
817
- flushSync(() => setZoom(clampedZoom))
1033
+ zoomEl.style.transform = `scale(${newScale})`
1034
+ zoomEl.style.width = `${Math.max(10000, 100 / newScale)}vw`
1035
+ zoomEl.style.height = `${Math.max(10000, 100 / newScale)}vh`
1036
+
1037
+ // Hint GPU compositing during active zoom
1038
+ zoomEl.dataset.zooming = ''
818
1039
 
819
1040
  // Scroll so the same canvas point stays under the anchor
820
1041
  el.scrollLeft = canvasX * newScale - anchorX
821
1042
  el.scrollTop = canvasY * newScale - anchorY
1043
+
1044
+ // Debounced commit: update React state for toolbar display + persistence
1045
+ clearTimeout(zoomCommitTimer.current)
1046
+ zoomCommitTimer.current = setTimeout(() => {
1047
+ // Remove GPU compositing hint
1048
+ delete zoomEl.dataset.zooming
1049
+ setZoom(clampedZoom)
1050
+ }, 150)
1051
+
1052
+ // Throttled zoom-changed event for external consumers (toolbar)
1053
+ if (!zoomEventTimer.current) {
1054
+ zoomEventTimer.current = setTimeout(() => {
1055
+ zoomEventTimer.current = null
1056
+ window[CANVAS_BRIDGE_STATE_KEY] = { active: true, canvasId, zoom: zoomRef.current }
1057
+ document.dispatchEvent(new CustomEvent('storyboard:canvas:zoom-changed', {
1058
+ detail: { zoom: zoomRef.current }
1059
+ }))
1060
+ }, 100)
1061
+ }
822
1062
  }
823
1063
 
824
1064
  // Signal canvas mount/unmount to CoreUIBar
825
1065
  useEffect(() => {
826
- window[CANVAS_BRIDGE_STATE_KEY] = { active: true, name, zoom: zoomRef.current }
1066
+ window[CANVAS_BRIDGE_STATE_KEY] = { active: true, canvasId, zoom: zoomRef.current }
827
1067
  document.dispatchEvent(new CustomEvent('storyboard:canvas:mounted', {
828
- detail: { name, zoom: zoomRef.current }
1068
+ detail: { canvasId, zoom: zoomRef.current }
829
1069
  }))
830
1070
 
831
1071
  function handleStatusRequest() {
832
- const state = window[CANVAS_BRIDGE_STATE_KEY] || { active: true, name, zoom: zoomRef.current }
1072
+ const state = window[CANVAS_BRIDGE_STATE_KEY] || { active: true, canvasId, zoom: zoomRef.current }
833
1073
  document.dispatchEvent(new CustomEvent('storyboard:canvas:status', { detail: state }))
834
1074
  }
835
1075
 
@@ -837,10 +1077,10 @@ export default function CanvasPage({ name }) {
837
1077
 
838
1078
  return () => {
839
1079
  document.removeEventListener('storyboard:canvas:status-request', handleStatusRequest)
840
- window[CANVAS_BRIDGE_STATE_KEY] = { active: false, name: '', zoom: 100 }
1080
+ window[CANVAS_BRIDGE_STATE_KEY] = { active: false, canvasId: '', zoom: 100 }
841
1081
  document.dispatchEvent(new CustomEvent('storyboard:canvas:unmounted'))
842
1082
  }
843
- }, [name])
1083
+ }, [canvasId])
844
1084
 
845
1085
  // Tell the Vite dev server to suppress full-reloads while this canvas is active.
846
1086
  // The ?canvas-hmr URL param opts out of the guard for canvas UI development.
@@ -860,7 +1100,70 @@ export default function CanvasPage({ name }) {
860
1100
  clearInterval(interval)
861
1101
  import.meta.hot.send('storyboard:canvas-hmr-guard', { active: false, hmrEnabled: true })
862
1102
  }
863
- }, [name])
1103
+ }, [canvasId])
1104
+
1105
+ // --- Selected widgets bridge ---
1106
+ // Writes .selectedwidgets.json so Copilot knows which canvas/widgets are active.
1107
+ // Uses a stable tabId to survive WebSocket reconnects.
1108
+ const selectionTabIdRef = useRef(Math.random().toString(36).slice(2, 10))
1109
+
1110
+ // Gather selected widget data from refs (safe for callbacks/timeouts)
1111
+ const getSelectedWidgetData = useCallback(() => {
1112
+ const ids = [...selectedIdsRef.current]
1113
+ const widgets = (stateRef.current.widgets || [])
1114
+ .filter(w => ids.includes(w.id))
1115
+ .map(w => ({ id: w.id, type: w.type, props: w.props }))
1116
+
1117
+ // Include jsx-* component selections
1118
+ for (const id of ids) {
1119
+ if (id.startsWith('jsx-') && !widgets.some(w => w.id === id)) {
1120
+ widgets.push({ id, type: 'component', props: { exportName: id.slice(4) } })
1121
+ }
1122
+ }
1123
+
1124
+ return { widgetIds: ids, widgets }
1125
+ }, [])
1126
+
1127
+ // Send focus event on mount, tab focus, and visibility change
1128
+ useEffect(() => {
1129
+ if (!import.meta.hot) return
1130
+
1131
+ const tabId = selectionTabIdRef.current
1132
+
1133
+ function sendFocus() {
1134
+ const { widgetIds, widgets } = getSelectedWidgetData()
1135
+ import.meta.hot.send('storyboard:canvas-focused', { tabId, canvasId, widgetIds, widgets })
1136
+ }
1137
+
1138
+ sendFocus()
1139
+
1140
+ function handleVisibility() {
1141
+ if (!document.hidden) sendFocus()
1142
+ }
1143
+ function handleFocus() { sendFocus() }
1144
+
1145
+ document.addEventListener('visibilitychange', handleVisibility)
1146
+ window.addEventListener('focus', handleFocus)
1147
+
1148
+ return () => {
1149
+ document.removeEventListener('visibilitychange', handleVisibility)
1150
+ window.removeEventListener('focus', handleFocus)
1151
+ import.meta.hot.send('storyboard:canvas-unfocused', { tabId })
1152
+ }
1153
+ }, [canvasId, getSelectedWidgetData])
1154
+
1155
+ // Debounced selection change (500ms) — reads from refs at fire time
1156
+ useEffect(() => {
1157
+ if (!import.meta.hot) return
1158
+
1159
+ const tabId = selectionTabIdRef.current
1160
+ const timer = setTimeout(() => {
1161
+ const { widgetIds, widgets } = getSelectedWidgetData()
1162
+ import.meta.hot.send('storyboard:selection-changed', { tabId, canvasId, widgetIds: widgetIds, widgets })
1163
+ }, 500)
1164
+
1165
+ return () => clearTimeout(timer)
1166
+ }, [selectedWidgetIds, canvasId, getSelectedWidgetData])
864
1167
 
865
1168
  // Add a widget by type — used by CanvasControls and CoreUIBar event
866
1169
  const addWidget = useCallback(async (type) => {
@@ -868,7 +1171,7 @@ export default function CanvasPage({ name }) {
868
1171
  const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
869
1172
  const pos = centerPositionForWidget(center, type, defaultProps)
870
1173
  try {
871
- const result = await addWidgetApi(name, {
1174
+ const result = await addWidgetApi(canvasId, {
872
1175
  type,
873
1176
  props: defaultProps,
874
1177
  position: pos,
@@ -880,16 +1183,43 @@ export default function CanvasPage({ name }) {
880
1183
  } catch (err) {
881
1184
  console.error('[canvas] Failed to add widget:', err)
882
1185
  }
883
- }, [name, undoRedo])
1186
+ }, [canvasId, undoRedo])
1187
+
1188
+ // Add a story widget by storyId — used by CanvasControls story picker
1189
+ const addStoryWidget = useCallback(async (storyId) => {
1190
+ const storyProps = { storyId, exportName: '', width: 600, height: 400 }
1191
+ const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
1192
+ const pos = centerPositionForWidget(center, 'story', storyProps)
1193
+ try {
1194
+ const result = await addWidgetApi(canvasId, {
1195
+ type: 'story',
1196
+ props: storyProps,
1197
+ position: pos,
1198
+ })
1199
+ if (result.success && result.widget) {
1200
+ undoRedo.snapshot(stateRef.current, 'add')
1201
+ setLocalWidgets((prev) => [...(prev || []), result.widget])
1202
+ }
1203
+ } catch (err) {
1204
+ console.error('[canvas] Failed to add story widget:', err)
1205
+ }
1206
+ }, [canvasId, undoRedo])
884
1207
 
885
1208
  // Listen for CoreUIBar add-widget events
886
1209
  useEffect(() => {
887
1210
  function handleAddWidget(e) {
888
1211
  addWidget(e.detail.type)
889
1212
  }
1213
+ function handleAddStoryWidget(e) {
1214
+ addStoryWidget(e.detail.storyId)
1215
+ }
890
1216
  document.addEventListener('storyboard:canvas:add-widget', handleAddWidget)
891
- return () => document.removeEventListener('storyboard:canvas:add-widget', handleAddWidget)
892
- }, [addWidget])
1217
+ document.addEventListener('storyboard:canvas:add-story-widget', handleAddStoryWidget)
1218
+ return () => {
1219
+ document.removeEventListener('storyboard:canvas:add-widget', handleAddWidget)
1220
+ document.removeEventListener('storyboard:canvas:add-story-widget', handleAddStoryWidget)
1221
+ }
1222
+ }, [addWidget, addStoryWidget])
893
1223
 
894
1224
  // Listen for zoom changes from CoreUIBar
895
1225
  useEffect(() => {
@@ -908,7 +1238,7 @@ export default function CanvasPage({ name }) {
908
1238
  function handleSnapToggle() {
909
1239
  setSnapEnabled((prev) => {
910
1240
  const next = !prev
911
- updateCanvas(name, { snapToGrid: next }).catch((err) =>
1241
+ updateCanvas(canvasId, { settings: { snapToGrid: next } }).catch((err) =>
912
1242
  console.error('[canvas] Failed to persist snap setting:', err)
913
1243
  )
914
1244
  return next
@@ -916,15 +1246,27 @@ export default function CanvasPage({ name }) {
916
1246
  }
917
1247
  document.addEventListener('storyboard:canvas:toggle-snap', handleSnapToggle)
918
1248
  return () => document.removeEventListener('storyboard:canvas:toggle-snap', handleSnapToggle)
919
- }, [name])
1249
+ }, [canvasId])
920
1250
 
921
1251
  // Broadcast snap state to Svelte toolbar
922
1252
  useEffect(() => {
923
1253
  document.dispatchEvent(new CustomEvent('storyboard:canvas:snap-state', {
924
1254
  detail: { snapEnabled }
925
1255
  }))
1256
+ snapEnabledRef.current = snapEnabled
926
1257
  }, [snapEnabled])
927
1258
 
1259
+ // Respond to snap-state requests from Svelte toolbar (handles mount-order race)
1260
+ useEffect(() => {
1261
+ function handleRequest() {
1262
+ document.dispatchEvent(new CustomEvent('storyboard:canvas:snap-state', {
1263
+ detail: { snapEnabled: snapEnabledRef.current }
1264
+ }))
1265
+ }
1266
+ document.addEventListener('storyboard:canvas:snap-state-request', handleRequest)
1267
+ return () => document.removeEventListener('storyboard:canvas:snap-state-request', handleRequest)
1268
+ }, [])
1269
+
928
1270
  // Listen for gridSize from Svelte toolbar config
929
1271
  useEffect(() => {
930
1272
  function handleGridSize(e) {
@@ -935,6 +1277,11 @@ export default function CanvasPage({ name }) {
935
1277
  return () => document.removeEventListener('storyboard:canvas:grid-size', handleGridSize)
936
1278
  }, [])
937
1279
 
1280
+ // Keep snapGridSize ref in sync for drop handler
1281
+ useEffect(() => {
1282
+ snapGridSizeRef.current = snapGridSize
1283
+ }, [snapGridSize])
1284
+
938
1285
  // Listen for zoom-to-fit from CoreUIBar
939
1286
  useEffect(() => {
940
1287
  function handleZoomToFit() {
@@ -955,13 +1302,28 @@ export default function CanvasPage({ name }) {
955
1302
  const fitZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(fitScale * 100)))
956
1303
  const newScale = fitZoom / 100
957
1304
 
958
- // Apply zoom synchronously so DOM updates before we scroll
1305
+ // Imperative DOM update same path as applyZoom
959
1306
  zoomRef.current = fitZoom
960
- flushSync(() => setZoom(fitZoom))
1307
+ const zoomEl = zoomElRef.current
1308
+ if (zoomEl) {
1309
+ zoomEl.style.transform = `scale(${newScale})`
1310
+ zoomEl.style.width = `${Math.max(10000, 100 / newScale)}vw`
1311
+ zoomEl.style.height = `${Math.max(10000, 100 / newScale)}vh`
1312
+ }
1313
+ setZoom(fitZoom)
961
1314
 
962
1315
  // Scroll so the bounding box top-left (with padding) is at viewport top-left
963
1316
  el.scrollLeft = (bounds.minX - FIT_PADDING) * newScale
964
1317
  el.scrollTop = (bounds.minY - FIT_PADDING) * newScale
1318
+
1319
+ // Persist after both zoom and scroll are settled
1320
+ if (viewportInitName.current === canvasId) {
1321
+ saveViewportState(canvasId, {
1322
+ zoom: fitZoom,
1323
+ scrollLeft: el.scrollLeft,
1324
+ scrollTop: el.scrollTop,
1325
+ })
1326
+ }
965
1327
  }
966
1328
  document.addEventListener('storyboard:canvas:zoom-to-fit', handleZoomToFit)
967
1329
  return () => document.removeEventListener('storyboard:canvas:zoom-to-fit', handleZoomToFit)
@@ -980,11 +1342,11 @@ export default function CanvasPage({ name }) {
980
1342
 
981
1343
  // Broadcast zoom level to CoreUIBar whenever it changes
982
1344
  useEffect(() => {
983
- window[CANVAS_BRIDGE_STATE_KEY] = { active: true, name, zoom }
1345
+ window[CANVAS_BRIDGE_STATE_KEY] = { active: true, canvasId, zoom }
984
1346
  document.dispatchEvent(new CustomEvent('storyboard:canvas:zoom-changed', {
985
1347
  detail: { zoom }
986
1348
  }))
987
- }, [name, zoom])
1349
+ }, [canvasId, zoom])
988
1350
 
989
1351
  // Delete selected widget on Delete/Backspace key
990
1352
  useEffect(() => {
@@ -1006,32 +1368,15 @@ export default function CanvasPage({ name }) {
1006
1368
  e.preventDefault()
1007
1369
  setSelectedWidgetIds(new Set())
1008
1370
  }
1009
- // Copy shortcuts (single widget selected):
1010
- // - cmd+c → copy URL/content
1011
- // - Shift+C (no cmd) → copy widget ID (or file path for images)
1371
+ // Copy shortcut (one or more widgets selected):
1372
+ // cmd+c → copy canvasId::id1,id2,... (for cross-canvas paste-duplicate)
1012
1373
  const mod = e.metaKey || e.ctrlKey
1013
- if (mod && e.key === 'c' && !e.shiftKey && selectedWidgetIds.size === 1) {
1014
- const widgetId = [...selectedWidgetIds][0]
1015
- const widget = localWidgets?.find(w => w.id === widgetId)
1016
- if (widget) {
1374
+ if (mod && e.key === 'c' && !e.shiftKey && selectedWidgetIds.size >= 1) {
1375
+ // Filter out non-duplicable widgets (jsx- component widgets are code)
1376
+ const copyableIds = [...selectedWidgetIds].filter(id => !id.startsWith('jsx-'))
1377
+ if (copyableIds.length > 0) {
1017
1378
  e.preventDefault()
1018
- const url = getWidgetCopyableUrl(widget)
1019
- if (url) {
1020
- navigator.clipboard.writeText(url).catch(() => {})
1021
- }
1022
- }
1023
- }
1024
- // Shift+C (uppercase C, no cmd) → copy ID or file path
1025
- if (e.key === 'C' && e.shiftKey && !mod && selectedWidgetIds.size === 1) {
1026
- const widgetId = [...selectedWidgetIds][0]
1027
- const widget = localWidgets?.find(w => w.id === widgetId)
1028
- if (widget) {
1029
- e.preventDefault()
1030
- if (widget.type === 'image' && widget.props?.src) {
1031
- navigator.clipboard.writeText(`src/canvas/images/${widget.props.src}`).catch(() => {})
1032
- } else {
1033
- navigator.clipboard.writeText(widgetId).catch(() => {})
1034
- }
1379
+ navigator.clipboard.writeText(`${canvasId}::${copyableIds.join(',')}`).catch(() => {})
1035
1380
  }
1036
1381
  }
1037
1382
  if (e.key === 'Delete' || e.key === 'Backspace') {
@@ -1044,7 +1389,7 @@ export default function CanvasPage({ name }) {
1044
1389
  if (!prev) return prev
1045
1390
  const next = prev.filter(w => !selectedWidgetIds.has(w.id))
1046
1391
  queueWrite(() =>
1047
- updateCanvas(name, { widgets: next }).catch(err =>
1392
+ updateCanvas(canvasId, { widgets: next }).catch(err =>
1048
1393
  console.error('[canvas] Failed to save multi-delete:', err)
1049
1394
  )
1050
1395
  )
@@ -1059,50 +1404,17 @@ export default function CanvasPage({ name }) {
1059
1404
  }
1060
1405
  document.addEventListener('keydown', handleKeyDown)
1061
1406
  return () => document.removeEventListener('keydown', handleKeyDown)
1062
- }, [selectedWidgetIds, localWidgets, handleWidgetRemove, undoRedo, name, debouncedSave])
1407
+ }, [selectedWidgetIds, localWidgets, handleWidgetRemove, undoRedo, canvasId, debouncedSave])
1063
1408
 
1064
- // Paste handler images become image widgets, same-origin URLs become prototypes,
1409
+ // Ref to store processImageFile for use by drop effect
1410
+ const processImageFileRef = useRef(null)
1411
+
1412
+ // Paste and drop handler — images become image widgets, same-origin URLs become prototypes,
1065
1413
  // other URLs become link previews, text becomes markdown
1066
1414
  useEffect(() => {
1067
1415
  const origin = window.location.origin
1068
1416
  const basePath = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
1069
- const baseUrl = origin + basePath
1070
-
1071
- // Check if a URL is same-origin, accounting for branch-deploy prefixes.
1072
- // e.g. https://site.com/branch--my-feature/Proto and https://site.com/storyboard/Proto
1073
- // are both same-origin prototype URLs.
1074
- function isSameOriginPrototype(url) {
1075
- if (!url.startsWith(origin)) return false
1076
- if (url.startsWith(baseUrl)) return true
1077
- // Match branch deploy URLs: origin + /branch--*/...
1078
- const pathAfterOrigin = url.slice(origin.length)
1079
- return BRANCH_PREFIX_RE.test(pathAfterOrigin)
1080
- }
1081
-
1082
- // Strip the base path (or any branch prefix) from a pathname to get a portable src.
1083
- function extractPrototypeSrc(pathname) {
1084
- // Strip current base path
1085
- if (basePath && pathname.startsWith(basePath)) {
1086
- return pathname.slice(basePath.length) || '/'
1087
- }
1088
- // Strip branch prefix: /branch--name/rest → /rest
1089
- const branchMatch = pathname.match(BRANCH_PREFIX_RE)
1090
- if (branchMatch) {
1091
- return pathname.slice(branchMatch[0].length) || '/'
1092
- }
1093
- return pathname
1094
- }
1095
-
1096
- /** Parse text as a web URL (http/https only). Returns URL object or null. */
1097
- function looksLikeWebUrl(text) {
1098
- try {
1099
- const url = new URL(text)
1100
- if (url.protocol === 'http:' || url.protocol === 'https:') return url
1101
- return null
1102
- } catch {
1103
- return null
1104
- }
1105
- }
1417
+ const pasteCtx = createPasteContext(origin, basePath)
1106
1418
 
1107
1419
  function blobToDataUrl(blob) {
1108
1420
  return new Promise((resolve, reject) => {
@@ -1122,6 +1434,59 @@ export default function CanvasPage({ name }) {
1122
1434
  })
1123
1435
  }
1124
1436
 
1437
+ /**
1438
+ * Process an image file (from paste or drop) and add it as a widget.
1439
+ * @param {File|Blob} file - Image file to process
1440
+ * @param {{ x: number, y: number }|null} position - Drop position, or null to use viewport center
1441
+ */
1442
+ async function processImageFile(file, position = null) {
1443
+ try {
1444
+ const dataUrl = await blobToDataUrl(file)
1445
+ const { width: natW, height: natH } = await getImageDimensions(dataUrl)
1446
+
1447
+ // Display at 2x retina: halve natural dimensions, then cap at 600px
1448
+ const maxWidth = 600
1449
+ let displayW = Math.round(natW / 2)
1450
+ let displayH = Math.round(natH / 2)
1451
+ if (displayW > maxWidth) {
1452
+ displayH = Math.round(displayH * (maxWidth / displayW))
1453
+ displayW = maxWidth
1454
+ }
1455
+
1456
+ const uploadResult = await uploadImage(dataUrl, canvasId)
1457
+ if (!uploadResult.success) {
1458
+ console.error('[canvas] Image upload failed:', uploadResult.error)
1459
+ return false
1460
+ }
1461
+
1462
+ // Use provided position or fall back to viewport center
1463
+ let pos
1464
+ if (position) {
1465
+ pos = { x: position.x, y: position.y }
1466
+ } else {
1467
+ const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
1468
+ pos = centerPositionForWidget(center, 'image', { width: displayW, height: displayH })
1469
+ }
1470
+
1471
+ const result = await addWidgetApi(canvasId, {
1472
+ type: 'image',
1473
+ props: { src: uploadResult.filename, private: false, width: displayW, height: displayH },
1474
+ position: pos,
1475
+ })
1476
+ if (result.success && result.widget) {
1477
+ undoRedo.snapshot(stateRef.current, 'add')
1478
+ setLocalWidgets((prev) => [...(prev || []), result.widget])
1479
+ }
1480
+ return true
1481
+ } catch (err) {
1482
+ console.error('[canvas] Failed to process image:', err)
1483
+ return false
1484
+ }
1485
+ }
1486
+
1487
+ // Store in ref for use by drag/drop effect
1488
+ processImageFileRef.current = processImageFile
1489
+
1125
1490
  async function handleImagePaste(e) {
1126
1491
  const items = e.clipboardData?.items
1127
1492
  if (!items) return false
@@ -1133,40 +1498,7 @@ export default function CanvasPage({ name }) {
1133
1498
  if (!blob) continue
1134
1499
 
1135
1500
  e.preventDefault()
1136
-
1137
- try {
1138
- const dataUrl = await blobToDataUrl(blob)
1139
- const { width: natW, height: natH } = await getImageDimensions(dataUrl)
1140
-
1141
- // Display at 2x retina: halve natural dimensions, then cap at 600px
1142
- const maxWidth = 600
1143
- let displayW = Math.round(natW / 2)
1144
- let displayH = Math.round(natH / 2)
1145
- if (displayW > maxWidth) {
1146
- displayH = Math.round(displayH * (maxWidth / displayW))
1147
- displayW = maxWidth
1148
- }
1149
-
1150
- const uploadResult = await uploadImage(dataUrl, name)
1151
- if (!uploadResult.success) {
1152
- console.error('[canvas] Image upload failed:', uploadResult.error)
1153
- return true
1154
- }
1155
-
1156
- const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
1157
- const pos = centerPositionForWidget(center, 'image', { width: displayW, height: displayH })
1158
- const result = await addWidgetApi(name, {
1159
- type: 'image',
1160
- props: { src: uploadResult.filename, private: false, width: displayW, height: displayH },
1161
- position: pos,
1162
- })
1163
- if (result.success && result.widget) {
1164
- undoRedo.snapshot(stateRef.current, 'add')
1165
- setLocalWidgets((prev) => [...(prev || []), result.widget])
1166
- }
1167
- } catch (err) {
1168
- console.error('[canvas] Failed to paste image:', err)
1169
- }
1501
+ await processImageFile(blob, null)
1170
1502
  return true
1171
1503
  }
1172
1504
  return false
@@ -1183,32 +1515,94 @@ export default function CanvasPage({ name }) {
1183
1515
  const text = e.clipboardData?.getData('text/plain')?.trim()
1184
1516
  if (!text) return
1185
1517
 
1186
- e.preventDefault()
1518
+ // Detect canvasId::widgetId or canvasId::id1,id2,id3 format for widget duplication
1519
+ // Also supports legacy canvasId/widgetId for basenames without slashes,
1520
+ // but only when the second segment looks like a widget ID (type-hash).
1521
+ const widgetRefMatch = text.match(/^(.+)::([^:]+)$/) || (text.indexOf('::') === -1 && text.match(/^([^/]+)\/((?:sticky-note|markdown|prototype|link-preview|figma-embed|component|image)-[a-z0-9]+)$/))
1522
+ if (widgetRefMatch) {
1523
+ e.preventDefault()
1524
+ const [, sourceCanvas, sourceWidgetRef] = widgetRefMatch
1525
+ const sourceWidgetIds = sourceWidgetRef.split(',').filter(id => !id.startsWith('jsx-'))
1526
+ if (sourceWidgetIds.length === 0) return
1187
1527
 
1188
- let type, props
1189
- const url = looksLikeWebUrl(text)
1190
- if (url) {
1191
- if (isFigmaUrl(text)) {
1192
- type = 'figma-embed'
1193
- props = { url: sanitizeFigmaUrl(text), width: 800, height: 450 }
1194
- } else if (isSameOriginPrototype(text)) {
1195
- const pathPortion = url.pathname + url.search + url.hash
1196
- const src = extractPrototypeSrc(pathPortion)
1197
- type = 'prototype'
1198
- props = { src: src || '/', originalSrc: src || '/', label: '', width: 800, height: 600 }
1199
- } else {
1200
- type = 'link-preview'
1201
- props = { url: text, title: '' }
1528
+ try {
1529
+ // Resolve source widgets in canvas order
1530
+ let sourceList
1531
+ if (sourceCanvas === canvasId) {
1532
+ sourceList = localWidgets ?? []
1533
+ } else {
1534
+ const canvasData = await getCanvasApi(sourceCanvas)
1535
+ sourceList = canvasData?.widgets ?? []
1536
+ }
1537
+
1538
+ const sourceWidgets = sourceList.filter(w => sourceWidgetIds.includes(w.id))
1539
+ if (sourceWidgets.length === 0) return
1540
+
1541
+ // Compute bounding box of source widgets for relative positioning
1542
+ const fallback = { width: 200, height: 150 }
1543
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
1544
+ for (const w of sourceWidgets) {
1545
+ const wx = w.position?.x ?? 0
1546
+ const wy = w.position?.y ?? 0
1547
+ const ww = w.props?.width ?? WIDGET_FALLBACK_SIZES[w.type]?.width ?? fallback.width
1548
+ const wh = w.props?.height ?? WIDGET_FALLBACK_SIZES[w.type]?.height ?? fallback.height
1549
+ if (wx < minX) minX = wx
1550
+ if (wy < minY) minY = wy
1551
+ if (wx + ww > maxX) maxX = wx + ww
1552
+ if (wy + wh > maxY) maxY = wy + wh
1553
+ }
1554
+ const groupW = maxX - minX
1555
+ const groupH = maxY - minY
1556
+
1557
+ // Center the group in the viewport
1558
+ const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
1559
+ const baseX = Math.round(center.x - groupW / 2)
1560
+ const baseY = Math.round(center.y - groupH / 2)
1561
+
1562
+ // Single undo snapshot for the entire paste
1563
+ undoRedo.snapshot(stateRef.current, 'add')
1564
+
1565
+ // Paste all widgets, collecting new IDs for selection
1566
+ const newWidgets = []
1567
+ for (const w of sourceWidgets) {
1568
+ const relX = (w.position?.x ?? 0) - minX
1569
+ const relY = (w.position?.y ?? 0) - minY
1570
+ const result = await addWidgetApi(canvasId, {
1571
+ type: w.type,
1572
+ props: { ...w.props },
1573
+ position: { x: baseX + relX, y: baseY + relY },
1574
+ })
1575
+ if (result.success && result.widget) {
1576
+ newWidgets.push(result.widget)
1577
+ }
1578
+ }
1579
+
1580
+ if (newWidgets.length > 0) {
1581
+ setLocalWidgets((prev) => [...(prev || []), ...newWidgets])
1582
+ setSelectedWidgetIds(new Set(newWidgets.map(w => w.id)))
1583
+ }
1584
+ } catch (err) {
1585
+ console.error('[canvas] Failed to paste widget reference:', err)
1202
1586
  }
1203
- } else {
1204
- type = 'markdown'
1205
- props = { content: text }
1587
+ // Always consume the ref — never fall through to markdown creation
1588
+ return
1589
+ }
1590
+
1591
+ e.preventDefault()
1592
+ const resolved = resolvePaste(text, pasteCtx, getPasteRules())
1593
+ if (!resolved) return
1594
+ const { type } = resolved
1595
+ let props = resolved.props
1596
+
1597
+ if (type === 'link-preview' && isGitHubEmbedUrl(props?.url || text)) {
1598
+ const githubUpdates = await buildGitHubPreviewUpdates(props?.url || text)
1599
+ if (githubUpdates) props = { ...props, ...githubUpdates }
1206
1600
  }
1207
1601
 
1208
1602
  const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
1209
1603
  const pos = centerPositionForWidget(center, type, props)
1210
1604
  try {
1211
- const result = await addWidgetApi(name, {
1605
+ const result = await addWidgetApi(canvasId, {
1212
1606
  type,
1213
1607
  props,
1214
1608
  position: pos,
@@ -1216,14 +1610,81 @@ export default function CanvasPage({ name }) {
1216
1610
  if (result.success && result.widget) {
1217
1611
  undoRedo.snapshot(stateRef.current, 'add')
1218
1612
  setLocalWidgets((prev) => [...(prev || []), result.widget])
1613
+ setSelectedWidgetIds(new Set([result.widget.id]))
1219
1614
  }
1220
1615
  } catch (err) {
1221
1616
  console.error('[canvas] Failed to add widget from paste:', err)
1222
1617
  }
1223
1618
  }
1619
+
1224
1620
  document.addEventListener('paste', handlePaste)
1225
1621
  return () => document.removeEventListener('paste', handlePaste)
1226
- }, [name, undoRedo])
1622
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1623
+ }, [canvasId, undoRedo, localWidgets])
1624
+
1625
+ // --- Drag and drop handlers for images from Finder/file manager ---
1626
+ // Separate effect to ensure listeners attach after scroll container mounts (loading=false)
1627
+ useEffect(() => {
1628
+ if (loading) return // Don't attach until canvas is loaded and scroll container exists
1629
+
1630
+ const scrollEl = scrollRef.current
1631
+ if (!scrollEl) return
1632
+
1633
+ function handleDragOver(e) {
1634
+ // Only handle if dragging files (not internal widget drag)
1635
+ if (!e.dataTransfer?.types?.includes('Files')) return
1636
+ e.preventDefault()
1637
+ e.dataTransfer.dropEffect = 'copy'
1638
+ }
1639
+
1640
+ async function handleDrop(e) {
1641
+ // Only handle file drops, not internal widget drags
1642
+ if (!e.dataTransfer?.types?.includes('Files')) return
1643
+
1644
+ // Prevent browser default (opening file) immediately for any file drop
1645
+ e.preventDefault()
1646
+ e.stopPropagation()
1647
+
1648
+ const files = e.dataTransfer.files
1649
+ if (!files || files.length === 0) return
1650
+
1651
+ // Filter to image files only — non-images are silently ignored (default already prevented)
1652
+ const imageFiles = Array.from(files).filter((f) => f.type.startsWith('image/'))
1653
+ if (imageFiles.length === 0) return
1654
+
1655
+ // Convert drop coordinates to canvas coordinates
1656
+ const rect = scrollEl.getBoundingClientRect()
1657
+ const scale = zoomRef.current / 100
1658
+
1659
+ // Mouse position relative to scroll container
1660
+ const mouseX = e.clientX - rect.left
1661
+ const mouseY = e.clientY - rect.top
1662
+
1663
+ // Convert to canvas coordinates (account for scroll and zoom)
1664
+ const canvasX = (scrollEl.scrollLeft + mouseX) / scale
1665
+ const canvasY = (scrollEl.scrollTop + mouseY) / scale
1666
+
1667
+ // Snap to grid if enabled, using current grid size
1668
+ const gridSize = snapGridSizeRef.current
1669
+ const shouldSnap = snapEnabledRef.current
1670
+ const snappedX = shouldSnap ? Math.round(canvasX / gridSize) * gridSize : Math.round(canvasX)
1671
+ const snappedY = shouldSnap ? Math.round(canvasY / gridSize) * gridSize : Math.round(canvasY)
1672
+
1673
+ // Process each image file, offsetting subsequent images
1674
+ for (let i = 0; i < imageFiles.length; i++) {
1675
+ const offset = shouldSnap ? i * gridSize : i * 24
1676
+ await processImageFileRef.current?.(imageFiles[i], { x: snappedX + offset, y: snappedY + offset })
1677
+ }
1678
+ }
1679
+
1680
+ scrollEl.addEventListener('dragover', handleDragOver)
1681
+ scrollEl.addEventListener('drop', handleDrop)
1682
+
1683
+ return () => {
1684
+ scrollEl.removeEventListener('dragover', handleDragOver)
1685
+ scrollEl.removeEventListener('drop', handleDrop)
1686
+ }
1687
+ }, [loading])
1227
1688
 
1228
1689
  // --- Undo / Redo ---
1229
1690
  const handleUndo = useCallback(() => {
@@ -1234,11 +1695,11 @@ export default function CanvasPage({ name }) {
1234
1695
  setLocalWidgets(previous.widgets)
1235
1696
  setLocalSources(previous.sources)
1236
1697
  queueWrite(() =>
1237
- updateCanvas(name, { widgets: previous.widgets, sources: previous.sources }).catch((err) =>
1698
+ updateCanvas(canvasId, { widgets: previous.widgets, sources: previous.sources }).catch((err) =>
1238
1699
  console.error('[canvas] Failed to persist undo:', err)
1239
1700
  )
1240
1701
  )
1241
- }, [name, debouncedSave, debouncedSourceSave, undoRedo])
1702
+ }, [canvasId, debouncedSave, debouncedSourceSave, undoRedo])
1242
1703
 
1243
1704
  const handleRedo = useCallback(() => {
1244
1705
  const next = undoRedo.redo(stateRef.current)
@@ -1248,11 +1709,11 @@ export default function CanvasPage({ name }) {
1248
1709
  setLocalWidgets(next.widgets)
1249
1710
  setLocalSources(next.sources)
1250
1711
  queueWrite(() =>
1251
- updateCanvas(name, { widgets: next.widgets, sources: next.sources }).catch((err) =>
1712
+ updateCanvas(canvasId, { widgets: next.widgets, sources: next.sources }).catch((err) =>
1252
1713
  console.error('[canvas] Failed to persist redo:', err)
1253
1714
  )
1254
1715
  )
1255
- }, [name, debouncedSave, debouncedSourceSave, undoRedo])
1716
+ }, [canvasId, debouncedSave, debouncedSourceSave, undoRedo])
1256
1717
 
1257
1718
  // Keyboard shortcuts — dev-only (Cmd+Z / Cmd+Shift+Z)
1258
1719
  useEffect(() => {
@@ -1311,6 +1772,69 @@ export default function CanvasPage({ name }) {
1311
1772
  return () => document.removeEventListener('wheel', handleWheel)
1312
1773
  }, [])
1313
1774
 
1775
+ // Receive cmd+wheel events forwarded from prototype/story iframes
1776
+ useEffect(() => {
1777
+ function handleMessage(e) {
1778
+ if (e.data?.type !== 'storyboard:embed:wheel') return
1779
+ zoomAccum.current += -e.data.deltaY
1780
+ const step = Math.trunc(zoomAccum.current)
1781
+ if (step === 0) return
1782
+ zoomAccum.current -= step
1783
+ applyZoom(zoomRef.current + step)
1784
+ }
1785
+ window.addEventListener('message', handleMessage)
1786
+ return () => window.removeEventListener('message', handleMessage)
1787
+ }, [])
1788
+
1789
+ // Touch pinch-to-zoom for mobile — two-finger pinch zooms the canvas
1790
+ const pinchState = useRef({ active: false, startDist: 0, startZoom: 0, centerX: 0, centerY: 0 })
1791
+ useEffect(() => {
1792
+ const el = scrollRef.current
1793
+ if (!el) return
1794
+
1795
+ function getTouchDist(t1, t2) {
1796
+ const dx = t1.clientX - t2.clientX
1797
+ const dy = t1.clientY - t2.clientY
1798
+ return Math.sqrt(dx * dx + dy * dy)
1799
+ }
1800
+
1801
+ function handleTouchStart(e) {
1802
+ if (e.touches.length !== 2) return
1803
+ const dist = getTouchDist(e.touches[0], e.touches[1])
1804
+ pinchState.current = {
1805
+ active: true,
1806
+ startDist: dist,
1807
+ startZoom: zoomRef.current,
1808
+ centerX: (e.touches[0].clientX + e.touches[1].clientX) / 2,
1809
+ centerY: (e.touches[0].clientY + e.touches[1].clientY) / 2,
1810
+ }
1811
+ }
1812
+
1813
+ function handleTouchMove(e) {
1814
+ if (!pinchState.current.active || e.touches.length !== 2) return
1815
+ e.preventDefault()
1816
+ const dist = getTouchDist(e.touches[0], e.touches[1])
1817
+ const ratio = dist / pinchState.current.startDist
1818
+ const newZoom = Math.round(pinchState.current.startZoom * ratio)
1819
+ applyZoom(newZoom, pinchState.current.centerX, pinchState.current.centerY)
1820
+ }
1821
+
1822
+ function handleTouchEnd() {
1823
+ pinchState.current.active = false
1824
+ }
1825
+
1826
+ el.addEventListener('touchstart', handleTouchStart, { passive: true })
1827
+ el.addEventListener('touchmove', handleTouchMove, { passive: false })
1828
+ el.addEventListener('touchend', handleTouchEnd)
1829
+ el.addEventListener('touchcancel', handleTouchEnd)
1830
+ return () => {
1831
+ el.removeEventListener('touchstart', handleTouchStart)
1832
+ el.removeEventListener('touchmove', handleTouchMove)
1833
+ el.removeEventListener('touchend', handleTouchEnd)
1834
+ el.removeEventListener('touchcancel', handleTouchEnd)
1835
+ }
1836
+ }, [])
1837
+
1314
1838
  // Space + drag to pan the canvas
1315
1839
  const [spaceHeld, setSpaceHeld] = useState(false)
1316
1840
  const isPanning = useRef(false)
@@ -1370,10 +1894,31 @@ export default function CanvasPage({ name }) {
1370
1894
  document.addEventListener('mouseup', handlePanEnd)
1371
1895
  }, [spaceHeld])
1372
1896
 
1897
+ // Stable callback for deselecting all widgets
1898
+ const handleDeselectAll = useCallback(() => setSelectedWidgetIds(new Set()), [])
1899
+
1900
+ // Marquee (lasso) multi-select on canvas background drag
1901
+ const { marqueeScreenRect, handleMarqueeMouseDown } = useMarqueeSelect({
1902
+ scrollRef,
1903
+ zoomRef: zoomRef,
1904
+ setSelectedWidgetIds,
1905
+ widgets: localWidgets,
1906
+ componentEntries,
1907
+ fallbackSizes: WIDGET_FALLBACK_SIZES,
1908
+ spaceHeld,
1909
+ isLocalDev,
1910
+ })
1911
+
1912
+ // Stable callback for widget removal + deselect
1913
+ const handleWidgetRemoveAndDeselect = useCallback((id) => {
1914
+ handleWidgetRemove(id)
1915
+ setSelectedWidgetIds(new Set())
1916
+ }, [handleWidgetRemove])
1917
+
1373
1918
  if (!canvas) {
1374
1919
  return (
1375
1920
  <div className={styles.empty}>
1376
- <p>Canvas &ldquo;{name}&rdquo; not found</p>
1921
+ <p>Canvas &ldquo;{canvasId}&rdquo; not found</p>
1377
1922
  </div>
1378
1923
  )
1379
1924
  }
@@ -1401,11 +1946,11 @@ export default function CanvasPage({ name }) {
1401
1946
  const canvasThemeVars = getCanvasThemeVars(canvasTheme)
1402
1947
  const canvasPrimerAttrs = getCanvasPrimerAttrs(canvasTheme)
1403
1948
 
1404
- // Merge JSX-sourced widgets (from .canvas.jsx) and JSON widgets
1949
+ // Merge JSX-sourced widgets and JSON widgets
1405
1950
  const allChildren = []
1406
1951
 
1407
1952
  // 1. Component widgets (from jsxExports or sources fallback)
1408
- const componentFeatures = getFeatures('component')
1953
+ const componentFeatures = getFeatures('component', { isLocalDev })
1409
1954
  for (const entry of componentEntries) {
1410
1955
  const { exportName, Component, sourceData } = entry
1411
1956
  const sourcePosition = sourceData.position || { x: 0, y: 0 }
@@ -1431,7 +1976,7 @@ export default function CanvasPage({ name }) {
1431
1976
  selected={selectedWidgetIds.has(`jsx-${exportName}`)}
1432
1977
  multiSelected={isMultiSelected && selectedWidgetIds.has(`jsx-${exportName}`)}
1433
1978
  onSelect={(shiftKey) => handleWidgetSelect(`jsx-${exportName}`, shiftKey)}
1434
- onDeselect={() => setSelectedWidgetIds(new Set())}
1979
+ onDeselect={handleDeselectAll}
1435
1980
  readOnly={!isLocalDev}
1436
1981
  >
1437
1982
  <ComponentWidget
@@ -1473,13 +2018,12 @@ export default function CanvasPage({ name }) {
1473
2018
  selected={selectedWidgetIds.has(widget.id)}
1474
2019
  multiSelected={isMultiSelected && selectedWidgetIds.has(widget.id)}
1475
2020
  onSelect={(shiftKey) => handleWidgetSelect(widget.id, shiftKey)}
1476
- onDeselect={() => setSelectedWidgetIds(new Set())}
2021
+ onDeselect={handleDeselectAll}
1477
2022
  onUpdate={isLocalDev ? handleWidgetUpdate : undefined}
1478
2023
  onCopy={isLocalDev ? handleWidgetCopy : undefined}
1479
- onRemove={isLocalDev ? (id) => {
1480
- handleWidgetRemove(id)
1481
- setSelectedWidgetIds(new Set())
1482
- } : undefined}
2024
+ onRemove={isLocalDev ? handleWidgetRemoveAndDeselect : undefined}
2025
+ onRefreshGitHub={isLocalDev ? handleRefreshGitHubWidget : undefined}
2026
+ canRefreshGitHub={isLocalDev}
1483
2027
  readOnly={!isLocalDev}
1484
2028
  />
1485
2029
  </div>
@@ -1491,24 +2035,13 @@ export default function CanvasPage({ name }) {
1491
2035
  return (
1492
2036
  <>
1493
2037
  <div className={styles.canvasTitle}>
1494
- <div className={styles.canvasTitleWrap}>
1495
- <span className={styles.canvasTitleMeasure} aria-hidden="true">{canvasTitle || ' '}</span>
1496
- {isLocalDev ? (
1497
- <input
1498
- ref={titleInputRef}
1499
- className={styles.canvasTitleInput}
1500
- value={canvasTitle}
1501
- size={1}
1502
- onChange={handleTitleChange}
1503
- onKeyDown={handleTitleKeyDown}
1504
- onMouseDown={(e) => e.stopPropagation()}
1505
- spellCheck={false}
1506
- aria-label="Canvas title"
1507
- />
1508
- ) : (
1509
- <h1 className={styles.canvasTitleStatic}>{canvasTitle}</h1>
1510
- )}
1511
- </div>
2038
+ <a href={(import.meta.env?.BASE_URL || '/')} className={styles.canvasLogo} aria-label="Go to homepage">
2039
+ <Icon name="iconoir/key-command" size={16} color="#fff" />
2040
+ </a>
2041
+ {siblingPages.length > 1 && (
2042
+ <h1 className={styles.canvasTitleStatic}>{canvasMeta?.title || canvas?.title || canvasId.split('/').pop()}</h1>
2043
+ )}
2044
+ <PageSelector currentName={canvasId} pages={siblingPages} isLocalDev={isLocalDev} />
1512
2045
  {isLocalDev && (
1513
2046
  <span className={styles.localEditingLabel}>Local editing</span>
1514
2047
  )}
@@ -1523,10 +2056,11 @@ export default function CanvasPage({ name }) {
1523
2056
  ...canvasThemeVars,
1524
2057
  ...(spaceHeld ? { cursor: panningActive ? 'grabbing' : 'grab' } : {}),
1525
2058
  }}
1526
- onClick={() => setSelectedWidgetIds(new Set())}
1527
- onMouseDown={handlePanStart}
2059
+ onMouseDown={(e) => { handlePanStart(e); handleMarqueeMouseDown(e); }}
1528
2060
  >
2061
+ <MarqueeOverlay rect={marqueeScreenRect} />
1529
2062
  <div
2063
+ ref={zoomElRef}
1530
2064
  data-storyboard-canvas-zoom
1531
2065
  data-sb-canvas-theme={canvasTheme}
1532
2066
  className={styles.canvasZoom}
@@ -1543,6 +2077,28 @@ export default function CanvasPage({ name }) {
1543
2077
  </Canvas>
1544
2078
  </div>
1545
2079
  </div>
2080
+ {showGhInstallBanner && (
2081
+ <aside className={styles.ghInstallBanner} role="status" aria-live="polite">
2082
+ <span className={styles.ghInstallBannerText}>
2083
+ GitHub embeds require local <code>gh</code> CLI access.
2084
+ </span>
2085
+ <a
2086
+ href={GH_INSTALL_URL}
2087
+ target="_blank"
2088
+ rel="noopener noreferrer"
2089
+ className={styles.ghInstallBannerLink}
2090
+ >
2091
+ Install GitHub CLI
2092
+ </a>
2093
+ <button
2094
+ type="button"
2095
+ className={styles.ghInstallBannerDismiss}
2096
+ onClick={() => setShowGhInstallBanner(false)}
2097
+ >
2098
+ Dismiss
2099
+ </button>
2100
+ </aside>
2101
+ )}
1546
2102
  </>
1547
2103
  )
1548
2104
  }