✨ feat(vscode): 代码阅读预览界面

Vite 加 code-reader.html 第二入口;代码块树(层级竖线、折叠、种类标签配色)、译文 / 源码按词性着色、块内直接编辑(防抖回写整行、Esc 回预览)、翻译下拉、解析失败提示;看板与代码阅读共用带类型的 acquireVsCodeApi 包装和 ErrorBoundary。
This commit is contained in:
cruldra committed 2026-09-24 21:01:34 +08:00
1 parent cab5ac0ebc
commit af56985d9a
16 files changed
+775 -74

No files matched your search

@@ -0,0 +1,129 @@
import type { ChangeEvent, KeyboardEvent, ReactElement } from 'react'
import type { BlockView } from './types'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { recolor } from './segments'
import { SegmentText } from './SegmentText'
import { postMessage } from './vscode'
const EDIT_DEBOUNCE_MS = 200
interface CodeEditorProps {
block: BlockView
/** 文档版本:每来一份新文档都要对一次输入框内容,哪怕这一块的代码没变 */
version: number
autoFocus: boolean
onAutoFocused: () => void
onExit: () => void
}
/** 一块源码的编辑状态:着色层显示,透明输入框接收输入,改动防抖后整行替换回文件。 */
export function CodeEditor({ block, version, autoFocus, onAutoFocused, onExit }: CodeEditorProps): ReactElement {
const [text, setText] = useState(block.code)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const blockRef = useRef(block)
blockRef.current = block
const textRef = useRef(text)
textRef.current = text
/** 本地改过、还没在新文档里看到的文字;有它时新文档不能覆盖有焦点的输入框 */
const unconfirmed = useRef<string | null>(null)
/** 最近发出的文字在文件里占几行:新文档到之前,下一次替换要盖住这么多行,而不是旧文档里的行数 */
const sentLineCount = useRef<number | null>(null)
const timer = useRef<number | undefined>(undefined)
const unsent = useRef(false)
const restoreSelection = useRef<[number, number] | null>(null)
function flush(): void {
window.clearTimeout(timer.current)
if (!unsent.current)
return
unsent.current = false
const { startLine, endLine } = blockRef.current
const value = textRef.current
postMessage({
type: 'edit',
startLine,
endLine: sentLineCount.current === null ? endLine : startLine + sentLineCount.current - 1,
text: value,
})
sentLineCount.current = value.split('\n').length
}
useEffect(() => {
const el = textareaRef.current
const focused = el !== null && document.activeElement === el
if (focused && unconfirmed.current !== null && unconfirmed.current !== block.code)
return
unconfirmed.current = null
sentLineCount.current = null
if (block.code === textRef.current)
return
if (focused)
restoreSelection.current = [el.selectionStart, el.selectionEnd]
setText(block.code)
}, [version, block.code])
useLayoutEffect(() => {
const el = textareaRef.current
const selection = restoreSelection.current
if (!el || !selection)
return
restoreSelection.current = null
el.setSelectionRange(Math.min(selection[0], text.length), Math.min(selection[1], text.length))
}, [text])
useEffect(() => {
const el = textareaRef.current
if (!autoFocus || !el)
return
el.focus()
el.setSelectionRange(el.value.length, el.value.length)
onAutoFocused()
}, [autoFocus, onAutoFocused])
// 卸载(切回预览、块消失)前把没发出去的改动发掉
useEffect(() => flush, [])
function onChange(e: ChangeEvent<HTMLTextAreaElement>): void {
const value = e.target.value
setText(value)
unconfirmed.current = value
unsent.current = true
window.clearTimeout(timer.current)
timer.current = window.setTimeout(flush, EDIT_DEBOUNCE_MS)
}
function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>): void {
if (e.key === 'Escape') {
e.preventDefault()
flush()
onExit()
}
// ⌘S 由 VS Code 接管保存:先把还在防抖里的改动发出去,免得存的是旧内容
else if (e.key === 's' && (e.metaKey || e.ctrlKey)) {
flush()
}
}
return (
<div className="cr-editor">
<textarea
ref={textareaRef}
className="cr-code"
value={text}
rows={1}
spellCheck={false}
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
aria-label="源码"
onChange={onChange}
onKeyDown={onKeyDown}
onBlur={flush}
/>
<pre className="cr-code" aria-hidden="true">
<SegmentText segments={recolor(block.codeTokens, block.code, text)} />
{text === '' || text.endsWith('\n') ? ' ' : ''}
</pre>
</div>
)
}