✨ feat(vscode): 代码阅读预览按住 ⌘ / Ctrl 点名字跳到定义
This commit is contained in:
1 parent
e5db1a2e65
commit
4e146eb48f
20 files changed
+870
-60
No files matched your search
@@ -1,11 +1,13 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import type { BlockTreeState } from './BlockTree'
|
||||
import type { DocumentMessage } from './types'
|
||||
import type { DocumentMessage, Reveal } from './types'
|
||||
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 { useDefinitionLinks } from './useDefinitionLinks'
|
||||
import { useReveal } from './useReveal'
|
||||
import { useScrollPosition } from './useScrollPosition'
|
||||
import { onMessage, postMessage, viewState } from './vscode'
|
||||
|
||||
@@ -24,12 +26,25 @@ export function App(): ReactElement | null {
|
||||
const [doc, setDoc] = useState<DocumentMessage | null>(null)
|
||||
const [view, setView] = useState<BlockModes>(savedBlockModes)
|
||||
const [focusId, setFocusId] = useState<string | null>(null)
|
||||
/** 等着滚过去的位置:新预览第一份文档带的,或已开着的预览收到的 reveal */
|
||||
const [reveal, setReveal] = useState<Reveal | null>(null)
|
||||
const expand = useCallback((ids: string[]) => setView(prev => ({ ...prev, collapsed: prev.collapsed.filter(id => !ids.includes(id)) })), [])
|
||||
const revealed = useCallback(() => setReveal(null), [])
|
||||
useScrollPosition(doc)
|
||||
useReveal(doc, reveal, view.collapsed, expand, revealed)
|
||||
useDefinitionLinks(doc?.version)
|
||||
|
||||
useEffect(() => {
|
||||
const off = onMessage((msg) => {
|
||||
if (msg.type === 'document')
|
||||
setDoc(msg)
|
||||
switch (msg.type) {
|
||||
case 'document':
|
||||
setDoc(msg)
|
||||
if (msg.reveal)
|
||||
setReveal(msg.reveal)
|
||||
return
|
||||
case 'reveal':
|
||||
setReveal({ line: msg.line, definition: msg.definition })
|
||||
}
|
||||
})
|
||||
postMessage({ type: 'ready' })
|
||||
return off
|
||||
|
||||
@@ -83,6 +83,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-id={block.id}
|
||||
data-line={block.startLine}
|
||||
data-editing={editing ? '' : undefined}
|
||||
>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import type { Segment } from './types'
|
||||
|
||||
/** 按词性给每一段上色,颜色在 codeReader.css 的 .cr-role-* 里按主题切换。 */
|
||||
/** 按词性给每一段上色,颜色在 codeReader.css 的 .cr-role-* 里按主题切换;带源码下标的名字记在 data-offset 上,跳到定义用。 */
|
||||
export function SegmentText({ segments }: { segments: Segment[] }): ReactElement {
|
||||
return (
|
||||
<>
|
||||
{segments.map((segment, index) => (
|
||||
<span key={index} className={`cr-role-${segment.role}`}>{segment.text}</span>
|
||||
<span key={index} className={`cr-role-${segment.role}`} data-offset={segment.offset}>{segment.text}</span>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -94,11 +94,31 @@ body.vscode-high-contrast-light {
|
||||
background: var(--kind);
|
||||
}
|
||||
|
||||
/* 跳到定义的目标块:边框和底色往聚焦色闪三下(共 1.5 秒)再复原;减少动态效果时改成静止高亮同样长的时间 */
|
||||
@keyframes cr-flash {
|
||||
50% {
|
||||
background: color-mix(in srgb, var(--vscode-focusBorder) 25%, transparent);
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
}
|
||||
@keyframes cr-flash-still {
|
||||
from, to {
|
||||
background: color-mix(in srgb, var(--vscode-focusBorder) 25%, transparent);
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
}
|
||||
.cr-block[data-flash] {
|
||||
animation: cr-flash 500ms ease-in-out 3;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cr-block,
|
||||
.cr-guide {
|
||||
transition: none;
|
||||
}
|
||||
.cr-block[data-flash] {
|
||||
animation: cr-flash-still 1500ms;
|
||||
}
|
||||
}
|
||||
|
||||
.cr-badge {
|
||||
@@ -134,6 +154,13 @@ body.vscode-high-contrast-light {
|
||||
.cr-role-comment { color: var(--cr-comment); }
|
||||
.cr-role-hint { color: var(--vscode-descriptionForeground); }
|
||||
|
||||
/* 按住 ⌘ / Ctrl 停在查得到定义的名字上:和编辑器里的链接一样 */
|
||||
.cr-code [data-link] {
|
||||
color: var(--vscode-editorLink-activeForeground);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 属性表的属性名:界面字体,和值一样的行高,两边逐行对齐 */
|
||||
.cr-prop-label {
|
||||
font-size: 12.5px;
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { BlockKind, BlockView, PropertyRow, Segment } from './types'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { revealPlan, scrollTopToShow } from './reveal'
|
||||
|
||||
function name(text: string, offset: number): Segment {
|
||||
return { text, role: 'variable', offset }
|
||||
}
|
||||
|
||||
function word(text: string): Segment {
|
||||
return { text, role: 'keyword' }
|
||||
}
|
||||
|
||||
function textBlock(id: string, kind: BlockKind, startLine: number, translation: Segment[], children: BlockView[] = []): BlockView {
|
||||
return { id, kind, startLine, endLine: startLine, code: '', codeTokens: [], children, translation }
|
||||
}
|
||||
|
||||
function factBlock(id: string, kind: BlockKind, startLine: number, properties: PropertyRow[], children: BlockView[] = []): BlockView {
|
||||
return { id, kind, startLine, endLine: startLine, code: '', codeTokens: [], children, properties }
|
||||
}
|
||||
|
||||
/**
|
||||
* 0 from luffy_agent.core.db import get_db
|
||||
* 2 def load(path, user):
|
||||
* 3 if not user:
|
||||
* 4 return get_db(path)
|
||||
* 8 LIMIT = 3
|
||||
*/
|
||||
const BLOCKS: BlockView[] = [
|
||||
factBlock('0', 'import', 0, [
|
||||
{ label: '来源', value: [name('luffy_agent', 5), { text: '.', role: 'type' }, name('core', 17)] },
|
||||
{ label: '导入项', value: [{ text: '1 个', role: 'hint' }], items: [{ cells: [[name('get_db', 34)]] }] },
|
||||
]),
|
||||
factBlock('1', 'function', 2, [
|
||||
{ label: '名称', value: [{ text: 'load', role: 'function', offset: 46 }] },
|
||||
{ label: '异步', value: [{ text: '否', role: 'storage' }] },
|
||||
{ label: '输入参数', value: [{ text: '2 个', role: 'hint' }], items: [{ cells: [[name('path', 51)], []] }, { cells: [[name('user', 57)], []] }] },
|
||||
], [
|
||||
textBlock('1.0', 'if', 3, [word('如果'), word('非'), name('user', 78)], [
|
||||
textBlock('1.0.0', 'return', 4, [word('返回'), name('get_db', 102), name('path', 109)]),
|
||||
]),
|
||||
]),
|
||||
factBlock('2', 'const', 8, [{ label: '名称', value: [name('LIMIT', 130)] }]),
|
||||
]
|
||||
|
||||
describe('跳到定义:滚到哪块、展开哪些', () => {
|
||||
it('名字在嵌套块的译文里:目标是那块,展开它所有的祖先块', () => {
|
||||
expect(revealPlan(BLOCKS, 4, 102)).toEqual({ id: '1.0.0', expand: ['1', '1.0'] })
|
||||
})
|
||||
|
||||
it('名字在属性行展开的子行里(参数):连那一行一起展开', () => {
|
||||
expect(revealPlan(BLOCKS, 2, 57)).toEqual({ id: '1', expand: ['1/p2'] })
|
||||
expect(revealPlan(BLOCKS, 0, 34)).toEqual({ id: '0', expand: ['0/p1'] })
|
||||
})
|
||||
|
||||
it('名字在属性行的值里:不用展开属性行', () => {
|
||||
expect(revealPlan(BLOCKS, 2, 46)).toEqual({ id: '1', expand: [] })
|
||||
expect(revealPlan(BLOCKS, 8, 130)).toEqual({ id: '2', expand: [] })
|
||||
})
|
||||
|
||||
it('页面上没有这个名字:按行找包含它的块,也展开祖先', () => {
|
||||
expect(revealPlan(BLOCKS, 3, 999)).toEqual({ id: '1.0', expand: ['1'] })
|
||||
expect(revealPlan(BLOCKS, 6, 999)).toEqual({ id: '1.0.0', expand: ['1', '1.0'] })
|
||||
expect(revealPlan(BLOCKS, 20, 999)).toEqual({ id: '2', expand: [] })
|
||||
})
|
||||
|
||||
it('按行找:同一行有几块取外层那块;行在所有块之前取第一块;没有块时没有目标', () => {
|
||||
const sameLine = [textBlock('0', 'if', 1, [word('如果')], [textBlock('0.0', 'return', 1, [word('返回')])])]
|
||||
expect(revealPlan(sameLine, 1, 999)).toEqual({ id: '0', expand: [] })
|
||||
expect(revealPlan(sameLine, 0, 999)).toEqual({ id: '0', expand: [] })
|
||||
expect(revealPlan([], 0, 0)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('把目标块滚进视口', () => {
|
||||
it('整块已经在视口里:不滚', () => {
|
||||
expect(scrollTopToShow({ top: 120, bottom: 160 }, 100, 500)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('在视口外:放到视口正中', () => {
|
||||
expect(scrollTopToShow({ top: 1000, bottom: 1040 }, 0, 500)).toBe(770)
|
||||
expect(scrollTopToShow({ top: 80, bottom: 120 }, 400, 500)).toBe(0)
|
||||
})
|
||||
|
||||
it('露了一半也算在视口外;比视口还高就顶部对齐', () => {
|
||||
expect(scrollTopToShow({ top: 580, bottom: 620 }, 100, 500)).toBe(350)
|
||||
expect(scrollTopToShow({ top: 900, bottom: 1600 }, 0, 500)).toBe(900)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { BlockView, Segment } from './types'
|
||||
import { propertyRowId } from './viewState'
|
||||
|
||||
/** 跳到定义时要滚到的块,以及要展开的块和属性行:它们折叠着,目标就不在页面上。 */
|
||||
export interface RevealPlan {
|
||||
id: string
|
||||
expand: string[]
|
||||
}
|
||||
|
||||
interface Placed {
|
||||
block: BlockView
|
||||
/** 从顶层到父块的块 id */
|
||||
ancestors: string[]
|
||||
}
|
||||
|
||||
/** 按页面上的先后(先父块、再子块)列出所有块。 */
|
||||
function* placed(blocks: BlockView[], ancestors: string[] = []): Generator<Placed> {
|
||||
for (const block of blocks) {
|
||||
yield { block, ancestors }
|
||||
yield* placed(block.children, [...ancestors, block.id])
|
||||
}
|
||||
}
|
||||
|
||||
function shows(segments: Segment[], offset: number): boolean {
|
||||
return segments.some(segment => segment.offset === offset)
|
||||
}
|
||||
|
||||
/** 块里哪儿显示着 offset 处的名字:'value' 是一行译文或属性行的值,数字是带子行的属性行序号(名字在它展开的子行里),不在这块里是 undefined。 */
|
||||
function locate(block: BlockView, offset: number): 'value' | number | undefined {
|
||||
if ('translation' in block)
|
||||
return shows(block.translation, offset) ? 'value' : undefined
|
||||
for (const [index, row] of block.properties.entries()) {
|
||||
if (shows(row.value, offset))
|
||||
return 'value'
|
||||
if (row.items?.some(item => item.cells.some(cell => shows(cell, offset))))
|
||||
return index
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳到定义:目标是显示着定义处那个名字的块,名字在属性行的子行里时连这一行一起展开;
|
||||
* 页面上找不到这个名字(例如定义是整个模块,落在文件开头)就取包含 line 的块,规则同 blockForLine。
|
||||
* 目标的祖先块都要展开。
|
||||
*/
|
||||
export function revealPlan(blocks: BlockView[], line: number, definition: number): RevealPlan | undefined {
|
||||
let byLine: Placed | undefined
|
||||
for (const item of placed(blocks)) {
|
||||
const where = locate(item.block, definition)
|
||||
if (where !== undefined)
|
||||
return { id: item.block.id, expand: typeof where === 'number' ? [...item.ancestors, propertyRowId(item.block.id, where)] : item.ancestors }
|
||||
if (item.block.startLine <= line && (!byLine || item.block.startLine > byLine.block.startLine))
|
||||
byLine = item
|
||||
}
|
||||
const target = byLine ?? (blocks.length > 0 ? { block: blocks[0], ancestors: [] } : undefined)
|
||||
return target && { id: target.block.id, expand: target.ancestors }
|
||||
}
|
||||
|
||||
/** 让一块露出来要滚到的 scrollTop,和 VS Code 跳到定义一样:整块已经在视口里就不滚;否则放到视口正中,比视口还高就顶部对齐。 */
|
||||
export function scrollTopToShow(box: { top: number, bottom: number }, viewTop: number, viewHeight: number): number | undefined {
|
||||
if (box.top >= viewTop && box.bottom <= viewTop + viewHeight)
|
||||
return undefined
|
||||
const height = box.bottom - box.top
|
||||
return Math.max(0, height >= viewHeight ? box.top : box.top - (viewHeight - height) / 2)
|
||||
}
|
||||
@@ -14,7 +14,8 @@ export type TokenRole
|
||||
| 'hint' // 属性表里的说明词:未标注 无 N 个 默认导出 …
|
||||
| 'variable' | 'function' | 'type' | 'string' | 'number' | 'literal' | 'comment' | 'plain'
|
||||
|
||||
export interface Segment { text: string, role: TokenRole }
|
||||
/** offset:照抄自源码里的名字、字符串时它在源码里的起始下标,⌘ / Ctrl 悬停、点击时拿它查定义;译词、提示词没有 */
|
||||
export interface Segment { text: string, role: TokenRole, offset?: number }
|
||||
|
||||
/** 属性表里展开的一个子行:几列对齐的格子,例如参数的名称 / 类型 / 默认值 */
|
||||
export interface PropertyItem { cells: Segment[][] }
|
||||
@@ -45,6 +46,15 @@ export type BlockView = BlockViewBase & ({ translation: Segment[] } | { properti
|
||||
|
||||
export interface TranslatorOption { id: string, label: string }
|
||||
|
||||
/**
|
||||
* 滚到包含 line 的块。definition 只在跳到定义时有:定义处那个名字在源码里的下标(Segment.offset),
|
||||
* 要展开把它折叠住的块和属性行,再让那块闪几下
|
||||
*/
|
||||
export interface Reveal {
|
||||
line: number
|
||||
definition?: number
|
||||
}
|
||||
|
||||
export interface DocumentMessage {
|
||||
type: 'document'
|
||||
version: number
|
||||
@@ -53,18 +63,29 @@ export interface DocumentMessage {
|
||||
translators: TranslatorOption[]
|
||||
translatorId: string
|
||||
error?: string
|
||||
/** 从文本编辑器切过来时它顶部那一行:只在新预览的第一份 document 里带,滚到包含这行的块 */
|
||||
revealLine?: number
|
||||
/** 只在新预览的第一份 document 里带:从文本编辑器切过来时是它顶部那一行,跳到定义时是定义处 */
|
||||
reveal?: Reveal
|
||||
}
|
||||
|
||||
export type ExtensionToWebview = DocumentMessage
|
||||
/**
|
||||
* reveal:已经开着的预览滚到某处(同 DocumentMessage.reveal)
|
||||
* definitionProbed:probeDefinition 的回答,found 表示查得到定义
|
||||
*/
|
||||
export type ExtensionToWebview
|
||||
= | DocumentMessage
|
||||
| ({ type: 'reveal' } & Reveal)
|
||||
| { type: 'definitionProbed', version: number, offset: number, found: boolean }
|
||||
|
||||
/**
|
||||
* edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来第一行的缩进)
|
||||
* visibleLine:视口顶部那块的起始行,切回文本编辑器时扩展端把这行放到顶部
|
||||
* probeDefinition:按住 ⌘ / Ctrl 悬停到带下标的名字上,问 offset 处有没有定义;version 是这份下标所属的文档版本
|
||||
* goToDefinition:⌘ / Ctrl + 点击,跳到 offset 处名字的定义
|
||||
*/
|
||||
export type WebviewToExtension
|
||||
= | { type: 'ready' }
|
||||
| { type: 'edit', startLine: number, endLine: number, text: string }
|
||||
| { type: 'selectTranslator', id: string }
|
||||
| { type: 'visibleLine', line: number }
|
||||
| { type: 'probeDefinition', version: number, offset: number }
|
||||
| { type: 'goToDefinition', version: number, offset: number }
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { WebviewToExtension } from './types'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { onMessage, postMessage } from './vscode'
|
||||
|
||||
/** 跳到定义用的修饰键,按平台习惯:macOS 是 ⌘,其余是 Ctrl。 */
|
||||
const IS_MAC = /Mac|iPhone|iPad/.test(navigator.userAgent)
|
||||
|
||||
function modifierHeld(event: MouseEvent | KeyboardEvent): boolean {
|
||||
return IS_MAC ? event.metaKey : event.ctrlKey
|
||||
}
|
||||
|
||||
/** 事件落在的那个带源码下标的名字(SegmentText 渲染的 data-offset)。 */
|
||||
function nameAt(target: EventTarget | null): HTMLElement | null {
|
||||
return target instanceof Element ? target.closest<HTMLElement>('[data-offset]') : null
|
||||
}
|
||||
|
||||
function offsetOf(el: HTMLElement | null): number | undefined {
|
||||
return el?.dataset.offset === undefined ? undefined : Number(el.dataset.offset)
|
||||
}
|
||||
|
||||
/**
|
||||
* 和 VS Code 编辑器里一样:按住 ⌘ / Ctrl、鼠标停在查得到定义的名字上时,名字显示成链接(data-link),点一下跳到定义。
|
||||
* 有没有定义问扩展端,回答按文档版本缓存:同一版本里每个名字只问一次,换了版本清空,迟到的旧版本回答丢掉。
|
||||
*/
|
||||
class DefinitionLinks {
|
||||
private version: number | undefined
|
||||
/** 这个版本里问过的下标:true / false 是回答,undefined 是问了还没回 */
|
||||
private readonly answers = new Map<number, boolean | undefined>()
|
||||
private hovered: HTMLElement | null = null
|
||||
private held = false
|
||||
private linked: HTMLElement | null = null
|
||||
|
||||
constructor(private readonly post: (msg: WebviewToExtension) => void) {}
|
||||
|
||||
setVersion(version: number | undefined): void {
|
||||
if (version === this.version)
|
||||
return
|
||||
this.version = version
|
||||
this.answers.clear()
|
||||
this.update()
|
||||
}
|
||||
|
||||
point(target: EventTarget | null, held: boolean): void {
|
||||
this.hovered = nameAt(target)
|
||||
this.held = held
|
||||
this.update()
|
||||
}
|
||||
|
||||
hold(held: boolean): void {
|
||||
this.held = held
|
||||
this.update()
|
||||
}
|
||||
|
||||
answer(version: number, offset: number, found: boolean): void {
|
||||
if (version !== this.version)
|
||||
return
|
||||
this.answers.set(offset, found)
|
||||
this.update()
|
||||
}
|
||||
|
||||
/** ⌘ / Ctrl + 点击名字:跳到定义,并拦下这次点击,不让它再触发块上的切换。已知没有定义的名字照常点。 */
|
||||
click(event: MouseEvent): void {
|
||||
const offset = modifierHeld(event) ? offsetOf(nameAt(event.target)) : undefined
|
||||
if (offset === undefined || this.version === undefined || this.answers.get(offset) === false)
|
||||
return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
this.post({ type: 'goToDefinition', version: this.version, offset })
|
||||
}
|
||||
|
||||
private update(): void {
|
||||
const offset = this.held ? offsetOf(this.hovered) : undefined
|
||||
if (offset !== undefined && this.version !== undefined && !this.answers.has(offset)) {
|
||||
this.answers.set(offset, undefined)
|
||||
this.post({ type: 'probeDefinition', version: this.version, offset })
|
||||
}
|
||||
const link = offset !== undefined && this.answers.get(offset) === true ? this.hovered : null
|
||||
if (link === this.linked)
|
||||
return
|
||||
this.linked?.removeAttribute('data-link')
|
||||
link?.setAttribute('data-link', '')
|
||||
this.linked = link
|
||||
}
|
||||
}
|
||||
|
||||
/** 预览里的名字能 ⌘ / Ctrl + 点击跳到定义;version 是当前显示的文档版本,名字的下标属于它。 */
|
||||
export function useDefinitionLinks(version: number | undefined): void {
|
||||
const [links] = useState(() => new DefinitionLinks(postMessage))
|
||||
|
||||
useEffect(() => {
|
||||
links.setVersion(version)
|
||||
}, [links, version])
|
||||
|
||||
useEffect(() => {
|
||||
const onMove = (event: MouseEvent): void => links.point(event.target, modifierHeld(event))
|
||||
const onOut = (event: MouseEvent): void => {
|
||||
if (event.relatedTarget === null)
|
||||
links.point(null, false)
|
||||
}
|
||||
const onKey = (event: KeyboardEvent): void => links.hold(modifierHeld(event))
|
||||
const onBlur = (): void => links.hold(false)
|
||||
const onClick = (event: MouseEvent): void => links.click(event)
|
||||
const off = onMessage((msg) => {
|
||||
if (msg.type === 'definitionProbed')
|
||||
links.answer(msg.version, msg.offset, msg.found)
|
||||
})
|
||||
document.addEventListener('mousemove', onMove)
|
||||
document.addEventListener('mouseout', onOut)
|
||||
// 捕获阶段:先于块里的按钮、折叠箭头拿到点击
|
||||
document.addEventListener('click', onClick, true)
|
||||
window.addEventListener('keydown', onKey)
|
||||
window.addEventListener('keyup', onKey)
|
||||
window.addEventListener('blur', onBlur)
|
||||
return () => {
|
||||
off()
|
||||
document.removeEventListener('mousemove', onMove)
|
||||
document.removeEventListener('mouseout', onOut)
|
||||
document.removeEventListener('click', onClick, true)
|
||||
window.removeEventListener('keydown', onKey)
|
||||
window.removeEventListener('keyup', onKey)
|
||||
window.removeEventListener('blur', onBlur)
|
||||
}
|
||||
}, [links])
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { DocumentMessage, Reveal } from './types'
|
||||
import { useLayoutEffect } from 'react'
|
||||
import { revealPlan, scrollTopToShow } from './reveal'
|
||||
import { blockForLine } from './scrollPosition'
|
||||
import { measureBlocks } from './useScrollPosition'
|
||||
|
||||
/** 让一块闪几下(codeReader.css 的 [data-flash]);正在闪的同一块再跳一次时从头闪。 */
|
||||
function flash(el: HTMLElement): void {
|
||||
const running = el.getAnimations().filter(animation => animation instanceof CSSAnimation)
|
||||
if (running.length > 0) {
|
||||
for (const animation of running)
|
||||
animation.currentTime = 0
|
||||
return
|
||||
}
|
||||
const onEnd = (event: AnimationEvent): void => {
|
||||
if (event.target !== el)
|
||||
return
|
||||
el.removeAttribute('data-flash')
|
||||
el.removeEventListener('animationend', onEnd)
|
||||
}
|
||||
el.addEventListener('animationend', onEnd)
|
||||
el.setAttribute('data-flash', '')
|
||||
}
|
||||
|
||||
/**
|
||||
* 滚到 reveal 指的位置,处理完调 done:
|
||||
* ① 从文本编辑器切过来(没有 definition):包含那一行的块顶到视口顶部,折叠着的不展开;
|
||||
* ② 跳到定义:先展开把目标藏起来的块和属性行(expand 之后重新渲染,再进来一次),再把目标块滚进视口、闪几下。
|
||||
*/
|
||||
export function useReveal(
|
||||
doc: DocumentMessage | null,
|
||||
reveal: Reveal | null,
|
||||
collapsed: readonly string[],
|
||||
expand: (ids: string[]) => void,
|
||||
done: () => void,
|
||||
): void {
|
||||
useLayoutEffect(() => {
|
||||
if (!doc || !reveal)
|
||||
return
|
||||
if (reveal.definition === undefined) {
|
||||
const top = blockForLine(measureBlocks(), reveal.line)?.top
|
||||
if (top !== undefined)
|
||||
window.scrollTo(0, top)
|
||||
done()
|
||||
return
|
||||
}
|
||||
const plan = revealPlan(doc.blocks, reveal.line, reveal.definition)
|
||||
const hidden = plan?.expand.filter(id => collapsed.includes(id)) ?? []
|
||||
if (hidden.length > 0) {
|
||||
expand(hidden)
|
||||
return
|
||||
}
|
||||
const el = plan && document.querySelector<HTMLElement>(`.cr-block[data-id="${plan.id}"]`)
|
||||
if (el) {
|
||||
const rect = el.getBoundingClientRect()
|
||||
const top = scrollTopToShow({ top: rect.top + window.scrollY, bottom: rect.bottom + window.scrollY }, window.scrollY, window.innerHeight)
|
||||
if (top !== undefined)
|
||||
window.scrollTo(0, top)
|
||||
flash(el)
|
||||
}
|
||||
done()
|
||||
}, [doc, reveal, collapsed, expand, done])
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { BlockBox } from './scrollPosition'
|
||||
import type { DocumentMessage } from './types'
|
||||
import { useEffect, useLayoutEffect, useRef } from 'react'
|
||||
import { anchorAt, blockForLine, scrollTopFor } from './scrollPosition'
|
||||
import { anchorAt, scrollTopFor } from './scrollPosition'
|
||||
import { postMessage, viewState } from './vscode'
|
||||
|
||||
/** 滚动时最多这么久记一次位置 */
|
||||
const RECORD_INTERVAL_MS = 100
|
||||
|
||||
/** 页面上每块(BlockCard 带 data-line)的位置,按页面上的先后。 */
|
||||
function measureBlocks(): BlockBox[] {
|
||||
export function measureBlocks(): BlockBox[] {
|
||||
const y = window.scrollY
|
||||
return Array.from(document.querySelectorAll<HTMLElement>('.cr-block[data-line]'), (el) => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
@@ -31,8 +31,8 @@ function record(reportedLine: { current: number | undefined }): void {
|
||||
/**
|
||||
* 记住并恢复预览看到的位置:
|
||||
* ① 滚动时把视口顶部那块记进 webview 状态,页面因标签页切走被重新加载后照着滚回去;
|
||||
* ② 同时把那块的起始行报给扩展端,切回文本编辑器时把这行放到顶部;
|
||||
* ③ 第一份文档带 revealLine(刚从文本编辑器切过来)时优先滚到包含这行的块。
|
||||
* ② 同时把那块的起始行报给扩展端,切回文本编辑器时把这行放到顶部。
|
||||
* 第一份文档带 reveal(刚从文本编辑器切过来、跳到定义)时不恢复,交给 useReveal 滚。
|
||||
*/
|
||||
export function useScrollPosition(doc: DocumentMessage | null): void {
|
||||
const restored = useRef(false)
|
||||
@@ -43,11 +43,8 @@ export function useScrollPosition(doc: DocumentMessage | null): void {
|
||||
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)
|
||||
const top = doc.reveal === undefined && saved ? scrollTopFor(measureBlocks(), saved) : undefined
|
||||
if (top !== undefined)
|
||||
window.scrollTo(0, top)
|
||||
record(reportedLine)
|
||||
|
||||
Reference in new issue
Block a user