@dfosco/storyboard-react 4.0.0-beta.37 → 4.0.0-beta.39
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
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dfosco/storyboard-react",
|
|
3
|
-
"version": "4.0.0-beta.
|
|
3
|
+
"version": "4.0.0-beta.39",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"dependencies": {
|
|
6
|
-
"@
|
|
7
|
-
"@dfosco/
|
|
6
|
+
"@base-ui/react": "^1.4.0",
|
|
7
|
+
"@dfosco/storyboard-core": "4.0.0-beta.39",
|
|
8
|
+
"@dfosco/tiny-canvas": "4.0.0-beta.39",
|
|
8
9
|
"@neodrag/react": "^2.3.1",
|
|
9
10
|
"glob": "^11.0.0",
|
|
10
11
|
"jsonc-parser": "^3.3.1",
|
package/src/ViewfinderNew.jsx
CHANGED
|
@@ -673,12 +673,10 @@ function useBranches(basePath) {
|
|
|
673
673
|
.then(data => { if (data?.name) setGitUser(data.name) })
|
|
674
674
|
.catch(() => {})
|
|
675
675
|
|
|
676
|
-
//
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
.catch(() => {})
|
|
681
|
-
}
|
|
676
|
+
// Always fetch live branch list from server API
|
|
677
|
+
fetch(`${apiBase}/_storyboard/worktrees`).then(r => r.ok ? r.json() : null)
|
|
678
|
+
.then(data => { if (Array.isArray(data) && data.length > 0) setBranches(data) })
|
|
679
|
+
.catch(() => {})
|
|
682
680
|
}, [])
|
|
683
681
|
|
|
684
682
|
const currentBranch = useMemo(() => {
|
|
@@ -715,9 +713,20 @@ function BranchDropdown({ basePath }) {
|
|
|
715
713
|
.filter(b => !b.lastModified || new Date(b.lastModified).getTime() > twoWeeksAgo)
|
|
716
714
|
.sort((a, b) => (a.branch || '').localeCompare(b.branch || ''))
|
|
717
715
|
|
|
716
|
+
const isLocalDev = typeof window !== 'undefined' && window.__SB_LOCAL_DEV__ === true
|
|
717
|
+
|
|
718
718
|
const switchBranch = async (branch) => {
|
|
719
719
|
setSwitching(branch)
|
|
720
720
|
setSwitchError(null)
|
|
721
|
+
|
|
722
|
+
if (!isLocalDev) {
|
|
723
|
+
// Prod: direct navigation
|
|
724
|
+
const target = branches?.find(b => b.branch === branch)
|
|
725
|
+
window.location.href = `${branchBasePath}${target?.folder || (branch === 'main' ? '' : `branch--${branch}/`)}`
|
|
726
|
+
return
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Dev: call switch-branch API to spin up server
|
|
721
730
|
const apiBase = (basePath || '/').replace(/\/$/, '')
|
|
722
731
|
try {
|
|
723
732
|
const res = await fetch(`${apiBase}/_storyboard/switch-branch`, {
|
|
@@ -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,43 +215,22 @@ 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
|
-
|
|
219
|
-
return (
|
|
220
|
-
<GitHubIssueCard
|
|
221
|
-
url={url}
|
|
222
|
-
title={title}
|
|
223
|
-
github={github}
|
|
224
|
-
width={width}
|
|
225
|
-
collapsed={!!props?.collapsed}
|
|
226
|
-
onUpdate={onUpdate}
|
|
227
|
-
/>
|
|
228
|
-
)
|
|
229
|
-
}
|
|
230
|
-
|
|
218
|
+
// All hooks must be called before any early return
|
|
231
219
|
const ogImage = props?.ogImage || null
|
|
232
220
|
const description = props?.description || ''
|
|
233
221
|
const canEdit = typeof onUpdate === 'function'
|
|
234
222
|
const cardRef = useRef(null)
|
|
235
223
|
const inputRef = useRef(null)
|
|
236
224
|
const [editing, setEditing] = useState(false)
|
|
237
|
-
const [editValue, setEditValue] = useState(title)
|
|
238
|
-
|
|
239
|
-
// Sync editValue when title prop changes externally
|
|
240
|
-
useEffect(() => { setEditValue(title) }, [title])
|
|
241
225
|
|
|
242
226
|
const startEditing = useCallback(() => {
|
|
243
227
|
if (!canEdit) return
|
|
244
|
-
setEditValue(title)
|
|
245
228
|
setEditing(true)
|
|
246
|
-
}, [canEdit
|
|
229
|
+
}, [canEdit])
|
|
247
230
|
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
if (trimmed !== title) {
|
|
252
|
-
onUpdate?.({ title: trimmed })
|
|
253
|
-
}
|
|
254
|
-
}, [editValue, title, onUpdate])
|
|
231
|
+
const handleTitleChange = useCallback((e) => {
|
|
232
|
+
onUpdate?.({ title: e.target.value })
|
|
233
|
+
}, [onUpdate])
|
|
255
234
|
|
|
256
235
|
useEffect(() => {
|
|
257
236
|
if (editing && inputRef.current) {
|
|
@@ -260,6 +239,19 @@ export default function LinkPreview({ id, props, onUpdate, resizable }) {
|
|
|
260
239
|
}
|
|
261
240
|
}, [editing])
|
|
262
241
|
|
|
242
|
+
if (github) {
|
|
243
|
+
return (
|
|
244
|
+
<GitHubIssueCard
|
|
245
|
+
url={url}
|
|
246
|
+
title={title}
|
|
247
|
+
github={github}
|
|
248
|
+
width={width}
|
|
249
|
+
collapsed={!!props?.collapsed}
|
|
250
|
+
onUpdate={onUpdate}
|
|
251
|
+
/>
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
|
|
263
255
|
const sizeStyle = (width || height)
|
|
264
256
|
? { ...(width ? { width: `${width}px` } : {}), ...(height ? { minHeight: `${height}px` } : {}) }
|
|
265
257
|
: undefined
|
|
@@ -270,56 +262,57 @@ export default function LinkPreview({ id, props, onUpdate, resizable }) {
|
|
|
270
262
|
const handleResize = (w, h) => onUpdate?.({ width: w, height: h })
|
|
271
263
|
|
|
272
264
|
return (
|
|
273
|
-
<div
|
|
274
|
-
{
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
{
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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}
|
|
297
310
|
onMouseDown={(e) => e.stopPropagation()}
|
|
298
311
|
onPointerDown={(e) => e.stopPropagation()}
|
|
299
|
-
/>
|
|
300
|
-
) : (
|
|
301
|
-
<p
|
|
302
|
-
className={styles.title}
|
|
303
|
-
data-canvas-allow-text-selection={!canEdit ? '' : undefined}
|
|
304
|
-
onDoubleClick={startEditing}
|
|
305
|
-
role={canEdit ? 'button' : undefined}
|
|
306
|
-
tabIndex={canEdit ? 0 : undefined}
|
|
307
|
-
onKeyDown={canEdit ? (e) => { if (e.key === 'Enter') startEditing() } : undefined}
|
|
308
312
|
>
|
|
309
|
-
{
|
|
310
|
-
</
|
|
311
|
-
|
|
312
|
-
{description && <p className={styles.description}>{description}</p>}
|
|
313
|
-
<a
|
|
314
|
-
href={url || '#'}
|
|
315
|
-
target="_blank"
|
|
316
|
-
rel="noopener noreferrer"
|
|
317
|
-
className={styles.url}
|
|
318
|
-
onMouseDown={(e) => e.stopPropagation()}
|
|
319
|
-
onPointerDown={(e) => e.stopPropagation()}
|
|
320
|
-
>
|
|
321
|
-
{hostname || url}
|
|
322
|
-
</a>
|
|
313
|
+
{hostname || url}
|
|
314
|
+
</a>
|
|
315
|
+
</div>
|
|
323
316
|
</div>
|
|
324
317
|
{resizable && <ResizeHandle targetRef={cardRef} width={width} height={height} onResize={handleResize} />}
|
|
325
318
|
</div>
|
|
@@ -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
|
})
|
package/src/vite/data-plugin.js
CHANGED
|
@@ -834,7 +834,7 @@ export default function storyboardDataPlugin() {
|
|
|
834
834
|
// can't trace into its deps. Include the remark entry points so
|
|
835
835
|
// Vite pre-bundles the full chain — covers all transitive CJS
|
|
836
836
|
// packages (debug, extend, etc.) without whack-a-mole.
|
|
837
|
-
include: ['remark', 'remark-gfm', 'remark-html'],
|
|
837
|
+
include: ['remark', 'remark-gfm', 'remark-html', 'use-sync-external-store/shim', 'use-sync-external-store/shim/with-selector'],
|
|
838
838
|
exclude: ['@dfosco/storyboard-react'],
|
|
839
839
|
},
|
|
840
840
|
}
|