diff --git a/vscode/webview-ui/code-reader.html b/vscode/webview-ui/code-reader.html new file mode 100644 index 0000000..11be2d8 --- /dev/null +++ b/vscode/webview-ui/code-reader.html @@ -0,0 +1,12 @@ + + + + + + 代码阅读 + + +
+ + + diff --git a/vscode/webview-ui/src/codeReader/App.tsx b/vscode/webview-ui/src/codeReader/App.tsx new file mode 100644 index 0000000..f1353a7 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/App.tsx @@ -0,0 +1,81 @@ +import type { ReactElement } from 'react' +import type { BlockTreeState } from './BlockTree' +import type { DocumentMessage } from './types' +import type { ViewState } from './vscode' +import { TriangleAlert } from 'lucide-react' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { SelectMenu } from '@/components/ui/select-menu' +import { BlockLevel } from './BlockTree' +import { loadViewState, onMessage, postMessage, saveViewState } from './vscode' + +function toggled(ids: string[], id: string): string[] { + return ids.includes(id) ? ids.filter(x => x !== id) : [...ids, id] +} + +export function App(): ReactElement | null { + const [doc, setDoc] = useState(null) + const [view, setView] = useState(loadViewState) + const [focusId, setFocusId] = useState(null) + + useEffect(() => { + const off = onMessage((msg) => { + if (msg.type === 'document') + setDoc(msg) + }) + postMessage({ type: 'ready' }) + return off + }, []) + + useEffect(() => { + saveViewState(view) + }, [view]) + + const onFocused = useCallback(() => setFocusId(null), []) + const tree = useMemo((): BlockTreeState | null => doc && { + version: doc.version, + editing: new Set(view.editing), + collapsed: new Set(view.collapsed), + focusId, + onFocused, + onToggleEdit: (id) => { + setFocusId(view.editing.includes(id) ? null : id) + setView(prev => ({ ...prev, editing: toggled(prev.editing, id) })) + }, + onToggleFold: id => setView(prev => ({ ...prev, collapsed: toggled(prev.collapsed, id) })), + }, [doc, view, focusId, onFocused]) + + if (!doc || !tree) + return null + + return ( +
+ {doc.translators.length > 1 && ( +
+ 翻译 + ({ value: t.id, label: t.label }))} + onChange={id => postMessage({ type: 'selectTranslator', id })} + title="选择翻译方式" + className="w-40 text-left" + /> +
+ )} + {doc.error !== undefined && ( +
+ +
+
+ 解析失败: + {doc.error} +
+
点编辑器右上角的「编辑」查看源码。
+
+
+ )} + {doc.blocks.length > 0 + ? + : doc.error === undefined &&
这个文件里没有代码。
} +
+ ) +} diff --git a/vscode/webview-ui/src/codeReader/BlockTree.tsx b/vscode/webview-ui/src/codeReader/BlockTree.tsx new file mode 100644 index 0000000..c2cef73 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/BlockTree.tsx @@ -0,0 +1,118 @@ +import type { ReactElement } from 'react' +import type { BlockKind, BlockView } from './types' +import { ChevronDown, ChevronRight, Eye, Pencil } from 'lucide-react' +import { Fragment } from 'react' +import { cn } from '@/lib/utils' +import { CodeEditor } from './CodeEditor' +import { SegmentText } from './SegmentText' + +const KIND_LABEL: Record = { + import: '导入', + function: '函数', + class: '类', + type: '类型', + field: '字段', + const: '常量', + variable: '变量', + if: '条件', + else: '否则', + loop: '循环', + try: '尝试', + catch: '捕获', + finally: '最终', + return: '返回', + statement: '语句', + comment: '注释', +} + +export interface BlockTreeState { + version: number + editing: ReadonlySet + collapsed: ReadonlySet + /** 刚点了「编辑」、要把光标放进去的块 */ + focusId: string | null + onFocused: () => void + onToggleEdit: (id: string) => void + onToggleFold: (id: string) => void +} + +/** 一层代码块:左边一条竖线标出这一层,每块的子块紧跟在它下面、再往右缩进一层。 */ +export function BlockLevel({ blocks, tree }: { blocks: BlockView[], tree: BlockTreeState }): ReactElement { + return ( +
+
+
+
+
+ {blocks.map(block => ( + + + {block.children.length > 0 && !tree.collapsed.has(block.id) && ( + + )} + + ))} +
+
+ ) +} + +/** 一块:折叠箭头、种类标签、译文或源码、右边的「编辑 / 预览」切换。 */ +function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }): ReactElement { + const editing = tree.editing.has(block.id) + const collapsed = tree.collapsed.has(block.id) + const hasChildren = block.children.length > 0 + const Chevron = collapsed ? ChevronRight : ChevronDown + return ( +
+ {hasChildren + ? ( + + ) + : } + + {KIND_LABEL[block.kind]} + +
+ {editing + ? ( + tree.onToggleEdit(block.id)} + /> + ) + : ( +

+ +

+ )} +
+ +
+ ) +} diff --git a/vscode/webview-ui/src/codeReader/CodeEditor.tsx b/vscode/webview-ui/src/codeReader/CodeEditor.tsx new file mode 100644 index 0000000..5d5143c --- /dev/null +++ b/vscode/webview-ui/src/codeReader/CodeEditor.tsx @@ -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(null) + const blockRef = useRef(block) + blockRef.current = block + const textRef = useRef(text) + textRef.current = text + /** 本地改过、还没在新文档里看到的文字;有它时新文档不能覆盖有焦点的输入框 */ + const unconfirmed = useRef(null) + /** 最近发出的文字在文件里占几行:新文档到之前,下一次替换要盖住这么多行,而不是旧文档里的行数 */ + const sentLineCount = useRef(null) + const timer = useRef(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): 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): void { + if (e.key === 'Escape') { + e.preventDefault() + flush() + onExit() + } + // ⌘S 由 VS Code 接管保存:先把还在防抖里的改动发出去,免得存的是旧内容 + else if (e.key === 's' && (e.metaKey || e.ctrlKey)) { + flush() + } + } + + return ( +
+