Files
deepseek-harness/packages/client/ui-primitives/src/markdown/CodeBlock.tsx
T
lsdsjyandlsdsjy 0e49f443dd feat(ui-conversation,ui-chat): 基于 user-text 支持富文本引用与草稿恢复 (#3206)
* feat(ui-chat,ui-conversation): 通过通用文本交互支持原文引用

* refactor(message-quotes): 插件化引用交互与文本扫描豁免

* test(message-quotes): 补齐插件装配预期与交互分发覆盖

* refactor(ui-conversation): 移除引用删除文案的语言切换刷新

* refactor(message-quotes): 移除引用扫描豁免并收窄文本交互接口

* fix(ui-chat): 修复引用浮层的滚动遮挡与恢复

* fix(ui-chat): 对齐 master 的聊天流注入与界面快照

* test(message-quotes): 补齐 Host 入口覆盖并固定子会话回放时序

* refactor(message-quotes): 清理文本依赖特例并修正引用按钮

* fix(ui-primitives): 让引用原文弹层按内容收缩

* fix(ui-primitives): 修复相邻引用悬停弹层重叠与误切换

* fix(message-quotes): 为引用悬停样式限定设备能力

---------

Co-authored-by: lsdsjy <[email protected]>
2026-10-21 14:41:41 +08:00

213 lines
9.3 KiB
TypeScript

import { Fragment, useCallback, useMemo, useRef, useState, useSyncExternalStore } from 'react'
import type { CSSProperties, ReactNode, Ref } from 'react'
import clsx from 'clsx'
import { writeClipboard } from '../clipboard.ts'
import { CodeToolbar, type CodeToolbarLabels } from '../CodeToolbar.tsx'
import {
StreamingHighlightSession, grammarLoadCount, highlightToHtml, subscribeGrammarLoaded,
} from './highlight.ts'
import type { HighlightSpan, StreamingHighlightFrame } from './highlight.ts'
import { useViewportHighlighting } from './useViewportHighlighting.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
/**
* The code is still growing (a streaming markdown fence): highlight through
* a per-instance {@link StreamingHighlightSession}, which re-tokenizes only
* appended text and keeps completed line groups (and DOM) untouched. The
* caller must keep the component instance stable across growth (a
* stream-stable React key); an unchanged streamed fence also retains that
* tree when it settles. Cold settled callers get shiki's HTML.
*/
streaming?: boolean | undefined
/** Extra class merged onto the wrapper (callers position; this component draws). */
className?: string | undefined
/** Ref for the stable source-content wrapper, for owners that use it as a scrollport. */
contentRef?: Ref<HTMLDivElement> | undefined
/** Show a numbered gutter without adding numbers to copied source. Defaults to false. */
lineNumbers?: boolean | undefined
/** Show the language and copy header; false when the caller supplies a toolbar. Defaults to true. */
showHeader?: boolean | undefined
/** Copy-button idle label; the owner passes localized copy (this package is cordis-free, so copy arrives via props). */
copyLabel: string
/** Copy-button label during the post-copy confirmation window. */
copiedLabel: string
/** Enable the shared card toolbar and spacing; omit for custom toolbar layouts. */
toolbarLabels?: CodeToolbarLabels | undefined
/** With toolbarLabels, use the owner's wrapping preference and omit the toolbar's local wrap action. */
wrap?: boolean | undefined
}
/**
* The `pre` attributes shiki's HTML arm emits for the css-variables theme,
* mirrored so the streaming arm's tree is interchangeable with the settled
* swap (`tests/streaming-code-block.client.spec.tsx` pins the two arms'
* parity).
*/
const SHIKI_PRE_PROPS = {
className: 'shiki css-variables',
style: { backgroundColor: 'var(--shiki-background)', color: 'var(--shiki-foreground)' },
tabIndex: 0,
} as const
/** Completed-line group size; React reconciles groups while the DOM remains line-for-line identical. */
const STREAMING_LINE_GROUP_SIZE = 32
function renderLine(line: readonly HighlightSpan[], index: number): ReactNode {
return (
<Fragment key={index}>
{index > 0 && '\n'}
<span className="line">
{line.map((span, spanIndex) => <span key={spanIndex} style={span.style}>{span.text}</span>)}
</span>
</Fragment>
)
}
export function CodeBlock({
code, lang, streaming, className, contentRef, lineNumbers = false, showHeader = true, copyLabel, copiedLabel, toolbarLabels, wrap,
}: CodeBlockProps) {
const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
const sourceLines = lineNumbers ? trimmed.split('\n') : undefined
const rootRef = useRef<HTMLDivElement>(null)
const highlighting = useViewportHighlighting(rootRef, lang)
// 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)
// Streaming state lives in refs mutated inside the memo (the MarkdownText
// streaming-cache pattern): the session's caches carry across chunks only
// because the owner keys this instance stably while the fence grows.
const sessionRef = useRef<StreamingHighlightSession | null>(null)
const lineCacheRef = useRef<{
code: string
lang: string | undefined
generation: number
frame: StreamingHighlightFrame
groups: ReactNode[]
pending: ReactNode[]
nextLine: number
body: ReactNode
} | null>(null)
const settledRef = useRef(false)
const streamedBody = useMemo(() => {
if (!highlighting) {
sessionRef.current = null
lineCacheRef.current = null
settledRef.current = false
return undefined
}
if (streaming !== true) {
const previous = lineCacheRef.current
if (previous !== null && previous.code === trimmed && previous.lang === lang) {
settledRef.current = true
return previous.body
}
sessionRef.current = null
lineCacheRef.current = null
settledRef.current = true
return undefined
}
if (settledRef.current) {
sessionRef.current = null
lineCacheRef.current = null
settledRef.current = false
}
sessionRef.current ??= new StreamingHighlightSession()
const frame = sessionRef.current.updateFrame(trimmed, lang)
if (frame === undefined) {
lineCacheRef.current = null
return undefined
}
const previous = lineCacheRef.current
if (previous?.frame === frame && previous.code === trimmed && previous.lang === lang) {
return previous.body
}
const sameGeneration = previous?.generation === frame.generation
const groups = sameGeneration ? [...previous.groups] : []
let pending = sameGeneration ? [...previous.pending] : []
let nextLine = sameGeneration ? previous.nextLine : 0
for (const line of frame.appended) {
pending.push(renderLine(line, nextLine))
nextLine += 1
if (pending.length !== STREAMING_LINE_GROUP_SIZE) continue
const start = nextLine - pending.length
groups.push(<Fragment key={start}>{pending}</Fragment>)
pending = []
}
const tail = frame.tail.map((line, index) => renderLine(line, nextLine + index))
const tailGroup = <Fragment key={nextLine - pending.length}>{[...pending, ...tail]}</Fragment>
const body = <pre {...SHIKI_PRE_PROPS}><code>{groups}{tailGroup}</code></pre>
lineCacheRef.current = {
code: trimmed, lang, generation: frame.generation, frame, groups, pending, nextLine, body,
}
return body
}, [streaming, highlighting, trimmed, lang, loaded])
const html = useMemo(
() => (highlighting && streaming !== true && streamedBody === undefined
? highlightToHtml(trimmed, lang)
: undefined),
[streaming, highlighting, streamedBody, trimmed, lang, loaded],
)
const [copied, setCopied] = useState(false)
const [localWrapped, setWrapped] = useState(true)
const wrapped = wrap ?? localWrapped
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])
// shiki's HTML 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.
const body = streamedBody !== undefined
? streamedBody
: html === undefined
? (
<pre className={css.plain}><code>{sourceLines === undefined ? trimmed : sourceLines.map((line, index) => (
<Fragment key={index}>{index > 0 && '\n'}<span className="line">{line}</span></Fragment>
))}</code></pre>
)
: (
<div dangerouslySetInnerHTML={{ __html: html }} />
)
return (
<div ref={rootRef} className={clsx(css.block, 'md-code-block', lineNumbers && css.numbered, toolbarLabels !== undefined && css.card, className)}
data-line-numbers={lineNumbers || undefined}
data-code-wrap={toolbarLabels === undefined ? undefined : wrapped}
style={sourceLines === undefined ? undefined : {
'--dsl-code-block-line-number-width': `${Math.max(2, String(sourceLines.length).length)}ch`,
} as CSSProperties}>
{/* These paired attributes are stable semantic hooks for owner styling and DOM tests. */}
{showHeader && <div className={css.bannerWrap}>
{toolbarLabels !== undefined ? <CodeToolbar
lang={lang} labels={toolbarLabels} copyLabel={copyLabel} copiedLabel={copiedLabel}
copied={copied} wrapped={wrapped} onCopy={onCopy}
onWrap={wrap === undefined ? () => { setWrapped(value => !value) } : undefined}
/> : <div className={css.banner} data-code-block-banner>
<div className={css.infostring} data-text-interaction-exclude="">{lang ?? ''}</div>
<div className={css.action}>
<button type="button" className={css.copyButton} onClick={onCopy}>
{copied ? copiedLabel : copyLabel}
</button>
</div>
</div>}
</div>}
<div ref={contentRef} className={css.content} data-code-block-content>{body}</div>
</div>
)
}