🐛 fix(vscode): 跳到定义时对准名字并闪
This commit is contained in:
1 parent
06df8919c8
commit
fc403d5b20
6 files changed
+307
-50
No files matched your search
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { definitionOffset } from './definitions'
|
||||||
|
|
||||||
|
vi.mock('vscode', () => ({
|
||||||
|
commands: { executeCommand: vi.fn() },
|
||||||
|
EventEmitter: class EventEmitter { event = vi.fn(); fire = vi.fn() },
|
||||||
|
Range: class Range {},
|
||||||
|
Uri: { parse: vi.fn() },
|
||||||
|
window: { createOutputChannel: vi.fn(() => ({ appendLine: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() })) },
|
||||||
|
workspace: { openTextDocument: vi.fn(), asRelativePath: vi.fn() },
|
||||||
|
}))
|
||||||
|
|
||||||
|
const SOURCE = [
|
||||||
|
'export class DeployActionError extends Error {',
|
||||||
|
' status = 1',
|
||||||
|
'}',
|
||||||
|
'export const fetchAppDeployments = async () => 1',
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
describe('定义范围的起点不在名字上时,用范围里那个名字', () => {
|
||||||
|
const nameAt = SOURCE.indexOf('fetchAppDeployments')
|
||||||
|
const whole = { start: 0, end: SOURCE.length }
|
||||||
|
|
||||||
|
it('没有选区:在大范围里找到和点击的名字一致的那一处,不用大范围的起点', () => {
|
||||||
|
expect(definitionOffset(SOURCE, whole, undefined, 'fetchAppDeployments')).toBe(nameAt)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('选区起点已经在名字上:用选区,不再往范围更前面找', () => {
|
||||||
|
const earlier = SOURCE.indexOf('DeployActionError')
|
||||||
|
expect(definitionOffset(SOURCE, whole, { start: nameAt, end: nameAt + 'fetchAppDeployments'.length }, 'fetchAppDeployments')).toBe(nameAt)
|
||||||
|
expect(definitionOffset(SOURCE, whole, { start: earlier, end: earlier + 'DeployActionError'.length }, 'DeployActionError')).toBe(earlier)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('选区是一整段、起点不在名字上:仍在这段里找名字', () => {
|
||||||
|
expect(definitionOffset(SOURCE, whole, whole, 'fetchAppDeployments')).toBe(nameAt)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('只是更长名字的前缀不算', () => {
|
||||||
|
const text = 'const fetchAppDeploymentsExtra = fetchAppDeployments'
|
||||||
|
expect(definitionOffset(text, { start: 0, end: text.length }, undefined, 'fetchAppDeployments')).toBe(text.lastIndexOf('fetchAppDeployments'))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -18,7 +18,55 @@ export interface Definition extends DefinitionTarget {
|
|||||||
lineText: string
|
lineText: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** uri 里 position 处名字的定义:交给 VS Code 注册的定义提供者(Pylance、TypeScript 语言服务……),和普通编辑器里 ⌘ + 点击查的是同一套。 */
|
/**
|
||||||
|
* 定义该落在名字上。选区起点已经在名字上就用它(targetSelectionRange 优先);
|
||||||
|
* 否则在整段范围里找和点击的名字文本一致的那一处。都对不上就退回选区或范围的起点。
|
||||||
|
*/
|
||||||
|
export function definitionOffset(
|
||||||
|
text: string,
|
||||||
|
range: { start: number, end: number },
|
||||||
|
selection: { start: number, end: number } | undefined,
|
||||||
|
name: string,
|
||||||
|
): number {
|
||||||
|
const selected = selection?.start
|
||||||
|
if (name !== '' && selected !== undefined && onName(text, selected, name))
|
||||||
|
return selected
|
||||||
|
if (name !== '') {
|
||||||
|
const found = findName(text, range.start, range.end, name)
|
||||||
|
if (found !== undefined)
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
return selection?.start ?? range.start
|
||||||
|
}
|
||||||
|
|
||||||
|
function onName(text: string, index: number | undefined, name: string): boolean {
|
||||||
|
if (index === undefined || index < 0 || name === '' || index + name.length > text.length)
|
||||||
|
return false
|
||||||
|
if (text.slice(index, index + name.length) !== name)
|
||||||
|
return false
|
||||||
|
return !isIdent(text[index - 1]) && !isIdent(text[index + name.length])
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIdent(ch: string | undefined): boolean {
|
||||||
|
return ch !== undefined && /[\p{L}\p{N}_$]/u.test(ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 范围里第一处和 name 整词一致的下标。name 贴着别的标识符(只是更长名字的前缀)不算。 */
|
||||||
|
function findName(text: string, start: number, end: number, name: string): number | undefined {
|
||||||
|
const sliceEnd = Math.min(text.length, Math.max(start, end))
|
||||||
|
let from = Math.max(0, start)
|
||||||
|
while (from < sliceEnd) {
|
||||||
|
const at = text.indexOf(name, from)
|
||||||
|
if (at < 0 || at + name.length > sliceEnd)
|
||||||
|
return undefined
|
||||||
|
if (onName(text, at, name))
|
||||||
|
return at
|
||||||
|
from = at + 1
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** uri 里 position 处名字的定义:交给 VS Code 注册的定义提供者(Pylance、TypeScript 语言服务……),和普通编辑器里 ⌘ + 点击查的是同一套。范围起点不在名字上时,改成范围里那个名字。 */
|
||||||
export async function languageDefinitions(uri: Uri, position: Position): Promise<FoundLocation[]> {
|
export async function languageDefinitions(uri: Uri, position: Position): Promise<FoundLocation[]> {
|
||||||
let provided: (Location | LocationLink)[]
|
let provided: (Location | LocationLink)[]
|
||||||
try {
|
try {
|
||||||
@@ -28,9 +76,54 @@ export async function languageDefinitions(uri: Uri, position: Position): Promise
|
|||||||
logger.add({ level: 'warn', source: 'codeReader', message: `查定义失败 ${uri.toString()}:${position.line + 1}:${err instanceof Error ? err.message : String(err)}` })
|
logger.add({ level: 'warn', source: 'codeReader', message: `查定义失败 ${uri.toString()}:${position.line + 1}:${err instanceof Error ? err.message : String(err)}` })
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
return provided.map(item => 'targetUri' in item
|
const name = await clickedName(uri, position)
|
||||||
? { uri: item.targetUri, range: item.targetSelectionRange ?? item.targetRange }
|
const located = await Promise.all(provided.map(async (item): Promise<FoundLocation> => {
|
||||||
: { uri: item.uri, range: item.range })
|
const link = 'targetUri' in item
|
||||||
|
? { uri: item.targetUri, range: item.targetRange, selection: item.targetSelectionRange }
|
||||||
|
: { uri: item.uri, range: item.range, selection: undefined }
|
||||||
|
return { uri: link.uri, range: await rangeOnName(link.uri, link.range, link.selection, name) }
|
||||||
|
}))
|
||||||
|
return located
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 点击处那个名字的文本。文档打不开、或点的不是一个词时是空串,定义范围就按语言服务给的起点。 */
|
||||||
|
async function clickedName(uri: Uri, position: Position): Promise<string> {
|
||||||
|
try {
|
||||||
|
const document = await workspace.openTextDocument(uri)
|
||||||
|
const word = document.getWordRangeAtPosition(position)
|
||||||
|
return word ? document.getText(word) : ''
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
logger.add({ level: 'warn', source: 'codeReader', message: `读不到 ${uri.toString()} 里点击的名字:${err instanceof Error ? err.message : String(err)}` })
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定义范围的起点不在名字上时,改成范围里真正的名字(见 definitionOffset),只框住这个名字。
|
||||||
|
* 对不上名字就保持原来的范围。目标文件打不开时也保持原样。
|
||||||
|
*/
|
||||||
|
async function rangeOnName(uri: Uri, range: Range, selection: Range | undefined, name: string): Promise<Range> {
|
||||||
|
const fallback = selection ?? range
|
||||||
|
if (name === '')
|
||||||
|
return fallback
|
||||||
|
try {
|
||||||
|
const document = await workspace.openTextDocument(uri)
|
||||||
|
const text = document.getText()
|
||||||
|
const offset = definitionOffset(
|
||||||
|
text,
|
||||||
|
{ start: document.offsetAt(range.start), end: document.offsetAt(range.end) },
|
||||||
|
selection ? { start: document.offsetAt(selection.start), end: document.offsetAt(selection.end) } : undefined,
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
if (!onName(text, offset, name))
|
||||||
|
return fallback
|
||||||
|
return new Range(document.positionAt(offset), document.positionAt(offset + name.length))
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
logger.add({ level: 'warn', source: 'codeReader', message: `读不到定义所在的 ${uri.toString()}:${err instanceof Error ? err.message : String(err)}` })
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 库规则查到的定义(RuleLocation)换成 VS Code 的位置。 */
|
/** 库规则查到的定义(RuleLocation)换成 VS Code 的位置。 */
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ body:is(.vscode-light, .vscode-high-contrast-light) [data-badge] { --kind: var(-
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 跳到定义的目标(整块,或属性表里的一行 / 子行):边框和底色往聚焦色闪三下(共 1.5 秒)再复原;减少动态效果时改成静止高亮同样长的时间 */
|
/* 跳到定义的目标(整块、属性表里的一行 / 子行,或定义处那个名字):边框和底色往聚焦色闪三下(共 1.5 秒)再复原;减少动态效果时改成静止高亮同样长的时间 */
|
||||||
@keyframes cr-flash {
|
@keyframes cr-flash {
|
||||||
50% {
|
50% {
|
||||||
background: color-mix(in srgb, var(--vscode-focusBorder) 25%, transparent);
|
background: color-mix(in srgb, var(--vscode-focusBorder) 25%, transparent);
|
||||||
@@ -156,7 +156,7 @@ body:is(.vscode-light, .vscode-high-contrast-light) [data-badge] { --kind: var(-
|
|||||||
border-color: var(--vscode-focusBorder);
|
border-color: var(--vscode-focusBorder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
:is(.cr-block, .cr-prop-row, .cr-prop-item)[data-flash] {
|
:is(.cr-block, .cr-prop-row, .cr-prop-item, [data-offset])[data-flash] {
|
||||||
animation: cr-flash 500ms ease-in-out 3;
|
animation: cr-flash 500ms ease-in-out 3;
|
||||||
}
|
}
|
||||||
/* 表格里的一行(参数):边框是和相邻行共用的分隔线,改用画在行里面的一圈聚焦色 */
|
/* 表格里的一行(参数):边框是和相邻行共用的分隔线,改用画在行里面的一圈聚焦色 */
|
||||||
@@ -181,7 +181,7 @@ body:is(.vscode-light, .vscode-high-contrast-light) [data-badge] { --kind: var(-
|
|||||||
.cr-guide {
|
.cr-guide {
|
||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
:is(.cr-block, .cr-prop-row, .cr-prop-item)[data-flash] {
|
:is(.cr-block, .cr-prop-row, .cr-prop-item, [data-offset])[data-flash] {
|
||||||
animation: cr-flash-still 1500ms;
|
animation: cr-flash-still 1500ms;
|
||||||
}
|
}
|
||||||
.cr-table-row[data-flash] {
|
.cr-table-row[data-flash] {
|
||||||
|
|||||||
@@ -148,8 +148,20 @@ describe('跳到定义:滚到哪块、展开哪些', () => {
|
|||||||
|
|
||||||
it('页面上没有这个名字:按行找包含它的块,也展开祖先', () => {
|
it('页面上没有这个名字:按行找包含它的块,也展开祖先', () => {
|
||||||
expect(revealPlan(BLOCKS, 3, 999)).toEqual({ id: '1.0', expand: ['1'] })
|
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, 4, 999)).toEqual({ id: '1.0.0', expand: ['1', '1.0'] })
|
||||||
expect(revealPlan(BLOCKS, 20, 999)).toEqual({ id: '2', expand: [] })
|
expect(revealPlan(BLOCKS, 8, 999)).toEqual({ id: '2', expand: [] })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('一行落在后一块,不选已经结束的前一块;都包住时取最小的那块', () => {
|
||||||
|
const blocks = [
|
||||||
|
{ ...textBlock('0', 'class', 3, [word('类')]), endLine: 4 },
|
||||||
|
{ ...textBlock('1', 'function', 1, [word('函数')]), endLine: 8 },
|
||||||
|
]
|
||||||
|
expect(revealPlan(blocks, 6, 999)).toEqual({ id: '1', expand: [] })
|
||||||
|
const inner = textBlock('0.0', 'return', 2, [word('返回')])
|
||||||
|
const outer = { ...textBlock('0', 'function', 1, [word('函数')], [inner]), endLine: 8 }
|
||||||
|
expect(revealPlan([outer], 2, 999)).toEqual({ id: '0.0', expand: ['0'] })
|
||||||
|
expect(revealPlan([outer], 6, 999)).toEqual({ id: '0', expand: [] })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('函数块默认折叠:目标在函数体里时,要展开的块里有这个函数;展开过一次就记住,不再挡着', () => {
|
it('函数块默认折叠:目标在函数体里时,要展开的块里有这个函数;展开过一次就记住,不再挡着', () => {
|
||||||
@@ -170,7 +182,7 @@ describe('跳到定义:滚到哪块、展开哪些', () => {
|
|||||||
expect(revealPlan([card], 0, 300)).toEqual({ id: '0.1.0', expand: [propertyRowId('0', 3), '0.1', propertyRowId('0.1', 2)] })
|
expect(revealPlan([card], 0, 300)).toEqual({ id: '0.1.0', expand: [propertyRowId('0', 3), '0.1', propertyRowId('0.1', 2)] })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('按行找:同一行有几块取外层那块;行在所有块之前取第一块;没有块时没有目标', () => {
|
it('按行找:同一行有几块、一样大时取外层那块;行在所有块之前取第一块;没有块时没有目标', () => {
|
||||||
const sameLine = [textBlock('0', 'if', 1, [word('如果')], [textBlock('0.0', 'return', 1, [word('返回')])])]
|
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, 1, 999)).toEqual({ id: '0', expand: [] })
|
||||||
expect(revealPlan(sameLine, 0, 999)).toEqual({ id: '0', expand: [] })
|
expect(revealPlan(sameLine, 0, 999)).toEqual({ id: '0', expand: [] })
|
||||||
@@ -198,6 +210,16 @@ describe('跳到定义:闪哪一处(defines)', () => {
|
|||||||
]),
|
]),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
it('父块的大范围盖住定义下标时,停在后面那个显示着这个名字的函数上,不闪父块', () => {
|
||||||
|
const parent = {
|
||||||
|
...factBlock('0', 'class', 1, [{ label: '名称', value: [name('DeployActionError', 10)] }]),
|
||||||
|
endLine: 12,
|
||||||
|
defines: range(0, 400),
|
||||||
|
}
|
||||||
|
const fn = factBlock('1', 'function', 14, [{ label: '名称', value: [name('fetchAppDeployments', 80)] }])
|
||||||
|
expect(revealPlan([parent, fn], 14, 80)).toEqual({ id: '1', expand: [] })
|
||||||
|
})
|
||||||
|
|
||||||
it('定义是块自己的名字:闪整块,属性行也标了它也一样', () => {
|
it('定义是块自己的名字:闪整块,属性行也标了它也一样', () => {
|
||||||
expect(revealPlan(blocks, 0, 4)).toEqual({ id: '0', expand: [] })
|
expect(revealPlan(blocks, 0, 4)).toEqual({ id: '0', expand: [] })
|
||||||
})
|
})
|
||||||
@@ -451,4 +473,8 @@ describe('把目标块滚进视口', () => {
|
|||||||
expect(scrollTopToShow({ top: 580, bottom: 620 }, 100, 500)).toBe(350)
|
expect(scrollTopToShow({ top: 580, bottom: 620 }, 100, 500)).toBe(350)
|
||||||
expect(scrollTopToShow({ top: 900, bottom: 1600 }, 0, 500)).toBe(900)
|
expect(scrollTopToShow({ top: 900, bottom: 1600 }, 0, 500)).toBe(900)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('筛选栏挡住时,滚动位置让目标出现在筛选栏下面', () => {
|
||||||
|
expect(scrollTopToShow({ top: 900, bottom: 1600 }, 0, 548, 48)).toBe(852)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { BlockView, Occurrence, PreviewTable, PreviewText, PropertyItem, SourceRange } from './types'
|
import type { BlockView, Inline, Occurrence, PreviewTable, PreviewText, PropertyItem, SourceRange } from './types'
|
||||||
import { blockPath } from './filter'
|
import { blockPath } from './filter'
|
||||||
import { showsOccurrence } from './occurrences'
|
import { showsOccurrence, sourceOf } from './occurrences'
|
||||||
import { showsOffset } from './segments'
|
|
||||||
import { propertyItemId, propertyRowId, unfoldToShow } from './viewState'
|
import { propertyItemId, propertyRowId, unfoldToShow } from './viewState'
|
||||||
|
|
||||||
/** 跳到定义时要滚到的块,就是这处定义的属性行 / 子行(只闪它),以及要展开的块、属性行和子行:它们折叠着,目标就不在页面上。 */
|
/** 跳到定义时要滚到的块,就是这处定义的属性行 / 子行(只闪它),以及要展开的块、属性行和子行:它们折叠着,目标就不在页面上。 */
|
||||||
@@ -12,8 +11,12 @@ export interface RevealPlan {
|
|||||||
expand: string[]
|
expand: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 目标在一块里的哪儿:row、expand 同 RevealPlan,expand 不含祖先块。 */
|
/** 目标在一块里的哪儿:row、expand 同 RevealPlan,expand 不含祖先块。span 是盖住定义下标的那段源码有多长。 */
|
||||||
type Spot = Omit<RevealPlan, 'id'>
|
interface Spot {
|
||||||
|
row?: string
|
||||||
|
expand: string[]
|
||||||
|
span: number
|
||||||
|
}
|
||||||
|
|
||||||
interface Placed {
|
interface Placed {
|
||||||
block: BlockView
|
block: BlockView
|
||||||
@@ -34,9 +37,44 @@ function covers(range: SourceRange | undefined, offset: number): boolean {
|
|||||||
return range !== undefined && range.startIndex <= offset && offset < range.endIndex
|
return range !== undefined && range.startIndex <= offset && offset < range.endIndex
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 文字里(连框里)显示着 offset 处的名字;Markdown 说明里的名字不带下标。 */
|
/** 文字里(连框里)盖住 offset 的那段源码有多长;Markdown 说明里的名字不带下标。 */
|
||||||
function shows(text: PreviewText, offset: number): boolean {
|
function textSpan(text: PreviewText, offset: number): number | undefined {
|
||||||
return !('markdown' in text) && showsOffset(text, offset)
|
return 'markdown' in text ? undefined : inlineSpan(text, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
function inlineSpan(pieces: Inline[], offset: number): number | undefined {
|
||||||
|
for (const piece of pieces) {
|
||||||
|
if (!('role' in piece)) {
|
||||||
|
const inner = inlineSpan(piece.segments, offset)
|
||||||
|
if (inner !== undefined)
|
||||||
|
return inner
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const range = sourceOf(piece)
|
||||||
|
if (range && range.start <= offset && offset < range.end)
|
||||||
|
return range.end - range.start
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function width(range: SourceRange): number {
|
||||||
|
return range.endIndex - range.startIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 实参表(一行译文下面那张、值那一格里套着的,都总是展开的)里有格子盖住 offset 时,那段有多长;套着的表一层层都算。 */
|
||||||
|
function tableSpan(table: PreviewTable | undefined, offset: number): number | undefined {
|
||||||
|
for (const item of table?.items ?? []) {
|
||||||
|
for (const cell of item.cells) {
|
||||||
|
const span = textSpan(cell, offset)
|
||||||
|
if (span !== undefined)
|
||||||
|
return span
|
||||||
|
}
|
||||||
|
const nested = tableSpan(item.table, offset)
|
||||||
|
if (nested !== undefined)
|
||||||
|
return nested
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 实参表(一行译文下面那张、值那一格里套着的,都总是展开的)里有格子满足 test,套着的表一层层都算。 */
|
/** 实参表(一行译文下面那张、值那一格里套着的,都总是展开的)里有格子满足 test,套着的表一层层都算。 */
|
||||||
@@ -49,28 +87,31 @@ function tableShows(table: PreviewTable | undefined, test: (cell: PreviewText) =
|
|||||||
* ① 块自己的名字(函数名、类名、只声明一个变量时的变量名)→ 整块;
|
* ① 块自己的名字(函数名、类名、只声明一个变量时的变量名)→ 整块;
|
||||||
* ② 属性表里讲这个定义的一行 / 子行(defines 盖住 offset:参数、导入项、多变量声明里的一个、对象一项的键)→ 只闪它,
|
* ② 属性表里讲这个定义的一行 / 子行(defines 盖住 offset:参数、导入项、多变量声明里的一个、对象一项的键)→ 只闪它,
|
||||||
* 子行连它上面的各层(所在的行、嵌套时外层的子行)一起展开;
|
* 子行连它上面的各层(所在的行、嵌套时外层的子行)一起展开;
|
||||||
* ③ 块里只是显示着这个名字(一行译文和它下面的表里的名字、没填 defines 的翻译插件)→ 整块,名字在子行里时展开它上面的各层。
|
* ③ 块里只是显示着这个名字(一段源码范围盖住 offset:一行译文和它下面的表里的名字、没填 defines 的翻译插件)→ 整块,名字在子行里时展开它上面的各层。
|
||||||
* 不在这块里是 undefined。
|
* span 是盖住 offset 的那段有多长。不在这块里是 undefined。
|
||||||
*/
|
*/
|
||||||
function locate(block: BlockView, offset: number): Spot | undefined {
|
function locate(block: BlockView, offset: number): Spot | undefined {
|
||||||
if (covers(block.defines, offset))
|
if (block.defines && covers(block.defines, offset))
|
||||||
return { expand: [] }
|
return { expand: [], span: width(block.defines) }
|
||||||
if ('translation' in block)
|
if ('translation' in block) {
|
||||||
return shows(block.translation, offset) || tableShows(block.table, cell => shows(cell, offset)) ? { expand: [] } : undefined
|
const span = textSpan(block.translation, offset) ?? tableSpan(block.table, offset)
|
||||||
|
return span === undefined ? undefined : { expand: [], span }
|
||||||
|
}
|
||||||
const rows = block.properties.map((row, index) => ({ row, id: propertyRowId(block.id, index) }))
|
const rows = block.properties.map((row, index) => ({ row, id: propertyRowId(block.id, index) }))
|
||||||
for (const { row, id } of rows) {
|
for (const { row, id } of rows) {
|
||||||
if (covers(row.defines, offset))
|
if (row.defines && covers(row.defines, offset))
|
||||||
return { row: id, expand: [] }
|
return { row: id, expand: [], span: width(row.defines) }
|
||||||
const item = definingItem(row.items, id, offset)
|
const item = definingItem(row.items, id, offset)
|
||||||
if (item)
|
if (item)
|
||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
for (const { row, id } of rows) {
|
for (const { row, id } of rows) {
|
||||||
if (shows(row.value, offset))
|
const value = textSpan(row.value, offset)
|
||||||
return { expand: [] }
|
if (value !== undefined)
|
||||||
const expand = showingItem(row.items, id, cell => shows(cell, offset))
|
return { expand: [], span: value }
|
||||||
if (expand)
|
const shown = shownItem(row.items, id, offset)
|
||||||
return { expand }
|
if (shown)
|
||||||
|
return shown
|
||||||
}
|
}
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
@@ -79,8 +120,8 @@ function locate(block: BlockView, offset: number): Spot | undefined {
|
|||||||
function definingItem(items: PropertyItem[] | undefined, parentId: string, offset: number): Spot | undefined {
|
function definingItem(items: PropertyItem[] | undefined, parentId: string, offset: number): Spot | undefined {
|
||||||
for (const [index, item] of (items ?? []).entries()) {
|
for (const [index, item] of (items ?? []).entries()) {
|
||||||
const id = propertyItemId(parentId, index)
|
const id = propertyItemId(parentId, index)
|
||||||
if (covers(item.defines, offset))
|
if (item.defines && covers(item.defines, offset))
|
||||||
return { row: id, expand: [parentId] }
|
return { row: id, expand: [parentId], span: width(item.defines) }
|
||||||
const inner = definingItem(item.items, id, offset)
|
const inner = definingItem(item.items, id, offset)
|
||||||
if (inner)
|
if (inner)
|
||||||
return { ...inner, expand: [parentId, ...inner.expand] }
|
return { ...inner, expand: [parentId, ...inner.expand] }
|
||||||
@@ -88,6 +129,25 @@ function definingItem(items: PropertyItem[] | undefined, parentId: string, offse
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** parentId 下面一层层的子行里,格子(连值那一格里套着的实参表)盖住 offset 的:展开它上面的各层,span 是那段有多长。 */
|
||||||
|
function shownItem(items: PropertyItem[] | undefined, parentId: string, offset: number): Spot | undefined {
|
||||||
|
for (const [index, item] of (items ?? []).entries()) {
|
||||||
|
let span: number | undefined
|
||||||
|
for (const cell of item.cells) {
|
||||||
|
span = textSpan(cell, offset)
|
||||||
|
if (span !== undefined)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
span ??= tableSpan(item.table, offset)
|
||||||
|
if (span !== undefined)
|
||||||
|
return { expand: [parentId], span }
|
||||||
|
const inner = shownItem(item.items, propertyItemId(parentId, index), offset)
|
||||||
|
if (inner)
|
||||||
|
return { ...inner, expand: [parentId, ...inner.expand] }
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* parentId 下面一层层的子行里,哪个的格子(连它值那一格里套着的实参表)满足 test(显示着要找的名字):
|
* parentId 下面一层层的子行里,哪个的格子(连它值那一格里套着的实参表)满足 test(显示着要找的名字):
|
||||||
* 要展开的各层(parentId 到那个子行所在的那一层)。
|
* 要展开的各层(parentId 到那个子行所在的那一层)。
|
||||||
@@ -104,18 +164,30 @@ function showingItem(items: PropertyItem[] | undefined, parentId: string, test:
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 跳到定义:目标见 locate;页面上找不到这处定义(例如定义是整个模块,落在文件开头)就取包含 line 的块,规则同 blockForLine。
|
* 跳到定义:目标见 locate。几块的源码范围都盖住这个下标时,留盖住它的那段最短的块——
|
||||||
|
* 父块的大范围盖住后面那个函数的名字时,停在函数上,不闪父块。
|
||||||
|
* 页面上找不到这处定义,就取包住 line 的块(startLine <= line <= endLine);几块都包住时取行数最少的,一样少时取先遇到的外层。
|
||||||
|
* 没有任何块包住这一行(它在所有块之前,或落在块与块的空档)时取第一块。没有块时没有目标。
|
||||||
* 目标的祖先块都要展开。
|
* 目标的祖先块都要展开。
|
||||||
*/
|
*/
|
||||||
export function revealPlan(blocks: BlockView[], line: number, definition: number): RevealPlan | undefined {
|
export function revealPlan(blocks: BlockView[], line: number, definition: number): RevealPlan | undefined {
|
||||||
|
let best: { plan: RevealPlan, span: number } | undefined
|
||||||
let byLine: Placed | undefined
|
let byLine: Placed | undefined
|
||||||
|
let byLineSpan = Number.POSITIVE_INFINITY
|
||||||
for (const item of placed(blocks)) {
|
for (const item of placed(blocks)) {
|
||||||
const spot = locate(item.block, definition)
|
const spot = locate(item.block, definition)
|
||||||
if (spot)
|
if (spot && (best === undefined || spot.span < best.span))
|
||||||
return { id: item.block.id, ...spot, expand: [...item.ancestors, ...spot.expand] }
|
best = { span: spot.span, plan: { id: item.block.id, ...(spot.row !== undefined && { row: spot.row }), expand: [...item.ancestors, ...spot.expand] } }
|
||||||
if (item.block.startLine <= line && (!byLine || item.block.startLine > byLine.block.startLine))
|
if (item.block.startLine <= line && line <= item.block.endLine) {
|
||||||
byLine = item
|
const span = item.block.endLine - item.block.startLine
|
||||||
|
if (span < byLineSpan) {
|
||||||
|
byLine = item
|
||||||
|
byLineSpan = span
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if (best)
|
||||||
|
return best.plan
|
||||||
const target = byLine ?? (blocks.length > 0 ? { block: blocks[0], ancestors: [] } : undefined)
|
const target = byLine ?? (blocks.length > 0 ? { block: blocks[0], ancestors: [] } : undefined)
|
||||||
return target && { id: target.block.id, expand: target.ancestors }
|
return target && { id: target.block.id, expand: target.ancestors }
|
||||||
}
|
}
|
||||||
@@ -158,12 +230,18 @@ export function occurrencePlan(blocks: BlockView[], occurrence: Occurrence, skip
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 让一块露出来时视口顶部要在的位置,和 VS Code 跳到定义一样:整块已经在视口里就不滚;否则放到视口正中,比视口还高就顶部对齐。
|
* 让目标露出来时 window 要滚到的位置(scrollY)。
|
||||||
* viewTop / viewHeight 是视口里露出块的那部分(钉住的筛选栏下面)。
|
* viewTop / viewHeight 是眼下整个视口(文档坐标里,视口顶到视口底)。
|
||||||
|
* inset 是钉住的筛选栏高度:能看见字的区域从 viewTop + inset 开始,高 viewHeight - inset。
|
||||||
|
* 目标已经整个在这块区域里就不滚;放得下就放到这块区域正中,比它还高就让目标顶对齐区域顶——
|
||||||
|
* 返回的 scrollY 已经减去筛选栏,目标顶出现在栏下面,不会藏到栏后面。
|
||||||
*/
|
*/
|
||||||
export function scrollTopToShow(box: { top: number, bottom: number }, viewTop: number, viewHeight: number): number | undefined {
|
export function scrollTopToShow(box: { top: number, bottom: number }, viewTop: number, viewHeight: number, inset = 0): number | undefined {
|
||||||
if (box.top >= viewTop && box.bottom <= viewTop + viewHeight)
|
const visibleTop = viewTop + inset
|
||||||
|
const visibleHeight = viewHeight - inset
|
||||||
|
if (box.top >= visibleTop && box.bottom <= visibleTop + visibleHeight)
|
||||||
return undefined
|
return undefined
|
||||||
const height = box.bottom - box.top
|
const height = box.bottom - box.top
|
||||||
return Math.max(0, height >= viewHeight ? box.top : box.top - (viewHeight - height) / 2)
|
const aligned = height >= visibleHeight ? box.top : box.top - (visibleHeight - height) / 2
|
||||||
|
return Math.max(0, aligned - inset)
|
||||||
}
|
}
|
||||||
@@ -24,25 +24,41 @@ export function flash(el: HTMLElement): void {
|
|||||||
el.setAttribute('data-flash', '')
|
el.setAttribute('data-flash', '')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 把 el 滚进钉住的筛选栏下面的视口,规则见 scrollTopToShow:已经整个看得见就不滚。 */
|
/** 把 el 滚进钉住的筛选栏下面的视口。已经整个看得见就不滚。beforeScroll 在真正滚动前拿到将要落到的位置。 */
|
||||||
/** 把 el 滚进钉住的筛选栏下面的视口,规则见 scrollTopToShow:已经整个看得见就不滚。beforeScroll 在真正滚动前拿到将要落到的位置。 */
|
|
||||||
export function showInViewport(el: HTMLElement, beforeScroll?: (position: ScrollPosition) => void): void {
|
export function showInViewport(el: HTMLElement, beforeScroll?: (position: ScrollPosition) => void): void {
|
||||||
const rect = el.getBoundingClientRect()
|
const rect = el.getBoundingClientRect()
|
||||||
const pinned = pinnedHeight()
|
const pinned = pinnedHeight()
|
||||||
const top = scrollTopToShow({ top: rect.top + window.scrollY, bottom: rect.bottom + window.scrollY }, window.scrollY + pinned, window.innerHeight - pinned)
|
const top = scrollTopToShow(
|
||||||
|
{ top: rect.top + window.scrollY, bottom: rect.bottom + window.scrollY },
|
||||||
|
window.scrollY,
|
||||||
|
window.innerHeight,
|
||||||
|
pinned,
|
||||||
|
)
|
||||||
if (top === undefined)
|
if (top === undefined)
|
||||||
return
|
return
|
||||||
const position = { x: 0, y: top - pinned }
|
const position = { x: 0, y: top }
|
||||||
beforeScroll?.(position)
|
beforeScroll?.(position)
|
||||||
window.scrollTo(position.x, position.y)
|
window.scrollTo(position.x, position.y)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 这块自己显示着的、下标就是定义处的那个名字。子块里的同名下标不算,免得闪到旁边那张卡片。 */
|
||||||
|
function nameAt(block: HTMLElement, offset: number): HTMLElement | null {
|
||||||
|
for (const el of block.querySelectorAll<HTMLElement>('[data-offset]')) {
|
||||||
|
if (Number(el.dataset.offset) !== offset)
|
||||||
|
continue
|
||||||
|
if (el.closest('.cr-block') === block)
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 滚到 reveal 指的位置,处理完调 done:
|
* 滚到 reveal 指的位置,处理完调 done:
|
||||||
* ① 从文本编辑器切过来(没有 definition):包含那一行的块顶到视口顶部(筛选栏下面),折叠着的、被筛掉的不展开;
|
* ① 从文本编辑器切过来(没有 definition):包含那一行的块顶到视口顶部(筛选栏下面),折叠着的、被筛掉的不展开;
|
||||||
* ② 跳到定义:目标或它的祖先块被筛掉了,先临时露出这条路径(show);再展开把目标折叠住的块、属性行和子行(expand)——
|
* ② 跳到定义:目标或它的祖先块被筛掉了,先临时露出这条路径(show);再展开把目标折叠住的块、属性行和子行(expand)——
|
||||||
* 每一步之后重新渲染、再进来一次;最后把目标(整块,或就是这处定义的属性行 / 子行)滚进视口、闪几下。
|
* 每一步之后重新渲染、再进来一次;最后把目标滚进筛选栏下面并闪几下。
|
||||||
* 目标被正在编辑的祖先盖住时闪那个祖先(uncoveredPlan)。
|
* 属性行 / 子行是这处定义时闪那一行;这块里显示着这个名字(下标盖住定义处)就闪这个名字,不闪整张卡片;
|
||||||
|
* 名字不在页面上才闪整块。目标被正在编辑的祖先盖住时闪那个祖先(uncoveredPlan)。
|
||||||
*/
|
*/
|
||||||
export function useReveal(
|
export function useReveal(
|
||||||
doc: DocumentMessage | null,
|
doc: DocumentMessage | null,
|
||||||
@@ -77,8 +93,10 @@ export function useReveal(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 块正在编辑时页面上没有属性表,就闪整块
|
// 块正在编辑时页面上没有属性表,就闪整块
|
||||||
|
const block = plan ? document.querySelector<HTMLElement>(`.cr-block[data-id="${plan.id}"]`) : null
|
||||||
const row = plan?.row === undefined ? null : document.querySelector<HTMLElement>(`:is(.cr-prop-row, .cr-prop-item, .cr-table-row)[data-id="${plan.row}"]`)
|
const row = plan?.row === undefined ? null : document.querySelector<HTMLElement>(`:is(.cr-prop-row, .cr-prop-item, .cr-table-row)[data-id="${plan.row}"]`)
|
||||||
const el = row ?? (plan && document.querySelector<HTMLElement>(`.cr-block[data-id="${plan.id}"]`))
|
const name = row || !block ? null : nameAt(block, reveal.definition)
|
||||||
|
const el = row ?? name ?? block
|
||||||
if (el) {
|
if (el) {
|
||||||
showInViewport(el)
|
showInViewport(el)
|
||||||
flash(el)
|
flash(el)
|
||||||
|
|||||||
Reference in new issue
Block a user