Compare commits
13 Commits
31e28b136f
...
7ad548d8e2
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ad548d8e2 | |||
| 10817c3aac | |||
| 533ac84e22 | |||
| b7191c09bb | |||
| 60e74b5383 | |||
| e252b9065b | |||
| bc851414b4 | |||
| e7639fef72 | |||
| a8c0809363 | |||
| fdbb51754d | |||
| f0c3d9c173 | |||
| 14d85e5265 | |||
| c8c3c88d99 |
@@ -64,4 +64,15 @@ describe('useJsonTools 窄屏手动比对', () => {
|
|||||||
act(() => result.current.toggleCollapseA());
|
act(() => result.current.toggleCollapseA());
|
||||||
expect(result.current.collapsedA).toBe(false);
|
expect(result.current.collapsedA).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('单输入经防抖后持久化,重挂载可恢复', async () => {
|
||||||
|
localStorage.clear();
|
||||||
|
const { result, unmount } = renderHook(() => useJsonTools());
|
||||||
|
act(() => result.current.setInput('{"x":1}'));
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
const { result: result2 } = renderHook(() => useJsonTools());
|
||||||
|
expect(result2.current.input).toBe('{"x":1}');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ChevronRight } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface CollapsiblePanelProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'title'> {
|
||||||
|
/** 面板标题 */
|
||||||
|
title: string;
|
||||||
|
/** 是否处于折叠态 */
|
||||||
|
collapsed: boolean;
|
||||||
|
onToggleCollapse: () => void;
|
||||||
|
/** 折叠态展示的单行缩略预览文本 */
|
||||||
|
preview?: string;
|
||||||
|
/** 展开态内容 */
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CollapsiblePanel({
|
||||||
|
title,
|
||||||
|
collapsed,
|
||||||
|
onToggleCollapse,
|
||||||
|
preview,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: CollapsiblePanelProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col overflow-hidden rounded-md border bg-card transition-all duration-300 ease-in-out',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
flex: collapsed ? '0 0 36px' : '1 1 120px',
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggleCollapse}
|
||||||
|
className={cn(
|
||||||
|
'flex h-[36px] w-full cursor-pointer select-none items-center justify-between bg-muted/20 hover:bg-muted/30 transition-colors px-3 shrink-0 text-left focus:outline-none focus:bg-muted/40',
|
||||||
|
!collapsed && 'border-b border-border/60',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-[11px] font-semibold text-foreground/90 shrink-0">{title}</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'flex-1 truncate font-mono text-xs text-muted-foreground/70 ml-3 transition-all duration-300',
|
||||||
|
collapsed ? 'opacity-100 max-w-full' : 'opacity-0 max-w-0 pointer-events-none',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{preview}
|
||||||
|
</span>
|
||||||
|
<ChevronRight
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4 shrink-0 text-muted-foreground hover:text-foreground transition-transform duration-300',
|
||||||
|
!collapsed && 'rotate-90',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex-1 min-h-0 flex flex-col transition-opacity duration-300',
|
||||||
|
collapsed ? 'opacity-0 pointer-events-none' : 'opacity-100',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,55 +1,45 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import EmptyPlaceholder from '@/components/EmptyPlaceholder';
|
|
||||||
import TextInputArea from '@/components/TextInputArea';
|
import TextInputArea from '@/components/TextInputArea';
|
||||||
import JsonResultPanel from './JsonResultPanel';
|
import JsonResultPanel from './JsonResultPanel';
|
||||||
|
import CollapsiblePanel from './CollapsiblePanel';
|
||||||
import { validateJson } from '@/utils/jsonFormatter';
|
import { validateJson } from '@/utils/jsonFormatter';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { buildPreview, type UseJsonToolsReturn } from '../useJsonTools';
|
||||||
import type { ConvertFunction, ConvertResult } from '../types';
|
import type { ConvertFunction, ConvertResult } from '../types';
|
||||||
|
|
||||||
const CONVERT_LABELS: Record<
|
const CONVERT_LABELS: Record<string, { inputPlaceholder: string; outputLabel: string }> = {
|
||||||
string,
|
|
||||||
{ inputPlaceholder: string; outputLabel: string; emptyHint: string }
|
|
||||||
> = {
|
|
||||||
yaml: {
|
yaml: {
|
||||||
inputPlaceholder: '输入需要转换的 JSON...',
|
inputPlaceholder: '输入需要转换的 JSON...',
|
||||||
outputLabel: 'YAML 结果',
|
outputLabel: 'YAML 结果',
|
||||||
emptyHint: '输入 JSON 后点击转换',
|
|
||||||
},
|
},
|
||||||
toml: {
|
toml: {
|
||||||
inputPlaceholder: '输入需要转换的 JSON...',
|
inputPlaceholder: '输入需要转换的 JSON...',
|
||||||
outputLabel: 'TOML 结果',
|
outputLabel: 'TOML 结果',
|
||||||
emptyHint: '输入 JSON 后点击转换',
|
|
||||||
},
|
},
|
||||||
minify: {
|
minify: {
|
||||||
inputPlaceholder: '输入需要压缩的 JSON...',
|
inputPlaceholder: '输入需要压缩的 JSON...',
|
||||||
outputLabel: '压缩结果',
|
outputLabel: '压缩结果',
|
||||||
emptyHint: '输入 JSON 后点击压缩',
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
interface JsonConvertSectionProps extends React.HTMLAttributes<HTMLDivElement> {
|
interface JsonConvertSectionProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
tools: UseJsonToolsReturn;
|
||||||
mode: string;
|
mode: string;
|
||||||
convertFunction: ConvertFunction;
|
convertFunction: ConvertFunction;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function JsonConvertSection({
|
export default function JsonConvertSection({
|
||||||
|
tools,
|
||||||
mode,
|
mode,
|
||||||
convertFunction,
|
convertFunction,
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: JsonConvertSectionProps) {
|
}: JsonConvertSectionProps) {
|
||||||
const [input, setInput] = useState('');
|
const { input, setInput, debouncedInput } = tools;
|
||||||
const [debouncedInput, setDebouncedInput] = useState('');
|
const [inputCollapsed, setInputCollapsed] = useState(false);
|
||||||
|
|
||||||
const labels = CONVERT_LABELS[mode] ?? CONVERT_LABELS.yaml;
|
const labels = CONVERT_LABELS[mode] ?? CONVERT_LABELS.yaml;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handle = setTimeout(() => {
|
|
||||||
setDebouncedInput(input);
|
|
||||||
}, 250);
|
|
||||||
return () => clearTimeout(handle);
|
|
||||||
}, [input]);
|
|
||||||
|
|
||||||
const error = useMemo(() => {
|
const error = useMemo(() => {
|
||||||
return validateJson(debouncedInput);
|
return validateJson(debouncedInput);
|
||||||
}, [debouncedInput]);
|
}, [debouncedInput]);
|
||||||
@@ -71,32 +61,40 @@ export default function JsonConvertSection({
|
|||||||
}
|
}
|
||||||
}, [debouncedInput, error, convertFunction]);
|
}, [debouncedInput, error, convertFunction]);
|
||||||
|
|
||||||
return (
|
const preview = useMemo(() => buildPreview(input), [input]);
|
||||||
<div className={cn('w-full flex flex-col gap-4', className)} {...props}>
|
|
||||||
<TextInputArea
|
|
||||||
placeholder={labels.inputPlaceholder}
|
|
||||||
value={input}
|
|
||||||
onChange={setInput}
|
|
||||||
externalError={error || runtimeError || undefined}
|
|
||||||
showClear={true}
|
|
||||||
allowCopy={true}
|
|
||||||
minRows={7}
|
|
||||||
maxRows={14}
|
|
||||||
onClear={() => setInput('')}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{result?.output ? (
|
return (
|
||||||
<JsonResultPanel
|
<div className={cn('w-full flex flex-1 min-h-0 flex-col gap-3', className)} {...props}>
|
||||||
title={labels.outputLabel}
|
<CollapsiblePanel
|
||||||
content={result.output}
|
title="JSON 输入"
|
||||||
originalBytes={result.originalBytes}
|
collapsed={inputCollapsed}
|
||||||
outputBytes={result.outputBytes}
|
onToggleCollapse={() => setInputCollapsed((v) => !v)}
|
||||||
maxHeight="380px"
|
preview={preview}
|
||||||
|
>
|
||||||
|
<TextInputArea
|
||||||
|
fill
|
||||||
|
borderless
|
||||||
|
placeholder={labels.inputPlaceholder}
|
||||||
|
value={input}
|
||||||
|
onChange={setInput}
|
||||||
|
externalError={error || runtimeError || undefined}
|
||||||
|
showClear={true}
|
||||||
|
allowCopy={true}
|
||||||
|
className="min-h-0 flex-1"
|
||||||
|
onClear={() => setInput('')}
|
||||||
/>
|
/>
|
||||||
) : (
|
</CollapsiblePanel>
|
||||||
<EmptyPlaceholder>
|
|
||||||
{error ? '请修正上方 JSON 的语法错误以开启实时流式格式化' : labels.emptyHint}
|
{result?.output && (
|
||||||
</EmptyPlaceholder>
|
<div className="flex min-h-0 flex-1">
|
||||||
|
<JsonResultPanel
|
||||||
|
fill
|
||||||
|
title={labels.outputLabel}
|
||||||
|
content={result.output}
|
||||||
|
originalBytes={result.originalBytes}
|
||||||
|
outputBytes={result.outputBytes}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { ChevronRight } from 'lucide-react';
|
|
||||||
import TextInputArea from '@/components/TextInputArea';
|
import TextInputArea from '@/components/TextInputArea';
|
||||||
|
import CollapsiblePanel from './CollapsiblePanel';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
export interface JsonDiffPanelProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {
|
export interface JsonDiffPanelProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {
|
||||||
@@ -30,62 +30,30 @@ export default function JsonDiffPanel({
|
|||||||
...props
|
...props
|
||||||
}: JsonDiffPanelProps) {
|
}: JsonDiffPanelProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<CollapsiblePanel
|
||||||
|
title={title}
|
||||||
|
collapsed={collapsed}
|
||||||
|
onToggleCollapse={onToggleCollapse}
|
||||||
|
preview={preview}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex flex-col overflow-hidden rounded-md border bg-card transition-all duration-300 ease-in-out',
|
|
||||||
error
|
error
|
||||||
? 'border-destructive focus-within:ring-1 focus-within:ring-destructive focus-within:border-destructive'
|
? 'border-destructive focus-within:ring-1 focus-within:ring-destructive focus-within:border-destructive'
|
||||||
: 'border-border focus-within:ring-1 focus-within:ring-ring focus-within:border-border/80',
|
: 'border-border focus-within:ring-1 focus-within:ring-ring focus-within:border-border/80',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
style={{
|
|
||||||
flex: collapsed ? '0 0 36px' : '1 1 120px',
|
|
||||||
}}
|
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<button
|
<TextInputArea
|
||||||
type="button"
|
fill
|
||||||
onClick={onToggleCollapse}
|
value={value}
|
||||||
className={cn(
|
onChange={onChange}
|
||||||
'flex h-[36px] w-full cursor-pointer select-none items-center justify-between bg-muted/20 hover:bg-muted/30 transition-colors px-3 shrink-0 text-left focus:outline-none focus:bg-muted/40',
|
placeholder={placeholder}
|
||||||
!collapsed && 'border-b border-border/60',
|
externalError={error ?? undefined}
|
||||||
)}
|
showClear={true}
|
||||||
>
|
allowCopy={true}
|
||||||
<span className="text-[11px] font-semibold text-foreground/90 shrink-0">{title}</span>
|
borderless
|
||||||
<span
|
className="min-h-0 flex-1"
|
||||||
className={cn(
|
/>
|
||||||
'flex-1 truncate font-mono text-xs text-muted-foreground/70 ml-3 transition-all duration-300',
|
</CollapsiblePanel>
|
||||||
collapsed ? 'opacity-100 max-w-full' : 'opacity-0 max-w-0 pointer-events-none',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{preview}
|
|
||||||
</span>
|
|
||||||
<ChevronRight
|
|
||||||
className={cn(
|
|
||||||
'h-4 w-4 shrink-0 text-muted-foreground hover:text-foreground transition-transform duration-300',
|
|
||||||
!collapsed && 'rotate-90',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex-1 min-h-0 flex flex-col transition-opacity duration-300',
|
|
||||||
collapsed ? 'opacity-0 pointer-events-none' : 'opacity-100',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<TextInputArea
|
|
||||||
fill
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
placeholder={placeholder}
|
|
||||||
externalError={error ?? undefined}
|
|
||||||
showClear={true}
|
|
||||||
allowCopy={true}
|
|
||||||
borderless
|
|
||||||
className="min-h-0 flex-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,27 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
formatJson,
|
formatJson,
|
||||||
type JsonFormatOptions,
|
type JsonFormatOptions,
|
||||||
type JsonFormatResult,
|
type JsonFormatResult,
|
||||||
validateJson,
|
validateJson,
|
||||||
} from '@/utils/jsonFormatter';
|
} from '@/utils/jsonFormatter';
|
||||||
import EmptyPlaceholder from '@/components/EmptyPlaceholder';
|
|
||||||
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
||||||
import TextInputArea from '@/components/TextInputArea';
|
import TextInputArea from '@/components/TextInputArea';
|
||||||
|
import CollapsiblePanel from './CollapsiblePanel';
|
||||||
import JsonResultPanel from './JsonResultPanel';
|
import JsonResultPanel from './JsonResultPanel';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { buildPreview, type UseJsonToolsReturn } from '../useJsonTools';
|
||||||
|
|
||||||
export default function JsonFormatSection() {
|
interface JsonFormatSectionProps {
|
||||||
const [input, setInput] = useState('');
|
tools: UseJsonToolsReturn;
|
||||||
const [debouncedInput, setDebouncedInput] = useState('');
|
}
|
||||||
|
|
||||||
|
export default function JsonFormatSection({ tools }: JsonFormatSectionProps) {
|
||||||
|
const { input, setInput, debouncedInput } = tools;
|
||||||
const [indentSize, setIndentSize] = useState<number>(2);
|
const [indentSize, setIndentSize] = useState<number>(2);
|
||||||
const [sortKeys, setSortKeys] = useState(false);
|
const [sortKeys, setSortKeys] = useState(false);
|
||||||
|
const [inputCollapsed, setInputCollapsed] = useState(false);
|
||||||
useEffect(() => {
|
|
||||||
const handle = setTimeout(() => {
|
|
||||||
setDebouncedInput(input);
|
|
||||||
}, 250);
|
|
||||||
return () => clearTimeout(handle);
|
|
||||||
}, [input]);
|
|
||||||
|
|
||||||
const error = useMemo(() => {
|
const error = useMemo(() => {
|
||||||
return validateJson(debouncedInput);
|
return validateJson(debouncedInput);
|
||||||
@@ -47,9 +45,11 @@ export default function JsonFormatSection() {
|
|||||||
}
|
}
|
||||||
}, [debouncedInput, error, indentSize, sortKeys]);
|
}, [debouncedInput, error, indentSize, sortKeys]);
|
||||||
|
|
||||||
|
const preview = useMemo(() => buildPreview(input), [input]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full flex flex-col gap-4">
|
<div className="w-full flex flex-1 min-h-0 flex-col gap-3">
|
||||||
<div className="flex h-10 items-center justify-between px-1.5 bg-secondary/40 rounded-xl border border-border/60">
|
<div className="flex h-10 items-center justify-between px-1.5 bg-secondary/40 rounded-xl border border-border/60 shrink-0">
|
||||||
<div className="flex gap-4 items-center w-full">
|
<div className="flex gap-4 items-center w-full">
|
||||||
<div className="flex gap-2 items-center shrink-0 select-none">
|
<div className="flex gap-2 items-center shrink-0 select-none">
|
||||||
<span className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider">
|
<span className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider">
|
||||||
@@ -57,7 +57,7 @@ export default function JsonFormatSection() {
|
|||||||
</span>
|
</span>
|
||||||
<SwitchButtonGroup
|
<SwitchButtonGroup
|
||||||
value={indentSize}
|
value={indentSize}
|
||||||
onChange={(v) => setIndentSize(Number(v))}
|
onChange={(v: number) => setIndentSize(Number(v))}
|
||||||
options={[2, 4, 6, 8].map((size) => ({ value: size, label: String(size) }))}
|
options={[2, 4, 6, 8].map((size) => ({ value: size, label: String(size) }))}
|
||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
@@ -86,29 +86,36 @@ export default function JsonFormatSection() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TextInputArea
|
<CollapsiblePanel
|
||||||
placeholder="输入需要格式化的 JSON..."
|
title="JSON 输入"
|
||||||
value={input}
|
collapsed={inputCollapsed}
|
||||||
onChange={setInput}
|
onToggleCollapse={() => setInputCollapsed((v) => !v)}
|
||||||
externalError={error || runtimeError || undefined}
|
preview={preview}
|
||||||
showClear={true}
|
>
|
||||||
allowCopy={true}
|
<TextInputArea
|
||||||
minRows={8}
|
fill
|
||||||
maxRows={15}
|
borderless
|
||||||
onClear={() => setInput('')}
|
placeholder="输入需要格式化的 JSON..."
|
||||||
/>
|
value={input}
|
||||||
|
onChange={setInput}
|
||||||
{result?.formatted ? (
|
externalError={error || runtimeError || undefined}
|
||||||
<JsonResultPanel
|
showClear={true}
|
||||||
title="格式化结果"
|
allowCopy={true}
|
||||||
content={result.formatted}
|
className="min-h-0 flex-1"
|
||||||
originalBytes={result.originalBytes}
|
onClear={() => setInput('')}
|
||||||
outputBytes={result.formattedBytes}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
</CollapsiblePanel>
|
||||||
<EmptyPlaceholder>
|
|
||||||
{error ? '请修正上方 JSON 的语法错误以开启实时流式格式化' : '输入 JSON 后点击格式化'}
|
{result?.formatted && (
|
||||||
</EmptyPlaceholder>
|
<div className="flex min-h-0 flex-1">
|
||||||
|
<JsonResultPanel
|
||||||
|
fill
|
||||||
|
title="格式化结果"
|
||||||
|
content={result.formatted}
|
||||||
|
originalBytes={result.originalBytes}
|
||||||
|
outputBytes={result.formattedBytes}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { formatBytes } from '@/utils/format';
|
import { formatBytes } from '@/utils/format';
|
||||||
import { CopyButton } from '@/components/CopyButton';
|
import { CopyButton } from '@/components/CopyButton';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
export interface JsonResultPanelProps {
|
export interface JsonResultPanelProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -8,6 +9,11 @@ export interface JsonResultPanelProps {
|
|||||||
outputBytes: number;
|
outputBytes: number;
|
||||||
outputSizeLabel?: string;
|
outputSizeLabel?: string;
|
||||||
maxHeight?: string;
|
maxHeight?: string;
|
||||||
|
/**
|
||||||
|
* 撑满父容器剩余高度并启用内部滚动(而非按 maxHeight 截断)。
|
||||||
|
* 用于 popup / sidepanel 等固定高度场景下与折叠输入面板共享垂直空间。
|
||||||
|
*/
|
||||||
|
fill?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function JsonResultPanel({
|
export default function JsonResultPanel({
|
||||||
@@ -17,9 +23,15 @@ export default function JsonResultPanel({
|
|||||||
outputBytes,
|
outputBytes,
|
||||||
outputSizeLabel = '格式化后大小',
|
outputSizeLabel = '格式化后大小',
|
||||||
maxHeight = '420px',
|
maxHeight = '420px',
|
||||||
|
fill = false,
|
||||||
}: JsonResultPanelProps) {
|
}: JsonResultPanelProps) {
|
||||||
return (
|
return (
|
||||||
<div className="relative rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden">
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden',
|
||||||
|
fill ? 'flex flex-col flex-1 min-h-0' : 'flex flex-col',
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="flex h-9 items-center justify-between px-4 border-b border-border bg-muted/50 select-none">
|
<div className="flex h-9 items-center justify-between px-4 border-b border-border bg-muted/50 select-none">
|
||||||
<div className="flex gap-4 items-center">
|
<div className="flex gap-4 items-center">
|
||||||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/90">
|
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/90">
|
||||||
@@ -43,8 +55,11 @@ export default function JsonResultPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="p-4 font-mono text-xs text-foreground/90 whitespace-pre-wrap break-all overflow-y-auto leading-relaxed select-text"
|
className={cn(
|
||||||
style={{ maxHeight }}
|
'p-4 font-mono text-xs text-foreground/90 whitespace-pre-wrap break-all overflow-y-auto leading-relaxed select-text',
|
||||||
|
fill && 'flex-1 min-h-0',
|
||||||
|
)}
|
||||||
|
style={fill ? undefined : { maxHeight }}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import JsonFormatSection from '../JsonFormatSection';
|
||||||
|
import type { UseJsonToolsReturn } from '../../useJsonTools';
|
||||||
|
|
||||||
|
function Harness({ initial = '' }: { initial?: string }) {
|
||||||
|
const [input, setInput] = useState(initial);
|
||||||
|
const [debouncedInput, setDebouncedInput] = useState(initial);
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => setDebouncedInput(input), 250);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [input]);
|
||||||
|
const tools = { input, setInput, debouncedInput } as unknown as UseJsonToolsReturn;
|
||||||
|
return <JsonFormatSection tools={tools} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('JsonFormatSection', () => {
|
||||||
|
it('渲染输入面板标题与缩进/键名排序工具栏', () => {
|
||||||
|
render(<Harness />);
|
||||||
|
expect(screen.getByText('JSON 输入')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('缩进')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('键名排序')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('输入无效 JSON 时不渲染结果面板与占位区', async () => {
|
||||||
|
render(<Harness initial="{ invalid json " />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('格式化结果')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
screen.queryByText('请修正上方 JSON 的语法错误以开启实时流式格式化'),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('输入 JSON 后点击格式化')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('输入有效 JSON 后渲染格式化结果面板', async () => {
|
||||||
|
render(<Harness initial='{"a":1,"b":2}' />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('格式化结果')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText(/"a": 1/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/"b": 2/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('点击标题可折叠输入面板', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<Harness initial='{"a":1}' />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('格式化结果')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(screen.getByText('JSON 输入'));
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText('输入需要格式化的 JSON...');
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(input.closest('.opacity-0')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText('{"a":1}(点击展开)')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -30,13 +30,13 @@ export default function Index() {
|
|||||||
{pageMode === 'diff' ? (
|
{pageMode === 'diff' ? (
|
||||||
<DiffWorkspace tools={tools} />
|
<DiffWorkspace tools={tools} />
|
||||||
) : pageMode === 'format' ? (
|
) : pageMode === 'format' ? (
|
||||||
<JsonFormatSection />
|
<JsonFormatSection tools={tools} />
|
||||||
) : pageMode === 'yaml' ? (
|
) : pageMode === 'yaml' ? (
|
||||||
<JsonConvertSection mode="yaml" convertFunction={yamlConvert} />
|
<JsonConvertSection tools={tools} mode="yaml" convertFunction={yamlConvert} />
|
||||||
) : pageMode === 'toml' ? (
|
) : pageMode === 'toml' ? (
|
||||||
<JsonConvertSection mode="toml" convertFunction={tomlConvert} />
|
<JsonConvertSection tools={tools} mode="toml" convertFunction={tomlConvert} />
|
||||||
) : (
|
) : (
|
||||||
<JsonConvertSection mode="minify" convertFunction={minifyConvert} />
|
<JsonConvertSection tools={tools} mode="minify" convertFunction={minifyConvert} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ export interface UseJsonToolsReturn {
|
|||||||
setRightInput: (val: string) => void;
|
setRightInput: (val: string) => void;
|
||||||
leftError: string | null;
|
leftError: string | null;
|
||||||
rightError: string | null;
|
rightError: string | null;
|
||||||
|
/** format/yaml/toml/minify 共享的输入内容(已持久化) */
|
||||||
|
input: string;
|
||||||
|
setInput: (val: string) => void;
|
||||||
|
/** input 防抖后的快照,用于格式化/转换计算与持久化 */
|
||||||
|
debouncedInput: string;
|
||||||
viewMode: ViewMode;
|
viewMode: ViewMode;
|
||||||
setViewMode: (mode: ViewMode) => void;
|
setViewMode: (mode: ViewMode) => void;
|
||||||
/** 当前生效的差异结果:宽屏为实时计算,窄屏为手动 Compare 后的结果 */
|
/** 当前生效的差异结果:宽屏为实时计算,窄屏为手动 Compare 后的结果 */
|
||||||
@@ -48,7 +53,7 @@ export interface UseJsonToolsReturn {
|
|||||||
minifyConvert: ConvertFunction;
|
minifyConvert: ConvertFunction;
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildPreview = (raw: string): string => {
|
export const buildPreview = (raw: string): string => {
|
||||||
const single = raw.replace(/\s+/g, ' ').trim();
|
const single = raw.replace(/\s+/g, ' ').trim();
|
||||||
const truncated = single.length > 80 ? `${single.slice(0, 80)}…` : single;
|
const truncated = single.length > 80 ? `${single.slice(0, 80)}…` : single;
|
||||||
return truncated ? `${truncated}(点击展开)` : '(点击展开)';
|
return truncated ? `${truncated}(点击展开)` : '(点击展开)';
|
||||||
@@ -59,18 +64,34 @@ export function useJsonTools(): UseJsonToolsReturn {
|
|||||||
|
|
||||||
const [pageMode, setPageMode] = useStorageState('jsonTools/pageMode', 'diff', isValidPageMode);
|
const [pageMode, setPageMode] = useStorageState('jsonTools/pageMode', 'diff', isValidPageMode);
|
||||||
|
|
||||||
const [leftInput, setLeftInputState] = useState('');
|
const [persistedLeft, setPersistedLeft] = useStorageState('jsonTools/diffLeft', '');
|
||||||
const [rightInput, setRightInputState] = useState('');
|
const [persistedRight, setPersistedRight] = useStorageState('jsonTools/diffRight', '');
|
||||||
const [debouncedLeft, setDebouncedLeft] = useState('');
|
const [persistedInput, setPersistedInput] = useStorageState('jsonTools/input', '');
|
||||||
const [debouncedRight, setDebouncedRight] = useState('');
|
|
||||||
|
const [leftInput, setLeftInputState] = useState<string>(persistedLeft);
|
||||||
|
const [rightInput, setRightInputState] = useState<string>(persistedRight);
|
||||||
|
const [input, setInput] = useState<string>(persistedInput);
|
||||||
|
const [debouncedLeft, setDebouncedLeft] = useState<string>(persistedLeft);
|
||||||
|
const [debouncedRight, setDebouncedRight] = useState<string>(persistedRight);
|
||||||
|
const [debouncedInput, setDebouncedInput] = useState<string>(persistedInput);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handle = setTimeout(() => {
|
const handle = setTimeout(() => {
|
||||||
setDebouncedLeft(leftInput);
|
setDebouncedLeft(leftInput);
|
||||||
setDebouncedRight(rightInput);
|
setDebouncedRight(rightInput);
|
||||||
|
setPersistedLeft(leftInput);
|
||||||
|
setPersistedRight(rightInput);
|
||||||
}, 250);
|
}, 250);
|
||||||
return () => clearTimeout(handle);
|
return () => clearTimeout(handle);
|
||||||
}, [leftInput, rightInput]);
|
}, [leftInput, rightInput, setPersistedLeft, setPersistedRight]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handle = setTimeout(() => {
|
||||||
|
setDebouncedInput(input);
|
||||||
|
setPersistedInput(input);
|
||||||
|
}, 250);
|
||||||
|
return () => clearTimeout(handle);
|
||||||
|
}, [input, setPersistedInput]);
|
||||||
|
|
||||||
const parseState = useMemo(() => {
|
const parseState = useMemo(() => {
|
||||||
const invalidMsg = '无效的 JSON 格式';
|
const invalidMsg = '无效的 JSON 格式';
|
||||||
@@ -207,6 +228,9 @@ export function useJsonTools(): UseJsonToolsReturn {
|
|||||||
setRightInput,
|
setRightInput,
|
||||||
leftError,
|
leftError,
|
||||||
rightError,
|
rightError,
|
||||||
|
input,
|
||||||
|
setInput,
|
||||||
|
debouncedInput,
|
||||||
viewMode: activeViewMode,
|
viewMode: activeViewMode,
|
||||||
setViewMode,
|
setViewMode,
|
||||||
activeResult,
|
activeResult,
|
||||||
|
|||||||
Vendored
+6
@@ -89,6 +89,12 @@ export interface StorageSchema {
|
|||||||
'qrCode/urlExpanded': boolean;
|
'qrCode/urlExpanded': boolean;
|
||||||
/** JSON 工具页面当前子模式 */
|
/** JSON 工具页面当前子模式 */
|
||||||
'jsonTools/pageMode': JsonToolsPageMode;
|
'jsonTools/pageMode': JsonToolsPageMode;
|
||||||
|
/** JSON 工具:format/yaml/toml/minify 共享的输入内容 */
|
||||||
|
'jsonTools/input': string;
|
||||||
|
/** JSON 工具:diff 模式左侧输入内容 */
|
||||||
|
'jsonTools/diffLeft': string;
|
||||||
|
/** JSON 工具:diff 模式右侧输入内容 */
|
||||||
|
'jsonTools/diffRight': string;
|
||||||
/** Base64 转换器页面当前子模式 */
|
/** Base64 转换器页面当前子模式 */
|
||||||
'base64Converter/pageMode': Base64ConverterPageMode;
|
'base64Converter/pageMode': Base64ConverterPageMode;
|
||||||
/** Base64 转换器「文件」子模式当前方向 */
|
/** Base64 转换器「文件」子模式当前方向 */
|
||||||
|
|||||||
Reference in New Issue
Block a user