✨ feat(vscode): 代码阅读预览加 ⌘F 查找栏:整份文件查(折叠着的块里的也算,跳过去时展开),按页面上显示的字不分大小写匹配、中文名也认原名,Enter / ⌘G 跳、Esc 关,高亮直接画在页面上不重画卡片

This commit is contained in:
cruldra committed 2026-10-14 05:40:59 +11:00
1 parent cc3fcda051
commit bd474986bd
27 files changed
+1833 -114

No files matched your search

+45
View File
@@ -1321,6 +1321,21 @@
"command": "superpowers.codeReader.previousOccurrence",
"title": "跳到上一处高亮",
"category": "代码阅读"
},
{
"command": "superpowers.codeReader.find",
"title": "查找",
"category": "代码阅读"
},
{
"command": "superpowers.codeReader.findNext",
"title": "查找下一处",
"category": "代码阅读"
},
{
"command": "superpowers.codeReader.findPrevious",
"title": "查找上一处",
"category": "代码阅读"
}
],
"keybindings": [
@@ -1335,6 +1350,24 @@
"key": "ctrl+up",
"mac": "cmd+up",
"when": "activeCustomEditorId == superpowers.codeReader.preview && superpowers.codeReader.hasOccurrences"
},
{
"command": "superpowers.codeReader.find",
"key": "ctrl+f",
"mac": "cmd+f",
"when": "activeCustomEditorId == superpowers.codeReader.preview"
},
{
"command": "superpowers.codeReader.findNext",
"key": "f3",
"mac": "cmd+g",
"when": "activeCustomEditorId == superpowers.codeReader.preview && superpowers.codeReader.findOpen"
},
{
"command": "superpowers.codeReader.findPrevious",
"key": "shift+f3",
"mac": "shift+cmd+g",
"when": "activeCustomEditorId == superpowers.codeReader.preview && superpowers.codeReader.findOpen"
}
],
"submenus": [
@@ -2940,6 +2973,18 @@
"command": "superpowers.codeReader.previousOccurrence",
"when": "activeCustomEditorId == superpowers.codeReader.preview"
},
{
"command": "superpowers.codeReader.find",
"when": "activeCustomEditorId == superpowers.codeReader.preview"
},
{
"command": "superpowers.codeReader.findNext",
"when": "activeCustomEditorId == superpowers.codeReader.preview"
},
{
"command": "superpowers.codeReader.findPrevious",
"when": "activeCustomEditorId == superpowers.codeReader.preview"
},
{
"command": "superpowers.codeReader.copyFileName",
"when": "false"
@@ -0,0 +1,50 @@
import { readFileSync } from 'node:fs'
import * as path from 'node:path'
import { describe, expect, it } from 'vitest'
import { FIND, FIND_CONTEXT, FIND_NEXT, FIND_PREVIOUS } from './findCommands'
interface Manifest {
contributes: {
commands: { command: string, title: string, category?: string }[]
keybindings: { command: string, key: string, mac?: string, when?: string }[]
customEditors: { viewType: string }[]
menus: { commandPalette: { command: string, when: string }[] }
}
}
const manifest = JSON.parse(readFileSync(path.resolve(__dirname, '../../../package.json'), 'utf8')) as Manifest
const { commands, keybindings, customEditors, menus } = manifest.contributes
const PREVIEW = customEditors[0].viewType
const FIND_COMMANDS = [FIND, FIND_NEXT, FIND_PREVIOUS]
describe('预览里的查找栏:package.json 里的命令和快捷键', () => {
it('三条命令叫「代码阅读:查找 / 查找下一处 / 查找上一处」', () => {
expect(commands.filter(c => FIND_COMMANDS.includes(c.command)).map(c => `${c.category}:${c.title}`))
.toEqual(['代码阅读:查找', '代码阅读:查找下一处', '代码阅读:查找上一处'])
})
it('⌘F(别的系统 Ctrl+F)在代码阅读预览是活动编辑器时打开查找栏;⌘G / ⇧⌘G(别的系统 F3 / Shift+F3)只在查找栏开着时跳', () => {
const open = `activeCustomEditorId == ${PREVIEW} && ${FIND_CONTEXT}`
expect(keybindings.filter(k => FIND_COMMANDS.includes(k.command))).toEqual([
{ command: FIND, key: 'ctrl+f', mac: 'cmd+f', when: `activeCustomEditorId == ${PREVIEW}` },
{ command: FIND_NEXT, key: 'f3', mac: 'cmd+g', when: open },
{ command: FIND_PREVIOUS, key: 'shift+f3', mac: 'shift+cmd+g', when: open },
])
})
it('命令面板里只在代码阅读预览是活动编辑器时出现', () => {
expect(menus.commandPalette.filter(item => FIND_COMMANDS.includes(item.command))).toEqual(
FIND_COMMANDS.map(command => ({ command, when: `activeCustomEditorId == ${PREVIEW}` })),
)
})
it('扩展端不写死键,只转命令;Enter、Esc 这些查找栏自己的键归页面', () => {
const sources = ['findCommands.ts', 'previewEditor.ts', 'messages.ts', '../index.ts'].map(file => readFileSync(path.resolve(__dirname, file), 'utf8'))
for (const text of sources)
expect(text).not.toMatch(/cmd\+|ctrl\+|f3|Escape|'Enter'/)
const page = ['FindBar.tsx', 'useFind.ts'].map(file => readFileSync(path.resolve(__dirname, '../../../webview-ui/src/codeReader', file), 'utf8')).join('\n')
expect(page).toContain(`'Escape'`)
expect(page).toContain(`'Enter'`)
expect(page).toContain(`msg.type === 'moveFind'`)
})
})
@@ -0,0 +1,7 @@
/** 预览里的查找栏:三条命令、快捷键 when 子句用的上下文键。键写在 package.json 里,扩展端只把命令转给页面。 */
export const FIND = 'superpowers.codeReader.find'
export const FIND_NEXT = 'superpowers.codeReader.findNext'
export const FIND_PREVIOUS = 'superpowers.codeReader.findPrevious'
/** 活动的预览里查找栏开着、页面有焦点:查找下一处 / 上一处的快捷键这时才归它。 */
export const FIND_CONTEXT = 'superpowers.codeReader.findOpen'
+6
View File
@@ -197,6 +197,8 @@ export interface StickyScrollSettings {
* rewriteEnd:askId 那一轮重写结束了:done 是模型答完、这一轮记下了,带交回的正文(text)、存下的那条的 id,没写进文件时带原因(error);
* failed 带给人看的原因,stopped 是被叫停,这两种都没记、文件没改
* moveOccurrence:跳到高亮着的名字的下一处(step 1)/ 上一处(step -1)出现,由这两条命令发给活动的预览
* openFind:打开查找栏,由「查找」命令发给活动的预览
* moveFind:查找栏里跳到下一处(step 1)/ 上一处(step -1)命中,由「查找下一处 / 上一处」命令发给查找栏开着的预览
* displayNames:version 这份文档里显示成中文名的名字,查到一步发一步(先本文件定义的,再别的文件定义的);不是页面上这个版本的不理
* diff:这份文件相比对比基准改了什么(审查工作副本里的、从「未提交的更改」点开的有改动的才有,见 [对比基准](vscode/src/codeReader/diff/diffBase.ts#DiffBase)):每发一份文档之后算好了补发,
* 基准变了(提交后 HEAD 动了、换了审查的工单、文件有了改动、这个标签页换了从哪儿打开)再发;null 是不比了(退出了审查、改动提交了、从别处点开了)
@@ -219,6 +221,8 @@ export type ExtensionToWebview
| ({ type: 'stickyScroll' } & StickyScrollSettings)
| { type: 'occurrencesFound', id: number, occurrences: Occurrence[] }
| { type: 'moveOccurrence', step: 1 | -1 }
| { type: 'openFind' }
| { type: 'moveFind', step: 1 | -1 }
| { type: 'displayNames', version: number, names: ShownName[] }
| ({ type: 'filter' } & FilterState)
| ({ type: 'codeColors' } & CodeColors)
@@ -262,6 +266,7 @@ export type ExtensionToWebview
* selectProfile:kind 那种对话换 profile(id 是 profile 名,「默认」是 ''),扩展端记下后把这种的 profiles 发给所有预览
* occurrenceNavigation:眼下能不能用键盘在高亮着的名字的各处出现之间跳(有高亮、页面有焦点、焦点不在输入框里),变了才发;
* 扩展端按它设 when 子句用的上下文键
* findNavigation:眼下查找栏的快捷键归不归这个页面(查找栏开着、页面有焦点),变了才发;扩展端按它设 when 子句用的上下文键
* openRepoLink:点了注释里的仓库路径链接,打开 path(从仓库根算起)。symbol 是类名、函数名或「类名.成员名」,没有就只打开文件或在代码阅读侧栏的树上定位目录;
* 没有 path 是不带路径的引用(`[Name]`、`[Class.member]`),按这份文件找 symbol:block 是它写在哪块的注释里(见 SymbolReference),
* 从那块的声明起一层层往外找本文件定义的,再是导入的(见 symbolLinks.ts)
@@ -291,6 +296,7 @@ export type WebviewToExtension
| { type: 'deleteQa', anchor: string, entryId: string }
| { type: 'selectProfile', kind: ChatKind, id: string }
| { type: 'occurrenceNavigation', available: boolean }
| { type: 'findNavigation', available: boolean }
| { type: 'openRepoLink', path?: string, symbol?: string, block?: string }
| { type: 'checkRepoLinks', id: number, paths: string[], references: SymbolReference[] }
| { type: 'checkRepoImages', id: number, paths: string[] }
@@ -9,6 +9,7 @@ import { describe, expect, it, vi } from 'vitest'
import { commands, window, workspace } from 'vscode'
import { documentMessage } from './documentMessage'
import { documentSymbolNodes } from './documentSymbols'
import { FIND_CONTEXT } from './findCommands'
import { OCCURRENCES_CONTEXT } from './occurrenceNavigation'
import { PREVIEW_VIEW_TYPE, PreviewEditorProvider } from './previewEditor'
@@ -563,3 +564,46 @@ describe('注释里不带路径的引用按它写在哪块的注释里找', () =
expect(page.posted()).toContainEqual({ type: 'repoLinkBroken', symbol: 'get_agent_admitted', block: HELPER })
})
})
describe('预览里的查找栏', () => {
const sent = (page: ReturnType<typeof tab>): ExtensionToWebview[] => page.posted().filter(msg => msg.type === 'openFind' || msg.type === 'moveFind')
it('「查找」发给活动的预览;「查找下一处 / 上一处」发给查找栏开着、有焦点的那个,都没有时发给活动的;还没 ready 的不发', async () => {
const { provider, document } = opening()
const first = tab()
const second = tab()
await provider.open(document.uri as never, {}, async () => provider.resolveCustomTextEditor(document as never, first.panel))
await provider.open(document.uri as never, {}, async () => provider.resolveCustomTextEditor(document as never, second.panel))
provider.find()
expect([...sent(first), ...sent(second)]).toEqual([])
await first.send({ type: 'ready' })
await second.send({ type: 'ready' })
first.hide()
provider.find()
expect(sent(first)).toEqual([])
expect(sent(second)).toEqual([{ type: 'openFind' }])
second.clear()
await first.send({ type: 'findNavigation', available: true })
provider.moveFind(1)
expect(sent(first)).toEqual([{ type: 'moveFind', step: 1 }])
expect(sent(second)).toEqual([])
await first.send({ type: 'findNavigation', available: false })
provider.moveFind(-1)
expect(sent(second)).toEqual([{ type: 'moveFind', step: -1 }])
})
it('页面报来查找栏开着、有焦点:设上下文键,⌘G 这时才归查找栏;变了才设', async () => {
const { provider, document } = opening()
const page = tab()
await provider.open(document.uri as never, {}, async () => provider.resolveCustomTextEditor(document as never, page.panel))
vi.mocked(commands.executeCommand).mockClear()
await page.send({ type: 'findNavigation', available: true })
await page.send({ type: 'findNavigation', available: true })
await page.send({ type: 'findNavigation', available: false })
expect(vi.mocked(commands.executeCommand).mock.calls.filter(([command, key]) => command === 'setContext' && key === FIND_CONTEXT))
.toEqual([['setContext', FIND_CONTEXT, true], ['setContext', FIND_CONTEXT, false]])
})
})
@@ -44,6 +44,7 @@ import { definitionKind, findDefinitions, languageDefinitions, pickDefinition, r
import { DefinitionSources } from './definitionSources'
import { documentMessage, pageDocument } from './documentMessage'
import { documentSymbolNodes } from './documentSymbols'
import { FIND_CONTEXT } from './findCommands'
import { NavigationContext, OCCURRENCES_CONTEXT } from './occurrenceNavigation'
import { occurrencesOf } from './occurrences'
import { languageOf, openingFor } from './openTarget'
@@ -80,6 +81,8 @@ export class PreviewEditorProvider implements CustomTextEditorProvider {
private readonly requests = new Map<string, OpenRequest>()
/** 哪个预览眼下能用键盘在高亮的各处出现之间跳,跟着它设快捷键 when 子句用的上下文键 */
readonly navigation = new NavigationContext<Preview>(value => void commands.executeCommand('setContext', OCCURRENCES_CONTEXT, value))
/** 哪个预览眼下查找栏开着、有焦点,跟着它设查找下一处 / 上一处快捷键 when 子句用的上下文键 */
readonly finding = new NavigationContext<Preview>(value => void commands.executeCommand('setContext', FIND_CONTEXT, value))
/** 调用的名字的定义是哪种块:各预览共用,定义所在的文件同一版只解析一次 */
readonly definitionKinds: DefinitionKinds
@@ -153,6 +156,7 @@ export class PreviewEditorProvider implements CustomTextEditorProvider {
panel.onDidDispose(() => {
this.previews.delete(preview)
this.navigation.update(preview, false)
this.finding.update(preview, false)
preview.dispose()
})
}
@@ -162,6 +166,16 @@ export class PreviewEditorProvider implements CustomTextEditorProvider {
this.navigation.target(this.previews, preview => preview.active)?.moveOccurrence(step)
}
/** 「查找」:打开查找栏。发给查找栏开着、有焦点的那个预览,没有就发给活动的预览。 */
find(): void {
this.finding.target(this.previews, preview => preview.active)?.find()
}
/** 「查找下一处 / 上一处」:发给查找栏开着、有焦点的那个预览,从命令面板运行时发给活动的预览。 */
moveFind(step: 1 | -1): void {
this.finding.target(this.previews, preview => preview.active)?.moveFind(step)
}
/** 让已打开的预览按当前注册表重发;给了 languageId 就只刷这种语言的。 */
refresh(languageId?: string): void {
for (const preview of this.previews) {
@@ -539,6 +553,18 @@ class Preview {
this.post({ type: 'moveOccurrence', step })
}
/** 打开查找栏;页面还没 ready 就算了。 */
find(): void {
if (this.ready)
this.post({ type: 'openFind' })
}
/** 查找栏里跳到下一处 / 上一处命中;页面还没 ready 就算了(还没有查找栏)。 */
moveFind(step: 1 | -1): void {
if (this.ready)
this.post({ type: 'moveFind', step })
}
/** 开着时又被点开(见 [PreviewEditorProvider.open]):这次说了从哪儿打开的就按它重新比,没说的照旧;要滚到哪、要不要聚焦输入框照这次的。 */
reopen(request: OpenRequest): void {
if (request.from !== undefined) {
@@ -744,6 +770,9 @@ class Preview {
return
case 'occurrenceNavigation':
this.owner.navigation.update(this, msg.available)
return
case 'findNavigation':
this.owner.finding.update(this, msg.available)
}
}
+4
View File
@@ -12,6 +12,7 @@ import { shellCommandRules } from './commands/shellCommands'
import { systemCommandRules } from './commands/systemCommands'
import { previewDiffBases } from './diff/diffBase'
import { BlockFilter } from './editor/blockFilter'
import { FIND, FIND_NEXT, FIND_PREVIOUS } from './editor/findCommands'
import { NEXT_OCCURRENCE, PREVIOUS_OCCURRENCE } from './editor/occurrenceNavigation'
import { languageOf, openingFor } from './editor/openTarget'
import { PATH_PREVIEW_SCHEME } from './editor/pathPreview'
@@ -212,6 +213,9 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
commands.registerCommand('superpowers.codeReader.showSource', (...args: unknown[]) => switcher.showSource(args)),
commands.registerCommand(NEXT_OCCURRENCE, () => previews.moveOccurrence(1)),
commands.registerCommand(PREVIOUS_OCCURRENCE, () => previews.moveOccurrence(-1)),
commands.registerCommand(FIND, () => previews.find()),
commands.registerCommand(FIND_NEXT, () => previews.moveFind(1)),
commands.registerCommand(FIND_PREVIOUS, () => previews.moveFind(-1)),
)
const api: CodeReaderApi = registry
@@ -343,3 +343,139 @@ describe('一条消息只重画受影响的卡片', () => {
expect(container.querySelectorAll('.cr-qa')).toHaveLength(2)
})
})
describe('查找栏', () => {
/**
* 0 def load(): 函数默认折叠,里面那句也有 path
* 1 return path
* 2 path = 1
*/
function statement(id: string, text: string, offset?: number): UnhashedBlock {
return { ...block(id, id), kind: 'statement', callTree: undefined, translation: [{ text, role: 'variable', ...(offset !== undefined && { offset }) }] } as UnhashedBlock
}
const DOC = documentOf(7, [
{ ...block('0', 'load'), translation: [{ text: 'load', role: 'function', offset: 4 }], children: [statement('0.0', 'return path')] },
statement('1', 'path = 1'),
])
const input = (): HTMLInputElement | null => container.querySelector<HTMLInputElement>('.cr-find-bar input')
const count = (): string | null | undefined => container.querySelector('.cr-find-count')?.textContent
const navigation = (): boolean[] => host.posted.flatMap(msg => (msg as WebviewToExtension).type === 'findNavigation' ? [(msg as { available: boolean }).available] : [])
async function type(text: string): Promise<void> {
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input(), text)
input()!.dispatchEvent(new Event('input', { bubbles: true }))
})
// 打字时等一下再找
await act(async () => new Promise(resolve => setTimeout(resolve, 200)))
}
async function press(key: string, init: KeyboardEventInit = {}): Promise<KeyboardEvent> {
const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init })
await act(async () => input()!.dispatchEvent(event))
return event
}
it('⌘F 打开查找栏、聚焦输入框;写了字马上找,数出整份文件里的(折叠着的块里的也算),跳到那处时展开它外面的块', async () => {
await load()
await deliver(DOC)
expect(input()).toBeNull()
expect(container.querySelector('[data-id="0.0"]')).toBeNull()
await deliver({ type: 'openFind' })
expect(document.activeElement).toBe(input())
await type('PATH')
expect(count()).toBe('1/2')
expect(container.querySelector('[data-id="0.0"]')).not.toBeNull()
expect(saved?.folds).toEqual({ 0: false })
})
it('enter 下一处、Shift+Enter 上一处,到头了从另一头接着;⌘G(扩展端发来 moveFind)也跳;没有命中写「无结果」', async () => {
await load()
await deliver(DOC)
await deliver({ type: 'openFind' })
await type('path')
expect(count()).toBe('1/2')
expect((await press('Enter')).defaultPrevented).toBe(true)
expect(count()).toBe('2/2')
await press('Enter')
expect(count()).toBe('1/2')
await press('Enter', { shiftKey: true })
expect(count()).toBe('2/2')
await deliver({ type: 'moveFind', step: 1 })
expect(count()).toBe('1/2')
await deliver({ type: 'moveFind', step: -1 })
expect(count()).toBe('2/2')
await type('nothing')
expect(count()).toBe('无结果')
})
it('esc 关掉查找栏、焦点还给页面,单击名字的高亮照旧;再按 ⌘F 还是原来的字、全选着', async () => {
await load()
await deliver(DOC)
await act(async () => container.querySelector<HTMLElement>('[data-offset="4"]')!.click())
const asked = [...host.posted].reverse().find((msg): msg is Extract<WebviewToExtension, { type: 'findOccurrences' }> => (msg as WebviewToExtension).type === 'findOccurrences')!
await deliver({ type: 'occurrencesFound', id: asked.id, occurrences: [{ start: 4, end: 8, kind: 'write' }] })
expect(container.querySelector('.cr-occurrence-name')?.textContent).toBe('load')
await deliver({ type: 'openFind' })
await type('path')
expect((await press('Escape')).defaultPrevented).toBe(true)
expect(input()).toBeNull()
expect(document.activeElement).toBe(document.body)
expect(container.querySelector('.cr-occurrence-name')?.textContent).toBe('load')
await deliver({ type: 'openFind' })
expect(input()?.value).toBe('path')
expect([input()?.selectionStart, input()?.selectionEnd]).toEqual([0, 4])
expect(count()).toBe('1/2')
})
it('页面上选中了一行字:⌘F 拿它填进去,从选中的那处开始找', async () => {
await load()
await deliver(DOC)
const text = container.querySelector('[data-id="1"] .cr-code span')!.firstChild!
const range = document.createRange()
range.setStart(text, 0)
range.setEnd(text, 4)
window.getSelection()!.removeAllRanges()
window.getSelection()!.addRange(range)
await deliver({ type: 'openFind' })
expect(input()?.value).toBe('path')
await act(async () => new Promise(resolve => setTimeout(resolve, 200)))
expect(count()).toBe('2/2')
})
it('查找栏开着、页面有焦点时告诉扩展端 ⌘G 归它,关了就不归', async () => {
await load()
await deliver(DOC)
await deliver({ type: 'openFind' })
expect(navigation().at(-1)).toBe(true)
await press('Escape')
expect(navigation().at(-1)).toBe(false)
})
it('换了一版文档:照旧按原来的字找,当前那处停在原来那处', async () => {
await load()
await deliver(DOC)
await deliver({ type: 'openFind' })
await type('path')
await press('Enter')
expect(count()).toBe('2/2')
await deliver(documentOf(8, [statement('9', 'path first'), ...DOC.blocks as unknown as UnhashedBlock[]]))
expect(input()?.value).toBe('path')
expect(count()).toBe('3/3')
})
it('写字、跳下一处:里面的字都不重画(高亮直接画在页面上),只有要展开的那块重画', async () => {
await load()
await deliver(DOC)
await deliver({ type: 'openFind' })
renders.start()
await type('path')
await deliver({ type: 'moveFind', step: 1 })
await deliver({ type: 'moveFind', step: 1 })
const counts = renders.stop()
expect(counts.SegmentText).toBeUndefined()
expect(counts.BlockItem ?? 0).toBeLessThanOrEqual(1)
})
})
+43 -19
View File
@@ -16,6 +16,8 @@ import { nameMap } from './displayNames'
import { startEditing } from './editing'
import { keptWhileEditing } from './filter'
import { FilterBar } from './FilterBar'
import { FindBar } from './FindBar'
import { FindPainter } from './findHighlights'
import { OverviewRuler } from './OverviewRuler'
import { pageLayout } from './pageLayout'
import { createPageStore } from './pageState'
@@ -28,6 +30,7 @@ import { createStore, useStore } from './store'
import { useChangeNav } from './useChangeNav'
import { useDefinitionLinks } from './useDefinitionLinks'
import { useDefinitionPeek } from './useDefinitionPeek'
import { useFind } from './useFind'
import { useMermaidDiagrams } from './useMermaidDiagrams'
import { useOccurrences } from './useOccurrences'
import { useQa } from './useQa'
@@ -78,11 +81,20 @@ export function App(): ReactElement | null {
const restoreCall = useRef(viewState.get().callTree)
/** 块树所在的页面内容:钉住卡片时在这里面量 */
const content = useRef<HTMLDivElement>(null)
/** 页面内容挂上时,卡片的位置从它量(pageLayout):筛选栏和它在同一次提交里画出来 */
/** 查找栏的高亮画在页面内容里 */
const [findPainter] = useState(() => new FindPainter())
/** 页面内容挂上时,卡片的位置从它量(pageLayout):筛选栏和它在同一次提交里画出来;查找的高亮也画在它里面 */
const attachContent = useCallback((el: HTMLDivElement | null) => {
content.current = el
return el ? pageLayout.attach(el, document.querySelector<HTMLElement>('.cr-filter-bar'), () => page.get().placement) : undefined
}, [])
if (!el)
return undefined
const detachLayout = pageLayout.attach(el, document.querySelector<HTMLElement>('.cr-filter-bar'), () => page.get().placement)
const detachFind = findPainter.attach(el)
return () => {
detachLayout()
detachFind()
}
}, [page, findPainter])
// 这一层重画了:同一段脚本里接着要量位置的(滚到某一行、跳到改动、滚到当前那一处)不能用重画前量的,先作废;放在它们前面
useLayoutEffect(() => pageLayout.invalidate())
const { doc, editing, folds, reveal, excluded, diff, onlyChanges, openCall, changes, collapsed, filter, covered, placement, unshown, stops } = useStore(page, current => current)
@@ -108,16 +120,13 @@ export function App(): ReactElement | null {
useRepoImages()
useMermaidDiagrams()
const nav = useChangeNav(stops, collapsed, expand, sticky)
/** 跳不到的块:被筛掉的、正在编辑的、被正在编辑的祖先盖住的;单击高亮和查找栏跳的时候都跳过它们 */
const skip = useCallback((id: string) => unshown.has(id) || covered.has(id) || editing.includes(id), [unshown, covered, editing])
const occurrencePage = useMemo((): OccurrencePage | undefined => doc
? {
blocks: doc.blocks,
placement,
skip: id => unshown.has(id) || covered.has(id) || editing.includes(id),
collapsed,
expand,
}
: undefined, [doc, placement, unshown, covered, editing, collapsed, expand])
? { blocks: doc.blocks, placement, skip, collapsed, expand }
: undefined, [doc, placement, skip, collapsed, expand])
const occurrences = useOccurrences(doc?.version, doc?.translatorId, occurrencePage, marks)
const find = useFind(occurrencePage, marks, findPainter)
/** 块树:状态是页面 store 里的 tree,动作改页面 store;整页一份,卡片拿到的回调一直是同一个 */
const [tree] = useState((): BlockTreeHandle => ({
@@ -239,14 +248,29 @@ export function App(): ReactElement | null {
translators={doc.translators}
translatorId={doc.translatorId}
onSelectTranslator={id => postMessage({ type: 'selectTranslator', id })}
below={diff && doc.error === undefined && (
<DiffBar
diff={diff}
onlyChanges={onlyChanges}
onToggleOnly={() => postMessage({ type: 'setOnlyChanges', on: !onlyChanges })}
canMove={stops.length > 0}
onMove={nav.move}
/>
below={(
<>
{diff && doc.error === undefined && (
<DiffBar
diff={diff}
onlyChanges={onlyChanges}
onToggleOnly={() => postMessage({ type: 'setOnlyChanges', on: !onlyChanges })}
canMove={stops.length > 0}
onMove={nav.move}
/>
)}
{find.open && (
<FindBar
query={find.query}
count={find.count}
canMove={find.canMove}
focus={find.focus}
onQuery={find.onQuery}
onMove={find.onMove}
onClose={find.onClose}
/>
)}
</>
)}
/>
{changes && diff && <OverviewRuler stops={stops} lineCount={diff.lineCount} onPick={nav.goTo} />}
@@ -22,7 +22,7 @@ import { chatOf, EMPTY_CHAT, sameChat, unfinished } from './qa'
import { BlockIdContext } from './SegmentText'
import { shallowEqual, staticStore, useStore } from './store'
import { TextPreview } from './TextPreview'
import { foldsByCard, listId, tableId } from './viewState'
import { foldsByCard, listId, tableId, textKey } from './viewState'
/** 块树里改代码的那部分状态:哪些块在编辑。页面上的块树才有;定义卡片浮窗里的块树没有,卡片只读,不画右上角的按钮 */
export interface EditState {
@@ -423,7 +423,7 @@ const BlockPreview = memo(({ block, children = NO_CHILDREN, folds, marks }: { bl
const folding = { collapsed: folds, onToggleFold: tree.onToggleFold }
return (
<>
{marks?.translation ? <DiffText diff={marks.translation} /> : <TextPreview text={block.translation} />}
{marks?.translation ? <DiffText diff={marks.translation} find={textKey(block.id, 'text')} /> : <TextPreview text={block.translation} find={textKey(block.id, 'text')} />}
{block.items && (
<div className="mt-[3px]">
<PropertyItems parentId={listId(block.id)} items={block.items} folding={folding} marks={marks?.items} />
@@ -434,7 +434,7 @@ const BlockPreview = memo(({ block, children = NO_CHILDREN, folds, marks }: { bl
<PropertyTable parentId={tableId(block.id)} columns={block.table.columns} items={block.table.items} marks={marks?.table} folding={folding} />
</div>
)}
{marks?.doc ? <DiffText diff={marks.doc} className="mt-0.5" /> : block.doc && <TextPreview text={block.doc} className="mt-0.5" />}
{marks?.doc ? <DiffText diff={marks.doc} className="mt-0.5" find={textKey(block.id, 'doc')} /> : block.doc && <TextPreview text={block.doc} className="mt-0.5" find={textKey(block.id, 'doc')} />}
{block.rows && (
<div className="mt-[3px]">
<PropertyGrid blockId={block.id} rows={block.rows} collapsed={folds} onToggleFold={tree.onToggleFold} marks={marks?.rows} />
@@ -23,12 +23,15 @@ export function DiffInline({ parts }: { parts: InlinePart[] }): ReactElement {
)
}
/** 预览里一段带增删的文字:着色的词逐截标(DiffInline),Markdown 说明照常排版、增删的字按词 / 字标(同 TextPreview 的排版)。 */
export function DiffText({ diff, className }: { diff: TextDiff, className?: string }): ReactElement {
/**
* 预览里一段带增删的文字:着色的词逐截标([DiffInline]),Markdown 说明照常排版、增删的字按词 / 字标。
* 排版和 `find` 同 [TextPreview](vscode/webview-ui/src/codeReader/TextPreview.tsx#TextPreview)。
*/
export function DiffText({ diff, className, find }: { diff: TextDiff, className?: string, find?: string }): ReactElement {
if ('markdown' in diff)
return <MarkdownPreview html={renderMarkdownDiff(diff.markdown)} className={className} />
return <MarkdownPreview html={renderMarkdownDiff(diff.markdown)} className={className} find={find} />
return (
<p className={cn('cr-code', className)}>
<p className={cn('cr-code', className)} data-find={find}>
<DiffInline parts={diff.inline} />
</p>
)
@@ -0,0 +1,80 @@
import type { KeyboardEvent, ReactElement } from 'react'
import { ArrowDown, ArrowUp, Search, X } from 'lucide-react'
import { useLayoutEffect, useRef } from 'react'
export interface FindBarProps {
query: string
/** 输入框后面写的数(见 [findCount](vscode/webview-ui/src/codeReader/find.ts#findCount));没写字时没有 */
count?: string
/** 有命中可以跳 */
canMove: boolean
/** 每变一次就聚焦输入框、选中里面的字(⌘F) */
focus: number
onQuery: (query: string) => void
/** 跳到上一处(-1)/ 下一处(1)命中 */
onMove: (step: 1 | -1) => void
onClose: () => void
}
/**
* 筛选栏下面的查找栏(⌘F 打开):搜索图标、输入框、「第几处/共几处」(没有命中写「无结果」)、上一处 / 下一处、关闭。
* 输入框里 Enter 跳下一处、Shift+Enter 跳上一处(拼字时的 Enter 不算),栏里哪儿按 Esc 都关掉;
* 这几个键用掉了就拦下(preventDefault),页面上按 Esc 清掉单击高亮的那一下不再跟着动。
*/
export function FindBar({ query, count, canMove, focus, onQuery, onMove, onClose }: FindBarProps): ReactElement {
const input = useRef<HTMLInputElement>(null)
useLayoutEffect(() => {
input.current?.focus()
input.current?.select()
}, [focus])
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
if (event.key === 'Escape') {
event.preventDefault()
onClose()
}
else if (event.key === 'Enter' && event.target === input.current && !event.nativeEvent.isComposing) {
event.preventDefault()
onMove(event.shiftKey ? -1 : 1)
}
}
return (
<div className="cr-find-bar mx-auto box-content flex h-[30px] max-w-[880px] items-center gap-2 px-6 text-xs whitespace-nowrap text-[var(--vscode-descriptionForeground)]" onKeyDown={onKeyDown}>
<Search size={14} strokeWidth={1.8} className="shrink-0" aria-hidden />
<input
ref={input}
type="text"
className="cr-find-input h-[22px] w-60 min-w-0 rounded px-1.5"
value={query}
placeholder="查找"
aria-label="查找"
spellCheck={false}
onChange={event => onQuery(event.target.value)}
/>
<span className="cr-find-count min-w-12" aria-live="polite">{count}</span>
<span className="flex items-center gap-0.5" role="group" aria-label="命中之间跳">
{([[-1, ArrowUp, '上一处(Shift+Enter)'], [1, ArrowDown, '下一处(Enter)']] as const).map(([step, Icon, label]) => (
<button
key={step}
type="button"
className="cr-tool flex h-[22px] w-6 items-center justify-center rounded disabled:opacity-40"
data-tooltip={label}
aria-label={label}
disabled={!canMove}
onClick={() => onMove(step)}
>
<Icon size={14} strokeWidth={1.8} className="text-[var(--vscode-foreground)]" aria-hidden />
</button>
))}
</span>
<button
type="button"
className="cr-tool flex h-[22px] w-6 items-center justify-center rounded"
data-tooltip="关闭(Esc)"
aria-label="关闭查找"
onClick={onClose}
>
<X size={14} strokeWidth={1.8} className="text-[var(--vscode-foreground)]" aria-hidden />
</button>
</div>
)
}
@@ -8,7 +8,7 @@ import { inlineText } from './segments'
import { SegmentText } from './SegmentText'
import { subRowsLayout, tableCells, tableTemplate } from './subRows'
import { TextPreview } from './TextPreview'
import { propertyItemId, propertyRowId, tableId } from './viewState'
import { propertyItemId, propertyRowId, tableId, textKey } from './viewState'
/** 列表子行里除最后一列以外各列的宽度(字典的键),最后一列占满剩下的;编辑区窄时按比例收,不把最后一列挤没 */
const CELL_WIDTHS = ['min(150px, 28%)', 'min(220px, 38%)']
@@ -64,18 +64,18 @@ function CellText({ cell, parts }: { cell: PreviewText, parts: TextDiff | undefi
return 'markdown' in cell ? <TextPreview text={cell} className="min-w-0" /> : <SegmentText segments={cell} />
}
/** 一格连外面那层:着色的词排在 .cr-code 的段落里,Markdown 说明是块,lead(差异记号、展开按钮)排在它左边。 */
function CellBox({ cell, parts, className, lead, ...attrs }: { cell: PreviewText, parts: TextDiff | undefined, className?: string, lead?: ReactNode, role?: string }): ReactElement {
/** 一格连外面那层:着色的词排在 .cr-code 的段落里,Markdown 说明是块,lead(差异记号、展开按钮)排在它左边。`find` 是这一格的记号(见 [textKey]),记在外面那层上。 */
function CellBox({ cell, parts, className, lead, find, ...attrs }: { cell: PreviewText, parts: TextDiff | undefined, className?: string, lead?: ReactNode, find?: string, role?: string }): ReactElement {
if (isBlock(cell, parts)) {
return (
<div className={cn('flex min-w-0 items-start gap-1', className)} {...attrs}>
<div className={cn('flex min-w-0 items-start gap-1', className)} data-find={find} {...attrs}>
{lead}
<CellText cell={cell} parts={parts} />
</div>
)
}
return (
<p className={cn('cr-code min-w-0', className)} {...attrs}>
<p className={cn('cr-code min-w-0', className)} data-find={find} {...attrs}>
{lead}
<CellText cell={cell} parts={parts} />
</p>
@@ -91,12 +91,12 @@ function cellLabel(cell: PreviewText): string {
* 子行的第一列(字典的键):带下一层的子行前面是折叠箭头(图标、大小、颜色同属性行的),点箭头或这一列切换;
* 同一层里不带下一层的子行在同样的位置留空,第一列才对得齐。
*/
function ItemHead({ id, cell, foldable, folding }: { id: string, cell: PreviewText, foldable: boolean, folding: Folding }): ReactElement {
function ItemHead({ id, cell, foldable, folding, find }: { id: string, cell: PreviewText, foldable: boolean, folding: Folding, find?: string }): ReactElement {
if (!foldable) {
return (
<div className="flex min-w-0 gap-1">
<span className="w-3.5 shrink-0" />
<CellBox cell={cell} parts={undefined} />
<CellBox cell={cell} parts={undefined} find={find} />
</div>
)
}
@@ -116,7 +116,7 @@ function ItemHead({ id, cell, foldable, folding }: { id: string, cell: PreviewTe
<Chevron size={14} strokeWidth={1.8} className="text-[var(--vscode-descriptionForeground)] group-hover:text-[var(--vscode-foreground)]" />
</button>
<div className="min-w-0 cursor-pointer" onClick={toggle}>
<CellBox cell={cell} parts={undefined} />
<CellBox cell={cell} parts={undefined} find={find} />
</div>
</div>
)
@@ -163,9 +163,12 @@ export function PropertyItems({ parentId, items, folding, marks }: { parentId: s
data-diff={change}
style={{ gridTemplateColumns: gridTemplate(item.cells.length) }}
>
{item.cells.map((cell, i) => i === 0 && nested
? <ItemHead key={i} id={id} cell={cell} foldable={children.length > 0 && change !== 'del'} folding={folds} />
: <CellBox key={i} cell={cell} parts={mark?.cells?.[i]} />)}
{item.cells.map((cell, i) => {
const find = change === 'del' ? undefined : textKey(id, String(i))
return i === 0 && nested
? <ItemHead key={i} id={id} cell={cell} foldable={children.length > 0 && change !== 'del'} folding={folds} find={find} />
: <CellBox key={i} cell={cell} parts={mark?.cells?.[i]} find={find} />
})}
</div>
{(open || item.table) && (change === 'del' ? <div className="cr-diff-gone">{below}</div> : below)}
</>
@@ -214,17 +217,18 @@ export function PropertyTable({ parentId, columns, items, marks, folding, toggle
const cells = tableCells(item, columns)
const listed = (item.items?.length ?? 0) > 0
const sign = (i: number): ReactNode => signed && i === 0 && <span className="cr-diff-sign" data-change={change} aria-hidden>{SIGNS[change ?? 'none']}</span>
const find = (i: number): string | undefined => change === 'del' ? undefined : textKey(id, String(i))
/** 一格:讲子块的那一格是展开它的按钮(只放着色的词),别的照常(见 CellBox) */
const cellBox = (cell: PreviewText, i: number, className?: string): ReactElement => toggle && i === 0
? (
<p className={cn('cr-code min-w-0', className)} role="cell">
<p className={cn('cr-code min-w-0', className)} role="cell" data-find={find(i)}>
{sign(i)}
<button type="button" className="cr-item-toggle" aria-expanded={toggle.open} aria-label={`${toggle.open ? '收起' : '展开'}${cellLabel(cell)}`} onClick={toggle.onToggle}>
<CellText cell={cell} parts={mark?.cells?.[i]} />
</button>
</p>
)
: <CellBox cell={cell} parts={mark?.cells?.[i]} className={className} lead={sign(i)} role="cell" />
: <CellBox cell={cell} parts={mark?.cells?.[i]} className={className} lead={sign(i)} find={find(i)} role="cell" />
const tint = toggle?.open && toggle.badge
? { 'data-badge': '', 'style': { '--kind-dark': toggle.badge.color.dark, '--kind-light': toggle.badge.color.light } as CSSProperties }
: {}
@@ -233,7 +237,7 @@ export function PropertyTable({ parentId, columns, items, marks, folding, toggle
{cells.map((cell, i) => (listed || item.table) && i === cells.length - 1
? (
<div key={i} className="cr-table-cell flex min-w-0 flex-col gap-[3px]" role="cell">
<CellBox cell={cell} parts={mark?.cells?.[i]} lead={sign(i)} />
<CellBox cell={cell} parts={mark?.cells?.[i]} lead={sign(i)} find={find(i)} />
{listed && <PropertyItems parentId={id} items={item.items ?? []} folding={change === 'del' ? FIXED : folding} marks={mark?.items} />}
{item.table && <PropertyTable parentId={tableId(id)} columns={item.table.columns} items={item.table.items} marks={mark?.table} folding={change === 'del' ? FIXED : folding} />}
</div>
@@ -248,7 +252,7 @@ export function PropertyTable({ parentId, columns, items, marks, folding, toggle
<div className="pl-[5px]">
<div className="cr-table" role="table" style={{ gridTemplateColumns: tableTemplate(columns.length) }}>
<div className="cr-table-head" role="row">
{columns.map((name, i) => <span key={i} className="cr-table-cell" role="columnheader">{name}</span>)}
{columns.map((name, i) => <span key={i} className="cr-table-cell" role="columnheader" data-find={textKey(parentId, `h${i}`)}>{name}</span>)}
</div>
{removed(-1)}
{items.map((item, index) => {
@@ -308,14 +312,15 @@ export function PropertyGrid({ blockId, rows, collapsed, onToggleFold, marks, sl
className="cr-prop-label group flex h-5 items-center gap-1 rounded-sm"
aria-label={`${folded ? '展开' : '折叠'}${row.label}`}
aria-expanded={!folded}
data-find={textKey(id, 'label')}
onClick={() => onToggleFold(id)}
>
<Chevron size={14} strokeWidth={1.8} className="shrink-0 text-[var(--vscode-descriptionForeground)] group-hover:text-[var(--vscode-foreground)]" />
{row.label}
</button>
)
: <span className="cr-prop-label flex h-5 items-center">{row.label}</span>}
<RowValue row={row} mark={mark} />
: <span className="cr-prop-label flex h-5 items-center" data-find={textKey(id, 'label')}>{row.label}</span>}
<RowValue row={row} mark={mark} id={id} />
</div>
{!folded && layout === 'table' && <PropertyTable parentId={id} columns={row.columns ?? []} items={row.items ?? []} marks={mark?.items} folding={folding} toggles={toggles} />}
{!folded && layout === 'list' && <PropertyItems parentId={id} items={row.items ?? []} folding={folding} marks={mark?.items} />}
@@ -330,16 +335,17 @@ export function PropertyGrid({ blockId, rows, collapsed, onToggleFold, marks, sl
/**
* 一行的值:着色的词或 Markdown(改了的逐截 / 逐词标);跟着说明(PropertyRow.note,输出参数的 `@return`)时说明接在值后面,
* 同一行放得下就并排,剩的地方不到 12 个字宽时换到下一行。
* 同一行放得下就并排,剩的地方不到 12 个字宽时换到下一行。`id` 是这一行的 id,值和说明按它记上记号(见 [textKey]);删掉的行没有。
*/
function RowValue({ row, mark }: { row: PropertyRow, mark: RowDiff | undefined }): ReactElement {
const value = mark?.value ? <DiffText diff={mark.value} className="min-w-0" /> : <TextPreview text={row.value} className="min-w-0" />
function RowValue({ row, mark, id }: { row: PropertyRow, mark: RowDiff | undefined, id?: string }): ReactElement {
const find = (part: string): string | undefined => id === undefined ? undefined : textKey(id, part)
const value = mark?.value ? <DiffText diff={mark.value} className="min-w-0" find={find('value')} /> : <TextPreview text={row.value} className="min-w-0" find={find('value')} />
if (!row.note)
return value
return (
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2">
{value}
{mark?.note ? <DiffText diff={mark.note} className="min-w-0 flex-[1_1_12em]" /> : <TextPreview text={row.note} className="min-w-0 flex-[1_1_12em]" />}
{mark?.note ? <DiffText diff={mark.note} className="min-w-0 flex-[1_1_12em]" find={find('note')} /> : <TextPreview text={row.note} className="min-w-0 flex-[1_1_12em]" find={find('note')} />}
</div>
)
}
@@ -9,20 +9,23 @@ import { SegmentText } from './SegmentText'
/** 卡片里的 Markdown 说明按哪种渲染:页面上是注释;定义卡片浮窗里是 peek(别的文件的注释,不认不带路径的引用)。 */
export const MarkdownKindContext = createContext<Exclude<MarkdownKind, 'answer'>>('comment')
/** 预览里的一段文字:着色的词按源码排版;Markdown 说明渲染成段落、列表、代码块。 */
export const TextPreview = memo(({ text, className }: { text: PreviewText, className?: string }): ReactElement => {
/**
* 预览里的一段文字:着色的词按源码排版;Markdown 说明渲染成段落、列表、代码块。
* `find` 是卡片上这段字的记号(见 [textKey](vscode/webview-ui/src/codeReader/viewState.ts#textKey)),查找栏按它找到这段字画高亮。
*/
export const TextPreview = memo(({ text, className, find }: { text: PreviewText, className?: string, find?: string }): ReactElement => {
const kind = useContext(MarkdownKindContext)
if ('markdown' in text)
return <MarkdownPreview html={renderMarkdown(text.markdown, kind)} className={className} />
return <MarkdownPreview html={renderMarkdown(text.markdown, kind)} className={className} find={find} />
return (
<p className={cn('cr-code', className)}>
<p className={cn('cr-code', className)} data-find={find}>
<SegmentText segments={text} />
</p>
)
})
TextPreview.displayName = 'TextPreview'
export function MarkdownPreview({ html, className }: { html: string, className?: string }): ReactElement {
export function MarkdownPreview({ html, className, find }: { html: string, className?: string, find?: string }): ReactElement {
const content = useMemo(() => ({ __html: html }), [html])
return <div className={cn('cr-markdown', className)} dangerouslySetInnerHTML={content} />
return <div className={cn('cr-markdown', className)} data-find={find} dangerouslySetInnerHTML={content} />
}
@@ -397,6 +397,45 @@ body:is(.vscode-light, .vscode-high-contrast-light) .cr-colored { color: var(--c
font-family: var(--vscode-editor-font-family, monospace);
}
/*
* 查找栏(FindBar.tsx)的输入框:配色同 VS Code 的输入框,聚焦时描焦点色。
* 命中画成自定义高亮(findHighlights.ts,不动页面元素),颜色同 VS Code 编辑器的查找:各处命中垫 findMatchHighlightBackground,
* 当前那处垫 findMatchBackground;自定义高亮画不了边框,当前那处的 findMatchBorder(主题给了才有,多是高对比主题)由 .cr-find-outline 盖上去。
* 和单击名字的高亮([data-occurrence])各画各的,叠在一起时都看得见
*/
.cr-find-input {
color: var(--vscode-input-foreground);
font: inherit;
background: var(--vscode-input-background);
border: 1px solid var(--vscode-input-border, color-mix(in srgb, var(--vscode-foreground) 20%, transparent));
outline: none;
}
.cr-find-input:focus {
border-color: var(--vscode-focusBorder);
}
.cr-find-input::placeholder {
color: var(--vscode-input-placeholderForeground);
}
::highlight(cr-find) {
background-color: var(--vscode-editor-findMatchHighlightBackground);
}
::highlight(cr-find-current) {
background-color: var(--vscode-editor-findMatchBackground);
}
/* 当前那处外面的框:按文档坐标摆,跟着页面滚;层级在页面内容之上,低于钉住的卡片(4)和筛选栏(10),滚到它们下面时被盖住 */
.cr-find-outline {
position: absolute;
top: 0;
left: 0;
z-index: 1;
pointer-events: none;
}
.cr-find-outline > div {
position: absolute;
box-sizing: border-box;
border: 1px solid var(--vscode-editor-findMatchBorder, transparent);
}
/* 属性表的属性名:界面字体,和值一样的行高,两边逐行对齐 */
.cr-prop-label {
font-size: 12.5px;
@@ -0,0 +1,205 @@
import type { FindResult, FindUnit } from './find'
import type { WrittenBlock } from './testBlocks'
import type { BlockKind, Inline, Markdown, PropertyRow, Segment } from './types'
import { describe, expect, it } from 'vitest'
import { nameMap } from './displayNames'
import { findCount, findMatches, findUnits, PageFind, paintPlan, shownText, spansOf } from './find'
import { placementOf } from './placement'
import { placed } from './testBlocks'
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, line: number, translation: Inline[], children: WrittenBlock[] = []): WrittenBlock {
return { id, anchor: id, kind, lines: [line, line], attributes: { kind }, concepts: [{ id: kind, label: kind, icon: 'box' }], children, translation }
}
function factBlock(id: string, kind: BlockKind, line: number, properties: PropertyRow[], children: WrittenBlock[] = []): WrittenBlock {
return { id, anchor: id, kind, lines: [line, line], attributes: { kind }, concepts: [{ id: kind, label: kind, icon: 'box' }], children, properties }
}
/** Markdown 显示成的字:测试里就是原文 */
const markdownText = (markdown: Markdown): string => markdown.markdown
/**
* 0 def load(path): 函数默认折叠
* 1 return get_db(path)
* 3 LIMIT = load(3)
*/
const LOAD_ROWS: PropertyRow[] = [
{ label: '名称', value: [name('load', 4)] },
{ label: '输入参数', value: [word('1 个')], items: [{ cells: [[name('path', 9)]] }] },
]
const BLOCKS: WrittenBlock[] = [
factBlock('0', 'function', 0, LOAD_ROWS, [
textBlock('0.0', 'return', 1, [word('返回'), name('get_db', 30), name('path', 37)]),
]),
textBlock('1', 'const', 3, [word('设'), name('LIMIT', 50), word('为'), name('load', 58)]),
]
function unitsOf(blocks: WrittenBlock[], names = nameMap([])): FindUnit[] {
const page = placed(blocks)
return findUnits(page, placementOf(page), names, markdownText)
}
const ANYWHERE = (): boolean => true
describe('查找栏:页面上能查的字', () => {
it('整份文件按页面先后排,折叠着的块里的也在;每段记着在哪块、画在哪、要展开什么、块在第几行', () => {
expect(unitsOf(BLOCKS).map(({ block, key, expand, line, text }) => ({ block, key, expand, line, text }))).toEqual([
{ block: '0', key: '0/p0#label', expand: [], line: 0, text: '名称' },
{ block: '0', key: '0/p0#value', expand: [], line: 0, text: 'load' },
{ block: '0', key: '0/p1#label', expand: [], line: 0, text: '输入参数' },
{ block: '0', key: '0/p1#value', expand: [], line: 0, text: '1 个' },
{ block: '0', key: '0/p1/0#0', expand: ['0/p1'], line: 0, text: 'path' },
{ block: '0.0', key: '0.0#text', expand: ['0'], line: 1, text: '返回get_dbpath' },
{ block: '1', key: '1#text', expand: [], line: 3, text: '设LIMIT为load' },
])
})
it('按页面上显示的字:起了中文名的名字是中文名(定义处后面还跟着原名),框是框头加框里的字,收起的类名只有小标', () => {
const names = nameMap([{ offset: 58, length: 4, name: '加载' }, { offset: 50, length: 5, name: '上限', definition: true }])
const shown = shownText([word('设'), name('LIMIT', 50), word('为'), name('load', 58)], 0, names)
expect(shown).toEqual({ text: '设上限LIMIT为加载', renamed: [{ start: 9, end: 11, original: 'load' }] })
const boxed: Inline[] = [
word('对'),
{ style: 'iteration', label: '每个', segments: [name('item', 3), { style: 'tag', label: '', segments: [word('x')] }] },
{ style: 'classes', label: '样式 3 个类', detail: 'a\nb\nc', segments: [word('a b c')] },
]
expect(shownText(boxed, 0, nameMap([])).text).toBe('对每个itemx样式 3 个类')
expect(unitsOf(BLOCKS, names).at(-1)?.text).toBe('设上限LIMIT为加载')
})
it('注释、说明的 Markdown 是渲染出来的字;属性名、表头照原样', () => {
const blocks = [factBlock('0', 'class', 0, [
{ label: '说明', value: { markdown: '**加载**配置' } },
{ label: '字段', value: [word('1 个')], columns: ['名称', '类型'], items: [{ cells: [[name('path', 9)], [word('str')]] }] },
])]
const page = placed(blocks)
const units = findUnits(page, placementOf(page), nameMap([]), markdown => markdown.markdown.replaceAll('**', ''))
expect(units.map(unit => [unit.key, unit.text])).toEqual([
['0/p0#label', '说明'],
['0/p0#value', '加载配置'],
['0/p1#label', '字段'],
['0/p1#value', '1 个'],
['0/p1#h0', '名称'],
['0/p1#h1', '类型'],
['0/p1/0#0', 'path'],
['0/p1/0#1', 'str'],
])
})
})
describe('查找栏:命中', () => {
it('纯文字、不分大小写,一段字里从前往后不重叠地找;特殊字符照字面', () => {
expect(spansOf('ab')('xAbaBab')).toEqual([{ start: 1, end: 3 }, { start: 3, end: 5 }, { start: 5, end: 7 }])
expect(spansOf('aa')('aaaa')).toEqual([{ start: 0, end: 2 }, { start: 2, end: 4 }])
expect(spansOf('a.(b')('xa.(b a-(b')).toEqual([{ start: 1, end: 5 }])
expect(spansOf('')('abc')).toEqual([])
})
it('各段字分开找,不跨段;按页面先后排', () => {
const units = unitsOf(BLOCKS)
expect(findMatches(units, 'LOAD')).toEqual([{ unit: 1, start: 0, end: 4 }, { unit: 6, start: 7, end: 11 }])
expect(findMatches(units, 'namepath')).toEqual([])
expect(findMatches(units, 'path').map(match => units[match.unit].key)).toEqual(['0/p1/0#0', '0.0#text'])
})
it('显示成中文名的名字,输入源码原名也算命中,命中的是整个中文名;中文名自己也命中时不重复算', () => {
const names = nameMap([{ offset: 58, length: 4, name: '加载' }, { offset: 4, length: 4, name: 'load加载' }])
const units = unitsOf(BLOCKS, names)
expect(findMatches(units, 'Load')).toEqual([{ unit: 1, start: 0, end: 4 }, { unit: 6, start: 7, end: 9 }])
expect(findMatches(units, '加载')).toEqual([{ unit: 1, start: 4, end: 6 }, { unit: 6, start: 7, end: 9 }])
})
it('查找栏上的数:没写字时不显示,没有命中写「无结果」,有当前那处写「第几处/共几处」,一处都跳不到时写「N 处」', () => {
const matches = [{ unit: 0, start: 0, end: 1 }, { unit: 1, start: 0, end: 1 }]
expect(findCount({ query: '', matches: [] })).toBeUndefined()
expect(findCount({ query: 'x', matches: [] })).toBe('无结果')
expect(findCount({ query: 'x', matches, current: 1 })).toBe('2/2')
expect(findCount({ query: 'x', matches })).toBe('2 处')
})
it('画高亮要的:按段字的记号分好各段的字和命中,当前那处是它那段里的第几个', () => {
const units = unitsOf(BLOCKS)
const result: FindResult = { query: 't', matches: findMatches(units, 't'), current: 2 }
const plan = paintPlan(units, result)
expect(plan.query).toBe('t')
expect(plan.texts.get('0/p1/0#0')).toEqual({ text: 'path', spans: [{ start: 2, end: 3 }] })
expect(plan.texts.get('0.0#text')).toEqual({ text: '返回get_dbpath', spans: [{ start: 4, end: 5 }, { start: 10, end: 11 }] })
expect(plan.texts.has('0/p0#value')).toBe(false)
expect(plan.current).toEqual({ key: '0.0#text', index: 1 })
expect(paintPlan(units, { ...result, current: 1 }).current).toEqual({ key: '0.0#text', index: 0 })
expect(paintPlan(units, { ...result, current: undefined }).current).toBeUndefined()
})
})
describe('查找栏:当前那处', () => {
function session(units: FindUnit[]): { find: PageFind, results: FindResult[] } {
const results: FindResult[] = []
const find = new PageFind(result => results.push(result))
find.setUnits(units, ANYWHERE)
return { find, results }
}
it('写了字:从给的那处(视口顶部那块的行、选中的字)往后第一处能跳到的是当前那处;被筛掉的块里的跳过,总数照旧', () => {
const units = unitsOf(BLOCKS)
const { find, results } = session(units)
expect(find.search('load', ANYWHERE, { line: 1, start: 0 })).toEqual({ unit: 6, start: 7, end: 11 })
expect(results.at(-1)).toEqual({ query: 'load', matches: [{ unit: 1, start: 0, end: 4 }, { unit: 6, start: 7, end: 11 }], current: 1 })
const other = session(units).find
expect(other.search('load', unit => unit.block !== '1', { line: 1, start: 0 })).toEqual({ unit: 1, start: 0, end: 4 })
expect(other.state.current).toBe(0)
expect(session(units).find.search('load', () => false, { line: 1, start: 0 })).toBeUndefined()
const selected = session(units).find
expect(selected.search('load', ANYWHERE, { key: '0/p0#value', line: 0, start: 1 })).toEqual({ unit: 6, start: 7, end: 11 })
expect(selected.search('load', ANYWHERE, { key: '0/p0#value', line: 0, start: 0 })).toEqual({ unit: 1, start: 0, end: 4 })
})
it('改了字:从原来那处往后接着找,原来那处还命中就停在原地', () => {
const units = unitsOf(BLOCKS)
const { find } = session(units)
find.search('pat', ANYWHERE)
expect(find.move(1, ANYWHERE)).toEqual({ unit: 5, start: 8, end: 11 })
expect(find.search('path', ANYWHERE)).toEqual({ unit: 5, start: 8, end: 12 })
expect(find.search('load', ANYWHERE)).toEqual({ unit: 6, start: 7, end: 11 })
expect(find.search('', ANYWHERE)).toBeUndefined()
expect(find.state).toEqual({ query: '', matches: [] })
})
it('下一处、上一处按页面先后,到头了从另一头接着;跳不到的跳过;没有命中时不动', () => {
const units = unitsOf(BLOCKS)
const { find } = session(units)
expect(find.move(1, ANYWHERE)).toBeUndefined()
find.search('a', ANYWHERE)
expect(find.state.current).toBe(0)
expect(find.state.matches).toHaveLength(4)
expect(find.move(-1, ANYWHERE)).toBe(find.state.matches[3])
expect(find.move(1, ANYWHERE)).toBe(find.state.matches[0])
const shown = (unit: FindUnit): boolean => unit.block !== '0.0'
expect(find.move(1, shown)).toBe(find.state.matches[1])
expect(find.move(1, shown)).toBe(find.state.matches[3])
expect(find.state.current).toBe(3)
})
it('换了一版文档:照旧按原来的字找,当前那处停在原来那段字里原来的位置往后;那段字没了,按原来那块的行往后找', () => {
const { find, results } = session(unitsOf(BLOCKS))
find.search('path', ANYWHERE)
find.move(1, ANYWHERE)
const before = results.length
const added = [textBlock('9', 'statement', 0, [word('path')]), ...BLOCKS]
find.setUnits(unitsOf(added), ANYWHERE)
expect(results).toHaveLength(before + 1)
expect(find.state.matches).toHaveLength(3)
expect(find.state.current).toBe(2)
const renumbered = [factBlock('0', 'function', 0, LOAD_ROWS), textBlock('1', 'return', 1, [name('path', 37)])]
find.setUnits(unitsOf(renumbered), ANYWHERE)
expect(find.state.current).toBe(1)
})
})
+231
View File
@@ -0,0 +1,231 @@
import type { NameMap } from './displayNames'
import type { Placement } from './placement'
import type { BlockView, Inline, Markdown } from './types'
import { shownName } from './displayNames'
import { occurrenceCount, stepIndex } from './occurrences'
import { cardTexts, placedBlocks } from './reveal'
/** 一段字里的 [start, end)。 */
export interface Span {
start: number
end: number
}
/** 显示成中文名的一段:中文名在那段字里占的地方,源码里的原名。 */
export interface Renamed extends Span {
original: string
}
/**
* 页面上能查的一段字(卡片上的一段,见 [cardTexts]):在哪块的卡片上,画在记号是 `key` 的元素上,
* `expand` 是要让它露出来得展开的块、属性行和子行(从顶层往下),`line` 是那块在文件里的第一行,`text` 是页面上显示的字。
*/
export interface FindUnit {
block: string
key: string
expand: string[]
line: number
text: string
renamed: Renamed[]
}
/** 一处命中:第几段字([FindUnit] 的序号)里的 [start, end)。 */
export interface FindMatch extends Span {
unit: number
}
/** 查找栏眼下的结果:查的字,按页面先后排的各处命中,当前那处的序号(一处都跳不到时没有)。 */
export interface FindResult {
query: string
matches: readonly FindMatch[]
current?: number
}
const NOTHING: FindResult = { query: '', matches: [] }
/**
* 着色的词在页面上显示的字,同 [SegmentText](vscode/webview-ui/src/codeReader/SegmentText.tsx#SegmentText) 画的:
* 起了中文名的名字是中文名(定义处后面还跟着原名),框是框头加框里的字,收起的类名只有小标。
* 只显示中文名的那几段记进 `renamed`,查原名时也算命中它。`base` 是所在那块的开头在文件里的下标,没有时不换中文名。
*/
export function shownText(pieces: readonly Inline[], base: number | undefined, names: NameMap): { text: string, renamed: Renamed[] } {
let text = ''
const renamed: Renamed[] = []
const add = (list: readonly Inline[]): void => {
for (const piece of list) {
if (!('role' in piece)) {
text += piece.label
if (piece.style !== 'classes')
add(piece.segments)
continue
}
const shown = base === undefined ? undefined : shownName(piece, base, names)
if (shown && !shown.definition)
renamed.push({ start: text.length, end: text.length + shown.name.length, original: piece.text })
text += shown ? `${shown.name}${shown.definition ? piece.text : ''}` : piece.text
}
}
add(pieces)
return { text, renamed }
}
/**
* 整份文件里能查的字,按页面先后:每块(折叠着的、被筛掉的也算)卡片上的各段字。
* Markdown 显示成什么字由 `markdownText` 给(渲染出来的字,见 [markdownText](vscode/webview-ui/src/codeReader/findHighlights.ts#markdownText))。
*/
export function findUnits(blocks: BlockView[], placement: Placement, names: NameMap, markdownText: (markdown: Markdown) => string): FindUnit[] {
const units: FindUnit[] = []
for (const { block, ancestors } of placedBlocks(blocks)) {
const place = placement.of(block.id)
for (const { key, text, expand } of cardTexts(block)) {
const shown = typeof text === 'string'
? { text, renamed: [] }
: 'markdown' in text ? { text: markdownText(text), renamed: [] } : shownText(text, place?.offset, names)
units.push({ block: block.id, key, expand: [...ancestors, ...expand], line: place?.line ?? 0, ...shown })
}
}
return units
}
/** 在一段字里按字面、不分大小写找 `query`:从前往后,各处不重叠。`query` 是空的时一处都没有。 */
export function spansOf(query: string): (text: string) => Span[] {
if (query === '')
return () => []
const pattern = new RegExp(query.replace(/[\\^$.*+?()[\]{}|/]/g, '\\$&'), 'giu')
return text => Array.from(text.matchAll(pattern), match => ({ start: match.index, end: match.index + match[0].length }))
}
/** 各段字里的命中,按页面先后排。只显示中文名的那段,原名命中而中文名没命中时整个中文名算一处。 */
export function findMatches(units: readonly FindUnit[], query: string): FindMatch[] {
const find = spansOf(query)
const matches: FindMatch[] = []
units.forEach((unit, index) => {
const spans = find(unit.text)
for (const renamed of unit.renamed) {
if (find(renamed.original).length > 0 && !spans.some(span => span.start < renamed.end && renamed.start < span.end))
spans.push({ start: renamed.start, end: renamed.end })
}
spans.sort((a, b) => a.start - b.start)
for (const span of spans)
matches.push({ unit: index, ...span })
})
return matches
}
/** 查找栏上输入框后面写的:没写字时不写,没有命中写「无结果」,别的同筛选栏上名字后面写的(见 [occurrenceCount])。 */
export function findCount(result: FindResult): string | undefined {
if (result.query === '')
return undefined
return result.matches.length === 0 ? '无结果' : occurrenceCount({ occurrences: result.matches, ...(result.current !== undefined && { current: result.current }) })
}
/** 往页面上画高亮要的:查的字,有命中的各段字(按记号)显示的字和命中,当前那处在哪段字里、是那段里的第几处。 */
export interface FindPaint {
query: string
texts: ReadonlyMap<string, { text: string, spans: Span[] }>
current?: { key: string, index: number }
}
export function paintPlan(units: readonly FindUnit[], result: FindResult): FindPaint {
const texts = new Map<string, { text: string, spans: Span[] }>()
let current: FindPaint['current']
result.matches.forEach((match, index) => {
const { key, text } = units[match.unit]
const entry = texts.get(key) ?? { text, spans: [] }
texts.set(key, entry)
if (index === result.current)
current = { key, index: entry.spans.length }
entry.spans.push({ start: match.start, end: match.end })
})
return { query: result.query, texts, ...(current && { current }) }
}
/**
* 从哪往后找当前那处:一段字(记号 `key`)里第 `start` 个字(原来那处、选中的字),那段字没有了从 `line` 这一行往后第一段字起;
* 不给 `key` 时只看行(视口顶部那块的行)。
*/
export interface FindAnchor {
key?: string
start: number
line: number
}
/**
* 查找栏里的查找:查的字、各处命中和当前那处,变了交给 `changed`。
* 当前那处总是从某处往后第一处能跳到的(`reachable` 说那段字所在的块画没画出来):写了字从原来那处往后(原来没有就从视口顶部那块往后),
* 换了一版文档从原来那处往后,原来那处还命中就停在原地;被筛掉的块里的跳过,但算在总数里。
*/
export class PageFind {
private units: readonly FindUnit[] = []
private result = NOTHING
constructor(private readonly changed: (result: FindResult) => void) {}
get state(): FindResult {
return this.result
}
unitOf(match: FindMatch): FindUnit {
return this.units[match.unit]
}
/** 眼下往页面上画什么(见 [paintPlan]) */
paint(): FindPaint {
return paintPlan(this.units, this.result)
}
/** 页面上能查的字换了(新一版文档、中文名到了、打开了查找栏):照旧按原来的字找,当前那处停在原来那处附近。 */
setUnits(units: readonly FindUnit[], reachable: (unit: FindUnit) => boolean): void {
const anchor = this.anchor()
this.units = units
if (this.result.query !== '')
this.show(this.found(this.result.query, anchor ?? { start: 0, line: 0 }, reachable))
}
/** 查的字换了:从 `from`(选中的字)往后找,没给时从原来那处往后,原来也没有当前那处时从文件开头。返回当前那处。 */
search(query: string, reachable: (unit: FindUnit) => boolean, from?: FindAnchor): FindMatch | undefined {
this.show(this.found(query, from ?? this.anchor() ?? { start: 0, line: 0 }, reachable))
return this.currentMatch()
}
/** 当前那处换成往后(`step` 1)/ 往前(`step` -1)下一处能跳到的,到头了从另一头接着;返回它,一处都跳不到时不动、返回 `undefined`。 */
move(step: 1 | -1, reachable: (unit: FindUnit) => boolean): FindMatch | undefined {
const { matches, current } = this.result
const index = stepIndex(matches, current ?? (step === 1 ? -1 : matches.length), 0, step, match => reachable(this.units[match.unit]))
if (index === undefined)
return undefined
this.show({ ...this.result, current: index })
return matches[index]
}
private currentMatch(): FindMatch | undefined {
const { matches, current } = this.result
return current === undefined ? undefined : matches[current]
}
private anchor(): FindAnchor | undefined {
const match = this.currentMatch()
const unit = match && this.units[match.unit]
return unit && { key: unit.key, start: match.start, line: unit.line }
}
/** 找 `query`,当前那处是从 `anchor` 往后第一处能跳到的:那段字还在就从它里面那个位置往后,没了从那一行往后第一段字起。 */
private found(query: string, anchor: FindAnchor, reachable: (unit: FindUnit) => boolean): FindResult {
if (query === '')
return NOTHING
const matches = findMatches(this.units, query)
const own = anchor.key === undefined ? -1 : this.units.findIndex(unit => unit.key === anchor.key)
const unit = own >= 0 ? own : this.units.findIndex(unit => unit.line >= anchor.line)
const start = own >= 0 ? anchor.start : 0
const first = unit < 0 ? matches.length : matches.findIndex(match => match.unit > unit || (match.unit === unit && match.start >= start))
const current = stepIndex(matches, (first < 0 ? matches.length : first) - 1, 0, 1, match => reachable(this.units[match.unit]))
return { query, matches, ...(current !== undefined && { current }) }
}
private show(result: FindResult): void {
if (result === this.result)
return
this.result = result
this.changed(result)
}
}
@@ -0,0 +1,182 @@
// @vitest-environment happy-dom
import type { BlockTreeHandle } from './BlockTree'
import type { TextMarks } from './SegmentText'
import type { BlockView, PropertyRow, TextDiff } from './types'
import { renderToStaticMarkup } from 'react-dom/server'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { BlockTree, treeState } from './BlockTree'
import { DiffText } from './DiffText'
import { nameMap } from './displayNames'
import { findMatches, findUnits, paintPlan } from './find'
import { FindPainter, markdownText, textOf } from './findHighlights'
import { placementOf } from './placement'
import { TextMarksContext } from './SegmentText'
import { staticStore } from './store'
import { placed } from './testBlocks'
vi.mock('./vscode', () => ({ postMessage: () => {}, onMessage: () => () => {}, viewState: { get: () => ({ editing: [], folds: {} }), update: () => {} } }))
function element(html: string): HTMLElement {
const el = document.createElement('div')
el.innerHTML = html
document.body.append(el)
return el
}
afterEach(() => {
document.body.innerHTML = ''
})
describe('页面上一段字显示的字', () => {
it('各个文字节点连起来;成块的元素前后断开(换行),差异里删掉的字、记号、收起的类名弹层、图标不算', () => {
const el = element([
'<p class="cr-code"><span class="cr-diff-sign">+</span><span>设</span><span class="cr-group"><span class="cr-group-head"><svg><text>图</text></svg>每个</span><span>x</span></span>',
'<span class="cr-diff-part" data-change="del"><span>旧</span></span><span class="cr-diff-part" data-change="add"><span>新</span></span>',
'<span class="cr-classes"><button><span class="cr-group-head">样式</span></button><span class="cr-classes-popup">a b</span></span></p>',
'<div class="cr-markdown"><p>第一段<del class="cr-diff-word">删</del><ins class="cr-diff-word">加</ins></p><ul><li>一</li><li>二</li></ul></div>',
].join(''))
const { text, nodes } = textOf(el)
expect(text).toBe('设每个x新样式\n第一段加\n一\n二')
expect(nodes.map(node => [node.node.data, node.start])).toEqual([['设', 0], ['每个', 1], ['x', 3], ['新', 4], ['样式', 5], ['第一段', 8], ['加', 11], ['一', 13], ['二', 15]])
})
it('markdown 说明显示的字:同一份渲染出来的 HTML 在页面上读出来的一样;同一段说明只渲染一次', () => {
const doc = { markdown: '# 标题\n\n**加载**配置,见 `load`:\n\n- 一\n- 二\n\n```py\nx = 1\n```' }
const text = markdownText(doc)
expect(text).toContain('加载配置,见 load:')
expect(text).not.toContain('**')
expect(markdownText(doc)).toBe(text)
const page = element(renderToStaticMarkup(<DiffText diff={{ markdown: [{ text: doc.markdown }] }} />))
expect(textOf(page.firstElementChild!).text).toBe(text)
})
})
/** 一张属性表卡片、一块一行译文(带拆开的项、实参表、说明、几行属性),一张挂着子块的卡片 */
const ROWS: PropertyRow[] = [
{ label: '名称', value: [{ text: 'load', role: 'function', offset: 4 }] },
{ label: '说明', value: { markdown: '**加载**配置\n\n- 一\n- 二' } },
{ label: '输出', value: [{ text: 'str', role: 'type' }], note: { markdown: '返回 `path`' } },
{ label: '输入参数', value: [{ text: '2 个', role: 'hint' }], columns: ['名称', '默认值'], items: [
{ cells: [[{ text: 'path', role: 'variable', offset: 9 }], [{ text: '无', role: 'hint' }], [{ text: '多给的', role: 'plain' }]] },
{ cells: [[{ text: 'opts', role: 'variable', offset: 15 }], { markdown: '`{}`' }], items: [{ cells: [[{ text: 'k', role: 'plain' }], [{ text: 'v', role: 'plain' }]], items: [{ cells: [[{ text: '深', role: 'plain' }]] }] }], table: { columns: ['实参'], items: [{ cells: [[{ text: 'z', role: 'plain' }]] }] } },
] },
{ label: '导入项', value: [{ text: '1 个', role: 'hint' }], items: [{ cells: [[{ text: 'a', role: 'plain' }], [{ text: 'b', role: 'plain' }]], items: [{ cells: [[{ text: 'c', role: 'plain' }]] }] }] },
]
const BLOCKS: BlockView[] = placed([
{ id: '0', anchor: '0', kind: 'function', attributes: { kind: 'function' }, concepts: [{ id: 'function', label: '函数', icon: 'box' }], properties: ROWS, children: [
{
id: '0.0',
anchor: '0.0',
kind: 'statement',
attributes: { kind: 'statement' },
concepts: [{ id: 'statement', label: '语句', icon: 'box' }],
children: [],
translation: [
{ text: '设', role: 'keyword' },
{ text: 'LIMIT', role: 'constant', offset: 40 },
{ style: 'iteration', label: '对每个', segments: [{ text: 'item', role: 'variable', offset: 50 }] },
{ style: 'classes', label: '样式 2 个类', detail: 'a\nb', segments: [{ text: 'a b', role: 'string' }] },
{ text: 'load', role: 'function', offset: 4 },
],
items: [{ cells: [[{ text: 'key', role: 'string' }], [{ text: 'value', role: 'string' }]] }],
table: { columns: ['参数', '值'], items: [{ cells: [[{ text: 'id', role: 'variable' }], [{ text: '1', role: 'number' }]] }] },
doc: { markdown: '说明 *文字*' },
rows: [{ label: '注释', value: { markdown: '备注' } }],
},
] },
{ id: '1', anchor: '1', kind: 'demo.card', badge: { label: '卡片', color: { dark: '#fff', light: '#000' }, icon: 'box' }, attributes: { kind: 'demo.card' }, concepts: [{ id: 'demo.card', label: '卡片', icon: 'box' }], properties: [
{ label: '方法', value: [{ text: '1 个', role: 'hint' }], columns: ['名称'], items: [{ cells: [[{ text: 'start', role: 'function' }]] }] },
], children: [
{ id: '1.0', anchor: '1.0', kind: 'demo.method', slot: { row: 0, item: 0 }, attributes: { kind: 'demo.method' }, concepts: [{ id: 'demo.method', label: '方法', icon: 'box' }], children: [], translation: [{ text: '启动服务', role: 'plain' }] },
] },
])
const NAMES = nameMap([{ offset: 4, length: 4, name: '加载' }, { offset: 40, length: 5, name: '上限', definition: true }])
/** 把块树画出来:什么都没折叠,中文名是 NAMES */
function renderPage(): HTMLElement {
const tree: BlockTreeHandle = {
state: staticStore(treeState({ collapsed: new Set(), hidden: new Set(), temporary: new Set(), covered: new Map() })),
onToggleFold: () => {},
onSetFolds: () => {},
onOpenRun: () => {},
}
const marks = staticStore<TextMarks>({ names: NAMES, placement: placementOf(BLOCKS) })
return element(renderToStaticMarkup(
<TextMarksContext value={marks}>
<BlockTree blocks={BLOCKS} tree={tree} items />
</TextMarksContext>,
))
}
describe('页面上画的字和算命中用的字对得上', () => {
it('每段能查的字在页面上正好一个元素记着它,读出来的字和算的一样(属性名、值、说明、表头、各层子行、译文、框、说明、几行属性、挂在子行上的子块)', () => {
const root = renderPage()
const units = findUnits(BLOCKS, placementOf(BLOCKS), NAMES, markdownText)
expect(units.length).toBeGreaterThan(30)
for (const unit of units) {
const found = root.querySelectorAll<HTMLElement>(`[data-find="${unit.key}"]`)
expect(found, unit.key).toHaveLength(1)
expect(textOf(found[0]).text, unit.key).toBe(unit.text)
}
expect(root.querySelectorAll('[data-find]')).toHaveLength(units.length)
})
})
describe('往页面上画查找的高亮', () => {
it('各段字按算好的命中画;当前那处是它那段里的第几处;没有命中的段、浮窗这类根外面的不画', () => {
const root = renderPage()
const outside = element('<p data-find="0/p0#label">名称</p>')
const units = findUnits(BLOCKS, placementOf(BLOCKS), NAMES, markdownText)
const matches = findMatches(units, 'load')
const painter = new FindPainter()
const detach = painter.attach(root)
const painted = painter.show(paintPlan(units, { query: 'load', matches, current: 1 }))
expect(painted.ranges.map(range => range.toString())).toEqual(['加载', '加载'])
expect(painted.current?.toString()).toBe('加载')
expect(painted.current?.startContainer.parentElement?.closest('[data-find]')?.getAttribute('data-find')).toBe('0.0#text')
expect(painted.text?.getAttribute('data-find')).toBe('0.0#text')
expect(outside.contains(painted.ranges[0].startContainer)).toBe(false)
const all = painter.show(paintPlan(units, { query: '名称', matches: findMatches(units, '名称'), current: 2 }))
expect(all.ranges.map(range => range.startContainer.parentElement?.closest('[data-find]')?.getAttribute('data-find'))).toEqual(['0/p0#label', '0/p3#h0', '1/p0#h0'])
expect(all.current?.startContainer.parentElement?.closest('[data-find]')?.getAttribute('data-find')).toBe('1/p0#h0')
detach()
})
it('页面上的字和算的对不上(差异里的旧字、画成图的说明):按页面上的字现找,当前那处取那段里同样第几处,没有就落在那段字上', () => {
const root = element('<p data-find="a#value">old new old</p><p data-find="b#value">gone</p>')
const painter = new FindPainter()
painter.attach(root)
const plan = { query: 'old', texts: new Map([['a#value', { text: 'new old', spans: [{ start: 4, end: 7 }] }], ['b#value', { text: 'old', spans: [{ start: 0, end: 3 }] }]]) }
const painted = painter.show({ ...plan, current: { key: 'a#value', index: 0 } })
expect(painted.ranges.map(range => [range.startOffset, range.endOffset])).toEqual([[0, 3], [8, 11]])
expect(painted.current?.startOffset).toBe(0)
const missing = painter.show({ ...plan, current: { key: 'b#value', index: 0 } })
expect(missing.current).toBeUndefined()
expect(missing.text?.textContent).toBe('gone')
expect(painter.show(undefined)).toEqual({ ranges: [] })
})
it('页面变了(展开了、换了一版文档):下一帧照旧按要画的重画', async () => {
const root = element('<p data-find="a#value">x</p>')
const painter = new FindPainter()
const detach = painter.attach(root)
painter.show({ query: 'x', texts: new Map([['b#value', { text: 'x', spans: [{ start: 0, end: 1 }] }]]), current: { key: 'b#value', index: 0 } })
expect(painter.painted.current).toBeUndefined()
root.insertAdjacentHTML('beforeend', '<p data-find="b#value">x</p>')
await vi.waitFor(() => expect(painter.painted.current?.toString()).toBe('x'))
detach()
})
})
describe('差异里改了的字', () => {
it('删掉的旧字不算,读出来的是新字', () => {
const diff: TextDiff = { inline: [{ segments: [{ text: 'a', role: 'plain' }] }, { segments: [{ text: 'b', role: 'plain' }], change: 'del' }, { segments: [{ text: 'c', role: 'plain' }], change: 'add' }] }
const el = element(renderToStaticMarkup(<DiffText diff={diff} find="x#text" />))
expect(el.querySelector('[data-find="x#text"]')).not.toBeNull()
expect(textOf(el).text).toBe('ac')
})
})
@@ -0,0 +1,247 @@
import type { FindPaint, Span } from './find'
import type { Markdown } from './types'
import { spansOf } from './find'
import { renderMarkdown } from './markdown'
/** 读字时跳过的:差异里删掉的字和行首的 + / − 记号,点开的类名弹层,图标和画出来的图(svg) */
const SKIPPED = '.cr-diff-sign, .cr-diff-part[data-change="del"], del.cr-diff-word, .cr-classes-popup, svg'
/** 成块排的元素:前后的字不连成一串,中间算一个换行(段落、列表项、标题、代码块、表格的格子) */
const BLOCK_TAGS = new Set([
'ADDRESS',
'ARTICLE',
'ASIDE',
'BLOCKQUOTE',
'BR',
'DD',
'DIV',
'DL',
'DT',
'FIGCAPTION',
'FIGURE',
'FOOTER',
'H1',
'H2',
'H3',
'H4',
'H5',
'H6',
'HEADER',
'HR',
'LI',
'MAIN',
'NAV',
'OL',
'P',
'PRE',
'SECTION',
'TABLE',
'TBODY',
'TD',
'TFOOT',
'TH',
'THEAD',
'TR',
'UL',
])
/** 一段字在页面上读出来的样子:显示的字,和各个文字节点从字里第几个字起。 */
export interface PageText {
text: string
nodes: { node: Text, start: number }[]
}
/**
* `root` 里显示的字:各个文字节点按先后连起来,成块的元素之间算一个换行(查的字是一行,不会跨块命中),[SKIPPED] 里的不算。
* 页面上的一段字和算命中用的 Markdown 说明([markdownText])都按它读,同一份 HTML 读出来一样。
*/
export function textOf(root: Node): PageText {
let text = ''
const nodes: PageText['nodes'] = []
let broken = false
const visit = (parent: Node): void => {
for (let child = parent.firstChild; child; child = child.nextSibling) {
if (child.nodeType === Node.TEXT_NODE) {
const value = (child as Text).data
if (value === '')
continue
if (broken && text !== '')
text += '\n'
broken = false
nodes.push({ node: child as Text, start: text.length })
text += value
}
else if (child.nodeType === Node.ELEMENT_NODE && !(child as Element).matches(SKIPPED)) {
const block = BLOCK_TAGS.has((child as Element).tagName)
broken ||= block
visit(child)
broken ||= block
}
}
}
visit(root)
return { text, nodes }
}
const markdownTexts = new WeakMap<Markdown, string>()
/** 一段 Markdown 说明在页面上显示的字:同页面上一样渲染成 HTML([renderMarkdown])再读([textOf]);同一段说明只读一次。 */
export function markdownText(markdown: Markdown): string {
let text = markdownTexts.get(markdown)
if (text === undefined) {
const template = document.createElement('template')
template.innerHTML = renderMarkdown(markdown.markdown)
text = textOf(template.content).text
markdownTexts.set(markdown, text)
}
return text
}
/** 一段字里 `span` 那几个字在页面上的范围;落不到文字节点上时没有。 */
function rangeIn({ nodes }: PageText, { start, end }: Span): Range | undefined {
let from: PageText['nodes'][number] | undefined
let to: PageText['nodes'][number] | undefined
for (const node of nodes) {
if (node.start <= start)
from = node
if (node.start < end)
to = node
}
if (!from || !to)
return undefined
const range = document.createRange()
range.setStart(from.node, start - from.start)
range.setEnd(to.node, end - to.start)
return range
}
/** 画好的查找高亮:各处命中,当前那处,当前那处所在的那段字(当前那处画不出来时滚到它)。 */
export interface PaintedFind {
ranges: Range[]
current?: Range
text?: HTMLElement
}
const NOT_PAINTED: PaintedFind = { ranges: [] }
/** CSS 里给查找高亮起的名字(见 codeReader.css 的 ::highlight):各处命中,当前那处 */
const ALL = 'cr-find'
const CURRENT = 'cr-find-current'
/**
* 把查找栏算好的命中画到页面上:用 CSS 的自定义高亮(`CSS.highlights`)给各处命中的字垫底色,不动页面的元素,块树不用重画;
* 当前那处另垫一层,再在它外面描一圈框(自定义高亮画不了边框,框是盖在页面上的几个空元素,见 [place])。
* 只画块树所在的那层页面内容([attach] 的 `root`)里记着字的元素(`data-find`),定义卡片浮窗、钉住的卡片头在它外面,不画。
* 页面上的字和算的一样时按算好的位置画;对不上(差异里的旧字、画成图的说明)时按页面上的字现找。
* 展开、换了一版文档这些让页面变了的,下一帧照旧按要画的重画。
*/
export class FindPainter {
private container: HTMLElement | null = null
private plan: FindPaint | undefined
private result = NOT_PAINTED
private frame = 0
private outline: HTMLElement | undefined
get painted(): PaintedFind {
return this.result
}
/** 画在哪层页面内容里(见 [attach]);还没挂上时没有 */
get root(): HTMLElement | null {
return this.container
}
/** 画在 `root` 里;返回停止画的函数 */
attach(root: HTMLElement): () => void {
this.container = root
const later = (): void => this.later()
const mutations = new MutationObserver(later)
mutations.observe(root, { childList: true, subtree: true, characterData: true })
const sizes = new ResizeObserver(later)
sizes.observe(root)
window.addEventListener('resize', later)
this.paint()
return () => {
mutations.disconnect()
sizes.disconnect()
window.removeEventListener('resize', later)
cancelAnimationFrame(this.frame)
if (this.container === root) {
this.container = null
this.paint()
}
}
}
/** 换成要画的(没有就清掉),马上画,交回画好的。 */
show(plan: FindPaint | undefined): PaintedFind {
this.plan = plan
this.paint()
return this.result
}
private later(): void {
if (!this.plan)
return
cancelAnimationFrame(this.frame)
this.frame = requestAnimationFrame(() => this.paint())
}
private paint(): void {
const { container: root, plan } = this
this.result = root && plan && plan.texts.size > 0 ? collect(root, plan) : NOT_PAINTED
const registry = typeof CSS !== 'undefined' ? CSS.highlights as HighlightRegistry | undefined : undefined
if (registry) {
const current = new Highlight(...(this.result.current ? [this.result.current] : []))
current.priority = 1
registry.set(ALL, new Highlight(...this.result.ranges))
registry.set(CURRENT, current)
}
this.place()
}
/**
* 摆当前那处外面的框:每行一个,按文档坐标摆,跟着页面滚动;层级低于钉住的卡片和筛选栏,滚到它们下面时被盖住。
* 滚过去之前那一带要先排版(见 [showInViewport](vscode/webview-ui/src/codeReader/useReveal.ts#showInViewport)),位置可能变了,滚完再摆一次。
*/
place(): void {
const rects = this.result.current?.getClientRects?.() ?? []
if (rects.length === 0) {
this.outline?.replaceChildren()
return
}
if (!this.outline?.isConnected) {
this.outline = document.createElement('div')
this.outline.className = 'cr-find-outline'
document.body.append(this.outline)
}
this.outline.replaceChildren(...Array.from(rects, (rect) => {
const box = document.createElement('div')
box.style.cssText = `left:${rect.left + window.scrollX}px;top:${rect.top + window.scrollY}px;width:${rect.width}px;height:${rect.height}px`
return box
}))
}
}
/** 按要画的读出 `root` 里各段字的命中:同一个记号有几个元素时当前那处取第一个。 */
function collect(root: HTMLElement, plan: FindPaint): PaintedFind {
const ranges: Range[] = []
let current: Range | undefined
let text: HTMLElement | undefined
const find = spansOf(plan.query)
for (const el of root.querySelectorAll<HTMLElement>('[data-find]')) {
const key = el.dataset.find ?? ''
const expected = plan.texts.get(key)
if (!expected)
continue
const page = textOf(el)
const spans = page.text === expected.text ? expected.spans : find(page.text)
const found = spans.flatMap(span => rangeIn(page, span) ?? [])
ranges.push(...found)
if (plan.current?.key === key && !text) {
text = el
current = found[Math.min(plan.current.index, found.length - 1)]
}
}
return { ranges, ...(current && { current }), ...(text && { text }) }
}
+40 -30
View File
@@ -133,8 +133,8 @@ export function isCurrent(segment: Segment, base: number, highlight: Highlight):
return current !== undefined && overlaps(segment, base, current)
}
/** 筛选栏上名字后面写的:有当前那一处时「第几处/共几处」(2/5),没有时「N 处」;看不见的也算在总数里。 */
export function occurrenceCount(highlight: Highlight): string {
/** 筛选栏上名字后面、查找栏上写的:有当前那一处时「第几处/共几处」(2/5),没有时「N 处」;看不见的也算在总数里。 */
export function occurrenceCount(highlight: { occurrences: readonly unknown[], current?: number }): string {
const total = highlight.occurrences.length
return highlight.current === undefined ? `${total} 处` : `${highlight.current + 1}/${total}`
}
@@ -143,12 +143,12 @@ export function occurrenceCount(highlight: Highlight): string {
* 往后(step 1)/ 往前(step -1)数,下一处能跳到的出现的序号,到头了从另一头接着数;一处都跳不到时没有。
* 从当前那一处(current)数起;没有当前那一处时从源码下标 from(点中的名字)数起。
*/
export function stepIndex(
occurrences: readonly Occurrence[],
export function stepIndex<T extends { start: number }>(
occurrences: readonly T[],
current: number | undefined,
from: number,
step: 1 | -1,
reachable: (occurrence: Occurrence) => boolean,
reachable: (occurrence: T) => boolean,
): number | undefined {
const count = occurrences.length
// 没有当前那一处时,起点是 from 前面最后一处(往后数)/ 后面第一处(往前数),数的时候跳过它本身所在的位置
@@ -332,6 +332,34 @@ export class OccurrenceHighlight {
}
}
/**
* 页面上一组快捷键眼下能不能用:要看的几件事(facts)记着,`test` 按它们算能不能用;变了才交给 `report` 告诉扩展端,
* 扩展端跟着设快捷键 when 子句用的上下文键。第一次 update 时不管变没变都告诉(页面重新加载后扩展端记的可能是旧页面的)。
*/
export class ShortcutContext<Facts extends object> {
private reported: boolean | undefined
constructor(
private facts: Facts,
private readonly test: (facts: Facts) => boolean,
private readonly report: (available: boolean) => void,
) {}
get available(): boolean {
return this.test(this.facts)
}
/** 改了哪几件事 */
update(change: Partial<Facts>): void {
this.facts = { ...this.facts, ...change }
const { available } = this
if (available === this.reported)
return
this.reported = available
this.report(available)
}
}
/** 能不能用键盘跳要看的几件事。 */
export interface NavigationFacts {
/** 有名字高亮着 */
@@ -346,30 +374,12 @@ export interface NavigationFacts {
* 眼下能不能用键盘在高亮着的名字的各处出现之间跳:有高亮、页面有焦点、焦点不在输入框里。变了才告诉扩展端,
* 扩展端跟着设快捷键 when 子句用的上下文键;页面也按它决定要不要拦下这些键在浏览器里的默认动作(滚到顶、滚到底)。
*/
export class OccurrenceNavigation {
private facts: NavigationFacts
private reported: boolean | undefined
constructor(private readonly post: (msg: WebviewToExtension) => void, initial: Omit<NavigationFacts, 'highlighted'>) {
this.facts = { highlighted: false, ...initial }
}
get available(): boolean {
const { highlighted, focused, typing } = this.facts
return highlighted && focused && !typing
}
/** 改了哪几件事;第一次调用时不管变没变都告诉扩展端(页面重新加载后扩展端记的可能是旧页面的) */
update(change: Partial<NavigationFacts>): void {
this.facts = { ...this.facts, ...change }
this.report()
}
private report(): void {
const { available } = this
if (available === this.reported)
return
this.reported = available
this.post({ type: 'occurrenceNavigation', available })
export class OccurrenceNavigation extends ShortcutContext<NavigationFacts> {
constructor(post: (msg: WebviewToExtension) => void, initial: Omit<NavigationFacts, 'highlighted'>) {
super(
{ highlighted: false, ...initial },
({ highlighted, focused, typing }) => highlighted && focused && !typing,
available => post({ type: 'occurrenceNavigation', available }),
)
}
}
@@ -2,7 +2,7 @@ import type { BlockView as HostBlockView } from '../../../src/codeReader/blockVi
import type { LanguageAdapter, LibraryRule, Translator } from '../../../src/codeReader/contract'
import type { RevealPlan } from './reveal'
import type { WrittenBlock } from './testBlocks'
import type { BlockKind, Inline, Occurrence, PropertyItem, PropertyRow, Segment } from './types'
import type { BlockKind, CardView, Inline, Occurrence, PreviewText, PropertyItem, PropertyRow, Segment } from './types'
import { describe, expect, it } from 'vitest'
import { toBlockViews } from '../../../src/codeReader/blockView'
import { pageBlocks } from '../../../src/codeReader/editor/pageBlocks'
@@ -14,7 +14,7 @@ import { WASM_DIR, withBlocks } from '../../../src/codeReader/testSupport'
import { pythonZhTranslator } from '../../../src/codeReader/translators/pythonZh'
import { typescriptZhTranslator } from '../../../src/codeReader/translators/typescriptZh'
import { placementOf } from './placement'
import { occurrencePlan, revealPlan, scrollTopToShow } from './reveal'
import { cardTexts, occurrencePlan, revealPlan, scrollTopToShow } from './reveal'
import { placed } from './testBlocks'
import { collapsedIds, propertyItemId, propertyRowId } from './viewState'
@@ -114,6 +114,85 @@ describe('用键盘跳到高亮的一处出现:滚到哪块、展开哪些', (
})
})
describe('卡片上的各段字:页面上的先后、画在哪、要展开的', () => {
const texts = (block: CardView): [string, PreviewText | string, string[]][] => [...cardTexts(block)].map(({ key, text, expand }) => [key, text, expand])
it('属性表:每行的属性名、值、说明,再是这一行下面的子行;列表的项下面的下一层要展开那一项', () => {
const [imports] = placed(BLOCKS)
expect(texts(imports)).toEqual([
['0/p0#label', '来源', []],
['0/p0#value', [name('luffy_agent', 5), { text: '.', role: 'type' }, name('core', 17)], []],
['0/p1#label', '导入项', []],
['0/p1#value', [{ text: '1 个', role: 'hint' }], []],
['0/p1/0#0', [name('get_db', 34)], ['0/p1']],
])
const nested = factBlock('3', 'variable', 9, [{
label: '值',
value: { markdown: '字典' },
note: { markdown: '说明' },
items: [{ cells: [[name('a', 1)], [name('b', 3)]], items: [{ cells: [[name('c', 5)]] }] }],
}])
expect(texts(placed([nested])[0])).toEqual([
['3/p0#label', '值', []],
['3/p0#value', { markdown: '字典' }, []],
['3/p0#note', { markdown: '说明' }, []],
['3/p0/0#0', [name('a', 1)], ['3/p0']],
['3/p0/0#1', [name('b', 3)], ['3/p0']],
['3/p0/0/0#0', [name('c', 5)], ['3/p0', '3/p0/0']],
])
})
it('表格:表头、各行按列名取的格子;行下面拆开的列表、套着的实参表总是展开的,列表的项下面的下一层要展开那一项', () => {
const table = factBlock('1', 'function', 2, [{
label: '输入参数',
value: [{ text: '1 个', role: 'hint' }],
columns: ['名称', '默认值'],
items: [{
cells: [[name('path', 51)], [word('无')], [word('多给的')]],
items: [{ cells: [[name('x', 60)]], items: [{ cells: [[name('y', 62)]] }] }],
table: { columns: ['实参'], items: [{ cells: [[name('z', 64)]] }] },
}],
}])
expect(texts(placed([table])[0])).toEqual([
['1/p0#label', '输入参数', []],
['1/p0#value', [{ text: '1 个', role: 'hint' }], []],
['1/p0#h0', '名称', ['1/p0']],
['1/p0#h1', '默认值', ['1/p0']],
['1/p0/0#0', [name('path', 51)], ['1/p0']],
['1/p0/0#1', [word('无')], ['1/p0']],
['1/p0/0/0#0', [name('x', 60)], ['1/p0']],
['1/p0/0/0/0#0', [name('y', 62)], ['1/p0', '1/p0/0/0']],
['1/p0/0/t#h0', '实参', ['1/p0']],
['1/p0/0/t/0#0', [name('z', 64)], ['1/p0']],
])
})
it('一行译文:译文,拆开的各项,实参表,说明,几行属性', () => {
const line: WrittenBlock = {
...textBlock('4', 'statement', 10, [word('设'), name('a', 1)]),
items: [{ cells: [[name('k', 3)], [name('v', 5)]] }],
table: { columns: ['参数'], items: [{ cells: [[name('p', 7)]] }] },
doc: { markdown: '说明' },
rows: [{ label: '注释', value: { markdown: '备注' } }],
}
expect(texts(placed([line])[0])).toEqual([
['4#text', [word('设'), name('a', 1)], []],
['4/i/0#0', [name('k', 3)], []],
['4/i/0#1', [name('v', 5)], []],
['4/t#h0', '参数', []],
['4/t/0#0', [name('p', 7)], []],
['4#doc', { markdown: '说明' }, []],
['4/p0#label', '注释', []],
['4/p0#value', { markdown: '备注' }, []],
])
})
it('用键盘跳到一处出现也按它找:一行译文下面几行属性里显示着的名字也算', () => {
const line: WrittenBlock = { ...textBlock('0', 'statement', 0, [word('设')]), rows: [{ label: '值', value: [name('get_db', 30)] }] }
expect(occurrenceIn([line], { start: 30, end: 36, kind: 'read' }, () => false)).toEqual({ id: '0', expand: [] })
})
})
describe('跳到定义:滚到哪块、展开哪些', () => {
it('名字在嵌套块的译文里:目标是那块,展开它所有的祖先块', () => {
expect(revealIn(BLOCKS, 4, 102)).toEqual({ id: '1.0.0', expand: ['1', '1.0'] })
+83 -24
View File
@@ -1,8 +1,9 @@
import type { Placement } from './placement'
import type { BlockView, Inline, Occurrence, PreviewTable, PreviewText, PropertyItem, PropertyRow, SourceRange } from './types'
import type { BlockView, CardView, Inline, Occurrence, PreviewTable, PreviewText, PropertyItem, PropertyRow, SourceRange } from './types'
import { blockPath } from './filter'
import { showsOccurrence, sourceOf } from './occurrences'
import { listId, propertyItemId, propertyRowId, tableId, unfoldToShow } from './viewState'
import { subRowsLayout, tableCells } from './subRows'
import { listId, propertyItemId, propertyRowId, tableId, textKey, unfoldToShow } from './viewState'
/** 跳到定义时要滚到的块,就是这处定义的属性行 / 子行(只闪它),以及要展开的块、属性行和子行:它们折叠着,目标就不在页面上。 */
export interface RevealPlan {
@@ -23,18 +24,18 @@ interface Spot {
defining: boolean
}
interface Placed {
export interface Placed {
block: BlockView
/** 要让它看得见得展开的块、属性行(从顶层往下,见 unfoldToShow) */
ancestors: string[]
}
/** 按页面上的先后(先父块、再子块)列出所有块。 */
function* placed(blocks: BlockView[], ancestors: string[] = []): Generator<Placed> {
export function* placedBlocks(blocks: BlockView[], ancestors: string[] = []): Generator<Placed> {
for (const block of blocks) {
yield { block, ancestors }
for (const child of block.children)
yield* placed([child], [...ancestors, ...unfoldToShow(block.id, child)])
yield* placedBlocks([child], [...ancestors, ...unfoldToShow(block.id, child)])
}
}
@@ -156,15 +157,6 @@ function shownItem(items: Iterable<PlacedItem>, offset: number): Spot | undefine
return undefined
}
/** 一层层的子行里,格子满足 test(显示着要找的名字)的第一个:要展开的各层。 */
function showingItem(items: Iterable<PlacedItem>, test: (cell: PreviewText) => boolean): string[] | undefined {
for (const { item, expand } of items) {
if (item.cells.some(test))
return expand
}
return undefined
}
/** 在几块里挑目标时,`spot` 是否比眼下选中的 `best` 更贴近这处定义:盖住下标的那段更短的更贴近,一样短时讲这个定义的胜过只是显示着这个名字的。 */
function closer(spot: Spot, best: Spot): boolean {
return spot.span !== best.span ? spot.span < best.span : spot.defining && !best.defining
@@ -183,7 +175,7 @@ export function revealPlan(blocks: BlockView[], placement: Placement, line: numb
let best: { plan: RevealPlan, spot: Spot } | undefined
let byLine: Placed | undefined
let byLineSpan = Number.POSITIVE_INFINITY
for (const item of placed(blocks)) {
for (const item of placedBlocks(blocks)) {
const place = placement.of(item.block.id)
if (!place)
continue
@@ -213,15 +205,82 @@ export function uncoveredPlan(plan: RevealPlan, covered: ReadonlyMap<string, str
return owner === undefined ? plan : { id: owner, expand: blockPath(owner).slice(0, -1) }
}
/** 块里哪儿显示着满足 test 的文字(译文和它下面拆开的各项、表,属性行的值、各层子行的格子):要展开的属性行和子行;不在这块里是 undefined。 */
/**
* 卡片上的一段字:画在页面上记号是 `key` 的元素上(见 [textKey]);属性名、列名是纯文字,别的是着色的词或 Markdown。
* `expand` 是要让它露出来得展开的属性行和子行(从外往里),不含祖先块。
*/
export interface CardText {
key: string
text: PreviewText | string
expand: string[]
}
/**
* 卡片上按页面先后排的各段字(同 [BlockPreview](vscode/webview-ui/src/codeReader/BlockTree.tsx#BlockPreview) 的排法):
* 属性表是每行的属性名、值、说明,再是这一行下面的子行;一行译文是译文,拆开的各项,实参表,说明,几行属性。
* 用键盘跳到一处出现、查找栏都按它找字在哪、要展开什么。挂在子行上的子块是别的块,不在这里。
*/
export function* cardTexts(block: CardView): Generator<CardText> {
if (!('translation' in block)) {
yield* rowTexts(block.id, block.properties)
return
}
yield { key: textKey(block.id, 'text'), text: block.translation, expand: [] }
yield* itemTexts(block.items, listId(block.id), [])
if (block.table)
yield* tableTexts(block.table, tableId(block.id), [])
if (block.doc)
yield { key: textKey(block.id, 'doc'), text: block.doc, expand: [] }
if (block.rows)
yield* rowTexts(block.id, block.rows)
}
/** 属性表各行的字:子行折叠在行上,给了列名的排成表格,没给的排成列表(见 [subRowsLayout])。 */
function* rowTexts(blockId: string, rows: PropertyRow[]): Generator<CardText> {
for (const [index, row] of rows.entries()) {
const id = propertyRowId(blockId, index)
yield { key: textKey(id, 'label'), text: row.label, expand: [] }
yield { key: textKey(id, 'value'), text: row.value, expand: [] }
if (row.note)
yield { key: textKey(id, 'note'), text: row.note, expand: [] }
const layout = subRowsLayout(row)
if (layout === 'table')
yield* tableTexts({ columns: row.columns ?? [], items: row.items ?? [] }, id, [id])
else if (layout === 'list')
yield* itemTexts(row.items, id, [id])
}
}
/** 排成列表的一层子行(`parentId` 下面)的格子:项下面的下一层折叠在项上,项带的实参表总是展开的。`expand` 是让这一层露出来得展开的。 */
function* itemTexts(items: PropertyItem[] | undefined, parentId: string, expand: string[]): Generator<CardText> {
for (const [index, item] of (items ?? []).entries()) {
const id = propertyItemId(parentId, index)
for (const [cell, text] of item.cells.entries())
yield { key: textKey(id, String(cell)), text, expand }
yield* itemTexts(item.items, id, [...expand, id])
if (item.table)
yield* tableTexts(item.table, tableId(id), expand)
}
}
/** 一张表(`parentId` 下面)的列名和各行按列名取的格子(见 [tableCells]);行下面拆开的列表、套着的实参表总是展开的。 */
function* tableTexts(table: PreviewTable, parentId: string, expand: string[]): Generator<CardText> {
for (const [column, name] of table.columns.entries())
yield { key: textKey(parentId, `h${column}`), text: name, expand }
for (const [index, item] of table.items.entries()) {
const id = propertyItemId(parentId, index)
for (const [cell, text] of tableCells(item, table.columns).entries())
yield { key: textKey(id, String(cell)), text, expand }
yield* itemTexts(item.items, id, expand)
if (item.table)
yield* tableTexts(item.table, tableId(id), expand)
}
}
/** 块里按页面先后第一段满足 `test` 的字(属性名、列名不算):要展开的属性行和子行;不在这块里是 `undefined`。 */
function showingSpot(block: BlockView, test: (text: PreviewText) => boolean): string[] | undefined {
if ('translation' in block)
return test(block.translation) ? [] : showingItem(textItems(block.id, block.items, block.table), test)
for (const [index, row] of block.properties.entries()) {
if (test(row.value))
return []
const expand = showingItem(rowItems(row, propertyRowId(block.id, index)), test)
if (expand)
for (const { text, expand } of cardTexts(block)) {
if (typeof text !== 'string' && test(text))
return expand
}
return undefined
@@ -232,7 +291,7 @@ function showingSpot(block: BlockView, test: (text: PreviewText) => boolean): st
* skip 的块(被筛掉的、正在编辑的、被正在编辑的祖先盖住的)里的不算;Markdown 说明里的名字不带下标,也不算。页面上哪儿都没有时是 undefined。
*/
export function occurrencePlan(blocks: BlockView[], placement: Placement, occurrence: Occurrence, skip: (id: string) => boolean): RevealPlan | undefined {
for (const { block, ancestors } of placed(blocks)) {
for (const { block, ancestors } of placedBlocks(blocks)) {
const base = placement.of(block.id)?.offset
const test = (text: PreviewText): boolean => base !== undefined && !('markdown' in text) && showsOccurrence(text, base, occurrence)
const expand = skip(block.id) ? undefined : showingSpot(block, test)
@@ -520,6 +520,8 @@ export interface CodeColors {
* rewriteProgress:askId 那一轮重写排上队了(queued)、开始了、正文长了(text 是到现在为止写出来的)、或换了在做的事;question 是记在这一轮上的话(点按钮时是那句技能命令)
* rewriteEnd:askId 那一轮重写结束了,见 RewriteEnd:done 是模型答完、记下了(没写进文件时带原因);failed、stopped 没记,文件没改
* moveOccurrence:跳到高亮着的名字的下一处(step 1)/ 上一处(step -1)出现
* openFind:打开查找栏(⌘F / Ctrl+F),聚焦输入框;页面上选中了一行字时先填进去
* moveFind:查找栏里跳到下一处(step 1)/ 上一处(step -1)命中(⌘G / ⇧⌘G,Windows 是 F3 / Shift+F3)
* displayNames:version 这份文档里显示成中文名的名字,查到一步来一步;不是页面上这个版本的不理
* diff:这份文件相比对比基准改了什么(审查工作副本里的、从「未提交的更改」点开的有改动的文件才有);每来一份文档之后补来,基准变了、换了从哪儿打开再来;null 是不比了
* openedFrom:这个标签页是从哪儿打开的,记进页面状态;ready 后来一次,开着时又从侧栏点开、换了从哪儿打开的再来
@@ -580,6 +582,8 @@ export type ExtensionToWebview
| ({ type: 'stickyScroll' } & StickyScrollSettings)
| { type: 'occurrencesFound', id: number, occurrences: Occurrence[] }
| { type: 'moveOccurrence', step: 1 | -1 }
| { type: 'openFind' }
| { type: 'moveFind', step: 1 | -1 }
| { type: 'displayNames', version: number, names: ShownName[] }
| { type: 'filter', excluded: Excluded, onlyChanges: boolean }
| ({ type: 'codeColors' } & CodeColors)
@@ -622,6 +626,7 @@ export type ExtensionToWebview
* deleteQa:删掉 anchor 那块下面的一次问答(已经理解了)
* selectProfile:kind 那种对话换 profile;不在本地先改,等扩展端发回这种的 profiles 再按它显示
* occurrenceNavigation:眼下能不能用键盘在高亮着的名字的各处出现之间跳(有高亮、页面有焦点、焦点不在输入框里),变了才发
* findNavigation:眼下查找栏的快捷键(下一处 / 上一处)归不归这个页面(查找栏开着、页面有焦点),变了才发
* openRepoLink:点了注释里的仓库路径链接,打开 path(从仓库根算起)。symbol 是类名、函数名或「类名.成员名」,没有就只打开文件或在代码阅读侧栏的树上定位目录;
* 没有 path 是不带路径的引用(`[Name]`、`[Class.member]`),扩展端按这份文件找 symbol:block 是它写在哪块的注释里(见 SymbolReference),
* 从那块的声明起一层层往外找
@@ -651,6 +656,7 @@ export type WebviewToExtension
| { type: 'deleteQa', anchor: string, entryId: string }
| { type: 'selectProfile', kind: ChatKind, id: string }
| { type: 'occurrenceNavigation', available: boolean }
| { type: 'findNavigation', available: boolean }
| { type: 'openRepoLink', path?: string, symbol?: string, block?: string }
| { type: 'checkRepoLinks', id: number, paths: string[], references: SymbolReference[] }
| { type: 'checkRepoImages', id: number, paths: string[] }
+210
View File
@@ -0,0 +1,210 @@
import type { NameMap } from './displayNames'
import type { FindAnchor, FindMatch, FindResult, FindUnit } from './find'
import type { FindPainter } from './findHighlights'
import type { TextMarks } from './SegmentText'
import type { ReadableStore } from './store'
import type { OccurrencePage } from './useOccurrences'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { findCount, findUnits, PageFind } from './find'
import { markdownText, textOf } from './findHighlights'
import { ShortcutContext } from './occurrences'
import { pageLayout } from './pageLayout'
import { useStore } from './store'
import { showInViewport } from './useReveal'
import { onMessage, postMessage } from './vscode'
/** 打字停下多久才找:大文件里每敲一个字都整份找一遍、重画一遍高亮会卡 */
const TYPING_MS = 100
const NO_UNITS: readonly FindUnit[] = []
const NO_NAMES: NameMap = new Map()
const NOTHING: FindResult = { query: '', matches: [] }
/** 查找栏要画的和它的动作,见 [FindBar](vscode/webview-ui/src/codeReader/FindBar.tsx#FindBar)。 */
export interface FindView {
open: boolean
query: string
count?: string
canMove: boolean
focus: number
onQuery: (query: string) => void
onMove: (step: 1 | -1) => void
onClose: () => void
}
/** 一次找:查的字,从哪往后找(⌘F 拿选中的字填进去时是选中的那处)。 */
interface Search {
query: string
from?: FindAnchor
}
/**
* 页面上选中的一行字,和查找从哪开始(它在页面内容 `root` 里哪段字的第几个字);没选中、选中了好几行、焦点在输入框里时没有。
* `line` 是那块在文件里的第一行(见 [FindAnchor])。
*/
function selectedText(root: HTMLElement | null, page: OccurrencePage | undefined): Search | undefined {
const selection = window.getSelection()
const query = selection?.toString() ?? ''
const field = document.activeElement
if (!selection || selection.rangeCount === 0 || query.trim() === '' || /[\r\n]/.test(query) || field instanceof HTMLInputElement || field instanceof HTMLTextAreaElement)
return undefined
const { startContainer, startOffset } = selection.getRangeAt(0)
const holder = (startContainer instanceof Element ? startContainer : startContainer.parentElement)?.closest<HTMLElement>('[data-find]')
const node = holder && root?.contains(holder) ? textOf(holder).nodes.find(item => item.node === startContainer) : undefined
const block = holder?.closest<HTMLElement>('.cr-block')?.dataset.id
if (!holder || !node || block === undefined)
return { query }
return { query, from: { key: holder.dataset.find, start: node.start + startOffset, line: page?.placement.of(block)?.line ?? 0 } }
}
/** 视口顶部那块(钉住的筛选栏下面)的行:查找栏刚写字、还没有当前那处时从它往后找。 */
function viewportAnchor(): FindAnchor {
return { start: 0, line: pageLayout.anchorAt(window.scrollY + pageLayout.pinnedHeight())?.line ?? 0 }
}
/**
* 查找栏:扩展端的「查找」命令(⌘F)发来 openFind 时打开、聚焦输入框,页面上选中了一行字时先填进去、从它那处开始找;
* 写了字停下一会儿就在整份文件里找(见 [PageFind]),当前那处展开把它折叠住的块、属性行和子行,滚进视口;
* 「查找下一处 / 上一处」命令(⌘G / ⇧⌘G)发来 moveFind、输入框里 Enter / Shift+Enter、栏上的按钮都在命中之间跳。
* 高亮由 `painter` 画在页面上,块树不重画;关掉时清掉高亮,查的字留着,下次打开照旧。
* 查找栏开着、页面有焦点时告诉扩展端 ⌘G 归这个页面(见 [ShortcutContext])。
* `page` 同单击高亮跳到一处出现用的页面状态,`marks` 是页面文字的标记(中文名在里面,找的时候才订阅,中文名到了不连累整页重画)。
*/
export function useFind(page: OccurrencePage | undefined, marks: ReadableStore<TextMarks>, painter: FindPainter): FindView {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [search, setSearch] = useState<Search>({ query: '' })
/** 每次 ⌘F 加一:聚焦输入框、选中里面的字 */
const [focus, setFocus] = useState(0)
const [result, setResult] = useState(NOTHING)
const [find] = useState(() => new PageFind(setResult))
const [shortcuts] = useState(() => new ShortcutContext(
{ open: false, focused: document.hasFocus() },
facts => facts.open && facts.focused,
available => postMessage({ type: 'findNavigation', available }),
))
/** 跳过几次:每跳一次,画完后把当前那处滚进视口 */
const [reveals, setReveals] = useState(0)
const latest = useRef({ page, open, query, search })
useLayoutEffect(() => {
latest.current = { page, open, query, search }
})
const reachable = useCallback((unit: FindUnit) => latest.current.page?.skip(unit.block) === false, [])
/** 跳到 match:先展开把它折叠住的,画完后滚过去 */
const reveal = useCallback((match: FindMatch) => {
const current = latest.current.page
const folded = current ? find.unitOf(match).expand.filter(id => current.collapsed.has(id)) : []
if (current && folded.length > 0)
current.expand(folded)
setReveals(count => count + 1)
}, [find])
// 开着、写了字才把整份文件的字备好;关着时不跟着新文档重算,原来那处留着,下次打开从它附近接着
const active = open && search.query !== ''
const names = useStore(marks, current => active ? current.names : NO_NAMES)
const blocks = page?.blocks
const placement = page?.placement
const units = useMemo(() => active && blocks && placement ? findUnits(blocks, placement, names, markdownText) : NO_UNITS, [active, blocks, placement, names])
// 先换上新的字(新一版文档、中文名到了、又打开了)再找:当前那处停在原来那处附近,不滚
useLayoutEffect(() => {
if (active)
find.setUnits(units, reachable)
}, [find, active, units, reachable])
useLayoutEffect(() => {
const from = search.from ?? (search.query !== '' && find.state.current === undefined ? viewportAnchor() : undefined)
const match = find.search(search.query, reachable, from)
if (match)
reveal(match)
}, [find, search, reachable, reveal])
useEffect(() => {
const timer = window.setTimeout(() => setSearch(previous => previous.query === query ? previous : { query }), TYPING_MS)
return () => window.clearTimeout(timer)
}, [query])
// 结果变了、开关了:照着画;之后页面变了(展开、换了一版文档)由 painter 自己下一帧重画
useLayoutEffect(() => {
painter.show(open && result.query !== '' ? find.paint() : undefined)
}, [painter, find, open, result])
useLayoutEffect(() => {
if (reveals === 0)
return
// 要展开的已经展开:按展开后的页面重画一遍,再滚到当前那处;它画不出来就滚到它那段字、那块
const painted = painter.show(latest.current.open ? find.paint() : undefined)
const { current, matches } = find.state
const block = current === undefined ? undefined : find.unitOf(matches[current]).block
const target = painted.current ?? painted.text ?? (block === undefined ? null : document.querySelector<HTMLElement>(`.cr-block[data-id="${block}"]`))
if (target)
showInViewport(target)
painter.place()
}, [reveals, painter, find])
const move = useCallback((step: 1 | -1) => {
const { open, query, search } = latest.current
if (!open)
return
// 刚写的字还没找:先找,跳到它的第一处
if (query !== search.query) {
setSearch({ query })
return
}
const match = find.move(step, reachable)
if (match)
reveal(match)
}, [find, reachable, reveal])
const close = useCallback(() => {
const focused = document.activeElement
if (focused instanceof HTMLElement && focused.closest('.cr-find-bar'))
focused.blur()
setOpen(false)
}, [])
useEffect(() => onMessage((msg) => {
if (msg.type === 'openFind') {
const selected = selectedText(painter.root, latest.current.page)
if (selected) {
setQuery(selected.query)
setSearch(selected)
}
setOpen(true)
setFocus(count => count + 1)
}
else if (msg.type === 'moveFind') {
move(msg.step)
}
}), [painter, move])
useEffect(() => shortcuts.update({ open }), [shortcuts, open])
useEffect(() => {
const onFocus = (): void => shortcuts.update({ focused: true })
const onBlur = (): void => shortcuts.update({ focused: false })
window.addEventListener('focus', onFocus)
window.addEventListener('blur', onBlur)
// 焦点落进页面里(⌘F 聚焦输入框)时页面一定有焦点
document.addEventListener('focusin', onFocus)
return () => {
window.removeEventListener('focus', onFocus)
window.removeEventListener('blur', onBlur)
document.removeEventListener('focusin', onFocus)
}
}, [shortcuts])
return {
open,
query,
count: open ? findCount(result) : undefined,
canMove: result.matches.length > 0,
focus,
onQuery: setQuery,
onMove: move,
onClose: close,
}
}
@@ -18,7 +18,7 @@ const CONTROLS = `a, button, input, select, textarea, [role="listbox"], [role="d
/** 不收文字的输入框:焦点在它们上面时方向键不归它们移光标 */
const NON_TEXT_INPUTS = new Set(['checkbox', 'radio', 'button', 'submit', 'reset'])
/** 用键盘跳到一处出现时要用的页面状态。 */
/** 用键盘跳到一处出现、查找栏跳到一处命中时要用的页面状态。 */
export interface OccurrencePage {
blocks: BlockView[]
/** 各块在文件里的位置:点中的名字、各处出现按它和文件里的下标对上 */
@@ -27,10 +27,15 @@ export function flash(el: HTMLElement): void {
}, end))
}
/** 把 el 滚进钉住的筛选栏和卡片下面的视口,量之前先把它那一带排版好(pageLayout.settle)。已经整个看得见就不滚。beforeScroll 在真正滚动前拿到将要落到的位置。 */
export function showInViewport(el: HTMLElement, beforeScroll?: (position: ScrollPosition) => void): void {
pageLayout.settle(el)
const rect = el.getBoundingClientRect()
/**
* 把 target(一个元素,或查找栏的一处命中这样的一段字)滚进钉住的筛选栏和卡片下面的视口,量之前先把它那一带排版好(pageLayout.settle)。
* 已经整个看得见就不滚。beforeScroll 在真正滚动前拿到将要落到的位置。
*/
export function showInViewport(target: HTMLElement | Range, beforeScroll?: (position: ScrollPosition) => void): void {
const el = target instanceof Range ? target.startContainer.parentElement : target
if (el)
pageLayout.settle(el)
const rect = target.getBoundingClientRect()
const pinned = pageLayout.pinnedHeight() + stickyHeight()
const top = scrollTopToShow(
{ top: rect.top + window.scrollY, bottom: rect.bottom + window.scrollY },
@@ -165,6 +165,15 @@ export function listId(blockId: string): string {
return `${blockId}/i`
}
/**
* 卡片上一段字的记号,画在显示它的元素上(data-find):`<所在块、属性行、子行或表的 id>#<哪一段>`,
* 例如 '6.1.0#text' 是这块的一行译文,'6.1.0/p3#label' 是第 3 行的属性名,'6.1.0/p3/1#0' 是它第 1 个子行的第 0 格,'6.1.0/p3#h0' 是那张表的第 0 个列名。
* 查找栏按它把算好的命中落到页面上的字上。
*/
export function textKey(ownerId: string, part: string): string {
return `${ownerId}#${part}`
}
/**
* 父块看得见时,要让它的一个子块也看得见得展开的(块 id、属性行 id):一般的子块是父块自己(收起子块时不画);
* 挂在父卡片子行上的子块(BlockSlot)是它所在的那一行和它自己。