Skip to content

Commit

Permalink
fix: shiki dynamic render
Browse files Browse the repository at this point in the history
Signed-off-by: Innei <i@innei.in>
  • Loading branch information
Innei committed Apr 8, 2024
1 parent 48897f2 commit 0bff0db
Show file tree
Hide file tree
Showing 6 changed files with 290 additions and 201 deletions.
68 changes: 62 additions & 6 deletions src/components/modules/shared/CodeBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import dynamic from 'next/dynamic'
import type { ReactNode } from 'react'

import { HighLighterPrismCdn } from '~/components/ui/code-highlighter'
import { ShikiHighLighterWrapper } from '~/components/ui/code-highlighter/shiki/ShikiWrapper'
import { isSupportedShikiLang } from '~/components/ui/code-highlighter/shiki/utils'
import { ExcalidrawLoading } from '~/components/ui/excalidraw/ExcalidrawLoading'
import { isClientSide } from '~/lib/env'
Expand Down Expand Up @@ -32,6 +33,7 @@ const ExcalidrawLazy = ({ data }: any) => {
}

let shikiImport: ComponentType<any>
let mermaidImport: ComponentType<any>
export const CodeBlockRender = (props: {
lang: string | undefined
content: string
Expand All @@ -41,9 +43,12 @@ export const CodeBlockRender = (props: {
const Content = useMemo(() => {
switch (props.lang) {
case 'mermaid': {
const Mermaid = dynamic(() =>
import('./Mermaid').then((mod) => mod.Mermaid),
)
const Mermaid =
mermaidImport ??
dynamic(() => import('./Mermaid').then((mod) => mod.Mermaid))
if (isClientSide) {
mermaidImport = Mermaid
}
return <Mermaid {...props} />
}
case 'excalidraw': {
Expand All @@ -58,18 +63,34 @@ export const CodeBlockRender = (props: {
}
default: {
const lang = props.lang
const nextProps = { ...props }
nextProps.content = formatCode(props.content)
if (lang && isSupportedShikiLang(lang)) {
const ShikiHighLighter =
shikiImport ??
dynamic(() =>
lazy(() =>
import('~/components/ui/code-highlighter/shiki/Shiki').then(
(mod) => mod.ShikiHighLighter,
(mod) => ({
default: mod.ShikiHighLighter,
}),
),
)
if (isClientSide) {
shikiImport = ShikiHighLighter
}
return <ShikiHighLighter {...props} />
return (
<Suspense
fallback={
<ShikiHighLighterWrapper {...nextProps}>
<pre className="bg-transparent px-5">
<code className="!px-5">{nextProps.content}</code>
</pre>
</ShikiHighLighterWrapper>
}
>
<ShikiHighLighter {...nextProps} />
</Suspense>
)
}

return <HighLighterPrismCdn {...props} />
Expand All @@ -83,3 +104,38 @@ export const CodeBlockRender = (props: {
</Suspense>
)
}

/**
* 格式化代码:去除多余的缩进。
多余的缩进:如果所有代码行中,开头都包括 n 个空格,那么开头的空格是多余的
*
*/
function formatCode(code: string): string {
const lines = code.split('\n')

// 计算最小的共同缩进(忽略空行)
let minIndent = Number.MAX_SAFE_INTEGER
lines.forEach((line) => {
if (line.trim().length > 0) {
// 忽略纯空格行
const leadingSpaces = line.match(/^ */)?.[0].length
if (leadingSpaces === undefined) return
minIndent = Math.min(minIndent, leadingSpaces)
}
})

// 如果所有行都不包含空格或者只有空行,则不做处理
if (minIndent === Number.MAX_SAFE_INTEGER) return code

// 移除每行的共同最小缩进
const formattedLines = lines.map((line) => {
if (line.trim().length === 0) {
// 如果是空行,则直接返回,避免移除空行的非空格字符(例如\t)
return line
} else {
return line.substring(minIndent)
}
})

return formattedLines.join('\n')
}
7 changes: 6 additions & 1 deletion src/components/ui/code-highlighter/shiki/Shiki.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,16 @@
}

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

& > span:last-child {
@apply mr-5;
}

/* 撑开没有内容的行 */
&::after {
content: ' ';
}
}

.highlighted,
Expand Down
224 changes: 41 additions & 183 deletions src/components/ui/code-highlighter/shiki/Shiki.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,10 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useState,
} from 'react'
import clsx from 'clsx'
import { useEffect, useMemo, useState } from 'react'
import { getHighlighterCore } from 'shiki'
import getWasm from 'shiki/wasm'
import type { FC } from 'react'
import type { HighlighterCore } from 'shiki'

import { getViewport } from '~/atoms/hooks'
import { AutoResizeHeight } from '~/components/modules/shared/AutoResizeHeight'
import { useMaskScrollArea } from '~/hooks/shared/use-mask-scrollarea'
import { stopPropagation } from '~/lib/dom'
import { clsxm } from '~/lib/helper'
import { toast } from '~/lib/toast'

import { MotionButtonBase } from '../../button'
import styles from './Shiki.module.css'
import { codeHighlighter, parseFilenameFromAttrs } from './utils'
import { ShikiHighLighterWrapper } from './ShikiWrapper'
import { codeHighlighter } from './utils'

interface Props {
lang: string | undefined
Expand All @@ -29,88 +13,51 @@ interface Props {
attrs?: string
}

let highlighterCore: HighlighterCore | null = null
const highlighterCore = await (async () => {
const loaded = await getHighlighterCore({
themes: [
import('shiki/themes/github-light.mjs'),
import('shiki/themes/github-dark.mjs'),
],
langs: [
() => import('shiki/langs/javascript.mjs'),
() => import('shiki/langs/typescript.mjs'),
() => import('shiki/langs/css.mjs'),
() => import('shiki/langs/tsx.mjs'),
() => import('shiki/langs/jsx.mjs'),
() => import('shiki/langs/json.mjs'),
() => import('shiki/langs/sql.mjs'),
() => import('shiki/langs/rust.mjs'),
() => import('shiki/langs/go.mjs'),
() => import('shiki/langs/cpp.mjs'),
() => import('shiki/langs/c.mjs'),
() => import('shiki/langs/markdown.mjs'),
() => import('shiki/langs/vue.mjs'),
() => import('shiki/langs/html.mjs'),
() => import('shiki/langs/asm.mjs'),
() => import('shiki/langs/shell.mjs'),
() => import('shiki/langs/ps.mjs'),
],
loadWasm: getWasm,
})

return loaded
})()

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

const handleCopy = useCallback(() => {
navigator.clipboard.writeText(value)
toast.success('已复制到剪贴板')
}, [value])

const [highlighter, setHighlighter] = useState(highlighterCore)

useLayoutEffect(() => {
if (highlighterCore) {
return
}
;(async () => {
const loaded = await getHighlighterCore({
themes: [
import('shiki/themes/github-light.mjs'),
import('shiki/themes/github-dark.mjs'),
],
langs: [
() => import('shiki/langs/javascript.mjs'),
() => import('shiki/langs/typescript.mjs'),
() => import('shiki/langs/css.mjs'),
() => import('shiki/langs/tsx.mjs'),
() => import('shiki/langs/jsx.mjs'),
() => import('shiki/langs/json.mjs'),
() => import('shiki/langs/sql.mjs'),
() => import('shiki/langs/rust.mjs'),
() => import('shiki/langs/go.mjs'),
() => import('shiki/langs/cpp.mjs'),
() => import('shiki/langs/c.mjs'),
() => import('shiki/langs/markdown.mjs'),
() => import('shiki/langs/vue.mjs'),
() => import('shiki/langs/html.mjs'),
() => import('shiki/langs/asm.mjs'),
() => import('shiki/langs/shell.mjs'),
() => import('shiki/langs/ps.mjs'),
],
loadWasm: getWasm,
})
setHighlighter(loaded)
highlighterCore = loaded
})()
}, [])

const [codeBlockRef, setCodeBlockRef] = useState<HTMLDivElement | null>(null)

const [isCollapsed, setIsCollapsed] = useState(true)
const [isOverflow, setIsOverflow] = useState(false)
useEffect(() => {
const $el = codeBlockRef

if (!$el) return

const windowHeight = getViewport().h
const halfWindowHeight = windowHeight / 2
const $elScrollHeight = $el.scrollHeight
if ($elScrollHeight >= halfWindowHeight) {
setIsOverflow(true)

$el.querySelector('.highlighted')?.scrollIntoView({
block: 'center',
})
} else {
setIsOverflow(false)
}
}, [value, codeBlockRef])

const highlightedHtml = useMemo(() => {
if (!highlighter) return ''
return codeHighlighter(highlighter, {
return codeHighlighter(highlighterCore, {
attrs: attrs || '',
// code: `${value.split('\n')[0].repeat(10)} // [!code highlight]\n${value}`,
code: value,
lang: language ? language.toLowerCase() : '',
})
}, [attrs, language, value, highlighter])
}, [attrs, language, value])

const [renderedHtml, setRenderedHtml] = useState(highlightedHtml)
const [codeBlockRef, setCodeBlockRef] = useState<HTMLDivElement | null>(null)
useEffect(() => {
setRenderedHtml(highlightedHtml)
requestAnimationFrame(() => {
Expand All @@ -132,100 +79,11 @@ export const ShikiHighLighter: FC<Props> = (props) => {
})
}, [codeBlockRef, highlightedHtml])

const filename = useMemo(() => {
return parseFilenameFromAttrs(attrs || '')
}, [attrs])
const [, maskClassName] = useMaskScrollArea({
element: codeBlockRef!,
size: 'lg',
})

const hasHeader = !!filename

return (
<div
className={clsx(styles['code-card'], 'group')}
onCopy={stopPropagation}
>
{!!filename && (
<div className="z-10 flex w-full items-center justify-between rounded-t-xl bg-accent/20 px-5 py-2 text-sm">
<span className="shrink-0 grow truncate">{filename}</span>
<span className="pointer-events-none shrink-0 grow-0" aria-hidden>
{language?.toUpperCase()}
</span>
</div>
)}

{!filename && !!language && (
<div
aria-hidden
className="pointer-events-none absolute bottom-3 right-3 z-10 text-sm opacity-60"
>
{language.toUpperCase()}
</div>
)}
<div className="bg-accent/5 py-4">
<MotionButtonBase
onClick={handleCopy}
className={clsx(
'absolute right-2 top-2 z-[1] flex text-xs center',
'rounded-md border border-accent/5 bg-accent/80 p-1.5 text-white backdrop-blur duration-200',
'opacity-0 group-hover:opacity-100',
filename && '!top-12',
)}
>
<i className="icon-[mingcute--copy-2-fill] size-4" />
</MotionButtonBase>
<AutoResizeHeight spring className="relative">
<div
ref={setCodeBlockRef}
className={clsxm(
'relative max-h-[50vh] w-full overflow-auto',
!isCollapsed ? '!max-h-full' : isOverflow ? maskClassName : '',
styles['scroll-container'],
)}
style={
{
'--sr-margin': !hasHeader
? `${(language?.length || 0) * 14 + 4}px`
: '1rem',
} as any
}
dangerouslySetInnerHTML={
renderedHtml
? {
__html: renderedHtml,
}
: undefined
}
>
{renderedHtml ? undefined : (
<pre className="bg-transparent px-5">
<code className="!px-5">{value}</code>
</pre>
)}
</div>

{isOverflow && isCollapsed && (
<div
className={`absolute inset-x-0 bottom-0 flex justify-center py-2 duration-200 ${
['mask-both-lg', 'mask-b-lg'].includes(maskClassName)
? ''
: 'pointer-events-none opacity-0'
}`}
>
<button
onClick={() => setIsCollapsed(false)}
aria-hidden
className="flex items-center justify-center text-xs"
>
<i className="icon-[mingcute--arrow-to-down-line]" />
<span className="ml-2">展开</span>
</button>
</div>
)}
</AutoResizeHeight>
</div>
</div>
<ShikiHighLighterWrapper
{...props}
renderedHTML={renderedHtml}
ref={setCodeBlockRef}
/>
)
}
Loading

0 comments on commit 0bff0db

Please sign in to comment.