Files
superwork/vscode/webview-ui/src/codeReader/CodeEditor.tsx
T

138 lines
4.5 KiB
TypeScript

import type { ChangeEvent, KeyboardEvent, ReactElement } from 'react'
import type { Segment } 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
/** 编辑框要的那一段源码:整块,或格子里单独打开的一段,字段一样。 */
export interface EditableSource {
startLine: number
endLine: number
code: string
codeTokens: Segment[]
}
interface CodeEditorProps {
source: EditableSource
/** 文档版本:每来一份新文档都要对一次输入框内容,哪怕这一段的代码没变 */
version: number
autoFocus: boolean
onAutoFocused: () => void
onExit: () => void
}
/** 一块源码的编辑状态:着色层显示,透明输入框接收输入,改动防抖后整行替换回文件。 */
export function CodeEditor({ source, version, autoFocus, onAutoFocused, onExit }: CodeEditorProps): ReactElement {
const [text, setText] = useState(source.code)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const sourceRef = useRef(source)
sourceRef.current = source
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 } = sourceRef.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 !== source.code)
return
unconfirmed.current = null
sentLineCount.current = null
if (source.code === textRef.current)
return
if (focused)
restoreSelection.current = [el.selectionStart, el.selectionEnd]
setText(source.code)
}, [version, source.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(source.codeTokens, source.code, text)} />
{text === '' || text.endsWith('\n') ? ' ' : ''}
</pre>
</div>
)
}