@dfosco/storyboard-react 4.0.0-beta.2 → 4.0.0-beta.21
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 +7 -4
- package/src/canvas/CanvasControls.jsx +51 -2
- package/src/canvas/CanvasControls.module.css +31 -0
- package/src/canvas/CanvasPage.dragdrop.test.jsx +346 -0
- package/src/canvas/CanvasPage.jsx +512 -235
- package/src/canvas/CanvasPage.module.css +9 -47
- package/src/canvas/ComponentErrorBoundary.jsx +50 -0
- package/src/canvas/PageSelector.jsx +102 -0
- package/src/canvas/PageSelector.module.css +93 -0
- package/src/canvas/PageSelector.test.jsx +104 -0
- package/src/canvas/canvasApi.js +4 -0
- package/src/canvas/canvasReloadGuard.js +37 -0
- package/src/canvas/canvasReloadGuard.test.js +27 -0
- package/src/canvas/componentIsolate.jsx +135 -0
- package/src/canvas/useCanvas.js +6 -2
- package/src/canvas/widgets/ComponentWidget.jsx +67 -9
- package/src/canvas/widgets/ComponentWidget.module.css +9 -6
- package/src/canvas/widgets/FigmaEmbed.jsx +26 -4
- package/src/canvas/widgets/FigmaEmbed.module.css +0 -7
- package/src/canvas/widgets/MarkdownBlock.jsx +94 -21
- package/src/canvas/widgets/MarkdownBlock.module.css +110 -2
- package/src/canvas/widgets/MarkdownBlock.test.jsx +39 -0
- package/src/canvas/widgets/PrototypeEmbed.jsx +196 -40
- package/src/canvas/widgets/PrototypeEmbed.module.css +30 -3
- package/src/canvas/widgets/StickyNote.module.css +5 -0
- package/src/canvas/widgets/StickyNote.test.jsx +9 -9
- package/src/canvas/widgets/StoryWidget.jsx +471 -0
- package/src/canvas/widgets/StoryWidget.module.css +200 -0
- package/src/canvas/widgets/WidgetChrome.jsx +54 -18
- package/src/canvas/widgets/WidgetChrome.module.css +4 -7
- package/src/canvas/widgets/WidgetWrapper.module.css +2 -0
- package/src/canvas/widgets/embedInteraction.test.jsx +155 -0
- package/src/canvas/widgets/embedOverlay.module.css +35 -0
- package/src/canvas/widgets/index.js +2 -0
- package/src/canvas/widgets/pasteRules.js +295 -0
- package/src/canvas/widgets/pasteRules.test.js +474 -0
- package/src/canvas/widgets/widgetConfig.js +16 -5
- package/src/canvas/widgets/widgetConfig.test.js +31 -9
- package/src/context.jsx +138 -13
- package/src/hooks/useSceneData.js +4 -2
- package/src/story/StoryPage.jsx +152 -0
- package/src/story/StoryPage.module.css +73 -0
- package/src/vite/data-plugin.js +441 -58
- package/src/vite/data-plugin.test.js +405 -5
|
@@ -1,17 +1,34 @@
|
|
|
1
|
-
import { useRef, useCallback, useState, useEffect } from 'react'
|
|
1
|
+
import { useRef, useCallback, useState, useEffect, useMemo } from 'react'
|
|
2
2
|
import WidgetWrapper from './WidgetWrapper.jsx'
|
|
3
3
|
import ResizeHandle from './ResizeHandle.jsx'
|
|
4
|
+
import ComponentErrorBoundary from '../ComponentErrorBoundary.jsx'
|
|
4
5
|
import styles from './ComponentWidget.module.css'
|
|
6
|
+
import overlayStyles from './embedOverlay.module.css'
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
9
|
* Renders a live JSX export from a .canvas.jsx companion file.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
+
*
|
|
11
|
+
* In dev mode (isLocalDev), each component is rendered inside an iframe
|
|
12
|
+
* via the /_storyboard/canvas/isolate middleware. This isolates broken
|
|
13
|
+
* components so they cannot crash the entire canvas page.
|
|
14
|
+
*
|
|
15
|
+
* In production, the component is rendered directly with an ErrorBoundary
|
|
16
|
+
* as a fallback safety net.
|
|
10
17
|
*
|
|
11
18
|
* Double-click the overlay to enter interactive mode (dropdowns, buttons work).
|
|
12
19
|
* Click outside to exit interactive mode.
|
|
13
20
|
*/
|
|
14
|
-
export default function ComponentWidget({
|
|
21
|
+
export default function ComponentWidget({
|
|
22
|
+
component: Component,
|
|
23
|
+
jsxModule,
|
|
24
|
+
exportName,
|
|
25
|
+
canvasTheme,
|
|
26
|
+
isLocalDev,
|
|
27
|
+
width,
|
|
28
|
+
height,
|
|
29
|
+
onUpdate,
|
|
30
|
+
resizable,
|
|
31
|
+
}) {
|
|
15
32
|
const containerRef = useRef(null)
|
|
16
33
|
const [interactive, setInteractive] = useState(false)
|
|
17
34
|
|
|
@@ -33,7 +50,21 @@ export default function ComponentWidget({ component: Component, width, height, o
|
|
|
33
50
|
return () => document.removeEventListener('pointerdown', handlePointerDown)
|
|
34
51
|
}, [interactive])
|
|
35
52
|
|
|
36
|
-
|
|
53
|
+
// Build iframe src for dev isolation
|
|
54
|
+
const iframeSrc = useMemo(() => {
|
|
55
|
+
if (!isLocalDev || !jsxModule || !exportName) return null
|
|
56
|
+
const basePath = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
|
|
57
|
+
const params = new URLSearchParams({
|
|
58
|
+
module: jsxModule,
|
|
59
|
+
export: exportName,
|
|
60
|
+
theme: canvasTheme || 'light',
|
|
61
|
+
})
|
|
62
|
+
return `${basePath}/_storyboard/canvas/isolate?${params}`
|
|
63
|
+
}, [isLocalDev, jsxModule, exportName, canvasTheme])
|
|
64
|
+
|
|
65
|
+
const useIframe = isLocalDev && iframeSrc
|
|
66
|
+
|
|
67
|
+
if (!useIframe && !Component) return null
|
|
37
68
|
|
|
38
69
|
const sizeStyle = {}
|
|
39
70
|
if (typeof width === 'number') sizeStyle.width = `${width}px`
|
|
@@ -43,13 +74,40 @@ export default function ComponentWidget({ component: Component, width, height, o
|
|
|
43
74
|
<WidgetWrapper>
|
|
44
75
|
<div ref={containerRef} className={styles.container} style={sizeStyle}>
|
|
45
76
|
<div className={styles.content}>
|
|
46
|
-
|
|
77
|
+
{useIframe ? (
|
|
78
|
+
<iframe
|
|
79
|
+
src={iframeSrc}
|
|
80
|
+
className={styles.iframe}
|
|
81
|
+
title={exportName || 'Component widget'}
|
|
82
|
+
sandbox="allow-same-origin allow-scripts"
|
|
83
|
+
/>
|
|
84
|
+
) : Component ? (
|
|
85
|
+
<ComponentErrorBoundary name={exportName}>
|
|
86
|
+
<Component />
|
|
87
|
+
</ComponentErrorBoundary>
|
|
88
|
+
) : null}
|
|
47
89
|
</div>
|
|
48
90
|
{!interactive && (
|
|
49
91
|
<div
|
|
50
|
-
className={
|
|
51
|
-
|
|
52
|
-
|
|
92
|
+
className={overlayStyles.interactOverlay}
|
|
93
|
+
onClick={(e) => {
|
|
94
|
+
// Don't enter interactive mode for modifier clicks (shift/meta/ctrl for multi-select)
|
|
95
|
+
if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
|
|
96
|
+
enterInteractive()
|
|
97
|
+
}}
|
|
98
|
+
role="button"
|
|
99
|
+
tabIndex={0}
|
|
100
|
+
onKeyDown={(e) => {
|
|
101
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
102
|
+
e.preventDefault()
|
|
103
|
+
e.stopPropagation()
|
|
104
|
+
enterInteractive()
|
|
105
|
+
}
|
|
106
|
+
}}
|
|
107
|
+
aria-label="Click to interact with component"
|
|
108
|
+
>
|
|
109
|
+
<span className={overlayStyles.interactHint}>Click to interact</span>
|
|
110
|
+
</div>
|
|
53
111
|
)}
|
|
54
112
|
{resizable && (
|
|
55
113
|
<ResizeHandle
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
.container {
|
|
2
2
|
position: relative;
|
|
3
|
-
overflow:
|
|
3
|
+
overflow: hidden;
|
|
4
4
|
min-width: 100px;
|
|
5
5
|
min-height: 60px;
|
|
6
|
+
background: var(--bgColor-default, #ffffff);
|
|
7
|
+
width: 100%;
|
|
8
|
+
height: 100%;
|
|
6
9
|
}
|
|
7
10
|
|
|
8
11
|
.content {
|
|
@@ -10,9 +13,9 @@
|
|
|
10
13
|
height: 100%;
|
|
11
14
|
}
|
|
12
15
|
|
|
13
|
-
.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
.iframe {
|
|
17
|
+
display: block;
|
|
18
|
+
width: 100%;
|
|
19
|
+
height: 100%;
|
|
20
|
+
border: none;
|
|
18
21
|
}
|
|
@@ -5,6 +5,7 @@ import { readProp } from './widgetProps.js'
|
|
|
5
5
|
import { schemas } from './widgetConfig.js'
|
|
6
6
|
import { toFigmaEmbedUrl, getFigmaTitle, getFigmaType, isFigmaUrl } from './figmaUrl.js'
|
|
7
7
|
import styles from './FigmaEmbed.module.css'
|
|
8
|
+
import overlayStyles from './embedOverlay.module.css'
|
|
8
9
|
|
|
9
10
|
const figmaEmbedSchema = schemas['figma-embed']
|
|
10
11
|
|
|
@@ -126,9 +127,25 @@ export default forwardRef(function FigmaEmbed({ props, onUpdate, resizable }, re
|
|
|
126
127
|
</div>
|
|
127
128
|
{!interactive && !expanded && (
|
|
128
129
|
<div
|
|
129
|
-
className={
|
|
130
|
-
|
|
131
|
-
|
|
130
|
+
className={overlayStyles.interactOverlay}
|
|
131
|
+
onClick={(e) => {
|
|
132
|
+
// Don't enter interactive mode for modifier clicks (shift/meta/ctrl for multi-select)
|
|
133
|
+
if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
|
|
134
|
+
enterInteractive()
|
|
135
|
+
}}
|
|
136
|
+
role="button"
|
|
137
|
+
tabIndex={0}
|
|
138
|
+
onKeyDown={(e) => {
|
|
139
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
140
|
+
e.preventDefault()
|
|
141
|
+
e.stopPropagation()
|
|
142
|
+
enterInteractive()
|
|
143
|
+
}
|
|
144
|
+
}}
|
|
145
|
+
aria-label="Click to interact with Figma embed"
|
|
146
|
+
>
|
|
147
|
+
<span className={overlayStyles.interactHint}>Click to interact</span>
|
|
148
|
+
</div>
|
|
132
149
|
)}
|
|
133
150
|
</>
|
|
134
151
|
) : (
|
|
@@ -171,8 +188,13 @@ export default forwardRef(function FigmaEmbed({ props, onUpdate, resizable }, re
|
|
|
171
188
|
style={expanded && embedUrl ? undefined : { display: 'none' }}
|
|
172
189
|
onClick={() => setExpanded(false)}
|
|
173
190
|
onPointerDown={(e) => e.stopPropagation()}
|
|
174
|
-
onKeyDown={(e) =>
|
|
191
|
+
onKeyDown={(e) => {
|
|
192
|
+
e.stopPropagation()
|
|
193
|
+
if (e.key === 'Escape') setExpanded(false)
|
|
194
|
+
}}
|
|
175
195
|
onWheel={(e) => e.stopPropagation()}
|
|
196
|
+
tabIndex={-1}
|
|
197
|
+
ref={(el) => { if (el && expanded) el.focus() }}
|
|
176
198
|
>
|
|
177
199
|
<div
|
|
178
200
|
ref={modalContainerRef}
|
|
@@ -1,33 +1,72 @@
|
|
|
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
|
|
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
|
|
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
|
-
|
|
12
|
-
.
|
|
13
|
-
.
|
|
14
|
-
.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
18
|
+
const result = remark()
|
|
19
|
+
.use(remarkGfm)
|
|
20
|
+
.use(remarkHtml, { sanitize: false })
|
|
21
|
+
.processSync(text)
|
|
22
|
+
return String(result)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Post-process rendered HTML to syntax-highlight fenced code blocks.
|
|
27
|
+
* remark-html outputs <pre><code class="language-xxx">...</code></pre>.
|
|
28
|
+
* We replace the code content with highlight.js output.
|
|
29
|
+
*/
|
|
30
|
+
let hljsPromise = null
|
|
31
|
+
function getHljs() {
|
|
32
|
+
if (!hljsPromise) {
|
|
33
|
+
hljsPromise = import('@dfosco/storyboard-core/inspector/highlighter').then((mod) => mod)
|
|
34
|
+
}
|
|
35
|
+
return hljsPromise
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function highlightCodeBlocks(html) {
|
|
39
|
+
if (!html.includes('<code class="language-')) return html
|
|
40
|
+
const { createInspectorHighlighter } = await getHljs()
|
|
41
|
+
const hl = await createInspectorHighlighter()
|
|
42
|
+
return html.replace(
|
|
43
|
+
/<pre><code class="language-(\w+)">([\s\S]*?)<\/code><\/pre>/g,
|
|
44
|
+
(match, lang, code) => {
|
|
45
|
+
try {
|
|
46
|
+
// Decode all HTML entities that remark-html may produce
|
|
47
|
+
const decoded = code
|
|
48
|
+
.replace(/</gi, '<')
|
|
49
|
+
.replace(/>/gi, '>')
|
|
50
|
+
.replace(/&/gi, '&')
|
|
51
|
+
.replace(/"/gi, '"')
|
|
52
|
+
.replace(/'/gi, "'")
|
|
53
|
+
.replace(/</g, '<')
|
|
54
|
+
.replace(/>/g, '>')
|
|
55
|
+
.replace(/"/g, '"')
|
|
56
|
+
.replace(/&/g, '&')
|
|
57
|
+
// codeToHtml returns a full <pre style="bg;fg"><code>...</code></pre>
|
|
58
|
+
return hl.codeToHtml(decoded, { lang })
|
|
59
|
+
} catch {
|
|
60
|
+
return match
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
)
|
|
26
64
|
}
|
|
27
65
|
|
|
28
|
-
export default function MarkdownBlock({ props, onUpdate }) {
|
|
66
|
+
export default function MarkdownBlock({ props, onUpdate, resizable }) {
|
|
29
67
|
const content = readProp(props, 'content', markdownSchema)
|
|
30
68
|
const width = readProp(props, 'width', markdownSchema)
|
|
69
|
+
const height = props?.height
|
|
31
70
|
const canEdit = typeof onUpdate === 'function'
|
|
32
71
|
const [editing, setEditing] = useState(false)
|
|
33
72
|
const editingActive = canEdit && editing
|
|
@@ -35,6 +74,32 @@ export default function MarkdownBlock({ props, onUpdate }) {
|
|
|
35
74
|
const blockRef = useRef(null)
|
|
36
75
|
const [editHeight, setEditHeight] = useState(null)
|
|
37
76
|
|
|
77
|
+
const handleResize = useCallback((w, h) => {
|
|
78
|
+
onUpdate?.({ width: w, height: h })
|
|
79
|
+
}, [onUpdate])
|
|
80
|
+
|
|
81
|
+
const rawHtml = useMemo(() => renderMarkdown(content), [content])
|
|
82
|
+
const [renderedHtml, setRenderedHtml] = useState(rawHtml)
|
|
83
|
+
|
|
84
|
+
// Re-highlight when theme changes
|
|
85
|
+
const [themeKey, setThemeKey] = useState(0)
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
function onThemeChanged() { setThemeKey((k) => k + 1) }
|
|
88
|
+
document.addEventListener('storyboard:theme:changed', onThemeChanged)
|
|
89
|
+
return () => document.removeEventListener('storyboard:theme:changed', onThemeChanged)
|
|
90
|
+
}, [])
|
|
91
|
+
|
|
92
|
+
// Async-highlight code blocks after initial render or theme change
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
setRenderedHtml(rawHtml)
|
|
95
|
+
if (!rawHtml.includes('<code class="language-')) return
|
|
96
|
+
let cancelled = false
|
|
97
|
+
highlightCodeBlocks(rawHtml).then((highlighted) => {
|
|
98
|
+
if (!cancelled) setRenderedHtml(highlighted)
|
|
99
|
+
})
|
|
100
|
+
return () => { cancelled = true }
|
|
101
|
+
}, [rawHtml, themeKey])
|
|
102
|
+
|
|
38
103
|
const handleContentChange = useCallback((e) => {
|
|
39
104
|
onUpdate?.({ content: e.target.value })
|
|
40
105
|
}, [onUpdate])
|
|
@@ -67,7 +132,7 @@ export default function MarkdownBlock({ props, onUpdate }) {
|
|
|
67
132
|
<div
|
|
68
133
|
ref={blockRef}
|
|
69
134
|
className={styles.block}
|
|
70
|
-
style={{ width, minHeight: editHeight || undefined }}
|
|
135
|
+
style={{ width, ...(height ? { height, overflow: 'auto' } : {}), minHeight: editHeight || undefined }}
|
|
71
136
|
>
|
|
72
137
|
{editingActive ? (
|
|
73
138
|
<textarea
|
|
@@ -97,12 +162,20 @@ export default function MarkdownBlock({ props, onUpdate }) {
|
|
|
97
162
|
tabIndex={canEdit ? 0 : undefined}
|
|
98
163
|
onKeyDown={canEdit ? (e) => { if (e.key === 'Enter') setEditing(true) } : undefined}
|
|
99
164
|
dangerouslySetInnerHTML={{
|
|
100
|
-
__html:
|
|
165
|
+
__html: renderedHtml || (canEdit
|
|
101
166
|
? '<p class="placeholder">Double-click to edit…</p>'
|
|
102
167
|
: '<p class="placeholder">No content</p>'),
|
|
103
168
|
}}
|
|
104
169
|
/>
|
|
105
170
|
)}
|
|
171
|
+
{resizable && (
|
|
172
|
+
<ResizeHandle
|
|
173
|
+
targetRef={blockRef}
|
|
174
|
+
minWidth={200}
|
|
175
|
+
minHeight={60}
|
|
176
|
+
onResize={handleResize}
|
|
177
|
+
/>
|
|
178
|
+
)}
|
|
106
179
|
</div>
|
|
107
180
|
</WidgetWrapper>
|
|
108
181
|
)
|
|
@@ -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);
|
|
@@ -52,17 +53,124 @@
|
|
|
52
53
|
background: var(--bgColor-neutral-muted, #afb8c133);
|
|
53
54
|
padding: 2px 5px;
|
|
54
55
|
border-radius: 4px;
|
|
55
|
-
font-size:
|
|
56
|
-
font-
|
|
56
|
+
font-size: 12px;
|
|
57
|
+
font-weight: 400;
|
|
58
|
+
font-family: "Ioskeley Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
.preview ul {
|
|
60
62
|
margin: 0 0 8px;
|
|
61
63
|
padding-left: 20px;
|
|
64
|
+
list-style-type: disc;
|
|
62
65
|
}
|
|
63
66
|
|
|
64
67
|
.preview li {
|
|
65
68
|
margin: 0 0 2px;
|
|
69
|
+
display: list-item;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.preview ol {
|
|
73
|
+
margin: 0 0 8px;
|
|
74
|
+
padding-left: 20px;
|
|
75
|
+
list-style-type: decimal;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* GFM: Task lists */
|
|
79
|
+
.preview input[type="checkbox"] {
|
|
80
|
+
margin-right: 6px;
|
|
81
|
+
pointer-events: none;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.preview li:has(input[type="checkbox"]) {
|
|
85
|
+
list-style: none;
|
|
86
|
+
margin-left: -20px;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/* GFM: Strikethrough */
|
|
90
|
+
.preview del {
|
|
91
|
+
text-decoration: line-through;
|
|
92
|
+
color: var(--sb--markdown-muted);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* GFM: Tables */
|
|
96
|
+
.preview table {
|
|
97
|
+
border-collapse: collapse;
|
|
98
|
+
margin: 8px 0;
|
|
99
|
+
width: 100%;
|
|
100
|
+
font-size: 13px;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.preview th,
|
|
104
|
+
.preview td {
|
|
105
|
+
border: 1px solid var(--borderColor-default, #d0d7de);
|
|
106
|
+
padding: 6px 12px;
|
|
107
|
+
text-align: left;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.preview th {
|
|
111
|
+
background: var(--bgColor-muted, #f6f8fa);
|
|
112
|
+
font-weight: 600;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/* GFM: Autolinks */
|
|
116
|
+
.preview a {
|
|
117
|
+
color: var(--sb--markdown-accent);
|
|
118
|
+
text-decoration: none;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.preview a:hover {
|
|
122
|
+
text-decoration: underline;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* Code blocks */
|
|
126
|
+
.preview pre {
|
|
127
|
+
padding: 12px 16px;
|
|
128
|
+
border-radius: 6px;
|
|
129
|
+
border: 1px solid var(--borderColor-muted, #d8dee4);
|
|
130
|
+
overflow-x: auto;
|
|
131
|
+
margin: 8px 0;
|
|
132
|
+
background: var(--bgColor-neutral-muted, #afb8c133);
|
|
133
|
+
line-height: 1.4;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/* When inspector highlighter sets inline background, let it through */
|
|
137
|
+
.preview pre[style] {
|
|
138
|
+
border-radius: 6px;
|
|
139
|
+
border: 1px solid var(--borderColor-muted, #d8dee4);
|
|
140
|
+
overflow-x: auto;
|
|
141
|
+
margin: 8px 0;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.preview pre code {
|
|
145
|
+
background: none;
|
|
146
|
+
padding: 0;
|
|
147
|
+
font-size: 12px;
|
|
148
|
+
font-weight: 400;
|
|
149
|
+
font-family: "Ioskeley Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
|
150
|
+
white-space: pre;
|
|
151
|
+
word-break: normal;
|
|
152
|
+
overflow-wrap: normal;
|
|
153
|
+
display: block;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/* Suppress line numbers from inspector highlighter's .line spans */
|
|
157
|
+
.preview :global(.line::before) {
|
|
158
|
+
display: none !important;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/* Blockquotes */
|
|
162
|
+
.preview blockquote {
|
|
163
|
+
border-left: 4px solid var(--borderColor-default, #d0d7de);
|
|
164
|
+
margin: 8px 0;
|
|
165
|
+
padding: 4px 16px;
|
|
166
|
+
color: var(--sb--markdown-muted);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/* Horizontal rules */
|
|
170
|
+
.preview hr {
|
|
171
|
+
border: none;
|
|
172
|
+
border-top: 1px solid var(--borderColor-default, #d0d7de);
|
|
173
|
+
margin: 16px 0;
|
|
66
174
|
}
|
|
67
175
|
|
|
68
176
|
.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
|
})
|