@dfosco/storyboard-react 2.7.1 → 3.0.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 +6 -3
- package/src/Viewfinder.jsx +5 -4
- package/src/canvas/CanvasPage.jsx +145 -0
- package/src/canvas/CanvasPage.module.css +15 -0
- package/src/canvas/CanvasToolbar.jsx +76 -0
- package/src/canvas/CanvasToolbar.module.css +92 -0
- package/src/canvas/canvasApi.js +41 -0
- package/src/canvas/useCanvas.js +74 -0
- package/src/canvas/widgets/ComponentWidget.jsx +15 -0
- package/src/canvas/widgets/MarkdownBlock.jsx +87 -0
- package/src/canvas/widgets/MarkdownBlock.module.css +78 -0
- package/src/canvas/widgets/PrototypeEmbed.jsx +33 -0
- package/src/canvas/widgets/PrototypeEmbed.module.css +26 -0
- package/src/canvas/widgets/StickyNote.jsx +106 -0
- package/src/canvas/widgets/StickyNote.module.css +136 -0
- package/src/canvas/widgets/WidgetWrapper.jsx +28 -0
- package/src/canvas/widgets/WidgetWrapper.module.css +53 -0
- package/src/canvas/widgets/index.js +21 -0
- package/src/canvas/widgets/widgetProps.js +144 -0
- package/src/hooks/useFeatureFlag.js +2 -4
- package/src/hooks/useFlows.js +50 -0
- package/src/hooks/useFlows.test.js +134 -0
- package/src/index.js +5 -0
- package/src/vite/data-plugin.js +129 -29
- package/src/vite/data-plugin.test.js +3 -3
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { renderHook } from '@testing-library/react'
|
|
2
|
+
import { createElement } from 'react'
|
|
3
|
+
import { init, getFlowsForPrototype } from '@dfosco/storyboard-core'
|
|
4
|
+
import { useFlows } from './useFlows.js'
|
|
5
|
+
import { StoryboardContext } from '../StoryboardContext.js'
|
|
6
|
+
|
|
7
|
+
// Test data with prototype-scoped flows
|
|
8
|
+
const SCOPED_FLOWS = {
|
|
9
|
+
'default': { meta: { title: 'Default' } },
|
|
10
|
+
'Signup/empty-form': { meta: { title: 'Empty Form' }, _route: '/Signup' },
|
|
11
|
+
'Signup/validation-errors': { meta: { title: 'Validation Errors' }, _route: '/Signup' },
|
|
12
|
+
'Signup/prefilled-review': { meta: { title: 'Prefilled Review' }, _route: '/Signup' },
|
|
13
|
+
'Signup/error-state': { meta: { title: 'Error State' }, _route: '/Signup' },
|
|
14
|
+
'Example/basic': { meta: { title: 'Example Data Flow' }, _route: '/Example' },
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function seedScopedData() {
|
|
18
|
+
init({ flows: SCOPED_FLOWS, objects: {}, records: {} })
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function createWrapperWithPrototype(flowName = 'default', prototypeName = null) {
|
|
22
|
+
return function Wrapper({ children }) {
|
|
23
|
+
return createElement(
|
|
24
|
+
StoryboardContext.Provider,
|
|
25
|
+
{ value: { data: {}, error: null, loading: false, flowName, sceneName: flowName, prototypeName } },
|
|
26
|
+
children,
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
seedScopedData()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
// ── Core utility: getFlowsForPrototype ──
|
|
36
|
+
|
|
37
|
+
describe('getFlowsForPrototype', () => {
|
|
38
|
+
it('returns flows scoped to the given prototype', () => {
|
|
39
|
+
const flows = getFlowsForPrototype('Signup')
|
|
40
|
+
expect(flows).toHaveLength(4)
|
|
41
|
+
expect(flows.map(f => f.name)).toEqual([
|
|
42
|
+
'empty-form', 'validation-errors', 'prefilled-review', 'error-state',
|
|
43
|
+
])
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('returns the full key with prototype prefix', () => {
|
|
47
|
+
const flows = getFlowsForPrototype('Signup')
|
|
48
|
+
expect(flows[0].key).toBe('Signup/empty-form')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('returns empty array for prototype with no flows', () => {
|
|
52
|
+
const flows = getFlowsForPrototype('NonExistent')
|
|
53
|
+
expect(flows).toEqual([])
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('returns empty array when prototypeName is null', () => {
|
|
57
|
+
const flows = getFlowsForPrototype(null)
|
|
58
|
+
expect(flows).toEqual([])
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('returns empty array when prototypeName is empty string', () => {
|
|
62
|
+
const flows = getFlowsForPrototype('')
|
|
63
|
+
expect(flows).toEqual([])
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('excludes global flows (no prototype prefix)', () => {
|
|
67
|
+
const flows = getFlowsForPrototype('Signup')
|
|
68
|
+
const keys = flows.map(f => f.key)
|
|
69
|
+
expect(keys).not.toContain('default')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('returns single flow for prototype with one flow', () => {
|
|
73
|
+
const flows = getFlowsForPrototype('Example')
|
|
74
|
+
expect(flows).toHaveLength(1)
|
|
75
|
+
expect(flows[0].name).toBe('basic')
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
// ── useFlows hook ──
|
|
80
|
+
|
|
81
|
+
describe('useFlows', () => {
|
|
82
|
+
it('returns flows for the current prototype', () => {
|
|
83
|
+
const { result } = renderHook(() => useFlows(), {
|
|
84
|
+
wrapper: createWrapperWithPrototype('Signup/empty-form', 'Signup'),
|
|
85
|
+
})
|
|
86
|
+
expect(result.current.flows).toHaveLength(4)
|
|
87
|
+
expect(result.current.flows[0].title).toBe('Empty Form')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('returns the active flow key', () => {
|
|
91
|
+
const { result } = renderHook(() => useFlows(), {
|
|
92
|
+
wrapper: createWrapperWithPrototype('Signup/validation-errors', 'Signup'),
|
|
93
|
+
})
|
|
94
|
+
expect(result.current.activeFlow).toBe('Signup/validation-errors')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('returns prototype name', () => {
|
|
98
|
+
const { result } = renderHook(() => useFlows(), {
|
|
99
|
+
wrapper: createWrapperWithPrototype('Signup/empty-form', 'Signup'),
|
|
100
|
+
})
|
|
101
|
+
expect(result.current.prototypeName).toBe('Signup')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('returns empty flows when no prototype', () => {
|
|
105
|
+
const { result } = renderHook(() => useFlows(), {
|
|
106
|
+
wrapper: createWrapperWithPrototype('default', null),
|
|
107
|
+
})
|
|
108
|
+
expect(result.current.flows).toEqual([])
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('switchFlow is a function', () => {
|
|
112
|
+
const { result } = renderHook(() => useFlows(), {
|
|
113
|
+
wrapper: createWrapperWithPrototype('Signup/empty-form', 'Signup'),
|
|
114
|
+
})
|
|
115
|
+
expect(typeof result.current.switchFlow).toBe('function')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('flow entries have title from meta', () => {
|
|
119
|
+
const { result } = renderHook(() => useFlows(), {
|
|
120
|
+
wrapper: createWrapperWithPrototype('Signup/empty-form', 'Signup'),
|
|
121
|
+
})
|
|
122
|
+
const titles = result.current.flows.map(f => f.title)
|
|
123
|
+
expect(titles).toContain('Empty Form')
|
|
124
|
+
expect(titles).toContain('Validation Errors')
|
|
125
|
+
expect(titles).toContain('Prefilled Review')
|
|
126
|
+
expect(titles).toContain('Error State')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('throws when used outside StoryboardProvider', () => {
|
|
130
|
+
expect(() => {
|
|
131
|
+
renderHook(() => useFlows())
|
|
132
|
+
}).toThrow('useFlows must be used within a <StoryboardProvider>')
|
|
133
|
+
})
|
|
134
|
+
})
|
package/src/index.js
CHANGED
|
@@ -16,6 +16,7 @@ export { useSceneData, useSceneLoading } from './hooks/useSceneData.js'
|
|
|
16
16
|
export { useOverride } from './hooks/useOverride.js'
|
|
17
17
|
export { useOverride as useSession } from './hooks/useOverride.js' // deprecated alias
|
|
18
18
|
export { useFlow, useScene } from './hooks/useScene.js'
|
|
19
|
+
export { useFlows } from './hooks/useFlows.js'
|
|
19
20
|
export { useRecord, useRecords } from './hooks/useRecord.js'
|
|
20
21
|
export { useObject } from './hooks/useObject.js'
|
|
21
22
|
export { useLocalStorage } from './hooks/useLocalStorage.js'
|
|
@@ -35,3 +36,7 @@ export { FormContext } from './context/FormContext.js'
|
|
|
35
36
|
|
|
36
37
|
// Viewfinder dashboard
|
|
37
38
|
export { default as Viewfinder } from './Viewfinder.jsx'
|
|
39
|
+
|
|
40
|
+
// Canvas
|
|
41
|
+
export { default as CanvasPage } from './canvas/CanvasPage.jsx'
|
|
42
|
+
export { useCanvas } from './canvas/useCanvas.js'
|
package/src/vite/data-plugin.js
CHANGED
|
@@ -3,11 +3,13 @@ import path from 'node:path'
|
|
|
3
3
|
import { execSync } from 'node:child_process'
|
|
4
4
|
import { globSync } from 'glob'
|
|
5
5
|
import { parse as parseJsonc } from 'jsonc-parser'
|
|
6
|
+
import { materializeFromText } from '../../../core/src/canvas/materializer.js'
|
|
6
7
|
|
|
7
8
|
const VIRTUAL_MODULE_ID = 'virtual:storyboard-data-index'
|
|
8
9
|
const RESOLVED_ID = '\0' + VIRTUAL_MODULE_ID
|
|
9
10
|
|
|
10
11
|
const GLOB_PATTERN = '**/*.{flow,scene,object,record,prototype,folder}.{json,jsonc}'
|
|
12
|
+
const CANVAS_GLOB_PATTERN = '**/*.canvas.jsonl'
|
|
11
13
|
|
|
12
14
|
/**
|
|
13
15
|
* Extract the data name and type suffix from a file path.
|
|
@@ -22,6 +24,42 @@ const GLOB_PATTERN = '**/*.{flow,scene,object,record,prototype,folder}.{json,jso
|
|
|
22
24
|
*/
|
|
23
25
|
function parseDataFile(filePath) {
|
|
24
26
|
const base = path.basename(filePath)
|
|
27
|
+
|
|
28
|
+
// Handle .canvas.jsonl files
|
|
29
|
+
const canvasJsonlMatch = base.match(/^(.+)\.canvas\.jsonl$/)
|
|
30
|
+
if (canvasJsonlMatch) {
|
|
31
|
+
if (canvasJsonlMatch[1].startsWith('_')) return null
|
|
32
|
+
const normalized = filePath.replace(/\\/g, '/')
|
|
33
|
+
if (normalized.split('/').some(seg => seg.startsWith('_'))) return null
|
|
34
|
+
|
|
35
|
+
const name = canvasJsonlMatch[1]
|
|
36
|
+
let inferredRoute = null
|
|
37
|
+
const canvasFolderMatch = normalized.match(/(?:^|\/)src\/canvases\/([^/]+)\.folder\//)
|
|
38
|
+
const canvasFolderName = canvasFolderMatch ? canvasFolderMatch[1] : null
|
|
39
|
+
const folderDirMatch = normalized.match(/(?:^|\/)src\/prototypes\/([^/]+)\.folder\//)
|
|
40
|
+
const folderName = folderDirMatch ? folderDirMatch[1] : null
|
|
41
|
+
|
|
42
|
+
const canvasCheck = normalized.match(/(?:^|\/)src\/canvases\//)
|
|
43
|
+
if (canvasCheck) {
|
|
44
|
+
const dirPath = normalized.substring(0, normalized.lastIndexOf('/'))
|
|
45
|
+
const routeBase = dirPath
|
|
46
|
+
.replace(/^.*?src\/canvases\//, '')
|
|
47
|
+
.replace(/[^/]*\.folder\/?/g, '')
|
|
48
|
+
inferredRoute = '/canvas/' + (routeBase ? routeBase + '/' : '') + name
|
|
49
|
+
inferredRoute = inferredRoute.replace(/\/+/g, '/').replace(/\/$/, '') || '/canvas'
|
|
50
|
+
}
|
|
51
|
+
const protoCheck = normalized.match(/(?:^|\/)src\/prototypes\//)
|
|
52
|
+
if (!canvasCheck && protoCheck) {
|
|
53
|
+
const dirPath = normalized.substring(0, normalized.lastIndexOf('/'))
|
|
54
|
+
const routeBase = dirPath
|
|
55
|
+
.replace(/^.*?src\/prototypes\//, '')
|
|
56
|
+
.replace(/[^/]*\.folder\/?/g, '')
|
|
57
|
+
inferredRoute = '/canvas/' + (routeBase ? routeBase + '/' : '') + name
|
|
58
|
+
inferredRoute = inferredRoute.replace(/\/+/g, '/').replace(/\/$/, '') || '/canvas'
|
|
59
|
+
}
|
|
60
|
+
return { name, suffix: 'canvas', ext: 'jsonl', folder: canvasFolderName || folderName, inferredRoute }
|
|
61
|
+
}
|
|
62
|
+
|
|
25
63
|
const match = base.match(/^(.+)\.(flow|scene|object|record|prototype|folder)\.(jsonc?)$/)
|
|
26
64
|
if (!match) return null
|
|
27
65
|
|
|
@@ -121,6 +159,7 @@ function getLastModified(root, dirPath) {
|
|
|
121
159
|
function buildIndex(root) {
|
|
122
160
|
const ignore = ['node_modules/**', 'dist/**', '.git/**']
|
|
123
161
|
const files = globSync(GLOB_PATTERN, { cwd: root, ignore, absolute: false })
|
|
162
|
+
const canvasFiles = globSync(CANVAS_GLOB_PATTERN, { cwd: root, ignore, absolute: false })
|
|
124
163
|
|
|
125
164
|
// Detect nested .folder/ directories (not supported)
|
|
126
165
|
// Scan directories directly since empty nested folders have no data files
|
|
@@ -137,12 +176,13 @@ function buildIndex(root) {
|
|
|
137
176
|
}
|
|
138
177
|
}
|
|
139
178
|
|
|
140
|
-
const index = { flow: {}, object: {}, record: {}, prototype: {}, folder: {} }
|
|
179
|
+
const index = { flow: {}, object: {}, record: {}, prototype: {}, folder: {}, canvas: {} }
|
|
141
180
|
const seen = {} // "name.suffix" → absolute path (for duplicate detection)
|
|
142
181
|
const protoFolders = {} // prototype name → folder name (for injection)
|
|
143
182
|
const flowRoutes = {} // flow name → inferred route (for _route injection)
|
|
183
|
+
const canvasRoutes = {} // canvas name → inferred route
|
|
144
184
|
|
|
145
|
-
for (const relPath of files) {
|
|
185
|
+
for (const relPath of [...files, ...canvasFiles]) {
|
|
146
186
|
const parsed = parseDataFile(relPath)
|
|
147
187
|
if (!parsed) continue
|
|
148
188
|
|
|
@@ -175,9 +215,14 @@ function buildIndex(root) {
|
|
|
175
215
|
if (parsed.suffix === 'flow' && parsed.inferredRoute) {
|
|
176
216
|
flowRoutes[parsed.name] = parsed.inferredRoute
|
|
177
217
|
}
|
|
218
|
+
|
|
219
|
+
// Track inferred routes for canvases
|
|
220
|
+
if (parsed.suffix === 'canvas' && parsed.inferredRoute) {
|
|
221
|
+
canvasRoutes[parsed.name] = parsed.inferredRoute
|
|
222
|
+
}
|
|
178
223
|
}
|
|
179
224
|
|
|
180
|
-
return { index, protoFolders, flowRoutes }
|
|
225
|
+
return { index, protoFolders, flowRoutes, canvasRoutes }
|
|
181
226
|
}
|
|
182
227
|
|
|
183
228
|
/**
|
|
@@ -248,25 +293,26 @@ function readConfig(root) {
|
|
|
248
293
|
}
|
|
249
294
|
|
|
250
295
|
/**
|
|
251
|
-
* Read
|
|
252
|
-
* Returns the full config object
|
|
296
|
+
* Read core-ui.config.json from @dfosco/storyboard-core.
|
|
297
|
+
* Returns the full config object with modes array.
|
|
253
298
|
* Falls back to hardcoded defaults if not found.
|
|
254
299
|
*/
|
|
255
300
|
function readModesConfig(root) {
|
|
256
301
|
const fallback = {
|
|
257
302
|
modes: [
|
|
258
|
-
{ name: 'prototype', label: 'Navigate' },
|
|
259
|
-
{ name: 'inspect', label: 'Develop' },
|
|
260
|
-
{ name: 'present', label: 'Collaborate' },
|
|
261
|
-
{ name: 'plan', label: 'Canvas' },
|
|
303
|
+
{ name: 'prototype', label: 'Navigate', hue: '#2a2a2a' },
|
|
304
|
+
{ name: 'inspect', label: 'Develop', hue: '#7655a4' },
|
|
305
|
+
{ name: 'present', label: 'Collaborate', hue: '#2a9d8f' },
|
|
306
|
+
{ name: 'plan', label: 'Canvas', hue: '#4a7fad' },
|
|
262
307
|
],
|
|
263
|
-
tools: {},
|
|
264
308
|
}
|
|
265
309
|
|
|
266
310
|
// Try local workspace path first (monorepo), then node_modules
|
|
267
311
|
const candidates = [
|
|
268
|
-
path.resolve(root, 'packages/core/
|
|
269
|
-
path.resolve(root, '
|
|
312
|
+
path.resolve(root, 'packages/core/core-ui.config.json'),
|
|
313
|
+
path.resolve(root, 'packages/core/configs/modes.config.json'),
|
|
314
|
+
path.resolve(root, 'node_modules/@dfosco/storyboard-core/core-ui.config.json'),
|
|
315
|
+
path.resolve(root, 'node_modules/@dfosco/storyboard-core/configs/modes.config.json'),
|
|
270
316
|
]
|
|
271
317
|
|
|
272
318
|
for (const filePath of candidates) {
|
|
@@ -274,7 +320,7 @@ function readModesConfig(root) {
|
|
|
274
320
|
const raw = fs.readFileSync(filePath, 'utf-8')
|
|
275
321
|
const parsed = JSON.parse(raw)
|
|
276
322
|
if (Array.isArray(parsed.modes) && parsed.modes.length > 0) {
|
|
277
|
-
return { modes: parsed.modes
|
|
323
|
+
return { modes: parsed.modes }
|
|
278
324
|
}
|
|
279
325
|
} catch {
|
|
280
326
|
// try next candidate
|
|
@@ -284,10 +330,10 @@ function readModesConfig(root) {
|
|
|
284
330
|
return fallback
|
|
285
331
|
}
|
|
286
332
|
|
|
287
|
-
function generateModule({ index, protoFolders, flowRoutes }, root) {
|
|
333
|
+
function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root) {
|
|
288
334
|
const declarations = []
|
|
289
|
-
const INDEX_KEYS = ['flow', 'object', 'record', 'prototype', 'folder']
|
|
290
|
-
const entries = { flow: [], object: [], record: [], prototype: [], folder: [] }
|
|
335
|
+
const INDEX_KEYS = ['flow', 'object', 'record', 'prototype', 'folder', 'canvas']
|
|
336
|
+
const entries = { flow: [], object: [], record: [], prototype: [], folder: [], canvas: [] }
|
|
291
337
|
const resolvedFlowRoutes = {} // flow name → resolved route (for multi-flow logging)
|
|
292
338
|
let i = 0
|
|
293
339
|
|
|
@@ -295,7 +341,9 @@ function generateModule({ index, protoFolders, flowRoutes }, root) {
|
|
|
295
341
|
for (const [name, absPath] of Object.entries(index[suffix])) {
|
|
296
342
|
const varName = `_d${i++}`
|
|
297
343
|
const raw = fs.readFileSync(absPath, 'utf-8')
|
|
298
|
-
let parsed =
|
|
344
|
+
let parsed = suffix === 'canvas'
|
|
345
|
+
? materializeFromText(raw)
|
|
346
|
+
: parseJsonc(raw)
|
|
299
347
|
|
|
300
348
|
// Auto-fill gitAuthor for prototype metadata from git history
|
|
301
349
|
if (suffix === 'prototype' && parsed && !parsed.gitAuthor) {
|
|
@@ -332,6 +380,37 @@ function generateModule({ index, protoFolders, flowRoutes }, root) {
|
|
|
332
380
|
}
|
|
333
381
|
}
|
|
334
382
|
|
|
383
|
+
// Inject inferred route and resolve JSX companion for canvases
|
|
384
|
+
if (suffix === 'canvas') {
|
|
385
|
+
if (canvasRoutes[name]) {
|
|
386
|
+
parsed = { ...parsed, _route: canvasRoutes[name] }
|
|
387
|
+
}
|
|
388
|
+
// Inject folder association
|
|
389
|
+
const folderDirMatch = path.relative(root, absPath).replace(/\\/g, '/').match(/(?:^|\/)src\/(?:prototypes|canvases)\/([^/]+)\.folder\//)
|
|
390
|
+
if (folderDirMatch) {
|
|
391
|
+
parsed = { ...parsed, _folder: folderDirMatch[1] }
|
|
392
|
+
}
|
|
393
|
+
// Resolve JSX companion file path
|
|
394
|
+
if (parsed?.jsx) {
|
|
395
|
+
const jsxPath = path.resolve(path.dirname(absPath), parsed.jsx)
|
|
396
|
+
if (fs.existsSync(jsxPath)) {
|
|
397
|
+
const relJsx = '/' + path.relative(root, jsxPath).replace(/\\/g, '/')
|
|
398
|
+
parsed = { ...parsed, _jsxModule: relJsx }
|
|
399
|
+
} else {
|
|
400
|
+
console.warn(
|
|
401
|
+
`[storyboard-data] Canvas "${name}" references JSX file "${parsed.jsx}" but it was not found at ${jsxPath}`
|
|
402
|
+
)
|
|
403
|
+
}
|
|
404
|
+
} else {
|
|
405
|
+
// Auto-detect a same-name .canvas.jsx companion
|
|
406
|
+
const autoJsx = absPath.replace(/\.canvas\.(jsonl|jsonc?)$/, '.canvas.jsx')
|
|
407
|
+
if (fs.existsSync(autoJsx)) {
|
|
408
|
+
const relJsx = '/' + path.relative(root, autoJsx).replace(/\\/g, '/')
|
|
409
|
+
parsed = { ...parsed, _jsxModule: relJsx }
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
335
414
|
// Resolve template variables (${currentDir}, ${currentProto}, ${currentProtoDir})
|
|
336
415
|
const templateVars = computeTemplateVars(absPath, root)
|
|
337
416
|
if (!templateVars.currentProto && raw.includes('${currentProto}')) {
|
|
@@ -354,7 +433,7 @@ function generateModule({ index, protoFolders, flowRoutes }, root) {
|
|
|
354
433
|
}
|
|
355
434
|
|
|
356
435
|
const imports = [`import { init } from '@dfosco/storyboard-core'`]
|
|
357
|
-
const initCalls = [`init({ flows, objects, records, prototypes, folders })`]
|
|
436
|
+
const initCalls = [`init({ flows, objects, records, prototypes, folders, canvases })`]
|
|
358
437
|
|
|
359
438
|
// Feature flags from storyboard.config.json
|
|
360
439
|
const { config } = readConfig(root)
|
|
@@ -383,15 +462,16 @@ function generateModule({ index, protoFolders, flowRoutes }, root) {
|
|
|
383
462
|
initCalls.push(`registerMode(${JSON.stringify(m.name)}, { label: ${JSON.stringify(m.label)} })`)
|
|
384
463
|
}
|
|
385
464
|
|
|
386
|
-
// Seed tool registry from modes.config.json
|
|
387
|
-
if (Object.keys(modesConfig.tools).length > 0) {
|
|
388
|
-
initCalls.push(`initTools(${JSON.stringify(modesConfig.tools)})`)
|
|
389
|
-
}
|
|
390
|
-
|
|
391
465
|
initCalls.push(`syncModeClasses()`)
|
|
392
466
|
}
|
|
393
467
|
}
|
|
394
468
|
|
|
469
|
+
// UI config from storyboard.config.json (menu visibility overrides)
|
|
470
|
+
if (config?.ui) {
|
|
471
|
+
imports.push(`import { initUIConfig } from '@dfosco/storyboard-core'`)
|
|
472
|
+
initCalls.push(`initUIConfig(${JSON.stringify(config.ui)})`)
|
|
473
|
+
}
|
|
474
|
+
|
|
395
475
|
// Log info when multiple flows target the same route
|
|
396
476
|
const routeGroups = {}
|
|
397
477
|
for (const [name, { route, isDefault }] of Object.entries(resolvedFlowRoutes)) {
|
|
@@ -422,14 +502,15 @@ function generateModule({ index, protoFolders, flowRoutes }, root) {
|
|
|
422
502
|
`const records = {\n${entries.record.join(',\n')}\n}`,
|
|
423
503
|
`const prototypes = {\n${entries.prototype.join(',\n')}\n}`,
|
|
424
504
|
`const folders = {\n${entries.folder.join(',\n')}\n}`,
|
|
505
|
+
`const canvases = {\n${entries.canvas.join(',\n')}\n}`,
|
|
425
506
|
'',
|
|
426
507
|
'// Backward-compatible alias',
|
|
427
508
|
'const scenes = flows',
|
|
428
509
|
'',
|
|
429
510
|
initCalls.join('\n'),
|
|
430
511
|
'',
|
|
431
|
-
`export { flows, scenes, objects, records, prototypes, folders }`,
|
|
432
|
-
`export const index = { flows, scenes, objects, records, prototypes, folders }`,
|
|
512
|
+
`export { flows, scenes, objects, records, prototypes, folders, canvases }`,
|
|
513
|
+
`export const index = { flows, scenes, objects, records, prototypes, folders, canvases }`,
|
|
433
514
|
`export default index`,
|
|
434
515
|
].join('\n')
|
|
435
516
|
}
|
|
@@ -437,7 +518,7 @@ function generateModule({ index, protoFolders, flowRoutes }, root) {
|
|
|
437
518
|
/**
|
|
438
519
|
* Vite plugin for storyboard data discovery.
|
|
439
520
|
*
|
|
440
|
-
* - Scans the repo for *.flow.json, *.scene.json (compat), *.object.json, *.record.json
|
|
521
|
+
* - Scans the repo for *.flow.json, *.scene.json (compat), *.object.json, *.record.json, *.canvas.jsonl
|
|
441
522
|
* - Validates no two files share the same name+suffix (hard build error)
|
|
442
523
|
* - Generates a virtual module `virtual:storyboard-data-index`
|
|
443
524
|
* - Watches for file additions/removals in dev mode
|
|
@@ -477,9 +558,15 @@ export default function storyboardDataPlugin() {
|
|
|
477
558
|
const watcher = server.watcher
|
|
478
559
|
|
|
479
560
|
const invalidate = (filePath) => {
|
|
561
|
+
const normalized = filePath.replace(/\\/g, '/')
|
|
562
|
+
// Skip .canvas.jsonl content changes entirely — these are mutated
|
|
563
|
+
// at runtime by the canvas server API. A full-reload would create
|
|
564
|
+
// a feedback loop (save → file change → reload → lose editing state).
|
|
565
|
+
if (/\.canvas\.jsonl$/.test(normalized)) return
|
|
566
|
+
|
|
480
567
|
const parsed = parseDataFile(filePath)
|
|
481
568
|
// Also invalidate when files are added/removed inside .folder/ directories
|
|
482
|
-
const inFolder =
|
|
569
|
+
const inFolder = normalized.includes('.folder/')
|
|
483
570
|
if (!parsed && !inFolder) return
|
|
484
571
|
// Rebuild index and invalidate virtual module
|
|
485
572
|
buildResult = null
|
|
@@ -490,6 +577,19 @@ export default function storyboardDataPlugin() {
|
|
|
490
577
|
}
|
|
491
578
|
}
|
|
492
579
|
|
|
580
|
+
const invalidateOnAddRemove = (filePath) => {
|
|
581
|
+
const parsed = parseDataFile(filePath)
|
|
582
|
+
const inFolder = filePath.replace(/\\/g, '/').includes('.folder/')
|
|
583
|
+
if (!parsed && !inFolder) return
|
|
584
|
+
// Canvas additions/removals DO need a reload (new routes)
|
|
585
|
+
buildResult = null
|
|
586
|
+
const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
|
|
587
|
+
if (mod) {
|
|
588
|
+
server.moduleGraph.invalidateModule(mod)
|
|
589
|
+
server.ws.send({ type: 'full-reload' })
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
493
593
|
// Watch storyboard.config.json for changes
|
|
494
594
|
const { configPath } = readConfig(root)
|
|
495
595
|
watcher.add(configPath)
|
|
@@ -504,8 +604,8 @@ export default function storyboardDataPlugin() {
|
|
|
504
604
|
}
|
|
505
605
|
}
|
|
506
606
|
|
|
507
|
-
watcher.on('add',
|
|
508
|
-
watcher.on('unlink',
|
|
607
|
+
watcher.on('add', invalidateOnAddRemove)
|
|
608
|
+
watcher.on('unlink', invalidateOnAddRemove)
|
|
509
609
|
watcher.on('change', (filePath) => {
|
|
510
610
|
invalidate(filePath)
|
|
511
611
|
invalidateConfig(filePath)
|
|
@@ -69,13 +69,13 @@ describe('storyboardDataPlugin', () => {
|
|
|
69
69
|
const code = plugin.load(RESOLVED_ID)
|
|
70
70
|
|
|
71
71
|
expect(code).toContain("import { init } from '@dfosco/storyboard-core'")
|
|
72
|
-
expect(code).toContain('init({ flows, objects, records, prototypes, folders })')
|
|
72
|
+
expect(code).toContain('init({ flows, objects, records, prototypes, folders, canvases })')
|
|
73
73
|
expect(code).toContain('"Test"')
|
|
74
74
|
expect(code).toContain('"Jane"')
|
|
75
75
|
expect(code).toContain('"First"')
|
|
76
76
|
// Backward-compat alias
|
|
77
77
|
expect(code).toContain('const scenes = flows')
|
|
78
|
-
expect(code).toContain('export { flows, scenes, objects, records, prototypes, folders }')
|
|
78
|
+
expect(code).toContain('export { flows, scenes, objects, records, prototypes, folders, canvases }')
|
|
79
79
|
})
|
|
80
80
|
|
|
81
81
|
it('load returns null for other IDs', () => {
|
|
@@ -161,7 +161,7 @@ describe('storyboardDataPlugin', () => {
|
|
|
161
161
|
|
|
162
162
|
// .scene.json files should be normalized to the flows category
|
|
163
163
|
expect(code).toContain('"Legacy Scene"')
|
|
164
|
-
expect(code).toContain('init({ flows, objects, records, prototypes, folders })')
|
|
164
|
+
expect(code).toContain('init({ flows, objects, records, prototypes, folders, canvases })')
|
|
165
165
|
})
|
|
166
166
|
|
|
167
167
|
it('buildStart resets the index cache', () => {
|