@dfosco/storyboard-react 4.0.0-beta.16 → 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.
|
|
3
|
+
"version": "4.0.0-beta.19",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"dependencies": {
|
|
6
|
-
"@dfosco/storyboard-core": "4.0.0-beta.
|
|
7
|
-
"@dfosco/tiny-canvas": "4.0.0-beta.
|
|
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(
|
|
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
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
const
|
|
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
|
-
//
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
pendingScrollRestore.current =
|
|
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
|
-
//
|
|
706
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
774
|
-
if (
|
|
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
|
-
|
|
786
|
-
|
|
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
|
-
|
|
217
|
-
|
|
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(
|
|
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
|
|
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/', () => {
|