diff --git a/docs/应用开发与版本迭代规范.md b/docs/应用开发与版本迭代规范.md index 11136a0..9e71e84 100644 --- a/docs/应用开发与版本迭代规范.md +++ b/docs/应用开发与版本迭代规范.md @@ -54,8 +54,8 @@ z = 补丁版本号(Patch) ## 3. 版本演进示意 ``` -0.1.0 ← 初始版本(当前) -0.1.1 ← 修复 Bug +0.1.0 ← 初始版本(已发布) +0.1.1 ← 体验打磨补丁(当前):拖拽编码识别 · 行级右键菜单 · 尾部换行显示修正 0.2.0 ← 新增功能 0.2.1 ← 修复该版本 Bug 0.3.0 ← 新增功能 diff --git a/package-lock.json b/package-lock.json index 9c3a336..16cc372 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "DiffLens", - "version": "0.1.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "DiffLens", - "version": "0.1.0", + "version": "0.1.1", "license": "MIT", "dependencies": { "@electron-toolkit/preload": "^3.0.1", @@ -4762,7 +4762,7 @@ "license": "MIT" }, "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", "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", "dev": true, @@ -7009,7 +7009,7 @@ } }, "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", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, diff --git a/package.json b/package.json index 2ce69ef..c8f642a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "DiffLens", - "version": "0.1.0", + "version": "0.1.1", "description": "DiffLens — 精美酷炫的文本对比桌面应用", "author": "thzxx", "license": "MIT", diff --git a/src/main/index.ts b/src/main/index.ts index 927d626..451f290 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -83,6 +83,11 @@ ipcMain.handle('file:open', async (_event, side: 'left' | 'right' | null) => { return { path: filePath, name, text, encoding } }) +/** IPC: 解码拖拽传入的原始字节(复用编码探测逻辑) */ +ipcMain.handle('file:decode-buffer', (_event, buffer: ArrayBuffer) => { + return decodeText(Buffer.from(buffer)) +}) + /** IPC: 打开系统文件管理器定位文件 */ ipcMain.handle('file:show-in-folder', (_event, filePath: string) => { shell.showItemInFolder(filePath) diff --git a/src/preload/index.ts b/src/preload/index.ts index 1732f76..1eda44d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -12,6 +12,8 @@ export interface FileData { const api = { openFile: (side?: 'left' | 'right'): Promise => ipcRenderer.invoke('file:open', side ?? null), + decodeBuffer: (buffer: ArrayBuffer): Promise<{ text: string; encoding: string }> => + ipcRenderer.invoke('file:decode-buffer', buffer), showInFolder: (filePath: string): Promise => ipcRenderer.invoke('file:show-in-folder', filePath), setClipboard: (text: string): Promise => diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 85db972..5a538b8 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 type { DiffResult, DiffSummary } from './diff/diffEngine' import DiffView from './components/DiffView' import Toolbar from './components/Toolbar' +import ContextMenu, { type ContextMenuItem } from './components/ContextMenu' import type { PaneMeta } from './components/DiffView' +import type { SideCell } from './diff/diffEngine' interface PaneState { meta: PaneMeta @@ -70,6 +79,7 @@ export default function App(): ReactElement { const [options, setOptions] = useState({ trimWhitespace: false, ignoreCase: false }) const [activeRowId, setActiveRowId] = useState(null) 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 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 text = await file.text().catch(() => '') - const pane: PaneState = { meta: { name: file.name, encoding: 'UTF-8' }, path: '', text } + let 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) else setPaneR(pane) }, []) @@ -112,6 +131,34 @@ export default function App(): ReactElement { [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 leftMeta = paneL?.meta ?? null @@ -155,6 +202,7 @@ export default function App(): ReactElement { rightMeta={rightMeta} activeRowId={activeRowId} onOpen={(s) => void openPane(s)} + onRowContext={onRowContext} /> + + {menu && setMenu(null)} />} ) } \ No newline at end of file diff --git a/src/renderer/src/components/ContextMenu.tsx b/src/renderer/src/components/ContextMenu.tsx new file mode 100644 index 0000000..5f6575a --- /dev/null +++ b/src/renderer/src/components/ContextMenu.tsx @@ -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 ( +
{ + e.preventDefault() + onClose() + }} + > +
e.stopPropagation()} + > + {items.map((it, i) => ( + + ))} +
+
+ ) +} \ No newline at end of file diff --git a/src/renderer/src/components/DiffView.tsx b/src/renderer/src/components/DiffView.tsx index 22f7f2d..e0811a7 100644 --- a/src/renderer/src/components/DiffView.tsx +++ b/src/renderer/src/components/DiffView.tsx @@ -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' export interface PaneMeta { @@ -12,6 +19,7 @@ interface DiffViewProps { rightMeta: PaneMeta | null activeRowId: string | null onOpen: (side: 'left' | 'right') => void + onRowContext: (e: ReactMouseEvent, side: 'left' | 'right', cell: SideCell) => void } function renderCellText(cell: SideCell): ReactNode { @@ -33,6 +41,7 @@ function SidePanel({ side, meta, onOpen, + onRowContext, scrollRef, onScroll, activeRowId @@ -41,6 +50,7 @@ function SidePanel({ side: 'left' | 'right' meta: PaneMeta | null onOpen: (side: 'left' | 'right') => void + onRowContext: (e: ReactMouseEvent, side: 'left' | 'right', cell: SideCell) => void scrollRef: RefObject onScroll: () => void activeRowId: string | null @@ -69,7 +79,9 @@ function SidePanel({ return (
{cell.lineNo ?? ''} - {renderCellText(cell)} + onRowContext(e, side, cell)}> + {renderCellText(cell)} +
) })} @@ -84,7 +96,8 @@ export default function DiffView({ leftMeta, rightMeta, activeRowId, - onOpen + onOpen, + onRowContext }: DiffViewProps): ReactElement { const leftRef = useRef(null) const rightRef = useRef(null) @@ -118,6 +131,7 @@ export default function DiffView({ side="left" meta={leftMeta} onOpen={onOpen} + onRowContext={onRowContext} scrollRef={leftRef} onScroll={makeScrollSync('left')} activeRowId={activeRowId} @@ -127,6 +141,7 @@ export default function DiffView({ side="right" meta={rightMeta} onOpen={onOpen} + onRowContext={onRowContext} scrollRef={rightRef} onScroll={makeScrollSync('right')} activeRowId={activeRowId} diff --git a/src/renderer/src/diff/diffEngine.ts b/src/renderer/src/diff/diffEngine.ts index e09964e..c35bb75 100644 --- a/src/renderer/src/diff/diffEngine.ts +++ b/src/renderer/src/diff/diffEngine.ts @@ -70,10 +70,12 @@ function wordSegments(leftText: string, rightText: string): { left: Seg[]; right return { left, right } } -/** 按行切分;空字符串返回空数组 */ +/** 按行切分;空字符串返回空数组。结尾换行符不产生一个额外空行 */ function splitLines(text: string): string[] { if (text === '') return [] - return text.split(/\r?\n/) + const lines = text.split(/\r?\n/) + if (lines[lines.length - 1] === '') lines.pop() + return lines } /** diff --git a/src/renderer/src/styles/global.css b/src/renderer/src/styles/global.css index 7042bda..b126e18 100644 --- a/src/renderer/src/styles/global.css +++ b/src/renderer/src/styles/global.css @@ -510,6 +510,42 @@ button:disabled { 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 { flex: 0 0 auto;