diff --git a/src/pages/JsonTools/__tests__/useJsonTools.test.ts b/src/pages/JsonTools/__tests__/useJsonTools.test.ts index 7a96ebc..d84563e 100644 --- a/src/pages/JsonTools/__tests__/useJsonTools.test.ts +++ b/src/pages/JsonTools/__tests__/useJsonTools.test.ts @@ -64,4 +64,15 @@ describe('useJsonTools 窄屏手动比对', () => { act(() => result.current.toggleCollapseA()); 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}'); + }); }); diff --git a/src/pages/JsonTools/components/CollapsiblePanel.tsx b/src/pages/JsonTools/components/CollapsiblePanel.tsx new file mode 100644 index 0000000..782866c --- /dev/null +++ b/src/pages/JsonTools/components/CollapsiblePanel.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { ChevronRight } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +export interface CollapsiblePanelProps extends Omit, '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 ( +
+ + +
+ {children} +
+
+ ); +} diff --git a/src/pages/JsonTools/components/JsonConvertSection.tsx b/src/pages/JsonTools/components/JsonConvertSection.tsx index 8375a5e..0d03eeb 100644 --- a/src/pages/JsonTools/components/JsonConvertSection.tsx +++ b/src/pages/JsonTools/components/JsonConvertSection.tsx @@ -1,55 +1,45 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import EmptyPlaceholder from '@/components/EmptyPlaceholder'; +import React, { useMemo } from 'react'; import TextInputArea from '@/components/TextInputArea'; import JsonResultPanel from './JsonResultPanel'; +import CollapsiblePanel from './CollapsiblePanel'; import { validateJson } from '@/utils/jsonFormatter'; import { cn } from '@/lib/utils'; +import { buildPreview, type UseJsonToolsReturn } from '../useJsonTools'; import type { ConvertFunction, ConvertResult } from '../types'; -const CONVERT_LABELS: Record< - string, - { inputPlaceholder: string; outputLabel: string; emptyHint: string } -> = { +const CONVERT_LABELS: Record = { yaml: { inputPlaceholder: '输入需要转换的 JSON...', outputLabel: 'YAML 结果', - emptyHint: '输入 JSON 后点击转换', }, toml: { inputPlaceholder: '输入需要转换的 JSON...', outputLabel: 'TOML 结果', - emptyHint: '输入 JSON 后点击转换', }, minify: { inputPlaceholder: '输入需要压缩的 JSON...', outputLabel: '压缩结果', - emptyHint: '输入 JSON 后点击压缩', }, }; interface JsonConvertSectionProps extends React.HTMLAttributes { + tools: UseJsonToolsReturn; mode: string; convertFunction: ConvertFunction; } export default function JsonConvertSection({ + tools, mode, convertFunction, className, ...props }: JsonConvertSectionProps) { - const [input, setInput] = useState(''); - const [debouncedInput, setDebouncedInput] = useState(''); + const { input, setInput, debouncedInput } = tools; + const [inputCollapsed, setInputCollapsed] = useState(false); const labels = CONVERT_LABELS[mode] ?? CONVERT_LABELS.yaml; - useEffect(() => { - const handle = setTimeout(() => { - setDebouncedInput(input); - }, 250); - return () => clearTimeout(handle); - }, [input]); - const error = useMemo(() => { return validateJson(debouncedInput); }, [debouncedInput]); @@ -71,32 +61,40 @@ export default function JsonConvertSection({ } }, [debouncedInput, error, convertFunction]); - return ( -
- setInput('')} - /> + const preview = useMemo(() => buildPreview(input), [input]); - {result?.output ? ( - + setInputCollapsed((v) => !v)} + preview={preview} + > + setInput('')} /> - ) : ( - - {error ? '请修正上方 JSON 的语法错误以开启实时流式格式化' : labels.emptyHint} - + + + {result?.output && ( +
+ +
)}
); diff --git a/src/pages/JsonTools/components/JsonDiffPanel.tsx b/src/pages/JsonTools/components/JsonDiffPanel.tsx index 0c627cf..5281e8e 100644 --- a/src/pages/JsonTools/components/JsonDiffPanel.tsx +++ b/src/pages/JsonTools/components/JsonDiffPanel.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { ChevronRight } from 'lucide-react'; import TextInputArea from '@/components/TextInputArea'; +import CollapsiblePanel from './CollapsiblePanel'; import { cn } from '@/lib/utils'; export interface JsonDiffPanelProps extends Omit, 'onChange'> { @@ -30,62 +30,30 @@ export default function JsonDiffPanel({ ...props }: JsonDiffPanelProps) { return ( -
- - -
- -
-
+ + ); } diff --git a/src/pages/JsonTools/components/JsonFormatSection.tsx b/src/pages/JsonTools/components/JsonFormatSection.tsx index 9bd7144..094d8d3 100644 --- a/src/pages/JsonTools/components/JsonFormatSection.tsx +++ b/src/pages/JsonTools/components/JsonFormatSection.tsx @@ -1,29 +1,27 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import { formatJson, type JsonFormatOptions, type JsonFormatResult, validateJson, } from '@/utils/jsonFormatter'; -import EmptyPlaceholder from '@/components/EmptyPlaceholder'; import SwitchButtonGroup from '@/components/SwitchButtonGroup'; import TextInputArea from '@/components/TextInputArea'; +import CollapsiblePanel from './CollapsiblePanel'; import JsonResultPanel from './JsonResultPanel'; import { Checkbox } from '@/components/ui/checkbox'; import { Label } from '@/components/ui/label'; +import { buildPreview, type UseJsonToolsReturn } from '../useJsonTools'; -export default function JsonFormatSection() { - const [input, setInput] = useState(''); - const [debouncedInput, setDebouncedInput] = useState(''); +interface JsonFormatSectionProps { + tools: UseJsonToolsReturn; +} + +export default function JsonFormatSection({ tools }: JsonFormatSectionProps) { + const { input, setInput, debouncedInput } = tools; const [indentSize, setIndentSize] = useState(2); const [sortKeys, setSortKeys] = useState(false); - - useEffect(() => { - const handle = setTimeout(() => { - setDebouncedInput(input); - }, 250); - return () => clearTimeout(handle); - }, [input]); + const [inputCollapsed, setInputCollapsed] = useState(false); const error = useMemo(() => { return validateJson(debouncedInput); @@ -47,9 +45,11 @@ export default function JsonFormatSection() { } }, [debouncedInput, error, indentSize, sortKeys]); + const preview = useMemo(() => buildPreview(input), [input]); + return ( -
-
+
+
@@ -57,7 +57,7 @@ export default function JsonFormatSection() { setIndentSize(Number(v))} + onChange={(v: number) => setIndentSize(Number(v))} options={[2, 4, 6, 8].map((size) => ({ value: size, label: String(size) }))} size="small" /> @@ -86,29 +86,36 @@ export default function JsonFormatSection() {
- setInput('')} - /> - - {result?.formatted ? ( - setInputCollapsed((v) => !v)} + preview={preview} + > + setInput('')} /> - ) : ( - - {error ? '请修正上方 JSON 的语法错误以开启实时流式格式化' : '输入 JSON 后点击格式化'} - + + + {result?.formatted && ( +
+ +
)}
); diff --git a/src/pages/JsonTools/components/JsonResultPanel.tsx b/src/pages/JsonTools/components/JsonResultPanel.tsx index b0b820f..5098999 100644 --- a/src/pages/JsonTools/components/JsonResultPanel.tsx +++ b/src/pages/JsonTools/components/JsonResultPanel.tsx @@ -1,5 +1,6 @@ import { formatBytes } from '@/utils/format'; import { CopyButton } from '@/components/CopyButton'; +import { cn } from '@/lib/utils'; export interface JsonResultPanelProps { title: string; @@ -8,6 +9,11 @@ export interface JsonResultPanelProps { outputBytes: number; outputSizeLabel?: string; maxHeight?: string; + /** + * 撑满父容器剩余高度并启用内部滚动(而非按 maxHeight 截断)。 + * 用于 popup / sidepanel 等固定高度场景下与折叠输入面板共享垂直空间。 + */ + fill?: boolean; } export default function JsonResultPanel({ @@ -17,9 +23,15 @@ export default function JsonResultPanel({ outputBytes, outputSizeLabel = '格式化后大小', maxHeight = '420px', + fill = false, }: JsonResultPanelProps) { return ( -
+
@@ -43,8 +55,11 @@ export default function JsonResultPanel({
{content}
diff --git a/src/pages/JsonTools/components/__tests__/JsonFormatSection.test.tsx b/src/pages/JsonTools/components/__tests__/JsonFormatSection.test.tsx new file mode 100644 index 0000000..cc07842 --- /dev/null +++ b/src/pages/JsonTools/components/__tests__/JsonFormatSection.test.tsx @@ -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 ; +} + +describe('JsonFormatSection', () => { + it('渲染输入面板标题与缩进/键名排序工具栏', () => { + render(); + expect(screen.getByText('JSON 输入')).toBeInTheDocument(); + expect(screen.getByText('缩进')).toBeInTheDocument(); + expect(screen.getByText('键名排序')).toBeInTheDocument(); + }); + + it('输入无效 JSON 时不渲染结果面板与占位区', async () => { + render(); + await waitFor(() => { + expect(screen.queryByText('格式化结果')).not.toBeInTheDocument(); + }); + expect( + screen.queryByText('请修正上方 JSON 的语法错误以开启实时流式格式化'), + ).not.toBeInTheDocument(); + expect(screen.queryByText('输入 JSON 后点击格式化')).not.toBeInTheDocument(); + }); + + it('输入有效 JSON 后渲染格式化结果面板', async () => { + render(); + 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(); + 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(); + }); +}); diff --git a/src/pages/JsonTools/index.tsx b/src/pages/JsonTools/index.tsx index f019610..9fb13a8 100644 --- a/src/pages/JsonTools/index.tsx +++ b/src/pages/JsonTools/index.tsx @@ -30,13 +30,13 @@ export default function Index() { {pageMode === 'diff' ? ( ) : pageMode === 'format' ? ( - + ) : pageMode === 'yaml' ? ( - + ) : pageMode === 'toml' ? ( - + ) : ( - + )}
); diff --git a/src/pages/JsonTools/useJsonTools.ts b/src/pages/JsonTools/useJsonTools.ts index 842079a..604601b 100644 --- a/src/pages/JsonTools/useJsonTools.ts +++ b/src/pages/JsonTools/useJsonTools.ts @@ -21,6 +21,11 @@ export interface UseJsonToolsReturn { setRightInput: (val: string) => void; leftError: string | null; rightError: string | null; + /** format/yaml/toml/minify 共享的输入内容(已持久化) */ + input: string; + setInput: (val: string) => void; + /** input 防抖后的快照,用于格式化/转换计算与持久化 */ + debouncedInput: string; viewMode: ViewMode; setViewMode: (mode: ViewMode) => void; /** 当前生效的差异结果:宽屏为实时计算,窄屏为手动 Compare 后的结果 */ @@ -48,7 +53,7 @@ export interface UseJsonToolsReturn { minifyConvert: ConvertFunction; } -const buildPreview = (raw: string): string => { +export const buildPreview = (raw: string): string => { const single = raw.replace(/\s+/g, ' ').trim(); const truncated = single.length > 80 ? `${single.slice(0, 80)}…` : single; return truncated ? `${truncated}(点击展开)` : '(点击展开)'; @@ -59,18 +64,34 @@ export function useJsonTools(): UseJsonToolsReturn { const [pageMode, setPageMode] = useStorageState('jsonTools/pageMode', 'diff', isValidPageMode); - const [leftInput, setLeftInputState] = useState(''); - const [rightInput, setRightInputState] = useState(''); - const [debouncedLeft, setDebouncedLeft] = useState(''); - const [debouncedRight, setDebouncedRight] = useState(''); + const [persistedLeft, setPersistedLeft] = useStorageState('jsonTools/diffLeft', ''); + const [persistedRight, setPersistedRight] = useStorageState('jsonTools/diffRight', ''); + const [persistedInput, setPersistedInput] = useStorageState('jsonTools/input', ''); + + const [leftInput, setLeftInputState] = useState(persistedLeft); + const [rightInput, setRightInputState] = useState(persistedRight); + const [input, setInput] = useState(persistedInput); + const [debouncedLeft, setDebouncedLeft] = useState(persistedLeft); + const [debouncedRight, setDebouncedRight] = useState(persistedRight); + const [debouncedInput, setDebouncedInput] = useState(persistedInput); useEffect(() => { const handle = setTimeout(() => { setDebouncedLeft(leftInput); setDebouncedRight(rightInput); + setPersistedLeft(leftInput); + setPersistedRight(rightInput); }, 250); 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 invalidMsg = '无效的 JSON 格式'; @@ -207,6 +228,9 @@ export function useJsonTools(): UseJsonToolsReturn { setRightInput, leftError, rightError, + input, + setInput, + debouncedInput, viewMode: activeViewMode, setViewMode, activeResult, diff --git a/src/types/storage.d.ts b/src/types/storage.d.ts index eb906de..7fb43e1 100644 --- a/src/types/storage.d.ts +++ b/src/types/storage.d.ts @@ -89,6 +89,12 @@ export interface StorageSchema { 'qrCode/urlExpanded': boolean; /** JSON 工具页面当前子模式 */ 'jsonTools/pageMode': JsonToolsPageMode; + /** JSON 工具:format/yaml/toml/minify 共享的输入内容 */ + 'jsonTools/input': string; + /** JSON 工具:diff 模式左侧输入内容 */ + 'jsonTools/diffLeft': string; + /** JSON 工具:diff 模式右侧输入内容 */ + 'jsonTools/diffRight': string; /** Base64 转换器页面当前子模式 */ 'base64Converter/pageMode': Base64ConverterPageMode; /** Base64 转换器「文件」子模式当前方向 */