✨ feat(vscode): 代码阅读预览顶部加块筛选栏
语言插件按和语言无关的 id 报块属性(通用的由宿主从 facts 推),翻译插件起名,webview 只按 id 筛; 筛选整个工作区共用、记在 workspaceState;JavaScript 不再显示「抽象:否」「仅类型:否」
This commit is contained in:
1 parent
4eaf2ae11d
commit
1c7e31c51a
29 files changed
+1364
-319
No files matched your search
@@ -1,11 +1,12 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import type { BlockTreeState } from './BlockTree'
|
||||
import type { DocumentMessage, Reveal } from './types'
|
||||
import type { DocumentMessage, Excluded, Reveal } from './types'
|
||||
import type { ViewState } from './viewState'
|
||||
import { TriangleAlert } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { SelectMenu } from '@/components/ui/select-menu'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BlockLevel } from './BlockTree'
|
||||
import { applyFilter, keptWhileEditing } from './filter'
|
||||
import { FilterBar } from './FilterBar'
|
||||
import { useDefinitionLinks } from './useDefinitionLinks'
|
||||
import { useReveal } from './useReveal'
|
||||
import { useScrollPosition } from './useScrollPosition'
|
||||
@@ -14,6 +15,8 @@ import { onMessage, postMessage, viewState } from './vscode'
|
||||
|
||||
type BlockModes = Pick<ViewState, 'editing' | 'folds'>
|
||||
|
||||
const NONE: ReadonlySet<string> = new Set()
|
||||
|
||||
function toggled(ids: string[], id: string): string[] {
|
||||
return ids.includes(id) ? ids.filter(x => x !== id) : [...ids, id]
|
||||
}
|
||||
@@ -29,16 +32,24 @@ export function App(): ReactElement | null {
|
||||
const [focusId, setFocusId] = useState<string | null>(null)
|
||||
/** 等着滚过去的位置:新预览第一份文档带的,或已开着的预览收到的 reveal */
|
||||
const [reveal, setReveal] = useState<Reveal | null>(null)
|
||||
/** 扩展端发来的块筛选(整个工作区共用);本地不改,改了发给扩展端、等它发回来 */
|
||||
const [excluded, setExcluded] = useState<Excluded>({})
|
||||
/** 跳到定义时临时露出来的块(目标和它的各层祖先),筛选一变、文档一刷新就回到筛选状态 */
|
||||
const [keep, setKeep] = useState<ReadonlySet<string>>(NONE)
|
||||
/** 收到消息时读眼下哪些块在编辑(消息回调只在挂载时注册一次) */
|
||||
const editing = useRef(view.editing)
|
||||
const collapsed = useMemo(() => collapsedIds(doc?.blocks ?? [], view.folds), [doc, view.folds])
|
||||
const filter = useMemo(() => applyFilter(doc?.blocks ?? [], excluded, keep), [doc, excluded, keep])
|
||||
/** 记下这几块、属性行、子行折叠还是展开,从此不再按默认 */
|
||||
const setFolded = useCallback((ids: string[], folded: boolean) => setView(prev => ({
|
||||
...prev,
|
||||
folds: { ...prev.folds, ...Object.fromEntries(ids.map(id => [id, folded])) },
|
||||
})), [])
|
||||
const expand = useCallback((ids: string[]) => setFolded(ids, false), [setFolded])
|
||||
const show = useCallback((ids: string[]) => setKeep(prev => new Set([...prev, ...ids])), [])
|
||||
const revealed = useCallback(() => setReveal(null), [])
|
||||
useScrollPosition(doc)
|
||||
useReveal(doc, reveal, collapsed, expand, revealed)
|
||||
useReveal(doc, reveal, collapsed, filter.hidden, show, expand, revealed)
|
||||
useDefinitionLinks(doc?.version)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -46,11 +57,16 @@ export function App(): ReactElement | null {
|
||||
switch (msg.type) {
|
||||
case 'document':
|
||||
setDoc(msg)
|
||||
setKeep(prev => keptWhileEditing(prev, editing.current))
|
||||
if (msg.reveal)
|
||||
setReveal(msg.reveal)
|
||||
return
|
||||
case 'reveal':
|
||||
setReveal({ line: msg.line, definition: msg.definition })
|
||||
return
|
||||
case 'filter':
|
||||
setExcluded(msg.excluded)
|
||||
setKeep(NONE)
|
||||
}
|
||||
})
|
||||
postMessage({ type: 'ready' })
|
||||
@@ -59,13 +75,18 @@ export function App(): ReactElement | null {
|
||||
|
||||
useEffect(() => {
|
||||
viewState.update(view)
|
||||
editing.current = view.editing
|
||||
}, [view])
|
||||
|
||||
const onFocused = useCallback(() => setFocusId(null), [])
|
||||
const kindNames = useMemo(() => new Map(doc?.attributes.find(attribute => attribute.id === 'kind')?.values.map(value => [value.id, value.name])), [doc])
|
||||
const tree = useMemo((): BlockTreeState | null => doc && {
|
||||
version: doc.version,
|
||||
editing: new Set(view.editing),
|
||||
collapsed,
|
||||
hidden: filter.hidden,
|
||||
temporary: filter.temporary,
|
||||
kindName: kind => kindNames.get(kind) ?? kind,
|
||||
focusId,
|
||||
onFocused,
|
||||
onToggleEdit: (id) => {
|
||||
@@ -73,40 +94,41 @@ export function App(): ReactElement | null {
|
||||
setView(prev => ({ ...prev, editing: toggled(prev.editing, id) }))
|
||||
},
|
||||
onToggleFold: id => setFolded([id], !collapsed.has(id)),
|
||||
}, [doc, view, collapsed, focusId, onFocused, setFolded])
|
||||
}, [doc, view, collapsed, filter, kindNames, focusId, onFocused, setFolded])
|
||||
|
||||
if (!doc || !tree)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className="mx-auto box-content max-w-[880px] px-6 py-5">
|
||||
{doc.translators.length > 1 && (
|
||||
<div className="mb-3 flex items-center justify-end gap-2">
|
||||
<span className="text-xs text-[var(--vscode-descriptionForeground)]">翻译</span>
|
||||
<SelectMenu
|
||||
value={doc.translatorId}
|
||||
options={doc.translators.map(t => ({ value: t.id, label: t.label }))}
|
||||
onChange={id => postMessage({ type: 'selectTranslator', id })}
|
||||
title="选择翻译方式"
|
||||
className="w-40 text-left"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{doc.error !== undefined && (
|
||||
<div className="mb-3 flex items-start gap-2 rounded-lg border border-[var(--vscode-inputValidation-errorBorder)] bg-[var(--vscode-inputValidation-errorBackground)] px-3 py-2">
|
||||
<TriangleAlert size={16} strokeWidth={1.8} className="mt-px shrink-0 text-[var(--vscode-errorForeground)]" />
|
||||
<div className="min-w-0">
|
||||
<div className="break-words">
|
||||
解析失败:
|
||||
{doc.error}
|
||||
<>
|
||||
<FilterBar
|
||||
attributes={doc.attributes}
|
||||
excluded={excluded}
|
||||
view={filter}
|
||||
onChange={next => postMessage({ type: 'setFilter', excluded: next })}
|
||||
translators={doc.translators}
|
||||
translatorId={doc.translatorId}
|
||||
onSelectTranslator={id => postMessage({ type: 'selectTranslator', id })}
|
||||
/>
|
||||
<div className="mx-auto box-content max-w-[880px] px-6 py-5">
|
||||
{doc.error !== undefined && (
|
||||
<div className="mb-3 flex items-start gap-2 rounded-lg border border-[var(--vscode-inputValidation-errorBorder)] bg-[var(--vscode-inputValidation-errorBackground)] px-3 py-2">
|
||||
<TriangleAlert size={16} strokeWidth={1.8} className="mt-px shrink-0 text-[var(--vscode-errorForeground)]" />
|
||||
<div className="min-w-0">
|
||||
<div className="break-words">
|
||||
解析失败:
|
||||
{doc.error}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-[var(--vscode-descriptionForeground)]">点编辑器右上角的「编辑」查看源码。</div>
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-[var(--vscode-descriptionForeground)]">点编辑器右上角的「编辑」查看源码。</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{doc.blocks.length > 0
|
||||
? <BlockLevel blocks={doc.blocks} tree={tree} />
|
||||
: doc.error === undefined && <div className="text-[var(--vscode-descriptionForeground)]">这个文件里没有代码。</div>}
|
||||
</div>
|
||||
)}
|
||||
{doc.blocks.length === 0
|
||||
? doc.error === undefined && <div className="text-[var(--vscode-descriptionForeground)]">这个文件里没有代码。</div>
|
||||
: doc.blocks.some(block => !filter.hidden.has(block.id))
|
||||
? <BlockLevel blocks={doc.blocks} tree={tree} />
|
||||
: <div className="text-[var(--vscode-descriptionForeground)]">所有块都被筛选隐藏了。</div>}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,36 +1,23 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import type { BlockKind, BlockView } from './types'
|
||||
import { ChevronDown, ChevronRight, Eye, Pencil } from 'lucide-react'
|
||||
import { ChevronDown, ChevronRight, Eye, EyeOff, Pencil } from 'lucide-react'
|
||||
import { Fragment } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { CodeEditor } from './CodeEditor'
|
||||
import { PropertyGrid } from './PropertyGrid'
|
||||
import { TextPreview } from './TextPreview'
|
||||
|
||||
const KIND_LABEL: Record<BlockKind, string> = {
|
||||
import: '导入',
|
||||
function: '函数',
|
||||
class: '类',
|
||||
type: '类型',
|
||||
field: '字段',
|
||||
const: '常量',
|
||||
variable: '变量',
|
||||
if: '条件',
|
||||
else: '否则',
|
||||
loop: '循环',
|
||||
try: '尝试',
|
||||
catch: '捕获',
|
||||
finally: '最终',
|
||||
return: '返回',
|
||||
statement: '语句',
|
||||
comment: '注释',
|
||||
}
|
||||
|
||||
export interface BlockTreeState {
|
||||
version: number
|
||||
editing: ReadonlySet<string>
|
||||
/** 眼下折叠着的块 id、属性行 id 和子行 id(propertyRowId、propertyItemId):用户的选择叠在默认上,见 collapsedIds */
|
||||
collapsed: ReadonlySet<string>
|
||||
/** 按筛选不画的块(连同子孙),见 applyFilter */
|
||||
hidden: ReadonlySet<string>
|
||||
/** 本来被筛掉、跳到定义时临时显示的块 */
|
||||
temporary: ReadonlySet<string>
|
||||
/** 种类标签上写什么:翻译插件给种类起的名字,没给就是 id */
|
||||
kindName: (kind: BlockKind) => string
|
||||
/** 刚点了「编辑」、要把光标放进去的块 */
|
||||
focusId: string | null
|
||||
onFocused: () => void
|
||||
@@ -38,8 +25,13 @@ export interface BlockTreeState {
|
||||
onToggleFold: (id: string) => void
|
||||
}
|
||||
|
||||
/** 块下面有没有画出来的子块:子块都被筛掉时不留空的一层、不给折叠箭头。 */
|
||||
function hasShownChildren(block: BlockView, tree: BlockTreeState): boolean {
|
||||
return block.children.some(child => !tree.hidden.has(child.id))
|
||||
}
|
||||
|
||||
/**
|
||||
* 一层代码块:左边一条竖线标出这一层,每块的子块紧跟在它下面、再往右缩进一层。
|
||||
* 一层代码块:左边一条竖线标出这一层,每块的子块紧跟在它下面、再往右缩进一层。被筛掉的块不画,不留空位,竖线跟着变短。
|
||||
* kind 是这一层所属块的种类(顶层没有),悬停那个块时竖线换成它的颜色。
|
||||
*/
|
||||
export function BlockLevel({ blocks, kind, tree }: { blocks: BlockView[], kind?: BlockKind, tree: BlockTreeState }): ReactElement {
|
||||
@@ -49,10 +41,10 @@ export function BlockLevel({ blocks, kind, tree }: { blocks: BlockView[], kind?:
|
||||
<div className="cr-guide w-px" />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
{blocks.map(block => (
|
||||
{blocks.filter(block => !tree.hidden.has(block.id)).map(block => (
|
||||
<Fragment key={block.id}>
|
||||
<BlockCard block={block} tree={tree} />
|
||||
{block.children.length > 0 && !tree.collapsed.has(block.id) && (
|
||||
{hasShownChildren(block, tree) && !tree.collapsed.has(block.id) && (
|
||||
<BlockLevel blocks={block.children} kind={block.kind} tree={tree} />
|
||||
)}
|
||||
</Fragment>
|
||||
@@ -73,7 +65,7 @@ function BlockPreview({ block, tree }: { block: BlockView, tree: BlockTreeState
|
||||
function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }): ReactElement {
|
||||
const editing = tree.editing.has(block.id)
|
||||
const collapsed = tree.collapsed.has(block.id)
|
||||
const hasChildren = block.children.length > 0
|
||||
const hasChildren = hasShownChildren(block, tree)
|
||||
const Chevron = collapsed ? ChevronRight : ChevronDown
|
||||
return (
|
||||
<div
|
||||
@@ -97,7 +89,7 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }):
|
||||
)
|
||||
: <span className="w-3.5 shrink-0" />}
|
||||
<span className="cr-badge flex h-5 w-11 shrink-0 items-center justify-center rounded text-[11px] leading-[14px] font-medium">
|
||||
{KIND_LABEL[block.kind]}
|
||||
{tree.kindName(block.kind)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
{editing
|
||||
@@ -112,6 +104,12 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }):
|
||||
)
|
||||
: <BlockPreview block={block} tree={tree} />}
|
||||
</div>
|
||||
{tree.temporary.has(block.id) && (
|
||||
<span className="cr-temporary flex h-5 shrink-0 items-center gap-1 rounded px-1.5 text-[11px] whitespace-nowrap">
|
||||
<EyeOff size={12} strokeWidth={1.8} aria-hidden />
|
||||
已被筛选隐藏,临时显示
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import type { KeyboardEvent, ReactElement } from 'react'
|
||||
import type { Facet, FilterView } from './filter'
|
||||
import type { Excluded, FileAttribute, TranslatorOption } from './types'
|
||||
import { Check, ChevronDown, ListFilter } from 'lucide-react'
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { SelectMenu } from '@/components/ui/select-menu'
|
||||
import { facetLabel, facetsOf, toggleValue } from './filter'
|
||||
|
||||
export interface FilterBarProps {
|
||||
attributes: FileAttribute[]
|
||||
excluded: Excluded
|
||||
view: FilterView
|
||||
onChange: (excluded: Excluded) => void
|
||||
translators: TranslatorOption[]
|
||||
translatorId: string
|
||||
onSelectTranslator: (id: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览顶部钉住的一条:「筛选」、这份文件里每个能筛的属性一个下拉框、右边汇总(共 N 块 / 已隐藏 N 块 · 清除),
|
||||
* 多于一种翻译时最右边是翻译下拉框。内容和代码块同宽对齐,滚动时块从它下面过去。
|
||||
*/
|
||||
export function FilterBar({ attributes, excluded, view, onChange, translators, translatorId, onSelectTranslator }: FilterBarProps): ReactElement {
|
||||
return (
|
||||
<div className="cr-filter-bar">
|
||||
<div className="mx-auto box-content flex max-w-[880px] flex-wrap items-center gap-2 px-6 py-1.5">
|
||||
<span className="flex h-6 w-[52px] shrink-0 items-center gap-1 text-xs text-[var(--vscode-descriptionForeground)]">
|
||||
<ListFilter size={14} strokeWidth={1.8} aria-hidden />
|
||||
筛选
|
||||
</span>
|
||||
{facetsOf(attributes, excluded).map(facet => (
|
||||
<FacetMenu key={facet.id} facet={facet} onToggle={value => onChange(toggleValue(excluded, facet.id, value))} />
|
||||
))}
|
||||
<span className="ml-auto flex h-6 items-center gap-1.5 pl-2 text-xs whitespace-nowrap text-[var(--vscode-descriptionForeground)]">
|
||||
{view.hiddenCount > 0
|
||||
? (
|
||||
<>
|
||||
{`已隐藏 ${view.hiddenCount} 块`}
|
||||
<span aria-hidden>·</span>
|
||||
<button type="button" className="cr-link" onClick={() => onChange({})}>清除</button>
|
||||
</>
|
||||
)
|
||||
: `共 ${view.total} 块`}
|
||||
</span>
|
||||
{translators.length > 1 && (
|
||||
<span className="flex h-6 items-center gap-2 border-l border-[var(--vscode-panel-border)] pl-3">
|
||||
<span className="text-xs whitespace-nowrap text-[var(--vscode-descriptionForeground)]">翻译</span>
|
||||
<SelectMenu
|
||||
value={translatorId}
|
||||
options={translators.map(t => ({ value: t.id, label: t.label }))}
|
||||
onChange={onSelectTranslator}
|
||||
title="选择翻译方式"
|
||||
className="w-36 text-left"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个属性的下拉框:按钮上是属性名(去掉了值时写勾着哪些,边框换聚焦色),点开是勾选列表,每个值后面是块数。
|
||||
* 列表盖在代码块上面;点外面、Esc、Tab 关掉。键盘:按钮上 ↓ ↑ 回车 空格打开,列表里 ↑ ↓ Home End 移动,空格 / 回车勾选。
|
||||
*/
|
||||
function FacetMenu({ facet, onToggle }: { facet: Facet, onToggle: (value: string) => void }): ReactElement {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [activeIndex, setActive] = useState(0)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
const listId = useId()
|
||||
const label = facetLabel(facet)
|
||||
const count = facet.values.length
|
||||
// 列表开着时文档刷新、值变少了,高亮落回最后一项
|
||||
const active = Math.min(activeIndex, count - 1)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open)
|
||||
return
|
||||
const onPointerDown = (event: MouseEvent): void => {
|
||||
if (event.target instanceof Node && !rootRef.current?.contains(event.target))
|
||||
setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onPointerDown, true)
|
||||
return () => document.removeEventListener('mousedown', onPointerDown, true)
|
||||
}, [open])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (open)
|
||||
listRef.current?.focus()
|
||||
}, [open])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (open)
|
||||
listRef.current?.querySelector<HTMLElement>(`[data-index="${active}"]`)?.scrollIntoView({ block: 'nearest' })
|
||||
}, [open, active])
|
||||
|
||||
const openAt = (index: number): void => {
|
||||
setActive(index)
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const close = (): void => {
|
||||
setOpen(false)
|
||||
buttonRef.current?.focus()
|
||||
}
|
||||
|
||||
const onButtonKeyDown = (event: KeyboardEvent<HTMLButtonElement>): void => {
|
||||
if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
openAt(0)
|
||||
}
|
||||
else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
openAt(count - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const onListKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
const move: Record<string, number> = { ArrowDown: (active + 1) % count, ArrowUp: (active - 1 + count) % count, Home: 0, End: count - 1 }
|
||||
if (event.key in move) {
|
||||
event.preventDefault()
|
||||
setActive(move[event.key])
|
||||
}
|
||||
else if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
onToggle(facet.values[active].id)
|
||||
}
|
||||
else if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
close()
|
||||
}
|
||||
else if (event.key === 'Tab') {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
className="cr-chip flex h-6 w-[150px] items-center gap-1 rounded px-2 text-left text-xs"
|
||||
data-active={label.active ? '' : undefined}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? listId : undefined}
|
||||
title={label.text}
|
||||
onClick={() => open ? setOpen(false) : openAt(0)}
|
||||
onKeyDown={onButtonKeyDown}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{label.text}</span>
|
||||
<ChevronDown size={12} strokeWidth={1.8} className="shrink-0 text-[var(--vscode-descriptionForeground)]" aria-hidden />
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
ref={listRef}
|
||||
id={listId}
|
||||
role="listbox"
|
||||
aria-multiselectable
|
||||
aria-label={facet.name}
|
||||
aria-activedescendant={`${listId}-${active}`}
|
||||
tabIndex={-1}
|
||||
className="cr-menu absolute top-[calc(100%+2px)] left-0 z-20 max-h-[min(360px,60vh)] w-[200px] overflow-y-auto rounded-md py-1"
|
||||
onKeyDown={onListKeyDown}
|
||||
>
|
||||
{facet.values.map((value, index) => (
|
||||
<div
|
||||
key={value.id}
|
||||
id={`${listId}-${index}`}
|
||||
role="option"
|
||||
aria-selected={value.checked}
|
||||
data-index={index}
|
||||
data-active={index === active ? '' : undefined}
|
||||
className="cr-option flex cursor-pointer items-center gap-2 px-2.5 py-1 text-xs"
|
||||
// 焦点留在列表上,方向键接着能用
|
||||
onMouseDown={event => event.preventDefault()}
|
||||
onMouseMove={() => setActive(index)}
|
||||
onClick={() => onToggle(value.id)}
|
||||
>
|
||||
<span className="cr-check flex size-3.5 shrink-0 items-center justify-center" data-checked={value.checked ? '' : undefined} aria-hidden>
|
||||
{value.checked && <Check size={12} strokeWidth={2.4} />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{value.name}</span>
|
||||
<span className="cr-option-count shrink-0 tabular-nums">{value.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -281,3 +281,70 @@ body.vscode-high-contrast-light {
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 筛选栏:钉在预览顶部、横贯整个编辑区,里面的内容和代码块同宽对齐;打开的下拉菜单盖在代码块上面 */
|
||||
.cr-filter-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: var(--vscode-editor-background);
|
||||
border-bottom: 1px solid var(--vscode-panel-border, transparent);
|
||||
}
|
||||
.cr-chip {
|
||||
color: var(--vscode-dropdown-foreground, var(--vscode-foreground));
|
||||
background: var(--vscode-dropdown-background);
|
||||
border: 1px solid var(--vscode-dropdown-border, var(--vscode-dropdown-background));
|
||||
}
|
||||
.cr-chip[data-active] {
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
.cr-chip:focus-visible {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
.cr-menu {
|
||||
color: var(--vscode-editorWidget-foreground, var(--vscode-foreground));
|
||||
background: var(--vscode-editorWidget-background);
|
||||
border: 1px solid var(--vscode-editorWidget-border, var(--vscode-widget-border, transparent));
|
||||
box-shadow: 0 2px 8px var(--vscode-widget-shadow, rgb(0 0 0 / 36%));
|
||||
outline: none;
|
||||
}
|
||||
.cr-option[data-active] {
|
||||
background: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
/* 键盘在列表里走时,高亮项再描一圈聚焦色 */
|
||||
.cr-menu:focus-visible .cr-option[data-active] {
|
||||
outline: 1px solid var(--vscode-list-focusOutline, var(--vscode-focusBorder));
|
||||
outline-offset: -1px;
|
||||
}
|
||||
.cr-check {
|
||||
border: 1px solid var(--vscode-checkbox-border, var(--vscode-editorLineNumber-foreground));
|
||||
border-radius: 3px;
|
||||
background: var(--vscode-checkbox-background, transparent);
|
||||
}
|
||||
.cr-check[data-checked] {
|
||||
color: var(--vscode-button-foreground, #fff);
|
||||
background: var(--vscode-focusBorder);
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
.cr-option-count {
|
||||
color: var(--vscode-editorLineNumber-foreground);
|
||||
}
|
||||
.cr-link {
|
||||
color: var(--vscode-textLink-foreground);
|
||||
}
|
||||
.cr-link:hover {
|
||||
color: var(--vscode-textLink-activeForeground);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.cr-link:focus-visible {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
outline-offset: 1px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* 跳到定义临时露出来的、本来被筛掉的块:右边一个小提示 */
|
||||
.cr-temporary {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
background: color-mix(in srgb, var(--vscode-focusBorder) 15%, transparent);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { BlockKind, BlockView, FileAttribute } from './types'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { applyFilter, blockPath, excludedBy, facetLabel, facetsOf, keptWhileEditing, toggleValue } from './filter'
|
||||
|
||||
function block(id: string, kind: BlockKind, extra: Record<string, string> = {}, children: BlockView[] = []): BlockView {
|
||||
return { id, kind, startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind, ...extra }, children, translation: [] }
|
||||
}
|
||||
|
||||
const NONE = new Set<string>()
|
||||
|
||||
/**
|
||||
* 0 const A(私有)
|
||||
* 1 function run(公共)
|
||||
* 1.0 if
|
||||
* 1.0.0 return
|
||||
* 1.1 const B(私有,嵌套在函数里的也有访问级别时)
|
||||
* 2 function helper(私有)
|
||||
* 2.0 if
|
||||
* 3 comment
|
||||
*/
|
||||
const BLOCKS = [
|
||||
block('0', 'const', { access: 'private' }),
|
||||
block('1', 'function', { access: 'public', async: 'true' }, [
|
||||
block('1.0', 'if', {}, [block('1.0.0', 'return')]),
|
||||
block('1.1', 'const', { access: 'private' }),
|
||||
]),
|
||||
block('2', 'function', { access: 'private', async: 'false' }, [block('2.0', 'if')]),
|
||||
block('3', 'comment'),
|
||||
]
|
||||
|
||||
describe('按筛选藏块', () => {
|
||||
it('不筛:一块都不藏,共几块数的是所有层', () => {
|
||||
expect(applyFilter(BLOCKS, {}, NONE)).toEqual({ hidden: new Set(), temporary: new Set(), hiddenCount: 0, total: 8 })
|
||||
})
|
||||
|
||||
it('去掉「私有」:私有的块连同子孙藏起来;没有访问级别的块(条件、返回、注释)不受影响;已隐藏只数直接被筛掉的块', () => {
|
||||
const view = applyFilter(BLOCKS, { access: ['private'] }, NONE)
|
||||
expect([...view.hidden].sort()).toEqual(['0', '1.1', '2', '2.0'])
|
||||
expect(view.hiddenCount).toBe(3)
|
||||
})
|
||||
|
||||
it('同一属性里勾着的值满足一个就显示,不同属性要同时满足', () => {
|
||||
const kinds = { kind: ['comment', 'if', 'return'] }
|
||||
expect([...applyFilter(BLOCKS, kinds, NONE).hidden].sort()).toEqual(['1.0', '1.0.0', '2.0', '3'])
|
||||
const both = applyFilter(BLOCKS, { access: ['private'], async: ['false'] }, NONE)
|
||||
expect([...both.hidden].sort()).toEqual(['0', '1.1', '2', '2.0'])
|
||||
expect(excludedBy(BLOCKS[1], { access: ['private'], async: ['false'] })).toBe(false)
|
||||
expect(excludedBy(BLOCKS[1], { async: ['true'] })).toBe(true)
|
||||
})
|
||||
|
||||
it('藏着的块里又被筛掉的子块不重复计数', () => {
|
||||
expect(applyFilter(BLOCKS, { kind: ['function', 'if'] }, NONE).hiddenCount).toBe(2)
|
||||
})
|
||||
|
||||
it('跳到定义临时露出来的:藏着的祖先下面只露出通向目标的路径,本来藏着的记进 temporary;已隐藏的块数照旧按筛选算', () => {
|
||||
const view = applyFilter(BLOCKS, { access: ['private'] }, new Set(blockPath('1.1')))
|
||||
expect([...view.hidden].sort()).toEqual(['0', '2', '2.0'])
|
||||
expect([...view.temporary]).toEqual(['1.1'])
|
||||
expect(view.hiddenCount).toBe(3)
|
||||
const path = applyFilter(BLOCKS, { kind: ['function'] }, new Set(blockPath('1.0')))
|
||||
expect([...path.hidden].sort()).toEqual(['1.1', '2', '2.0'])
|
||||
expect([...path.temporary].sort()).toEqual(['1', '1.0'])
|
||||
expect(path.hiddenCount).toBe(2)
|
||||
const visible = applyFilter(BLOCKS, { access: ['private'] }, new Set(blockPath('1.0')))
|
||||
expect(visible.temporary).toEqual(new Set())
|
||||
})
|
||||
|
||||
it('临时显示的目标自己的子块照常按筛选显示,子块里被筛掉的仍然藏着', () => {
|
||||
const view = applyFilter(BLOCKS, { access: ['private'] }, new Set(blockPath('2')))
|
||||
expect([...view.temporary]).toEqual(['2'])
|
||||
expect([...view.hidden].sort()).toEqual(['0', '1.1'])
|
||||
const inner = applyFilter(BLOCKS, { kind: ['function', 'return'] }, new Set(blockPath('1')))
|
||||
expect([...inner.hidden].sort()).toEqual(['1.0.0', '2', '2.0'])
|
||||
expect(inner.hiddenCount).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
const ATTRIBUTES: FileAttribute[] = [
|
||||
{ id: 'kind', name: '种类', values: [{ id: 'const', name: '常量', count: 2 }, { id: 'function', name: '函数', count: 2 }] },
|
||||
{ id: 'access', name: '访问级别', values: [{ id: 'private', name: '私有', count: 3 }, { id: 'public', name: '公共', count: 1 }] },
|
||||
{ id: 'typeOnly', name: '仅类型', values: [{ id: 'false', name: '否', count: 4 }] },
|
||||
{ id: 'unsafe', name: 'unsafe', values: [{ id: 'true', name: 'true', count: 1 }, { id: 'false', name: 'false', count: 1 }] },
|
||||
]
|
||||
|
||||
describe('筛选栏上的下拉框', () => {
|
||||
it('给有两种以上值的属性,照文件里属性的先后;只有一种值的不给,除非这个值正被去掉', () => {
|
||||
expect(facetsOf(ATTRIBUTES, {}).map(facet => facet.id)).toEqual(['kind', 'access', 'unsafe'])
|
||||
expect(facetsOf(ATTRIBUTES, { typeOnly: ['false'] }).map(facet => facet.id)).toEqual(['kind', 'access', 'typeOnly', 'unsafe'])
|
||||
expect(facetsOf(ATTRIBUTES, { typeOnly: ['true'] }).map(facet => facet.id)).toEqual(['kind', 'access', 'unsafe'])
|
||||
})
|
||||
|
||||
it('选项是文件里出现的值和块数,没去掉的勾着', () => {
|
||||
const [, access] = facetsOf(ATTRIBUTES, { access: ['private', 'protected'] })
|
||||
expect(access.values).toEqual([
|
||||
{ id: 'private', name: '私有', count: 3, checked: false },
|
||||
{ id: 'public', name: '公共', count: 1, checked: true },
|
||||
])
|
||||
})
|
||||
|
||||
it('按钮上的字:没去掉值时是属性名;勾一种写值名,几种写「已选 N 种」,都不勾写「未选」', () => {
|
||||
const [kind, access] = facetsOf(ATTRIBUTES, { access: ['private'] })
|
||||
expect(facetLabel(kind)).toEqual({ text: '种类', active: false })
|
||||
expect(facetLabel(access)).toEqual({ text: '访问级别:公共', active: true })
|
||||
const three = { id: 'k', name: '种类', values: ['a', 'b', 'c'].map(id => ({ id, name: id, count: 1, checked: id !== 'c' })) }
|
||||
expect(facetLabel(three)).toEqual({ text: '种类:已选 2 种', active: true })
|
||||
expect(facetLabel({ ...three, values: three.values.map(value => ({ ...value, checked: false })) })).toEqual({ text: '种类:未选', active: true })
|
||||
})
|
||||
|
||||
it('勾 / 不勾一个值:记去掉的值,一个都不去掉的属性不留', () => {
|
||||
expect(toggleValue({}, 'access', 'private')).toEqual({ access: ['private'] })
|
||||
expect(toggleValue({ access: ['private'], kind: ['if'] }, 'access', 'public')).toEqual({ access: ['private', 'public'], kind: ['if'] })
|
||||
expect(toggleValue({ access: ['private'], kind: ['if'] }, 'access', 'private')).toEqual({ kind: ['if'] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('临时显示的块', () => {
|
||||
it('块 id 的各段前缀就是从顶层到它的路径', () => {
|
||||
expect(blockPath('1.0.2')).toEqual(['1', '1.0', '1.0.2'])
|
||||
expect(blockPath('3')).toEqual(['3'])
|
||||
})
|
||||
|
||||
it('文档刷新后只留正在编辑的块和它的祖先', () => {
|
||||
const keep = new Set(['2', '2.0', '2.0.1', '5'])
|
||||
expect(keptWhileEditing(keep, ['2.0'])).toEqual(new Set(['2', '2.0']))
|
||||
expect(keptWhileEditing(keep, [])).toEqual(new Set())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { AttributeValueCount, BlockView, Excluded, FileAttribute } from './types'
|
||||
|
||||
/** 页面上按筛选怎么显示。 */
|
||||
export interface FilterView {
|
||||
/** 不画出来的块:被筛掉的块连同子孙,临时显示的除外 */
|
||||
hidden: ReadonlySet<string>
|
||||
/** 本来被筛掉、跳到定义时临时显示的块,带「已被筛选隐藏,临时显示」 */
|
||||
temporary: ReadonlySet<string>
|
||||
/** 直接被筛掉的块数,跟着藏起来的子孙不算:「已隐藏 N 块」 */
|
||||
hiddenCount: number
|
||||
/** 整份文件(所有层)的块数:「共 N 块」 */
|
||||
total: number
|
||||
}
|
||||
|
||||
/** 筛选栏上的一个下拉框:一个属性和它在这份文件里的值,checked 是勾着(没被去掉)。 */
|
||||
export interface Facet {
|
||||
id: string
|
||||
name: string
|
||||
values: (AttributeValueCount & { checked: boolean })[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 块被不被筛掉:它有的属性里有一个的值被去掉了就筛掉。也就是同一属性里勾着的值满足一个就行、不同属性要同时满足;
|
||||
* 块没有的属性(条件、返回块没有访问级别)不受那个属性的筛选影响。
|
||||
*/
|
||||
export function excludedBy(block: BlockView, excluded: Excluded): boolean {
|
||||
return Object.entries(block.attributes).some(([attribute, value]) => excluded[attribute]?.includes(value) ?? false)
|
||||
}
|
||||
|
||||
/**
|
||||
* 按筛选算出哪些块不画:块被筛掉,连同它的子孙一起藏。keep 是跳到定义时要临时露出来的块(目标和它的各层祖先),
|
||||
* 其中本来藏着的照画、记进 temporary:藏着的祖先下面只露出通向目标的那条路径,目标自己和没临时显示的块一样,
|
||||
* 它的子块照常按筛选显示(函数照样能展开看主体)。
|
||||
*/
|
||||
export function applyFilter(blocks: readonly BlockView[], excluded: Excluded, keep: ReadonlySet<string>): FilterView {
|
||||
const hidden = new Set<string>()
|
||||
const temporary = new Set<string>()
|
||||
let hiddenCount = 0
|
||||
let total = 0
|
||||
// filteredAbove 只按筛选算(数「已隐藏」用),hiddenAbove 还算上临时显示
|
||||
const visit = (list: readonly BlockView[], filteredAbove: boolean, hiddenAbove: boolean): void => {
|
||||
for (const block of list) {
|
||||
total += 1
|
||||
const out = excludedBy(block, excluded)
|
||||
if (out && !filteredAbove)
|
||||
hiddenCount += 1
|
||||
const hiddenHere = hiddenAbove || out
|
||||
if (hiddenHere)
|
||||
(keep.has(block.id) ? temporary : hidden).add(block.id)
|
||||
const target = keep.has(block.id) && !block.children.some(child => keep.has(child.id))
|
||||
visit(block.children, filteredAbove || out, hiddenHere && !target)
|
||||
}
|
||||
}
|
||||
visit(blocks, false, false)
|
||||
return { hidden, temporary, hiddenCount, total }
|
||||
}
|
||||
|
||||
/**
|
||||
* 筛选栏上给哪些属性下拉框,照 attributes 的先后:这份文件里有两种以上值的属性(只有一种值的,筛它等于按种类筛,不给);
|
||||
* 只有一种值、但这个值正被去掉的也给,不然藏起来的块在栏上找不到原因、也勾不回来。
|
||||
*/
|
||||
export function facetsOf(attributes: readonly FileAttribute[], excluded: Excluded): Facet[] {
|
||||
return attributes.flatMap((attribute) => {
|
||||
const values = attribute.values.map(value => ({ ...value, checked: !(excluded[attribute.id]?.includes(value.id) ?? false) }))
|
||||
return values.length >= 2 || values.some(value => !value.checked) ? [{ id: attribute.id, name: attribute.name, values }] : []
|
||||
})
|
||||
}
|
||||
|
||||
/** 下拉框上写什么:没去掉值时是属性名;去掉了(active),勾着一种写「属性名:值名」,几种写「属性名:已选 N 种」,一种不勾写「属性名:未选」。 */
|
||||
export function facetLabel(facet: Facet): { text: string, active: boolean } {
|
||||
const checked = facet.values.filter(value => value.checked)
|
||||
if (checked.length === facet.values.length)
|
||||
return { text: facet.name, active: false }
|
||||
if (checked.length === 0)
|
||||
return { text: `${facet.name}:未选`, active: true }
|
||||
return { text: checked.length === 1 ? `${facet.name}:${checked[0].name}` : `${facet.name}:已选 ${checked.length} 种`, active: true }
|
||||
}
|
||||
|
||||
/** 勾上 / 去掉属性的一个值后的筛选;一个值都没去掉的属性不留。 */
|
||||
export function toggleValue(excluded: Excluded, attribute: string, value: string): Excluded {
|
||||
const current = excluded[attribute] ?? []
|
||||
const next = current.includes(value) ? current.filter(item => item !== value) : [...current, value]
|
||||
const rest = Object.fromEntries(Object.entries(excluded).filter(([key]) => key !== attribute))
|
||||
return next.length > 0 ? { ...rest, [attribute]: next } : rest
|
||||
}
|
||||
|
||||
/** 块 id 是它在树里的位置('1.0.2'):从顶层到它的各块就是 id 的各段前缀 '1'、'1.0'、'1.0.2'。 */
|
||||
export function blockPath(id: string): string[] {
|
||||
const parts = id.split('.')
|
||||
return parts.map((_, i) => parts.slice(0, i + 1).join('.'))
|
||||
}
|
||||
|
||||
/**
|
||||
* 文档刷新后临时显示的块回到筛选状态,只留正在编辑的块和它的祖先:块 id 按位置编号,刷新后可能换成别的块;
|
||||
* 但正在改的那块不能打着字就消失。
|
||||
*/
|
||||
export function keptWhileEditing(keep: ReadonlySet<string>, editing: readonly string[]): Set<string> {
|
||||
const paths = new Set(editing.flatMap(blockPath))
|
||||
return new Set([...keep].filter(id => paths.has(id)))
|
||||
}
|
||||
@@ -20,11 +20,11 @@ function word(text: string): Segment {
|
||||
}
|
||||
|
||||
function textBlock(id: string, kind: BlockKind, startLine: number, translation: Segment[], children: BlockView[] = []): BlockView {
|
||||
return { id, kind, startLine, endLine: startLine, code: '', codeTokens: [], children, translation }
|
||||
return { id, kind, startLine, endLine: startLine, code: '', codeTokens: [], attributes: { kind }, children, translation }
|
||||
}
|
||||
|
||||
function factBlock(id: string, kind: BlockKind, startLine: number, properties: PropertyRow[], children: BlockView[] = []): BlockView {
|
||||
return { id, kind, startLine, endLine: startLine, code: '', codeTokens: [], children, properties }
|
||||
return { id, kind, startLine, endLine: startLine, code: '', codeTokens: [], attributes: { kind }, children, properties }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,7 +58,7 @@ describe('跳到定义:滚到哪块、展开哪些', () => {
|
||||
|
||||
it('注释块、函数「说明」的 Markdown 里的名字不带下标,不算显示着定义', () => {
|
||||
const blocks: BlockView[] = [
|
||||
{ id: '0', kind: 'comment', startLine: 0, endLine: 0, code: '', codeTokens: [], children: [], translation: { markdown: '见 `get_db`' } },
|
||||
{ id: '0', kind: 'comment', startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind: 'comment' }, children: [], translation: { markdown: '见 `get_db`' } },
|
||||
factBlock('1', 'function', 1, [
|
||||
{ label: '说明', value: { markdown: '同 `get_db`' } },
|
||||
{ label: '输入参数', value: [{ text: '1 个', role: 'hint' }], items: [{ cells: [[name('get_db', 30)]] }] },
|
||||
|
||||
@@ -108,7 +108,10 @@ export function revealPlan(blocks: BlockView[], line: number, definition: number
|
||||
return target && { id: target.block.id, expand: target.ancestors }
|
||||
}
|
||||
|
||||
/** 让一块露出来要滚到的 scrollTop,和 VS Code 跳到定义一样:整块已经在视口里就不滚;否则放到视口正中,比视口还高就顶部对齐。 */
|
||||
/**
|
||||
* 让一块露出来时视口顶部要在的位置,和 VS Code 跳到定义一样:整块已经在视口里就不滚;否则放到视口正中,比视口还高就顶部对齐。
|
||||
* viewTop / viewHeight 是视口里露出块的那部分(钉住的筛选栏下面)。
|
||||
*/
|
||||
export function scrollTopToShow(box: { top: number, bottom: number }, viewTop: number, viewHeight: number): number | undefined {
|
||||
if (box.top >= viewTop && box.bottom <= viewTop + viewHeight)
|
||||
return undefined
|
||||
|
||||
@@ -7,20 +7,23 @@ export interface BlockBox {
|
||||
bottom: number
|
||||
}
|
||||
|
||||
/** 视口顶部那块的起始行 + 视口顶部在块内往下偏了多少像素;按块对不上时退回 scrollTop。 */
|
||||
/**
|
||||
* 视口顶部那块的起始行 + 视口顶部在块内往下偏了多少像素;按块对不上时退回 scrollTop。
|
||||
* 「视口顶部」指露出块的那部分的顶部,也就是钉住的筛选栏的下沿;scrollTop 是它在页面里的位置。
|
||||
*/
|
||||
export interface ScrollAnchor {
|
||||
line: number
|
||||
offset: number
|
||||
scrollTop: number
|
||||
}
|
||||
|
||||
/** 视口顶部那块:第一个下边还在视口顶部以下的块;全都滚过去了就取最后一块。没有块时没有位置可记。 */
|
||||
/** 视口顶部(scrollTop,见 ScrollAnchor)那块:第一个下边还在视口顶部以下的块;全都滚过去了就取最后一块。没有块时没有位置可记。 */
|
||||
export function anchorAt(boxes: readonly BlockBox[], scrollTop: number): ScrollAnchor | undefined {
|
||||
const box = boxes.find(b => b.bottom > scrollTop) ?? boxes.at(-1)
|
||||
return box && { line: box.line, offset: scrollTop - box.top, scrollTop }
|
||||
}
|
||||
|
||||
/** 恢复时的 scrollTop:起始行还是那一行的块还在就对齐到它,否则用原来的 scrollTop。 */
|
||||
/** 恢复时视口顶部要在的位置(见 ScrollAnchor):起始行还是那一行的块还在就对齐到它,否则用原来的 scrollTop。 */
|
||||
export function scrollTopFor(boxes: readonly BlockBox[], anchor: ScrollAnchor): number {
|
||||
const box = boxes.find(b => b.line === anchor.line)
|
||||
return box ? box.top + anchor.offset : anchor.scrollTop
|
||||
|
||||
@@ -59,6 +59,8 @@ interface BlockViewBase {
|
||||
codeTokens: Segment[]
|
||||
/** 块自己的名字(函数名、类名、只声明一个变量时的变量名)在源码里的范围:跳到的定义就是它时闪整块 */
|
||||
defines?: SourceRange
|
||||
/** 筛选属性:属性 id → 值 id(含 kind),都是和语言无关的英文 id,显示名在 DocumentMessage.attributes 里 */
|
||||
attributes: Record<string, string>
|
||||
children: BlockView[]
|
||||
}
|
||||
|
||||
@@ -67,6 +69,23 @@ export type BlockView = BlockViewBase & ({ translation: PreviewText } | { proper
|
||||
|
||||
export interface TranslatorOption { id: string, label: string }
|
||||
|
||||
/** 这份文件里出现过的一个属性值:显示名和有几块是这个值 */
|
||||
export interface AttributeValueCount {
|
||||
id: string
|
||||
name: string
|
||||
count: number
|
||||
}
|
||||
|
||||
/** 这份文件里出现过的一个筛选属性:显示名,出现过的值按第一次出现的先后排 */
|
||||
export interface FileAttribute {
|
||||
id: string
|
||||
name: string
|
||||
values: AttributeValueCount[]
|
||||
}
|
||||
|
||||
/** 块筛选:属性 id → 不勾(藏起来)的值 id;整个工作区的预览共用一份,记在扩展端 */
|
||||
export type Excluded = Record<string, string[]>
|
||||
|
||||
/**
|
||||
* 滚到包含 line 的块。definition 只在跳到定义时有:定义处那个名字在源码里的下标(Segment.offset),
|
||||
* 要展开把它折叠住的块和属性行,再让就是这处定义的那一块或属性表里那一行闪几下
|
||||
@@ -81,6 +100,8 @@ export interface DocumentMessage {
|
||||
version: number
|
||||
languageId: string
|
||||
blocks: BlockView[]
|
||||
/** 这份文件里出现过的筛选属性和值(带显示名、块数),按筛选栏的先后排:通用属性在前,语言自己的在后 */
|
||||
attributes: FileAttribute[]
|
||||
translators: TranslatorOption[]
|
||||
translatorId: string
|
||||
error?: string
|
||||
@@ -91,11 +112,13 @@ export interface DocumentMessage {
|
||||
/**
|
||||
* reveal:已经开着的预览滚到某处(同 DocumentMessage.reveal)
|
||||
* definitionProbed:id 那次 probeDefinition 的回答,found 表示查得到定义
|
||||
* filter:整个工作区共用的块筛选;ready 后先于第一份文档来一次,之后任何一个预览改了都会来
|
||||
*/
|
||||
export type ExtensionToWebview
|
||||
= | DocumentMessage
|
||||
| ({ type: 'reveal' } & Reveal)
|
||||
| { type: 'definitionProbed', id: number, found: boolean }
|
||||
| { type: 'filter', excluded: Excluded }
|
||||
|
||||
/**
|
||||
* edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来第一行的缩进)
|
||||
@@ -103,9 +126,11 @@ export type ExtensionToWebview
|
||||
* probeDefinition:按住 ⌘ / Ctrl 指到带下标的名字上,问 offset 处有没有定义;id 标出是哪一次问,回答带回来;
|
||||
* version 是这份下标所属的文档版本
|
||||
* goToDefinition:⌘ / Ctrl + 点击,跳到 offset 处名字的定义
|
||||
* setFilter:改块筛选(整份替换);不在本地先改,等扩展端发回 filter 再按它显示
|
||||
*/
|
||||
export type WebviewToExtension
|
||||
= | { type: 'ready' }
|
||||
| { type: 'setFilter', excluded: Excluded }
|
||||
| { type: 'edit', startLine: number, endLine: number, text: string }
|
||||
| { type: 'selectTranslator', id: string }
|
||||
| { type: 'visibleLine', line: number }
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { DocumentMessage, Reveal } from './types'
|
||||
import { useLayoutEffect } from 'react'
|
||||
import { blockPath } from './filter'
|
||||
import { revealPlan, scrollTopToShow } from './reveal'
|
||||
import { blockForLine } from './scrollPosition'
|
||||
import { measureBlocks } from './useScrollPosition'
|
||||
import { measureBlocks, pinnedHeight } from './useScrollPosition'
|
||||
|
||||
/** 让一块或属性表里的一行闪几下(codeReader.css 的 [data-flash]);正在闪的同一处再跳一次时从头闪。 */
|
||||
function flash(el: HTMLElement): void {
|
||||
@@ -24,13 +25,16 @@ function flash(el: HTMLElement): void {
|
||||
|
||||
/**
|
||||
* 滚到 reveal 指的位置,处理完调 done:
|
||||
* ① 从文本编辑器切过来(没有 definition):包含那一行的块顶到视口顶部,折叠着的不展开;
|
||||
* ② 跳到定义:先展开把目标藏起来的块、属性行和子行(expand 之后重新渲染,再进来一次),再把目标(整块,或就是这处定义的属性行 / 子行)滚进视口、闪几下。
|
||||
* ① 从文本编辑器切过来(没有 definition):包含那一行的块顶到视口顶部(筛选栏下面),折叠着的、被筛掉的不展开;
|
||||
* ② 跳到定义:目标或它的祖先块被筛掉了,先临时露出这条路径(show);再展开把目标折叠住的块、属性行和子行(expand)——
|
||||
* 每一步之后重新渲染、再进来一次;最后把目标(整块,或就是这处定义的属性行 / 子行)滚进视口、闪几下。
|
||||
*/
|
||||
export function useReveal(
|
||||
doc: DocumentMessage | null,
|
||||
reveal: Reveal | null,
|
||||
collapsed: ReadonlySet<string>,
|
||||
filtered: ReadonlySet<string>,
|
||||
show: (ids: string[]) => void,
|
||||
expand: (ids: string[]) => void,
|
||||
done: () => void,
|
||||
): void {
|
||||
@@ -40,11 +44,16 @@ export function useReveal(
|
||||
if (reveal.definition === undefined) {
|
||||
const top = blockForLine(measureBlocks(), reveal.line)?.top
|
||||
if (top !== undefined)
|
||||
window.scrollTo(0, top)
|
||||
window.scrollTo(0, top - pinnedHeight())
|
||||
done()
|
||||
return
|
||||
}
|
||||
const plan = revealPlan(doc.blocks, reveal.line, reveal.definition)
|
||||
const path = plan ? blockPath(plan.id) : []
|
||||
if (path.some(id => filtered.has(id))) {
|
||||
show(path)
|
||||
return
|
||||
}
|
||||
const hidden = plan?.expand.filter(id => collapsed.has(id)) ?? []
|
||||
if (hidden.length > 0) {
|
||||
expand(hidden)
|
||||
@@ -55,11 +64,12 @@ export function useReveal(
|
||||
const el = row ?? (plan && document.querySelector<HTMLElement>(`.cr-block[data-id="${plan.id}"]`))
|
||||
if (el) {
|
||||
const rect = el.getBoundingClientRect()
|
||||
const top = scrollTopToShow({ top: rect.top + window.scrollY, bottom: rect.bottom + window.scrollY }, window.scrollY, window.innerHeight)
|
||||
const pinned = pinnedHeight()
|
||||
const top = scrollTopToShow({ top: rect.top + window.scrollY, bottom: rect.bottom + window.scrollY }, window.scrollY + pinned, window.innerHeight - pinned)
|
||||
if (top !== undefined)
|
||||
window.scrollTo(0, top)
|
||||
window.scrollTo(0, top - pinned)
|
||||
flash(el)
|
||||
}
|
||||
done()
|
||||
}, [doc, reveal, collapsed, expand, done])
|
||||
}, [doc, reveal, collapsed, filtered, show, expand, done])
|
||||
}
|
||||
@@ -7,7 +7,12 @@ import { postMessage, viewState } from './vscode'
|
||||
/** 滚动时最多这么久记一次位置 */
|
||||
const RECORD_INTERVAL_MS = 100
|
||||
|
||||
/** 页面上每块(BlockCard 带 data-line)的位置,按页面上的先后。 */
|
||||
/** 顶部钉住的筛选栏的高度:视口里露出块的部分从它下面开始,记位置、滚过去都按这部分算。 */
|
||||
export function pinnedHeight(): number {
|
||||
return document.querySelector<HTMLElement>('.cr-filter-bar')?.offsetHeight ?? 0
|
||||
}
|
||||
|
||||
/** 页面上每块(BlockCard 带 data-line)的位置,按页面上的先后;被筛掉的块不在页面上,不算。 */
|
||||
export function measureBlocks(): BlockBox[] {
|
||||
const y = window.scrollY
|
||||
return Array.from(document.querySelectorAll<HTMLElement>('.cr-block[data-line]'), (el) => {
|
||||
@@ -18,7 +23,7 @@ export function measureBlocks(): BlockBox[] {
|
||||
|
||||
/** 记下视口顶部那块;它的起始行变了才报给扩展端。 */
|
||||
function record(reportedLine: { current: number | undefined }): void {
|
||||
const anchor = anchorAt(measureBlocks(), window.scrollY)
|
||||
const anchor = anchorAt(measureBlocks(), window.scrollY + pinnedHeight())
|
||||
if (!anchor)
|
||||
return
|
||||
viewState.update({ scroll: anchor })
|
||||
@@ -46,7 +51,7 @@ export function useScrollPosition(doc: DocumentMessage | null): void {
|
||||
const saved = viewState.get().scroll
|
||||
const top = doc.reveal === undefined && saved ? scrollTopFor(measureBlocks(), saved) : undefined
|
||||
if (top !== undefined)
|
||||
window.scrollTo(0, top)
|
||||
window.scrollTo(0, top - pinnedHeight())
|
||||
record(reportedLine)
|
||||
}, [doc])
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ function memoryStorage(initial?: unknown): ViewStateStorage {
|
||||
}
|
||||
|
||||
function block(id: string, kind: BlockKind, children: BlockView[] = []): BlockView {
|
||||
return { id, kind, startLine: 0, endLine: 0, code: '', codeTokens: [], children, translation: [] }
|
||||
return { id, kind, startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind }, children, translation: [] }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user