Skip to content

Commit 0bff0db

Browse files
committed
fix: shiki dynamic render
Signed-off-by: Innei <i@innei.in>
1 parent 48897f2 commit 0bff0db

6 files changed

Lines changed: 290 additions & 201 deletions

File tree

src/components/modules/shared/CodeBlock.tsx

Lines changed: 62 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import dynamic from 'next/dynamic'
55
import type { ReactNode } from 'react'
66

77
import { HighLighterPrismCdn } from '~/components/ui/code-highlighter'
8+
import { ShikiHighLighterWrapper } from '~/components/ui/code-highlighter/shiki/ShikiWrapper'
89
import { isSupportedShikiLang } from '~/components/ui/code-highlighter/shiki/utils'
910
import { ExcalidrawLoading } from '~/components/ui/excalidraw/ExcalidrawLoading'
1011
import { isClientSide } from '~/lib/env'
@@ -32,6 +33,7 @@ const ExcalidrawLazy = ({ data }: any) => {
3233
}
3334

3435
let shikiImport: ComponentType<any>
36+
let mermaidImport: ComponentType<any>
3537
export const CodeBlockRender = (props: {
3638
lang: string | undefined
3739
content: string
@@ -41,9 +43,12 @@ export const CodeBlockRender = (props: {
4143
const Content = useMemo(() => {
4244
switch (props.lang) {
4345
case 'mermaid': {
44-
const Mermaid = dynamic(() =>
45-
import('./Mermaid').then((mod) => mod.Mermaid),
46-
)
46+
const Mermaid =
47+
mermaidImport ??
48+
dynamic(() => import('./Mermaid').then((mod) => mod.Mermaid))
49+
if (isClientSide) {
50+
mermaidImport = Mermaid
51+
}
4752
return <Mermaid {...props} />
4853
}
4954
case 'excalidraw': {
@@ -58,18 +63,34 @@ export const CodeBlockRender = (props: {
5863
}
5964
default: {
6065
const lang = props.lang
66+
const nextProps = { ...props }
67+
nextProps.content = formatCode(props.content)
6168
if (lang && isSupportedShikiLang(lang)) {
6269
const ShikiHighLighter =
6370
shikiImport ??
64-
dynamic(() =>
71+
lazy(() =>
6572
import('~/components/ui/code-highlighter/shiki/Shiki').then(
66-
(mod) => mod.ShikiHighLighter,
73+
(mod) => ({
74+
default: mod.ShikiHighLighter,
75+
}),
6776
),
6877
)
6978
if (isClientSide) {
7079
shikiImport = ShikiHighLighter
7180
}
72-
return <ShikiHighLighter {...props} />
81+
return (
82+
<Suspense
83+
fallback={
84+
<ShikiHighLighterWrapper {...nextProps}>
85+
<pre className="bg-transparent px-5">
86+
<code className="!px-5">{nextProps.content}</code>
87+
</pre>
88+
</ShikiHighLighterWrapper>
89+
}
90+
>
91+
<ShikiHighLighter {...nextProps} />
92+
</Suspense>
93+
)
7394
}
7495

7596
return <HighLighterPrismCdn {...props} />
@@ -83,3 +104,38 @@ export const CodeBlockRender = (props: {
83104
</Suspense>
84105
)
85106
}
107+
108+
/**
109+
* 格式化代码:去除多余的缩进。
110+
多余的缩进:如果所有代码行中,开头都包括 n 个空格,那么开头的空格是多余的
111+
*
112+
*/
113+
function formatCode(code: string): string {
114+
const lines = code.split('\n')
115+
116+
// 计算最小的共同缩进(忽略空行)
117+
let minIndent = Number.MAX_SAFE_INTEGER
118+
lines.forEach((line) => {
119+
if (line.trim().length > 0) {
120+
// 忽略纯空格行
121+
const leadingSpaces = line.match(/^ */)?.[0].length
122+
if (leadingSpaces === undefined) return
123+
minIndent = Math.min(minIndent, leadingSpaces)
124+
}
125+
})
126+
127+
// 如果所有行都不包含空格或者只有空行,则不做处理
128+
if (minIndent === Number.MAX_SAFE_INTEGER) return code
129+
130+
// 移除每行的共同最小缩进
131+
const formattedLines = lines.map((line) => {
132+
if (line.trim().length === 0) {
133+
// 如果是空行,则直接返回,避免移除空行的非空格字符(例如\t)
134+
return line
135+
} else {
136+
return line.substring(minIndent)
137+
}
138+
})
139+
140+
return formattedLines.join('\n')
141+
}

src/components/ui/code-highlighter/shiki/Shiki.module.css

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,16 @@
1717
}
1818

1919
.line {
20-
@apply block min-h-[1em] px-5;
20+
@apply block px-5;
2121

2222
& > span:last-child {
2323
@apply mr-5;
2424
}
25+
26+
/* 撑开没有内容的行 */
27+
&::after {
28+
content: ' ';
29+
}
2530
}
2631

2732
.highlighted,
Lines changed: 41 additions & 183 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,10 @@
1-
import {
2-
useCallback,
3-
useEffect,
4-
useLayoutEffect,
5-
useMemo,
6-
useState,
7-
} from 'react'
8-
import clsx from 'clsx'
1+
import { useEffect, useMemo, useState } from 'react'
92
import { getHighlighterCore } from 'shiki'
103
import getWasm from 'shiki/wasm'
114
import type { FC } from 'react'
12-
import type { HighlighterCore } from 'shiki'
135

14-
import { getViewport } from '~/atoms/hooks'
15-
import { AutoResizeHeight } from '~/components/modules/shared/AutoResizeHeight'
16-
import { useMaskScrollArea } from '~/hooks/shared/use-mask-scrollarea'
17-
import { stopPropagation } from '~/lib/dom'
18-
import { clsxm } from '~/lib/helper'
19-
import { toast } from '~/lib/toast'
20-
21-
import { MotionButtonBase } from '../../button'
22-
import styles from './Shiki.module.css'
23-
import { codeHighlighter, parseFilenameFromAttrs } from './utils'
6+
import { ShikiHighLighterWrapper } from './ShikiWrapper'
7+
import { codeHighlighter } from './utils'
248

259
interface Props {
2610
lang: string | undefined
@@ -29,88 +13,51 @@ interface Props {
2913
attrs?: string
3014
}
3115

32-
let highlighterCore: HighlighterCore | null = null
16+
const highlighterCore = await (async () => {
17+
const loaded = await getHighlighterCore({
18+
themes: [
19+
import('shiki/themes/github-light.mjs'),
20+
import('shiki/themes/github-dark.mjs'),
21+
],
22+
langs: [
23+
() => import('shiki/langs/javascript.mjs'),
24+
() => import('shiki/langs/typescript.mjs'),
25+
() => import('shiki/langs/css.mjs'),
26+
() => import('shiki/langs/tsx.mjs'),
27+
() => import('shiki/langs/jsx.mjs'),
28+
() => import('shiki/langs/json.mjs'),
29+
() => import('shiki/langs/sql.mjs'),
30+
() => import('shiki/langs/rust.mjs'),
31+
() => import('shiki/langs/go.mjs'),
32+
() => import('shiki/langs/cpp.mjs'),
33+
() => import('shiki/langs/c.mjs'),
34+
() => import('shiki/langs/markdown.mjs'),
35+
() => import('shiki/langs/vue.mjs'),
36+
() => import('shiki/langs/html.mjs'),
37+
() => import('shiki/langs/asm.mjs'),
38+
() => import('shiki/langs/shell.mjs'),
39+
() => import('shiki/langs/ps.mjs'),
40+
],
41+
loadWasm: getWasm,
42+
})
43+
44+
return loaded
45+
})()
3346

3447
export const ShikiHighLighter: FC<Props> = (props) => {
3548
const { lang: language, content: value, attrs } = props
3649

37-
const handleCopy = useCallback(() => {
38-
navigator.clipboard.writeText(value)
39-
toast.success('已复制到剪贴板')
40-
}, [value])
41-
42-
const [highlighter, setHighlighter] = useState(highlighterCore)
43-
44-
useLayoutEffect(() => {
45-
if (highlighterCore) {
46-
return
47-
}
48-
;(async () => {
49-
const loaded = await getHighlighterCore({
50-
themes: [
51-
import('shiki/themes/github-light.mjs'),
52-
import('shiki/themes/github-dark.mjs'),
53-
],
54-
langs: [
55-
() => import('shiki/langs/javascript.mjs'),
56-
() => import('shiki/langs/typescript.mjs'),
57-
() => import('shiki/langs/css.mjs'),
58-
() => import('shiki/langs/tsx.mjs'),
59-
() => import('shiki/langs/jsx.mjs'),
60-
() => import('shiki/langs/json.mjs'),
61-
() => import('shiki/langs/sql.mjs'),
62-
() => import('shiki/langs/rust.mjs'),
63-
() => import('shiki/langs/go.mjs'),
64-
() => import('shiki/langs/cpp.mjs'),
65-
() => import('shiki/langs/c.mjs'),
66-
() => import('shiki/langs/markdown.mjs'),
67-
() => import('shiki/langs/vue.mjs'),
68-
() => import('shiki/langs/html.mjs'),
69-
() => import('shiki/langs/asm.mjs'),
70-
() => import('shiki/langs/shell.mjs'),
71-
() => import('shiki/langs/ps.mjs'),
72-
],
73-
loadWasm: getWasm,
74-
})
75-
setHighlighter(loaded)
76-
highlighterCore = loaded
77-
})()
78-
}, [])
79-
80-
const [codeBlockRef, setCodeBlockRef] = useState<HTMLDivElement | null>(null)
81-
82-
const [isCollapsed, setIsCollapsed] = useState(true)
83-
const [isOverflow, setIsOverflow] = useState(false)
84-
useEffect(() => {
85-
const $el = codeBlockRef
86-
87-
if (!$el) return
88-
89-
const windowHeight = getViewport().h
90-
const halfWindowHeight = windowHeight / 2
91-
const $elScrollHeight = $el.scrollHeight
92-
if ($elScrollHeight >= halfWindowHeight) {
93-
setIsOverflow(true)
94-
95-
$el.querySelector('.highlighted')?.scrollIntoView({
96-
block: 'center',
97-
})
98-
} else {
99-
setIsOverflow(false)
100-
}
101-
}, [value, codeBlockRef])
102-
10350
const highlightedHtml = useMemo(() => {
104-
if (!highlighter) return ''
105-
return codeHighlighter(highlighter, {
51+
return codeHighlighter(highlighterCore, {
10652
attrs: attrs || '',
10753
// code: `${value.split('\n')[0].repeat(10)} // [!code highlight]\n${value}`,
10854
code: value,
10955
lang: language ? language.toLowerCase() : '',
11056
})
111-
}, [attrs, language, value, highlighter])
57+
}, [attrs, language, value])
11258

11359
const [renderedHtml, setRenderedHtml] = useState(highlightedHtml)
60+
const [codeBlockRef, setCodeBlockRef] = useState<HTMLDivElement | null>(null)
11461
useEffect(() => {
11562
setRenderedHtml(highlightedHtml)
11663
requestAnimationFrame(() => {
@@ -132,100 +79,11 @@ export const ShikiHighLighter: FC<Props> = (props) => {
13279
})
13380
}, [codeBlockRef, highlightedHtml])
13481

135-
const filename = useMemo(() => {
136-
return parseFilenameFromAttrs(attrs || '')
137-
}, [attrs])
138-
const [, maskClassName] = useMaskScrollArea({
139-
element: codeBlockRef!,
140-
size: 'lg',
141-
})
142-
143-
const hasHeader = !!filename
144-
14582
return (
146-
<div
147-
className={clsx(styles['code-card'], 'group')}
148-
onCopy={stopPropagation}
149-
>
150-
{!!filename && (
151-
<div className="z-10 flex w-full items-center justify-between rounded-t-xl bg-accent/20 px-5 py-2 text-sm">
152-
<span className="shrink-0 grow truncate">{filename}</span>
153-
<span className="pointer-events-none shrink-0 grow-0" aria-hidden>
154-
{language?.toUpperCase()}
155-
</span>
156-
</div>
157-
)}
158-
159-
{!filename && !!language && (
160-
<div
161-
aria-hidden
162-
className="pointer-events-none absolute bottom-3 right-3 z-10 text-sm opacity-60"
163-
>
164-
{language.toUpperCase()}
165-
</div>
166-
)}
167-
<div className="bg-accent/5 py-4">
168-
<MotionButtonBase
169-
onClick={handleCopy}
170-
className={clsx(
171-
'absolute right-2 top-2 z-[1] flex text-xs center',
172-
'rounded-md border border-accent/5 bg-accent/80 p-1.5 text-white backdrop-blur duration-200',
173-
'opacity-0 group-hover:opacity-100',
174-
filename && '!top-12',
175-
)}
176-
>
177-
<i className="icon-[mingcute--copy-2-fill] size-4" />
178-
</MotionButtonBase>
179-
<AutoResizeHeight spring className="relative">
180-
<div
181-
ref={setCodeBlockRef}
182-
className={clsxm(
183-
'relative max-h-[50vh] w-full overflow-auto',
184-
!isCollapsed ? '!max-h-full' : isOverflow ? maskClassName : '',
185-
styles['scroll-container'],
186-
)}
187-
style={
188-
{
189-
'--sr-margin': !hasHeader
190-
? `${(language?.length || 0) * 14 + 4}px`
191-
: '1rem',
192-
} as any
193-
}
194-
dangerouslySetInnerHTML={
195-
renderedHtml
196-
? {
197-
__html: renderedHtml,
198-
}
199-
: undefined
200-
}
201-
>
202-
{renderedHtml ? undefined : (
203-
<pre className="bg-transparent px-5">
204-
<code className="!px-5">{value}</code>
205-
</pre>
206-
)}
207-
</div>
208-
209-
{isOverflow && isCollapsed && (
210-
<div
211-
className={`absolute inset-x-0 bottom-0 flex justify-center py-2 duration-200 ${
212-
['mask-both-lg', 'mask-b-lg'].includes(maskClassName)
213-
? ''
214-
: 'pointer-events-none opacity-0'
215-
}`}
216-
>
217-
<button
218-
onClick={() => setIsCollapsed(false)}
219-
aria-hidden
220-
className="flex items-center justify-center text-xs"
221-
>
222-
<i className="icon-[mingcute--arrow-to-down-line]" />
223-
<span className="ml-2">展开</span>
224-
</button>
225-
</div>
226-
)}
227-
</AutoResizeHeight>
228-
</div>
229-
</div>
83+
<ShikiHighLighterWrapper
84+
{...props}
85+
renderedHTML={renderedHtml}
86+
ref={setCodeBlockRef}
87+
/>
23088
)
23189
}

0 commit comments

Comments
 (0)