fix: v0.2.4 健壮性与体验打磨 - 读取容错、10MB 大文件防护、横向滚动同步、Esc/外点关闭、二进制预警

This commit is contained in:
2026-08-17 21:03:40 +08:00
parent e10ddea30b
commit 4ee5d62348
17 changed files with 380 additions and 39 deletions
+4 -1
View File
@@ -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
---
+3 -2
View File
@@ -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
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "DiffLens",
"version": "0.2.3",
"version": "0.2.4",
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
"author": "thzxx",
"license": "MIT",
+44 -12
View File
@@ -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
})
+17 -3
View File
@@ -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<FileData | null> =>
openFile: (side?: 'left' | 'right'): Promise<FileOpenResult> =>
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),
+64 -9
View File
@@ -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<HTMLDivElement>(null)
const closePaste = useCallback((): void => setPasteOpen(false), [])
// 点击外部 / 按 Esc 关闭粘贴下拉
useDismiss(pasteWrapRef, closePaste)
// toast 定时器:新提示先清掉旧定时器,避免前一条把后一条提前清空
const toastTimer = useRef<number | null>(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<ReturnType<typeof window.api.openFile>>
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 {
<button className="btn ghost" onClick={() => void openPane('right')}>
</button>
<div className="tool-group export-wrap">
<div className="tool-group export-wrap" ref={pasteWrapRef}>
<button className="btn ghost" onClick={() => setPasteOpen((o) => !o)}>
</button>
+135 -4
View File
@@ -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>): typeof window.api {
@@ -8,9 +8,10 @@ function mockApi(overrides?: Partial<typeof window.api>): 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(<App />)
@@ -225,6 +230,132 @@ describe('App - 右侧面板交互', () => {
})
})
describe('App - 提示与容错', () => {
it('连续提示时后一条不被前一条定时器清除', () => {
vi.useFakeTimers()
render(<App />)
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(<App />)
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(<App />)
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(<App />)
fireEvent.click(screen.getByText('打开左侧'))
expect(await screen.findByText(/文件读取失败,请重试/)).toBeInTheDocument()
})
it('拖放超过 10MB 的文件被拦截', async () => {
render(<App />)
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(<App />)
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(<App />)
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(<App />)
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(<App />)
fireEvent.click(screen.getByText('粘贴文本'))
expect(screen.getByText('粘贴到左侧')).toBeInTheDocument()
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.queryByText('粘贴到左侧')).not.toBeInTheDocument()
})
it('点击外部区域关闭粘贴下拉', () => {
render(<App />)
fireEvent.click(screen.getByText('粘贴文本'))
expect(screen.getByText('粘贴到左侧')).toBeInTheDocument()
fireEvent.mouseDown(document.body)
expect(screen.queryByText('粘贴到左侧')).not.toBeInTheDocument()
})
})
describe('App - 手动粘贴文本', () => {
it('粘贴文本到左侧并开始对比', async () => {
render(<App />)
@@ -48,4 +48,11 @@ describe('ContextMenu', () => {
fireEvent.contextMenu(container.querySelector('.ctx-backdrop') as Element)
expect(onClose).toHaveBeenCalled()
})
it('按 Esc 触发关闭', () => {
const onClose = vi.fn()
render(<ContextMenu x={10} y={10} items={[]} onClose={onClose} />)
fireEvent.keyDown(document, { key: 'Escape' })
expect(onClose).toHaveBeenCalled()
})
})
+10 -1
View File
@@ -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 (
<div
className="ctx-backdrop"
@@ -105,6 +105,16 @@ describe('DiffView', () => {
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')
+4 -1
View File
@@ -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
})
@@ -21,4 +21,24 @@ describe('TextInputModal', () => {
fireEvent.click(screen.getByText('取消'))
expect(onCancel).toHaveBeenCalled()
})
it('按 Esc 触发 onCancel', () => {
const onCancel = vi.fn()
render(
<TextInputModal title="粘贴左侧文本" onCancel={onCancel} onConfirm={vi.fn()} />
)
fireEvent.keyDown(document, { key: 'Escape' })
expect(onCancel).toHaveBeenCalled()
})
it('Ctrl+Enter 快捷提交', () => {
const onConfirm = vi.fn()
render(
<TextInputModal title="粘贴左侧文本" onCancel={vi.fn()} onConfirm={onConfirm} />
)
const ta = screen.getByPlaceholderText(/粘贴或输入/)
fireEvent.change(ta, { target: { value: 'a\nb' } })
fireEvent.keyDown(ta, { key: 'Enter', ctrlKey: true })
expect(onConfirm).toHaveBeenCalledWith('a\nb')
})
})
+14 -1
View File
@@ -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 (
<div
className="modal-backdrop"
@@ -28,6 +37,10 @@ export default function TextInputModal({
className="text-modal-input"
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
// Ctrl/Cmd + Enter 快捷提交
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') onConfirm(text)
}}
autoFocus
placeholder="在此粘贴或输入文本,然后点击“开始对比”…"
spellCheck={false}
@@ -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()
})
})
+7 -2
View File
@@ -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<DiffOptions>): void => onOptionsChange({ ...options, ...patch })
const [exportOpen, setExportOpen] = useState(false)
const exportWrapRef = useRef<HTMLDivElement>(null)
const closeExport = useCallback((): void => setExportOpen(false), [])
// 点击外部 / 按 Esc 关闭导出下拉
useDismiss(exportWrapRef, closeExport)
const canNav = navCount > 0
@@ -83,7 +88,7 @@ export default function Toolbar({
</button>
</div>
<div className="tool-group export-wrap">
<div className="tool-group export-wrap" ref={exportWrapRef}>
<button
className="btn primary"
disabled={!canExport}
+22
View File
@@ -0,0 +1,22 @@
import { useEffect, type RefObject } from 'react'
/**
* 点击组件外部或按下 Esc 时触发 onDismiss。
* 用于下拉菜单等轻量弹出层的统一关闭行为。
*/
export function useDismiss(ref: RefObject<HTMLElement | null>, onDismiss: () => void): void {
useEffect(() => {
const onPointerDown = (e: MouseEvent): void => {
if (ref.current && !ref.current.contains(e.target as Node)) onDismiss()
}
const onKeyDown = (e: KeyboardEvent): void => {
if (e.key === 'Escape') onDismiss()
}
document.addEventListener('mousedown', onPointerDown)
document.addEventListener('keydown', onKeyDown)
return () => {
document.removeEventListener('mousedown', onPointerDown)
document.removeEventListener('keydown', onKeyDown)
}
}, [ref, onDismiss])
}