feat: v0.1.1 体验打磨 - 拖拽编码识别、行级右键菜单、行显示修正
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface FileData {
|
||||
const api = {
|
||||
openFile: (side?: 'left' | 'right'): Promise<FileData | 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> =>
|
||||
ipcRenderer.invoke('file:show-in-folder', filePath),
|
||||
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 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<DiffOpts>({ trimWhitespace: false, ignoreCase: false })
|
||||
const [activeRowId, setActiveRowId] = useState<string | null>(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}
|
||||
/>
|
||||
<Toolbar
|
||||
options={options}
|
||||
@@ -193,6 +241,8 @@ export default function App(): ReactElement {
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{menu && <ContextMenu x={menu.x} y={menu.y} items={menu.items} onClose={() => setMenu(null)} />}
|
||||
</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'
|
||||
|
||||
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<HTMLDivElement>
|
||||
onScroll: () => void
|
||||
activeRowId: string | null
|
||||
@@ -69,7 +79,9 @@ function SidePanel({
|
||||
return (
|
||||
<div className={cls} data-row={row.id} key={row.id + '-' + side}>
|
||||
<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>
|
||||
)
|
||||
})}
|
||||
@@ -84,7 +96,8 @@ export default function DiffView({
|
||||
leftMeta,
|
||||
rightMeta,
|
||||
activeRowId,
|
||||
onOpen
|
||||
onOpen,
|
||||
onRowContext
|
||||
}: DiffViewProps): ReactElement {
|
||||
const leftRef = useRef<HTMLDivElement>(null)
|
||||
const rightRef = useRef<HTMLDivElement>(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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user