@dfosco/storyboard-react 4.0.0-beta.24 → 4.0.0-beta.26

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.
@@ -1,5 +1,4 @@
1
- import { createElement, useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
- import { flushSync } from 'react-dom'
1
+ import { createElement, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
3
2
  import { Canvas } from '@dfosco/tiny-canvas'
4
3
  import '@dfosco/tiny-canvas/style.css'
5
4
  import { useCanvas } from './useCanvas.js'
@@ -106,18 +105,18 @@ function debounce(fn, ms) {
106
105
  }
107
106
 
108
107
  /** Per-canvas viewport state persistence (zoom + scroll position). */
109
- function getViewportStorageKey(canvasName) {
110
- return `sb-canvas-viewport:${canvasName}`
108
+ function getViewportStorageKey(canvasId) {
109
+ return `sb-canvas-viewport:${canvasId}`
111
110
  }
112
111
 
113
- function loadViewportState(canvasName) {
112
+ function loadViewportState(canvasId) {
114
113
  try {
115
- const raw = localStorage.getItem(getViewportStorageKey(canvasName))
114
+ const raw = localStorage.getItem(getViewportStorageKey(canvasId))
116
115
  if (!raw) return null
117
116
  const state = JSON.parse(raw)
118
117
  const timestamp = typeof state.timestamp === 'number' ? state.timestamp : 0
119
118
  if (Date.now() - timestamp > VIEWPORT_TTL_MS) {
120
- localStorage.removeItem(getViewportStorageKey(canvasName))
119
+ localStorage.removeItem(getViewportStorageKey(canvasId))
121
120
  return null
122
121
  }
123
122
  return {
@@ -128,9 +127,9 @@ function loadViewportState(canvasName) {
128
127
  } catch { return null }
129
128
  }
130
129
 
131
- function saveViewportState(canvasName, state) {
130
+ function saveViewportState(canvasId, state) {
132
131
  try {
133
- localStorage.setItem(getViewportStorageKey(canvasName), JSON.stringify({
132
+ localStorage.setItem(getViewportStorageKey(canvasId), JSON.stringify({
134
133
  ...state,
135
134
  timestamp: Date.now(),
136
135
  }))
@@ -267,8 +266,10 @@ function WidgetRenderer({ widget, onUpdate, widgetRef }) {
267
266
  /**
268
267
  * Wrapper for each JSON widget that holds its own ref for imperative actions.
269
268
  * This allows WidgetChrome to dispatch actions to the widget via ref.
269
+ *
270
+ * Memoized to prevent re-renders during zoom and unrelated state changes.
270
271
  */
271
- function ChromeWrappedWidget({
272
+ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
272
273
  widget,
273
274
  selected,
274
275
  multiSelected,
@@ -293,6 +294,10 @@ function ChromeWrappedWidget({
293
294
  }
294
295
  }, [widget, onRemove, onCopy])
295
296
 
297
+ const handleWidgetFieldUpdate = useCallback((updates) => {
298
+ onUpdate?.(widget.id, updates)
299
+ }, [onUpdate, widget.id])
300
+
296
301
  return (
297
302
  <WidgetChrome
298
303
  widgetId={widget.id}
@@ -305,40 +310,55 @@ function ChromeWrappedWidget({
305
310
  onSelect={onSelect}
306
311
  onDeselect={onDeselect}
307
312
  onAction={handleAction}
308
- onUpdate={onUpdate ? (updates) => onUpdate(widget.id, updates) : undefined}
313
+ onUpdate={onUpdate ? handleWidgetFieldUpdate : undefined}
309
314
  readOnly={readOnly}
310
315
  >
311
316
  <WidgetRenderer
312
317
  widget={widget}
313
- onUpdate={onUpdate ? (updates) => onUpdate(widget.id, updates) : undefined}
318
+ onUpdate={onUpdate ? handleWidgetFieldUpdate : undefined}
314
319
  widgetRef={widgetRef}
315
320
  />
316
321
  </WidgetChrome>
317
322
  )
318
- }
323
+ }, function chromeWidgetAreEqual(prev, next) {
324
+ return (
325
+ prev.widget === next.widget &&
326
+ prev.selected === next.selected &&
327
+ prev.multiSelected === next.multiSelected &&
328
+ prev.readOnly === next.readOnly &&
329
+ prev.onSelect === next.onSelect &&
330
+ prev.onDeselect === next.onDeselect &&
331
+ prev.onUpdate === next.onUpdate &&
332
+ prev.onRemove === next.onRemove &&
333
+ prev.onCopy === next.onCopy
334
+ )
335
+ })
319
336
 
320
337
  /**
321
338
  * Generic canvas page component.
322
339
  * Reads canvas data from the index and renders all widgets on a draggable surface.
323
340
  *
324
- * @param {{ name: string }} props - Canvas name as indexed by the data plugin
341
+ * @param {{ canvasId: string }} props - Canvas name as indexed by the data plugin
325
342
  */
326
- export default function CanvasPage({ name, siblingPages = [], canvasMeta = null }) {
327
- const { canvas, jsxExports, jsxError, loading } = useCanvas(name)
343
+ export default function CanvasPage({ canvasId, siblingPages = [], canvasMeta = null }) {
344
+ const { canvas, jsxExports, jsxError, loading } = useCanvas(canvasId)
328
345
  const isLocalDev = typeof window !== 'undefined' && window.__SB_LOCAL_DEV__ === true && !new URLSearchParams(window.location.search).has('prodMode')
329
346
 
330
347
  // Local mutable copy of widgets for instant UI updates
331
348
  const [localWidgets, setLocalWidgets] = useState(canvas?.widgets ?? null)
332
349
  const [trackedCanvas, setTrackedCanvas] = useState(canvas)
333
350
  const [selectedWidgetIds, setSelectedWidgetIds] = useState(() => new Set())
334
- const initialViewport = loadViewportState(name)
351
+ const initialViewport = loadViewportState(canvasId)
335
352
  const [zoom, setZoom] = useState(initialViewport?.zoom ?? 100)
336
353
  const zoomRef = useRef(initialViewport?.zoom ?? 100)
337
354
  const scrollRef = useRef(null)
355
+ const zoomElRef = useRef(null)
356
+ const zoomCommitTimer = useRef(null)
357
+ const zoomEventTimer = useRef(null)
338
358
  const pendingScrollRestore = useRef(initialViewport)
339
359
  // Gate viewport persistence until initial positioning is complete.
340
- // Tracks which canvas name was last initialized — save effects only
341
- // write when this matches `name`, preventing cross-canvas corruption.
360
+ // Tracks which canvasId was last initialized — save effects only
361
+ // write when this matches `canvasId`, preventing cross-canvas corruption.
342
362
  const viewportInitName = useRef(null)
343
363
  const [localSources, setLocalSources] = useState(canvas?.sources ?? [])
344
364
  const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
@@ -482,7 +502,7 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
482
502
  undoRedo.reset()
483
503
  // Block saves until the new canvas's viewport is fully restored.
484
504
  viewportInitName.current = null
485
- const newViewport = loadViewportState(name)
505
+ const newViewport = loadViewportState(canvasId)
486
506
  pendingScrollRestore.current = newViewport
487
507
  // Restore zoom from the new canvas's saved state
488
508
  const newZoom = newViewport?.zoom ?? 100
@@ -492,8 +512,8 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
492
512
 
493
513
  // Debounced save to server
494
514
  const debouncedSave = useRef(
495
- debounce((canvasName, widgets) => {
496
- updateCanvas(canvasName, { widgets }).catch((err) =>
515
+ debounce((canvasId, widgets) => {
516
+ updateCanvas(canvasId, { widgets }).catch((err) =>
497
517
  console.error('[canvas] Failed to save:', err)
498
518
  )
499
519
  }, 2000)
@@ -512,20 +532,20 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
512
532
  const next = prev.map((w) =>
513
533
  w.id === widgetId ? { ...w, props: { ...w.props, ...snapped } } : w
514
534
  )
515
- debouncedSave(name, next)
535
+ debouncedSave(canvasId, next)
516
536
  return next
517
537
  })
518
- }, [name, debouncedSave, undoRedo, snapEnabled, snapGridSize])
538
+ }, [canvasId, debouncedSave, undoRedo, snapEnabled, snapGridSize])
519
539
 
520
540
  const handleWidgetRemove = useCallback((widgetId) => {
521
541
  undoRedo.snapshot(stateRef.current, 'remove', widgetId)
522
542
  setLocalWidgets((prev) => prev ? prev.filter((w) => w.id !== widgetId) : prev)
523
543
  queueWrite(() =>
524
- removeWidgetApi(name, widgetId).catch((err) =>
544
+ removeWidgetApi(canvasId, widgetId).catch((err) =>
525
545
  console.error('[canvas] Failed to remove widget:', err)
526
546
  )
527
547
  )
528
- }, [name, undoRedo])
548
+ }, [canvasId, undoRedo])
529
549
 
530
550
  const handleWidgetCopy = useCallback(async (widget) => {
531
551
  // Find the next free offset — check how many copies already exist at +n*40
@@ -541,7 +561,7 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
541
561
  const position = { x: baseX + n * 40, y: baseY + n * 40 }
542
562
  try {
543
563
  undoRedo.snapshot(stateRef.current, 'add')
544
- const result = await addWidgetApi(name, {
564
+ const result = await addWidgetApi(canvasId, {
545
565
  type: widget.type,
546
566
  props: { ...widget.props },
547
567
  position,
@@ -552,11 +572,11 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
552
572
  } catch (err) {
553
573
  console.error('[canvas] Failed to copy widget:', err)
554
574
  }
555
- }, [name, localWidgets, undoRedo])
575
+ }, [canvasId, localWidgets, undoRedo])
556
576
 
557
577
  const debouncedSourceSave = useRef(
558
- debounce((canvasName, sources) => {
559
- updateCanvas(canvasName, { sources }).catch((err) =>
578
+ debounce((canvasId, sources) => {
579
+ updateCanvas(canvasId, { sources }).catch((err) =>
560
580
  console.error('[canvas] Failed to save sources:', err)
561
581
  )
562
582
  }, 2000)
@@ -574,10 +594,10 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
574
594
  const next = current.some((s) => s?.export === exportName)
575
595
  ? current.map((s) => (s?.export === exportName ? { ...s, ...snapped } : s))
576
596
  : [...current, { export: exportName, ...snapped }]
577
- debouncedSourceSave(name, next)
597
+ debouncedSourceSave(canvasId, next)
578
598
  return next
579
599
  })
580
- }, [name, debouncedSourceSave, undoRedo, snapEnabled, snapGridSize])
600
+ }, [canvasId, debouncedSourceSave, undoRedo, snapEnabled, snapGridSize])
581
601
 
582
602
  const handleItemDragEnd = useCallback((dragId, position) => {
583
603
  if (!dragId || !position) {
@@ -629,7 +649,7 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
629
649
  return w
630
650
  })
631
651
  queueWrite(() =>
632
- updateCanvas(name, { widgets: next }).catch((err) =>
652
+ updateCanvas(canvasId, { widgets: next }).catch((err) =>
633
653
  console.error('[canvas] Failed to save multi-move:', err)
634
654
  )
635
655
  )
@@ -661,7 +681,7 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
661
681
  })
662
682
  if (changed) {
663
683
  queueWrite(() =>
664
- updateCanvas(name, { sources: next }).catch((err) =>
684
+ updateCanvas(canvasId, { sources: next }).catch((err) =>
665
685
  console.error('[canvas] Failed to save multi-move sources:', err)
666
686
  )
667
687
  )
@@ -680,7 +700,7 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
680
700
  ? current.map((s) => (s?.export === sourceExport ? { ...s, position: rounded } : s))
681
701
  : [...current, { export: sourceExport, position: rounded }]
682
702
  queueWrite(() =>
683
- updateCanvas(name, { sources: next }).catch((err) =>
703
+ updateCanvas(canvasId, { sources: next }).catch((err) =>
684
704
  console.error('[canvas] Failed to save source position:', err)
685
705
  )
686
706
  )
@@ -696,18 +716,25 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
696
716
  w.id === dragId ? { ...w, position: rounded } : w
697
717
  )
698
718
  queueWrite(() =>
699
- updateCanvas(name, { widgets: next }).catch((err) =>
719
+ updateCanvas(canvasId, { widgets: next }).catch((err) =>
700
720
  console.error('[canvas] Failed to save widget position:', err)
701
721
  )
702
722
  )
703
723
  return next
704
724
  })
705
- }, [name, undoRedo, debouncedSave, transitionPeers, clearDragPreview])
725
+ }, [canvasId, undoRedo, debouncedSave, transitionPeers, clearDragPreview])
706
726
 
727
+ // Keep zoomRef in sync when React state is set (e.g. by toolbar or zoom-to-fit)
707
728
  useEffect(() => {
708
729
  zoomRef.current = zoom
709
730
  }, [zoom])
710
731
 
732
+ // Cleanup zoom timers on unmount
733
+ useEffect(() => () => {
734
+ clearTimeout(zoomCommitTimer.current)
735
+ clearTimeout(zoomEventTimer.current)
736
+ }, [])
737
+
711
738
  // Restore scroll position from localStorage after first render.
712
739
  // When saved state is fresh (< 15 min), restore it. Otherwise zoom-to-fit
713
740
  // all objects so the user sees a useful overview instead of stale coordinates.
@@ -730,7 +757,14 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
730
757
  const fitZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(fitScale * 100)))
731
758
  const newScale = fitZoom / 100
732
759
  zoomRef.current = fitZoom
733
- flushSync(() => setZoom(fitZoom))
760
+ // Imperative DOM update for initial zoom-to-fit — same path as applyZoom
761
+ const zoomEl = zoomElRef.current
762
+ if (zoomEl) {
763
+ zoomEl.style.transform = `scale(${newScale})`
764
+ zoomEl.style.width = `${Math.max(10000, 100 / newScale)}vw`
765
+ zoomEl.style.height = `${Math.max(10000, 100 / newScale)}vh`
766
+ }
767
+ setZoom(fitZoom)
734
768
  el.scrollLeft = (bounds.minX - FIT_PADDING) * newScale
735
769
  el.scrollTop = (bounds.minY - FIT_PADDING) * newScale
736
770
  } else {
@@ -739,8 +773,8 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
739
773
  }
740
774
  }
741
775
  // Allow save effects for this canvas now that positioning is settled.
742
- viewportInitName.current = name
743
- }, [name, loading])
776
+ viewportInitName.current = canvasId
777
+ }, [canvasId, loading])
744
778
 
745
779
  // Center on a specific widget if `?widget=<id>` is in the URL
746
780
  useEffect(() => {
@@ -795,23 +829,23 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
795
829
  // operations (applyZoom, zoom-to-fit) adjust scroll AFTER setZoom, so the
796
830
  // scroll values would be stale at this point.
797
831
  useEffect(() => {
798
- if (viewportInitName.current !== name) return
832
+ if (viewportInitName.current !== canvasId) return
799
833
  const el = scrollRef.current
800
834
  // Read current scroll so the zoom entry doesn't zero-out position,
801
835
  // but the authoritative scroll save comes from the scroll handler.
802
- saveViewportState(name, {
836
+ saveViewportState(canvasId, {
803
837
  zoom,
804
838
  scrollLeft: el?.scrollLeft ?? 0,
805
839
  scrollTop: el?.scrollTop ?? 0,
806
840
  })
807
- }, [name, zoom])
841
+ }, [canvasId, zoom])
808
842
 
809
843
  useEffect(() => {
810
844
  const el = scrollRef.current
811
845
  if (!el) return
812
846
  const saveNow = () => {
813
- if (viewportInitName.current !== name) return
814
- saveViewportState(name, {
847
+ if (viewportInitName.current !== canvasId) return
848
+ saveViewportState(canvasId, {
815
849
  zoom: zoomRef.current,
816
850
  scrollLeft: el.scrollLeft,
817
851
  scrollTop: el.scrollTop,
@@ -819,7 +853,7 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
819
853
  }
820
854
  const debouncedScrollSave = debounce(saveNow, 150)
821
855
  function handleScroll() {
822
- if (viewportInitName.current !== name) return
856
+ if (viewportInitName.current !== canvasId) return
823
857
  debouncedScrollSave()
824
858
  }
825
859
  el.addEventListener('scroll', handleScroll, { passive: true })
@@ -839,19 +873,26 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
839
873
  // beforeunload doesn't fire).
840
874
  saveNow()
841
875
  }
842
- }, [name, loading])
876
+ }, [canvasId, loading])
843
877
 
844
878
  /**
845
879
  * Zoom to a new level, anchoring on an optional client-space point.
846
880
  * When a cursor position is provided (e.g. from a wheel event), the
847
881
  * canvas point under the cursor stays fixed. Otherwise falls back to
848
882
  * the viewport center.
883
+ *
884
+ * Performs an imperative DOM mutation instead of a React state update
885
+ * to avoid triggering a full re-render of the widget tree on every
886
+ * zoom tick. React state is committed after a debounce for toolbar
887
+ * display updates.
849
888
  */
850
889
  function applyZoom(newZoom, clientX, clientY) {
851
890
  const el = scrollRef.current
891
+ const zoomEl = zoomElRef.current
852
892
  const clampedZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, newZoom))
853
893
 
854
- if (!el) {
894
+ if (!el || !zoomEl) {
895
+ zoomRef.current = clampedZoom
855
896
  setZoom(clampedZoom)
856
897
  return
857
898
  }
@@ -869,35 +910,48 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
869
910
  const canvasX = (el.scrollLeft + anchorX) / oldScale
870
911
  const canvasY = (el.scrollTop + anchorY) / oldScale
871
912
 
872
- // Synchronous render so the DOM has the new transform before we adjust scroll
913
+ // Imperative DOM update no React re-render
873
914
  zoomRef.current = clampedZoom
874
- flushSync(() => setZoom(clampedZoom))
915
+ zoomEl.style.transform = `scale(${newScale})`
916
+ zoomEl.style.width = `${Math.max(10000, 100 / newScale)}vw`
917
+ zoomEl.style.height = `${Math.max(10000, 100 / newScale)}vh`
918
+
919
+ // Hint GPU compositing during active zoom
920
+ zoomEl.dataset.zooming = ''
875
921
 
876
922
  // Scroll so the same canvas point stays under the anchor
877
923
  el.scrollLeft = canvasX * newScale - anchorX
878
924
  el.scrollTop = canvasY * newScale - anchorY
879
925
 
880
- // Persist after both zoom and scroll are settled (the zoom effect
881
- // fires inside flushSync before the scroll adjustment above, so it
882
- // would capture stale scroll values).
883
- if (viewportInitName.current === name) {
884
- saveViewportState(name, {
885
- zoom: clampedZoom,
886
- scrollLeft: el.scrollLeft,
887
- scrollTop: el.scrollTop,
888
- })
926
+ // Debounced commit: update React state for toolbar display + persistence
927
+ clearTimeout(zoomCommitTimer.current)
928
+ zoomCommitTimer.current = setTimeout(() => {
929
+ // Remove GPU compositing hint
930
+ delete zoomEl.dataset.zooming
931
+ setZoom(clampedZoom)
932
+ }, 150)
933
+
934
+ // Throttled zoom-changed event for external consumers (toolbar)
935
+ if (!zoomEventTimer.current) {
936
+ zoomEventTimer.current = setTimeout(() => {
937
+ zoomEventTimer.current = null
938
+ window[CANVAS_BRIDGE_STATE_KEY] = { active: true, canvasId, zoom: zoomRef.current }
939
+ document.dispatchEvent(new CustomEvent('storyboard:canvas:zoom-changed', {
940
+ detail: { zoom: zoomRef.current }
941
+ }))
942
+ }, 100)
889
943
  }
890
944
  }
891
945
 
892
946
  // Signal canvas mount/unmount to CoreUIBar
893
947
  useEffect(() => {
894
- window[CANVAS_BRIDGE_STATE_KEY] = { active: true, name, zoom: zoomRef.current }
948
+ window[CANVAS_BRIDGE_STATE_KEY] = { active: true, canvasId, zoom: zoomRef.current }
895
949
  document.dispatchEvent(new CustomEvent('storyboard:canvas:mounted', {
896
- detail: { name, zoom: zoomRef.current }
950
+ detail: { canvasId, zoom: zoomRef.current }
897
951
  }))
898
952
 
899
953
  function handleStatusRequest() {
900
- const state = window[CANVAS_BRIDGE_STATE_KEY] || { active: true, name, zoom: zoomRef.current }
954
+ const state = window[CANVAS_BRIDGE_STATE_KEY] || { active: true, canvasId, zoom: zoomRef.current }
901
955
  document.dispatchEvent(new CustomEvent('storyboard:canvas:status', { detail: state }))
902
956
  }
903
957
 
@@ -905,10 +959,10 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
905
959
 
906
960
  return () => {
907
961
  document.removeEventListener('storyboard:canvas:status-request', handleStatusRequest)
908
- window[CANVAS_BRIDGE_STATE_KEY] = { active: false, name: '', zoom: 100 }
962
+ window[CANVAS_BRIDGE_STATE_KEY] = { active: false, canvasId: '', zoom: 100 }
909
963
  document.dispatchEvent(new CustomEvent('storyboard:canvas:unmounted'))
910
964
  }
911
- }, [name])
965
+ }, [canvasId])
912
966
 
913
967
  // Tell the Vite dev server to suppress full-reloads while this canvas is active.
914
968
  // The ?canvas-hmr URL param opts out of the guard for canvas UI development.
@@ -928,7 +982,7 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
928
982
  clearInterval(interval)
929
983
  import.meta.hot.send('storyboard:canvas-hmr-guard', { active: false, hmrEnabled: true })
930
984
  }
931
- }, [name])
985
+ }, [canvasId])
932
986
 
933
987
  // Add a widget by type — used by CanvasControls and CoreUIBar event
934
988
  const addWidget = useCallback(async (type) => {
@@ -936,7 +990,7 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
936
990
  const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
937
991
  const pos = centerPositionForWidget(center, type, defaultProps)
938
992
  try {
939
- const result = await addWidgetApi(name, {
993
+ const result = await addWidgetApi(canvasId, {
940
994
  type,
941
995
  props: defaultProps,
942
996
  position: pos,
@@ -948,7 +1002,7 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
948
1002
  } catch (err) {
949
1003
  console.error('[canvas] Failed to add widget:', err)
950
1004
  }
951
- }, [name, undoRedo])
1005
+ }, [canvasId, undoRedo])
952
1006
 
953
1007
  // Add a story widget by storyId — used by CanvasControls story picker
954
1008
  const addStoryWidget = useCallback(async (storyId) => {
@@ -956,7 +1010,7 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
956
1010
  const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
957
1011
  const pos = centerPositionForWidget(center, 'story', storyProps)
958
1012
  try {
959
- const result = await addWidgetApi(name, {
1013
+ const result = await addWidgetApi(canvasId, {
960
1014
  type: 'story',
961
1015
  props: storyProps,
962
1016
  position: pos,
@@ -968,7 +1022,7 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
968
1022
  } catch (err) {
969
1023
  console.error('[canvas] Failed to add story widget:', err)
970
1024
  }
971
- }, [name, undoRedo])
1025
+ }, [canvasId, undoRedo])
972
1026
 
973
1027
  // Listen for CoreUIBar add-widget events
974
1028
  useEffect(() => {
@@ -1003,7 +1057,7 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
1003
1057
  function handleSnapToggle() {
1004
1058
  setSnapEnabled((prev) => {
1005
1059
  const next = !prev
1006
- updateCanvas(name, { settings: { snapToGrid: next } }).catch((err) =>
1060
+ updateCanvas(canvasId, { settings: { snapToGrid: next } }).catch((err) =>
1007
1061
  console.error('[canvas] Failed to persist snap setting:', err)
1008
1062
  )
1009
1063
  return next
@@ -1011,7 +1065,7 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
1011
1065
  }
1012
1066
  document.addEventListener('storyboard:canvas:toggle-snap', handleSnapToggle)
1013
1067
  return () => document.removeEventListener('storyboard:canvas:toggle-snap', handleSnapToggle)
1014
- }, [name])
1068
+ }, [canvasId])
1015
1069
 
1016
1070
  // Broadcast snap state to Svelte toolbar
1017
1071
  useEffect(() => {
@@ -1067,17 +1121,23 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
1067
1121
  const fitZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(fitScale * 100)))
1068
1122
  const newScale = fitZoom / 100
1069
1123
 
1070
- // Apply zoom synchronously so DOM updates before we scroll
1124
+ // Imperative DOM update same path as applyZoom
1071
1125
  zoomRef.current = fitZoom
1072
- flushSync(() => setZoom(fitZoom))
1126
+ const zoomEl = zoomElRef.current
1127
+ if (zoomEl) {
1128
+ zoomEl.style.transform = `scale(${newScale})`
1129
+ zoomEl.style.width = `${Math.max(10000, 100 / newScale)}vw`
1130
+ zoomEl.style.height = `${Math.max(10000, 100 / newScale)}vh`
1131
+ }
1132
+ setZoom(fitZoom)
1073
1133
 
1074
1134
  // Scroll so the bounding box top-left (with padding) is at viewport top-left
1075
1135
  el.scrollLeft = (bounds.minX - FIT_PADDING) * newScale
1076
1136
  el.scrollTop = (bounds.minY - FIT_PADDING) * newScale
1077
1137
 
1078
1138
  // Persist after both zoom and scroll are settled
1079
- if (viewportInitName.current === name) {
1080
- saveViewportState(name, {
1139
+ if (viewportInitName.current === canvasId) {
1140
+ saveViewportState(canvasId, {
1081
1141
  zoom: fitZoom,
1082
1142
  scrollLeft: el.scrollLeft,
1083
1143
  scrollTop: el.scrollTop,
@@ -1101,11 +1161,11 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
1101
1161
 
1102
1162
  // Broadcast zoom level to CoreUIBar whenever it changes
1103
1163
  useEffect(() => {
1104
- window[CANVAS_BRIDGE_STATE_KEY] = { active: true, name, zoom }
1164
+ window[CANVAS_BRIDGE_STATE_KEY] = { active: true, canvasId, zoom }
1105
1165
  document.dispatchEvent(new CustomEvent('storyboard:canvas:zoom-changed', {
1106
1166
  detail: { zoom }
1107
1167
  }))
1108
- }, [name, zoom])
1168
+ }, [canvasId, zoom])
1109
1169
 
1110
1170
  // Delete selected widget on Delete/Backspace key
1111
1171
  useEffect(() => {
@@ -1128,12 +1188,12 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
1128
1188
  setSelectedWidgetIds(new Set())
1129
1189
  }
1130
1190
  // Copy shortcut (single widget selected):
1131
- // cmd+c → copy canvasName::widgetId (for cross-canvas paste-duplicate)
1191
+ // cmd+c → copy canvasId::widgetId (for cross-canvas paste-duplicate)
1132
1192
  const mod = e.metaKey || e.ctrlKey
1133
1193
  if (mod && e.key === 'c' && !e.shiftKey && selectedWidgetIds.size === 1) {
1134
1194
  const widgetId = [...selectedWidgetIds][0]
1135
1195
  e.preventDefault()
1136
- navigator.clipboard.writeText(`${name}::${widgetId}`).catch(() => {})
1196
+ navigator.clipboard.writeText(`${canvasId}::${widgetId}`).catch(() => {})
1137
1197
  }
1138
1198
  if (e.key === 'Delete' || e.key === 'Backspace') {
1139
1199
  e.preventDefault()
@@ -1145,7 +1205,7 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
1145
1205
  if (!prev) return prev
1146
1206
  const next = prev.filter(w => !selectedWidgetIds.has(w.id))
1147
1207
  queueWrite(() =>
1148
- updateCanvas(name, { widgets: next }).catch(err =>
1208
+ updateCanvas(canvasId, { widgets: next }).catch(err =>
1149
1209
  console.error('[canvas] Failed to save multi-delete:', err)
1150
1210
  )
1151
1211
  )
@@ -1160,7 +1220,7 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
1160
1220
  }
1161
1221
  document.addEventListener('keydown', handleKeyDown)
1162
1222
  return () => document.removeEventListener('keydown', handleKeyDown)
1163
- }, [selectedWidgetIds, localWidgets, handleWidgetRemove, undoRedo, name, debouncedSave])
1223
+ }, [selectedWidgetIds, localWidgets, handleWidgetRemove, undoRedo, canvasId, debouncedSave])
1164
1224
 
1165
1225
  // Ref to store processImageFile for use by drop effect
1166
1226
  const processImageFileRef = useRef(null)
@@ -1209,7 +1269,7 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
1209
1269
  displayW = maxWidth
1210
1270
  }
1211
1271
 
1212
- const uploadResult = await uploadImage(dataUrl, name)
1272
+ const uploadResult = await uploadImage(dataUrl, canvasId)
1213
1273
  if (!uploadResult.success) {
1214
1274
  console.error('[canvas] Image upload failed:', uploadResult.error)
1215
1275
  return false
@@ -1224,7 +1284,7 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
1224
1284
  pos = centerPositionForWidget(center, 'image', { width: displayW, height: displayH })
1225
1285
  }
1226
1286
 
1227
- const result = await addWidgetApi(name, {
1287
+ const result = await addWidgetApi(canvasId, {
1228
1288
  type: 'image',
1229
1289
  props: { src: uploadResult.filename, private: false, width: displayW, height: displayH },
1230
1290
  position: pos,
@@ -1271,8 +1331,8 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
1271
1331
  const text = e.clipboardData?.getData('text/plain')?.trim()
1272
1332
  if (!text) return
1273
1333
 
1274
- // Detect canvasName::widgetId format for widget duplication (cross-canvas copy-paste)
1275
- // Also supports legacy canvasName/widgetId for basenames without slashes,
1334
+ // Detect canvasId::widgetId format for widget duplication (cross-canvas copy-paste)
1335
+ // Also supports legacy canvasId/widgetId for basenames without slashes,
1276
1336
  // but only when the second segment looks like a widget ID (type-hash).
1277
1337
  const widgetRefMatch = text.match(/^(.+)::([^:]+)$/) || (text.indexOf('::') === -1 && text.match(/^([^/]+)\/((?:sticky-note|markdown|prototype|link-preview|figma-embed|component|image)-[a-z0-9]+)$/))
1278
1338
  if (widgetRefMatch) {
@@ -1282,7 +1342,7 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
1282
1342
  if (sourceWidgetId.startsWith('jsx-')) return
1283
1343
  try {
1284
1344
  let sourceWidget = null
1285
- if (sourceCanvas === name) {
1345
+ if (sourceCanvas === canvasId) {
1286
1346
  sourceWidget = (localWidgets ?? []).find(w => w.id === sourceWidgetId)
1287
1347
  } else {
1288
1348
  const canvasData = await getCanvasApi(sourceCanvas)
@@ -1292,7 +1352,7 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
1292
1352
  const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
1293
1353
  const pos = centerPositionForWidget(center, sourceWidget.type, sourceWidget.props)
1294
1354
  undoRedo.snapshot(stateRef.current, 'add')
1295
- const result = await addWidgetApi(name, {
1355
+ const result = await addWidgetApi(canvasId, {
1296
1356
  type: sourceWidget.type,
1297
1357
  props: { ...sourceWidget.props },
1298
1358
  position: pos,
@@ -1316,7 +1376,7 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
1316
1376
  const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
1317
1377
  const pos = centerPositionForWidget(center, type, props)
1318
1378
  try {
1319
- const result = await addWidgetApi(name, {
1379
+ const result = await addWidgetApi(canvasId, {
1320
1380
  type,
1321
1381
  props,
1322
1382
  position: pos,
@@ -1332,7 +1392,7 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
1332
1392
 
1333
1393
  document.addEventListener('paste', handlePaste)
1334
1394
  return () => document.removeEventListener('paste', handlePaste)
1335
- }, [name, undoRedo, localWidgets])
1395
+ }, [canvasId, undoRedo, localWidgets])
1336
1396
 
1337
1397
  // --- Drag and drop handlers for images from Finder/file manager ---
1338
1398
  // Separate effect to ensure listeners attach after scroll container mounts (loading=false)
@@ -1407,11 +1467,11 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
1407
1467
  setLocalWidgets(previous.widgets)
1408
1468
  setLocalSources(previous.sources)
1409
1469
  queueWrite(() =>
1410
- updateCanvas(name, { widgets: previous.widgets, sources: previous.sources }).catch((err) =>
1470
+ updateCanvas(canvasId, { widgets: previous.widgets, sources: previous.sources }).catch((err) =>
1411
1471
  console.error('[canvas] Failed to persist undo:', err)
1412
1472
  )
1413
1473
  )
1414
- }, [name, debouncedSave, debouncedSourceSave, undoRedo])
1474
+ }, [canvasId, debouncedSave, debouncedSourceSave, undoRedo])
1415
1475
 
1416
1476
  const handleRedo = useCallback(() => {
1417
1477
  const next = undoRedo.redo(stateRef.current)
@@ -1421,11 +1481,11 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
1421
1481
  setLocalWidgets(next.widgets)
1422
1482
  setLocalSources(next.sources)
1423
1483
  queueWrite(() =>
1424
- updateCanvas(name, { widgets: next.widgets, sources: next.sources }).catch((err) =>
1484
+ updateCanvas(canvasId, { widgets: next.widgets, sources: next.sources }).catch((err) =>
1425
1485
  console.error('[canvas] Failed to persist redo:', err)
1426
1486
  )
1427
1487
  )
1428
- }, [name, debouncedSave, debouncedSourceSave, undoRedo])
1488
+ }, [canvasId, debouncedSave, debouncedSourceSave, undoRedo])
1429
1489
 
1430
1490
  // Keyboard shortcuts — dev-only (Cmd+Z / Cmd+Shift+Z)
1431
1491
  useEffect(() => {
@@ -1595,7 +1655,7 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
1595
1655
  if (!canvas) {
1596
1656
  return (
1597
1657
  <div className={styles.empty}>
1598
- <p>Canvas &ldquo;{name}&rdquo; not found</p>
1658
+ <p>Canvas &ldquo;{canvasId}&rdquo; not found</p>
1599
1659
  </div>
1600
1660
  )
1601
1661
  }
@@ -1623,6 +1683,15 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
1623
1683
  const canvasThemeVars = getCanvasThemeVars(canvasTheme)
1624
1684
  const canvasPrimerAttrs = getCanvasPrimerAttrs(canvasTheme)
1625
1685
 
1686
+ // Stable callback for deselecting all widgets
1687
+ const handleDeselectAll = useCallback(() => setSelectedWidgetIds(new Set()), [])
1688
+
1689
+ // Stable callback for widget removal + deselect
1690
+ const handleWidgetRemoveAndDeselect = useCallback((id) => {
1691
+ handleWidgetRemove(id)
1692
+ setSelectedWidgetIds(new Set())
1693
+ }, [handleWidgetRemove])
1694
+
1626
1695
  // Merge JSX-sourced widgets (from .canvas.jsx) and JSON widgets
1627
1696
  const allChildren = []
1628
1697
 
@@ -1653,7 +1722,7 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
1653
1722
  selected={selectedWidgetIds.has(`jsx-${exportName}`)}
1654
1723
  multiSelected={isMultiSelected && selectedWidgetIds.has(`jsx-${exportName}`)}
1655
1724
  onSelect={(shiftKey) => handleWidgetSelect(`jsx-${exportName}`, shiftKey)}
1656
- onDeselect={() => setSelectedWidgetIds(new Set())}
1725
+ onDeselect={handleDeselectAll}
1657
1726
  readOnly={!isLocalDev}
1658
1727
  >
1659
1728
  <ComponentWidget
@@ -1695,13 +1764,10 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
1695
1764
  selected={selectedWidgetIds.has(widget.id)}
1696
1765
  multiSelected={isMultiSelected && selectedWidgetIds.has(widget.id)}
1697
1766
  onSelect={(shiftKey) => handleWidgetSelect(widget.id, shiftKey)}
1698
- onDeselect={() => setSelectedWidgetIds(new Set())}
1767
+ onDeselect={handleDeselectAll}
1699
1768
  onUpdate={isLocalDev ? handleWidgetUpdate : undefined}
1700
1769
  onCopy={isLocalDev ? handleWidgetCopy : undefined}
1701
- onRemove={isLocalDev ? (id) => {
1702
- handleWidgetRemove(id)
1703
- setSelectedWidgetIds(new Set())
1704
- } : undefined}
1770
+ onRemove={isLocalDev ? handleWidgetRemoveAndDeselect : undefined}
1705
1771
  readOnly={!isLocalDev}
1706
1772
  />
1707
1773
  </div>
@@ -1713,8 +1779,8 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
1713
1779
  return (
1714
1780
  <>
1715
1781
  <div className={styles.canvasTitle}>
1716
- <h1 className={styles.canvasTitleStatic}>{canvasMeta?.title || canvas?.title || name.split('/').pop()}</h1>
1717
- <PageSelector currentName={name} pages={siblingPages} />
1782
+ <h1 className={styles.canvasTitleStatic}>{canvasMeta?.title || canvas?.title || canvasId.split('/').pop()}</h1>
1783
+ <PageSelector currentName={canvasId} pages={siblingPages} />
1718
1784
  {isLocalDev && (
1719
1785
  <span className={styles.localEditingLabel}>Local editing</span>
1720
1786
  )}
@@ -1729,10 +1795,11 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
1729
1795
  ...canvasThemeVars,
1730
1796
  ...(spaceHeld ? { cursor: panningActive ? 'grabbing' : 'grab' } : {}),
1731
1797
  }}
1732
- onClick={() => setSelectedWidgetIds(new Set())}
1798
+ onClick={handleDeselectAll}
1733
1799
  onMouseDown={handlePanStart}
1734
1800
  >
1735
1801
  <div
1802
+ ref={zoomElRef}
1736
1803
  data-storyboard-canvas-zoom
1737
1804
  data-sb-canvas-theme={canvasTheme}
1738
1805
  className={styles.canvasZoom}