feat: DiffLens v0.1.0 初始版本 - 文本对比桌面应用(TypeScript + React + Electron)
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
import { join } from 'path'
|
||||
import { app, shell, BrowserWindow, Menu, ipcMain, dialog } from 'electron'
|
||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||
import iconv from 'iconv-lite'
|
||||
import fs from 'fs'
|
||||
|
||||
function createWindow(): void {
|
||||
// 主窗口
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 820,
|
||||
minWidth: 900,
|
||||
minHeight: 600,
|
||||
show: false,
|
||||
autoHideMenuBar: false,
|
||||
title: 'DiffLens',
|
||||
icon: join(__dirname, '../../resources/icon.png'),
|
||||
backgroundColor: '#0a0e17',
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: false,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false
|
||||
}
|
||||
})
|
||||
|
||||
mainWindow.on('ready-to-show', () => {
|
||||
mainWindow.show()
|
||||
})
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
shell.openExternal(details.url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
// 开发模式加载 dev server,生产加载打包后的 html
|
||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
||||
} else {
|
||||
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 探测文本编码:优先 BOM,其次严格 UTF-8,
|
||||
* 失败则回退 GBK 解码,返回 { text, encoding }。
|
||||
*/
|
||||
function decodeText(buf: Buffer): { text: string; encoding: string } {
|
||||
if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) {
|
||||
return { text: buf.subarray(3).toString('utf8'), encoding: 'UTF-8 (BOM)' }
|
||||
}
|
||||
if (buf.length >= 2 && buf[0] === 0xfe && buf[1] === 0xff) {
|
||||
return { text: iconv.decode(buf.subarray(2), 'utf16-be'), encoding: 'UTF-16 BE' }
|
||||
}
|
||||
if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) {
|
||||
return { text: buf.subarray(2).toString('utf16le'), encoding: 'UTF-16 LE' }
|
||||
}
|
||||
// 严格 UTF-8 探测
|
||||
try {
|
||||
const decoder = new TextDecoder('utf-8', { fatal: true })
|
||||
const text = decoder.decode(buf)
|
||||
return { text, encoding: 'UTF-8' }
|
||||
} catch {
|
||||
return { text: iconv.decode(buf, 'gbk'), encoding: 'GBK' }
|
||||
}
|
||||
}
|
||||
|
||||
/** IPC: 打开文件对话框并读取内容 */
|
||||
ipcMain.handle('file:open', async (_event, side: 'left' | 'right' | null) => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: `选择${side === 'left' ? '左侧' : side === 'right' ? '右侧' : ''}文本文件`,
|
||||
properties: ['openFile'],
|
||||
filters: [
|
||||
{ name: '文本文件', extensions: ['txt', 'md', 'json', 'js', 'ts', 'tsx', 'jsx', 'css', 'html', 'xml', 'yml', 'yaml', 'py', 'java', 'go', 'rs', 'c', 'cpp', 'h', 'log', 'csv', 'sql'] },
|
||||
{ name: '所有文件', extensions: ['*'] }
|
||||
]
|
||||
})
|
||||
if (result.canceled || result.filePaths.length === 0) return null
|
||||
const filePath = result.filePaths[0]
|
||||
const buf = fs.readFileSync(filePath)
|
||||
const { text, encoding } = decodeText(buf)
|
||||
const name = filePath.split(/[\\/]/).pop() ?? filePath
|
||||
return { path: filePath, name, text, encoding }
|
||||
})
|
||||
|
||||
/** IPC: 打开系统文件管理器定位文件 */
|
||||
ipcMain.handle('file:show-in-folder', (_event, filePath: string) => {
|
||||
shell.showItemInFolder(filePath)
|
||||
return true
|
||||
})
|
||||
|
||||
/** IPC: 复制文本到剪贴板 */
|
||||
ipcMain.handle('clipboard:write', (_event, text: string) => {
|
||||
require('electron').clipboard.writeText(text)
|
||||
return true
|
||||
})
|
||||
|
||||
function buildMenu(): void {
|
||||
const isMac = process.platform === 'darwin'
|
||||
const template: Electron.MenuItemConstructorOptions[] = [
|
||||
...(isMac
|
||||
? [
|
||||
{
|
||||
label: app.name,
|
||||
submenu: [
|
||||
{ role: 'about' as const },
|
||||
{ type: 'separator' as const },
|
||||
{ role: 'quit' as const }
|
||||
]
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: '文件',
|
||||
submenu: [
|
||||
{ label: '打开文件(左侧)', accelerator: 'CmdOrCtrl+O', click: () => sendMenuCommand('open-left') },
|
||||
{ label: '打开文件(右侧)', accelerator: 'CmdOrCtrl+Alt+O', click: () => sendMenuCommand('open-right') },
|
||||
{ type: 'separator' },
|
||||
isMac ? { role: 'close' as const } : { role: 'quit' as const }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '编辑',
|
||||
submenu: [
|
||||
{ role: 'undo' as const },
|
||||
{ role: 'redo' as const },
|
||||
{ type: 'separator' as const },
|
||||
{ role: 'cut' as const },
|
||||
{ role: 'copy' as const },
|
||||
{ role: 'paste' as const },
|
||||
{ role: 'selectAll' as const }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '视图',
|
||||
submenu: [
|
||||
{ role: 'reload' as const },
|
||||
{ role: 'forceReload' as const },
|
||||
{ role: 'toggleDevTools' as const },
|
||||
{ type: 'separator' as const },
|
||||
{ role: 'resetZoom' as const },
|
||||
{ role: 'zoomIn' as const },
|
||||
{ role: 'zoomOut' as const },
|
||||
{ type: 'separator' as const },
|
||||
{ role: 'togglefullscreen' as const }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '帮助',
|
||||
submenu: [{ role: 'about' as const }]
|
||||
}
|
||||
]
|
||||
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
|
||||
}
|
||||
|
||||
/** 向渲染进程广播菜单指令 */
|
||||
function sendMenuCommand(cmd: string): void {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.webContents.send('menu:command', cmd)
|
||||
}
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
electronApp.setAppUserModelId('com.metonateam.difflens')
|
||||
|
||||
app.on('browser-window-created', (_, window) => {
|
||||
optimizer.watchWindowShortcuts(window)
|
||||
})
|
||||
|
||||
buildMenu()
|
||||
createWindow()
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||
})
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
import { DiffLensApi } from './index'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
api: DiffLensApi
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
|
||||
/** 打开文件返回的数据结构 */
|
||||
export interface FileData {
|
||||
path: string
|
||||
name: string
|
||||
text: string
|
||||
encoding: string
|
||||
}
|
||||
|
||||
/** 主进程暴露给渲染进程的安全 API */
|
||||
const api = {
|
||||
openFile: (side?: 'left' | 'right'): Promise<FileData | null> =>
|
||||
ipcRenderer.invoke('file:open', side ?? null),
|
||||
showInFolder: (filePath: string): Promise<boolean> =>
|
||||
ipcRenderer.invoke('file:show-in-folder', filePath),
|
||||
setClipboard: (text: string): Promise<boolean> =>
|
||||
ipcRenderer.invoke('clipboard:write', text),
|
||||
/** 订阅顶部菜单命令,返回取消订阅函数 */
|
||||
onMenuCommand: (callback: (cmd: string) => void): (() => void) => {
|
||||
const listener = (_e: unknown, cmd: string): void => callback(cmd)
|
||||
ipcRenderer.on('menu:command', listener)
|
||||
return () => {
|
||||
ipcRenderer.removeListener('menu:command', listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type DiffLensApi = typeof api
|
||||
|
||||
if (process.contextIsolated) {
|
||||
try {
|
||||
contextBridge.exposeInMainWorld('api', api)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
} else {
|
||||
// 非隔离环境下透传到 window
|
||||
;(window as unknown as { api: DiffLensApi }).api = api
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>DiffLens</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data:"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,206 @@
|
||||
import { useEffect, useMemo, useState, useCallback, type ReactElement } 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 type { PaneMeta } from './components/DiffView'
|
||||
|
||||
interface PaneState {
|
||||
meta: PaneMeta
|
||||
path: string
|
||||
text: string
|
||||
}
|
||||
|
||||
interface DiffOpts {
|
||||
trimWhitespace: boolean
|
||||
ignoreCase: boolean
|
||||
}
|
||||
|
||||
function LogoIcon(): ReactElement {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="11" cy="11" r="6.2" stroke="#fff" strokeWidth="2.1" />
|
||||
<line x1="15.5" y1="15.5" x2="20.5" y2="20.5" stroke="#fff" strokeWidth="2.4" strokeLinecap="round" />
|
||||
<path d="M5 11h3.4M5 14h2" stroke="#030712" strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyPane({
|
||||
side,
|
||||
onOpen,
|
||||
onDrop
|
||||
}: {
|
||||
side: 'left' | 'right'
|
||||
onOpen: (s: 'left' | 'right') => void
|
||||
onDrop: (s: 'left' | 'right', file: File) => void
|
||||
}): ReactElement {
|
||||
const [drag, setDrag] = useState(false)
|
||||
return (
|
||||
<div
|
||||
className={'pane-empty' + (drag ? ' dragging' : '')}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
setDrag(true)
|
||||
}}
|
||||
onDragLeave={() => setDrag(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
setDrag(false)
|
||||
const f = e.dataTransfer.files[0]
|
||||
if (f) onDrop(side, f)
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<span className={'pane-dot ' + side} style={{ display: 'inline-block' }} />
|
||||
<h3>选择{side === 'left' ? '左侧' : '右侧'}文件</h3>
|
||||
<p>{side === 'left' ? '被对比的原始文本' : '用于对比的新文本'}</p>
|
||||
<button className="btn primary" onClick={() => onOpen(side)}>
|
||||
打开文件
|
||||
</button>
|
||||
<div className="pane-drop-hint">或直接将文件拖拽到此处</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App(): ReactElement {
|
||||
const [paneL, setPaneL] = useState<PaneState | null>(null)
|
||||
const [paneR, setPaneR] = useState<PaneState | null>(null)
|
||||
const [options, setOptions] = useState<DiffOpts>({ trimWhitespace: false, ignoreCase: false })
|
||||
const [activeRowId, setActiveRowId] = useState<string | null>(null)
|
||||
const [navIndex, setNavIndex] = useState(0)
|
||||
|
||||
const openPane = useCallback(async (side: 'left' | 'right') => {
|
||||
const data = await window.api.openFile(side)
|
||||
if (!data) return
|
||||
const pane: PaneState = { meta: { name: data.name, encoding: data.encoding }, path: data.path, text: data.text }
|
||||
if (side === 'left') setPaneL(pane)
|
||||
else setPaneR(pane)
|
||||
}, [])
|
||||
|
||||
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 }
|
||||
if (side === 'left') setPaneL(pane)
|
||||
else setPaneR(pane)
|
||||
}, [])
|
||||
|
||||
// 订阅顶层菜单命令
|
||||
useEffect(() => {
|
||||
return window.api.onMenuCommand((cmd) => {
|
||||
if (cmd === 'open-left') void openPane('left')
|
||||
else if (cmd === 'open-right') void openPane('right')
|
||||
})
|
||||
}, [openPane])
|
||||
|
||||
const diff: DiffResult = useMemo(() => {
|
||||
return computeDiff(paneL?.text ?? '', paneR?.text ?? '', options)
|
||||
}, [paneL, paneR, options])
|
||||
|
||||
const changedRows = useMemo(() => diff.rows.filter((r) => r.isChanged), [diff])
|
||||
const summary: DiffSummary = diff.summary
|
||||
|
||||
// diff 内容变化时重置导航
|
||||
useEffect(() => {
|
||||
setNavIndex(0)
|
||||
setActiveRowId(null)
|
||||
}, [paneL, paneR, options])
|
||||
|
||||
const go = useCallback(
|
||||
(dir: 1 | -1) => {
|
||||
const len = changedRows.length
|
||||
if (len === 0) return
|
||||
let ni = navIndex + dir
|
||||
if (ni >= len) ni = 0
|
||||
if (ni < 0) ni = len - 1
|
||||
setNavIndex(ni)
|
||||
setActiveRowId(changedRows[ni].id)
|
||||
},
|
||||
[changedRows, navIndex]
|
||||
)
|
||||
|
||||
const anyPane = paneL !== null || paneR !== null
|
||||
|
||||
const leftMeta = paneL?.meta ?? null
|
||||
const rightMeta = paneR?.meta ?? null
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="app-header">
|
||||
<div className="logo">
|
||||
<div className="logo-mark">
|
||||
<LogoIcon />
|
||||
</div>
|
||||
<div>
|
||||
<span className="logo-title">DiffLens</span>
|
||||
<span className="logo-sub">TEXT DIFF</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="header-spacer" />
|
||||
<button className="btn ghost" onClick={() => void openPane('left')}>
|
||||
打开左侧
|
||||
</button>
|
||||
<button className="btn ghost" onClick={() => void openPane('right')}>
|
||||
打开右侧
|
||||
</button>
|
||||
<button
|
||||
className="btn ghost"
|
||||
onClick={() => {
|
||||
setPaneL(null)
|
||||
setPaneR(null)
|
||||
}}
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{anyPane ? (
|
||||
<>
|
||||
<DiffView
|
||||
diff={diff}
|
||||
leftMeta={leftMeta}
|
||||
rightMeta={rightMeta}
|
||||
activeRowId={activeRowId}
|
||||
onOpen={(s) => void openPane(s)}
|
||||
/>
|
||||
<Toolbar
|
||||
options={options}
|
||||
onOptionsChange={setOptions}
|
||||
summary={summary}
|
||||
navIndex={navIndex}
|
||||
navCount={changedRows.length}
|
||||
onNav={go}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="diff-main">
|
||||
<EmptyPane side="left" onOpen={(s) => void openPane(s)} onDrop={(s, f) => void dropPane(s, f)} />
|
||||
<EmptyPane side="right" onOpen={(s) => void openPane(s)} onDrop={(s, f) => void dropPane(s, f)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<footer className="status-bar">
|
||||
<div className="status-left">
|
||||
<span className="status-item">
|
||||
左:<b>{paneL ? paneL.meta.name : '—'}</b>
|
||||
</span>
|
||||
<span className="status-item">
|
||||
右:<b>{paneR ? paneR.meta.name : '—'}</b>
|
||||
</span>
|
||||
<span className="status-item">
|
||||
变更行:<b>{summary.changedLines}</b>
|
||||
</span>
|
||||
</div>
|
||||
<div className="status-spacer" />
|
||||
<div className="status-right">
|
||||
{summary.changedLines === 0 ? (
|
||||
<span className="eq-badge same">内容一致</span>
|
||||
) : (
|
||||
<span className="eq-badge diff">有差异</span>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useEffect, useRef, type ReactNode, type ReactElement, type RefObject } from 'react'
|
||||
import { DiffResult, DiffRow, SideCell } from '../diff/diffEngine'
|
||||
|
||||
export interface PaneMeta {
|
||||
name: string
|
||||
encoding: string
|
||||
}
|
||||
|
||||
interface DiffViewProps {
|
||||
diff: DiffResult
|
||||
leftMeta: PaneMeta | null
|
||||
rightMeta: PaneMeta | null
|
||||
activeRowId: string | null
|
||||
onOpen: (side: 'left' | 'right') => void
|
||||
}
|
||||
|
||||
function renderCellText(cell: SideCell): ReactNode {
|
||||
if (cell.segs) {
|
||||
return cell.segs.map((s, i) => (
|
||||
<span key={i} className={'seg seg-' + s.kind}>
|
||||
{s.text}
|
||||
</span>
|
||||
))
|
||||
}
|
||||
if (cell.text === null) {
|
||||
return <span className="plain-empty">​</span>
|
||||
}
|
||||
return <span className="plain">{cell.text}</span>
|
||||
}
|
||||
|
||||
function SidePanel({
|
||||
rows,
|
||||
side,
|
||||
meta,
|
||||
onOpen,
|
||||
scrollRef,
|
||||
onScroll,
|
||||
activeRowId
|
||||
}: {
|
||||
rows: DiffRow[]
|
||||
side: 'left' | 'right'
|
||||
meta: PaneMeta | null
|
||||
onOpen: (side: 'left' | 'right') => void
|
||||
scrollRef: RefObject<HTMLDivElement>
|
||||
onScroll: () => void
|
||||
activeRowId: string | null
|
||||
}): ReactElement {
|
||||
return (
|
||||
<div className={'pane pane-mid pane-' + side}>
|
||||
<div className="panel-head">
|
||||
<span className={'pane-dot ' + side} />
|
||||
<span className="pane-file">{meta ? meta.name : '(未选择)'}</span>
|
||||
{meta && <span className="pane-meta">{meta.encoding}</span>}
|
||||
<span className="pane-actions">
|
||||
<button className="btn" onClick={() => onOpen(side)}>
|
||||
{meta ? '重新选择' : '打开文件'}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div className="diff-scroll" ref={scrollRef} onScroll={onScroll}>
|
||||
<div className="diff-columns">
|
||||
{rows.map((row) => {
|
||||
const cell = side === 'left' ? row.left : row.right
|
||||
const cls =
|
||||
'diff-row ' +
|
||||
row.rowKind +
|
||||
(activeRowId === row.id ? ' active' : '') +
|
||||
(side === 'left' && row.rowKind === 'added' ? ' base' : '')
|
||||
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>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DiffView({
|
||||
diff,
|
||||
leftMeta,
|
||||
rightMeta,
|
||||
activeRowId,
|
||||
onOpen
|
||||
}: DiffViewProps): ReactElement {
|
||||
const leftRef = useRef<HTMLDivElement>(null)
|
||||
const rightRef = useRef<HTMLDivElement>(null)
|
||||
const syncing = useRef(false)
|
||||
|
||||
const makeScrollSync =
|
||||
(target: 'left' | 'right') =>
|
||||
(): void => {
|
||||
if (syncing.current) return
|
||||
syncing.current = true
|
||||
const src = target === 'left' ? leftRef.current : rightRef.current
|
||||
const dst = target === 'left' ? rightRef.current : leftRef.current
|
||||
if (src && dst) dst.scrollTop = src.scrollTop
|
||||
requestAnimationFrame(() => {
|
||||
syncing.current = false
|
||||
})
|
||||
}
|
||||
|
||||
// 差异导航定位:命中行后滚动到中间,并靠滚动联动自动同步右侧
|
||||
useEffect(() => {
|
||||
if (!activeRowId) return
|
||||
const el = leftRef.current?.querySelector<HTMLElement>(`[data-row="${activeRowId}"]`)
|
||||
if (el) el.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
}, [activeRowId])
|
||||
|
||||
return (
|
||||
<div className="diff-view">
|
||||
<div className="diff-main">
|
||||
<SidePanel
|
||||
rows={diff.rows}
|
||||
side="left"
|
||||
meta={leftMeta}
|
||||
onOpen={onOpen}
|
||||
scrollRef={leftRef}
|
||||
onScroll={makeScrollSync('left')}
|
||||
activeRowId={activeRowId}
|
||||
/>
|
||||
<SidePanel
|
||||
rows={diff.rows}
|
||||
side="right"
|
||||
meta={rightMeta}
|
||||
onOpen={onOpen}
|
||||
scrollRef={rightRef}
|
||||
onScroll={makeScrollSync('right')}
|
||||
activeRowId={activeRowId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { type ReactElement } from 'react'
|
||||
import { DiffSummary } from '../diff/diffEngine'
|
||||
|
||||
export interface DiffOptions {
|
||||
trimWhitespace: boolean
|
||||
ignoreCase: boolean
|
||||
}
|
||||
|
||||
interface ToolbarProps {
|
||||
options: DiffOptions
|
||||
onOptionsChange: (options: DiffOptions) => void
|
||||
summary: DiffSummary
|
||||
navIndex: number
|
||||
navCount: number
|
||||
onNav: (dir: 1 | -1) => void
|
||||
}
|
||||
|
||||
export default function Toolbar({
|
||||
options,
|
||||
onOptionsChange,
|
||||
summary,
|
||||
navIndex,
|
||||
navCount,
|
||||
onNav
|
||||
}: ToolbarProps): ReactElement {
|
||||
const set = (patch: Partial<DiffOptions>): void => onOptionsChange({ ...options, ...patch })
|
||||
|
||||
const canNav = navCount > 0
|
||||
|
||||
return (
|
||||
<div className="toolbar">
|
||||
<div className="tool-group">
|
||||
<span className="tool-label">比较选项</span>
|
||||
<label className="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={options.trimWhitespace}
|
||||
onChange={(e) => set({ trimWhitespace: e.target.checked })}
|
||||
/>
|
||||
忽略行首尾空白
|
||||
</label>
|
||||
<label className="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={options.ignoreCase}
|
||||
onChange={(e) => set({ ignoreCase: e.target.checked })}
|
||||
/>
|
||||
忽略大小写
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="tool-group">
|
||||
<button
|
||||
className="nav-btn"
|
||||
title="上一处差异"
|
||||
disabled={!canNav}
|
||||
onClick={() => onNav(-1)}
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
<span className="nav-count">
|
||||
{navCount > 0 ? `${navIndex + 1} / ${navCount}` : '0 / 0'}
|
||||
</span>
|
||||
<button
|
||||
className="nav-btn"
|
||||
title="下一处差异"
|
||||
disabled={!canNav}
|
||||
onClick={() => onNav(1)}
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="header-spacer" style={{ margin: 0 }} />
|
||||
|
||||
<div className="stats">
|
||||
<span className="badge add">
|
||||
<span className="dot" />+{summary.inserted}
|
||||
</span>
|
||||
<span className="badge del">
|
||||
<span className="dot" />−{summary.deleted}
|
||||
</span>
|
||||
<span className="badge mod">
|
||||
<span className="dot" />~{summary.modified}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { diffArrays, diffWordsWithSpace } from 'diff'
|
||||
|
||||
/** 词级分段类型 */
|
||||
export type SegKind = 'common' | 'insert' | 'delete'
|
||||
|
||||
export interface Seg {
|
||||
text: string
|
||||
kind: SegKind
|
||||
}
|
||||
|
||||
export interface SideCell {
|
||||
/** 原文行号(1 基),缺行时为 null(该侧为空槽) */
|
||||
lineNo: number | null
|
||||
/** 该行原始文本,无行时为 null */
|
||||
text: string | null
|
||||
/** 词级高亮分段,仅修改行填充 */
|
||||
segs: Seg[] | null
|
||||
}
|
||||
|
||||
export type RowKind = 'unchanged' | 'removed' | 'added' | 'modified'
|
||||
|
||||
export interface DiffRow {
|
||||
id: string
|
||||
rowKind: RowKind
|
||||
isChanged: boolean
|
||||
left: SideCell
|
||||
right: SideCell
|
||||
}
|
||||
|
||||
export interface DiffSummary {
|
||||
changedLines: number
|
||||
inserted: number
|
||||
deleted: number
|
||||
modified: number
|
||||
}
|
||||
|
||||
export interface DiffOptions {
|
||||
/** 忽略行首/行尾空白差异 */
|
||||
trimWhitespace?: boolean
|
||||
/** 忽略大小写 */
|
||||
ignoreCase?: boolean
|
||||
}
|
||||
|
||||
export interface DiffResult {
|
||||
rows: DiffRow[]
|
||||
summary: DiffSummary
|
||||
}
|
||||
|
||||
let rowSeq = 0
|
||||
|
||||
function emptyLine(): SideCell {
|
||||
return { lineNo: null, text: null, segs: null }
|
||||
}
|
||||
|
||||
/** 词级 diff:对同一对齐的左右两行求差异,得到左右两套高亮分段 */
|
||||
function wordSegments(leftText: string, rightText: string): { left: Seg[]; right: Seg[] } {
|
||||
const parts = diffWordsWithSpace(leftText, rightText)
|
||||
const left: Seg[] = []
|
||||
const right: Seg[] = []
|
||||
for (const part of parts) {
|
||||
if (part.added) {
|
||||
right.push({ text: part.value, kind: 'insert' })
|
||||
} else if (part.removed) {
|
||||
left.push({ text: part.value, kind: 'delete' })
|
||||
} else {
|
||||
left.push({ text: part.value, kind: 'common' })
|
||||
right.push({ text: part.value, kind: 'common' })
|
||||
}
|
||||
}
|
||||
return { left, right }
|
||||
}
|
||||
|
||||
/** 按行切分;空字符串返回空数组 */
|
||||
function splitLines(text: string): string[] {
|
||||
if (text === '') return []
|
||||
return text.split(/\r?\n/)
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算文本差异。
|
||||
* diffArrays 以“归一化行”做行级 LCS,得到增/删/同;
|
||||
* 删除段与新增段配对为“修改”行并做词级内联高亮。
|
||||
* 展示文本始终取原始行,选项只影响“是否判等”。
|
||||
*/
|
||||
export function computeDiff(
|
||||
leftText: string,
|
||||
rightText: string,
|
||||
options: DiffOptions = {}
|
||||
): DiffResult {
|
||||
rowSeq = 0
|
||||
const leftOrig = splitLines(leftText)
|
||||
const rightOrig = splitLines(rightText)
|
||||
|
||||
const norm = (s: string): string => {
|
||||
let v = options.trimWhitespace ? s.trim() : s
|
||||
if (options.ignoreCase) v = v.toLowerCase()
|
||||
return v
|
||||
}
|
||||
const leftNorm = leftOrig.map(norm)
|
||||
const rightNorm = rightOrig.map(norm)
|
||||
|
||||
const parts = diffArrays(leftNorm, rightNorm)
|
||||
const rows: DiffRow[] = []
|
||||
let leftPtr = 1 // 左侧下一个待消费行号(1 基)
|
||||
let rightPtr = 1
|
||||
|
||||
// 暂存的纯删除行(等待与随后新增配对)
|
||||
let queuedRemoved: { text: string; lineNo: number }[] = []
|
||||
|
||||
const flushRemoved = (): void => {
|
||||
for (const r of queuedRemoved) {
|
||||
rows.push({
|
||||
id: `r${rowSeq++}`,
|
||||
rowKind: 'removed',
|
||||
isChanged: true,
|
||||
left: { lineNo: r.lineNo, text: r.text, segs: null },
|
||||
right: emptyLine()
|
||||
})
|
||||
}
|
||||
queuedRemoved = []
|
||||
}
|
||||
|
||||
/** 删除段与新增段配对为修改行 */
|
||||
const commitModified = (addedRows: { text: string; lineNo: number }[]): void => {
|
||||
const count = Math.max(queuedRemoved.length, addedRows.length)
|
||||
for (let i = 0; i < count; i++) {
|
||||
const l = queuedRemoved[i]
|
||||
const r = addedRows[i]
|
||||
if (l && r) {
|
||||
const { left: lSegs, right: rSegs } = wordSegments(l.text, r.text)
|
||||
rows.push({
|
||||
id: `r${rowSeq++}`,
|
||||
rowKind: 'modified',
|
||||
isChanged: true,
|
||||
left: { lineNo: l.lineNo, text: l.text, segs: lSegs },
|
||||
right: { lineNo: r.lineNo, text: r.text, segs: rSegs }
|
||||
})
|
||||
} else if (l) {
|
||||
rows.push({
|
||||
id: `r${rowSeq++}`,
|
||||
rowKind: 'removed',
|
||||
isChanged: true,
|
||||
left: { lineNo: l.lineNo, text: l.text, segs: null },
|
||||
right: emptyLine()
|
||||
})
|
||||
} else if (r) {
|
||||
rows.push({
|
||||
id: `r${rowSeq++}`,
|
||||
rowKind: 'added',
|
||||
isChanged: true,
|
||||
left: emptyLine(),
|
||||
right: { lineNo: r.lineNo, text: r.text, segs: null }
|
||||
})
|
||||
}
|
||||
}
|
||||
queuedRemoved = []
|
||||
}
|
||||
|
||||
for (const part of parts) {
|
||||
const value = part.value as string[] // diffArrays 的 value 为行数组
|
||||
if (part.removed) {
|
||||
const len = value.length
|
||||
for (let i = 0; i < len; i++) {
|
||||
queuedRemoved.push({ text: leftOrig[leftPtr - 1 + i], lineNo: leftPtr + i })
|
||||
}
|
||||
leftPtr += len
|
||||
} else if (part.added) {
|
||||
const len = value.length
|
||||
const addedRows: { text: string; lineNo: number }[] = []
|
||||
for (let i = 0; i < len; i++) {
|
||||
addedRows.push({ text: rightOrig[rightPtr - 1 + i], lineNo: rightPtr + i })
|
||||
}
|
||||
rightPtr += len
|
||||
if (queuedRemoved.length > 0) {
|
||||
commitModified(addedRows)
|
||||
} else {
|
||||
for (const r of addedRows) {
|
||||
rows.push({
|
||||
id: `r${rowSeq++}`,
|
||||
rowKind: 'added',
|
||||
isChanged: true,
|
||||
left: emptyLine(),
|
||||
right: { lineNo: r.lineNo, text: r.text, segs: null }
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// common
|
||||
if (queuedRemoved.length > 0) flushRemoved()
|
||||
const len = value.length
|
||||
for (let i = 0; i < len; i++) {
|
||||
const lt = leftOrig[leftPtr - 1 + i]
|
||||
const rt = rightOrig[rightPtr - 1 + i]
|
||||
rows.push({
|
||||
id: `r${rowSeq++}`,
|
||||
rowKind: 'unchanged',
|
||||
isChanged: false,
|
||||
left: { lineNo: leftPtr + i, text: lt, segs: null },
|
||||
right: { lineNo: rightPtr + i, text: rt, segs: null }
|
||||
})
|
||||
}
|
||||
leftPtr += len
|
||||
rightPtr += len
|
||||
}
|
||||
}
|
||||
if (queuedRemoved.length > 0) flushRemoved()
|
||||
|
||||
const summary: DiffSummary = {
|
||||
changedLines: rows.filter((r) => r.isChanged).length,
|
||||
inserted: rows.filter((r) => r.rowKind === 'added').length,
|
||||
deleted: rows.filter((r) => r.rowKind === 'removed').length,
|
||||
modified: rows.filter((r) => r.rowKind === 'modified').length
|
||||
}
|
||||
|
||||
return { rows, summary }
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './styles/global.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,558 @@
|
||||
/* ============ DiffLens 全局主题(暗色科幻) ============ */
|
||||
:root {
|
||||
--bg: #0a0e17;
|
||||
--bg-deep: #070b12;
|
||||
--panel: #0e1420;
|
||||
--panel-2: #101828;
|
||||
--surface: rgba(148, 163, 255, 0.06);
|
||||
--surface-hover: rgba(124, 108, 255, 0.14);
|
||||
--border: rgba(148, 163, 255, 0.14);
|
||||
--border-strong: rgba(148, 163, 255, 0.32);
|
||||
|
||||
--text: #dbe3f4;
|
||||
--muted: #74809a;
|
||||
--accent: #8b7cff;
|
||||
--accent-2: #22d3ee;
|
||||
--pink: #f472b6;
|
||||
|
||||
/* diff 语义色 */
|
||||
--add-bg: rgba(34, 197, 94, 0.13);
|
||||
--add-fg: #7ee2a8;
|
||||
--add-inline: rgba(34, 197, 94, 0.30);
|
||||
--del-bg: rgba(248, 113, 113, 0.12);
|
||||
--del-fg: #f7a9a9;
|
||||
--del-inline: rgba(248, 113, 113, 0.30);
|
||||
--mod-bg: rgba(245, 158, 11, 0.11);
|
||||
--mod-fg: #f6bd87;
|
||||
--mod-inline-delete: rgba(248, 113, 113, 0.34);
|
||||
--mod-inline-insert: rgba(34, 197, 94, 0.34);
|
||||
|
||||
--line-height: 21px;
|
||||
--mono: 'Cascadia Mono', 'JetBrains Mono', Consolas, 'Menlo', monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', 'Microsoft YaHei', system-ui, sans-serif;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
background: radial-gradient(1200px 600px at 20% -10%, rgba(124, 108, 255, 0.16), transparent 60%),
|
||||
radial-gradient(1000px 500px at 90% 110%, rgba(34, 211, 238, 0.12), transparent 55%),
|
||||
var(--bg);
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(148, 163, 255, 0.18);
|
||||
border-radius: 6px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(148, 163, 255, 0.34);
|
||||
background-clip: content-box;
|
||||
}
|
||||
::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ============ 整体布局 ============ */
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 12px 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: linear-gradient(180deg, rgba(124, 108, 255, 0.10), rgba(10, 14, 23, 0));
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.logo-mark {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 9px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||||
box-shadow: 0 0 18px rgba(124, 108, 255, 0.55);
|
||||
flex: none;
|
||||
}
|
||||
.logo-title {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
background: linear-gradient(90deg, #c9bfff, var(--accent-2));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
.logo-sub {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
letter-spacing: 3px;
|
||||
}
|
||||
|
||||
.header-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ============ 面板头(左右栏位) ============ */
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 12px;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
min-height: 46px;
|
||||
}
|
||||
.pane-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex: none;
|
||||
}
|
||||
.pane-dot.left {
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 8px var(--accent);
|
||||
}
|
||||
.pane-dot.right {
|
||||
background: var(--accent-2);
|
||||
box-shadow: 0 0 8px var(--accent-2);
|
||||
}
|
||||
.pane-file {
|
||||
font-weight: 600;
|
||||
font-family: var(--mono);
|
||||
max-width: 40%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pane-meta {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.pane-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
transition: all 0.16s ease;
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
background: var(--surface-hover);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
.btn.primary {
|
||||
background: linear-gradient(135deg, var(--accent), #6a4dff);
|
||||
border-color: transparent;
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 16px rgba(124, 108, 255, 0.35);
|
||||
}
|
||||
.btn.primary:hover:not(:disabled) {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
.btn.ghost {
|
||||
background: transparent;
|
||||
}
|
||||
.btn.ghost:hover:not(:disabled) {
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
/* ============ 空面板 CTA ============ */
|
||||
.pane-empty {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px dashed var(--border-strong);
|
||||
border-radius: 12px;
|
||||
margin: 14px;
|
||||
background: var(--surface);
|
||||
transition: all 0.18s ease;
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
}
|
||||
.pane-empty.dragging {
|
||||
border-color: var(--accent);
|
||||
background: rgba(124, 108, 255, 0.08);
|
||||
box-shadow: inset 0 0 30px rgba(124, 108, 255, 0.15);
|
||||
}
|
||||
.pane-empty h3 {
|
||||
font-size: 15px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.pane-empty p {
|
||||
color: var(--muted);
|
||||
margin-bottom: 16px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.pane-drop-hint {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* ============ Diff 视图 ============ */
|
||||
.diff-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.diff-view {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.diff-empty-pane {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--muted);
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.pane-mid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: linear-gradient(180deg, rgba(10, 14, 23, 0.6), var(--bg-deep));
|
||||
}
|
||||
|
||||
.diff-scroll {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.diff-columns {
|
||||
min-width: max-content;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.diff-row {
|
||||
display: flex;
|
||||
height: var(--line-height);
|
||||
line-height: var(--line-height);
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
white-space: pre;
|
||||
width: 100%;
|
||||
}
|
||||
.diff-row.base {
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.ln {
|
||||
flex: 0 0 46px;
|
||||
text-align: right;
|
||||
padding-right: 8px;
|
||||
color: #4d5a74;
|
||||
background: rgba(124, 108, 255, 0.05);
|
||||
border-right: 1px solid var(--border);
|
||||
position: sticky;
|
||||
left: 0;
|
||||
user-select: none;
|
||||
}
|
||||
.diff-row.unchanged .ln {
|
||||
color: #46536e;
|
||||
}
|
||||
|
||||
.tx {
|
||||
flex: 1;
|
||||
padding: 0 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 行级别背景 */
|
||||
.diff-row.modified {
|
||||
background: var(--mod-bg);
|
||||
}
|
||||
.diff-row.modified .ln {
|
||||
color: var(--mod-fg);
|
||||
background: rgba(245, 158, 11, 0.16);
|
||||
border-right-color: rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
.diff-row.added {
|
||||
background: var(--add-bg);
|
||||
}
|
||||
.diff-row.added .ln {
|
||||
color: var(--add-fg);
|
||||
background: rgba(34, 197, 94, 0.14);
|
||||
border-right-color: rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
.diff-row.removed {
|
||||
background: var(--del-bg);
|
||||
}
|
||||
.diff-row.removed .ln {
|
||||
color: var(--del-fg);
|
||||
background: rgba(248, 113, 113, 0.13);
|
||||
border-right-color: rgba(248, 113, 113, 0.3);
|
||||
}
|
||||
|
||||
/* 焦点行(差异导航定位) */
|
||||
.diff-row.active {
|
||||
outline: 1px solid var(--accent-2);
|
||||
outline-offset: -1px;
|
||||
background: rgba(34, 211, 238, 0.10);
|
||||
}
|
||||
|
||||
/* 词级内联高亮 */
|
||||
.seg {
|
||||
border-radius: 2px;
|
||||
}
|
||||
.seg-insert {
|
||||
background: var(--mod-inline-insert);
|
||||
box-shadow: 0 0 8px rgba(34, 197, 94, 0.25);
|
||||
}
|
||||
.seg-delete {
|
||||
background: var(--mod-inline-delete);
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: rgba(248, 113, 113, 0.8);
|
||||
box-shadow: 0 0 8px rgba(248, 113, 113, 0.2);
|
||||
}
|
||||
|
||||
/* 空内容行占位显示 */
|
||||
.diff-row .tx .plain-empty {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
/* ============ 工具栏 ============ */
|
||||
.toolbar {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 8px 18px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: rgba(10, 14, 23, 0.7);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tool-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tool-label {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
/* 选项开关(checkbox) */
|
||||
.switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
user-select: none;
|
||||
}
|
||||
.switch input {
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.switch input:checked {
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||||
border-color: transparent;
|
||||
}
|
||||
.switch input:checked::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 1px;
|
||||
width: 4px;
|
||||
height: 8px;
|
||||
border: solid #fff;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
/* 导航按钮 */
|
||||
.nav-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
font-size: 15px;
|
||||
color: var(--text);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.nav-btn:hover:not(:disabled) {
|
||||
background: var(--surface-hover);
|
||||
border-color: var(--border-strong);
|
||||
box-shadow: 0 0 12px rgba(124, 108, 255, 0.3);
|
||||
}
|
||||
.nav-count {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 统计徽章 */
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.badge .dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.badge.add {
|
||||
color: var(--add-fg);
|
||||
background: var(--add-bg);
|
||||
}
|
||||
.badge.add .dot {
|
||||
background: var(--add-fg);
|
||||
box-shadow: 0 0 8px var(--add-fg);
|
||||
}
|
||||
.badge.del {
|
||||
color: var(--del-fg);
|
||||
background: var(--del-bg);
|
||||
}
|
||||
.badge.del .dot {
|
||||
background: var(--del-fg);
|
||||
box-shadow: 0 0 8px var(--del-fg);
|
||||
}
|
||||
.badge.mod {
|
||||
color: var(--mod-fg);
|
||||
background: var(--mod-bg);
|
||||
}
|
||||
.badge.mod .dot {
|
||||
background: var(--mod-fg);
|
||||
box-shadow: 0 0 8px var(--mod-fg);
|
||||
}
|
||||
|
||||
/* ============ 状态栏 ============ */
|
||||
.status-bar {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 4px 18px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
border-top: 1px solid rgba(148, 163, 255, 0.08);
|
||||
}
|
||||
.status-left {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
}
|
||||
.status-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.status-item b {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.status-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.status-right {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.eq-badge {
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.eq-badge.same {
|
||||
color: var(--add-fg);
|
||||
background: var(--add-bg);
|
||||
}
|
||||
.eq-badge.diff {
|
||||
color: var(--muted);
|
||||
background: var(--surface);
|
||||
}
|
||||
Reference in New Issue
Block a user