✨ feat(vscode): 代码阅读侧栏行加复制菜单,块按钮只留图标,筛选菜单加全选 / 取消全选

This commit is contained in:
cruldra committed 2026-09-26 20:39:21 +08:00
1 parent 631699a570
commit c84e21ff57
16 files changed
+555 -119

No files matched your search

@@ -0,0 +1,20 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import { BlockTools } from './BlockTools'
const render = (editing: boolean): string => renderToStaticMarkup(<BlockTools editing={editing} onToggleEdit={() => {}} />)
/** 标签之间的字:图标(svg)没有字,按钮上有字就会出现在这里 */
const visibleText = (html: string): string => html.replace(/<[^>]*>/g, '').trim()
describe('块右上角的图标按钮', () => {
it.each([[false, '编辑'], [true, '预览']] as const)('编辑中 = %s:只有图标不带字,名字在悬停提示和 aria-label 里', (editing, label) => {
const html = render(editing)
expect(html).toMatch(/^<div[^>]* role="group" aria-label="块操作"/)
expect(visibleText(html)).toBe('')
expect(html.match(/<button/g)).toHaveLength(1)
expect(html).toContain(`title="${label}" aria-label="${label}"`)
expect(html).toMatch(/<button[^>]*><svg/)
expect(html.includes('data-active')).toBe(editing)
})
})
@@ -0,0 +1,25 @@
import type { ReactElement } from 'react'
import { Eye, Pencil } from 'lucide-react'
/**
* 块右上角的一排图标按钮(普通块、属性表、库规则的卡片共用):只放图标,名字写在悬停提示(title)和读屏用的 aria-label 里。
* 眼下只有「编辑 / 预览」切换:预览时是铅笔「编辑」,编辑时是眼睛「预览」、带按下的底色。
*/
export function BlockTools({ editing, onToggleEdit }: { editing: boolean, onToggleEdit: () => void }): ReactElement {
const label = editing ? '预览' : '编辑'
const Icon = editing ? Eye : Pencil
return (
<div className="flex h-5 shrink-0 items-center gap-0.5" role="group" aria-label="块操作">
<button
type="button"
className="cr-tool flex h-5 w-6 items-center justify-center rounded"
data-active={editing ? '' : undefined}
title={label}
aria-label={label}
onClick={onToggleEdit}
>
<Icon size={14} strokeWidth={1.8} aria-hidden />
</button>
</div>
)
}
+4 -14
View File
@@ -1,8 +1,8 @@
import type { CSSProperties, ReactElement } from 'react'
import type { BlockView } from './types'
import { ChevronDown, ChevronRight, Eye, EyeOff, Pencil } from 'lucide-react'
import { ChevronDown, ChevronRight, EyeOff } from 'lucide-react'
import { Fragment } from 'react'
import { cn } from '@/lib/utils'
import { BlockTools } from './BlockTools'
import { CodeEditor } from './CodeEditor'
import { PropertyGrid } from './PropertyGrid'
import { TextPreview } from './TextPreview'
@@ -86,7 +86,7 @@ function BlockPreview({ block, tree }: { block: BlockView, tree: BlockTreeState
)
}
/** 一块:折叠箭头、种类标签、预览内容或源码、右边的「编辑 / 预览」切换。 */
/** 一块:折叠箭头、种类标签、预览内容或源码、右上角的一排图标按钮(BlockTools)。 */
function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }): ReactElement {
const editing = tree.editing.has(block.id)
const collapsed = tree.collapsed.has(block.id)
@@ -135,17 +135,7 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }):
已被筛选隐藏,临时显示
</span>
)}
<button
type="button"
className={cn(
'flex shrink-0 items-center gap-1 rounded px-2 py-0.5 text-xs leading-4 text-[var(--vscode-descriptionForeground)] hover:bg-[var(--vscode-toolbar-hoverBackground)]',
editing && 'bg-[var(--vscode-toolbar-activeBackground)]',
)}
onClick={() => tree.onToggleEdit(block.id)}
>
{editing ? <Eye size={13} strokeWidth={1.8} /> : <Pencil size={13} strokeWidth={1.8} />}
{editing ? '预览' : '编辑'}
</button>
<BlockTools editing={editing} onToggleEdit={() => tree.onToggleEdit(block.id)} />
</div>
)
}
+83 -57
View File
@@ -1,11 +1,11 @@
import type { KeyboardEvent, ReactElement } from 'react'
import type { FocusEvent, KeyboardEvent, ReactElement } from 'react'
import type { Facet, FilterView } from './filter'
import type { Highlight } from './occurrences'
import type { Excluded, FileAttribute, TranslatorOption } from './types'
import { Check, ChevronDown, ListFilter } from 'lucide-react'
import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
import { useId, useLayoutEffect, useRef, useState } from 'react'
import { SelectMenu } from '@/components/ui/select-menu'
import { facetLabel, facetsOf, toggleValue } from './filter'
import { facetLabel, facetsOf, setChecked, toggleValue } from './filter'
export interface FilterBarProps {
attributes: FileAttribute[]
@@ -32,7 +32,12 @@ export function FilterBar({ attributes, excluded, view, highlight, onChange, tra
筛选
</span>
{facetsOf(attributes, excluded).map(facet => (
<FacetMenu key={facet.id} facet={facet} onToggle={value => onChange(toggleValue(excluded, facet.id, value))} />
<FacetMenu
key={facet.id}
facet={facet}
onToggle={value => onChange(toggleValue(excluded, facet.id, value))}
onSetAll={checked => onChange(setChecked(excluded, facet.id, facet.values.map(value => value.id), checked))}
/>
))}
<span className="ml-auto flex h-6 min-w-0 items-center gap-1.5 pl-2 text-xs whitespace-nowrap text-[var(--vscode-descriptionForeground)]">
{highlight && (
@@ -71,10 +76,16 @@ export function FilterBar({ attributes, excluded, view, highlight, onChange, tra
}
/**
* 一个属性的下拉框:按钮上是属性名(去掉了值时写勾着哪些,边框换聚焦色),点开是勾选列表,每个值后面是块数。
* 列表盖在代码块上面;点外面、Esc、Tab 关掉。键盘:按钮上 ↓ ↑ 回车 空格打开,列表里 ↑ ↓ Home End 移动,空格 / 回车勾选。
* 一个属性的下拉框:按钮上是属性名(去掉了值时写勾着哪些,边框换聚焦色),点开的菜单盖在代码块上面:
* 顶上一行「全选 · 取消全选」,分隔线下面是勾选列表,每个值后面是块数。点外面、Tab 出去、Esc 关掉。
* 键盘:按钮上 ↓ ↑ 回车 空格打开、焦点落在列表上,列表里 ↑ ↓ Home End 移动,空格 / 回车勾选,Shift+Tab 回到「取消全选」「全选」。
*/
function FacetMenu({ facet, onToggle }: { facet: Facet, onToggle: (value: string) => void }): ReactElement {
function FacetMenu({ facet, onToggle, onSetAll }: {
facet: Facet
onToggle: (value: string) => void
/** 全选(true)/ 取消全选(false) */
onSetAll: (checked: boolean) => void
}): ReactElement {
const [open, setOpen] = useState(false)
const [activeIndex, setActive] = useState(0)
const rootRef = useRef<HTMLDivElement>(null)
@@ -86,17 +97,6 @@ function FacetMenu({ facet, onToggle }: { facet: Facet, onToggle: (value: string
// 列表开着时文档刷新、值变少了,高亮落回最后一项
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()
@@ -117,6 +117,12 @@ function FacetMenu({ facet, onToggle }: { facet: Facet, onToggle: (value: string
buttonRef.current?.focus()
}
// 焦点离开按钮和菜单(点外面、Tab 出去、切到别的窗口)就关
const onBlur = (event: FocusEvent<HTMLDivElement>): void => {
if (!(event.relatedTarget instanceof Node && rootRef.current?.contains(event.relatedTarget)))
setOpen(false)
}
const onButtonKeyDown = (event: KeyboardEvent<HTMLButtonElement>): void => {
if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
@@ -128,6 +134,14 @@ function FacetMenu({ facet, onToggle }: { facet: Facet, onToggle: (value: string
}
}
const onMenuKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
close()
}
}
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) {
@@ -138,18 +152,10 @@ function FacetMenu({ facet, onToggle }: { facet: Facet, onToggle: (value: string
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">
<div ref={rootRef} className="relative" onBlur={open ? onBlur : undefined}>
<button
ref={buttonRef}
type="button"
@@ -167,37 +173,57 @@ function FacetMenu({ facet, onToggle }: { facet: Facet, onToggle: (value: string
</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}
className="cr-menu absolute top-[calc(100%+2px)] left-0 z-20 flex max-h-[min(360px,60vh)] w-[200px] flex-col rounded-md py-1"
onKeyDown={onMenuKeyDown}
>
{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 className="flex shrink-0 items-center gap-3 px-2.5 py-1 text-xs">
{([[true, '全选'], [false, '取消全选']] as const).map(([checked, text]) => (
<button
key={text}
type="button"
className="cr-link"
// 点鼠标时焦点留在列表上,方向键接着能用;键盘从列表 Shift+Tab 回到这两个
onMouseDown={event => event.preventDefault()}
onClick={() => onSetAll(checked)}
>
{text}
</button>
))}
</div>
<div className="cr-menu-divider my-1 h-px shrink-0" aria-hidden />
<div
ref={listRef}
id={listId}
role="listbox"
aria-multiselectable
aria-label={facet.name}
aria-activedescendant={`${listId}-${active}`}
tabIndex={0}
className="cr-menu-list min-h-0 overflow-y-auto"
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>
)}
</div>
@@ -91,6 +91,21 @@ body:is(.vscode-light, .vscode-high-contrast-light) [data-badge] { --kind: var(-
background: color-mix(in srgb, var(--kind) 7%, var(--vscode-editor-background));
}
/* 块右上角的图标按钮:悬停、按下(这一块在编辑)有底色,键盘聚焦描一圈聚焦色 */
.cr-tool {
color: var(--vscode-descriptionForeground);
}
.cr-tool:hover {
background: var(--vscode-toolbar-hoverBackground);
}
.cr-tool[data-active] {
background: var(--vscode-toolbar-activeBackground);
}
.cr-tool:focus-visible {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: -1px;
}
/* 层级竖线:悬停一个块时,它子层的竖线换成它的种类色,标出这个块管到哪里为止 */
.cr-guide {
background: var(--vscode-editorIndentGuide-background1);
@@ -424,13 +439,18 @@ body:is(.vscode-light, .vscode-high-contrast-light) [data-badge] { --kind: var(-
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%));
}
.cr-menu-divider {
background: var(--vscode-menu-separatorBackground, var(--vscode-editorWidget-border, var(--vscode-panel-border)));
}
.cr-menu-list {
outline: none;
}
.cr-option[data-active] {
background: var(--vscode-list-hoverBackground);
}
/* 键盘在列表里走时,高亮项再描一圈聚焦色 */
.cr-menu:focus-visible .cr-option[data-active] {
.cr-menu-list:focus-visible .cr-option[data-active] {
outline: 1px solid var(--vscode-list-focusOutline, var(--vscode-focusBorder));
outline-offset: -1px;
}
@@ -1,6 +1,6 @@
import type { BlockKind, BlockView, FileAttribute } from './types'
import { describe, expect, it } from 'vitest'
import { applyFilter, blockPath, excludedBy, facetLabel, facetsOf, keptWhileEditing, toggleValue } from './filter'
import { applyFilter, blockPath, excludedBy, facetLabel, facetsOf, keptWhileEditing, setChecked, 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: [] }
@@ -111,6 +111,30 @@ describe('筛选栏上的下拉框', () => {
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'] })
})
it('全选:这份文件里的值都勾上,别的文件才有的值照旧去掉,别的属性不动', () => {
expect(setChecked({ access: ['private', 'protected'], kind: ['if'] }, 'access', ['private', 'public'], true))
.toEqual({ access: ['protected'], kind: ['if'] })
expect(setChecked({ access: ['private'], kind: ['if'] }, 'access', ['private', 'public'], true)).toEqual({ kind: ['if'] })
expect(setChecked({}, 'access', ['private', 'public'], true)).toEqual({})
})
it('取消全选:这份文件里的值都去掉,已经去掉的不重复记', () => {
expect(setChecked({ access: ['protected'], kind: ['if'] }, 'access', ['private', 'public', 'protected'], false))
.toEqual({ access: ['protected', 'private', 'public'], kind: ['if'] })
const excluded = setChecked({}, 'access', ['private', 'public'], false)
const [, access] = facetsOf(ATTRIBUTES, excluded)
expect(access.values.every(value => !value.checked)).toBe(true)
expect(facetLabel(access)).toEqual({ text: '访问级别:未选', active: true })
})
it('取消全选后有这个属性的块全藏起来,没有这个属性的照常显示;再全选全回来', () => {
const none = setChecked({}, 'access', ['private', 'public'], false)
const view = applyFilter(BLOCKS, none, NONE)
expect([...view.hidden].sort()).toEqual(['0', '1', '1.0', '1.0.0', '1.1', '2', '2.0'])
expect(view.hiddenCount).toBe(3)
expect(applyFilter(BLOCKS, setChecked(none, 'access', ['private', 'public'], true), NONE).hidden).toEqual(NONE)
})
})
describe('临时显示的块', () => {
+16 -1
View File
@@ -79,7 +79,22 @@ export function facetLabel(facet: Facet): { text: string, active: boolean } {
/** 勾上 / 去掉属性的一个值后的筛选;一个值都没去掉的属性不留。 */
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]
return withExcluded(excluded, attribute, current.includes(value) ? current.filter(item => item !== value) : [...current, value])
}
/**
* 下拉菜单的「全选 / 取消全选」:把 values(这份文件里这个属性出现的值)一起勾上或去掉。
* 筛选整个工作区共用,别的文件才有的值记着的去掉状态不动;取消全选后有这个属性的块全藏起来。
*/
export function setChecked(excluded: Excluded, attribute: string, values: readonly string[], checked: boolean): Excluded {
const current = excluded[attribute] ?? []
return withExcluded(excluded, attribute, checked
? current.filter(item => !values.includes(item))
: [...current, ...values.filter(item => !current.includes(item))])
}
/** 换掉一个属性去掉的值;一个值都没去掉的属性不留。 */
function withExcluded(excluded: Excluded, attribute: string, next: string[]): Excluded {
const rest = Object.fromEntries(Object.entries(excluded).filter(([key]) => key !== attribute))
return next.length > 0 ? { ...rest, [attribute]: next } : rest
}