@dfosco/storyboard-react 4.0.0-beta.24 → 4.0.0-beta.25
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 +3 -3
- package/src/canvas/CanvasPage.bridge.test.jsx +8 -8
- package/src/canvas/CanvasPage.dragdrop.test.jsx +8 -8
- package/src/canvas/CanvasPage.jsx +77 -77
- package/src/canvas/CanvasPage.multiselect.test.jsx +11 -11
- package/src/canvas/CanvasToolbar.jsx +2 -2
- package/src/canvas/canvasApi.js +12 -10
- package/src/canvas/useCanvas.js +9 -8
- package/src/canvas/widgets/ComponentWidget.jsx +21 -6
- package/src/canvas/widgets/ComponentWidget.module.css +5 -0
- package/src/canvas/widgets/FigmaEmbed.jsx +44 -15
- package/src/canvas/widgets/PrototypeEmbed.jsx +217 -208
- package/src/canvas/widgets/PrototypeEmbed.module.css +61 -19
- package/src/canvas/widgets/StoryWidget.jsx +142 -171
- package/src/canvas/widgets/StoryWidget.module.css +38 -28
- package/src/canvas/widgets/WidgetChrome.jsx +3 -2
- package/src/canvas/widgets/embedInteraction.test.jsx +86 -4
- package/src/canvas/widgets/embedTheme.js +20 -0
- package/src/canvas/widgets/iframeDevLogs.js +49 -0
- package/src/canvas/widgets/iframeDevLogs.test.jsx +81 -0
- package/src/canvas/widgets/snapshotDisplay.test.jsx +289 -0
- package/src/canvas/widgets/useSnapshotCapture.js +221 -0
- package/src/canvas/widgets/useSnapshotCapture.test.jsx +149 -0
- package/src/context.jsx +14 -14
- package/src/story/StoryPage.jsx +7 -7
- package/src/vite/data-plugin.js +25 -20
- package/src/vite/data-plugin.test.js +4 -4
- package/src/canvas/widgets/useViewportEntry.js +0 -93
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.25",
|
|
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.25",
|
|
7
|
+
"@dfosco/tiny-canvas": "4.0.0-beta.25",
|
|
8
8
|
"@neodrag/react": "^2.3.1",
|
|
9
9
|
"glob": "^11.0.0",
|
|
10
10
|
"jsonc-parser": "^3.3.1",
|
|
@@ -105,11 +105,11 @@ describe('CanvasPage canvas bridge', () => {
|
|
|
105
105
|
document.addEventListener('storyboard:canvas:mounted', mountedHandler)
|
|
106
106
|
document.addEventListener('storyboard:canvas:status', statusHandler)
|
|
107
107
|
|
|
108
|
-
const { unmount } = render(<CanvasPage
|
|
108
|
+
const { unmount } = render(<CanvasPage canvasId="design-overview" />)
|
|
109
109
|
|
|
110
110
|
expect(window.__storyboardCanvasBridgeState).toEqual({
|
|
111
111
|
active: true,
|
|
112
|
-
|
|
112
|
+
canvasId: 'design-overview',
|
|
113
113
|
zoom: 100,
|
|
114
114
|
})
|
|
115
115
|
expect(mountedHandler).toHaveBeenCalled()
|
|
@@ -118,7 +118,7 @@ describe('CanvasPage canvas bridge', () => {
|
|
|
118
118
|
expect(statusHandler).toHaveBeenCalled()
|
|
119
119
|
expect(statusHandler.mock.calls.at(-1)?.[0]?.detail).toEqual({
|
|
120
120
|
active: true,
|
|
121
|
-
|
|
121
|
+
canvasId: 'design-overview',
|
|
122
122
|
zoom: 100,
|
|
123
123
|
})
|
|
124
124
|
|
|
@@ -132,13 +132,13 @@ describe('CanvasPage canvas bridge', () => {
|
|
|
132
132
|
const unmountedHandler = vi.fn()
|
|
133
133
|
document.addEventListener('storyboard:canvas:unmounted', unmountedHandler)
|
|
134
134
|
|
|
135
|
-
const { unmount } = render(<CanvasPage
|
|
135
|
+
const { unmount } = render(<CanvasPage canvasId="design-overview" />)
|
|
136
136
|
unmount()
|
|
137
137
|
|
|
138
138
|
expect(unmountedHandler).toHaveBeenCalled()
|
|
139
139
|
expect(window.__storyboardCanvasBridgeState).toEqual({
|
|
140
140
|
active: false,
|
|
141
|
-
|
|
141
|
+
canvasId: '',
|
|
142
142
|
zoom: 100,
|
|
143
143
|
})
|
|
144
144
|
|
|
@@ -146,7 +146,7 @@ describe('CanvasPage canvas bridge', () => {
|
|
|
146
146
|
})
|
|
147
147
|
|
|
148
148
|
it.skip('persists dragged JSON widgets and JSX sources to canvas JSONL via update API', async () => {
|
|
149
|
-
render(<CanvasPage
|
|
149
|
+
render(<CanvasPage canvasId="design-overview" />)
|
|
150
150
|
|
|
151
151
|
fireEvent.click(screen.getByTestId('drag-widget'))
|
|
152
152
|
// Flush the promise-based write queue
|
|
@@ -179,7 +179,7 @@ describe('CanvasPage canvas bridge', () => {
|
|
|
179
179
|
})
|
|
180
180
|
|
|
181
181
|
it.skip('clamps negative drag positions to zero', async () => {
|
|
182
|
-
render(<CanvasPage
|
|
182
|
+
render(<CanvasPage canvasId="design-overview" />)
|
|
183
183
|
|
|
184
184
|
fireEvent.click(screen.getByTestId('drag-widget-negative'))
|
|
185
185
|
await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
|
|
@@ -243,7 +243,7 @@ describe('canvas target fallback', () => {
|
|
|
243
243
|
localStorage.setItem('sb-color-scheme', 'dark')
|
|
244
244
|
document.documentElement.setAttribute('data-sb-canvas-theme', 'dark')
|
|
245
245
|
|
|
246
|
-
render(<CanvasPage
|
|
246
|
+
render(<CanvasPage canvasId="design-overview" />)
|
|
247
247
|
|
|
248
248
|
const scroll = document.querySelector('[data-storyboard-canvas-scroll]')
|
|
249
249
|
const jsxWidget = document.getElementById('jsx-PrimaryButtons')
|
|
@@ -123,7 +123,7 @@ describe('CanvasPage image drag-and-drop', () => {
|
|
|
123
123
|
})
|
|
124
124
|
|
|
125
125
|
it('allows drop by preventing default on dragover with Files', () => {
|
|
126
|
-
render(<CanvasPage
|
|
126
|
+
render(<CanvasPage canvasId="test-canvas" />)
|
|
127
127
|
const scrollContainer = document.querySelector('[data-storyboard-canvas-scroll]')
|
|
128
128
|
|
|
129
129
|
const dragOverEvent = new Event('dragover', { bubbles: true, cancelable: true })
|
|
@@ -137,7 +137,7 @@ describe('CanvasPage image drag-and-drop', () => {
|
|
|
137
137
|
})
|
|
138
138
|
|
|
139
139
|
it('ignores dragover without Files type (internal widget drag)', () => {
|
|
140
|
-
render(<CanvasPage
|
|
140
|
+
render(<CanvasPage canvasId="test-canvas" />)
|
|
141
141
|
const scrollContainer = document.querySelector('[data-storyboard-canvas-scroll]')
|
|
142
142
|
|
|
143
143
|
const dragOverEvent = new Event('dragover', { bubbles: true, cancelable: true })
|
|
@@ -150,7 +150,7 @@ describe('CanvasPage image drag-and-drop', () => {
|
|
|
150
150
|
})
|
|
151
151
|
|
|
152
152
|
it('uploads image and creates widget on drop', async () => {
|
|
153
|
-
render(<CanvasPage
|
|
153
|
+
render(<CanvasPage canvasId="test-canvas" />)
|
|
154
154
|
const scrollContainer = document.querySelector('[data-storyboard-canvas-scroll]')
|
|
155
155
|
|
|
156
156
|
const imageFile = createMockImageFile('photo.png', 'image/png')
|
|
@@ -199,7 +199,7 @@ describe('CanvasPage image drag-and-drop', () => {
|
|
|
199
199
|
})
|
|
200
200
|
|
|
201
201
|
it('ignores non-image files but prevents browser default', async () => {
|
|
202
|
-
render(<CanvasPage
|
|
202
|
+
render(<CanvasPage canvasId="test-canvas" />)
|
|
203
203
|
const scrollContainer = document.querySelector('[data-storyboard-canvas-scroll]')
|
|
204
204
|
|
|
205
205
|
const textFile = new File(['text content'], 'readme.txt', { type: 'text/plain' })
|
|
@@ -225,7 +225,7 @@ describe('CanvasPage image drag-and-drop', () => {
|
|
|
225
225
|
})
|
|
226
226
|
|
|
227
227
|
it('processes multiple image files on drop', async () => {
|
|
228
|
-
render(<CanvasPage
|
|
228
|
+
render(<CanvasPage canvasId="test-canvas" />)
|
|
229
229
|
const scrollContainer = document.querySelector('[data-storyboard-canvas-scroll]')
|
|
230
230
|
|
|
231
231
|
const image1 = createMockImageFile('photo1.png', 'image/png')
|
|
@@ -253,7 +253,7 @@ describe('CanvasPage image drag-and-drop', () => {
|
|
|
253
253
|
})
|
|
254
254
|
|
|
255
255
|
it('ignores drop without Files type', async () => {
|
|
256
|
-
render(<CanvasPage
|
|
256
|
+
render(<CanvasPage canvasId="test-canvas" />)
|
|
257
257
|
const scrollContainer = document.querySelector('[data-storyboard-canvas-scroll]')
|
|
258
258
|
|
|
259
259
|
const dropEvent = new Event('drop', { bubbles: true, cancelable: true })
|
|
@@ -273,7 +273,7 @@ describe('CanvasPage image drag-and-drop', () => {
|
|
|
273
273
|
const originalSnapToGrid = mockCanvas.snapToGrid
|
|
274
274
|
mockCanvas.snapToGrid = true
|
|
275
275
|
|
|
276
|
-
render(<CanvasPage
|
|
276
|
+
render(<CanvasPage canvasId="test-canvas" />)
|
|
277
277
|
const scrollContainer = document.querySelector('[data-storyboard-canvas-scroll]')
|
|
278
278
|
|
|
279
279
|
const imageFile = createMockImageFile()
|
|
@@ -311,7 +311,7 @@ describe('CanvasPage image drag-and-drop', () => {
|
|
|
311
311
|
const originalSnapToGrid = mockCanvas.snapToGrid
|
|
312
312
|
mockCanvas.snapToGrid = false
|
|
313
313
|
|
|
314
|
-
render(<CanvasPage
|
|
314
|
+
render(<CanvasPage canvasId="test-canvas" />)
|
|
315
315
|
const scrollContainer = document.querySelector('[data-storyboard-canvas-scroll]')
|
|
316
316
|
|
|
317
317
|
const imageFile = createMockImageFile()
|
|
@@ -106,18 +106,18 @@ function debounce(fn, ms) {
|
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
/** Per-canvas viewport state persistence (zoom + scroll position). */
|
|
109
|
-
function getViewportStorageKey(
|
|
110
|
-
return `sb-canvas-viewport:${
|
|
109
|
+
function getViewportStorageKey(canvasId) {
|
|
110
|
+
return `sb-canvas-viewport:${canvasId}`
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
-
function loadViewportState(
|
|
113
|
+
function loadViewportState(canvasId) {
|
|
114
114
|
try {
|
|
115
|
-
const raw = localStorage.getItem(getViewportStorageKey(
|
|
115
|
+
const raw = localStorage.getItem(getViewportStorageKey(canvasId))
|
|
116
116
|
if (!raw) return null
|
|
117
117
|
const state = JSON.parse(raw)
|
|
118
118
|
const timestamp = typeof state.timestamp === 'number' ? state.timestamp : 0
|
|
119
119
|
if (Date.now() - timestamp > VIEWPORT_TTL_MS) {
|
|
120
|
-
localStorage.removeItem(getViewportStorageKey(
|
|
120
|
+
localStorage.removeItem(getViewportStorageKey(canvasId))
|
|
121
121
|
return null
|
|
122
122
|
}
|
|
123
123
|
return {
|
|
@@ -128,9 +128,9 @@ function loadViewportState(canvasName) {
|
|
|
128
128
|
} catch { return null }
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
function saveViewportState(
|
|
131
|
+
function saveViewportState(canvasId, state) {
|
|
132
132
|
try {
|
|
133
|
-
localStorage.setItem(getViewportStorageKey(
|
|
133
|
+
localStorage.setItem(getViewportStorageKey(canvasId), JSON.stringify({
|
|
134
134
|
...state,
|
|
135
135
|
timestamp: Date.now(),
|
|
136
136
|
}))
|
|
@@ -321,24 +321,24 @@ function ChromeWrappedWidget({
|
|
|
321
321
|
* Generic canvas page component.
|
|
322
322
|
* Reads canvas data from the index and renders all widgets on a draggable surface.
|
|
323
323
|
*
|
|
324
|
-
* @param {{
|
|
324
|
+
* @param {{ canvasId: string }} props - Canvas name as indexed by the data plugin
|
|
325
325
|
*/
|
|
326
|
-
export default function CanvasPage({
|
|
327
|
-
const { canvas, jsxExports, jsxError, loading } = useCanvas(
|
|
326
|
+
export default function CanvasPage({ canvasId, siblingPages = [], canvasMeta = null }) {
|
|
327
|
+
const { canvas, jsxExports, jsxError, loading } = useCanvas(canvasId)
|
|
328
328
|
const isLocalDev = typeof window !== 'undefined' && window.__SB_LOCAL_DEV__ === true && !new URLSearchParams(window.location.search).has('prodMode')
|
|
329
329
|
|
|
330
330
|
// Local mutable copy of widgets for instant UI updates
|
|
331
331
|
const [localWidgets, setLocalWidgets] = useState(canvas?.widgets ?? null)
|
|
332
332
|
const [trackedCanvas, setTrackedCanvas] = useState(canvas)
|
|
333
333
|
const [selectedWidgetIds, setSelectedWidgetIds] = useState(() => new Set())
|
|
334
|
-
const initialViewport = loadViewportState(
|
|
334
|
+
const initialViewport = loadViewportState(canvasId)
|
|
335
335
|
const [zoom, setZoom] = useState(initialViewport?.zoom ?? 100)
|
|
336
336
|
const zoomRef = useRef(initialViewport?.zoom ?? 100)
|
|
337
337
|
const scrollRef = useRef(null)
|
|
338
338
|
const pendingScrollRestore = useRef(initialViewport)
|
|
339
339
|
// Gate viewport persistence until initial positioning is complete.
|
|
340
|
-
// Tracks which
|
|
341
|
-
// write when this matches `
|
|
340
|
+
// Tracks which canvasId was last initialized — save effects only
|
|
341
|
+
// write when this matches `canvasId`, preventing cross-canvas corruption.
|
|
342
342
|
const viewportInitName = useRef(null)
|
|
343
343
|
const [localSources, setLocalSources] = useState(canvas?.sources ?? [])
|
|
344
344
|
const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
|
|
@@ -482,7 +482,7 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
|
|
|
482
482
|
undoRedo.reset()
|
|
483
483
|
// Block saves until the new canvas's viewport is fully restored.
|
|
484
484
|
viewportInitName.current = null
|
|
485
|
-
const newViewport = loadViewportState(
|
|
485
|
+
const newViewport = loadViewportState(canvasId)
|
|
486
486
|
pendingScrollRestore.current = newViewport
|
|
487
487
|
// Restore zoom from the new canvas's saved state
|
|
488
488
|
const newZoom = newViewport?.zoom ?? 100
|
|
@@ -492,8 +492,8 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
|
|
|
492
492
|
|
|
493
493
|
// Debounced save to server
|
|
494
494
|
const debouncedSave = useRef(
|
|
495
|
-
debounce((
|
|
496
|
-
updateCanvas(
|
|
495
|
+
debounce((canvasId, widgets) => {
|
|
496
|
+
updateCanvas(canvasId, { widgets }).catch((err) =>
|
|
497
497
|
console.error('[canvas] Failed to save:', err)
|
|
498
498
|
)
|
|
499
499
|
}, 2000)
|
|
@@ -512,20 +512,20 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
|
|
|
512
512
|
const next = prev.map((w) =>
|
|
513
513
|
w.id === widgetId ? { ...w, props: { ...w.props, ...snapped } } : w
|
|
514
514
|
)
|
|
515
|
-
debouncedSave(
|
|
515
|
+
debouncedSave(canvasId, next)
|
|
516
516
|
return next
|
|
517
517
|
})
|
|
518
|
-
}, [
|
|
518
|
+
}, [canvasId, debouncedSave, undoRedo, snapEnabled, snapGridSize])
|
|
519
519
|
|
|
520
520
|
const handleWidgetRemove = useCallback((widgetId) => {
|
|
521
521
|
undoRedo.snapshot(stateRef.current, 'remove', widgetId)
|
|
522
522
|
setLocalWidgets((prev) => prev ? prev.filter((w) => w.id !== widgetId) : prev)
|
|
523
523
|
queueWrite(() =>
|
|
524
|
-
removeWidgetApi(
|
|
524
|
+
removeWidgetApi(canvasId, widgetId).catch((err) =>
|
|
525
525
|
console.error('[canvas] Failed to remove widget:', err)
|
|
526
526
|
)
|
|
527
527
|
)
|
|
528
|
-
}, [
|
|
528
|
+
}, [canvasId, undoRedo])
|
|
529
529
|
|
|
530
530
|
const handleWidgetCopy = useCallback(async (widget) => {
|
|
531
531
|
// Find the next free offset — check how many copies already exist at +n*40
|
|
@@ -541,7 +541,7 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
|
|
|
541
541
|
const position = { x: baseX + n * 40, y: baseY + n * 40 }
|
|
542
542
|
try {
|
|
543
543
|
undoRedo.snapshot(stateRef.current, 'add')
|
|
544
|
-
const result = await addWidgetApi(
|
|
544
|
+
const result = await addWidgetApi(canvasId, {
|
|
545
545
|
type: widget.type,
|
|
546
546
|
props: { ...widget.props },
|
|
547
547
|
position,
|
|
@@ -552,11 +552,11 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
|
|
|
552
552
|
} catch (err) {
|
|
553
553
|
console.error('[canvas] Failed to copy widget:', err)
|
|
554
554
|
}
|
|
555
|
-
}, [
|
|
555
|
+
}, [canvasId, localWidgets, undoRedo])
|
|
556
556
|
|
|
557
557
|
const debouncedSourceSave = useRef(
|
|
558
|
-
debounce((
|
|
559
|
-
updateCanvas(
|
|
558
|
+
debounce((canvasId, sources) => {
|
|
559
|
+
updateCanvas(canvasId, { sources }).catch((err) =>
|
|
560
560
|
console.error('[canvas] Failed to save sources:', err)
|
|
561
561
|
)
|
|
562
562
|
}, 2000)
|
|
@@ -574,10 +574,10 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
|
|
|
574
574
|
const next = current.some((s) => s?.export === exportName)
|
|
575
575
|
? current.map((s) => (s?.export === exportName ? { ...s, ...snapped } : s))
|
|
576
576
|
: [...current, { export: exportName, ...snapped }]
|
|
577
|
-
debouncedSourceSave(
|
|
577
|
+
debouncedSourceSave(canvasId, next)
|
|
578
578
|
return next
|
|
579
579
|
})
|
|
580
|
-
}, [
|
|
580
|
+
}, [canvasId, debouncedSourceSave, undoRedo, snapEnabled, snapGridSize])
|
|
581
581
|
|
|
582
582
|
const handleItemDragEnd = useCallback((dragId, position) => {
|
|
583
583
|
if (!dragId || !position) {
|
|
@@ -629,7 +629,7 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
|
|
|
629
629
|
return w
|
|
630
630
|
})
|
|
631
631
|
queueWrite(() =>
|
|
632
|
-
updateCanvas(
|
|
632
|
+
updateCanvas(canvasId, { widgets: next }).catch((err) =>
|
|
633
633
|
console.error('[canvas] Failed to save multi-move:', err)
|
|
634
634
|
)
|
|
635
635
|
)
|
|
@@ -661,7 +661,7 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
|
|
|
661
661
|
})
|
|
662
662
|
if (changed) {
|
|
663
663
|
queueWrite(() =>
|
|
664
|
-
updateCanvas(
|
|
664
|
+
updateCanvas(canvasId, { sources: next }).catch((err) =>
|
|
665
665
|
console.error('[canvas] Failed to save multi-move sources:', err)
|
|
666
666
|
)
|
|
667
667
|
)
|
|
@@ -680,7 +680,7 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
|
|
|
680
680
|
? current.map((s) => (s?.export === sourceExport ? { ...s, position: rounded } : s))
|
|
681
681
|
: [...current, { export: sourceExport, position: rounded }]
|
|
682
682
|
queueWrite(() =>
|
|
683
|
-
updateCanvas(
|
|
683
|
+
updateCanvas(canvasId, { sources: next }).catch((err) =>
|
|
684
684
|
console.error('[canvas] Failed to save source position:', err)
|
|
685
685
|
)
|
|
686
686
|
)
|
|
@@ -696,13 +696,13 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
|
|
|
696
696
|
w.id === dragId ? { ...w, position: rounded } : w
|
|
697
697
|
)
|
|
698
698
|
queueWrite(() =>
|
|
699
|
-
updateCanvas(
|
|
699
|
+
updateCanvas(canvasId, { widgets: next }).catch((err) =>
|
|
700
700
|
console.error('[canvas] Failed to save widget position:', err)
|
|
701
701
|
)
|
|
702
702
|
)
|
|
703
703
|
return next
|
|
704
704
|
})
|
|
705
|
-
}, [
|
|
705
|
+
}, [canvasId, undoRedo, debouncedSave, transitionPeers, clearDragPreview])
|
|
706
706
|
|
|
707
707
|
useEffect(() => {
|
|
708
708
|
zoomRef.current = zoom
|
|
@@ -739,8 +739,8 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
|
|
|
739
739
|
}
|
|
740
740
|
}
|
|
741
741
|
// Allow save effects for this canvas now that positioning is settled.
|
|
742
|
-
viewportInitName.current =
|
|
743
|
-
}, [
|
|
742
|
+
viewportInitName.current = canvasId
|
|
743
|
+
}, [canvasId, loading])
|
|
744
744
|
|
|
745
745
|
// Center on a specific widget if `?widget=<id>` is in the URL
|
|
746
746
|
useEffect(() => {
|
|
@@ -795,23 +795,23 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
|
|
|
795
795
|
// operations (applyZoom, zoom-to-fit) adjust scroll AFTER setZoom, so the
|
|
796
796
|
// scroll values would be stale at this point.
|
|
797
797
|
useEffect(() => {
|
|
798
|
-
if (viewportInitName.current !==
|
|
798
|
+
if (viewportInitName.current !== canvasId) return
|
|
799
799
|
const el = scrollRef.current
|
|
800
800
|
// Read current scroll so the zoom entry doesn't zero-out position,
|
|
801
801
|
// but the authoritative scroll save comes from the scroll handler.
|
|
802
|
-
saveViewportState(
|
|
802
|
+
saveViewportState(canvasId, {
|
|
803
803
|
zoom,
|
|
804
804
|
scrollLeft: el?.scrollLeft ?? 0,
|
|
805
805
|
scrollTop: el?.scrollTop ?? 0,
|
|
806
806
|
})
|
|
807
|
-
}, [
|
|
807
|
+
}, [canvasId, zoom])
|
|
808
808
|
|
|
809
809
|
useEffect(() => {
|
|
810
810
|
const el = scrollRef.current
|
|
811
811
|
if (!el) return
|
|
812
812
|
const saveNow = () => {
|
|
813
|
-
if (viewportInitName.current !==
|
|
814
|
-
saveViewportState(
|
|
813
|
+
if (viewportInitName.current !== canvasId) return
|
|
814
|
+
saveViewportState(canvasId, {
|
|
815
815
|
zoom: zoomRef.current,
|
|
816
816
|
scrollLeft: el.scrollLeft,
|
|
817
817
|
scrollTop: el.scrollTop,
|
|
@@ -819,7 +819,7 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
|
|
|
819
819
|
}
|
|
820
820
|
const debouncedScrollSave = debounce(saveNow, 150)
|
|
821
821
|
function handleScroll() {
|
|
822
|
-
if (viewportInitName.current !==
|
|
822
|
+
if (viewportInitName.current !== canvasId) return
|
|
823
823
|
debouncedScrollSave()
|
|
824
824
|
}
|
|
825
825
|
el.addEventListener('scroll', handleScroll, { passive: true })
|
|
@@ -839,7 +839,7 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
|
|
|
839
839
|
// beforeunload doesn't fire).
|
|
840
840
|
saveNow()
|
|
841
841
|
}
|
|
842
|
-
}, [
|
|
842
|
+
}, [canvasId, loading])
|
|
843
843
|
|
|
844
844
|
/**
|
|
845
845
|
* Zoom to a new level, anchoring on an optional client-space point.
|
|
@@ -880,8 +880,8 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
|
|
|
880
880
|
// Persist after both zoom and scroll are settled (the zoom effect
|
|
881
881
|
// fires inside flushSync before the scroll adjustment above, so it
|
|
882
882
|
// would capture stale scroll values).
|
|
883
|
-
if (viewportInitName.current ===
|
|
884
|
-
saveViewportState(
|
|
883
|
+
if (viewportInitName.current === canvasId) {
|
|
884
|
+
saveViewportState(canvasId, {
|
|
885
885
|
zoom: clampedZoom,
|
|
886
886
|
scrollLeft: el.scrollLeft,
|
|
887
887
|
scrollTop: el.scrollTop,
|
|
@@ -891,13 +891,13 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
|
|
|
891
891
|
|
|
892
892
|
// Signal canvas mount/unmount to CoreUIBar
|
|
893
893
|
useEffect(() => {
|
|
894
|
-
window[CANVAS_BRIDGE_STATE_KEY] = { active: true,
|
|
894
|
+
window[CANVAS_BRIDGE_STATE_KEY] = { active: true, canvasId, zoom: zoomRef.current }
|
|
895
895
|
document.dispatchEvent(new CustomEvent('storyboard:canvas:mounted', {
|
|
896
|
-
detail: {
|
|
896
|
+
detail: { canvasId, zoom: zoomRef.current }
|
|
897
897
|
}))
|
|
898
898
|
|
|
899
899
|
function handleStatusRequest() {
|
|
900
|
-
const state = window[CANVAS_BRIDGE_STATE_KEY] || { active: true,
|
|
900
|
+
const state = window[CANVAS_BRIDGE_STATE_KEY] || { active: true, canvasId, zoom: zoomRef.current }
|
|
901
901
|
document.dispatchEvent(new CustomEvent('storyboard:canvas:status', { detail: state }))
|
|
902
902
|
}
|
|
903
903
|
|
|
@@ -905,10 +905,10 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
|
|
|
905
905
|
|
|
906
906
|
return () => {
|
|
907
907
|
document.removeEventListener('storyboard:canvas:status-request', handleStatusRequest)
|
|
908
|
-
window[CANVAS_BRIDGE_STATE_KEY] = { active: false,
|
|
908
|
+
window[CANVAS_BRIDGE_STATE_KEY] = { active: false, canvasId: '', zoom: 100 }
|
|
909
909
|
document.dispatchEvent(new CustomEvent('storyboard:canvas:unmounted'))
|
|
910
910
|
}
|
|
911
|
-
}, [
|
|
911
|
+
}, [canvasId])
|
|
912
912
|
|
|
913
913
|
// Tell the Vite dev server to suppress full-reloads while this canvas is active.
|
|
914
914
|
// The ?canvas-hmr URL param opts out of the guard for canvas UI development.
|
|
@@ -928,7 +928,7 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
|
|
|
928
928
|
clearInterval(interval)
|
|
929
929
|
import.meta.hot.send('storyboard:canvas-hmr-guard', { active: false, hmrEnabled: true })
|
|
930
930
|
}
|
|
931
|
-
}, [
|
|
931
|
+
}, [canvasId])
|
|
932
932
|
|
|
933
933
|
// Add a widget by type — used by CanvasControls and CoreUIBar event
|
|
934
934
|
const addWidget = useCallback(async (type) => {
|
|
@@ -936,7 +936,7 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
|
|
|
936
936
|
const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
|
|
937
937
|
const pos = centerPositionForWidget(center, type, defaultProps)
|
|
938
938
|
try {
|
|
939
|
-
const result = await addWidgetApi(
|
|
939
|
+
const result = await addWidgetApi(canvasId, {
|
|
940
940
|
type,
|
|
941
941
|
props: defaultProps,
|
|
942
942
|
position: pos,
|
|
@@ -948,7 +948,7 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
|
|
|
948
948
|
} catch (err) {
|
|
949
949
|
console.error('[canvas] Failed to add widget:', err)
|
|
950
950
|
}
|
|
951
|
-
}, [
|
|
951
|
+
}, [canvasId, undoRedo])
|
|
952
952
|
|
|
953
953
|
// Add a story widget by storyId — used by CanvasControls story picker
|
|
954
954
|
const addStoryWidget = useCallback(async (storyId) => {
|
|
@@ -956,7 +956,7 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
|
|
|
956
956
|
const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
|
|
957
957
|
const pos = centerPositionForWidget(center, 'story', storyProps)
|
|
958
958
|
try {
|
|
959
|
-
const result = await addWidgetApi(
|
|
959
|
+
const result = await addWidgetApi(canvasId, {
|
|
960
960
|
type: 'story',
|
|
961
961
|
props: storyProps,
|
|
962
962
|
position: pos,
|
|
@@ -968,7 +968,7 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
|
|
|
968
968
|
} catch (err) {
|
|
969
969
|
console.error('[canvas] Failed to add story widget:', err)
|
|
970
970
|
}
|
|
971
|
-
}, [
|
|
971
|
+
}, [canvasId, undoRedo])
|
|
972
972
|
|
|
973
973
|
// Listen for CoreUIBar add-widget events
|
|
974
974
|
useEffect(() => {
|
|
@@ -1003,7 +1003,7 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
|
|
|
1003
1003
|
function handleSnapToggle() {
|
|
1004
1004
|
setSnapEnabled((prev) => {
|
|
1005
1005
|
const next = !prev
|
|
1006
|
-
updateCanvas(
|
|
1006
|
+
updateCanvas(canvasId, { settings: { snapToGrid: next } }).catch((err) =>
|
|
1007
1007
|
console.error('[canvas] Failed to persist snap setting:', err)
|
|
1008
1008
|
)
|
|
1009
1009
|
return next
|
|
@@ -1011,7 +1011,7 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
|
|
|
1011
1011
|
}
|
|
1012
1012
|
document.addEventListener('storyboard:canvas:toggle-snap', handleSnapToggle)
|
|
1013
1013
|
return () => document.removeEventListener('storyboard:canvas:toggle-snap', handleSnapToggle)
|
|
1014
|
-
}, [
|
|
1014
|
+
}, [canvasId])
|
|
1015
1015
|
|
|
1016
1016
|
// Broadcast snap state to Svelte toolbar
|
|
1017
1017
|
useEffect(() => {
|
|
@@ -1076,8 +1076,8 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
|
|
|
1076
1076
|
el.scrollTop = (bounds.minY - FIT_PADDING) * newScale
|
|
1077
1077
|
|
|
1078
1078
|
// Persist after both zoom and scroll are settled
|
|
1079
|
-
if (viewportInitName.current ===
|
|
1080
|
-
saveViewportState(
|
|
1079
|
+
if (viewportInitName.current === canvasId) {
|
|
1080
|
+
saveViewportState(canvasId, {
|
|
1081
1081
|
zoom: fitZoom,
|
|
1082
1082
|
scrollLeft: el.scrollLeft,
|
|
1083
1083
|
scrollTop: el.scrollTop,
|
|
@@ -1101,11 +1101,11 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
|
|
|
1101
1101
|
|
|
1102
1102
|
// Broadcast zoom level to CoreUIBar whenever it changes
|
|
1103
1103
|
useEffect(() => {
|
|
1104
|
-
window[CANVAS_BRIDGE_STATE_KEY] = { active: true,
|
|
1104
|
+
window[CANVAS_BRIDGE_STATE_KEY] = { active: true, canvasId, zoom }
|
|
1105
1105
|
document.dispatchEvent(new CustomEvent('storyboard:canvas:zoom-changed', {
|
|
1106
1106
|
detail: { zoom }
|
|
1107
1107
|
}))
|
|
1108
|
-
}, [
|
|
1108
|
+
}, [canvasId, zoom])
|
|
1109
1109
|
|
|
1110
1110
|
// Delete selected widget on Delete/Backspace key
|
|
1111
1111
|
useEffect(() => {
|
|
@@ -1128,12 +1128,12 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
|
|
|
1128
1128
|
setSelectedWidgetIds(new Set())
|
|
1129
1129
|
}
|
|
1130
1130
|
// Copy shortcut (single widget selected):
|
|
1131
|
-
// cmd+c → copy
|
|
1131
|
+
// cmd+c → copy canvasId::widgetId (for cross-canvas paste-duplicate)
|
|
1132
1132
|
const mod = e.metaKey || e.ctrlKey
|
|
1133
1133
|
if (mod && e.key === 'c' && !e.shiftKey && selectedWidgetIds.size === 1) {
|
|
1134
1134
|
const widgetId = [...selectedWidgetIds][0]
|
|
1135
1135
|
e.preventDefault()
|
|
1136
|
-
navigator.clipboard.writeText(`${
|
|
1136
|
+
navigator.clipboard.writeText(`${canvasId}::${widgetId}`).catch(() => {})
|
|
1137
1137
|
}
|
|
1138
1138
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
1139
1139
|
e.preventDefault()
|
|
@@ -1145,7 +1145,7 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
|
|
|
1145
1145
|
if (!prev) return prev
|
|
1146
1146
|
const next = prev.filter(w => !selectedWidgetIds.has(w.id))
|
|
1147
1147
|
queueWrite(() =>
|
|
1148
|
-
updateCanvas(
|
|
1148
|
+
updateCanvas(canvasId, { widgets: next }).catch(err =>
|
|
1149
1149
|
console.error('[canvas] Failed to save multi-delete:', err)
|
|
1150
1150
|
)
|
|
1151
1151
|
)
|
|
@@ -1160,7 +1160,7 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
|
|
|
1160
1160
|
}
|
|
1161
1161
|
document.addEventListener('keydown', handleKeyDown)
|
|
1162
1162
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
1163
|
-
}, [selectedWidgetIds, localWidgets, handleWidgetRemove, undoRedo,
|
|
1163
|
+
}, [selectedWidgetIds, localWidgets, handleWidgetRemove, undoRedo, canvasId, debouncedSave])
|
|
1164
1164
|
|
|
1165
1165
|
// Ref to store processImageFile for use by drop effect
|
|
1166
1166
|
const processImageFileRef = useRef(null)
|
|
@@ -1209,7 +1209,7 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
|
|
|
1209
1209
|
displayW = maxWidth
|
|
1210
1210
|
}
|
|
1211
1211
|
|
|
1212
|
-
const uploadResult = await uploadImage(dataUrl,
|
|
1212
|
+
const uploadResult = await uploadImage(dataUrl, canvasId)
|
|
1213
1213
|
if (!uploadResult.success) {
|
|
1214
1214
|
console.error('[canvas] Image upload failed:', uploadResult.error)
|
|
1215
1215
|
return false
|
|
@@ -1224,7 +1224,7 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
|
|
|
1224
1224
|
pos = centerPositionForWidget(center, 'image', { width: displayW, height: displayH })
|
|
1225
1225
|
}
|
|
1226
1226
|
|
|
1227
|
-
const result = await addWidgetApi(
|
|
1227
|
+
const result = await addWidgetApi(canvasId, {
|
|
1228
1228
|
type: 'image',
|
|
1229
1229
|
props: { src: uploadResult.filename, private: false, width: displayW, height: displayH },
|
|
1230
1230
|
position: pos,
|
|
@@ -1271,8 +1271,8 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
|
|
|
1271
1271
|
const text = e.clipboardData?.getData('text/plain')?.trim()
|
|
1272
1272
|
if (!text) return
|
|
1273
1273
|
|
|
1274
|
-
// Detect
|
|
1275
|
-
// Also supports legacy
|
|
1274
|
+
// Detect canvasId::widgetId format for widget duplication (cross-canvas copy-paste)
|
|
1275
|
+
// Also supports legacy canvasId/widgetId for basenames without slashes,
|
|
1276
1276
|
// but only when the second segment looks like a widget ID (type-hash).
|
|
1277
1277
|
const widgetRefMatch = text.match(/^(.+)::([^:]+)$/) || (text.indexOf('::') === -1 && text.match(/^([^/]+)\/((?:sticky-note|markdown|prototype|link-preview|figma-embed|component|image)-[a-z0-9]+)$/))
|
|
1278
1278
|
if (widgetRefMatch) {
|
|
@@ -1282,7 +1282,7 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
|
|
|
1282
1282
|
if (sourceWidgetId.startsWith('jsx-')) return
|
|
1283
1283
|
try {
|
|
1284
1284
|
let sourceWidget = null
|
|
1285
|
-
if (sourceCanvas ===
|
|
1285
|
+
if (sourceCanvas === canvasId) {
|
|
1286
1286
|
sourceWidget = (localWidgets ?? []).find(w => w.id === sourceWidgetId)
|
|
1287
1287
|
} else {
|
|
1288
1288
|
const canvasData = await getCanvasApi(sourceCanvas)
|
|
@@ -1292,7 +1292,7 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
|
|
|
1292
1292
|
const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
|
|
1293
1293
|
const pos = centerPositionForWidget(center, sourceWidget.type, sourceWidget.props)
|
|
1294
1294
|
undoRedo.snapshot(stateRef.current, 'add')
|
|
1295
|
-
const result = await addWidgetApi(
|
|
1295
|
+
const result = await addWidgetApi(canvasId, {
|
|
1296
1296
|
type: sourceWidget.type,
|
|
1297
1297
|
props: { ...sourceWidget.props },
|
|
1298
1298
|
position: pos,
|
|
@@ -1316,7 +1316,7 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
|
|
|
1316
1316
|
const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
|
|
1317
1317
|
const pos = centerPositionForWidget(center, type, props)
|
|
1318
1318
|
try {
|
|
1319
|
-
const result = await addWidgetApi(
|
|
1319
|
+
const result = await addWidgetApi(canvasId, {
|
|
1320
1320
|
type,
|
|
1321
1321
|
props,
|
|
1322
1322
|
position: pos,
|
|
@@ -1332,7 +1332,7 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
|
|
|
1332
1332
|
|
|
1333
1333
|
document.addEventListener('paste', handlePaste)
|
|
1334
1334
|
return () => document.removeEventListener('paste', handlePaste)
|
|
1335
|
-
}, [
|
|
1335
|
+
}, [canvasId, undoRedo, localWidgets])
|
|
1336
1336
|
|
|
1337
1337
|
// --- Drag and drop handlers for images from Finder/file manager ---
|
|
1338
1338
|
// Separate effect to ensure listeners attach after scroll container mounts (loading=false)
|
|
@@ -1407,11 +1407,11 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
|
|
|
1407
1407
|
setLocalWidgets(previous.widgets)
|
|
1408
1408
|
setLocalSources(previous.sources)
|
|
1409
1409
|
queueWrite(() =>
|
|
1410
|
-
updateCanvas(
|
|
1410
|
+
updateCanvas(canvasId, { widgets: previous.widgets, sources: previous.sources }).catch((err) =>
|
|
1411
1411
|
console.error('[canvas] Failed to persist undo:', err)
|
|
1412
1412
|
)
|
|
1413
1413
|
)
|
|
1414
|
-
}, [
|
|
1414
|
+
}, [canvasId, debouncedSave, debouncedSourceSave, undoRedo])
|
|
1415
1415
|
|
|
1416
1416
|
const handleRedo = useCallback(() => {
|
|
1417
1417
|
const next = undoRedo.redo(stateRef.current)
|
|
@@ -1421,11 +1421,11 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
|
|
|
1421
1421
|
setLocalWidgets(next.widgets)
|
|
1422
1422
|
setLocalSources(next.sources)
|
|
1423
1423
|
queueWrite(() =>
|
|
1424
|
-
updateCanvas(
|
|
1424
|
+
updateCanvas(canvasId, { widgets: next.widgets, sources: next.sources }).catch((err) =>
|
|
1425
1425
|
console.error('[canvas] Failed to persist redo:', err)
|
|
1426
1426
|
)
|
|
1427
1427
|
)
|
|
1428
|
-
}, [
|
|
1428
|
+
}, [canvasId, debouncedSave, debouncedSourceSave, undoRedo])
|
|
1429
1429
|
|
|
1430
1430
|
// Keyboard shortcuts — dev-only (Cmd+Z / Cmd+Shift+Z)
|
|
1431
1431
|
useEffect(() => {
|
|
@@ -1595,7 +1595,7 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
|
|
|
1595
1595
|
if (!canvas) {
|
|
1596
1596
|
return (
|
|
1597
1597
|
<div className={styles.empty}>
|
|
1598
|
-
<p>Canvas “{
|
|
1598
|
+
<p>Canvas “{canvasId}” not found</p>
|
|
1599
1599
|
</div>
|
|
1600
1600
|
)
|
|
1601
1601
|
}
|
|
@@ -1713,8 +1713,8 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
|
|
|
1713
1713
|
return (
|
|
1714
1714
|
<>
|
|
1715
1715
|
<div className={styles.canvasTitle}>
|
|
1716
|
-
<h1 className={styles.canvasTitleStatic}>{canvasMeta?.title || canvas?.title ||
|
|
1717
|
-
<PageSelector currentName={
|
|
1716
|
+
<h1 className={styles.canvasTitleStatic}>{canvasMeta?.title || canvas?.title || canvasId.split('/').pop()}</h1>
|
|
1717
|
+
<PageSelector currentName={canvasId} pages={siblingPages} />
|
|
1718
1718
|
{isLocalDev && (
|
|
1719
1719
|
<span className={styles.localEditingLabel}>Local editing</span>
|
|
1720
1720
|
)}
|