@dfosco/storyboard-react 4.0.0-beta.18 → 4.0.0-beta.19

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.
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-react",
3
- "version": "4.0.0-beta.18",
3
+ "version": "4.0.0-beta.19",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@dfosco/storyboard-core": "4.0.0-beta.18",
7
- "@dfosco/tiny-canvas": "4.0.0-beta.18",
6
+ "@dfosco/storyboard-core": "4.0.0-beta.19",
7
+ "@dfosco/tiny-canvas": "4.0.0-beta.19",
8
8
  "@neodrag/react": "^2.3.1",
9
9
  "glob": "^11.0.0",
10
10
  "jsonc-parser": "^3.3.1",
@@ -21,6 +21,9 @@ import styles from './CanvasPage.module.css'
21
21
  const ZOOM_MIN = 25
22
22
  const ZOOM_MAX = 200
23
23
 
24
+ /** Saved viewport state older than this is considered stale — zoom-to-fit instead. */
25
+ const VIEWPORT_TTL_MS = 15 * 60 * 1000
26
+
24
27
  const CANVAS_BRIDGE_STATE_KEY = '__storyboardCanvasBridgeState'
25
28
 
26
29
  /** Matches branch-deploy base path prefixes like /branch--my-feature/ */
@@ -112,6 +115,11 @@ function loadViewportState(canvasName) {
112
115
  const raw = localStorage.getItem(getViewportStorageKey(canvasName))
113
116
  if (!raw) return null
114
117
  const state = JSON.parse(raw)
118
+ const timestamp = typeof state.timestamp === 'number' ? state.timestamp : 0
119
+ if (Date.now() - timestamp > VIEWPORT_TTL_MS) {
120
+ localStorage.removeItem(getViewportStorageKey(canvasName))
121
+ return null
122
+ }
115
123
  return {
116
124
  zoom: typeof state.zoom === 'number' ? Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, state.zoom)) : null,
117
125
  scrollLeft: typeof state.scrollLeft === 'number' ? state.scrollLeft : null,
@@ -122,7 +130,10 @@ function loadViewportState(canvasName) {
122
130
 
123
131
  function saveViewportState(canvasName, state) {
124
132
  try {
125
- localStorage.setItem(getViewportStorageKey(canvasName), JSON.stringify(state))
133
+ localStorage.setItem(getViewportStorageKey(canvasName), JSON.stringify({
134
+ ...state,
135
+ timestamp: Date.now(),
136
+ }))
126
137
  } catch { /* quota exceeded — non-critical */ }
127
138
  }
128
139
 
@@ -325,10 +336,10 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
325
336
  const zoomRef = useRef(initialViewport?.zoom ?? 100)
326
337
  const scrollRef = useRef(null)
327
338
  const pendingScrollRestore = useRef(initialViewport)
328
- // Gate viewport persistence until initial positioning is complete (restore,
329
- // ?widget= deep-link, or first visit). Prevents early save effects from
330
- // overwriting the saved scroll position with 0,0.
331
- const viewportInitDone = useRef(false)
339
+ // 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.
342
+ const viewportInitName = useRef(null)
332
343
  const [localSources, setLocalSources] = useState(canvas?.sources ?? [])
333
344
  const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
334
345
  const [snapEnabled, setSnapEnabled] = useState(canvas?.snapToGrid ?? false)
@@ -468,10 +479,14 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
468
479
  setSnapEnabled(canvas?.snapToGrid ?? false)
469
480
  setSnapGridSize(canvas?.gridSize || 40)
470
481
  undoRedo.reset()
471
- // Reset viewport init gate so save effects don't persist stale positions
472
- // while the new canvas's viewport is being restored.
473
- viewportInitDone.current = false
474
- pendingScrollRestore.current = loadViewportState(name)
482
+ // Block saves until the new canvas's viewport is fully restored.
483
+ viewportInitName.current = null
484
+ const newViewport = loadViewportState(name)
485
+ pendingScrollRestore.current = newViewport
486
+ // Restore zoom from the new canvas's saved state
487
+ const newZoom = newViewport?.zoom ?? 100
488
+ zoomRef.current = newZoom
489
+ setZoom(newZoom)
475
490
  }
476
491
 
477
492
  // Debounced save to server
@@ -692,21 +707,38 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
692
707
  zoomRef.current = zoom
693
708
  }, [zoom])
694
709
 
695
- // Restore scroll position from localStorage after first render
710
+ // Restore scroll position from localStorage after first render.
711
+ // When saved state is fresh (< 15 min), restore it. Otherwise zoom-to-fit
712
+ // all objects so the user sees a useful overview instead of stale coordinates.
696
713
  useEffect(() => {
697
714
  const el = scrollRef.current
698
715
  if (!el || loading) return
699
716
  const saved = pendingScrollRestore.current
700
717
  if (saved) {
718
+ // Fresh saved viewport — restore exactly
701
719
  if (saved.scrollLeft != null) el.scrollLeft = saved.scrollLeft
702
720
  if (saved.scrollTop != null) el.scrollTop = saved.scrollTop
703
721
  pendingScrollRestore.current = null
722
+ } else {
723
+ // No saved state or stale — zoom-to-fit all objects
724
+ const bounds = computeCanvasBounds(localWidgets, componentEntries)
725
+ if (bounds && el.clientWidth > 0 && el.clientHeight > 0) {
726
+ const boxW = bounds.maxX - bounds.minX + FIT_PADDING * 2
727
+ const boxH = bounds.maxY - bounds.minY + FIT_PADDING * 2
728
+ const fitScale = Math.min(el.clientWidth / boxW, el.clientHeight / boxH)
729
+ const fitZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(fitScale * 100)))
730
+ const newScale = fitZoom / 100
731
+ zoomRef.current = fitZoom
732
+ flushSync(() => setZoom(fitZoom))
733
+ el.scrollLeft = (bounds.minX - FIT_PADDING) * newScale
734
+ el.scrollTop = (bounds.minY - FIT_PADDING) * newScale
735
+ } else {
736
+ el.scrollLeft = 0
737
+ el.scrollTop = 0
738
+ }
704
739
  }
705
- // Mark viewport init complete so save effects can start persisting.
706
- // This covers: restored saved position, first visit (no saved state),
707
- // and name changes. The ?widget= effect below may override position
708
- // and that's fine — it runs after this in the same commit.
709
- viewportInitDone.current = true
740
+ // Allow save effects for this canvas now that positioning is settled.
741
+ viewportInitName.current = name
710
742
  }, [name, loading])
711
743
 
712
744
  // Center on a specific widget if `?widget=<id>` is in the URL
@@ -756,10 +788,16 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
756
788
  window.history.replaceState({}, '', url.toString())
757
789
  }, [loading, localWidgets, componentEntries])
758
790
 
759
- // Persist viewport state (zoom + scroll) to localStorage on changes
791
+ // Persist viewport state (zoom only) to localStorage on zoom changes.
792
+ // Scroll position is persisted separately by the debounced scroll handler,
793
+ // cleanup handler, and beforeunload — never here, because imperative zoom
794
+ // operations (applyZoom, zoom-to-fit) adjust scroll AFTER setZoom, so the
795
+ // scroll values would be stale at this point.
760
796
  useEffect(() => {
761
- if (!viewportInitDone.current) return
797
+ if (viewportInitName.current !== name) return
762
798
  const el = scrollRef.current
799
+ // Read current scroll so the zoom entry doesn't zero-out position,
800
+ // but the authoritative scroll save comes from the scroll handler.
763
801
  saveViewportState(name, {
764
802
  zoom,
765
803
  scrollLeft: el?.scrollLeft ?? 0,
@@ -770,30 +808,35 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
770
808
  useEffect(() => {
771
809
  const el = scrollRef.current
772
810
  if (!el) return
773
- function handleScroll() {
774
- if (!viewportInitDone.current) return
811
+ const saveNow = () => {
812
+ if (viewportInitName.current !== name) return
775
813
  saveViewportState(name, {
776
814
  zoom: zoomRef.current,
777
815
  scrollLeft: el.scrollLeft,
778
816
  scrollTop: el.scrollTop,
779
817
  })
780
818
  }
819
+ const debouncedScrollSave = debounce(saveNow, 150)
820
+ function handleScroll() {
821
+ if (viewportInitName.current !== name) return
822
+ debouncedScrollSave()
823
+ }
781
824
  el.addEventListener('scroll', handleScroll, { passive: true })
782
825
 
783
826
  // Flush viewport state on page unload so a refresh never misses it
784
827
  function handleBeforeUnload() {
785
- if (!viewportInitDone.current) return
786
- saveViewportState(name, {
787
- zoom: zoomRef.current,
788
- scrollLeft: el.scrollLeft,
789
- scrollTop: el.scrollTop,
790
- })
828
+ debouncedScrollSave.cancel()
829
+ saveNow()
791
830
  }
792
831
  window.addEventListener('beforeunload', handleBeforeUnload)
793
832
 
794
833
  return () => {
834
+ debouncedScrollSave.cancel()
795
835
  el.removeEventListener('scroll', handleScroll)
796
836
  window.removeEventListener('beforeunload', handleBeforeUnload)
837
+ // Save final state on cleanup (covers SPA navigation where
838
+ // beforeunload doesn't fire).
839
+ saveNow()
797
840
  }
798
841
  }, [name, loading])
799
842
 
@@ -832,6 +875,17 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
832
875
  // Scroll so the same canvas point stays under the anchor
833
876
  el.scrollLeft = canvasX * newScale - anchorX
834
877
  el.scrollTop = canvasY * newScale - anchorY
878
+
879
+ // Persist after both zoom and scroll are settled (the zoom effect
880
+ // fires inside flushSync before the scroll adjustment above, so it
881
+ // would capture stale scroll values).
882
+ if (viewportInitName.current === name) {
883
+ saveViewportState(name, {
884
+ zoom: clampedZoom,
885
+ scrollLeft: el.scrollLeft,
886
+ scrollTop: el.scrollTop,
887
+ })
888
+ }
835
889
  }
836
890
 
837
891
  // Signal canvas mount/unmount to CoreUIBar
@@ -1019,6 +1073,15 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
1019
1073
  // Scroll so the bounding box top-left (with padding) is at viewport top-left
1020
1074
  el.scrollLeft = (bounds.minX - FIT_PADDING) * newScale
1021
1075
  el.scrollTop = (bounds.minY - FIT_PADDING) * newScale
1076
+
1077
+ // Persist after both zoom and scroll are settled
1078
+ if (viewportInitName.current === name) {
1079
+ saveViewportState(name, {
1080
+ zoom: fitZoom,
1081
+ scrollLeft: el.scrollLeft,
1082
+ scrollTop: el.scrollTop,
1083
+ })
1084
+ }
1022
1085
  }
1023
1086
  document.addEventListener('storyboard:canvas:zoom-to-fit', handleZoomToFit)
1024
1087
  return () => document.removeEventListener('storyboard:canvas:zoom-to-fit', handleZoomToFit)
@@ -1420,6 +1483,55 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
1420
1483
  return () => document.removeEventListener('wheel', handleWheel)
1421
1484
  }, [])
1422
1485
 
1486
+ // Touch pinch-to-zoom for mobile — two-finger pinch zooms the canvas
1487
+ const pinchState = useRef({ active: false, startDist: 0, startZoom: 0, centerX: 0, centerY: 0 })
1488
+ useEffect(() => {
1489
+ const el = scrollRef.current
1490
+ if (!el) return
1491
+
1492
+ function getTouchDist(t1, t2) {
1493
+ const dx = t1.clientX - t2.clientX
1494
+ const dy = t1.clientY - t2.clientY
1495
+ return Math.sqrt(dx * dx + dy * dy)
1496
+ }
1497
+
1498
+ function handleTouchStart(e) {
1499
+ if (e.touches.length !== 2) return
1500
+ const dist = getTouchDist(e.touches[0], e.touches[1])
1501
+ pinchState.current = {
1502
+ active: true,
1503
+ startDist: dist,
1504
+ startZoom: zoomRef.current,
1505
+ centerX: (e.touches[0].clientX + e.touches[1].clientX) / 2,
1506
+ centerY: (e.touches[0].clientY + e.touches[1].clientY) / 2,
1507
+ }
1508
+ }
1509
+
1510
+ function handleTouchMove(e) {
1511
+ if (!pinchState.current.active || e.touches.length !== 2) return
1512
+ e.preventDefault()
1513
+ const dist = getTouchDist(e.touches[0], e.touches[1])
1514
+ const ratio = dist / pinchState.current.startDist
1515
+ const newZoom = Math.round(pinchState.current.startZoom * ratio)
1516
+ applyZoom(newZoom, pinchState.current.centerX, pinchState.current.centerY)
1517
+ }
1518
+
1519
+ function handleTouchEnd() {
1520
+ pinchState.current.active = false
1521
+ }
1522
+
1523
+ el.addEventListener('touchstart', handleTouchStart, { passive: true })
1524
+ el.addEventListener('touchmove', handleTouchMove, { passive: false })
1525
+ el.addEventListener('touchend', handleTouchEnd)
1526
+ el.addEventListener('touchcancel', handleTouchEnd)
1527
+ return () => {
1528
+ el.removeEventListener('touchstart', handleTouchStart)
1529
+ el.removeEventListener('touchmove', handleTouchMove)
1530
+ el.removeEventListener('touchend', handleTouchEnd)
1531
+ el.removeEventListener('touchcancel', handleTouchEnd)
1532
+ }
1533
+ }, [])
1534
+
1423
1535
  // Space + drag to pan the canvas
1424
1536
  const [spaceHeld, setSpaceHeld] = useState(false)
1425
1537
  const isPanning = useRef(false)
@@ -39,6 +39,35 @@ function resolveModulePath(modulePath) {
39
39
  return base ? `${base}${modulePath}` : modulePath
40
40
  }
41
41
 
42
+ /** Cache for the static story sources JSON fetched in prod builds. */
43
+ let _storySourcesCache = null
44
+
45
+ /**
46
+ * Fetch story source code. In dev, uses Vite's ?raw dynamic import.
47
+ * In prod, fetches from the build-time _storyboard/stories/sources.json.
48
+ */
49
+ async function fetchStorySource(modulePath) {
50
+ // Dev: use Vite's ?raw import for live source
51
+ if (import.meta.env.DEV) {
52
+ const mod = await import(/* @vite-ignore */ `${resolveModulePath(modulePath)}?raw`)
53
+ return mod.default || ''
54
+ }
55
+
56
+ // Prod: load from static JSON endpoint (same pattern as inspector.json)
57
+ if (!_storySourcesCache) {
58
+ const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
59
+ const res = await fetch(`${base}/_storyboard/stories/sources.json`)
60
+ if (!res.ok) throw new Error(`Story sources not available (${res.status})`)
61
+ _storySourcesCache = await res.json()
62
+ }
63
+
64
+ // _storyModule is like "/src/canvas/stories/foo.story.jsx" — strip leading /
65
+ const key = modulePath.startsWith('/') ? modulePath.slice(1) : modulePath
66
+ const source = _storySourcesCache[key]
67
+ if (source == null) throw new Error(`Source not found for ${key}`)
68
+ return source
69
+ }
70
+
42
71
  export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate, resizable }, ref) {
43
72
  const storyId = props?.storyId || ''
44
73
  const exportName = props?.exportName || ''
@@ -213,12 +242,10 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
213
242
  let cancelled = false
214
243
  Promise.resolve().then(() => { if (!cancelled) setSourceLoading(true) })
215
244
 
216
- // Use dynamic import with ?raw to get the file contents as a string.
217
- // Vite's ?raw suffix returns a module whose default export is the raw text.
218
- import(/* @vite-ignore */ `${resolveModulePath(story._storyModule)}?raw`)
219
- .then((mod) => {
245
+ fetchStorySource(story._storyModule)
246
+ .then((code) => {
220
247
  if (cancelled) return
221
- setSourceCode(mod.default || '// Empty file')
248
+ setSourceCode(code || '// Empty file')
222
249
  })
223
250
  .catch(() => { if (!cancelled) setSourceCode('// Failed to load source') })
224
251
  .finally(() => { if (!cancelled) setSourceLoading(false) })
@@ -259,8 +286,7 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
259
286
  const story = getStoryData(storyId)
260
287
  if (!story?._storyModule) return
261
288
  try {
262
- const mod = await import(/* @vite-ignore */ `${resolveModulePath(story._storyModule)}?raw`)
263
- const code = mod.default || ''
289
+ const code = await fetchStorySource(story._storyModule)
264
290
  setSourceCode(code)
265
291
  await navigator.clipboard?.writeText(code)
266
292
  } catch { /* ignore */ }
@@ -1115,7 +1115,7 @@ describe('canvas watcher behavior', () => {
1115
1115
 
1116
1116
  expect(code).toContain('const stories = {')
1117
1117
  expect(code).toContain('init({ flows, objects, records, prototypes, folders, canvases, stories })')
1118
- expect(code).toContain('export { flows, scenes, objects, records, prototypes, folders, canvases, stories }')
1118
+ expect(code).toContain('export { flows, scenes, objects, records, prototypes, folders, canvases, canvasAliases, stories }')
1119
1119
  })
1120
1120
 
1121
1121
  it('infers /components/ route for stories in src/canvas/', () => {