261 lines
13 KiB
TypeScript
261 lines
13 KiB
TypeScript
import type { ReactElement } from 'react'
|
||
import type { Inline, InlinePart, ItemDiff, ItemsDiff, PropertyItem, PropertyRow, Removed, RowsDiff } from './types'
|
||
import { ChevronDown, ChevronRight } from 'lucide-react'
|
||
import { Fragment } from 'react'
|
||
import { DiffInline, DiffText } from './DiffText'
|
||
import { inlineText } from './segments'
|
||
import { SegmentText } from './SegmentText'
|
||
import { subRowsLayout, tableCells, tableTemplate } from './subRows'
|
||
import { TextPreview } from './TextPreview'
|
||
import { propertyItemId, propertyRowId, tableId } from './viewState'
|
||
|
||
/** 列表子行里除最后一列以外各列的宽度(字典的键),最后一列占满剩下的;编辑区窄时按比例收,不把最后一列挤没 */
|
||
const CELL_WIDTHS = ['min(150px, 28%)', 'min(220px, 38%)']
|
||
|
||
function gridTemplate(count: number): string {
|
||
const fixed = Array.from({ length: count - 1 }, (_, i) => CELL_WIDTHS[i] ?? CELL_WIDTHS[CELL_WIDTHS.length - 1])
|
||
return [...fixed, 'minmax(0, 1fr)'].join(' ')
|
||
}
|
||
|
||
/** 眼下折叠着哪些属性行、子行,点折叠箭头时切换哪个。 */
|
||
interface Folding {
|
||
collapsed: ReadonlySet<string>
|
||
onToggleFold: (id: string) => void
|
||
}
|
||
|
||
/** 删掉的行下面的子行:总是展开,点了也不收(旧内容只是看看)。 */
|
||
const FIXED: Folding = { collapsed: new Set(), onToggleFold: () => {} }
|
||
|
||
/** 表格行第一格前的记号:加的 +、删的 −,没变的空一格,列照样对齐。 */
|
||
const SIGNS = { add: '+', del: '−', none: ' ' } as const
|
||
|
||
/** 删掉的行(子行)里排在新的第 after 行后面的那些,带着它在删掉的行里的序号(页面上认它用)。 */
|
||
function removedAt<T>(removed: readonly Removed<T>[] | undefined, after: number): { value: T, index: number }[] {
|
||
return (removed ?? []).flatMap((entry, index) => entry.after === after ? [{ value: entry.value, index }] : [])
|
||
}
|
||
|
||
/** 一格的字:这一格的值改了就逐截标(DiffInline),否则照常。 */
|
||
function CellText({ cell, parts }: { cell: Inline[], parts: InlinePart[] | undefined }): ReactElement {
|
||
return parts ? <DiffInline parts={parts} /> : <SegmentText segments={cell} />
|
||
}
|
||
|
||
/**
|
||
* 子行的第一列(字典的键):带下一层的子行前面是折叠箭头(图标、大小、颜色同属性行的),点箭头或这一列切换;
|
||
* 同一层里不带下一层的子行在同样的位置留空,第一列才对得齐。
|
||
*/
|
||
function ItemHead({ id, cell, foldable, folding }: { id: string, cell: Inline[], foldable: boolean, folding: Folding }): ReactElement {
|
||
if (!foldable) {
|
||
return (
|
||
<div className="flex min-w-0 gap-1">
|
||
<span className="w-3.5 shrink-0" />
|
||
<p className="cr-code min-w-0">
|
||
<SegmentText segments={cell} />
|
||
</p>
|
||
</div>
|
||
)
|
||
}
|
||
const folded = folding.collapsed.has(id)
|
||
const Chevron = folded ? ChevronRight : ChevronDown
|
||
return (
|
||
<button
|
||
type="button"
|
||
className="group flex min-w-0 items-start gap-1 rounded-sm text-left"
|
||
aria-label={`${folded ? '展开' : '折叠'}${inlineText(cell)}`}
|
||
aria-expanded={!folded}
|
||
onClick={() => folding.onToggleFold(id)}
|
||
>
|
||
<span className="flex h-5 w-3.5 shrink-0 items-center">
|
||
<Chevron size={14} strokeWidth={1.8} className="text-[var(--vscode-descriptionForeground)] group-hover:text-[var(--vscode-foreground)]" />
|
||
</span>
|
||
<span className="cr-code min-w-0">
|
||
<SegmentText segments={cell} />
|
||
</span>
|
||
</button>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 展开在一行或一个子行(parentId)下面的一层子行:左边一条竖线,每个子行几列对齐。
|
||
* 带下一层的子行(字典 / 对象的值又是字典 / 对象),下一层展开在它下面、再往右缩进一层,默认展开。
|
||
* 标差异时(marks)新加的子行淡绿底、左边 +,删掉的放回原来的位置、淡红底划掉、左边 −,改了的格子逐截标。
|
||
*/
|
||
function PropertyItems({ parentId, items, folding, marks }: { parentId: string, items: PropertyItem[], folding: Folding, marks?: ItemsDiff }): ReactElement {
|
||
const nested = items.some(item => (item.items?.length ?? 0) > 0)
|
||
/** 一个子行:删掉的(change 是 del)不带 id、下面的子行总是展开 */
|
||
const itemRow = (item: PropertyItem, id: string, change: 'add' | 'del' | undefined, mark: ItemDiff | undefined): ReactElement => {
|
||
const children = item.items ?? []
|
||
const folds = change === 'del' ? FIXED : folding
|
||
return (
|
||
<>
|
||
<div
|
||
className="cr-prop-item grid gap-x-3"
|
||
data-id={change === 'del' ? undefined : id}
|
||
data-diff={change}
|
||
style={{ gridTemplateColumns: gridTemplate(item.cells.length) }}
|
||
>
|
||
{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>
|
||
{children.length > 0 && !folds.collapsed.has(id) && (change === 'del'
|
||
? (
|
||
<div className="cr-diff-gone">
|
||
<PropertyItems parentId={id} items={children} folding={folds} />
|
||
</div>
|
||
)
|
||
: <PropertyItems parentId={id} items={children} folding={folds} marks={mark?.items} />)}
|
||
</>
|
||
)
|
||
}
|
||
const removed = (after: number): ReactElement[] => removedAt(marks?.removed, after)
|
||
.map(({ value, index }) => <Fragment key={`removed-${index}`}>{itemRow(value, `${parentId}/x${index}`, 'del', undefined)}</Fragment>)
|
||
return (
|
||
<div className="flex gap-2.5 pl-[5px]">
|
||
<div className="cr-guide w-px shrink-0" />
|
||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||
{removed(-1)}
|
||
{items.map((item, index) => {
|
||
const mark = marks?.changed[index]
|
||
return (
|
||
<Fragment key={index}>
|
||
{itemRow(item, propertyItemId(parentId, index), mark?.added && 'add', mark)}
|
||
{removed(index)}
|
||
</Fragment>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 给了列名的子行(输入参数、数据表的字段)排成表格:第一行是表头(列名),下面每个子行一行;外框圆角,格子之间、行之间一条分隔线。
|
||
* 列宽按整列的内容定(见 tableTemplate),最后一列分剩下的,长了在格子里换行。一行译文下面摊开实参的表也用它;
|
||
* 行带 table(实参的值里跨几行写的调用)时,那张表排在最后一格里、文字下面,往右缩一点,一层层往下套。
|
||
* 标差异时(marks)有加行、删行的表每行第一格前多一个记号位(+ / −,没变的空着),加的行淡绿底,删的行放回原来的位置、淡红底划掉。
|
||
*/
|
||
export function PropertyTable({ parentId, columns, items, marks }: { parentId: string, columns: string[], items: PropertyItem[], marks?: ItemsDiff }): 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 => {
|
||
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]} />
|
||
</>
|
||
)
|
||
return (
|
||
<div className="cr-table-row" role="row" data-id={change === 'del' ? undefined : id} data-diff={change}>
|
||
{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>
|
||
<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>
|
||
)
|
||
}
|
||
const removed = (after: number): ReactElement[] => removedAt(marks?.removed, after)
|
||
.map(({ value, index }) => <Fragment key={`removed-${index}`}>{tableRow(value, `${parentId}/x${index}`, 'del', undefined)}</Fragment>)
|
||
return (
|
||
<div className="pl-[5px]">
|
||
<div className="cr-table" role="table" style={{ gridTemplateColumns: tableTemplate(columns.length) }}>
|
||
<div className="cr-table-head" role="row">
|
||
{columns.map((name, i) => <span key={i} className="cr-table-cell" role="columnheader">{name}</span>)}
|
||
</div>
|
||
{removed(-1)}
|
||
{items.map((item, index) => {
|
||
const mark = marks?.changed[index]
|
||
return (
|
||
<Fragment key={index}>
|
||
{tableRow(item, propertyItemId(parentId, index), mark?.added && 'add', mark)}
|
||
{removed(index)}
|
||
</Fragment>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 函数、类、枚举、导入、常量、变量块和库规则的卡片的预览:属性名一列对齐,值按词性着色;输入参数、导入项、字典 / 对象的值下面展开子行,
|
||
* 给了列名的(输入参数、数据表的字段)排成表格,没给的排成列表。
|
||
* 标差异时(marks)新加的行淡绿底、左边 +,删掉的行放回原来的位置、淡红底划掉、左边 −,值改了的按旧值 → 新值或逐词标。
|
||
*/
|
||
export function PropertyGrid({ blockId, rows, collapsed, onToggleFold, marks }: {
|
||
blockId: string
|
||
rows: PropertyRow[]
|
||
collapsed: ReadonlySet<string>
|
||
onToggleFold: (id: string) => void
|
||
/** 标差异时这张属性表改了哪些行 */
|
||
marks?: RowsDiff
|
||
}): ReactElement {
|
||
const removed = (after: number): ReactElement[] => removedAt(marks?.removed, after)
|
||
.map(({ value, index }) => <RemovedRow key={`removed-${index}`} id={`${blockId}/x${index}`} row={value} />)
|
||
return (
|
||
<div className="grid grid-cols-[minmax(76px,max-content)_minmax(0,1fr)] gap-x-3 gap-y-[3px] *:col-span-full">
|
||
{removed(-1)}
|
||
{rows.map((row, index) => {
|
||
const layout = subRowsLayout(row)
|
||
const id = propertyRowId(blockId, index)
|
||
const folded = collapsed.has(id)
|
||
const Chevron = folded ? ChevronRight : ChevronDown
|
||
const mark = marks?.changed[index]
|
||
return (
|
||
<Fragment key={index}>
|
||
<div className="cr-prop-row grid grid-cols-subgrid" data-id={id} data-diff={mark?.added && 'add'}>
|
||
{layout
|
||
? (
|
||
<button
|
||
type="button"
|
||
className="cr-prop-label group flex h-5 items-center gap-1 rounded-sm"
|
||
aria-label={`${folded ? '展开' : '折叠'}${row.label}`}
|
||
aria-expanded={!folded}
|
||
onClick={() => onToggleFold(id)}
|
||
>
|
||
<Chevron size={14} strokeWidth={1.8} className="shrink-0 text-[var(--vscode-descriptionForeground)] group-hover:text-[var(--vscode-foreground)]" />
|
||
{row.label}
|
||
</button>
|
||
)
|
||
: <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 === 'list' && <PropertyItems parentId={id} items={row.items ?? []} folding={{ collapsed, onToggleFold }} marks={mark?.items} />}
|
||
{removed(index)}
|
||
</Fragment>
|
||
)
|
||
})}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** 属性表里删掉的一行:整行淡红底、划掉,左边 −;它下面的子行照样摊开、划掉。 */
|
||
function RemovedRow({ id, row }: { id: string, row: PropertyRow }): ReactElement {
|
||
const layout = subRowsLayout(row)
|
||
return (
|
||
<>
|
||
<div className="cr-prop-row grid grid-cols-subgrid" data-diff="del">
|
||
<span className="cr-prop-label flex h-5 items-center">{row.label}</span>
|
||
<TextPreview text={row.value} className="min-w-0" />
|
||
</div>
|
||
{layout && (
|
||
<div className="cr-diff-gone">
|
||
{layout === 'table'
|
||
? <PropertyTable parentId={id} columns={row.columns ?? []} items={row.items ?? []} />
|
||
: <PropertyItems parentId={id} items={row.items ?? []} folding={FIXED} />}
|
||
</div>
|
||
)}
|
||
</>
|
||
)
|
||
}
|