diff --git a/vscode/src/codeReader/editor/messages.ts b/vscode/src/codeReader/editor/messages.ts index ac83bb8..e5b4727 100644 --- a/vscode/src/codeReader/editor/messages.ts +++ b/vscode/src/codeReader/editor/messages.ts @@ -15,12 +15,18 @@ export interface DocumentMessage { translators: TranslatorOption[] translatorId: string error?: string + /** 从文本编辑器切过来时它顶部那一行:新预览只在第一份 document 里带,webview 滚到包含这行的块 */ + revealLine?: number } export type ExtensionToWebview = DocumentMessage -/** edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来的缩进) */ +/** + * edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来的缩进) + * visibleLine:预览视口顶部那块的起始行,切回文本编辑器时把这行放到顶部 + */ export type WebviewToExtension = | { type: 'ready' } | { type: 'edit', startLine: number, endLine: number, text: string } | { type: 'selectTranslator', id: string } + | { type: 'visibleLine', line: number } diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts index bd34061..50431a9 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -1,4 +1,4 @@ -import type { CustomTextEditorProvider, Disposable, TextDocument, Uri, Webview, WebviewPanel } from 'vscode' +import type { CustomTextEditorProvider, Disposable, TextDocument, Uri, WebviewPanel } from 'vscode' import type { CodeReaderRegistry } from '../registry' import type { TreeSitterRuntime } from '../runtime' import type { ExtensionToWebview, WebviewToExtension } from './messages' @@ -19,6 +19,8 @@ const REFRESH_DELAY_MS = 150 */ export class PreviewEditorProvider implements CustomTextEditorProvider { private readonly previews = new Set() + /** 按文档 uri:从文本编辑器切过来时它顶部那一行,等着交给马上要打开的新预览 */ + private readonly revealLines = new Map() constructor( private readonly extensionUri: Uri, @@ -29,7 +31,9 @@ export class PreviewEditorProvider implements CustomTextEditorProvider { resolveCustomTextEditor(document: TextDocument, panel: WebviewPanel): void { panel.webview.options = { enableScripts: true, localResourceRoots: [webviewUiRoot(this.extensionUri)] } panel.webview.html = webviewHtml(panel.webview, this.extensionUri, 'code-reader.html') - const preview = new Preview(document, panel.webview, this.registry, this.runtime, languageId => this.refresh(languageId)) + const key = document.uri.toString() + const preview = new Preview(document, panel, this.registry, this.runtime, this.revealLines.get(key), languageId => this.refresh(languageId)) + this.revealLines.delete(key) this.previews.add(preview) panel.onDidDispose(() => { this.previews.delete(preview) @@ -44,6 +48,26 @@ export class PreviewEditorProvider implements CustomTextEditorProvider { void preview.push() } } + + /** 用 open 打开 uri 的预览,新预览先滚到包含 line 的块。 */ + async openAtLine(uri: Uri, line: number, open: () => Thenable): Promise { + const key = uri.toString() + this.revealLines.set(key, line) + try { + await open() + } + finally { + // 切换命令等新预览解析完才返回;切换没成时也不能把这行留给以后别处的打开 + this.revealLines.delete(key) + } + } + + /** uri 的预览视口顶部那块的起始行;几个编辑器组里都开着这个文件的预览时取活动的那个。 */ + visibleLine(uri: Uri): number | undefined { + const key = uri.toString() + const previews = [...this.previews].filter(p => p.uri.toString() === key) + return (previews.find(p => p.active) ?? previews[0])?.visibleLine + } } class Preview { @@ -53,16 +77,20 @@ class Preview { private latest = 0 private timer: NodeJS.Timeout | undefined private readonly disposables: Disposable[] + /** webview 报来的视口顶部那块的起始行 */ + visibleLine: number | undefined constructor( private readonly document: TextDocument, - private readonly webview: Webview, + private readonly panel: WebviewPanel, private readonly registry: CodeReaderRegistry, private readonly runtime: TreeSitterRuntime, + /** 只随第一份真正发出去的文档走:之后页面因标签页切走重新加载,要按 webview 自己记的位置恢复 */ + private revealLine: number | undefined, private readonly onTranslatorSelected: (languageId: string) => void, ) { this.disposables = [ - webview.onDidReceiveMessage((msg: WebviewToExtension) => this.handleMessage(msg)), + panel.webview.onDidReceiveMessage((msg: WebviewToExtension) => this.handleMessage(msg)), workspace.onDidChangeTextDocument((e) => { if (e.document === document && e.contentChanges.length > 0) this.schedulePush() @@ -74,14 +102,25 @@ class Preview { return this.document.languageId } + get uri(): Uri { + return this.document.uri + } + + get active(): boolean { + return this.panel.active + } + async push(): Promise { clearTimeout(this.timer) if (!this.ready) return const request = ++this.latest const message: ExtensionToWebview = await documentMessage(this.document, this.registry, this.runtime) - if (request === this.latest) - void this.webview.postMessage(message) + if (request !== this.latest) + return + const { revealLine } = this + this.revealLine = undefined + void this.panel.webview.postMessage(revealLine === undefined ? message : { ...message, revealLine }) } dispose(): void { @@ -107,6 +146,9 @@ class Preview { case 'selectTranslator': await this.registry.selectTranslator(this.languageId, msg.id) this.onTranslatorSelected(this.languageId) + return + case 'visibleLine': + this.visibleLine = msg.line } } diff --git a/vscode/src/codeReader/editor/switchEditor.ts b/vscode/src/codeReader/editor/switchEditor.ts new file mode 100644 index 0000000..47e3285 --- /dev/null +++ b/vscode/src/codeReader/editor/switchEditor.ts @@ -0,0 +1,77 @@ +import type { Disposable, TextEditor } from 'vscode' +import type { PreviewEditorProvider } from './previewEditor' +import { commands, Range, TabInputCustom, TextEditorRevealType, Uri, window } from 'vscode' +import { PREVIEW_VIEW_TYPE } from './previewEditor' + +/** VS Code 自带普通文本编辑器的 editor id。 */ +const TEXT_EDITOR = 'default' +/** + * VS Code 自带的「重新打开编辑器的方式」:在原标签页位置换成另一种编辑器,新编辑器打开后才返回。 + * 原文件有未保存改动也不弹保存框——改动在 TextDocument 里,不跟着标签页走。 + */ +const REOPEN_WITH = 'reopenActiveEditorWith' +/** 切换命令返回时文本编辑器还没同步到扩展这边的话,最多再等这么久 */ +const EDITOR_WAIT_MS = 1000 + +function sameUri(a: Uri, b: Uri): boolean { + return a.toString() === b.toString() +} + +/** 标题栏按钮传来的是 (文件 Uri, { groupId }),命令面板里调用时没有参数。 */ +function resourceArg(args: unknown[]): Uri | undefined { + return args[0] instanceof Uri ? args[0] : undefined +} + +function activeCustomEditorUri(): Uri | undefined { + const input = window.tabGroups.activeTabGroup.activeTab?.input + return input instanceof TabInputCustom ? input.uri : undefined +} + +function textEditorOf(uri: Uri): TextEditor | undefined { + const active = window.activeTextEditor + if (active && sameUri(active.document.uri, uri)) + return active + return window.visibleTextEditors.find(editor => sameUri(editor.document.uri, uri)) +} + +/** 编辑器变化和命令结果是分开通知到扩展这边的:没看到 uri 的文本编辑器就等它出现。 */ +function waitForTextEditor(uri: Uri): Promise { + const found = textEditorOf(uri) + if (found) + return Promise.resolve(found) + const disposables: Disposable[] = [] + return new Promise((resolve) => { + disposables.push(window.onDidChangeVisibleTextEditors(() => { + const editor = textEditorOf(uri) + if (editor) + resolve(editor) + })) + const timer = setTimeout(resolve, EDITOR_WAIT_MS, undefined) + disposables.push({ dispose: () => clearTimeout(timer) }) + }).finally(() => disposables.forEach(d => d.dispose())) +} + +/** 文本编辑器 → 预览(同一标签页原位替换):新预览先滚到文本编辑器顶部那一行所在的块。 */ +export async function showPreview(previews: PreviewEditorProvider, args: unknown[]): Promise { + const uri = resourceArg(args) ?? window.activeTextEditor?.document.uri + const topLine = uri ? textEditorOf(uri)?.visibleRanges[0]?.start.line : undefined + const reopen = (): Thenable => commands.executeCommand(REOPEN_WITH, PREVIEW_VIEW_TYPE, ...args) + if (uri && topLine !== undefined) + await previews.openAtLine(uri, topLine, reopen) + else + await reopen() +} + +/** 预览 → 文本编辑器(同一标签页原位替换):把预览视口顶部那块的起始行放到文本编辑器顶部。 */ +export async function showSource(previews: PreviewEditorProvider, args: unknown[]): Promise { + const uri = resourceArg(args) ?? activeCustomEditorUri() + const line = uri ? previews.visibleLine(uri) : undefined + await commands.executeCommand(REOPEN_WITH, TEXT_EDITOR, ...args) + if (!uri || line === undefined) + return + const editor = await waitForTextEditor(uri) + if (!editor) + return + const target = Math.min(line, editor.document.lineCount - 1) + editor.revealRange(new Range(target, 0, target, 0), TextEditorRevealType.AtTop) +} diff --git a/vscode/src/codeReader/index.ts b/vscode/src/codeReader/index.ts index 61d9dba..9ebd0b8 100644 --- a/vscode/src/codeReader/index.ts +++ b/vscode/src/codeReader/index.ts @@ -2,6 +2,7 @@ import type { ExtensionContext, Uri } from 'vscode' import type { CodeReaderApi } from './contract' import { commands, window, workspace } from 'vscode' import { PREVIEW_VIEW_TYPE, PreviewEditorProvider } from './editor/previewEditor' +import { showPreview, showSource } from './editor/switchEditor' import { FileTreeProvider } from './explorer/fileTree' import { createPythonAdapter } from './languages/python' import { createTypeScriptAdapter } from './languages/typescript' @@ -14,13 +15,6 @@ import { typescriptZhTranslator } from './translators/typescriptZh' const LANGUAGE_IDS_CONTEXT = 'superpowers.codeReader.languageIds' const FILES_VIEW = 'superpowers.codeReader.files' const OPEN_COMMAND = 'superpowers.codeReader.open' -/** VS Code 自带普通文本编辑器的 editor id。 */ -const TEXT_EDITOR = 'default' -/** - * VS Code 自带的「重新打开编辑器的方式」:在原标签页位置换成另一种编辑器。 - * 原文件有未保存改动也不弹保存框——改动在 TextDocument 里,不跟着标签页走。 - */ -const REOPEN_WITH = 'reopenActiveEditorWith' /** 按 VS Code 的判断(含 files.associations)取文件的 languageId;图片等二进制文件打不开成文本,返回 undefined。 */ async function languageOf(uri: Uri): Promise { @@ -63,10 +57,8 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { commands.registerCommand('superpowers.codeReader.refresh', () => files.refresh()), commands.registerCommand(OPEN_COMMAND, (uri: Uri) => openFile(uri, registry)), // 标题栏按钮传来的是 (文件 Uri, { groupId }),原样转给 VS Code 定位是哪一组的哪个标签 - commands.registerCommand('superpowers.codeReader.showPreview', (...args: unknown[]) => - commands.executeCommand(REOPEN_WITH, PREVIEW_VIEW_TYPE, ...args)), - commands.registerCommand('superpowers.codeReader.showSource', (...args: unknown[]) => - commands.executeCommand(REOPEN_WITH, TEXT_EDITOR, ...args)), + commands.registerCommand('superpowers.codeReader.showPreview', (...args: unknown[]) => showPreview(previews, args)), + commands.registerCommand('superpowers.codeReader.showSource', (...args: unknown[]) => showSource(previews, args)), ) const api: CodeReaderApi = registry diff --git a/vscode/webview-ui/src/codeReader/App.tsx b/vscode/webview-ui/src/codeReader/App.tsx index f1353a7..c489ba0 100644 --- a/vscode/webview-ui/src/codeReader/App.tsx +++ b/vscode/webview-ui/src/codeReader/App.tsx @@ -1,21 +1,30 @@ import type { ReactElement } from 'react' import type { BlockTreeState } from './BlockTree' import type { DocumentMessage } from './types' -import type { ViewState } from './vscode' +import type { ViewState } from './viewState' 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' +import { useScrollPosition } from './useScrollPosition' +import { onMessage, postMessage, viewState } from './vscode' + +type BlockModes = Pick function toggled(ids: string[], id: string): string[] { return ids.includes(id) ? ids.filter(x => x !== id) : [...ids, id] } +function savedBlockModes(): BlockModes { + const { editing, collapsed } = viewState.get() + return { editing, collapsed } +} + export function App(): ReactElement | null { const [doc, setDoc] = useState(null) - const [view, setView] = useState(loadViewState) + const [view, setView] = useState(savedBlockModes) const [focusId, setFocusId] = useState(null) + useScrollPosition(doc) useEffect(() => { const off = onMessage((msg) => { @@ -27,7 +36,7 @@ export function App(): ReactElement | null { }, []) useEffect(() => { - saveViewState(view) + viewState.update(view) }, [view]) const onFocused = useCallback(() => setFocusId(null), []) diff --git a/vscode/webview-ui/src/codeReader/BlockTree.tsx b/vscode/webview-ui/src/codeReader/BlockTree.tsx index c984b58..eeb1879 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.tsx @@ -70,6 +70,7 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }):
{hasChildren diff --git a/vscode/webview-ui/src/codeReader/scrollPosition.test.ts b/vscode/webview-ui/src/codeReader/scrollPosition.test.ts new file mode 100644 index 0000000..5b69f91 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/scrollPosition.test.ts @@ -0,0 +1,68 @@ +import type { BlockBox } from './scrollPosition' +import { describe, expect, it } from 'vitest' +import { anchorAt, blockForLine, scrollTopFor } from './scrollPosition' + +// 页面上的块:顶部有 20px 工具栏,块与块之间 8px 间距 +const boxes: BlockBox[] = [ + { line: 0, top: 20, bottom: 60 }, + { line: 3, top: 68, bottom: 108 }, + { line: 4, top: 116, bottom: 196 }, + { line: 10, top: 204, bottom: 244 }, +] + +describe('anchorAt:视口顶部那块', () => { + it('视口顶部落在块中间:记这块的起始行和块内偏移', () => { + expect(anchorAt(boxes, 150)).toEqual({ line: 4, offset: 34, scrollTop: 150 }) + }) + + it('视口顶部落在块间空隙:取下面那块,偏移为负', () => { + expect(anchorAt(boxes, 110)).toEqual({ line: 4, offset: -6, scrollTop: 110 }) + }) + + it('还在最顶上:取第一块', () => { + expect(anchorAt(boxes, 0)).toEqual({ line: 0, offset: -20, scrollTop: 0 }) + }) + + it('所有块都滚过去了:取最后一块', () => { + expect(anchorAt(boxes, 300)).toEqual({ line: 10, offset: 96, scrollTop: 300 }) + }) + + it('没有块:没有位置可记', () => { + expect(anchorAt([], 50)).toBeUndefined() + }) +}) + +describe('scrollTopFor:按记下的位置恢复', () => { + it('内容没变:回到原来的 scrollTop', () => { + expect(scrollTopFor(boxes, anchorAt(boxes, 150)!)).toBe(150) + }) + + it('隐藏期间上面多了一块:跟着那块走,而不是停在原来的像素', () => { + const anchor = anchorAt(boxes, 150)! + const edited = [{ line: 0, top: 20, bottom: 60 }, { line: 1, top: 68, bottom: 108 }, ...boxes.slice(1).map(b => ({ ...b, top: b.top + 48, bottom: b.bottom + 48 }))] + expect(scrollTopFor(edited, anchor)).toBe(198) + }) + + it('那一行已经没有块了:退回原来的 scrollTop', () => { + expect(scrollTopFor(boxes, { line: 7, offset: 12, scrollTop: 140 })).toBe(140) + }) +}) + +describe('blockForLine:包含某行的块', () => { + it('正好是块的起始行', () => { + expect(blockForLine(boxes, 4)?.line).toBe(4) + }) + + it('落在块中间或块后面的空行:取之前最近的块', () => { + expect(blockForLine(boxes, 8)?.line).toBe(4) + expect(blockForLine(boxes, 99)?.line).toBe(10) + }) + + it('在所有块之前:取第一块', () => { + expect(blockForLine([{ line: 5, top: 20, bottom: 60 }, { line: 9, top: 68, bottom: 108 }], 2)?.line).toBe(5) + }) + + it('没有块:找不到', () => { + expect(blockForLine([], 3)).toBeUndefined() + }) +}) diff --git a/vscode/webview-ui/src/codeReader/scrollPosition.ts b/vscode/webview-ui/src/codeReader/scrollPosition.ts new file mode 100644 index 0000000..9056869 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/scrollPosition.ts @@ -0,0 +1,40 @@ +/** 预览的滚动位置按「块」记,而不是按像素:文件在标签页隐藏期间可能被改过,块比 scrollTop 对得准。 */ + +/** 一块在页面里的位置:它的起始行,和相对页面顶部的上下边(像素)。按页面上的先后排列。 */ +export interface BlockBox { + line: number + top: number + bottom: number +} + +/** 视口顶部那块的起始行 + 视口顶部在块内往下偏了多少像素;按块对不上时退回 scrollTop。 */ +export interface ScrollAnchor { + line: number + offset: number + scrollTop: number +} + +/** 视口顶部那块:第一个下边还在视口顶部以下的块;全都滚过去了就取最后一块。没有块时没有位置可记。 */ +export function anchorAt(boxes: readonly BlockBox[], scrollTop: number): ScrollAnchor | undefined { + const box = boxes.find(b => b.bottom > scrollTop) ?? boxes.at(-1) + return box && { line: box.line, offset: scrollTop - box.top, scrollTop } +} + +/** 恢复时的 scrollTop:起始行还是那一行的块还在就对齐到它,否则用原来的 scrollTop。 */ +export function scrollTopFor(boxes: readonly BlockBox[], anchor: ScrollAnchor): number { + const box = boxes.find(b => b.line === anchor.line) + return box ? box.top + anchor.offset : anchor.scrollTop +} + +/** + * 包含这一行的块:起始行不超过它的块里起始行最大的那块(行落在块与块之间的空行、 + * 或折叠起来的子块里时,就是它之前最近的块);这一行在所有块之前时取第一块。 + */ +export function blockForLine(boxes: readonly BlockBox[], line: number): BlockBox | undefined { + let found: BlockBox | undefined + for (const box of boxes) { + if (box.line <= line && (!found || box.line > found.line)) + found = box + } + return found ?? boxes[0] +} diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index d61c695..9464928 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -40,12 +40,18 @@ export interface DocumentMessage { translators: TranslatorOption[] translatorId: string error?: string + /** 从文本编辑器切过来时它顶部那一行:只在新预览的第一份 document 里带,滚到包含这行的块 */ + revealLine?: number } export type ExtensionToWebview = DocumentMessage -/** edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来第一行的缩进) */ +/** + * edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来第一行的缩进) + * visibleLine:视口顶部那块的起始行,切回文本编辑器时扩展端把这行放到顶部 + */ export type WebviewToExtension = | { type: 'ready' } | { type: 'edit', startLine: number, endLine: number, text: string } | { type: 'selectTranslator', id: string } + | { type: 'visibleLine', line: number } diff --git a/vscode/webview-ui/src/codeReader/useScrollPosition.ts b/vscode/webview-ui/src/codeReader/useScrollPosition.ts new file mode 100644 index 0000000..f230364 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/useScrollPosition.ts @@ -0,0 +1,70 @@ +import type { BlockBox } from './scrollPosition' +import type { DocumentMessage } from './types' +import { useEffect, useLayoutEffect, useRef } from 'react' +import { anchorAt, blockForLine, scrollTopFor } from './scrollPosition' +import { postMessage, viewState } from './vscode' + +/** 滚动时最多这么久记一次位置 */ +const RECORD_INTERVAL_MS = 100 + +/** 页面上每块(BlockCard 带 data-line)的位置,按页面上的先后。 */ +function measureBlocks(): BlockBox[] { + const y = window.scrollY + return Array.from(document.querySelectorAll('.cr-block[data-line]'), (el) => { + const rect = el.getBoundingClientRect() + return { line: Number(el.dataset.line), top: rect.top + y, bottom: rect.bottom + y } + }) +} + +/** 记下视口顶部那块;它的起始行变了才报给扩展端。 */ +function record(reportedLine: { current: number | undefined }): void { + const anchor = anchorAt(measureBlocks(), window.scrollY) + if (!anchor) + return + viewState.update({ scroll: anchor }) + if (anchor.line !== reportedLine.current) { + reportedLine.current = anchor.line + postMessage({ type: 'visibleLine', line: anchor.line }) + } +} + +/** + * 记住并恢复预览看到的位置: + * ① 滚动时把视口顶部那块记进 webview 状态,页面因标签页切走被重新加载后照着滚回去; + * ② 同时把那块的起始行报给扩展端,切回文本编辑器时把这行放到顶部; + * ③ 第一份文档带 revealLine(刚从文本编辑器切过来)时优先滚到包含这行的块。 + */ +export function useScrollPosition(doc: DocumentMessage | null): void { + const restored = useRef(false) + const reportedLine = useRef(undefined) + + // 第一份文档渲染进 DOM 后、浏览器绘制前滚好,不会先闪一下顶部 + useLayoutEffect(() => { + if (!doc || restored.current) + return + restored.current = true + const boxes = measureBlocks() + const saved = viewState.get().scroll + const top = doc.revealLine !== undefined + ? blockForLine(boxes, doc.revealLine)?.top + : saved && scrollTopFor(boxes, saved) + if (top !== undefined) + window.scrollTo(0, top) + record(reportedLine) + }, [doc]) + + useEffect(() => { + let timer: number | undefined + const onScroll = (): void => { + timer ??= window.setTimeout(() => { + timer = undefined + record(reportedLine) + }, RECORD_INTERVAL_MS) + } + window.addEventListener('scroll', onScroll, { passive: true }) + return () => { + window.removeEventListener('scroll', onScroll) + window.clearTimeout(timer) + } + }, []) +} diff --git a/vscode/webview-ui/src/codeReader/viewState.test.ts b/vscode/webview-ui/src/codeReader/viewState.test.ts new file mode 100644 index 0000000..0c92151 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/viewState.test.ts @@ -0,0 +1,37 @@ +import type { ViewState, ViewStateStorage } from './viewState' +import { describe, expect, it } from 'vitest' +import { createViewStateStore } from './viewState' + +/** 模拟 VS Code 的 webview 状态:存进去的要能序列化,页面重新加载后原样取回 */ +function memoryStorage(): ViewStateStorage { + let saved: string | undefined + return { + getState: () => saved === undefined ? undefined : JSON.parse(saved) as ViewState, + setState: (state) => { + saved = JSON.stringify(state) + }, + } +} + +describe('webview 状态', () => { + it('第一次打开:没有编辑中、折叠的块,也没有滚动位置', () => { + expect(createViewStateStore(memoryStorage()).get()).toEqual({ editing: [], collapsed: [] }) + }) + + it('块状态和滚动位置各自更新,互不覆盖', () => { + const storage = memoryStorage() + const store = createViewStateStore(storage) + store.update({ editing: ['2'], collapsed: ['6.1'] }) + store.update({ scroll: { line: 12, offset: 30, scrollTop: 480 } }) + store.update({ editing: [], collapsed: ['6.1'] }) + expect(storage.getState()).toEqual({ editing: [], collapsed: ['6.1'], scroll: { line: 12, offset: 30, scrollTop: 480 } }) + }) + + it('页面重新加载后取回上次存的全部状态', () => { + const storage = memoryStorage() + const store = createViewStateStore(storage) + store.update({ collapsed: ['3'] }) + store.update({ scroll: { line: 40, offset: -6, scrollTop: 1210 } }) + expect(createViewStateStore(storage).get()).toEqual({ editing: [], collapsed: ['3'], scroll: { line: 40, offset: -6, scrollTop: 1210 } }) + }) +}) diff --git a/vscode/webview-ui/src/codeReader/viewState.ts b/vscode/webview-ui/src/codeReader/viewState.ts new file mode 100644 index 0000000..7ce8bc3 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/viewState.ts @@ -0,0 +1,34 @@ +import type { ScrollAnchor } from './scrollPosition' + +/** + * 标签页切走时 VS Code 会销毁 webview、切回来重新加载,靠它恢复:哪些块停在「编辑」、 + * 哪些块折叠了(块 id 是它在树里的位置,文档刷新后照旧对得上),以及滚到了哪。 + */ +export interface ViewState { + editing: string[] + collapsed: string[] + scroll?: ScrollAnchor +} + +export interface ViewStateStorage { + getState: () => ViewState | undefined + setState: (state: ViewState) => void +} + +export interface ViewStateStore { + get: () => ViewState + /** 只改给出的那几项,其余照旧,整份写回 */ + update: (patch: Partial) => void +} + +/** 一个 webview 只有一份状态:块的状态和滚动位置由不同地方各自更新,存的时候合在一起。 */ +export function createViewStateStore(storage: ViewStateStorage): ViewStateStore { + let current: ViewState = storage.getState() ?? { editing: [], collapsed: [] } + return { + get: () => current, + update: (patch) => { + current = { ...current, ...patch } + storage.setState(current) + }, + } +} diff --git a/vscode/webview-ui/src/codeReader/vscode.ts b/vscode/webview-ui/src/codeReader/vscode.ts index 7e954a7..1da158d 100644 --- a/vscode/webview-ui/src/codeReader/vscode.ts +++ b/vscode/webview-ui/src/codeReader/vscode.ts @@ -1,22 +1,12 @@ -/** 代码阅读预览与扩展之间的收发,以及按块 id 记的视图状态。 */ +/** 代码阅读预览与扩展之间的收发,以及 webview 状态。 */ import type { ExtensionToWebview, WebviewToExtension } from './types' +import type { ViewState } from './viewState' import { createVsCodeBridge } from '@/lib/vscodeBridge' - -/** 哪些块停在「编辑」、哪些块折叠了。块 id 是它在树里的位置,文档刷新后照旧对得上。 */ -export interface ViewState { - editing: string[] - collapsed: string[] -} +import { createViewStateStore } from './viewState' const bridge = createVsCodeBridge() export const { postMessage, onMessage } = bridge -export function loadViewState(): ViewState { - return bridge.getState() ?? { editing: [], collapsed: [] } -} - -export function saveViewState(state: ViewState): void { - bridge.setState(state) -} +export const viewState = createViewStateStore(bridge)