fix: v0.3.2 报告对齐与性能收尾 - 纯文本报告行号右对齐、超长行列宽封顶防卡顿、对话框与拖拽扩展名清单对齐、右键菜单打开时导航快捷键守卫、清理导出死属性
This commit is contained in:
@@ -93,7 +93,7 @@ src/
|
|||||||
- 作者:thzxx
|
- 作者:thzxx
|
||||||
- 组织:MetonaTeam
|
- 组织:MetonaTeam
|
||||||
- 许可证:MIT License(见 [LICENSE](./LICENSE))
|
- 许可证:MIT License(见 [LICENSE](./LICENSE))
|
||||||
- 版本:0.3.1
|
- 版本:0.3.2
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+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.3.1`**
|
当前基线版本:**`0.3.2`**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -64,7 +64,8 @@ z = 补丁版本号(Patch)
|
|||||||
0.2.5 ← 一致性与边角修复(已发布):右键菜单边缘防溢出 · 粘贴入口 10MB 防护 · 拖拽读取失败提示 · 多文件拖拽提示 · 剪贴板统一走主进程 · 主进程异步读取
|
0.2.5 ← 一致性与边角修复(已发布):右键菜单边缘防溢出 · 粘贴入口 10MB 防护 · 拖拽读取失败提示 · 多文件拖拽提示 · 剪贴板统一走主进程 · 主进程异步读取
|
||||||
0.2.6 ← 主进程异步收尾与边角防护(已发布):保存报告异步写盘 · 拖拽解码 handler 异步化 · 空白粘贴拦截 · 超多行卡顿预警 · 清理 mac 无效签名配置 · 文档与测试同步
|
0.2.6 ← 主进程异步收尾与边角防护(已发布):保存报告异步写盘 · 拖拽解码 handler 异步化 · 空白粘贴拦截 · 超多行卡顿预警 · 清理 mac 无效签名配置 · 文档与测试同步
|
||||||
0.3.0 ← 大文件性能与差异导航增强(已发布):自研虚拟滚动 · 仅看差异过滤(折叠提示行) · F7/Shift+F7 导航快捷键 · 交换左右侧 · 忽略空行
|
0.3.0 ← 大文件性能与差异导航增强(已发布):自研虚拟滚动 · 仅看差异过滤(折叠提示行) · F7/Shift+F7 导航快捷键 · 交换左右侧 · 忽略空行
|
||||||
0.3.1 ← 稳健性与测试补强(当前):超长单行词级 diff 防护 · 打开对话框补“所有文件”筛选 · 虚拟滚动 minWidth 测试覆盖 · 滚动同步去锁化重构
|
0.3.1 ← 稳健性与测试补强(已发布):超长单行词级 diff 防护 · 打开对话框补“所有文件”筛选 · 虚拟滚动 minWidth 测试覆盖 · 滚动同步去锁化重构
|
||||||
|
0.3.2 ← 报告对齐与性能收尾(当前):纯文本报告行号右对齐 · 超长行列宽封顶防卡顿 · 对话框与拖拽扩展名清单对齐 · 右键菜单打开时导航快捷键守卫 · 清理导出死属性
|
||||||
0.4.0 ← 新增功能(候选:主进程解码 worker 化 · 渲染进程 diff 计算 worker 化 · Playwright E2E · 文件夹对比)
|
0.4.0 ← 新增功能(候选:主进程解码 worker 化 · 渲染进程 diff 计算 worker 化 · Playwright E2E · 文件夹对比)
|
||||||
...
|
...
|
||||||
0.y.z ← 长期停留,永不进入 1.x
|
0.y.z ← 长期停留,永不进入 1.x
|
||||||
|
|||||||
+3
-3
@@ -76,19 +76,19 @@ src/renderer/src/
|
|||||||
|
|
||||||
### 5.2 报告生成(`report.ts`)
|
### 5.2 报告生成(`report.ts`)
|
||||||
- HTML:输出合法文档、含文件名/统计/类型高亮语义
|
- HTML:输出合法文档、含文件名/统计/类型高亮语义
|
||||||
- 纯文本:含增删改标记(`+ -`)
|
- 纯文本:含增删改标记(`+ -`)、行号按最大位数右对齐
|
||||||
- Markdown:表格化、管道符转义保持表格结构
|
- Markdown:表格化、管道符转义保持表格结构
|
||||||
- 格式分发与扩展名映射(`REPORT_EXT`)
|
- 格式分发与扩展名映射(`REPORT_EXT`)
|
||||||
|
|
||||||
### 5.3 文本工具(`textUtils.ts`)
|
### 5.3 文本工具(`textUtils.ts`)
|
||||||
- 文本扩展名白名单放行 / 拒绝 / 大小写不敏感
|
- 文本扩展名白名单放行 / 拒绝 / 大小写不敏感
|
||||||
- `countLines` 与 diff 引擎切分规则一致(结尾换行、CRLF)
|
- `countLines` 与 diff 引擎切分规则一致(结尾换行、CRLF)
|
||||||
- `displayCols` 半角 1 列 / 全角 2 列 / 中英混排叠加
|
- `displayCols` 半角 1 列 / 全角 2 列 / 中英混排叠加 / 传 cap 时达上限提前返回
|
||||||
|
|
||||||
### 5.4 对比视图(`DiffView.tsx`)
|
### 5.4 对比视图(`DiffView.tsx`)
|
||||||
- 渲染:行内容、行号、词级高亮段、行尾空白标注
|
- 渲染:行内容、行号、词级高亮段、行尾空白标注
|
||||||
- 虚拟滚动:大行数下仅渲染可见窗口(DOM 行数远小于总量);滚动后窗口移动;容器总高度按全部行数撑满
|
- 虚拟滚动:大行数下仅渲染可见窗口(DOM 行数远小于总量);滚动后窗口移动;容器总高度按全部行数撑满
|
||||||
- 内容最小宽度:探针测得字符宽后按最大列宽换算 minWidth(半角/全角列宽);探针未测得时不设置
|
- 内容最小宽度:探针测得字符宽后按最大列宽换算 minWidth(半角/全角列宽,超长行封顶);探针未测得时不设置
|
||||||
- 折叠提示行:默认文案与自定义文案(仅看差异视图)
|
- 折叠提示行:默认文案与自定义文案(仅看差异视图)
|
||||||
- 交互:左右/横向滚动同步、右键菜单回调、拖放回调、导航定位走受控 `scrollTo`
|
- 交互:左右/横向滚动同步、右键菜单回调、拖放回调、导航定位走受控 `scrollTo`
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "DiffLens",
|
"name": "DiffLens",
|
||||||
"version": "0.3.1",
|
"version": "0.3.2",
|
||||||
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
|
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
|
||||||
"author": "thzxx",
|
"author": "thzxx",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
+3
-2
@@ -94,8 +94,9 @@ ipcMain.handle('file:open', async (_event, side: 'left' | 'right' | null) => {
|
|||||||
title: `选择${side === 'left' ? '左侧' : side === 'right' ? '右侧' : ''}文本文件`,
|
title: `选择${side === 'left' ? '左侧' : side === 'right' ? '右侧' : ''}文本文件`,
|
||||||
properties: ['openFile'],
|
properties: ['openFile'],
|
||||||
filters: [
|
filters: [
|
||||||
{ name: '文本文件', extensions: ['txt', 'md', 'json', 'js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx', 'css', 'scss', 'less', 'html', 'htm', 'xml', 'yml', 'yaml', 'toml', 'ini', 'cfg', 'conf', 'properties', 'env', 'py', 'java', 'go', 'rs', 'c', 'h', 'cpp', 'hpp', 'cs', 'swift', 'kt', 'rb', 'php', 'sh', 'bat', 'ps1', 'log', 'csv', 'sql', 'vue', 'graphql', 'gradle'] },
|
// 与渲染进程 TEXT_EXTENSIONS(src/renderer/src/diff/textUtils.ts)完全对齐;两进程无法共享模块,改动需双向同步
|
||||||
// 兜底入口:拖拽白名单比对话框筛选项更全(svelte/tsv/lock 等),补“所有文件”消除两种入口的不一致
|
{ name: '文本文件', extensions: ['txt', 'text', 'md', 'markdown', 'json', 'js', 'mjs', 'cjs', 'ts', 'mts', 'cts', 'tsx', 'jsx', 'css', 'scss', 'less', 'html', 'htm', 'xml', 'yml', 'yaml', 'toml', 'ini', 'cfg', 'conf', 'config', 'properties', 'env', 'py', 'java', 'go', 'rs', 'c', 'h', 'cpp', 'hpp', 'cc', 'cs', 'swift', 'kt', 'rb', 'php', 'pl', 'sh', 'zsh', 'bash', 'fish', 'bat', 'cmd', 'ps1', 'log', 'csv', 'tsv', 'sql', 'vue', 'svelte', 'graphql', 'gql', 'gradle', 'lock'] },
|
||||||
|
// 兜底入口:允许打开任意扩展名文件
|
||||||
{ name: '所有文件', extensions: ['*'] }
|
{ name: '所有文件', extensions: ['*'] }
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -266,17 +266,17 @@ export default function App(): ReactElement {
|
|||||||
[changedRows, navIndex]
|
[changedRows, navIndex]
|
||||||
)
|
)
|
||||||
|
|
||||||
// 差异导航快捷键:F7 下一处 / Shift+F7 上一处;粘贴弹窗打开时不抢占
|
// 差异导航快捷键:F7 下一处 / Shift+F7 上一处;粘贴弹窗或右键菜单打开时不抢占
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent): void => {
|
const onKey = (e: KeyboardEvent): void => {
|
||||||
if (e.key !== 'F7') return
|
if (e.key !== 'F7') return
|
||||||
if (pasteSide !== null) return
|
if (pasteSide !== null || menu !== null) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
go(e.shiftKey ? -1 : 1)
|
go(e.shiftKey ? -1 : 1)
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', onKey)
|
window.addEventListener('keydown', onKey)
|
||||||
return () => window.removeEventListener('keydown', onKey)
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
}, [go, pasteSide])
|
}, [go, pasteSide, menu])
|
||||||
|
|
||||||
// 行级右键菜单
|
// 行级右键菜单
|
||||||
const onRowContext = useCallback(
|
const onRowContext = useCallback(
|
||||||
@@ -438,7 +438,6 @@ export default function App(): ReactElement {
|
|||||||
navCount={changedRows.length}
|
navCount={changedRows.length}
|
||||||
onNav={go}
|
onNav={go}
|
||||||
onExport={(fmt) => void exportReport(fmt)}
|
onExport={(fmt) => void exportReport(fmt)}
|
||||||
canExport={anyPane}
|
|
||||||
onlyDiff={onlyDiff}
|
onlyDiff={onlyDiff}
|
||||||
onOnlyDiffChange={setOnlyDiff}
|
onOnlyDiffChange={setOnlyDiff}
|
||||||
onSwap={swapPanes}
|
onSwap={swapPanes}
|
||||||
|
|||||||
@@ -491,6 +491,17 @@ describe('App - F7 / Shift+F7 差异导航快捷键', () => {
|
|||||||
// 导航未被触发,计数保持 1 / 2
|
// 导航未被触发,计数保持 1 / 2
|
||||||
expect(screen.getByText('1 / 2')).toBeInTheDocument()
|
expect(screen.getByText('1 / 2')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('右键菜单打开时 F7 不触发导航', async () => {
|
||||||
|
render(<App />)
|
||||||
|
fireEvent.click(screen.getByText('打开左侧'))
|
||||||
|
await screen.findByText('1 / 2')
|
||||||
|
fireEvent.contextMenu((await screen.findAllByText(/line1/))[0])
|
||||||
|
expect(await screen.findByText('复制左侧此行内容')).toBeInTheDocument()
|
||||||
|
fireEvent.keyDown(window, { key: 'F7' })
|
||||||
|
// 导航未被触发,计数保持 1 / 2
|
||||||
|
expect(screen.getByText('1 / 2')).toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('App - 交换左右侧', () => {
|
describe('App - 交换左右侧', () => {
|
||||||
|
|||||||
@@ -254,6 +254,32 @@ describe('DiffView - 虚拟滚动', () => {
|
|||||||
expect(columns[0].style.minWidth).toBe('')
|
expect(columns[0].style.minWidth).toBe('')
|
||||||
expect(columns[1].style.minWidth).toBe('')
|
expect(columns[1].style.minWidth).toBe('')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('超长行列宽封顶,minWidth 不随行长无限增长', () => {
|
||||||
|
// 探针 mock 为 10 字符 70px → 每字符 7px;列宽封顶 5000 → 5000×7+86
|
||||||
|
const rectSpy = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({
|
||||||
|
width: 70,
|
||||||
|
height: 21,
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 70,
|
||||||
|
bottom: 21,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
toJSON: () => ({})
|
||||||
|
} as DOMRect)
|
||||||
|
const longRow: DiffRow = {
|
||||||
|
id: 'long0',
|
||||||
|
rowKind: 'unchanged',
|
||||||
|
isChanged: false,
|
||||||
|
left: { lineNo: 1, text: 'a'.repeat(200000), segs: null },
|
||||||
|
right: { lineNo: 1, text: 'b', segs: null }
|
||||||
|
}
|
||||||
|
const { container } = renderDiff([longRow])
|
||||||
|
const columns = container.querySelectorAll('.diff-columns') as NodeListOf<HTMLElement>
|
||||||
|
expect(columns[0].style.minWidth).toBe('35086px')
|
||||||
|
rectSpy.mockRestore()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('DiffView - 折叠提示行', () => {
|
describe('DiffView - 折叠提示行', () => {
|
||||||
|
|||||||
@@ -213,13 +213,15 @@ export default function DiffView({
|
|||||||
if (w > 0) setCharW(w / 10)
|
if (w > 0) setCharW(w / 10)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
/** 列宽计算封顶:超长行(base64/压缩产物可达数 MB 单行)达到上限即停,避免逐字符全量遍历;与词级防护阈值同源 */
|
||||||
|
const MAX_COLS_CAP = 5000
|
||||||
const colsOf = (rows: DisplayItem[], pick: (r: DiffRow) => SideCell): number => {
|
const colsOf = (rows: DisplayItem[], pick: (r: DiffRow) => SideCell): number => {
|
||||||
let max = 40
|
let max = 40
|
||||||
for (const it of rows) {
|
for (const it of rows) {
|
||||||
if (isFold(it)) continue
|
if (isFold(it)) continue
|
||||||
const t = pick(it).text
|
const t = pick(it).text
|
||||||
if (t) {
|
if (t) {
|
||||||
const c = displayCols(t)
|
const c = displayCols(t, MAX_COLS_CAP)
|
||||||
if (c > max) max = c
|
if (c > max) max = c
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { DiffSummary } from '../diff/diffEngine'
|
|||||||
|
|
||||||
const summary: DiffSummary = { changedLines: 3, inserted: 1, deleted: 1, modified: 1 }
|
const summary: DiffSummary = { changedLines: 3, inserted: 1, deleted: 1, modified: 1 }
|
||||||
|
|
||||||
function setup(extra?: { canExport?: boolean }) {
|
function setup() {
|
||||||
const onOptionsChange = vi.fn()
|
const onOptionsChange = vi.fn()
|
||||||
const onNav = vi.fn()
|
const onNav = vi.fn()
|
||||||
const onExport = vi.fn()
|
const onExport = vi.fn()
|
||||||
@@ -20,7 +20,6 @@ function setup(extra?: { canExport?: boolean }) {
|
|||||||
navCount={3}
|
navCount={3}
|
||||||
onNav={onNav}
|
onNav={onNav}
|
||||||
onExport={onExport}
|
onExport={onExport}
|
||||||
canExport={extra?.canExport ?? true}
|
|
||||||
onlyDiff={false}
|
onlyDiff={false}
|
||||||
onOnlyDiffChange={onOnlyDiffChange}
|
onOnlyDiffChange={onOnlyDiffChange}
|
||||||
onSwap={onSwap}
|
onSwap={onSwap}
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ interface ToolbarProps {
|
|||||||
navCount: number
|
navCount: number
|
||||||
onNav: (dir: 1 | -1) => void
|
onNav: (dir: 1 | -1) => void
|
||||||
onExport: (fmt: ReportFormat) => void
|
onExport: (fmt: ReportFormat) => void
|
||||||
canExport: boolean
|
|
||||||
/** 仅看差异视图开关 */
|
/** 仅看差异视图开关 */
|
||||||
onlyDiff: boolean
|
onlyDiff: boolean
|
||||||
onOnlyDiffChange: (v: boolean) => void
|
onOnlyDiffChange: (v: boolean) => void
|
||||||
@@ -33,7 +32,6 @@ export default function Toolbar({
|
|||||||
navCount,
|
navCount,
|
||||||
onNav,
|
onNav,
|
||||||
onExport,
|
onExport,
|
||||||
canExport,
|
|
||||||
onlyDiff,
|
onlyDiff,
|
||||||
onOnlyDiffChange,
|
onOnlyDiffChange,
|
||||||
onSwap
|
onSwap
|
||||||
@@ -115,11 +113,7 @@ export default function Toolbar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="tool-group export-wrap" ref={exportWrapRef}>
|
<div className="tool-group export-wrap" ref={exportWrapRef}>
|
||||||
<button
|
<button className="btn primary" onClick={() => setExportOpen((o) => !o)}>
|
||||||
className="btn primary"
|
|
||||||
disabled={!canExport}
|
|
||||||
onClick={() => setExportOpen((o) => !o)}
|
|
||||||
>
|
|
||||||
导出报告
|
导出报告
|
||||||
</button>
|
</button>
|
||||||
{exportOpen && (
|
{exportOpen && (
|
||||||
|
|||||||
@@ -29,6 +29,22 @@ describe('buildTxtReport', () => {
|
|||||||
expect(txt).toContain('DiffLens')
|
expect(txt).toContain('DiffLens')
|
||||||
expect(txt).toContain('+')
|
expect(txt).toContain('+')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('行号按最大位数右对齐填充', () => {
|
||||||
|
const left = Array.from({ length: 12 }, (_, i) => `l${i}`).join('\n')
|
||||||
|
const right = left.replace('l1', 'changed')
|
||||||
|
const { rows, summary } = computeDiff(left, right)
|
||||||
|
const txt = buildTxtReport(rows, ctx, summary)
|
||||||
|
// 最大行号 12 → 宽度 2:行号 2 左补空格,行号 12 原样(修改行左右行号同样填充)
|
||||||
|
expect(txt).toContain('~ 2 | l1 ==> 2 | changed')
|
||||||
|
expect(txt).toContain(' 12 | l11')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('行号位数一致时无额外填充', () => {
|
||||||
|
const { rows, summary } = computeDiff('a\nc', 'a\nb')
|
||||||
|
const txt = buildTxtReport(rows, ctx, summary)
|
||||||
|
expect(txt).toContain('~ 2 | c ==> 2 | b')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('buildMarkdownReport', () => {
|
describe('buildMarkdownReport', () => {
|
||||||
|
|||||||
@@ -20,9 +20,11 @@ export interface ReportContext {
|
|||||||
const esc = (s: string): string =>
|
const esc = (s: string): string =>
|
||||||
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||||
|
|
||||||
/** 格式化为固定宽行号(右对齐) */
|
/** 格式化行号:传 width 时按位数右对齐(左补空格,用于纯文本报告对齐);null 返回空串 */
|
||||||
function padNo(n: number | null): string {
|
function padNo(n: number | null, width?: number): string {
|
||||||
return n === null ? '' : String(n)
|
if (n === null) return ''
|
||||||
|
const s = String(n)
|
||||||
|
return width !== undefined ? s.padStart(width) : s
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 单元格文本(词级高亮段在 HTML 里渲染为 span) */
|
/** 单元格文本(词级高亮段在 HTML 里渲染为 span) */
|
||||||
@@ -140,16 +142,25 @@ export function buildTxtReport(
|
|||||||
const head = headLines(ctx, summary, '====== DiffLens 差异报告 ======')
|
const head = headLines(ctx, summary, '====== DiffLens 差异报告 ======')
|
||||||
const lines = [...head, '----------------------------------']
|
const lines = [...head, '----------------------------------']
|
||||||
|
|
||||||
|
// 行号列宽取全部行号的最大位数,右对齐填充保证各行文本起始列一致
|
||||||
|
let maxNo = 0
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.left.lineNo !== null && row.left.lineNo > maxNo) maxNo = row.left.lineNo
|
||||||
|
if (row.right.lineNo !== null && row.right.lineNo > maxNo) maxNo = row.right.lineNo
|
||||||
|
}
|
||||||
|
const width = String(maxNo).length
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const tag = typeTag(row.rowKind)
|
const tag = typeTag(row.rowKind)
|
||||||
if (row.rowKind === 'modified') {
|
if (row.rowKind === 'modified') {
|
||||||
// 修改行:左/右并置
|
// 修改行:左/右并置
|
||||||
const l = padNo(row.left.lineNo) || ' '
|
const l = padNo(row.left.lineNo, width)
|
||||||
const r = padNo(row.right.lineNo) || ' '
|
const r = padNo(row.right.lineNo, width)
|
||||||
lines.push(`~ ${l} | ${row.left.text ?? ''} ==> ${r} | ${row.right.text ?? ''}`)
|
lines.push(`~ ${l} | ${row.left.text ?? ''} ==> ${r} | ${row.right.text ?? ''}`)
|
||||||
} else {
|
} else {
|
||||||
const no = padNo(
|
const no = padNo(
|
||||||
row.rowKind === 'added' ? row.right.lineNo : row.left.lineNo
|
row.rowKind === 'added' ? row.right.lineNo : row.left.lineNo,
|
||||||
|
width
|
||||||
)
|
)
|
||||||
const text = row.rowKind === 'added' ? row.right.text : row.left.text
|
const text = row.rowKind === 'added' ? row.right.text : row.left.text
|
||||||
lines.push(`${tag} ${no} | ${text ?? ''}`)
|
lines.push(`${tag} ${no} | ${text ?? ''}`)
|
||||||
|
|||||||
@@ -68,4 +68,17 @@ describe('displayCols 列宽估算', () => {
|
|||||||
it('中英混排按叠加列宽计', () => {
|
it('中英混排按叠加列宽计', () => {
|
||||||
expect(displayCols('a中b')).toBe(4)
|
expect(displayCols('a中b')).toBe(4)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('传入 cap 时达到上限提前返回', () => {
|
||||||
|
expect(displayCols('a'.repeat(9999), 5000)).toBe(5000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('未达 cap 时返回真实列宽', () => {
|
||||||
|
expect(displayCols('abc', 5000)).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('全角字符跨过 cap 时返回实际累计列宽', () => {
|
||||||
|
// 2499 个半角后接全角:2499 + 2 = 2501 ≥ 2500
|
||||||
|
expect(displayCols('a'.repeat(2499) + '中', 2500)).toBe(2501)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -102,11 +102,13 @@ function isWideCodePoint(cp: number): boolean {
|
|||||||
/**
|
/**
|
||||||
* 估算等宽字体下的显示列宽:半角 1 列、双宽字符 2 列。
|
* 估算等宽字体下的显示列宽:半角 1 列、双宽字符 2 列。
|
||||||
* 用于虚拟滚动下预计算内容最小宽度,替代全量 DOM 的 max-content 测量。
|
* 用于虚拟滚动下预计算内容最小宽度,替代全量 DOM 的 max-content 测量。
|
||||||
|
* cap 为列宽累计上限:达到即提前返回,供调用方跳过超长行的全量逐字符遍历。
|
||||||
*/
|
*/
|
||||||
export function displayCols(text: string): number {
|
export function displayCols(text: string, cap?: number): number {
|
||||||
let n = 0
|
let n = 0
|
||||||
for (const ch of text) {
|
for (const ch of text) {
|
||||||
n += isWideCodePoint(ch.codePointAt(0) ?? 0) ? 2 : 1
|
n += isWideCodePoint(ch.codePointAt(0) ?? 0) ? 2 : 1
|
||||||
|
if (cap !== undefined && n >= cap) return n
|
||||||
}
|
}
|
||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user