@dfosco/storyboard-react 4.0.0-beta.3 → 4.0.0-beta.30

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 +7 -4
  2. package/src/canvas/CanvasControls.jsx +51 -2
  3. package/src/canvas/CanvasControls.module.css +31 -0
  4. package/src/canvas/CanvasPage.bridge.test.jsx +95 -10
  5. package/src/canvas/CanvasPage.dragdrop.test.jsx +346 -0
  6. package/src/canvas/CanvasPage.jsx +790 -302
  7. package/src/canvas/CanvasPage.module.css +70 -47
  8. package/src/canvas/CanvasPage.multiselect.test.jsx +13 -11
  9. package/src/canvas/CanvasToolbar.jsx +2 -2
  10. package/src/canvas/ComponentErrorBoundary.jsx +50 -0
  11. package/src/canvas/PageSelector.jsx +102 -0
  12. package/src/canvas/PageSelector.module.css +93 -0
  13. package/src/canvas/PageSelector.test.jsx +104 -0
  14. package/src/canvas/canvasApi.js +22 -8
  15. package/src/canvas/canvasReloadGuard.js +37 -0
  16. package/src/canvas/canvasReloadGuard.test.js +27 -0
  17. package/src/canvas/componentIsolate.jsx +135 -0
  18. package/src/canvas/useCanvas.js +15 -10
  19. package/src/canvas/widgets/CodePenEmbed.jsx +292 -0
  20. package/src/canvas/widgets/CodePenEmbed.module.css +161 -0
  21. package/src/canvas/widgets/ComponentWidget.jsx +82 -9
  22. package/src/canvas/widgets/ComponentWidget.module.css +14 -6
  23. package/src/canvas/widgets/FigmaEmbed.jsx +110 -24
  24. package/src/canvas/widgets/FigmaEmbed.module.css +21 -7
  25. package/src/canvas/widgets/LinkPreview.jsx +247 -18
  26. package/src/canvas/widgets/LinkPreview.module.css +349 -8
  27. package/src/canvas/widgets/LinkPreview.test.jsx +71 -0
  28. package/src/canvas/widgets/MarkdownBlock.jsx +95 -21
  29. package/src/canvas/widgets/MarkdownBlock.module.css +133 -2
  30. package/src/canvas/widgets/MarkdownBlock.test.jsx +39 -0
  31. package/src/canvas/widgets/PrototypeEmbed.jsx +319 -70
  32. package/src/canvas/widgets/PrototypeEmbed.module.css +74 -4
  33. package/src/canvas/widgets/StickyNote.module.css +5 -0
  34. package/src/canvas/widgets/StickyNote.test.jsx +9 -9
  35. package/src/canvas/widgets/StoryWidget.jsx +512 -0
  36. package/src/canvas/widgets/StoryWidget.module.css +211 -0
  37. package/src/canvas/widgets/WidgetChrome.jsx +76 -20
  38. package/src/canvas/widgets/WidgetChrome.module.css +4 -7
  39. package/src/canvas/widgets/WidgetWrapper.module.css +2 -0
  40. package/src/canvas/widgets/codepenUrl.js +75 -0
  41. package/src/canvas/widgets/codepenUrl.test.js +76 -0
  42. package/src/canvas/widgets/embedInteraction.test.jsx +235 -0
  43. package/src/canvas/widgets/embedOverlay.module.css +35 -0
  44. package/src/canvas/widgets/embedTheme.js +56 -0
  45. package/src/canvas/widgets/githubUrl.js +82 -0
  46. package/src/canvas/widgets/githubUrl.test.js +74 -0
  47. package/src/canvas/widgets/iframeDevLogs.js +49 -0
  48. package/src/canvas/widgets/iframeDevLogs.test.jsx +81 -0
  49. package/src/canvas/widgets/index.js +4 -0
  50. package/src/canvas/widgets/pasteRules.js +295 -0
  51. package/src/canvas/widgets/pasteRules.test.js +474 -0
  52. package/src/canvas/widgets/refreshQueue.js +108 -0
  53. package/src/canvas/widgets/snapshotDisplay.test.jsx +259 -0
  54. package/src/canvas/widgets/useSnapshotCapture.js +157 -0
  55. package/src/canvas/widgets/useSnapshotCapture.test.jsx +164 -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 +458 -71
  63. package/src/vite/data-plugin.test.js +405 -5
@@ -0,0 +1,71 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import LinkPreview from './LinkPreview.jsx'
4
+
5
+ describe('LinkPreview', () => {
6
+ it('renders GitHub issue card with markdown body and author byline', () => {
7
+ const { container } = render(
8
+ <LinkPreview
9
+ id="link-1"
10
+ props={{
11
+ url: 'https://github.com/dfosco/storyboard/issues/42',
12
+ title: '#42 Ship GitHub embeds',
13
+ github: {
14
+ context: 'GitHub · dfosco/storyboard · Issue #42',
15
+ body: '## Summary\n\nThis is a **bold** point.\n\n- Item one\n- Item two',
16
+ authors: ['dfosco'],
17
+ createdAt: '2026-01-01T00:00:00Z',
18
+ },
19
+ }}
20
+ />,
21
+ )
22
+
23
+ // Title split: text + muted number
24
+ expect(screen.getByText('Ship GitHub embeds')).toBeInTheDocument()
25
+ expect(screen.getByText('#42')).toBeInTheDocument()
26
+
27
+ // Markdown body renders headings, bold, lists
28
+ const headings = container.querySelectorAll('h2')
29
+ expect(headings.length).toBeGreaterThanOrEqual(1)
30
+ // Find the body heading (not the title)
31
+ const summaryHeading = Array.from(headings).find(h => h.textContent === 'Summary')
32
+ expect(summaryHeading).toBeTruthy()
33
+ expect(container.querySelectorAll('li')).toHaveLength(2)
34
+
35
+ // Author byline
36
+ expect(screen.getByText('dfosco')).toBeInTheDocument()
37
+ })
38
+
39
+ it('does not render GitHub layout for non-GitHub links', () => {
40
+ render(
41
+ <LinkPreview
42
+ id="link-2"
43
+ props={{
44
+ url: 'https://example.com/docs',
45
+ title: 'Example docs',
46
+ }}
47
+ />,
48
+ )
49
+
50
+ expect(screen.getByText('Example docs')).toBeInTheDocument()
51
+ expect(screen.getByText('example.com')).toBeInTheDocument()
52
+ })
53
+
54
+ it('renders plain link-preview without github data', () => {
55
+ const { container } = render(
56
+ <LinkPreview
57
+ id="link-3"
58
+ props={{
59
+ url: 'https://figma.com/design/abc',
60
+ title: 'My design',
61
+ width: 320,
62
+ height: 120,
63
+ }}
64
+ />,
65
+ )
66
+
67
+ expect(screen.getByText('My design')).toBeInTheDocument()
68
+ // No issue card rendered
69
+ expect(container.querySelector('header')).toBeNull()
70
+ })
71
+ })
@@ -1,33 +1,73 @@
1
- import { useState, useRef, useEffect, useCallback } from 'react'
1
+ import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
2
+ import { remark } from 'remark'
3
+ import remarkGfm from 'remark-gfm'
4
+ import remarkHtml from 'remark-html'
2
5
  import WidgetWrapper from './WidgetWrapper.jsx'
3
- import { readProp, markdownSchema } from './widgetProps.js'
6
+ import ResizeHandle from './ResizeHandle.jsx'
7
+ import { readProp } from './widgetProps.js'
8
+ import { schemas } from './widgetConfig.js'
4
9
  import styles from './MarkdownBlock.module.css'
5
10
 
11
+ const markdownSchema = schemas['markdown']
12
+
6
13
  /**
7
- * Renders markdown as plain HTML using a minimal built-in converter.
14
+ * Renders markdown to HTML using remark with GitHub Flavored Markdown support.
8
15
  */
9
16
  function renderMarkdown(text) {
10
17
  if (!text) return ''
11
- return text
12
- .replace(/^### (.+)$/gm, '<h3>$1</h3>')
13
- .replace(/^## (.+)$/gm, '<h2>$1</h2>')
14
- .replace(/^# (.+)$/gm, '<h1>$1</h1>')
15
- .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
16
- .replace(/\*(.+?)\*/g, '<em>$1</em>')
17
- .replace(/`(.+?)`/g, '<code>$1</code>')
18
- .replace(/^- (.+)$/gm, '<li>$1</li>')
19
- .replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>')
20
- .replace(/\n\n/g, '</p><p>')
21
- .replace(/\n/g, '<br>')
22
- .replace(/^(.+)$/gm, (line) => {
23
- if (line.startsWith('<')) return line
24
- return `<p>${line}</p>`
25
- })
18
+ const result = remark()
19
+ .use(remarkGfm)
20
+ .use(remarkHtml, { sanitize: false })
21
+ .processSync(text)
22
+ // Open all links in new tabs
23
+ return String(result).replace(/<a\s/g, '<a target="_blank" rel="noopener noreferrer" ')
24
+ }
25
+
26
+ /**
27
+ * Post-process rendered HTML to syntax-highlight fenced code blocks.
28
+ * remark-html outputs <pre><code class="language-xxx">...</code></pre>.
29
+ * We replace the code content with highlight.js output.
30
+ */
31
+ let hljsPromise = null
32
+ function getHljs() {
33
+ if (!hljsPromise) {
34
+ hljsPromise = import('@dfosco/storyboard-core/inspector/highlighter').then((mod) => mod)
35
+ }
36
+ return hljsPromise
37
+ }
38
+
39
+ async function highlightCodeBlocks(html) {
40
+ if (!html.includes('<code class="language-')) return html
41
+ const { createInspectorHighlighter } = await getHljs()
42
+ const hl = await createInspectorHighlighter()
43
+ return html.replace(
44
+ /<pre><code class="language-(\w+)">([\s\S]*?)<\/code><\/pre>/g,
45
+ (match, lang, code) => {
46
+ try {
47
+ // Decode all HTML entities that remark-html may produce
48
+ const decoded = code
49
+ .replace(/&#x3C;/gi, '<')
50
+ .replace(/&#x3E;/gi, '>')
51
+ .replace(/&#x26;/gi, '&')
52
+ .replace(/&#x22;/gi, '"')
53
+ .replace(/&#x27;/gi, "'")
54
+ .replace(/&lt;/g, '<')
55
+ .replace(/&gt;/g, '>')
56
+ .replace(/&quot;/g, '"')
57
+ .replace(/&amp;/g, '&')
58
+ // codeToHtml returns a full <pre style="bg;fg"><code>...</code></pre>
59
+ return hl.codeToHtml(decoded, { lang })
60
+ } catch {
61
+ return match
62
+ }
63
+ }
64
+ )
26
65
  }
27
66
 
28
- export default function MarkdownBlock({ props, onUpdate }) {
67
+ export default function MarkdownBlock({ props, onUpdate, resizable }) {
29
68
  const content = readProp(props, 'content', markdownSchema)
30
69
  const width = readProp(props, 'width', markdownSchema)
70
+ const height = props?.height
31
71
  const canEdit = typeof onUpdate === 'function'
32
72
  const [editing, setEditing] = useState(false)
33
73
  const editingActive = canEdit && editing
@@ -35,6 +75,32 @@ export default function MarkdownBlock({ props, onUpdate }) {
35
75
  const blockRef = useRef(null)
36
76
  const [editHeight, setEditHeight] = useState(null)
37
77
 
78
+ const handleResize = useCallback((w, h) => {
79
+ onUpdate?.({ width: w, height: h })
80
+ }, [onUpdate])
81
+
82
+ const rawHtml = useMemo(() => renderMarkdown(content), [content])
83
+ const [renderedHtml, setRenderedHtml] = useState(rawHtml)
84
+
85
+ // Re-highlight when theme changes
86
+ const [themeKey, setThemeKey] = useState(0)
87
+ useEffect(() => {
88
+ function onThemeChanged() { setThemeKey((k) => k + 1) }
89
+ document.addEventListener('storyboard:theme:changed', onThemeChanged)
90
+ return () => document.removeEventListener('storyboard:theme:changed', onThemeChanged)
91
+ }, [])
92
+
93
+ // Async-highlight code blocks after initial render or theme change
94
+ useEffect(() => {
95
+ setRenderedHtml(rawHtml)
96
+ if (!rawHtml.includes('<code class="language-')) return
97
+ let cancelled = false
98
+ highlightCodeBlocks(rawHtml).then((highlighted) => {
99
+ if (!cancelled) setRenderedHtml(highlighted)
100
+ })
101
+ return () => { cancelled = true }
102
+ }, [rawHtml, themeKey])
103
+
38
104
  const handleContentChange = useCallback((e) => {
39
105
  onUpdate?.({ content: e.target.value })
40
106
  }, [onUpdate])
@@ -67,7 +133,7 @@ export default function MarkdownBlock({ props, onUpdate }) {
67
133
  <div
68
134
  ref={blockRef}
69
135
  className={styles.block}
70
- style={{ width, minHeight: editHeight || undefined }}
136
+ style={{ width, ...(height ? { height, overflow: 'auto' } : {}), minHeight: editHeight || undefined }}
71
137
  >
72
138
  {editingActive ? (
73
139
  <textarea
@@ -97,12 +163,20 @@ export default function MarkdownBlock({ props, onUpdate }) {
97
163
  tabIndex={canEdit ? 0 : undefined}
98
164
  onKeyDown={canEdit ? (e) => { if (e.key === 'Enter') setEditing(true) } : undefined}
99
165
  dangerouslySetInnerHTML={{
100
- __html: renderMarkdown(content) || (canEdit
166
+ __html: renderedHtml || (canEdit
101
167
  ? '<p class="placeholder">Double-click to edit…</p>'
102
168
  : '<p class="placeholder">No content</p>'),
103
169
  }}
104
170
  />
105
171
  )}
172
+ {resizable && (
173
+ <ResizeHandle
174
+ targetRef={blockRef}
175
+ minWidth={200}
176
+ minHeight={60}
177
+ onResize={handleResize}
178
+ />
179
+ )}
106
180
  </div>
107
181
  </WidgetWrapper>
108
182
  )
@@ -1,4 +1,5 @@
1
1
  .block {
2
+ position: relative;
2
3
  min-height: 80px;
3
4
  --sb--markdown-bg: var(--bgColor-default, #ffffff);
4
5
  --sb--markdown-fg: var(--fgColor-default, #1f2328);
@@ -23,6 +24,38 @@
23
24
  pointer-events: none;
24
25
  }
25
26
 
27
+ .preview a {
28
+ color: var(--sb--markdown-accent);
29
+ text-decoration: none;
30
+ pointer-events: auto;
31
+ cursor: pointer;
32
+ }
33
+
34
+ .preview a:hover {
35
+ text-decoration: underline;
36
+ }
37
+
38
+ .preview img {
39
+ max-width: 100%;
40
+ height: auto;
41
+ border-radius: 6px;
42
+ border: 1px solid var(--borderColor-default, #d0d7de);
43
+ margin: 8px 0;
44
+ display: block;
45
+ pointer-events: auto;
46
+ }
47
+
48
+ .preview video {
49
+ max-width: 100%;
50
+ height: auto;
51
+ border-radius: 6px;
52
+ border: 1px solid var(--borderColor-default, #d0d7de);
53
+ margin: 8px 0;
54
+ display: block;
55
+ pointer-events: auto;
56
+ background: var(--bgColor-muted, #f6f8fa);
57
+ }
58
+
26
59
  .preview h1 {
27
60
  font-size: 20px;
28
61
  font-weight: 700;
@@ -52,17 +85,115 @@
52
85
  background: var(--bgColor-neutral-muted, #afb8c133);
53
86
  padding: 2px 5px;
54
87
  border-radius: 4px;
55
- font-size: 13px;
56
- font-family: ui-monospace, monospace;
88
+ font-size: 12px;
89
+ font-weight: 400;
90
+ font-family: "Ioskeley Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
57
91
  }
58
92
 
59
93
  .preview ul {
60
94
  margin: 0 0 8px;
61
95
  padding-left: 20px;
96
+ list-style-type: disc;
62
97
  }
63
98
 
64
99
  .preview li {
65
100
  margin: 0 0 2px;
101
+ display: list-item;
102
+ }
103
+
104
+ .preview ol {
105
+ margin: 0 0 8px;
106
+ padding-left: 20px;
107
+ list-style-type: decimal;
108
+ }
109
+
110
+ /* GFM: Task lists — accent-colored checkboxes */
111
+ .preview input[type="checkbox"] {
112
+ margin-right: 6px;
113
+ pointer-events: none;
114
+ accent-color: var(--sb--markdown-accent);
115
+ }
116
+
117
+ .preview li:has(input[type="checkbox"]) {
118
+ list-style: none;
119
+ margin-left: -20px;
120
+ }
121
+
122
+ /* GFM: Strikethrough */
123
+ .preview del {
124
+ text-decoration: line-through;
125
+ color: var(--sb--markdown-muted);
126
+ }
127
+
128
+ /* GFM: Tables */
129
+ .preview table {
130
+ border-collapse: collapse;
131
+ margin: 8px 0;
132
+ width: 100%;
133
+ font-size: 13px;
134
+ }
135
+
136
+ .preview th,
137
+ .preview td {
138
+ border: 1px solid var(--borderColor-default, #d0d7de);
139
+ padding: 6px 12px;
140
+ text-align: left;
141
+ }
142
+
143
+ .preview th {
144
+ background: var(--bgColor-muted, #f6f8fa);
145
+ font-weight: 600;
146
+ }
147
+
148
+ /* Code blocks */
149
+ .preview pre {
150
+ padding: 12px 16px;
151
+ border-radius: 6px;
152
+ border: 1px solid var(--borderColor-muted, #d8dee4);
153
+ overflow-x: auto;
154
+ margin: 8px 0;
155
+ background: var(--bgColor-neutral-muted, #afb8c133);
156
+ line-height: 1.4;
157
+ }
158
+
159
+ /* When inspector highlighter sets inline background, let it through */
160
+ .preview pre[style] {
161
+ border-radius: 6px;
162
+ border: 1px solid var(--borderColor-muted, #d8dee4);
163
+ overflow-x: auto;
164
+ margin: 8px 0;
165
+ }
166
+
167
+ .preview pre code {
168
+ background: none;
169
+ padding: 0;
170
+ font-size: 12px;
171
+ font-weight: 400;
172
+ font-family: "Ioskeley Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
173
+ white-space: pre;
174
+ word-break: normal;
175
+ overflow-wrap: normal;
176
+ display: block;
177
+ }
178
+
179
+ /* Suppress line numbers from inspector highlighter's .line spans */
180
+ .preview :global(.line::before) {
181
+ display: none !important;
182
+ }
183
+
184
+ /* Blockquotes */
185
+ .preview blockquote {
186
+ border-left: 4px solid var(--borderColor-default, #d0d7de);
187
+ margin: 8px 0;
188
+ padding: 4px 16px;
189
+ color: var(--sb--markdown-muted);
190
+ }
191
+
192
+ /* Horizontal rules */
193
+ .preview hr {
194
+ border: none;
195
+ border-top: 1px solid var(--borderColor-default, #d0d7de);
196
+ margin: 16px 0;
66
197
  }
67
198
 
68
199
  .preview :global(.placeholder) {
@@ -50,4 +50,43 @@ describe('MarkdownBlock', () => {
50
50
 
51
51
  expect(setData).toHaveBeenCalledWith('text/plain', '**Hello**\n- item')
52
52
  })
53
+
54
+ describe('GitHub Flavored Markdown', () => {
55
+ it('renders tables', () => {
56
+ const markdown = `| Name | Age |
57
+ | --- | --- |
58
+ | Alice | 30 |`
59
+ const { container } = render(<MarkdownBlock props={{ content: markdown, width: 420 }} />)
60
+
61
+ expect(container.querySelector('table')).not.toBeNull()
62
+ expect(container.querySelector('th')).not.toBeNull()
63
+ expect(screen.getByText('Alice')).toBeTruthy()
64
+ })
65
+
66
+ it('renders task lists', () => {
67
+ const markdown = `- [x] Done
68
+ - [ ] Todo`
69
+ const { container } = render(<MarkdownBlock props={{ content: markdown, width: 420 }} />)
70
+
71
+ const checkboxes = container.querySelectorAll('input[type="checkbox"]')
72
+ expect(checkboxes).toHaveLength(2)
73
+ expect(checkboxes[0].checked).toBe(true)
74
+ expect(checkboxes[1].checked).toBe(false)
75
+ })
76
+
77
+ it('renders strikethrough', () => {
78
+ const { container } = render(<MarkdownBlock props={{ content: '~~deleted~~', width: 420 }} />)
79
+
80
+ expect(container.querySelector('del')).not.toBeNull()
81
+ expect(screen.getByText('deleted')).toBeTruthy()
82
+ })
83
+
84
+ it('renders autolinks', () => {
85
+ const { container } = render(<MarkdownBlock props={{ content: 'https://github.com', width: 420 }} />)
86
+
87
+ const link = container.querySelector('a')
88
+ expect(link).not.toBeNull()
89
+ expect(link.href).toBe('https://github.com/')
90
+ })
91
+ })
53
92
  })