@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 +2 -2
- package/src/canvas/CanvasPage.bridge.test.jsx +140 -0
- package/src/canvas/CanvasPage.jsx +89 -23
- package/src/canvas/textSelection.js +10 -0
- package/src/canvas/textSelection.test.js +26 -0
- package/src/canvas/useCanvas.js +20 -0
- package/src/canvas/widgets/MarkdownBlock.jsx +1 -0
- package/src/canvas/widgets/StickyNote.jsx +1 -1
- package/src/canvas/widgets/StickyNote.module.css +25 -0
- package/src/vite/data-plugin.js +100 -10
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dfosco/storyboard-react",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"dependencies": {
|
|
6
|
-
"@dfosco/storyboard-core": "3.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
+
|
package/src/canvas/useCanvas.js
CHANGED
|
@@ -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
|
}
|
package/src/vite/data-plugin.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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
|