Files
deepseek-harness/packages/client/ui-primitives/src/markdown/CodeBlock.tsx
T

75 lines
3.2 KiB
TypeScript
Raw Normal View History

// CodeBlock: one code surface for every consumer — markdown fences, the
// run_code program body, and the details panel's raw args/output — with
// shiki highlighting for the registered grammars and an identical-geometry
2026-07-27 12:02:28 +08:00
// plain fallback for everything else. Chrome (language banner + copy) matches
// deepsuite `@deepseek/md` code blocks; token colors stay on `--shiki-*`.
import { useCallback, useMemo, useRef, useState, useSyncExternalStore } from 'react'
import clsx from 'clsx'
import { writeClipboard } from '../clipboard.ts'
import { grammarLoadCount, highlightToHtml, subscribeGrammarLoaded } from './highlight.ts'
import css from './CodeBlock.module.css'
export interface CodeBlockProps {
/** The source text, rendered verbatim (trailing newline trimmed for display). */
code: string
/** Grammar hint (markdown fence info string or a fixed caller id); unknown = plain. */
lang?: string | undefined
/** Extra class merged onto the wrapper (callers position; this component draws). */
className?: string | undefined
2026-07-30 15:56:41 +08:00
/** Copy-button idle label; the owner passes localized copy (this package is cordis-free, so copy arrives via props). */
copyLabel?: string | undefined
/** Copy-button label during the post-copy confirmation window. */
copiedLabel?: string | undefined
}
2026-07-30 15:56:41 +08:00
export function CodeBlock({ code, lang, className, copyLabel = '复制', copiedLabel = '复制成功' }: CodeBlockProps) {
const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
// Re-render when a lazy grammar finishes loading, so a fence that showed plain
// text while its language's grammar imported picks up highlighting. The
// snapshot value is opaque; only its change across renders drives the memo.
const loaded = useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount, grammarLoadCount)
const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang, loaded])
2026-07-27 12:02:28 +08:00
const rootRef = useRef<HTMLDivElement>(null)
const [copied, setCopied] = useState(false)
const onCopy = useCallback(() => {
if (copied) return
/* v8 ignore next -- both arms always mount a <pre>; trimmed is the
typed fallback if the DOM shape ever diverges. */
const text = rootRef.current?.querySelector('pre')?.textContent ?? trimmed
2026-07-27 12:58:20 +08:00
void writeClipboard(text).then((ok) => {
if (!ok) return
setCopied(true)
window.setTimeout(() => { setCopied(false) }, 1000)
2026-07-27 12:58:20 +08:00
})
2026-07-27 12:02:28 +08:00
}, [copied, trimmed])
const body = html === undefined
? (
<pre className={css.plain}><code>{trimmed}</code></pre>
)
2026-07-27 12:02:28 +08:00
: (
// shiki's output is a static span tree it generated from `code` (no user
// HTML passes through), the sanctioned innerHTML consumption path per
// shiki's own docs.
<div dangerouslySetInnerHTML={{ __html: html }} />
)
2026-07-27 12:02:28 +08:00
return (
<div ref={rootRef} className={clsx(css.block, 'md-code-block', className)}>
<div className={css.bannerWrap}>
<div className={css.banner}>
<div className={css.infostring}>{lang ?? ''}</div>
<div className={css.action}>
<button type="button" className={css.copyButton} onClick={onCopy}>
2026-07-30 15:56:41 +08:00
{copied ? copiedLabel : copyLabel}
2026-07-27 12:02:28 +08:00
</button>
</div>
</div>
</div>
2026-07-27 12:02:28 +08:00
{body}
</div>
)
}