@dfosco/storyboard-react 2.8.0 → 3.1.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/CanvasControls.jsx +123 -0
- package/src/canvas/CanvasControls.module.css +133 -0
- package/src/canvas/CanvasPage.jsx +433 -0
- package/src/canvas/CanvasPage.module.css +73 -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/LinkPreview.jsx +34 -0
- package/src/canvas/widgets/LinkPreview.module.css +51 -0
- package/src/canvas/widgets/MarkdownBlock.jsx +91 -0
- package/src/canvas/widgets/MarkdownBlock.module.css +78 -0
- package/src/canvas/widgets/PrototypeEmbed.jsx +179 -0
- package/src/canvas/widgets/PrototypeEmbed.module.css +242 -0
- package/src/canvas/widgets/StickyNote.jsx +98 -0
- package/src/canvas/widgets/StickyNote.module.css +111 -0
- package/src/canvas/widgets/WidgetWrapper.jsx +15 -0
- package/src/canvas/widgets/WidgetWrapper.module.css +23 -0
- package/src/canvas/widgets/index.js +23 -0
- package/src/canvas/widgets/widgetProps.js +151 -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 +131 -29
- package/src/vite/data-plugin.test.js +3 -3
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { useContext, useMemo, useCallback } from 'react'
|
|
2
|
+
import { StoryboardContext } from '../StoryboardContext.js'
|
|
3
|
+
import { getFlowsForPrototype, resolveFlowRoute } from '@dfosco/storyboard-core'
|
|
4
|
+
import { getFlowMeta } from '@dfosco/storyboard-core'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* List all flows for the current prototype and switch between them.
|
|
8
|
+
*
|
|
9
|
+
* @returns {{
|
|
10
|
+
* flows: Array<{ key: string, name: string, title: string, route: string }>,
|
|
11
|
+
* activeFlow: string,
|
|
12
|
+
* switchFlow: (flowKey: string) => void,
|
|
13
|
+
* prototypeName: string | null
|
|
14
|
+
* }}
|
|
15
|
+
*/
|
|
16
|
+
export function useFlows() {
|
|
17
|
+
const context = useContext(StoryboardContext)
|
|
18
|
+
if (context === null) {
|
|
19
|
+
throw new Error('useFlows must be used within a <StoryboardProvider>')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const { flowName: activeFlow, prototypeName } = context
|
|
23
|
+
|
|
24
|
+
const flows = useMemo(() => {
|
|
25
|
+
if (!prototypeName) return []
|
|
26
|
+
return getFlowsForPrototype(prototypeName).map(f => {
|
|
27
|
+
const meta = getFlowMeta(f.key)
|
|
28
|
+
return {
|
|
29
|
+
key: f.key,
|
|
30
|
+
name: f.name,
|
|
31
|
+
title: meta?.title || f.name,
|
|
32
|
+
route: resolveFlowRoute(f.key),
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
}, [prototypeName])
|
|
36
|
+
|
|
37
|
+
const switchFlow = useCallback((flowKey) => {
|
|
38
|
+
const flow = flows.find(f => f.key === flowKey)
|
|
39
|
+
if (flow) {
|
|
40
|
+
window.location.href = flow.route
|
|
41
|
+
}
|
|
42
|
+
}, [flows])
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
flows,
|
|
46
|
+
activeFlow,
|
|
47
|
+
switchFlow,
|
|
48
|
+
prototypeName,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -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 '@dfosco/storyboard-core/canvas/materializer'
|
|
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,44 @@ 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\/canvas\/([^/]+)\.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\/canvas\//)
|
|
43
|
+
if (canvasCheck) {
|
|
44
|
+
const dirPath = normalized.substring(0, normalized.lastIndexOf('/'))
|
|
45
|
+
const routeBase = (dirPath + '/')
|
|
46
|
+
.replace(/^.*?src\/canvas\//, '')
|
|
47
|
+
.replace(/[^/]*\.folder\/?/g, '')
|
|
48
|
+
.replace(/\/$/, '')
|
|
49
|
+
inferredRoute = '/canvas/' + (routeBase ? routeBase + '/' : '') + name
|
|
50
|
+
inferredRoute = inferredRoute.replace(/\/+/g, '/').replace(/\/$/, '') || '/canvas'
|
|
51
|
+
}
|
|
52
|
+
const protoCheck = normalized.match(/(?:^|\/)src\/prototypes\//)
|
|
53
|
+
if (!canvasCheck && protoCheck) {
|
|
54
|
+
const dirPath = normalized.substring(0, normalized.lastIndexOf('/'))
|
|
55
|
+
const routeBase = (dirPath + '/')
|
|
56
|
+
.replace(/^.*?src\/prototypes\//, '')
|
|
57
|
+
.replace(/[^/]*\.folder\/?/g, '')
|
|
58
|
+
.replace(/\/$/, '')
|
|
59
|
+
inferredRoute = '/canvas/' + (routeBase ? routeBase + '/' : '') + name
|
|
60
|
+
inferredRoute = inferredRoute.replace(/\/+/g, '/').replace(/\/$/, '') || '/canvas'
|
|
61
|
+
}
|
|
62
|
+
return { name, suffix: 'canvas', ext: 'jsonl', folder: canvasFolderName || folderName, inferredRoute }
|
|
63
|
+
}
|
|
64
|
+
|
|
25
65
|
const match = base.match(/^(.+)\.(flow|scene|object|record|prototype|folder)\.(jsonc?)$/)
|
|
26
66
|
if (!match) return null
|
|
27
67
|
|
|
@@ -121,6 +161,7 @@ function getLastModified(root, dirPath) {
|
|
|
121
161
|
function buildIndex(root) {
|
|
122
162
|
const ignore = ['node_modules/**', 'dist/**', '.git/**']
|
|
123
163
|
const files = globSync(GLOB_PATTERN, { cwd: root, ignore, absolute: false })
|
|
164
|
+
const canvasFiles = globSync(CANVAS_GLOB_PATTERN, { cwd: root, ignore, absolute: false })
|
|
124
165
|
|
|
125
166
|
// Detect nested .folder/ directories (not supported)
|
|
126
167
|
// Scan directories directly since empty nested folders have no data files
|
|
@@ -137,12 +178,13 @@ function buildIndex(root) {
|
|
|
137
178
|
}
|
|
138
179
|
}
|
|
139
180
|
|
|
140
|
-
const index = { flow: {}, object: {}, record: {}, prototype: {}, folder: {} }
|
|
181
|
+
const index = { flow: {}, object: {}, record: {}, prototype: {}, folder: {}, canvas: {} }
|
|
141
182
|
const seen = {} // "name.suffix" → absolute path (for duplicate detection)
|
|
142
183
|
const protoFolders = {} // prototype name → folder name (for injection)
|
|
143
184
|
const flowRoutes = {} // flow name → inferred route (for _route injection)
|
|
185
|
+
const canvasRoutes = {} // canvas name → inferred route
|
|
144
186
|
|
|
145
|
-
for (const relPath of files) {
|
|
187
|
+
for (const relPath of [...files, ...canvasFiles]) {
|
|
146
188
|
const parsed = parseDataFile(relPath)
|
|
147
189
|
if (!parsed) continue
|
|
148
190
|
|
|
@@ -175,9 +217,14 @@ function buildIndex(root) {
|
|
|
175
217
|
if (parsed.suffix === 'flow' && parsed.inferredRoute) {
|
|
176
218
|
flowRoutes[parsed.name] = parsed.inferredRoute
|
|
177
219
|
}
|
|
220
|
+
|
|
221
|
+
// Track inferred routes for canvases
|
|
222
|
+
if (parsed.suffix === 'canvas' && parsed.inferredRoute) {
|
|
223
|
+
canvasRoutes[parsed.name] = parsed.inferredRoute
|
|
224
|
+
}
|
|
178
225
|
}
|
|
179
226
|
|
|
180
|
-
return { index, protoFolders, flowRoutes }
|
|
227
|
+
return { index, protoFolders, flowRoutes, canvasRoutes }
|
|
181
228
|
}
|
|
182
229
|
|
|
183
230
|
/**
|
|
@@ -248,25 +295,26 @@ function readConfig(root) {
|
|
|
248
295
|
}
|
|
249
296
|
|
|
250
297
|
/**
|
|
251
|
-
* Read
|
|
252
|
-
* Returns the full config object
|
|
298
|
+
* Read core-ui.config.json from @dfosco/storyboard-core.
|
|
299
|
+
* Returns the full config object with modes array.
|
|
253
300
|
* Falls back to hardcoded defaults if not found.
|
|
254
301
|
*/
|
|
255
302
|
function readModesConfig(root) {
|
|
256
303
|
const fallback = {
|
|
257
304
|
modes: [
|
|
258
|
-
{ name: 'prototype', label: 'Navigate' },
|
|
259
|
-
{ name: 'inspect', label: 'Develop' },
|
|
260
|
-
{ name: 'present', label: 'Collaborate' },
|
|
261
|
-
{ name: 'plan', label: 'Canvas' },
|
|
305
|
+
{ name: 'prototype', label: 'Navigate', hue: '#2a2a2a' },
|
|
306
|
+
{ name: 'inspect', label: 'Develop', hue: '#7655a4' },
|
|
307
|
+
{ name: 'present', label: 'Collaborate', hue: '#2a9d8f' },
|
|
308
|
+
{ name: 'plan', label: 'Canvas', hue: '#4a7fad' },
|
|
262
309
|
],
|
|
263
|
-
tools: {},
|
|
264
310
|
}
|
|
265
311
|
|
|
266
312
|
// Try local workspace path first (monorepo), then node_modules
|
|
267
313
|
const candidates = [
|
|
268
|
-
path.resolve(root, 'packages/core/
|
|
269
|
-
path.resolve(root, '
|
|
314
|
+
path.resolve(root, 'packages/core/core-ui.config.json'),
|
|
315
|
+
path.resolve(root, 'packages/core/configs/modes.config.json'),
|
|
316
|
+
path.resolve(root, 'node_modules/@dfosco/storyboard-core/core-ui.config.json'),
|
|
317
|
+
path.resolve(root, 'node_modules/@dfosco/storyboard-core/configs/modes.config.json'),
|
|
270
318
|
]
|
|
271
319
|
|
|
272
320
|
for (const filePath of candidates) {
|
|
@@ -274,7 +322,7 @@ function readModesConfig(root) {
|
|
|
274
322
|
const raw = fs.readFileSync(filePath, 'utf-8')
|
|
275
323
|
const parsed = JSON.parse(raw)
|
|
276
324
|
if (Array.isArray(parsed.modes) && parsed.modes.length > 0) {
|
|
277
|
-
return { modes: parsed.modes
|
|
325
|
+
return { modes: parsed.modes }
|
|
278
326
|
}
|
|
279
327
|
} catch {
|
|
280
328
|
// try next candidate
|
|
@@ -284,10 +332,10 @@ function readModesConfig(root) {
|
|
|
284
332
|
return fallback
|
|
285
333
|
}
|
|
286
334
|
|
|
287
|
-
function generateModule({ index, protoFolders, flowRoutes }, root) {
|
|
335
|
+
function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root) {
|
|
288
336
|
const declarations = []
|
|
289
|
-
const INDEX_KEYS = ['flow', 'object', 'record', 'prototype', 'folder']
|
|
290
|
-
const entries = { flow: [], object: [], record: [], prototype: [], folder: [] }
|
|
337
|
+
const INDEX_KEYS = ['flow', 'object', 'record', 'prototype', 'folder', 'canvas']
|
|
338
|
+
const entries = { flow: [], object: [], record: [], prototype: [], folder: [], canvas: [] }
|
|
291
339
|
const resolvedFlowRoutes = {} // flow name → resolved route (for multi-flow logging)
|
|
292
340
|
let i = 0
|
|
293
341
|
|
|
@@ -295,7 +343,9 @@ function generateModule({ index, protoFolders, flowRoutes }, root) {
|
|
|
295
343
|
for (const [name, absPath] of Object.entries(index[suffix])) {
|
|
296
344
|
const varName = `_d${i++}`
|
|
297
345
|
const raw = fs.readFileSync(absPath, 'utf-8')
|
|
298
|
-
let parsed =
|
|
346
|
+
let parsed = suffix === 'canvas'
|
|
347
|
+
? materializeFromText(raw)
|
|
348
|
+
: parseJsonc(raw)
|
|
299
349
|
|
|
300
350
|
// Auto-fill gitAuthor for prototype metadata from git history
|
|
301
351
|
if (suffix === 'prototype' && parsed && !parsed.gitAuthor) {
|
|
@@ -332,6 +382,37 @@ function generateModule({ index, protoFolders, flowRoutes }, root) {
|
|
|
332
382
|
}
|
|
333
383
|
}
|
|
334
384
|
|
|
385
|
+
// Inject inferred route and resolve JSX companion for canvases
|
|
386
|
+
if (suffix === 'canvas') {
|
|
387
|
+
if (canvasRoutes[name]) {
|
|
388
|
+
parsed = { ...parsed, _route: canvasRoutes[name] }
|
|
389
|
+
}
|
|
390
|
+
// Inject folder association
|
|
391
|
+
const folderDirMatch = path.relative(root, absPath).replace(/\\/g, '/').match(/(?:^|\/)src\/(?:prototypes|canvas)\/([^/]+)\.folder\//)
|
|
392
|
+
if (folderDirMatch) {
|
|
393
|
+
parsed = { ...parsed, _folder: folderDirMatch[1] }
|
|
394
|
+
}
|
|
395
|
+
// Resolve JSX companion file path
|
|
396
|
+
if (parsed?.jsx) {
|
|
397
|
+
const jsxPath = path.resolve(path.dirname(absPath), parsed.jsx)
|
|
398
|
+
if (fs.existsSync(jsxPath)) {
|
|
399
|
+
const relJsx = '/' + path.relative(root, jsxPath).replace(/\\/g, '/')
|
|
400
|
+
parsed = { ...parsed, _jsxModule: relJsx }
|
|
401
|
+
} else {
|
|
402
|
+
console.warn(
|
|
403
|
+
`[storyboard-data] Canvas "${name}" references JSX file "${parsed.jsx}" but it was not found at ${jsxPath}`
|
|
404
|
+
)
|
|
405
|
+
}
|
|
406
|
+
} else {
|
|
407
|
+
// Auto-detect a same-name .canvas.jsx companion
|
|
408
|
+
const autoJsx = absPath.replace(/\.canvas\.(jsonl|jsonc?)$/, '.canvas.jsx')
|
|
409
|
+
if (fs.existsSync(autoJsx)) {
|
|
410
|
+
const relJsx = '/' + path.relative(root, autoJsx).replace(/\\/g, '/')
|
|
411
|
+
parsed = { ...parsed, _jsxModule: relJsx }
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
335
416
|
// Resolve template variables (${currentDir}, ${currentProto}, ${currentProtoDir})
|
|
336
417
|
const templateVars = computeTemplateVars(absPath, root)
|
|
337
418
|
if (!templateVars.currentProto && raw.includes('${currentProto}')) {
|
|
@@ -354,7 +435,7 @@ function generateModule({ index, protoFolders, flowRoutes }, root) {
|
|
|
354
435
|
}
|
|
355
436
|
|
|
356
437
|
const imports = [`import { init } from '@dfosco/storyboard-core'`]
|
|
357
|
-
const initCalls = [`init({ flows, objects, records, prototypes, folders })`]
|
|
438
|
+
const initCalls = [`init({ flows, objects, records, prototypes, folders, canvases })`]
|
|
358
439
|
|
|
359
440
|
// Feature flags from storyboard.config.json
|
|
360
441
|
const { config } = readConfig(root)
|
|
@@ -383,15 +464,16 @@ function generateModule({ index, protoFolders, flowRoutes }, root) {
|
|
|
383
464
|
initCalls.push(`registerMode(${JSON.stringify(m.name)}, { label: ${JSON.stringify(m.label)} })`)
|
|
384
465
|
}
|
|
385
466
|
|
|
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
467
|
initCalls.push(`syncModeClasses()`)
|
|
392
468
|
}
|
|
393
469
|
}
|
|
394
470
|
|
|
471
|
+
// UI config from storyboard.config.json (menu visibility overrides)
|
|
472
|
+
if (config?.ui) {
|
|
473
|
+
imports.push(`import { initUIConfig } from '@dfosco/storyboard-core'`)
|
|
474
|
+
initCalls.push(`initUIConfig(${JSON.stringify(config.ui)})`)
|
|
475
|
+
}
|
|
476
|
+
|
|
395
477
|
// Log info when multiple flows target the same route
|
|
396
478
|
const routeGroups = {}
|
|
397
479
|
for (const [name, { route, isDefault }] of Object.entries(resolvedFlowRoutes)) {
|
|
@@ -422,14 +504,15 @@ function generateModule({ index, protoFolders, flowRoutes }, root) {
|
|
|
422
504
|
`const records = {\n${entries.record.join(',\n')}\n}`,
|
|
423
505
|
`const prototypes = {\n${entries.prototype.join(',\n')}\n}`,
|
|
424
506
|
`const folders = {\n${entries.folder.join(',\n')}\n}`,
|
|
507
|
+
`const canvases = {\n${entries.canvas.join(',\n')}\n}`,
|
|
425
508
|
'',
|
|
426
509
|
'// Backward-compatible alias',
|
|
427
510
|
'const scenes = flows',
|
|
428
511
|
'',
|
|
429
512
|
initCalls.join('\n'),
|
|
430
513
|
'',
|
|
431
|
-
`export { flows, scenes, objects, records, prototypes, folders }`,
|
|
432
|
-
`export const index = { flows, scenes, objects, records, prototypes, folders }`,
|
|
514
|
+
`export { flows, scenes, objects, records, prototypes, folders, canvases }`,
|
|
515
|
+
`export const index = { flows, scenes, objects, records, prototypes, folders, canvases }`,
|
|
433
516
|
`export default index`,
|
|
434
517
|
].join('\n')
|
|
435
518
|
}
|
|
@@ -437,7 +520,7 @@ function generateModule({ index, protoFolders, flowRoutes }, root) {
|
|
|
437
520
|
/**
|
|
438
521
|
* Vite plugin for storyboard data discovery.
|
|
439
522
|
*
|
|
440
|
-
* - Scans the repo for *.flow.json, *.scene.json (compat), *.object.json, *.record.json
|
|
523
|
+
* - Scans the repo for *.flow.json, *.scene.json (compat), *.object.json, *.record.json, *.canvas.jsonl
|
|
441
524
|
* - Validates no two files share the same name+suffix (hard build error)
|
|
442
525
|
* - Generates a virtual module `virtual:storyboard-data-index`
|
|
443
526
|
* - Watches for file additions/removals in dev mode
|
|
@@ -477,9 +560,15 @@ export default function storyboardDataPlugin() {
|
|
|
477
560
|
const watcher = server.watcher
|
|
478
561
|
|
|
479
562
|
const invalidate = (filePath) => {
|
|
563
|
+
const normalized = filePath.replace(/\\/g, '/')
|
|
564
|
+
// Skip .canvas.jsonl content changes entirely — these are mutated
|
|
565
|
+
// at runtime by the canvas server API. A full-reload would create
|
|
566
|
+
// a feedback loop (save → file change → reload → lose editing state).
|
|
567
|
+
if (/\.canvas\.jsonl$/.test(normalized)) return
|
|
568
|
+
|
|
480
569
|
const parsed = parseDataFile(filePath)
|
|
481
570
|
// Also invalidate when files are added/removed inside .folder/ directories
|
|
482
|
-
const inFolder =
|
|
571
|
+
const inFolder = normalized.includes('.folder/')
|
|
483
572
|
if (!parsed && !inFolder) return
|
|
484
573
|
// Rebuild index and invalidate virtual module
|
|
485
574
|
buildResult = null
|
|
@@ -490,6 +579,19 @@ export default function storyboardDataPlugin() {
|
|
|
490
579
|
}
|
|
491
580
|
}
|
|
492
581
|
|
|
582
|
+
const invalidateOnAddRemove = (filePath) => {
|
|
583
|
+
const parsed = parseDataFile(filePath)
|
|
584
|
+
const inFolder = filePath.replace(/\\/g, '/').includes('.folder/')
|
|
585
|
+
if (!parsed && !inFolder) return
|
|
586
|
+
// Canvas additions/removals DO need a reload (new routes)
|
|
587
|
+
buildResult = null
|
|
588
|
+
const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
|
|
589
|
+
if (mod) {
|
|
590
|
+
server.moduleGraph.invalidateModule(mod)
|
|
591
|
+
server.ws.send({ type: 'full-reload' })
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
493
595
|
// Watch storyboard.config.json for changes
|
|
494
596
|
const { configPath } = readConfig(root)
|
|
495
597
|
watcher.add(configPath)
|
|
@@ -504,8 +606,8 @@ export default function storyboardDataPlugin() {
|
|
|
504
606
|
}
|
|
505
607
|
}
|
|
506
608
|
|
|
507
|
-
watcher.on('add',
|
|
508
|
-
watcher.on('unlink',
|
|
609
|
+
watcher.on('add', invalidateOnAddRemove)
|
|
610
|
+
watcher.on('unlink', invalidateOnAddRemove)
|
|
509
611
|
watcher.on('change', (filePath) => {
|
|
510
612
|
invalidate(filePath)
|
|
511
613
|
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', () => {
|