🐛 fix(vscode): 代码阅读预览记住浏览位置,编辑 / 预览互切位置对齐
This commit is contained in:
1 parent
6897954644
commit
37306bd23e
13 files changed
+409
-37
No files matched your search
@@ -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 }
|
||||
@@ -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<Preview>()
|
||||
/** 按文档 uri:从文本编辑器切过来时它顶部那一行,等着交给马上要打开的新预览 */
|
||||
private readonly revealLines = new Map<string, number>()
|
||||
|
||||
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<unknown>): Promise<void> {
|
||||
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<void> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<TextEditor | undefined> {
|
||||
const found = textEditorOf(uri)
|
||||
if (found)
|
||||
return Promise.resolve(found)
|
||||
const disposables: Disposable[] = []
|
||||
return new Promise<TextEditor | undefined>((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<void> {
|
||||
const uri = resourceArg(args) ?? window.activeTextEditor?.document.uri
|
||||
const topLine = uri ? textEditorOf(uri)?.visibleRanges[0]?.start.line : undefined
|
||||
const reopen = (): Thenable<unknown> => 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<void> {
|
||||
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)
|
||||
}
|
||||
@@ -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<string | undefined> {
|
||||
@@ -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
|
||||
|
||||
@@ -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<ViewState, 'editing' | 'collapsed'>
|
||||
|
||||
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<DocumentMessage | null>(null)
|
||||
const [view, setView] = useState<ViewState>(loadViewState)
|
||||
const [view, setView] = useState<BlockModes>(savedBlockModes)
|
||||
const [focusId, setFocusId] = useState<string | null>(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), [])
|
||||
|
||||
@@ -70,6 +70,7 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }):
|
||||
<div
|
||||
className="cr-block flex items-start gap-2.5 rounded-lg py-2 pr-2.5 pl-2"
|
||||
data-kind={block.kind}
|
||||
data-line={block.startLine}
|
||||
data-editing={editing ? '' : undefined}
|
||||
>
|
||||
{hasChildren
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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<HTMLElement>('.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<number | undefined>(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)
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
@@ -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 } })
|
||||
})
|
||||
})
|
||||
@@ -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<ViewState>) => 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)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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<ExtensionToWebview, WebviewToExtension, ViewState>()
|
||||
|
||||
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)
|
||||
Reference in new issue
Block a user