diff --git a/vscode/src/codeReader/editor/definitions.test.ts b/vscode/src/codeReader/editor/definitions.test.ts new file mode 100644 index 0000000..57fbc29 --- /dev/null +++ b/vscode/src/codeReader/editor/definitions.test.ts @@ -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')) + }) +}) diff --git a/vscode/src/codeReader/editor/definitions.ts b/vscode/src/codeReader/editor/definitions.ts index c990f1a..86f8592 100644 --- a/vscode/src/codeReader/editor/definitions.ts +++ b/vscode/src/codeReader/editor/definitions.ts @@ -18,7 +18,55 @@ export interface Definition extends DefinitionTarget { 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 { let provided: (Location | LocationLink)[] 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)}` }) return [] } - return provided.map(item => 'targetUri' in item - ? { uri: item.targetUri, range: item.targetSelectionRange ?? item.targetRange } - : { uri: item.uri, range: item.range }) + const name = await clickedName(uri, position) + const located = await Promise.all(provided.map(async (item): Promise => { + 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 { + 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 { + 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 的位置。 */ diff --git a/vscode/webview-ui/src/codeReader/codeReader.css b/vscode/webview-ui/src/codeReader/codeReader.css index 56ad74e..42a72c0 100644 --- a/vscode/webview-ui/src/codeReader/codeReader.css +++ b/vscode/webview-ui/src/codeReader/codeReader.css @@ -143,7 +143,7 @@ body:is(.vscode-light, .vscode-high-contrast-light) [data-badge] { --kind: var(- border-radius: 4px; } -/* 跳到定义的目标(整块,或属性表里的一行 / 子行):边框和底色往聚焦色闪三下(共 1.5 秒)再复原;减少动态效果时改成静止高亮同样长的时间 */ +/* 跳到定义的目标(整块、属性表里的一行 / 子行,或定义处那个名字):边框和底色往聚焦色闪三下(共 1.5 秒)再复原;减少动态效果时改成静止高亮同样长的时间 */ @keyframes cr-flash { 50% { 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); } } -: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; } /* 表格里的一行(参数):边框是和相邻行共用的分隔线,改用画在行里面的一圈聚焦色 */ @@ -181,7 +181,7 @@ body:is(.vscode-light, .vscode-high-contrast-light) [data-badge] { --kind: var(- .cr-guide { 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; } .cr-table-row[data-flash] { diff --git a/vscode/webview-ui/src/codeReader/reveal.test.ts b/vscode/webview-ui/src/codeReader/reveal.test.ts index a7ee4c5..1be4df1 100644 --- a/vscode/webview-ui/src/codeReader/reveal.test.ts +++ b/vscode/webview-ui/src/codeReader/reveal.test.ts @@ -148,8 +148,20 @@ describe('跳到定义:滚到哪块、展开哪些', () => { 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: [] }) + expect(revealPlan(BLOCKS, 4, 999)).toEqual({ id: '1.0.0', expand: ['1', '1.0'] }) + 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('函数块默认折叠:目标在函数体里时,要展开的块里有这个函数;展开过一次就记住,不再挡着', () => { @@ -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)] }) }) - it('按行找:同一行有几块取外层那块;行在所有块之前取第一块;没有块时没有目标', () => { + 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: [] }) @@ -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('定义是块自己的名字:闪整块,属性行也标了它也一样', () => { 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: 900, bottom: 1600 }, 0, 500)).toBe(900) }) + + it('筛选栏挡住时,滚动位置让目标出现在筛选栏下面', () => { + expect(scrollTopToShow({ top: 900, bottom: 1600 }, 0, 548, 48)).toBe(852) + }) }) diff --git a/vscode/webview-ui/src/codeReader/reveal.ts b/vscode/webview-ui/src/codeReader/reveal.ts index 6751ecf..13320c2 100644 --- a/vscode/webview-ui/src/codeReader/reveal.ts +++ b/vscode/webview-ui/src/codeReader/reveal.ts @@ -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 { showsOccurrence } from './occurrences' -import { showsOffset } from './segments' +import { showsOccurrence, sourceOf } from './occurrences' import { propertyItemId, propertyRowId, unfoldToShow } from './viewState' /** 跳到定义时要滚到的块,就是这处定义的属性行 / 子行(只闪它),以及要展开的块、属性行和子行:它们折叠着,目标就不在页面上。 */ @@ -12,8 +11,12 @@ export interface RevealPlan { expand: string[] } -/** 目标在一块里的哪儿:row、expand 同 RevealPlan,expand 不含祖先块。 */ -type Spot = Omit +/** 目标在一块里的哪儿:row、expand 同 RevealPlan,expand 不含祖先块。span 是盖住定义下标的那段源码有多长。 */ +interface Spot { + row?: string + expand: string[] + span: number +} interface Placed { block: BlockView @@ -34,9 +37,44 @@ function covers(range: SourceRange | undefined, offset: number): boolean { return range !== undefined && range.startIndex <= offset && offset < range.endIndex } -/** 文字里(连框里)显示着 offset 处的名字;Markdown 说明里的名字不带下标。 */ -function shows(text: PreviewText, offset: number): boolean { - return !('markdown' in text) && showsOffset(text, offset) +/** 文字里(连框里)盖住 offset 的那段源码有多长;Markdown 说明里的名字不带下标。 */ +function textSpan(text: PreviewText, offset: number): number | undefined { + 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,套着的表一层层都算。 */ @@ -49,28 +87,31 @@ function tableShows(table: PreviewTable | undefined, test: (cell: PreviewText) = * ① 块自己的名字(函数名、类名、只声明一个变量时的变量名)→ 整块; * ② 属性表里讲这个定义的一行 / 子行(defines 盖住 offset:参数、导入项、多变量声明里的一个、对象一项的键)→ 只闪它, * 子行连它上面的各层(所在的行、嵌套时外层的子行)一起展开; - * ③ 块里只是显示着这个名字(一行译文和它下面的表里的名字、没填 defines 的翻译插件)→ 整块,名字在子行里时展开它上面的各层。 - * 不在这块里是 undefined。 + * ③ 块里只是显示着这个名字(一段源码范围盖住 offset:一行译文和它下面的表里的名字、没填 defines 的翻译插件)→ 整块,名字在子行里时展开它上面的各层。 + * span 是盖住 offset 的那段有多长。不在这块里是 undefined。 */ function locate(block: BlockView, offset: number): Spot | undefined { - if (covers(block.defines, offset)) - return { expand: [] } - if ('translation' in block) - return shows(block.translation, offset) || tableShows(block.table, cell => shows(cell, offset)) ? { expand: [] } : undefined + if (block.defines && covers(block.defines, offset)) + return { expand: [], span: width(block.defines) } + if ('translation' in block) { + 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) })) for (const { row, id } of rows) { - if (covers(row.defines, offset)) - return { row: id, expand: [] } + if (row.defines && covers(row.defines, offset)) + return { row: id, expand: [], span: width(row.defines) } const item = definingItem(row.items, id, offset) if (item) return item } for (const { row, id } of rows) { - if (shows(row.value, offset)) - return { expand: [] } - const expand = showingItem(row.items, id, cell => shows(cell, offset)) - if (expand) - return { expand } + const value = textSpan(row.value, offset) + if (value !== undefined) + return { expand: [], span: value } + const shown = shownItem(row.items, id, offset) + if (shown) + return shown } 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 { for (const [index, item] of (items ?? []).entries()) { const id = propertyItemId(parentId, index) - if (covers(item.defines, offset)) - return { row: id, expand: [parentId] } + if (item.defines && covers(item.defines, offset)) + return { row: id, expand: [parentId], span: width(item.defines) } const inner = definingItem(item.items, id, offset) if (inner) return { ...inner, expand: [parentId, ...inner.expand] } @@ -88,6 +129,25 @@ function definingItem(items: PropertyItem[] | undefined, parentId: string, offse 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 到那个子行所在的那一层)。 @@ -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 { + let best: { plan: RevealPlan, span: number } | undefined let byLine: Placed | undefined + let byLineSpan = Number.POSITIVE_INFINITY for (const item of placed(blocks)) { const spot = locate(item.block, definition) - if (spot) - return { id: item.block.id, ...spot, expand: [...item.ancestors, ...spot.expand] } - if (item.block.startLine <= line && (!byLine || item.block.startLine > byLine.block.startLine)) - byLine = item + if (spot && (best === undefined || spot.span < best.span)) + 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 && line <= item.block.endLine) { + 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) return target && { id: target.block.id, expand: target.ancestors } } @@ -158,12 +230,18 @@ export function occurrencePlan(blocks: BlockView[], occurrence: Occurrence, skip } /** - * 让一块露出来时视口顶部要在的位置,和 VS Code 跳到定义一样:整块已经在视口里就不滚;否则放到视口正中,比视口还高就顶部对齐。 - * viewTop / viewHeight 是视口里露出块的那部分(钉住的筛选栏下面)。 + * 让目标露出来时 window 要滚到的位置(scrollY)。 + * viewTop / viewHeight 是眼下整个视口(文档坐标里,视口顶到视口底)。 + * inset 是钉住的筛选栏高度:能看见字的区域从 viewTop + inset 开始,高 viewHeight - inset。 + * 目标已经整个在这块区域里就不滚;放得下就放到这块区域正中,比它还高就让目标顶对齐区域顶—— + * 返回的 scrollY 已经减去筛选栏,目标顶出现在栏下面,不会藏到栏后面。 */ -export function scrollTopToShow(box: { top: number, bottom: number }, viewTop: number, viewHeight: number): number | undefined { - if (box.top >= viewTop && box.bottom <= viewTop + viewHeight) +export function scrollTopToShow(box: { top: number, bottom: number }, viewTop: number, viewHeight: number, inset = 0): number | undefined { + const visibleTop = viewTop + inset + const visibleHeight = viewHeight - inset + if (box.top >= visibleTop && box.bottom <= visibleTop + visibleHeight) return undefined 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) } diff --git a/vscode/webview-ui/src/codeReader/useReveal.ts b/vscode/webview-ui/src/codeReader/useReveal.ts index 55ebd6c..6b0be8f 100644 --- a/vscode/webview-ui/src/codeReader/useReveal.ts +++ b/vscode/webview-ui/src/codeReader/useReveal.ts @@ -24,25 +24,41 @@ export function flash(el: HTMLElement): void { el.setAttribute('data-flash', '') } -/** 把 el 滚进钉住的筛选栏下面的视口,规则见 scrollTopToShow:已经整个看得见就不滚。 */ -/** 把 el 滚进钉住的筛选栏下面的视口,规则见 scrollTopToShow:已经整个看得见就不滚。beforeScroll 在真正滚动前拿到将要落到的位置。 */ +/** 把 el 滚进钉住的筛选栏下面的视口。已经整个看得见就不滚。beforeScroll 在真正滚动前拿到将要落到的位置。 */ export function showInViewport(el: HTMLElement, beforeScroll?: (position: ScrollPosition) => void): void { const rect = el.getBoundingClientRect() 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) return - const position = { x: 0, y: top - pinned } + const position = { x: 0, y: top } beforeScroll?.(position) window.scrollTo(position.x, position.y) } +/** 这块自己显示着的、下标就是定义处的那个名字。子块里的同名下标不算,免得闪到旁边那张卡片。 */ +function nameAt(block: HTMLElement, offset: number): HTMLElement | null { + for (const el of block.querySelectorAll('[data-offset]')) { + if (Number(el.dataset.offset) !== offset) + continue + if (el.closest('.cr-block') === block) + return el + } + return null +} + /** * 滚到 reveal 指的位置,处理完调 done: * ① 从文本编辑器切过来(没有 definition):包含那一行的块顶到视口顶部(筛选栏下面),折叠着的、被筛掉的不展开; * ② 跳到定义:目标或它的祖先块被筛掉了,先临时露出这条路径(show);再展开把目标折叠住的块、属性行和子行(expand)—— - * 每一步之后重新渲染、再进来一次;最后把目标(整块,或就是这处定义的属性行 / 子行)滚进视口、闪几下。 - * 目标被正在编辑的祖先盖住时闪那个祖先(uncoveredPlan)。 + * 每一步之后重新渲染、再进来一次;最后把目标滚进筛选栏下面并闪几下。 + * 属性行 / 子行是这处定义时闪那一行;这块里显示着这个名字(下标盖住定义处)就闪这个名字,不闪整张卡片; + * 名字不在页面上才闪整块。目标被正在编辑的祖先盖住时闪那个祖先(uncoveredPlan)。 */ export function useReveal( doc: DocumentMessage | null, @@ -77,8 +93,10 @@ export function useReveal( return } // 块正在编辑时页面上没有属性表,就闪整块 + const block = plan ? document.querySelector(`.cr-block[data-id="${plan.id}"]`) : null const row = plan?.row === undefined ? null : document.querySelector(`:is(.cr-prop-row, .cr-prop-item, .cr-table-row)[data-id="${plan.row}"]`) - const el = row ?? (plan && document.querySelector(`.cr-block[data-id="${plan.id}"]`)) + const name = row || !block ? null : nameAt(block, reveal.definition) + const el = row ?? name ?? block if (el) { showInViewport(el) flash(el)