Skip to content

Commit a996413

Browse files
authored
feat: add mermaid support to Code block component (#726)
1 parent ff4b7a9 commit a996413

File tree

13 files changed

+590
-402
lines changed

13 files changed

+590
-402
lines changed

package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@
3535
"fix:format": "prettier --write --no-error-on-unmatched-pattern ./src",
3636
"fix:js": "eslint ./src --fix",
3737
"prepublishOnly": "npm run build",
38-
"firebase:start": "yarn build:storybook && npx firebase emulators:start"
38+
"firebase:start": "yarn build:storybook && npx firebase emulators:start",
39+
"analyze": "vite-bundle-visualizer -o stats.html"
3940
},
4041
"engines": {
4142
"node": ">=20.0.0"
@@ -132,6 +133,7 @@
132133
"typescript": "5.8.2",
133134
"typescript-eslint": "8.24.0",
134135
"vite": "5.4.8",
136+
"vite-bundle-visualizer": "1.2.1",
135137
"vite-plugin-dts": "4.3.0",
136138
"vitest": "2.1.9"
137139
},
@@ -151,4 +153,4 @@
151153
"eslint --fix"
152154
]
153155
}
154-
}
156+
}

src/components/Code.tsx

Lines changed: 95 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Div, Flex } from 'honorable'
1+
import { Div } from 'honorable'
22
import {
33
type ComponentProps,
44
type PropsWithChildren,
@@ -18,6 +18,7 @@ import useResizeObserver from '../hooks/useResizeObserver'
1818

1919
import Card, { type CardProps } from './Card'
2020
import Highlight from './Highlight'
21+
import { downloadMermaidSvg, Mermaid, MermaidRefHandle } from './Mermaid'
2122
import { ListBoxItem } from './ListBoxItem'
2223
import { Select } from './Select'
2324
import SubTab from './SubTab'
@@ -34,6 +35,9 @@ import CopyIcon from './icons/CopyIcon'
3435
import DropdownArrowIcon from './icons/DropdownArrowIcon'
3536
import FileIcon from './icons/FileIcon'
3637
import Button from './Button'
38+
import { DownloadIcon } from '../icons'
39+
import IconFrame from './IconFrame'
40+
import Flex from './Flex'
3741

3842
type CodeProps = Omit<CardProps, 'children'> & {
3943
children?: string
@@ -43,6 +47,7 @@ type CodeProps = Omit<CardProps, 'children'> & {
4347
tabs?: CodeTabData[]
4448
title?: ReactNode
4549
onSelectedTabChange?: (key: string) => void
50+
isStreaming?: boolean // currently just used to block mermaid from rendering mid-stream, but might have other uses later on
4651
}
4752

4853
type TabInterfaceT = 'tabs' | 'dropdown'
@@ -133,6 +138,15 @@ const CopyButton = styled(CopyButtonBase)<{ $verticallyCenter: boolean }>(
133138
})
134139
)
135140

141+
const MermaidButtonsSC = styled.div(({ theme }) => ({
142+
position: 'absolute',
143+
right: theme.spacing.medium,
144+
top: theme.spacing.medium,
145+
gap: theme.spacing.xsmall,
146+
display: 'flex',
147+
alignItems: 'center',
148+
}))
149+
136150
type CodeTabData = {
137151
key: string
138152
label?: string
@@ -297,8 +311,16 @@ const CodeSelect = styled(CodeSelectUnstyled)<{ $isDisabled?: boolean }>(
297311
function CodeContent({
298312
children,
299313
hasSetHeight,
314+
language,
315+
isStreaming = false,
300316
...props
301-
}: ComponentProps<typeof Highlight> & { hasSetHeight: boolean }) {
317+
}: ComponentProps<typeof Highlight> & {
318+
hasSetHeight: boolean
319+
isStreaming?: boolean
320+
}) {
321+
const { spacing, borderRadiuses } = useTheme()
322+
const mermaidRef = useRef<MermaidRefHandle>(null)
323+
const [mermaidError, setMermaidError] = useState<Nullable<Error>>(null)
302324
const [copied, setCopied] = useState(false)
303325
const codeString = children?.trim() || ''
304326
const multiLine = !!codeString.match(/\r?\n/) || hasSetHeight
@@ -313,38 +335,80 @@ function CodeContent({
313335
useEffect(() => {
314336
if (copied) {
315337
const timeout = setTimeout(() => setCopied(false), 1000)
316-
317338
return () => clearTimeout(timeout)
318339
}
319340
}, [copied])
320341

321-
if (typeof children !== 'string') {
342+
if (typeof children !== 'string')
322343
throw new Error('Code component expects a string as its children')
323-
}
344+
345+
const isMermaidDownloadable =
346+
language === 'mermaid' && !isStreaming && !mermaidError
324347

325348
return (
326-
<Div
327-
height="100%"
328-
overflow="auto"
329-
alignItems="center"
349+
<div
350+
css={{
351+
height: '100%',
352+
overflow: 'auto',
353+
alignItems: 'center',
354+
}}
330355
>
331-
<CopyButton
332-
copied={copied}
333-
handleCopy={handleCopy}
334-
$verticallyCenter={!multiLine}
335-
/>
336-
<Div
337-
paddingHorizontal="medium"
338-
paddingVertical={multiLine ? 'medium' : 'small'}
356+
{isMermaidDownloadable ? (
357+
<MermaidButtonsSC>
358+
<IconFrame
359+
clickable
360+
onClick={handleCopy}
361+
icon={copied ? <CheckIcon /> : <CopyIcon />}
362+
type="floating"
363+
tooltip="Copy Mermaid code"
364+
/>
365+
<IconFrame
366+
clickable
367+
onClick={() => {
368+
const { svgStr } = mermaidRef.current
369+
if (!svgStr) return
370+
downloadMermaidSvg(svgStr)
371+
}}
372+
icon={<DownloadIcon />}
373+
type="floating"
374+
tooltip="Download as PNG"
375+
/>
376+
</MermaidButtonsSC>
377+
) : (
378+
<CopyButton
379+
copied={copied}
380+
handleCopy={handleCopy}
381+
$verticallyCenter={!multiLine}
382+
/>
383+
)}
384+
<div
385+
css={{
386+
...(isMermaidDownloadable ? { backgroundColor: 'white' } : {}),
387+
padding: `${multiLine ? spacing.medium : spacing.small}px ${
388+
spacing.medium
389+
}px`,
390+
borderBottomLeftRadius: borderRadiuses.large,
391+
borderBottomRightRadius: borderRadiuses.large,
392+
}}
339393
>
340-
<Highlight
341-
key={codeString}
342-
{...props}
343-
>
344-
{codeString}
345-
</Highlight>
346-
</Div>
347-
</Div>
394+
{isMermaidDownloadable ? (
395+
<Mermaid
396+
ref={mermaidRef}
397+
setError={setMermaidError}
398+
>
399+
{codeString}
400+
</Mermaid>
401+
) : (
402+
<Highlight
403+
key={codeString}
404+
language={language}
405+
{...props}
406+
>
407+
{codeString}
408+
</Highlight>
409+
)}
410+
</div>
411+
</div>
348412
)
349413
}
350414

@@ -357,6 +421,7 @@ function CodeUnstyled({
357421
tabs,
358422
title,
359423
onSelectedTabChange,
424+
isStreaming = false,
360425
...props
361426
}: CodeProps) {
362427
const parentFillLevel = useFillLevel()
@@ -381,9 +446,7 @@ function CodeUnstyled({
381446
selectedKey: selectedTabKey,
382447
onSelectionChange: (key: string) => {
383448
setSelectedTabKey(key)
384-
if (typeof onSelectedTabChange === 'function') {
385-
onSelectedTabChange(key)
386-
}
449+
if (typeof onSelectedTabChange === 'function') onSelectedTabChange(key)
387450
},
388451
}),
389452
[onSelectedTabChange, selectedTabKey, tabInterface, setTabInterface, tabs]
@@ -449,6 +512,7 @@ function CodeUnstyled({
449512
language={tab.language}
450513
showLineNumbers={showLineNumbers}
451514
hasSetHeight={hasSetHeight}
515+
isStreaming={isStreaming}
452516
>
453517
{tab.content}
454518
</CodeContent>
@@ -464,6 +528,7 @@ function CodeUnstyled({
464528
language={language}
465529
showLineNumbers={showLineNumbers}
466530
hasSetHeight={hasSetHeight}
531+
isStreaming={isStreaming}
467532
>
468533
{children}
469534
</CodeContent>
@@ -479,12 +544,12 @@ function CodeUnstyled({
479544
}
480545

481546
const Code = styled(CodeUnstyled)((_) => ({
482-
[`${CopyButton}`]: {
547+
[`${CopyButton}, ${MermaidButtonsSC}`]: {
483548
opacity: 0,
484549
pointerEvents: 'none',
485550
transition: 'opacity 0.2s ease',
486551
},
487-
[`&:hover ${CopyButton}`]: {
552+
[`&:hover ${CopyButton}, &:hover ${MermaidButtonsSC}`]: {
488553
opacity: 1,
489554
pointerEvents: 'auto',
490555
transition: 'opacity 0.2s ease',

src/components/Markdown.tsx

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ type MarkdownProps = {
1515
text: string
1616
gitUrl?: string
1717
mainBranch?: string
18+
isStreaming?: boolean
1819
} & ReactMarkdownOptions
1920

2021
function getLastStringChild(children: any, depth = 0): any {
@@ -31,7 +32,13 @@ function getLastStringChild(children: any, depth = 0): any {
3132
return lastChild
3233
}
3334

34-
function MarkdownPreformatted({ children }: { children?: ReactNode }) {
35+
function MarkdownPreformatted({
36+
children,
37+
isStreaming,
38+
}: {
39+
children?: ReactNode
40+
isStreaming?: boolean
41+
}) {
3542
const theme = useTheme()
3643
let lang
3744

@@ -44,6 +51,7 @@ function MarkdownPreformatted({ children }: { children?: ReactNode }) {
4451

4552
return (
4653
<MultilineCode
54+
isStreaming={isStreaming}
4755
css={{
4856
marginTop: theme.spacing.xxsmall,
4957
'h1 + &, h2 + &, h3 + &, h4 + &, h5 + &, h6 + &': {
@@ -300,7 +308,13 @@ function MarkdownLink({
300308
)
301309
}
302310

303-
function Markdown({ text, gitUrl, mainBranch, ...props }: MarkdownProps) {
311+
function Markdown({
312+
text,
313+
gitUrl,
314+
mainBranch,
315+
isStreaming = false,
316+
...props
317+
}: MarkdownProps) {
304318
return useMemo(
305319
() => (
306320
<ReactMarkdown
@@ -336,7 +350,12 @@ function Markdown({ text, gitUrl, mainBranch, ...props }: MarkdownProps) {
336350
),
337351
span: (props) => <MdSpan {...props} />,
338352
code: (props) => <InlineCode {...props} />,
339-
pre: ({ node: _, ...props }) => <MarkdownPreformatted {...props} />,
353+
pre: ({ node: _, ...props }) => (
354+
<MarkdownPreformatted
355+
{...props}
356+
isStreaming={isStreaming}
357+
/>
358+
),
340359
hr: (props) => <MdHr {...props} />,
341360
th: (props) => <MdTh {...props} />,
342361
td: (props) => <MdTd {...props} />,
@@ -347,7 +366,7 @@ function Markdown({ text, gitUrl, mainBranch, ...props }: MarkdownProps) {
347366
{text}
348367
</ReactMarkdown>
349368
),
350-
[props, gitUrl, mainBranch, text]
369+
[props, text, gitUrl, mainBranch, isStreaming]
351370
)
352371
}
353372

0 commit comments

Comments
 (0)