Files
testing-tool/pages/JsonTools/JsonTree.tsx
T
雨霖铃 de7da9f1db fix: 修复暗黑模式样式兼容性
- 在 tailwind.config.js 中启用 darkMode: 'class' 并扩展语义化颜色变量
- 将全项目硬编码颜色(bg-white、text-gray-*、border-gray-* 等)替换为语义化类名
- 修复 popup/index.html 硬编码浅色背景色
- 同步更新相关单元测试中的类名断言
2026-05-22 16:04:50 +08:00

223 lines
6.7 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from 'react';
import type { DiffNode, DiffType } from './types';
export type TreeSide = 'left' | 'right';
interface JsonTreeProps {
node: DiffNode;
side: TreeSide;
defaultExpandDepth?: number;
activePath?: string;
}
interface NodeRowProps {
node: DiffNode;
side: TreeSide;
depth: number;
defaultExpandDepth: number;
activePath?: string;
isLastChild: boolean;
}
const formatPrimitive = (v: unknown): string => {
if (v === null) return 'null';
if (typeof v === 'string') return JSON.stringify(v);
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
return JSON.stringify(v);
};
/**
* 决定当前节点在指定一侧是否需要渲染。
* 例如:'added' 节点只在 right 侧出现,'removed' 节点只在 left 侧出现。
*/
const shouldRenderOnSide = (type: DiffType, side: TreeSide): boolean => {
if (type === 'added') return side === 'right';
if (type === 'removed') return side === 'left';
return true;
};
const getValueForSide = (node: DiffNode, side: TreeSide): unknown => {
return side === 'left' ? node.oldValue : node.newValue;
};
const getRowBg = (type: DiffType, side: TreeSide): string | undefined => {
if (!shouldRenderOnSide(type, side)) return undefined;
if (type === 'added') return 'bg-green-50';
if (type === 'removed') return 'bg-red-50';
if (type === 'modified') return 'bg-amber-50';
return undefined;
};
const getValueColor = (type: DiffType, side: TreeSide): string | undefined => {
if (!shouldRenderOnSide(type, side)) return undefined;
if (type === 'added') return 'text-green-600';
if (type === 'removed') return 'text-red-600';
if (type === 'modified') return 'text-amber-600';
return undefined;
};
const isContainerValue = (v: unknown): boolean => {
return (typeof v === 'object' && v !== null) || Array.isArray(v);
};
const NodeRow = ({
node,
side,
depth,
defaultExpandDepth,
activePath,
isLastChild,
}: NodeRowProps) => {
// 'auto' = follow defaults + activePath; otherwise user explicitly toggled
const [override, setOverride] = useState<'auto' | 'open' | 'closed'>('auto');
const rowRef = useRef<HTMLDivElement | null>(null);
const onActivePath = Boolean(
activePath &&
(activePath === node.path ||
activePath.startsWith(`${node.path}.`) ||
activePath.startsWith(`${node.path}[`)),
);
const expanded =
override === 'open'
? true
: override === 'closed'
? false
: onActivePath || depth < defaultExpandDepth;
// 当激活路径定位到本节点时滚动到视图中心(仅 DOM 副作用,不更新 state)
useEffect(() => {
if (activePath === node.path) {
rowRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, [activePath, node.path]);
if (!shouldRenderOnSide(node.type, side)) {
// 渲染占位空行以保持左右两侧高度一致
return (
<div className="text-transparent select-none" style={{ paddingLeft: `${depth * 1.5}rem` }}>
·
</div>
);
}
const value = getValueForSide(node, side);
const isContainer = isContainerValue(value) && Array.isArray(node.children);
const isArray = Array.isArray(value);
const bg = getRowBg(node.type, side);
const valueColor = getValueColor(node.type, side);
const isActive = activePath === node.path;
// 根节点渲染
const isRoot = depth === 0;
if (isContainer && node.children) {
const open = isArray ? '[' : '{';
const close = isArray ? ']' : '}';
return (
<div ref={rowRef}>
<div
onClick={() => setOverride(expanded ? 'closed' : 'open')}
className={`cursor-pointer pr-1 py-0.5 ${bg ?? ''} ${
isActive ? 'ring-2 ring-blue-500 rounded' : ''
} flex items-center gap-1 whitespace-nowrap hover:${bg ? 'bg-opacity-80' : 'bg-muted'}`}
style={{ paddingLeft: `${depth * 1.5}rem` }}
>
<span className="w-3 text-muted-foreground text-[11px]">{expanded ? '▾' : '▸'}</span>
{!isRoot && (
<span className="text-foreground font-bold">{isArrayKeyDisplay(node.key)}:</span>
)}
<span className="text-muted-foreground">{open}</span>
{!expanded && <span className="text-muted-foreground italic">{summarize(value)}</span>}
{!expanded && (
<span className="text-muted-foreground">
{close}
{isLastChild ? '' : ','}
</span>
)}
</div>
{expanded && (
<div>
{node.children.map((child, idx) => (
<NodeRow
key={child.path}
node={child}
side={side}
depth={depth + 1}
defaultExpandDepth={defaultExpandDepth}
activePath={activePath}
isLastChild={idx === node.children!.length - 1}
/>
))}
</div>
)}
{expanded && (
<div
className="text-muted-foreground whitespace-nowrap"
style={{ paddingLeft: `${depth * 1.5 + 1.0625}rem` }}
>
{close}
{isLastChild ? '' : ','}
</div>
)}
</div>
);
}
// 叶子节点
return (
<div
ref={rowRef}
className={`pr-1 py-0.5 ${bg ?? ''} ${
isActive ? 'ring-2 ring-blue-500 rounded' : ''
} flex items-center gap-1 whitespace-nowrap`}
style={{ paddingLeft: `${depth * 1.5}rem` }}
>
<span className="w-3" />
{!isRoot && <span className="text-foreground font-bold">{isArrayKeyDisplay(node.key)}:</span>}
<span className={valueColor ?? 'text-foreground'}>
{formatPrimitive(value)}
{isLastChild ? '' : ','}
</span>
</div>
);
};
const isArrayKeyDisplay = (key: string): string => {
// 数组索引在父级渲染中已加方括号;这里仅显示对象键名
return key;
};
const summarize = (v: unknown): string => {
if (Array.isArray(v)) return ` ${v.length} ${v.length === 1 ? 'item' : 'items'} `;
if (v && typeof v === 'object') {
const n = Object.keys(v).length;
return ` ${n} ${n === 1 ? 'key' : 'keys'} `;
}
return '';
};
export default function JsonTree({
node,
side,
defaultExpandDepth = 2,
activePath,
}: JsonTreeProps) {
const sideKey = useMemo(() => side, [side]);
return (
<div className="p-3 rounded-lg bg-background border border-border font-mono text-sm overflow-x-auto min-h-[200px] max-h-[480px] overflow-y-auto">
<NodeRow
node={node}
side={sideKey}
depth={0}
defaultExpandDepth={defaultExpandDepth}
activePath={activePath}
isLastChild
/>
</div>
);
}
export type { JsonTreeProps };