fix: v0.2.5 一致性与边角修复 - 右键菜单边缘防溢出、粘贴10MB防护、拖拽失败提示、多文件提示、剪贴板走主进程IPC、主进程异步读取
This commit is contained in:
@@ -19,7 +19,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两
|
|||||||
- **二进制文件预警**:疑似二进制内容给出乱码提示,避免误读为差异
|
- **二进制文件预警**:疑似二进制内容给出乱码提示,避免误读为差异
|
||||||
- **差异统计徽章**:实时统计新增 / 删除 / 修改行数
|
- **差异统计徽章**:实时统计新增 / 删除 / 修改行数
|
||||||
- **导出一键报告**:将对比结果导出为 HTML / 纯文本 / Markdown 三种格式,便于分享与归档
|
- **导出一键报告**:将对比结果导出为 HTML / 纯文本 / Markdown 三种格式,便于分享与归档
|
||||||
- **拖拽与菜单双入口**:支持文件拖拽导入,也支持顶部菜单与快捷键打开
|
- **拖拽与按钮双入口**:支持文件拖拽导入,也可通过界面按钮选择文件或粘贴文本
|
||||||
- **滚动联动**:左右两个面板滚动位置自动同步
|
- **滚动联动**:左右两个面板滚动位置自动同步
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -90,7 +90,7 @@ src/
|
|||||||
- 作者:thzxx
|
- 作者:thzxx
|
||||||
- 组织:MetonaTeam
|
- 组织:MetonaTeam
|
||||||
- 许可证:MIT License(见 [LICENSE](./LICENSE))
|
- 许可证:MIT License(见 [LICENSE](./LICENSE))
|
||||||
- 版本:0.2.4
|
- 版本:0.2.5
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -20,7 +20,7 @@ z = 补丁版本号(Patch)
|
|||||||
> **`x` 永远是 `0`,永远不要提升到 `1.0.0`。**
|
> **`x` 永远是 `0`,永远不要提升到 `1.0.0`。**
|
||||||
> 版本迭代**只允许修改 `y` 和 `z`**,`x` 保持 `0` 不变。
|
> 版本迭代**只允许修改 `y` 和 `z`**,`x` 保持 `0` 不变。
|
||||||
|
|
||||||
当前基线版本:**`0.2.4`**
|
当前基线版本:**`0.2.5`**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -60,7 +60,8 @@ z = 补丁版本号(Patch)
|
|||||||
0.2.1 ← 建立测试体系(已发布):Vitest 单元+组件测试 · 测试策略文档
|
0.2.1 ← 建立测试体系(已发布):Vitest 单元+组件测试 · 测试策略文档
|
||||||
0.2.2 ← 体验打磨(已发布):拖拽处处可用 · 手动粘贴对比 · 导出后一键定位 · 报告/可读性增强 · 仅限文本文件
|
0.2.2 ← 体验打磨(已发布):拖拽处处可用 · 手动粘贴对比 · 导出后一键定位 · 报告/可读性增强 · 仅限文本文件
|
||||||
0.2.3 ← 安装器优化(已发布):NSIS 向导式安装,支持手动选择安装目录
|
0.2.3 ← 安装器优化(已发布):NSIS 向导式安装,支持手动选择安装目录
|
||||||
0.2.4 ← 健壮性与体验打磨(当前):读取容错与 10MB 大文件防护 · 横向滚动同步 · 下拉点击外部/Esc 关闭 · 二进制误判预警
|
0.2.4 ← 健壮性与体验打磨(已发布):读取容错与 10MB 大文件防护 · 横向滚动同步 · 下拉点击外部/Esc 关闭 · 二进制误判预警
|
||||||
|
0.2.5 ← 一致性与边角修复(当前):右键菜单边缘防溢出 · 粘贴入口 10MB 防护 · 拖拽读取失败提示 · 多文件拖拽提示 · 剪贴板统一走主进程 · 主进程异步读取
|
||||||
0.3.0 ← 新增功能
|
0.3.0 ← 新增功能
|
||||||
...
|
...
|
||||||
0.y.z ← 长期停留,永不进入 1.x
|
0.y.z ← 长期停留,永不进入 1.x
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "DiffLens",
|
"name": "DiffLens",
|
||||||
"version": "0.2.4",
|
"version": "0.2.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "DiffLens",
|
"name": "DiffLens",
|
||||||
"version": "0.2.4",
|
"version": "0.2.5",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@electron-toolkit/preload": "^3.0.1",
|
"@electron-toolkit/preload": "^3.0.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "DiffLens",
|
"name": "DiffLens",
|
||||||
"version": "0.2.4",
|
"version": "0.2.5",
|
||||||
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
|
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
|
||||||
"author": "thzxx",
|
"author": "thzxx",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
+2
-2
@@ -102,11 +102,11 @@ ipcMain.handle('file:open', async (_event, side: 'left' | 'right' | null) => {
|
|||||||
const name = filePath.split(/[\\/]/).pop() ?? filePath
|
const name = filePath.split(/[\\/]/).pop() ?? filePath
|
||||||
let buf: Buffer
|
let buf: Buffer
|
||||||
try {
|
try {
|
||||||
const stat = fs.statSync(filePath)
|
const stat = await fs.promises.stat(filePath)
|
||||||
if (stat.size > MAX_FILE_SIZE) {
|
if (stat.size > MAX_FILE_SIZE) {
|
||||||
return { error: 'too-large', name, size: stat.size }
|
return { error: 'too-large', name, size: stat.size }
|
||||||
}
|
}
|
||||||
buf = fs.readFileSync(filePath)
|
buf = await fs.promises.readFile(filePath)
|
||||||
} catch {
|
} catch {
|
||||||
return { error: 'read-failed', name }
|
return { error: 'read-failed', name }
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-20
@@ -8,7 +8,7 @@ import {
|
|||||||
type MouseEvent as ReactMouseEvent
|
type MouseEvent as ReactMouseEvent
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { computeDiff } from './diff/diffEngine'
|
import { computeDiff } from './diff/diffEngine'
|
||||||
import type { DiffResult, DiffSummary } from './diff/diffEngine'
|
import type { DiffOptions, DiffResult, DiffSummary } from './diff/diffEngine'
|
||||||
import DiffView from './components/DiffView'
|
import DiffView from './components/DiffView'
|
||||||
import Toolbar from './components/Toolbar'
|
import Toolbar from './components/Toolbar'
|
||||||
import ContextMenu, { type ContextMenuItem } from './components/ContextMenu'
|
import ContextMenu, { type ContextMenuItem } from './components/ContextMenu'
|
||||||
@@ -37,11 +37,6 @@ interface PaneState {
|
|||||||
text: string
|
text: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DiffOpts {
|
|
||||||
trimWhitespace: boolean
|
|
||||||
ignoreCase: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
function LogoIcon(): ReactElement {
|
function LogoIcon(): ReactElement {
|
||||||
return (
|
return (
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||||
@@ -59,7 +54,7 @@ function EmptyPane({
|
|||||||
}: {
|
}: {
|
||||||
side: 'left' | 'right'
|
side: 'left' | 'right'
|
||||||
onOpen: (s: 'left' | 'right') => void
|
onOpen: (s: 'left' | 'right') => void
|
||||||
onDrop: (s: 'left' | 'right', file: File) => void
|
onDrop: (s: 'left' | 'right', files: File[]) => void
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
const [drag, setDrag] = useState(false)
|
const [drag, setDrag] = useState(false)
|
||||||
return (
|
return (
|
||||||
@@ -73,8 +68,8 @@ function EmptyPane({
|
|||||||
onDrop={(e) => {
|
onDrop={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setDrag(false)
|
setDrag(false)
|
||||||
const f = e.dataTransfer.files[0]
|
const files = Array.from(e.dataTransfer.files)
|
||||||
if (f) onDrop(side, f)
|
if (files.length > 0) onDrop(side, files)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
@@ -93,7 +88,7 @@ function EmptyPane({
|
|||||||
export default function App(): ReactElement {
|
export default function App(): ReactElement {
|
||||||
const [paneL, setPaneL] = useState<PaneState | null>(null)
|
const [paneL, setPaneL] = useState<PaneState | null>(null)
|
||||||
const [paneR, setPaneR] = useState<PaneState | null>(null)
|
const [paneR, setPaneR] = useState<PaneState | null>(null)
|
||||||
const [options, setOptions] = useState<DiffOpts>({ trimWhitespace: false, ignoreCase: false })
|
const [options, setOptions] = useState<DiffOptions>({ trimWhitespace: false, ignoreCase: false })
|
||||||
const [activeRowId, setActiveRowId] = useState<string | null>(null)
|
const [activeRowId, setActiveRowId] = useState<string | null>(null)
|
||||||
const [navIndex, setNavIndex] = useState(0)
|
const [navIndex, setNavIndex] = useState(0)
|
||||||
const [menu, setMenu] = useState<{ x: number; y: number; items: ContextMenuItem[] } | null>(null)
|
const [menu, setMenu] = useState<{ x: number; y: number; items: ContextMenuItem[] } | null>(null)
|
||||||
@@ -155,7 +150,10 @@ export default function App(): ReactElement {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const dropPane = useCallback(
|
const dropPane = useCallback(
|
||||||
async (side: 'left' | 'right', file: File) => {
|
async (side: 'left' | 'right', files: File[]) => {
|
||||||
|
const file = files[0]
|
||||||
|
if (!file) return
|
||||||
|
if (files.length > 1) showToast('一次只能导入一个文件,已取第一个')
|
||||||
if (!isTextFile(file.name)) {
|
if (!isTextFile(file.name)) {
|
||||||
showToast(`仅支持文本文件:${file.name}`)
|
showToast(`仅支持文本文件:${file.name}`)
|
||||||
return
|
return
|
||||||
@@ -174,7 +172,13 @@ export default function App(): ReactElement {
|
|||||||
encoding = decoded.encoding
|
encoding = decoded.encoding
|
||||||
binary = decoded.binary
|
binary = decoded.binary
|
||||||
} catch {
|
} catch {
|
||||||
text = await file.text().catch(() => '')
|
// IPC 解码失败时回退浏览器原生读取;两者都失败则明确报错,不静默加载空文本
|
||||||
|
const fallback = await file.text().catch(() => null)
|
||||||
|
if (fallback === null) {
|
||||||
|
showToast(`文件读取失败:${file.name}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
text = fallback
|
||||||
}
|
}
|
||||||
if (binary) showToast(`疑似二进制文件,内容可能乱码:${file.name}`)
|
if (binary) showToast(`疑似二进制文件,内容可能乱码:${file.name}`)
|
||||||
const pane: PaneState = { meta: { name: file.name, encoding }, path: '', text }
|
const pane: PaneState = { meta: { name: file.name, encoding }, path: '', text }
|
||||||
@@ -219,11 +223,11 @@ export default function App(): ReactElement {
|
|||||||
const items: ContextMenuItem[] = [
|
const items: ContextMenuItem[] = [
|
||||||
{
|
{
|
||||||
label: `复制${sideName}此行内容`,
|
label: `复制${sideName}此行内容`,
|
||||||
action: () => void navigator.clipboard.writeText(cell.text ?? '')
|
action: () => void window.api.setClipboard(cell.text ?? '')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '复制文件名',
|
label: '复制文件名',
|
||||||
action: () => void navigator.clipboard.writeText(pane?.meta.name ?? '')
|
action: () => void window.api.setClipboard(pane?.meta.name ?? '')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '在文件夹中显示',
|
label: '在文件夹中显示',
|
||||||
@@ -270,12 +274,20 @@ export default function App(): ReactElement {
|
|||||||
|
|
||||||
const anyPane = paneL !== null || paneR !== null
|
const anyPane = paneL !== null || paneR !== null
|
||||||
|
|
||||||
const confirmPaste = useCallback((side: 'left' | 'right', text: string) => {
|
const confirmPaste = useCallback(
|
||||||
const pane: PaneState = { meta: { name: '手动文本', encoding: '文本' }, path: '', text }
|
(side: 'left' | 'right', text: string) => {
|
||||||
if (side === 'left') setPaneL(pane)
|
// 与文件入口的 10MB 字节上限保持对称:超限拒绝且不关闭弹窗,保留内容供编辑
|
||||||
else setPaneR(pane)
|
if (new TextEncoder().encode(text).length > MAX_FILE_SIZE) {
|
||||||
setPasteSide(null)
|
showToast('粘贴内容超过 10MB,暂不支持对比')
|
||||||
}, [])
|
return
|
||||||
|
}
|
||||||
|
const pane: PaneState = { meta: { name: '手动文本', encoding: '文本' }, path: '', text }
|
||||||
|
if (side === 'left') setPaneL(pane)
|
||||||
|
else setPaneR(pane)
|
||||||
|
setPasteSide(null)
|
||||||
|
},
|
||||||
|
[showToast]
|
||||||
|
)
|
||||||
|
|
||||||
const leftMeta = paneL?.meta ?? null
|
const leftMeta = paneL?.meta ?? null
|
||||||
const rightMeta = paneR?.meta ?? null
|
const rightMeta = paneR?.meta ?? null
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|||||||
import { render, screen, fireEvent, act } from '@testing-library/react'
|
import { render, screen, fireEvent, act } from '@testing-library/react'
|
||||||
import App from '../App'
|
import App from '../App'
|
||||||
|
|
||||||
|
let clipboardSpy: ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
function mockApi(overrides?: Partial<typeof window.api>): typeof window.api {
|
function mockApi(overrides?: Partial<typeof window.api>): typeof window.api {
|
||||||
|
// 剪贴板统一走主进程 IPC,spy 挂在 setClipboard 上
|
||||||
|
clipboardSpy = vi.fn(async () => true)
|
||||||
const base = {
|
const base = {
|
||||||
openFile: async () => ({
|
openFile: async () => ({
|
||||||
path: '/tmp/a.txt',
|
path: '/tmp/a.txt',
|
||||||
@@ -14,23 +18,16 @@ function mockApi(overrides?: Partial<typeof window.api>): typeof window.api {
|
|||||||
decodeBuffer: async () => ({ text: 'line1\nline2', encoding: 'UTF-8', binary: false }),
|
decodeBuffer: async () => ({ text: 'line1\nline2', encoding: 'UTF-8', binary: false }),
|
||||||
saveReport: async () => ({ ok: false, path: null }),
|
saveReport: async () => ({ ok: false, path: null }),
|
||||||
showInFolder: async () => true,
|
showInFolder: async () => true,
|
||||||
setClipboard: async () => true
|
setClipboard: clipboardSpy
|
||||||
}
|
}
|
||||||
return { ...base, ...overrides } as typeof window.api
|
return { ...base, ...overrides } as typeof window.api
|
||||||
}
|
}
|
||||||
|
|
||||||
let clipboardSpy: ReturnType<typeof vi.fn>
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
window.api = mockApi()
|
window.api = mockApi()
|
||||||
// jsdom 未实现 scrollIntoView,导航定位需要打桩
|
// jsdom 未实现 scrollIntoView,导航定位需要打桩
|
||||||
Element.prototype.scrollIntoView = vi.fn()
|
Element.prototype.scrollIntoView = vi.fn()
|
||||||
// jsdom 未实现 navigator.clipboard,右键复制需要打桩
|
|
||||||
clipboardSpy = vi.fn()
|
|
||||||
Object.defineProperty(navigator, 'clipboard', {
|
|
||||||
value: { writeText: clipboardSpy },
|
|
||||||
configurable: true
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -175,7 +172,7 @@ describe('App - 右键菜单操作', () => {
|
|||||||
expect(screen.queryByText('复制左侧此行内容')).not.toBeInTheDocument()
|
expect(screen.queryByText('复制左侧此行内容')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('执行复制文件名', async () => {
|
it('执行复制文件名(走主进程剪贴板 IPC)', async () => {
|
||||||
render(<App />)
|
render(<App />)
|
||||||
fireEvent.click(screen.getByText('打开左侧'))
|
fireEvent.click(screen.getByText('打开左侧'))
|
||||||
await screen.findByText('导出报告')
|
await screen.findByText('导出报告')
|
||||||
@@ -323,6 +320,31 @@ describe('App - 提示与容错', () => {
|
|||||||
expect(await screen.findByText(/疑似二进制文件/)).toBeInTheDocument()
|
expect(await screen.findByText(/疑似二进制文件/)).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('拖放文件解码与兜底读取均失败时提示且保持空态', async () => {
|
||||||
|
window.api = mockApi({
|
||||||
|
decodeBuffer: async () => {
|
||||||
|
throw new Error('boom')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
vi.spyOn(File.prototype, 'text').mockRejectedValue(new Error('io'))
|
||||||
|
render(<App />)
|
||||||
|
const file = new File(['x'], 'bad.txt', { type: 'text/plain' })
|
||||||
|
const pane = screen.getByText(/选择左侧文件/).closest('.pane-empty') as Element
|
||||||
|
fireEvent.drop(pane, { dataTransfer: { files: [file] } })
|
||||||
|
expect(await screen.findByText(/文件读取失败:bad\.txt/)).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('导出报告')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('多文件拖拽时提示已取第一个并加载首个文件', async () => {
|
||||||
|
render(<App />)
|
||||||
|
const first = new File(['a'], 'first.txt', { type: 'text/plain' })
|
||||||
|
const second = new File(['b'], 'second.txt', { type: 'text/plain' })
|
||||||
|
const pane = screen.getByText(/选择左侧文件/).closest('.pane-empty') as Element
|
||||||
|
fireEvent.drop(pane, { dataTransfer: { files: [first, second] } })
|
||||||
|
expect(await screen.findByText(/一次只能导入一个文件/)).toBeInTheDocument()
|
||||||
|
expect(screen.getAllByText('first.txt').length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
it('导出时 IPC 异常给出失败提示', async () => {
|
it('导出时 IPC 异常给出失败提示', async () => {
|
||||||
window.api = mockApi({
|
window.api = mockApi({
|
||||||
saveReport: async () => {
|
saveReport: async () => {
|
||||||
@@ -379,4 +401,18 @@ describe('App - 手动粘贴文本', () => {
|
|||||||
fireEvent.click(screen.getByText('开始对比'))
|
fireEvent.click(screen.getByText('开始对比'))
|
||||||
expect(await screen.findByText('导出报告')).toBeInTheDocument()
|
expect(await screen.findByText('导出报告')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('粘贴内容超过 10MB 被拒绝且弹窗保持打开', async () => {
|
||||||
|
render(<App />)
|
||||||
|
fireEvent.click(screen.getByText('粘贴文本'))
|
||||||
|
fireEvent.click(screen.getByText('粘贴到左侧'))
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/粘贴或输入/), {
|
||||||
|
target: { value: 'x'.repeat(10 * 1024 * 1024 + 1) }
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByText('开始对比'))
|
||||||
|
expect(await screen.findByText(/粘贴内容超过 10MB/)).toBeInTheDocument()
|
||||||
|
// 弹窗保持打开,未进入对比视图
|
||||||
|
expect(screen.getByText('开始对比')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('导出报告')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -55,4 +55,45 @@ describe('ContextMenu', () => {
|
|||||||
fireEvent.keyDown(document, { key: 'Escape' })
|
fireEvent.keyDown(document, { key: 'Escape' })
|
||||||
expect(onClose).toHaveBeenCalled()
|
expect(onClose).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('靠近右/下边缘时收回视口内', () => {
|
||||||
|
// 模拟菜单实测尺寸 200x120
|
||||||
|
const rectSpy = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({
|
||||||
|
width: 200,
|
||||||
|
height: 120,
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 200,
|
||||||
|
bottom: 120,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
toJSON: () => ({})
|
||||||
|
} as DOMRect)
|
||||||
|
render(
|
||||||
|
<ContextMenu
|
||||||
|
x={window.innerWidth + 500}
|
||||||
|
y={window.innerHeight + 500}
|
||||||
|
items={[{ label: '菜单项', action: vi.fn() }]}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
const menu = document.querySelector('.ctx-menu') as HTMLElement
|
||||||
|
expect(parseFloat(menu.style.left)).toBe(window.innerWidth - 8 - 200)
|
||||||
|
expect(parseFloat(menu.style.top)).toBe(window.innerHeight - 8 - 120)
|
||||||
|
rectSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('未溢出时保持原始坐标', () => {
|
||||||
|
render(
|
||||||
|
<ContextMenu
|
||||||
|
x={10}
|
||||||
|
y={10}
|
||||||
|
items={[{ label: '菜单项', action: vi.fn() }]}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
const menu = document.querySelector('.ctx-menu') as HTMLElement
|
||||||
|
expect(menu.style.left).toBe('10px')
|
||||||
|
expect(menu.style.top).toBe('10px')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, type ReactElement } from 'react'
|
import { useEffect, useLayoutEffect, useRef, useState, type ReactElement } from 'react'
|
||||||
|
|
||||||
export interface ContextMenuItem {
|
export interface ContextMenuItem {
|
||||||
label: string
|
label: string
|
||||||
@@ -19,6 +19,9 @@ export default function ContextMenu({
|
|||||||
items,
|
items,
|
||||||
onClose
|
onClose
|
||||||
}: ContextMenuProps): ReactElement {
|
}: ContextMenuProps): ReactElement {
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [pos, setPos] = useState({ left: x, top: y })
|
||||||
|
|
||||||
// Esc 关闭菜单
|
// Esc 关闭菜单
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent): void => {
|
const onKey = (e: KeyboardEvent): void => {
|
||||||
@@ -28,6 +31,21 @@ export default function ContextMenu({
|
|||||||
return () => document.removeEventListener('keydown', onKey)
|
return () => document.removeEventListener('keydown', onKey)
|
||||||
}, [onClose])
|
}, [onClose])
|
||||||
|
|
||||||
|
// 渲染后实测菜单尺寸,溢出视口时收回边界内(右/下各留 8px 边距)
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = menuRef.current
|
||||||
|
if (!el) return
|
||||||
|
const { width, height } = el.getBoundingClientRect()
|
||||||
|
const vw = window.innerWidth
|
||||||
|
const vh = window.innerHeight
|
||||||
|
const margin = 8
|
||||||
|
let left = x
|
||||||
|
let top = y
|
||||||
|
if (left + width > vw - margin) left = Math.max(margin, vw - margin - width)
|
||||||
|
if (top + height > vh - margin) top = Math.max(margin, vh - margin - height)
|
||||||
|
setPos((prev) => (prev.left === left && prev.top === top ? prev : { left, top }))
|
||||||
|
}, [x, y, items])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="ctx-backdrop"
|
className="ctx-backdrop"
|
||||||
@@ -38,8 +56,9 @@ export default function ContextMenu({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
ref={menuRef}
|
||||||
className="ctx-menu"
|
className="ctx-menu"
|
||||||
style={{ left: x, top: y }}
|
style={{ left: pos.left, top: pos.top }}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{items.map((it, i) => (
|
{items.map((it, i) => (
|
||||||
|
|||||||
@@ -199,6 +199,6 @@ describe('DiffView', () => {
|
|||||||
const panes = container.querySelectorAll('.pane')
|
const panes = container.querySelectorAll('.pane')
|
||||||
const file = new File(['x'], 'c.txt', { type: 'text/plain' })
|
const file = new File(['x'], 'c.txt', { type: 'text/plain' })
|
||||||
fireEvent.drop(panes[0] as Element, { dataTransfer: { files: [file] } })
|
fireEvent.drop(panes[0] as Element, { dataTransfer: { files: [file] } })
|
||||||
expect(onDropFile).toHaveBeenCalledWith('left', file)
|
expect(onDropFile).toHaveBeenCalledWith('left', [file])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -20,7 +20,7 @@ interface DiffViewProps {
|
|||||||
activeRowId: string | null
|
activeRowId: string | null
|
||||||
onOpen: (side: 'left' | 'right') => void
|
onOpen: (side: 'left' | 'right') => void
|
||||||
onRowContext: (e: ReactMouseEvent, side: 'left' | 'right', cell: SideCell) => void
|
onRowContext: (e: ReactMouseEvent, side: 'left' | 'right', cell: SideCell) => void
|
||||||
onDropFile: (side: 'left' | 'right', file: File) => void
|
onDropFile: (side: 'left' | 'right', files: File[]) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 把行尾空白用淡色高亮显示,便于识别被忽略/忽略差异 */
|
/** 把行尾空白用淡色高亮显示,便于识别被忽略/忽略差异 */
|
||||||
@@ -66,7 +66,7 @@ function SidePanel({
|
|||||||
meta: PaneMeta | null
|
meta: PaneMeta | null
|
||||||
onOpen: (side: 'left' | 'right') => void
|
onOpen: (side: 'left' | 'right') => void
|
||||||
onRowContext: (e: ReactMouseEvent, side: 'left' | 'right', cell: SideCell) => void
|
onRowContext: (e: ReactMouseEvent, side: 'left' | 'right', cell: SideCell) => void
|
||||||
onDropFile: (side: 'left' | 'right', file: File) => void
|
onDropFile: (side: 'left' | 'right', files: File[]) => void
|
||||||
scrollRef: RefObject<HTMLDivElement>
|
scrollRef: RefObject<HTMLDivElement>
|
||||||
onScroll: () => void
|
onScroll: () => void
|
||||||
activeRowId: string | null
|
activeRowId: string | null
|
||||||
@@ -77,8 +77,8 @@ function SidePanel({
|
|||||||
onDragOver={(e) => e.preventDefault()}
|
onDragOver={(e) => e.preventDefault()}
|
||||||
onDrop={(e) => {
|
onDrop={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const f = e.dataTransfer.files[0]
|
const files = Array.from(e.dataTransfer.files)
|
||||||
if (f) onDropFile(side, f)
|
if (files.length > 0) onDropFile(side, files)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="panel-head">
|
<div className="panel-head">
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
import { useCallback, useRef, useState, type ReactElement } from 'react'
|
import { useCallback, useRef, useState, type ReactElement } from 'react'
|
||||||
import { DiffSummary } from '../diff/diffEngine'
|
import type { DiffOptions, DiffSummary } from '../diff/diffEngine'
|
||||||
import { type ReportFormat } from '../diff/report'
|
import { type ReportFormat } from '../diff/report'
|
||||||
import { useDismiss } from '../hooks/useDismiss'
|
import { useDismiss } from '../hooks/useDismiss'
|
||||||
|
|
||||||
export interface DiffOptions {
|
|
||||||
trimWhitespace: boolean
|
|
||||||
ignoreCase: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const FORMATS: { fmt: ReportFormat; label: string }[] = [
|
const FORMATS: { fmt: ReportFormat; label: string }[] = [
|
||||||
{ fmt: 'html', label: 'HTML 报告' },
|
{ fmt: 'html', label: 'HTML 报告' },
|
||||||
{ fmt: 'txt', label: '纯文本' },
|
{ fmt: 'txt', label: '纯文本' },
|
||||||
|
|||||||
@@ -46,8 +46,6 @@ export interface DiffResult {
|
|||||||
summary: DiffSummary
|
summary: DiffSummary
|
||||||
}
|
}
|
||||||
|
|
||||||
let rowSeq = 0
|
|
||||||
|
|
||||||
function emptyLine(): SideCell {
|
function emptyLine(): SideCell {
|
||||||
return { lineNo: null, text: null, segs: null }
|
return { lineNo: null, text: null, segs: null }
|
||||||
}
|
}
|
||||||
@@ -89,7 +87,7 @@ export function computeDiff(
|
|||||||
rightText: string,
|
rightText: string,
|
||||||
options: DiffOptions = {}
|
options: DiffOptions = {}
|
||||||
): DiffResult {
|
): DiffResult {
|
||||||
rowSeq = 0
|
let rowSeq = 0
|
||||||
const leftOrig = splitLines(leftText)
|
const leftOrig = splitLines(leftText)
|
||||||
const rightOrig = splitLines(rightText)
|
const rightOrig = splitLines(rightText)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user