98 lines
3.4 KiB
TypeScript
98 lines
3.4 KiB
TypeScript
// 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
|
|
// 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 } from 'react'
|
|
import clsx from 'clsx'
|
|
import { highlightToHtml } 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
|
|
}
|
|
|
|
/** @returns true only when the host accepted the write. */
|
|
async function writeClipboard(text: string): Promise<boolean> {
|
|
if (navigator.clipboard?.writeText) {
|
|
try {
|
|
await navigator.clipboard.writeText(text)
|
|
return true
|
|
} catch {
|
|
// Denied permissions / iframe policy — do not claim success.
|
|
return false
|
|
}
|
|
}
|
|
// jsdom and older hosts: best-effort execCommand path when present.
|
|
const exec = typeof document.execCommand === 'function'
|
|
? document.execCommand.bind(document)
|
|
: undefined
|
|
if (exec === undefined) return false
|
|
const el = document.createElement('textarea')
|
|
el.value = text
|
|
el.setAttribute('readonly', '')
|
|
el.style.position = 'fixed'
|
|
el.style.left = '-9999px'
|
|
document.body.appendChild(el)
|
|
el.select()
|
|
try {
|
|
return exec('copy')
|
|
} catch {
|
|
return false
|
|
} finally {
|
|
el.remove()
|
|
}
|
|
}
|
|
|
|
export function CodeBlock({ code, lang, className }: CodeBlockProps) {
|
|
const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
|
|
const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang])
|
|
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
|
|
void writeClipboard(text).then((ok) => {
|
|
if (!ok) return
|
|
setCopied(true)
|
|
window.setTimeout(() => setCopied(false), 1000)
|
|
})
|
|
}, [copied, trimmed])
|
|
|
|
const body = html === undefined
|
|
? (
|
|
<pre className={css.plain}><code>{trimmed}</code></pre>
|
|
)
|
|
: (
|
|
// eslint-disable-next-line react/no-danger -- 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 }} />
|
|
)
|
|
|
|
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}>
|
|
{copied ? '复制成功' : '复制'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{body}
|
|
</div>
|
|
)
|
|
}
|