@dfosco/storyboard-react 3.6.1 → 3.7.0

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,9 +1,9 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-react",
3
- "version": "3.6.1",
3
+ "version": "3.7.0",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@dfosco/storyboard-core": "3.6.1",
6
+ "@dfosco/storyboard-core": "3.7.0",
7
7
  "@dfosco/tiny-canvas": "^1.1.0",
8
8
  "@neodrag/react": "^2.3.1",
9
9
  "glob": "^11.0.0",
@@ -0,0 +1,140 @@
1
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
2
+ import CanvasPage from './CanvasPage.jsx'
3
+ import { updateCanvas } from './canvasApi.js'
4
+
5
+ vi.mock('@dfosco/tiny-canvas', () => ({
6
+ Canvas: ({ children, onDragEnd }) => (
7
+ <div data-testid="tiny-canvas">
8
+ {children}
9
+ <button
10
+ data-testid="drag-widget"
11
+ onClick={() => onDragEnd?.('widget-1', { x: 111.4, y: 222.7 })}
12
+ >
13
+ drag widget
14
+ </button>
15
+ <button
16
+ data-testid="drag-source"
17
+ onClick={() => onDragEnd?.('jsx-PrimaryButtons', { x: 333.2, y: 444.8 })}
18
+ >
19
+ drag source
20
+ </button>
21
+ </div>
22
+ ),
23
+ }))
24
+
25
+ const mockCanvas = {
26
+ title: 'Bridge Test Canvas',
27
+ widgets: [{ id: 'widget-1', type: 'mock-widget', position: { x: 10, y: 20 }, props: {} }],
28
+ sources: [{ export: 'PrimaryButtons', position: { x: 1, y: 2 } }],
29
+ centered: false,
30
+ dotted: false,
31
+ grid: false,
32
+ gridSize: 18,
33
+ colorMode: 'auto',
34
+ }
35
+
36
+ vi.mock('./useCanvas.js', () => ({
37
+ useCanvas: () => ({ canvas: mockCanvas, jsxExports: null, loading: false }),
38
+ }))
39
+
40
+ vi.mock('./widgets/index.js', () => ({
41
+ getWidgetComponent: () => function MockWidget() { return <div>mock widget</div> },
42
+ }))
43
+
44
+ vi.mock('./widgets/widgetProps.js', () => ({
45
+ schemas: {},
46
+ getDefaults: () => ({}),
47
+ }))
48
+
49
+ vi.mock('./canvasApi.js', () => ({
50
+ addWidget: vi.fn(),
51
+ updateCanvas: vi.fn(() => Promise.resolve({ success: true })),
52
+ removeWidget: vi.fn(),
53
+ }))
54
+
55
+ describe('CanvasPage canvas bridge', () => {
56
+ beforeEach(() => {
57
+ delete window.__storyboardCanvasBridgeState
58
+ vi.clearAllMocks()
59
+ })
60
+
61
+ it('publishes bridge state and responds to status requests', () => {
62
+ const mountedHandler = vi.fn()
63
+ const statusHandler = vi.fn()
64
+ document.addEventListener('storyboard:canvas:mounted', mountedHandler)
65
+ document.addEventListener('storyboard:canvas:status', statusHandler)
66
+
67
+ const { unmount } = render(<CanvasPage name="design-overview" />)
68
+
69
+ expect(window.__storyboardCanvasBridgeState).toEqual({
70
+ active: true,
71
+ name: 'design-overview',
72
+ zoom: 100,
73
+ })
74
+ expect(mountedHandler).toHaveBeenCalled()
75
+
76
+ document.dispatchEvent(new CustomEvent('storyboard:canvas:status-request'))
77
+ expect(statusHandler).toHaveBeenCalled()
78
+ expect(statusHandler.mock.calls.at(-1)?.[0]?.detail).toEqual({
79
+ active: true,
80
+ name: 'design-overview',
81
+ zoom: 100,
82
+ })
83
+
84
+ unmount()
85
+
86
+ document.removeEventListener('storyboard:canvas:mounted', mountedHandler)
87
+ document.removeEventListener('storyboard:canvas:status', statusHandler)
88
+ })
89
+
90
+ it('marks bridge inactive on unmount', () => {
91
+ const unmountedHandler = vi.fn()
92
+ document.addEventListener('storyboard:canvas:unmounted', unmountedHandler)
93
+
94
+ const { unmount } = render(<CanvasPage name="design-overview" />)
95
+ unmount()
96
+
97
+ expect(unmountedHandler).toHaveBeenCalled()
98
+ expect(window.__storyboardCanvasBridgeState).toEqual({
99
+ active: false,
100
+ name: '',
101
+ zoom: 100,
102
+ })
103
+
104
+ document.removeEventListener('storyboard:canvas:unmounted', unmountedHandler)
105
+ })
106
+
107
+ it('persists dragged JSON widgets and JSX sources to canvas JSONL via update API', async () => {
108
+ render(<CanvasPage name="design-overview" />)
109
+
110
+ fireEvent.click(screen.getByTestId('drag-widget'))
111
+ await waitFor(() => {
112
+ expect(updateCanvas).toHaveBeenCalledWith(
113
+ 'design-overview',
114
+ expect.objectContaining({
115
+ widgets: expect.arrayContaining([
116
+ expect.objectContaining({
117
+ id: 'widget-1',
118
+ position: { x: 111, y: 223 },
119
+ }),
120
+ ]),
121
+ })
122
+ )
123
+ })
124
+
125
+ fireEvent.click(screen.getByTestId('drag-source'))
126
+ await waitFor(() => {
127
+ expect(updateCanvas).toHaveBeenCalledWith(
128
+ 'design-overview',
129
+ expect.objectContaining({
130
+ sources: expect.arrayContaining([
131
+ expect.objectContaining({
132
+ export: 'PrimaryButtons',
133
+ position: { x: 333, y: 445 },
134
+ }),
135
+ ]),
136
+ })
137
+ )
138
+ })
139
+ })
140
+ })
@@ -2,6 +2,7 @@ import { createElement, useCallback, useEffect, useRef, useState } from 'react'
2
2
  import { Canvas } from '@dfosco/tiny-canvas'
3
3
  import '@dfosco/tiny-canvas/style.css'
4
4
  import { useCanvas } from './useCanvas.js'
5
+ import { shouldPreventCanvasTextSelection } from './textSelection.js'
5
6
  import { getWidgetComponent } from './widgets/index.js'
6
7
  import { schemas, getDefaults } from './widgets/widgetProps.js'
7
8
  import ComponentWidget from './widgets/ComponentWidget.jsx'
@@ -11,6 +12,8 @@ import styles from './CanvasPage.module.css'
11
12
  const ZOOM_MIN = 25
12
13
  const ZOOM_MAX = 200
13
14
 
15
+ const CANVAS_BRIDGE_STATE_KEY = '__storyboardCanvasBridgeState'
16
+
14
17
  /**
15
18
  * Debounce helper — returns a function that delays invocation.
16
19
  */
@@ -22,21 +25,6 @@ function debounce(fn, ms) {
22
25
  }
23
26
  }
24
27
 
25
- /**
26
- * Save a drag position to localStorage so tiny-canvas picks it up on render.
27
- */
28
- function saveWidgetPosition(widgetId, x, y) {
29
- try {
30
- const queue = JSON.parse(localStorage.getItem('tiny-canvas-queue')) || []
31
- const now = new Date().toISOString().replace(/[:.]/g, '-')
32
- const entry = { id: widgetId, x, y, time: now }
33
- const idx = queue.findIndex((item) => item.id === widgetId)
34
- if (idx >= 0) queue[idx] = entry
35
- else queue.push(entry)
36
- localStorage.setItem('tiny-canvas-queue', JSON.stringify(queue))
37
- } catch { /* localStorage unavailable */ }
38
- }
39
-
40
28
  /**
41
29
  * Get viewport-center coordinates for placing a new widget.
42
30
  */
@@ -47,6 +35,10 @@ function getViewportCenter() {
47
35
  }
48
36
  }
49
37
 
38
+ function roundPosition(value) {
39
+ return Math.round(value)
40
+ }
41
+
50
42
  /** Renders a single JSON-defined widget by type lookup. */
51
43
  function WidgetRenderer({ widget, onUpdate }) {
52
44
  const Component = getWidgetComponent(widget.type)
@@ -75,13 +67,16 @@ export default function CanvasPage({ name }) {
75
67
  const [trackedCanvas, setTrackedCanvas] = useState(canvas)
76
68
  const [selectedWidgetId, setSelectedWidgetId] = useState(null)
77
69
  const [zoom, setZoom] = useState(100)
70
+ const zoomRef = useRef(100)
78
71
  const scrollRef = useRef(null)
79
72
  const [canvasTitle, setCanvasTitle] = useState(canvas?.title || name)
80
73
  const titleInputRef = useRef(null)
74
+ const [localSources, setLocalSources] = useState(canvas?.sources ?? [])
81
75
 
82
76
  if (canvas !== trackedCanvas) {
83
77
  setTrackedCanvas(canvas)
84
78
  setLocalWidgets(canvas?.widgets ?? null)
79
+ setLocalSources(canvas?.sources ?? [])
85
80
  setCanvasTitle(canvas?.title || name)
86
81
  }
87
82
 
@@ -133,15 +128,61 @@ export default function CanvasPage({ name }) {
133
128
  )
134
129
  }, [name])
135
130
 
136
- // Signal canvas mount/unmount to CoreUIBar (include zoom state)
131
+ const handleItemDragEnd = useCallback((dragId, position) => {
132
+ if (!dragId || !position) return
133
+ const rounded = { x: roundPosition(position.x), y: roundPosition(position.y) }
134
+
135
+ if (dragId.startsWith('jsx-')) {
136
+ const sourceExport = dragId.replace(/^jsx-/, '')
137
+ setLocalSources((prev) => {
138
+ const current = Array.isArray(prev) ? prev : []
139
+ const next = current.some((s) => s?.export === sourceExport)
140
+ ? current.map((s) => (s?.export === sourceExport ? { ...s, position: rounded } : s))
141
+ : [...current, { export: sourceExport, position: rounded }]
142
+ updateCanvas(name, { sources: next }).catch((err) =>
143
+ console.error('[canvas] Failed to save source position:', err)
144
+ )
145
+ return next
146
+ })
147
+ return
148
+ }
149
+
150
+ setLocalWidgets((prev) => {
151
+ if (!prev) return prev
152
+ const next = prev.map((w) =>
153
+ w.id === dragId ? { ...w, position: rounded } : w
154
+ )
155
+ updateCanvas(name, { widgets: next }).catch((err) =>
156
+ console.error('[canvas] Failed to save widget position:', err)
157
+ )
158
+ return next
159
+ })
160
+ }, [name])
161
+
162
+ useEffect(() => {
163
+ zoomRef.current = zoom
164
+ }, [zoom])
165
+
166
+ // Signal canvas mount/unmount to CoreUIBar
137
167
  useEffect(() => {
168
+ window[CANVAS_BRIDGE_STATE_KEY] = { active: true, name, zoom: zoomRef.current }
138
169
  document.dispatchEvent(new CustomEvent('storyboard:canvas:mounted', {
139
- detail: { name, zoom }
170
+ detail: { name, zoom: zoomRef.current }
140
171
  }))
172
+
173
+ function handleStatusRequest() {
174
+ const state = window[CANVAS_BRIDGE_STATE_KEY] || { active: true, name, zoom: zoomRef.current }
175
+ document.dispatchEvent(new CustomEvent('storyboard:canvas:status', { detail: state }))
176
+ }
177
+
178
+ document.addEventListener('storyboard:canvas:status-request', handleStatusRequest)
179
+
141
180
  return () => {
181
+ document.removeEventListener('storyboard:canvas:status-request', handleStatusRequest)
182
+ window[CANVAS_BRIDGE_STATE_KEY] = { active: false, name: '', zoom: 100 }
142
183
  document.dispatchEvent(new CustomEvent('storyboard:canvas:unmounted'))
143
184
  }
144
- }, [name, zoom])
185
+ }, [name])
145
186
 
146
187
  // Add a widget by type — used by CanvasControls and CoreUIBar event
147
188
  const addWidget = useCallback(async (type) => {
@@ -154,7 +195,6 @@ export default function CanvasPage({ name }) {
154
195
  position: pos,
155
196
  })
156
197
  if (result.success && result.widget) {
157
- saveWidgetPosition(result.widget.id, pos.x, pos.y)
158
198
  setLocalWidgets((prev) => [...(prev || []), result.widget])
159
199
  }
160
200
  } catch (err) {
@@ -185,12 +225,23 @@ export default function CanvasPage({ name }) {
185
225
 
186
226
  // Broadcast zoom level to CoreUIBar whenever it changes
187
227
  useEffect(() => {
228
+ window[CANVAS_BRIDGE_STATE_KEY] = { active: true, name, zoom }
188
229
  document.dispatchEvent(new CustomEvent('storyboard:canvas:zoom-changed', {
189
230
  detail: { zoom }
190
231
  }))
191
- }, [zoom])
232
+ }, [name, zoom])
192
233
 
193
234
  // Delete selected widget on Delete/Backspace key
235
+ useEffect(() => {
236
+ function handleSelectStart(e) {
237
+ if (shouldPreventCanvasTextSelection(e.target)) {
238
+ e.preventDefault()
239
+ }
240
+ }
241
+ document.addEventListener('selectstart', handleSelectStart)
242
+ return () => document.removeEventListener('selectstart', handleSelectStart)
243
+ }, [])
244
+
194
245
  useEffect(() => {
195
246
  function handleKeyDown(e) {
196
247
  if (!selectedWidgetId) return
@@ -246,7 +297,6 @@ export default function CanvasPage({ name }) {
246
297
  position: pos,
247
298
  })
248
299
  if (result.success && result.widget) {
249
- saveWidgetPosition(result.widget.id, pos.x, pos.y)
250
300
  setLocalWidgets((prev) => [...(prev || []), result.widget])
251
301
  }
252
302
  } catch (err) {
@@ -361,11 +411,23 @@ export default function CanvasPage({ name }) {
361
411
  // Merge JSX-sourced widgets (from .canvas.jsx) and JSON widgets
362
412
  const allChildren = []
363
413
 
414
+ const sourcePositionByExport = Object.fromEntries(
415
+ (localSources || [])
416
+ .filter((source) => source?.export)
417
+ .map((source) => [source.export, source.position || { x: 0, y: 0 }])
418
+ )
419
+
364
420
  // 1. JSX-sourced component widgets
365
421
  if (jsxExports) {
366
422
  for (const [exportName, Component] of Object.entries(jsxExports)) {
423
+ const sourcePosition = sourcePositionByExport[exportName] || { x: 0, y: 0 }
367
424
  allChildren.push(
368
- <div key={`jsx-${exportName}`} id={`jsx-${exportName}`}>
425
+ <div
426
+ key={`jsx-${exportName}`}
427
+ id={`jsx-${exportName}`}
428
+ data-tc-x={sourcePosition.x}
429
+ data-tc-y={sourcePosition.y}
430
+ >
369
431
  <ComponentWidget component={Component} />
370
432
  </div>
371
433
  )
@@ -378,6 +440,8 @@ export default function CanvasPage({ name }) {
378
440
  <div
379
441
  key={widget.id}
380
442
  id={widget.id}
443
+ data-tc-x={widget?.position?.x ?? 0}
444
+ data-tc-y={widget?.position?.y ?? 0}
381
445
  onClick={(e) => {
382
446
  e.stopPropagation()
383
447
  setSelectedWidgetId(widget.id)
@@ -411,12 +475,14 @@ export default function CanvasPage({ name }) {
411
475
  </div>
412
476
  <div
413
477
  ref={scrollRef}
478
+ data-storyboard-canvas-scroll
414
479
  className={styles.canvasScroll}
415
480
  style={spaceHeld ? { cursor: panningActive ? 'grabbing' : 'grab' } : undefined}
416
481
  onClick={() => setSelectedWidgetId(null)}
417
482
  onMouseDown={handlePanStart}
418
483
  >
419
484
  <div
485
+ data-storyboard-canvas-zoom
420
486
  className={styles.canvasZoom}
421
487
  style={{
422
488
  transform: `scale(${scale})`,
@@ -425,7 +491,7 @@ export default function CanvasPage({ name }) {
425
491
  height: `${Math.max(10000, 100 / scale)}vh`,
426
492
  }}
427
493
  >
428
- <Canvas {...canvasProps}>
494
+ <Canvas {...canvasProps} onDragEnd={handleItemDragEnd}>
429
495
  {allChildren}
430
496
  </Canvas>
431
497
  </div>
@@ -0,0 +1,10 @@
1
+ const TEXT_SELECTION_EDITING_SELECTOR = 'textarea, [contenteditable="true"], [contenteditable=""], [data-canvas-allow-text-selection]'
2
+
3
+ /**
4
+ * Returns true when canvas mouse interactions should suppress browser text selection.
5
+ */
6
+ export function shouldPreventCanvasTextSelection(target) {
7
+ if (!(target instanceof Element)) return true
8
+ return !target.closest(TEXT_SELECTION_EDITING_SELECTOR)
9
+ }
10
+
@@ -0,0 +1,26 @@
1
+ import { shouldPreventCanvasTextSelection } from './textSelection.js'
2
+
3
+ describe('shouldPreventCanvasTextSelection', () => {
4
+ it('prevents selection for regular canvas elements', () => {
5
+ const container = document.createElement('div')
6
+ const regular = document.createElement('p')
7
+ container.appendChild(regular)
8
+
9
+ expect(shouldPreventCanvasTextSelection(regular)).toBe(true)
10
+ })
11
+
12
+ it('allows selection for textarea editing', () => {
13
+ const textarea = document.createElement('textarea')
14
+ expect(shouldPreventCanvasTextSelection(textarea)).toBe(false)
15
+ })
16
+
17
+ it('allows selection for explicit editable markers', () => {
18
+ const wrapper = document.createElement('div')
19
+ wrapper.setAttribute('data-canvas-allow-text-selection', '')
20
+ const child = document.createElement('span')
21
+ wrapper.appendChild(child)
22
+
23
+ expect(shouldPreventCanvasTextSelection(child)).toBe(false)
24
+ })
25
+ })
26
+
@@ -70,5 +70,25 @@ export function useCanvas(name) {
70
70
  })
71
71
  }, [canvas?._jsxModule])
72
72
 
73
+ // In dev, react to file mutations from the data plugin without reloading
74
+ // the current page. This keeps canvas editing state and route stable.
75
+ useEffect(() => {
76
+ if (!import.meta.hot || !buildTimeCanvas) return
77
+
78
+ const handleCanvasFileChanged = ({ data }) => {
79
+ if (!data || data.name !== name) return
80
+ fetchCanvasFromServer(name).then((fresh) => {
81
+ if (fresh) {
82
+ setCanvas((prev) => ({ ...(prev || buildTimeCanvas), ...fresh }))
83
+ }
84
+ })
85
+ }
86
+
87
+ import.meta.hot.on('storyboard:canvas-file-changed', handleCanvasFileChanged)
88
+ return () => {
89
+ import.meta.hot.off('storyboard:canvas-file-changed', handleCanvasFileChanged)
90
+ }
91
+ }, [name, buildTimeCanvas])
92
+
73
93
  return { canvas, jsxExports, loading }
74
94
  }
@@ -62,6 +62,7 @@ export default function MarkdownBlock({ props, onUpdate }) {
62
62
  <textarea
63
63
  ref={textareaRef}
64
64
  className={styles.editor}
65
+ data-canvas-allow-text-selection
65
66
  style={{ minHeight: editHeight ? editHeight - 2 : undefined }}
66
67
  value={content}
67
68
  onChange={handleContentChange}
@@ -53,6 +53,7 @@ export default function StickyNote({ props, onUpdate }) {
53
53
  <textarea
54
54
  ref={textareaRef}
55
55
  className={styles.textarea}
56
+ data-canvas-allow-text-selection
56
57
  value={text}
57
58
  onChange={handleTextChange}
58
59
  onBlur={() => setEditing(false)}
@@ -95,4 +96,3 @@ export default function StickyNote({ props, onUpdate }) {
95
96
  </div>
96
97
  )
97
98
  }
98
-
@@ -14,6 +14,13 @@
14
14
  position: relative;
15
15
  }
16
16
 
17
+ :global(html[data-color-mode='dark']) .sticky,
18
+ :global(html[data-sb-theme^='dark']) .sticky {
19
+ background: color-mix(in srgb, var(--sticky-bg) 30%, #0d1117 70%);
20
+ border-color: color-mix(in srgb, var(--sticky-bg) 55%, #f0f6fc 18%);
21
+ box-shadow: 2px 3px 10px rgba(0, 0, 0, 0.35);
22
+ }
23
+
17
24
  .text {
18
25
  padding: 16px 20px;
19
26
  margin: 0;
@@ -26,6 +33,11 @@
26
33
  min-height: 60px;
27
34
  }
28
35
 
36
+ :global(html[data-color-mode='dark']) .text,
37
+ :global(html[data-sb-theme^='dark']) .text {
38
+ color: color-mix(in srgb, var(--sticky-bg) 30%, #f0f6fc 70%);
39
+ }
40
+
29
41
  .textarea {
30
42
  position: absolute;
31
43
  top: 0;
@@ -47,6 +59,11 @@
47
59
  resize: none;
48
60
  }
49
61
 
62
+ :global(html[data-color-mode='dark']) .textarea,
63
+ :global(html[data-sb-theme^='dark']) .textarea {
64
+ color: color-mix(in srgb, var(--sticky-bg) 26%, #f0f6fc 74%);
65
+ }
66
+
50
67
  /* Color picker area — sits below the sticky */
51
68
 
52
69
  .pickerArea {
@@ -82,6 +99,14 @@
82
99
  z-index: 10;
83
100
  }
84
101
 
102
+ :global(html[data-color-mode='dark']) .pickerPopup,
103
+ :global(html[data-sb-theme^='dark']) .pickerPopup {
104
+ background: var(--bgColor-muted, #161b22);
105
+ box-shadow:
106
+ 0 0 0 1px rgba(255, 255, 255, 0.08),
107
+ 0 4px 12px rgba(0, 0, 0, 0.45);
108
+ }
109
+
85
110
  .pickerArea:hover .pickerDot {
86
111
  opacity: 0;
87
112
  }
@@ -573,13 +573,37 @@ export default function storyboardDataPlugin() {
573
573
  configureServer(server) {
574
574
  // Watch for data file changes in dev mode
575
575
  const watcher = server.watcher
576
+ if (!buildResult) buildResult = buildIndex(root)
577
+ const knownCanvasNames = new Set(Object.keys(buildResult.index.canvas || {}))
578
+ const pendingCanvasUnlinks = new Map()
579
+
580
+ const triggerFullReload = () => {
581
+ buildResult = null
582
+ const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
583
+ if (mod) {
584
+ server.moduleGraph.invalidateModule(mod)
585
+ server.ws.send({ type: 'full-reload' })
586
+ }
587
+ }
576
588
 
577
589
  const invalidate = (filePath) => {
578
590
  const normalized = filePath.replace(/\\/g, '/')
579
591
  // Skip .canvas.jsonl content changes entirely — these are mutated
580
592
  // at runtime by the canvas server API. A full-reload would create
581
593
  // a feedback loop (save → file change → reload → lose editing state).
582
- if (/\.canvas\.jsonl$/.test(normalized)) return
594
+ // Instead, send a custom HMR event so the active canvas page can refetch
595
+ // file-backed data in place with no navigation or document reload.
596
+ if (/\.canvas\.jsonl$/.test(normalized)) {
597
+ const parsed = parseDataFile(filePath)
598
+ if (parsed?.suffix === 'canvas' && parsed?.name) {
599
+ server.ws.send({
600
+ type: 'custom',
601
+ event: 'storyboard:canvas-file-changed',
602
+ data: { name: parsed.name },
603
+ })
604
+ }
605
+ return
606
+ }
583
607
 
584
608
  // Invalidate when toolbar.config.json inside a prototype changes
585
609
  if (normalized.endsWith('/toolbar.config.json') && normalized.includes('/prototypes/')) {
@@ -605,17 +629,64 @@ export default function storyboardDataPlugin() {
605
629
  }
606
630
  }
607
631
 
608
- const invalidateOnAddRemove = (filePath) => {
632
+ const invalidateOnAddRemove = (filePath, eventType) => {
609
633
  const parsed = parseDataFile(filePath)
610
634
  const inFolder = filePath.replace(/\\/g, '/').includes('.folder/')
611
635
  if (!parsed && !inFolder) return
612
- // Canvas additions/removals DO need a reload (new routes)
613
- buildResult = null
614
- const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
615
- if (mod) {
616
- server.moduleGraph.invalidateModule(mod)
617
- server.ws.send({ type: 'full-reload' })
636
+
637
+ // Canvas writers/editors can emit unlink+add for an in-place save.
638
+ // Treat canvas add/unlink as runtime data updates and never full-reload
639
+ // from watcher events. Canvas pages sync from disk via custom WS events.
640
+ if (parsed?.suffix === 'canvas') {
641
+ const name = parsed.name
642
+ if (eventType === 'unlink') {
643
+ const timer = setTimeout(() => {
644
+ pendingCanvasUnlinks.delete(name)
645
+ knownCanvasNames.delete(name)
646
+ server.ws.send({
647
+ type: 'custom',
648
+ event: 'storyboard:canvas-file-changed',
649
+ data: { name },
650
+ })
651
+ }, 1500)
652
+ pendingCanvasUnlinks.set(name, timer)
653
+ return
654
+ }
655
+
656
+ if (eventType === 'add') {
657
+ const pending = pendingCanvasUnlinks.get(name)
658
+ if (pending) {
659
+ clearTimeout(pending)
660
+ pendingCanvasUnlinks.delete(name)
661
+ server.ws.send({
662
+ type: 'custom',
663
+ event: 'storyboard:canvas-file-changed',
664
+ data: { name },
665
+ })
666
+ return
667
+ }
668
+
669
+ if (knownCanvasNames.has(name)) {
670
+ server.ws.send({
671
+ type: 'custom',
672
+ event: 'storyboard:canvas-file-changed',
673
+ data: { name },
674
+ })
675
+ return
676
+ }
677
+
678
+ knownCanvasNames.add(name)
679
+ server.ws.send({
680
+ type: 'custom',
681
+ event: 'storyboard:canvas-file-changed',
682
+ data: { name },
683
+ })
684
+ return
685
+ }
618
686
  }
687
+
688
+ // Non-canvas additions/removals and folder changes update the route/data graph.
689
+ triggerFullReload()
619
690
  }
620
691
 
621
692
  // Watch storyboard.config.json for changes
@@ -632,14 +703,33 @@ export default function storyboardDataPlugin() {
632
703
  }
633
704
  }
634
705
 
635
- watcher.on('add', invalidateOnAddRemove)
636
- watcher.on('unlink', invalidateOnAddRemove)
706
+ watcher.on('add', (filePath) => invalidateOnAddRemove(filePath, 'add'))
707
+ watcher.on('unlink', (filePath) => invalidateOnAddRemove(filePath, 'unlink'))
637
708
  watcher.on('change', (filePath) => {
638
709
  invalidate(filePath)
639
710
  invalidateConfig(filePath)
640
711
  })
641
712
  },
642
713
 
714
+ handleHotUpdate(ctx) {
715
+ const normalized = ctx.file.replace(/\\/g, '/')
716
+ if (!/\.canvas\.jsonl$/.test(normalized)) return
717
+
718
+ const parsed = parseDataFile(ctx.file)
719
+ if (parsed?.suffix === 'canvas' && parsed?.name) {
720
+ ctx.server.ws.send({
721
+ type: 'custom',
722
+ event: 'storyboard:canvas-file-changed',
723
+ data: { name: parsed.name },
724
+ })
725
+ }
726
+
727
+ // Prevent Vite's default fallback behavior (full page reload) for
728
+ // non-module .canvas.jsonl edits. Canvas pages consume these updates
729
+ // through the custom WS event and in-page refetch.
730
+ return []
731
+ },
732
+
643
733
  // Rebuild index on each build start
644
734
  buildStart() {
645
735
  buildResult = null