@dfosco/storyboard-react 4.0.0-beta.4 → 4.0.0-beta.41

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 +9 -4
  2. package/src/Icon.jsx +179 -0
  3. package/src/Viewfinder.jsx +1030 -57
  4. package/src/Viewfinder.module.css +1524 -155
  5. package/src/canvas/CanvasControls.jsx +51 -2
  6. package/src/canvas/CanvasControls.module.css +31 -0
  7. package/src/canvas/CanvasPage.bridge.test.jsx +95 -10
  8. package/src/canvas/CanvasPage.dragdrop.test.jsx +346 -0
  9. package/src/canvas/CanvasPage.jsx +843 -301
  10. package/src/canvas/CanvasPage.module.css +73 -50
  11. package/src/canvas/CanvasPage.multiselect.test.jsx +13 -11
  12. package/src/canvas/CanvasToolbar.jsx +2 -2
  13. package/src/canvas/ComponentErrorBoundary.jsx +50 -0
  14. package/src/canvas/PageSelector.jsx +198 -0
  15. package/src/canvas/PageSelector.module.css +158 -0
  16. package/src/canvas/PageSelector.test.jsx +104 -0
  17. package/src/canvas/canvasApi.js +22 -8
  18. package/src/canvas/canvasReloadGuard.js +37 -0
  19. package/src/canvas/canvasReloadGuard.test.js +27 -0
  20. package/src/canvas/componentIsolate.jsx +135 -0
  21. package/src/canvas/useCanvas.js +15 -10
  22. package/src/canvas/widgets/CodePenEmbed.jsx +292 -0
  23. package/src/canvas/widgets/CodePenEmbed.module.css +161 -0
  24. package/src/canvas/widgets/ComponentWidget.jsx +82 -9
  25. package/src/canvas/widgets/ComponentWidget.module.css +14 -6
  26. package/src/canvas/widgets/FigmaEmbed.jsx +110 -24
  27. package/src/canvas/widgets/FigmaEmbed.module.css +21 -7
  28. package/src/canvas/widgets/LinkPreview.jsx +297 -11
  29. package/src/canvas/widgets/LinkPreview.module.css +386 -18
  30. package/src/canvas/widgets/LinkPreview.test.jsx +193 -0
  31. package/src/canvas/widgets/MarkdownBlock.jsx +95 -21
  32. package/src/canvas/widgets/MarkdownBlock.module.css +133 -2
  33. package/src/canvas/widgets/MarkdownBlock.test.jsx +39 -0
  34. package/src/canvas/widgets/PrototypeEmbed.jsx +95 -144
  35. package/src/canvas/widgets/PrototypeEmbed.module.css +74 -4
  36. package/src/canvas/widgets/StickyNote.module.css +5 -0
  37. package/src/canvas/widgets/StickyNote.test.jsx +9 -9
  38. package/src/canvas/widgets/StoryWidget.jsx +276 -0
  39. package/src/canvas/widgets/StoryWidget.module.css +211 -0
  40. package/src/canvas/widgets/WidgetChrome.jsx +76 -20
  41. package/src/canvas/widgets/WidgetChrome.module.css +4 -7
  42. package/src/canvas/widgets/WidgetWrapper.module.css +2 -0
  43. package/src/canvas/widgets/codepenUrl.js +75 -0
  44. package/src/canvas/widgets/codepenUrl.test.js +76 -0
  45. package/src/canvas/widgets/embedInteraction.test.jsx +235 -0
  46. package/src/canvas/widgets/embedOverlay.module.css +35 -0
  47. package/src/canvas/widgets/embedTheme.js +56 -0
  48. package/src/canvas/widgets/githubUrl.js +82 -0
  49. package/src/canvas/widgets/githubUrl.test.js +74 -0
  50. package/src/canvas/widgets/iframeDevLogs.js +49 -0
  51. package/src/canvas/widgets/iframeDevLogs.test.jsx +81 -0
  52. package/src/canvas/widgets/index.js +4 -0
  53. package/src/canvas/widgets/pasteRules.js +295 -0
  54. package/src/canvas/widgets/pasteRules.test.js +474 -0
  55. package/src/canvas/widgets/snapshotDisplay.test.jsx +259 -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 +375 -57
  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,17 @@ export default function CanvasPage({ name }) {
958
1299
  e.preventDefault()
959
1300
  setSelectedWidgetIds(new Set())
960
1301
  }
1302
+ // Copy shortcut (one or more widgets selected):
1303
+ // cmd+c → copy canvasId::id1,id2,... (for cross-canvas paste-duplicate)
1304
+ const mod = e.metaKey || e.ctrlKey
1305
+ if (mod && e.key === 'c' && !e.shiftKey && selectedWidgetIds.size >= 1) {
1306
+ // Filter out non-duplicable widgets (jsx- component widgets are code)
1307
+ const copyableIds = [...selectedWidgetIds].filter(id => !id.startsWith('jsx-'))
1308
+ if (copyableIds.length > 0) {
1309
+ e.preventDefault()
1310
+ navigator.clipboard.writeText(`${canvasId}::${copyableIds.join(',')}`).catch(() => {})
1311
+ }
1312
+ }
961
1313
  if (e.key === 'Delete' || e.key === 'Backspace') {
962
1314
  e.preventDefault()
963
1315
  if (selectedWidgetIds.size > 1) {
@@ -968,7 +1320,7 @@ export default function CanvasPage({ name }) {
968
1320
  if (!prev) return prev
969
1321
  const next = prev.filter(w => !selectedWidgetIds.has(w.id))
970
1322
  queueWrite(() =>
971
- updateCanvas(name, { widgets: next }).catch(err =>
1323
+ updateCanvas(canvasId, { widgets: next }).catch(err =>
972
1324
  console.error('[canvas] Failed to save multi-delete:', err)
973
1325
  )
974
1326
  )
@@ -983,50 +1335,17 @@ export default function CanvasPage({ name }) {
983
1335
  }
984
1336
  document.addEventListener('keydown', handleKeyDown)
985
1337
  return () => document.removeEventListener('keydown', handleKeyDown)
986
- }, [selectedWidgetIds, handleWidgetRemove, undoRedo, name, debouncedSave])
1338
+ }, [selectedWidgetIds, localWidgets, handleWidgetRemove, undoRedo, canvasId, debouncedSave])
987
1339
 
988
- // Paste handler images become image widgets, same-origin URLs become prototypes,
1340
+ // Ref to store processImageFile for use by drop effect
1341
+ const processImageFileRef = useRef(null)
1342
+
1343
+ // Paste and drop handler — images become image widgets, same-origin URLs become prototypes,
989
1344
  // other URLs become link previews, text becomes markdown
990
1345
  useEffect(() => {
991
1346
  const origin = window.location.origin
992
1347
  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
- }
1348
+ const pasteCtx = createPasteContext(origin, basePath)
1030
1349
 
1031
1350
  function blobToDataUrl(blob) {
1032
1351
  return new Promise((resolve, reject) => {
@@ -1046,6 +1365,59 @@ export default function CanvasPage({ name }) {
1046
1365
  })
1047
1366
  }
1048
1367
 
1368
+ /**
1369
+ * Process an image file (from paste or drop) and add it as a widget.
1370
+ * @param {File|Blob} file - Image file to process
1371
+ * @param {{ x: number, y: number }|null} position - Drop position, or null to use viewport center
1372
+ */
1373
+ async function processImageFile(file, position = null) {
1374
+ try {
1375
+ const dataUrl = await blobToDataUrl(file)
1376
+ const { width: natW, height: natH } = await getImageDimensions(dataUrl)
1377
+
1378
+ // Display at 2x retina: halve natural dimensions, then cap at 600px
1379
+ const maxWidth = 600
1380
+ let displayW = Math.round(natW / 2)
1381
+ let displayH = Math.round(natH / 2)
1382
+ if (displayW > maxWidth) {
1383
+ displayH = Math.round(displayH * (maxWidth / displayW))
1384
+ displayW = maxWidth
1385
+ }
1386
+
1387
+ const uploadResult = await uploadImage(dataUrl, canvasId)
1388
+ if (!uploadResult.success) {
1389
+ console.error('[canvas] Image upload failed:', uploadResult.error)
1390
+ return false
1391
+ }
1392
+
1393
+ // Use provided position or fall back to viewport center
1394
+ let pos
1395
+ if (position) {
1396
+ pos = { x: position.x, y: position.y }
1397
+ } else {
1398
+ const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
1399
+ pos = centerPositionForWidget(center, 'image', { width: displayW, height: displayH })
1400
+ }
1401
+
1402
+ const result = await addWidgetApi(canvasId, {
1403
+ type: 'image',
1404
+ props: { src: uploadResult.filename, private: false, width: displayW, height: displayH },
1405
+ position: pos,
1406
+ })
1407
+ if (result.success && result.widget) {
1408
+ undoRedo.snapshot(stateRef.current, 'add')
1409
+ setLocalWidgets((prev) => [...(prev || []), result.widget])
1410
+ }
1411
+ return true
1412
+ } catch (err) {
1413
+ console.error('[canvas] Failed to process image:', err)
1414
+ return false
1415
+ }
1416
+ }
1417
+
1418
+ // Store in ref for use by drag/drop effect
1419
+ processImageFileRef.current = processImageFile
1420
+
1049
1421
  async function handleImagePaste(e) {
1050
1422
  const items = e.clipboardData?.items
1051
1423
  if (!items) return false
@@ -1057,40 +1429,7 @@ export default function CanvasPage({ name }) {
1057
1429
  if (!blob) continue
1058
1430
 
1059
1431
  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
- }
1432
+ await processImageFile(blob, null)
1094
1433
  return true
1095
1434
  }
1096
1435
  return false
@@ -1107,32 +1446,94 @@ export default function CanvasPage({ name }) {
1107
1446
  const text = e.clipboardData?.getData('text/plain')?.trim()
1108
1447
  if (!text) return
1109
1448
 
1110
- e.preventDefault()
1449
+ // Detect canvasId::widgetId or canvasId::id1,id2,id3 format for widget duplication
1450
+ // Also supports legacy canvasId/widgetId for basenames without slashes,
1451
+ // but only when the second segment looks like a widget ID (type-hash).
1452
+ const widgetRefMatch = text.match(/^(.+)::([^:]+)$/) || (text.indexOf('::') === -1 && text.match(/^([^/]+)\/((?:sticky-note|markdown|prototype|link-preview|figma-embed|component|image)-[a-z0-9]+)$/))
1453
+ if (widgetRefMatch) {
1454
+ e.preventDefault()
1455
+ const [, sourceCanvas, sourceWidgetRef] = widgetRefMatch
1456
+ const sourceWidgetIds = sourceWidgetRef.split(',').filter(id => !id.startsWith('jsx-'))
1457
+ if (sourceWidgetIds.length === 0) return
1111
1458
 
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: '' }
1459
+ try {
1460
+ // Resolve source widgets in canvas order
1461
+ let sourceList
1462
+ if (sourceCanvas === canvasId) {
1463
+ sourceList = localWidgets ?? []
1464
+ } else {
1465
+ const canvasData = await getCanvasApi(sourceCanvas)
1466
+ sourceList = canvasData?.widgets ?? []
1467
+ }
1468
+
1469
+ const sourceWidgets = sourceList.filter(w => sourceWidgetIds.includes(w.id))
1470
+ if (sourceWidgets.length === 0) return
1471
+
1472
+ // Compute bounding box of source widgets for relative positioning
1473
+ const fallback = { width: 200, height: 150 }
1474
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
1475
+ for (const w of sourceWidgets) {
1476
+ const wx = w.position?.x ?? 0
1477
+ const wy = w.position?.y ?? 0
1478
+ const ww = w.props?.width ?? WIDGET_FALLBACK_SIZES[w.type]?.width ?? fallback.width
1479
+ const wh = w.props?.height ?? WIDGET_FALLBACK_SIZES[w.type]?.height ?? fallback.height
1480
+ if (wx < minX) minX = wx
1481
+ if (wy < minY) minY = wy
1482
+ if (wx + ww > maxX) maxX = wx + ww
1483
+ if (wy + wh > maxY) maxY = wy + wh
1484
+ }
1485
+ const groupW = maxX - minX
1486
+ const groupH = maxY - minY
1487
+
1488
+ // Center the group in the viewport
1489
+ const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
1490
+ const baseX = Math.round(center.x - groupW / 2)
1491
+ const baseY = Math.round(center.y - groupH / 2)
1492
+
1493
+ // Single undo snapshot for the entire paste
1494
+ undoRedo.snapshot(stateRef.current, 'add')
1495
+
1496
+ // Paste all widgets, collecting new IDs for selection
1497
+ const newWidgets = []
1498
+ for (const w of sourceWidgets) {
1499
+ const relX = (w.position?.x ?? 0) - minX
1500
+ const relY = (w.position?.y ?? 0) - minY
1501
+ const result = await addWidgetApi(canvasId, {
1502
+ type: w.type,
1503
+ props: { ...w.props },
1504
+ position: { x: baseX + relX, y: baseY + relY },
1505
+ })
1506
+ if (result.success && result.widget) {
1507
+ newWidgets.push(result.widget)
1508
+ }
1509
+ }
1510
+
1511
+ if (newWidgets.length > 0) {
1512
+ setLocalWidgets((prev) => [...(prev || []), ...newWidgets])
1513
+ setSelectedWidgetIds(new Set(newWidgets.map(w => w.id)))
1514
+ }
1515
+ } catch (err) {
1516
+ console.error('[canvas] Failed to paste widget reference:', err)
1126
1517
  }
1127
- } else {
1128
- type = 'markdown'
1129
- props = { content: text }
1518
+ // Always consume the ref — never fall through to markdown creation
1519
+ return
1520
+ }
1521
+
1522
+ e.preventDefault()
1523
+ const resolved = resolvePaste(text, pasteCtx, getPasteRules())
1524
+ if (!resolved) return
1525
+ const { type } = resolved
1526
+ let props = resolved.props
1527
+
1528
+ if (type === 'link-preview' && isGitHubEmbedUrl(props?.url || text)) {
1529
+ const githubUpdates = await buildGitHubPreviewUpdates(props?.url || text)
1530
+ if (githubUpdates) props = { ...props, ...githubUpdates }
1130
1531
  }
1131
1532
 
1132
1533
  const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
1133
1534
  const pos = centerPositionForWidget(center, type, props)
1134
1535
  try {
1135
- const result = await addWidgetApi(name, {
1536
+ const result = await addWidgetApi(canvasId, {
1136
1537
  type,
1137
1538
  props,
1138
1539
  position: pos,
@@ -1140,14 +1541,81 @@ export default function CanvasPage({ name }) {
1140
1541
  if (result.success && result.widget) {
1141
1542
  undoRedo.snapshot(stateRef.current, 'add')
1142
1543
  setLocalWidgets((prev) => [...(prev || []), result.widget])
1544
+ setSelectedWidgetIds(new Set([result.widget.id]))
1143
1545
  }
1144
1546
  } catch (err) {
1145
1547
  console.error('[canvas] Failed to add widget from paste:', err)
1146
1548
  }
1147
1549
  }
1550
+
1148
1551
  document.addEventListener('paste', handlePaste)
1149
1552
  return () => document.removeEventListener('paste', handlePaste)
1150
- }, [name, undoRedo])
1553
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1554
+ }, [canvasId, undoRedo, localWidgets])
1555
+
1556
+ // --- Drag and drop handlers for images from Finder/file manager ---
1557
+ // Separate effect to ensure listeners attach after scroll container mounts (loading=false)
1558
+ useEffect(() => {
1559
+ if (loading) return // Don't attach until canvas is loaded and scroll container exists
1560
+
1561
+ const scrollEl = scrollRef.current
1562
+ if (!scrollEl) return
1563
+
1564
+ function handleDragOver(e) {
1565
+ // Only handle if dragging files (not internal widget drag)
1566
+ if (!e.dataTransfer?.types?.includes('Files')) return
1567
+ e.preventDefault()
1568
+ e.dataTransfer.dropEffect = 'copy'
1569
+ }
1570
+
1571
+ async function handleDrop(e) {
1572
+ // Only handle file drops, not internal widget drags
1573
+ if (!e.dataTransfer?.types?.includes('Files')) return
1574
+
1575
+ // Prevent browser default (opening file) immediately for any file drop
1576
+ e.preventDefault()
1577
+ e.stopPropagation()
1578
+
1579
+ const files = e.dataTransfer.files
1580
+ if (!files || files.length === 0) return
1581
+
1582
+ // Filter to image files only — non-images are silently ignored (default already prevented)
1583
+ const imageFiles = Array.from(files).filter((f) => f.type.startsWith('image/'))
1584
+ if (imageFiles.length === 0) return
1585
+
1586
+ // Convert drop coordinates to canvas coordinates
1587
+ const rect = scrollEl.getBoundingClientRect()
1588
+ const scale = zoomRef.current / 100
1589
+
1590
+ // Mouse position relative to scroll container
1591
+ const mouseX = e.clientX - rect.left
1592
+ const mouseY = e.clientY - rect.top
1593
+
1594
+ // Convert to canvas coordinates (account for scroll and zoom)
1595
+ const canvasX = (scrollEl.scrollLeft + mouseX) / scale
1596
+ const canvasY = (scrollEl.scrollTop + mouseY) / scale
1597
+
1598
+ // Snap to grid if enabled, using current grid size
1599
+ const gridSize = snapGridSizeRef.current
1600
+ const shouldSnap = snapEnabledRef.current
1601
+ const snappedX = shouldSnap ? Math.round(canvasX / gridSize) * gridSize : Math.round(canvasX)
1602
+ const snappedY = shouldSnap ? Math.round(canvasY / gridSize) * gridSize : Math.round(canvasY)
1603
+
1604
+ // Process each image file, offsetting subsequent images
1605
+ for (let i = 0; i < imageFiles.length; i++) {
1606
+ const offset = shouldSnap ? i * gridSize : i * 24
1607
+ await processImageFileRef.current?.(imageFiles[i], { x: snappedX + offset, y: snappedY + offset })
1608
+ }
1609
+ }
1610
+
1611
+ scrollEl.addEventListener('dragover', handleDragOver)
1612
+ scrollEl.addEventListener('drop', handleDrop)
1613
+
1614
+ return () => {
1615
+ scrollEl.removeEventListener('dragover', handleDragOver)
1616
+ scrollEl.removeEventListener('drop', handleDrop)
1617
+ }
1618
+ }, [loading])
1151
1619
 
1152
1620
  // --- Undo / Redo ---
1153
1621
  const handleUndo = useCallback(() => {
@@ -1158,11 +1626,11 @@ export default function CanvasPage({ name }) {
1158
1626
  setLocalWidgets(previous.widgets)
1159
1627
  setLocalSources(previous.sources)
1160
1628
  queueWrite(() =>
1161
- updateCanvas(name, { widgets: previous.widgets, sources: previous.sources }).catch((err) =>
1629
+ updateCanvas(canvasId, { widgets: previous.widgets, sources: previous.sources }).catch((err) =>
1162
1630
  console.error('[canvas] Failed to persist undo:', err)
1163
1631
  )
1164
1632
  )
1165
- }, [name, debouncedSave, debouncedSourceSave, undoRedo])
1633
+ }, [canvasId, debouncedSave, debouncedSourceSave, undoRedo])
1166
1634
 
1167
1635
  const handleRedo = useCallback(() => {
1168
1636
  const next = undoRedo.redo(stateRef.current)
@@ -1172,11 +1640,11 @@ export default function CanvasPage({ name }) {
1172
1640
  setLocalWidgets(next.widgets)
1173
1641
  setLocalSources(next.sources)
1174
1642
  queueWrite(() =>
1175
- updateCanvas(name, { widgets: next.widgets, sources: next.sources }).catch((err) =>
1643
+ updateCanvas(canvasId, { widgets: next.widgets, sources: next.sources }).catch((err) =>
1176
1644
  console.error('[canvas] Failed to persist redo:', err)
1177
1645
  )
1178
1646
  )
1179
- }, [name, debouncedSave, debouncedSourceSave, undoRedo])
1647
+ }, [canvasId, debouncedSave, debouncedSourceSave, undoRedo])
1180
1648
 
1181
1649
  // Keyboard shortcuts — dev-only (Cmd+Z / Cmd+Shift+Z)
1182
1650
  useEffect(() => {
@@ -1235,6 +1703,69 @@ export default function CanvasPage({ name }) {
1235
1703
  return () => document.removeEventListener('wheel', handleWheel)
1236
1704
  }, [])
1237
1705
 
1706
+ // Receive cmd+wheel events forwarded from prototype/story iframes
1707
+ useEffect(() => {
1708
+ function handleMessage(e) {
1709
+ if (e.data?.type !== 'storyboard:embed:wheel') return
1710
+ zoomAccum.current += -e.data.deltaY
1711
+ const step = Math.trunc(zoomAccum.current)
1712
+ if (step === 0) return
1713
+ zoomAccum.current -= step
1714
+ applyZoom(zoomRef.current + step)
1715
+ }
1716
+ window.addEventListener('message', handleMessage)
1717
+ return () => window.removeEventListener('message', handleMessage)
1718
+ }, [])
1719
+
1720
+ // Touch pinch-to-zoom for mobile — two-finger pinch zooms the canvas
1721
+ const pinchState = useRef({ active: false, startDist: 0, startZoom: 0, centerX: 0, centerY: 0 })
1722
+ useEffect(() => {
1723
+ const el = scrollRef.current
1724
+ if (!el) return
1725
+
1726
+ function getTouchDist(t1, t2) {
1727
+ const dx = t1.clientX - t2.clientX
1728
+ const dy = t1.clientY - t2.clientY
1729
+ return Math.sqrt(dx * dx + dy * dy)
1730
+ }
1731
+
1732
+ function handleTouchStart(e) {
1733
+ if (e.touches.length !== 2) return
1734
+ const dist = getTouchDist(e.touches[0], e.touches[1])
1735
+ pinchState.current = {
1736
+ active: true,
1737
+ startDist: dist,
1738
+ startZoom: zoomRef.current,
1739
+ centerX: (e.touches[0].clientX + e.touches[1].clientX) / 2,
1740
+ centerY: (e.touches[0].clientY + e.touches[1].clientY) / 2,
1741
+ }
1742
+ }
1743
+
1744
+ function handleTouchMove(e) {
1745
+ if (!pinchState.current.active || e.touches.length !== 2) return
1746
+ e.preventDefault()
1747
+ const dist = getTouchDist(e.touches[0], e.touches[1])
1748
+ const ratio = dist / pinchState.current.startDist
1749
+ const newZoom = Math.round(pinchState.current.startZoom * ratio)
1750
+ applyZoom(newZoom, pinchState.current.centerX, pinchState.current.centerY)
1751
+ }
1752
+
1753
+ function handleTouchEnd() {
1754
+ pinchState.current.active = false
1755
+ }
1756
+
1757
+ el.addEventListener('touchstart', handleTouchStart, { passive: true })
1758
+ el.addEventListener('touchmove', handleTouchMove, { passive: false })
1759
+ el.addEventListener('touchend', handleTouchEnd)
1760
+ el.addEventListener('touchcancel', handleTouchEnd)
1761
+ return () => {
1762
+ el.removeEventListener('touchstart', handleTouchStart)
1763
+ el.removeEventListener('touchmove', handleTouchMove)
1764
+ el.removeEventListener('touchend', handleTouchEnd)
1765
+ el.removeEventListener('touchcancel', handleTouchEnd)
1766
+ }
1767
+ }, [])
1768
+
1238
1769
  // Space + drag to pan the canvas
1239
1770
  const [spaceHeld, setSpaceHeld] = useState(false)
1240
1771
  const isPanning = useRef(false)
@@ -1294,10 +1825,19 @@ export default function CanvasPage({ name }) {
1294
1825
  document.addEventListener('mouseup', handlePanEnd)
1295
1826
  }, [spaceHeld])
1296
1827
 
1828
+ // Stable callback for deselecting all widgets
1829
+ const handleDeselectAll = useCallback(() => setSelectedWidgetIds(new Set()), [])
1830
+
1831
+ // Stable callback for widget removal + deselect
1832
+ const handleWidgetRemoveAndDeselect = useCallback((id) => {
1833
+ handleWidgetRemove(id)
1834
+ setSelectedWidgetIds(new Set())
1835
+ }, [handleWidgetRemove])
1836
+
1297
1837
  if (!canvas) {
1298
1838
  return (
1299
1839
  <div className={styles.empty}>
1300
- <p>Canvas &ldquo;{name}&rdquo; not found</p>
1840
+ <p>Canvas &ldquo;{canvasId}&rdquo; not found</p>
1301
1841
  </div>
1302
1842
  )
1303
1843
  }
@@ -1328,54 +1868,50 @@ export default function CanvasPage({ name }) {
1328
1868
  // Merge JSX-sourced widgets (from .canvas.jsx) and JSON widgets
1329
1869
  const allChildren = []
1330
1870
 
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}
1871
+ // 1. Component widgets (from jsxExports or sources fallback)
1872
+ const componentFeatures = getFeatures('component', { isLocalDev })
1873
+ for (const entry of componentEntries) {
1874
+ const { exportName, Component, sourceData } = entry
1875
+ const sourcePosition = sourceData.position || { x: 0, y: 0 }
1876
+ allChildren.push(
1877
+ <div
1878
+ key={`jsx-${exportName}`}
1879
+ id={`jsx-${exportName}`}
1880
+ data-tc-x={sourcePosition.x}
1881
+ data-tc-y={sourcePosition.y}
1882
+ {...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle, .tc-drag-surface' } : {})}
1883
+ {...canvasPrimerAttrs}
1884
+ style={canvasThemeVars}
1885
+ onClick={isLocalDev ? (e) => {
1886
+ e.stopPropagation()
1887
+ if (!e.target.closest('.tc-drag-handle')) {
1888
+ handleWidgetSelect(`jsx-${exportName}`, e.shiftKey)
1889
+ }
1890
+ } : undefined}
1891
+ >
1892
+ <WidgetChrome
1893
+ widgetId={`jsx-${exportName}`}
1894
+ features={componentFeatures}
1895
+ selected={selectedWidgetIds.has(`jsx-${exportName}`)}
1896
+ multiSelected={isMultiSelected && selectedWidgetIds.has(`jsx-${exportName}`)}
1897
+ onSelect={(shiftKey) => handleWidgetSelect(`jsx-${exportName}`, shiftKey)}
1898
+ onDeselect={handleDeselectAll}
1899
+ readOnly={!isLocalDev}
1358
1900
  >
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
- }
1901
+ <ComponentWidget
1902
+ component={Component}
1903
+ jsxModule={canvas?._jsxModule}
1904
+ exportName={exportName}
1905
+ canvasTheme={canvasTheme}
1906
+ isLocalDev={isLocalDev}
1907
+ width={sourceData.width}
1908
+ height={sourceData.height}
1909
+ onUpdate={isLocalDev ? (updates) => handleSourceUpdate(exportName, updates) : undefined}
1910
+ resizable={isResizable('component') && isLocalDev}
1911
+ />
1912
+ </WidgetChrome>
1913
+ </div>
1914
+ )
1379
1915
  }
1380
1916
 
1381
1917
  // 2. JSON-defined mutable widgets (selectable, wrapped in WidgetChrome)
@@ -1401,13 +1937,12 @@ export default function CanvasPage({ name }) {
1401
1937
  selected={selectedWidgetIds.has(widget.id)}
1402
1938
  multiSelected={isMultiSelected && selectedWidgetIds.has(widget.id)}
1403
1939
  onSelect={(shiftKey) => handleWidgetSelect(widget.id, shiftKey)}
1404
- onDeselect={() => setSelectedWidgetIds(new Set())}
1940
+ onDeselect={handleDeselectAll}
1405
1941
  onUpdate={isLocalDev ? handleWidgetUpdate : undefined}
1406
1942
  onCopy={isLocalDev ? handleWidgetCopy : undefined}
1407
- onRemove={isLocalDev ? (id) => {
1408
- handleWidgetRemove(id)
1409
- setSelectedWidgetIds(new Set())
1410
- } : undefined}
1943
+ onRemove={isLocalDev ? handleWidgetRemoveAndDeselect : undefined}
1944
+ onRefreshGitHub={isLocalDev ? handleRefreshGitHubWidget : undefined}
1945
+ canRefreshGitHub={isLocalDev}
1411
1946
  readOnly={!isLocalDev}
1412
1947
  />
1413
1948
  </div>
@@ -1419,24 +1954,8 @@ export default function CanvasPage({ name }) {
1419
1954
  return (
1420
1955
  <>
1421
1956
  <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>
1957
+ <h1 className={styles.canvasTitleStatic}>{canvasMeta?.title || canvas?.title || canvasId.split('/').pop()}</h1>
1958
+ <PageSelector currentName={canvasId} pages={siblingPages} isLocalDev={isLocalDev} />
1440
1959
  {isLocalDev && (
1441
1960
  <span className={styles.localEditingLabel}>Local editing</span>
1442
1961
  )}
@@ -1451,10 +1970,11 @@ export default function CanvasPage({ name }) {
1451
1970
  ...canvasThemeVars,
1452
1971
  ...(spaceHeld ? { cursor: panningActive ? 'grabbing' : 'grab' } : {}),
1453
1972
  }}
1454
- onClick={() => setSelectedWidgetIds(new Set())}
1973
+ onClick={handleDeselectAll}
1455
1974
  onMouseDown={handlePanStart}
1456
1975
  >
1457
1976
  <div
1977
+ ref={zoomElRef}
1458
1978
  data-storyboard-canvas-zoom
1459
1979
  data-sb-canvas-theme={canvasTheme}
1460
1980
  className={styles.canvasZoom}
@@ -1471,6 +1991,28 @@ export default function CanvasPage({ name }) {
1471
1991
  </Canvas>
1472
1992
  </div>
1473
1993
  </div>
1994
+ {showGhInstallBanner && (
1995
+ <aside className={styles.ghInstallBanner} role="status" aria-live="polite">
1996
+ <span className={styles.ghInstallBannerText}>
1997
+ GitHub embeds require local <code>gh</code> CLI access.
1998
+ </span>
1999
+ <a
2000
+ href={GH_INSTALL_URL}
2001
+ target="_blank"
2002
+ rel="noopener noreferrer"
2003
+ className={styles.ghInstallBannerLink}
2004
+ >
2005
+ Install GitHub CLI
2006
+ </a>
2007
+ <button
2008
+ type="button"
2009
+ className={styles.ghInstallBannerDismiss}
2010
+ onClick={() => setShowGhInstallBanner(false)}
2011
+ >
2012
+ Dismiss
2013
+ </button>
2014
+ </aside>
2015
+ )}
1474
2016
  </>
1475
2017
  )
1476
2018
  }