@dfosco/storyboard-react 4.0.0-beta.3 → 4.0.0-beta.30

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 (63) hide show
  1. package/package.json +7 -4
  2. package/src/canvas/CanvasControls.jsx +51 -2
  3. package/src/canvas/CanvasControls.module.css +31 -0
  4. package/src/canvas/CanvasPage.bridge.test.jsx +95 -10
  5. package/src/canvas/CanvasPage.dragdrop.test.jsx +346 -0
  6. package/src/canvas/CanvasPage.jsx +790 -302
  7. package/src/canvas/CanvasPage.module.css +70 -47
  8. package/src/canvas/CanvasPage.multiselect.test.jsx +13 -11
  9. package/src/canvas/CanvasToolbar.jsx +2 -2
  10. package/src/canvas/ComponentErrorBoundary.jsx +50 -0
  11. package/src/canvas/PageSelector.jsx +102 -0
  12. package/src/canvas/PageSelector.module.css +93 -0
  13. package/src/canvas/PageSelector.test.jsx +104 -0
  14. package/src/canvas/canvasApi.js +22 -8
  15. package/src/canvas/canvasReloadGuard.js +37 -0
  16. package/src/canvas/canvasReloadGuard.test.js +27 -0
  17. package/src/canvas/componentIsolate.jsx +135 -0
  18. package/src/canvas/useCanvas.js +15 -10
  19. package/src/canvas/widgets/CodePenEmbed.jsx +292 -0
  20. package/src/canvas/widgets/CodePenEmbed.module.css +161 -0
  21. package/src/canvas/widgets/ComponentWidget.jsx +82 -9
  22. package/src/canvas/widgets/ComponentWidget.module.css +14 -6
  23. package/src/canvas/widgets/FigmaEmbed.jsx +110 -24
  24. package/src/canvas/widgets/FigmaEmbed.module.css +21 -7
  25. package/src/canvas/widgets/LinkPreview.jsx +247 -18
  26. package/src/canvas/widgets/LinkPreview.module.css +349 -8
  27. package/src/canvas/widgets/LinkPreview.test.jsx +71 -0
  28. package/src/canvas/widgets/MarkdownBlock.jsx +95 -21
  29. package/src/canvas/widgets/MarkdownBlock.module.css +133 -2
  30. package/src/canvas/widgets/MarkdownBlock.test.jsx +39 -0
  31. package/src/canvas/widgets/PrototypeEmbed.jsx +319 -70
  32. package/src/canvas/widgets/PrototypeEmbed.module.css +74 -4
  33. package/src/canvas/widgets/StickyNote.module.css +5 -0
  34. package/src/canvas/widgets/StickyNote.test.jsx +9 -9
  35. package/src/canvas/widgets/StoryWidget.jsx +512 -0
  36. package/src/canvas/widgets/StoryWidget.module.css +211 -0
  37. package/src/canvas/widgets/WidgetChrome.jsx +76 -20
  38. package/src/canvas/widgets/WidgetChrome.module.css +4 -7
  39. package/src/canvas/widgets/WidgetWrapper.module.css +2 -0
  40. package/src/canvas/widgets/codepenUrl.js +75 -0
  41. package/src/canvas/widgets/codepenUrl.test.js +76 -0
  42. package/src/canvas/widgets/embedInteraction.test.jsx +235 -0
  43. package/src/canvas/widgets/embedOverlay.module.css +35 -0
  44. package/src/canvas/widgets/embedTheme.js +56 -0
  45. package/src/canvas/widgets/githubUrl.js +82 -0
  46. package/src/canvas/widgets/githubUrl.test.js +74 -0
  47. package/src/canvas/widgets/iframeDevLogs.js +49 -0
  48. package/src/canvas/widgets/iframeDevLogs.test.jsx +81 -0
  49. package/src/canvas/widgets/index.js +4 -0
  50. package/src/canvas/widgets/pasteRules.js +295 -0
  51. package/src/canvas/widgets/pasteRules.test.js +474 -0
  52. package/src/canvas/widgets/refreshQueue.js +108 -0
  53. package/src/canvas/widgets/snapshotDisplay.test.jsx +259 -0
  54. package/src/canvas/widgets/useSnapshotCapture.js +157 -0
  55. package/src/canvas/widgets/useSnapshotCapture.test.jsx +164 -0
  56. package/src/canvas/widgets/widgetConfig.js +16 -5
  57. package/src/canvas/widgets/widgetConfig.test.js +34 -12
  58. package/src/context.jsx +141 -16
  59. package/src/hooks/useSceneData.js +4 -2
  60. package/src/story/StoryPage.jsx +117 -0
  61. package/src/story/StoryPage.module.css +18 -0
  62. package/src/vite/data-plugin.js +458 -71
  63. package/src/vite/data-plugin.test.js +405 -5
@@ -1,5 +1,4 @@
1
- import { createElement, useCallback, useEffect, 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,52 @@ 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 { isGitHubEmbedUrl } from './widgets/githubUrl.js'
12
13
  import WidgetChrome from './widgets/WidgetChrome.jsx'
13
14
  import ComponentWidget from './widgets/ComponentWidget.jsx'
14
15
  import useUndoRedo from './useUndoRedo.js'
15
- import { addWidget as addWidgetApi, updateCanvas, removeWidget as removeWidgetApi, uploadImage } from './canvasApi.js'
16
+ import {
17
+ addWidget as addWidgetApi,
18
+ checkGitHubCliAvailable,
19
+ fetchGitHubEmbed,
20
+ getCanvas as getCanvasApi,
21
+ removeWidget as removeWidgetApi,
22
+ updateCanvas,
23
+ uploadImage,
24
+ } from './canvasApi.js'
25
+ import PageSelector from './PageSelector.jsx'
26
+ import { stories as storyIndex } from 'virtual:storyboard-data-index'
16
27
  import styles from './CanvasPage.module.css'
17
28
 
18
29
  const ZOOM_MIN = 25
19
30
  const ZOOM_MAX = 200
20
31
 
32
+ /** Saved viewport state older than this is considered stale — zoom-to-fit instead. */
33
+ const VIEWPORT_TTL_MS = 15 * 60 * 1000
34
+
21
35
  const CANVAS_BRIDGE_STATE_KEY = '__storyboardCanvasBridgeState'
36
+ const GH_INSTALL_URL = 'https://github.com/cli/cli'
22
37
 
23
38
  /** Matches branch-deploy base path prefixes like /branch--my-feature/ */
24
39
  const BRANCH_PREFIX_RE = /^\/branch--[^/]+/
25
40
 
41
+ // Build a reverse map from story route paths → { storyId, route }
42
+ const storyRouteIndex = new Map()
43
+ for (const [storyId, data] of Object.entries(storyIndex || {})) {
44
+ if (data?._route) {
45
+ storyRouteIndex.set(data._route.replace(/\/+$/, ''), storyId)
46
+ }
47
+ }
48
+
26
49
  function getToolbarColorMode(theme) {
27
50
  return String(theme || 'light').startsWith('dark') ? 'dark' : 'light'
28
51
  }
29
52
 
30
53
  function resolveCanvasThemeFromStorage() {
31
54
  if (typeof localStorage === 'undefined') return 'light'
32
- let sync = { prototype: true, toolbar: false, codeBoxes: true, canvas: false }
55
+ let sync = { prototype: true, toolbar: false, codeBoxes: true, canvas: true }
33
56
  try {
34
57
  const rawSync = localStorage.getItem('sb-theme-sync')
35
58
  if (rawSync) sync = { ...sync, ...JSON.parse(rawSync) }
@@ -47,6 +70,36 @@ function resolveCanvasThemeFromStorage() {
47
70
  return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
48
71
  }
49
72
 
73
+ /**
74
+ * Get the copyable URL for a widget based on its type.
75
+ * Returns the most relevant URL/path for the widget content.
76
+ */
77
+ // eslint-disable-next-line no-unused-vars
78
+ function getWidgetCopyableUrl(widget) {
79
+ const { type, props = {} } = widget
80
+ const base = (typeof import.meta !== 'undefined' && import.meta.env?.BASE_URL) || '/'
81
+ switch (type) {
82
+ case 'prototype':
83
+ // Prototype src is a path like "/MyPrototype" - make it a full URL
84
+ return props.src ? `${window.location.origin}${base.replace(/\/$/, '')}${props.src}` : ''
85
+ case 'figma-embed':
86
+ return props.url || ''
87
+ case 'link-preview':
88
+ return props.url || ''
89
+ case 'image':
90
+ // Return the served image URL
91
+ return props.src ? `${window.location.origin}${base.replace(/\/$/, '')}/_storyboard/canvas/images/${props.src}` : ''
92
+ case 'sticky-note':
93
+ // Sticky notes have text content, not a URL
94
+ return props.text || ''
95
+ case 'markdown':
96
+ // Markdown has content, not a URL
97
+ return props.content || ''
98
+ default:
99
+ return ''
100
+ }
101
+ }
102
+
50
103
  /**
51
104
  * Debounce helper — returns a function that delays invocation.
52
105
  * Exposes `.cancel()` to abort pending calls (used by undo/redo).
@@ -62,15 +115,20 @@ function debounce(fn, ms) {
62
115
  }
63
116
 
64
117
  /** Per-canvas viewport state persistence (zoom + scroll position). */
65
- function getViewportStorageKey(canvasName) {
66
- return `sb-canvas-viewport:${canvasName}`
118
+ function getViewportStorageKey(canvasId) {
119
+ return `sb-canvas-viewport:${canvasId}`
67
120
  }
68
121
 
69
- function loadViewportState(canvasName) {
122
+ function loadViewportState(canvasId) {
70
123
  try {
71
- const raw = localStorage.getItem(getViewportStorageKey(canvasName))
124
+ const raw = localStorage.getItem(getViewportStorageKey(canvasId))
72
125
  if (!raw) return null
73
126
  const state = JSON.parse(raw)
127
+ const timestamp = typeof state.timestamp === 'number' ? state.timestamp : 0
128
+ if (Date.now() - timestamp > VIEWPORT_TTL_MS) {
129
+ localStorage.removeItem(getViewportStorageKey(canvasId))
130
+ return null
131
+ }
74
132
  return {
75
133
  zoom: typeof state.zoom === 'number' ? Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, state.zoom)) : null,
76
134
  scrollLeft: typeof state.scrollLeft === 'number' ? state.scrollLeft : null,
@@ -79,9 +137,12 @@ function loadViewportState(canvasName) {
79
137
  } catch { return null }
80
138
  }
81
139
 
82
- function saveViewportState(canvasName, state) {
140
+ function saveViewportState(canvasId, state) {
83
141
  try {
84
- localStorage.setItem(getViewportStorageKey(canvasName), JSON.stringify(state))
142
+ localStorage.setItem(getViewportStorageKey(canvasId), JSON.stringify({
143
+ ...state,
144
+ timestamp: Date.now(),
145
+ }))
85
146
  } catch { /* quota exceeded — non-critical */ }
86
147
  }
87
148
 
@@ -158,7 +219,7 @@ const FIT_PADDING = 48
158
219
  * Compute the axis-aligned bounding box that contains every widget and source.
159
220
  * Returns { minX, minY, maxX, maxY } in canvas-space coordinates, or null if empty.
160
221
  */
161
- function computeCanvasBounds(widgets, sources, jsxExports) {
222
+ function computeCanvasBounds(widgets, componentEntries) {
162
223
  let minX = Infinity
163
224
  let minY = Infinity
164
225
  let maxX = -Infinity
@@ -179,31 +240,25 @@ function computeCanvasBounds(widgets, sources, jsxExports) {
179
240
  hasItems = true
180
241
  }
181
242
 
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
- }
243
+ // Component widgets (from jsxExports or sources fallback)
244
+ for (const entry of componentEntries) {
245
+ const x = entry.sourceData?.position?.x ?? 0
246
+ const y = entry.sourceData?.position?.y ?? 0
247
+ const fallback = WIDGET_FALLBACK_SIZES['component']
248
+ const width = entry.sourceData?.width ?? fallback.width
249
+ const height = entry.sourceData?.height ?? fallback.height
250
+ minX = Math.min(minX, x)
251
+ minY = Math.min(minY, y)
252
+ maxX = Math.max(maxX, x + width)
253
+ maxY = Math.max(maxY, y + height)
254
+ hasItems = true
200
255
  }
201
256
 
202
257
  return hasItems ? { minX, minY, maxX, maxY } : null
203
258
  }
204
259
 
205
260
  /** Renders a single JSON-defined widget by type lookup. */
206
- function WidgetRenderer({ widget, onUpdate, widgetRef }) {
261
+ function WidgetRenderer({ widget, onUpdate, widgetRef, onRefreshGitHub, canRefreshGitHub }) {
207
262
  const Component = getWidgetComponent(widget.type)
208
263
  if (!Component) {
209
264
  console.warn(`[canvas] Unknown widget type: ${widget.type}`)
@@ -211,7 +266,14 @@ function WidgetRenderer({ widget, onUpdate, widgetRef }) {
211
266
  }
212
267
  const resizable = isResizable(widget.type) && !!onUpdate
213
268
  // Only pass ref to forwardRef-wrapped components (e.g. PrototypeEmbed)
214
- const elementProps = { id: widget.id, props: widget.props, onUpdate, resizable }
269
+ const elementProps = {
270
+ id: widget.id,
271
+ props: widget.props,
272
+ onUpdate,
273
+ resizable,
274
+ onRefreshGitHub,
275
+ canRefreshGitHub,
276
+ }
215
277
  if (Component.$$typeof === Symbol.for('react.forward_ref')) {
216
278
  elementProps.ref = widgetRef
217
279
  }
@@ -221,8 +283,10 @@ function WidgetRenderer({ widget, onUpdate, widgetRef }) {
221
283
  /**
222
284
  * Wrapper for each JSON widget that holds its own ref for imperative actions.
223
285
  * This allows WidgetChrome to dispatch actions to the widget via ref.
286
+ *
287
+ * Memoized to prevent re-renders during zoom and unrelated state changes.
224
288
  */
225
- function ChromeWrappedWidget({
289
+ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
226
290
  widget,
227
291
  selected,
228
292
  multiSelected,
@@ -231,18 +295,64 @@ function ChromeWrappedWidget({
231
295
  onUpdate,
232
296
  onRemove,
233
297
  onCopy,
298
+ onRefreshGitHub,
299
+ canRefreshGitHub,
234
300
  readOnly,
235
301
  }) {
236
302
  const widgetRef = useRef(null)
237
- const features = getFeatures(widget.type)
303
+ const rawFeatures = getFeatures(widget.type, { isLocalDev: !readOnly })
304
+
305
+ // Dynamically adjust features based on widget state
306
+ const features = useMemo(() => {
307
+ const isGitHub = !!widget.props?.github
308
+ return rawFeatures.map((f) => {
309
+ // Toggle collapse label and hide when content is short (no github = no collapse)
310
+ if (f.action === 'toggle-collapse') {
311
+ if (!isGitHub) return null
312
+ return {
313
+ ...f,
314
+ label: widget.props?.collapsed ? 'Expand height' : 'Collapse height',
315
+ icon: widget.props?.collapsed ? 'unfold' : 'fold',
316
+ }
317
+ }
318
+ // Hide refresh-github for non-GitHub link previews
319
+ if (f.action === 'refresh-github' && !isGitHub) return null
320
+ return f
321
+ }).filter(Boolean)
322
+ }, [rawFeatures, widget.props?.github, widget.props?.collapsed])
238
323
 
239
324
  const handleAction = useCallback((actionId) => {
240
325
  if (actionId === 'delete') {
241
326
  onRemove?.(widget.id)
242
327
  } else if (actionId === 'copy') {
243
328
  onCopy?.(widget)
329
+ } else if (actionId === 'copy-text') {
330
+ const title = widget.props?.title || ''
331
+ const body = widget.props?.text || widget.props?.content || widget.props?.github?.body || ''
332
+ const text = title && body ? `# ${title}\n\n${body}` : title || body
333
+ navigator.clipboard?.writeText(text).catch(() => {})
334
+ } else if (actionId === 'open-external') {
335
+ const url = widget.props?.url || widget.props?.src
336
+ if (url) window.open(url, '_blank', 'noopener,noreferrer')
337
+ } else if (actionId === 'refresh-github') {
338
+ const url = widget.props?.url
339
+ if (url && onRefreshGitHub) onRefreshGitHub(widget.id, url)
340
+ } else if (actionId === 'toggle-collapse') {
341
+ const wasCollapsed = !!widget.props?.collapsed
342
+ onUpdate?.(widget.id, { collapsed: !wasCollapsed })
343
+ // When collapsing, pan viewport to center the widget
344
+ if (!wasCollapsed) {
345
+ requestAnimationFrame(() => {
346
+ const el = document.getElementById(widget.id)
347
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
348
+ })
349
+ }
244
350
  }
245
- }, [widget, onRemove, onCopy])
351
+ }, [widget, onRemove, onCopy, onRefreshGitHub])
352
+
353
+ const handleWidgetFieldUpdate = useCallback((updates) => {
354
+ onUpdate?.(widget.id, updates)
355
+ }, [onUpdate, widget.id])
246
356
 
247
357
  return (
248
358
  <WidgetChrome
@@ -256,43 +366,96 @@ function ChromeWrappedWidget({
256
366
  onSelect={onSelect}
257
367
  onDeselect={onDeselect}
258
368
  onAction={handleAction}
259
- onUpdate={onUpdate ? (updates) => onUpdate(widget.id, updates) : undefined}
369
+ onUpdate={onUpdate ? handleWidgetFieldUpdate : undefined}
260
370
  readOnly={readOnly}
261
371
  >
262
372
  <WidgetRenderer
263
373
  widget={widget}
264
- onUpdate={onUpdate ? (updates) => onUpdate(widget.id, updates) : undefined}
374
+ onUpdate={onUpdate ? handleWidgetFieldUpdate : undefined}
265
375
  widgetRef={widgetRef}
376
+ onRefreshGitHub={onRefreshGitHub}
377
+ canRefreshGitHub={canRefreshGitHub}
266
378
  />
267
379
  </WidgetChrome>
268
380
  )
269
- }
381
+ }, function chromeWidgetAreEqual(prev, next) {
382
+ return (
383
+ prev.widget === next.widget &&
384
+ prev.selected === next.selected &&
385
+ prev.multiSelected === next.multiSelected &&
386
+ prev.readOnly === next.readOnly &&
387
+ prev.onSelect === next.onSelect &&
388
+ prev.onDeselect === next.onDeselect &&
389
+ prev.onUpdate === next.onUpdate &&
390
+ prev.onRemove === next.onRemove &&
391
+ prev.onCopy === next.onCopy
392
+ )
393
+ })
270
394
 
271
395
  /**
272
396
  * Generic canvas page component.
273
397
  * Reads canvas data from the index and renders all widgets on a draggable surface.
274
398
  *
275
- * @param {{ name: string }} props - Canvas name as indexed by the data plugin
399
+ * @param {{ canvasId: string }} props - Canvas name as indexed by the data plugin
276
400
  */
277
- export default function CanvasPage({ name }) {
278
- const { canvas, jsxExports, loading } = useCanvas(name)
401
+ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages = [], canvasMeta = null }) {
402
+ const canvasId = canvasIdProp || name || ''
403
+ const { canvas, jsxExports, jsxError, loading } = useCanvas(canvasId)
279
404
  const isLocalDev = typeof window !== 'undefined' && window.__SB_LOCAL_DEV__ === true && !new URLSearchParams(window.location.search).has('prodMode')
280
405
 
281
406
  // Local mutable copy of widgets for instant UI updates
282
407
  const [localWidgets, setLocalWidgets] = useState(canvas?.widgets ?? null)
283
408
  const [trackedCanvas, setTrackedCanvas] = useState(canvas)
284
409
  const [selectedWidgetIds, setSelectedWidgetIds] = useState(() => new Set())
285
- const initialViewport = loadViewportState(name)
410
+ const initialViewport = loadViewportState(canvasId)
286
411
  const [zoom, setZoom] = useState(initialViewport?.zoom ?? 100)
287
412
  const zoomRef = useRef(initialViewport?.zoom ?? 100)
288
413
  const scrollRef = useRef(null)
414
+ const zoomElRef = useRef(null)
415
+ const zoomCommitTimer = useRef(null)
416
+ const zoomEventTimer = useRef(null)
289
417
  const pendingScrollRestore = useRef(initialViewport)
290
- const [canvasTitle, setCanvasTitle] = useState(canvas?.title || name)
291
- const titleInputRef = useRef(null)
418
+ // Gate viewport persistence until initial positioning is complete.
419
+ // Tracks which canvasId was last initialized — save effects only
420
+ // write when this matches `canvasId`, preventing cross-canvas corruption.
421
+ const viewportInitName = useRef(null)
292
422
  const [localSources, setLocalSources] = useState(canvas?.sources ?? [])
293
423
  const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
294
424
  const [snapEnabled, setSnapEnabled] = useState(canvas?.snapToGrid ?? false)
295
425
  const [snapGridSize, setSnapGridSize] = useState(canvas?.gridSize || 40)
426
+ const [showGhInstallBanner, setShowGhInstallBanner] = useState(false)
427
+
428
+ // Refs for snap settings (used by drop handler inside effect closure)
429
+ const snapEnabledRef = useRef(snapEnabled)
430
+ const snapGridSizeRef = useRef(snapGridSize)
431
+
432
+ // Centralized list of component export names.
433
+ // When jsxExports is available, use it (discovers new exports not yet in sources).
434
+ // When jsxExports is null (module import failed), fall back to sources so iframes
435
+ // still render — the error is contained inside each iframe.
436
+ const componentEntries = useMemo(() => {
437
+ const sourceMap = Object.fromEntries(
438
+ (localSources || []).filter((s) => s?.export).map((s) => [s.export, s]),
439
+ )
440
+ if (jsxExports) {
441
+ return Object.keys(jsxExports).map((exportName) => ({
442
+ exportName,
443
+ Component: jsxExports[exportName],
444
+ sourceData: sourceMap[exportName] || {},
445
+ }))
446
+ }
447
+ // Fallback: use sources when module import failed (iframe isolation still works)
448
+ if (jsxError && canvas?._jsxModule) {
449
+ return (localSources || [])
450
+ .filter((s) => s?.export)
451
+ .map((s) => ({
452
+ exportName: s.export,
453
+ Component: null,
454
+ sourceData: s,
455
+ }))
456
+ }
457
+ return []
458
+ }, [jsxExports, jsxError, localSources, canvas?._jsxModule])
296
459
 
297
460
  // Undo/redo history — tracks both widgets and sources as a combined snapshot
298
461
  const undoRedo = useUndoRedo()
@@ -349,13 +512,13 @@ export default function CanvasPage({ name }) {
349
512
  // Flag to suppress the click-based selection reset that fires after a drag
350
513
  const justDraggedRef = useRef(false)
351
514
 
352
- const handleItemDragStart = useCallback((dragId, position) => {
515
+ const handleItemDragStart = useCallback((dragId) => {
353
516
  const ids = selectedIdsRef.current
354
517
  peerArticlesRef.current.clear()
355
518
  if (ids.size <= 1 || !ids.has(dragId)) return
356
519
 
357
520
  // Suppress selection changes for the duration of the drag
358
- justDraggedRef.current = true
521
+ justDraggedRef.current = true // eslint-disable-line react-hooks/immutability
359
522
 
360
523
  // Collect peer article elements for transition on drag end
361
524
  for (const id of ids) {
@@ -394,40 +557,28 @@ export default function CanvasPage({ name }) {
394
557
  setTrackedCanvas(canvas)
395
558
  setLocalWidgets(canvas?.widgets ?? null)
396
559
  setLocalSources(canvas?.sources ?? [])
397
- setCanvasTitle(canvas?.title || name)
560
+ setSnapEnabled(canvas?.snapToGrid ?? false)
561
+ setSnapGridSize(canvas?.gridSize || 40)
398
562
  undoRedo.reset()
563
+ // Block saves until the new canvas's viewport is fully restored.
564
+ viewportInitName.current = null
565
+ const newViewport = loadViewportState(canvasId)
566
+ pendingScrollRestore.current = newViewport
567
+ // Restore zoom from the new canvas's saved state
568
+ const newZoom = newViewport?.zoom ?? 100
569
+ zoomRef.current = newZoom
570
+ setZoom(newZoom)
399
571
  }
400
572
 
401
573
  // Debounced save to server
402
574
  const debouncedSave = useRef(
403
- debounce((canvasName, widgets) => {
404
- updateCanvas(canvasName, { widgets }).catch((err) =>
575
+ debounce((canvasId, widgets) => {
576
+ updateCanvas(canvasId, { widgets }).catch((err) =>
405
577
  console.error('[canvas] Failed to save:', err)
406
578
  )
407
579
  }, 2000)
408
580
  ).current
409
581
 
410
- const debouncedTitleSave = useRef(
411
- debounce((canvasName, title) => {
412
- updateCanvas(canvasName, { settings: { title } }).catch((err) =>
413
- console.error('[canvas] Failed to save title:', err)
414
- )
415
- }, 1000)
416
- ).current
417
-
418
- const handleTitleChange = useCallback((e) => {
419
- const newTitle = e.target.value
420
- setCanvasTitle(newTitle)
421
- debouncedTitleSave(name, newTitle)
422
- }, [name, debouncedTitleSave])
423
-
424
- const handleTitleKeyDown = useCallback((e) => {
425
- if (e.key === 'Enter') {
426
- e.target.blur()
427
- }
428
- e.stopPropagation()
429
- }, [])
430
-
431
582
  const handleWidgetUpdate = useCallback((widgetId, updates) => {
432
583
  undoRedo.snapshot(stateRef.current, 'edit', widgetId)
433
584
  // Snap width/height to grid when snap is enabled
@@ -441,20 +592,20 @@ export default function CanvasPage({ name }) {
441
592
  const next = prev.map((w) =>
442
593
  w.id === widgetId ? { ...w, props: { ...w.props, ...snapped } } : w
443
594
  )
444
- debouncedSave(name, next)
595
+ debouncedSave(canvasId, next)
445
596
  return next
446
597
  })
447
- }, [name, debouncedSave, undoRedo, snapEnabled, snapGridSize])
598
+ }, [canvasId, debouncedSave, undoRedo, snapEnabled, snapGridSize])
448
599
 
449
600
  const handleWidgetRemove = useCallback((widgetId) => {
450
601
  undoRedo.snapshot(stateRef.current, 'remove', widgetId)
451
602
  setLocalWidgets((prev) => prev ? prev.filter((w) => w.id !== widgetId) : prev)
452
603
  queueWrite(() =>
453
- removeWidgetApi(name, widgetId).catch((err) =>
604
+ removeWidgetApi(canvasId, widgetId).catch((err) =>
454
605
  console.error('[canvas] Failed to remove widget:', err)
455
606
  )
456
607
  )
457
- }, [name, undoRedo])
608
+ }, [canvasId, undoRedo])
458
609
 
459
610
  const handleWidgetCopy = useCallback(async (widget) => {
460
611
  // Find the next free offset — check how many copies already exist at +n*40
@@ -470,7 +621,7 @@ export default function CanvasPage({ name }) {
470
621
  const position = { x: baseX + n * 40, y: baseY + n * 40 }
471
622
  try {
472
623
  undoRedo.snapshot(stateRef.current, 'add')
473
- const result = await addWidgetApi(name, {
624
+ const result = await addWidgetApi(canvasId, {
474
625
  type: widget.type,
475
626
  props: { ...widget.props },
476
627
  position,
@@ -481,11 +632,63 @@ export default function CanvasPage({ name }) {
481
632
  } catch (err) {
482
633
  console.error('[canvas] Failed to copy widget:', err)
483
634
  }
484
- }, [name, localWidgets, undoRedo])
635
+ }, [canvasId, localWidgets, undoRedo])
636
+
637
+ const showMissingGhBanner = useCallback(() => {
638
+ setShowGhInstallBanner(true)
639
+ }, [])
640
+
641
+ const buildGitHubPreviewUpdates = useCallback(async (url) => {
642
+ try {
643
+ const availability = await checkGitHubCliAvailable()
644
+ if (!availability?.available) {
645
+ showMissingGhBanner()
646
+ return null
647
+ }
648
+
649
+ const result = await fetchGitHubEmbed(url)
650
+ if (result?.code === 'gh_unavailable') {
651
+ showMissingGhBanner()
652
+ return null
653
+ }
654
+ if (!result?.success || !result?.snapshot) return null
655
+
656
+ const snapshot = result.snapshot
657
+ return {
658
+ title: snapshot.title || '',
659
+ width: 580,
660
+ height: 400,
661
+ github: {
662
+ kind: snapshot.kind || 'issue',
663
+ parentKind: snapshot.parentKind || snapshot.kind || 'issue',
664
+ context: snapshot.context || '',
665
+ body: snapshot.body || '',
666
+ bodyHtml: snapshot.bodyHtml || '',
667
+ authors: Array.isArray(snapshot.authors)
668
+ ? snapshot.authors.filter((author) => typeof author === 'string' && author.trim())
669
+ : [],
670
+ createdAt: snapshot.createdAt ?? null,
671
+ updatedAt: snapshot.updatedAt ?? null,
672
+ fetchedAt: new Date().toISOString(),
673
+ },
674
+ }
675
+ } catch (err) {
676
+ console.error('[canvas] Failed to fetch GitHub embed metadata:', err)
677
+ return null
678
+ }
679
+ }, [showMissingGhBanner])
680
+
681
+ const handleRefreshGitHubWidget = useCallback(async (widgetId, url) => {
682
+ if (!widgetId || !url) return { updated: false }
683
+ const updates = await buildGitHubPreviewUpdates(url)
684
+ if (!updates) return { updated: false }
685
+ handleWidgetUpdate(widgetId, updates)
686
+ return { updated: true }
687
+ }, [buildGitHubPreviewUpdates, handleWidgetUpdate])
485
688
 
486
689
  const debouncedSourceSave = useRef(
487
- debounce((canvasName, sources) => {
488
- updateCanvas(canvasName, { sources }).catch((err) =>
690
+ debounce((canvasId, sources) => {
691
+ updateCanvas(canvasId, { sources }).catch((err) =>
489
692
  console.error('[canvas] Failed to save sources:', err)
490
693
  )
491
694
  }, 2000)
@@ -503,10 +706,10 @@ export default function CanvasPage({ name }) {
503
706
  const next = current.some((s) => s?.export === exportName)
504
707
  ? current.map((s) => (s?.export === exportName ? { ...s, ...snapped } : s))
505
708
  : [...current, { export: exportName, ...snapped }]
506
- debouncedSourceSave(name, next)
709
+ debouncedSourceSave(canvasId, next)
507
710
  return next
508
711
  })
509
- }, [name, debouncedSourceSave, undoRedo, snapEnabled, snapGridSize])
712
+ }, [canvasId, debouncedSourceSave, undoRedo, snapEnabled, snapGridSize])
510
713
 
511
714
  const handleItemDragEnd = useCallback((dragId, position) => {
512
715
  if (!dragId || !position) {
@@ -521,7 +724,7 @@ export default function CanvasPage({ name }) {
521
724
  if (ids.size > 1 && ids.has(dragId)) {
522
725
  transitionPeers()
523
726
  // Suppress the click-based selection reset that fires after pointerup
524
- justDraggedRef.current = true
727
+ justDraggedRef.current = true // eslint-disable-line react-hooks/immutability
525
728
  requestAnimationFrame(() => { justDraggedRef.current = false })
526
729
  undoRedo.snapshot(stateRef.current, 'multi-move')
527
730
 
@@ -558,7 +761,7 @@ export default function CanvasPage({ name }) {
558
761
  return w
559
762
  })
560
763
  queueWrite(() =>
561
- updateCanvas(name, { widgets: next }).catch((err) =>
764
+ updateCanvas(canvasId, { widgets: next }).catch((err) =>
562
765
  console.error('[canvas] Failed to save multi-move:', err)
563
766
  )
564
767
  )
@@ -590,7 +793,7 @@ export default function CanvasPage({ name }) {
590
793
  })
591
794
  if (changed) {
592
795
  queueWrite(() =>
593
- updateCanvas(name, { sources: next }).catch((err) =>
796
+ updateCanvas(canvasId, { sources: next }).catch((err) =>
594
797
  console.error('[canvas] Failed to save multi-move sources:', err)
595
798
  )
596
799
  )
@@ -609,7 +812,7 @@ export default function CanvasPage({ name }) {
609
812
  ? current.map((s) => (s?.export === sourceExport ? { ...s, position: rounded } : s))
610
813
  : [...current, { export: sourceExport, position: rounded }]
611
814
  queueWrite(() =>
612
- updateCanvas(name, { sources: next }).catch((err) =>
815
+ updateCanvas(canvasId, { sources: next }).catch((err) =>
613
816
  console.error('[canvas] Failed to save source position:', err)
614
817
  )
615
818
  )
@@ -625,28 +828,65 @@ export default function CanvasPage({ name }) {
625
828
  w.id === dragId ? { ...w, position: rounded } : w
626
829
  )
627
830
  queueWrite(() =>
628
- updateCanvas(name, { widgets: next }).catch((err) =>
831
+ updateCanvas(canvasId, { widgets: next }).catch((err) =>
629
832
  console.error('[canvas] Failed to save widget position:', err)
630
833
  )
631
834
  )
632
835
  return next
633
836
  })
634
- }, [name, undoRedo, debouncedSave, transitionPeers, clearDragPreview])
837
+ }, [canvasId, undoRedo, debouncedSave, transitionPeers, clearDragPreview])
635
838
 
839
+ // Keep zoomRef in sync when React state is set (e.g. by toolbar or zoom-to-fit)
636
840
  useEffect(() => {
637
841
  zoomRef.current = zoom
638
842
  }, [zoom])
639
843
 
640
- // Restore scroll position from localStorage after first render
844
+ // Cleanup zoom timers on unmount
845
+ useEffect(() => () => {
846
+ clearTimeout(zoomCommitTimer.current)
847
+ clearTimeout(zoomEventTimer.current)
848
+ }, [])
849
+
850
+ // Restore scroll position from localStorage after first render.
851
+ // When saved state is fresh (< 15 min), restore it. Otherwise zoom-to-fit
852
+ // all objects so the user sees a useful overview instead of stale coordinates.
641
853
  useEffect(() => {
642
854
  const el = scrollRef.current
855
+ if (!el || loading) return
643
856
  const saved = pendingScrollRestore.current
644
- if (el && saved) {
857
+ if (saved) {
858
+ // Fresh saved viewport — restore exactly
645
859
  if (saved.scrollLeft != null) el.scrollLeft = saved.scrollLeft
646
860
  if (saved.scrollTop != null) el.scrollTop = saved.scrollTop
647
861
  pendingScrollRestore.current = null
862
+ } else {
863
+ // No saved state or stale — zoom-to-fit all objects
864
+ const bounds = computeCanvasBounds(localWidgets, componentEntries)
865
+ if (bounds && el.clientWidth > 0 && el.clientHeight > 0) {
866
+ const boxW = bounds.maxX - bounds.minX + FIT_PADDING * 2
867
+ const boxH = bounds.maxY - bounds.minY + FIT_PADDING * 2
868
+ const fitScale = Math.min(el.clientWidth / boxW, el.clientHeight / boxH)
869
+ const fitZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(fitScale * 100)))
870
+ const newScale = fitZoom / 100
871
+ zoomRef.current = fitZoom
872
+ // Imperative DOM update for initial zoom-to-fit — same path as applyZoom
873
+ const zoomEl = zoomElRef.current
874
+ if (zoomEl) {
875
+ zoomEl.style.transform = `scale(${newScale})`
876
+ zoomEl.style.width = `${Math.max(10000, 100 / newScale)}vw`
877
+ zoomEl.style.height = `${Math.max(10000, 100 / newScale)}vh`
878
+ }
879
+ setZoom(fitZoom)
880
+ el.scrollLeft = (bounds.minX - FIT_PADDING) * newScale
881
+ el.scrollTop = (bounds.minY - FIT_PADDING) * newScale
882
+ } else {
883
+ el.scrollLeft = 0
884
+ el.scrollTop = 0
885
+ }
648
886
  }
649
- }, [name, loading])
887
+ // Allow save effects for this canvas now that positioning is settled.
888
+ viewportInitName.current = canvasId
889
+ }, [canvasId, loading])
650
890
 
651
891
  // Center on a specific widget if `?widget=<id>` is in the URL
652
892
  useEffect(() => {
@@ -673,16 +913,13 @@ export default function CanvasPage({ name }) {
673
913
  // Check JSX sources (jsx-ExportName)
674
914
  if (!widget && targetId.startsWith('jsx-')) {
675
915
  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)) {
916
+ const entry = componentEntries.find((e) => e.exportName === exportName)
917
+ if (entry) {
681
918
  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
919
+ x = entry.sourceData?.position?.x ?? 0
920
+ y = entry.sourceData?.position?.y ?? 0
921
+ w = entry.sourceData?.width ?? fallback.width
922
+ h = entry.sourceData?.height ?? fallback.height
686
923
  }
687
924
  }
688
925
 
@@ -696,57 +933,78 @@ export default function CanvasPage({ name }) {
696
933
  const url = new URL(window.location.href)
697
934
  url.searchParams.delete('widget')
698
935
  window.history.replaceState({}, '', url.toString())
699
- }, [loading, localWidgets, localSources, jsxExports])
936
+ }, [loading, localWidgets, componentEntries])
700
937
 
701
- // Persist viewport state (zoom + scroll) to localStorage on changes
938
+ // Persist viewport state (zoom only) to localStorage on zoom changes.
939
+ // Scroll position is persisted separately by the debounced scroll handler,
940
+ // cleanup handler, and beforeunload — never here, because imperative zoom
941
+ // operations (applyZoom, zoom-to-fit) adjust scroll AFTER setZoom, so the
942
+ // scroll values would be stale at this point.
702
943
  useEffect(() => {
944
+ if (viewportInitName.current !== canvasId) return
703
945
  const el = scrollRef.current
704
- saveViewportState(name, {
946
+ // Read current scroll so the zoom entry doesn't zero-out position,
947
+ // but the authoritative scroll save comes from the scroll handler.
948
+ saveViewportState(canvasId, {
705
949
  zoom,
706
950
  scrollLeft: el?.scrollLeft ?? 0,
707
951
  scrollTop: el?.scrollTop ?? 0,
708
952
  })
709
- }, [name, zoom])
953
+ }, [canvasId, zoom])
710
954
 
711
955
  useEffect(() => {
712
956
  const el = scrollRef.current
713
957
  if (!el) return
714
- function handleScroll() {
715
- saveViewportState(name, {
958
+ const saveNow = () => {
959
+ if (viewportInitName.current !== canvasId) return
960
+ saveViewportState(canvasId, {
716
961
  zoom: zoomRef.current,
717
962
  scrollLeft: el.scrollLeft,
718
963
  scrollTop: el.scrollTop,
719
964
  })
720
965
  }
966
+ const debouncedScrollSave = debounce(saveNow, 150)
967
+ function handleScroll() {
968
+ if (viewportInitName.current !== canvasId) return
969
+ debouncedScrollSave()
970
+ }
721
971
  el.addEventListener('scroll', handleScroll, { passive: true })
722
972
 
723
973
  // Flush viewport state on page unload so a refresh never misses it
724
974
  function handleBeforeUnload() {
725
- saveViewportState(name, {
726
- zoom: zoomRef.current,
727
- scrollLeft: el.scrollLeft,
728
- scrollTop: el.scrollTop,
729
- })
975
+ debouncedScrollSave.cancel()
976
+ saveNow()
730
977
  }
731
978
  window.addEventListener('beforeunload', handleBeforeUnload)
732
979
 
733
980
  return () => {
981
+ debouncedScrollSave.cancel()
734
982
  el.removeEventListener('scroll', handleScroll)
735
983
  window.removeEventListener('beforeunload', handleBeforeUnload)
984
+ // Save final state on cleanup (covers SPA navigation where
985
+ // beforeunload doesn't fire).
986
+ saveNow()
736
987
  }
737
- }, [name, loading])
988
+ }, [canvasId, loading])
738
989
 
739
990
  /**
740
991
  * Zoom to a new level, anchoring on an optional client-space point.
741
992
  * When a cursor position is provided (e.g. from a wheel event), the
742
993
  * canvas point under the cursor stays fixed. Otherwise falls back to
743
994
  * the viewport center.
995
+ *
996
+ * Performs an imperative DOM mutation instead of a React state update
997
+ * to avoid triggering a full re-render of the widget tree on every
998
+ * zoom tick. React state is committed after a debounce for toolbar
999
+ * display updates.
744
1000
  */
745
1001
  function applyZoom(newZoom, clientX, clientY) {
746
1002
  const el = scrollRef.current
1003
+ const zoomEl = zoomElRef.current
747
1004
  const clampedZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, newZoom))
748
1005
 
749
- if (!el) {
1006
+ if (!el || !zoomEl) {
1007
+ zoomRef.current = clampedZoom
750
1008
  setZoom(clampedZoom)
751
1009
  return
752
1010
  }
@@ -764,24 +1022,48 @@ export default function CanvasPage({ name }) {
764
1022
  const canvasX = (el.scrollLeft + anchorX) / oldScale
765
1023
  const canvasY = (el.scrollTop + anchorY) / oldScale
766
1024
 
767
- // Synchronous render so the DOM has the new transform before we adjust scroll
1025
+ // Imperative DOM update no React re-render
768
1026
  zoomRef.current = clampedZoom
769
- flushSync(() => setZoom(clampedZoom))
1027
+ zoomEl.style.transform = `scale(${newScale})`
1028
+ zoomEl.style.width = `${Math.max(10000, 100 / newScale)}vw`
1029
+ zoomEl.style.height = `${Math.max(10000, 100 / newScale)}vh`
1030
+
1031
+ // Hint GPU compositing during active zoom
1032
+ zoomEl.dataset.zooming = ''
770
1033
 
771
1034
  // Scroll so the same canvas point stays under the anchor
772
1035
  el.scrollLeft = canvasX * newScale - anchorX
773
1036
  el.scrollTop = canvasY * newScale - anchorY
1037
+
1038
+ // Debounced commit: update React state for toolbar display + persistence
1039
+ clearTimeout(zoomCommitTimer.current)
1040
+ zoomCommitTimer.current = setTimeout(() => {
1041
+ // Remove GPU compositing hint
1042
+ delete zoomEl.dataset.zooming
1043
+ setZoom(clampedZoom)
1044
+ }, 150)
1045
+
1046
+ // Throttled zoom-changed event for external consumers (toolbar)
1047
+ if (!zoomEventTimer.current) {
1048
+ zoomEventTimer.current = setTimeout(() => {
1049
+ zoomEventTimer.current = null
1050
+ window[CANVAS_BRIDGE_STATE_KEY] = { active: true, canvasId, zoom: zoomRef.current }
1051
+ document.dispatchEvent(new CustomEvent('storyboard:canvas:zoom-changed', {
1052
+ detail: { zoom: zoomRef.current }
1053
+ }))
1054
+ }, 100)
1055
+ }
774
1056
  }
775
1057
 
776
1058
  // Signal canvas mount/unmount to CoreUIBar
777
1059
  useEffect(() => {
778
- window[CANVAS_BRIDGE_STATE_KEY] = { active: true, name, zoom: zoomRef.current }
1060
+ window[CANVAS_BRIDGE_STATE_KEY] = { active: true, canvasId, zoom: zoomRef.current }
779
1061
  document.dispatchEvent(new CustomEvent('storyboard:canvas:mounted', {
780
- detail: { name, zoom: zoomRef.current }
1062
+ detail: { canvasId, zoom: zoomRef.current }
781
1063
  }))
782
1064
 
783
1065
  function handleStatusRequest() {
784
- const state = window[CANVAS_BRIDGE_STATE_KEY] || { active: true, name, zoom: zoomRef.current }
1066
+ const state = window[CANVAS_BRIDGE_STATE_KEY] || { active: true, canvasId, zoom: zoomRef.current }
785
1067
  document.dispatchEvent(new CustomEvent('storyboard:canvas:status', { detail: state }))
786
1068
  }
787
1069
 
@@ -789,10 +1071,10 @@ export default function CanvasPage({ name }) {
789
1071
 
790
1072
  return () => {
791
1073
  document.removeEventListener('storyboard:canvas:status-request', handleStatusRequest)
792
- window[CANVAS_BRIDGE_STATE_KEY] = { active: false, name: '', zoom: 100 }
1074
+ window[CANVAS_BRIDGE_STATE_KEY] = { active: false, canvasId: '', zoom: 100 }
793
1075
  document.dispatchEvent(new CustomEvent('storyboard:canvas:unmounted'))
794
1076
  }
795
- }, [name])
1077
+ }, [canvasId])
796
1078
 
797
1079
  // Tell the Vite dev server to suppress full-reloads while this canvas is active.
798
1080
  // The ?canvas-hmr URL param opts out of the guard for canvas UI development.
@@ -812,7 +1094,7 @@ export default function CanvasPage({ name }) {
812
1094
  clearInterval(interval)
813
1095
  import.meta.hot.send('storyboard:canvas-hmr-guard', { active: false, hmrEnabled: true })
814
1096
  }
815
- }, [name])
1097
+ }, [canvasId])
816
1098
 
817
1099
  // Add a widget by type — used by CanvasControls and CoreUIBar event
818
1100
  const addWidget = useCallback(async (type) => {
@@ -820,7 +1102,7 @@ export default function CanvasPage({ name }) {
820
1102
  const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
821
1103
  const pos = centerPositionForWidget(center, type, defaultProps)
822
1104
  try {
823
- const result = await addWidgetApi(name, {
1105
+ const result = await addWidgetApi(canvasId, {
824
1106
  type,
825
1107
  props: defaultProps,
826
1108
  position: pos,
@@ -832,16 +1114,43 @@ export default function CanvasPage({ name }) {
832
1114
  } catch (err) {
833
1115
  console.error('[canvas] Failed to add widget:', err)
834
1116
  }
835
- }, [name, undoRedo])
1117
+ }, [canvasId, undoRedo])
1118
+
1119
+ // Add a story widget by storyId — used by CanvasControls story picker
1120
+ const addStoryWidget = useCallback(async (storyId) => {
1121
+ const storyProps = { storyId, exportName: '', width: 600, height: 400 }
1122
+ const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
1123
+ const pos = centerPositionForWidget(center, 'story', storyProps)
1124
+ try {
1125
+ const result = await addWidgetApi(canvasId, {
1126
+ type: 'story',
1127
+ props: storyProps,
1128
+ position: pos,
1129
+ })
1130
+ if (result.success && result.widget) {
1131
+ undoRedo.snapshot(stateRef.current, 'add')
1132
+ setLocalWidgets((prev) => [...(prev || []), result.widget])
1133
+ }
1134
+ } catch (err) {
1135
+ console.error('[canvas] Failed to add story widget:', err)
1136
+ }
1137
+ }, [canvasId, undoRedo])
836
1138
 
837
1139
  // Listen for CoreUIBar add-widget events
838
1140
  useEffect(() => {
839
1141
  function handleAddWidget(e) {
840
1142
  addWidget(e.detail.type)
841
1143
  }
1144
+ function handleAddStoryWidget(e) {
1145
+ addStoryWidget(e.detail.storyId)
1146
+ }
842
1147
  document.addEventListener('storyboard:canvas:add-widget', handleAddWidget)
843
- return () => document.removeEventListener('storyboard:canvas:add-widget', handleAddWidget)
844
- }, [addWidget])
1148
+ document.addEventListener('storyboard:canvas:add-story-widget', handleAddStoryWidget)
1149
+ return () => {
1150
+ document.removeEventListener('storyboard:canvas:add-widget', handleAddWidget)
1151
+ document.removeEventListener('storyboard:canvas:add-story-widget', handleAddStoryWidget)
1152
+ }
1153
+ }, [addWidget, addStoryWidget])
845
1154
 
846
1155
  // Listen for zoom changes from CoreUIBar
847
1156
  useEffect(() => {
@@ -860,7 +1169,7 @@ export default function CanvasPage({ name }) {
860
1169
  function handleSnapToggle() {
861
1170
  setSnapEnabled((prev) => {
862
1171
  const next = !prev
863
- updateCanvas(name, { snapToGrid: next }).catch((err) =>
1172
+ updateCanvas(canvasId, { settings: { snapToGrid: next } }).catch((err) =>
864
1173
  console.error('[canvas] Failed to persist snap setting:', err)
865
1174
  )
866
1175
  return next
@@ -868,15 +1177,27 @@ export default function CanvasPage({ name }) {
868
1177
  }
869
1178
  document.addEventListener('storyboard:canvas:toggle-snap', handleSnapToggle)
870
1179
  return () => document.removeEventListener('storyboard:canvas:toggle-snap', handleSnapToggle)
871
- }, [name])
1180
+ }, [canvasId])
872
1181
 
873
1182
  // Broadcast snap state to Svelte toolbar
874
1183
  useEffect(() => {
875
1184
  document.dispatchEvent(new CustomEvent('storyboard:canvas:snap-state', {
876
1185
  detail: { snapEnabled }
877
1186
  }))
1187
+ snapEnabledRef.current = snapEnabled
878
1188
  }, [snapEnabled])
879
1189
 
1190
+ // Respond to snap-state requests from Svelte toolbar (handles mount-order race)
1191
+ useEffect(() => {
1192
+ function handleRequest() {
1193
+ document.dispatchEvent(new CustomEvent('storyboard:canvas:snap-state', {
1194
+ detail: { snapEnabled: snapEnabledRef.current }
1195
+ }))
1196
+ }
1197
+ document.addEventListener('storyboard:canvas:snap-state-request', handleRequest)
1198
+ return () => document.removeEventListener('storyboard:canvas:snap-state-request', handleRequest)
1199
+ }, [])
1200
+
880
1201
  // Listen for gridSize from Svelte toolbar config
881
1202
  useEffect(() => {
882
1203
  function handleGridSize(e) {
@@ -887,13 +1208,18 @@ export default function CanvasPage({ name }) {
887
1208
  return () => document.removeEventListener('storyboard:canvas:grid-size', handleGridSize)
888
1209
  }, [])
889
1210
 
1211
+ // Keep snapGridSize ref in sync for drop handler
1212
+ useEffect(() => {
1213
+ snapGridSizeRef.current = snapGridSize
1214
+ }, [snapGridSize])
1215
+
890
1216
  // Listen for zoom-to-fit from CoreUIBar
891
1217
  useEffect(() => {
892
1218
  function handleZoomToFit() {
893
1219
  const el = scrollRef.current
894
1220
  if (!el) return
895
1221
 
896
- const bounds = computeCanvasBounds(localWidgets, localSources, jsxExports)
1222
+ const bounds = computeCanvasBounds(localWidgets, componentEntries)
897
1223
  if (!bounds) return
898
1224
 
899
1225
  const boxW = bounds.maxX - bounds.minX + FIT_PADDING * 2
@@ -907,17 +1233,32 @@ export default function CanvasPage({ name }) {
907
1233
  const fitZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(fitScale * 100)))
908
1234
  const newScale = fitZoom / 100
909
1235
 
910
- // Apply zoom synchronously so DOM updates before we scroll
1236
+ // Imperative DOM update same path as applyZoom
911
1237
  zoomRef.current = fitZoom
912
- flushSync(() => setZoom(fitZoom))
1238
+ const zoomEl = zoomElRef.current
1239
+ if (zoomEl) {
1240
+ zoomEl.style.transform = `scale(${newScale})`
1241
+ zoomEl.style.width = `${Math.max(10000, 100 / newScale)}vw`
1242
+ zoomEl.style.height = `${Math.max(10000, 100 / newScale)}vh`
1243
+ }
1244
+ setZoom(fitZoom)
913
1245
 
914
1246
  // Scroll so the bounding box top-left (with padding) is at viewport top-left
915
1247
  el.scrollLeft = (bounds.minX - FIT_PADDING) * newScale
916
1248
  el.scrollTop = (bounds.minY - FIT_PADDING) * newScale
1249
+
1250
+ // Persist after both zoom and scroll are settled
1251
+ if (viewportInitName.current === canvasId) {
1252
+ saveViewportState(canvasId, {
1253
+ zoom: fitZoom,
1254
+ scrollLeft: el.scrollLeft,
1255
+ scrollTop: el.scrollTop,
1256
+ })
1257
+ }
917
1258
  }
918
1259
  document.addEventListener('storyboard:canvas:zoom-to-fit', handleZoomToFit)
919
1260
  return () => document.removeEventListener('storyboard:canvas:zoom-to-fit', handleZoomToFit)
920
- }, [localWidgets, localSources, jsxExports])
1261
+ }, [localWidgets, componentEntries])
921
1262
 
922
1263
  // Canvas background should follow toolbar theme target.
923
1264
  useEffect(() => {
@@ -932,11 +1273,11 @@ export default function CanvasPage({ name }) {
932
1273
 
933
1274
  // Broadcast zoom level to CoreUIBar whenever it changes
934
1275
  useEffect(() => {
935
- window[CANVAS_BRIDGE_STATE_KEY] = { active: true, name, zoom }
1276
+ window[CANVAS_BRIDGE_STATE_KEY] = { active: true, canvasId, zoom }
936
1277
  document.dispatchEvent(new CustomEvent('storyboard:canvas:zoom-changed', {
937
1278
  detail: { zoom }
938
1279
  }))
939
- }, [name, zoom])
1280
+ }, [canvasId, zoom])
940
1281
 
941
1282
  // Delete selected widget on Delete/Backspace key
942
1283
  useEffect(() => {
@@ -958,6 +1299,14 @@ export default function CanvasPage({ name }) {
958
1299
  e.preventDefault()
959
1300
  setSelectedWidgetIds(new Set())
960
1301
  }
1302
+ // Copy shortcut (single widget selected):
1303
+ // cmd+c → copy canvasId::widgetId (for cross-canvas paste-duplicate)
1304
+ const mod = e.metaKey || e.ctrlKey
1305
+ if (mod && e.key === 'c' && !e.shiftKey && selectedWidgetIds.size === 1) {
1306
+ const widgetId = [...selectedWidgetIds][0]
1307
+ e.preventDefault()
1308
+ navigator.clipboard.writeText(`${canvasId}::${widgetId}`).catch(() => {})
1309
+ }
961
1310
  if (e.key === 'Delete' || e.key === 'Backspace') {
962
1311
  e.preventDefault()
963
1312
  if (selectedWidgetIds.size > 1) {
@@ -968,7 +1317,7 @@ export default function CanvasPage({ name }) {
968
1317
  if (!prev) return prev
969
1318
  const next = prev.filter(w => !selectedWidgetIds.has(w.id))
970
1319
  queueWrite(() =>
971
- updateCanvas(name, { widgets: next }).catch(err =>
1320
+ updateCanvas(canvasId, { widgets: next }).catch(err =>
972
1321
  console.error('[canvas] Failed to save multi-delete:', err)
973
1322
  )
974
1323
  )
@@ -983,50 +1332,17 @@ export default function CanvasPage({ name }) {
983
1332
  }
984
1333
  document.addEventListener('keydown', handleKeyDown)
985
1334
  return () => document.removeEventListener('keydown', handleKeyDown)
986
- }, [selectedWidgetIds, handleWidgetRemove, undoRedo, name, debouncedSave])
1335
+ }, [selectedWidgetIds, localWidgets, handleWidgetRemove, undoRedo, canvasId, debouncedSave])
1336
+
1337
+ // Ref to store processImageFile for use by drop effect
1338
+ const processImageFileRef = useRef(null)
987
1339
 
988
- // Paste handler — images become image widgets, same-origin URLs become prototypes,
1340
+ // Paste and drop handler — images become image widgets, same-origin URLs become prototypes,
989
1341
  // other URLs become link previews, text becomes markdown
990
1342
  useEffect(() => {
991
1343
  const origin = window.location.origin
992
1344
  const basePath = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
993
- const baseUrl = origin + basePath
994
-
995
- // Check if a URL is same-origin, accounting for branch-deploy prefixes.
996
- // e.g. https://site.com/branch--my-feature/Proto and https://site.com/storyboard/Proto
997
- // are both same-origin prototype URLs.
998
- function isSameOriginPrototype(url) {
999
- if (!url.startsWith(origin)) return false
1000
- if (url.startsWith(baseUrl)) return true
1001
- // Match branch deploy URLs: origin + /branch--*/...
1002
- const pathAfterOrigin = url.slice(origin.length)
1003
- return BRANCH_PREFIX_RE.test(pathAfterOrigin)
1004
- }
1005
-
1006
- // Strip the base path (or any branch prefix) from a pathname to get a portable src.
1007
- function extractPrototypeSrc(pathname) {
1008
- // Strip current base path
1009
- if (basePath && pathname.startsWith(basePath)) {
1010
- return pathname.slice(basePath.length) || '/'
1011
- }
1012
- // Strip branch prefix: /branch--name/rest → /rest
1013
- const branchMatch = pathname.match(BRANCH_PREFIX_RE)
1014
- if (branchMatch) {
1015
- return pathname.slice(branchMatch[0].length) || '/'
1016
- }
1017
- return pathname
1018
- }
1019
-
1020
- /** Parse text as a web URL (http/https only). Returns URL object or null. */
1021
- function looksLikeWebUrl(text) {
1022
- try {
1023
- const url = new URL(text)
1024
- if (url.protocol === 'http:' || url.protocol === 'https:') return url
1025
- return null
1026
- } catch {
1027
- return null
1028
- }
1029
- }
1345
+ const pasteCtx = createPasteContext(origin, basePath)
1030
1346
 
1031
1347
  function blobToDataUrl(blob) {
1032
1348
  return new Promise((resolve, reject) => {
@@ -1046,6 +1362,59 @@ export default function CanvasPage({ name }) {
1046
1362
  })
1047
1363
  }
1048
1364
 
1365
+ /**
1366
+ * Process an image file (from paste or drop) and add it as a widget.
1367
+ * @param {File|Blob} file - Image file to process
1368
+ * @param {{ x: number, y: number }|null} position - Drop position, or null to use viewport center
1369
+ */
1370
+ async function processImageFile(file, position = null) {
1371
+ try {
1372
+ const dataUrl = await blobToDataUrl(file)
1373
+ const { width: natW, height: natH } = await getImageDimensions(dataUrl)
1374
+
1375
+ // Display at 2x retina: halve natural dimensions, then cap at 600px
1376
+ const maxWidth = 600
1377
+ let displayW = Math.round(natW / 2)
1378
+ let displayH = Math.round(natH / 2)
1379
+ if (displayW > maxWidth) {
1380
+ displayH = Math.round(displayH * (maxWidth / displayW))
1381
+ displayW = maxWidth
1382
+ }
1383
+
1384
+ const uploadResult = await uploadImage(dataUrl, canvasId)
1385
+ if (!uploadResult.success) {
1386
+ console.error('[canvas] Image upload failed:', uploadResult.error)
1387
+ return false
1388
+ }
1389
+
1390
+ // Use provided position or fall back to viewport center
1391
+ let pos
1392
+ if (position) {
1393
+ pos = { x: position.x, y: position.y }
1394
+ } else {
1395
+ const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
1396
+ pos = centerPositionForWidget(center, 'image', { width: displayW, height: displayH })
1397
+ }
1398
+
1399
+ const result = await addWidgetApi(canvasId, {
1400
+ type: 'image',
1401
+ props: { src: uploadResult.filename, private: false, width: displayW, height: displayH },
1402
+ position: pos,
1403
+ })
1404
+ if (result.success && result.widget) {
1405
+ undoRedo.snapshot(stateRef.current, 'add')
1406
+ setLocalWidgets((prev) => [...(prev || []), result.widget])
1407
+ }
1408
+ return true
1409
+ } catch (err) {
1410
+ console.error('[canvas] Failed to process image:', err)
1411
+ return false
1412
+ }
1413
+ }
1414
+
1415
+ // Store in ref for use by drag/drop effect
1416
+ processImageFileRef.current = processImageFile
1417
+
1049
1418
  async function handleImagePaste(e) {
1050
1419
  const items = e.clipboardData?.items
1051
1420
  if (!items) return false
@@ -1057,40 +1426,7 @@ export default function CanvasPage({ name }) {
1057
1426
  if (!blob) continue
1058
1427
 
1059
1428
  e.preventDefault()
1060
-
1061
- try {
1062
- const dataUrl = await blobToDataUrl(blob)
1063
- const { width: natW, height: natH } = await getImageDimensions(dataUrl)
1064
-
1065
- // Display at 2x retina: halve natural dimensions, then cap at 600px
1066
- const maxWidth = 600
1067
- let displayW = Math.round(natW / 2)
1068
- let displayH = Math.round(natH / 2)
1069
- if (displayW > maxWidth) {
1070
- displayH = Math.round(displayH * (maxWidth / displayW))
1071
- displayW = maxWidth
1072
- }
1073
-
1074
- const uploadResult = await uploadImage(dataUrl, name)
1075
- if (!uploadResult.success) {
1076
- console.error('[canvas] Image upload failed:', uploadResult.error)
1077
- return true
1078
- }
1079
-
1080
- const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
1081
- const pos = centerPositionForWidget(center, 'image', { width: displayW, height: displayH })
1082
- const result = await addWidgetApi(name, {
1083
- type: 'image',
1084
- props: { src: uploadResult.filename, private: false, width: displayW, height: displayH },
1085
- position: pos,
1086
- })
1087
- if (result.success && result.widget) {
1088
- undoRedo.snapshot(stateRef.current, 'add')
1089
- setLocalWidgets((prev) => [...(prev || []), result.widget])
1090
- }
1091
- } catch (err) {
1092
- console.error('[canvas] Failed to paste image:', err)
1093
- }
1429
+ await processImageFile(blob, null)
1094
1430
  return true
1095
1431
  }
1096
1432
  return false
@@ -1107,32 +1443,58 @@ export default function CanvasPage({ name }) {
1107
1443
  const text = e.clipboardData?.getData('text/plain')?.trim()
1108
1444
  if (!text) return
1109
1445
 
1110
- e.preventDefault()
1111
-
1112
- let type, props
1113
- const url = looksLikeWebUrl(text)
1114
- if (url) {
1115
- if (isFigmaUrl(text)) {
1116
- type = 'figma-embed'
1117
- props = { url: sanitizeFigmaUrl(text), width: 800, height: 450 }
1118
- } else if (isSameOriginPrototype(text)) {
1119
- const pathPortion = url.pathname + url.search + url.hash
1120
- const src = extractPrototypeSrc(pathPortion)
1121
- type = 'prototype'
1122
- props = { src: src || '/', originalSrc: src || '/', label: '', width: 800, height: 600 }
1123
- } else {
1124
- type = 'link-preview'
1125
- props = { url: text, title: '' }
1446
+ // Detect canvasId::widgetId format for widget duplication (cross-canvas copy-paste)
1447
+ // Also supports legacy canvasId/widgetId for basenames without slashes,
1448
+ // but only when the second segment looks like a widget ID (type-hash).
1449
+ const widgetRefMatch = text.match(/^(.+)::([^:]+)$/) || (text.indexOf('::') === -1 && text.match(/^([^/]+)\/((?:sticky-note|markdown|prototype|link-preview|figma-embed|component|image)-[a-z0-9]+)$/))
1450
+ if (widgetRefMatch) {
1451
+ e.preventDefault()
1452
+ const [, sourceCanvas, sourceWidgetId] = widgetRefMatch
1453
+ // Component widgets are code, not duplicable data silently consume the ref
1454
+ if (sourceWidgetId.startsWith('jsx-')) return
1455
+ try {
1456
+ let sourceWidget = null
1457
+ if (sourceCanvas === canvasId) {
1458
+ sourceWidget = (localWidgets ?? []).find(w => w.id === sourceWidgetId)
1459
+ } else {
1460
+ const canvasData = await getCanvasApi(sourceCanvas)
1461
+ sourceWidget = (canvasData?.widgets ?? []).find(w => w.id === sourceWidgetId)
1462
+ }
1463
+ if (sourceWidget) {
1464
+ const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
1465
+ const pos = centerPositionForWidget(center, sourceWidget.type, sourceWidget.props)
1466
+ undoRedo.snapshot(stateRef.current, 'add')
1467
+ const result = await addWidgetApi(canvasId, {
1468
+ type: sourceWidget.type,
1469
+ props: { ...sourceWidget.props },
1470
+ position: pos,
1471
+ })
1472
+ if (result.success && result.widget) {
1473
+ setLocalWidgets((prev) => [...(prev || []), result.widget])
1474
+ }
1475
+ }
1476
+ } catch (err) {
1477
+ console.error('[canvas] Failed to paste widget reference:', err)
1126
1478
  }
1127
- } else {
1128
- type = 'markdown'
1129
- props = { content: text }
1479
+ // Always consume the ref — never fall through to markdown creation
1480
+ return
1481
+ }
1482
+
1483
+ e.preventDefault()
1484
+ const resolved = resolvePaste(text, pasteCtx, getPasteRules())
1485
+ if (!resolved) return
1486
+ const { type } = resolved
1487
+ let props = resolved.props
1488
+
1489
+ if (type === 'link-preview' && isGitHubEmbedUrl(props?.url || text)) {
1490
+ const githubUpdates = await buildGitHubPreviewUpdates(props?.url || text)
1491
+ if (githubUpdates) props = { ...props, ...githubUpdates }
1130
1492
  }
1131
1493
 
1132
1494
  const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
1133
1495
  const pos = centerPositionForWidget(center, type, props)
1134
1496
  try {
1135
- const result = await addWidgetApi(name, {
1497
+ const result = await addWidgetApi(canvasId, {
1136
1498
  type,
1137
1499
  props,
1138
1500
  position: pos,
@@ -1145,9 +1507,75 @@ export default function CanvasPage({ name }) {
1145
1507
  console.error('[canvas] Failed to add widget from paste:', err)
1146
1508
  }
1147
1509
  }
1510
+
1148
1511
  document.addEventListener('paste', handlePaste)
1149
1512
  return () => document.removeEventListener('paste', handlePaste)
1150
- }, [name, undoRedo])
1513
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1514
+ }, [canvasId, undoRedo, localWidgets])
1515
+
1516
+ // --- Drag and drop handlers for images from Finder/file manager ---
1517
+ // Separate effect to ensure listeners attach after scroll container mounts (loading=false)
1518
+ useEffect(() => {
1519
+ if (loading) return // Don't attach until canvas is loaded and scroll container exists
1520
+
1521
+ const scrollEl = scrollRef.current
1522
+ if (!scrollEl) return
1523
+
1524
+ function handleDragOver(e) {
1525
+ // Only handle if dragging files (not internal widget drag)
1526
+ if (!e.dataTransfer?.types?.includes('Files')) return
1527
+ e.preventDefault()
1528
+ e.dataTransfer.dropEffect = 'copy'
1529
+ }
1530
+
1531
+ async function handleDrop(e) {
1532
+ // Only handle file drops, not internal widget drags
1533
+ if (!e.dataTransfer?.types?.includes('Files')) return
1534
+
1535
+ // Prevent browser default (opening file) immediately for any file drop
1536
+ e.preventDefault()
1537
+ e.stopPropagation()
1538
+
1539
+ const files = e.dataTransfer.files
1540
+ if (!files || files.length === 0) return
1541
+
1542
+ // Filter to image files only — non-images are silently ignored (default already prevented)
1543
+ const imageFiles = Array.from(files).filter((f) => f.type.startsWith('image/'))
1544
+ if (imageFiles.length === 0) return
1545
+
1546
+ // Convert drop coordinates to canvas coordinates
1547
+ const rect = scrollEl.getBoundingClientRect()
1548
+ const scale = zoomRef.current / 100
1549
+
1550
+ // Mouse position relative to scroll container
1551
+ const mouseX = e.clientX - rect.left
1552
+ const mouseY = e.clientY - rect.top
1553
+
1554
+ // Convert to canvas coordinates (account for scroll and zoom)
1555
+ const canvasX = (scrollEl.scrollLeft + mouseX) / scale
1556
+ const canvasY = (scrollEl.scrollTop + mouseY) / scale
1557
+
1558
+ // Snap to grid if enabled, using current grid size
1559
+ const gridSize = snapGridSizeRef.current
1560
+ const shouldSnap = snapEnabledRef.current
1561
+ const snappedX = shouldSnap ? Math.round(canvasX / gridSize) * gridSize : Math.round(canvasX)
1562
+ const snappedY = shouldSnap ? Math.round(canvasY / gridSize) * gridSize : Math.round(canvasY)
1563
+
1564
+ // Process each image file, offsetting subsequent images
1565
+ for (let i = 0; i < imageFiles.length; i++) {
1566
+ const offset = shouldSnap ? i * gridSize : i * 24
1567
+ await processImageFileRef.current?.(imageFiles[i], { x: snappedX + offset, y: snappedY + offset })
1568
+ }
1569
+ }
1570
+
1571
+ scrollEl.addEventListener('dragover', handleDragOver)
1572
+ scrollEl.addEventListener('drop', handleDrop)
1573
+
1574
+ return () => {
1575
+ scrollEl.removeEventListener('dragover', handleDragOver)
1576
+ scrollEl.removeEventListener('drop', handleDrop)
1577
+ }
1578
+ }, [loading])
1151
1579
 
1152
1580
  // --- Undo / Redo ---
1153
1581
  const handleUndo = useCallback(() => {
@@ -1158,11 +1586,11 @@ export default function CanvasPage({ name }) {
1158
1586
  setLocalWidgets(previous.widgets)
1159
1587
  setLocalSources(previous.sources)
1160
1588
  queueWrite(() =>
1161
- updateCanvas(name, { widgets: previous.widgets, sources: previous.sources }).catch((err) =>
1589
+ updateCanvas(canvasId, { widgets: previous.widgets, sources: previous.sources }).catch((err) =>
1162
1590
  console.error('[canvas] Failed to persist undo:', err)
1163
1591
  )
1164
1592
  )
1165
- }, [name, debouncedSave, debouncedSourceSave, undoRedo])
1593
+ }, [canvasId, debouncedSave, debouncedSourceSave, undoRedo])
1166
1594
 
1167
1595
  const handleRedo = useCallback(() => {
1168
1596
  const next = undoRedo.redo(stateRef.current)
@@ -1172,11 +1600,11 @@ export default function CanvasPage({ name }) {
1172
1600
  setLocalWidgets(next.widgets)
1173
1601
  setLocalSources(next.sources)
1174
1602
  queueWrite(() =>
1175
- updateCanvas(name, { widgets: next.widgets, sources: next.sources }).catch((err) =>
1603
+ updateCanvas(canvasId, { widgets: next.widgets, sources: next.sources }).catch((err) =>
1176
1604
  console.error('[canvas] Failed to persist redo:', err)
1177
1605
  )
1178
1606
  )
1179
- }, [name, debouncedSave, debouncedSourceSave, undoRedo])
1607
+ }, [canvasId, debouncedSave, debouncedSourceSave, undoRedo])
1180
1608
 
1181
1609
  // Keyboard shortcuts — dev-only (Cmd+Z / Cmd+Shift+Z)
1182
1610
  useEffect(() => {
@@ -1235,6 +1663,55 @@ export default function CanvasPage({ name }) {
1235
1663
  return () => document.removeEventListener('wheel', handleWheel)
1236
1664
  }, [])
1237
1665
 
1666
+ // Touch pinch-to-zoom for mobile — two-finger pinch zooms the canvas
1667
+ const pinchState = useRef({ active: false, startDist: 0, startZoom: 0, centerX: 0, centerY: 0 })
1668
+ useEffect(() => {
1669
+ const el = scrollRef.current
1670
+ if (!el) return
1671
+
1672
+ function getTouchDist(t1, t2) {
1673
+ const dx = t1.clientX - t2.clientX
1674
+ const dy = t1.clientY - t2.clientY
1675
+ return Math.sqrt(dx * dx + dy * dy)
1676
+ }
1677
+
1678
+ function handleTouchStart(e) {
1679
+ if (e.touches.length !== 2) return
1680
+ const dist = getTouchDist(e.touches[0], e.touches[1])
1681
+ pinchState.current = {
1682
+ active: true,
1683
+ startDist: dist,
1684
+ startZoom: zoomRef.current,
1685
+ centerX: (e.touches[0].clientX + e.touches[1].clientX) / 2,
1686
+ centerY: (e.touches[0].clientY + e.touches[1].clientY) / 2,
1687
+ }
1688
+ }
1689
+
1690
+ function handleTouchMove(e) {
1691
+ if (!pinchState.current.active || e.touches.length !== 2) return
1692
+ e.preventDefault()
1693
+ const dist = getTouchDist(e.touches[0], e.touches[1])
1694
+ const ratio = dist / pinchState.current.startDist
1695
+ const newZoom = Math.round(pinchState.current.startZoom * ratio)
1696
+ applyZoom(newZoom, pinchState.current.centerX, pinchState.current.centerY)
1697
+ }
1698
+
1699
+ function handleTouchEnd() {
1700
+ pinchState.current.active = false
1701
+ }
1702
+
1703
+ el.addEventListener('touchstart', handleTouchStart, { passive: true })
1704
+ el.addEventListener('touchmove', handleTouchMove, { passive: false })
1705
+ el.addEventListener('touchend', handleTouchEnd)
1706
+ el.addEventListener('touchcancel', handleTouchEnd)
1707
+ return () => {
1708
+ el.removeEventListener('touchstart', handleTouchStart)
1709
+ el.removeEventListener('touchmove', handleTouchMove)
1710
+ el.removeEventListener('touchend', handleTouchEnd)
1711
+ el.removeEventListener('touchcancel', handleTouchEnd)
1712
+ }
1713
+ }, [])
1714
+
1238
1715
  // Space + drag to pan the canvas
1239
1716
  const [spaceHeld, setSpaceHeld] = useState(false)
1240
1717
  const isPanning = useRef(false)
@@ -1294,10 +1771,19 @@ export default function CanvasPage({ name }) {
1294
1771
  document.addEventListener('mouseup', handlePanEnd)
1295
1772
  }, [spaceHeld])
1296
1773
 
1774
+ // Stable callback for deselecting all widgets
1775
+ const handleDeselectAll = useCallback(() => setSelectedWidgetIds(new Set()), [])
1776
+
1777
+ // Stable callback for widget removal + deselect
1778
+ const handleWidgetRemoveAndDeselect = useCallback((id) => {
1779
+ handleWidgetRemove(id)
1780
+ setSelectedWidgetIds(new Set())
1781
+ }, [handleWidgetRemove])
1782
+
1297
1783
  if (!canvas) {
1298
1784
  return (
1299
1785
  <div className={styles.empty}>
1300
- <p>Canvas &ldquo;{name}&rdquo; not found</p>
1786
+ <p>Canvas &ldquo;{canvasId}&rdquo; not found</p>
1301
1787
  </div>
1302
1788
  )
1303
1789
  }
@@ -1328,54 +1814,50 @@ export default function CanvasPage({ name }) {
1328
1814
  // Merge JSX-sourced widgets (from .canvas.jsx) and JSON widgets
1329
1815
  const allChildren = []
1330
1816
 
1331
- const sourceDataByExport = Object.fromEntries(
1332
- (localSources || [])
1333
- .filter((source) => source?.export)
1334
- .map((source) => [source.export, source])
1335
- )
1336
-
1337
- // 1. JSX-sourced component widgets (wrapped in WidgetChrome, not deletable)
1338
- const componentFeatures = getFeatures('component')
1339
- if (jsxExports) {
1340
- for (const [exportName, Component] of Object.entries(jsxExports)) {
1341
- const sourceData = sourceDataByExport[exportName] || {}
1342
- const sourcePosition = sourceData.position || { x: 0, y: 0 }
1343
- allChildren.push(
1344
- <div
1345
- key={`jsx-${exportName}`}
1346
- id={`jsx-${exportName}`}
1347
- data-tc-x={sourcePosition.x}
1348
- data-tc-y={sourcePosition.y}
1349
- {...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle, .tc-drag-surface' } : {})}
1350
- {...canvasPrimerAttrs}
1351
- style={canvasThemeVars}
1352
- onClick={isLocalDev ? (e) => {
1353
- e.stopPropagation()
1354
- if (!e.target.closest('.tc-drag-handle')) {
1355
- handleWidgetSelect(`jsx-${exportName}`, e.shiftKey)
1356
- }
1357
- } : undefined}
1817
+ // 1. Component widgets (from jsxExports or sources fallback)
1818
+ const componentFeatures = getFeatures('component', { isLocalDev })
1819
+ for (const entry of componentEntries) {
1820
+ const { exportName, Component, sourceData } = entry
1821
+ const sourcePosition = sourceData.position || { x: 0, y: 0 }
1822
+ allChildren.push(
1823
+ <div
1824
+ key={`jsx-${exportName}`}
1825
+ id={`jsx-${exportName}`}
1826
+ data-tc-x={sourcePosition.x}
1827
+ data-tc-y={sourcePosition.y}
1828
+ {...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle, .tc-drag-surface' } : {})}
1829
+ {...canvasPrimerAttrs}
1830
+ style={canvasThemeVars}
1831
+ onClick={isLocalDev ? (e) => {
1832
+ e.stopPropagation()
1833
+ if (!e.target.closest('.tc-drag-handle')) {
1834
+ handleWidgetSelect(`jsx-${exportName}`, e.shiftKey)
1835
+ }
1836
+ } : undefined}
1837
+ >
1838
+ <WidgetChrome
1839
+ widgetId={`jsx-${exportName}`}
1840
+ features={componentFeatures}
1841
+ selected={selectedWidgetIds.has(`jsx-${exportName}`)}
1842
+ multiSelected={isMultiSelected && selectedWidgetIds.has(`jsx-${exportName}`)}
1843
+ onSelect={(shiftKey) => handleWidgetSelect(`jsx-${exportName}`, shiftKey)}
1844
+ onDeselect={handleDeselectAll}
1845
+ readOnly={!isLocalDev}
1358
1846
  >
1359
- <WidgetChrome
1360
- widgetId={`jsx-${exportName}`}
1361
- features={componentFeatures}
1362
- selected={selectedWidgetIds.has(`jsx-${exportName}`)}
1363
- multiSelected={isMultiSelected && selectedWidgetIds.has(`jsx-${exportName}`)}
1364
- onSelect={(shiftKey) => handleWidgetSelect(`jsx-${exportName}`, shiftKey)}
1365
- onDeselect={() => setSelectedWidgetIds(new Set())}
1366
- readOnly={!isLocalDev}
1367
- >
1368
- <ComponentWidget
1369
- component={Component}
1370
- width={sourceData.width}
1371
- height={sourceData.height}
1372
- onUpdate={isLocalDev ? (updates) => handleSourceUpdate(exportName, updates) : undefined}
1373
- resizable={isResizable('component') && isLocalDev}
1374
- />
1375
- </WidgetChrome>
1376
- </div>
1377
- )
1378
- }
1847
+ <ComponentWidget
1848
+ component={Component}
1849
+ jsxModule={canvas?._jsxModule}
1850
+ exportName={exportName}
1851
+ canvasTheme={canvasTheme}
1852
+ isLocalDev={isLocalDev}
1853
+ width={sourceData.width}
1854
+ height={sourceData.height}
1855
+ onUpdate={isLocalDev ? (updates) => handleSourceUpdate(exportName, updates) : undefined}
1856
+ resizable={isResizable('component') && isLocalDev}
1857
+ />
1858
+ </WidgetChrome>
1859
+ </div>
1860
+ )
1379
1861
  }
1380
1862
 
1381
1863
  // 2. JSON-defined mutable widgets (selectable, wrapped in WidgetChrome)
@@ -1401,13 +1883,12 @@ export default function CanvasPage({ name }) {
1401
1883
  selected={selectedWidgetIds.has(widget.id)}
1402
1884
  multiSelected={isMultiSelected && selectedWidgetIds.has(widget.id)}
1403
1885
  onSelect={(shiftKey) => handleWidgetSelect(widget.id, shiftKey)}
1404
- onDeselect={() => setSelectedWidgetIds(new Set())}
1886
+ onDeselect={handleDeselectAll}
1405
1887
  onUpdate={isLocalDev ? handleWidgetUpdate : undefined}
1406
1888
  onCopy={isLocalDev ? handleWidgetCopy : undefined}
1407
- onRemove={isLocalDev ? (id) => {
1408
- handleWidgetRemove(id)
1409
- setSelectedWidgetIds(new Set())
1410
- } : undefined}
1889
+ onRemove={isLocalDev ? handleWidgetRemoveAndDeselect : undefined}
1890
+ onRefreshGitHub={isLocalDev ? handleRefreshGitHubWidget : undefined}
1891
+ canRefreshGitHub={isLocalDev}
1411
1892
  readOnly={!isLocalDev}
1412
1893
  />
1413
1894
  </div>
@@ -1419,24 +1900,8 @@ export default function CanvasPage({ name }) {
1419
1900
  return (
1420
1901
  <>
1421
1902
  <div className={styles.canvasTitle}>
1422
- <div className={styles.canvasTitleWrap}>
1423
- <span className={styles.canvasTitleMeasure} aria-hidden="true">{canvasTitle || ' '}</span>
1424
- {isLocalDev ? (
1425
- <input
1426
- ref={titleInputRef}
1427
- className={styles.canvasTitleInput}
1428
- value={canvasTitle}
1429
- size={1}
1430
- onChange={handleTitleChange}
1431
- onKeyDown={handleTitleKeyDown}
1432
- onMouseDown={(e) => e.stopPropagation()}
1433
- spellCheck={false}
1434
- aria-label="Canvas title"
1435
- />
1436
- ) : (
1437
- <h1 className={styles.canvasTitleStatic}>{canvasTitle}</h1>
1438
- )}
1439
- </div>
1903
+ <h1 className={styles.canvasTitleStatic}>{canvasMeta?.title || canvas?.title || canvasId.split('/').pop()}</h1>
1904
+ <PageSelector currentName={canvasId} pages={siblingPages} />
1440
1905
  {isLocalDev && (
1441
1906
  <span className={styles.localEditingLabel}>Local editing</span>
1442
1907
  )}
@@ -1451,10 +1916,11 @@ export default function CanvasPage({ name }) {
1451
1916
  ...canvasThemeVars,
1452
1917
  ...(spaceHeld ? { cursor: panningActive ? 'grabbing' : 'grab' } : {}),
1453
1918
  }}
1454
- onClick={() => setSelectedWidgetIds(new Set())}
1919
+ onClick={handleDeselectAll}
1455
1920
  onMouseDown={handlePanStart}
1456
1921
  >
1457
1922
  <div
1923
+ ref={zoomElRef}
1458
1924
  data-storyboard-canvas-zoom
1459
1925
  data-sb-canvas-theme={canvasTheme}
1460
1926
  className={styles.canvasZoom}
@@ -1471,6 +1937,28 @@ export default function CanvasPage({ name }) {
1471
1937
  </Canvas>
1472
1938
  </div>
1473
1939
  </div>
1940
+ {showGhInstallBanner && (
1941
+ <aside className={styles.ghInstallBanner} role="status" aria-live="polite">
1942
+ <span className={styles.ghInstallBannerText}>
1943
+ GitHub embeds require local <code>gh</code> CLI access.
1944
+ </span>
1945
+ <a
1946
+ href={GH_INSTALL_URL}
1947
+ target="_blank"
1948
+ rel="noopener noreferrer"
1949
+ className={styles.ghInstallBannerLink}
1950
+ >
1951
+ Install GitHub CLI
1952
+ </a>
1953
+ <button
1954
+ type="button"
1955
+ className={styles.ghInstallBannerDismiss}
1956
+ onClick={() => setShowGhInstallBanner(false)}
1957
+ >
1958
+ Dismiss
1959
+ </button>
1960
+ </aside>
1961
+ )}
1474
1962
  </>
1475
1963
  )
1476
1964
  }