Skip to content

Commit bb7c858

Browse files
authored
fix: bake pan/zoom directly into mermaid component (#731)
1 parent c9afc66 commit bb7c858

File tree

4 files changed

+262
-136
lines changed

4 files changed

+262
-136
lines changed

src/components/Code.tsx

Lines changed: 68 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
import { Div } from 'honorable'
21
import {
32
type ComponentProps,
3+
createContext,
44
type PropsWithChildren,
55
type ReactNode,
66
type RefObject,
7-
createContext,
87
useCallback,
98
useContext,
109
useEffect,
@@ -16,10 +15,12 @@ import styled, { useTheme } from 'styled-components'
1615

1716
import useResizeObserver from '../hooks/useResizeObserver'
1817

18+
import Button from './Button'
1919
import Card, { type CardProps } from './Card'
20+
import Flex from './Flex'
2021
import Highlight from './Highlight'
21-
import { downloadMermaidSvg, Mermaid, MermaidRefHandle } from './Mermaid'
2222
import { ListBoxItem } from './ListBoxItem'
23+
import { Mermaid, MermaidRefHandle } from './Mermaid'
2324
import { Select } from './Select'
2425
import SubTab from './SubTab'
2526
import { TabList, type TabListStateProps } from './TabList'
@@ -34,10 +35,6 @@ import CheckIcon from './icons/CheckIcon'
3435
import CopyIcon from './icons/CopyIcon'
3536
import DropdownArrowIcon from './icons/DropdownArrowIcon'
3637
import FileIcon from './icons/FileIcon'
37-
import Button from './Button'
38-
import { DownloadIcon } from '../icons'
39-
import IconFrame from './IconFrame'
40-
import Flex from './Flex'
4138

4239
type CodeProps = Omit<CardProps, 'children'> & {
4340
children?: string
@@ -139,15 +136,6 @@ const CopyButton = styled(CopyButtonBase)<{ $verticallyCenter: boolean }>(
139136
})
140137
)
141138

142-
const MermaidButtonsSC = styled.div(({ theme }) => ({
143-
position: 'absolute',
144-
right: theme.spacing.medium,
145-
top: theme.spacing.medium,
146-
gap: theme.spacing.xsmall,
147-
display: 'flex',
148-
alignItems: 'center',
149-
}))
150-
151139
type CodeTabData = {
152140
key: string
153141
label?: string
@@ -321,10 +309,7 @@ function CodeContent({
321309
isStreaming?: boolean
322310
setMermaidError?: (error: Nullable<Error>) => void
323311
}) {
324-
const { spacing, borderRadiuses } = useTheme()
325312
const mermaidRef = useRef<MermaidRefHandle>(null)
326-
const [copied, setCopied] = useState(false)
327-
328313
const [mermaidError, setMermaidErrorState] = useState<Nullable<Error>>(null)
329314
const setMermaidError = useCallback(
330315
(error: Nullable<Error>) => {
@@ -336,93 +321,62 @@ function CodeContent({
336321

337322
const codeString = children?.trim() || ''
338323
const multiLine = !!codeString.match(/\r?\n/) || hasSetHeight
339-
const handleCopy = useCallback(
340-
() =>
341-
window.navigator.clipboard
342-
.writeText(codeString)
343-
.then(() => setCopied(true)),
344-
[codeString]
345-
)
346324

347-
useEffect(() => {
348-
if (copied) {
349-
const timeout = setTimeout(() => setCopied(false), 1000)
350-
return () => clearTimeout(timeout)
351-
}
352-
}, [copied])
325+
const { copied, handleCopy } = useCopyText(codeString)
353326

354327
if (typeof children !== 'string')
355328
throw new Error('Code component expects a string as its children')
356329

357-
const isMermaidDownloadable =
358-
language === 'mermaid' && !isStreaming && !mermaidError
330+
const validMermaid = language === 'mermaid' && !isStreaming && !mermaidError
359331

360332
return (
361-
<div
362-
css={{
363-
height: '100%',
364-
overflow: 'auto',
365-
alignItems: 'center',
366-
}}
367-
>
368-
{isMermaidDownloadable ? (
369-
<MermaidButtonsSC>
370-
<IconFrame
371-
clickable
372-
onClick={handleCopy}
373-
icon={copied ? <CheckIcon /> : <CopyIcon />}
374-
type="floating"
375-
tooltip="Copy Mermaid code"
376-
/>
377-
<IconFrame
378-
clickable
379-
onClick={() => {
380-
const { svgStr } = mermaidRef.current
381-
if (!svgStr) return
382-
downloadMermaidSvg(svgStr)
383-
}}
384-
icon={<DownloadIcon />}
385-
type="floating"
386-
tooltip="Download as PNG"
387-
/>
388-
</MermaidButtonsSC>
389-
) : (
390-
<CopyButton
391-
copied={copied}
392-
handleCopy={handleCopy}
393-
$verticallyCenter={!multiLine}
394-
/>
395-
)}
396-
<div
397-
css={{
398-
...(isMermaidDownloadable ? { backgroundColor: 'white' } : {}),
399-
padding: `${multiLine ? spacing.medium : spacing.small}px ${
400-
spacing.medium
401-
}px`,
402-
borderBottomLeftRadius: borderRadiuses.large,
403-
borderBottomRightRadius: borderRadiuses.large,
404-
}}
333+
<div css={{ position: 'relative', overflow: 'hidden', height: '100%' }}>
334+
<CodeContentSC
335+
$validMermaid={validMermaid}
336+
$multiLine={multiLine}
405337
>
406-
{isMermaidDownloadable ? (
338+
{validMermaid ? (
407339
<Mermaid
408340
ref={mermaidRef}
409341
setError={setMermaidError}
410342
diagram={codeString}
411343
/>
412344
) : (
413-
<Highlight
414-
key={codeString}
415-
language={language}
416-
{...props}
417-
>
418-
{codeString}
419-
</Highlight>
345+
<>
346+
<CopyButton
347+
copied={copied}
348+
handleCopy={handleCopy}
349+
$verticallyCenter={!multiLine}
350+
/>
351+
<Highlight
352+
key={codeString}
353+
language={language}
354+
{...props}
355+
>
356+
{codeString}
357+
</Highlight>
358+
</>
420359
)}
421-
</div>
360+
</CodeContentSC>
422361
</div>
423362
)
424363
}
425364

365+
const CodeContentSC = styled.div<{
366+
$validMermaid: boolean
367+
$multiLine: boolean
368+
}>(({ theme, $validMermaid, $multiLine }) => ({
369+
height: '100%',
370+
overflow: 'auto',
371+
alignItems: 'center',
372+
padding: `${$multiLine ? theme.spacing.medium : theme.spacing.small}px ${
373+
theme.spacing.medium
374+
}px`,
375+
borderBottomLeftRadius: theme.borderRadiuses.large,
376+
borderBottomRightRadius: theme.borderRadiuses.large,
377+
...($validMermaid ? { backgroundColor: 'white', padding: 0 } : {}),
378+
}))
379+
426380
function CodeUnstyled({
427381
ref,
428382
children,
@@ -512,13 +466,7 @@ function CodeUnstyled({
512466
tabKey={tab.key}
513467
mode="multipanel"
514468
stateRef={tabStateRef}
515-
as={
516-
<Div
517-
position="relative"
518-
height="100%"
519-
overflow="hidden"
520-
/>
521-
}
469+
css={{ height: '100%', overflow: 'hidden' }}
522470
>
523471
<CodeContent
524472
language={tab.language}
@@ -532,21 +480,15 @@ function CodeUnstyled({
532480
</TabPanel>
533481
))
534482
) : (
535-
<Div
536-
position="relative"
537-
height="100%"
538-
overflow="hidden"
483+
<CodeContent
484+
language={language}
485+
showLineNumbers={showLineNumbers}
486+
hasSetHeight={hasSetHeight}
487+
isStreaming={isStreaming}
488+
setMermaidError={setMermaidError}
539489
>
540-
<CodeContent
541-
language={language}
542-
showLineNumbers={showLineNumbers}
543-
hasSetHeight={hasSetHeight}
544-
isStreaming={isStreaming}
545-
setMermaidError={setMermaidError}
546-
>
547-
{children}
548-
</CodeContent>
549-
</Div>
490+
{children}
491+
</CodeContent>
550492
)}
551493
</Flex>
552494
</Card>
@@ -558,17 +500,33 @@ function CodeUnstyled({
558500
}
559501

560502
const Code = styled(CodeUnstyled)((_) => ({
561-
[`${CopyButton}, ${MermaidButtonsSC}`]: {
503+
[`${CopyButton}`]: {
562504
opacity: 0,
563505
pointerEvents: 'none',
564506
transition: 'opacity 0.2s ease',
565507
},
566-
[`&:hover ${CopyButton}, &:hover ${MermaidButtonsSC}`]: {
508+
[`&:hover ${CopyButton}`]: {
567509
opacity: 1,
568510
pointerEvents: 'auto',
569511
transition: 'opacity 0.2s ease',
570512
},
571513
}))
572514

515+
export function useCopyText(text: string) {
516+
const [copied, setCopied] = useState(false)
517+
const handleCopy = useCallback(
518+
() =>
519+
window.navigator.clipboard.writeText(text).then(() => setCopied(true)),
520+
[text]
521+
)
522+
useEffect(() => {
523+
if (copied) {
524+
const timeout = setTimeout(() => setCopied(false), 1000)
525+
return () => clearTimeout(timeout)
526+
}
527+
}, [copied])
528+
return { copied, handleCopy }
529+
}
530+
573531
export default Code
574532
export type { CodeProps }

src/components/Mermaid.tsx

Lines changed: 54 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,17 @@ import {
66
useLayoutEffect,
77
useState,
88
} from 'react'
9-
import styled from 'styled-components'
9+
import {
10+
CheckIcon,
11+
CopyIcon,
12+
DownloadIcon,
13+
IconFrame,
14+
ReloadIcon,
15+
styledTheme,
16+
} from '..'
17+
import { useCopyText } from './Code'
1018
import Highlight from './Highlight'
19+
import { PanZoomWrapper } from './PanZoomWrapper'
1120

1221
const MERMAID_CDN_URL =
1322
'https://cdn.jsdelivr.net/npm/[email protected]/dist/mermaid.min.js'
@@ -26,14 +35,16 @@ export function Mermaid({
2635
diagram,
2736
setError: setErrorProp,
2837
...props
29-
}: Omit<ComponentPropsWithoutRef<'div'>, 'children'> & {
38+
}: Omit<ComponentPropsWithoutRef<typeof PanZoomWrapper>, 'children'> & {
3039
diagram: string
3140
ref?: Ref<MermaidRefHandle>
3241
setError?: (error: Nullable<Error>) => void
3342
}) {
3443
const [svgStr, setSvgStr] = useState<Nullable<string>>()
3544
const [isLoading, setIsLoading] = useState(true)
3645
const [error, setErrorState] = useState<Nullable<Error>>(null)
46+
const [panZoomKey, setPanZoomKey] = useState(0) // increment to force panzoom wrapper to reset
47+
const { copied, handleCopy } = useCopyText(diagram)
3748

3849
const setError = useCallback(
3950
(error: Nullable<Error>) => {
@@ -99,33 +110,52 @@ export function Mermaid({
99110
async
100111
src={MERMAID_CDN_URL}
101112
/>
102-
<MermaidContainerSC {...props}>
103-
{isLoading && <div>Loading diagram...</div>}
104-
{svgStr && (
105-
<div
106-
dangerouslySetInnerHTML={{ __html: svgStr }}
107-
style={{
108-
display: isLoading ? 'none' : 'block',
109-
width: '100%',
110-
textAlign: 'center',
111-
}}
112-
/>
113+
<PanZoomWrapper
114+
key={panZoomKey}
115+
actionButtons={
116+
<>
117+
<IconFrame
118+
clickable
119+
onClick={() => setPanZoomKey((key) => key + 1)}
120+
icon={<ReloadIcon />}
121+
type="floating"
122+
tooltip="Reset view to original size"
123+
/>
124+
<IconFrame
125+
clickable
126+
onClick={handleCopy}
127+
icon={copied ? <CheckIcon /> : <CopyIcon />}
128+
type="floating"
129+
tooltip="Copy Mermaid code"
130+
/>
131+
<IconFrame
132+
clickable
133+
onClick={() => svgStr && downloadMermaidSvg(svgStr)}
134+
icon={<DownloadIcon />}
135+
type="floating"
136+
tooltip="Download as PNG"
137+
/>
138+
</>
139+
}
140+
{...props}
141+
>
142+
{isLoading ? (
143+
<div css={{ color: styledTheme.colors.grey[950] }}>
144+
Loading diagram...
145+
</div>
146+
) : (
147+
svgStr && (
148+
<div
149+
dangerouslySetInnerHTML={{ __html: svgStr }}
150+
style={{ textAlign: 'center' }}
151+
/>
152+
)
113153
)}
114-
</MermaidContainerSC>
154+
</PanZoomWrapper>
115155
</>
116156
)
117157
}
118158

119-
const MermaidContainerSC = styled.div(({ theme }) => ({
120-
display: 'flex',
121-
justifyContent: 'center',
122-
alignItems: 'center',
123-
padding: theme.spacing.medium,
124-
overflow: 'auto',
125-
width: '100%',
126-
height: '100%',
127-
}))
128-
129159
let initialized = false
130160
const getOrInitializeMermaid = () => {
131161
if (!window.mermaid) return null

0 commit comments

Comments
 (0)