@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.
Files changed (75) hide show
  1. package/package.json +6 -3
  2. package/src/AuthModal/AuthModal.jsx +134 -0
  3. package/src/AuthModal/AuthModal.module.css +221 -0
  4. package/src/BranchBar/BranchBar.jsx +56 -0
  5. package/src/BranchBar/BranchBar.module.css +230 -0
  6. package/src/BranchBar/useBranches.js +79 -0
  7. package/src/CommandPalette/CommandPalette.jsx +936 -0
  8. package/src/CommandPalette/CreateDialog.jsx +219 -0
  9. package/src/CommandPalette/command-palette.css +111 -0
  10. package/src/Icon.jsx +180 -0
  11. package/src/Viewfinder.jsx +1104 -57
  12. package/src/Viewfinder.module.css +1107 -149
  13. package/src/canvas/CanvasControls.jsx +51 -2
  14. package/src/canvas/CanvasControls.module.css +31 -0
  15. package/src/canvas/CanvasPage.bridge.test.jsx +142 -19
  16. package/src/canvas/CanvasPage.dragdrop.test.jsx +346 -0
  17. package/src/canvas/CanvasPage.jsx +807 -251
  18. package/src/canvas/CanvasPage.module.css +98 -50
  19. package/src/canvas/CanvasPage.multiselect.test.jsx +13 -11
  20. package/src/canvas/CanvasToolbar.jsx +2 -2
  21. package/src/canvas/MarqueeOverlay.jsx +20 -0
  22. package/src/canvas/PageSelector.jsx +239 -0
  23. package/src/canvas/PageSelector.module.css +165 -0
  24. package/src/canvas/PageSelector.test.jsx +104 -0
  25. package/src/canvas/canvasApi.js +22 -8
  26. package/src/canvas/canvasTheme.js +96 -52
  27. package/src/canvas/componentIsolate.jsx +33 -7
  28. package/src/canvas/useCanvas.js +9 -8
  29. package/src/canvas/useCanvas.test.js +4 -4
  30. package/src/canvas/useMarqueeSelect.js +187 -0
  31. package/src/canvas/useMarqueeSelect.test.js +78 -0
  32. package/src/canvas/widgets/CodePenEmbed.jsx +292 -0
  33. package/src/canvas/widgets/CodePenEmbed.module.css +161 -0
  34. package/src/canvas/widgets/ComponentWidget.jsx +42 -10
  35. package/src/canvas/widgets/ComponentWidget.module.css +6 -5
  36. package/src/canvas/widgets/FigmaEmbed.jsx +110 -24
  37. package/src/canvas/widgets/FigmaEmbed.module.css +21 -7
  38. package/src/canvas/widgets/LinkPreview.jsx +297 -11
  39. package/src/canvas/widgets/LinkPreview.module.css +386 -18
  40. package/src/canvas/widgets/LinkPreview.test.jsx +193 -0
  41. package/src/canvas/widgets/MarkdownBlock.jsx +86 -5
  42. package/src/canvas/widgets/MarkdownBlock.module.css +64 -15
  43. package/src/canvas/widgets/PrototypeEmbed.jsx +96 -145
  44. package/src/canvas/widgets/PrototypeEmbed.module.css +74 -4
  45. package/src/canvas/widgets/StickyNote.module.css +5 -0
  46. package/src/canvas/widgets/StickyNote.test.jsx +9 -9
  47. package/src/canvas/widgets/StoryWidget.jsx +277 -0
  48. package/src/canvas/widgets/StoryWidget.module.css +211 -0
  49. package/src/canvas/widgets/WidgetChrome.jsx +76 -20
  50. package/src/canvas/widgets/WidgetChrome.module.css +2 -6
  51. package/src/canvas/widgets/WidgetWrapper.module.css +2 -0
  52. package/src/canvas/widgets/codepenUrl.js +75 -0
  53. package/src/canvas/widgets/codepenUrl.test.js +76 -0
  54. package/src/canvas/widgets/embedInteraction.test.jsx +235 -0
  55. package/src/canvas/widgets/embedOverlay.module.css +35 -0
  56. package/src/canvas/widgets/embedTheme.js +138 -39
  57. package/src/canvas/widgets/githubUrl.js +82 -0
  58. package/src/canvas/widgets/githubUrl.test.js +74 -0
  59. package/src/canvas/widgets/iframeDevLogs.js +49 -0
  60. package/src/canvas/widgets/iframeDevLogs.test.jsx +81 -0
  61. package/src/canvas/widgets/index.js +4 -0
  62. package/src/canvas/widgets/pasteRules.js +295 -0
  63. package/src/canvas/widgets/pasteRules.test.js +474 -0
  64. package/src/canvas/widgets/snapshotDisplay.test.jsx +259 -0
  65. package/src/canvas/widgets/widgetConfig.js +16 -5
  66. package/src/canvas/widgets/widgetConfig.test.js +34 -12
  67. package/src/context.jsx +145 -16
  68. package/src/hooks/useSceneData.js +4 -2
  69. package/src/hooks/useThemeState.js +61 -0
  70. package/src/hooks/useThemeState.test.js +66 -0
  71. package/src/index.js +10 -0
  72. package/src/story/StoryPage.jsx +117 -0
  73. package/src/story/StoryPage.module.css +18 -0
  74. package/src/vite/data-plugin.js +348 -66
  75. 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
- export default function LinkPreview({ props }) {
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
- hostname = new URL(url).hostname
12
- } catch { /* invalid URL */ }
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
- <WidgetWrapper>
16
- <div className={styles.card}>
17
- <span className={styles.icon}>🔗</span>
18
- <div className={styles.text}>
19
- {title && <p className={styles.title}>{title}</p>}
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
- </WidgetWrapper>
317
+ {resizable && <ResizeHandle targetRef={cardRef} width={width} height={height} onResize={handleResize} />}
318
+ </div>
33
319
  )
34
320
  }