fix: v0.2.4 健壮性与体验打磨 - 读取容错、10MB 大文件防护、横向滚动同步、Esc/外点关闭、二进制预警
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "DiffLens",
|
||||
"version": "0.2.3",
|
||||
"version": "0.2.4",
|
||||
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
|
||||
"author": "thzxx",
|
||||
"license": "MIT",
|
||||
|
||||
+44
-12
@@ -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
@@ -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),
|
||||
|
||||
@@ -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,22 +100,59 @@ 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)
|
||||
// 卸载时清理定时器,避免泄漏
|
||||
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
|
||||
const pane: PaneState = { meta: { name: data.name, encoding: data.encoding }, path: data.path, text: data.text }
|
||||
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) => {
|
||||
@@ -118,16 +160,23 @@ export default function App(): ReactElement {
|
||||
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>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
Reference in New Issue
Block a user