✨ feat(vscode): 代码阅读的 React 组件卡片:原子图标,名称后面跟具名导出 / 默认导出 / 客户端组件 / 记忆化 / 转发 ref 小标签,属性表按属性一行(类型取字段、默认值、必填、说明,来自 XxxProps 可点,导入的类型经工作区索引查),"use client" 和组件上的 JSDoc 不再单独成块
This commit is contained in:
1 parent
1a32a51e75
commit
15f3a6600d
14 files changed
+666
-34
No files matched your search
@@ -310,18 +310,21 @@ export function toBlockViews(
|
||||
}
|
||||
/**
|
||||
* 块先问启用的库规则,没认领就照它自己的样子。认领了的:规则讲的是整条语句,头部就是整个范围;
|
||||
* 规则没并进来的子块(方法、别的成员)不管写在哪,都照源码顺序留作它的子块。
|
||||
* 规则没并进来的子块(方法、别的成员)不管写在哪,都照源码顺序留作它的子块。规则略去的块(讲在了别的卡片里)不出块。
|
||||
*/
|
||||
function build(block: CodeBlock, span: SourceSpan, id: string, scope: string): BlockView {
|
||||
function build(block: CodeBlock, span: SourceSpan, id: string, scope: string): BlockView | undefined {
|
||||
const claim = active.length > 0 ? claimBlock(block, active, context, layerRules.report) : undefined
|
||||
if (!claim)
|
||||
return plain(block, span, id, scope)
|
||||
const kept = block.children.filter(child => !claim.block.absorbs.includes(child))
|
||||
if ('as' in claim.block) {
|
||||
const replacement = claim.block
|
||||
if ('omit' in replacement)
|
||||
return undefined
|
||||
const kept = block.children.filter(child => !replacement.absorbs.includes(child))
|
||||
if ('as' in replacement) {
|
||||
const { attributes } = block
|
||||
return plain({ ...claim.block.as, node: block.node, header: span, children: kept, ...(attributes && { attributes }) }, span, id, scope)
|
||||
return plain({ ...replacement.as, node: block.node, header: span, children: kept, ...(attributes && { attributes }) }, span, id, scope)
|
||||
}
|
||||
const { kind, badge, rows, defines, attributes } = claim.block
|
||||
const { kind, badge, rows, defines, attributes } = replacement
|
||||
const { anchor, inner } = anchors.place(scope, kind, defines && source.slice(defines.startIndex, defines.endIndex), rowsText(span))
|
||||
return {
|
||||
id,
|
||||
@@ -336,9 +339,16 @@ export function toBlockViews(
|
||||
properties: rows,
|
||||
}
|
||||
}
|
||||
/** 一串兄弟块转成视图数据;略去的块不占位置,id 按出了块的往下数。 */
|
||||
function list(siblings: readonly CodeBlock[], prefix: string, scope: string, from = 0): BlockView[] {
|
||||
const spans = blockSpans(siblings, source)
|
||||
return siblings.map((block, i) => build(block, spans[i], `${prefix}${from + i}`, scope))
|
||||
const views: BlockView[] = []
|
||||
siblings.forEach((block, i) => {
|
||||
const view = build(block, spans[i], `${prefix}${from + views.length}`, scope)
|
||||
if (view)
|
||||
views.push(view)
|
||||
})
|
||||
return views
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
@@ -545,18 +545,30 @@ export type TokenRole
|
||||
*/
|
||||
export interface Segment { text: string, role: TokenRole, offset?: number, reference?: RuleReference, color?: ThemeColor, refers?: SourceRange }
|
||||
|
||||
/** 框的样式,决定框头的图标和框的颜色:'iteration' 是写在表达式里的遍历,图标、颜色同「循环」块。 */
|
||||
export type GroupStyle = 'iteration'
|
||||
/**
|
||||
* 框的样式,决定框头的图标和框的颜色:
|
||||
* - iteration:写在表达式里的遍历(渲染大纲里 `.map` 的「对 … 的每一项」也是),图标、颜色同「循环」块;
|
||||
* - condition:按条件(渲染大纲里 `cond &&` 的「当 … 时」、三元的「如果 …」),图标、颜色同「条件」块;
|
||||
* - branch:条件的一支(「是」「否」「否则」),颜色同「条件」块,不带图标;
|
||||
* - skip:这个条件下不渲染(提前 `return null`),图标、颜色同「返回」块;
|
||||
* - tag:小标签(名称后面的「具名导出」、属性名后面的「回调」),只有框头的字,颜色是 SegmentGroup.color;
|
||||
* - classes:收起来的一串类名(「样式 N 个类」),中性色,点开、悬停看 SegmentGroup.detail。
|
||||
*/
|
||||
export type GroupStyle = 'iteration' | 'condition' | 'branch' | 'skip' | 'tag' | 'classes'
|
||||
|
||||
/**
|
||||
* 框起来的一组词:译文里自成一体的一段(写在表达式里的遍历),预览把它画成嵌在前后文字里的一个小框,框头是图标和 label。
|
||||
* 框里的词照样带源码下标,还能再套框。
|
||||
* 框起来的一组词:译文里自成一体的一段(写在表达式里的遍历、渲染大纲一行前面的条件),预览把它画成嵌在前后文字里的一个小框,
|
||||
* 框头是图标和 label。框里的词照样带源码下标,还能再套框。
|
||||
*/
|
||||
export interface SegmentGroup {
|
||||
style: GroupStyle
|
||||
/** 框头的文字(遍历的结果:生成器 / 列表 …),翻译插件给。 */
|
||||
/** 框头的文字(遍历的结果:生成器 / 列表 …,小标签的字),翻译插件、库规则给;只有图标、没有字时是空的。 */
|
||||
label: string
|
||||
segments: Inline[]
|
||||
/** 这个框自己的颜色(暗色、亮色主题各一个),盖过样式的颜色:tag 按标签的意思上色。 */
|
||||
color?: ThemeColor
|
||||
/** 点开、悬停时看的全文,一行一条:classes 的全部类名(按条件切换的写成「条件 时:类」)。 */
|
||||
detail?: string
|
||||
}
|
||||
|
||||
/** 译文里的一段:着色的词,或框起来的一组词。 */
|
||||
@@ -722,7 +734,7 @@ export interface ThemeColor { dark: string, light: string }
|
||||
/**
|
||||
* 种类图标:插件自己定义的一组 lucide 图标(https://lucide.dev/icons 上的 kebab-case 名字)。前 26 个给内置种类(几种共用一个:
|
||||
* 命令、管道、执行共用 square-terminal,解释器、解析指令共用 terminal,选项、设置共用 sliders-horizontal,字段、标签共用 tag),
|
||||
* 后面的给库规则的种类;库规则的种类(KindBadge.icon)只能从这组里挑,要新图标就加进这组(webview 的 kindIcons.tsx 同时给它配上图标),
|
||||
* 后面的给库规则的种类(React 组件的原子、副作用的闪电、事件处理的点击手势也在里面);库规则的种类(KindBadge.icon)只能从这组里挑,要新图标就加进这组(webview 的 kindIcons.tsx 同时给它配上图标),
|
||||
* 没有按任意名字找图标的路。
|
||||
*/
|
||||
export const KIND_ICON_NAMES = [
|
||||
@@ -758,6 +770,9 @@ export const KIND_ICON_NAMES = [
|
||||
'calendar-clock',
|
||||
'globe',
|
||||
'network',
|
||||
'atom',
|
||||
'zap',
|
||||
'mouse-pointer-click',
|
||||
] as const
|
||||
|
||||
export type KindIconName = (typeof KIND_ICON_NAMES)[number]
|
||||
@@ -767,9 +782,16 @@ export interface KindBadge { label: string, color: ThemeColor, icon: KindIconNam
|
||||
|
||||
/**
|
||||
* 库规则认领一块后给出的替身,预览里它代替原来那块,下面挂着原块留下的子块:
|
||||
* 规则自己的卡片(RuleCard),或认成契约里现成的一种属性表块(RuleFacts)。
|
||||
* 规则自己的卡片(RuleCard),或认成契约里现成的一种属性表块(RuleFacts);
|
||||
* 这块讲在了别的卡片里时是略去它(RuleOmit)。
|
||||
*/
|
||||
export type RuleBlock = RuleCard | RuleFacts
|
||||
export type RuleBlock = RuleCard | RuleFacts | RuleOmit
|
||||
|
||||
/**
|
||||
* 这块讲在了别的卡片里,不再单独成块:React 文件开头的 "use client" 讲成了组件卡片上的「客户端组件」标签,
|
||||
* 组件上面的 JSDoc 讲成了卡片的「说明」。预览不画它;它不能有子块(有子块时宿主当规则没认)。
|
||||
*/
|
||||
export interface RuleOmit { omit: true }
|
||||
|
||||
/** 规则自己的卡片:一张属性表,种类的图标、颜色、名字和每一行都由规则给(用词归规则)。 */
|
||||
export interface RuleCard {
|
||||
@@ -884,7 +906,8 @@ export interface LibraryRule {
|
||||
languageIds: string[]
|
||||
/**
|
||||
* 关心的库(模块名):这份源码里有一条导入(ImportFacts.sources,去掉引号)等于它、或以「它.」「它/」开头(它的子模块)才启用。
|
||||
* 宿主按语言插件给的导入判断,规则不用自己读导入语句。
|
||||
* 宿主按语言插件给的导入判断,规则不用自己读导入语句。空数组是不看导入、languageIds 里这几种语言的源码都启用
|
||||
* (React 组件:新的 JSX 写法不用导入 react,TSX / JSX 文件里就是 React 组件)。
|
||||
*/
|
||||
libraries: string[]
|
||||
/**
|
||||
|
||||
@@ -114,9 +114,11 @@ export function removedView(view: BlockView): BlockView {
|
||||
}
|
||||
}
|
||||
|
||||
/** 一个词比较用的样子:词性和字(框是样式、框头和框里的词);源码下标不算,两个版本里同样的词下标不一样。 */
|
||||
/** 一个词比较用的样子:词性和字(框是样式、框头、点开看的全文和框里的词);源码下标不算,两个版本里同样的词下标不一样。 */
|
||||
function pieceKey(piece: Inline): string {
|
||||
return 'role' in piece ? `${piece.role}\u0000${piece.text}` : `${piece.style}\u0000${piece.label}\u0000[${piece.segments.map(pieceKey).join('\u0001')}]`
|
||||
if ('role' in piece)
|
||||
return `${piece.role}\u0000${piece.text}`
|
||||
return `${piece.style}\u0000${piece.label}\u0000${piece.detail ?? ''}\u0000[${piece.segments.map(pieceKey).join('\u0001')}]`
|
||||
}
|
||||
|
||||
/** 着色的词拼成的字(框只算框里的词)。 */
|
||||
|
||||
@@ -29,6 +29,7 @@ import { celeryRule } from './rules/celery'
|
||||
import { dataclassRule } from './rules/dataclass'
|
||||
import { enumRule } from './rules/enum'
|
||||
import { fastapiRule } from './rules/fastapi'
|
||||
import { reactRule } from './rules/react'
|
||||
import { sqlalchemyRule } from './rules/sqlalchemy'
|
||||
import { bundledWasmDir, TreeSitterRuntime } from './runtime'
|
||||
import { bashZhTranslator } from './translators/bashZh'
|
||||
@@ -143,6 +144,7 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
|
||||
api.registerLibraryRule(enumRule),
|
||||
api.registerLibraryRule(dataclassRule),
|
||||
api.registerLibraryRule(fastapiRule),
|
||||
api.registerLibraryRule(reactRule),
|
||||
)
|
||||
return api
|
||||
}
|
||||
@@ -88,6 +88,11 @@ describe('库规则:启用', () => {
|
||||
expect(list.map(view => view.kind)).toEqual(['import', 'class'])
|
||||
expect(reports).toEqual([])
|
||||
})
|
||||
|
||||
it('libraries 是空的规则不看导入:这种语言的文件都启用', async () => {
|
||||
expect(await activates('import os', { ...cardRule('any'), libraries: [] })).toBe(true)
|
||||
expect(await activates('x = 1', { ...cardRule('any'), libraries: [] })).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('库规则:认领', () => {
|
||||
@@ -177,6 +182,17 @@ describe('库规则:认领', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('略去的块不出块,后面的块 id 接着往下数;还有子块的块不能略去,记下来、照常显示', async () => {
|
||||
const omitting = (kind: string): LibraryRule => ({ ...cardRule('omit'), claim: block => block.kind === kind ? { omit: true } : undefined })
|
||||
const list = await views(MODEL, [omitting('import')])
|
||||
expect(list.map(view => [view.id, view.kind])).toEqual([['0', 'class']])
|
||||
expect(list[0].children.map(child => child.id)).toEqual(['0.0', '0.1', '0.2', '0.3'])
|
||||
|
||||
const reports: string[] = []
|
||||
expect((await views(MODEL, [omitting('class')], reports)).map(view => view.kind)).toEqual(['import', 'class'])
|
||||
expect(reports).toEqual(['库规则 omit 给第 3 行的 class 块的替身不合约定,这块照常显示:要略去的块还有子块'])
|
||||
})
|
||||
|
||||
it('认成契约里现成的属性表块:种类、属性表、筛选属性照语言插件报的这种块,编辑范围是整个类;并进来的不是子块时照常显示', async () => {
|
||||
const asEnum = (absorbs: (block: CodeBlock) => CodeBlock[]): LibraryRule => ({
|
||||
...cardRule('lib'),
|
||||
|
||||
@@ -26,10 +26,10 @@ export function fromLibrary(module: string, library: string): boolean {
|
||||
return module === library || module.startsWith(`${library}.`) || module.startsWith(`${library}/`)
|
||||
}
|
||||
|
||||
/** 这份源码启用的规则,保持注册顺序:导入了规则关心的某个库。 */
|
||||
/** 这份源码启用的规则,保持注册顺序:导入了规则关心的某个库,或规则不看导入(libraries 是空的)。 */
|
||||
export function activeRules(rules: readonly LibraryRule[], imports: readonly ImportFacts[]): LibraryRule[] {
|
||||
const modules = imports.flatMap(facts => facts.sources.map(moduleName))
|
||||
return rules.filter(rule => rule.libraries.some(library => modules.some(module => fromLibrary(module, library))))
|
||||
return rules.filter(rule => rule.libraries.length === 0 || rule.libraries.some(library => modules.some(module => fromLibrary(module, library))))
|
||||
}
|
||||
|
||||
/** 一块被哪个规则认领、替身是什么。 */
|
||||
@@ -40,6 +40,8 @@ export interface Claim {
|
||||
|
||||
/** 替身不合 RuleBlock 的约定时说哪儿不对。 */
|
||||
function problemOf(rule: LibraryRule, block: CodeBlock, claimed: RuleBlock): string | undefined {
|
||||
if ('omit' in claimed)
|
||||
return block.children.length > 0 ? '要略去的块还有子块' : undefined
|
||||
if (claimed.absorbs.some(child => !block.children.includes(child)))
|
||||
return '要并进来的块不是这块的子块'
|
||||
if ('as' in claimed)
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { BlockView } from '../blockView'
|
||||
import type { PropertyRow, RuleContext } from '../contract'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { toBlockViews } from '../blockView'
|
||||
import { createTypeScriptAdapter } from '../languages/typescript'
|
||||
import { propertyLines, readFixture, WASM_DIR, withBlocks } from '../testSupport'
|
||||
import { typescriptZhTranslator } from '../translators/typescriptZh'
|
||||
import { reactRule } from './react'
|
||||
|
||||
const ts = createTypeScriptAdapter(WASM_DIR)
|
||||
|
||||
const TITLEBAR = readFixture('desktopTitlebar.tsx.txt')
|
||||
const LEGAL = readFixture('legalDocPage.tsx.txt')
|
||||
const POSTER = readFixture('posterDialog.tsx.txt')
|
||||
const SHARE = readFixture('shareButton.tsx.txt')
|
||||
|
||||
/** 规则查名字的定义时查到的值(名字原文 → IndexEntry.value),别的没查到。 */
|
||||
function known(values: Record<string, string>): RuleContext['lookup'] {
|
||||
return (_index, node) => values[node.text] === undefined ? undefined : { key: node.text, name: { startIndex: 0, endIndex: 0 }, value: values[node.text] }
|
||||
}
|
||||
|
||||
async function views(source: string, lookup?: RuleContext['lookup'], reports: string[] = []): Promise<BlockView[]> {
|
||||
return withBlocks(ts, 'typescriptreact', source, blocks =>
|
||||
toBlockViews(blocks, { source, language: ts, markers: [] }, typescriptZhTranslator, { rules: [reactRule], report: message => reports.push(message), lookup }))
|
||||
}
|
||||
|
||||
function flatten(list: readonly BlockView[]): BlockView[] {
|
||||
return list.flatMap(view => [view, ...flatten(view.children)])
|
||||
}
|
||||
|
||||
function properties(view: BlockView | undefined): PropertyRow[] {
|
||||
if (!view || !('properties' in view))
|
||||
throw new Error(`块 ${view?.id} 不是属性表`)
|
||||
return view.properties
|
||||
}
|
||||
|
||||
/** 组件名是 name 的组件卡片。 */
|
||||
async function card(source: string, name: string, lookup?: RuleContext['lookup']): Promise<BlockView> {
|
||||
const found = flatten(await views(source, lookup)).find(view => view.kind === 'react.component' && view.defines && source.slice(view.defines.startIndex, view.defines.endIndex) === name)
|
||||
if (!found)
|
||||
throw new Error(`没有 ${name} 的组件卡片`)
|
||||
return found
|
||||
}
|
||||
|
||||
/** 卡片里 label 那一行(连子行)写成的文字,见 propertyLines。 */
|
||||
function rowLines(view: BlockView, label: string): string[] {
|
||||
const row = properties(view).find(item => item.label === label)
|
||||
return row ? propertyLines([row]) : []
|
||||
}
|
||||
|
||||
describe('react 库规则:认领', () => {
|
||||
it('只管 TSX / JSX,不看导入(新的 JSX 写法不用导入 react)', () => {
|
||||
expect(reactRule.languageIds).toEqual(['typescriptreact', 'javascriptreact'])
|
||||
expect(reactRule.libraries).toEqual([])
|
||||
})
|
||||
|
||||
it('luffy 的 DesktopTitlebar:五条导入后面是组件卡片,"use client" 和文档注释不再单独成块', async () => {
|
||||
const blocks = await views(TITLEBAR)
|
||||
expect(blocks.map(view => `${view.kind} ${view.startLine + 1}`)).toEqual(['import 3', 'import 5', 'import 6', 'import 7', 'import 9', 'react.component 11'])
|
||||
const view = blocks[5]
|
||||
expect(view.badge).toEqual({ label: 'React 组件', color: { dark: '#61dafb', light: '#0a7ea4' }, icon: 'atom' })
|
||||
expect(view.attributes).toMatchObject({ kind: 'react.component', access: 'public' })
|
||||
expect(propertyLines(properties(view)).slice(0, 4)).toEqual([
|
||||
'名称: DesktopTitlebar ⟦具名导出|⟧ ⟦客户端组件|⟧',
|
||||
'说明: 窗口顶部 40px. 整条连同里面的文字都是拖拽区 (data-tauri-drag-region="deep"), 按钮除外. 侧栏开关只在侧栏顶部.',
|
||||
'属性: 1 个',
|
||||
' platform | DesktopShell["platform"] | — | 是',
|
||||
])
|
||||
})
|
||||
|
||||
it('文件里没有组件时 "use client" 照常成块;组件上面没并进块头部的 JSDoc 讲成说明、不单独成块', async () => {
|
||||
expect((await views('"use client";\nexport const useX = () => 1;')).map(view => view.kind)).toEqual(['statement', 'const'])
|
||||
const source = ['import { memo } from "react";', '/** 一行标签 */', 'export const Label = memo(({ text }: { text: string }) => <span>{text}</span>);'].join('\n')
|
||||
const blocks = await views(source)
|
||||
expect(blocks.map(view => view.kind)).toEqual(['import', 'react.component'])
|
||||
expect(propertyLines(properties(blocks[1])).slice(0, 2)).toEqual(['名称: Label ⟦具名导出|⟧ ⟦记忆化|⟧', '说明: 一行标签'])
|
||||
})
|
||||
|
||||
it('小写开头的工具函数、不渲染 JSX 的大写函数照常是函数块', async () => {
|
||||
const blocks = await views(['const slugify = (raw: string) => raw.trim();', 'function Upper() { return 1 }', 'function lower() { return <i /> }'].join('\n'))
|
||||
expect(blocks.map(view => view.kind)).toEqual(['const', 'function', 'function'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('react 库规则:属性', () => {
|
||||
it('luffy 的 LegalDocPage:来自 LegalDocPageProps(名字能跳到定义),字段带说明时多一列说明', async () => {
|
||||
const view = await card(LEGAL, 'LegalDocPage')
|
||||
const row = properties(view).find(item => item.label === '属性')
|
||||
expect(row?.columns).toEqual(['名称', '类型', '默认值', '必填', '说明'])
|
||||
expect(rowLines(view, '属性')).toEqual([
|
||||
'属性: 1 个 · 来自 LegalDocPageProps',
|
||||
' doc | "terms" | "privacy" | — | 是 | terms = 用户服务协议; privacy = 隐私政策',
|
||||
])
|
||||
const from = row && !('markdown' in row.value) ? row.value.at(-1) : undefined
|
||||
expect(from && 'role' in from && LEGAL.slice(from.offset, (from.offset ?? 0) + from.text.length)).toBe('LegalDocPageProps')
|
||||
expect(row?.items?.[0].defines && LEGAL.slice(row.items[0].defines.startIndex, row.items[0].defines.endIndex)).toBe('doc')
|
||||
})
|
||||
|
||||
it('luffy 的 ShareButton:回调标「回调」、类型写成函数签名;StepLabel 的 children 标「子元素」', async () => {
|
||||
expect(rowLines(await card(SHARE, 'ShareButton'), '属性')).toEqual([
|
||||
'属性: 5 个',
|
||||
' open | 布尔 | — | 是 | ',
|
||||
' onOpenChange ⟦回调|⟧ | (next: 布尔) => 无 | — | 是 | ',
|
||||
' deployUrl | 字符串 | 空 | — | 是 | 已发布的持久 URL; null = 尚未发布 (点击后由父组件驱动发布)。',
|
||||
' sessionId | 字符串 | — | 是 | ',
|
||||
' title | 字符串 | — | 是 | ',
|
||||
])
|
||||
expect(rowLines(await card(POSTER, 'StepLabel'), '属性')).toEqual([
|
||||
'属性: 2 个',
|
||||
' n | 数值 | — | 是',
|
||||
' children ⟦子元素|⟧ | ReactNode | — | 是',
|
||||
])
|
||||
})
|
||||
|
||||
it('默认值、`?` 不必填、改了名、其余属性;方法签名写成函数签名', async () => {
|
||||
const source = [
|
||||
'interface Props { size?: number; onPick(v: string): void; label: string }',
|
||||
'export const A = ({ size = 16, label: text, ...rest }: Props) => <i />;',
|
||||
].join('\n')
|
||||
expect(rowLines(await card(source, 'A'), '属性')).toEqual([
|
||||
'属性: 4 个 · 来自 Props',
|
||||
' size | 数值 | 16 | 否',
|
||||
' onPick ⟦回调|⟧ | (v: 字符串) => 无 | — | 是',
|
||||
' label(组件里叫 text) | 字符串 | — | 是',
|
||||
' ...rest | 其余的属性 | — | 否',
|
||||
])
|
||||
})
|
||||
|
||||
it('导入的属性类型:工作区索引查到了就展开字段,没查到时照解构的写、类型未标注;没写参数是无', async () => {
|
||||
const source = [
|
||||
'import type { CardProps } from "./types";',
|
||||
'export const Card = ({ title, onClose = noop }: CardProps) => <div />;',
|
||||
'export function Empty() { return <i /> }',
|
||||
].join('\n')
|
||||
expect(rowLines(await card(source, 'Card'), '属性')).toEqual([
|
||||
'属性: 2 个 · 来自 CardProps',
|
||||
' title | 未标注 | — | —',
|
||||
' onClose | 未标注 | noop | 否',
|
||||
])
|
||||
const fields = JSON.stringify([
|
||||
{ name: 'title', type: 'string', optional: false, callback: false, doc: '标题' },
|
||||
{ name: 'onClose', type: '() => void', optional: true, callback: true },
|
||||
{ name: 'size', type: 'number', optional: true, callback: false },
|
||||
])
|
||||
expect(rowLines(await card(source, 'Card', known({ CardProps: fields })), '属性')).toEqual([
|
||||
'属性: 3 个 · 来自 CardProps',
|
||||
' title | string | — | 是 | 标题',
|
||||
' onClose ⟦回调|⟧ | () => void | noop | 否 | ',
|
||||
' size | number | — | 否 | ',
|
||||
])
|
||||
expect(rowLines(await card(source, 'Empty'), '属性')).toEqual(['属性: 无'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,278 @@
|
||||
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'
|
||||
|
||||
/**
|
||||
* React 库规则:TSX / JSX 里的 React 函数组件(见 reactTree.ts 的 componentAt)整块换成「React 组件」卡片,不再是普通函数卡片的输入参数 / 输出参数。
|
||||
* 卡片:名称(后面跟导出方式、客户端组件、记忆化、转发 ref 这些小标签)/ 说明 / 属性(解构的每个属性一行,类型取属性类型里对应的字段;
|
||||
* 属性类型是接口、类型别名时写来自哪个类型,导入的类型经工作区索引查它的字段),再按钩子分区。
|
||||
* 文件开头的 "use client" 讲成了「客户端组件」、组件上面的 JSDoc 讲成了「说明」,都不再单独成块。
|
||||
* 新的 JSX 写法不用导入 react,规则不看导入:TSX / JSX 文件都启用。用词都在词表里,按翻译插件的显示语言挑。
|
||||
*/
|
||||
|
||||
const RULE_ID = 'react'
|
||||
const KIND = 'react.component'
|
||||
|
||||
/** 组件色:React 的青色,渲染大纲里的组件、「客户端组件」标签也用它;白底上照原色看不清,亮色主题取同色相深一些的。 */
|
||||
const COMPONENT_COLOR: ThemeColor = { dark: '#61dafb', light: '#0a7ea4' }
|
||||
/** 导出方式的标签:同 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>
|
||||
tags: Record<'named' | 'default' | 'client' | 'memo' | 'forwardRef', string>
|
||||
/** 属性名后面的标签:函数类型的属性、children */
|
||||
roles: Record<'callback' | 'children', string>
|
||||
/** 属性表的列:名称 | 类型 | 默认值 | 必填,有属性带说明时多一列说明 */
|
||||
propHeads: string[]
|
||||
propDoc: string
|
||||
yes: string
|
||||
no: string
|
||||
/** 表里没有的值 */
|
||||
nothing: string
|
||||
/** 没写类型 */
|
||||
unmarked: string
|
||||
count: (n: number) => string
|
||||
/** 属性来自哪个类型:「 · 来自 」 */
|
||||
from: string
|
||||
/** `...rest` 的类型:其余的属性 */
|
||||
rest: string
|
||||
/** 解构时改了名:「(组件里叫 x)」 */
|
||||
renamed: [string, string]
|
||||
}
|
||||
|
||||
const ZH: Words = {
|
||||
kinds: { component: 'React 组件' },
|
||||
labels: { name: '名称', doc: '说明', props: '属性' },
|
||||
tags: { named: '具名导出', default: '默认导出', client: '客户端组件', memo: '记忆化', forwardRef: '转发 ref' },
|
||||
roles: { callback: '回调', children: '子元素' },
|
||||
propHeads: ['名称', '类型', '默认值', '必填'],
|
||||
propDoc: '说明',
|
||||
yes: '是',
|
||||
no: '否',
|
||||
nothing: '—',
|
||||
unmarked: '未标注',
|
||||
count: n => n > 0 ? `${n} 个` : '无',
|
||||
from: ' · 来自 ',
|
||||
rest: '其余的属性',
|
||||
renamed: ['(组件里叫 ', ')'],
|
||||
}
|
||||
|
||||
const WORDS: Record<string, Words> = { zh: ZH }
|
||||
|
||||
interface Env {
|
||||
context: RuleContext
|
||||
words: Words
|
||||
}
|
||||
|
||||
/** 小标签(见 GroupStyle 的 tag)。 */
|
||||
function tag(label: string, color: ThemeColor): SegmentGroup {
|
||||
return { style: 'tag', label, segments: [], color }
|
||||
}
|
||||
|
||||
/** 规则自己的用词里当关键字着色的:是、否。 */
|
||||
function yesNo(value: boolean, words: Words): Inline[] {
|
||||
return [{ text: value ? words.yes : words.no, role: 'storage' }]
|
||||
}
|
||||
|
||||
// ─── 名称、说明 ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** 名称:组件名用组件色,后面跟导出方式、客户端组件、记忆化、转发 ref 这些小标签。 */
|
||||
function nameRow(component: ComponentFacts, { words }: Env): PropertyRow {
|
||||
const tags = [
|
||||
...(component.exported ? [tag(words.tags[component.exported], EXPORT_COLOR)] : []),
|
||||
...(component.client ? [tag(words.tags.client, COMPONENT_COLOR)] : []),
|
||||
...component.wrappers.map(wrapper => tag(words.tags[wrapper], WRAPPER_COLOR)),
|
||||
]
|
||||
return {
|
||||
label: words.labels.name,
|
||||
value: [{ ...name(component.name, 'function'), color: COMPONENT_COLOR }, ...tags.flatMap(item => [plain(' '), item])],
|
||||
}
|
||||
}
|
||||
|
||||
/** 说明:组件上面的 JSDoc。 */
|
||||
function docRows(component: ComponentFacts, { context, words }: Env): PropertyRow[] {
|
||||
const doc = component.doc && context.doc(component.doc)
|
||||
return doc ? [{ label: words.labels.doc, value: doc }] : []
|
||||
}
|
||||
|
||||
// ─── 属性 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 工作区里各文件顶层声明的接口、类型别名:名字 → 各字段的文字(JSON,见 FieldText),组件的属性类型是导入的时候查它。 */
|
||||
const PROP_TYPES_INDEX: WorkspaceIndex = {
|
||||
id: `${RULE_ID}.propTypes`,
|
||||
languageId: 'typescriptreact',
|
||||
include: '**/*.{ts,tsx}',
|
||||
mentions: source => /\b(?:interface|type)\s+[A-Z]/.test(source),
|
||||
entries: root => declaredTypes(root).map(({ name: typeName, fields }) => ({ key: typeName.text, name: range(typeName), value: JSON.stringify(fields) })),
|
||||
}
|
||||
|
||||
/** 属性表的一行讲的一个属性:这个文件里的字段、查索引得到的别的文件里的字段,和解构出来的那一项(都可能没有)。 */
|
||||
interface PropEntry {
|
||||
key: string
|
||||
field?: FieldFacts
|
||||
text?: FieldText
|
||||
binding?: BindingFacts
|
||||
}
|
||||
|
||||
/** 查索引得到的别的文件里的字段;值不是这个规则存的样子时当没查到。 */
|
||||
function importedFields(from: Node | undefined, { context }: Env): FieldText[] | undefined {
|
||||
const value = from && context.lookup(PROP_TYPES_INDEX, from)?.value
|
||||
if (!value)
|
||||
return undefined
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value)
|
||||
return Array.isArray(parsed) ? parsed as FieldText[] : undefined
|
||||
}
|
||||
catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 属性表的各行:属性类型的字段照类型里的顺序(解构出来的那项给默认值、定义处),类型里没有的解构项接在后面,`...rest` 最后;
|
||||
* 类型查不到字段、又没解构时是参数本身一行。
|
||||
*/
|
||||
function propEntries(props: PropsFacts, env: Env): PropEntry[] {
|
||||
const texts = props.fields ? undefined : importedFields(props.from, env)
|
||||
const keys = props.fields?.map(field => field.name.text) ?? texts?.map(text => text.name)
|
||||
const bindingOf = (key: string): BindingFacts | undefined => props.bindings.find(binding => !binding.rest && binding.key === key)
|
||||
const declared: PropEntry[] = props.fields
|
||||
? props.fields.map(field => ({ key: field.name.text, field, ...withBinding(bindingOf(field.name.text)) }))
|
||||
: (texts ?? []).map(text => ({ key: text.name, text, ...withBinding(bindingOf(text.name)) }))
|
||||
const extra = props.bindings.filter(binding => binding.rest || !keys?.includes(binding.key)).map(binding => ({ key: binding.key, binding }))
|
||||
const entries = [...declared, ...extra.filter(entry => !entry.binding.rest), ...extra.filter(entry => entry.binding.rest)]
|
||||
if (entries.length === 0 && props.parameter && !keys)
|
||||
return [{ key: props.parameter.text }]
|
||||
return entries
|
||||
}
|
||||
|
||||
function withBinding(binding: BindingFacts | undefined): { binding?: BindingFacts } {
|
||||
return binding ? { binding } : {}
|
||||
}
|
||||
|
||||
/** 名称一格:属性名(定义处能跳),children、回调后面跟小标签,解构时改了名写出组件里叫什么。 */
|
||||
function propName(entry: PropEntry, env: Env): Inline[] {
|
||||
const { words } = env
|
||||
const { binding, field, text } = entry
|
||||
const renamed = binding && !binding.rest && binding.name.text !== entry.key
|
||||
const head: Inline[] = field
|
||||
? [name(field.name, 'variable')]
|
||||
: binding && !renamed ? [...(binding.rest ? [plain('...')] : []), name(binding.name, 'variable')] : [{ text: entry.key, role: 'variable' }]
|
||||
const role = entry.key === 'children' ? words.roles.children : (field?.callback ?? text?.callback) ? words.roles.callback : undefined
|
||||
return [
|
||||
...head,
|
||||
...(role ? [plain(' '), tag(role, COMPONENT_COLOR)] : []),
|
||||
...(renamed ? [hint(words.renamed[0]), name(binding.name, 'variable'), hint(words.renamed[1])] : []),
|
||||
]
|
||||
}
|
||||
|
||||
/** 类型一格:字段的类型照常翻,方法签名写成函数签名;别的文件里的字段照写文字;`...rest` 是其余的属性。 */
|
||||
function propType(entry: PropEntry, props: PropsFacts, { context, words }: Env): Inline[] {
|
||||
const { field, text, binding } = entry
|
||||
if (field?.type?.type === 'formal_parameters')
|
||||
return [...context.translate(field.type), plain(' => '), ...(field.returns ? context.translate(field.returns) : [hint(words.unmarked)])]
|
||||
if (field?.type)
|
||||
return context.translate(field.type)
|
||||
if (text?.type)
|
||||
return [{ text: text.type, role: 'type' }]
|
||||
if (binding?.rest)
|
||||
return [hint(words.rest)]
|
||||
if (!binding && props.type)
|
||||
return context.translate(props.type)
|
||||
return [hint(words.unmarked)]
|
||||
}
|
||||
|
||||
/** 必填一格:类型里写了 `?` 的不必填;没有类型可看时解构写了默认值的不必填,否则不知道。 */
|
||||
function propRequired(entry: PropEntry, words: Words): Inline[] {
|
||||
const optional = entry.field?.optional ?? entry.text?.optional
|
||||
if (optional !== undefined)
|
||||
return yesNo(!optional, words)
|
||||
if (entry.binding?.default || entry.binding?.rest)
|
||||
return yesNo(false, words)
|
||||
return [hint(words.nothing)]
|
||||
}
|
||||
|
||||
/** 说明一格:字段上面的 JSDoc / 注释。 */
|
||||
function propDoc(entry: PropEntry): Inline[] {
|
||||
const text = entry.field ? commentText(entry.field.docs) : entry.text?.doc
|
||||
return text ? [{ text: text.replace(/\s*\n\s*/g, ' '), role: 'comment' }] : []
|
||||
}
|
||||
|
||||
/** 属性:解构的每个属性一行(名称 / 类型 / 默认值 / 必填,有属性带说明时多一列说明);属性类型是一个名字时「属性」后面写来自哪里。 */
|
||||
function propsRow(component: ComponentFacts, env: Env): PropertyRow {
|
||||
const { context, words } = env
|
||||
const props = propsOf(component)
|
||||
const entries = propEntries(props, env)
|
||||
const docs = entries.map(propDoc)
|
||||
const described = docs.some(doc => doc.length > 0)
|
||||
const items = entries.map((entry, index): PropertyItem => {
|
||||
const defines = entry.binding?.name ?? entry.field?.name
|
||||
return {
|
||||
cells: [
|
||||
propName(entry, env),
|
||||
propType(entry, props, env),
|
||||
entry.binding?.default ? context.translate(entry.binding.default) : [hint(words.nothing)],
|
||||
propRequired(entry, words),
|
||||
...(described ? [docs[index]] : []),
|
||||
],
|
||||
...(defines && { defines: range(defines) }),
|
||||
}
|
||||
})
|
||||
return {
|
||||
label: words.labels.props,
|
||||
value: [hint(words.count(items.length)), ...(props.from ? [hint(words.from), name(props.from, 'type')] : [])],
|
||||
...(items.length > 0 && { items, columns: described ? [...words.propHeads, words.propDoc] : words.propHeads }),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 认领 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 组件卡片。 */
|
||||
function componentCard(component: ComponentFacts, env: Env): RuleCard {
|
||||
return {
|
||||
kind: KIND,
|
||||
badge: badge(env.words),
|
||||
rows: [nameRow(component, env), ...docRows(component, env), propsRow(component, env)],
|
||||
defines: range(component.name),
|
||||
absorbs: [],
|
||||
}
|
||||
}
|
||||
|
||||
function badge(words: Words): KindBadge {
|
||||
return { label: words.kinds.component, color: COMPONENT_COLOR, icon: 'atom' }
|
||||
}
|
||||
|
||||
/**
|
||||
* 讲在了组件卡片里、不再单独成块的:文件开头的 "use client"(文件里有组件时,讲成「客户端组件」标签),
|
||||
* 紧贴在组件前面、没并进组件块头部的 JSDoc(值是 memo(…)、直接返回 JSX 的箭头函数这类常量块,讲成「说明」)。
|
||||
*/
|
||||
function omitted(block: CodeBlock): boolean {
|
||||
const root = block.node.tree.rootNode
|
||||
if (block.kind === 'statement')
|
||||
return sameNode(clientDirective(root), block.node) && componentsIn(root).length > 0
|
||||
if (block.kind === 'comment') {
|
||||
const next = block.node.nextSibling
|
||||
const component = next ? componentAt(next) : undefined
|
||||
return !!component?.doc && sameNode(component.doc, block.node)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export const reactRule: LibraryRule = {
|
||||
id: RULE_ID,
|
||||
label: 'React 组件',
|
||||
languageIds: ['typescriptreact', 'javascriptreact'],
|
||||
libraries: [],
|
||||
claim: (block, context) => {
|
||||
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
|
||||
},
|
||||
}
|
||||
@@ -182,7 +182,12 @@ export function directives(root: Node): Node[] {
|
||||
|
||||
/** 文件开头写了 "use client":这个文件里的组件在浏览器里渲染(Next.js 的客户端组件)。 */
|
||||
export function isClientFile(root: Node): boolean {
|
||||
return directives(root).some(statement => stringContent(syntax(statement)[0]) === 'use client')
|
||||
return !!clientDirective(root)
|
||||
}
|
||||
|
||||
/** 文件开头那句 "use client";没写时没有。 */
|
||||
export function clientDirective(root: Node): Node | undefined {
|
||||
return directives(root).find(statement => stringContent(syntax(statement)[0]) === 'use client')
|
||||
}
|
||||
|
||||
/** 名字怎么导出的:声明前面写了 export(带 default 是默认导出),或文件里别处 `export default 名字` / `export { 名字 }`。 */
|
||||
|
||||
@@ -31,3 +31,19 @@ describe('着色的词', () => {
|
||||
expect(html).toBe('<span class="cr-role-string" data-offset="20">/users/</span><span class="cr-role-variable" data-offset="90">user_id</span>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('框起来的一组词', () => {
|
||||
it('小标签只有框头的字,颜色写在 style 上;按条件的前缀只有图标、框里的词照常上色', () => {
|
||||
const tag = renderToStaticMarkup(<SegmentText segments={[{ style: 'tag', label: '具名导出', segments: [], color: { dark: '#c586c0', light: '#af00db' } }]} />)
|
||||
expect(tag).toBe('<span class="cr-group" data-style="tag" data-colored="" style="--group-dark:#c586c0;--group-light:#af00db"><span class="cr-group-head">具名导出</span></span>')
|
||||
const condition = renderToStaticMarkup(<SegmentText segments={[{ style: 'condition', label: '', segments: [{ text: '当', role: 'keyword' }] }]} />)
|
||||
expect(condition).toMatch(/^<span class="cr-group" data-style="condition"><span class="cr-group-head" data-bare=""><svg[^>]*>.*<\/svg><\/span><span class="cr-role-keyword">当<\/span><\/span>$/)
|
||||
const branch = renderToStaticMarkup(<SegmentText segments={[{ style: 'branch', label: '', segments: [{ text: '是', role: 'keyword' }] }]} />)
|
||||
expect(branch).toBe('<span class="cr-group" data-style="branch"><span class="cr-role-keyword">是</span></span>')
|
||||
})
|
||||
|
||||
it('收起来的类名是个按钮:悬停提示是全部类名,收着时不弹层', () => {
|
||||
const html = renderToStaticMarkup(<SegmentText segments={[{ style: 'classes', label: '样式 2 个类', segments: [], detail: 'flex gap-2' }]} />)
|
||||
expect(html).toBe('<span class="cr-classes"><button type="button" class="cr-group" data-style="classes" aria-expanded="false" data-tooltip="flex gap-2"><span class="cr-group-head">样式 2 个类</span></button></span>')
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import type { CSSProperties, ReactElement } from 'react'
|
||||
import type { GroupStyle, Inline, SegmentGroup } from './types'
|
||||
import { useContext } from 'react'
|
||||
import { useContext, useState } from 'react'
|
||||
import { DisplayNameContext, shownName } from './displayNames'
|
||||
import { KIND_ICON_COMPONENTS, KIND_ICONS } from './kindIcons'
|
||||
import { isCurrent, segmentOccurrence, sourceOf } from './occurrences'
|
||||
@@ -42,19 +42,62 @@ export function SegmentText({ segments }: { segments: Inline[] }): ReactElement
|
||||
)
|
||||
}
|
||||
|
||||
/** 框的样式 → 框头图标:遍历同「循环」块的图标;颜色在 codeReader.css 的 .cr-group[data-style] 里。 */
|
||||
const GROUP_ICONS: Record<GroupStyle, LucideIcon> = { iteration: KIND_ICON_COMPONENTS[KIND_ICONS.loop] }
|
||||
/**
|
||||
* 框的样式 → 框头图标:遍历同「循环」块、按条件同「条件」块、不渲染同「返回」块的图标;条件的一支、小标签、类名不带图标。
|
||||
* 颜色在 codeReader.css 的 .cr-group[data-style] 里,框自己给了颜色(color)时用它。
|
||||
*/
|
||||
const GROUP_ICONS: Record<GroupStyle, LucideIcon | undefined> = {
|
||||
iteration: KIND_ICON_COMPONENTS[KIND_ICONS.loop],
|
||||
condition: KIND_ICON_COMPONENTS[KIND_ICONS.if],
|
||||
branch: undefined,
|
||||
skip: KIND_ICON_COMPONENTS[KIND_ICONS.return],
|
||||
tag: undefined,
|
||||
classes: undefined,
|
||||
}
|
||||
|
||||
/** 框起来的一组词:嵌在前后文字里的小框,放不下时整个挪到下一行,比一行还长时在框里换行。 */
|
||||
/** 框自己的颜色,暗色、亮色两个都写在 style 上,CSS 按主题挑一个(见 .cr-group[data-colored])。 */
|
||||
function groupColor(group: SegmentGroup): CSSProperties | undefined {
|
||||
return group.color && { '--group-dark': group.color.dark, '--group-light': group.color.light } as CSSProperties
|
||||
}
|
||||
|
||||
/**
|
||||
* 框起来的一组词:嵌在前后文字里的小框,放不下时整个挪到下一行,比一行还长时在框里换行。框头是图标和 label,只有图标、只有字、都没有都行;
|
||||
* 收起来的类名另见 ClassesChip。
|
||||
*/
|
||||
function GroupBox({ group }: { group: SegmentGroup }): ReactElement {
|
||||
if (group.style === 'classes')
|
||||
return <ClassesChip group={group} />
|
||||
const Icon = GROUP_ICONS[group.style]
|
||||
return (
|
||||
<span className="cr-group" data-style={group.style}>
|
||||
<span className="cr-group-head">
|
||||
<Icon size={12} strokeWidth={2} aria-hidden />
|
||||
{group.label}
|
||||
</span>
|
||||
<span className="cr-group" data-style={group.style} data-colored={group.color ? '' : undefined} style={groupColor(group)}>
|
||||
{(Icon || group.label) && (
|
||||
<span className="cr-group-head" data-bare={group.label ? undefined : ''}>
|
||||
{Icon && <Icon size={12} strokeWidth={2} aria-hidden />}
|
||||
{group.label}
|
||||
</span>
|
||||
)}
|
||||
<SegmentText segments={group.segments} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** 收起来的一串类名(「样式 N 个类」):悬停看全部类名,点一下在它下面弹出、盖住后面的字,再点收起。 */
|
||||
function ClassesChip({ group }: { group: SegmentGroup }): ReactElement {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<span className="cr-classes">
|
||||
<button
|
||||
type="button"
|
||||
className="cr-group"
|
||||
data-style="classes"
|
||||
data-open={open ? '' : undefined}
|
||||
aria-expanded={open}
|
||||
data-tooltip={open ? undefined : group.detail}
|
||||
onClick={() => setOpen(value => !value)}
|
||||
>
|
||||
<span className="cr-group-head">{group.label}</span>
|
||||
</button>
|
||||
{open && <span className="cr-classes-popup" role="note">{group.detail}</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -381,6 +381,73 @@ body:is(.vscode-light, .vscode-high-contrast-light) .cr-colored { color: var(--c
|
||||
color: var(--group);
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* 只有图标的框头(渲染大纲一行前面的「当 … 时」):图标和后面的字贴近一点;框里没有词时(小标签、类名)框头后面不留空 */
|
||||
.cr-group-head[data-bare] {
|
||||
margin-right: 4px;
|
||||
}
|
||||
.cr-group-head:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* 渲染大纲一行前面的小框(React 组件卡片):按条件、条件的一支同「条件」块的颜色,不渲染同「返回」块;
|
||||
* 小标签(名称后面的「具名导出」、属性名后面的「回调」)用规则给的颜色(暗色、亮色各一个,按主题挑)
|
||||
*/
|
||||
.cr-group:is([data-style="condition"], [data-style="branch"]) { --group: var(--cr-kind-branch); }
|
||||
.cr-group[data-style="skip"] { --group: var(--cr-kind-return); }
|
||||
.cr-group[data-colored] { --group: var(--group-dark); }
|
||||
body:is(.vscode-light, .vscode-high-contrast-light) .cr-group[data-colored] { --group: var(--group-light); }
|
||||
.cr-group[data-style="tag"] {
|
||||
line-height: 16px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/*
|
||||
* 收起来的一串类名(「样式 N 个类」):中性色的小框,是个按钮;点开时换成焦点色的边、在下面弹出全部类名,
|
||||
* 弹层盖住后面的字,不挤开它们
|
||||
*/
|
||||
.cr-group[data-style="classes"] {
|
||||
margin: 0;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
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 {
|
||||
font-weight: 400;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
.cr-group[data-style="classes"]:hover,
|
||||
.cr-group[data-style="classes"][data-open] {
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
.cr-group[data-style="classes"][data-open] .cr-group-head {
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
.cr-classes {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
}
|
||||
.cr-classes-popup {
|
||||
position: absolute;
|
||||
top: calc(100% + 2px);
|
||||
left: 0;
|
||||
z-index: 20;
|
||||
width: max-content;
|
||||
max-width: min(460px, 70vw);
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--vscode-editorHoverWidget-border, var(--vscode-editorWidget-border));
|
||||
border-radius: 6px;
|
||||
background: var(--vscode-editorHoverWidget-background, var(--vscode-editorWidget-background));
|
||||
box-shadow: 0 2px 8px var(--vscode-widget-shadow, rgb(0 0 0 / 36%));
|
||||
color: var(--vscode-editorHoverWidget-foreground, var(--vscode-foreground));
|
||||
font-family: var(--vscode-editor-font-family, monospace);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/*
|
||||
* 属性表里带列名的子行(输入参数、数据表的字段)排成的表格:外框圆角,格子之间、行之间一条主题的表格分隔线;
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ReactElement } from 'react'
|
||||
import type { BlockKind, BlockView, KindIconName } from './types'
|
||||
import {
|
||||
Activity,
|
||||
Atom,
|
||||
Box,
|
||||
Braces,
|
||||
CalendarClock,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
ListOrdered,
|
||||
Lock,
|
||||
MessageSquareText,
|
||||
MousePointerClick,
|
||||
Network,
|
||||
Package,
|
||||
Play,
|
||||
@@ -34,6 +36,7 @@ import {
|
||||
Terminal,
|
||||
TriangleAlert,
|
||||
Variable,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
|
||||
/** 插件定义的这组种类图标,每个名字一个 lucide 图标(逐个 import,打包只带这些)。 */
|
||||
@@ -70,6 +73,9 @@ export const KIND_ICON_COMPONENTS: Record<KindIconName, LucideIcon> = {
|
||||
'calendar-clock': CalendarClock,
|
||||
'globe': Globe,
|
||||
'network': Network,
|
||||
'atom': Atom,
|
||||
'zap': Zap,
|
||||
'mouse-pointer-click': MousePointerClick,
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -28,14 +28,23 @@ export interface Segment { text: string, role: TokenRole, offset?: number, refer
|
||||
/** 规则自己查得到定义的一个名字:哪个规则、规则自己认的键 */
|
||||
export interface RuleReference { rule: string, key: string }
|
||||
|
||||
/** 框的样式,决定框头的图标和框的颜色:'iteration' 是写在表达式里的遍历,图标、颜色同「循环」块 */
|
||||
export type GroupStyle = 'iteration'
|
||||
/**
|
||||
* 框的样式,决定框头的图标和框的颜色:iteration 写在表达式里的遍历(同「循环」块),condition 按条件(同「条件」块),
|
||||
* branch 条件的一支(同「条件」块的颜色、不带图标),skip 这个条件下不渲染(同「返回」块),tag 小标签(颜色是 SegmentGroup.color),
|
||||
* classes 收起来的一串类名(中性色,点开、悬停看 detail)
|
||||
*/
|
||||
export type GroupStyle = 'iteration' | 'condition' | 'branch' | 'skip' | 'tag' | 'classes'
|
||||
|
||||
/** 框起来的一组词:画成嵌在前后文字里的一个小框,框头是图标和 label;框里的词照样带源码下标,还能再套框 */
|
||||
/**
|
||||
* 框起来的一组词:画成嵌在前后文字里的一个小框,框头是图标和 label(只有图标时 label 是空的);框里的词照样带源码下标,还能再套框。
|
||||
* color 盖过样式的颜色(tag 按标签的意思上色),detail 是点开、悬停时看的全文(classes 的全部类名,一行一条)
|
||||
*/
|
||||
export interface SegmentGroup {
|
||||
style: GroupStyle
|
||||
label: string
|
||||
segments: Inline[]
|
||||
color?: ThemeColor
|
||||
detail?: string
|
||||
}
|
||||
|
||||
/** 译文里的一段:着色的词,或框起来的一组词 */
|
||||
@@ -90,7 +99,7 @@ export type KindIconName
|
||||
= | 'import' | 'square-function' | 'box' | 'braces' | 'list-ordered' | 'triangle-alert' | 'tag' | 'lock' | 'variable'
|
||||
| 'split' | 'corner-down-right' | 'repeat' | 'shield' | 'shield-alert' | 'shield-check' | 'corner-down-left' | 'code'
|
||||
| 'message-square-text' | 'terminal' | 'sliders-horizontal' | 'signpost' | 'square-terminal' | 'layers' | 'copy' | 'activity' | 'play'
|
||||
| 'database' | 'package' | 'server-cog' | 'calendar-clock' | 'globe' | 'network'
|
||||
| 'database' | 'package' | 'server-cog' | 'calendar-clock' | 'globe' | 'network' | 'atom' | 'zap' | 'mouse-pointer-click'
|
||||
|
||||
/** 库规则的种类:名字(悬停提示、读屏念的文字)、颜色和图标(从 KindIconName 里挑);块左边只画图标 */
|
||||
export interface KindBadge { label: string, color: ThemeColor, icon: KindIconName }
|
||||
|
||||
Reference in new issue
Block a user