✨ feat(vscode): 代码阅读 React 组件卡片按钩子分区:状态 / 引用 / 读取的数据 / 派生值 / 副作用 / 事件处理;副作用、处理函数成子卡片放进卡片那一行下面(库规则新增把子块放进属性表某一行的机制,事件处理表点名称展开)
This commit is contained in:
1 parent
15f3a6600d
commit
ee807ec7a4
19 files changed
+1057
-56
No files matched your search
@@ -31,9 +31,17 @@ interface BlockViewBase {
|
||||
attributes: BlockAttributes
|
||||
/** 库规则的卡片才有:种类的名字、颜色和图标(RuleCard.badge)。预览同类块一样默认收起它的子块。 */
|
||||
badge?: KindBadge
|
||||
/** 库规则放进父卡片里的子块才有(见 RuleSlot):画在父卡片属性表第 row 行下面,不排在父卡片的子块里。 */
|
||||
slot?: BlockSlot
|
||||
children: BlockView[]
|
||||
}
|
||||
|
||||
/** 子块画在父卡片的哪儿:属性表第 row 行下面;item 是讲它的那个子行,点那个子行的第一格才展开(见 RuleSlot.item)。 */
|
||||
export interface BlockSlot {
|
||||
row: number
|
||||
item?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 发给 webview 的一块(可序列化):函数、类、枚举、异常、导入、常量、变量块和库规则的卡片预览时是属性表,注释块是 Markdown 说明,其余是一行译文。
|
||||
* 一行译文下面依次是:跨几行写的调用摊开各个实参的表(TextTranslation.table),块的说明(TextBlock.doc,翻译插件给的 Markdown 原文)。
|
||||
@@ -325,6 +333,7 @@ export function toBlockViews(
|
||||
return plain({ ...replacement.as, node: block.node, header: span, children: kept, ...(attributes && { attributes }) }, span, id, scope)
|
||||
}
|
||||
const { kind, badge, rows, defines, attributes } = replacement
|
||||
const slots = new Map((replacement.slots ?? []).map(({ block: child, row, item }): [CodeBlock, BlockSlot] => [child, { row, ...(item !== undefined && { item }) }]))
|
||||
const { anchor, inner } = anchors.place(scope, kind, defines && source.slice(defines.startIndex, defines.endIndex), rowsText(span))
|
||||
return {
|
||||
id,
|
||||
@@ -335,18 +344,19 @@ export function toBlockViews(
|
||||
...(defines && { defines: { startIndex: defines.startIndex, endIndex: defines.endIndex } }),
|
||||
attributes: { ...blockAttributes(block), ...attributes, kind },
|
||||
badge,
|
||||
children: list(kept, `${id}.`, inner),
|
||||
children: list(kept, `${id}.`, inner, 0, slots),
|
||||
properties: rows,
|
||||
}
|
||||
}
|
||||
/** 一串兄弟块转成视图数据;略去的块不占位置,id 按出了块的往下数。 */
|
||||
function list(siblings: readonly CodeBlock[], prefix: string, scope: string, from = 0): BlockView[] {
|
||||
/** 一串兄弟块转成视图数据;略去的块不占位置,id 按出了块的往下数。slots 是父卡片放进它属性表里的那几块(见 BlockSlot)。 */
|
||||
function list(siblings: readonly CodeBlock[], prefix: string, scope: string, from = 0, slots?: ReadonlyMap<CodeBlock, BlockSlot>): BlockView[] {
|
||||
const spans = blockSpans(siblings, source)
|
||||
const views: BlockView[] = []
|
||||
siblings.forEach((block, i) => {
|
||||
const view = build(block, spans[i], `${prefix}${from + views.length}`, scope)
|
||||
const slot = slots?.get(block)
|
||||
if (view)
|
||||
views.push(view)
|
||||
views.push(slot ? { ...view, slot } : view)
|
||||
})
|
||||
return views
|
||||
}
|
||||
|
||||
@@ -813,6 +813,19 @@ export interface RuleCard {
|
||||
* 这一块的编辑范围同别的块,是原块整条语句(类就是整个类),盖住留下的子块:它编辑时预览不画这些子块,同一行不会同时在两处能改。
|
||||
*/
|
||||
absorbs: CodeBlock[]
|
||||
/** 画在卡片某一行下面的子块(见 RuleSlot);一块只能放一处。 */
|
||||
slots?: RuleSlot[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 卡片里的一处子块:block(原块 children 里的对象,没并进来的)不排在卡片后面,画在卡片第 row 行下面当子卡片,
|
||||
* 照常转成块(翻译、库规则、编辑、问 AI、差异都照常)。item 是这一行的子行里讲它的那一个(第几个,0 起):
|
||||
* 这时它默认收着,点那个子行的第一格才展开、一次只展开一个(React 组件的事件处理表点名称展开处理函数)。
|
||||
*/
|
||||
export interface RuleSlot {
|
||||
block: CodeBlock
|
||||
row: number
|
||||
item?: number
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { BlockView, RuleRun } from './blockView'
|
||||
import type { CodeBlock, LibraryRule, RuleBlock, RuleLocation, RuleWorkspace, Segment } from './contract'
|
||||
import type { CodeBlock, LibraryRule, RuleBlock, RuleLocation, RuleSlot, RuleWorkspace, Segment } from './contract'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { fileAttributes } from './attributes'
|
||||
import { toBlockViews } from './blockView'
|
||||
@@ -193,6 +193,33 @@ describe('库规则:认领', () => {
|
||||
expect(reports).toEqual(['库规则 omit 给第 3 行的 class 块的替身不合约定,这块照常显示:要略去的块还有子块'])
|
||||
})
|
||||
|
||||
it('放进卡片属性表里的子块:照常转成块,记下画在哪一行(哪个子行);不是留下的子块、没有那一行 / 子行、放了两处的不合约定', async () => {
|
||||
const rows = [{ label: '字段', value: [], items: [{ cells: [[{ text: 'name', role: 'variable' as const }]] }], columns: ['名称'] }, { label: '方法', value: [] }]
|
||||
const slotting = (slots: (block: CodeBlock) => RuleSlot[], id = 'slot'): LibraryRule => ({
|
||||
...cardRule(id),
|
||||
claim: block => block.kind === 'class'
|
||||
? { kind: `${id}.card`, badge: { label: '卡片', color: { dark: '#fff', light: '#000' }, icon: 'box' }, rows, absorbs: block.children.slice(0, 1), slots: slots(block) }
|
||||
: undefined,
|
||||
})
|
||||
const [, card] = await views(MODEL, [slotting(block => [{ block: block.children[2], row: 0, item: 0 }, { block: block.children[3], row: 1 }])])
|
||||
expect(card.children.map(child => [child.kind, child.slot])).toEqual([['comment', undefined], ['variable', { row: 0, item: 0 }], ['function', { row: 1 }]])
|
||||
|
||||
const reports: string[] = []
|
||||
const bad = await views(MODEL, [
|
||||
slotting(block => [{ block: block.children[0], row: 1 }], 'absorbed'),
|
||||
slotting(block => [{ block: block.children[3], row: 5 }], 'row'),
|
||||
slotting(block => [{ block: block.children[3], row: 1, item: 0 }], 'item'),
|
||||
slotting(block => [{ block: block.children[3], row: 1 }, { block: block.children[3], row: 0 }], 'twice'),
|
||||
], reports)
|
||||
expect(bad[1].kind).toBe('class')
|
||||
expect(reports.map(report => report.replace(/^.*照常显示:/, ''))).toEqual([
|
||||
'放进卡片里的块不是留下的子块',
|
||||
'放进卡片第 5 行下面的块,卡片没有这一行',
|
||||
'放进卡片第 1 行第 0 个子行的块,这一行没有这个子行',
|
||||
'同一块放进了卡片里的几处',
|
||||
])
|
||||
})
|
||||
|
||||
it('认成契约里现成的属性表块:种类、属性表、筛选属性照语言插件报的这种块,编辑范围是整个类;并进来的不是子块时照常显示', async () => {
|
||||
const asEnum = (absorbs: (block: CodeBlock) => CodeBlock[]): LibraryRule => ({
|
||||
...cardRule('lib'),
|
||||
|
||||
@@ -48,6 +48,17 @@ function problemOf(rule: LibraryRule, block: CodeBlock, claimed: RuleBlock): str
|
||||
return undefined
|
||||
if (!claimed.kind.startsWith(`${rule.id}.`))
|
||||
return `种类 id「${claimed.kind}」没以「${rule.id}.」开头`
|
||||
const slots = claimed.slots ?? []
|
||||
if (slots.some(slot => !block.children.includes(slot.block) || claimed.absorbs.includes(slot.block)))
|
||||
return '放进卡片里的块不是留下的子块'
|
||||
if (new Set(slots.map(slot => slot.block)).size < slots.length)
|
||||
return '同一块放进了卡片里的几处'
|
||||
for (const { row, item } of slots) {
|
||||
if (!claimed.rows[row])
|
||||
return `放进卡片第 ${row} 行下面的块,卡片没有这一行`
|
||||
if (item !== undefined && !claimed.rows[row].items?.[item])
|
||||
return `放进卡片第 ${row} 行第 ${item} 个子行的块,这一行没有这个子行`
|
||||
}
|
||||
for (const { reference, offset } of referencedSegments(claimed.rows)) {
|
||||
if (reference?.rule !== rule.id)
|
||||
return `引用「${reference?.key}」标的规则是「${reference?.rule}」,不是自己`
|
||||
|
||||
@@ -151,3 +151,98 @@ describe('react 库规则:属性', () => {
|
||||
expect(rowLines(await card(source, 'Empty'), '属性')).toEqual(['属性: 无'])
|
||||
})
|
||||
})
|
||||
|
||||
/** 块和它的子块写成文字:「种类 行号 @行/子行」(放进父卡片属性表里的写在哪),子块多缩进两格。 */
|
||||
function outlineOf(view: BlockView, pad = ''): string[] {
|
||||
const slot = view.slot ? ` @${view.slot.row}${view.slot.item !== undefined ? `/${view.slot.item}` : ''}` : ''
|
||||
return [`${pad}${view.kind} ${view.startLine + 1}${slot}`, ...view.children.flatMap(child => outlineOf(child, `${pad} `))]
|
||||
}
|
||||
|
||||
describe('react 库规则:分区', () => {
|
||||
it('luffy 的 LegalDocPage:状态(渲染中重置写进改它的)/ 引用 / 读取的数据 / 派生值 / 副作用 / 事件处理,讲进卡片的语句不再单独成块', async () => {
|
||||
const view = await card(LEGAL, 'LegalDocPage')
|
||||
expect(propertyLines(properties(view)).slice(4)).toEqual([
|
||||
'状态: 2 个',
|
||||
' activeSection | headings[0]?.id ?? "" | setActiveSection(渲染时,当 doc 不全等 prevDoc 时;副作用 2)',
|
||||
' prevDoc | doc | setPrevDoc(渲染时,当 doc 不全等 prevDoc 时)',
|
||||
'引用: 1 个',
|
||||
' scrollRef | 空 | 挂在 <main> 的 ref 上;副作用 1、副作用 2、handleSectionClick 里用它',
|
||||
'读取的数据: 2 个',
|
||||
' cfg | useSiteConfig() | 自定义钩子',
|
||||
' router | useRouter() | 路由器,跳转用',
|
||||
'派生值: 2 个',
|
||||
' markdown | 如果 doc 全等 "terms" 则 cfg.termsContent 否则 cfg.privacyContent | 每次渲染',
|
||||
' headings | extractHeadings(markdown) | markdown 变了时(useMemo)',
|
||||
'副作用: 2 个',
|
||||
'事件处理: 3 个',
|
||||
' handleSectionClick | 每个 <button> 的 onClick(传 h.id) | 3 句',
|
||||
' handlePrint | <button>(打印) 的 onClick | 1 句',
|
||||
' handleBack | <button>(返回) 的 onClick | 2 句',
|
||||
])
|
||||
// 没讲进卡片的注释照常是子块;副作用放在「副作用」那一行下面,处理函数放在事件处理表对应的子行上
|
||||
expect(outlineOf(view).filter(line => !/^ {4}/.test(line))).toEqual([
|
||||
'react.component 51',
|
||||
' comment 68',
|
||||
' react.effect 76 @7',
|
||||
' react.effect 81 @7',
|
||||
' react.handler 104 @8/0',
|
||||
' react.handler 112 @8/1',
|
||||
' react.handler 116 @8/2',
|
||||
' return 124',
|
||||
])
|
||||
})
|
||||
|
||||
it('副作用子卡片:时机按依赖读、做什么是上面的注释、返回的清理函数并进「清理」,别的语句放在「做什么」下面', async () => {
|
||||
const view = await card(LEGAL, 'LegalDocPage')
|
||||
const [first, second] = view.children.filter(child => child.kind === 'react.effect')
|
||||
expect(first.badge).toEqual({ label: '副作用', color: { dark: '#e5c07b', light: '#9a6a00' }, icon: 'zap' })
|
||||
expect(propertyLines(properties(first))).toEqual(['时机: doc 变了时', '做什么: 切换文档时正文滚回顶部 (DOM 副作用).'])
|
||||
expect(propertyLines(properties(second))).toEqual([
|
||||
'时机: doc、headings 变了时',
|
||||
'做什么: 滚动时按 heading.top 计算当前高亮章节.',
|
||||
'清理: container.removeEventListener("scroll", handleScroll)',
|
||||
])
|
||||
expect(second.children.map(child => `${child.kind} ${child.startLine + 1} @${child.slot?.row}`)).toEqual(['variable 82 @1', 'if 83 @1', 'function 84 @1', 'statement 100 @1'])
|
||||
})
|
||||
|
||||
it('处理函数子卡片:名称(能跳到定义)/ 参数 / 做什么,函数里的语句放在「做什么」下面', async () => {
|
||||
const view = await card(LEGAL, 'LegalDocPage')
|
||||
const handler = view.children.find(child => child.kind === 'react.handler')
|
||||
expect(handler?.badge).toEqual({ label: '事件处理', color: { dark: '#dcdcaa', light: '#795e26' }, icon: 'mouse-pointer-click' })
|
||||
expect(handler && LEGAL.slice(handler.defines?.startIndex, handler.defines?.endIndex)).toBe('handleSectionClick')
|
||||
expect(propertyLines(properties(handler))).toEqual(['名称: handleSectionClick', '参数: id: 字符串', '做什么: 3 句'])
|
||||
expect(handler?.children.map(child => `${child.kind} @${child.slot?.row}`)).toEqual(['variable @2', 'variable @2', 'if @2'])
|
||||
})
|
||||
|
||||
it('luffy 的 ShareButton:useCallback 算派生值、写依赖,主体是语句块时点名称展开它的子卡片;只在挂载时的副作用', async () => {
|
||||
const view = await card(SHARE, 'ShareButton')
|
||||
expect(rowLines(view, '派生值')).toEqual(['派生值: 1 个', ' startShare | 函数 3 句 | deployUrl、sessionId、title 变了时(useCallback)'])
|
||||
expect(rowLines(view, '引用')).toEqual([
|
||||
'引用: 2 个',
|
||||
' activeRef | 真 | 副作用 1、startShare 里用它',
|
||||
' creatingRef | 假 | startShare 里用它',
|
||||
])
|
||||
const callback = view.children.find(child => child.kind === 'react.handler')
|
||||
expect(callback?.slot).toEqual({ row: 5, item: 0 })
|
||||
const [mounted] = view.children.filter(child => child.kind === 'react.effect')
|
||||
expect(propertyLines(properties(mounted))).toEqual(['时机: 只在挂载时', '做什么: 2 句', '清理: activeRef.current = 假;'])
|
||||
})
|
||||
|
||||
it('读取的数据:store 选择器读成从哪个 store 读什么,别的库的钩子写哪个库;没绑在 JSX 上的处理函数写被谁调用', async () => {
|
||||
const titlebar = await card(TITLEBAR, 'DesktopTitlebar')
|
||||
expect(rowLines(titlebar, '读取的数据')).toEqual([
|
||||
'读取的数据: 2 个',
|
||||
' pathname | usePathname() | 当前路径',
|
||||
' title | useDesktopShell((s) => s.title) | 从 desktop store 读 title',
|
||||
])
|
||||
expect(rowLines(titlebar, '派生值')).toEqual(['派生值: 1 个', ' center | title ?? PAGE_TITLES[pathname] ?? "" | 每次渲染'])
|
||||
const poster = await card(POSTER, 'PosterDialog')
|
||||
expect(rowLines(poster, '事件处理')).toEqual([
|
||||
'事件处理: 3 个',
|
||||
' exportPng | 被 download、share 调用 | 1 句',
|
||||
' download | <Button> 的 onClick | 9 句',
|
||||
' share | <Button> 的 onClick | 4 句',
|
||||
])
|
||||
expect(rowLines(poster, '副作用')).toEqual(['副作用: 1 个'])
|
||||
})
|
||||
})
|
||||
@@ -1,29 +1,79 @@
|
||||
import type { CodeBlock, Inline, KindBadge, LibraryRule, Node, PropertyItem, PropertyRow, RuleCard, RuleContext, SegmentGroup, ThemeColor, WorkspaceIndex } from '../contract'
|
||||
import type { BindingFacts, ComponentFacts, FieldFacts, FieldText, PropsFacts } from './reactTree'
|
||||
import { clientDirective, commentText, componentAt, componentOf, componentsIn, declaredTypes, propsOf, sameNode } from './reactTree'
|
||||
import { hint, name, plain, range } from './segments'
|
||||
import type { CodeBlock, Inline, KindBadge, LibraryRule, Node, PropertyItem, PropertyRow, RuleCard, RuleContext, RuleSlot, SegmentGroup, ThemeColor, TokenRole, WorkspaceIndex } from '../contract'
|
||||
import type { BindingFacts, BodyFacts, ComponentFacts, DerivedFacts, EffectFacts, FieldFacts, FieldText, HandlerFacts, Place, PropsFacts, ReadFacts } from './reactTree'
|
||||
import { argumentsOf, attributeName, attributeValue, bodyOf, clientDirective, commentsBefore, commentText, componentAt, componentOf, componentsIn, declaredTypes, enclosingComponent, isComponentTag, isFunction, named, parametersOf, placesOf, propsOf, sameNode, statementsOf, stringContent, unwrap } from './reactTree'
|
||||
import { hint, joined, name, plain, range } from './segments'
|
||||
|
||||
/**
|
||||
* React 库规则:TSX / JSX 里的 React 函数组件(见 reactTree.ts 的 componentAt)整块换成「React 组件」卡片,不再是普通函数卡片的输入参数 / 输出参数。
|
||||
* 卡片:名称(后面跟导出方式、客户端组件、记忆化、转发 ref 这些小标签)/ 说明 / 属性(解构的每个属性一行,类型取属性类型里对应的字段;
|
||||
* 属性类型是接口、类型别名时写来自哪个类型,导入的类型经工作区索引查它的字段),再按钩子分区。
|
||||
* 属性类型是接口、类型别名时写来自哪个类型,导入的类型经工作区索引查它的字段),再按钩子分区,没有就不画:
|
||||
* 状态(useState / useReducer:名称 / 初始值 / 改它的,渲染时按条件重置状态的 if 也写进改它的)、引用(useRef)、
|
||||
* 读取的数据(别的钩子:变量 / 来自 / 意思)、派生值(组件体里别的常量、useMemo / useCallback:名称 / 怎么算 / 什么时候算)、
|
||||
* 副作用(每个 useEffect / useLayoutEffect 一张子卡片:时机 / 做什么 / 清理)、事件处理(组件里定义的函数:名称 / 绑定在 / 做什么,
|
||||
* 点名称展开它的子卡片)。讲进分区的语句并进卡片;副作用、处理函数(和主体是语句块的 useMemo / useCallback)照常是块,放进卡片那一行下面。
|
||||
* 文件开头的 "use client" 讲成了「客户端组件」、组件上面的 JSDoc 讲成了「说明」,都不再单独成块。
|
||||
* 新的 JSX 写法不用导入 react,规则不看导入:TSX / JSX 文件都启用。用词都在词表里,按翻译插件的显示语言挑。
|
||||
*/
|
||||
|
||||
const RULE_ID = 'react'
|
||||
const KIND = 'react.component'
|
||||
const EFFECT_KIND = 'react.effect'
|
||||
const HANDLER_KIND = 'react.handler'
|
||||
|
||||
/** 组件色:React 的青色,渲染大纲里的组件、「客户端组件」标签也用它;白底上照原色看不清,亮色主题取同色相深一些的。 */
|
||||
const COMPONENT_COLOR: ThemeColor = { dark: '#61dafb', light: '#0a7ea4' }
|
||||
/** JSX 里的原生标签(div、span):标签尖括号的灰。 */
|
||||
const NATIVE_TAG_COLOR: ThemeColor = { dark: '#808080', light: '#6f6f6f' }
|
||||
/** 副作用子卡片:同「选项」块的黄;事件处理子卡片:同「函数」块。 */
|
||||
const EFFECT_COLOR: ThemeColor = { dark: '#e5c07b', light: '#9a6a00' }
|
||||
const HANDLER_COLOR: ThemeColor = { dark: '#dcdcaa', light: '#795e26' }
|
||||
/** 导出方式的标签:同 import / export 关键字的颜色。 */
|
||||
const EXPORT_COLOR: ThemeColor = { dark: '#c586c0', light: '#af00db' }
|
||||
/** 记忆化、转发 ref 的标签:同 const / function 关键字的颜色。 */
|
||||
const WRAPPER_COLOR: ThemeColor = { dark: '#569cd6', light: '#0000ff' }
|
||||
|
||||
interface Words {
|
||||
kinds: Record<'component', string>
|
||||
labels: Record<'name' | 'doc' | 'props', string>
|
||||
kinds: Record<'component' | 'effect' | 'handler', string>
|
||||
labels: Record<'name' | 'doc' | 'props' | 'state' | 'refs' | 'reads' | 'derived' | 'effects' | 'handlers' | 'timing' | 'does' | 'cleanup' | 'parameters', string>
|
||||
/** 各分区表格的列 */
|
||||
heads: Record<'state' | 'refs' | 'reads' | 'derived' | 'handlers', string[]>
|
||||
/** 副作用的时机、派生值什么时候算:a、b 变了时,只在挂载时,每次渲染后,每次渲染 */
|
||||
changed: string
|
||||
mount: string
|
||||
afterRender: string
|
||||
everyRender: string
|
||||
/** useLayoutEffect 在浏览器绘制前跑 */
|
||||
beforePaint: string
|
||||
/** 派生值是哪个钩子算的:「(useMemo)」 */
|
||||
hookNote: (hook: string) => string
|
||||
/** 函数主体有几句 */
|
||||
statements: (n: number) => string
|
||||
/** useCallback 记住的是一个函数 */
|
||||
fn: string
|
||||
/** 用在哪:第几个副作用(只有一个时不写几)、渲染时、渲染时按条件重置 */
|
||||
effectPlace: (index?: number) => string
|
||||
renderPlace: string
|
||||
resetPlace: [string, string]
|
||||
/** 「<button> 的 onClick」,在列表里一项项渲染的「每个 <button>」,在属性值里调用时带的实参「(传 h.id)」 */
|
||||
of: string
|
||||
each: string
|
||||
passes: [string, string]
|
||||
/** 引用挂在哪个元素上:「挂在 <main> 的 ref 上」;在别处用到:「副作用 1、handleSectionClick 里用它」 */
|
||||
attached: [string, string]
|
||||
usedIn: string
|
||||
/** 处理函数没绑在 JSX 上,被别的函数调用:「被 download、share 调用」 */
|
||||
calledBy: [string, string]
|
||||
listSeparator: string
|
||||
placeSeparator: string
|
||||
parens: [string, string]
|
||||
/** 读取的数据:库自带的钩子读的是什么(来源模块 → 钩子名 → 意思) */
|
||||
hooks: Record<string, Record<string, string>>
|
||||
/** 「从 desktop store 读 title」「从 desktop store 算 …」 */
|
||||
store: [string, string, string]
|
||||
/** 「从 ThemeContext 上下文读」 */
|
||||
context: [string, string]
|
||||
customHook: string
|
||||
libraryHook: (module: string) => string
|
||||
tags: Record<'named' | 'default' | 'client' | 'memo' | 'forwardRef', string>
|
||||
/** 属性名后面的标签:函数类型的属性、children */
|
||||
roles: Record<'callback' | 'children', string>
|
||||
@@ -46,8 +96,73 @@ interface Words {
|
||||
}
|
||||
|
||||
const ZH: Words = {
|
||||
kinds: { component: 'React 组件' },
|
||||
labels: { name: '名称', doc: '说明', props: '属性' },
|
||||
kinds: { component: 'React 组件', effect: '副作用', handler: '事件处理' },
|
||||
labels: {
|
||||
name: '名称',
|
||||
doc: '说明',
|
||||
props: '属性',
|
||||
state: '状态',
|
||||
refs: '引用',
|
||||
reads: '读取的数据',
|
||||
derived: '派生值',
|
||||
effects: '副作用',
|
||||
handlers: '事件处理',
|
||||
timing: '时机',
|
||||
does: '做什么',
|
||||
cleanup: '清理',
|
||||
parameters: '参数',
|
||||
},
|
||||
heads: {
|
||||
state: ['名称', '初始值', '改它的'],
|
||||
refs: ['名称', '初始值', '用在'],
|
||||
reads: ['变量', '来自', '意思'],
|
||||
derived: ['名称', '怎么算', '什么时候算'],
|
||||
handlers: ['名称', '绑定在', '做什么'],
|
||||
},
|
||||
changed: '变了时',
|
||||
mount: '只在挂载时',
|
||||
afterRender: '每次渲染后',
|
||||
everyRender: '每次渲染',
|
||||
beforePaint: ',浏览器绘制前',
|
||||
hookNote: hook => `(${hook})`,
|
||||
statements: n => `${n} 句`,
|
||||
fn: '函数 ',
|
||||
effectPlace: index => index === undefined ? '副作用' : `副作用 ${index}`,
|
||||
renderPlace: '渲染时',
|
||||
resetPlace: ['渲染时,当 ', ' 时'],
|
||||
of: ' 的 ',
|
||||
each: '每个 ',
|
||||
passes: ['(传 ', ')'],
|
||||
attached: ['挂在 ', ' 的 ref 上'],
|
||||
usedIn: ' 里用它',
|
||||
calledBy: ['被 ', ' 调用'],
|
||||
listSeparator: '、',
|
||||
placeSeparator: ';',
|
||||
parens: ['(', ')'],
|
||||
hooks: {
|
||||
'next/navigation': {
|
||||
usePathname: '当前路径',
|
||||
useRouter: '路由器,跳转用',
|
||||
useSearchParams: '地址栏里的查询参数',
|
||||
useParams: '路径里的动态参数',
|
||||
useSelectedLayoutSegment: '当前选中的下一级布局段',
|
||||
useSelectedLayoutSegments: '当前选中的各级布局段',
|
||||
},
|
||||
'next/router': { useRouter: '路由器,跳转用' },
|
||||
'react': {
|
||||
useId: '一个稳定的唯一 id',
|
||||
useTransition: '在不在过渡中,和开始过渡的函数',
|
||||
useSyncExternalStore: '外部数据源眼下的值',
|
||||
useOptimistic: '乐观更新后的值,和更新它的函数',
|
||||
useActionState: '表单动作的状态,和触发它的函数',
|
||||
useDeferredValue: '晚一步更新的值',
|
||||
},
|
||||
'react-dom': { useFormStatus: '所在表单的提交状态' },
|
||||
},
|
||||
store: ['从 ', ' store 读 ', ' store 算 '],
|
||||
context: ['从 ', ' 上下文读'],
|
||||
customHook: '自定义钩子',
|
||||
libraryHook: module => `${module} 的钩子`,
|
||||
tags: { named: '具名导出', default: '默认导出', client: '客户端组件', memo: '记忆化', forwardRef: '转发 ref' },
|
||||
roles: { callback: '回调', children: '子元素' },
|
||||
propHeads: ['名称', '类型', '默认值', '必填'],
|
||||
@@ -230,16 +345,425 @@ function propsRow(component: ComponentFacts, env: Env): PropertyRow {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 时机、用在哪 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** 组件卡片的各分区要用的:组件,和组件体里各种语句归到哪儿。 */
|
||||
interface Analysis {
|
||||
component: ComponentFacts
|
||||
body: BodyFacts
|
||||
}
|
||||
|
||||
function keyword(text: string): Inline {
|
||||
return { text, role: 'keyword' }
|
||||
}
|
||||
|
||||
/** 名字照抄(能跳到定义);解构模式照常翻。 */
|
||||
function binding(node: Node, role: TokenRole, { context }: Env): Inline[] {
|
||||
return node.type === 'identifier' ? [name(node, role)] : context.translate(node)
|
||||
}
|
||||
|
||||
/** 依赖数组读成什么时候跑 / 算:`[a, b]` 是 a、b 变了时,`[]` 是只在挂载时,没写是 fallback(每次渲染后 / 每次渲染)。 */
|
||||
function timing(deps: Node | undefined, fallback: string, { context, words }: Env): Inline[] {
|
||||
const value = unwrap(deps)
|
||||
if (!value)
|
||||
return [keyword(fallback)]
|
||||
if (value.type !== 'array')
|
||||
return [...context.translate(value), plain(' '), keyword(words.changed)]
|
||||
const items = named(value).filter(item => item.type !== 'comment')
|
||||
if (items.length === 0)
|
||||
return [keyword(words.mount)]
|
||||
return [...joined(items.map(item => context.translate(item)), hint(words.listSeparator)), plain(' '), keyword(words.changed)]
|
||||
}
|
||||
|
||||
/** 元素写成 `<Tag>`:组件用组件色、能跳到定义,原生标签灰色;带 aria-label / title 字符串时后面写上它,好认是哪个按钮。 */
|
||||
function elementRef(element: Node): Inline[] {
|
||||
const tagName = element.childForFieldName('name')
|
||||
if (!tagName)
|
||||
return [plain('<>')]
|
||||
const component = isComponentTag(tagName)
|
||||
const labelled = named(element).find(child => child.type === 'jsx_attribute' && ['aria-label', 'title'].includes(attributeName(child)))
|
||||
const label = labelled && stringContent(attributeValue(labelled))
|
||||
return [
|
||||
{ text: `<${tagName.text}>`, role: 'type', color: component ? COMPONENT_COLOR : NATIVE_TAG_COLOR, ...(component && { refers: range(tagName) }) },
|
||||
...(label ? [hint(`(${label})`)] : []),
|
||||
]
|
||||
}
|
||||
|
||||
/** 绑在 JSX 属性上的一处:「每个 <button> 的 onClick(传 h.id)」。 */
|
||||
function jsxPlace(place: Extract<Place, { kind: 'jsx' }>, { context, words }: Env): Inline[] {
|
||||
const args = place.call ? argumentsOf(place.call) : []
|
||||
return [
|
||||
...(place.each ? [plain(words.each)] : []),
|
||||
...elementRef(place.element),
|
||||
plain(words.of),
|
||||
{ text: place.attribute.text, role: 'variable' },
|
||||
...(args.length > 0 ? [hint(words.passes[0]), ...joined(args.map(arg => context.translate(arg)), plain(', ')), hint(words.passes[1])] : []),
|
||||
]
|
||||
}
|
||||
|
||||
/** 用到的一处读成什么(见 Place)。 */
|
||||
function placeText(place: Place, { body }: Analysis, env: Env): Inline[] {
|
||||
const { context, words } = env
|
||||
switch (place.kind) {
|
||||
case 'effect':
|
||||
return [hint(words.effectPlace(body.effects.length > 1 ? place.index + 1 : undefined))]
|
||||
case 'handler':
|
||||
return [name(place.name, 'function')]
|
||||
case 'derived':
|
||||
return binding(place.name, 'variable', env)
|
||||
case 'read':
|
||||
return binding(place.target, 'variable', env)
|
||||
case 'reset':
|
||||
return [hint(words.resetPlace[0]), ...context.translate(place.condition), hint(words.resetPlace[1])]
|
||||
case 'jsx':
|
||||
return jsxPlace(place, env)
|
||||
default:
|
||||
return [hint(words.renderPlace)]
|
||||
}
|
||||
}
|
||||
|
||||
/** 几处写在括号里、用分号隔开:「(副作用 2;渲染时,当 … 时)」;没有时什么都不写。 */
|
||||
function inParens(parts: Inline[][], { words }: Env): Inline[] {
|
||||
return parts.length > 0 ? [hint(words.parens[0]), ...joined(parts, hint(words.placeSeparator)), hint(words.parens[1])] : []
|
||||
}
|
||||
|
||||
/** 注释说的做什么:去掉注释符号,几行接成一行。 */
|
||||
function commentLine(comments: readonly Node[]): string | undefined {
|
||||
const text = commentText(comments).replace(/\s*\n\s*/g, ' ')
|
||||
return text || undefined
|
||||
}
|
||||
|
||||
// ─── 状态、引用、读取的数据、派生值 ──────────────────────────────────────────
|
||||
|
||||
/** 状态:名称 / 初始值 / 改它的(改状态的函数,括号里写在哪儿改:副作用、处理函数、渲染时按条件重置……)。 */
|
||||
function stateRow(analysis: Analysis, env: Env): PropertyRow {
|
||||
const { component, body } = analysis
|
||||
const { context, words } = env
|
||||
const items = body.states.map((state): PropertyItem => {
|
||||
const initial = state.initial ? context.translate(state.initial) : [hint(words.nothing)]
|
||||
const reducer = state.reducer ? [hint(words.parens[0]), ...context.translate(state.reducer), hint(words.parens[1])] : []
|
||||
const changes = state.setter
|
||||
? [name(state.setter, 'function'), ...inParens(placesOf(state.setter, component, body).map(place => placeText(place, analysis, env)), env)]
|
||||
: [hint(words.nothing)]
|
||||
return { cells: [binding(state.value, 'variable', env), [...initial, ...reducer], changes], defines: range(state.value) }
|
||||
})
|
||||
return { label: words.labels.state, value: [hint(words.count(items.length))], items, columns: words.heads.state }
|
||||
}
|
||||
|
||||
/** 引用:名称 / 初始值 / 用在(挂在哪个元素的 ref 上,在哪些副作用、处理函数里用它)。 */
|
||||
function refRow(analysis: Analysis, env: Env): PropertyRow {
|
||||
const { component, body } = analysis
|
||||
const { context, words } = env
|
||||
const items = body.refs.map((ref): PropertyItem => {
|
||||
const places = placesOf(ref.name, component, body)
|
||||
const attached = places.filter((place): place is Extract<Place, { kind: 'jsx' }> => place.kind === 'jsx' && place.attribute.text === 'ref')
|
||||
const others = places.filter(place => !attached.includes(place as Extract<Place, { kind: 'jsx' }>))
|
||||
const parts = [
|
||||
...attached.map(place => [plain(words.attached[0]), ...elementRef(place.element), plain(words.attached[1])]),
|
||||
...(others.length > 0 ? [[...joined(others.map(place => placeText(place, analysis, env)), hint(words.listSeparator)), hint(words.usedIn)]] : []),
|
||||
]
|
||||
return {
|
||||
cells: [binding(ref.name, 'variable', env), ref.initial ? context.translate(ref.initial) : [hint(words.nothing)], parts.length > 0 ? joined(parts, hint(words.placeSeparator)) : [hint(words.nothing)]],
|
||||
defines: range(ref.name),
|
||||
}
|
||||
})
|
||||
return { label: words.labels.refs, value: [hint(words.count(items.length))], items, columns: words.heads.refs }
|
||||
}
|
||||
|
||||
/** 名字是从哪个模块导入的(去掉引号);`React.useX` 看 React 从哪导入。没导入(同文件里定义的)时没有。 */
|
||||
function moduleOf(local: string, { context }: Env): string | undefined {
|
||||
for (const facts of context.imports) {
|
||||
for (const item of facts.items) {
|
||||
const bound = item.kind === 'named' ? (item.alias ?? item.name) : item.alias
|
||||
if (bound?.text === local)
|
||||
return facts.sources[0]?.text.replace(/^(['"`])([\s\S]*)\1$/, '$2')
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** 自己项目里的模块:相对路径、`@/` 这类路径别名。 */
|
||||
function isLocalModule(module: string): boolean {
|
||||
return /^(?:\.|@\/|~\/|#)/.test(module)
|
||||
}
|
||||
|
||||
/** store 叫什么:模块路径最后一节是 store 时取上一节(`@/lib/desktop/store` 是 desktop),否则去掉 store、use 字样,再不行取钩子名。 */
|
||||
function storeName(hook: string, module: string | undefined): string {
|
||||
const parts = module?.split('/').filter(Boolean) ?? []
|
||||
const last = parts.at(-1)
|
||||
if (last && /^store$/i.test(last) && parts.length > 1)
|
||||
return parts[parts.length - 2]
|
||||
const stripped = last?.replace(/[-_.]?store$/i, '').replace(/^use[-_]?/i, '')
|
||||
return stripped && !stripped.startsWith('@') ? stripped : hook.replace(/^use/, '')
|
||||
}
|
||||
|
||||
/** 选择器 `(s) => s.a.b` 取的那一串属性(a、b);不是从参数上直接取属性的写法时没有。 */
|
||||
function selectedPath(selector: Node | undefined): Node[] | undefined {
|
||||
const fn = unwrap(selector)
|
||||
if (!fn || fn.type !== 'arrow_function')
|
||||
return undefined
|
||||
const [parameter] = parametersOf(fn)
|
||||
const body = unwrap(fn.childForFieldName('body'))
|
||||
if (parametersOf(fn).length !== 1 || parameter.name.type !== 'identifier' || !body)
|
||||
return undefined
|
||||
const path = (node: Node): Node[] | undefined => {
|
||||
if (node.type !== 'member_expression')
|
||||
return undefined
|
||||
const object = node.childForFieldName('object')
|
||||
const property = node.childForFieldName('property')
|
||||
if (!object || !property)
|
||||
return undefined
|
||||
if (object.type === 'identifier')
|
||||
return object.text === parameter.name.text ? [property] : undefined
|
||||
const inner = path(object)
|
||||
return inner && [...inner, property]
|
||||
}
|
||||
return path(body)
|
||||
}
|
||||
|
||||
/**
|
||||
* 读的数据是什么:库自带的钩子照词表(usePathname → 当前路径);useContext 读哪个上下文;
|
||||
* 传选择器的(`useXxx((s) => s.a)`)是从哪个 store 读 a;自己项目里的是自定义钩子,别的库的写是哪个库的钩子。
|
||||
*/
|
||||
function readMeaning(read: ReadFacts, env: Env): Inline[] {
|
||||
const { context, words } = env
|
||||
const hook = read.hook.text
|
||||
const callee = unwrap(read.call.childForFieldName('function'))
|
||||
const owner = callee?.type === 'member_expression' ? callee.childForFieldName('object')?.text : hook
|
||||
const module = owner ? moduleOf(owner, env) : undefined
|
||||
const known = module ? words.hooks[module]?.[hook] : undefined
|
||||
if (known)
|
||||
return [plain(known)]
|
||||
const [first] = argumentsOf(read.call)
|
||||
if (hook === 'useContext' && first)
|
||||
return [hint(words.context[0]), ...context.translate(first), hint(words.context[1])]
|
||||
const path = selectedPath(first)
|
||||
const store = storeName(hook, module)
|
||||
if (path)
|
||||
return [hint(words.store[0]), plain(store), hint(words.store[1]), ...joined(path.map(property => [name(property, 'variable')]), plain('.'))]
|
||||
const selector = unwrap(first)
|
||||
if (selector?.type === 'arrow_function' && parametersOf(selector).length === 1 && selector.childForFieldName('body')?.type !== 'statement_block')
|
||||
return [hint(words.store[0]), plain(store), hint(words.store[2]), ...context.translate(selector.childForFieldName('body') as Node)]
|
||||
return [hint(!module || isLocalModule(module) ? words.customHook : words.libraryHook(module))]
|
||||
}
|
||||
|
||||
/** 读取的数据:变量 / 来自(钩子调用照常翻)/ 意思。 */
|
||||
function readRow(analysis: Analysis, env: Env): PropertyRow {
|
||||
const { context, words } = env
|
||||
const items = analysis.body.reads.map((read): PropertyItem => ({
|
||||
cells: [binding(read.target, 'variable', env), context.translate(read.call), readMeaning(read, env)],
|
||||
defines: range(read.target),
|
||||
}))
|
||||
return { label: words.labels.reads, value: [hint(words.count(items.length))], items, columns: words.heads.reads }
|
||||
}
|
||||
|
||||
/** useMemo / useCallback 的函数主体是语句块:点名称展开看它的代码。 */
|
||||
function blockBodied(item: DerivedFacts): boolean {
|
||||
return item.memo?.callback?.childForFieldName('body')?.type === 'statement_block'
|
||||
}
|
||||
|
||||
/** 派生值:名称 / 怎么算(照常翻;useMemo 是算值的那个表达式,useCallback 是个函数)/ 什么时候算(useMemo、useCallback 按依赖)。 */
|
||||
function derivedRow(analysis: Analysis, env: Env): PropertyRow {
|
||||
const { context, words } = env
|
||||
const items = analysis.body.derived.map((item): PropertyItem => {
|
||||
const { memo } = item
|
||||
const callback = unwrap(memo?.callback)
|
||||
const body = callback?.childForFieldName('body')
|
||||
let how: Inline[]
|
||||
if (!memo || !callback)
|
||||
how = context.translate(item.value)
|
||||
else if (memo.hook.text === 'useCallback')
|
||||
how = [hint(words.fn), ...(blockBodied(item) ? [hint(words.statements(statementsOf(callback).length))] : context.translate(callback))]
|
||||
else
|
||||
how = body?.type === 'statement_block' ? [hint(words.statements(statementsOf(callback).length))] : context.translate(body ?? callback)
|
||||
const when = memo ? [...timing(memo.deps, words.everyRender, env), hint(words.hookNote(memo.hook.text))] : [plain(words.everyRender)]
|
||||
return { cells: [binding(item.name, 'variable', env), how, when], defines: range(item.name) }
|
||||
})
|
||||
return { label: words.labels.derived, value: [hint(words.count(items.length))], items, columns: words.heads.derived }
|
||||
}
|
||||
|
||||
// ─── 副作用、事件处理 ─────────────────────────────────────────────────────────
|
||||
|
||||
/** 副作用、处理函数做什么:上面的注释(子卡片里用说明色,表格里用正文色,见 role);没写注释时直接返回的表达式照常翻,函数主体写几句。 */
|
||||
function doesText(comments: readonly Node[], fn: Node | undefined, env: Env, role: 'hint' | 'plain' = 'hint'): Inline[] {
|
||||
const comment = commentLine(comments)
|
||||
if (comment)
|
||||
return [{ text: comment, role }]
|
||||
const body = fn?.childForFieldName('body')
|
||||
if (body && body.type !== 'statement_block')
|
||||
return env.context.translate(body)
|
||||
return [hint(env.words.statements(fn ? statementsOf(fn).length : 0))]
|
||||
}
|
||||
|
||||
/** 清理:返回的清理函数做什么(`() => …` 翻它的主体,语句块一句句翻,别的照常翻)。 */
|
||||
function cleanupText(cleanup: Node, { context, words }: Env): Inline[] {
|
||||
const value = unwrap(named(cleanup).find(child => child.type !== 'comment'))
|
||||
if (!value)
|
||||
return []
|
||||
const body = isFunction(value) ? value.childForFieldName('body') : undefined
|
||||
if (body?.type === 'statement_block')
|
||||
return joined(statementsOf(value).map(statement => context.translate(statement)), hint(words.placeSeparator))
|
||||
return context.translate(body ?? value)
|
||||
}
|
||||
|
||||
function effectBadge(words: Words): KindBadge {
|
||||
return { label: words.kinds.effect, color: EFFECT_COLOR, icon: 'zap' }
|
||||
}
|
||||
|
||||
function handlerBadge(words: Words): KindBadge {
|
||||
return { label: words.kinds.handler, color: HANDLER_COLOR, icon: 'mouse-pointer-click' }
|
||||
}
|
||||
|
||||
/** 一个副作用的子卡片:时机 / 做什么(下面是副作用函数里的语句)/ 清理(返回清理函数时才有,那句 return 并进来)。 */
|
||||
function effectCard(block: CodeBlock, effect: EffectFacts, env: Env): RuleCard {
|
||||
const { words } = env
|
||||
const callback = unwrap(effect.callback)
|
||||
const cleanup = effect.cleanup && block.children.find(child => sameNode(child.node, effect.cleanup))
|
||||
const rows: PropertyRow[] = [
|
||||
{ label: words.labels.timing, value: [...timing(effect.deps, words.afterRender, env), ...(effect.hook.text === 'useLayoutEffect' ? [hint(words.beforePaint)] : [])] },
|
||||
{ label: words.labels.does, value: doesText(effect.comments, callback, env) },
|
||||
...(effect.cleanup ? [{ label: words.labels.cleanup, value: cleanupText(effect.cleanup, env) }] : []),
|
||||
]
|
||||
return {
|
||||
kind: EFFECT_KIND,
|
||||
badge: effectBadge(words),
|
||||
rows,
|
||||
absorbs: cleanup ? [cleanup] : [],
|
||||
slots: block.children.filter(child => child !== cleanup).map(child => ({ block: child, row: 1 })),
|
||||
}
|
||||
}
|
||||
|
||||
/** 一个处理函数(和 useCallback 记住的函数)的子卡片:名称 / 参数 / 做什么(下面是函数里的语句)。 */
|
||||
function handlerCard(block: CodeBlock, fnName: Node, fn: Node, comments: readonly Node[], env: Env): RuleCard {
|
||||
const { context, words } = env
|
||||
const parameters = parametersOf(fn).map(parameter => [
|
||||
...binding(parameter.name, 'variable', env),
|
||||
...(parameter.type ? [plain(': '), ...context.translate(parameter.type)] : []),
|
||||
...(parameter.default ? [plain(' = '), ...context.translate(parameter.default)] : []),
|
||||
])
|
||||
const rows: PropertyRow[] = [
|
||||
{ label: words.labels.name, value: [name(fnName, 'function')] },
|
||||
...(parameters.length > 0 ? [{ label: words.labels.parameters, value: joined(parameters, plain(', ')) }] : []),
|
||||
{ label: words.labels.does, value: doesText(comments, fn, env) },
|
||||
]
|
||||
return {
|
||||
kind: HANDLER_KIND,
|
||||
badge: handlerBadge(words),
|
||||
rows,
|
||||
defines: range(fnName),
|
||||
absorbs: [],
|
||||
slots: block.children.map(child => ({ block: child, row: rows.length - 1 })),
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理函数绑在哪:JSX 上的属性(「<button> 的 onClick」);没绑在 JSX 上时写被谁调用。 */
|
||||
function boundText(handler: HandlerFacts, analysis: Analysis, env: Env): Inline[] {
|
||||
const { words } = env
|
||||
const places = placesOf(handler.name, analysis.component, analysis.body)
|
||||
const bound = places.filter((place): place is Extract<Place, { kind: 'jsx' }> => place.kind === 'jsx')
|
||||
if (bound.length > 0)
|
||||
return joined(bound.map(place => jsxPlace(place, env)), hint(words.listSeparator))
|
||||
const callers = places.filter(place => place.kind !== 'render')
|
||||
if (callers.length > 0)
|
||||
return [hint(words.calledBy[0]), ...joined(callers.map(place => placeText(place, analysis, env)), hint(words.listSeparator)), hint(words.calledBy[1])]
|
||||
return [hint(words.nothing)]
|
||||
}
|
||||
|
||||
/** 事件处理:名称 / 绑定在 / 做什么,点名称在表下面展开它的子卡片。 */
|
||||
function handlerRow(analysis: Analysis, env: Env): PropertyRow {
|
||||
const { words } = env
|
||||
const items = analysis.body.handlers.map((handler): PropertyItem => ({
|
||||
cells: [[name(handler.name, 'function')], boundText(handler, analysis, env), doesText(handler.comments, handler.fn, env, 'plain')],
|
||||
defines: range(handler.name),
|
||||
}))
|
||||
return { label: words.labels.handlers, value: [hint(words.count(items.length))], items, columns: words.heads.handlers }
|
||||
}
|
||||
|
||||
/** 组件体里的一句是副作用、处理函数、useCallback 记住的函数时,它的子卡片;别的没有。 */
|
||||
function nestedCard(block: CodeBlock, env: Env): RuleCard | undefined {
|
||||
if (!['statement', 'function', 'const', 'variable'].includes(block.kind))
|
||||
return undefined
|
||||
const component = enclosingComponent(block.node)
|
||||
if (!component)
|
||||
return undefined
|
||||
const body = bodyOf(component)
|
||||
const at = (facts: { statement: Node }): boolean => sameNode(facts.statement, block.node)
|
||||
const effect = body.effects.find(at)
|
||||
if (effect)
|
||||
return effectCard(block, effect, env)
|
||||
const handler = body.handlers.find(at)
|
||||
if (handler)
|
||||
return handlerCard(block, handler.name, handler.fn, handler.comments, env)
|
||||
const callback = body.derived.find(item => at(item) && item.memo?.hook.text === 'useCallback' && blockBodied(item))
|
||||
const fn = unwrap(callback?.memo?.callback)
|
||||
return callback && fn ? handlerCard(block, callback.name, fn, commentsBefore(callback.statement), env) : undefined
|
||||
}
|
||||
|
||||
// ─── 认领 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 组件卡片。 */
|
||||
function componentCard(component: ComponentFacts, env: Env): RuleCard {
|
||||
function componentCard(block: CodeBlock, component: ComponentFacts, env: Env): RuleCard {
|
||||
const { words } = env
|
||||
const analysis: Analysis = { component, body: bodyOf(component) }
|
||||
const { body } = analysis
|
||||
const rows: PropertyRow[] = [nameRow(component, env), ...docRows(component, env), propsRow(component, env)]
|
||||
const absorbs: CodeBlock[] = []
|
||||
const slots: RuleSlot[] = []
|
||||
const childAt = (node: Node | undefined): CodeBlock | undefined => node && block.children.find(child => sameNode(child.node, node))
|
||||
/** 讲进卡片里的语句(和讲它的注释)并进来,不再单独成块。 */
|
||||
const absorb = (...nodes: (Node | undefined)[]): void => {
|
||||
for (const child of nodes.map(childAt)) {
|
||||
if (child && !absorbs.includes(child))
|
||||
absorbs.push(child)
|
||||
}
|
||||
}
|
||||
if (body.states.length > 0) {
|
||||
rows.push(stateRow(analysis, env))
|
||||
absorb(...body.states.map(state => state.statement), ...body.resets.map(reset => reset.statement))
|
||||
}
|
||||
if (body.refs.length > 0) {
|
||||
rows.push(refRow(analysis, env))
|
||||
absorb(...body.refs.map(ref => ref.statement))
|
||||
}
|
||||
if (body.reads.length > 0) {
|
||||
rows.push(readRow(analysis, env))
|
||||
absorb(...body.reads.map(read => read.statement))
|
||||
}
|
||||
if (body.derived.length > 0) {
|
||||
const row = rows.push(derivedRow(analysis, env)) - 1
|
||||
body.derived.forEach((item, index) => {
|
||||
const child = childAt(item.statement)
|
||||
if (child && blockBodied(item))
|
||||
slots.push({ block: child, row, item: index })
|
||||
else
|
||||
absorb(item.statement)
|
||||
})
|
||||
}
|
||||
if (body.effects.length > 0) {
|
||||
const row = rows.push({ label: words.labels.effects, value: [hint(words.count(body.effects.length))] }) - 1
|
||||
for (const effect of body.effects) {
|
||||
const child = childAt(effect.statement)
|
||||
if (child)
|
||||
slots.push({ block: child, row })
|
||||
absorb(effect.comments[0])
|
||||
}
|
||||
}
|
||||
if (body.handlers.length > 0) {
|
||||
const row = rows.push(handlerRow(analysis, env)) - 1
|
||||
body.handlers.forEach((handler, index) => {
|
||||
const child = childAt(handler.statement)
|
||||
if (child)
|
||||
slots.push({ block: child, row, item: index })
|
||||
absorb(handler.comments[0])
|
||||
})
|
||||
}
|
||||
return {
|
||||
kind: KIND,
|
||||
badge: badge(env.words),
|
||||
rows: [nameRow(component, env), ...docRows(component, env), propsRow(component, env)],
|
||||
badge: badge(words),
|
||||
rows,
|
||||
defines: range(component.name),
|
||||
absorbs: [],
|
||||
absorbs,
|
||||
slots,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,7 +796,9 @@ export const reactRule: LibraryRule = {
|
||||
const env: Env = { context, words: WORDS[context.displayLanguage ?? ''] ?? ZH }
|
||||
const component = componentOf(block)
|
||||
if (component)
|
||||
return componentCard(component, env)
|
||||
return omitted(block) ? { omit: true } : undefined
|
||||
return componentCard(block, component, env)
|
||||
if (omitted(block))
|
||||
return { omit: true }
|
||||
return nestedCard(block, env)
|
||||
},
|
||||
}
|
||||
@@ -680,6 +680,9 @@ export function bodyOf(component: ComponentFacts): BodyFacts {
|
||||
else if (declarator && target && value && hook) {
|
||||
facts.reads.push({ statement, target, call: value, hook })
|
||||
}
|
||||
else if (componentAt(statement)) {
|
||||
// 组件里又定义的组件照常成块(自己也是一张组件卡片),不算处理函数
|
||||
}
|
||||
else if (declarator && target?.type === 'identifier' && value && isFunction(value)) {
|
||||
facts.handlers.push({ statement, name: target, fn: value, comments: commentsBefore(statement) })
|
||||
}
|
||||
@@ -714,6 +717,38 @@ export function bodyOf(component: ComponentFacts): BodyFacts {
|
||||
return facts
|
||||
}
|
||||
|
||||
/** 函数的一个参数:名字(或解构模式)、类型、默认值。 */
|
||||
export interface ParameterText {
|
||||
name: Node
|
||||
type?: Node
|
||||
default?: Node
|
||||
}
|
||||
|
||||
/** 函数的参数,照源码顺序(不带括号的单参数箭头函数 `x => …` 也算)。 */
|
||||
export function parametersOf(fn: Node): ParameterText[] {
|
||||
const single = fn.childForFieldName('parameter')
|
||||
if (single)
|
||||
return [{ name: single }]
|
||||
return syntax(fn.childForFieldName('parameters')).map((parameter): ParameterText => {
|
||||
if (parameter.type === 'required_parameter' || parameter.type === 'optional_parameter') {
|
||||
const type = syntax(parameter.childForFieldName('type'))[0]
|
||||
const fallback = parameter.childForFieldName('value')
|
||||
return { name: parameter.childForFieldName('pattern') ?? parameter, ...(type && { type }), ...(fallback && { default: fallback }) }
|
||||
}
|
||||
if (parameter.type === 'assignment_pattern') {
|
||||
const fallback = parameter.childForFieldName('right')
|
||||
return { name: parameter.childForFieldName('left') ?? parameter, ...(fallback && { default: fallback }) }
|
||||
}
|
||||
return { name: parameter }
|
||||
})
|
||||
}
|
||||
|
||||
/** 函数主体的语句(去掉注释);直接返回表达式的箭头函数没有。 */
|
||||
export function statementsOf(fn: Node): Node[] {
|
||||
const body = fn.childForFieldName('body')
|
||||
return body?.type === 'statement_block' ? syntax(body) : []
|
||||
}
|
||||
|
||||
// ─── 用在哪 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -146,6 +146,7 @@ export function App(): ReactElement | null {
|
||||
}))
|
||||
},
|
||||
onToggleFold: id => setFolded([id], !collapsed.has(id)),
|
||||
onSetFolds: folds => setView(prev => ({ ...prev, folds: { ...prev.folds, ...folds } })),
|
||||
...(qa.state.available && { qa }),
|
||||
...(changes && { diff: changes }),
|
||||
...(folded && { folded }),
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { BlockTreeState } from './BlockTree'
|
||||
import type { BlockView, KindBadge } from './types'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { BlockLevel } from './BlockTree'
|
||||
import { collapsedIds } from './viewState'
|
||||
|
||||
// 高亮的上下文所在的模块、编辑框都连着和 VS Code 收发消息的那一套,渲染成 HTML 用不着
|
||||
vi.mock('./useOccurrences', async () => {
|
||||
const { createContext } = await import('react')
|
||||
return { HighlightContext: createContext(undefined) }
|
||||
})
|
||||
vi.mock('./vscode', () => ({ postMessage: () => {}, onMessage: () => () => {}, viewState: { get: () => ({ editing: [], folds: {} }), update: () => {} } }))
|
||||
|
||||
const BADGE: KindBadge = { label: 'React 组件', color: { dark: '#61dafb', light: '#0a7ea4' }, icon: 'atom' }
|
||||
|
||||
function view(id: string, kind: string, text: string, extra: Partial<BlockView> = {}): BlockView {
|
||||
return { id, anchor: id, kind, startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind }, children: [], translation: [{ text, role: 'plain' }], ...extra } as BlockView
|
||||
}
|
||||
|
||||
/** 一张组件卡片:第 1 行下面放着一个副作用,第 2 行的两个子行各讲一个处理函数,后面还有一个留下的注释块。 */
|
||||
const CARD: BlockView = {
|
||||
...view('0', 'react.component', ''),
|
||||
badge: BADGE,
|
||||
properties: [
|
||||
{ label: '名称', value: [{ text: 'Page', role: 'function' }] },
|
||||
{ label: '副作用', value: [{ text: '1 个', role: 'hint' }] },
|
||||
{ label: '事件处理', value: [{ text: '2 个', role: 'hint' }], columns: ['名称', '绑定在', '做什么'], items: [
|
||||
{ cells: [[{ text: 'handleA', role: 'function' }], [], []] },
|
||||
{ cells: [[{ text: 'handleB', role: 'function' }], [], []] },
|
||||
] },
|
||||
],
|
||||
children: [
|
||||
view('0.0', 'react.effect', '滚回顶部', { slot: { row: 1 }, badge: { ...BADGE, label: '副作用', icon: 'zap' } }),
|
||||
view('0.1', 'react.handler', '处理 A', { slot: { row: 2, item: 0 }, badge: { ...BADGE, label: '事件处理', icon: 'mouse-pointer-click' } }),
|
||||
view('0.2', 'react.handler', '处理 B', { slot: { row: 2, item: 1 }, badge: { ...BADGE, label: '事件处理', icon: 'mouse-pointer-click' } }),
|
||||
view('0.3', 'comment', '留下的注释'),
|
||||
],
|
||||
}
|
||||
|
||||
function render(folds: Record<string, boolean>): string {
|
||||
const collapsed = collapsedIds([CARD], folds)
|
||||
const tree: BlockTreeState = {
|
||||
version: 1,
|
||||
editing: new Set(),
|
||||
collapsed,
|
||||
hidden: new Set(),
|
||||
temporary: new Set(),
|
||||
covered: new Map(),
|
||||
kindName: kind => kind,
|
||||
focusId: null,
|
||||
onFocused: () => {},
|
||||
onToggleEdit: () => {},
|
||||
onToggleFold: () => {},
|
||||
onSetFolds: () => {},
|
||||
onOpenRun: () => {},
|
||||
}
|
||||
return renderToStaticMarkup(<BlockLevel blocks={[CARD]} tree={tree} />)
|
||||
}
|
||||
|
||||
describe('放进卡片属性表里的子块', () => {
|
||||
it('副作用画在它那一行下面;点子行才展开的处理函数默认不画,名称是展开它的按钮;卡片收着时留下的子块不画', () => {
|
||||
const html = render({})
|
||||
expect(html).toContain('滚回顶部')
|
||||
expect(html).not.toContain('处理 A')
|
||||
expect(html).not.toContain('留下的注释')
|
||||
expect(html).toContain('<button type="button" class="cr-item-toggle" aria-expanded="false" aria-label="展开handleA">')
|
||||
// 副作用画在「副作用」那一行后面、「事件处理」那一行前面
|
||||
expect(html.indexOf('滚回顶部')).toBeGreaterThan(html.indexOf('副作用</button>'))
|
||||
expect(html.indexOf('滚回顶部')).toBeLessThan(html.indexOf('事件处理</button>'))
|
||||
})
|
||||
|
||||
it('展开一个处理函数:画在表下面,那一行标成展开、垫上它的种类色,卡片左上角的箭头收起它', () => {
|
||||
const html = render({ 0.1: false, 0: false })
|
||||
expect(html).toContain('处理 A')
|
||||
expect(html).not.toContain('处理 B')
|
||||
expect(html).toContain('data-open="" data-badge="" style="--kind-dark:#61dafb;--kind-light:#0a7ea4"')
|
||||
expect(html).toContain('aria-label="收起"')
|
||||
expect(html).toContain('留下的注释')
|
||||
})
|
||||
|
||||
it('折叠放着子块的那一行:它下面的子块跟着收起', () => {
|
||||
expect(render({ '0/p1': true })).not.toContain('滚回顶部')
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { CSSProperties, ReactElement, ReactNode } from 'react'
|
||||
import type { DiffTree, FoldedRuns, PlacedRemoved } from './diffView'
|
||||
import type { GridSlots } from './PropertyGrid'
|
||||
import type { QaState } from './qa'
|
||||
import type { PendingImage } from './qaImages'
|
||||
import type { BlockMarks, BlockView } from './types'
|
||||
@@ -34,6 +35,8 @@ export interface BlockTreeState {
|
||||
onFocused: () => void
|
||||
onToggleEdit: (id: string) => void
|
||||
onToggleFold: (id: string) => void
|
||||
/** 一次记下几块、几行折叠还是展开(点子行展开一块、同一行里别的收起) */
|
||||
onSetFolds: (folds: Record<string, boolean>) => void
|
||||
/** 问 AI;文件不在工作区文件夹里(问答没地方存)时没有 */
|
||||
qa?: QaTree
|
||||
/** 代码差异:新增、修改的块和删掉的块;侧栏开着「未提交的更改」或审查工单、基准里有这个文件时才有 */
|
||||
@@ -56,9 +59,12 @@ export interface QaTree {
|
||||
onSelectProfile: (id: string) => void
|
||||
}
|
||||
|
||||
/** 块下面有没有画出来的子块(删掉的子块也算):子块都被筛掉、被盖住时不留空的一层、不给折叠箭头。 */
|
||||
/**
|
||||
* 块下面有没有画出来的子块(删掉的子块也算):子块都被筛掉、被盖住时不留空的一层、不给折叠箭头。
|
||||
* 放进卡片属性表里的子块(BlockSlot)画在卡片里,不算。
|
||||
*/
|
||||
function hasShownChildren(block: BlockView, tree: BlockTreeState): boolean {
|
||||
return block.children.some(child => shown(child, tree)) || (!tree.covered.has(block.id) && tree.diff?.removed.has(block.id) === true)
|
||||
return block.children.some(child => !child.slot && shown(child, tree)) || (!tree.covered.has(block.id) && tree.diff?.removed.has(block.id) === true)
|
||||
}
|
||||
|
||||
const CHANGE_NAMES = { add: '新增', mod: '修改', del: '删除' } as const
|
||||
@@ -110,13 +116,20 @@ function kindProps(block: BlockView | undefined): { 'data-kind'?: string, 'data-
|
||||
* 一层代码块:左边一条竖线标出这一层,每块的子块紧跟在它下面、再往右缩进一层。被筛掉、被盖住的块不画,不留空位,竖线跟着变短。
|
||||
* owner 是这一层所属的块(顶层没有),悬停它时竖线换成它的种类色。
|
||||
* 标差异时每张卡片左边外侧一条改动色条,删掉的块收成一行放回原来的位置(见 RemovedCard);「只看改动」时连着没变的块折成一条(UnchangedStrip)。
|
||||
* slotted:这一层是画在卡片属性表某一行下面的子块(BlockSlot),blocks 只有那几块。
|
||||
*/
|
||||
export function BlockLevel({ blocks, owner, tree }: { blocks: BlockView[], owner?: BlockView, tree: BlockTreeState }): ReactElement {
|
||||
const removed = tree.diff?.removed.get(owner?.id ?? '') ?? []
|
||||
// 删掉的块排在它原来前面那块后面;那块被筛掉了也照排,不跟着消失
|
||||
export function BlockLevel({ blocks, owner, tree, slotted = false }: { blocks: BlockView[], owner?: BlockView, tree: BlockTreeState, slotted?: boolean }): ReactElement {
|
||||
// 放进卡片属性表里的子块画在卡片里(slotted 的那一层),这一层不画;删掉的块都放回卡片后面这一层
|
||||
const listed = slotted ? blocks : blocks.filter(block => !block.slot)
|
||||
const removed = slotted ? [] : tree.diff?.removed.get(owner?.id ?? '') ?? []
|
||||
const listedIds = new Set(listed.map(block => block.id))
|
||||
// 删掉的块排在它原来前面那块后面;那块被筛掉了也照排,不跟着消失;那块画在卡片里时排到这一层最后
|
||||
const removedAfter = (id: string | undefined): ReactElement[] => removed
|
||||
.filter(item => item.after === id)
|
||||
.map(item => <RemovedCard key={item.id} placed={item} tree={tree} />)
|
||||
const removedElsewhere = removed
|
||||
.filter(item => item.after !== undefined && !listedIds.has(item.after))
|
||||
.map(item => <RemovedCard key={item.id} placed={item} tree={tree} />)
|
||||
return (
|
||||
<div className="cr-level flex gap-2" {...kindProps(owner)}>
|
||||
<div className="flex w-3 shrink-0 justify-center">
|
||||
@@ -124,7 +137,7 @@ export function BlockLevel({ blocks, owner, tree }: { blocks: BlockView[], owner
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
{removedAfter(undefined)}
|
||||
{blocks.map(block => (
|
||||
{listed.map(block => (
|
||||
<Fragment key={block.id}>
|
||||
{tree.folded?.runs.has(block.id) && <UnchangedStrip count={tree.folded.runs.get(block.id) ?? 0} tree={tree} onOpen={() => tree.onOpenRun(block.id)} />}
|
||||
{shown(block, tree) && !tree.folded?.folded.has(block.id) && (
|
||||
@@ -141,6 +154,7 @@ export function BlockLevel({ blocks, owner, tree }: { blocks: BlockView[], owner
|
||||
{removedAfter(block.id)}
|
||||
</Fragment>
|
||||
))}
|
||||
{removedElsewhere}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -169,7 +183,7 @@ function BlockQa({ anchor, qa }: { anchor: string, qa: QaTree }): ReactElement {
|
||||
*/
|
||||
function BlockPreview({ block, tree, marks }: { block: BlockView, tree: BlockTreeState, marks?: BlockMarks }): ReactElement {
|
||||
if ('properties' in block)
|
||||
return <PropertyGrid blockId={block.id} rows={block.properties} collapsed={tree.collapsed} onToggleFold={tree.onToggleFold} marks={marks?.rows} />
|
||||
return <PropertyGrid blockId={block.id} rows={block.properties} collapsed={tree.collapsed} onToggleFold={tree.onToggleFold} marks={marks?.rows} slots={cardSlots(block, tree)} />
|
||||
return (
|
||||
<>
|
||||
{marks?.translation ? <DiffText diff={marks.translation} /> : <TextPreview text={block.translation} />}
|
||||
@@ -183,12 +197,36 @@ function BlockPreview({ block, tree, marks }: { block: BlockView, tree: BlockTre
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 卡片属性表里放的子块(BlockSlot)怎么画:一行下面的几块画成一层块(同块树);点子行才展开的,点那个子行的第一格展开它、
|
||||
* 同一行里别的收起,展开着的再点收起。卡片没有放进属性表的子块时没有。
|
||||
*/
|
||||
function cardSlots(block: BlockView, tree: BlockTreeState): GridSlots | undefined {
|
||||
const blocks = block.children.filter(child => child.slot)
|
||||
if (blocks.length === 0)
|
||||
return undefined
|
||||
return {
|
||||
blocks,
|
||||
render: list => <BlockLevel blocks={list} owner={block} tree={tree} slotted />,
|
||||
onToggle: (child) => {
|
||||
if (!tree.collapsed.has(child.id)) {
|
||||
tree.onSetFolds({ [child.id]: true })
|
||||
return
|
||||
}
|
||||
const others = blocks.filter(other => other !== child && other.slot?.row === child.slot?.row && other.slot?.item !== undefined)
|
||||
tree.onSetFolds({ ...Object.fromEntries(others.map(other => [other.id, true])), [child.id]: false })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** 一块:折叠箭头、种类图标、预览内容或源码、右上角的一排图标按钮(BlockTools)。 */
|
||||
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 = hasShownChildren(block, tree)
|
||||
const Chevron = collapsed ? ChevronRight : ChevronDown
|
||||
// 点父卡片子行才展开的子块:画出来就是展开着的,箭头收起整张
|
||||
const toggled = block.slot?.item !== undefined
|
||||
const hasChildren = toggled || hasShownChildren(block, tree)
|
||||
const Chevron = collapsed && !toggled ? ChevronRight : ChevronDown
|
||||
const change = tree.diff?.changes.get(block.id)
|
||||
return (
|
||||
<div
|
||||
@@ -205,8 +243,8 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }):
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-5 w-3.5 shrink-0 items-center justify-center rounded-sm text-[var(--vscode-descriptionForeground)] hover:text-[var(--vscode-foreground)]"
|
||||
aria-label={collapsed ? '展开子块' : '折叠子块'}
|
||||
aria-expanded={!collapsed}
|
||||
aria-label={toggled ? '收起' : collapsed ? '展开子块' : '折叠子块'}
|
||||
aria-expanded={toggled || !collapsed}
|
||||
onClick={() => tree.onToggleFold(block.id)}
|
||||
>
|
||||
<Chevron size={14} strokeWidth={1.8} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import type { Inline, InlinePart, ItemDiff, ItemsDiff, PropertyItem, PropertyRow, Removed, RowsDiff } from './types'
|
||||
import type { CSSProperties, ReactElement, ReactNode } from 'react'
|
||||
import type { BlockView, Inline, InlinePart, ItemDiff, ItemsDiff, KindBadge, PropertyItem, PropertyRow, Removed, RowsDiff } from './types'
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import { Fragment } from 'react'
|
||||
import { DiffInline, DiffText } from './DiffText'
|
||||
@@ -17,6 +17,23 @@ function gridTemplate(count: number): string {
|
||||
return [...fixed, 'minmax(0, 1fr)'].join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* 库规则放进卡片属性表里的子块(BlockSlot):blocks 是卡片的这些子块,render 把一行下面的几块画成一层块(同块树),
|
||||
* onToggle 是点子行第一格时展开 / 收起讲它的那一块(点子行才展开的,见 BlockSlot.item)。
|
||||
*/
|
||||
export interface GridSlots {
|
||||
blocks: BlockView[]
|
||||
render: (blocks: BlockView[]) => ReactNode
|
||||
onToggle: (block: BlockView) => void
|
||||
}
|
||||
|
||||
/** 表格里一个子行讲的子块:展开着没有,它的种类(展开时这一行淡淡垫上它的颜色),点第一格时怎么切换。 */
|
||||
interface ItemToggle {
|
||||
open: boolean
|
||||
badge?: KindBadge
|
||||
onToggle: () => void
|
||||
}
|
||||
|
||||
/** 眼下折叠着哪些属性行、子行,点折叠箭头时切换哪个。 */
|
||||
interface Folding {
|
||||
collapsed: ReadonlySet<string>
|
||||
@@ -137,21 +154,38 @@ function PropertyItems({ parentId, items, folding, marks }: { parentId: string,
|
||||
* 列宽按整列的内容定(见 tableTemplate),最后一列分剩下的,长了在格子里换行。一行译文下面摊开实参的表也用它;
|
||||
* 行带 table(实参的值里跨几行写的调用)时,那张表排在最后一格里、文字下面,往右缩一点,一层层往下套。
|
||||
* 标差异时(marks)有加行、删行的表每行第一格前多一个记号位(+ / −,没变的空着),加的行淡绿底,删的行放回原来的位置、淡红底划掉。
|
||||
* 讲某个子块的行(toggles,React 组件的事件处理表)第一格是按钮,点它在表下面展开那一块,展开时这一行淡淡垫上那一块的种类色。
|
||||
*/
|
||||
export function PropertyTable({ parentId, columns, items, marks }: { parentId: string, columns: string[], items: PropertyItem[], marks?: ItemsDiff }): ReactElement {
|
||||
export function PropertyTable({ parentId, columns, items, marks, toggles }: {
|
||||
parentId: string
|
||||
columns: string[]
|
||||
items: PropertyItem[]
|
||||
marks?: ItemsDiff
|
||||
/** 按子行下标:讲它的子块(点第一格展开,见 GridSlots) */
|
||||
toggles?: ReadonlyMap<number, ItemToggle>
|
||||
}): ReactElement {
|
||||
// 有加行、删行的表每行第一格前留一个记号位
|
||||
const signed = marks !== undefined && (marks.removed.length > 0 || Object.values(marks.changed).some(mark => mark.added))
|
||||
/** 一行:删掉的(change 是 del)不带 id,套着的表不再标 */
|
||||
const tableRow = (item: PropertyItem, id: string, change: 'add' | 'del' | undefined, mark: ItemDiff | undefined): ReactElement => {
|
||||
/** 一行:删掉的(change 是 del)不带 id,套着的表不再标;toggle 是这一行讲的子块,第一格成了展开它的按钮 */
|
||||
const tableRow = (item: PropertyItem, id: string, change: 'add' | 'del' | undefined, mark: ItemDiff | undefined, toggle?: ItemToggle): ReactElement => {
|
||||
const cells = tableCells(item, columns)
|
||||
const text = (cell: Inline[], i: number): ReactElement => (
|
||||
<>
|
||||
{signed && i === 0 && <span className="cr-diff-sign" data-change={change} aria-hidden>{SIGNS[change ?? 'none']}</span>}
|
||||
<CellText cell={cell} parts={mark?.cells?.[i]} />
|
||||
{toggle && i === 0
|
||||
? (
|
||||
<button type="button" className="cr-item-toggle" aria-expanded={toggle.open} aria-label={`${toggle.open ? '收起' : '展开'}${inlineText(cell)}`} onClick={toggle.onToggle}>
|
||||
<CellText cell={cell} parts={mark?.cells?.[i]} />
|
||||
</button>
|
||||
)
|
||||
: <CellText cell={cell} parts={mark?.cells?.[i]} />}
|
||||
</>
|
||||
)
|
||||
const tint = toggle?.open && toggle.badge
|
||||
? { 'data-badge': '', 'style': { '--kind-dark': toggle.badge.color.dark, '--kind-light': toggle.badge.color.light } as CSSProperties }
|
||||
: {}
|
||||
return (
|
||||
<div className="cr-table-row" role="row" data-id={change === 'del' ? undefined : id} data-diff={change}>
|
||||
<div className="cr-table-row" role="row" data-id={change === 'del' ? undefined : id} data-diff={change} data-open={toggle?.open ? '' : undefined} {...tint}>
|
||||
{cells.map((cell, i) => item.table && i === cells.length - 1
|
||||
? (
|
||||
<div key={i} className="cr-table-cell flex min-w-0 flex-col gap-[3px]" role="cell">
|
||||
@@ -176,7 +210,7 @@ export function PropertyTable({ parentId, columns, items, marks }: { parentId: s
|
||||
const mark = marks?.changed[index]
|
||||
return (
|
||||
<Fragment key={index}>
|
||||
{tableRow(item, propertyItemId(parentId, index), mark?.added && 'add', mark)}
|
||||
{tableRow(item, propertyItemId(parentId, index), mark?.added && 'add', mark, toggles?.get(index))}
|
||||
{removed(index)}
|
||||
</Fragment>
|
||||
)
|
||||
@@ -191,13 +225,15 @@ export function PropertyTable({ parentId, columns, items, marks }: { parentId: s
|
||||
* 给了列名的(输入参数、数据表的字段)排成表格,没给的排成列表。
|
||||
* 标差异时(marks)新加的行淡绿底、左边 +,删掉的行放回原来的位置、淡红底划掉、左边 −,值改了的按旧值 → 新值或逐词标。
|
||||
*/
|
||||
export function PropertyGrid({ blockId, rows, collapsed, onToggleFold, marks }: {
|
||||
export function PropertyGrid({ blockId, rows, collapsed, onToggleFold, marks, slots }: {
|
||||
blockId: string
|
||||
rows: PropertyRow[]
|
||||
collapsed: ReadonlySet<string>
|
||||
onToggleFold: (id: string) => void
|
||||
/** 标差异时这张属性表改了哪些行 */
|
||||
marks?: RowsDiff
|
||||
/** 放进这张属性表里的子块(库规则的卡片),见 GridSlots */
|
||||
slots?: GridSlots
|
||||
}): ReactElement {
|
||||
const removed = (after: number): ReactElement[] => removedAt(marks?.removed, after)
|
||||
.map(({ value, index }) => <RemovedRow key={`removed-${index}`} id={`${blockId}/x${index}`} row={value} />)
|
||||
@@ -210,10 +246,16 @@ export function PropertyGrid({ blockId, rows, collapsed, onToggleFold, marks }:
|
||||
const folded = collapsed.has(id)
|
||||
const Chevron = folded ? ChevronRight : ChevronDown
|
||||
const mark = marks?.changed[index]
|
||||
const placed = slots?.blocks.filter(block => block.slot?.row === index) ?? []
|
||||
// 点子行才展开的子块只画展开着的;别的放进这一行的子块总是画
|
||||
const shownBlocks = placed.filter(block => block.slot?.item === undefined || !collapsed.has(block.id))
|
||||
const toggles = new Map(placed.flatMap((block): [number, ItemToggle][] => block.slot?.item === undefined
|
||||
? []
|
||||
: [[block.slot.item, { open: !collapsed.has(block.id), badge: block.badge, onToggle: () => slots?.onToggle(block) }]]))
|
||||
return (
|
||||
<Fragment key={index}>
|
||||
<div className="cr-prop-row grid grid-cols-subgrid" data-id={id} data-diff={mark?.added && 'add'}>
|
||||
{layout
|
||||
{layout || placed.length > 0
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -229,8 +271,9 @@ export function PropertyGrid({ blockId, rows, collapsed, onToggleFold, marks }:
|
||||
: <span className="cr-prop-label flex h-5 items-center">{row.label}</span>}
|
||||
{mark?.value ? <DiffText diff={mark.value} className="min-w-0" /> : <TextPreview text={row.value} className="min-w-0" />}
|
||||
</div>
|
||||
{!folded && layout === 'table' && <PropertyTable parentId={id} columns={row.columns ?? []} items={row.items ?? []} marks={mark?.items} />}
|
||||
{!folded && layout === 'table' && <PropertyTable parentId={id} columns={row.columns ?? []} items={row.items ?? []} marks={mark?.items} toggles={toggles} />}
|
||||
{!folded && layout === 'list' && <PropertyItems parentId={id} items={row.items ?? []} folding={{ collapsed, onToggleFold }} marks={mark?.items} />}
|
||||
{!folded && slots && shownBlocks.length > 0 && <div className="cr-slot">{slots.render(shownBlocks)}</div>}
|
||||
{removed(index)}
|
||||
</Fragment>
|
||||
)
|
||||
|
||||
@@ -473,6 +473,28 @@ body:is(.vscode-light, .vscode-high-contrast-light) .cr-group[data-colored] { --
|
||||
.cr-table-row {
|
||||
border-top: 1px solid var(--cr-table-border);
|
||||
}
|
||||
/* 讲某个子块的行(React 组件的事件处理表):第一格是按钮,悬停划下划线;展开时这一行淡淡垫上那一块的种类色 */
|
||||
.cr-table-row[data-open] {
|
||||
background: color-mix(in srgb, var(--kind) 10%, transparent);
|
||||
}
|
||||
.cr-item-toggle {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cr-item-toggle:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 放进卡片属性表某一行下面的子块(副作用、展开的处理函数):和表格一样往里缩一点 */
|
||||
.cr-slot {
|
||||
padding-left: 5px;
|
||||
}
|
||||
.cr-table .cr-table-cell {
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
@@ -115,6 +115,35 @@ describe('foldUnchanged', () => {
|
||||
})
|
||||
})
|
||||
|
||||
/** 一张卡片:两个副作用放在第 7 行下面,两个处理函数放在第 8 行的子行上,后面一个留下的子块;只有第二个处理函数改了 */
|
||||
const CARD_BLOCKS = [
|
||||
block('0', 'card', [
|
||||
block('0.0', 'e1', [], { slot: { row: 7 } }),
|
||||
block('0.1', 'e2', [], { slot: { row: 7 } }),
|
||||
block('0.2', 'h1', [], { slot: { row: 8, item: 0 } }),
|
||||
block('0.3', 'h2', [block('0.3.0', 'h2s', [], { slot: { row: 2 } })], { slot: { row: 8, item: 1 }, startLine: 4 }),
|
||||
block('0.4', 'rest'),
|
||||
]),
|
||||
]
|
||||
|
||||
function cardTree(): NonNullable<ReturnType<typeof diffTree>> {
|
||||
return diffTree(CARD_BLOCKS, diff({ blocks: { card: { change: 'mod' }, h2: { change: 'mod' }, h2s: { change: 'mod', own: true } } }), 2, () => true)!
|
||||
}
|
||||
|
||||
describe('放进卡片属性表里的子块', () => {
|
||||
it('只看改动:画在卡片里的按所在的行分开折,点子行才展开的不折、改了的照样往里看', () => {
|
||||
const { runs, folded } = foldUnchanged(CARD_BLOCKS, cardTree(), () => true, new Set(), new Set())
|
||||
expect([...runs]).toEqual([['0.0', 2], ['0.4', 1]])
|
||||
expect([...folded].sort()).toEqual(['0.0', '0.1', '0.4'])
|
||||
})
|
||||
|
||||
it('上一处 / 下一处:要展开的是子块所在的那一行(点子行才展开的连它自己),不是卡片', () => {
|
||||
expect(changeStops(CARD_BLOCKS, cardTree(), new Set()).map(stop => [stop.id, stop.ancestors])).toEqual([
|
||||
['0.3.0', ['0/p8', '0.3', '0.3/p2']],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('changeStops', () => {
|
||||
it('新增的最外面那块、自己改了的修改块、删掉的块,照页面上的先后;修改块只算自己那几行', () => {
|
||||
const stops = changeStops(TREE_BLOCKS, tree([removed('', 'c'), removed('a', undefined)]), new Set())
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { BlockView, ChangedBlock, DiffStats, DocumentDiff, RemovedView } from './types'
|
||||
import { inlineText } from './segments'
|
||||
import { unfoldToShow } from './viewState'
|
||||
|
||||
/** 页面上怎么标差异:按块 id 取改动,按层取删掉的块。基准里没有这个文件(整份新增)时不逐块标,没有它。 */
|
||||
export interface DiffTree {
|
||||
@@ -88,8 +89,8 @@ export function foldUnchanged(blocks: readonly BlockView[], tree: DiffTree, show
|
||||
block.children.forEach(fold)
|
||||
}
|
||||
const kept = (block: BlockView): boolean => keep.has(block.id) || block.children.some(kept)
|
||||
const visit = (list: readonly BlockView[], owner: string): void => {
|
||||
const breaks = new Set((tree.removed.get(owner) ?? []).map(item => item.after))
|
||||
/** 画在同一处的一串兄弟块:连着没变的折成一段 */
|
||||
function visitRun(list: readonly BlockView[], breaks: ReadonlySet<string | undefined>): void {
|
||||
let run: BlockView[] = []
|
||||
const close = (): void => {
|
||||
if (run.length > 0 && !opened.has(run[0].id)) {
|
||||
@@ -113,6 +114,22 @@ 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))
|
||||
const places = new Map<string, BlockView[]>()
|
||||
for (const block of list) {
|
||||
if (block.slot?.item !== undefined) {
|
||||
if (tree.changes.has(block.id) || kept(block))
|
||||
visit(block.children, block.id)
|
||||
continue
|
||||
}
|
||||
const place = block.slot ? `${block.slot.row}` : ''
|
||||
places.set(place, [...places.get(place) ?? [], block])
|
||||
}
|
||||
for (const group of places.values())
|
||||
visitRun(group, breaks)
|
||||
}
|
||||
visit(blocks, '')
|
||||
return { runs, folded }
|
||||
}
|
||||
@@ -120,7 +137,7 @@ export function foldUnchanged(blocks: readonly BlockView[], tree: DiffTree, show
|
||||
/**
|
||||
* 「上一处 / 下一处」和总览条上的一处改动:新增的最外面那块、自己改了的修改块、删掉的块,照页面上的先后。
|
||||
* id 是页面上的块 id(删掉的块是 PlacedRemoved.id);from / to 是它在新文件里的行(0 起),修改块只算它自己那几行(到第一个子块之前);
|
||||
* ancestors 是要展开才看得见它的各层块。
|
||||
* ancestors 是要展开才看得见它的各层块和属性行(放进父卡片里的子块要展开它所在的那一行,见 unfoldToShow)。
|
||||
*/
|
||||
export interface ChangeStop {
|
||||
id: string
|
||||
@@ -133,14 +150,16 @@ export interface ChangeStop {
|
||||
/** 页面上的各处改动(见 ChangeStop);hidden 里的块(被筛掉的)连同子孙不算。 */
|
||||
export function changeStops(blocks: readonly BlockView[], tree: DiffTree, hidden: ReadonlySet<string>): ChangeStop[] {
|
||||
const stops: ChangeStop[] = []
|
||||
const visit = (list: readonly BlockView[], owner: string, ancestors: string[]): void => {
|
||||
/** 一层块:owner 是父块 id(顶层 ''),ancestorsOf 给出要让某个子块看得见得展开的,removedAncestors 是这一层删掉的块要展开的 */
|
||||
const visit = (list: readonly BlockView[], owner: string, ancestorsOf: (block: BlockView) => string[], removedAncestors: string[]): void => {
|
||||
const removed = tree.removed.get(owner) ?? []
|
||||
const removedAfter = (id: string | undefined): void => {
|
||||
for (const item of removed.filter(entry => entry.after === id))
|
||||
stops.push({ id: item.id, change: 'del', from: item.removed.line, to: item.removed.line, ancestors })
|
||||
stops.push({ id: item.id, change: 'del', from: item.removed.line, to: item.removed.line, ancestors: removedAncestors })
|
||||
}
|
||||
removedAfter(undefined)
|
||||
for (const block of list) {
|
||||
const ancestors = ancestorsOf(block)
|
||||
const change = hidden.has(block.id) ? undefined : tree.changes.get(block.id)
|
||||
if (change?.change === 'add' && change.outermost)
|
||||
stops.push({ id: block.id, change: 'add', from: block.startLine, to: block.endLine, ancestors })
|
||||
@@ -149,11 +168,11 @@ export function changeStops(blocks: readonly BlockView[], tree: DiffTree, hidden
|
||||
stops.push({ id: block.id, change: 'mod', from: block.startLine, to: own, ancestors })
|
||||
}
|
||||
if (change?.change === 'mod')
|
||||
visit(block.children, block.id, [...ancestors, block.id])
|
||||
visit(block.children, block.id, child => [...ancestors, ...unfoldToShow(block.id, child)], [...ancestors, block.id])
|
||||
removedAfter(block.id)
|
||||
}
|
||||
}
|
||||
visit(blocks, '', [])
|
||||
visit(blocks, '', () => [], [])
|
||||
return stops
|
||||
}
|
||||
|
||||
|
||||
@@ -160,6 +160,16 @@ describe('跳到定义:滚到哪块、展开哪些', () => {
|
||||
expect(hidden({ '1': false, '1.0': true })).toEqual(['1.0'])
|
||||
})
|
||||
|
||||
it('放进卡片属性表里的子块:展开它所在的那一行,不展开卡片自己;点子行才展开的连它自己一起展开', () => {
|
||||
const statement = { ...textBlock('0.1.0', 'statement', 5, [name('scrollTo', 300)]), slot: { row: 2 } }
|
||||
const handler = { ...factBlock('0.1', 'function', 4, [{ label: '名称', value: [name('handleBack', 200)] }], [statement]), slot: { row: 3, item: 0 } }
|
||||
const effect = { ...textBlock('0.0', 'statement', 2, [name('useEffect', 100)]), slot: { row: 2 } }
|
||||
const card = factBlock('0', 'function', 1, [{ label: '名称', value: [name('Page', 10)] }], [effect, handler])
|
||||
expect(revealPlan([card], 0, 100)).toEqual({ id: '0.0', expand: [propertyRowId('0', 2)] })
|
||||
expect(revealPlan([card], 0, 200)).toEqual({ id: '0.1', expand: [propertyRowId('0', 3), '0.1'] })
|
||||
expect(revealPlan([card], 0, 300)).toEqual({ id: '0.1.0', expand: [propertyRowId('0', 3), '0.1', propertyRowId('0.1', 2)] })
|
||||
})
|
||||
|
||||
it('按行找:同一行有几块取外层那块;行在所有块之前取第一块;没有块时没有目标', () => {
|
||||
const sameLine = [textBlock('0', 'if', 1, [word('如果')], [textBlock('0.0', 'return', 1, [word('返回')])])]
|
||||
expect(revealPlan(sameLine, 1, 999)).toEqual({ id: '0', expand: [] })
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { BlockView, Occurrence, PreviewTable, PreviewText, PropertyItem, So
|
||||
import { blockPath } from './filter'
|
||||
import { showsOccurrence } from './occurrences'
|
||||
import { showsOffset } from './segments'
|
||||
import { propertyItemId, propertyRowId } from './viewState'
|
||||
import { propertyItemId, propertyRowId, unfoldToShow } from './viewState'
|
||||
|
||||
/** 跳到定义时要滚到的块,就是这处定义的属性行 / 子行(只闪它),以及要展开的块、属性行和子行:它们折叠着,目标就不在页面上。 */
|
||||
export interface RevealPlan {
|
||||
@@ -17,7 +17,7 @@ type Spot = Omit<RevealPlan, 'id'>
|
||||
|
||||
interface Placed {
|
||||
block: BlockView
|
||||
/** 从顶层到父块的块 id */
|
||||
/** 要让它看得见得展开的块、属性行(从顶层往下,见 unfoldToShow) */
|
||||
ancestors: string[]
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@ interface Placed {
|
||||
function* placed(blocks: BlockView[], ancestors: string[] = []): Generator<Placed> {
|
||||
for (const block of blocks) {
|
||||
yield { block, ancestors }
|
||||
yield* placed(block.children, [...ancestors, block.id])
|
||||
for (const child of block.children)
|
||||
yield* placed([child], [...ancestors, ...unfoldToShow(block.id, child)])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -124,9 +124,20 @@ interface BlockViewBase {
|
||||
attributes: Record<string, string>
|
||||
/** 库规则认领的块才有:种类的名字、颜色和图标,由规则给;和类块一样默认收起子块 */
|
||||
badge?: KindBadge
|
||||
/** 库规则放进父卡片里的子块才有:画在父卡片属性表第 row 行下面,不排在父卡片的子块里;item 见 BlockSlot */
|
||||
slot?: BlockSlot
|
||||
children: BlockView[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 子块画在父卡片的哪儿:属性表第 row 行下面(折叠这一行时跟着收起);item 是讲它的那个子行,
|
||||
* 这时它默认收着(它的块 id 记成折叠着),点那个子行的第一格才展开、一次只展开一个(React 组件的事件处理表点名称)
|
||||
*/
|
||||
export interface BlockSlot {
|
||||
row: number
|
||||
item?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 函数、类、枚举、导入、常量、变量块和库规则的卡片预览时是属性表,注释块是 Markdown 说明,其余是一行译文;
|
||||
* 一行译文下面依次是:跨几行写的调用摊开各个实参的表(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, propertyItemId, propertyRowId } from './viewState'
|
||||
import { collapsedIds, createViewStateStore, propertyItemId, propertyRowId, unfoldToShow } from './viewState'
|
||||
|
||||
/** 模拟 VS Code 的 webview 状态:存进去的要能序列化,页面重新加载后原样取回;也能塞进旧版本存的东西 */
|
||||
function memoryStorage(initial?: unknown): ViewStateStorage {
|
||||
@@ -119,6 +119,18 @@ describe('眼下折叠着哪些', () => {
|
||||
expect([...collapsedIds([block('0', 'exception', [block('0.0', 'function')]), block('1', 'if')], {})]).toEqual(['0', '0.0'])
|
||||
})
|
||||
|
||||
it('放进父卡片里的子块:点子行才展开的默认收着(整张不画),别的照种类;要让它看得见得展开它所在的那一行(点子行才展开的还有它自己)', () => {
|
||||
const badge = { label: '副作用', color: { dark: '#e5c07b', light: '#9a6a00' }, icon: 'zap' as const }
|
||||
const effect: BlockView = { ...block('0.0', 'statement', [{ ...block('0.0.0', 'variable'), slot: { row: 1 } }]), badge, kind: 'react.effect', slot: { row: 7 } }
|
||||
const handler: BlockView = { ...block('0.1', 'function'), badge, kind: 'react.handler', slot: { row: 8, item: 0 } }
|
||||
const card: BlockView = { ...block('0', 'if', [effect, handler]), badge, kind: 'react.component' }
|
||||
expect([...collapsedIds([card], {})].sort()).toEqual(['0', '0.0', '0.1'])
|
||||
expect([...collapsedIds([card], { 0.1: false })].sort()).toEqual(['0', '0.0'])
|
||||
expect(unfoldToShow('0', effect)).toEqual(['0/p7'])
|
||||
expect(unfoldToShow('0', handler)).toEqual(['0/p8', '0.1'])
|
||||
expect(unfoldToShow('0', block('0.2', 'comment'))).toEqual(['0'])
|
||||
})
|
||||
|
||||
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'])
|
||||
|
||||
@@ -54,9 +54,12 @@ function readViewState(saved: unknown): ViewState {
|
||||
return isScrollAnchor(state.scroll) ? { editing, folds, scroll: state.scroll } : { editing, folds }
|
||||
}
|
||||
|
||||
/** 块默认折叠吗:函数、类、枚举、异常块和库规则的卡片收起子块(属性表照常显示),其余块展开。属性行和带下一层的子行默认都展开。 */
|
||||
export function foldedByDefault(block: Pick<BlockView, 'kind' | 'badge'>): boolean {
|
||||
return block.badge !== undefined || ['function', 'class', 'enum', 'exception'].includes(block.kind)
|
||||
/**
|
||||
* 块默认折叠吗:函数、类、枚举、异常块和库规则的卡片收起子块(属性表照常显示),其余块展开。属性行和带下一层的子行默认都展开。
|
||||
* 父卡片里点子行才展开的子块(BlockSlot.item)默认收着:它折叠着就是整张不画。
|
||||
*/
|
||||
export function foldedByDefault(block: Pick<BlockView, 'kind' | 'badge' | 'slot'>): boolean {
|
||||
return block.slot?.item !== undefined || block.badge !== undefined || ['function', 'class', 'enum', 'exception'].includes(block.kind)
|
||||
}
|
||||
|
||||
/** 眼下折叠着的块、属性行和子行:用户点过的按他点的,没点过的按 foldedByDefault;opened 里的块(标了改动的)默认展开。 */
|
||||
@@ -98,3 +101,13 @@ export function propertyItemId(parentId: string, itemIndex: number): string {
|
||||
export function tableId(ownerId: string): string {
|
||||
return `${ownerId}/t`
|
||||
}
|
||||
|
||||
/**
|
||||
* 父块看得见时,要让它的一个子块也看得见得展开的(块 id、属性行 id):一般的子块是父块自己(收起子块时不画);
|
||||
* 放进父卡片属性表里的子块(BlockSlot)是它所在的那一行,点子行才展开的还有它自己。
|
||||
*/
|
||||
export function unfoldToShow(parentId: string, child: Pick<BlockView, 'id' | 'slot'>): string[] {
|
||||
if (!child.slot)
|
||||
return [parentId]
|
||||
return [propertyRowId(parentId, child.slot.row), ...(child.slot.item !== undefined ? [child.id] : [])]
|
||||
}
|
||||
Reference in new issue
Block a user