* 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]>
213 lines
9.3 KiB
TypeScript
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>
|
|
)
|
|
}
|