@dfosco/storyboard-react 4.0.0-beta.8 → 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
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
display: flex;
|
|
4
4
|
align-items: center;
|
|
5
5
|
justify-content: center;
|
|
6
|
-
min-height: 100vh;
|
|
6
|
+
min-height: calc(100vh - var(--sb-branch-bar-height, 0px));
|
|
7
7
|
font-family: var(--tc-font-stack, system-ui, -apple-system, sans-serif);
|
|
8
8
|
color: var(--fgColor-muted, #656d76);
|
|
9
9
|
font-size: 16px;
|
|
@@ -15,8 +15,9 @@
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
.canvasScroll {
|
|
18
|
+
position: relative;
|
|
18
19
|
width: 100vw;
|
|
19
|
-
height: 100vh;
|
|
20
|
+
height: calc(100vh - var(--sb-branch-bar-height, 0px));
|
|
20
21
|
overflow: auto;
|
|
21
22
|
background-color: var(--sb--canvas-bg, var(--bgColor-muted, #f6f8fa));
|
|
22
23
|
}
|
|
@@ -32,11 +33,32 @@
|
|
|
32
33
|
min-height: 100%;
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
/* GPU compositing hint during active zoom gestures — applied imperatively
|
|
37
|
+
via data-zooming attribute and removed on zoom-end to avoid permanent
|
|
38
|
+
memory pressure on the large canvas surface. */
|
|
39
|
+
.canvasZoom[data-zooming] {
|
|
40
|
+
will-change: transform;
|
|
41
|
+
}
|
|
42
|
+
|
|
35
43
|
/* Selection outline is now handled by WidgetChrome.module.css (.widgetSlotSelected) */
|
|
36
44
|
|
|
45
|
+
.canvasLogo {
|
|
46
|
+
width: 32px;
|
|
47
|
+
height: 32px;
|
|
48
|
+
background: var(--bgColor-emphasis, #313131);
|
|
49
|
+
border-radius: 6px;
|
|
50
|
+
display: flex;
|
|
51
|
+
align-items: center;
|
|
52
|
+
justify-content: center;
|
|
53
|
+
color: var(--fgColor-onEmphasis, #fff);
|
|
54
|
+
flex-shrink: 0;
|
|
55
|
+
text-decoration: none;
|
|
56
|
+
transform: rotate(-1deg);
|
|
57
|
+
}
|
|
58
|
+
|
|
37
59
|
.canvasTitle {
|
|
38
60
|
position: fixed;
|
|
39
|
-
top: 12px;
|
|
61
|
+
top: calc(12px + var(--sb-branch-bar-height, 0px));
|
|
40
62
|
left: 16px;
|
|
41
63
|
z-index: 10;
|
|
42
64
|
display: flex;
|
|
@@ -44,58 +66,14 @@
|
|
|
44
66
|
gap: 8px;
|
|
45
67
|
}
|
|
46
68
|
|
|
47
|
-
.
|
|
48
|
-
display: inline-grid;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
.canvasTitleWrap > * {
|
|
52
|
-
grid-area: 1 / 1;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
.canvasTitleMeasure {
|
|
56
|
-
visibility: hidden;
|
|
57
|
-
white-space: pre;
|
|
58
|
-
font-family: var(--tc-font-stack, system-ui, -apple-system, sans-serif);
|
|
59
|
-
font-size: 14px;
|
|
60
|
-
font-weight: 600;
|
|
61
|
-
padding: 4px 8px;
|
|
62
|
-
border: 1px solid transparent;
|
|
63
|
-
min-width: 80px;
|
|
64
|
-
pointer-events: none;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
.canvasTitleInput {
|
|
69
|
+
.canvasTitleStatic {
|
|
68
70
|
font-family: var(--tc-font-stack, system-ui, -apple-system, sans-serif);
|
|
69
71
|
font-size: 14px;
|
|
70
72
|
font-weight: 600;
|
|
71
73
|
color: var(--fgColor-muted, #656d76);
|
|
72
|
-
background: transparent;
|
|
73
|
-
border: 1px solid transparent;
|
|
74
|
-
border-radius: 6px;
|
|
75
|
-
padding: 4px 8px;
|
|
76
74
|
margin: 0;
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
min-width: 0;
|
|
80
|
-
transition: border-color 150ms, background-color 150ms, color 150ms;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
.canvasTitleInput:hover {
|
|
84
|
-
color: var(--fgColor-default, #1f2328);
|
|
85
|
-
border-color: var(--borderColor-default, #d1d9e0);
|
|
86
|
-
background: var(--bgColor-default, #ffffff);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
.canvasTitleInput:focus {
|
|
90
|
-
color: var(--fgColor-default, #1f2328);
|
|
91
|
-
border-color: var(--bgColor-accent-emphasis, #2f81f7);
|
|
92
|
-
background: var(--bgColor-default, #ffffff);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
.canvasTitleStatic {
|
|
96
|
-
composes: canvasTitleInput;
|
|
97
|
-
cursor: default;
|
|
98
|
-
pointer-events: none;
|
|
75
|
+
padding: 4px 8px;
|
|
76
|
+
white-space: nowrap;
|
|
99
77
|
}
|
|
100
78
|
|
|
101
79
|
/* Remove tiny-canvas wrapper clipping — widgets handle their own overflow/radius */
|
|
@@ -103,6 +81,12 @@
|
|
|
103
81
|
overflow: visible;
|
|
104
82
|
}
|
|
105
83
|
|
|
84
|
+
/* Elevate stacking context for hovered/selected widgets so their chrome
|
|
85
|
+
(toolbar, menus, selection outline) renders above sibling widgets. */
|
|
86
|
+
:global(.tc-drag:has([data-tc-elevated])) {
|
|
87
|
+
z-index: 1;
|
|
88
|
+
}
|
|
89
|
+
|
|
106
90
|
.localEditingLabel {
|
|
107
91
|
display: inline-flex;
|
|
108
92
|
align-items: center;
|
|
@@ -117,3 +101,67 @@
|
|
|
117
101
|
pointer-events: none;
|
|
118
102
|
user-select: none;
|
|
119
103
|
}
|
|
104
|
+
|
|
105
|
+
.ghInstallBanner {
|
|
106
|
+
position: fixed;
|
|
107
|
+
left: 50%;
|
|
108
|
+
bottom: 12px;
|
|
109
|
+
transform: translateX(-50%);
|
|
110
|
+
z-index: 15;
|
|
111
|
+
display: flex;
|
|
112
|
+
align-items: center;
|
|
113
|
+
gap: 10px;
|
|
114
|
+
padding: 8px 12px;
|
|
115
|
+
border-radius: 8px;
|
|
116
|
+
border: 1px solid var(--borderColor-default, #d1d9e0);
|
|
117
|
+
background: color-mix(in srgb, var(--bgColor-default, #ffffff) 88%, transparent);
|
|
118
|
+
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.16);
|
|
119
|
+
backdrop-filter: blur(8px);
|
|
120
|
+
font-size: 12px;
|
|
121
|
+
color: var(--fgColor-default, #1f2328);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.ghInstallBannerText {
|
|
125
|
+
white-space: nowrap;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.ghInstallBannerText code {
|
|
129
|
+
font-size: 11px;
|
|
130
|
+
padding: 1px 4px;
|
|
131
|
+
border-radius: 4px;
|
|
132
|
+
background: var(--bgColor-muted, #f6f8fa);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.ghInstallBannerLink {
|
|
136
|
+
color: var(--fgColor-accent, #0969da);
|
|
137
|
+
font-weight: 600;
|
|
138
|
+
text-decoration: none;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.ghInstallBannerLink:hover {
|
|
142
|
+
text-decoration: underline;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.ghInstallBannerDismiss {
|
|
146
|
+
border: 1px solid var(--borderColor-default, #d1d9e0);
|
|
147
|
+
background: var(--bgColor-default, #ffffff);
|
|
148
|
+
color: var(--fgColor-default, #1f2328);
|
|
149
|
+
border-radius: 6px;
|
|
150
|
+
font-size: 11px;
|
|
151
|
+
padding: 4px 8px;
|
|
152
|
+
cursor: pointer;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.ghInstallBannerDismiss:hover {
|
|
156
|
+
background: var(--bgColor-muted, #f6f8fa);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/* Marquee selection rectangle */
|
|
160
|
+
.marqueeRect {
|
|
161
|
+
position: absolute;
|
|
162
|
+
background: rgba(56, 132, 255, 0.12);
|
|
163
|
+
border: 1.5px solid rgba(56, 132, 255, 0.6);
|
|
164
|
+
border-radius: 2px;
|
|
165
|
+
pointer-events: none;
|
|
166
|
+
z-index: 9999;
|
|
167
|
+
}
|
|
@@ -103,6 +103,8 @@ vi.mock('./widgets/figmaUrl.js', () => ({
|
|
|
103
103
|
|
|
104
104
|
vi.mock('./canvasApi.js', () => ({
|
|
105
105
|
addWidget: vi.fn(),
|
|
106
|
+
checkGitHubCliAvailable: vi.fn(() => Promise.resolve({ available: true })),
|
|
107
|
+
fetchGitHubEmbed: vi.fn(() => Promise.resolve({ success: false })),
|
|
106
108
|
updateCanvas: vi.fn(() => Promise.resolve({ success: true })),
|
|
107
109
|
removeWidget: vi.fn(() => Promise.resolve({ success: true })),
|
|
108
110
|
uploadImage: vi.fn(),
|
|
@@ -123,7 +125,7 @@ describe('CanvasPage multi-select', () => {
|
|
|
123
125
|
})
|
|
124
126
|
|
|
125
127
|
it('shift+click on select handle adds widget to selection', async () => {
|
|
126
|
-
render(<CanvasPage
|
|
128
|
+
render(<CanvasPage canvasId="test-canvas" />)
|
|
127
129
|
|
|
128
130
|
// Select first widget
|
|
129
131
|
fireEvent.click(screen.getByTestId('select-w1'))
|
|
@@ -138,7 +140,7 @@ describe('CanvasPage multi-select', () => {
|
|
|
138
140
|
})
|
|
139
141
|
|
|
140
142
|
it('shift+click on already selected widget removes it from selection', async () => {
|
|
141
|
-
render(<CanvasPage
|
|
143
|
+
render(<CanvasPage canvasId="test-canvas" />)
|
|
142
144
|
|
|
143
145
|
// Select both
|
|
144
146
|
fireEvent.click(screen.getByTestId('select-w1'))
|
|
@@ -155,7 +157,7 @@ describe('CanvasPage multi-select', () => {
|
|
|
155
157
|
})
|
|
156
158
|
|
|
157
159
|
it('normal click replaces multi-selection with single', async () => {
|
|
158
|
-
render(<CanvasPage
|
|
160
|
+
render(<CanvasPage canvasId="test-canvas" />)
|
|
159
161
|
|
|
160
162
|
// Multi-select
|
|
161
163
|
fireEvent.click(screen.getByTestId('select-w1'))
|
|
@@ -172,7 +174,7 @@ describe('CanvasPage multi-select', () => {
|
|
|
172
174
|
})
|
|
173
175
|
|
|
174
176
|
it('sets multiSelected on all selected widgets when multiple are selected', async () => {
|
|
175
|
-
render(<CanvasPage
|
|
177
|
+
render(<CanvasPage canvasId="test-canvas" />)
|
|
176
178
|
|
|
177
179
|
fireEvent.click(screen.getByTestId('select-w1'))
|
|
178
180
|
fireEvent.click(screen.getByTestId('shift-select-w2'))
|
|
@@ -185,7 +187,7 @@ describe('CanvasPage multi-select', () => {
|
|
|
185
187
|
})
|
|
186
188
|
|
|
187
189
|
it('Escape clears all selection', async () => {
|
|
188
|
-
render(<CanvasPage
|
|
190
|
+
render(<CanvasPage canvasId="test-canvas" />)
|
|
189
191
|
|
|
190
192
|
fireEvent.click(screen.getByTestId('select-w1'))
|
|
191
193
|
fireEvent.click(screen.getByTestId('shift-select-w2'))
|
|
@@ -199,7 +201,7 @@ describe('CanvasPage multi-select', () => {
|
|
|
199
201
|
})
|
|
200
202
|
|
|
201
203
|
it('Delete removes all selected widgets and calls updateCanvas', async () => {
|
|
202
|
-
render(<CanvasPage
|
|
204
|
+
render(<CanvasPage canvasId="test-canvas" />)
|
|
203
205
|
|
|
204
206
|
// Multi-select w1 and w2
|
|
205
207
|
fireEvent.click(screen.getByTestId('select-w1'))
|
|
@@ -224,7 +226,7 @@ describe('CanvasPage multi-select', () => {
|
|
|
224
226
|
})
|
|
225
227
|
|
|
226
228
|
it('single-select Delete uses removeWidget API', async () => {
|
|
227
|
-
render(<CanvasPage
|
|
229
|
+
render(<CanvasPage canvasId="test-canvas" />)
|
|
228
230
|
|
|
229
231
|
fireEvent.click(screen.getByTestId('select-w1'))
|
|
230
232
|
fireEvent.keyDown(document, { key: 'Backspace' })
|
|
@@ -234,7 +236,7 @@ describe('CanvasPage multi-select', () => {
|
|
|
234
236
|
})
|
|
235
237
|
|
|
236
238
|
it('multi-select move applies delta to all selected widgets', async () => {
|
|
237
|
-
render(<CanvasPage
|
|
239
|
+
render(<CanvasPage canvasId="test-canvas" />)
|
|
238
240
|
|
|
239
241
|
// Multi-select w1 (100,100) and w2 (300,100)
|
|
240
242
|
fireEvent.click(screen.getByTestId('select-w1'))
|
|
@@ -265,7 +267,7 @@ describe('CanvasPage multi-select', () => {
|
|
|
265
267
|
})
|
|
266
268
|
|
|
267
269
|
it('multi-select drag captures peer articles on drag start', async () => {
|
|
268
|
-
render(<CanvasPage
|
|
270
|
+
render(<CanvasPage canvasId="test-canvas" />)
|
|
269
271
|
|
|
270
272
|
// Multi-select w1 and w2
|
|
271
273
|
fireEvent.click(screen.getByTestId('select-w1'))
|
|
@@ -288,7 +290,7 @@ describe('CanvasPage multi-select', () => {
|
|
|
288
290
|
})
|
|
289
291
|
|
|
290
292
|
it('multi-select drag preserves selection after drag end', async () => {
|
|
291
|
-
render(<CanvasPage
|
|
293
|
+
render(<CanvasPage canvasId="test-canvas" />)
|
|
292
294
|
|
|
293
295
|
// Multi-select w1 and w2
|
|
294
296
|
fireEvent.click(screen.getByTestId('select-w1'))
|
|
@@ -313,7 +315,7 @@ describe('CanvasPage multi-select', () => {
|
|
|
313
315
|
})
|
|
314
316
|
|
|
315
317
|
it('any selected widget can serve as drag handler for the group', async () => {
|
|
316
|
-
render(<CanvasPage
|
|
318
|
+
render(<CanvasPage canvasId="test-canvas" />)
|
|
317
319
|
|
|
318
320
|
// Multi-select w1 (100,100), w2 (300,100), w3 (500,200)
|
|
319
321
|
fireEvent.click(screen.getByTestId('select-w1'))
|
|
@@ -9,7 +9,7 @@ const WIDGET_TYPES = getMenuWidgetTypes()
|
|
|
9
9
|
/**
|
|
10
10
|
* Floating toolbar for adding widgets to a canvas.
|
|
11
11
|
*/
|
|
12
|
-
export default function CanvasToolbar({
|
|
12
|
+
export default function CanvasToolbar({ canvasId, onWidgetAdded }) {
|
|
13
13
|
const [open, setOpen] = useState(false)
|
|
14
14
|
const [adding, setAdding] = useState(false)
|
|
15
15
|
|
|
@@ -18,7 +18,7 @@ export default function CanvasToolbar({ canvasName, onWidgetAdded }) {
|
|
|
18
18
|
setAdding(true)
|
|
19
19
|
try {
|
|
20
20
|
const defaultProps = schemas[type] ? getDefaults(schemas[type]) : {}
|
|
21
|
-
const result = await addWidgetApi(
|
|
21
|
+
const result = await addWidgetApi(canvasId, {
|
|
22
22
|
type,
|
|
23
23
|
props: defaultProps,
|
|
24
24
|
position: { x: 0, y: 0 },
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import styles from './CanvasPage.module.css'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Renders the translucent selection rectangle during a marquee drag.
|
|
5
|
+
* Positioned relative to the scroll container.
|
|
6
|
+
*/
|
|
7
|
+
export default function MarqueeOverlay({ rect }) {
|
|
8
|
+
if (!rect) return null
|
|
9
|
+
return (
|
|
10
|
+
<div
|
|
11
|
+
className={styles.marqueeRect}
|
|
12
|
+
style={{
|
|
13
|
+
left: rect.x,
|
|
14
|
+
top: rect.y,
|
|
15
|
+
width: rect.w,
|
|
16
|
+
height: rect.h,
|
|
17
|
+
}}
|
|
18
|
+
/>
|
|
19
|
+
)
|
|
20
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { useCallback, useRef, useState, useEffect } from 'react'
|
|
2
|
+
import { createCanvas } from './canvasApi.js'
|
|
3
|
+
import styles from './PageSelector.module.css'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* In-canvas page selector — shows sibling pages in the same canvas group.
|
|
7
|
+
* Only renders when 2+ sibling pages exist.
|
|
8
|
+
* Uses window.location for navigation to avoid requiring a Router context.
|
|
9
|
+
*
|
|
10
|
+
* @param {{ currentName: string, pages: Array<{ name: string, route: string, title: string }>, isLocalDev?: boolean }} props
|
|
11
|
+
*/
|
|
12
|
+
export default function PageSelector({ currentName, pages: initialPages, isLocalDev = false }) {
|
|
13
|
+
const [open, setOpen] = useState(() => {
|
|
14
|
+
try {
|
|
15
|
+
if (typeof sessionStorage !== 'undefined' && sessionStorage.getItem('sb-open-page-selector')) {
|
|
16
|
+
sessionStorage.removeItem('sb-open-page-selector')
|
|
17
|
+
return true
|
|
18
|
+
}
|
|
19
|
+
} catch { /* ignore */ }
|
|
20
|
+
return false
|
|
21
|
+
})
|
|
22
|
+
const [adding, setAdding] = useState(false)
|
|
23
|
+
const [newName, setNewName] = useState('')
|
|
24
|
+
const [creating, setCreating] = useState(false)
|
|
25
|
+
const [pages, setPages] = useState(initialPages)
|
|
26
|
+
const [successMsg, setSuccessMsg] = useState(null)
|
|
27
|
+
const containerRef = useRef(null)
|
|
28
|
+
const inputRef = useRef(null)
|
|
29
|
+
|
|
30
|
+
// Sync pages when prop changes (e.g. HMR reload)
|
|
31
|
+
useEffect(() => { setPages(initialPages) }, [initialPages])
|
|
32
|
+
|
|
33
|
+
const currentPage = pages.find((p) => p.name === currentName)
|
|
34
|
+
const currentLabel = currentPage?.title || currentName.split('/').pop()
|
|
35
|
+
const currentIndex = pages.findIndex((p) => p.name === currentName)
|
|
36
|
+
|
|
37
|
+
// Derive folder from currentName (e.g. "Examples/Design Overview" → "Examples")
|
|
38
|
+
const folder = currentName.includes('/') ? currentName.split('/')[0] : ''
|
|
39
|
+
|
|
40
|
+
const handleSelect = useCallback(
|
|
41
|
+
(page) => {
|
|
42
|
+
if (page.name !== currentName) {
|
|
43
|
+
const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
|
|
44
|
+
window.location.href = base + page.route
|
|
45
|
+
}
|
|
46
|
+
setOpen(false)
|
|
47
|
+
},
|
|
48
|
+
[currentName],
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
const handleAddPage = useCallback(async () => {
|
|
52
|
+
const trimmed = newName.trim()
|
|
53
|
+
if (!trimmed || creating) return
|
|
54
|
+
setCreating(true)
|
|
55
|
+
try {
|
|
56
|
+
// Single-page canvas (no folder) → convert to multi-page folder
|
|
57
|
+
const isSinglePage = !currentName.includes('/')
|
|
58
|
+
const createBody = isSinglePage
|
|
59
|
+
? { name: trimmed, convertFrom: currentName }
|
|
60
|
+
: { name: trimmed, folder: folder || undefined }
|
|
61
|
+
|
|
62
|
+
const result = await createCanvas(createBody)
|
|
63
|
+
if (result.error) {
|
|
64
|
+
console.error('Failed to create canvas page:', result.error)
|
|
65
|
+
setCreating(false)
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
const route = result.route
|
|
69
|
+
const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
|
|
70
|
+
const targetUrl = base + route
|
|
71
|
+
|
|
72
|
+
// Optimistically add the new page to the bottom of the list
|
|
73
|
+
const pageName = result.name || trimmed
|
|
74
|
+
setPages(prev => [...prev, { name: pageName, route, title: trimmed }])
|
|
75
|
+
|
|
76
|
+
// Show success confirmation and reset form
|
|
77
|
+
setSuccessMsg(`"${trimmed}" created`)
|
|
78
|
+
setAdding(false)
|
|
79
|
+
setNewName('')
|
|
80
|
+
setCreating(false)
|
|
81
|
+
|
|
82
|
+
// Stash a flag so the page selector opens automatically on the new page
|
|
83
|
+
try { sessionStorage.setItem('sb-open-page-selector', '1') } catch { /* ignore */ }
|
|
84
|
+
|
|
85
|
+
// Navigate to the new page after Vite picks up the new file
|
|
86
|
+
if (import.meta.hot) {
|
|
87
|
+
const timer = setTimeout(() => {
|
|
88
|
+
window.location.href = targetUrl
|
|
89
|
+
}, 3000)
|
|
90
|
+
import.meta.hot.on('vite:beforeFullReload', () => {
|
|
91
|
+
clearTimeout(timer)
|
|
92
|
+
sessionStorage.setItem('sb-pending-navigate', targetUrl)
|
|
93
|
+
})
|
|
94
|
+
} else {
|
|
95
|
+
setTimeout(() => { window.location.href = targetUrl }, 1000)
|
|
96
|
+
}
|
|
97
|
+
} catch (err) {
|
|
98
|
+
console.error('Failed to create canvas page:', err)
|
|
99
|
+
setCreating(false)
|
|
100
|
+
}
|
|
101
|
+
}, [newName, currentName, folder, creating])
|
|
102
|
+
|
|
103
|
+
// Focus input when entering add mode
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (adding && inputRef.current) inputRef.current.focus()
|
|
106
|
+
}, [adding])
|
|
107
|
+
|
|
108
|
+
// Close on outside click
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
if (!open) return
|
|
111
|
+
function handleClick(e) {
|
|
112
|
+
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
|
113
|
+
setOpen(false)
|
|
114
|
+
setAdding(false)
|
|
115
|
+
setNewName('')
|
|
116
|
+
setSuccessMsg(null)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
document.addEventListener('mousedown', handleClick)
|
|
120
|
+
return () => document.removeEventListener('mousedown', handleClick)
|
|
121
|
+
}, [open])
|
|
122
|
+
|
|
123
|
+
// Close on Escape
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
if (!open) return
|
|
126
|
+
function handleKey(e) {
|
|
127
|
+
if (e.key === 'Escape') {
|
|
128
|
+
if (adding) {
|
|
129
|
+
setAdding(false)
|
|
130
|
+
setNewName('')
|
|
131
|
+
} else {
|
|
132
|
+
setOpen(false)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
document.addEventListener('keydown', handleKey)
|
|
137
|
+
return () => document.removeEventListener('keydown', handleKey)
|
|
138
|
+
}, [open, adding])
|
|
139
|
+
|
|
140
|
+
// Show selector when there are multiple pages, or in dev mode (to allow adding pages)
|
|
141
|
+
if (!pages || (pages.length < 2 && !isLocalDev)) return null
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<nav ref={containerRef} className={styles.container} aria-label="Canvas pages">
|
|
145
|
+
<button
|
|
146
|
+
className={styles.trigger}
|
|
147
|
+
onClick={() => setOpen((v) => !v)}
|
|
148
|
+
aria-expanded={open}
|
|
149
|
+
aria-haspopup="listbox"
|
|
150
|
+
title="Switch canvas page"
|
|
151
|
+
>
|
|
152
|
+
<span className={styles.label}>{currentLabel}</span>
|
|
153
|
+
<span className={styles.badge}>
|
|
154
|
+
{currentIndex + 1}/{pages.length}
|
|
155
|
+
</span>
|
|
156
|
+
<svg
|
|
157
|
+
className={`${styles.chevron} ${open ? styles.chevronOpen : ''}`}
|
|
158
|
+
width="12"
|
|
159
|
+
height="12"
|
|
160
|
+
viewBox="0 0 12 12"
|
|
161
|
+
fill="none"
|
|
162
|
+
aria-hidden="true"
|
|
163
|
+
>
|
|
164
|
+
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
165
|
+
</svg>
|
|
166
|
+
</button>
|
|
167
|
+
{open && (
|
|
168
|
+
<ul className={styles.menu} role="listbox" aria-label="Canvas pages">
|
|
169
|
+
{pages.map((page) => (
|
|
170
|
+
<li
|
|
171
|
+
key={page.name}
|
|
172
|
+
role="option"
|
|
173
|
+
aria-selected={page.name === currentName}
|
|
174
|
+
className={`${styles.item} ${page.name === currentName ? styles.itemActive : ''}`}
|
|
175
|
+
onClick={() => handleSelect(page)}
|
|
176
|
+
onKeyDown={(e) => {
|
|
177
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
178
|
+
e.preventDefault()
|
|
179
|
+
handleSelect(page)
|
|
180
|
+
}
|
|
181
|
+
}}
|
|
182
|
+
tabIndex={0}
|
|
183
|
+
>
|
|
184
|
+
{page.title}
|
|
185
|
+
</li>
|
|
186
|
+
))}
|
|
187
|
+
{isLocalDev && (
|
|
188
|
+
<>
|
|
189
|
+
<li className={styles.separator} role="separator" />
|
|
190
|
+
{adding ? (
|
|
191
|
+
<li className={styles.addForm}>
|
|
192
|
+
<input
|
|
193
|
+
ref={inputRef}
|
|
194
|
+
className={styles.addInput}
|
|
195
|
+
type="text"
|
|
196
|
+
placeholder="Page name"
|
|
197
|
+
value={newName}
|
|
198
|
+
onChange={(e) => setNewName(e.target.value)}
|
|
199
|
+
onKeyDown={(e) => {
|
|
200
|
+
if (e.key === 'Enter') {
|
|
201
|
+
e.preventDefault()
|
|
202
|
+
handleAddPage()
|
|
203
|
+
}
|
|
204
|
+
}}
|
|
205
|
+
disabled={creating}
|
|
206
|
+
/>
|
|
207
|
+
<button
|
|
208
|
+
className={styles.addSubmit}
|
|
209
|
+
onClick={handleAddPage}
|
|
210
|
+
disabled={!newName.trim() || creating}
|
|
211
|
+
>
|
|
212
|
+
{creating ? '…' : 'Add'}
|
|
213
|
+
</button>
|
|
214
|
+
</li>
|
|
215
|
+
) : (
|
|
216
|
+
<li
|
|
217
|
+
className={styles.addItem}
|
|
218
|
+
onClick={() => setAdding(true)}
|
|
219
|
+
tabIndex={0}
|
|
220
|
+
onKeyDown={(e) => {
|
|
221
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
222
|
+
e.preventDefault()
|
|
223
|
+
setAdding(true)
|
|
224
|
+
}
|
|
225
|
+
}}
|
|
226
|
+
>
|
|
227
|
+
+ Add new page
|
|
228
|
+
</li>
|
|
229
|
+
)}
|
|
230
|
+
{successMsg && (
|
|
231
|
+
<li className={styles.successMsg}>✓ {successMsg}</li>
|
|
232
|
+
)}
|
|
233
|
+
</>
|
|
234
|
+
)}
|
|
235
|
+
</ul>
|
|
236
|
+
)}
|
|
237
|
+
</nav>
|
|
238
|
+
)
|
|
239
|
+
}
|