diff --git a/src/components/modules/shared/CodeBlock.tsx b/src/components/modules/shared/CodeBlock.tsx index 2bae2233a7..faa3ca29be 100644 --- a/src/components/modules/shared/CodeBlock.tsx +++ b/src/components/modules/shared/CodeBlock.tsx @@ -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' @@ -32,6 +33,7 @@ const ExcalidrawLazy = ({ data }: any) => { } let shikiImport: ComponentType +let mermaidImport: ComponentType export const CodeBlockRender = (props: { lang: string | undefined content: string @@ -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 } case 'excalidraw': { @@ -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 + return ( + +
+                    {nextProps.content}
+                  
+ + } + > + +
+ ) } return @@ -83,3 +104,38 @@ export const CodeBlockRender = (props: { ) } + +/** + * 格式化代码:去除多余的缩进。 + 多余的缩进:如果所有代码行中,开头都包括 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') +} diff --git a/src/components/ui/code-highlighter/shiki/Shiki.module.css b/src/components/ui/code-highlighter/shiki/Shiki.module.css index 42545f94f7..833cd2f4b2 100644 --- a/src/components/ui/code-highlighter/shiki/Shiki.module.css +++ b/src/components/ui/code-highlighter/shiki/Shiki.module.css @@ -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, diff --git a/src/components/ui/code-highlighter/shiki/Shiki.tsx b/src/components/ui/code-highlighter/shiki/Shiki.tsx index abab5be9bb..19b9366f9a 100644 --- a/src/components/ui/code-highlighter/shiki/Shiki.tsx +++ b/src/components/ui/code-highlighter/shiki/Shiki.tsx @@ -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 @@ -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) => { 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(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(null) useEffect(() => { setRenderedHtml(highlightedHtml) requestAnimationFrame(() => { @@ -132,100 +79,11 @@ export const ShikiHighLighter: FC = (props) => { }) }, [codeBlockRef, highlightedHtml]) - const filename = useMemo(() => { - return parseFilenameFromAttrs(attrs || '') - }, [attrs]) - const [, maskClassName] = useMaskScrollArea({ - element: codeBlockRef!, - size: 'lg', - }) - - const hasHeader = !!filename - return ( -
- {!!filename && ( -
- {filename} - - {language?.toUpperCase()} - -
- )} - - {!filename && !!language && ( -
- {language.toUpperCase()} -
- )} -
- - - - -
- {renderedHtml ? undefined : ( -
-                {value}
-              
- )} -
- - {isOverflow && isCollapsed && ( -
- -
- )} -
-
-
+ ) } diff --git a/src/components/ui/code-highlighter/shiki/ShikiWrapper.tsx b/src/components/ui/code-highlighter/shiki/ShikiWrapper.tsx new file mode 100644 index 0000000000..4830b41381 --- /dev/null +++ b/src/components/ui/code-highlighter/shiki/ShikiWrapper.tsx @@ -0,0 +1,161 @@ +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useState, +} from 'react' +import clsx from 'clsx' +import type { PropsWithChildren } from 'react' + +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 { parseFilenameFromAttrs } from './utils' + +interface Props { + lang: string | undefined + content: string + + attrs?: string + renderedHTML?: string +} + +export const ShikiHighLighterWrapper = forwardRef< + HTMLDivElement, + PropsWithChildren +>((props, ref) => { + const { lang: language, content: value, attrs } = props + + const handleCopy = useCallback(() => { + navigator.clipboard.writeText(value) + toast.success('已复制到剪贴板') + }, [value]) + + const [codeBlockRef, setCodeBlockRef] = useState(null) + + useImperativeHandle(ref, () => codeBlockRef!) + + 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 filename = useMemo(() => { + return parseFilenameFromAttrs(attrs || '') + }, [attrs]) + const [, maskClassName] = useMaskScrollArea({ + element: codeBlockRef!, + size: 'lg', + }) + + const hasHeader = !!filename + + return ( +
+ {!!filename && ( +
+ {filename} + + {language?.toUpperCase()} + +
+ )} + + {!filename && !!language && ( +
+ {language.toUpperCase()} +
+ )} +
+ + + + +
+ {props.children} +
+ + {isOverflow && isCollapsed && ( +
+ +
+ )} +
+
+
+ ) +}) + +ShikiHighLighterWrapper.displayName = 'ShikiHighLighterWrapper' diff --git a/src/components/ui/markdown/Markdown.tsx b/src/components/ui/markdown/Markdown.tsx index 1371e70786..35915ff57b 100644 --- a/src/components/ui/markdown/Markdown.tsx +++ b/src/components/ui/markdown/Markdown.tsx @@ -4,11 +4,11 @@ import React, { Fragment, memo, Suspense, useMemo, useRef } from 'react' import { clsx } from 'clsx' import { compiler, sanitizeUrl } from 'markdown-to-jsx' -import dynamic from 'next/dynamic' import Script from 'next/script' import type { MarkdownToJSX } from 'markdown-to-jsx' import type { FC, PropsWithChildren } from 'react' +import { CodeBlockRender } from '~/components/modules/shared/CodeBlock' import { FloatPopover } from '~/components/ui/float-popover' import { MAIN_MARKDOWN_ID } from '~/constants/dom-id' import { isDev } from '~/lib/env' @@ -43,12 +43,6 @@ import { MTag } from './renderers/tag' import { getFootNoteDomId, getFootNoteRefDomId } from './utils/get-id' import { redHighlight } from './utils/redHighlight' -const CodeBlock = dynamic(() => - import('~/components/modules/shared/CodeBlock').then( - (mod) => mod.CodeBlockRender, - ), -) - export interface MdProps { value?: string @@ -95,6 +89,7 @@ export const Markdown: FC = if (typeof mdContent != 'string') return null const mdElement = compiler(mdContent, { + doNotProcessHtmlElements: ['tab', 'style', 'script'] as any[], wrapper: null, // @ts-ignore overrides: { @@ -112,7 +107,8 @@ export const Markdown: FC = tag: MTag, Tabs, - Tab, + + tab: Tab, // for custom react component // Tag: MTag, @@ -239,11 +235,16 @@ export const Markdown: FC = } }, }, - + // htmlBlock: { + // react(node, output, state) { + // console.log(node, state) + // return null + // }, + // }, codeBlock: { react(node, output, state) { return ( - void }>(null!) @@ -82,5 +84,11 @@ export const Tab: FC<{ return addTab(label) }, []) - return {children} + return ( + + + {children as string} + + + ) }