@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.
Files changed (63) hide show
  1. package/package.json +9 -4
  2. package/src/Icon.jsx +179 -0
  3. package/src/Viewfinder.jsx +1030 -57
  4. package/src/Viewfinder.module.css +1524 -155
  5. package/src/canvas/CanvasControls.jsx +51 -2
  6. package/src/canvas/CanvasControls.module.css +31 -0
  7. package/src/canvas/CanvasPage.bridge.test.jsx +95 -10
  8. package/src/canvas/CanvasPage.dragdrop.test.jsx +346 -0
  9. package/src/canvas/CanvasPage.jsx +843 -301
  10. package/src/canvas/CanvasPage.module.css +73 -50
  11. package/src/canvas/CanvasPage.multiselect.test.jsx +13 -11
  12. package/src/canvas/CanvasToolbar.jsx +2 -2
  13. package/src/canvas/ComponentErrorBoundary.jsx +50 -0
  14. package/src/canvas/PageSelector.jsx +198 -0
  15. package/src/canvas/PageSelector.module.css +158 -0
  16. package/src/canvas/PageSelector.test.jsx +104 -0
  17. package/src/canvas/canvasApi.js +22 -8
  18. package/src/canvas/canvasReloadGuard.js +37 -0
  19. package/src/canvas/canvasReloadGuard.test.js +27 -0
  20. package/src/canvas/componentIsolate.jsx +135 -0
  21. package/src/canvas/useCanvas.js +15 -10
  22. package/src/canvas/widgets/CodePenEmbed.jsx +292 -0
  23. package/src/canvas/widgets/CodePenEmbed.module.css +161 -0
  24. package/src/canvas/widgets/ComponentWidget.jsx +82 -9
  25. package/src/canvas/widgets/ComponentWidget.module.css +14 -6
  26. package/src/canvas/widgets/FigmaEmbed.jsx +110 -24
  27. package/src/canvas/widgets/FigmaEmbed.module.css +21 -7
  28. package/src/canvas/widgets/LinkPreview.jsx +297 -11
  29. package/src/canvas/widgets/LinkPreview.module.css +386 -18
  30. package/src/canvas/widgets/LinkPreview.test.jsx +193 -0
  31. package/src/canvas/widgets/MarkdownBlock.jsx +95 -21
  32. package/src/canvas/widgets/MarkdownBlock.module.css +133 -2
  33. package/src/canvas/widgets/MarkdownBlock.test.jsx +39 -0
  34. package/src/canvas/widgets/PrototypeEmbed.jsx +95 -144
  35. package/src/canvas/widgets/PrototypeEmbed.module.css +74 -4
  36. package/src/canvas/widgets/StickyNote.module.css +5 -0
  37. package/src/canvas/widgets/StickyNote.test.jsx +9 -9
  38. package/src/canvas/widgets/StoryWidget.jsx +276 -0
  39. package/src/canvas/widgets/StoryWidget.module.css +211 -0
  40. package/src/canvas/widgets/WidgetChrome.jsx +76 -20
  41. package/src/canvas/widgets/WidgetChrome.module.css +4 -7
  42. package/src/canvas/widgets/WidgetWrapper.module.css +2 -0
  43. package/src/canvas/widgets/codepenUrl.js +75 -0
  44. package/src/canvas/widgets/codepenUrl.test.js +76 -0
  45. package/src/canvas/widgets/embedInteraction.test.jsx +235 -0
  46. package/src/canvas/widgets/embedOverlay.module.css +35 -0
  47. package/src/canvas/widgets/embedTheme.js +56 -0
  48. package/src/canvas/widgets/githubUrl.js +82 -0
  49. package/src/canvas/widgets/githubUrl.test.js +74 -0
  50. package/src/canvas/widgets/iframeDevLogs.js +49 -0
  51. package/src/canvas/widgets/iframeDevLogs.test.jsx +81 -0
  52. package/src/canvas/widgets/index.js +4 -0
  53. package/src/canvas/widgets/pasteRules.js +295 -0
  54. package/src/canvas/widgets/pasteRules.test.js +474 -0
  55. package/src/canvas/widgets/snapshotDisplay.test.jsx +259 -0
  56. package/src/canvas/widgets/widgetConfig.js +16 -5
  57. package/src/canvas/widgets/widgetConfig.test.js +34 -12
  58. package/src/context.jsx +141 -16
  59. package/src/hooks/useSceneData.js +4 -2
  60. package/src/story/StoryPage.jsx +117 -0
  61. package/src/story/StoryPage.module.css +18 -0
  62. package/src/vite/data-plugin.js +375 -57
  63. package/src/vite/data-plugin.test.js +405 -5
@@ -4,10 +4,36 @@ import WidgetWrapper from './WidgetWrapper.jsx'
4
4
  import { readProp } from './widgetProps.js'
5
5
  import { schemas } from './widgetConfig.js'
6
6
  import { toFigmaEmbedUrl, getFigmaTitle, getFigmaType, isFigmaUrl } from './figmaUrl.js'
7
+ import { useIframeDevLogs } from './iframeDevLogs.js'
7
8
  import styles from './FigmaEmbed.module.css'
9
+ import overlayStyles from './embedOverlay.module.css'
8
10
 
9
11
  const figmaEmbedSchema = schemas['figma-embed']
10
12
 
13
+ /** Feather-icons figma icon (monochrome, stroke-based) */
14
+ function FigmaIcon({ size = 32, className }) {
15
+ return (
16
+ <svg
17
+ className={className}
18
+ width={size}
19
+ height={size}
20
+ viewBox="0 0 24 24"
21
+ fill="none"
22
+ stroke="currentColor"
23
+ strokeWidth="2"
24
+ strokeLinecap="round"
25
+ strokeLinejoin="round"
26
+ aria-hidden="true"
27
+ >
28
+ <path d="M5 5.5A3.5 3.5 0 0 1 8.5 2H12v7H8.5A3.5 3.5 0 0 1 5 5.5z" />
29
+ <path d="M12 2h3.5a3.5 3.5 0 1 1 0 7H12V2z" />
30
+ <path d="M12 12.5a3.5 3.5 0 1 1 7 0 3.5 3.5 0 1 1-7 0z" />
31
+ <path d="M5 19.5A3.5 3.5 0 0 1 8.5 16H12v3.5a3.5 3.5 0 1 1-7 0z" />
32
+ <path d="M5 12.5A3.5 3.5 0 0 1 8.5 9H12v7H8.5A3.5 3.5 0 0 1 5 12.5z" />
33
+ </svg>
34
+ )
35
+ }
36
+
11
37
  /** Inline Figma logo SVG */
12
38
  function FigmaLogo() {
13
39
  return (
@@ -29,11 +55,15 @@ export default forwardRef(function FigmaEmbed({ props, onUpdate, resizable }, re
29
55
  const height = readProp(props, 'height', figmaEmbedSchema)
30
56
 
31
57
  const [interactive, setInteractive] = useState(false)
58
+ const [showIframe, setShowIframe] = useState(true)
32
59
  const [expanded, setExpanded] = useState(false)
33
60
 
34
61
  const iframeRef = useRef(null)
62
+ const embedRef = useRef(null)
35
63
  const inlineContainerRef = useRef(null)
36
64
  const modalContainerRef = useRef(null)
65
+ const teardownTimerRef = useRef(null)
66
+ const exitSessionRef = useRef(0)
37
67
 
38
68
  // Validate URL at render time — only embed known Figma URLs
39
69
  const isValid = useMemo(() => isFigmaUrl(url), [url])
@@ -42,7 +72,38 @@ export default forwardRef(function FigmaEmbed({ props, onUpdate, resizable }, re
42
72
  const figmaType = useMemo(() => getFigmaType(url), [url])
43
73
  const typeLabel = figmaType ? TYPE_LABELS[figmaType] : 'Figma'
44
74
 
45
- const enterInteractive = useCallback(() => setInteractive(true), [])
75
+ useIframeDevLogs({
76
+ widget: 'FigmaEmbed',
77
+ loaded: showIframe && Boolean(embedUrl),
78
+ src: embedUrl,
79
+ })
80
+
81
+ const enterInteractive = useCallback(() => {
82
+ exitSessionRef.current++
83
+ clearTimeout(teardownTimerRef.current)
84
+ setShowIframe(true)
85
+ setInteractive(true)
86
+ }, [])
87
+
88
+ useEffect(() => {
89
+ if (!interactive || expanded) return
90
+ function handlePointerDown(e) {
91
+ if (embedRef.current && !embedRef.current.contains(e.target)) {
92
+ setInteractive(false)
93
+ // Keep iframe alive for 5 min — Figma is slow to reload
94
+ const session = ++exitSessionRef.current
95
+ clearTimeout(teardownTimerRef.current)
96
+ teardownTimerRef.current = setTimeout(() => {
97
+ if (exitSessionRef.current !== session) return
98
+ setShowIframe(false)
99
+ }, 5 * 60 * 1000)
100
+ }
101
+ }
102
+ document.addEventListener('pointerdown', handlePointerDown)
103
+ return () => document.removeEventListener('pointerdown', handlePointerDown)
104
+ }, [interactive, expanded])
105
+
106
+ useEffect(() => () => clearTimeout(teardownTimerRef.current), [])
46
107
 
47
108
  // Close expanded modal on Escape
48
109
  useEffect(() => {
@@ -96,6 +157,7 @@ export default forwardRef(function FigmaEmbed({ props, onUpdate, resizable }, re
96
157
  if (actionId === 'open-external') {
97
158
  if (url) window.open(url, '_blank', 'noopener')
98
159
  } else if (actionId === 'expand') {
160
+ setShowIframe(true)
99
161
  setExpanded(true)
100
162
  }
101
163
  },
@@ -104,39 +166,58 @@ export default forwardRef(function FigmaEmbed({ props, onUpdate, resizable }, re
104
166
  return (
105
167
  <>
106
168
  <WidgetWrapper>
107
- <div className={styles.embed} style={{ width, height }}>
169
+ <div ref={embedRef} className={styles.embed} style={{ width, height }}>
108
170
  <div className={styles.header}>
109
171
  <FigmaLogo />
110
172
  <span className={styles.headerTitle}>{title}</span>
111
173
  </div>
112
174
  {embedUrl ? (
113
175
  <>
114
- <div
115
- ref={inlineContainerRef}
116
- className={styles.iframeContainer}
117
- style={expanded ? { visibility: 'hidden' } : undefined}
118
- >
119
- <iframe
120
- ref={iframeRef}
121
- src={embedUrl}
122
- className={styles.iframe}
123
- title={`Figma ${typeLabel}: ${title}`}
124
- allowFullScreen
125
- />
126
- </div>
176
+ {showIframe ? (
177
+ <div
178
+ ref={inlineContainerRef}
179
+ className={styles.iframeContainer}
180
+ style={expanded ? { visibility: 'hidden' } : undefined}
181
+ >
182
+ <iframe
183
+ ref={iframeRef}
184
+ src={embedUrl}
185
+ className={styles.iframe}
186
+ title={`Figma ${typeLabel}: ${title}`}
187
+ allowFullScreen
188
+ />
189
+ </div>
190
+ ) : (
191
+ <div className={styles.iframeContainer} />
192
+ )}
127
193
  {!interactive && !expanded && (
128
194
  <div
129
- className={styles.dragOverlay}
130
- onDoubleClick={enterInteractive}
131
- />
195
+ className={overlayStyles.interactOverlay}
196
+ onClick={(e) => {
197
+ // Don't enter interactive mode for modifier clicks (shift/meta/ctrl for multi-select)
198
+ if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
199
+ enterInteractive()
200
+ }}
201
+ role="button"
202
+ tabIndex={0}
203
+ onKeyDown={(e) => {
204
+ if (e.key === 'Enter' || e.key === ' ') {
205
+ e.preventDefault()
206
+ e.stopPropagation()
207
+ enterInteractive()
208
+ }
209
+ }}
210
+ aria-label="Click to interact with Figma embed"
211
+ >
212
+ <span className={overlayStyles.interactHint}>Click to interact</span>
213
+ </div>
132
214
  )}
133
215
  </>
134
216
  ) : (
135
- <div className={styles.iframeContainer} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
136
- <p style={{ color: 'var(--fgColor-muted, #656d76)', fontSize: 14, fontStyle: 'italic' }}>
137
- No Figma URL
138
- </p>
139
- </div>
217
+ <div className={styles.emptyState}>
218
+ <FigmaIcon size={32} className={styles.emptyIcon} />
219
+ <span className={styles.emptyLabel}>No Figma URL</span>
220
+ </div>
140
221
  )}
141
222
  </div>
142
223
  {resizable && (
@@ -171,8 +252,13 @@ export default forwardRef(function FigmaEmbed({ props, onUpdate, resizable }, re
171
252
  style={expanded && embedUrl ? undefined : { display: 'none' }}
172
253
  onClick={() => setExpanded(false)}
173
254
  onPointerDown={(e) => e.stopPropagation()}
174
- onKeyDown={(e) => e.stopPropagation()}
255
+ onKeyDown={(e) => {
256
+ e.stopPropagation()
257
+ if (e.key === 'Escape') setExpanded(false)
258
+ }}
175
259
  onWheel={(e) => e.stopPropagation()}
260
+ tabIndex={-1}
261
+ ref={(el) => { if (el && expanded) el.focus() }}
176
262
  >
177
263
  <div
178
264
  ref={modalContainerRef}
@@ -47,13 +47,6 @@
47
47
  display: block;
48
48
  }
49
49
 
50
- .dragOverlay {
51
- position: absolute;
52
- inset: 0;
53
- z-index: 1;
54
- cursor: grab;
55
- }
56
-
57
50
  .resizeHandle {
58
51
  position: absolute;
59
52
  bottom: 0;
@@ -145,3 +138,24 @@
145
138
  .expandClose:hover {
146
139
  background: rgba(0, 0, 0, 0.7);
147
140
  }
141
+
142
+ .emptyState {
143
+ width: 100%;
144
+ height: calc(100% - 10px);
145
+ display: flex;
146
+ flex-direction: column;
147
+ align-items: center;
148
+ justify-content: center;
149
+ gap: 8px;
150
+ }
151
+
152
+ .emptyIcon {
153
+ color: var(--fgColor-muted, #656d76);
154
+ opacity: 0.5;
155
+ }
156
+
157
+ .emptyLabel {
158
+ color: var(--fgColor-muted, #656d76);
159
+ font-size: 13px;
160
+ font-style: italic;
161
+ }
@@ -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
  }