diff --git a/src/components/TextInputArea.tsx b/src/components/TextInputArea.tsx index 3af1d16..9e92015 100644 --- a/src/components/TextInputArea.tsx +++ b/src/components/TextInputArea.tsx @@ -42,6 +42,12 @@ export interface TextInputAreaProps extends Omit< title?: string; externalError?: string; onClear?: () => void; + + /** + * 撑满父容器高度并启用内部滚动(而非按 minRows/maxRows 自动撑高)。 + * 用于需要固定高度 + 内部滚动的卡片面板。 + */ + fill?: boolean; } function ActionButton({ @@ -106,6 +112,7 @@ const TextInputArea = forwardRef((props title, externalError, onClear, + fill = false, ...restProps } = props; @@ -122,6 +129,7 @@ const TextInputArea = forwardRef((props useImperativeHandle(ref, () => internalRef.current as HTMLTextAreaElement); const adjustHeight = useCallback(() => { + if (fill) return; const textArea = internalRef.current; if (!textArea) return; @@ -132,7 +140,7 @@ const TextInputArea = forwardRef((props const nextHeight = Math.max(textArea.scrollHeight, computedMin); textArea.style.height = `${Math.min(nextHeight, computedMax)}px`; - }, [minRows, maxRows]); + }, [minRows, maxRows, fill]); React.useEffect(() => { adjustHeight(); @@ -204,7 +212,7 @@ const TextInputArea = forwardRef((props const hasBottomBar = allowCopy || showClear || bottomActions.length > 0; return ( -
+
{hasTopBar && (
@@ -234,6 +242,7 @@ const TextInputArea = forwardRef((props
((props autoFocus={autoFocus} readOnly={readOnly} placeholder={placeholder} - className="w-full bg-transparent px-4 py-3 font-mono text-sm leading-relaxed text-foreground placeholder:text-muted-foreground/50 focus:outline-none resize-none border-0 block" + className={cn( + 'w-full bg-transparent px-4 py-3 font-mono text-sm leading-relaxed text-foreground placeholder:text-muted-foreground/50 focus:outline-none resize-none border-0 block', + fill && 'flex-1 h-full overflow-auto diff-scroll', + )} {...restProps} /> diff --git a/src/hooks/useMediaQuery.ts b/src/hooks/useMediaQuery.ts new file mode 100644 index 0000000..54d0447 --- /dev/null +++ b/src/hooks/useMediaQuery.ts @@ -0,0 +1,25 @@ +import { useCallback, useSyncExternalStore } from 'react'; + +/** + * 监听媒体查询,返回是否匹配。 + * 用于在窄屏(popup/sidepanel)与宽屏(tab)间切换布局。 + */ +export function useMediaQuery(query: string): boolean { + const subscribe = useCallback( + (onChange: () => void) => { + if (typeof window === 'undefined' || !window.matchMedia) return () => {}; + const mql = window.matchMedia(query); + mql.addEventListener('change', onChange); + return () => mql.removeEventListener('change', onChange); + }, + [query], + ); + + const getSnapshot = useCallback( + () => + typeof window !== 'undefined' && window.matchMedia ? window.matchMedia(query).matches : false, + [query], + ); + + return useSyncExternalStore(subscribe, getSnapshot, () => false); +} diff --git a/src/pages/JsonTools/__tests__/index.test.tsx b/src/pages/JsonTools/__tests__/index.test.tsx index 66c0f00..c05e6a0 100644 --- a/src/pages/JsonTools/__tests__/index.test.tsx +++ b/src/pages/JsonTools/__tests__/index.test.tsx @@ -1,10 +1,43 @@ import { render, screen } from '@testing-library/react'; -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import Index from '../index'; +const setWide = (wide: boolean) => { + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: wide, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })); +}; + describe('JsonTools 页面', () => { + beforeEach(() => { + // 默认窄屏(matches:false) + setWide(false); + }); + it('应该渲染模式切换按钮', () => { render(); expect(screen.getAllByRole('button').length).toBeGreaterThan(0); }); + + it('窄屏渲染 A/B 数据面板与 Compare 按钮', () => { + setWide(false); + render(); + expect(screen.getByText('数据 A(原始)')).toBeInTheDocument(); + expect(screen.getByText('数据 B(目标)')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Compare A & B/i })).toBeInTheDocument(); + }); + + it('宽屏渲染原始/目标 JSON 标签', () => { + setWide(true); + render(); + expect(screen.getByText('原始 JSON')).toBeInTheDocument(); + expect(screen.getByText('目标 JSON')).toBeInTheDocument(); + }); }); diff --git a/src/pages/JsonTools/__tests__/useJsonTools.test.ts b/src/pages/JsonTools/__tests__/useJsonTools.test.ts new file mode 100644 index 0000000..7a96ebc --- /dev/null +++ b/src/pages/JsonTools/__tests__/useJsonTools.test.ts @@ -0,0 +1,67 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { useJsonTools } from '../useJsonTools'; + +describe('useJsonTools 窄屏手动比对', () => { + it('canCompare 在两侧输入合法前为 false', () => { + const { result } = renderHook(() => useJsonTools()); + expect(result.current.canCompare).toBe(false); + + act(() => result.current.setLeftInput('{"a":1}')); + expect(result.current.canCompare).toBe(false); + + act(() => result.current.setRightInput('{"b":2}')); + expect(result.current.canCompare).toBe(true); + }); + + it('非法 JSON 时 canCompare 为 false', () => { + const { result } = renderHook(() => useJsonTools()); + act(() => { + result.current.setLeftInput('{"a":1}'); + result.current.setRightInput('not json'); + }); + expect(result.current.canCompare).toBe(false); + }); + + it('handleCompare 生成结果并自动折叠两侧面板', () => { + const { result } = renderHook(() => useJsonTools()); + act(() => { + result.current.setLeftInput('{"a":1}'); + result.current.setRightInput('{"a":2}'); + }); + act(() => result.current.handleCompare()); + + expect(result.current.hasCompared).toBe(true); + expect(result.current.activeResult).not.toBeNull(); + expect(result.current.total).toBeGreaterThan(0); + expect(result.current.collapsedA).toBe(true); + expect(result.current.collapsedB).toBe(true); + }); + + it('比对后修改输入会重置结果,但保留折叠态', () => { + const { result } = renderHook(() => useJsonTools()); + act(() => { + result.current.setLeftInput('{"a":1}'); + result.current.setRightInput('{"a":2}'); + }); + act(() => result.current.handleCompare()); + expect(result.current.hasCompared).toBe(true); + expect(result.current.collapsedA).toBe(true); + expect(result.current.collapsedB).toBe(true); + + act(() => result.current.setLeftInput('{"a":3}')); + expect(result.current.hasCompared).toBe(false); + expect(result.current.activeResult).toBeNull(); + expect(result.current.collapsedA).toBe(true); + expect(result.current.collapsedB).toBe(true); + }); + + it('toggleCollapseA/B 可逆切换折叠态', () => { + const { result } = renderHook(() => useJsonTools()); + expect(result.current.collapsedA).toBe(false); + act(() => result.current.toggleCollapseA()); + expect(result.current.collapsedA).toBe(true); + act(() => result.current.toggleCollapseA()); + expect(result.current.collapsedA).toBe(false); + }); +}); diff --git a/src/pages/JsonTools/components/CompareActionBar.tsx b/src/pages/JsonTools/components/CompareActionBar.tsx new file mode 100644 index 0000000..fa84cdc --- /dev/null +++ b/src/pages/JsonTools/components/CompareActionBar.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { ArrowLeftRight } from 'lucide-react'; +import DiffNavigator from './DiffNavigator'; +import { cn } from '@/lib/utils'; + +export interface CompareActionBarProps extends React.HTMLAttributes { + canCompare: boolean; + onCompare: () => void; + hasCompared: boolean; + total: number; + currentIndex: number; + onPrev: () => void; + onNext: () => void; + resultsVisible?: boolean; + onShowResults?: () => void; +} + +export default function CompareActionBar({ + canCompare, + onCompare, + hasCompared, + total, + currentIndex, + onPrev, + onNext, + resultsVisible = true, + onShowResults, + className, + ...props +}: CompareActionBarProps) { + return ( +
+ {hasCompared ? ( + resultsVisible ? ( + + ) : ( + + ) + ) : ( + + )} +
+ ); +} diff --git a/src/pages/JsonTools/components/DiffResult.tsx b/src/pages/JsonTools/components/DiffResult.tsx index 9a5a668..cf6df2e 100644 --- a/src/pages/JsonTools/components/DiffResult.tsx +++ b/src/pages/JsonTools/components/DiffResult.tsx @@ -7,28 +7,99 @@ export interface DiffResultProps extends React.HTMLAttributes { result: DiffResultType; viewMode: ViewMode; activePath?: string; + /** 窄屏填充模式:撑满父容器可用高度并内部滚动,而非使用固定最小/最大高度 */ + fill?: boolean; } export default function DiffResult({ result, viewMode, activePath, + fill = false, className, ...props }: DiffResultProps) { + const [expandedPaths, setExpandedPaths] = React.useState>({}); + const togglePath = React.useCallback((path: string, currentExpanded: boolean) => { + setExpandedPaths((prev) => ({ + ...prev, + [path]: currentExpanded ? 'closed' : 'open', + })); + }, []); + + const leftRef = React.useRef(null); + const rightRef = React.useRef(null); + + React.useEffect(() => { + const leftEl = leftRef.current; + const rightEl = rightRef.current; + if (!leftEl || !rightEl) return; + + let isSyncingLeftScroll = false; + let isSyncingRightScroll = false; + + const handleLeftScroll = () => { + if (isSyncingLeftScroll) { + isSyncingLeftScroll = false; + return; + } + isSyncingRightScroll = true; + rightEl.scrollTop = leftEl.scrollTop; + rightEl.scrollLeft = leftEl.scrollLeft; + }; + + const handleRightScroll = () => { + if (isSyncingRightScroll) { + isSyncingRightScroll = false; + return; + } + isSyncingLeftScroll = true; + leftEl.scrollTop = rightEl.scrollTop; + leftEl.scrollLeft = rightEl.scrollLeft; + }; + + leftEl.addEventListener('scroll', handleLeftScroll); + rightEl.addEventListener('scroll', handleRightScroll); + + return () => { + leftEl.removeEventListener('scroll', handleLeftScroll); + rightEl.removeEventListener('scroll', handleRightScroll); + }; + }, [viewMode]); + if (viewMode === 'sideBySide') { return (
-
+
- +
-
+
- +
); @@ -37,12 +108,15 @@ export default function DiffResult({ return (
- +
+ +
); } @@ -189,7 +263,7 @@ const UnifiedRow = ({ depth, type, text, active, multiline }: UnifiedRowProps) = currentTheme.bg, currentTheme.text, active && - 'bg-primary/10 relative before:absolute before:left-0 before:top-0 before:bottom-0 before:w-1 before:bg-blue-500', + 'bg-primary/10 relative before:absolute before:left-0 before:top-0 before:bottom-0 before:w-1 before:bg-blue-500 before:content-[""]', )} style={{ paddingLeft: `${Math.max(0.5, depth * 1.25)}rem`, diff --git a/src/pages/JsonTools/components/DiffWorkspace.tsx b/src/pages/JsonTools/components/DiffWorkspace.tsx new file mode 100644 index 0000000..677c7df --- /dev/null +++ b/src/pages/JsonTools/components/DiffWorkspace.tsx @@ -0,0 +1,154 @@ +import JsonDiffInput from './JsonDiffInput'; +import JsonDiffPanel from './JsonDiffPanel'; +import CompareActionBar from './CompareActionBar'; +import DiffResult from './DiffResult'; +import DiffNavigator from './DiffNavigator'; +import SwitchButtonGroup from '@/components/SwitchButtonGroup'; +import EmptyPlaceholder from '@/components/EmptyPlaceholder'; +import type { UseJsonToolsReturn } from '../useJsonTools'; +import type { ViewMode } from '../types'; + +interface DiffWorkspaceProps { + tools: UseJsonToolsReturn; +} + +const VIEW_OPTIONS: { value: ViewMode; label: string }[] = [ + { value: 'sideBySide', label: '并排' }, + { value: 'unified', label: '统一' }, +]; + +export default function DiffWorkspace({ tools }: DiffWorkspaceProps) { + const { + isWide, + viewMode, + setViewMode, + leftInput, + rightInput, + setLeftInput, + setRightInput, + leftError, + rightError, + activeResult, + total, + currentDiffIndex, + handlePrev, + handleNext, + activePath, + hasCompared, + collapsedA, + collapsedB, + toggleCollapseA, + toggleCollapseB, + canCompare, + handleCompare, + previewA, + previewB, + } = tools; + + const viewToggle = ( +
+ setViewMode(v)} + options={VIEW_OPTIONS} + size="small" + /> +
+ ); + + if (isWide) { + return ( +
+ {viewToggle} + +
+ + +
+ + {activeResult ? ( +
+
+ +
+ +
+ ) : ( + + {leftError || rightError + ? '请修正上方 JSON 的语法错误以开启实时流式比对' + : '输入两侧 JSON 后点击比较'} + + )} +
+ ); + } + + const resultsVisible = collapsedA && collapsedB; + + const handleShowResults = () => { + if (!collapsedA) toggleCollapseA(); + if (!collapsedB) toggleCollapseB(); + }; + + return ( +
+ + + + + {activeResult && resultsVisible ? ( +
+ +
+ ) : null} +
+ ); +} diff --git a/src/pages/JsonTools/components/JsonDiffPanel.tsx b/src/pages/JsonTools/components/JsonDiffPanel.tsx new file mode 100644 index 0000000..dc995ca --- /dev/null +++ b/src/pages/JsonTools/components/JsonDiffPanel.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { ChevronDown, ChevronRight } from 'lucide-react'; +import TextInputArea from '@/components/TextInputArea'; +import { cn } from '@/lib/utils'; + +export interface JsonDiffPanelProps extends Omit, 'onChange'> { + /** 面板标题,如 "数据 A(原始)" */ + title: string; + placeholder: string; + value: string; + onChange: (value: string) => void; + error?: string | null; + /** 是否处于折叠态(Accordion 收起) */ + collapsed: boolean; + onToggleCollapse: () => void; + /** 折叠态展示的单行缩略预览文本 */ + preview: string; +} + +export default function JsonDiffPanel({ + title, + placeholder, + value, + onChange, + error, + collapsed, + onToggleCollapse, + preview, + className, + ...props +}: JsonDiffPanelProps) { + return ( +
+ {collapsed ? ( + + ) : ( + <> +
+ {title} + +
+ + + )} +
+ ); +} diff --git a/src/pages/JsonTools/components/JsonTree.tsx b/src/pages/JsonTools/components/JsonTree.tsx index e3ee6f2..abb3450 100644 --- a/src/pages/JsonTools/components/JsonTree.tsx +++ b/src/pages/JsonTools/components/JsonTree.tsx @@ -10,6 +10,9 @@ export interface JsonTreeProps extends React.HTMLAttributes { side: TreeSide; defaultExpandDepth?: number; activePath?: string; + fill?: boolean; + expandedPaths?: Record; + onTogglePath?: (path: string, currentExpanded: boolean) => void; } interface NodeRowProps { @@ -19,6 +22,8 @@ interface NodeRowProps { defaultExpandDepth: number; activePath?: string; isLastChild: boolean; + expandedPaths?: Record; + onTogglePath?: (path: string, currentExpanded: boolean) => void; } const formatPrimitive = (v: unknown): string => { @@ -62,8 +67,17 @@ const isContainerValue = (v: unknown): boolean => { }; const NodeRow = React.memo( - ({ node, side, depth, defaultExpandDepth, activePath, isLastChild }: NodeRowProps) => { - const [override, setOverride] = useState<'auto' | 'open' | 'closed'>('auto'); + ({ + node, + side, + depth, + defaultExpandDepth, + activePath, + isLastChild, + expandedPaths = {}, + onTogglePath, + }: NodeRowProps) => { + const override = expandedPaths[node.path] || 'auto'; const rowRef = useRef(null); const onActivePath = useMemo(() => { @@ -123,7 +137,7 @@ const NodeRow = React.memo( return (
setOverride(expanded ? 'closed' : 'open')} + onClick={() => onTogglePath?.(node.path, expanded)} className={cn( 'group flex items-center gap-1 py-0.5 pr-2 text-xs font-mono select-none cursor-pointer rounded-sm w-full h-[22px] leading-relaxed', theme.bg, @@ -171,6 +185,8 @@ const NodeRow = React.memo( defaultExpandDepth={defaultExpandDepth} activePath={activePath} isLastChild={idx === node.children!.length - 1} + expandedPaths={expandedPaths} + onTogglePath={onTogglePath} /> ))}
@@ -204,7 +220,7 @@ const NodeRow = React.memo( {!isRoot && ( {node.key}: )} - + {formatPrimitive(value)} {isLastChild ? '' : ','} @@ -215,33 +231,51 @@ const NodeRow = React.memo( NodeRow.displayName = 'NodeRow'; -export default function JsonTree({ - node, - side, - defaultExpandDepth = 2, - activePath, - className, - ...props -}: JsonTreeProps) { - return ( -
- -
- ); -} +const JsonTree = React.forwardRef( + ( + { + node, + side, + defaultExpandDepth = 2, + activePath, + fill = false, + expandedPaths, + onTogglePath, + className, + ...props + }, + ref, + ) => { + return ( +
+
+ +
+
+ ); + }, +); + +JsonTree.displayName = 'JsonTree'; + +export default JsonTree; const summarize = (v: unknown): string => { if (Array.isArray(v)) return `${v.length} ${v.length === 1 ? 'item' : 'items'}`; diff --git a/src/pages/JsonTools/components/__tests__/CompareActionBar.test.tsx b/src/pages/JsonTools/components/__tests__/CompareActionBar.test.tsx new file mode 100644 index 0000000..734fa71 --- /dev/null +++ b/src/pages/JsonTools/components/__tests__/CompareActionBar.test.tsx @@ -0,0 +1,56 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi } from 'vitest'; +import CompareActionBar from '../CompareActionBar'; + +describe('CompareActionBar', () => { + it('不可比较时按钮禁用', () => { + render( + {}} + hasCompared={false} + total={0} + currentIndex={0} + onPrev={() => {}} + onNext={() => {}} + />, + ); + expect(screen.getByRole('button', { name: /Compare A & B/i })).toBeDisabled(); + }); + + it('可比较时点击触发 onCompare', async () => { + const user = userEvent.setup(); + const onCompare = vi.fn(); + render( + {}} + onNext={() => {}} + />, + ); + await user.click(screen.getByRole('button', { name: /Compare A & B/i })); + expect(onCompare).toHaveBeenCalled(); + }); + + it('已比较后展示 DiffNavigator(上一处/下一处)', () => { + render( + {}} + hasCompared={true} + total={3} + currentIndex={1} + onPrev={() => {}} + onNext={() => {}} + />, + ); + expect(screen.getByLabelText('上一个')).toBeInTheDocument(); + expect(screen.getByLabelText('下一个')).toBeInTheDocument(); + expect(screen.queryByText('无差异')).not.toBeInTheDocument(); + }); +}); diff --git a/src/pages/JsonTools/components/__tests__/JsonDiffPanel.test.tsx b/src/pages/JsonTools/components/__tests__/JsonDiffPanel.test.tsx new file mode 100644 index 0000000..66009cb --- /dev/null +++ b/src/pages/JsonTools/components/__tests__/JsonDiffPanel.test.tsx @@ -0,0 +1,56 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi } from 'vitest'; +import JsonDiffPanel from '../JsonDiffPanel'; + +describe('JsonDiffPanel', () => { + it('展开态渲染标题与输入框', () => { + render( + {}} + collapsed={false} + onToggleCollapse={() => {}} + preview="" + />, + ); + expect(screen.getByText('数据 A(原始)')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('x')).toBeInTheDocument(); + }); + + it('折叠态渲染预览并隐藏输入框', () => { + render( + {}} + collapsed={true} + onToggleCollapse={() => {}} + preview="abc(点击展开)" + />, + ); + expect(screen.getByText('abc(点击展开)')).toBeInTheDocument(); + expect(screen.queryByPlaceholderText('x')).not.toBeInTheDocument(); + }); + + it('点击标题可切换折叠', async () => { + const user = userEvent.setup(); + const onToggle = vi.fn(); + render( + {}} + collapsed={false} + onToggleCollapse={onToggle} + preview="" + />, + ); + await user.click(screen.getByText('T')); + expect(onToggle).toHaveBeenCalled(); + }); +}); diff --git a/src/pages/JsonTools/index.tsx b/src/pages/JsonTools/index.tsx index fe44397..f019610 100644 --- a/src/pages/JsonTools/index.tsx +++ b/src/pages/JsonTools/index.tsx @@ -1,41 +1,18 @@ -import JsonDiffInput from './components/JsonDiffInput'; -import DiffResult from './components/DiffResult'; -import DiffNavigator from './components/DiffNavigator'; +import DiffWorkspace from './components/DiffWorkspace'; import JsonFormatSection from './components/JsonFormatSection'; import JsonConvertSection from './components/JsonConvertSection'; import SwitchButtonGroup from '@/components/SwitchButtonGroup'; -import EmptyPlaceholder from '@/components/EmptyPlaceholder'; import { useJsonTools } from './useJsonTools'; import type { JsonToolsPageMode } from '@/types/storage'; -import type { ViewMode } from './types'; type PageMode = JsonToolsPageMode; export default function Index() { - const { - pageMode, - setPageMode, - leftInput, - rightInput, - setLeftInput, - setRightInput, - leftError, - rightError, - viewMode, - setViewMode, - diffResult, - total, - currentDiffIndex, - handlePrev, - handleNext, - activePath, - yamlConvert, - tomlConvert, - minifyConvert, - } = useJsonTools(); + const tools = useJsonTools(); + const { pageMode, setPageMode, yamlConvert, tomlConvert, minifyConvert } = tools; return ( -
+
setPageMode(v)} @@ -47,62 +24,11 @@ export default function Index() { { value: 'minify', label: '压缩' }, ]} size="small" - className="w-full sm:w-auto" + className="w-full shrink-0 sm:w-auto" /> {pageMode === 'diff' ? ( -
-
- setViewMode(v)} - options={[ - { value: 'sideBySide', label: '并排' }, - { value: 'unified', label: '统一' }, - ]} - size="small" - /> -
- -
- - -
- - {diffResult ? ( -
-
- -
- -
- ) : ( - - {leftError || rightError - ? '请修正上方 JSON 的语法错误以开启实时流式比对' - : '输入两侧 JSON 后点击比较'} - - )} -
+ ) : pageMode === 'format' ? ( ) : pageMode === 'yaml' ? ( diff --git a/src/pages/JsonTools/useJsonTools.ts b/src/pages/JsonTools/useJsonTools.ts index 46623a1..842079a 100644 --- a/src/pages/JsonTools/useJsonTools.ts +++ b/src/pages/JsonTools/useJsonTools.ts @@ -5,10 +5,14 @@ import { jsonToYaml } from '@/utils/jsonToYaml'; import { jsonToToml } from '@/utils/jsonToToml'; import { minifyJson } from '@/utils/jsonFormatter'; import { isValidPageMode, tryParse } from './constants'; +import { useMediaQuery } from '@/hooks/useMediaQuery'; import type { JsonToolsPageMode } from '@/types/storage'; import type { ConvertFunction, ViewMode } from './types'; +type DiffResultValue = ReturnType; + export interface UseJsonToolsReturn { + isWide: boolean; pageMode: JsonToolsPageMode; setPageMode: (mode: JsonToolsPageMode) => void; leftInput: string; @@ -19,22 +23,44 @@ export interface UseJsonToolsReturn { rightError: string | null; viewMode: ViewMode; setViewMode: (mode: ViewMode) => void; - diffResult: ReturnType | null; + /** 当前生效的差异结果:宽屏为实时计算,窄屏为手动 Compare 后的结果 */ + activeResult: DiffResultValue | null; total: number; currentDiffIndex: number; handlePrev: () => void; handleNext: () => void; activePath: string | undefined; + /** 窄屏:是否已点击 Compare 生成结果 */ + hasCompared: boolean; + /** 窄屏:左右面板是否折叠 */ + collapsedA: boolean; + collapsedB: boolean; + toggleCollapseA: () => void; + toggleCollapseB: () => void; + /** 窄屏:两侧均有合法非空输入时可触发比对 */ + canCompare: boolean; + handleCompare: () => void; + /** 窄屏:折叠态单行预览文本 */ + previewA: string; + previewB: string; yamlConvert: ConvertFunction; tomlConvert: ConvertFunction; minifyConvert: ConvertFunction; } +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}(点击展开)` : '(点击展开)'; +}; + export function useJsonTools(): UseJsonToolsReturn { + const isWide = useMediaQuery('(min-width: 768px)'); + const [pageMode, setPageMode] = useStorageState('jsonTools/pageMode', 'diff', isValidPageMode); - const [leftInput, setLeftInput] = useState(''); - const [rightInput, setRightInput] = useState(''); + const [leftInput, setLeftInputState] = useState(''); + const [rightInput, setRightInputState] = useState(''); const [debouncedLeft, setDebouncedLeft] = useState(''); const [debouncedRight, setDebouncedRight] = useState(''); @@ -58,9 +84,17 @@ export function useJsonTools(): UseJsonToolsReturn { const rightError = parseState.right.error; const [viewMode, setViewMode] = useState('sideBySide'); + const activeViewMode = isWide ? viewMode : 'unified'; const [currentDiffIndex, setCurrentDiffIndex] = useState(0); - const diffResult = useMemo(() => { + // 窄屏手动比对状态 + const [hasCompared, setHasCompared] = useState(false); + const [manualResult, setManualResult] = useState(null); + const [collapsedA, setCollapsedA] = useState(false); + const [collapsedB, setCollapsedB] = useState(false); + + // 宽屏:实时流式比对(保持原行为) + const liveResult = useMemo(() => { const { left, right } = parseState; if (left.error || right.error || debouncedLeft.trim() === '' || debouncedRight.trim() === '') { return null; @@ -68,7 +102,70 @@ export function useJsonTools(): UseJsonToolsReturn { return diffJson(left.value, right.value); }, [parseState, debouncedLeft, debouncedRight]); - const total = diffResult?.diffPaths.length ?? 0; + const activeResult = isWide ? liveResult : manualResult; + + // 即时解析(用于比对按钮可用态与折叠预览,避免 250ms 延迟) + const immediateParse = useMemo(() => { + const invalidMsg = '无效的 JSON 格式'; + return { + left: tryParse(leftInput, invalidMsg), + right: tryParse(rightInput, invalidMsg), + }; + }, [leftInput, rightInput]); + + const canCompare = + leftInput.trim() !== '' && + rightInput.trim() !== '' && + !immediateParse.left.error && + !immediateParse.right.error; + + const previewA = useMemo(() => buildPreview(leftInput), [leftInput]); + const previewB = useMemo(() => buildPreview(rightInput), [rightInput]); + + const setLeftInput = useCallback( + (val: string) => { + setLeftInputState(val); + if (hasCompared) { + setHasCompared(false); + setManualResult(null); + setCurrentDiffIndex(0); + } else { + setCurrentDiffIndex(0); + } + }, + [hasCompared], + ); + + const setRightInput = useCallback( + (val: string) => { + setRightInputState(val); + if (hasCompared) { + setHasCompared(false); + setManualResult(null); + setCurrentDiffIndex(0); + } else { + setCurrentDiffIndex(0); + } + }, + [hasCompared], + ); + + const handleCompare = useCallback(() => { + if (!canCompare) return; + const left = immediateParse.left.value; + const right = immediateParse.right.value; + const result = diffJson(left, right); + setManualResult(result); + setHasCompared(true); + setCollapsedA(true); + setCollapsedB(true); + setCurrentDiffIndex(0); + }, [canCompare, immediateParse]); + + const toggleCollapseA = useCallback(() => setCollapsedA((v) => !v), []); + const toggleCollapseB = useCallback(() => setCollapsedB((v) => !v), []); + + const total = activeResult?.diffPaths.length ?? 0; const handlePrev = useCallback(() => { if (total === 0) return; @@ -80,7 +177,10 @@ export function useJsonTools(): UseJsonToolsReturn { setCurrentDiffIndex((idx) => (idx + 1) % total); }, [total]); - const activePath = diffResult && total > 0 ? diffResult.diffPaths[currentDiffIndex] : undefined; + const activePath = + activeResult && total > 0 && currentDiffIndex < total + ? activeResult.diffPaths[currentDiffIndex] + : undefined; const yamlConvert: ConvertFunction = useCallback((text: string) => { const r = jsonToYaml(text); @@ -98,28 +198,32 @@ export function useJsonTools(): UseJsonToolsReturn { }, []); return { + isWide, pageMode, setPageMode, leftInput, rightInput, - setLeftInput: (val: string) => { - setLeftInput(val); - setCurrentDiffIndex(0); - }, - setRightInput: (val: string) => { - setRightInput(val); - setCurrentDiffIndex(0); - }, + setLeftInput, + setRightInput, leftError, rightError, - viewMode, + viewMode: activeViewMode, setViewMode, - diffResult, + activeResult, total, currentDiffIndex, handlePrev, handleNext, activePath, + hasCompared, + collapsedA, + collapsedB, + toggleCollapseA, + toggleCollapseB, + canCompare, + handleCompare, + previewA, + previewB, yamlConvert, tomlConvert, minifyConvert, diff --git a/src/styles/shell.css b/src/styles/shell.css index 6f1f4a7..792dac9 100644 --- a/src/styles/shell.css +++ b/src/styles/shell.css @@ -93,6 +93,27 @@ display: none; } +/* 细滚动条:仅作用于需要内部滚动的面板/结果区(覆盖上方的全局隐藏) */ +.diff-scroll { + scrollbar-width: thin; + scrollbar-color: rgba(0, 0, 0, 0.2) transparent; +} +.diff-scroll::-webkit-scrollbar { + display: block; + width: 6px; + height: 6px; +} +.diff-scroll::-webkit-scrollbar-track { + background: transparent; +} +.diff-scroll::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.2); + border-radius: 3px; +} +.dark .diff-scroll::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.25); +} + @layer components { .page-transition-enter { @apply motion-reduce:transition-none;