@dfosco/storyboard-react 4.0.0-beta.9 → 4.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/AuthModal/AuthModal.jsx +134 -0
- package/src/AuthModal/AuthModal.module.css +221 -0
- package/src/BranchBar/BranchBar.jsx +56 -0
- package/src/BranchBar/BranchBar.module.css +230 -0
- package/src/BranchBar/useBranches.js +79 -0
- package/src/CommandPalette/CommandPalette.jsx +936 -0
- package/src/CommandPalette/CreateDialog.jsx +219 -0
- package/src/CommandPalette/command-palette.css +111 -0
- package/src/Icon.jsx +180 -0
- package/src/Viewfinder.jsx +1104 -57
- package/src/Viewfinder.module.css +1107 -149
- package/src/canvas/CanvasControls.jsx +51 -2
- package/src/canvas/CanvasControls.module.css +31 -0
- package/src/canvas/CanvasPage.bridge.test.jsx +142 -19
- package/src/canvas/CanvasPage.dragdrop.test.jsx +346 -0
- package/src/canvas/CanvasPage.jsx +807 -251
- package/src/canvas/CanvasPage.module.css +98 -50
- package/src/canvas/CanvasPage.multiselect.test.jsx +13 -11
- package/src/canvas/CanvasToolbar.jsx +2 -2
- package/src/canvas/MarqueeOverlay.jsx +20 -0
- package/src/canvas/PageSelector.jsx +239 -0
- package/src/canvas/PageSelector.module.css +165 -0
- package/src/canvas/PageSelector.test.jsx +104 -0
- package/src/canvas/canvasApi.js +22 -8
- package/src/canvas/canvasTheme.js +96 -52
- package/src/canvas/componentIsolate.jsx +33 -7
- package/src/canvas/useCanvas.js +9 -8
- package/src/canvas/useCanvas.test.js +4 -4
- package/src/canvas/useMarqueeSelect.js +187 -0
- package/src/canvas/useMarqueeSelect.test.js +78 -0
- package/src/canvas/widgets/CodePenEmbed.jsx +292 -0
- package/src/canvas/widgets/CodePenEmbed.module.css +161 -0
- package/src/canvas/widgets/ComponentWidget.jsx +42 -10
- package/src/canvas/widgets/ComponentWidget.module.css +6 -5
- package/src/canvas/widgets/FigmaEmbed.jsx +110 -24
- package/src/canvas/widgets/FigmaEmbed.module.css +21 -7
- package/src/canvas/widgets/LinkPreview.jsx +297 -11
- package/src/canvas/widgets/LinkPreview.module.css +386 -18
- package/src/canvas/widgets/LinkPreview.test.jsx +193 -0
- package/src/canvas/widgets/MarkdownBlock.jsx +86 -5
- package/src/canvas/widgets/MarkdownBlock.module.css +64 -15
- package/src/canvas/widgets/PrototypeEmbed.jsx +96 -145
- package/src/canvas/widgets/PrototypeEmbed.module.css +74 -4
- package/src/canvas/widgets/StickyNote.module.css +5 -0
- package/src/canvas/widgets/StickyNote.test.jsx +9 -9
- package/src/canvas/widgets/StoryWidget.jsx +277 -0
- package/src/canvas/widgets/StoryWidget.module.css +211 -0
- package/src/canvas/widgets/WidgetChrome.jsx +76 -20
- package/src/canvas/widgets/WidgetChrome.module.css +2 -6
- package/src/canvas/widgets/WidgetWrapper.module.css +2 -0
- package/src/canvas/widgets/codepenUrl.js +75 -0
- package/src/canvas/widgets/codepenUrl.test.js +76 -0
- package/src/canvas/widgets/embedInteraction.test.jsx +235 -0
- package/src/canvas/widgets/embedOverlay.module.css +35 -0
- package/src/canvas/widgets/embedTheme.js +138 -39
- package/src/canvas/widgets/githubUrl.js +82 -0
- package/src/canvas/widgets/githubUrl.test.js +74 -0
- package/src/canvas/widgets/iframeDevLogs.js +49 -0
- package/src/canvas/widgets/iframeDevLogs.test.jsx +81 -0
- package/src/canvas/widgets/index.js +4 -0
- package/src/canvas/widgets/pasteRules.js +295 -0
- package/src/canvas/widgets/pasteRules.test.js +474 -0
- package/src/canvas/widgets/snapshotDisplay.test.jsx +259 -0
- package/src/canvas/widgets/widgetConfig.js +16 -5
- package/src/canvas/widgets/widgetConfig.test.js +34 -12
- package/src/context.jsx +145 -16
- package/src/hooks/useSceneData.js +4 -2
- package/src/hooks/useThemeState.js +61 -0
- package/src/hooks/useThemeState.test.js +66 -0
- package/src/index.js +10 -0
- package/src/story/StoryPage.jsx +117 -0
- package/src/story/StoryPage.module.css +18 -0
- package/src/vite/data-plugin.js +348 -66
- package/src/vite/data-plugin.test.js +405 -5
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for iframe snapshot display — single snapshot prop.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
5
|
+
import { render, fireEvent, waitFor, act } from '@testing-library/react'
|
|
6
|
+
import PrototypeEmbed from './PrototypeEmbed.jsx'
|
|
7
|
+
import StoryWidget from './StoryWidget.jsx'
|
|
8
|
+
|
|
9
|
+
vi.mock('@dfosco/storyboard-core', () => ({
|
|
10
|
+
buildPrototypeIndex: () => ({
|
|
11
|
+
folders: [],
|
|
12
|
+
prototypes: [
|
|
13
|
+
{
|
|
14
|
+
name: 'Design Overview',
|
|
15
|
+
dirName: 'examples',
|
|
16
|
+
isExternal: false,
|
|
17
|
+
hideFlows: true,
|
|
18
|
+
flows: [{ route: '/test', name: 'default', meta: { title: 'Design Overview' } }],
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
globalFlows: [],
|
|
22
|
+
sorted: { title: { prototypes: [], folders: [] } },
|
|
23
|
+
}),
|
|
24
|
+
getStoryData: (storyId) => ({ _route: `/components/${storyId}` }),
|
|
25
|
+
}))
|
|
26
|
+
|
|
27
|
+
vi.mock('./WidgetWrapper.jsx', () => ({
|
|
28
|
+
default: ({ children }) => <div data-testid="widget-wrapper">{children}</div>,
|
|
29
|
+
}))
|
|
30
|
+
|
|
31
|
+
vi.mock('@dfosco/storyboard-core/inspector/highlighter', () => ({
|
|
32
|
+
createInspectorHighlighter: async () => ({
|
|
33
|
+
codeToHtml: () => '<pre><code></code></pre>',
|
|
34
|
+
}),
|
|
35
|
+
}), { virtual: true })
|
|
36
|
+
|
|
37
|
+
vi.mock('./ResizeHandle.jsx', () => ({
|
|
38
|
+
default: () => <div data-testid="resize-handle" />,
|
|
39
|
+
}))
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Render inside a wrapper with data-sb-canvas-theme, matching the real
|
|
43
|
+
* CanvasPage DOM structure that subscribeCanvasTheme reads from.
|
|
44
|
+
*/
|
|
45
|
+
function renderInCanvas(ui, theme = 'light') {
|
|
46
|
+
const wrapper = document.createElement('div')
|
|
47
|
+
wrapper.setAttribute('data-sb-canvas-theme', theme)
|
|
48
|
+
document.body.appendChild(wrapper)
|
|
49
|
+
const result = render(ui, { container: wrapper })
|
|
50
|
+
return { ...result, wrapper }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
document.querySelectorAll('[data-sb-canvas-theme]').forEach(el => el.remove())
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe('Snapshot display', () => {
|
|
58
|
+
describe('PrototypeEmbed', () => {
|
|
59
|
+
it('shows snapshot image when valid snapshot prop exists', () => {
|
|
60
|
+
const { wrapper } = renderInCanvas(
|
|
61
|
+
<PrototypeEmbed
|
|
62
|
+
id="proto-abc123"
|
|
63
|
+
props={{
|
|
64
|
+
src: '/test',
|
|
65
|
+
width: 400,
|
|
66
|
+
height: 300,
|
|
67
|
+
zoom: 100,
|
|
68
|
+
snapshot: '/_storyboard/canvas/images/snapshot-proto-abc123.webp?v=123',
|
|
69
|
+
}}
|
|
70
|
+
onUpdate={vi.fn()}
|
|
71
|
+
resizable={false}
|
|
72
|
+
/>
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
const img = wrapper.querySelector('img')
|
|
76
|
+
expect(img).toBeInTheDocument()
|
|
77
|
+
expect(img.src).toContain('snapshot-proto-abc123.webp')
|
|
78
|
+
expect(wrapper.querySelector('iframe')).not.toBeInTheDocument()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('falls back to snapshotLight for backward compat', () => {
|
|
82
|
+
const { wrapper } = renderInCanvas(
|
|
83
|
+
<PrototypeEmbed
|
|
84
|
+
id="proto-abc123"
|
|
85
|
+
props={{
|
|
86
|
+
src: '/test',
|
|
87
|
+
width: 400,
|
|
88
|
+
height: 300,
|
|
89
|
+
zoom: 100,
|
|
90
|
+
snapshotLight: '/_storyboard/canvas/images/snapshot-proto-abc123--light.webp?v=1',
|
|
91
|
+
}}
|
|
92
|
+
onUpdate={vi.fn()}
|
|
93
|
+
resizable={false}
|
|
94
|
+
/>
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
const img = wrapper.querySelector('img')
|
|
98
|
+
expect(img).toBeInTheDocument()
|
|
99
|
+
expect(img.src).toContain('snapshot-proto-abc123--light.webp')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('shows placeholder when no snapshot exists', () => {
|
|
103
|
+
const { wrapper } = renderInCanvas(
|
|
104
|
+
<PrototypeEmbed
|
|
105
|
+
id="proto-xyz"
|
|
106
|
+
props={{ src: '/test', width: 400, height: 300, zoom: 100 }}
|
|
107
|
+
onUpdate={vi.fn()}
|
|
108
|
+
resizable={false}
|
|
109
|
+
/>
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
expect(wrapper.querySelector('img')).not.toBeInTheDocument()
|
|
113
|
+
expect(wrapper.querySelector('iframe')).not.toBeInTheDocument()
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('falls back to placeholder when snapshot image fails to load', () => {
|
|
117
|
+
const { wrapper } = renderInCanvas(
|
|
118
|
+
<PrototypeEmbed
|
|
119
|
+
id="proto-abc123"
|
|
120
|
+
props={{
|
|
121
|
+
src: '/test',
|
|
122
|
+
width: 400,
|
|
123
|
+
height: 300,
|
|
124
|
+
zoom: 100,
|
|
125
|
+
snapshot: '/_storyboard/canvas/images/snapshot-proto-abc123.webp?v=123',
|
|
126
|
+
}}
|
|
127
|
+
onUpdate={vi.fn()}
|
|
128
|
+
resizable={false}
|
|
129
|
+
/>
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
const img = wrapper.querySelector('img')
|
|
133
|
+
expect(img).toBeInTheDocument()
|
|
134
|
+
fireEvent.error(img)
|
|
135
|
+
expect(wrapper.querySelector('img')).not.toBeInTheDocument()
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('ignores snapshot that does not match widget ID', () => {
|
|
139
|
+
const { wrapper } = renderInCanvas(
|
|
140
|
+
<PrototypeEmbed
|
|
141
|
+
id="proto-abc123"
|
|
142
|
+
props={{
|
|
143
|
+
src: '/test',
|
|
144
|
+
width: 400,
|
|
145
|
+
height: 300,
|
|
146
|
+
zoom: 100,
|
|
147
|
+
snapshot: '/_storyboard/canvas/images/snapshot-other-widget.webp?v=123',
|
|
148
|
+
}}
|
|
149
|
+
onUpdate={vi.fn()}
|
|
150
|
+
resizable={false}
|
|
151
|
+
/>
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
expect(wrapper.querySelector('img')).not.toBeInTheDocument()
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('does not show snapshot for external URLs', () => {
|
|
158
|
+
const { wrapper } = renderInCanvas(
|
|
159
|
+
<PrototypeEmbed
|
|
160
|
+
id="proto-ext"
|
|
161
|
+
props={{
|
|
162
|
+
src: 'https://example.com',
|
|
163
|
+
width: 400,
|
|
164
|
+
height: 300,
|
|
165
|
+
zoom: 100,
|
|
166
|
+
snapshot: '/_storyboard/canvas/images/snapshot-proto-ext.webp?v=123',
|
|
167
|
+
}}
|
|
168
|
+
onUpdate={vi.fn()}
|
|
169
|
+
resizable={false}
|
|
170
|
+
/>
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
expect(wrapper.querySelector('img')).not.toBeInTheDocument()
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
describe('StoryWidget', () => {
|
|
178
|
+
it('shows snapshot image when valid snapshot prop exists', () => {
|
|
179
|
+
const { wrapper } = renderInCanvas(
|
|
180
|
+
<StoryWidget
|
|
181
|
+
id="story-abc123"
|
|
182
|
+
props={{
|
|
183
|
+
storyId: 'button-patterns',
|
|
184
|
+
exportName: 'Primary',
|
|
185
|
+
width: 400,
|
|
186
|
+
height: 300,
|
|
187
|
+
snapshot: '/_storyboard/canvas/images/snapshot-story-abc123.webp?v=456',
|
|
188
|
+
}}
|
|
189
|
+
onUpdate={vi.fn()}
|
|
190
|
+
resizable={false}
|
|
191
|
+
/>
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
const img = wrapper.querySelector('img')
|
|
195
|
+
expect(img).toBeInTheDocument()
|
|
196
|
+
expect(img.src).toContain('snapshot-story-abc123.webp')
|
|
197
|
+
expect(wrapper.querySelector('iframe')).not.toBeInTheDocument()
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('falls back to snapshotDark for backward compat', () => {
|
|
201
|
+
const { wrapper } = renderInCanvas(
|
|
202
|
+
<StoryWidget
|
|
203
|
+
id="story-abc123"
|
|
204
|
+
props={{
|
|
205
|
+
storyId: 'button-patterns',
|
|
206
|
+
snapshotDark: '/_storyboard/canvas/images/snapshot-story-abc123--dark.webp?v=1',
|
|
207
|
+
}}
|
|
208
|
+
onUpdate={vi.fn()}
|
|
209
|
+
resizable={false}
|
|
210
|
+
/>
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
const img = wrapper.querySelector('img')
|
|
214
|
+
expect(img).toBeInTheDocument()
|
|
215
|
+
expect(img.src).toContain('snapshot-story-abc123--dark.webp')
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('shows placeholder when no snapshot exists', () => {
|
|
219
|
+
const { wrapper } = renderInCanvas(
|
|
220
|
+
<StoryWidget
|
|
221
|
+
id="story-xyz"
|
|
222
|
+
props={{
|
|
223
|
+
storyId: 'button-patterns',
|
|
224
|
+
exportName: 'Primary',
|
|
225
|
+
width: 400,
|
|
226
|
+
height: 300,
|
|
227
|
+
}}
|
|
228
|
+
onUpdate={vi.fn()}
|
|
229
|
+
resizable={false}
|
|
230
|
+
/>
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
expect(wrapper.querySelector('img')).not.toBeInTheDocument()
|
|
234
|
+
expect(wrapper.querySelector('iframe')).not.toBeInTheDocument()
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('falls back to placeholder when snapshot image fails to load', () => {
|
|
238
|
+
const { wrapper } = renderInCanvas(
|
|
239
|
+
<StoryWidget
|
|
240
|
+
id="story-abc123"
|
|
241
|
+
props={{
|
|
242
|
+
storyId: 'button-patterns',
|
|
243
|
+
exportName: 'Primary',
|
|
244
|
+
width: 400,
|
|
245
|
+
height: 300,
|
|
246
|
+
snapshot: '/_storyboard/canvas/images/snapshot-story-abc123.webp?v=456',
|
|
247
|
+
}}
|
|
248
|
+
onUpdate={vi.fn()}
|
|
249
|
+
resizable={false}
|
|
250
|
+
/>
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
const img = wrapper.querySelector('img')
|
|
254
|
+
expect(img).toBeInTheDocument()
|
|
255
|
+
fireEvent.error(img)
|
|
256
|
+
expect(wrapper.querySelector('img')).not.toBeInTheDocument()
|
|
257
|
+
})
|
|
258
|
+
})
|
|
259
|
+
})
|
|
@@ -34,7 +34,16 @@ function resolveFeature(feature) {
|
|
|
34
34
|
if (key === 'items' && Array.isArray(val)) {
|
|
35
35
|
resolved[key] = val.map((item) => {
|
|
36
36
|
const r = {}
|
|
37
|
-
for (const [k, v] of Object.entries(item))
|
|
37
|
+
for (const [k, v] of Object.entries(item)) {
|
|
38
|
+
// Resolve nested alt object inside items
|
|
39
|
+
if (k === 'alt' && v && typeof v === 'object') {
|
|
40
|
+
const altResolved = {}
|
|
41
|
+
for (const [ak, av] of Object.entries(v)) altResolved[ak] = resolveVar(av)
|
|
42
|
+
r[k] = altResolved
|
|
43
|
+
} else {
|
|
44
|
+
r[k] = resolveVar(v)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
38
47
|
return r
|
|
39
48
|
})
|
|
40
49
|
} else if (key === 'alt' && val && typeof val === 'object') {
|
|
@@ -103,14 +112,16 @@ export const widgetTypes = buildWidgetTypes()
|
|
|
103
112
|
|
|
104
113
|
/**
|
|
105
114
|
* Get the feature list for a widget type.
|
|
106
|
-
* In production
|
|
115
|
+
* In production (or when isLocalDev is false, e.g. ?prodMode simulation),
|
|
116
|
+
* only features with `prod: true` are returned.
|
|
107
117
|
* In dev, all features are returned.
|
|
108
118
|
* @param {string} type — widget type string
|
|
119
|
+
* @param {{ isLocalDev?: boolean }} [options]
|
|
109
120
|
* @returns {Array} features array from config (variables resolved), or empty array
|
|
110
121
|
*/
|
|
111
|
-
export function getFeatures(type) {
|
|
122
|
+
export function getFeatures(type, { isLocalDev = true } = {}) {
|
|
112
123
|
const features = widgetTypes[type]?.features ?? []
|
|
113
|
-
if (import.meta.env?.PROD) {
|
|
124
|
+
if (import.meta.env?.PROD || !isLocalDev) {
|
|
114
125
|
return features.filter(f => f.prod)
|
|
115
126
|
}
|
|
116
127
|
return features
|
|
@@ -146,6 +157,6 @@ export function getWidgetMeta(type) {
|
|
|
146
157
|
*/
|
|
147
158
|
export function getMenuWidgetTypes() {
|
|
148
159
|
return Object.entries(widgetTypes)
|
|
149
|
-
.filter(([type]) => type !== 'link-preview' && type !== 'image' && type !== 'figma-embed')
|
|
160
|
+
.filter(([type]) => type !== 'link-preview' && type !== 'image' && type !== 'figma-embed' && type !== 'codepen-embed' && type !== 'story')
|
|
150
161
|
.map(([type, def]) => ({ type, label: def.label, icon: def.icon }))
|
|
151
162
|
}
|
|
@@ -2,21 +2,24 @@ import { describe, expect, it } from 'vitest'
|
|
|
2
2
|
import { isResizable, getFeatures, getWidgetMeta } from './widgetConfig.js'
|
|
3
3
|
|
|
4
4
|
describe('isResizable', () => {
|
|
5
|
-
// Vitest runs
|
|
6
|
-
//
|
|
7
|
-
it('returns
|
|
8
|
-
expect(isResizable('sticky-note')).toBe(
|
|
9
|
-
expect(isResizable('prototype')).toBe(
|
|
10
|
-
expect(isResizable('figma-embed')).toBe(
|
|
11
|
-
expect(isResizable('image')).toBe(
|
|
12
|
-
expect(isResizable('component')).toBe(
|
|
13
|
-
})
|
|
14
|
-
|
|
15
|
-
it('returns false for
|
|
16
|
-
expect(isResizable('markdown')).toBe(false)
|
|
5
|
+
// Vitest runs in dev mode by default (import.meta.env.PROD = false)
|
|
6
|
+
// In dev mode, all resize-enabled widgets are resizable
|
|
7
|
+
it('returns true for resize-enabled widgets in dev mode', () => {
|
|
8
|
+
expect(isResizable('sticky-note')).toBe(true)
|
|
9
|
+
expect(isResizable('prototype')).toBe(true)
|
|
10
|
+
expect(isResizable('figma-embed')).toBe(true)
|
|
11
|
+
expect(isResizable('image')).toBe(true)
|
|
12
|
+
expect(isResizable('component')).toBe(true)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('returns false for link-preview (resize disabled)', () => {
|
|
17
16
|
expect(isResizable('link-preview')).toBe(false)
|
|
18
17
|
})
|
|
19
18
|
|
|
19
|
+
it('returns true for markdown (resize enabled, dev only)', () => {
|
|
20
|
+
expect(isResizable('markdown')).toBe(true)
|
|
21
|
+
})
|
|
22
|
+
|
|
20
23
|
it('returns false for unknown widget types', () => {
|
|
21
24
|
expect(isResizable('nonexistent')).toBe(false)
|
|
22
25
|
})
|
|
@@ -32,6 +35,25 @@ describe('getFeatures', () => {
|
|
|
32
35
|
it('returns empty array for unknown widget types', () => {
|
|
33
36
|
expect(getFeatures('nonexistent')).toEqual([])
|
|
34
37
|
})
|
|
38
|
+
|
|
39
|
+
it('returns only prod features when isLocalDev is false', () => {
|
|
40
|
+
const features = getFeatures('figma-embed', { isLocalDev: false })
|
|
41
|
+
expect(features.length).toBeGreaterThan(0)
|
|
42
|
+
expect(features.every(f => f.prod === true)).toBe(true)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('returns all features when isLocalDev is true (default)', () => {
|
|
46
|
+
const allFeatures = getFeatures('figma-embed')
|
|
47
|
+
const prodFeatures = getFeatures('figma-embed', { isLocalDev: false })
|
|
48
|
+
expect(allFeatures.length).toBeGreaterThan(prodFeatures.length)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('includes menu-only prod features when isLocalDev is false', () => {
|
|
52
|
+
const features = getFeatures('figma-embed', { isLocalDev: false })
|
|
53
|
+
const menuFeature = features.find(f => f.menu)
|
|
54
|
+
expect(menuFeature).toBeDefined()
|
|
55
|
+
expect(menuFeature.prod).toBe(true)
|
|
56
|
+
})
|
|
35
57
|
})
|
|
36
58
|
|
|
37
59
|
describe('getWidgetMeta', () => {
|
package/src/context.jsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useEffect, useMemo, Suspense, lazy } from 'react'
|
|
2
2
|
import { useParams, useLocation } from 'react-router-dom'
|
|
3
|
-
// Named import seeds the core data index via init() AND provides canvas route data
|
|
4
|
-
import { canvases } from 'virtual:storyboard-data-index'
|
|
3
|
+
// Named import seeds the core data index via init() AND provides canvas/story route data
|
|
4
|
+
import { canvases, canvasAliases, stories } from 'virtual:storyboard-data-index'
|
|
5
5
|
import { loadFlow, flowExists, findRecord, deepMerge, setFlowClass, installBodyClassSync, resolveFlowName, resolveRecordName, isModesEnabled } from '@dfosco/storyboard-core'
|
|
6
6
|
import { StoryboardContext } from './StoryboardContext.js'
|
|
7
7
|
import styles from './FlowError.module.css'
|
|
@@ -9,24 +9,77 @@ import styles from './FlowError.module.css'
|
|
|
9
9
|
export { StoryboardContext }
|
|
10
10
|
|
|
11
11
|
const CanvasPageLazy = lazy(() => import('./canvas/CanvasPage.jsx'))
|
|
12
|
+
const StoryPageLazy = lazy(() => import('./story/StoryPage.jsx'))
|
|
12
13
|
|
|
13
14
|
// Build a map from canvas route paths → canvas names at module load time
|
|
14
15
|
const canvasRouteMap = new Map()
|
|
16
|
+
// Build a map from group name → array of { name, route, title } for page selector
|
|
17
|
+
const canvasGroupMap = new Map()
|
|
15
18
|
for (const [name, data] of Object.entries(canvases || {})) {
|
|
16
|
-
const route = (data?._route ||
|
|
19
|
+
const route = (data?._route || `/canvas/${name}`).replace(/\/+$/, '')
|
|
17
20
|
canvasRouteMap.set(route, name)
|
|
21
|
+
const group = data?._group
|
|
22
|
+
if (group) {
|
|
23
|
+
if (!canvasGroupMap.has(group)) canvasGroupMap.set(group, [])
|
|
24
|
+
canvasGroupMap.get(group).push({
|
|
25
|
+
name,
|
|
26
|
+
route,
|
|
27
|
+
title: data?.title || name.split('/').pop(),
|
|
28
|
+
_canvasMeta: data?._canvasMeta || null,
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Build a map from story route paths → story names at module load time
|
|
34
|
+
const storyRouteMap = new Map()
|
|
35
|
+
for (const [name, data] of Object.entries(stories || {})) {
|
|
36
|
+
if (data?._route) {
|
|
37
|
+
const route = data._route.replace(/\/+$/, '')
|
|
38
|
+
storyRouteMap.set(route, name)
|
|
39
|
+
}
|
|
18
40
|
}
|
|
19
41
|
|
|
20
42
|
function matchCanvasRoute(pathname) {
|
|
21
|
-
const normalized = pathname
|
|
43
|
+
const normalized = stripBasePath(pathname)
|
|
22
44
|
return canvasRouteMap.get(normalized) || null
|
|
23
45
|
}
|
|
24
46
|
|
|
47
|
+
function matchStoryRoute(pathname) {
|
|
48
|
+
const normalized = stripBasePath(pathname)
|
|
49
|
+
return storyRouteMap.get(normalized) || null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Strip the app's sub-path prefix (e.g. /storyboard) from the pathname.
|
|
54
|
+
* React Router's basename strips the branch prefix but not the app name prefix
|
|
55
|
+
* when the app runs under a nested base path.
|
|
56
|
+
*/
|
|
57
|
+
function stripBasePath(pathname) {
|
|
58
|
+
let p = pathname.replace(/\/+$/, '') || '/'
|
|
59
|
+
// BASE_URL includes branch prefix + app path (e.g. /branch--name/storyboard/)
|
|
60
|
+
// React Router strips the branch prefix but may leave the app sub-path
|
|
61
|
+
const base = (import.meta.env?.BASE_URL || '/').replace(/\/+$/, '')
|
|
62
|
+
if (base && base !== '/') {
|
|
63
|
+
// Extract just the last segment(s) after the branch prefix
|
|
64
|
+
const withoutBranch = base.replace(/^\/branch--[^/]+/, '')
|
|
65
|
+
const subPath = withoutBranch.replace(/\/+$/, '')
|
|
66
|
+
if (subPath && p.startsWith(subPath)) {
|
|
67
|
+
p = p.slice(subPath.length) || '/'
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return p
|
|
71
|
+
}
|
|
72
|
+
|
|
25
73
|
function isCanvasPath(pathname) {
|
|
26
|
-
const normalized = pathname
|
|
74
|
+
const normalized = stripBasePath(pathname)
|
|
27
75
|
return normalized === '/canvas' || normalized.startsWith('/canvas/')
|
|
28
76
|
}
|
|
29
77
|
|
|
78
|
+
function isStoryPath(pathname) {
|
|
79
|
+
const normalized = stripBasePath(pathname)
|
|
80
|
+
return normalized === '/components' || normalized.startsWith('/components/')
|
|
81
|
+
}
|
|
82
|
+
|
|
30
83
|
/**
|
|
31
84
|
* Derives the top-level prototype name from a pathname.
|
|
32
85
|
* "/Dashboard" → "Dashboard", "/Dashboard/sub" → "Dashboard"
|
|
@@ -66,10 +119,17 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
|
|
|
66
119
|
const params = useParams()
|
|
67
120
|
|
|
68
121
|
// Canvas route detection — matches current URL against registered canvas routes
|
|
69
|
-
const
|
|
122
|
+
const canvasId = useMemo(() => matchCanvasRoute(location.pathname), [location.pathname])
|
|
70
123
|
const isMissingCanvasRoute = useMemo(
|
|
71
|
-
() => isCanvasPath(location.pathname) && !
|
|
72
|
-
[location.pathname,
|
|
124
|
+
() => isCanvasPath(location.pathname) && !canvasId && !matchStoryRoute(location.pathname),
|
|
125
|
+
[location.pathname, canvasId],
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
// Story route detection — matches current URL against registered story routes
|
|
129
|
+
const storyName = useMemo(() => matchStoryRoute(location.pathname), [location.pathname])
|
|
130
|
+
const isMissingStoryRoute = useMemo(
|
|
131
|
+
() => isStoryPath(location.pathname) && !storyName,
|
|
132
|
+
[location.pathname, storyName],
|
|
73
133
|
)
|
|
74
134
|
|
|
75
135
|
const searchParams = new URLSearchParams(location.search)
|
|
@@ -77,9 +137,9 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
|
|
|
77
137
|
const prototypeName = getPrototypeName(location.pathname)
|
|
78
138
|
const pageFlow = getPageFlowName(location.pathname)
|
|
79
139
|
|
|
80
|
-
// Resolve flow name with prototype scoping (skip for canvas pages)
|
|
140
|
+
// Resolve flow name with prototype scoping (skip for canvas/story pages)
|
|
81
141
|
const activeFlowName = useMemo(() => {
|
|
82
|
-
if (
|
|
142
|
+
if (canvasId || isMissingCanvasRoute || storyName || isMissingStoryRoute) return null
|
|
83
143
|
const requested = sceneParam || flowName || sceneName
|
|
84
144
|
if (requested) {
|
|
85
145
|
// Allow fully-scoped flow names from URLs/widgets without re-prefixing
|
|
@@ -103,11 +163,32 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
|
|
|
103
163
|
// 4. Global default — or null if no flow exists at all
|
|
104
164
|
if (flowExists('default')) return 'default'
|
|
105
165
|
return null
|
|
106
|
-
}, [
|
|
166
|
+
}, [canvasId, isMissingCanvasRoute, storyName, isMissingStoryRoute, sceneParam, flowName, sceneName, prototypeName, pageFlow])
|
|
107
167
|
|
|
108
168
|
// Auto-install body class sync (sb-key--value classes on <body>)
|
|
109
169
|
useEffect(() => installBodyClassSync(), [])
|
|
110
170
|
|
|
171
|
+
// Update document.title to reflect the current artifact
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
const base = import.meta.env?.BASE_URL || '/'
|
|
174
|
+
const branchMatch = base.match(/\/branch--([^/]+)/)
|
|
175
|
+
const branchSuffix = branchMatch ? ` (${branchMatch[1]})` : ''
|
|
176
|
+
|
|
177
|
+
let title
|
|
178
|
+
if (canvasId) {
|
|
179
|
+
const canvasData = canvases?.[canvasId]
|
|
180
|
+
const meta = canvasData?._canvasMeta
|
|
181
|
+
const pageTitle = canvasData?.title || canvasId.split('/').pop()
|
|
182
|
+
title = (meta?.title || pageTitle) + ' · Storyboard'
|
|
183
|
+
} else if (prototypeName) {
|
|
184
|
+
title = prototypeName + ' · Storyboard'
|
|
185
|
+
} else {
|
|
186
|
+
title = 'Storyboard'
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
document.title = title + branchSuffix
|
|
190
|
+
}, [canvasId, prototypeName])
|
|
191
|
+
|
|
111
192
|
// Mount design modes UI when enabled in storyboard.config.json
|
|
112
193
|
useEffect(() => {
|
|
113
194
|
if (!isModesEnabled()) return
|
|
@@ -124,9 +205,9 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
|
|
|
124
205
|
return () => cleanup?.()
|
|
125
206
|
}, [])
|
|
126
207
|
|
|
127
|
-
// Skip flow loading for canvas pages and flow-less pages
|
|
208
|
+
// Skip flow loading for canvas/story pages and flow-less pages
|
|
128
209
|
const { data, error } = useMemo(() => {
|
|
129
|
-
if (
|
|
210
|
+
if (canvasId || isMissingCanvasRoute || storyName || isMissingStoryRoute) return { data: null, error: null }
|
|
130
211
|
if (!activeFlowName) return { data: {}, error: null }
|
|
131
212
|
try {
|
|
132
213
|
let flowData = loadFlow(activeFlowName)
|
|
@@ -145,10 +226,18 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
|
|
|
145
226
|
} catch (err) {
|
|
146
227
|
return { data: null, error: err.message }
|
|
147
228
|
}
|
|
148
|
-
}, [
|
|
229
|
+
}, [canvasId, isMissingCanvasRoute, storyName, isMissingStoryRoute, activeFlowName, recordName, recordParam, params, prototypeName])
|
|
149
230
|
|
|
150
231
|
// Canvas pages get their own rendering path — no flow data needed
|
|
151
|
-
if (
|
|
232
|
+
if (canvasId) {
|
|
233
|
+
const canvasData = canvases?.[canvasId]
|
|
234
|
+
const group = canvasData?._group
|
|
235
|
+
// Include the current canvas as a sibling even if it's the only page in its group,
|
|
236
|
+
// so the PageSelector can render and allow adding new pages.
|
|
237
|
+
const siblingPages = group
|
|
238
|
+
? canvasGroupMap.get(group) || []
|
|
239
|
+
: [{ name: canvasId, route: canvasData?._route || `/canvas/${canvasId}`, title: canvasData?.title || canvasId.split('/').pop() }]
|
|
240
|
+
const canvasMeta = canvasData?._canvasMeta || null
|
|
152
241
|
const canvasValue = {
|
|
153
242
|
data: null,
|
|
154
243
|
error: null,
|
|
@@ -160,7 +249,26 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
|
|
|
160
249
|
return (
|
|
161
250
|
<StoryboardContext.Provider value={canvasValue}>
|
|
162
251
|
<Suspense fallback={null}>
|
|
163
|
-
<CanvasPageLazy
|
|
252
|
+
<CanvasPageLazy canvasId={canvasId} siblingPages={siblingPages} canvasMeta={canvasMeta} />
|
|
253
|
+
</Suspense>
|
|
254
|
+
</StoryboardContext.Provider>
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Story pages get their own rendering path — no flow data needed
|
|
259
|
+
if (storyName) {
|
|
260
|
+
const storyValue = {
|
|
261
|
+
data: null,
|
|
262
|
+
error: null,
|
|
263
|
+
loading: false,
|
|
264
|
+
flowName: null,
|
|
265
|
+
sceneName: null,
|
|
266
|
+
prototypeName: null,
|
|
267
|
+
}
|
|
268
|
+
return (
|
|
269
|
+
<StoryboardContext.Provider value={storyValue}>
|
|
270
|
+
<Suspense fallback={null}>
|
|
271
|
+
<StoryPageLazy name={storyName} />
|
|
164
272
|
</Suspense>
|
|
165
273
|
</StoryboardContext.Provider>
|
|
166
274
|
)
|
|
@@ -187,6 +295,27 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
|
|
|
187
295
|
)
|
|
188
296
|
}
|
|
189
297
|
|
|
298
|
+
if (isMissingStoryRoute) {
|
|
299
|
+
const currentUrl = `${location.pathname}${location.search}`
|
|
300
|
+
const truncatedUrl = currentUrl.length > 60
|
|
301
|
+
? currentUrl.slice(0, 60) + '…'
|
|
302
|
+
: currentUrl
|
|
303
|
+
|
|
304
|
+
return (
|
|
305
|
+
<main className={styles.container}>
|
|
306
|
+
<div className={styles.banner}>
|
|
307
|
+
<strong>Story not found</strong>
|
|
308
|
+
No story matches this route.
|
|
309
|
+
</div>
|
|
310
|
+
<p className={styles.meta}>
|
|
311
|
+
Tried to open{' '}
|
|
312
|
+
<a href={currentUrl} title={currentUrl}>{truncatedUrl}</a>
|
|
313
|
+
</p>
|
|
314
|
+
<a className={styles.homeLink} href="/">← Go to index page</a>
|
|
315
|
+
</main>
|
|
316
|
+
)
|
|
317
|
+
}
|
|
318
|
+
|
|
190
319
|
const value = {
|
|
191
320
|
data,
|
|
192
321
|
error,
|
|
@@ -17,10 +17,12 @@ import { subscribeToStorage, getStorageSnapshot } from '@dfosco/storyboard-core'
|
|
|
17
17
|
*
|
|
18
18
|
* @param {string} [path] - Dot-notation path (e.g. 'user.profile.name').
|
|
19
19
|
* Omit to get the entire flow object.
|
|
20
|
+
* @param {{ optional?: boolean }} [opts] - Pass { optional: true } to suppress
|
|
21
|
+
* the "path not found" warning for optional data.
|
|
20
22
|
* @returns {*} The resolved value. Returns {} if path is missing after loading.
|
|
21
23
|
* @throws If used outside a StoryboardProvider.
|
|
22
24
|
*/
|
|
23
|
-
export function useFlowData(path) {
|
|
25
|
+
export function useFlowData(path, opts) {
|
|
24
26
|
const context = useContext(StoryboardContext)
|
|
25
27
|
|
|
26
28
|
if (context === null) {
|
|
@@ -73,7 +75,7 @@ export function useFlowData(path) {
|
|
|
73
75
|
}
|
|
74
76
|
|
|
75
77
|
if (sceneValue === undefined) {
|
|
76
|
-
if (data != null && Object.keys(data).length > 0) {
|
|
78
|
+
if (!opts?.optional && data != null && Object.keys(data).length > 0) {
|
|
77
79
|
console.warn(`[useFlowData] Path "${path}" not found in flow data.`)
|
|
78
80
|
}
|
|
79
81
|
return {}
|