@dfosco/storyboard-react 4.0.0-beta.9 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -3
- package/src/AuthModal/AuthModal.jsx +134 -0
- package/src/AuthModal/AuthModal.module.css +221 -0
- package/src/BranchBar/BranchBar.jsx +56 -0
- package/src/BranchBar/BranchBar.module.css +230 -0
- package/src/BranchBar/useBranches.js +79 -0
- package/src/CommandPalette/CommandPalette.jsx +936 -0
- package/src/CommandPalette/CreateDialog.jsx +219 -0
- package/src/CommandPalette/command-palette.css +111 -0
- package/src/Icon.jsx +180 -0
- package/src/Viewfinder.jsx +1104 -57
- package/src/Viewfinder.module.css +1107 -149
- package/src/canvas/CanvasControls.jsx +51 -2
- package/src/canvas/CanvasControls.module.css +31 -0
- package/src/canvas/CanvasPage.bridge.test.jsx +142 -19
- package/src/canvas/CanvasPage.dragdrop.test.jsx +346 -0
- package/src/canvas/CanvasPage.jsx +807 -251
- package/src/canvas/CanvasPage.module.css +98 -50
- package/src/canvas/CanvasPage.multiselect.test.jsx +13 -11
- package/src/canvas/CanvasToolbar.jsx +2 -2
- package/src/canvas/MarqueeOverlay.jsx +20 -0
- package/src/canvas/PageSelector.jsx +239 -0
- package/src/canvas/PageSelector.module.css +165 -0
- package/src/canvas/PageSelector.test.jsx +104 -0
- package/src/canvas/canvasApi.js +22 -8
- package/src/canvas/canvasTheme.js +96 -52
- package/src/canvas/componentIsolate.jsx +33 -7
- package/src/canvas/useCanvas.js +9 -8
- package/src/canvas/useCanvas.test.js +4 -4
- package/src/canvas/useMarqueeSelect.js +187 -0
- package/src/canvas/useMarqueeSelect.test.js +78 -0
- package/src/canvas/widgets/CodePenEmbed.jsx +292 -0
- package/src/canvas/widgets/CodePenEmbed.module.css +161 -0
- package/src/canvas/widgets/ComponentWidget.jsx +42 -10
- package/src/canvas/widgets/ComponentWidget.module.css +6 -5
- package/src/canvas/widgets/FigmaEmbed.jsx +110 -24
- package/src/canvas/widgets/FigmaEmbed.module.css +21 -7
- package/src/canvas/widgets/LinkPreview.jsx +297 -11
- package/src/canvas/widgets/LinkPreview.module.css +386 -18
- package/src/canvas/widgets/LinkPreview.test.jsx +193 -0
- package/src/canvas/widgets/MarkdownBlock.jsx +86 -5
- package/src/canvas/widgets/MarkdownBlock.module.css +64 -15
- package/src/canvas/widgets/PrototypeEmbed.jsx +96 -145
- package/src/canvas/widgets/PrototypeEmbed.module.css +74 -4
- package/src/canvas/widgets/StickyNote.module.css +5 -0
- package/src/canvas/widgets/StickyNote.test.jsx +9 -9
- package/src/canvas/widgets/StoryWidget.jsx +277 -0
- package/src/canvas/widgets/StoryWidget.module.css +211 -0
- package/src/canvas/widgets/WidgetChrome.jsx +76 -20
- package/src/canvas/widgets/WidgetChrome.module.css +2 -6
- package/src/canvas/widgets/WidgetWrapper.module.css +2 -0
- package/src/canvas/widgets/codepenUrl.js +75 -0
- package/src/canvas/widgets/codepenUrl.test.js +76 -0
- package/src/canvas/widgets/embedInteraction.test.jsx +235 -0
- package/src/canvas/widgets/embedOverlay.module.css +35 -0
- package/src/canvas/widgets/embedTheme.js +138 -39
- package/src/canvas/widgets/githubUrl.js +82 -0
- package/src/canvas/widgets/githubUrl.test.js +74 -0
- package/src/canvas/widgets/iframeDevLogs.js +49 -0
- package/src/canvas/widgets/iframeDevLogs.test.jsx +81 -0
- package/src/canvas/widgets/index.js +4 -0
- package/src/canvas/widgets/pasteRules.js +295 -0
- package/src/canvas/widgets/pasteRules.test.js +474 -0
- package/src/canvas/widgets/snapshotDisplay.test.jsx +259 -0
- package/src/canvas/widgets/widgetConfig.js +16 -5
- package/src/canvas/widgets/widgetConfig.test.js +34 -12
- package/src/context.jsx +145 -16
- package/src/hooks/useSceneData.js +4 -2
- package/src/hooks/useThemeState.js +61 -0
- package/src/hooks/useThemeState.test.js +66 -0
- package/src/index.js +10 -0
- package/src/story/StoryPage.jsx +117 -0
- package/src/story/StoryPage.module.css +18 -0
- package/src/vite/data-plugin.js +348 -66
- package/src/vite/data-plugin.test.js +405 -5
|
@@ -1,24 +1,309 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import { remark } from 'remark'
|
|
3
|
+
import remarkGfm from 'remark-gfm'
|
|
4
|
+
import remarkHtml from 'remark-html'
|
|
5
|
+
import { MarkGithubIcon } from '@primer/octicons-react'
|
|
1
6
|
import WidgetWrapper from './WidgetWrapper.jsx'
|
|
7
|
+
import ResizeHandle from './ResizeHandle.jsx'
|
|
2
8
|
import { readProp, linkPreviewSchema } from './widgetProps.js'
|
|
3
9
|
import styles from './LinkPreview.module.css'
|
|
4
10
|
|
|
5
|
-
|
|
11
|
+
const VIDEO_EXT_RE = /\.(mp4|mov|webm|ogg)(\?[^)]*)?$/i
|
|
12
|
+
const VIDEO_URL_LINE_RE = /^<p>\s*(https?:\/\/[^\s<]+\.(mp4|mov|webm|ogg)(?:\?[^\s<]*)?)\s*<\/p>$/gim
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Post-process HTML body for canvas rendering:
|
|
16
|
+
* - Links open in new tabs
|
|
17
|
+
* - Unwrap <details> wrappers around videos (GitHub wraps them)
|
|
18
|
+
* - Convert bare video URLs and video-linked images to <video> elements
|
|
19
|
+
* - Mark checked checkboxes with data attribute for accent styling
|
|
20
|
+
*/
|
|
21
|
+
function postProcessHtml(html) {
|
|
22
|
+
if (!html) return ''
|
|
23
|
+
let out = html
|
|
24
|
+
|
|
25
|
+
// Open all links in new tabs
|
|
26
|
+
out = out.replace(/<a\s/g, '<a target="_blank" rel="noopener noreferrer" ')
|
|
27
|
+
// Dedupe target if GitHub already set it
|
|
28
|
+
out = out.replace(/target="_blank"\s*rel="noopener noreferrer"\s*target="_blank"/g, 'target="_blank"')
|
|
29
|
+
|
|
30
|
+
// Unwrap <details><summary>...</summary><video ...></details> → just the <video>
|
|
31
|
+
out = out.replace(/<details[^>]*>\s*<summary[^>]*>[\s\S]*?<\/summary>\s*(<video[\s\S]*?<\/video>)\s*<\/details>/gi, '$1')
|
|
32
|
+
|
|
33
|
+
// Convert bare video URLs (wrapped in <p>) into <video> elements
|
|
34
|
+
out = out.replace(VIDEO_URL_LINE_RE, (_, url) =>
|
|
35
|
+
`<video src="${url}" controls preload="none"></video>`
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
// Convert img tags pointing at video files to <video>
|
|
39
|
+
out = out.replace(/<img\s+([^>]*?)src="([^"]+\.(mp4|mov|webm|ogg)(?:\?[^"]*)?)"([^>]*)\/?>/gi, (_, _pre, url) =>
|
|
40
|
+
`<video src="${url}" controls preload="none"></video>`
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
// Existing <video> tags from GitHub: set preload=none to prevent auto-loading spinner
|
|
44
|
+
out = out.replace(/<video\s/g, '<video preload="none" ')
|
|
45
|
+
// Dedupe if we already added it
|
|
46
|
+
out = out.replace(/preload="none"\s+preload="[^"]*"/g, 'preload="none"')
|
|
47
|
+
|
|
48
|
+
// Remove disabled from checkboxes so accent-color works (CSS blocks interaction instead)
|
|
49
|
+
out = out.replace(/<input\s+([^>]*?)disabled([^>]*)>/gi, (match, before, after) => {
|
|
50
|
+
if (!match.includes('type="checkbox"')) return match
|
|
51
|
+
return `<input ${before}${after}>`
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
return out
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function renderMarkdown(text) {
|
|
58
|
+
if (!text) return ''
|
|
59
|
+
const result = remark()
|
|
60
|
+
.use(remarkGfm)
|
|
61
|
+
.use(remarkHtml, { sanitize: false })
|
|
62
|
+
.processSync(text)
|
|
63
|
+
return postProcessHtml(String(result))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function timeAgo(dateStr) {
|
|
67
|
+
if (!dateStr) return ''
|
|
68
|
+
const date = new Date(dateStr)
|
|
69
|
+
if (Number.isNaN(date.getTime())) return ''
|
|
70
|
+
const seconds = Math.floor((Date.now() - date.getTime()) / 1000)
|
|
71
|
+
if (seconds < 60) return 'just now'
|
|
72
|
+
const minutes = Math.floor(seconds / 60)
|
|
73
|
+
if (minutes < 60) return `${minutes} minute${minutes === 1 ? '' : 's'} ago`
|
|
74
|
+
const hours = Math.floor(minutes / 60)
|
|
75
|
+
if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago`
|
|
76
|
+
const days = Math.floor(hours / 24)
|
|
77
|
+
if (days < 30) return `${days} day${days === 1 ? '' : 's'} ago`
|
|
78
|
+
const months = Math.floor(days / 30)
|
|
79
|
+
if (months < 12) return `${months} month${months === 1 ? '' : 's'} ago`
|
|
80
|
+
const years = Math.floor(months / 12)
|
|
81
|
+
return `${years} year${years === 1 ? '' : 's'} ago`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Split a title like "#42 Ship GitHub embeds" into { number: "#42", rest: "Ship GitHub embeds" }.
|
|
86
|
+
*/
|
|
87
|
+
function splitIssueTitle(title) {
|
|
88
|
+
if (!title) return { number: '', rest: '' }
|
|
89
|
+
const match = title.match(/^(#\d+)\s+(.*)$/)
|
|
90
|
+
if (match) return { number: match[1], rest: match[2] }
|
|
91
|
+
return { number: '', rest: title }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const KIND_LABELS = {
|
|
95
|
+
issue: 'Issue',
|
|
96
|
+
pull_request: 'Pull Request',
|
|
97
|
+
discussion: 'Discussion',
|
|
98
|
+
comment: 'Comment',
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function getCommentKindLabel(github) {
|
|
102
|
+
if (github?.kind !== 'comment') return KIND_LABELS[github?.kind] || 'GitHub'
|
|
103
|
+
if (github?.parentKind === 'issue') return 'Issue Comment'
|
|
104
|
+
if (github?.parentKind === 'pull_request') return 'PR Comment'
|
|
105
|
+
if (github?.parentKind === 'discussion') return 'Discussion Comment'
|
|
106
|
+
return 'Comment'
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function GitHubIssueCard({ url, title, github, width, collapsed, onUpdate }) {
|
|
110
|
+
const authors = Array.isArray(github?.authors)
|
|
111
|
+
? github.authors.filter((a) => typeof a === 'string' && a.trim())
|
|
112
|
+
: []
|
|
113
|
+
const primaryAuthor = authors[0] || ''
|
|
114
|
+
const createdAgo = timeAgo(github?.createdAt)
|
|
115
|
+
const { number: issueNumber, rest: titleText } = splitIssueTitle(title)
|
|
116
|
+
|
|
117
|
+
const kindLabel = getCommentKindLabel(github)
|
|
118
|
+
|
|
119
|
+
// Prefer pre-rendered bodyHtml (has signed image URLs), fall back to remark for discussions
|
|
120
|
+
const bodyHtml = useMemo(() => {
|
|
121
|
+
if (github?.bodyHtml) return postProcessHtml(github.bodyHtml)
|
|
122
|
+
return renderMarkdown(github?.body || '')
|
|
123
|
+
}, [github?.bodyHtml, github?.body])
|
|
124
|
+
|
|
125
|
+
// Set body HTML via ref — avoids React destroying/recreating video elements on re-render
|
|
126
|
+
const bodyRef = useRef(null)
|
|
127
|
+
const lastHtmlRef = useRef('')
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
if (bodyRef.current && bodyHtml !== lastHtmlRef.current) {
|
|
130
|
+
bodyRef.current.innerHTML = bodyHtml
|
|
131
|
+
lastHtmlRef.current = bodyHtml
|
|
132
|
+
}
|
|
133
|
+
}, [bodyHtml])
|
|
134
|
+
|
|
135
|
+
// Also set on initial mount via callback ref
|
|
136
|
+
const setBodyRef = useCallback((el) => {
|
|
137
|
+
bodyRef.current = el
|
|
138
|
+
if (el && bodyHtml && bodyHtml !== lastHtmlRef.current) {
|
|
139
|
+
el.innerHTML = bodyHtml
|
|
140
|
+
lastHtmlRef.current = bodyHtml
|
|
141
|
+
}
|
|
142
|
+
}, [bodyHtml])
|
|
143
|
+
|
|
144
|
+
const sizeStyle = {
|
|
145
|
+
...(width ? { width: `${width}px` } : {}),
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<WidgetWrapper>
|
|
150
|
+
<div className={`${styles.issueCard} ${collapsed ? styles.issueCardCollapsed : ''}`} style={sizeStyle}>
|
|
151
|
+
<div className={styles.typeBar}>
|
|
152
|
+
<MarkGithubIcon size={16} />
|
|
153
|
+
<span>{kindLabel}</span>
|
|
154
|
+
</div>
|
|
155
|
+
<header className={styles.issueHeader}>
|
|
156
|
+
<h2 className={styles.issueTitle}>
|
|
157
|
+
<a
|
|
158
|
+
href={url || '#'}
|
|
159
|
+
target="_blank"
|
|
160
|
+
rel="noopener noreferrer"
|
|
161
|
+
className={styles.issueTitleLink}
|
|
162
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
163
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
164
|
+
>
|
|
165
|
+
{titleText || url}
|
|
166
|
+
{issueNumber && <span className={styles.issueNumber}> {issueNumber}</span>}
|
|
167
|
+
</a>
|
|
168
|
+
</h2>
|
|
169
|
+
</header>
|
|
170
|
+
|
|
171
|
+
<div className={styles.issueByline}>
|
|
172
|
+
<div className={styles.issueBylineLeft}>
|
|
173
|
+
{primaryAuthor && (
|
|
174
|
+
<a
|
|
175
|
+
href={`https://github.com/${primaryAuthor}`}
|
|
176
|
+
target="_blank"
|
|
177
|
+
rel="noopener noreferrer"
|
|
178
|
+
className={styles.authorLink}
|
|
179
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
180
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
181
|
+
>
|
|
182
|
+
<img
|
|
183
|
+
className={styles.avatar}
|
|
184
|
+
src={`https://github.com/${primaryAuthor}.png?size=40`}
|
|
185
|
+
alt=""
|
|
186
|
+
width="20"
|
|
187
|
+
height="20"
|
|
188
|
+
loading="lazy"
|
|
189
|
+
/>
|
|
190
|
+
{primaryAuthor}
|
|
191
|
+
</a>
|
|
192
|
+
)}
|
|
193
|
+
<span className={styles.bylineText}>
|
|
194
|
+
{primaryAuthor && createdAgo ? ` opened ${createdAgo}` : createdAgo ? `Opened ${createdAgo}` : ''}
|
|
195
|
+
</span>
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
{bodyHtml && (
|
|
200
|
+
<div
|
|
201
|
+
className={`${styles.issueBody} ${collapsed ? styles.issueBodyScrollable : ''}`}
|
|
202
|
+
ref={setBodyRef}
|
|
203
|
+
/>
|
|
204
|
+
)}
|
|
205
|
+
</div>
|
|
206
|
+
</WidgetWrapper>
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export default function LinkPreview({ props, onUpdate, resizable }) {
|
|
6
211
|
const url = readProp(props, 'url', linkPreviewSchema)
|
|
7
212
|
const title = readProp(props, 'title', linkPreviewSchema)
|
|
213
|
+
const github = props?.github && typeof props.github === 'object' ? props.github : null
|
|
214
|
+
|
|
215
|
+
const width = typeof props?.width === 'number' ? props.width : null
|
|
216
|
+
const height = typeof props?.height === 'number' ? props.height : null
|
|
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
|
+
|
|
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
|
+
|
|
255
|
+
const sizeStyle = (width || height)
|
|
256
|
+
? { ...(width ? { width: `${width}px` } : {}), ...(height ? { minHeight: `${height}px` } : {}) }
|
|
257
|
+
: undefined
|
|
8
258
|
|
|
9
259
|
let hostname = ''
|
|
10
|
-
try {
|
|
11
|
-
|
|
12
|
-
|
|
260
|
+
try { hostname = new URL(url).hostname } catch { /* */ }
|
|
261
|
+
|
|
262
|
+
const handleResize = (w, h) => onUpdate?.({ width: w, height: h })
|
|
13
263
|
|
|
14
264
|
return (
|
|
15
|
-
<
|
|
16
|
-
<div className={styles.card}>
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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>}
|
|
20
305
|
<a
|
|
21
|
-
href={url}
|
|
306
|
+
href={url || '#'}
|
|
22
307
|
target="_blank"
|
|
23
308
|
rel="noopener noreferrer"
|
|
24
309
|
className={styles.url}
|
|
@@ -29,6 +314,7 @@ export default function LinkPreview({ props }) {
|
|
|
29
314
|
</a>
|
|
30
315
|
</div>
|
|
31
316
|
</div>
|
|
32
|
-
|
|
317
|
+
{resizable && <ResizeHandle targetRef={cardRef} width={width} height={height} onResize={handleResize} />}
|
|
318
|
+
</div>
|
|
33
319
|
)
|
|
34
320
|
}
|