feat: v0.1.1 体验打磨 - 拖拽编码识别、行级右键菜单、行显示修正

This commit is contained in:
2026-08-17 17:30:02 +08:00
parent f5da68e624
commit fb9a202d97
10 changed files with 179 additions and 15 deletions
+2 -2
View File
@@ -54,8 +54,8 @@ z = 补丁版本号(Patch
## 3. 版本演进示意 ## 3. 版本演进示意
``` ```
0.1.0 ← 初始版本(当前 0.1.0 ← 初始版本(已发布
0.1.1 ← 修复 Bug 0.1.1 ← 体验打磨补丁(当前):拖拽编码识别 · 行级右键菜单 · 尾部换行显示修正
0.2.0 ← 新增功能 0.2.0 ← 新增功能
0.2.1 ← 修复该版本 Bug 0.2.1 ← 修复该版本 Bug
0.3.0 ← 新增功能 0.3.0 ← 新增功能
+4 -4
View File
@@ -1,12 +1,12 @@
{ {
"name": "DiffLens", "name": "DiffLens",
"version": "0.1.0", "version": "0.1.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "DiffLens", "name": "DiffLens",
"version": "0.1.0", "version": "0.1.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/preload": "^3.0.1",
@@ -4762,7 +4762,7 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/is-unicode-supported": { "node_modules/is-unicode-supported": {
"version": "0.1.0", "version": "0.1.1",
"resolved": "https://registry.npmmirror.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", "resolved": "https://registry.npmmirror.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz",
"integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==",
"dev": true, "dev": true,
@@ -7009,7 +7009,7 @@
} }
}, },
"node_modules/yocto-queue": { "node_modules/yocto-queue": {
"version": "0.1.0", "version": "0.1.1",
"resolved": "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"dev": true, "dev": true,
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "DiffLens", "name": "DiffLens",
"version": "0.1.0", "version": "0.1.1",
"description": "DiffLens — 精美酷炫的文本对比桌面应用", "description": "DiffLens — 精美酷炫的文本对比桌面应用",
"author": "thzxx", "author": "thzxx",
"license": "MIT", "license": "MIT",
+5
View File
@@ -83,6 +83,11 @@ ipcMain.handle('file:open', async (_event, side: 'left' | 'right' | null) => {
return { path: filePath, name, text, encoding } return { path: filePath, name, text, encoding }
}) })
/** IPC: 解码拖拽传入的原始字节(复用编码探测逻辑) */
ipcMain.handle('file:decode-buffer', (_event, buffer: ArrayBuffer) => {
return decodeText(Buffer.from(buffer))
})
/** IPC: 打开系统文件管理器定位文件 */ /** IPC: 打开系统文件管理器定位文件 */
ipcMain.handle('file:show-in-folder', (_event, filePath: string) => { ipcMain.handle('file:show-in-folder', (_event, filePath: string) => {
shell.showItemInFolder(filePath) shell.showItemInFolder(filePath)
+2
View File
@@ -12,6 +12,8 @@ export interface FileData {
const api = { const api = {
openFile: (side?: 'left' | 'right'): Promise<FileData | null> => openFile: (side?: 'left' | 'right'): Promise<FileData | null> =>
ipcRenderer.invoke('file:open', side ?? null), ipcRenderer.invoke('file:open', side ?? null),
decodeBuffer: (buffer: ArrayBuffer): Promise<{ text: string; encoding: string }> =>
ipcRenderer.invoke('file:decode-buffer', buffer),
showInFolder: (filePath: string): Promise<boolean> => showInFolder: (filePath: string): Promise<boolean> =>
ipcRenderer.invoke('file:show-in-folder', filePath), ipcRenderer.invoke('file:show-in-folder', filePath),
setClipboard: (text: string): Promise<boolean> => setClipboard: (text: string): Promise<boolean> =>
+53 -3
View File
@@ -1,9 +1,18 @@
import { useEffect, useMemo, useState, useCallback, type ReactElement } from 'react' import {
useEffect,
useMemo,
useState,
useCallback,
type ReactElement,
type MouseEvent as ReactMouseEvent
} from 'react'
import { computeDiff } from './diff/diffEngine' import { computeDiff } from './diff/diffEngine'
import type { DiffResult, DiffSummary } from './diff/diffEngine' import type { 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 type { PaneMeta } from './components/DiffView' import type { PaneMeta } from './components/DiffView'
import type { SideCell } from './diff/diffEngine'
interface PaneState { interface PaneState {
meta: PaneMeta meta: PaneMeta
@@ -70,6 +79,7 @@ export default function App(): ReactElement {
const [options, setOptions] = useState<DiffOpts>({ trimWhitespace: false, ignoreCase: false }) const [options, setOptions] = useState<DiffOpts>({ 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 openPane = useCallback(async (side: 'left' | 'right') => { const openPane = useCallback(async (side: 'left' | 'right') => {
const data = await window.api.openFile(side) const data = await window.api.openFile(side)
@@ -80,8 +90,17 @@ export default function App(): ReactElement {
}, []) }, [])
const dropPane = useCallback(async (side: 'left' | 'right', file: File) => { const dropPane = useCallback(async (side: 'left' | 'right', file: File) => {
const text = await file.text().catch(() => '') let text = ''
const pane: PaneState = { meta: { name: file.name, encoding: 'UTF-8' }, path: '', text } let encoding = 'UTF-8'
try {
const buf = await file.arrayBuffer()
const decoded = await window.api.decodeBuffer(buf)
text = decoded.text
encoding = decoded.encoding
} catch {
text = await file.text().catch(() => '')
}
const pane: PaneState = { meta: { name: file.name, encoding }, path: '', text }
if (side === 'left') setPaneL(pane) if (side === 'left') setPaneL(pane)
else setPaneR(pane) else setPaneR(pane)
}, []) }, [])
@@ -112,6 +131,34 @@ export default function App(): ReactElement {
[changedRows, navIndex] [changedRows, navIndex]
) )
// 行级右键菜单
const onRowContext = useCallback(
(e: ReactMouseEvent, side: 'left' | 'right', cell: SideCell) => {
e.preventDefault()
const pane = side === 'left' ? paneL : paneR
const sideName = side === 'left' ? '左侧' : '右侧'
const items: ContextMenuItem[] = [
{
label: `复制${sideName}此行内容`,
action: () => void navigator.clipboard.writeText(cell.text ?? '')
},
{
label: '复制文件名',
action: () => void navigator.clipboard.writeText(pane?.meta.name ?? '')
},
{
label: '在文件夹中显示',
disabled: !pane?.path,
action: () => {
if (pane?.path) void window.api.showInFolder(pane.path)
}
}
]
setMenu({ x: e.clientX, y: e.clientY, items })
},
[paneL, paneR]
)
const anyPane = paneL !== null || paneR !== null const anyPane = paneL !== null || paneR !== null
const leftMeta = paneL?.meta ?? null const leftMeta = paneL?.meta ?? null
@@ -155,6 +202,7 @@ export default function App(): ReactElement {
rightMeta={rightMeta} rightMeta={rightMeta}
activeRowId={activeRowId} activeRowId={activeRowId}
onOpen={(s) => void openPane(s)} onOpen={(s) => void openPane(s)}
onRowContext={onRowContext}
/> />
<Toolbar <Toolbar
options={options} options={options}
@@ -193,6 +241,8 @@ export default function App(): ReactElement {
)} )}
</div> </div>
</footer> </footer>
{menu && <ContextMenu x={menu.x} y={menu.y} items={menu.items} onClose={() => setMenu(null)} />}
</div> </div>
) )
} }
@@ -0,0 +1,54 @@
import { type ReactElement } from 'react'
export interface ContextMenuItem {
label: string
disabled?: boolean
action: () => void
}
interface ContextMenuProps {
x: number
y: number
items: ContextMenuItem[]
onClose: () => void
}
export default function ContextMenu({
x,
y,
items,
onClose
}: ContextMenuProps): ReactElement {
return (
<div
className="ctx-backdrop"
onMouseDown={onClose}
onContextMenu={(e) => {
e.preventDefault()
onClose()
}}
>
<div
className="ctx-menu"
style={{ left: x, top: y }}
onMouseDown={(e) => e.stopPropagation()}
>
{items.map((it, i) => (
<button
key={i}
className={'ctx-item' + (it.disabled ? ' disabled' : '')}
disabled={it.disabled}
onClick={() => {
if (!it.disabled) {
it.action()
onClose()
}
}}
>
{it.label}
</button>
))}
</div>
</div>
)
}
+18 -3
View File
@@ -1,4 +1,11 @@
import { useEffect, useRef, type ReactNode, type ReactElement, type RefObject } from 'react' import {
useEffect,
useRef,
type ReactNode,
type ReactElement,
type RefObject,
type MouseEvent as ReactMouseEvent
} from 'react'
import { DiffResult, DiffRow, SideCell } from '../diff/diffEngine' import { DiffResult, DiffRow, SideCell } from '../diff/diffEngine'
export interface PaneMeta { export interface PaneMeta {
@@ -12,6 +19,7 @@ interface DiffViewProps {
rightMeta: PaneMeta | null rightMeta: PaneMeta | null
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
} }
function renderCellText(cell: SideCell): ReactNode { function renderCellText(cell: SideCell): ReactNode {
@@ -33,6 +41,7 @@ function SidePanel({
side, side,
meta, meta,
onOpen, onOpen,
onRowContext,
scrollRef, scrollRef,
onScroll, onScroll,
activeRowId activeRowId
@@ -41,6 +50,7 @@ function SidePanel({
side: 'left' | 'right' side: 'left' | 'right'
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
scrollRef: RefObject<HTMLDivElement> scrollRef: RefObject<HTMLDivElement>
onScroll: () => void onScroll: () => void
activeRowId: string | null activeRowId: string | null
@@ -69,7 +79,9 @@ function SidePanel({
return ( return (
<div className={cls} data-row={row.id} key={row.id + '-' + side}> <div className={cls} data-row={row.id} key={row.id + '-' + side}>
<span className={'ln ' + side}>{cell.lineNo ?? ''}</span> <span className={'ln ' + side}>{cell.lineNo ?? ''}</span>
<span className="tx">{renderCellText(cell)}</span> <span className="tx" onContextMenu={(e) => onRowContext(e, side, cell)}>
{renderCellText(cell)}
</span>
</div> </div>
) )
})} })}
@@ -84,7 +96,8 @@ export default function DiffView({
leftMeta, leftMeta,
rightMeta, rightMeta,
activeRowId, activeRowId,
onOpen onOpen,
onRowContext
}: DiffViewProps): ReactElement { }: DiffViewProps): ReactElement {
const leftRef = useRef<HTMLDivElement>(null) const leftRef = useRef<HTMLDivElement>(null)
const rightRef = useRef<HTMLDivElement>(null) const rightRef = useRef<HTMLDivElement>(null)
@@ -118,6 +131,7 @@ export default function DiffView({
side="left" side="left"
meta={leftMeta} meta={leftMeta}
onOpen={onOpen} onOpen={onOpen}
onRowContext={onRowContext}
scrollRef={leftRef} scrollRef={leftRef}
onScroll={makeScrollSync('left')} onScroll={makeScrollSync('left')}
activeRowId={activeRowId} activeRowId={activeRowId}
@@ -127,6 +141,7 @@ export default function DiffView({
side="right" side="right"
meta={rightMeta} meta={rightMeta}
onOpen={onOpen} onOpen={onOpen}
onRowContext={onRowContext}
scrollRef={rightRef} scrollRef={rightRef}
onScroll={makeScrollSync('right')} onScroll={makeScrollSync('right')}
activeRowId={activeRowId} activeRowId={activeRowId}
+4 -2
View File
@@ -70,10 +70,12 @@ function wordSegments(leftText: string, rightText: string): { left: Seg[]; right
return { left, right } return { left, right }
} }
/** 按行切分;空字符串返回空数组 */ /** 按行切分;空字符串返回空数组。结尾换行符不产生一个额外空行 */
function splitLines(text: string): string[] { function splitLines(text: string): string[] {
if (text === '') return [] if (text === '') return []
return text.split(/\r?\n/) const lines = text.split(/\r?\n/)
if (lines[lines.length - 1] === '') lines.pop()
return lines
} }
/** /**
+36
View File
@@ -510,6 +510,42 @@ button:disabled {
box-shadow: 0 0 8px var(--mod-fg); box-shadow: 0 0 8px var(--mod-fg);
} }
/* ============ 右键菜单 ============ */
.ctx-backdrop {
position: fixed;
inset: 0;
z-index: 1000;
}
.ctx-menu {
position: fixed;
min-width: 168px;
padding: 5px;
border-radius: 10px;
background: rgba(16, 22, 34, 0.96);
border: 1px solid var(--border-strong);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.6), 0 0 24px rgba(124, 108, 255, 0.18);
backdrop-filter: blur(8px);
display: flex;
flex-direction: column;
gap: 2px;
z-index: 1001;
}
.ctx-item {
text-align: left;
padding: 7px 10px;
border-radius: 6px;
font-size: 12.5px;
color: var(--text);
background: transparent;
transition: background 0.13s ease;
}
.ctx-item:hover:not(.disabled) {
background: var(--surface-hover);
}
.ctx-item.disabled {
opacity: 0.4;
}
/* ============ 状态栏 ============ */ /* ============ 状态栏 ============ */
.status-bar { .status-bar {
flex: 0 0 auto; flex: 0 0 auto;