feat: v0.3.0 大文件性能与差异导航增强 - 自研虚拟滚动、仅看差异过滤(折叠提示行)、F7/Shift+F7导航快捷键、交换左右侧、忽略空行
This commit is contained in:
@@ -11,14 +11,17 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两
|
|||||||
|
|
||||||
- **双栏并排对比**:左(原文件)右(对比文件)对照,行号清晰对齐
|
- **双栏并排对比**:左(原文件)右(对比文件)对照,行号清晰对齐
|
||||||
- **行级 + 词级双重高亮**:整行增删一目了然,同一行内的细微改动也能被精确定位
|
- **行级 + 词级双重高亮**:整行增删一目了然,同一行内的细微改动也能被精确定位
|
||||||
- **差异导航**:一键“上一处 / 下一处”在差异之间跳跃,也可直接点击定位
|
- **虚拟滚动**:仅渲染可见行,数万行大文件滚动依然流畅
|
||||||
|
- **差异导航**:一键“上一处 / 下一处”在差异之间跳跃,支持 F7 / Shift+F7 快捷键
|
||||||
|
- **仅看差异**:一键过滤未更改行,连续相同内容折叠为提示行,专注浏览差异
|
||||||
|
- **交换左右侧**:一键互换两侧内容与视角
|
||||||
- **智能编码识别**:自动探测 UTF-8 / UTF-16 / GBK(含 BOM),中文文档不乱码
|
- **智能编码识别**:自动探测 UTF-8 / UTF-16 / GBK(含 BOM),中文文档不乱码
|
||||||
- **忽略选项**:支持“忽略行首尾空白”“忽略大小写”,适配弱差异场景
|
- **忽略选项**:支持“忽略行首尾空白”“忽略大小写”“忽略空行”,适配弱差异场景
|
||||||
- **手动粘贴对比**:无需文件,直接粘贴两侧文本即可开始对比
|
- **手动粘贴对比**:无需文件,直接粘贴两侧文本即可开始对比
|
||||||
- **大文件防护**:超过 10MB 的文件自动拦截并提示;行数过多时给出滚动卡顿预警
|
- **大文件防护**:超过 10MB 的文件自动拦截并提示;行数过多时给出耗时预警
|
||||||
- **二进制文件预警**:疑似二进制内容给出乱码提示,避免误读为差异
|
- **二进制文件预警**:疑似二进制内容给出乱码提示,避免误读为差异
|
||||||
- **差异统计徽章**:实时统计新增 / 删除 / 修改行数
|
- **差异统计徽章**:实时统计新增 / 删除 / 修改行数
|
||||||
- **导出一键报告**:将对比结果导出为 HTML / 纯文本 / Markdown 三种格式,便于分享与归档
|
- **导出一键报告**:将对比结果导出为 HTML / 纯文本 / Markdown 三种格式(始终包含全量行),便于分享与归档
|
||||||
- **拖拽与按钮双入口**:支持文件拖拽导入,也可通过界面按钮选择文件或粘贴文本
|
- **拖拽与按钮双入口**:支持文件拖拽导入,也可通过界面按钮选择文件或粘贴文本
|
||||||
- **滚动联动**:左右两个面板滚动位置自动同步
|
- **滚动联动**:左右两个面板滚动位置自动同步
|
||||||
|
|
||||||
@@ -90,7 +93,7 @@ src/
|
|||||||
- 作者:thzxx
|
- 作者:thzxx
|
||||||
- 组织:MetonaTeam
|
- 组织:MetonaTeam
|
||||||
- 许可证:MIT License(见 [LICENSE](./LICENSE))
|
- 许可证:MIT License(见 [LICENSE](./LICENSE))
|
||||||
- 版本:0.2.6
|
- 版本:0.3.0
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+4
-3
@@ -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.6`**
|
当前基线版本:**`0.3.0`**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -62,8 +62,9 @@ z = 补丁版本号(Patch)
|
|||||||
0.2.3 ← 安装器优化(已发布):NSIS 向导式安装,支持手动选择安装目录
|
0.2.3 ← 安装器优化(已发布):NSIS 向导式安装,支持手动选择安装目录
|
||||||
0.2.4 ← 健壮性与体验打磨(已发布):读取容错与 10MB 大文件防护 · 横向滚动同步 · 下拉点击外部/Esc 关闭 · 二进制误判预警
|
0.2.4 ← 健壮性与体验打磨(已发布):读取容错与 10MB 大文件防护 · 横向滚动同步 · 下拉点击外部/Esc 关闭 · 二进制误判预警
|
||||||
0.2.5 ← 一致性与边角修复(已发布):右键菜单边缘防溢出 · 粘贴入口 10MB 防护 · 拖拽读取失败提示 · 多文件拖拽提示 · 剪贴板统一走主进程 · 主进程异步读取
|
0.2.5 ← 一致性与边角修复(已发布):右键菜单边缘防溢出 · 粘贴入口 10MB 防护 · 拖拽读取失败提示 · 多文件拖拽提示 · 剪贴板统一走主进程 · 主进程异步读取
|
||||||
0.2.6 ← 主进程异步收尾与边角防护(当前):保存报告异步写盘 · 拖拽解码 handler 异步化 · 空白粘贴拦截 · 超多行卡顿预警 · 清理 mac 无效签名配置 · 文档与测试同步
|
0.2.6 ← 主进程异步收尾与边角防护(已发布):保存报告异步写盘 · 拖拽解码 handler 异步化 · 空白粘贴拦截 · 超多行卡顿预警 · 清理 mac 无效签名配置 · 文档与测试同步
|
||||||
0.3.0 ← 新增功能(候选:虚拟滚动 · 仅看差异过滤 · 差异导航快捷键 · 交换左右侧 · 忽略空行 · 主进程解码 worker 化)
|
0.3.0 ← 大文件性能与差异导航增强(当前):自研虚拟滚动 · 仅看差异过滤(折叠提示行) · F7/Shift+F7 导航快捷键 · 交换左右侧 · 忽略空行
|
||||||
|
0.4.0 ← 新增功能(候选:主进程解码 worker 化 · 渲染进程 diff 计算 worker 化 · Playwright E2E · 文件夹对比)
|
||||||
...
|
...
|
||||||
0.y.z ← 长期停留,永不进入 1.x
|
0.y.z ← 长期停留,永不进入 1.x
|
||||||
```
|
```
|
||||||
|
|||||||
+18
-5
@@ -70,7 +70,7 @@ src/renderer/src/
|
|||||||
- 完全一致 → 全部 `unchanged`,无差异
|
- 完全一致 → 全部 `unchanged`,无差异
|
||||||
- 行级:新增、删除(含左/右行号、空槽 lineNo 为 `null`)
|
- 行级:新增、删除(含左/右行号、空槽 lineNo 为 `null`)
|
||||||
- 词级:同一删除/新增配对为 `modified` 并产生左右 `segs`
|
- 词级:同一删除/新增配对为 `modified` 并产生左右 `segs`
|
||||||
- 忽略选项:`ignoreCase`、`trimWhitespace` 生效与关闭
|
- 忽略选项:`ignoreCase`、`trimWhitespace`、`ignoreBlankLines` 生效与关闭
|
||||||
- 行切分:结尾换行不产生多余空行
|
- 行切分:结尾换行不产生多余空行
|
||||||
|
|
||||||
### 5.2 报告生成(`report.ts`)
|
### 5.2 报告生成(`report.ts`)
|
||||||
@@ -79,10 +79,23 @@ src/renderer/src/
|
|||||||
- Markdown:表格化、管道符转义保持表格结构
|
- Markdown:表格化、管道符转义保持表格结构
|
||||||
- 格式分发与扩展名映射(`REPORT_EXT`)
|
- 格式分发与扩展名映射(`REPORT_EXT`)
|
||||||
|
|
||||||
### 5.3 应用冒烟(`App.tsx`)
|
### 5.3 文本工具(`textUtils.ts`)
|
||||||
- 渲染应用名 `DiffLens`
|
- 文本扩展名白名单放行 / 拒绝 / 大小写不敏感
|
||||||
- 未加载文件时展示左右空态面板
|
- `countLines` 与 diff 引擎切分规则一致(结尾换行、CRLF)
|
||||||
- 未加载文件时不显示导出入口(导出仅在加载后可用)
|
- `displayCols` 半角 1 列 / 全角 2 列 / 中英混排叠加
|
||||||
|
|
||||||
|
### 5.4 对比视图(`DiffView.tsx`)
|
||||||
|
- 渲染:行内容、行号、词级高亮段、行尾空白标注
|
||||||
|
- 虚拟滚动:大行数下仅渲染可见窗口(DOM 行数远小于总量);滚动后窗口移动;容器总高度按全部行数撑满
|
||||||
|
- 折叠提示行:默认文案与自定义文案(仅看差异视图)
|
||||||
|
- 交互:左右/横向滚动同步、右键菜单回调、拖放回调、导航定位走受控 `scrollTo`
|
||||||
|
|
||||||
|
### 5.5 应用冒烟与集成(`App.tsx`)
|
||||||
|
- 渲染应用名 `DiffLens`;未加载文件时展示左右空态面板、不显示导出入口
|
||||||
|
- 加载文件后的对比视图、导出、右键菜单、拖拽、粘贴、容错提示
|
||||||
|
- 仅看差异视图:未更改行折叠、折叠提示出现、关闭后恢复
|
||||||
|
- F7 / Shift+F7 导航快捷键;粘贴弹窗打开时快捷键不抢占
|
||||||
|
- 交换左右侧后面板文件互换
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "DiffLens",
|
"name": "DiffLens",
|
||||||
"version": "0.2.6",
|
"version": "0.3.0",
|
||||||
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
|
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
|
||||||
"author": "thzxx",
|
"author": "thzxx",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from 'react'
|
} from 'react'
|
||||||
import { computeDiff } from './diff/diffEngine'
|
import { computeDiff } from './diff/diffEngine'
|
||||||
import type { DiffOptions, DiffResult, DiffSummary } from './diff/diffEngine'
|
import type { DiffOptions, DiffResult, DiffSummary } from './diff/diffEngine'
|
||||||
import DiffView from './components/DiffView'
|
import DiffView, { type DisplayItem, type FoldMarker } 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'
|
||||||
import TextInputModal from './components/TextInputModal'
|
import TextInputModal from './components/TextInputModal'
|
||||||
@@ -88,7 +88,13 @@ 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<DiffOptions>({ trimWhitespace: false, ignoreCase: false })
|
const [options, setOptions] = useState<DiffOptions>({
|
||||||
|
trimWhitespace: false,
|
||||||
|
ignoreCase: false,
|
||||||
|
ignoreBlankLines: false
|
||||||
|
})
|
||||||
|
// 仅看差异:视图层过滤选项(不影响比较语义,也与报告导出无关,报告始终全量行)
|
||||||
|
const [onlyDiff, setOnlyDiff] = useState(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)
|
||||||
@@ -119,11 +125,11 @@ export default function App(): ReactElement {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// 超多行预警:行数超过阈值时提示滚动可能卡顿(不阻断加载)
|
// 超多行预警:行数超过阈值时提示计算耗时(虚拟化已解决渲染卡顿,剩余瓶颈在 diff 计算本身)
|
||||||
const warnHeavyLines = useCallback(
|
const warnHeavyLines = useCallback(
|
||||||
(text: string): void => {
|
(text: string): void => {
|
||||||
const lines = countLines(text)
|
const lines = countLines(text)
|
||||||
if (lines > HEAVY_LINES) showToast(`行数较多(${lines} 行),滚动可能出现卡顿`)
|
if (lines > HEAVY_LINES) showToast(`行数较多(${lines} 行),计算与首次加载可能耗时`)
|
||||||
},
|
},
|
||||||
[showToast]
|
[showToast]
|
||||||
)
|
)
|
||||||
@@ -206,11 +212,46 @@ export default function App(): ReactElement {
|
|||||||
const changedRows = useMemo(() => diff.rows.filter((r) => r.isChanged), [diff])
|
const changedRows = useMemo(() => diff.rows.filter((r) => r.isChanged), [diff])
|
||||||
const summary: DiffSummary = diff.summary
|
const summary: DiffSummary = diff.summary
|
||||||
|
|
||||||
// diff 内容变化时重置导航
|
// “仅看差异”视图行集:过滤未更改行,在连续省略处插入折叠提示行(占一个虚拟行位)
|
||||||
|
const displayRows = useMemo(() => {
|
||||||
|
if (!onlyDiff) return diff.rows as DisplayItem[]
|
||||||
|
if (diff.rows.length === 0) return [] as DisplayItem[]
|
||||||
|
if (changedRows.length === 0) {
|
||||||
|
const m: FoldMarker = {
|
||||||
|
fold: true,
|
||||||
|
skipped: diff.rows.length,
|
||||||
|
note: '两文件内容完全一致,已折叠全部未更改行'
|
||||||
|
}
|
||||||
|
return [m]
|
||||||
|
}
|
||||||
|
const out: DisplayItem[] = []
|
||||||
|
let skipped = 0
|
||||||
|
for (const r of diff.rows) {
|
||||||
|
if (r.isChanged) {
|
||||||
|
if (skipped > 0) {
|
||||||
|
out.push({ fold: true, skipped })
|
||||||
|
skipped = 0
|
||||||
|
}
|
||||||
|
out.push(r)
|
||||||
|
} else {
|
||||||
|
skipped++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (skipped > 0) out.push({ fold: true, skipped })
|
||||||
|
return out
|
||||||
|
}, [diff, onlyDiff, changedRows.length])
|
||||||
|
|
||||||
|
// 交换左右两侧(React 18 事件回调内自动批处理,两态一同更新)
|
||||||
|
const swapPanes = useCallback((): void => {
|
||||||
|
setPaneL(paneR)
|
||||||
|
setPaneR(paneL)
|
||||||
|
}, [paneL, paneR])
|
||||||
|
|
||||||
|
// diff 内容或视图选项变化时重置导航
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setNavIndex(0)
|
setNavIndex(0)
|
||||||
setActiveRowId(null)
|
setActiveRowId(null)
|
||||||
}, [paneL, paneR, options])
|
}, [paneL, paneR, options, onlyDiff])
|
||||||
|
|
||||||
const go = useCallback(
|
const go = useCallback(
|
||||||
(dir: 1 | -1) => {
|
(dir: 1 | -1) => {
|
||||||
@@ -225,6 +266,18 @@ export default function App(): ReactElement {
|
|||||||
[changedRows, navIndex]
|
[changedRows, navIndex]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 差异导航快捷键:F7 下一处 / Shift+F7 上一处;粘贴弹窗打开时不抢占
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent): void => {
|
||||||
|
if (e.key !== 'F7') return
|
||||||
|
if (pasteSide !== null) return
|
||||||
|
e.preventDefault()
|
||||||
|
go(e.shiftKey ? -1 : 1)
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
|
}, [go, pasteSide])
|
||||||
|
|
||||||
// 行级右键菜单
|
// 行级右键菜单
|
||||||
const onRowContext = useCallback(
|
const onRowContext = useCallback(
|
||||||
(e: ReactMouseEvent, side: 'left' | 'right', cell: SideCell) => {
|
(e: ReactMouseEvent, side: 'left' | 'right', cell: SideCell) => {
|
||||||
@@ -369,7 +422,7 @@ export default function App(): ReactElement {
|
|||||||
{anyPane ? (
|
{anyPane ? (
|
||||||
<>
|
<>
|
||||||
<DiffView
|
<DiffView
|
||||||
diff={diff}
|
items={displayRows}
|
||||||
leftMeta={leftMeta}
|
leftMeta={leftMeta}
|
||||||
rightMeta={rightMeta}
|
rightMeta={rightMeta}
|
||||||
activeRowId={activeRowId}
|
activeRowId={activeRowId}
|
||||||
@@ -386,6 +439,9 @@ export default function App(): ReactElement {
|
|||||||
onNav={go}
|
onNav={go}
|
||||||
onExport={(fmt) => void exportReport(fmt)}
|
onExport={(fmt) => void exportReport(fmt)}
|
||||||
canExport={anyPane}
|
canExport={anyPane}
|
||||||
|
onlyDiff={onlyDiff}
|
||||||
|
onOnlyDiffChange={setOnlyDiff}
|
||||||
|
onSwap={swapPanes}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -26,8 +26,9 @@ function mockApi(overrides?: Partial<typeof window.api>): typeof window.api {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.restoreAllMocks()
|
vi.restoreAllMocks()
|
||||||
window.api = mockApi()
|
window.api = mockApi()
|
||||||
// jsdom 未实现 scrollIntoView,导航定位需要打桩
|
// jsdom 未实现滚动定位,导航定位(虚拟化受控 scrollTop)需要打桩
|
||||||
Element.prototype.scrollIntoView = vi.fn()
|
Element.prototype.scrollIntoView = vi.fn()
|
||||||
|
Element.prototype.scrollTo = vi.fn()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -140,7 +141,7 @@ describe('App - 差异导航与清空', () => {
|
|||||||
fireEvent.click(screen.getByText('打开左侧'))
|
fireEvent.click(screen.getByText('打开左侧'))
|
||||||
await screen.findByText('导出报告')
|
await screen.findByText('导出报告')
|
||||||
expect(screen.getByText('1 / 2')).toBeInTheDocument()
|
expect(screen.getByText('1 / 2')).toBeInTheDocument()
|
||||||
fireEvent.click(screen.getByTitle('下一处差异'))
|
fireEvent.click(screen.getByTitle(/下一处差异/))
|
||||||
expect(screen.getByText('2 / 2')).toBeInTheDocument()
|
expect(screen.getByText('2 / 2')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -428,4 +429,86 @@ describe('App - 手动粘贴文本', () => {
|
|||||||
expect(screen.getByText('开始对比')).toBeInTheDocument()
|
expect(screen.getByText('开始对比')).toBeInTheDocument()
|
||||||
expect(screen.queryByText('导出报告')).not.toBeInTheDocument()
|
expect(screen.queryByText('导出报告')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('App - 仅看差异视图', () => {
|
||||||
|
async function loadBothSides() {
|
||||||
|
window.api = mockApi({
|
||||||
|
openFile: async (side) =>
|
||||||
|
side === 'left'
|
||||||
|
? { path: '/tmp/a.txt', name: 'a.txt', text: 'same\ndiff-left\nsame2', encoding: 'UTF-8', binary: false }
|
||||||
|
: { path: '/tmp/b.txt', name: 'b.txt', text: 'same\ndiff-right\nsame2', encoding: 'UTF-8', binary: false }
|
||||||
|
})
|
||||||
|
const utils = render(<App />)
|
||||||
|
fireEvent.click(screen.getByText('打开左侧'))
|
||||||
|
await screen.findByText('导出报告')
|
||||||
|
fireEvent.click(screen.getByText('打开右侧'))
|
||||||
|
await screen.findAllByText('b.txt')
|
||||||
|
return utils
|
||||||
|
}
|
||||||
|
|
||||||
|
it('开启后未更改行被折叠并出现折叠提示', async () => {
|
||||||
|
const { container } = await loadBothSides()
|
||||||
|
// 默认视图能看到未更改行
|
||||||
|
expect(screen.getAllByText('same').length).toBeGreaterThan(0)
|
||||||
|
fireEvent.click(screen.getByLabelText('仅看差异'))
|
||||||
|
// 未更改行被折叠
|
||||||
|
expect(screen.queryAllByText('same')).toHaveLength(0)
|
||||||
|
expect(screen.queryAllByText('same2')).toHaveLength(0)
|
||||||
|
// 差异行仍在(修改行左右两栏各一条);same 与 same2 是两个独立折叠段,左右两栏共 4 条提示
|
||||||
|
expect(container.querySelectorAll('.diff-row.modified').length).toBe(2)
|
||||||
|
expect(screen.getAllByText(/已折叠 1 行相同内容/).length).toBe(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('关闭开关后恢复全量视图', async () => {
|
||||||
|
await loadBothSides()
|
||||||
|
fireEvent.click(screen.getByLabelText('仅看差异'))
|
||||||
|
expect(screen.queryAllByText('same')).toHaveLength(0)
|
||||||
|
fireEvent.click(screen.getByLabelText('仅看差异'))
|
||||||
|
expect(screen.getAllByText('same').length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('App - F7 / Shift+F7 差异导航快捷键', () => {
|
||||||
|
it('F7 跳到下一处差异,Shift+F7 回到上一处', async () => {
|
||||||
|
render(<App />)
|
||||||
|
fireEvent.click(screen.getByText('打开左侧'))
|
||||||
|
await screen.findByText('1 / 2')
|
||||||
|
fireEvent.keyDown(window, { key: 'F7' })
|
||||||
|
expect(screen.getByText('2 / 2')).toBeInTheDocument()
|
||||||
|
fireEvent.keyDown(window, { key: 'F7', shiftKey: true })
|
||||||
|
expect(screen.getByText('1 / 2')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('粘贴弹窗打开时 F7 不触发导航', async () => {
|
||||||
|
render(<App />)
|
||||||
|
fireEvent.click(screen.getByText('打开左侧'))
|
||||||
|
await screen.findByText('1 / 2')
|
||||||
|
fireEvent.click(screen.getByText('粘贴文本'))
|
||||||
|
fireEvent.click(screen.getByText('粘贴到左侧'))
|
||||||
|
expect(screen.getByText('开始对比')).toBeInTheDocument()
|
||||||
|
fireEvent.keyDown(window, { key: 'F7' })
|
||||||
|
// 导航未被触发,计数保持 1 / 2
|
||||||
|
expect(screen.getByText('1 / 2')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('App - 交换左右侧', () => {
|
||||||
|
it('交换后左右面板文件互换', async () => {
|
||||||
|
window.api = mockApi({
|
||||||
|
openFile: async (side) =>
|
||||||
|
side === 'left'
|
||||||
|
? { path: '/tmp/a.txt', name: 'a.txt', text: 'aaa', encoding: 'UTF-8', binary: false }
|
||||||
|
: { path: '/tmp/b.txt', name: 'b.txt', text: 'bbb', encoding: 'UTF-8', binary: false }
|
||||||
|
})
|
||||||
|
const { container } = render(<App />)
|
||||||
|
fireEvent.click(screen.getByText('打开左侧'))
|
||||||
|
await screen.findByText('导出报告')
|
||||||
|
fireEvent.click(screen.getByText('打开右侧'))
|
||||||
|
await screen.findAllByText('b.txt')
|
||||||
|
const files = () => Array.from(container.querySelectorAll('.pane-file')).map((e) => e.textContent)
|
||||||
|
expect(files()).toEqual(['a.txt', 'b.txt'])
|
||||||
|
fireEvent.click(screen.getByText('⇄ 交换左右'))
|
||||||
|
expect(files()).toEqual(['b.txt', 'a.txt'])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -1,25 +1,18 @@
|
|||||||
import { describe, it, expect, vi, beforeAll } from 'vitest'
|
import { describe, it, expect, vi, beforeAll } from 'vitest'
|
||||||
import { render, screen, fireEvent } from '@testing-library/react'
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
import DiffView from './DiffView'
|
import DiffView, { ROW_HEIGHT, type FoldMarker, type DisplayItem } from './DiffView'
|
||||||
import type { DiffRow } from '../diff/diffEngine'
|
import type { DiffRow } from '../diff/diffEngine'
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
// jsdom 未实现 scrollIntoView,测试里打桩
|
// jsdom 未实现滚动定位,测试里打桩(虚拟化后导航定位走 scrollTo)
|
||||||
|
Element.prototype.scrollTo = vi.fn()
|
||||||
Element.prototype.scrollIntoView = vi.fn()
|
Element.prototype.scrollIntoView = vi.fn()
|
||||||
})
|
})
|
||||||
|
|
||||||
function renderDiff(rows: DiffRow[], activeRowId: string | null = null) {
|
function renderDiff(items: DisplayItem[], activeRowId: string | null = null) {
|
||||||
return render(
|
return render(
|
||||||
<DiffView
|
<DiffView
|
||||||
diff={{
|
items={items}
|
||||||
rows,
|
|
||||||
summary: {
|
|
||||||
changedLines: rows.filter((r) => r.isChanged).length,
|
|
||||||
inserted: rows.filter((r) => r.rowKind === 'added').length,
|
|
||||||
deleted: rows.filter((r) => r.rowKind === 'removed').length,
|
|
||||||
modified: rows.filter((r) => r.rowKind === 'modified').length
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
leftMeta={{ name: 'a.txt', encoding: 'UTF-8' }}
|
leftMeta={{ name: 'a.txt', encoding: 'UTF-8' }}
|
||||||
rightMeta={{ name: 'b.txt', encoding: 'GBK' }}
|
rightMeta={{ name: 'b.txt', encoding: 'GBK' }}
|
||||||
activeRowId={activeRowId}
|
activeRowId={activeRowId}
|
||||||
@@ -62,9 +55,9 @@ describe('DiffView', () => {
|
|||||||
expect(screen.getAllByText('2').length).toBeGreaterThan(0)
|
expect(screen.getAllByText('2').length).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('设置活动行时调用 scrollIntoView 定位', () => {
|
it('设置活动行时通过受控 scrollTop 定位(scrollTo)', () => {
|
||||||
renderDiff(baseRows, 'r1')
|
renderDiff(baseRows, 'r1')
|
||||||
expect(Element.prototype.scrollIntoView).toHaveBeenCalled()
|
expect(Element.prototype.scrollTo).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('词级 segs 渲染删除/新增高亮段', () => {
|
it('词级 segs 渲染删除/新增高亮段', () => {
|
||||||
@@ -129,10 +122,7 @@ describe('DiffView', () => {
|
|||||||
const onRowContext = vi.fn()
|
const onRowContext = vi.fn()
|
||||||
render(
|
render(
|
||||||
<DiffView
|
<DiffView
|
||||||
diff={{
|
items={baseRows}
|
||||||
rows: baseRows,
|
|
||||||
summary: { changedLines: 1, inserted: 1, deleted: 0, modified: 0 }
|
|
||||||
}}
|
|
||||||
leftMeta={{ name: 'a.txt', encoding: 'UTF-8' }}
|
leftMeta={{ name: 'a.txt', encoding: 'UTF-8' }}
|
||||||
rightMeta={{ name: 'b.txt', encoding: 'GBK' }}
|
rightMeta={{ name: 'b.txt', encoding: 'GBK' }}
|
||||||
activeRowId={null}
|
activeRowId={null}
|
||||||
@@ -149,10 +139,7 @@ describe('DiffView', () => {
|
|||||||
const onOpen = vi.fn()
|
const onOpen = vi.fn()
|
||||||
render(
|
render(
|
||||||
<DiffView
|
<DiffView
|
||||||
diff={{
|
items={baseRows}
|
||||||
rows: baseRows,
|
|
||||||
summary: { changedLines: 1, inserted: 1, deleted: 0, modified: 0 }
|
|
||||||
}}
|
|
||||||
leftMeta={{ name: 'a.txt', encoding: 'UTF-8' }}
|
leftMeta={{ name: 'a.txt', encoding: 'UTF-8' }}
|
||||||
rightMeta={{ name: 'b.txt', encoding: 'GBK' }}
|
rightMeta={{ name: 'b.txt', encoding: 'GBK' }}
|
||||||
activeRowId={null}
|
activeRowId={null}
|
||||||
@@ -179,26 +166,81 @@ describe('DiffView', () => {
|
|||||||
|
|
||||||
it('拖放文件到面板触发 onDropFile', () => {
|
it('拖放文件到面板触发 onDropFile', () => {
|
||||||
const onDropFile = vi.fn()
|
const onDropFile = vi.fn()
|
||||||
const { container } = (() => {
|
const { container } = render(
|
||||||
const utils = render(
|
<DiffView
|
||||||
<DiffView
|
items={baseRows}
|
||||||
diff={{
|
leftMeta={{ name: 'a.txt', encoding: 'UTF-8' }}
|
||||||
rows: baseRows,
|
rightMeta={{ name: 'b.txt', encoding: 'GBK' }}
|
||||||
summary: { changedLines: 1, inserted: 1, deleted: 0, modified: 0 }
|
activeRowId={null}
|
||||||
}}
|
onOpen={vi.fn()}
|
||||||
leftMeta={{ name: 'a.txt', encoding: 'UTF-8' }}
|
onRowContext={vi.fn()}
|
||||||
rightMeta={{ name: 'b.txt', encoding: 'GBK' }}
|
onDropFile={onDropFile}
|
||||||
activeRowId={null}
|
/>
|
||||||
onOpen={vi.fn()}
|
)
|
||||||
onRowContext={vi.fn()}
|
|
||||||
onDropFile={onDropFile}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
return utils
|
|
||||||
})()
|
|
||||||
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])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('DiffView - 虚拟滚动', () => {
|
||||||
|
function makeRows(n: number): DiffRow[] {
|
||||||
|
return Array.from({ length: n }, (_, i) => ({
|
||||||
|
id: `v${i}`,
|
||||||
|
rowKind: 'unchanged' as const,
|
||||||
|
isChanged: false,
|
||||||
|
left: { lineNo: i + 1, text: `L${i}`, segs: null },
|
||||||
|
right: { lineNo: i + 1, text: `R${i}`, segs: null }
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
it('大行数下只渲染可见窗口(DOM 行数远小于总量)', () => {
|
||||||
|
const { container } = renderDiff(makeRows(1000))
|
||||||
|
const rows = container.querySelectorAll('.diff-row')
|
||||||
|
// 两栏合计不超过 (600/21 向上取整 + 2×overscan) × 2 附近的量级
|
||||||
|
expect(rows.length).toBeGreaterThan(0)
|
||||||
|
expect(rows.length).toBeLessThan(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('滚动后窗口移动到对应区段', () => {
|
||||||
|
const { container } = renderDiff(makeRows(1000))
|
||||||
|
const scrollers = container.querySelectorAll('.diff-scroll')
|
||||||
|
const left = scrollers[0] as HTMLElement
|
||||||
|
left.scrollTop = 500 * ROW_HEIGHT
|
||||||
|
fireEvent.scroll(left)
|
||||||
|
expect(screen.getAllByText('L500').length).toBeGreaterThan(0)
|
||||||
|
// 远离窗口的行不再渲染
|
||||||
|
expect(screen.queryByText('L0')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('滚动容器总高度按全部行数撑满', () => {
|
||||||
|
const { container } = renderDiff(makeRows(100))
|
||||||
|
const columns = container.querySelectorAll('.diff-columns') as NodeListOf<HTMLElement>
|
||||||
|
expect(columns[0].style.height).toBe(`${100 * ROW_HEIGHT}px`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('DiffView - 折叠提示行', () => {
|
||||||
|
it('渲染折叠提示与行数', () => {
|
||||||
|
const items: DisplayItem[] = [
|
||||||
|
{ fold: true, skipped: 42 },
|
||||||
|
{
|
||||||
|
id: 'r9',
|
||||||
|
rowKind: 'modified',
|
||||||
|
isChanged: true,
|
||||||
|
left: { lineNo: 43, text: 'a', segs: null },
|
||||||
|
right: { lineNo: 43, text: 'b', segs: null }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
const { container } = renderDiff(items)
|
||||||
|
expect(screen.getAllByText(/已折叠 42 行相同内容/).length).toBe(2)
|
||||||
|
expect(container.querySelectorAll('.diff-row.fold').length).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('自定义文案(完全一致场景)覆盖默认提示', () => {
|
||||||
|
const m: FoldMarker = { fold: true, skipped: 10, note: '两文件内容完全一致,已折叠全部未更改行' }
|
||||||
|
renderDiff([m])
|
||||||
|
expect(screen.getAllByText('两文件内容完全一致,已折叠全部未更改行').length).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,20 +1,46 @@
|
|||||||
import {
|
import {
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
type ReactElement,
|
type ReactElement,
|
||||||
type RefObject,
|
type RefObject,
|
||||||
type MouseEvent as ReactMouseEvent
|
type MouseEvent as ReactMouseEvent
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { DiffResult, DiffRow, SideCell } from '../diff/diffEngine'
|
import { DiffRow, SideCell } from '../diff/diffEngine'
|
||||||
|
import { displayCols } from '../diff/textUtils'
|
||||||
|
|
||||||
export interface PaneMeta {
|
export interface PaneMeta {
|
||||||
name: string
|
name: string
|
||||||
encoding: string
|
encoding: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 虚拟行高(px)。与 global.css 的 --line-height: 21px 保持一致,改动需双向同步 */
|
||||||
|
export const ROW_HEIGHT = 21
|
||||||
|
/** 窗口上下各多渲染的缓冲行数 */
|
||||||
|
const OVERSCAN = 8
|
||||||
|
/** 视口高度兜底值(jsdom 无布局,clientHeight 为 0 时使用) */
|
||||||
|
const FALLBACK_VIEWPORT = 600
|
||||||
|
/** 内容最小宽度 = 列宽 × 字符宽 + 行号列与内边距的固定开销 */
|
||||||
|
const CONTENT_WIDTH_PAD = 86
|
||||||
|
|
||||||
|
/** “仅看差异”视图中的折叠提示行(占用一个虚拟行位,高度与其他行一致) */
|
||||||
|
export interface FoldMarker {
|
||||||
|
fold: true
|
||||||
|
/** 被折叠的未更改行数 */
|
||||||
|
skipped: number
|
||||||
|
/** 自定义提示文案(如“完全一致”场景) */
|
||||||
|
note?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DisplayItem = DiffRow | FoldMarker
|
||||||
|
|
||||||
|
export const isFold = (item: DisplayItem): item is FoldMarker => 'fold' in item
|
||||||
|
|
||||||
interface DiffViewProps {
|
interface DiffViewProps {
|
||||||
diff: DiffResult
|
items: DisplayItem[]
|
||||||
leftMeta: PaneMeta | null
|
leftMeta: PaneMeta | null
|
||||||
rightMeta: PaneMeta | null
|
rightMeta: PaneMeta | null
|
||||||
activeRowId: string | null
|
activeRowId: string | null
|
||||||
@@ -51,7 +77,7 @@ function renderCellText(cell: SideCell): ReactNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SidePanel({
|
function SidePanel({
|
||||||
rows,
|
items,
|
||||||
side,
|
side,
|
||||||
meta,
|
meta,
|
||||||
onOpen,
|
onOpen,
|
||||||
@@ -59,9 +85,10 @@ function SidePanel({
|
|||||||
onDropFile,
|
onDropFile,
|
||||||
scrollRef,
|
scrollRef,
|
||||||
onScroll,
|
onScroll,
|
||||||
activeRowId
|
activeRowId,
|
||||||
|
minWidth
|
||||||
}: {
|
}: {
|
||||||
rows: DiffRow[]
|
items: DisplayItem[]
|
||||||
side: 'left' | 'right'
|
side: 'left' | 'right'
|
||||||
meta: PaneMeta | null
|
meta: PaneMeta | null
|
||||||
onOpen: (side: 'left' | 'right') => void
|
onOpen: (side: 'left' | 'right') => void
|
||||||
@@ -70,7 +97,38 @@ function SidePanel({
|
|||||||
scrollRef: RefObject<HTMLDivElement>
|
scrollRef: RefObject<HTMLDivElement>
|
||||||
onScroll: () => void
|
onScroll: () => void
|
||||||
activeRowId: string | null
|
activeRowId: string | null
|
||||||
|
minWidth: number | undefined
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
|
// 当前渲染窗口 [start, end),随滚动位置按固定行高换算
|
||||||
|
const [range, setRange] = useState({ start: 0, end: 0 })
|
||||||
|
|
||||||
|
const updateRange = (): void => {
|
||||||
|
const el = scrollRef.current
|
||||||
|
if (!el) return
|
||||||
|
const vh = el.clientHeight || FALLBACK_VIEWPORT
|
||||||
|
const start = Math.max(0, Math.floor(el.scrollTop / ROW_HEIGHT) - OVERSCAN)
|
||||||
|
const end = Math.min(items.length, Math.ceil((el.scrollTop + vh) / ROW_HEIGHT) + OVERSCAN)
|
||||||
|
setRange((prev) => (prev.start === start && prev.end === end ? prev : { start, end }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 挂载与行集变化时重算窗口;行数未变时窗口按索引自然刷新
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
updateRange()
|
||||||
|
}, [items])
|
||||||
|
|
||||||
|
// 窗口尺寸变化时补齐窗口
|
||||||
|
useEffect(() => {
|
||||||
|
const onResize = (): void => updateRange()
|
||||||
|
window.addEventListener('resize', onResize)
|
||||||
|
return () => window.removeEventListener('resize', onResize)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [items])
|
||||||
|
|
||||||
|
const handleScroll = (): void => {
|
||||||
|
updateRange()
|
||||||
|
onScroll()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={'pane pane-mid pane-' + side}
|
className={'pane pane-mid pane-' + side}
|
||||||
@@ -91,9 +149,29 @@ function SidePanel({
|
|||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="diff-scroll" ref={scrollRef} onScroll={onScroll}>
|
<div className="diff-scroll" ref={scrollRef} onScroll={handleScroll}>
|
||||||
<div className="diff-columns">
|
{/* 总高度撑满全部行 + paddingTop 平移到窗口起点,仅渲染可见切片 */}
|
||||||
{rows.map((row) => {
|
<div
|
||||||
|
className="diff-columns"
|
||||||
|
style={{
|
||||||
|
height: items.length * ROW_HEIGHT,
|
||||||
|
minWidth,
|
||||||
|
paddingTop: range.start * ROW_HEIGHT
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{items.slice(range.start, range.end).map((item, i) => {
|
||||||
|
const idx = range.start + i
|
||||||
|
if (isFold(item)) {
|
||||||
|
return (
|
||||||
|
<div className="diff-row fold" key={'fold-' + idx}>
|
||||||
|
<span className={'ln ' + side} />
|
||||||
|
<span className="tx fold-note">
|
||||||
|
{item.note ?? `⋯ 已折叠 ${item.skipped} 行相同内容 ⋯`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const row = item
|
||||||
const cell = side === 'left' ? row.left : row.right
|
const cell = side === 'left' ? row.left : row.right
|
||||||
const cls =
|
const cls =
|
||||||
'diff-row ' +
|
'diff-row ' +
|
||||||
@@ -116,7 +194,7 @@ function SidePanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function DiffView({
|
export default function DiffView({
|
||||||
diff,
|
items,
|
||||||
leftMeta,
|
leftMeta,
|
||||||
rightMeta,
|
rightMeta,
|
||||||
activeRowId,
|
activeRowId,
|
||||||
@@ -128,6 +206,32 @@ export default function DiffView({
|
|||||||
const rightRef = useRef<HTMLDivElement>(null)
|
const rightRef = useRef<HTMLDivElement>(null)
|
||||||
const syncing = useRef(false)
|
const syncing = useRef(false)
|
||||||
|
|
||||||
|
// 等宽字符宽度探针:首帧实测一次,用于虚拟化下预计算内容最小宽度
|
||||||
|
const probeRef = useRef<HTMLSpanElement>(null)
|
||||||
|
const [charW, setCharW] = useState(0)
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const w = probeRef.current?.getBoundingClientRect().width ?? 0
|
||||||
|
if (w > 0) setCharW(w / 10)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const colsOf = (rows: DisplayItem[], pick: (r: DiffRow) => SideCell): number => {
|
||||||
|
let max = 40
|
||||||
|
for (const it of rows) {
|
||||||
|
if (isFold(it)) continue
|
||||||
|
const t = pick(it).text
|
||||||
|
if (t) {
|
||||||
|
const c = displayCols(t)
|
||||||
|
if (c > max) max = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return max
|
||||||
|
}
|
||||||
|
const leftCols = useMemo(() => colsOf(items, (r) => r.left), [items])
|
||||||
|
const rightCols = useMemo(() => colsOf(items, (r) => r.right), [items])
|
||||||
|
// 探针未测得(jsdom/首帧)时不设 minWidth,退回 CSS min-width: max-content
|
||||||
|
const leftMinW = charW > 0 ? leftCols * charW + CONTENT_WIDTH_PAD : undefined
|
||||||
|
const rightMinW = charW > 0 ? rightCols * charW + CONTENT_WIDTH_PAD : undefined
|
||||||
|
|
||||||
const makeScrollSync =
|
const makeScrollSync =
|
||||||
(target: 'left' | 'right') =>
|
(target: 'left' | 'right') =>
|
||||||
(): void => {
|
(): void => {
|
||||||
@@ -144,18 +248,29 @@ export default function DiffView({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 差异导航定位:命中行后滚动到中间,并靠滚动联动自动同步右侧
|
// 差异导航定位:目标行换算为受控 scrollTop(虚拟化下行可能未渲染,scrollIntoView 不可用)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeRowId) return
|
if (!activeRowId) return
|
||||||
const el = leftRef.current?.querySelector<HTMLElement>(`[data-row="${activeRowId}"]`)
|
const idx = items.findIndex((it) => !isFold(it) && it.id === activeRowId)
|
||||||
if (el) el.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
if (idx < 0) return
|
||||||
}, [activeRowId])
|
const vh = leftRef.current?.clientHeight || FALLBACK_VIEWPORT
|
||||||
|
const maxScroll = Math.max(0, items.length * ROW_HEIGHT - vh)
|
||||||
|
const target = Math.min(
|
||||||
|
Math.max(idx * ROW_HEIGHT - Math.floor(vh / 2) + Math.floor(ROW_HEIGHT / 2), 0),
|
||||||
|
maxScroll
|
||||||
|
)
|
||||||
|
leftRef.current?.scrollTo({ top: target })
|
||||||
|
rightRef.current?.scrollTo({ top: target })
|
||||||
|
}, [activeRowId, items])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="diff-view">
|
<div className="diff-view">
|
||||||
|
<span className="width-probe" ref={probeRef} aria-hidden="true">
|
||||||
|
0000000000
|
||||||
|
</span>
|
||||||
<div className="diff-main">
|
<div className="diff-main">
|
||||||
<SidePanel
|
<SidePanel
|
||||||
rows={diff.rows}
|
items={items}
|
||||||
side="left"
|
side="left"
|
||||||
meta={leftMeta}
|
meta={leftMeta}
|
||||||
onOpen={onOpen}
|
onOpen={onOpen}
|
||||||
@@ -164,9 +279,10 @@ export default function DiffView({
|
|||||||
scrollRef={leftRef}
|
scrollRef={leftRef}
|
||||||
onScroll={makeScrollSync('left')}
|
onScroll={makeScrollSync('left')}
|
||||||
activeRowId={activeRowId}
|
activeRowId={activeRowId}
|
||||||
|
minWidth={leftMinW}
|
||||||
/>
|
/>
|
||||||
<SidePanel
|
<SidePanel
|
||||||
rows={diff.rows}
|
items={items}
|
||||||
side="right"
|
side="right"
|
||||||
meta={rightMeta}
|
meta={rightMeta}
|
||||||
onOpen={onOpen}
|
onOpen={onOpen}
|
||||||
@@ -175,8 +291,9 @@ export default function DiffView({
|
|||||||
scrollRef={rightRef}
|
scrollRef={rightRef}
|
||||||
onScroll={makeScrollSync('right')}
|
onScroll={makeScrollSync('right')}
|
||||||
activeRowId={activeRowId}
|
activeRowId={activeRowId}
|
||||||
|
minWidth={rightMinW}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ function setup(extra?: { canExport?: boolean }) {
|
|||||||
const onOptionsChange = vi.fn()
|
const onOptionsChange = vi.fn()
|
||||||
const onNav = vi.fn()
|
const onNav = vi.fn()
|
||||||
const onExport = vi.fn()
|
const onExport = vi.fn()
|
||||||
|
const onOnlyDiffChange = vi.fn()
|
||||||
|
const onSwap = vi.fn()
|
||||||
const utils = render(
|
const utils = render(
|
||||||
<Toolbar
|
<Toolbar
|
||||||
options={{ trimWhitespace: false, ignoreCase: false }}
|
options={{ trimWhitespace: false, ignoreCase: false, ignoreBlankLines: false }}
|
||||||
onOptionsChange={onOptionsChange}
|
onOptionsChange={onOptionsChange}
|
||||||
summary={summary}
|
summary={summary}
|
||||||
navIndex={0}
|
navIndex={0}
|
||||||
@@ -19,9 +21,12 @@ function setup(extra?: { canExport?: boolean }) {
|
|||||||
onNav={onNav}
|
onNav={onNav}
|
||||||
onExport={onExport}
|
onExport={onExport}
|
||||||
canExport={extra?.canExport ?? true}
|
canExport={extra?.canExport ?? true}
|
||||||
|
onlyDiff={false}
|
||||||
|
onOnlyDiffChange={onOnlyDiffChange}
|
||||||
|
onSwap={onSwap}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
return { onOptionsChange, onNav, onExport, ...utils }
|
return { onOptionsChange, onNav, onExport, onOnlyDiffChange, onSwap, ...utils }
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('Toolbar', () => {
|
describe('Toolbar', () => {
|
||||||
@@ -35,24 +40,42 @@ describe('Toolbar', () => {
|
|||||||
it('切换忽略大小写触发 onOptionsChange', () => {
|
it('切换忽略大小写触发 onOptionsChange', () => {
|
||||||
const { onOptionsChange } = setup()
|
const { onOptionsChange } = setup()
|
||||||
fireEvent.click(screen.getByLabelText('忽略大小写'))
|
fireEvent.click(screen.getByLabelText('忽略大小写'))
|
||||||
expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: false, ignoreCase: true })
|
expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: false, ignoreCase: true, ignoreBlankLines: false })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('切换忽略行首尾空白触发 onOptionsChange', () => {
|
it('切换忽略行首尾空白触发 onOptionsChange', () => {
|
||||||
const { onOptionsChange } = setup()
|
const { onOptionsChange } = setup()
|
||||||
fireEvent.click(screen.getByLabelText('忽略行首尾空白'))
|
fireEvent.click(screen.getByLabelText('忽略行首尾空白'))
|
||||||
expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: true, ignoreCase: false })
|
expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: true, ignoreCase: false, ignoreBlankLines: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('切换忽略空行触发 onOptionsChange', () => {
|
||||||
|
const { onOptionsChange } = setup()
|
||||||
|
fireEvent.click(screen.getByLabelText('忽略空行'))
|
||||||
|
expect(onOptionsChange).toHaveBeenCalledWith({ trimWhitespace: false, ignoreCase: false, ignoreBlankLines: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('切换仅看差异触发 onOnlyDiffChange(true)', () => {
|
||||||
|
const { onOnlyDiffChange } = setup()
|
||||||
|
fireEvent.click(screen.getByLabelText('仅看差异'))
|
||||||
|
expect(onOnlyDiffChange).toHaveBeenCalledWith(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('点击交换左右触发 onSwap', () => {
|
||||||
|
const { onSwap } = setup()
|
||||||
|
fireEvent.click(screen.getByText('⇄ 交换左右'))
|
||||||
|
expect(onSwap).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('点击下一处差异调用 onNav(1)', () => {
|
it('点击下一处差异调用 onNav(1)', () => {
|
||||||
const { onNav } = setup()
|
const { onNav } = setup()
|
||||||
fireEvent.click(screen.getByTitle('下一处差异'))
|
fireEvent.click(screen.getByTitle(/下一处差异/))
|
||||||
expect(onNav).toHaveBeenCalledWith(1)
|
expect(onNav).toHaveBeenCalledWith(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('点击上一处差异调用 onNav(-1)', () => {
|
it('点击上一处差异调用 onNav(-1)', () => {
|
||||||
const { onNav } = setup()
|
const { onNav } = setup()
|
||||||
fireEvent.click(screen.getByTitle('上一处差异'))
|
fireEvent.click(screen.getByTitle(/上一处差异/))
|
||||||
expect(onNav).toHaveBeenCalledWith(-1)
|
expect(onNav).toHaveBeenCalledWith(-1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ interface ToolbarProps {
|
|||||||
onNav: (dir: 1 | -1) => void
|
onNav: (dir: 1 | -1) => void
|
||||||
onExport: (fmt: ReportFormat) => void
|
onExport: (fmt: ReportFormat) => void
|
||||||
canExport: boolean
|
canExport: boolean
|
||||||
|
/** 仅看差异视图开关 */
|
||||||
|
onlyDiff: boolean
|
||||||
|
onOnlyDiffChange: (v: boolean) => void
|
||||||
|
/** 交换左右两侧 */
|
||||||
|
onSwap: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Toolbar({
|
export default function Toolbar({
|
||||||
@@ -28,7 +33,10 @@ export default function Toolbar({
|
|||||||
navCount,
|
navCount,
|
||||||
onNav,
|
onNav,
|
||||||
onExport,
|
onExport,
|
||||||
canExport
|
canExport,
|
||||||
|
onlyDiff,
|
||||||
|
onOnlyDiffChange,
|
||||||
|
onSwap
|
||||||
}: ToolbarProps): ReactElement {
|
}: ToolbarProps): ReactElement {
|
||||||
const set = (patch: Partial<DiffOptions>): void => onOptionsChange({ ...options, ...patch })
|
const set = (patch: Partial<DiffOptions>): void => onOptionsChange({ ...options, ...patch })
|
||||||
const [exportOpen, setExportOpen] = useState(false)
|
const [exportOpen, setExportOpen] = useState(false)
|
||||||
@@ -59,12 +67,35 @@ export default function Toolbar({
|
|||||||
/>
|
/>
|
||||||
忽略大小写
|
忽略大小写
|
||||||
</label>
|
</label>
|
||||||
|
<label className="switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={options.ignoreBlankLines ?? false}
|
||||||
|
onChange={(e) => set({ ignoreBlankLines: e.target.checked })}
|
||||||
|
/>
|
||||||
|
忽略空行
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tool-group">
|
||||||
|
<span className="tool-label">视图</span>
|
||||||
|
<label className="switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={onlyDiff}
|
||||||
|
onChange={(e) => onOnlyDiffChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
仅看差异
|
||||||
|
</label>
|
||||||
|
<button className="btn" onClick={onSwap} title="交换左右两侧内容">
|
||||||
|
⇄ 交换左右
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="tool-group">
|
<div className="tool-group">
|
||||||
<button
|
<button
|
||||||
className="nav-btn"
|
className="nav-btn"
|
||||||
title="上一处差异"
|
title="上一处差异(Shift+F7)"
|
||||||
disabled={!canNav}
|
disabled={!canNav}
|
||||||
onClick={() => onNav(-1)}
|
onClick={() => onNav(-1)}
|
||||||
>
|
>
|
||||||
@@ -75,7 +106,7 @@ export default function Toolbar({
|
|||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
className="nav-btn"
|
className="nav-btn"
|
||||||
title="下一处差异"
|
title="下一处差异(F7)"
|
||||||
disabled={!canNav}
|
disabled={!canNav}
|
||||||
onClick={() => onNav(1)}
|
onClick={() => onNav(1)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -115,6 +115,39 @@ describe('computeDiff - 忽略选项', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('computeDiff - 忽略空行', () => {
|
||||||
|
it('开启后单侧空行不产生差异', () => {
|
||||||
|
const { summary } = computeDiff('a\n\nb', 'a\nb', { ignoreBlankLines: true })
|
||||||
|
expect(summary.changedLines).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('未开启时单侧空行仍被识别为删除', () => {
|
||||||
|
const { summary } = computeDiff('a\n\nb', 'a\nb')
|
||||||
|
expect(summary.deleted).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('空行被剔除后内容行差异仍被识别', () => {
|
||||||
|
const { rows, summary } = computeDiff('a\n\nb', 'a\nx\nb', { ignoreBlankLines: true })
|
||||||
|
expect(summary.changedLines).toBe(1)
|
||||||
|
expect(summary.inserted).toBe(1)
|
||||||
|
// 行号保留原始文件位置(x 位于右侧第 2 行)
|
||||||
|
const added = rows.find((r) => r.rowKind === 'added')
|
||||||
|
expect(added!.right.lineNo).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('两侧不同空白字符的空行互相判等', () => {
|
||||||
|
const { summary } = computeDiff('a\n \nb', 'a\n\t\nb', { ignoreBlankLines: true })
|
||||||
|
expect(summary.changedLines).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('剔除空行后行号保留原值', () => {
|
||||||
|
const { rows } = computeDiff('a\n\n\nb', 'a\n\n\nb', { ignoreBlankLines: true })
|
||||||
|
expect(rows).toHaveLength(2)
|
||||||
|
expect(rows[1].left.lineNo).toBe(4)
|
||||||
|
expect(rows[1].right.lineNo).toBe(4)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('computeDiff - 行切分细节', () => {
|
describe('computeDiff - 行切分细节', () => {
|
||||||
it('结尾换行不产生多余空行', () => {
|
it('结尾换行不产生多余空行', () => {
|
||||||
const { rows, summary } = computeDiff('a\n', 'a')
|
const { rows, summary } = computeDiff('a\n', 'a')
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ export interface DiffOptions {
|
|||||||
trimWhitespace?: boolean
|
trimWhitespace?: boolean
|
||||||
/** 忽略大小写 */
|
/** 忽略大小写 */
|
||||||
ignoreCase?: boolean
|
ignoreCase?: boolean
|
||||||
|
/** 忽略空行差异(两侧空白行互相判等,不产生增删差异) */
|
||||||
|
ignoreBlankLines?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DiffResult {
|
export interface DiffResult {
|
||||||
@@ -50,6 +52,12 @@ function emptyLine(): SideCell {
|
|||||||
return { lineNo: null, text: null, segs: null }
|
return { lineNo: null, text: null, segs: null }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 带原始行号的行(忽略空行模式下空行被剔除,行号保留原值以维持定位) */
|
||||||
|
interface NumberedLine {
|
||||||
|
text: string
|
||||||
|
lineNo: number
|
||||||
|
}
|
||||||
|
|
||||||
/** 词级 diff:对同一对齐的左右两行求差异,得到左右两套高亮分段 */
|
/** 词级 diff:对同一对齐的左右两行求差异,得到左右两套高亮分段 */
|
||||||
function wordSegments(leftText: string, rightText: string): { left: Seg[]; right: Seg[] } {
|
function wordSegments(leftText: string, rightText: string): { left: Seg[]; right: Seg[] } {
|
||||||
const parts = diffWordsWithSpace(leftText, rightText)
|
const parts = diffWordsWithSpace(leftText, rightText)
|
||||||
@@ -80,7 +88,8 @@ export function splitLines(text: string): string[] {
|
|||||||
* 计算文本差异。
|
* 计算文本差异。
|
||||||
* diffArrays 以“归一化行”做行级 LCS,得到增/删/同;
|
* diffArrays 以“归一化行”做行级 LCS,得到增/删/同;
|
||||||
* 删除段与新增段配对为“修改”行并做词级内联高亮。
|
* 删除段与新增段配对为“修改”行并做词级内联高亮。
|
||||||
* 展示文本始终取原始行,选项只影响“是否判等”。
|
* trimWhitespace / ignoreCase 只影响“是否判等”,展示文本始终取原始行;
|
||||||
|
* ignoreBlankLines 则把空白行从比较与视图中整体剔除(行号保留原值)。
|
||||||
*/
|
*/
|
||||||
export function computeDiff(
|
export function computeDiff(
|
||||||
leftText: string,
|
leftText: string,
|
||||||
@@ -91,21 +100,31 @@ export function computeDiff(
|
|||||||
const leftOrig = splitLines(leftText)
|
const leftOrig = splitLines(leftText)
|
||||||
const rightOrig = splitLines(rightText)
|
const rightOrig = splitLines(rightText)
|
||||||
|
|
||||||
|
// 行号与行文本绑定;忽略空行模式下剔除空白行(行号保留原值,便于定位原始文件位置)
|
||||||
|
const leftSeq: NumberedLine[] = leftOrig.map((text, i) => ({ text, lineNo: i + 1 }))
|
||||||
|
const rightSeq: NumberedLine[] = rightOrig.map((text, i) => ({ text, lineNo: i + 1 }))
|
||||||
|
const leftUsed = options.ignoreBlankLines
|
||||||
|
? leftSeq.filter((p) => p.text.trim() !== '')
|
||||||
|
: leftSeq
|
||||||
|
const rightUsed = options.ignoreBlankLines
|
||||||
|
? rightSeq.filter((p) => p.text.trim() !== '')
|
||||||
|
: rightSeq
|
||||||
|
|
||||||
const norm = (s: string): string => {
|
const norm = (s: string): string => {
|
||||||
let v = options.trimWhitespace ? s.trim() : s
|
let v = options.trimWhitespace ? s.trim() : s
|
||||||
if (options.ignoreCase) v = v.toLowerCase()
|
if (options.ignoreCase) v = v.toLowerCase()
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
const leftNorm = leftOrig.map(norm)
|
const leftNorm = leftUsed.map((p) => norm(p.text))
|
||||||
const rightNorm = rightOrig.map(norm)
|
const rightNorm = rightUsed.map((p) => norm(p.text))
|
||||||
|
|
||||||
const parts = diffArrays(leftNorm, rightNorm)
|
const parts = diffArrays(leftNorm, rightNorm)
|
||||||
const rows: DiffRow[] = []
|
const rows: DiffRow[] = []
|
||||||
let leftPtr = 1 // 左侧下一个待消费行号(1 基)
|
let leftPtr = 0 // 左侧下一个待消费下标(0 基,指向 leftUsed)
|
||||||
let rightPtr = 1
|
let rightPtr = 0
|
||||||
|
|
||||||
// 暂存的纯删除行(等待与随后新增配对)
|
// 暂存的纯删除行(等待与随后新增配对)
|
||||||
let queuedRemoved: { text: string; lineNo: number }[] = []
|
let queuedRemoved: NumberedLine[] = []
|
||||||
|
|
||||||
const flushRemoved = (): void => {
|
const flushRemoved = (): void => {
|
||||||
for (const r of queuedRemoved) {
|
for (const r of queuedRemoved) {
|
||||||
@@ -121,7 +140,7 @@ export function computeDiff(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 删除段与新增段配对为修改行 */
|
/** 删除段与新增段配对为修改行 */
|
||||||
const commitModified = (addedRows: { text: string; lineNo: number }[]): void => {
|
const commitModified = (addedRows: NumberedLine[]): void => {
|
||||||
const count = Math.max(queuedRemoved.length, addedRows.length)
|
const count = Math.max(queuedRemoved.length, addedRows.length)
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const l = queuedRemoved[i]
|
const l = queuedRemoved[i]
|
||||||
@@ -161,14 +180,14 @@ export function computeDiff(
|
|||||||
if (part.removed) {
|
if (part.removed) {
|
||||||
const len = value.length
|
const len = value.length
|
||||||
for (let i = 0; i < len; i++) {
|
for (let i = 0; i < len; i++) {
|
||||||
queuedRemoved.push({ text: leftOrig[leftPtr - 1 + i], lineNo: leftPtr + i })
|
queuedRemoved.push(leftUsed[leftPtr + i])
|
||||||
}
|
}
|
||||||
leftPtr += len
|
leftPtr += len
|
||||||
} else if (part.added) {
|
} else if (part.added) {
|
||||||
const len = value.length
|
const len = value.length
|
||||||
const addedRows: { text: string; lineNo: number }[] = []
|
const addedRows: NumberedLine[] = []
|
||||||
for (let i = 0; i < len; i++) {
|
for (let i = 0; i < len; i++) {
|
||||||
addedRows.push({ text: rightOrig[rightPtr - 1 + i], lineNo: rightPtr + i })
|
addedRows.push(rightUsed[rightPtr + i])
|
||||||
}
|
}
|
||||||
rightPtr += len
|
rightPtr += len
|
||||||
if (queuedRemoved.length > 0) {
|
if (queuedRemoved.length > 0) {
|
||||||
@@ -189,14 +208,14 @@ export function computeDiff(
|
|||||||
if (queuedRemoved.length > 0) flushRemoved()
|
if (queuedRemoved.length > 0) flushRemoved()
|
||||||
const len = value.length
|
const len = value.length
|
||||||
for (let i = 0; i < len; i++) {
|
for (let i = 0; i < len; i++) {
|
||||||
const lt = leftOrig[leftPtr - 1 + i]
|
const lp = leftUsed[leftPtr + i]
|
||||||
const rt = rightOrig[rightPtr - 1 + i]
|
const rp = rightUsed[rightPtr + i]
|
||||||
rows.push({
|
rows.push({
|
||||||
id: `r${rowSeq++}`,
|
id: `r${rowSeq++}`,
|
||||||
rowKind: 'unchanged',
|
rowKind: 'unchanged',
|
||||||
isChanged: false,
|
isChanged: false,
|
||||||
left: { lineNo: leftPtr + i, text: lt, segs: null },
|
left: { lineNo: lp.lineNo, text: lp.text, segs: null },
|
||||||
right: { lineNo: rightPtr + i, text: rt, segs: null }
|
right: { lineNo: rp.lineNo, text: rp.text, segs: null }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
leftPtr += len
|
leftPtr += len
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { TEXT_EXTENSIONS, isTextFile, HEAVY_LINES, countLines } from './textUtils'
|
import { TEXT_EXTENSIONS, isTextFile, HEAVY_LINES, countLines, displayCols } from './textUtils'
|
||||||
|
|
||||||
describe('textUtils', () => {
|
describe('textUtils', () => {
|
||||||
it('常见文本扩展名放行', () => {
|
it('常见文本扩展名放行', () => {
|
||||||
@@ -52,4 +52,20 @@ describe('countLines / HEAVY_LINES', () => {
|
|||||||
it('超多行预警阈值为 50000', () => {
|
it('超多行预警阈值为 50000', () => {
|
||||||
expect(HEAVY_LINES).toBe(50000)
|
expect(HEAVY_LINES).toBe(50000)
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('displayCols 列宽估算', () => {
|
||||||
|
it('半角字符按 1 列计', () => {
|
||||||
|
expect(displayCols('hello')).toBe(5)
|
||||||
|
expect(displayCols('')).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('CJK 与全角字符按 2 列计', () => {
|
||||||
|
expect(displayCols('中文')).toBe(4)
|
||||||
|
expect(displayCols('ab')).toBe(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('中英混排按叠加列宽计', () => {
|
||||||
|
expect(displayCols('a中b')).toBe(4)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -81,4 +81,32 @@ export const HEAVY_LINES = 50000
|
|||||||
/** 统计文本行数,切分规则与 diff 引擎一致(结尾换行不计数) */
|
/** 统计文本行数,切分规则与 diff 引擎一致(结尾换行不计数) */
|
||||||
export function countLines(text: string): number {
|
export function countLines(text: string): number {
|
||||||
return splitLines(text).length
|
return splitLines(text).length
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断码点是否为双宽字符(CJK / 全角 / 韩文 / 常见 emoji 区段) */
|
||||||
|
function isWideCodePoint(cp: number): boolean {
|
||||||
|
return (
|
||||||
|
(cp >= 0x1100 && cp <= 0x115f) || // 谚文字母
|
||||||
|
(cp >= 0x2e80 && cp <= 0xa4cf) || // CJK 部首 ~ 彝文
|
||||||
|
(cp >= 0xac00 && cp <= 0xd7a3) || // 谚文音节
|
||||||
|
(cp >= 0xf900 && cp <= 0xfaff) || // CJK 兼容表意
|
||||||
|
(cp >= 0xfe30 && cp <= 0xfe4f) || // CJK 兼容形式
|
||||||
|
(cp >= 0xff00 && cp <= 0xff60) || // 全角形式
|
||||||
|
(cp >= 0xffe0 && cp <= 0xffe6) ||
|
||||||
|
(cp >= 0x1f300 && cp <= 0x1f9ff) || // emoji 区段
|
||||||
|
(cp >= 0x20000 && cp <= 0x3fffd) || // CJK 扩展 B+
|
||||||
|
cp === 0x3000 // 全角空格
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 估算等宽字体下的显示列宽:半角 1 列、双宽字符 2 列。
|
||||||
|
* 用于虚拟滚动下预计算内容最小宽度,替代全量 DOM 的 max-content 测量。
|
||||||
|
*/
|
||||||
|
export function displayCols(text: string): number {
|
||||||
|
let n = 0
|
||||||
|
for (const ch of text) {
|
||||||
|
n += isWideCodePoint(ch.codePointAt(0) ?? 0) ? 2 : 1
|
||||||
|
}
|
||||||
|
return n
|
||||||
}
|
}
|
||||||
@@ -378,6 +378,31 @@ button:disabled {
|
|||||||
color: transparent;
|
color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============ 折叠提示行(仅看差异视图) ============ */
|
||||||
|
.diff-row.fold {
|
||||||
|
background: rgba(148, 163, 255, 0.04);
|
||||||
|
}
|
||||||
|
.diff-row.fold .ln {
|
||||||
|
background: rgba(148, 163, 255, 0.03);
|
||||||
|
}
|
||||||
|
.fold-note {
|
||||||
|
color: var(--muted);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 11.5px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ 虚拟滚动宽度探针 ============ */
|
||||||
|
.width-probe {
|
||||||
|
position: absolute;
|
||||||
|
visibility: hidden;
|
||||||
|
white-space: pre;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12.5px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* ============ 工具栏 ============ */
|
/* ============ 工具栏 ============ */
|
||||||
.toolbar {
|
.toolbar {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
|||||||
Reference in New Issue
Block a user