✨ feat(vscode): 代码阅读页面画链式调用卡片:竖轨道、小链、收起的闭包和合成的表

链的各步排在值下面的竖轨道上,方法一列对齐、轨道线是函数色淡到 35%;实参是链的缩进成空心点小链,注释挂在步骤下;收起的闭包是能点的小框,点开那一步才画挂着的子块(折叠 id 记在属性行下面);跳到定义、查找、高度估算、只看改动都算上链里的字和步骤下的子块
This commit is contained in:
cruldra committed 2026-10-15 21:05:07 +11:00
1 parent ee595f3da9
commit 1956879aa1
13 files changed
+531 -47

No files matched your search

@@ -9,9 +9,11 @@ import { fileAttributes } from '../../../src/codeReader/attributes'
import { readBlockViews, toBlockViews } from '../../../src/codeReader/blockView'
import { pageBlocks } from '../../../src/codeReader/editor/pageBlocks'
import { createPythonAdapter } from '../../../src/codeReader/languages/python'
import { createRustAdapter } from '../../../src/codeReader/languages/rust'
import { fastapiRule } from '../../../src/codeReader/rules/fastapi'
import { runtime, WASM_DIR, withBlocks } from '../../../src/codeReader/testSupport'
import { readFixture, runtime, WASM_DIR, withBlocks } from '../../../src/codeReader/testSupport'
import { pythonZhTranslator } from '../../../src/codeReader/translators/pythonZh'
import { rustZhTranslator } from '../../../src/codeReader/translators/rustZh'
import { BlockTree, treeState } from './BlockTree'
import { applyFilter } from './filter'
import { FilterBar } from './FilterBar'
@@ -19,7 +21,7 @@ import { NO_PLACEMENT, placementOf } from './placement'
import { EMPTY_CHAT } from './qa'
import { staticStore } from './store'
import { NO_SPAN } from './testBlocks'
import { collapsedIds } from './viewState'
import { chainStepId, collapsedIds } from './viewState'
// 编辑框连着和 VS Code 收发消息的那一套,渲染成 HTML 用不着
vi.mock('./vscode', () => ({ postMessage: () => {}, onMessage: () => () => {}, viewState: { get: () => ({ editing: [], folds: {} }), update: () => {} } }))
@@ -323,6 +325,81 @@ describe('一行译文下面拆开的各项', () => {
})
})
describe('链式调用卡片', () => {
/** 一段 Rust 经过宿主转成的发给页面的块:第一个函数和它函数体里的块 */
async function rustBlocks(source: string): Promise<BlockView[]> {
const language = createRustAdapter(WASM_DIR)
const views = await withBlocks(language, 'rust', source, blocks => toBlockViews(blocks, { source, language, markers: [] }, rustZhTranslator))
return pageBlocks(views, source).filter(block => block.kind === 'function')
}
/** 画成页面,函数展开着;open 是点开的那几步(折叠 id) */
async function chainPage(source: string, open: string[] = []): Promise<{ root: HTMLElement, chain: BlockView }> {
const [fn] = await rustBlocks(source)
const folds = Object.fromEntries([[fn.id, false], ...open.map(id => [id, false])])
const root = document.createElement('div')
root.innerHTML = markup([fn], tree({ collapsed: collapsedIds([fn], folds) }))
const chain = fn.children.find(child => 'translation' in child && child.rows?.some(row => row.chain))!
return { root, chain }
}
it('起点、步骤两行,链的各步排在「步骤」那一行的值下面的竖轨道上:一步一个点、方法和值;合成的一步下面是各次实参的表;写在上面的注释挂在那一步下面', async () => {
const { root, chain } = await chainPage(readFixture('rustChain.rs'))
const card = root.querySelector(`[data-id="${chain.id}"]`)!
expect(card.querySelector('.cr-code')?.closest('.cr-prop-row')).not.toBeNull()
const steps = [...card.querySelectorAll('.cr-chain-step')]
expect(steps.map(step => step.querySelector('.cr-chain-line')?.textContent)).toEqual([
'.plugin4 次',
'.setup闭包|app| · 3 句',
'.on_window_event闭包|window, event| · 1 句',
'.manageapp_state',
'.invoke_handlertauri::generate_handler![ device_resume, settings_get, web::preview_open ]',
'.buildtauri::generate_context!()',
'.expect"error while building tauri application"',
'.run闭包|app, event| · match event · 2 支',
])
expect(steps.every(step => step.querySelector(':scope > .cr-chain-rail > .cr-chain-dot'))).toBe(true)
const plugin = steps[0]
expect(plugin.querySelector('.cr-chain-label')?.textContent).toBe('注释')
expect(plugin.querySelector('.cr-table-head')?.textContent).toBe('实参')
expect([...plugin.querySelectorAll('.cr-table-row')].map(row => row.textContent)).toHaveLength(4)
expect(card.closest('.cr-prop-row')).toBeNull()
})
it('收起来的闭包是一个按钮,默认收着、闭包体的子块不画;点开那一步(它的折叠 id 展开)后子块画在那一步下面,别的步照旧收着', async () => {
const source = readFixture('rustChain.rs')
const closed = (await chainPage(source)).root
const setupFold = closed.querySelectorAll('.cr-chain-step')[1].querySelector('button[data-style="fold"]')!
expect(setupFold.getAttribute('aria-expanded')).toBe('false')
expect(setupFold.getAttribute('aria-label')).toBe('展开闭包')
expect(closed.textContent).not.toContain('tunnels.json')
const { root, chain } = await chainPage(source, [chainStepId(chain0(await rustBlocks(source)), { row: 1, step: 1 })])
const [, setup, windowEvent] = root.querySelectorAll('.cr-chain-step')
expect(setup.querySelector('button[data-style="fold"]')?.getAttribute('aria-expanded')).toBe('true')
expect(setup.querySelector('.cr-chain-blocks')?.textContent).toContain('tunnels.json')
expect(windowEvent.querySelector('.cr-chain-blocks')).toBeNull()
expect(root.textContent).not.toContain('window.hide')
// 挂在步骤下面的子块不再排到卡片后面那一层
expect(root.querySelector(`[data-id="${chain.id}"] + .cr-level`)).toBeNull()
})
it('实参本身是链的一步在值下面缩进成一条小链(空心点),一节一行;变量的初始值是起点,链接在下面', async () => {
const source = 'fn f() {\n let names = rows.iter().filter(|r| r.active).collect();\n tauri::Builder::default()\n .plugin(\n opener::Builder::new()\n .open(false)\n .build(),\n )\n .manage(state);\n}\n'
const { root } = await chainPage(source)
const mini = root.querySelector('.cr-chain-mini')!
expect([...mini.querySelectorAll('.cr-chain-step')].map(line => line.textContent)).toEqual(['opener::Builder::new()', '.open(false)', '.build()'])
expect(mini.querySelectorAll('.cr-chain-dot[data-hollow]')).toHaveLength(3)
const value = [...root.querySelectorAll('.cr-prop-row')].find(row => row.textContent?.startsWith('初始值'))!
expect(value.querySelector('.cr-code')?.textContent).toBe('rows')
expect([...value.querySelectorAll('.cr-chain-line')].map(line => line.textContent)).toEqual(['遍历', '筛选|r| r.active', '收集'])
})
})
/** 一个函数体里第一张链式调用卡片的块 id */
function chain0(blocks: BlockView[]): string {
return blocks[0].children.find(child => 'translation' in child && child.rows?.some(row => row.chain))!.id
}
describe('编辑框', () => {
it('编辑着的块:编辑框里是文档源码按它的编辑范围取的整行、去掉共同缩进,着色取自整份源码的着色', async () => {
const source = 'def f(x):\n if x:\n return "a"\n'
+35 -11
View File
@@ -3,7 +3,7 @@ import type { SourceText } from './blockSource'
import type { RewriteTool } from './BlockTools'
import type { DiffTree, FoldedRuns, PlacedRemoved } from './diffView'
import type { Place, Placement } from './placement'
import type { GridSlots } from './PropertyGrid'
import type { ChainSlots, GridSlots } from './PropertyGrid'
import type { ChatState, ChatView, QaState } from './qa'
import type { PendingImage } from './qaImages'
import type { ReadableStore } from './store'
@@ -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, textKey } from './viewState'
import { chainStepId, foldsByCard, listId, tableId, textKey } from './viewState'
/** 块树里改代码的那部分状态:哪些块在编辑。页面上的块树才有;定义卡片浮窗里的块树没有,卡片只读,不画右上角的按钮 */
export interface EditState {
@@ -150,10 +150,15 @@ const NO_CHAT: ReadableStore<ChatState> = staticStore(EMPTY_CHAT)
/**
* 块下面有没有画出来的子块(删掉的子块也算):子块都被筛掉、被盖住时不留空的一层、不给折叠箭头。
* 挂在卡片子行上的子块(BlockSlot)画在卡片属性表里,不算。
* 挂在卡片子行上的子块(BlockSlot)、挂在链式调用某一步下面的子块(ChainSlot)画在卡片里,不算。
*/
function hasShownChildren(block: BlockView, state: BlockTreeState): boolean {
return block.children.some(child => !child.slot && shown(child, state)) || (!state.covered.has(block.id) && state.diff?.removed.has(block.id) === true)
return block.children.some(child => !inCard(child) && shown(child, state)) || (!state.covered.has(block.id) && state.diff?.removed.has(block.id) === true)
}
/** 子块画在父卡片里(属性表子行上的 BlockSlot、链式调用某一步下面的 ChainSlot),不排在父卡片后面那一层。 */
function inCard(block: BlockView): boolean {
return block.slot !== undefined || block.step !== undefined
}
const CHANGE_NAMES = { add: '新增', mod: '修改', del: '删除' } as const
@@ -372,12 +377,12 @@ function Item({ item, height, children }: { item: boolean, height: number, child
* 一层代码块:左边一条竖线标出这一层,每块的子块紧跟在它下面、再往右缩进一层。被筛掉、被盖住的块不画,不留空位,竖线跟着变短。
* owner 是这一层所属的块(顶层没有,记在 data-owner 上,钉住卡片时按它算卡片连同子块管到哪、套着几层),悬停它时竖线换成它的种类色。
* 标差异时每张卡片左边外侧一条改动色条,删掉的块收成一行放回原来的位置(见 RemovedCard);「只看改动」时连着没变的块折成一条(UnchangedStrip)。
* slotted:这一层是画在卡片属性表某一行下面的子块(BlockSlot),blocks 只有那几块。items:这一层每块套一层 .cr-item(只有页面上的顶层)。
* slotted:这一层是画在卡片里的子块(属性表某一行下面的 BlockSlot、链式调用某一步下面的 ChainSlot),blocks 只有那几块。items:这一层每块套一层 .cr-item(只有页面上的顶层)。
*/
export function BlockLevel({ blocks, owner, slotted = false, items = false }: { blocks: BlockView[], owner?: CardView, slotted?: boolean, items?: boolean }): ReactElement {
const tree = useBlockTree()
// 挂在卡片子行上的子块画在卡片属性表里(slotted 的那一层),这一层不画;删掉的块都放回卡片后面这一层
const listed = useMemo(() => slotted ? blocks : blocks.filter(block => !block.slot), [blocks, slotted])
// 挂在卡片子行上、链式调用某一步下面的子块画在卡片里(slotted 的那一层),这一层不画;删掉的块都放回卡片后面这一层
const listed = useMemo(() => slotted ? blocks : blocks.filter(block => !inCard(block)), [blocks, slotted])
const { removed, diff } = useStore(tree.state, state => ({
removed: slotted ? NO_REMOVED : state.diff?.removed.get(owner?.id ?? '') ?? NO_REMOVED,
diff: state.diff !== undefined,
@@ -418,12 +423,15 @@ export function BlockLevel({ blocks, owner, slotted = false, items = false }: {
const BlockPreview = memo(({ block, children = NO_CHILDREN, folds, marks }: { block: CardView, children?: BlockView[], folds: ReadonlySet<string>, marks?: BlockMarks }): ReactElement => {
const tree = useBlockTree()
const slots = useMemo(() => cardSlots(block, children, folds, tree), [block, children, folds, tree])
const chain = useMemo(() => chainSlots(block, children, folds, tree), [block, children, folds, tree])
if ('properties' in block)
return <PropertyGrid blockId={block.id} rows={block.properties} collapsed={folds} onToggleFold={tree.onToggleFold} marks={marks?.rows} slots={slots} />
return <PropertyGrid blockId={block.id} rows={block.properties} collapsed={folds} onToggleFold={tree.onToggleFold} marks={marks?.rows} slots={slots} chain={chain} />
const folding = { collapsed: folds, onToggleFold: tree.onToggleFold }
// 整句是链的块译文只剩链外面的词,什么都没剩时不画这一行
const empty = Array.isArray(block.translation) && block.translation.length === 0
return (
<>
{marks?.translation ? <DiffText diff={marks.translation} find={textKey(block.id, 'text')} /> : <TextPreview text={block.translation} find={textKey(block.id, 'text')} />}
{marks?.translation ? <DiffText diff={marks.translation} find={textKey(block.id, 'text')} /> : !empty && <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} />
@@ -436,8 +444,8 @@ const BlockPreview = memo(({ block, children = NO_CHILDREN, folds, marks }: { bl
)}
{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} />
<div className={empty ? undefined : 'mt-[3px]'}>
<PropertyGrid blockId={block.id} rows={block.rows} collapsed={folds} onToggleFold={tree.onToggleFold} marks={marks?.rows} chain={chain} />
</div>
)}
</>
@@ -467,6 +475,22 @@ function cardSlots(block: CardView, children: readonly BlockView[], folds: Reado
}
}
/**
* 挂在链式调用某一步下面的子块(ChainSlot)怎么画:一步下面的几块画成一层块(同块树);那一步的折叠 id(chainStepId)没折着就画,
* 点那一步收起来的闭包时切换它。卡片没有这样的子块时没有。
*/
function chainSlots(block: CardView, children: readonly BlockView[], folds: ReadonlySet<string>, tree: BlockTreeHandle): ChainSlots | undefined {
const blocks = children.filter(child => child.step)
if (blocks.length === 0)
return undefined
return {
blocks,
render: list => <BlockLevel blocks={list} owner={block} slotted />,
open: slot => !folds.has(chainStepId(block.id, slot)),
onToggle: slot => tree.onToggleFold(chainStepId(block.id, slot)),
}
}
/**
* 一块的卡片:折叠箭头、种类图标、预览内容或源码、右上角的一排图标按钮(BlockTools,只读的块树没有)。
* 卡片上记着块记号(data-anchor),说明里不带路径的引用靠它认自己写在哪块(见 commentLinkOf)。
@@ -1,14 +1,15 @@
import type { CSSProperties, ReactElement, ReactNode } from 'react'
import type { BlockView, ItemDiff, ItemsDiff, KindBadge, PreviewText, PropertyItem, PropertyRow, Removed, RowDiff, RowsDiff, TextDiff } from './types'
import type { BlockView, ChainSlot, ChainStep, Inline, ItemDiff, ItemsDiff, KindBadge, PreviewText, PropertyItem, PropertyRow, Removed, RowDiff, RowsDiff, TextDiff } from './types'
import { ChevronDown, ChevronRight } from 'lucide-react'
import { Fragment } from 'react'
import { cn } from '@/lib/utils'
import { ChainFoldContext } from './chainFold'
import { DiffInline, DiffText } from './DiffText'
import { inlineText } from './segments'
import { SegmentText } from './SegmentText'
import { subRowsLayout, tableCells, tableTemplate } from './subRows'
import { TextPreview } from './TextPreview'
import { propertyItemId, propertyRowId, tableId, textKey } from './viewState'
import { chainStepId, propertyItemId, propertyRowId, tableId, textKey } from './viewState'
/** 列表子行里除最后一列以外各列的宽度(字典的键),最后一列占满剩下的;编辑区窄时按比例收,不把最后一列挤没 */
const CELL_WIDTHS = ['min(150px, 28%)', 'min(220px, 38%)']
@@ -28,6 +29,17 @@ export interface GridSlots {
onToggle: (block: BlockView) => void
}
/**
* 挂在卡片链式调用某一步下面的子块(ChainSlot):blocks 是卡片的这些子块,render 把一步下面的几块画成一层块(同块树),
* open 是那一步眼下展开着没有,onToggle 是点那一步收起来的闭包时展开 / 收起它。
*/
export interface ChainSlots {
blocks: BlockView[]
render: (blocks: BlockView[]) => ReactNode
open: (slot: ChainSlot) => boolean
onToggle: (slot: ChainSlot) => void
}
/** 表格里一个子行讲的子块:展开着没有,它的种类(展开时这一行淡淡垫上它的颜色),点第一格时怎么切换。 */
interface ItemToggle {
open: boolean
@@ -36,7 +48,7 @@ interface ItemToggle {
}
/** 眼下折叠着哪些属性行、子行,点折叠箭头时切换哪个。 */
interface Folding {
export interface Folding {
collapsed: ReadonlySet<string>
onToggleFold: (id: string) => void
}
@@ -274,7 +286,7 @@ export function PropertyTable({ parentId, columns, items, marks, folding, toggle
* 给了列名的(输入参数、数据表的字段)排成表格,没给的排成列表。
* 标差异时(marks)新加的行淡绿底、左边 +,删掉的行放回原来的位置、淡红底划掉、左边 −,值改了的按旧值 → 新值或逐词标。
*/
export function PropertyGrid({ blockId, rows, collapsed, onToggleFold, marks, slots }: {
export function PropertyGrid({ blockId, rows, collapsed, onToggleFold, marks, slots, chain }: {
blockId: string
rows: PropertyRow[]
collapsed: ReadonlySet<string>
@@ -283,6 +295,8 @@ export function PropertyGrid({ blockId, rows, collapsed, onToggleFold, marks, sl
marks?: RowsDiff
/** 挂在这张属性表子行上的子块(库规则的卡片),见 GridSlots */
slots?: GridSlots
/** 挂在某一行链式调用某一步下面的子块,见 ChainSlots */
chain?: ChainSlots
}): ReactElement {
const folding: Folding = { collapsed, onToggleFold }
const removed = (after: number): ReactElement[] => removedAt(marks?.removed, after)
@@ -320,7 +334,14 @@ export function PropertyGrid({ blockId, rows, collapsed, onToggleFold, marks, sl
</button>
)
: <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} />
{row.chain
? (
<div className="min-w-0">
<RowValue row={row} mark={mark} id={id} />
<ChainRail blockId={blockId} row={index} steps={row.chain} folding={folding} slots={chain} />
</div>
)
: <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} />}
@@ -357,7 +378,10 @@ function RemovedRow({ id, row }: { id: string, row: PropertyRow }): ReactElement
<>
<div className="cr-prop-row grid grid-cols-subgrid" data-diff="del">
<span className="cr-prop-label flex h-5 items-center">{row.label}</span>
<RowValue row={row} mark={undefined} />
<div className="min-w-0">
<RowValue row={row} mark={undefined} />
{row.chain && <ChainRail blockId={id} row={0} steps={row.chain} folding={FIXED} />}
</div>
</div>
{layout && (
<div className="cr-diff-gone">
@@ -369,3 +393,85 @@ function RemovedRow({ id, row }: { id: string, row: PropertyRow }): ReactElement
</>
)
}
/** 一列着色的词大约占几个等宽字宽:汉字这类全角字算两个。 */
function textWidth(pieces: Inline[]): number {
let width = 0
for (const char of inlineText(pieces))
width += /[\u2E80-\uFFEF]/.test(char) ? 2 : 1
return width
}
/**
* 链式调用的各步排成的竖轨道,接在属性行的值(链的起点)下面:一步一个实心点,点与点之间一条函数色的线;方法那一列按最长的那个对齐,
* 值那一列照抄实参,收起来的闭包是能点的小框,点开时这一步挂着的子块(slots)画在这一步下面。
* 一步下面依次是小链(空心点,一节一行)、几行属性(写在上面的注释,属性名在方法那一列)、表(合成的一步各次的实参)、展开着的子块。
* 删掉的行里画的旧链没有 slots,闭包的小框不能点。
*
* ![链式调用卡片](docs/prototype/code-reader/openpencil/exports/rust-chain-a.png)
*/
export function ChainRail({ blockId, row, steps, folding, slots }: {
blockId: string
/** 链在属性表的第几行 */
row: number
steps: ChainStep[]
folding: Folding
slots?: ChainSlots
}): ReactElement {
const width = Math.max(4, ...steps.map(step => textWidth(step.name)))
return (
<div className="cr-chain" style={{ '--cr-chain-name': `${width}ch` } as CSSProperties}>
{steps.map((step, index) => {
const slot = { row, step: index }
const id = chainStepId(blockId, slot)
const blocks = slots?.blocks.filter(block => block.step?.row === row && block.step.step === index) ?? []
const fold = slots && blocks.length > 0 ? { open: slots.open(slot), onToggle: () => slots.onToggle(slot) } : undefined
return (
<div key={index} className="cr-chain-step" data-id={id}>
<ChainDot first={index === 0} last={index === steps.length - 1} />
<div className="cr-chain-body">
<ChainFoldContext value={fold}>
<div className="cr-chain-line">
<p className="cr-code cr-chain-name" data-find={textKey(id, 'name')}><SegmentText segments={step.name} /></p>
<p className="cr-code min-w-0" data-find={textKey(id, 'value')}><SegmentText segments={step.value} /></p>
</div>
{step.lines && (
<div className="cr-chain-line">
<span />
<div className="cr-chain-mini">
{step.lines.map((line, at) => (
<div key={at} className="cr-chain-step">
<ChainDot first={at === 0} last={at === (step.lines?.length ?? 0) - 1} hollow />
<p className="cr-code min-w-0" data-find={textKey(id, `l${at}`)}><SegmentText segments={line} /></p>
</div>
))}
</div>
</div>
)}
</ChainFoldContext>
{step.rows?.map((item, at) => (
<div key={at} className="cr-chain-line">
<span className="cr-chain-label" data-find={textKey(id, `r${at}-label`)}>{item.label}</span>
<TextPreview text={item.value} className="min-w-0" find={textKey(id, `r${at}`)} />
</div>
))}
{step.table && <PropertyTable parentId={tableId(id)} columns={step.table.columns} items={step.table.items} folding={folding} />}
{fold?.open && <div className="cr-chain-blocks">{slots?.render(blocks)}</div>}
</div>
</div>
)
})}
</div>
)
}
/** 轨道上的一个点,上下各一截线连着前后两步(第一步上面、最后一步下面不画);小链的点是空心的。 */
function ChainDot({ first, last, hollow = false }: { first: boolean, last: boolean, hollow?: boolean }): ReactElement {
return (
<span className="cr-chain-rail" data-first={first ? '' : undefined} data-last={last ? '' : undefined}>
<span className="cr-chain-up" />
<span className="cr-chain-dot" data-hollow={hollow ? '' : undefined} />
<span className="cr-chain-down" />
</span>
)
}
@@ -5,7 +5,9 @@ import type { Highlight, OccurrenceStyle } from './occurrences'
import type { Placement } from './placement'
import type { ReadableStore } from './store'
import type { GroupStyle, Inline, Segment, SegmentGroup, ShownName } from './types'
import { ChevronDown, ChevronRight } from 'lucide-react'
import { createContext, memo, useContext, useState } from 'react'
import { ChainFoldContext } from './chainFold'
import { sameShownName, shownName } from './displayNames'
import { KIND_ICON_COMPONENTS } from './kindIcons'
import { isCurrent, segmentOccurrence, sourceOf } from './occurrences'
@@ -106,6 +108,7 @@ const GROUP_ICONS: Record<GroupStyle, LucideIcon | undefined> = {
branch: undefined,
tag: undefined,
classes: undefined,
fold: undefined,
}
/** 框自己的颜色,暗色、亮色两个都写在 style 上,CSS 按主题挑一个(见 .cr-group[data-colored])。 */
@@ -120,6 +123,8 @@ function groupColor(group: SegmentGroup): CSSProperties | undefined {
function GroupBox({ group }: { group: SegmentGroup }): ReactElement {
if (group.style === 'classes')
return <ClassesChip group={group} />
if (group.style === 'fold')
return <FoldChip group={group} />
const Icon = GROUP_ICONS[group.style]
return (
<span className="cr-group" data-style={group.style} data-colored={group.color ? '' : undefined} style={groupColor(group)}>
@@ -134,6 +139,33 @@ function GroupBox({ group }: { group: SegmentGroup }): ReactElement {
)
}
/**
* 链式调用卡片里收起来的闭包、async 块(「闭包 |app| · 37 句」):前面一个折叠箭头,点它展开、收起这一步下面挂着的子块
* (开关是所在那一步给的,见 [ChainFoldContext]);不在链的某一步里时只是一个小框。
*/
function FoldChip({ group }: { group: SegmentGroup }): ReactElement {
const fold = useContext(ChainFoldContext)
const Chevron = fold?.open ? ChevronDown : ChevronRight
return (
<button
type="button"
className="cr-group"
data-style="fold"
data-open={fold?.open ? '' : undefined}
aria-expanded={fold?.open ?? false}
aria-label={`${fold?.open ? '收起' : '展开'}${group.label}`}
disabled={!fold}
onClick={fold?.onToggle}
>
<span className="cr-group-head">
<Chevron size={12} strokeWidth={2} aria-hidden />
{group.label}
</span>
<SegmentText segments={group.segments} />
</button>
)
}
/** 收起来的一串类名(「样式 N 个类」):悬停看全部类名,点一下在它下面弹出、盖住后面的字,再点收起。 */
function ClassesChip({ group }: { group: SegmentGroup }): ReactElement {
const [open, setOpen] = useState(false)
@@ -0,0 +1,13 @@
import { createContext } from 'react'
/** 链式调用的一步收起来的闭包眼下展开着没有,点它时怎么切换。 */
export interface ChainFold {
open: boolean
onToggle: () => void
}
/**
* 链式调用卡片的一步交给它值里那几个 fold 框(收起来的闭包、async 块)的开关;不在链的某一步里(删掉的块的旧内容)时没有,
* fold 框只是一个不能点的小框。
*/
export const ChainFoldContext = createContext<ChainFold | undefined>(undefined)
+105 -7
View File
@@ -512,28 +512,33 @@ body:is(.vscode-light, .vscode-high-contrast-light) .cr-group[data-colored] { --
}
/*
* 收起来的一串类名(「样式 N 个类」):中性色的小框,是个按钮;点开时换成焦点色的边、在下面弹出全部类名,
* 弹层盖住后面的字,不挤开它们
* 收起来的一串类名(「样式 N 个类」)、链式调用里收起来的闭包(「闭包 |app| · 37 句」):中性色的小框,是个按钮;
* 点开时换成焦点色的边,类名在下面弹出全部类名(弹层盖住后面的字,不挤开它们),闭包在这一步下面展开子块。
* 闭包框不在链的某一步里时(删掉的块的旧内容)不能点
*/
.cr-group[data-style="classes"] {
.cr-group:is([data-style="classes"], [data-style="fold"]) {
margin: 0;
font: inherit;
color: inherit;
text-align: left;
cursor: pointer;
background: var(--vscode-input-background);
border-color: var(--vscode-editorWidget-border, var(--vscode-contrastBorder, transparent));
}
.cr-group[data-style="classes"] .cr-group-head {
.cr-group:is([data-style="classes"], [data-style="fold"]) .cr-group-head {
font-weight: 400;
color: var(--vscode-descriptionForeground);
}
.cr-group[data-style="classes"]:hover,
.cr-group[data-style="classes"][data-open] {
.cr-group:is([data-style="classes"], [data-style="fold"]):not(:disabled):hover,
.cr-group:is([data-style="classes"], [data-style="fold"])[data-open] {
border-color: var(--vscode-focusBorder);
}
.cr-group[data-style="classes"][data-open] .cr-group-head {
.cr-group:is([data-style="classes"], [data-style="fold"])[data-open] .cr-group-head {
color: var(--vscode-foreground);
}
.cr-group[data-style="fold"]:disabled {
cursor: default;
}
.cr-classes {
position: relative;
display: inline-block;
@@ -604,6 +609,99 @@ body:is(.vscode-light, .vscode-high-contrast-light) .cr-group[data-colored] { --
.cr-slot {
padding-left: 5px;
}
/*
* 链式调用的竖轨道(见 ChainRail):一步一行,左边一列是点和上下两截线(函数色淡到 35%,第一步上面、最后一步下面不画),
* 方法那一列按最长的那个定宽(--cr-chain-name)、各步对齐;步与步之间的空隙在每一步的下边距里,线才连得上。
* 小链用空心的小点、细一点的说明色线,一节一行
*/
.cr-chain {
--cr-chain-line: color-mix(in srgb, var(--cr-function) 35%, transparent);
display: flex;
flex-direction: column;
margin-top: 3px;
}
.cr-chain-step {
display: flex;
gap: 4px;
}
.cr-chain-rail {
display: flex;
flex: none;
flex-direction: column;
align-items: center;
width: 14px;
}
.cr-chain-up {
flex: none;
width: 2px;
height: 6px;
background: var(--cr-chain-line);
}
.cr-chain-down {
flex: 1;
width: 2px;
background: var(--cr-chain-line);
}
.cr-chain-rail[data-first] > .cr-chain-up,
.cr-chain-rail[data-last] > .cr-chain-down {
visibility: hidden;
}
.cr-chain-dot {
flex: none;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--cr-function);
}
.cr-chain-body {
display: flex;
flex: 1;
flex-direction: column;
gap: 2px;
min-width: 0;
padding-bottom: 6px;
}
.cr-chain-step:last-child > .cr-chain-body {
padding-bottom: 0;
}
.cr-chain-line {
display: grid;
grid-template-columns: var(--cr-chain-name) minmax(0, 1fr);
column-gap: 12px;
}
.cr-chain-name {
white-space: nowrap;
}
.cr-chain-label {
font-size: 12.5px;
line-height: 20px;
color: var(--vscode-descriptionForeground);
white-space: nowrap;
}
.cr-chain-mini {
--cr-chain-line: color-mix(in srgb, var(--vscode-descriptionForeground) 45%, transparent);
display: flex;
flex-direction: column;
min-width: 0;
}
.cr-chain-mini .cr-chain-up {
width: 1px;
height: 7px;
}
.cr-chain-mini .cr-chain-down {
width: 1px;
}
.cr-chain-dot[data-hollow] {
box-sizing: border-box;
width: 6px;
height: 6px;
border: 1px solid var(--vscode-descriptionForeground);
background: transparent;
}
.cr-chain-blocks {
padding-top: 2px;
}
.cr-table .cr-table-cell {
padding: 2px 8px;
}
+3 -3
View File
@@ -115,14 +115,14 @@ export function foldUnchanged(blocks: readonly BlockView[], tree: DiffTree, show
}
close()
}
/** 一层子块:挂在父卡片子行上、点子行才展开的不折、只往里看改动,别的照常折 */
/** 一层子块:画在父卡片里的(挂在子行上、链式调用某一步下面,点开才展开)不折、只往里看改动,别的照常折 */
function visit(list: readonly BlockView[], owner: string): void {
const breaks = new Set((tree.removed.get(owner) ?? []).map(item => item.after))
for (const block of list) {
if (block.slot && (tree.changes.has(block.id) || kept(block)))
if ((block.slot || block.step) && (tree.changes.has(block.id) || kept(block)))
visit(block.children, block.id)
}
visitRun(list.filter(block => !block.slot), breaks)
visitRun(list.filter(block => !block.slot && !block.step), breaks)
}
visit(blocks, '')
return { runs, folded }
@@ -1,4 +1,4 @@
import type { BlockView, PreviewText, PropertyItem, PropertyRow } from './types'
import type { BlockView, ChainStep, PreviewText, PropertyItem, PropertyRow } from './types'
import { foldedByDefault } from './viewState'
/** 一行着色的词的高(像素),同 .cr-code 的行高 */
@@ -26,7 +26,12 @@ function tableHeight(items: readonly PropertyItem[]): number {
}
function rowsHeight(rows: readonly PropertyRow[]): number {
return rows.reduce((sum, row) => sum + textHeight(row.value) + ROW_GAP + (row.columns ? tableHeight(row.items ?? []) : itemsHeight(row.items)), 0)
return rows.reduce((sum, row) => sum + textHeight(row.value) + ROW_GAP + (row.columns ? tableHeight(row.items ?? []) : itemsHeight(row.items)) + chainHeight(row.chain), 0)
}
/** 链式调用的各步:每步一行、步与步之间的空隙,加上小链各节、几行属性、表;挂在步骤下面的子块默认收着,不算 */
function chainHeight(steps: readonly ChainStep[] | undefined): number {
return (steps ?? []).reduce((sum, step) => sum + LINE + 6 + LINE * (step.lines?.length ?? 0) + rowsHeight(step.rows ?? []) + (step.table ? tableHeight(step.table.items) : 0), 0)
}
/** 一张卡片大约多高:属性表按行数,一行译文按译文、拆开的各项、实参表、说明、几行属性 */
@@ -49,9 +54,9 @@ function subtreeHeight(block: BlockView): number {
return height
}
/** 子块那一层:挂在卡片子行上的(BlockSlot)画在卡片里,默认收着,不算 */
/** 子块那一层:挂在卡片子行上的(BlockSlot)、链式调用某一步下面的(ChainSlot)画在卡片里,默认收着,不算 */
function childrenHeight(block: BlockView): number {
return block.children.reduce((sum, child) => child.slot ? sum : sum + GAP + subtreeHeight(child), 0)
return block.children.reduce((sum, child) => child.slot || child.step ? sum : sum + GAP + subtreeHeight(child), 0)
}
/**
@@ -187,6 +187,38 @@ describe('卡片上的各段字:页面上的先后、画在哪、要展开的'
])
})
it('链式调用:起点、步骤两行,「步骤」那一行带着的各步依次是方法、值、小链各节、注释的名字和值、表,都总是展开的', () => {
const line: WrittenBlock = {
...textBlock('5', 'statement', 11, []),
rows: [
{ label: '起点', value: [name('app', 1)] },
{ label: '步骤', value: [{ text: '2 个', role: 'hint' }], chain: [
{ name: [word('.plugin')], value: [], lines: [[name('opener', 5)], [word('.build()')]], rows: [{ label: '注释', value: { markdown: '先装插件' } }] },
{ name: [word('.manage')], value: [{ text: '2 次', role: 'hint' }], table: { columns: ['实参'], items: [{ cells: [[name('a', 9)]] }] } },
] },
],
}
expect(texts(placed([line])[0])).toEqual([
['5#text', [], []],
['5/p0#label', '起点', []],
['5/p0#value', [name('app', 1)], []],
['5/p1#label', '步骤', []],
['5/p1#value', [{ text: '2 个', role: 'hint' }], []],
['5/p1/s0#name', [word('.plugin')], []],
['5/p1/s0#value', [], []],
['5/p1/s0#l0', [name('opener', 5)], []],
['5/p1/s0#l1', [word('.build()')], []],
['5/p1/s0#r0-label', '注释', []],
['5/p1/s0#r0', { markdown: '先装插件' }, []],
['5/p1/s1#name', [word('.manage')], []],
['5/p1/s1#value', [{ text: '2 次', role: 'hint' }], []],
['5/p1/s1/t#h0', '实参', []],
['5/p1/s1/t/0#0', [name('a', 9)], []],
])
expect(occurrenceIn([line], { start: 9, end: 10, kind: 'read' }, () => false)).toEqual({ id: '5', expand: [] })
expect(revealIn([line], 11, 5)).toEqual({ id: '5', expand: [] })
})
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: [] })
+46 -6
View File
@@ -3,7 +3,7 @@ import type { BlockView, CardView, Inline, Occurrence, PreviewTable, PreviewText
import { blockPath } from './filter'
import { showsOccurrence, sourceOf } from './occurrences'
import { subRowsLayout, tableCells } from './subRows'
import { listId, propertyItemId, propertyRowId, tableId, textKey, unfoldToShow } from './viewState'
import { chainStepId, listId, propertyItemId, propertyRowId, tableId, textKey, unfoldToShow } from './viewState'
/** 跳到定义时要滚到的块,就是这处定义的属性行 / 子行(只闪它),以及要展开的块、属性行和子行:它们折叠着,目标就不在页面上。 */
export interface RevealPlan {
@@ -120,27 +120,46 @@ function locate(block: BlockView, offset: number): Spot | undefined {
if (block.defines && covers(block.defines, offset))
return { expand: [], span: width(block.defines), defining: true }
if ('translation' in block) {
const defined = definedItem(textItems(block.id, block.items, block.table), offset)
const rows = block.rows ?? []
const defined = definedItem(textItems(block.id, block.items, block.table), offset) ?? definedRow(block.id, rows, offset)
if (defined)
return defined
const span = textSpan(block.translation, offset)
return span === undefined ? shownItem(textItems(block.id, block.items, block.table), offset) : { expand: [], span, defining: false }
if (span !== undefined)
return { expand: [], span, defining: false }
return shownItem(textItems(block.id, block.items, block.table), offset) ?? shownRow(block.id, rows, offset)
}
const rows = block.properties.map((row, index) => ({ row, id: propertyRowId(block.id, index) }))
for (const { row, id } of rows) {
return definedRow(block.id, block.properties, offset) ?? shownRow(block.id, block.properties, offset)
}
/** 属性表各行里讲 offset 处这个定义的([locate] 的 ②):行自己的 defines,或它下面一层层的子行。 */
function definedRow(blockId: string, rows: PropertyRow[], offset: number): Spot | undefined {
for (const [index, row] of rows.entries()) {
const id = propertyRowId(blockId, index)
if (row.defines && covers(row.defines, offset))
return { row: id, expand: [], span: width(row.defines), defining: true }
const defined = definedItem(rowItems(row, id), offset)
if (defined)
return defined
}
for (const { row, id } of rows) {
return undefined
}
/** 属性表各行里只是显示着 offset 处这个名字的([locate] 的 ③):值,子行的格子,带着的链式调用各步的字。 */
function shownRow(blockId: string, rows: PropertyRow[], offset: number): Spot | undefined {
for (const [index, row] of rows.entries()) {
const id = propertyRowId(blockId, index)
const value = textSpan(row.value, offset)
if (value !== undefined)
return { expand: [], span: value, defining: false }
const shown = shownItem(rowItems(row, id), offset)
if (shown)
return shown
for (const { text, expand } of chainTexts(blockId, row, index)) {
const span = typeof text === 'string' ? undefined : textSpan(text, offset)
if (span !== undefined)
return { expand, span, defining: false }
}
}
return undefined
}
@@ -260,6 +279,27 @@ function* rowTexts(blockId: string, rows: PropertyRow[]): Generator<CardText> {
yield* tableTexts({ columns: row.columns ?? [], items: row.items ?? [] }, id, [id])
else if (layout === 'list')
yield* itemTexts(row.items, id, [id])
yield* chainTexts(blockId, row, index)
}
}
/**
* 属性表第 `index` 行带着的链式调用各步的字(同 [ChainRail](vscode/webview-ui/src/codeReader/PropertyGrid.tsx#ChainRail) 的排法):
* 方法、值、小链各节、几行属性的名字和值、表,都总是展开的。挂在步骤下面的子块是别的块,不在这里。
*/
function* chainTexts(blockId: string, row: PropertyRow, index: number): Generator<CardText> {
for (const [at, step] of (row.chain ?? []).entries()) {
const id = chainStepId(blockId, { row: index, step: at })
yield { key: textKey(id, 'name'), text: step.name, expand: [] }
yield { key: textKey(id, 'value'), text: step.value, expand: [] }
for (const [line, text] of (step.lines ?? []).entries())
yield { key: textKey(id, `l${line}`), text, expand: [] }
for (const [item, { label, value }] of (step.rows ?? []).entries()) {
yield { key: textKey(id, `r${item}-label`), text: label, expand: [] }
yield { key: textKey(id, `r${item}`), text: value, expand: [] }
}
if (step.table)
yield* tableTexts(step.table, tableId(id), [])
}
}
+27 -2
View File
@@ -33,9 +33,9 @@ export interface RuleReference { rule: string, key: string }
/**
* 框的样式,决定框头的图标和框的颜色:iteration 写在表达式里的遍历(同「循环」块),condition 按条件(同「条件」块),
* branch 条件的一支(同「条件」块的颜色、不带图标),tag 小标签(颜色是 SegmentGroup.color),
* classes 收起来的一串类名(中性色,点开、悬停看 detail)
* classes 收起来的一串类名(中性色,点开、悬停看 detail),fold 链式调用卡片里收起来的闭包、async 块(点它展开、收起那一步下面挂着的子块)
*/
export type GroupStyle = 'iteration' | 'condition' | 'branch' | 'tag' | 'classes'
export type GroupStyle = 'iteration' | 'condition' | 'branch' | 'tag' | 'classes' | 'fold'
/**
* 框起来的一组词:画成嵌在前后文字里的一个小框,框头是图标和 label(只有图标时 label 是空的);框里的词照样带源码下标,还能再套框。
@@ -93,6 +93,8 @@ export interface PropertyRow extends Defines {
note?: Markdown
items?: PropertyItem[]
columns?: string[]
/** 值是一条链式调用的起点时,链的各步:排成一条竖轨道,接在值下面 */
chain?: ChainStep[]
}
/** 一张带表头和边框的表:列名,每行几列对齐的格子,同 PropertyRow 给了 columns 的子行 */
@@ -101,6 +103,18 @@ export interface PreviewTable {
items: PropertyItem[]
}
/**
* 链式调用卡片的一步(同扩展端 contract 的 ChainStep):方法那一列(叫法或 `.名字`)、值那一列(收起来的闭包是 fold 框),
* 下面依次是小链(一节一行)、几行属性(写在这一步上面的注释)、表(合成的一步各次的实参,或规则换成的表)
*/
export interface ChainStep {
name: Inline[]
value: Inline[]
lines?: Inline[][]
rows?: PropertyRow[]
table?: PreviewTable
}
/** 一种颜色在暗色、亮色主题下各用哪个(CSS 颜色值);高对比主题跟着暗 / 亮走 */
export interface ThemeColor { dark: string, light: string }
@@ -159,6 +173,8 @@ interface CardViewBase {
badge?: KindBadge
/** 库规则挂在父卡片子行上的子块才有:展开时画在父卡片属性表第 row 行下面,不排在父卡片的子块里;见 BlockSlot */
slot?: BlockSlot
/** 挂在父卡片链式调用某一步下面的子块才有:画在那一步下面,不排在父卡片的子块里;见 ChainSlot */
step?: ChainSlot
/** 能看调用树的块才有(有名字的函数、类,讲 HTTP 接口的卡片):有它才摆调用树按钮 */
callTree?: CallTreeTarget
}
@@ -177,6 +193,15 @@ export interface BlockSlot {
item: number
}
/**
* 子块挂在父卡片链式调用的哪一步:属性表(一行译文的卡片是下面那几行)第 row 行那条链的第 step 步。
* 那一步默认收着(折叠 id 见 chainStepId),点它的闭包展开,这一步下面的子块一起画出来
*/
export interface ChainSlot {
row: number
step: number
}
/**
* 函数、类、枚举、导入、常量、变量块和库规则的卡片预览时是属性表,文件开头的说明是 Markdown,其余是一行译文;
* 一行译文下面依次是:跨几行写的字面量拆开的各项(items),跨几行写的调用摊开各个实参的表(table),块的说明(doc,Markdown 原文),
@@ -1,7 +1,7 @@
import type { BlockKind, BlockView } from './types'
import type { ViewStateStorage } from './viewState'
import { describe, expect, it } from 'vitest'
import { collapsedIds, createViewStateStore, foldsByCard, propertyItemId, propertyRowId, unfoldToShow } from './viewState'
import { chainStepId, collapsedIds, createViewStateStore, foldsByCard, propertyItemId, propertyRowId, unfoldToShow } from './viewState'
/** 模拟 VS Code 的 webview 状态:存进去的要能序列化,页面重新加载后原样取回;也能塞进旧版本存的东西 */
function memoryStorage(initial?: unknown): ViewStateStorage {
@@ -149,6 +149,19 @@ describe('眼下折叠着哪些', () => {
expect(unfoldToShow('0', block('0.2', 'comment'))).toEqual(['0'])
})
it('挂在链式调用某一步下面的子块:那一步默认收着(它的折叠 id 记在属性行下面),子块自己照常;要让它看得见得展开那一步;那一步下面有标了改动的块时默认展开', () => {
const first: BlockView = { ...block('0.0', 'statement'), step: { row: 1, step: 2 } }
const second: BlockView = { ...block('0.1', 'if', [block('0.1.0', 'return')]), step: { row: 1, step: 2 } }
const third: BlockView = { ...block('0.2', 'statement'), step: { row: 1, step: 4 } }
const chain: BlockView = block('0', 'statement', [first, second, third])
expect(chainStepId('0', { row: 1, step: 2 })).toBe('0/p1/s2')
expect([...collapsedIds([chain], {})].sort()).toEqual(['0/p1/s2', '0/p1/s4'])
expect([...collapsedIds([chain], { '0/p1/s2': false })]).toEqual(['0/p1/s4'])
expect([...collapsedIds([chain], {}, new Set(['0.1']))]).toEqual(['0/p1/s4'])
expect(unfoldToShow('0', second)).toEqual(['0/p1/s2'])
expect(foldsByCard(new Set(['0/p1/s2'])).get('0')).toEqual(new Set(['0/p1/s2']))
})
it('标了改动的块默认展开,用户折叠过的照样折叠', () => {
expect([...collapsedIds(BLOCKS, {}, new Set(['1', '2']))].sort()).toEqual(['1.0.0', '2.0'])
expect([...collapsedIds(BLOCKS, { 1: true }, new Set(['1']))].sort()).toEqual(['1', '1.0.0', '2', '2.0'])
+24 -5
View File
@@ -1,5 +1,5 @@
import type { ScrollAnchor } from './scrollPosition'
import type { BlockView, OpenedFrom } from './types'
import type { BlockView, ChainSlot, OpenedFrom } from './types'
/**
* 页面重新加载(webview 进程崩了重建、开发者重载 webview)时靠它恢复(标签页切走时页面一直留着,用不上它):哪些块停在「编辑」、
@@ -84,13 +84,22 @@ export function foldedByDefault(block: Pick<BlockView, 'kind' | 'badge' | 'slot'
return block.slot !== undefined || block.badge !== undefined || ['function', 'class', 'enum', 'exception'].includes(block.kind)
}
/** 眼下折叠着的块、属性行和子行:用户点过的按他点的,没点过的按 foldedByDefault;opened 里的块(标了改动的)默认展开。 */
/**
* 眼下折叠着的块、属性行、子行和链式调用的步:用户点过的按他点的,没点过的按 foldedByDefault,链里挂着子块的步默认收着;
* opened 里的块(标了改动的)默认展开,它挂在哪一步下面那一步也默认展开。
*/
export function collapsedIds(blocks: readonly BlockView[], folds: Readonly<Record<string, boolean>>, opened: ReadonlySet<string> = new Set()): Set<string> {
const collapsed = new Set(Object.keys(folds).filter(id => folds[id]))
const visit = (list: readonly BlockView[]): void => {
for (const block of list) {
if (folds[block.id] === undefined && foldedByDefault(block) && !opened.has(block.id))
collapsed.add(block.id)
const steps = block.children.flatMap(child => child.step ? [{ id: chainStepId(block.id, child.step), opened: opened.has(child.id) }] : [])
const shown = new Set(steps.filter(step => step.opened).map(step => step.id))
for (const { id } of steps) {
if (folds[id] === undefined && !shown.has(id))
collapsed.add(id)
}
visit(block.children)
}
}
@@ -175,10 +184,20 @@ export function textKey(ownerId: string, part: string): string {
}
/**
* 父块看得见时,要让它的一个子块也看得见得展开的(块 id、属性行 id):一般的子块是父块自己(收起子块时不画);
* 挂在父卡片子行上的子块(BlockSlot)是它所在的那一行和它自己。
* 链式调用的一步收起来的闭包的折叠 id,和块 id 放在同一份 folds 里:`<属性行 id>/s<步序号>`,例如 '6.1.0/p1/s3'。
* 默认收着(见 collapsedIds),展开时这一步下面挂着的子块一起画出来;带 '/' 的 id 归它那张卡片(见 foldsByCard)。
*/
export function unfoldToShow(parentId: string, child: Pick<BlockView, 'id' | 'slot'>): string[] {
export function chainStepId(blockId: string, slot: ChainSlot): string {
return `${propertyRowId(blockId, slot.row)}/s${slot.step}`
}
/**
* 父块看得见时,要让它的一个子块也看得见得展开的(块 id、属性行 id、链的步):一般的子块是父块自己(收起子块时不画);
* 挂在父卡片子行上的子块(BlockSlot)是它所在的那一行和它自己;挂在链式调用某一步下面的子块(ChainSlot)是那一步。
*/
export function unfoldToShow(parentId: string, child: Pick<BlockView, 'id' | 'slot' | 'step'>): string[] {
if (child.step)
return [chainStepId(parentId, child.step)]
if (!child.slot)
return [parentId]
return [propertyRowId(parentId, child.slot.row), child.id]