✨ feat(vscode): 构造参数区,改它的和来自能单独打开源码
This commit is contained in:
1 parent
e322f01edb
commit
e8ae19d084
13 files changed
+345
-70
No files matched your search
@@ -289,6 +289,12 @@ export function toBlockViews(
|
||||
translate: node => layerTranslator ? layerTranslator.expression(node, layerDocument) : [{ text: node.text, role: 'plain' }],
|
||||
doc: node => layerTranslator?.doc(node, layerDocument),
|
||||
lookup: layerRules.lookup ?? (() => undefined),
|
||||
snippet: (node) => {
|
||||
const startRow = node.startPosition.row
|
||||
const end = node.endPosition
|
||||
const endRow = end.column === 0 && end.row > startRow ? end.row - 1 : end.row
|
||||
return sourceRows({ startIndex: node.startIndex, endIndex: node.endIndex, startRow, endRow })
|
||||
},
|
||||
}
|
||||
/** 一行译文,连着摊开实参的表、块的说明(有才有)。 */
|
||||
const textView = (block: TextBlock): { translation: PreviewText, table?: PreviewTable, doc?: Markdown } => {
|
||||
|
||||
@@ -594,11 +594,40 @@ export type PreviewText = Inline[] | Markdown
|
||||
*/
|
||||
export interface Defines { defines?: SourceRange }
|
||||
|
||||
/**
|
||||
* 能单独打开编辑的一段源码:这几行整行,去掉共同缩进,着色和块的 code / codeTokens 同一套切法。
|
||||
* 网页不自己切。点铅笔之后只出现这一段,不换整张卡片。
|
||||
*/
|
||||
export interface SourceSnippet {
|
||||
/** 起止行,0 起,含头含尾。 */
|
||||
startLine: number
|
||||
endLine: number
|
||||
code: string
|
||||
/** 着色,拼起来等于 code。 */
|
||||
codeTokens: Segment[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 属性表一个子行的某一格里,能单独打开的一段源码。
|
||||
* cell 是第几格(0 起),from / to 是这一格文字里这一处的范围(含头不含尾)。
|
||||
*/
|
||||
export interface CellEdit {
|
||||
cell: number
|
||||
from: number
|
||||
to: number
|
||||
snippet: SourceSnippet
|
||||
}
|
||||
|
||||
/** 属性表里展开的一个子行:几列对齐的格子,例如参数的名称 / 类型 / 默认值、字典一项的键 / 值。 */
|
||||
export interface PropertyItem extends Defines {
|
||||
cells: Inline[][]
|
||||
/** 展开在这个子行下面的下一层子行(字典 / 对象的值又是字典 / 对象时,它的各项);一层层可以一直往下套。 */
|
||||
items?: PropertyItem[]
|
||||
/**
|
||||
* 这一行里能单独打开的几段源码(状态「改它的」的每一处、读取的数据的「来自」)。
|
||||
* 打开后只出现那一段。没有能打开的就不写。
|
||||
*/
|
||||
edits?: CellEdit[]
|
||||
/**
|
||||
* 排在这一行最后一格里、那格文字下面的表:实参表里一个实参的值里有跨几行写的调用时,是那个调用的实参表,
|
||||
* 表里的行又可以带 table,一层层往下套。总是展开的。
|
||||
@@ -916,6 +945,8 @@ export interface RuleContext {
|
||||
displayLanguage?: string
|
||||
/** 用当前翻译插件把一个表达式或类型节点翻成一行译文(Translator.expression):名字带源码下标,能跳到定义、高亮各处出现;没有翻译插件时照抄源码。 */
|
||||
translate: (node: Node) => Inline[]
|
||||
/** 一个节点占的整行源码和着色(见 SourceSnippet),和块视图切行是同一套。规则把它挂在译文旁边。 */
|
||||
snippet: (node: Node) => SourceSnippet
|
||||
/** 用当前翻译插件取一段说明文字(Translator.doc);没有翻译插件、没有文字时没有。 */
|
||||
doc: (node: Node) => Markdown | undefined
|
||||
/**
|
||||
|
||||
@@ -64,7 +64,7 @@ describe('react 库规则:认领', () => {
|
||||
expect(propertyLines(properties(view)).slice(0, 4)).toEqual([
|
||||
'名称: DesktopTitlebar ⟦具名导出|⟧ ⟦客户端组件|⟧',
|
||||
'说明: 窗口顶部 40px. 整条连同里面的文字都是拖拽区 (data-tauri-drag-region="deep"), 按钮除外. 侧栏开关只在侧栏顶部.',
|
||||
'属性: 1 个',
|
||||
'构造参数: 1 个',
|
||||
' platform | DesktopShell["platform"] | — | 是',
|
||||
])
|
||||
})
|
||||
@@ -83,13 +83,13 @@ describe('react 库规则:认领', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('react 库规则:属性', () => {
|
||||
describe('react 库规则:构造参数', () => {
|
||||
it('luffy 的 LegalDocPage:来自 LegalDocPageProps(名字能跳到定义),字段带说明时多一列说明', async () => {
|
||||
const view = await card(LEGAL, 'LegalDocPage')
|
||||
const row = properties(view).find(item => item.label === '属性')
|
||||
const row = properties(view).find(item => item.label === '构造参数')
|
||||
expect(row?.columns).toEqual(['名称', '类型', '默认值', '必填', '说明'])
|
||||
expect(rowLines(view, '属性')).toEqual([
|
||||
'属性: 1 个 · 来自 LegalDocPageProps',
|
||||
expect(rowLines(view, '构造参数')).toEqual([
|
||||
'构造参数: 1 个 · 来自 LegalDocPageProps',
|
||||
' doc | "terms" | "privacy" | — | 是 | terms = 用户服务协议; privacy = 隐私政策',
|
||||
])
|
||||
const from = row && !('markdown' in row.value) ? row.value.at(-1) : undefined
|
||||
@@ -98,16 +98,16 @@ describe('react 库规则:属性', () => {
|
||||
})
|
||||
|
||||
it('luffy 的 ShareButton:回调标「回调」、类型写成函数签名;StepLabel 的 children 标「子元素」', async () => {
|
||||
expect(rowLines(await card(SHARE, 'ShareButton'), '属性')).toEqual([
|
||||
'属性: 5 个',
|
||||
expect(rowLines(await card(SHARE, 'ShareButton'), '构造参数')).toEqual([
|
||||
'构造参数: 5 个',
|
||||
' open | 布尔 | — | 是 | ',
|
||||
' onOpenChange ⟦回调|⟧ | (next: 布尔) => 无 | — | 是 | ',
|
||||
' deployUrl | 字符串 | 空 | — | 是 | 已发布的持久 URL; null = 尚未发布 (点击后由父组件驱动发布)。',
|
||||
' sessionId | 字符串 | — | 是 | ',
|
||||
' title | 字符串 | — | 是 | ',
|
||||
])
|
||||
expect(rowLines(await card(POSTER, 'StepLabel'), '属性')).toEqual([
|
||||
'属性: 2 个',
|
||||
expect(rowLines(await card(POSTER, 'StepLabel'), '构造参数')).toEqual([
|
||||
'构造参数: 2 个',
|
||||
' n | 数值 | — | 是',
|
||||
' children ⟦子元素|⟧ | ReactNode | — | 是',
|
||||
])
|
||||
@@ -118,8 +118,8 @@ describe('react 库规则:属性', () => {
|
||||
'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',
|
||||
expect(rowLines(await card(source, 'A'), '构造参数')).toEqual([
|
||||
'构造参数: 4 个 · 来自 Props',
|
||||
' size | 数值 | 16 | 否',
|
||||
' onPick ⟦回调|⟧ | (v: 字符串) => 无 | — | 是',
|
||||
' label(组件里叫 text) | 字符串 | — | 是',
|
||||
@@ -133,8 +133,8 @@ describe('react 库规则:属性', () => {
|
||||
'export const Card = ({ title, onClose = noop }: CardProps) => <div />;',
|
||||
'export function Empty() { return <i /> }',
|
||||
].join('\n')
|
||||
expect(rowLines(await card(source, 'Card'), '属性')).toEqual([
|
||||
'属性: 2 个 · 来自 CardProps',
|
||||
expect(rowLines(await card(source, 'Card'), '构造参数')).toEqual([
|
||||
'构造参数: 2 个 · 来自 CardProps',
|
||||
' title | 未标注 | — | —',
|
||||
' onClose | 未标注 | noop | 否',
|
||||
])
|
||||
@@ -143,13 +143,13 @@ describe('react 库规则:属性', () => {
|
||||
{ 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',
|
||||
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(['属性: 无'])
|
||||
expect(rowLines(await card(source, 'Empty'), '构造参数')).toEqual(['构造参数: 无'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -257,11 +257,37 @@ describe('react 库规则:分区', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('单独打开一段源码', () => {
|
||||
it('读取的数据挂的是这次调用,改它的里 JSX 那一处是整个属性', async () => {
|
||||
const source = [
|
||||
'export function Panel() {',
|
||||
' const pathname =',
|
||||
' usePathname()',
|
||||
' const [open, setOpen] = useState(false)',
|
||||
' return (',
|
||||
' <Dialog',
|
||||
' onOpenChange={',
|
||||
' setOpen',
|
||||
' }',
|
||||
' />',
|
||||
' )',
|
||||
'}',
|
||||
].join('\n')
|
||||
const view = await card(source, 'Panel')
|
||||
const read = properties(view).find(row => row.label === '读取的数据')?.items?.[0]
|
||||
expect(read?.edits?.map(edit => edit.snippet.code)).toEqual(['usePathname()'])
|
||||
const state = properties(view).find(row => row.label === '状态')?.items?.[0]
|
||||
expect(state?.edits?.map(edit => edit.snippet.code)).toEqual(['onOpenChange={\n setOpen\n}'])
|
||||
expect(view.code).toContain('function Panel')
|
||||
expect(read?.edits?.[0].snippet.code).not.toContain('function Panel')
|
||||
})
|
||||
})
|
||||
|
||||
describe('react 库规则:渲染大纲', () => {
|
||||
it('luffy 的 DesktopTitlebar:整张卡片讲完了组件,没有留下的子块;渲染大纲每行一个元素,原生标签、组件、样式小标、属性、插值', async () => {
|
||||
const view = await card(TITLEBAR, 'DesktopTitlebar')
|
||||
expect(view.children).toEqual([])
|
||||
expect(properties(view).map(row => row.label)).toEqual(['名称', '说明', '属性', '读取的数据', '派生值', '渲染'])
|
||||
expect(properties(view).map(row => row.label)).toEqual(['名称', '说明', '构造参数', '读取的数据', '派生值', '渲染'])
|
||||
expect(rowLines(view, '渲染')).toEqual([
|
||||
'渲染: 1 处返回',
|
||||
' div ⟦样式 8 个类|⟧ data-tauri-drag-region="deep"',
|
||||
@@ -388,7 +414,7 @@ describe('react 库规则:代码差异', () => {
|
||||
|
||||
const componentChange = diff.blocks[component.anchor]
|
||||
expect(componentChange).toMatchObject({ change: 'mod', own: true })
|
||||
const propsIndex = properties(component).findIndex(row => row.label === '属性')
|
||||
const propsIndex = properties(component).findIndex(row => row.label === '构造参数')
|
||||
expect(Object.keys(componentChange.marks?.rows?.changed ?? {})).toEqual([String(propsIndex)])
|
||||
expect(componentChange.marks?.rows?.changed[propsIndex].items?.changed[1]).toEqual({ added: true })
|
||||
expect(componentChange.marks?.rows?.removed).toEqual([])
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { CodeBlock, Inline, KindBadge, LibraryRule, Node, PropertyItem, PropertyRow, RuleCard, RuleContext, RuleSlot, SegmentGroup, ThemeColor, TokenRole, WorkspaceIndex } from '../contract'
|
||||
import type { CellEdit, CodeBlock, Inline, KindBadge, LibraryRule, Node, PropertyItem, PropertyRow, RuleCard, RuleContext, RuleSlot, SegmentGroup, ThemeColor, TokenRole, WorkspaceIndex } from '../contract'
|
||||
import type { BindingFacts, BodyFacts, ClassFacts, ComponentFacts, DerivedFacts, EffectFacts, FieldFacts, FieldText, HandlerFacts, OutlineNode, Place, PropsFacts, ReadFacts } from './reactTree'
|
||||
import { argumentsOf, attributeName, attributeValue, bodyOf, clientDirective, commentsBefore, commentText, componentAt, componentOf, componentsIn, declaredTypes, enclosingComponent, isComponentTag, isFunction, named, outlineOf, 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 这些小标签)/ 说明 / 属性(解构的每个属性一行,类型取属性类型里对应的字段;
|
||||
* 卡片:名称(后面跟导出方式、客户端组件、记忆化、转发 ref 这些小标签)/ 说明 / 构造参数(解构的每个属性一行,类型取属性类型里对应的字段;
|
||||
* 属性类型是接口、类型别名时写来自哪个类型,导入的类型经工作区索引查它的字段),再按钩子分区,没有就不画:
|
||||
* 状态(useState / useReducer:名称 / 初始值 / 改它的,渲染时按条件重置状态的 if 也写进改它的)、引用(useRef)、
|
||||
* 读取的数据(别的钩子:变量 / 来自 / 意思)、派生值(组件体里别的常量、useMemo / useCallback:名称 / 怎么算 / 什么时候算)、
|
||||
@@ -123,7 +123,7 @@ const ZH: Words = {
|
||||
labels: {
|
||||
name: '名称',
|
||||
doc: '说明',
|
||||
props: '属性',
|
||||
props: '构造参数',
|
||||
state: '状态',
|
||||
refs: '引用',
|
||||
reads: '读取的数据',
|
||||
@@ -254,7 +254,7 @@ function docRows(component: ComponentFacts, { context, words }: Env): PropertyRo
|
||||
return doc ? [{ label: words.labels.doc, value: doc }] : []
|
||||
}
|
||||
|
||||
// ─── 属性 ─────────────────────────────────────────────────────────────────────
|
||||
// ─── 构造参数 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 工作区里各文件顶层声明的接口、类型别名:名字 → 各字段的文字(JSON,见 FieldText),组件的属性类型是导入的时候查它。 */
|
||||
const PROP_TYPES_INDEX: WorkspaceIndex = {
|
||||
@@ -357,7 +357,7 @@ function propDoc(entry: PropEntry): Inline[] {
|
||||
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)
|
||||
@@ -487,16 +487,54 @@ function commentLine(comments: readonly Node[]): string | undefined {
|
||||
// ─── 状态、引用、读取的数据、派生值 ──────────────────────────────────────────
|
||||
|
||||
/** 状态:名称 / 初始值 / 改它的(改状态的函数,括号里写在哪儿改:副作用、处理函数、渲染时按条件重置……)。 */
|
||||
/** 这一处改状态的源码:JSX 取包着值的整个属性,处理函数取函数,派生值取那个值,另一处读取取那次调用,重置取条件。纯渲染没有节点。 */
|
||||
function placeNode(place: Place, body: BodyFacts): Node | undefined {
|
||||
switch (place.kind) {
|
||||
case 'jsx':
|
||||
return place.attribute.parent?.type === 'jsx_attribute' ? place.attribute.parent : undefined
|
||||
case 'handler':
|
||||
return body.handlers.find(handler => sameNode(handler.name, place.name))?.fn
|
||||
case 'derived':
|
||||
return body.derived.find(item => sameNode(item.name, place.name))?.value
|
||||
case 'read':
|
||||
return body.reads.find(read => sameNode(read.target, place.target))?.call
|
||||
case 'reset':
|
||||
return place.condition
|
||||
case 'effect':
|
||||
return body.effects[place.index]?.statement
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** 「改它的」这一格:函数名,括号里每一处各挂自己的源码。 */
|
||||
function changesOf(analysis: Analysis, setter: Node, env: Env): { cell: Inline[], edits: CellEdit[] } {
|
||||
const places = placesOf(setter, analysis.component, analysis.body)
|
||||
const parts = places.map(place => placeText(place, analysis, env))
|
||||
const cell = [name(setter, 'function'), ...inParens(parts, env)]
|
||||
const edits: CellEdit[] = []
|
||||
let at = 2
|
||||
parts.forEach((part, index) => {
|
||||
const node = placeNode(places[index], analysis.body)
|
||||
if (node)
|
||||
edits.push({ cell: 2, from: at, to: at + part.length, snippet: env.context.snippet(node) })
|
||||
at += part.length + (index < parts.length - 1 ? 1 : 0)
|
||||
})
|
||||
return { cell, edits }
|
||||
}
|
||||
|
||||
function stateRow(analysis: Analysis, env: Env): PropertyRow {
|
||||
const { component, body } = analysis
|
||||
const { 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) }
|
||||
const changes = state.setter ? changesOf(analysis, state.setter, env) : { cell: [hint(words.nothing)], edits: [] }
|
||||
return {
|
||||
cells: [binding(state.value, 'variable', env), [...initial, ...reducer], changes.cell],
|
||||
...(changes.edits.length > 0 && { edits: changes.edits }),
|
||||
defines: range(state.value),
|
||||
}
|
||||
})
|
||||
return { label: words.labels.state, value: [hint(words.count(items.length))], items, columns: words.heads.state }
|
||||
}
|
||||
@@ -601,10 +639,14 @@ function readMeaning(read: ReadFacts, env: Env): Inline[] {
|
||||
/** 读取的数据:变量 / 来自(钩子调用照常翻)/ 意思。 */
|
||||
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),
|
||||
}))
|
||||
const items = analysis.body.reads.map((read): PropertyItem => {
|
||||
const from = context.translate(read.call)
|
||||
return {
|
||||
cells: [binding(read.target, 'variable', env), from, readMeaning(read, env)],
|
||||
edits: [{ cell: 1, from: 0, to: from.length, snippet: context.snippet(read.call) }],
|
||||
defines: range(read.target),
|
||||
}
|
||||
})
|
||||
return { label: words.labels.reads, value: [hint(words.count(items.length))], items, columns: words.heads.reads }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { CodeBlock, Node } from '../contract'
|
||||
|
||||
/**
|
||||
* 读 React 函数组件的语法树(React 库规则用,见 react.ts):认组件,读出它的属性、组件体里的各种钩子、处理函数和渲染的 JSX。
|
||||
* 读 React 函数组件的语法树(React 库规则用,见 react.ts):认组件,读出它的构造参数、组件体里的各种钩子、处理函数和渲染的 JSX。
|
||||
* 只读语法树、不翻译,读出来的节点都指向源码;怎么排成卡片、用什么词归 react.ts。
|
||||
*/
|
||||
|
||||
|
||||
@@ -11,10 +11,11 @@ import { BlockLevel } from './BlockTree'
|
||||
import { DiffBar, NewFileBanner } from './DiffBar'
|
||||
import { changeStops, diffTree, foldUnchanged, openedByDiff } from './diffView'
|
||||
import { acceptNames, DisplayNameContext, documentNames } from './displayNames'
|
||||
import { coveredByEditing, startEditing } from './editing'
|
||||
import { coveredByEditing, snippetRefs, startEditing } from './editing'
|
||||
import { applyFilter, excludedBy, keptWhileEditing } from './filter'
|
||||
import { FilterBar } from './FilterBar'
|
||||
import { OverviewRuler } from './OverviewRuler'
|
||||
import { SnippetEditContext } from './PropertyGrid'
|
||||
import { asksOf, historyOf, PATH_ASK_ANCHOR } from './qa'
|
||||
import { useChangeNav } from './useChangeNav'
|
||||
import { useDefinitionLinks } from './useDefinitionLinks'
|
||||
@@ -67,6 +68,7 @@ export function App(): ReactElement | null {
|
||||
const collapsed = useMemo(() => collapsedIds(doc?.blocks ?? [], view.folds, openedByDiff(changes)), [doc, view.folds, changes])
|
||||
const filter = useMemo(() => applyFilter(doc?.blocks ?? [], excluded, keep), [doc, excluded, keep])
|
||||
const covered = useMemo(() => coveredByEditing(doc?.blocks ?? [], new Set(view.editing)), [doc, view.editing])
|
||||
const snippetIds = useMemo(() => new Map(snippetRefs(doc?.blocks ?? []).map(ref => [ref.edit, ref.id])), [doc])
|
||||
/** 「只看改动」开着(整个工作区共用,扩展端记着);本地不改,改了发给扩展端、等它发回来 */
|
||||
const [onlyChanges, setOnlyChanges] = useState(false)
|
||||
/** 「只看改动」时点开过的几段没变的块(按第一块的 id),筛选一变就重新折 */
|
||||
@@ -176,6 +178,7 @@ export function App(): ReactElement | null {
|
||||
return (
|
||||
<HighlightContext value={highlight}>
|
||||
<DisplayNameContext value={names?.names ?? NO_NAMES}>
|
||||
<SnippetEditContext value={{ ids: snippetIds, editing: tree.editing, onToggle: tree.onToggleEdit, version: doc.version, focusId, onFocused }}>
|
||||
<FilterBar
|
||||
attributes={doc.attributes}
|
||||
excluded={excluded}
|
||||
@@ -236,6 +239,7 @@ export function App(): ReactElement | null {
|
||||
? <BlockLevel blocks={doc.blocks} tree={tree} />
|
||||
: <div className="text-[var(--vscode-descriptionForeground)]">所有块都被筛选隐藏了。</div>}
|
||||
</div>
|
||||
</SnippetEditContext>
|
||||
</DisplayNameContext>
|
||||
</HighlightContext>
|
||||
)
|
||||
|
||||
@@ -270,7 +270,7 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }):
|
||||
{editing
|
||||
? (
|
||||
<CodeEditor
|
||||
block={block}
|
||||
source={block}
|
||||
version={tree.version}
|
||||
autoFocus={tree.focusId === block.id}
|
||||
onAutoFocused={tree.onFocused}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ChangeEvent, KeyboardEvent, ReactElement } from 'react'
|
||||
import type { BlockView } from './types'
|
||||
import type { Segment } from './types'
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { recolor } from './segments'
|
||||
import { SegmentText } from './SegmentText'
|
||||
@@ -7,9 +7,17 @@ import { postMessage } from './vscode'
|
||||
|
||||
const EDIT_DEBOUNCE_MS = 200
|
||||
|
||||
/** 编辑框要的那一段源码:整块,或格子里单独打开的一段,字段一样。 */
|
||||
export interface EditableSource {
|
||||
startLine: number
|
||||
endLine: number
|
||||
code: string
|
||||
codeTokens: Segment[]
|
||||
}
|
||||
|
||||
interface CodeEditorProps {
|
||||
block: BlockView
|
||||
/** 文档版本:每来一份新文档都要对一次输入框内容,哪怕这一块的代码没变 */
|
||||
source: EditableSource
|
||||
/** 文档版本:每来一份新文档都要对一次输入框内容,哪怕这一段的代码没变 */
|
||||
version: number
|
||||
autoFocus: boolean
|
||||
onAutoFocused: () => void
|
||||
@@ -17,11 +25,11 @@ interface CodeEditorProps {
|
||||
}
|
||||
|
||||
/** 一块源码的编辑状态:着色层显示,透明输入框接收输入,改动防抖后整行替换回文件。 */
|
||||
export function CodeEditor({ block, version, autoFocus, onAutoFocused, onExit }: CodeEditorProps): ReactElement {
|
||||
const [text, setText] = useState(block.code)
|
||||
export function CodeEditor({ source, version, autoFocus, onAutoFocused, onExit }: CodeEditorProps): ReactElement {
|
||||
const [text, setText] = useState(source.code)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const blockRef = useRef(block)
|
||||
blockRef.current = block
|
||||
const sourceRef = useRef(source)
|
||||
sourceRef.current = source
|
||||
const textRef = useRef(text)
|
||||
textRef.current = text
|
||||
/** 本地改过、还没在新文档里看到的文字;有它时新文档不能覆盖有焦点的输入框 */
|
||||
@@ -37,7 +45,7 @@ export function CodeEditor({ block, version, autoFocus, onAutoFocused, onExit }:
|
||||
if (!unsent.current)
|
||||
return
|
||||
unsent.current = false
|
||||
const { startLine, endLine } = blockRef.current
|
||||
const { startLine, endLine } = sourceRef.current
|
||||
const value = textRef.current
|
||||
postMessage({
|
||||
type: 'edit',
|
||||
@@ -51,16 +59,16 @@ export function CodeEditor({ block, version, autoFocus, onAutoFocused, onExit }:
|
||||
useEffect(() => {
|
||||
const el = textareaRef.current
|
||||
const focused = el !== null && document.activeElement === el
|
||||
if (focused && unconfirmed.current !== null && unconfirmed.current !== block.code)
|
||||
if (focused && unconfirmed.current !== null && unconfirmed.current !== source.code)
|
||||
return
|
||||
unconfirmed.current = null
|
||||
sentLineCount.current = null
|
||||
if (block.code === textRef.current)
|
||||
if (source.code === textRef.current)
|
||||
return
|
||||
if (focused)
|
||||
restoreSelection.current = [el.selectionStart, el.selectionEnd]
|
||||
setText(block.code)
|
||||
}, [version, block.code])
|
||||
setText(source.code)
|
||||
}, [version, source.code])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = textareaRef.current
|
||||
@@ -121,7 +129,7 @@ export function CodeEditor({ block, version, autoFocus, onAutoFocused, onExit }:
|
||||
onBlur={flush}
|
||||
/>
|
||||
<pre className="cr-code" aria-hidden="true">
|
||||
<SegmentText segments={recolor(block.codeTokens, block.code, text)} />
|
||||
<SegmentText segments={recolor(source.codeTokens, source.code, text)} />
|
||||
{text === '' || text.endsWith('\n') ? ' ' : ''}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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 type { BlockView, CellEdit, Inline, InlinePart, ItemDiff, ItemsDiff, KindBadge, PropertyItem, PropertyRow, Removed, RowsDiff } from './types'
|
||||
import { ChevronDown, ChevronRight, Eye, Pencil } from 'lucide-react'
|
||||
import { createContext, Fragment, useContext } from 'react'
|
||||
import { CodeEditor } from './CodeEditor'
|
||||
import { DiffInline, DiffText } from './DiffText'
|
||||
import { inlineText } from './segments'
|
||||
import { SegmentText } from './SegmentText'
|
||||
@@ -56,6 +57,83 @@ function CellText({ cell, parts }: { cell: Inline[], parts: InlinePart[] | undef
|
||||
return parts ? <DiffInline parts={parts} /> : <SegmentText segments={cell} />
|
||||
}
|
||||
|
||||
/** 格子里单独打开的那段源码:和块共用同一套铅笔、同一个编辑框。 */
|
||||
export interface SnippetEditState {
|
||||
ids: ReadonlyMap<CellEdit, string>
|
||||
editing: ReadonlySet<string>
|
||||
onToggle: (id: string) => void
|
||||
version: number
|
||||
focusId: string | null
|
||||
onFocused: () => void
|
||||
}
|
||||
|
||||
export const SnippetEditContext = createContext<SnippetEditState | null>(null)
|
||||
|
||||
function EditToggle({ editing, onToggle }: { editing: boolean, onToggle: () => void }): ReactElement {
|
||||
const label = editing ? '预览' : '编辑'
|
||||
const Icon = editing ? Eye : Pencil
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="cr-tool inline-flex h-5 w-6 items-center justify-center rounded align-middle"
|
||||
data-active={editing ? '' : undefined}
|
||||
data-tooltip={label}
|
||||
aria-label={label}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<Icon size={14} strokeWidth={1.8} aria-hidden />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function SnippetSpan({ edit, segments }: { edit: CellEdit, segments: Inline[] }): ReactElement {
|
||||
const ctx = useContext(SnippetEditContext)
|
||||
const id = ctx?.ids.get(edit)
|
||||
if (!ctx || id === undefined)
|
||||
return <SegmentText segments={segments} />
|
||||
const editing = ctx.editing.has(id)
|
||||
return (
|
||||
<span className="inline-flex flex-wrap items-baseline gap-0.5">
|
||||
{editing
|
||||
? (
|
||||
<span className="block w-full">
|
||||
<CodeEditor
|
||||
source={edit.snippet}
|
||||
version={ctx.version}
|
||||
autoFocus={ctx.focusId === id}
|
||||
onAutoFocused={ctx.onFocused}
|
||||
onExit={() => ctx.onToggle(id)}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
: <SegmentText segments={segments} />}
|
||||
<EditToggle editing={editing} onToggle={() => ctx.onToggle(id)} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function CellView({ cell, index, edits, parts }: {
|
||||
cell: Inline[]
|
||||
index: number
|
||||
edits: CellEdit[] | undefined
|
||||
parts: InlinePart[] | undefined
|
||||
}): ReactElement {
|
||||
const mine = (edits ?? []).filter(edit => edit.cell === index).sort((a, b) => a.from - b.from)
|
||||
if (mine.length === 0)
|
||||
return <CellText cell={cell} parts={parts} />
|
||||
const chunks: ReactElement[] = []
|
||||
let at = 0
|
||||
mine.forEach((edit, n) => {
|
||||
if (edit.from > at)
|
||||
chunks.push(<SegmentText key={`t${n}`} segments={cell.slice(at, edit.from)} />)
|
||||
chunks.push(<SnippetSpan key={`s${n}`} edit={edit} segments={cell.slice(edit.from, edit.to)} />)
|
||||
at = edit.to
|
||||
})
|
||||
if (at < cell.length)
|
||||
chunks.push(<SegmentText key="tail" segments={cell.slice(at)} />)
|
||||
return <>{chunks}</>
|
||||
}
|
||||
|
||||
/**
|
||||
* 子行的第一列(字典的键):带下一层的子行前面是折叠箭头(图标、大小、颜色同属性行的),点箭头或这一列切换;
|
||||
* 同一层里不带下一层的子行在同样的位置留空,第一列才对得齐。
|
||||
@@ -115,9 +193,9 @@ function PropertyItems({ parentId, items, folding, marks }: { parentId: string,
|
||||
{item.cells.map((cell, i) => i === 0 && nested
|
||||
? <ItemHead key={i} id={id} cell={cell} foldable={children.length > 0 && change !== 'del'} folding={folds} />
|
||||
: (
|
||||
<p key={i} className="cr-code min-w-0">
|
||||
<CellText cell={cell} parts={mark?.cells?.[i]} />
|
||||
</p>
|
||||
<div key={i} className="cr-code min-w-0">
|
||||
<CellView cell={cell} index={i} edits={item.edits} parts={mark?.cells?.[i]} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{children.length > 0 && !folds.collapsed.has(id) && (change === 'del'
|
||||
@@ -177,10 +255,10 @@ export function PropertyTable({ parentId, columns, items, marks, toggles }: {
|
||||
{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]} />
|
||||
<CellView cell={cell} index={i} edits={item.edits} parts={mark?.cells?.[i]} />
|
||||
</button>
|
||||
)
|
||||
: <CellText cell={cell} parts={mark?.cells?.[i]} />}
|
||||
: <CellView cell={cell} index={i} edits={item.edits} parts={mark?.cells?.[i]} />}
|
||||
</>
|
||||
)
|
||||
const tint = toggle?.open && toggle.badge
|
||||
@@ -191,11 +269,11 @@ export function PropertyTable({ parentId, columns, items, marks, toggles }: {
|
||||
{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">
|
||||
<p className="cr-code">{text(cell, i)}</p>
|
||||
<div className="cr-code">{text(cell, i)}</div>
|
||||
<PropertyTable parentId={tableId(id)} columns={item.table.columns} items={item.table.items} marks={mark?.table} />
|
||||
</div>
|
||||
)
|
||||
: <p key={i} className="cr-table-cell cr-code min-w-0" role="cell">{text(cell, i)}</p>)}
|
||||
: <div key={i} className="cr-table-cell cr-code min-w-0" role="cell">{text(cell, i)}</div>)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -63,4 +63,24 @@ describe('同一行不会同时在两处能改', () => {
|
||||
const plain = revealPlan(BLOCKS, 15, 999)
|
||||
expect(plain && uncoveredPlan(plain, covered)).toEqual(plain)
|
||||
})
|
||||
|
||||
it('格子里单独打开的一段,和盖住它的块、跟它重叠的另一段不能同时编辑', () => {
|
||||
const snippet = (startLine: number, endLine: number) => ({ startLine, endLine, code: 'x', codeTokens: [] })
|
||||
const card = block('1', 'react.component', [0, 20])
|
||||
card.properties = [{
|
||||
label: '状态',
|
||||
value: [],
|
||||
items: [{
|
||||
cells: [[{ text: 'a', role: 'plain' }], [{ text: 'b', role: 'plain' }]],
|
||||
edits: [
|
||||
{ cell: 0, from: 0, to: 1, snippet: snippet(4, 6) },
|
||||
{ cell: 1, from: 0, to: 1, snippet: snippet(6, 7) },
|
||||
],
|
||||
}],
|
||||
}]
|
||||
const blocks = [card, block('2', 'function', [30, 32])]
|
||||
expect(startEditing(blocks, ['1~0', '2'], '1')).toEqual(['2', '1'])
|
||||
expect(startEditing(blocks, ['1', '2'], '1~0')).toEqual(['2', '1~0'])
|
||||
expect(startEditing(blocks, ['1~0', '2'], '1~1')).toEqual(['2', '1~1'])
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { BlockView } from './types'
|
||||
import type { BlockView, CellEdit, PropertyItem } from './types'
|
||||
|
||||
/**
|
||||
* 同一时刻能编辑的几段源码不重叠,同一行不会同时在两处能改:
|
||||
* ① 块的编辑范围是它的完整源码,子孙都在它的编辑框里:块编辑时它的子孙都不画;
|
||||
* ② 点「编辑」时,编辑范围和这块重叠的别的块(祖先、子孙,`} else {` 这样共用一行的兄弟块)退出编辑。
|
||||
* ② 点「编辑」时,编辑范围和这块重叠的别的块(祖先、子孙,`} else {` 这样共用一行的兄弟块)退出编辑;
|
||||
* ③ 属性表格子里单独打开的那一段(见 CellEdit)行号落在组件块里面,和块同一条规矩:重叠就不能同时编辑。
|
||||
* 组件正在编辑时预览不画,里面这段的编辑器也不画。
|
||||
*/
|
||||
|
||||
type Lines = Pick<BlockView, 'startLine' | 'endLine'>
|
||||
@@ -41,12 +43,51 @@ function* allBlocks(blocks: readonly BlockView[]): Generator<BlockView> {
|
||||
}
|
||||
|
||||
/** 点了 id 这块的「编辑」之后正在编辑的块:加上它,编辑范围和它重叠的别的块退出编辑。 */
|
||||
export interface SnippetRef {
|
||||
id: string
|
||||
edit: CellEdit
|
||||
startLine: number
|
||||
endLine: number
|
||||
}
|
||||
|
||||
/** 各块属性表里能单独打开的源码,按画出来的顺序编号。id 是「块 id~序号」。 */
|
||||
export function snippetRefs(blocks: readonly BlockView[]): SnippetRef[] {
|
||||
const refs: SnippetRef[] = []
|
||||
const take = (blockId: string, items: PropertyItem[] | undefined, n: number): number => {
|
||||
for (const item of items ?? []) {
|
||||
for (const edit of item.edits ?? []) {
|
||||
refs.push({ id: `${blockId}~${n}`, edit, startLine: edit.snippet.startLine, endLine: edit.snippet.endLine })
|
||||
n += 1
|
||||
}
|
||||
n = take(blockId, item.items, n)
|
||||
n = take(blockId, item.table?.items, n)
|
||||
}
|
||||
return n
|
||||
}
|
||||
for (const block of allBlocks(blocks)) {
|
||||
let n = 0
|
||||
if ('properties' in block) {
|
||||
for (const row of block.properties)
|
||||
n = take(block.id, row.items, n)
|
||||
}
|
||||
else if (block.table) {
|
||||
take(block.id, block.table.items, 0)
|
||||
}
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
/** 点了 id 这段的「编辑」之后正在编辑的几段:加上它,行重叠的别的段(块,或格子里的一段源码)退出编辑。 */
|
||||
export function startEditing(blocks: readonly BlockView[], editing: readonly string[], id: string): string[] {
|
||||
const byId = new Map([...allBlocks(blocks)].map(block => [block.id, block]))
|
||||
const byId = new Map<string, Lines>()
|
||||
for (const block of allBlocks(blocks))
|
||||
byId.set(block.id, block)
|
||||
for (const ref of snippetRefs(blocks))
|
||||
byId.set(ref.id, ref)
|
||||
const target = byId.get(id)
|
||||
const others = editing.filter((other) => {
|
||||
const block = byId.get(other)
|
||||
return other !== id && !(target && block && overlaps(target, block))
|
||||
const span = byId.get(other)
|
||||
return other !== id && !(target && span && overlaps(target, span))
|
||||
})
|
||||
return [...others, id]
|
||||
}
|
||||
@@ -66,11 +66,30 @@ export interface SourceRange { startIndex: number, endIndex: number }
|
||||
*/
|
||||
export interface Defines { defines?: SourceRange }
|
||||
|
||||
/** 能单独打开编辑的一段源码:整行切好、着色好,和块的 code / codeTokens 同一套。网页不自己切 */
|
||||
export interface SourceSnippet {
|
||||
/** 起止行,0 起,含头含尾 */
|
||||
startLine: number
|
||||
endLine: number
|
||||
code: string
|
||||
codeTokens: Segment[]
|
||||
}
|
||||
|
||||
/** 属性表一个子行的某一格里,能单独打开的一段源码。cell 是第几格,from / to 是这一格文字里这一处(含头不含尾) */
|
||||
export interface CellEdit {
|
||||
cell: number
|
||||
from: number
|
||||
to: number
|
||||
snippet: SourceSnippet
|
||||
}
|
||||
|
||||
/** 属性表里展开的一个子行:几列对齐的格子,例如参数的名称 / 类型 / 默认值、字典一项的键 / 值 */
|
||||
export interface PropertyItem extends Defines {
|
||||
cells: Inline[][]
|
||||
/** 展开在这个子行下面的下一层子行(字典 / 对象的值又是字典 / 对象时,它的各项),一层层可以一直往下套 */
|
||||
items?: PropertyItem[]
|
||||
/** 这一行里能单独打开的几段源码。打开后只出现那一段。没有能打开的就不写 */
|
||||
edits?: CellEdit[]
|
||||
/** 排在这一行最后一格里、那格文字下面的表(实参的值里跨几行写的调用的实参表),表里的行又可以带 table;总是展开 */
|
||||
table?: PreviewTable
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user