diff --git a/README.md b/README.md index 6d1761a..cd84b9b 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,9 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两 - **差异导航**:一键“上一处 / 下一处”在差异之间跳跃,也可直接点击定位 - **智能编码识别**:自动探测 UTF-8 / UTF-16 / GBK(含 BOM),中文文档不乱码 - **忽略选项**:支持“忽略行首尾空白”“忽略大小写”,适配弱差异场景 +- **手动粘贴对比**:无需文件,直接粘贴两侧文本即可开始对比 +- **大文件防护**:超过 10MB 的文件自动拦截并提示,避免界面卡死 +- **二进制文件预警**:疑似二进制内容给出乱码提示,避免误读为差异 - **差异统计徽章**:实时统计新增 / 删除 / 修改行数 - **导出一键报告**:将对比结果导出为 HTML / 纯文本 / Markdown 三种格式,便于分享与归档 - **拖拽与菜单双入口**:支持文件拖拽导入,也支持顶部菜单与快捷键打开 @@ -87,7 +90,7 @@ src/ - 作者:thzxx - 组织:MetonaTeam - 许可证:MIT License(见 [LICENSE](./LICENSE)) -- 版本:0.2.1 +- 版本:0.2.4 --- diff --git a/docs/应用开发与版本迭代规范.md b/docs/应用开发与版本迭代规范.md index 9ee949e..4b33737 100644 --- a/docs/应用开发与版本迭代规范.md +++ b/docs/应用开发与版本迭代规范.md @@ -20,7 +20,7 @@ z = 补丁版本号(Patch) > **`x` 永远是 `0`,永远不要提升到 `1.0.0`。** > 版本迭代**只允许修改 `y` 和 `z`**,`x` 保持 `0` 不变。 -当前基线版本:**`0.2.3`** +当前基线版本:**`0.2.4`** --- @@ -59,7 +59,8 @@ z = 补丁版本号(Patch) 0.2.0 ← 导出差异报告(已发布):HTML / 纯文本 / Markdown 三种格式 0.2.1 ← 建立测试体系(已发布):Vitest 单元+组件测试 · 测试策略文档 0.2.2 ← 体验打磨(已发布):拖拽处处可用 · 手动粘贴对比 · 导出后一键定位 · 报告/可读性增强 · 仅限文本文件 -0.2.3 ← 安装器优化(当前):NSIS 向导式安装,支持手动选择安装目录 +0.2.3 ← 安装器优化(已发布):NSIS 向导式安装,支持手动选择安装目录 +0.2.4 ← 健壮性与体验打磨(当前):读取容错与 10MB 大文件防护 · 横向滚动同步 · 下拉点击外部/Esc 关闭 · 二进制误判预警 0.3.0 ← 新增功能 ... 0.y.z ← 长期停留,永不进入 1.x diff --git a/package-lock.json b/package-lock.json index b06bb4d..5c39638 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "DiffLens", - "version": "0.2.2", + "version": "0.2.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "DiffLens", - "version": "0.2.3", + "version": "0.2.4", "license": "MIT", "dependencies": { "@electron-toolkit/preload": "^3.0.1", diff --git a/package.json b/package.json index 5f8a281..a2c7f5e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "DiffLens", - "version": "0.2.3", + "version": "0.2.4", "description": "DiffLens — 精美酷炫的文本对比桌面应用", "author": "thzxx", "license": "MIT", diff --git a/src/main/index.ts b/src/main/index.ts index c2db911..8b2f15e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,5 +1,5 @@ import { join } from 'path' -import { app, shell, BrowserWindow, Menu, ipcMain, dialog } from 'electron' +import { app, shell, BrowserWindow, Menu, ipcMain, dialog, clipboard } from 'electron' import { electronApp, optimizer, is } from '@electron-toolkit/utils' import iconv from 'iconv-lite' import fs from 'fs' @@ -41,27 +41,50 @@ function createWindow(): void { } } +/** 单侧文件大小上限(10MB):超限直接拦截,避免超大文件卡死界面 */ +const MAX_FILE_SIZE = 10 * 1024 * 1024 + +/** + * 缓冲区二进制启发式判定:出现 NUL 字节立即判定; + * 否则统计前 8KB 内不可见控制字符占比(> 5% 视为二进制)。 + */ +function looksBinary(buf: Buffer): boolean { + const len = Math.min(buf.length, 8000) + if (len === 0) return false + let suspicious = 0 + for (let i = 0; i < len; i++) { + const b = buf[i] + if (b === 0) return true + if (b < 0x09 || (b > 0x0d && b < 0x20)) suspicious++ + } + return suspicious / len > 0.05 +} + /** * 探测文本编码:优先 BOM,其次严格 UTF-8, - * 失败则回退 GBK 解码,返回 { text, encoding }。 + * 失败则回退 GBK 解码,返回 { text, encoding, binary }。 */ -function decodeText(buf: Buffer): { text: string; encoding: string } { +function decodeText(buf: Buffer): { text: string; encoding: string; binary: boolean } { if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) { - return { text: buf.subarray(3).toString('utf8'), encoding: 'UTF-8 (BOM)' } + return { text: buf.subarray(3).toString('utf8'), encoding: 'UTF-8 (BOM)', binary: false } } if (buf.length >= 2 && buf[0] === 0xfe && buf[1] === 0xff) { - return { text: iconv.decode(buf.subarray(2), 'utf16-be'), encoding: 'UTF-16 BE' } + return { text: iconv.decode(buf.subarray(2), 'utf16-be'), encoding: 'UTF-16 BE', binary: false } } if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) { - return { text: buf.subarray(2).toString('utf16le'), encoding: 'UTF-16 LE' } + return { text: buf.subarray(2).toString('utf16le'), encoding: 'UTF-16 LE', binary: false } } // 严格 UTF-8 探测 try { const decoder = new TextDecoder('utf-8', { fatal: true }) const text = decoder.decode(buf) - return { text, encoding: 'UTF-8' } + return { text, encoding: 'UTF-8', binary: false } } catch { - return { text: iconv.decode(buf, 'gbk'), encoding: 'GBK' } + const text = iconv.decode(buf, 'gbk') + // GBK 覆盖面广,二进制内容也能解出“文字”;用字节启发式 + 替换符占比双保险 + const bad = (text.match(/\uFFFD/g) ?? []).length + const binary = looksBinary(buf) || (text.length > 0 && bad / text.length > 0.05) + return { text, encoding: 'GBK', binary } } } @@ -76,10 +99,19 @@ ipcMain.handle('file:open', async (_event, side: 'left' | 'right' | null) => { }) if (result.canceled || result.filePaths.length === 0) return null const filePath = result.filePaths[0] - const buf = fs.readFileSync(filePath) - const { text, encoding } = decodeText(buf) const name = filePath.split(/[\\/]/).pop() ?? filePath - return { path: filePath, name, text, encoding } + let buf: Buffer + try { + const stat = fs.statSync(filePath) + if (stat.size > MAX_FILE_SIZE) { + return { error: 'too-large', name, size: stat.size } + } + buf = fs.readFileSync(filePath) + } catch { + return { error: 'read-failed', name } + } + const { text, encoding, binary } = decodeText(buf) + return { path: filePath, name, text, encoding, binary } }) /** IPC: 解码拖拽传入的原始字节(复用编码探测逻辑) */ @@ -111,7 +143,7 @@ ipcMain.handle('file:show-in-folder', (_event, filePath: string) => { /** IPC: 复制文本到剪贴板 */ ipcMain.handle('clipboard:write', (_event, text: string) => { - require('electron').clipboard.writeText(text) + clipboard.writeText(text) return true }) diff --git a/src/preload/index.ts b/src/preload/index.ts index 57d192b..36e1445 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,18 +1,32 @@ import { contextBridge, ipcRenderer } from 'electron' -/** 打开文件返回的数据结构 */ +/** 打开文件成功返回的数据结构 */ export interface FileData { path: string name: string text: string encoding: string + /** 疑似二进制内容(解码启发式判定),渲染端据此提示乱码风险 */ + binary: boolean } +/** 打开文件失败的返回结构 */ +export interface FileOpenError { + error: 'too-large' | 'read-failed' + /** 文件名(用于提示) */ + name?: string + /** too-large 时的文件字节数 */ + size?: number +} + +/** 打开文件结果:成功数据 / 失败原因 / 用户取消(null) */ +export type FileOpenResult = FileData | FileOpenError | null + /** 主进程暴露给渲染进程的安全 API */ const api = { - openFile: (side?: 'left' | 'right'): Promise => + openFile: (side?: 'left' | 'right'): Promise => ipcRenderer.invoke('file:open', side ?? null), - decodeBuffer: (buffer: ArrayBuffer): Promise<{ text: string; encoding: string }> => + decodeBuffer: (buffer: ArrayBuffer): Promise<{ text: string; encoding: string; binary: boolean }> => ipcRenderer.invoke('file:decode-buffer', buffer), saveReport: (content: string, defaultName: string): Promise<{ ok: boolean; path: string | null }> => ipcRenderer.invoke('file:save-report', content, defaultName), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index b4fcff1..096c09c 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -3,6 +3,7 @@ import { useMemo, useState, useCallback, + useRef, type ReactElement, type MouseEvent as ReactMouseEvent } from 'react' @@ -14,6 +15,7 @@ import ContextMenu, { type ContextMenuItem } from './components/ContextMenu' import TextInputModal from './components/TextInputModal' import { buildReport, REPORT_EXT, type ReportFormat, type ReportContext } from './diff/report' import { isTextFile } from './diff/textUtils' +import { useDismiss } from './hooks/useDismiss' import type { PaneMeta } from './components/DiffView' import type { SideCell } from './diff/diffEngine' @@ -26,6 +28,9 @@ function stamp(): string { )}${p(d.getSeconds())}` } +/** 拖拽导入的文件大小上限(与主进程 file:open 的 10MB 限制保持一致) */ +const MAX_FILE_SIZE = 10 * 1024 * 1024 + interface PaneState { meta: PaneMeta path: string @@ -95,39 +100,83 @@ export default function App(): ReactElement { const [toast, setToast] = useState<{ text: string; action?: { label: string; onClick: () => void } } | null>(null) const [pasteSide, setPasteSide] = useState<'left' | 'right' | null>(null) const [pasteOpen, setPasteOpen] = useState(false) + const pasteWrapRef = useRef(null) + const closePaste = useCallback((): void => setPasteOpen(false), []) + // 点击外部 / 按 Esc 关闭粘贴下拉 + useDismiss(pasteWrapRef, closePaste) + + // toast 定时器:新提示先清掉旧定时器,避免前一条把后一条提前清空 + const toastTimer = useRef(null) const showToast = useCallback( (text: string, action?: { label: string; onClick: () => void }) => { setToast({ text, action }) - window.setTimeout(() => setToast(null), 3600) + if (toastTimer.current !== null) window.clearTimeout(toastTimer.current) + toastTimer.current = window.setTimeout(() => setToast(null), 3600) }, [] ) - const openPane = useCallback(async (side: 'left' | 'right') => { - const data = await window.api.openFile(side) - if (!data) return - const pane: PaneState = { meta: { name: data.name, encoding: data.encoding }, path: data.path, text: data.text } - if (side === 'left') setPaneL(pane) - else setPaneR(pane) + // 卸载时清理定时器,避免泄漏 + useEffect(() => { + return () => { + if (toastTimer.current !== null) window.clearTimeout(toastTimer.current) + } }, []) + const openPane = useCallback( + async (side: 'left' | 'right') => { + let data: Awaited> + try { + data = await window.api.openFile(side) + } catch { + showToast('文件读取失败,请重试') + return + } + if (!data) return + if ('error' in data) { + showToast( + data.error === 'too-large' + ? `文件超过 10MB,暂不支持对比:${data.name ?? ''}` + : `文件读取失败:${data.name ?? ''}(可能被占用或权限不足)` + ) + return + } + if (data.binary) showToast(`疑似二进制文件,内容可能乱码:${data.name}`) + const pane: PaneState = { + meta: { name: data.name, encoding: data.encoding }, + path: data.path, + text: data.text + } + if (side === 'left') setPaneL(pane) + else setPaneR(pane) + }, + [showToast] + ) + const dropPane = useCallback( async (side: 'left' | 'right', file: File) => { if (!isTextFile(file.name)) { showToast(`仅支持文本文件:${file.name}`) return } + if (file.size > MAX_FILE_SIZE) { + showToast(`文件超过 10MB,暂不支持对比:${file.name}`) + return + } let text = '' let encoding = 'UTF-8' + let binary = false try { const buf = await file.arrayBuffer() const decoded = await window.api.decodeBuffer(buf) text = decoded.text encoding = decoded.encoding + binary = decoded.binary } catch { text = await file.text().catch(() => '') } + if (binary) showToast(`疑似二进制文件,内容可能乱码:${file.name}`) const pane: PaneState = { meta: { name: file.name, encoding }, path: '', text } if (side === 'left') setPaneL(pane) else setPaneR(pane) @@ -201,7 +250,13 @@ export default function App(): ReactElement { } const content = buildReport(diff.rows, ctx, summary, fmt) const defaultName = `DiffLens-report-${stamp()}.${REPORT_EXT[fmt]}` - const res = await window.api.saveReport(content, defaultName) + let res: { ok: boolean; path: string | null } + try { + res = await window.api.saveReport(content, defaultName) + } catch { + showToast('导出失败,请重试') + return + } if (res.ok && res.path) { const p = res.path showToast(`报告已保存:${p}`, { @@ -244,7 +299,7 @@ export default function App(): ReactElement { -
+
diff --git a/src/renderer/src/__tests__/App.test.tsx b/src/renderer/src/__tests__/App.test.tsx index 26f98df..f7dd487 100644 --- a/src/renderer/src/__tests__/App.test.tsx +++ b/src/renderer/src/__tests__/App.test.tsx @@ -1,5 +1,5 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, fireEvent } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render, screen, fireEvent, act } from '@testing-library/react' import App from '../App' function mockApi(overrides?: Partial): typeof window.api { @@ -8,9 +8,10 @@ function mockApi(overrides?: Partial): typeof window.api { path: '/tmp/a.txt', name: 'a.txt', text: 'line1\nline2', - encoding: 'UTF-8' + encoding: 'UTF-8', + binary: false }), - decodeBuffer: async () => ({ text: 'line1\nline2', encoding: 'UTF-8' }), + decodeBuffer: async () => ({ text: 'line1\nline2', encoding: 'UTF-8', binary: false }), saveReport: async () => ({ ok: false, path: null }), showInFolder: async () => true, setClipboard: async () => true @@ -32,6 +33,10 @@ beforeEach(() => { }) }) +afterEach(() => { + vi.useRealTimers() +}) + describe('App - 初始空态', () => { it('渲染应用名称 DiffLens', () => { render() @@ -225,6 +230,132 @@ describe('App - 右侧面板交互', () => { }) }) +describe('App - 提示与容错', () => { + it('连续提示时后一条不被前一条定时器清除', () => { + vi.useFakeTimers() + render() + const pane = screen.getByText(/选择左侧文件/).closest('.pane-empty') as Element + fireEvent.drop(pane, { + dataTransfer: { files: [new File(['x'], 'a.png', { type: 'image/png' })] } + }) + expect(screen.getByText(/仅支持文本文件:a.png/)).toBeInTheDocument() + act(() => { + vi.advanceTimersByTime(3000) + }) + fireEvent.drop(pane, { + dataTransfer: { files: [new File(['x'], 'b.png', { type: 'image/png' })] } + }) + expect(screen.getByText(/仅支持文本文件:b.png/)).toBeInTheDocument() + act(() => { + vi.advanceTimersByTime(3000) + }) + // 旧实现中第一个 3600ms 定时器会把第二条提示提前清掉 + expect(screen.getByText(/仅支持文本文件:b.png/)).toBeInTheDocument() + act(() => { + vi.advanceTimersByTime(1000) + }) + expect(screen.queryByText(/仅支持文本文件:b.png/)).not.toBeInTheDocument() + }) + + it('文件超过 10MB 时被拦截并保持空态', async () => { + window.api = mockApi({ + openFile: async () => ({ error: 'too-large', name: 'big.log', size: 11 * 1024 * 1024 }) + }) + render() + fireEvent.click(screen.getByText('打开左侧')) + expect(await screen.findByText(/文件超过 10MB/)).toBeInTheDocument() + expect(screen.queryByText('导出报告')).not.toBeInTheDocument() + }) + + it('读取失败时提示且保持空态', async () => { + window.api = mockApi({ openFile: async () => ({ error: 'read-failed', name: 'x.txt' }) }) + render() + fireEvent.click(screen.getByText('打开左侧')) + expect(await screen.findByText(/文件读取失败/)).toBeInTheDocument() + expect(screen.queryByText('导出报告')).not.toBeInTheDocument() + }) + + it('IPC 异常时兜底提示', async () => { + window.api = mockApi({ + openFile: async () => { + throw new Error('ipc boom') + } + }) + render() + fireEvent.click(screen.getByText('打开左侧')) + expect(await screen.findByText(/文件读取失败,请重试/)).toBeInTheDocument() + }) + + it('拖放超过 10MB 的文件被拦截', async () => { + render() + const file = new File(['x'], 'big.log', { type: 'text/plain' }) + Object.defineProperty(file, 'size', { value: 12 * 1024 * 1024 }) + const pane = screen.getByText(/选择左侧文件/).closest('.pane-empty') as Element + fireEvent.drop(pane, { dataTransfer: { files: [file] } }) + expect(await screen.findByText(/文件超过 10MB/)).toBeInTheDocument() + expect(screen.queryByText('导出报告')).not.toBeInTheDocument() + }) + + it('打开疑似二进制文件时加载并给出乱码预警', async () => { + window.api = mockApi({ + openFile: async () => ({ + path: '/tmp/d', + name: 'data', + text: 'zz', + encoding: 'GBK', + binary: true + }) + }) + render() + fireEvent.click(screen.getByText('打开左侧')) + expect(await screen.findByText(/疑似二进制文件/)).toBeInTheDocument() + expect(await screen.findByText('导出报告')).toBeInTheDocument() + }) + + it('拖放疑似二进制文件时给出乱码预警', async () => { + window.api = mockApi({ + decodeBuffer: async () => ({ text: 'zz', encoding: 'GBK', binary: true }) + }) + render() + const file = new File(['zz'], 'noext', { type: '' }) + const pane = screen.getByText(/选择左侧文件/).closest('.pane-empty') as Element + fireEvent.drop(pane, { dataTransfer: { files: [file] } }) + expect(await screen.findByText(/疑似二进制文件/)).toBeInTheDocument() + }) + + it('导出时 IPC 异常给出失败提示', async () => { + window.api = mockApi({ + saveReport: async () => { + throw new Error('boom') + } + }) + render() + fireEvent.click(screen.getByText('打开左侧')) + await screen.findByText('导出报告') + fireEvent.click(screen.getByText('导出报告')) + fireEvent.click(screen.getByText('HTML 报告')) + expect(await screen.findByText(/导出失败/)).toBeInTheDocument() + }) +}) + +describe('App - 粘贴下拉交互', () => { + it('按 Esc 关闭粘贴下拉', () => { + render() + fireEvent.click(screen.getByText('粘贴文本')) + expect(screen.getByText('粘贴到左侧')).toBeInTheDocument() + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.queryByText('粘贴到左侧')).not.toBeInTheDocument() + }) + + it('点击外部区域关闭粘贴下拉', () => { + render() + fireEvent.click(screen.getByText('粘贴文本')) + expect(screen.getByText('粘贴到左侧')).toBeInTheDocument() + fireEvent.mouseDown(document.body) + expect(screen.queryByText('粘贴到左侧')).not.toBeInTheDocument() + }) +}) + describe('App - 手动粘贴文本', () => { it('粘贴文本到左侧并开始对比', async () => { render() diff --git a/src/renderer/src/components/ContextMenu.test.tsx b/src/renderer/src/components/ContextMenu.test.tsx index 0060609..f91ec73 100644 --- a/src/renderer/src/components/ContextMenu.test.tsx +++ b/src/renderer/src/components/ContextMenu.test.tsx @@ -48,4 +48,11 @@ describe('ContextMenu', () => { fireEvent.contextMenu(container.querySelector('.ctx-backdrop') as Element) expect(onClose).toHaveBeenCalled() }) + + it('按 Esc 触发关闭', () => { + const onClose = vi.fn() + render() + fireEvent.keyDown(document, { key: 'Escape' }) + expect(onClose).toHaveBeenCalled() + }) }) \ No newline at end of file diff --git a/src/renderer/src/components/ContextMenu.tsx b/src/renderer/src/components/ContextMenu.tsx index 5f6575a..5773200 100644 --- a/src/renderer/src/components/ContextMenu.tsx +++ b/src/renderer/src/components/ContextMenu.tsx @@ -1,4 +1,4 @@ -import { type ReactElement } from 'react' +import { useEffect, type ReactElement } from 'react' export interface ContextMenuItem { label: string @@ -19,6 +19,15 @@ export default function ContextMenu({ items, onClose }: ContextMenuProps): ReactElement { + // Esc 关闭菜单 + useEffect(() => { + const onKey = (e: KeyboardEvent): void => { + if (e.key === 'Escape') onClose() + } + document.addEventListener('keydown', onKey) + return () => document.removeEventListener('keydown', onKey) + }, [onClose]) + return (
{ expect(right.scrollTop).toBe(120) }) + it('横向滚动时同步另一侧面板', () => { + const { container } = renderDiff(baseRows) + const scrollers = container.querySelectorAll('.diff-scroll') + const left = scrollers[0] as HTMLElement + const right = scrollers[1] as HTMLElement + left.scrollLeft = 80 + fireEvent.scroll(left) + expect(right.scrollLeft).toBe(80) + }) + it('右侧滚动时同步左侧面板', () => { const { container } = renderDiff(baseRows) const scrollers = container.querySelectorAll('.diff-scroll') diff --git a/src/renderer/src/components/DiffView.tsx b/src/renderer/src/components/DiffView.tsx index e5010b7..7da2b0c 100644 --- a/src/renderer/src/components/DiffView.tsx +++ b/src/renderer/src/components/DiffView.tsx @@ -135,7 +135,10 @@ export default function DiffView({ syncing.current = true const src = target === 'left' ? leftRef.current : rightRef.current const dst = target === 'left' ? rightRef.current : leftRef.current - if (src && dst) dst.scrollTop = src.scrollTop + if (src && dst) { + dst.scrollTop = src.scrollTop + dst.scrollLeft = src.scrollLeft + } requestAnimationFrame(() => { syncing.current = false }) diff --git a/src/renderer/src/components/TextInputModal.test.tsx b/src/renderer/src/components/TextInputModal.test.tsx index 9318039..9f0c588 100644 --- a/src/renderer/src/components/TextInputModal.test.tsx +++ b/src/renderer/src/components/TextInputModal.test.tsx @@ -21,4 +21,24 @@ describe('TextInputModal', () => { fireEvent.click(screen.getByText('取消')) expect(onCancel).toHaveBeenCalled() }) + + it('按 Esc 触发 onCancel', () => { + const onCancel = vi.fn() + render( + + ) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(onCancel).toHaveBeenCalled() + }) + + it('Ctrl+Enter 快捷提交', () => { + const onConfirm = vi.fn() + render( + + ) + const ta = screen.getByPlaceholderText(/粘贴或输入/) + fireEvent.change(ta, { target: { value: 'a\nb' } }) + fireEvent.keyDown(ta, { key: 'Enter', ctrlKey: true }) + expect(onConfirm).toHaveBeenCalledWith('a\nb') + }) }) \ No newline at end of file diff --git a/src/renderer/src/components/TextInputModal.tsx b/src/renderer/src/components/TextInputModal.tsx index 34ac3e6..f0f14a9 100644 --- a/src/renderer/src/components/TextInputModal.tsx +++ b/src/renderer/src/components/TextInputModal.tsx @@ -1,4 +1,4 @@ -import { useState, type ReactElement } from 'react' +import { useEffect, useState, type ReactElement } from 'react' interface TextInputModalProps { title: string @@ -13,6 +13,15 @@ export default function TextInputModal({ }: TextInputModalProps): ReactElement { const [text, setText] = useState('') + // Esc 取消 + useEffect(() => { + const onKey = (e: KeyboardEvent): void => { + if (e.key === 'Escape') onCancel() + } + document.addEventListener('keydown', onKey) + return () => document.removeEventListener('keydown', onKey) + }, [onCancel]) + return (
setText(e.target.value)} + onKeyDown={(e) => { + // Ctrl/Cmd + Enter 快捷提交 + if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') onConfirm(text) + }} autoFocus placeholder="在此粘贴或输入文本,然后点击“开始对比”…" spellCheck={false} diff --git a/src/renderer/src/components/Toolbar.test.tsx b/src/renderer/src/components/Toolbar.test.tsx index 949abca..ea5c36f 100644 --- a/src/renderer/src/components/Toolbar.test.tsx +++ b/src/renderer/src/components/Toolbar.test.tsx @@ -62,4 +62,20 @@ describe('Toolbar', () => { fireEvent.click(screen.getByText('HTML 报告')) expect(onExport).toHaveBeenCalledWith('html') }) + + it('点击外部区域关闭导出菜单', () => { + setup() + fireEvent.click(screen.getByText('导出报告')) + expect(screen.getByText('HTML 报告')).toBeInTheDocument() + fireEvent.mouseDown(document.body) + expect(screen.queryByText('HTML 报告')).not.toBeInTheDocument() + }) + + it('按 Esc 关闭导出菜单', () => { + setup() + fireEvent.click(screen.getByText('导出报告')) + expect(screen.getByText('HTML 报告')).toBeInTheDocument() + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.queryByText('HTML 报告')).not.toBeInTheDocument() + }) }) \ No newline at end of file diff --git a/src/renderer/src/components/Toolbar.tsx b/src/renderer/src/components/Toolbar.tsx index 4293e4e..f6994f8 100644 --- a/src/renderer/src/components/Toolbar.tsx +++ b/src/renderer/src/components/Toolbar.tsx @@ -1,6 +1,7 @@ -import { useState, type ReactElement } from 'react' +import { useCallback, useRef, useState, type ReactElement } from 'react' import { DiffSummary } from '../diff/diffEngine' import { type ReportFormat } from '../diff/report' +import { useDismiss } from '../hooks/useDismiss' export interface DiffOptions { trimWhitespace: boolean @@ -36,6 +37,10 @@ export default function Toolbar({ }: ToolbarProps): ReactElement { const set = (patch: Partial): void => onOptionsChange({ ...options, ...patch }) const [exportOpen, setExportOpen] = useState(false) + const exportWrapRef = useRef(null) + const closeExport = useCallback((): void => setExportOpen(false), []) + // 点击外部 / 按 Esc 关闭导出下拉 + useDismiss(exportWrapRef, closeExport) const canNav = navCount > 0 @@ -83,7 +88,7 @@ export default function Toolbar({
-
+