@dfosco/storyboard-react 4.0.0-beta.4 → 4.0.0-beta.41
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 +9 -4
- package/src/Icon.jsx +179 -0
- package/src/Viewfinder.jsx +1030 -57
- package/src/Viewfinder.module.css +1524 -155
- package/src/canvas/CanvasControls.jsx +51 -2
- package/src/canvas/CanvasControls.module.css +31 -0
- package/src/canvas/CanvasPage.bridge.test.jsx +95 -10
- package/src/canvas/CanvasPage.dragdrop.test.jsx +346 -0
- package/src/canvas/CanvasPage.jsx +843 -301
- package/src/canvas/CanvasPage.module.css +73 -50
- package/src/canvas/CanvasPage.multiselect.test.jsx +13 -11
- package/src/canvas/CanvasToolbar.jsx +2 -2
- package/src/canvas/ComponentErrorBoundary.jsx +50 -0
- package/src/canvas/PageSelector.jsx +198 -0
- package/src/canvas/PageSelector.module.css +158 -0
- package/src/canvas/PageSelector.test.jsx +104 -0
- package/src/canvas/canvasApi.js +22 -8
- package/src/canvas/canvasReloadGuard.js +37 -0
- package/src/canvas/canvasReloadGuard.test.js +27 -0
- package/src/canvas/componentIsolate.jsx +135 -0
- package/src/canvas/useCanvas.js +15 -10
- package/src/canvas/widgets/CodePenEmbed.jsx +292 -0
- package/src/canvas/widgets/CodePenEmbed.module.css +161 -0
- package/src/canvas/widgets/ComponentWidget.jsx +82 -9
- package/src/canvas/widgets/ComponentWidget.module.css +14 -6
- 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 +95 -21
- package/src/canvas/widgets/MarkdownBlock.module.css +133 -2
- package/src/canvas/widgets/MarkdownBlock.test.jsx +39 -0
- package/src/canvas/widgets/PrototypeEmbed.jsx +95 -144
- 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 +276 -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 +4 -7
- 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 +56 -0
- 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 +141 -16
- package/src/hooks/useSceneData.js +4 -2
- package/src/story/StoryPage.jsx +117 -0
- package/src/story/StoryPage.module.css +18 -0
- package/src/vite/data-plugin.js +375 -57
- 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;
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
.canvasScroll {
|
|
18
18
|
width: 100vw;
|
|
19
|
-
height: 100vh;
|
|
19
|
+
height: calc(100vh - var(--sb-branch-bar-height, 0px));
|
|
20
20
|
overflow: auto;
|
|
21
21
|
background-color: var(--sb--canvas-bg, var(--bgColor-muted, #f6f8fa));
|
|
22
22
|
}
|
|
@@ -32,11 +32,18 @@
|
|
|
32
32
|
min-height: 100%;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/* GPU compositing hint during active zoom gestures — applied imperatively
|
|
36
|
+
via data-zooming attribute and removed on zoom-end to avoid permanent
|
|
37
|
+
memory pressure on the large canvas surface. */
|
|
38
|
+
.canvasZoom[data-zooming] {
|
|
39
|
+
will-change: transform;
|
|
40
|
+
}
|
|
41
|
+
|
|
35
42
|
/* Selection outline is now handled by WidgetChrome.module.css (.widgetSlotSelected) */
|
|
36
43
|
|
|
37
44
|
.canvasTitle {
|
|
38
45
|
position: fixed;
|
|
39
|
-
top: 12px;
|
|
46
|
+
top: calc(12px + var(--sb-branch-bar-height, 0px));
|
|
40
47
|
left: 16px;
|
|
41
48
|
z-index: 10;
|
|
42
49
|
display: flex;
|
|
@@ -44,58 +51,14 @@
|
|
|
44
51
|
gap: 8px;
|
|
45
52
|
}
|
|
46
53
|
|
|
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 {
|
|
54
|
+
.canvasTitleStatic {
|
|
68
55
|
font-family: var(--tc-font-stack, system-ui, -apple-system, sans-serif);
|
|
69
56
|
font-size: 14px;
|
|
70
57
|
font-weight: 600;
|
|
71
58
|
color: var(--fgColor-muted, #656d76);
|
|
72
|
-
background: transparent;
|
|
73
|
-
border: 1px solid transparent;
|
|
74
|
-
border-radius: 6px;
|
|
75
|
-
padding: 4px 8px;
|
|
76
59
|
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;
|
|
60
|
+
padding: 4px 8px;
|
|
61
|
+
white-space: nowrap;
|
|
99
62
|
}
|
|
100
63
|
|
|
101
64
|
/* Remove tiny-canvas wrapper clipping — widgets handle their own overflow/radius */
|
|
@@ -103,6 +66,12 @@
|
|
|
103
66
|
overflow: visible;
|
|
104
67
|
}
|
|
105
68
|
|
|
69
|
+
/* Elevate stacking context for hovered/selected widgets so their chrome
|
|
70
|
+
(toolbar, menus, selection outline) renders above sibling widgets. */
|
|
71
|
+
:global(.tc-drag:has([data-tc-elevated])) {
|
|
72
|
+
z-index: 1;
|
|
73
|
+
}
|
|
74
|
+
|
|
106
75
|
.localEditingLabel {
|
|
107
76
|
display: inline-flex;
|
|
108
77
|
align-items: center;
|
|
@@ -117,3 +86,57 @@
|
|
|
117
86
|
pointer-events: none;
|
|
118
87
|
user-select: none;
|
|
119
88
|
}
|
|
89
|
+
|
|
90
|
+
.ghInstallBanner {
|
|
91
|
+
position: fixed;
|
|
92
|
+
left: 50%;
|
|
93
|
+
bottom: 12px;
|
|
94
|
+
transform: translateX(-50%);
|
|
95
|
+
z-index: 15;
|
|
96
|
+
display: flex;
|
|
97
|
+
align-items: center;
|
|
98
|
+
gap: 10px;
|
|
99
|
+
padding: 8px 12px;
|
|
100
|
+
border-radius: 8px;
|
|
101
|
+
border: 1px solid var(--borderColor-default, #d1d9e0);
|
|
102
|
+
background: color-mix(in srgb, var(--bgColor-default, #ffffff) 88%, transparent);
|
|
103
|
+
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.16);
|
|
104
|
+
backdrop-filter: blur(8px);
|
|
105
|
+
font-size: 12px;
|
|
106
|
+
color: var(--fgColor-default, #1f2328);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.ghInstallBannerText {
|
|
110
|
+
white-space: nowrap;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.ghInstallBannerText code {
|
|
114
|
+
font-size: 11px;
|
|
115
|
+
padding: 1px 4px;
|
|
116
|
+
border-radius: 4px;
|
|
117
|
+
background: var(--bgColor-muted, #f6f8fa);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.ghInstallBannerLink {
|
|
121
|
+
color: var(--fgColor-accent, #0969da);
|
|
122
|
+
font-weight: 600;
|
|
123
|
+
text-decoration: none;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.ghInstallBannerLink:hover {
|
|
127
|
+
text-decoration: underline;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.ghInstallBannerDismiss {
|
|
131
|
+
border: 1px solid var(--borderColor-default, #d1d9e0);
|
|
132
|
+
background: var(--bgColor-default, #ffffff);
|
|
133
|
+
color: var(--fgColor-default, #1f2328);
|
|
134
|
+
border-radius: 6px;
|
|
135
|
+
font-size: 11px;
|
|
136
|
+
padding: 4px 8px;
|
|
137
|
+
cursor: pointer;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.ghInstallBannerDismiss:hover {
|
|
141
|
+
background: var(--bgColor-muted, #f6f8fa);
|
|
142
|
+
}
|
|
@@ -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,50 @@
|
|
|
1
|
+
import { Component } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Error boundary for canvas component widgets.
|
|
5
|
+
* Catches render-time errors so a single broken component
|
|
6
|
+
* doesn't crash the entire canvas page.
|
|
7
|
+
*
|
|
8
|
+
* Used as a production fallback when iframe isolation is not available.
|
|
9
|
+
*/
|
|
10
|
+
export default class ComponentErrorBoundary extends Component {
|
|
11
|
+
constructor(props) {
|
|
12
|
+
super(props)
|
|
13
|
+
this.state = { error: null }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
static getDerivedStateFromError(error) {
|
|
17
|
+
return { error }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
componentDidCatch(error, info) {
|
|
21
|
+
console.error(
|
|
22
|
+
`[storyboard] Component widget "${this.props.name || 'unknown'}" crashed:`,
|
|
23
|
+
error,
|
|
24
|
+
info?.componentStack,
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
render() {
|
|
29
|
+
if (this.state.error) {
|
|
30
|
+
return (
|
|
31
|
+
<div style={{
|
|
32
|
+
padding: '16px',
|
|
33
|
+
color: '#cf222e',
|
|
34
|
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
|
35
|
+
fontSize: '13px',
|
|
36
|
+
lineHeight: 1.5,
|
|
37
|
+
whiteSpace: 'pre-wrap',
|
|
38
|
+
wordBreak: 'break-word',
|
|
39
|
+
minWidth: 200,
|
|
40
|
+
minHeight: 60,
|
|
41
|
+
}}>
|
|
42
|
+
<strong>{this.props.name || 'Component'}</strong>
|
|
43
|
+
<br />
|
|
44
|
+
{String(this.state.error.message || this.state.error)}
|
|
45
|
+
</div>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
return this.props.children
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
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, isLocalDev = false }) {
|
|
13
|
+
const [open, setOpen] = useState(false)
|
|
14
|
+
const [adding, setAdding] = useState(false)
|
|
15
|
+
const [newName, setNewName] = useState('')
|
|
16
|
+
const [creating, setCreating] = useState(false)
|
|
17
|
+
const containerRef = useRef(null)
|
|
18
|
+
const inputRef = useRef(null)
|
|
19
|
+
|
|
20
|
+
const currentPage = pages.find((p) => p.name === currentName)
|
|
21
|
+
const currentLabel = currentPage?.title || currentName.split('/').pop()
|
|
22
|
+
const currentIndex = pages.findIndex((p) => p.name === currentName)
|
|
23
|
+
|
|
24
|
+
// Derive folder from currentName (e.g. "Examples/Design Overview" → "Examples")
|
|
25
|
+
const folder = currentName.includes('/') ? currentName.split('/')[0] : ''
|
|
26
|
+
|
|
27
|
+
const handleSelect = useCallback(
|
|
28
|
+
(page) => {
|
|
29
|
+
if (page.name !== currentName) {
|
|
30
|
+
const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
|
|
31
|
+
window.location.href = base + page.route
|
|
32
|
+
}
|
|
33
|
+
setOpen(false)
|
|
34
|
+
},
|
|
35
|
+
[currentName],
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
const handleAddPage = useCallback(async () => {
|
|
39
|
+
const trimmed = newName.trim()
|
|
40
|
+
if (!trimmed || creating) return
|
|
41
|
+
setCreating(true)
|
|
42
|
+
try {
|
|
43
|
+
const result = await createCanvas({ name: trimmed, folder: folder || undefined })
|
|
44
|
+
if (result.error) {
|
|
45
|
+
console.error('Failed to create canvas page:', result.error)
|
|
46
|
+
setCreating(false)
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
// Navigate to the new page once Vite picks it up
|
|
50
|
+
const kebab = trimmed
|
|
51
|
+
.replace(/[^a-zA-Z0-9\s_-]/g, '')
|
|
52
|
+
.trim()
|
|
53
|
+
.replace(/[\s_]+/g, '-')
|
|
54
|
+
.toLowerCase()
|
|
55
|
+
.replace(/-+/g, '-')
|
|
56
|
+
.replace(/^-|-$/g, '')
|
|
57
|
+
const route = folder ? `/${folder}/${kebab}` : `/${kebab}`
|
|
58
|
+
const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
|
|
59
|
+
// Small delay to let Vite detect the new file
|
|
60
|
+
setTimeout(() => { window.location.href = base + route }, 600)
|
|
61
|
+
} catch (err) {
|
|
62
|
+
console.error('Failed to create canvas page:', err)
|
|
63
|
+
setCreating(false)
|
|
64
|
+
}
|
|
65
|
+
}, [newName, folder, creating])
|
|
66
|
+
|
|
67
|
+
// Focus input when entering add mode
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (adding && inputRef.current) inputRef.current.focus()
|
|
70
|
+
}, [adding])
|
|
71
|
+
|
|
72
|
+
// Close on outside click
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (!open) return
|
|
75
|
+
function handleClick(e) {
|
|
76
|
+
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
|
77
|
+
setOpen(false)
|
|
78
|
+
setAdding(false)
|
|
79
|
+
setNewName('')
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
document.addEventListener('mousedown', handleClick)
|
|
83
|
+
return () => document.removeEventListener('mousedown', handleClick)
|
|
84
|
+
}, [open])
|
|
85
|
+
|
|
86
|
+
// Close on Escape
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
if (!open) return
|
|
89
|
+
function handleKey(e) {
|
|
90
|
+
if (e.key === 'Escape') {
|
|
91
|
+
if (adding) {
|
|
92
|
+
setAdding(false)
|
|
93
|
+
setNewName('')
|
|
94
|
+
} else {
|
|
95
|
+
setOpen(false)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
document.addEventListener('keydown', handleKey)
|
|
100
|
+
return () => document.removeEventListener('keydown', handleKey)
|
|
101
|
+
}, [open, adding])
|
|
102
|
+
|
|
103
|
+
if (!pages || pages.length < 2) return null
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<nav ref={containerRef} className={styles.container} aria-label="Canvas pages">
|
|
107
|
+
<button
|
|
108
|
+
className={styles.trigger}
|
|
109
|
+
onClick={() => setOpen((v) => !v)}
|
|
110
|
+
aria-expanded={open}
|
|
111
|
+
aria-haspopup="listbox"
|
|
112
|
+
title="Switch canvas page"
|
|
113
|
+
>
|
|
114
|
+
<span className={styles.label}>{currentLabel}</span>
|
|
115
|
+
<span className={styles.badge}>
|
|
116
|
+
{currentIndex + 1}/{pages.length}
|
|
117
|
+
</span>
|
|
118
|
+
<svg
|
|
119
|
+
className={`${styles.chevron} ${open ? styles.chevronOpen : ''}`}
|
|
120
|
+
width="12"
|
|
121
|
+
height="12"
|
|
122
|
+
viewBox="0 0 12 12"
|
|
123
|
+
fill="none"
|
|
124
|
+
aria-hidden="true"
|
|
125
|
+
>
|
|
126
|
+
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
127
|
+
</svg>
|
|
128
|
+
</button>
|
|
129
|
+
{open && (
|
|
130
|
+
<ul className={styles.menu} role="listbox" aria-label="Canvas pages">
|
|
131
|
+
{pages.map((page) => (
|
|
132
|
+
<li
|
|
133
|
+
key={page.name}
|
|
134
|
+
role="option"
|
|
135
|
+
aria-selected={page.name === currentName}
|
|
136
|
+
className={`${styles.item} ${page.name === currentName ? styles.itemActive : ''}`}
|
|
137
|
+
onClick={() => handleSelect(page)}
|
|
138
|
+
onKeyDown={(e) => {
|
|
139
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
140
|
+
e.preventDefault()
|
|
141
|
+
handleSelect(page)
|
|
142
|
+
}
|
|
143
|
+
}}
|
|
144
|
+
tabIndex={0}
|
|
145
|
+
>
|
|
146
|
+
{page.title}
|
|
147
|
+
</li>
|
|
148
|
+
))}
|
|
149
|
+
{isLocalDev && (
|
|
150
|
+
<>
|
|
151
|
+
<li className={styles.separator} role="separator" />
|
|
152
|
+
{adding ? (
|
|
153
|
+
<li className={styles.addForm}>
|
|
154
|
+
<input
|
|
155
|
+
ref={inputRef}
|
|
156
|
+
className={styles.addInput}
|
|
157
|
+
type="text"
|
|
158
|
+
placeholder="Page name"
|
|
159
|
+
value={newName}
|
|
160
|
+
onChange={(e) => setNewName(e.target.value)}
|
|
161
|
+
onKeyDown={(e) => {
|
|
162
|
+
if (e.key === 'Enter') {
|
|
163
|
+
e.preventDefault()
|
|
164
|
+
handleAddPage()
|
|
165
|
+
}
|
|
166
|
+
}}
|
|
167
|
+
disabled={creating}
|
|
168
|
+
/>
|
|
169
|
+
<button
|
|
170
|
+
className={styles.addSubmit}
|
|
171
|
+
onClick={handleAddPage}
|
|
172
|
+
disabled={!newName.trim() || creating}
|
|
173
|
+
>
|
|
174
|
+
{creating ? '…' : 'Add'}
|
|
175
|
+
</button>
|
|
176
|
+
</li>
|
|
177
|
+
) : (
|
|
178
|
+
<li
|
|
179
|
+
className={styles.addItem}
|
|
180
|
+
onClick={() => setAdding(true)}
|
|
181
|
+
tabIndex={0}
|
|
182
|
+
onKeyDown={(e) => {
|
|
183
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
184
|
+
e.preventDefault()
|
|
185
|
+
setAdding(true)
|
|
186
|
+
}
|
|
187
|
+
}}
|
|
188
|
+
>
|
|
189
|
+
+ Add new page
|
|
190
|
+
</li>
|
|
191
|
+
)}
|
|
192
|
+
</>
|
|
193
|
+
)}
|
|
194
|
+
</ul>
|
|
195
|
+
)}
|
|
196
|
+
</nav>
|
|
197
|
+
)
|
|
198
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
.container {
|
|
2
|
+
position: relative;
|
|
3
|
+
font-size: 13px;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
.trigger {
|
|
7
|
+
display: inline-flex;
|
|
8
|
+
align-items: center;
|
|
9
|
+
gap: 6px;
|
|
10
|
+
padding: 5px 10px;
|
|
11
|
+
border: 1px solid var(--borderColor-default, rgba(0, 0, 0, 0.15));
|
|
12
|
+
border-radius: 6px;
|
|
13
|
+
background: var(--bgColor-default, #fff);
|
|
14
|
+
color: var(--fgColor-default, #1f2328);
|
|
15
|
+
cursor: pointer;
|
|
16
|
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
|
17
|
+
transition: border-color 0.15s, box-shadow 0.15s;
|
|
18
|
+
line-height: 1;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.trigger:hover {
|
|
22
|
+
border-color: var(--borderColor-emphasis, rgba(0, 0, 0, 0.3));
|
|
23
|
+
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.12);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.label {
|
|
27
|
+
font-weight: 600;
|
|
28
|
+
max-width: 200px;
|
|
29
|
+
overflow: hidden;
|
|
30
|
+
text-overflow: ellipsis;
|
|
31
|
+
white-space: nowrap;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.badge {
|
|
35
|
+
font-size: 11px;
|
|
36
|
+
color: var(--fgColor-muted, #656d76);
|
|
37
|
+
font-variant-numeric: tabular-nums;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.chevron {
|
|
41
|
+
color: var(--fgColor-muted, #656d76);
|
|
42
|
+
transition: transform 0.15s;
|
|
43
|
+
flex-shrink: 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.chevronOpen {
|
|
47
|
+
transform: rotate(180deg);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.menu {
|
|
51
|
+
position: absolute;
|
|
52
|
+
top: calc(100% + 4px);
|
|
53
|
+
left: 0;
|
|
54
|
+
min-width: 180px;
|
|
55
|
+
max-width: 300px;
|
|
56
|
+
max-height: 320px;
|
|
57
|
+
overflow-y: auto;
|
|
58
|
+
margin: 0;
|
|
59
|
+
padding: 4px;
|
|
60
|
+
list-style: none;
|
|
61
|
+
background: var(--bgColor-default, #fff);
|
|
62
|
+
border: 1px solid var(--borderColor-default, rgba(0, 0, 0, 0.15));
|
|
63
|
+
border-radius: 8px;
|
|
64
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.item {
|
|
68
|
+
padding: 6px 10px;
|
|
69
|
+
border-radius: 4px;
|
|
70
|
+
cursor: pointer;
|
|
71
|
+
white-space: nowrap;
|
|
72
|
+
overflow: hidden;
|
|
73
|
+
text-overflow: ellipsis;
|
|
74
|
+
color: var(--fgColor-default, #1f2328);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.item:hover {
|
|
78
|
+
background: var(--bgColor-muted, #f6f8fa);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.item:focus-visible {
|
|
82
|
+
outline: 2px solid var(--focus-outlineColor, #0969da);
|
|
83
|
+
outline-offset: -2px;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.itemActive {
|
|
87
|
+
font-weight: 600;
|
|
88
|
+
background: var(--bgColor-accent-muted, #ddf4ff);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.itemActive:hover {
|
|
92
|
+
background: var(--bgColor-accent-muted, #ddf4ff);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.separator {
|
|
96
|
+
height: 1px;
|
|
97
|
+
background: var(--borderColor-default, rgba(0, 0, 0, 0.15));
|
|
98
|
+
margin: 4px 8px;
|
|
99
|
+
list-style: none;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.addItem {
|
|
103
|
+
padding: 6px 10px;
|
|
104
|
+
border-radius: 4px;
|
|
105
|
+
cursor: pointer;
|
|
106
|
+
white-space: nowrap;
|
|
107
|
+
color: var(--fgColor-muted, #656d76);
|
|
108
|
+
font-size: 12px;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.addItem:hover {
|
|
112
|
+
background: var(--bgColor-muted, #f6f8fa);
|
|
113
|
+
color: var(--fgColor-default, #1f2328);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.addForm {
|
|
117
|
+
display: flex;
|
|
118
|
+
gap: 4px;
|
|
119
|
+
padding: 4px 6px;
|
|
120
|
+
list-style: none;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.addInput {
|
|
124
|
+
flex: 1;
|
|
125
|
+
min-width: 0;
|
|
126
|
+
padding: 4px 8px;
|
|
127
|
+
border: 1px solid var(--borderColor-default, rgba(0, 0, 0, 0.15));
|
|
128
|
+
border-radius: 4px;
|
|
129
|
+
font-size: 12px;
|
|
130
|
+
font-family: inherit;
|
|
131
|
+
outline: none;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.addInput:focus {
|
|
135
|
+
border-color: var(--focus-outlineColor, #0969da);
|
|
136
|
+
box-shadow: 0 0 0 2px rgba(9, 105, 218, 0.3);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.addSubmit {
|
|
140
|
+
padding: 4px 10px;
|
|
141
|
+
border: none;
|
|
142
|
+
border-radius: 4px;
|
|
143
|
+
background: var(--bgColor-accent-emphasis, #0969da);
|
|
144
|
+
color: #fff;
|
|
145
|
+
font-size: 12px;
|
|
146
|
+
font-family: inherit;
|
|
147
|
+
cursor: pointer;
|
|
148
|
+
white-space: nowrap;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.addSubmit:hover {
|
|
152
|
+
opacity: 0.9;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.addSubmit:disabled {
|
|
156
|
+
opacity: 0.5;
|
|
157
|
+
cursor: default;
|
|
158
|
+
}
|