feat: v0.1.1 体验打磨 - 拖拽编码识别、行级右键菜单、行显示修正
This commit is contained in:
+2
-2
@@ -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 ← 新增功能
|
||||||
|
|||||||
Generated
+4
-4
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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> =>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user