✨ feat(vscode): 代码阅读预览加 ⌘F 查找栏:整份文件查(折叠着的块里的也算,跳过去时展开),按页面上显示的字不分大小写匹配、中文名也认原名,Enter / ⌘G 跳、Esc 关,高亮直接画在页面上不重画卡片
This commit is contained in:
1 parent
cc3fcda051
commit
bd474986bd
27 files changed
+1833
-114
No files matched your search
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 }) }
|
||||
}
|
||||
@@ -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'] })
|
||||
|
||||
@@ -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[] }
|
||||
|
||||
@@ -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)是它所在的那一行和它自己。
|
||||
|
||||
Reference in new issue
Block a user