@dfosco/storyboard-react 4.0.0-beta.36 → 4.0.0-beta.38
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 +5 -3
- package/src/Icon.jsx +179 -0
- package/src/ViewfinderNew.jsx +1170 -0
- package/src/ViewfinderNew.module.css +1773 -0
- package/src/canvas/CanvasPage.jsx +14 -0
- package/src/canvas/widgets/LinkPreview.jsx +78 -21
- package/src/canvas/widgets/LinkPreview.module.css +50 -23
- package/src/canvas/widgets/LinkPreview.test.jsx +123 -1
- package/src/canvas/widgets/MarkdownBlock.module.css +2 -2
- package/src/canvas/widgets/PrototypeEmbed.jsx +1 -1
- package/src/canvas/widgets/StoryWidget.jsx +1 -1
- package/src/canvas/widgets/StoryWidget.module.css +3 -3
- package/src/index.js +1 -1
- package/src/vite/data-plugin.js +24 -0
- package/src/Viewfinder.jsx +0 -72
- package/src/Viewfinder.module.css +0 -235
- package/src/canvas/widgets/useSnapshotCapture.test.jsx +0 -164
|
@@ -1663,6 +1663,20 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1663
1663
|
return () => document.removeEventListener('wheel', handleWheel)
|
|
1664
1664
|
}, [])
|
|
1665
1665
|
|
|
1666
|
+
// Receive cmd+wheel events forwarded from prototype/story iframes
|
|
1667
|
+
useEffect(() => {
|
|
1668
|
+
function handleMessage(e) {
|
|
1669
|
+
if (e.data?.type !== 'storyboard:embed:wheel') return
|
|
1670
|
+
zoomAccum.current += -e.data.deltaY
|
|
1671
|
+
const step = Math.trunc(zoomAccum.current)
|
|
1672
|
+
if (step === 0) return
|
|
1673
|
+
zoomAccum.current -= step
|
|
1674
|
+
applyZoom(zoomRef.current + step)
|
|
1675
|
+
}
|
|
1676
|
+
window.addEventListener('message', handleMessage)
|
|
1677
|
+
return () => window.removeEventListener('message', handleMessage)
|
|
1678
|
+
}, [])
|
|
1679
|
+
|
|
1666
1680
|
// Touch pinch-to-zoom for mobile — two-finger pinch zooms the canvas
|
|
1667
1681
|
const pinchState = useRef({ active: false, startDist: 0, startZoom: 0, centerX: 0, centerY: 0 })
|
|
1668
1682
|
useEffect(() => {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
2
|
import { remark } from 'remark'
|
|
3
3
|
import remarkGfm from 'remark-gfm'
|
|
4
4
|
import remarkHtml from 'remark-html'
|
|
@@ -207,7 +207,7 @@ function GitHubIssueCard({ url, title, github, width, collapsed, onUpdate }) {
|
|
|
207
207
|
)
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
-
export default function LinkPreview({
|
|
210
|
+
export default function LinkPreview({ props, onUpdate, resizable }) {
|
|
211
211
|
const url = readProp(props, 'url', linkPreviewSchema)
|
|
212
212
|
const title = readProp(props, 'title', linkPreviewSchema)
|
|
213
213
|
const github = props?.github && typeof props.github === 'object' ? props.github : null
|
|
@@ -215,6 +215,30 @@ export default function LinkPreview({ id, props, onUpdate, resizable }) {
|
|
|
215
215
|
const width = typeof props?.width === 'number' ? props.width : null
|
|
216
216
|
const height = typeof props?.height === 'number' ? props.height : null
|
|
217
217
|
|
|
218
|
+
// All hooks must be called before any early return
|
|
219
|
+
const ogImage = props?.ogImage || null
|
|
220
|
+
const description = props?.description || ''
|
|
221
|
+
const canEdit = typeof onUpdate === 'function'
|
|
222
|
+
const cardRef = useRef(null)
|
|
223
|
+
const inputRef = useRef(null)
|
|
224
|
+
const [editing, setEditing] = useState(false)
|
|
225
|
+
|
|
226
|
+
const startEditing = useCallback(() => {
|
|
227
|
+
if (!canEdit) return
|
|
228
|
+
setEditing(true)
|
|
229
|
+
}, [canEdit])
|
|
230
|
+
|
|
231
|
+
const handleTitleChange = useCallback((e) => {
|
|
232
|
+
onUpdate?.({ title: e.target.value })
|
|
233
|
+
}, [onUpdate])
|
|
234
|
+
|
|
235
|
+
useEffect(() => {
|
|
236
|
+
if (editing && inputRef.current) {
|
|
237
|
+
inputRef.current.focus()
|
|
238
|
+
inputRef.current.select()
|
|
239
|
+
}
|
|
240
|
+
}, [editing])
|
|
241
|
+
|
|
218
242
|
if (github) {
|
|
219
243
|
return (
|
|
220
244
|
<GitHubIssueCard
|
|
@@ -238,26 +262,59 @@ export default function LinkPreview({ id, props, onUpdate, resizable }) {
|
|
|
238
262
|
const handleResize = (w, h) => onUpdate?.({ width: w, height: h })
|
|
239
263
|
|
|
240
264
|
return (
|
|
241
|
-
<
|
|
242
|
-
<div className={styles.card} style={sizeStyle}>
|
|
243
|
-
|
|
244
|
-
<
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
265
|
+
<div className={styles.container}>
|
|
266
|
+
<div ref={cardRef} className={styles.card} style={sizeStyle}>
|
|
267
|
+
{ogImage && (
|
|
268
|
+
<img
|
|
269
|
+
className={styles.ogImage}
|
|
270
|
+
src={ogImage}
|
|
271
|
+
alt=""
|
|
272
|
+
loading="lazy"
|
|
273
|
+
onError={(e) => { e.target.style.display = 'none' }}
|
|
274
|
+
/>
|
|
275
|
+
)}
|
|
276
|
+
<div className={styles.body}>
|
|
277
|
+
{editing ? (
|
|
278
|
+
<input
|
|
279
|
+
ref={inputRef}
|
|
280
|
+
className={styles.titleInput}
|
|
281
|
+
data-canvas-allow-text-selection
|
|
282
|
+
type="text"
|
|
283
|
+
value={title}
|
|
284
|
+
onChange={handleTitleChange}
|
|
285
|
+
onBlur={() => setEditing(false)}
|
|
286
|
+
onKeyDown={(e) => {
|
|
287
|
+
if (e.key === 'Enter' || e.key === 'Escape') setEditing(false)
|
|
288
|
+
}}
|
|
289
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
290
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
291
|
+
/>
|
|
292
|
+
) : (
|
|
293
|
+
<p
|
|
294
|
+
className={styles.title}
|
|
295
|
+
data-canvas-allow-text-selection={!canEdit ? '' : undefined}
|
|
296
|
+
onDoubleClick={startEditing}
|
|
297
|
+
role={canEdit ? 'button' : undefined}
|
|
298
|
+
tabIndex={canEdit ? 0 : undefined}
|
|
299
|
+
onKeyDown={canEdit ? (e) => { if (e.key === 'Enter') startEditing() } : undefined}
|
|
300
|
+
>
|
|
301
|
+
{title || hostname || url || 'Untitled'}
|
|
302
|
+
</p>
|
|
303
|
+
)}
|
|
304
|
+
{description && <p className={styles.description}>{description}</p>}
|
|
305
|
+
<a
|
|
306
|
+
href={url || '#'}
|
|
307
|
+
target="_blank"
|
|
308
|
+
rel="noopener noreferrer"
|
|
309
|
+
className={styles.url}
|
|
310
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
311
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
312
|
+
>
|
|
313
|
+
{hostname || url}
|
|
314
|
+
</a>
|
|
248
315
|
</div>
|
|
249
|
-
<a
|
|
250
|
-
href={url || '#'}
|
|
251
|
-
target="_blank"
|
|
252
|
-
rel="noopener noreferrer"
|
|
253
|
-
className={styles.url}
|
|
254
|
-
onMouseDown={(e) => e.stopPropagation()}
|
|
255
|
-
onPointerDown={(e) => e.stopPropagation()}
|
|
256
|
-
>
|
|
257
|
-
{hostname || url}
|
|
258
|
-
</a>
|
|
259
316
|
</div>
|
|
260
|
-
{resizable && <ResizeHandle width={width} height={height} onResize={handleResize} />}
|
|
261
|
-
</
|
|
317
|
+
{resizable && <ResizeHandle targetRef={cardRef} width={width} height={height} onResize={handleResize} />}
|
|
318
|
+
</div>
|
|
262
319
|
)
|
|
263
320
|
}
|
|
@@ -1,42 +1,42 @@
|
|
|
1
1
|
/* ── Plain link-preview card ──────────────────────────────────────── */
|
|
2
2
|
|
|
3
|
+
.container {
|
|
4
|
+
position: relative;
|
|
5
|
+
}
|
|
6
|
+
|
|
3
7
|
.card {
|
|
4
8
|
display: flex;
|
|
5
9
|
flex-direction: column;
|
|
6
|
-
gap: 10px;
|
|
7
|
-
padding: 14px 16px;
|
|
8
|
-
text-decoration: none;
|
|
9
|
-
color: inherit;
|
|
10
10
|
width: 320px;
|
|
11
|
-
|
|
12
|
-
border
|
|
13
|
-
border: 1px solid var(--borderColor-default, #d1d9e0);
|
|
11
|
+
border-radius: 6px;
|
|
12
|
+
border: 2px solid var(--borderColor-default, #d1d9e0);
|
|
14
13
|
background: var(--bgColor-default, #ffffff);
|
|
15
|
-
transition: background 150ms, border-color 150ms;
|
|
16
14
|
box-sizing: border-box;
|
|
15
|
+
overflow: hidden;
|
|
16
|
+
font-family: var(--tc-font-stack, system-ui, -apple-system, sans-serif);
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
.card
|
|
20
|
-
|
|
21
|
-
border-color: var(--borderColor-muted, #afb8c1);
|
|
19
|
+
:global([data-sb-canvas-theme^='dark']) .card {
|
|
20
|
+
border-color: var(--borderColor-default, #3d444d);
|
|
22
21
|
}
|
|
23
22
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
gap: 12px;
|
|
28
|
-
width: 100%;
|
|
23
|
+
/* Hide own border when parent widget slot is selected (avoid double focus ring) */
|
|
24
|
+
:global([data-widget-selected]) .card {
|
|
25
|
+
border-color: transparent;
|
|
29
26
|
}
|
|
30
27
|
|
|
31
|
-
.
|
|
32
|
-
|
|
33
|
-
|
|
28
|
+
.ogImage {
|
|
29
|
+
width: 100%;
|
|
30
|
+
max-height: 200px;
|
|
31
|
+
object-fit: cover;
|
|
32
|
+
display: block;
|
|
34
33
|
}
|
|
35
34
|
|
|
36
|
-
.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
35
|
+
.body {
|
|
36
|
+
display: flex;
|
|
37
|
+
flex-direction: column;
|
|
38
|
+
gap: 4px;
|
|
39
|
+
padding: 12px 14px;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
.title {
|
|
@@ -45,6 +45,33 @@
|
|
|
45
45
|
font-weight: 600;
|
|
46
46
|
line-height: 1.35;
|
|
47
47
|
color: var(--fgColor-default, #1f2328);
|
|
48
|
+
cursor: text;
|
|
49
|
+
word-break: break-word;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.titleInput {
|
|
53
|
+
margin: 0;
|
|
54
|
+
padding: 0;
|
|
55
|
+
border: none;
|
|
56
|
+
outline: none;
|
|
57
|
+
background: transparent;
|
|
58
|
+
font-family: inherit;
|
|
59
|
+
font-size: 14px;
|
|
60
|
+
font-weight: 600;
|
|
61
|
+
line-height: 1.35;
|
|
62
|
+
color: var(--fgColor-default, #1f2328);
|
|
63
|
+
width: 100%;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.description {
|
|
67
|
+
margin: 0;
|
|
68
|
+
font-size: 12px;
|
|
69
|
+
line-height: 1.4;
|
|
70
|
+
color: var(--fgColor-muted, #656d76);
|
|
71
|
+
display: -webkit-box;
|
|
72
|
+
-webkit-line-clamp: 2;
|
|
73
|
+
-webkit-box-orient: vertical;
|
|
74
|
+
overflow: hidden;
|
|
48
75
|
}
|
|
49
76
|
|
|
50
77
|
.url {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { render, screen } from '@testing-library/react'
|
|
1
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
2
2
|
import { describe, expect, it, vi } from 'vitest'
|
|
3
3
|
import LinkPreview from './LinkPreview.jsx'
|
|
4
4
|
|
|
@@ -68,4 +68,126 @@ describe('LinkPreview', () => {
|
|
|
68
68
|
// No issue card rendered
|
|
69
69
|
expect(container.querySelector('header')).toBeNull()
|
|
70
70
|
})
|
|
71
|
+
|
|
72
|
+
it('renders OG image when present', () => {
|
|
73
|
+
const { container } = render(
|
|
74
|
+
<LinkPreview
|
|
75
|
+
id="link-4"
|
|
76
|
+
props={{
|
|
77
|
+
url: 'https://example.com',
|
|
78
|
+
title: 'With image',
|
|
79
|
+
ogImage: 'https://example.com/og.png',
|
|
80
|
+
}}
|
|
81
|
+
/>,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
const img = container.querySelector('img')
|
|
85
|
+
expect(img).toBeTruthy()
|
|
86
|
+
expect(img.src).toBe('https://example.com/og.png')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('does not render image element when ogImage is absent', () => {
|
|
90
|
+
const { container } = render(
|
|
91
|
+
<LinkPreview
|
|
92
|
+
id="link-5"
|
|
93
|
+
props={{
|
|
94
|
+
url: 'https://example.com',
|
|
95
|
+
title: 'No image',
|
|
96
|
+
}}
|
|
97
|
+
/>,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
expect(container.querySelector('img')).toBeNull()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('hides broken OG image on error', () => {
|
|
104
|
+
const { container } = render(
|
|
105
|
+
<LinkPreview
|
|
106
|
+
id="link-6"
|
|
107
|
+
props={{
|
|
108
|
+
url: 'https://example.com',
|
|
109
|
+
title: 'Broken image',
|
|
110
|
+
ogImage: 'https://example.com/broken.png',
|
|
111
|
+
}}
|
|
112
|
+
/>,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
const img = container.querySelector('img')
|
|
116
|
+
expect(img).toBeTruthy()
|
|
117
|
+
fireEvent.error(img)
|
|
118
|
+
expect(img.style.display).toBe('none')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('renders description when present', () => {
|
|
122
|
+
render(
|
|
123
|
+
<LinkPreview
|
|
124
|
+
id="link-7"
|
|
125
|
+
props={{
|
|
126
|
+
url: 'https://example.com',
|
|
127
|
+
title: 'With desc',
|
|
128
|
+
description: 'A short description of the page',
|
|
129
|
+
}}
|
|
130
|
+
/>,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
expect(screen.getByText('A short description of the page')).toBeInTheDocument()
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('enters edit mode on double-click and saves on change', () => {
|
|
137
|
+
const onUpdate = vi.fn()
|
|
138
|
+
render(
|
|
139
|
+
<LinkPreview
|
|
140
|
+
id="link-8"
|
|
141
|
+
props={{
|
|
142
|
+
url: 'https://example.com',
|
|
143
|
+
title: 'Editable title',
|
|
144
|
+
}}
|
|
145
|
+
onUpdate={onUpdate}
|
|
146
|
+
/>,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
const titleEl = screen.getByText('Editable title')
|
|
150
|
+
fireEvent.doubleClick(titleEl)
|
|
151
|
+
|
|
152
|
+
const input = document.querySelector('input[type="text"]')
|
|
153
|
+
expect(input).toBeTruthy()
|
|
154
|
+
expect(input.value).toBe('Editable title')
|
|
155
|
+
|
|
156
|
+
fireEvent.change(input, { target: { value: 'New title' } })
|
|
157
|
+
expect(onUpdate).toHaveBeenCalledWith({ title: 'New title' })
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('does not enter edit mode when onUpdate is missing', () => {
|
|
161
|
+
render(
|
|
162
|
+
<LinkPreview
|
|
163
|
+
id="link-9"
|
|
164
|
+
props={{
|
|
165
|
+
url: 'https://example.com',
|
|
166
|
+
title: 'Read-only',
|
|
167
|
+
}}
|
|
168
|
+
/>,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
const titleEl = screen.getByText('Read-only')
|
|
172
|
+
fireEvent.doubleClick(titleEl)
|
|
173
|
+
|
|
174
|
+
// Should not show an input
|
|
175
|
+
expect(document.querySelector('input[type="text"]')).toBeNull()
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('shows fallback text when title is empty', () => {
|
|
179
|
+
render(
|
|
180
|
+
<LinkPreview
|
|
181
|
+
id="link-10"
|
|
182
|
+
props={{
|
|
183
|
+
url: 'https://example.com/page',
|
|
184
|
+
}}
|
|
185
|
+
onUpdate={() => {}}
|
|
186
|
+
/>,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
// Falls back to hostname — appears in both title and URL
|
|
190
|
+
const matches = screen.getAllByText('example.com')
|
|
191
|
+
expect(matches.length).toBeGreaterThanOrEqual(1)
|
|
192
|
+
})
|
|
71
193
|
})
|
|
@@ -87,7 +87,7 @@
|
|
|
87
87
|
border-radius: 4px;
|
|
88
88
|
font-size: 12px;
|
|
89
89
|
font-weight: 400;
|
|
90
|
-
font-family:
|
|
90
|
+
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
.preview ul {
|
|
@@ -169,7 +169,7 @@
|
|
|
169
169
|
padding: 0;
|
|
170
170
|
font-size: 12px;
|
|
171
171
|
font-weight: 400;
|
|
172
|
-
font-family:
|
|
172
|
+
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
|
173
173
|
white-space: pre;
|
|
174
174
|
word-break: normal;
|
|
175
175
|
overflow-wrap: normal;
|
|
@@ -403,7 +403,7 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
403
403
|
</div>
|
|
404
404
|
)}
|
|
405
405
|
</div>
|
|
406
|
-
{resizable && <ResizeHandle width={width} height={height} onResize={handleResize} />}
|
|
406
|
+
{resizable && <ResizeHandle targetRef={embedRef} width={width} height={height} onResize={handleResize} />}
|
|
407
407
|
</WidgetWrapper>
|
|
408
408
|
{createPortal(
|
|
409
409
|
<div
|
|
@@ -270,7 +270,7 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
|
|
|
270
270
|
</>
|
|
271
271
|
)}
|
|
272
272
|
</div>
|
|
273
|
-
{resizable && <ResizeHandle width={width} height={height} onResize={handleResize} />}
|
|
273
|
+
{resizable && <ResizeHandle targetRef={containerRef} width={width} height={height} onResize={handleResize} />}
|
|
274
274
|
</WidgetWrapper>
|
|
275
275
|
)
|
|
276
276
|
})
|
|
@@ -118,7 +118,7 @@
|
|
|
118
118
|
}
|
|
119
119
|
|
|
120
120
|
.codeLabel {
|
|
121
|
-
font-family:
|
|
121
|
+
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
.codeCloseBtn {
|
|
@@ -152,7 +152,7 @@
|
|
|
152
152
|
.codeBlock pre {
|
|
153
153
|
margin: 0;
|
|
154
154
|
padding: var(--base-size-8, 8px) !important;
|
|
155
|
-
font-family:
|
|
155
|
+
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
|
156
156
|
font-size: 12px;
|
|
157
157
|
font-weight: 400;
|
|
158
158
|
line-height: 1.6;
|
|
@@ -163,7 +163,7 @@
|
|
|
163
163
|
|
|
164
164
|
/* Fallback when no highlighted HTML (plain pre/code) */
|
|
165
165
|
.codeBlock > code {
|
|
166
|
-
font-family:
|
|
166
|
+
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
|
167
167
|
font-size: 12px;
|
|
168
168
|
font-weight: 400;
|
|
169
169
|
line-height: 1.6;
|
package/src/index.js
CHANGED
|
@@ -35,7 +35,7 @@ export { FormContext } from './context/FormContext.js'
|
|
|
35
35
|
// ModeSwitch and ToolbarShell UI moved to @dfosco/storyboard-svelte-ui
|
|
36
36
|
|
|
37
37
|
// Viewfinder dashboard
|
|
38
|
-
export { default as Viewfinder } from './
|
|
38
|
+
export { default as Viewfinder } from './ViewfinderNew.jsx'
|
|
39
39
|
|
|
40
40
|
// Canvas
|
|
41
41
|
export { default as CanvasPage } from './canvas/CanvasPage.jsx'
|
package/src/vite/data-plugin.js
CHANGED
|
@@ -1128,6 +1128,30 @@ export default function storyboardDataPlugin() {
|
|
|
1128
1128
|
return []
|
|
1129
1129
|
},
|
|
1130
1130
|
|
|
1131
|
+
// Inject __SB_BRANCHES__ into HTML so the Viewfinder branch selector works.
|
|
1132
|
+
// Reads .worktrees/ports.json to enumerate active worktree dev servers.
|
|
1133
|
+
transformIndexHtml(html, ctx) {
|
|
1134
|
+
// Only inject in dev mode
|
|
1135
|
+
if (!ctx.server) return html
|
|
1136
|
+
|
|
1137
|
+
try {
|
|
1138
|
+
const portsJsonPath = path.resolve(root, '.worktrees', 'ports.json')
|
|
1139
|
+
if (!fs.existsSync(portsJsonPath)) return html
|
|
1140
|
+
|
|
1141
|
+
const ports = JSON.parse(fs.readFileSync(portsJsonPath, 'utf-8'))
|
|
1142
|
+
const branches = Object.entries(ports)
|
|
1143
|
+
.filter(([name]) => name !== 'main')
|
|
1144
|
+
.map(([name, port]) => ({ branch: name, folder: `branch--${name}`, port }))
|
|
1145
|
+
|
|
1146
|
+
if (branches.length === 0) return html
|
|
1147
|
+
|
|
1148
|
+
const script = `<script>window.__SB_BRANCHES__ = ${JSON.stringify(branches)};</script>`
|
|
1149
|
+
return html.replace('</head>', `${script}\n</head>`)
|
|
1150
|
+
} catch {
|
|
1151
|
+
return html
|
|
1152
|
+
}
|
|
1153
|
+
},
|
|
1154
|
+
|
|
1131
1155
|
// Rebuild index on each build start
|
|
1132
1156
|
buildStart() {
|
|
1133
1157
|
buildResult = null
|
package/src/Viewfinder.jsx
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
|
|
2
|
-
import { useRef, useEffect, useMemo } from 'react'
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Viewfinder — thin React wrapper around the Svelte Viewfinder component.
|
|
6
|
-
*
|
|
7
|
-
* Mounts the core Svelte Viewfinder into a container div and manages
|
|
8
|
-
* its lifecycle via React's useEffect.
|
|
9
|
-
*
|
|
10
|
-
* @param {Object} props
|
|
11
|
-
* @param {Record<string, unknown>} [props.scenes] - Scene/flow index (deprecated, ignored — data comes from core)
|
|
12
|
-
* @param {Record<string, unknown>} [props.flows] - Flow index (deprecated, ignored — data comes from core)
|
|
13
|
-
* @param {Record<string, unknown>} [props.pageModules] - import.meta.glob result for page files
|
|
14
|
-
* @param {string} [props.basePath] - Base URL path
|
|
15
|
-
* @param {string} [props.title] - Header title
|
|
16
|
-
* @param {string} [props.subtitle] - Optional subtitle
|
|
17
|
-
* @param {boolean} [props.showThumbnails] - Show thumbnail previews
|
|
18
|
-
* @param {boolean} [props.hideDefaultFlow] - Hide the "default" flow from the "Other flows" section
|
|
19
|
-
*/
|
|
20
|
-
export default function Viewfinder({ pageModules = {}, basePath, title = 'Storyboard', subtitle, showThumbnails = false, hideDefaultFlow, hideDefaultScene = false }) {
|
|
21
|
-
const containerRef = useRef(null)
|
|
22
|
-
const handleRef = useRef(null)
|
|
23
|
-
|
|
24
|
-
const shouldHideDefault = hideDefaultFlow ?? hideDefaultScene
|
|
25
|
-
|
|
26
|
-
const knownRoutes = useMemo(() => Object.keys(pageModules)
|
|
27
|
-
.map(p => p.replace('/src/prototypes/', '').replace('.jsx', ''))
|
|
28
|
-
.filter(n => !n.startsWith('_') && n !== 'index' && n !== 'viewfinder'),
|
|
29
|
-
[pageModules])
|
|
30
|
-
|
|
31
|
-
useEffect(() => {
|
|
32
|
-
if (!containerRef.current) return
|
|
33
|
-
|
|
34
|
-
let cancelled = false
|
|
35
|
-
|
|
36
|
-
import('@dfosco/storyboard-core/ui-runtime').then(({ mountViewfinder, unmountViewfinder }) => {
|
|
37
|
-
if (cancelled) return
|
|
38
|
-
// Ensure clean state for re-mounts
|
|
39
|
-
unmountViewfinder()
|
|
40
|
-
handleRef.current = mountViewfinder(containerRef.current, {
|
|
41
|
-
title,
|
|
42
|
-
subtitle,
|
|
43
|
-
basePath,
|
|
44
|
-
knownRoutes,
|
|
45
|
-
showThumbnails,
|
|
46
|
-
hideDefaultFlow: shouldHideDefault,
|
|
47
|
-
})
|
|
48
|
-
// Wait for styles to be fully loaded before revealing
|
|
49
|
-
handleRef.current.ready.then(() => {
|
|
50
|
-
requestAnimationFrame(() => {
|
|
51
|
-
if (containerRef.current) containerRef.current.style.opacity = '1'
|
|
52
|
-
})
|
|
53
|
-
})
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
return () => {
|
|
57
|
-
cancelled = true
|
|
58
|
-
if (handleRef.current) {
|
|
59
|
-
handleRef.current.destroy()
|
|
60
|
-
handleRef.current = null
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
}, [title, subtitle, basePath, knownRoutes, showThumbnails, shouldHideDefault])
|
|
64
|
-
|
|
65
|
-
return <div ref={containerRef} style={{
|
|
66
|
-
minHeight: '100vh',
|
|
67
|
-
background: 'var(--bgColor-default, #0d1117)',
|
|
68
|
-
opacity: 0,
|
|
69
|
-
transition: 'opacity 0.15s ease',
|
|
70
|
-
}} />
|
|
71
|
-
}
|
|
72
|
-
|