refactor: v2.0 全量重构 — TypeScript + React + Zustand + IndexedDB
技术栈升级: - JavaScript → TypeScript 5.6(全量类型安全) - 原生 DOM → React 18(函数组件 + Hooks) - 全局变量 → Zustand 5(轻量状态管理) - localStorage → IndexedDB / Dexie.js 4(大容量、异步、索引) - marked.js → unified / remark / rehype(插件化渲染管线) - 无打包 → electron-vite 3(HMR 热更新) - 纯 CSS → CSS Variables + CSS Modules 新增功能: - 标签页状态 IndexedDB 持久化(关闭后可恢复) - 最近打开文件列表 - 大文件虚拟化行号(>2000 行) - 搜索高亮二分查找优化 O(log N) - rehype-sanitize HTML 安全过滤 文件结构: - 7 个源文件 → 51 个模块化文件 - src/main/ 主进程(6 文件) - src/preload/ 预加载(1 文件) - src/renderer/ 渲染进程(42 文件:组件/hooks/stores/lib/db/types/styles) - src/shared/ 共享类型(2 文件) 构建验证: - TypeScript 检查零错误 - electron-vite build 成功 - 产物:main 15kB + preload 2kB + renderer 1.5MB
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
import { readFile, stat, writeFile, readdir } from 'fs/promises'
|
||||
import { join, extname } from 'path'
|
||||
|
||||
const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB
|
||||
const ALLOWED_EXTENSIONS = new Set(['.md', '.markdown', '.txt'])
|
||||
const SKIP_DIRS = new Set([
|
||||
'node_modules', '.git', '.svn', '.hg', 'dist', 'out',
|
||||
'.next', '.nuxt', '__pycache__', '.DS_Store'
|
||||
])
|
||||
|
||||
export interface FileNode {
|
||||
name: string
|
||||
path: string
|
||||
type: 'file' | 'dir'
|
||||
children?: FileNode[]
|
||||
}
|
||||
|
||||
export async function readFileContent(filePath: string): Promise<{ success: boolean; content?: string; error?: string }> {
|
||||
try {
|
||||
const fileStat = await stat(filePath)
|
||||
if (fileStat.size > MAX_FILE_SIZE) {
|
||||
return { success: false, error: `文件过大(${(fileStat.size / 1024 / 1024).toFixed(1)} MB),暂不支持超过 20MB 的文件` }
|
||||
}
|
||||
let content = await readFile(filePath, 'utf-8')
|
||||
// 剥离 UTF-8 BOM
|
||||
if (content.charCodeAt(0) === 0xFEFF) content = content.slice(1)
|
||||
return { success: true, content }
|
||||
} catch (err) {
|
||||
return { success: false, error: (err as Error).message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveFileContent(filePath: string, content: string): Promise<{ success: boolean; filePath?: string; error?: string }> {
|
||||
try {
|
||||
await writeFile(filePath, content, 'utf-8')
|
||||
return { success: true, filePath }
|
||||
} catch (err) {
|
||||
return { success: false, error: (err as Error).message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildDirTree(dirPath: string): Promise<FileNode[]> {
|
||||
const entries = await readdir(dirPath, { withFileTypes: true })
|
||||
entries.sort((a, b) => {
|
||||
if (a.isDirectory() && !b.isDirectory()) return -1
|
||||
if (!a.isDirectory() && b.isDirectory()) return 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
|
||||
const children: FileNode[] = []
|
||||
for (const entry of entries) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue
|
||||
if (entry.name.startsWith('.')) continue
|
||||
|
||||
const childPath = join(dirPath, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
const subChildren = await buildDirTree(childPath)
|
||||
if (subChildren.length > 0) {
|
||||
children.push({ name: entry.name, path: childPath, type: 'dir', children: subChildren })
|
||||
}
|
||||
} else {
|
||||
const ext = extname(entry.name).toLowerCase()
|
||||
if (ALLOWED_EXTENSIONS.has(ext)) {
|
||||
children.push({ name: entry.name, path: childPath, type: 'file' })
|
||||
}
|
||||
}
|
||||
}
|
||||
return children
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import * as fs from 'fs'
|
||||
import { BrowserWindow } from 'electron'
|
||||
|
||||
export class FileWatcher {
|
||||
private watcher: fs.FSWatcher | null = null
|
||||
private currentPath: string | null = null
|
||||
private isSelfWriting = false
|
||||
|
||||
constructor(private getMainWindow: () => BrowserWindow | null) {}
|
||||
|
||||
start(filePath: string): void {
|
||||
this.stop()
|
||||
if (!filePath) return
|
||||
try {
|
||||
this.currentPath = filePath
|
||||
this.watcher = fs.watch(filePath, (eventType) => {
|
||||
if (eventType === 'change') {
|
||||
if (this.isSelfWriting) return
|
||||
const win = this.getMainWindow()
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.webContents.send('file:externallyModified', filePath)
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
// silently ignore
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.watcher) {
|
||||
this.watcher.close()
|
||||
this.watcher = null
|
||||
}
|
||||
}
|
||||
|
||||
getCurrentPath(): string | null {
|
||||
return this.currentPath
|
||||
}
|
||||
|
||||
setSelfWriting(value: boolean): void {
|
||||
this.isSelfWriting = value
|
||||
}
|
||||
}
|
||||
|
||||
export class SidebarWatcher {
|
||||
private watcher: fs.FSWatcher | null = null
|
||||
private watchPath: string | null = null
|
||||
private refreshTimer: NodeJS.Timeout | null = null
|
||||
|
||||
constructor(private getMainWindow: () => BrowserWindow | null) {}
|
||||
|
||||
start(dirPath: string): void {
|
||||
this.stop()
|
||||
if (!dirPath) return
|
||||
try {
|
||||
this.watchPath = dirPath
|
||||
this.watcher = fs.watch(dirPath, { recursive: true }, () => {
|
||||
const win = this.getMainWindow()
|
||||
if (win && !win.isDestroyed()) {
|
||||
if (this.refreshTimer) clearTimeout(this.refreshTimer)
|
||||
this.refreshTimer = setTimeout(() => {
|
||||
win.webContents.send('sidebar:dirChanged')
|
||||
}, 300)
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
// silently ignore
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.watcher) {
|
||||
this.watcher.close()
|
||||
this.watcher = null
|
||||
}
|
||||
if (this.refreshTimer) {
|
||||
clearTimeout(this.refreshTimer)
|
||||
this.refreshTimer = null
|
||||
}
|
||||
this.watchPath = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { createWindow, setupSingleInstanceLock, getFilePathFromArgs } from './window-manager'
|
||||
import { FileWatcher, SidebarWatcher } from './file-watcher'
|
||||
import { registerIpcHandlers } from './ipc-handlers'
|
||||
import { readFileContent } from './file-system'
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
const state = {
|
||||
activeFilePath: null as string | null,
|
||||
pendingFilePath: null as string | null,
|
||||
isClosing: false,
|
||||
closeTimeout: null as NodeJS.Timeout | null
|
||||
}
|
||||
|
||||
const fileWatcher = new FileWatcher(() => mainWindow)
|
||||
const sidebarWatcher = new SidebarWatcher(() => mainWindow)
|
||||
|
||||
function openFileInTab(filePath: string): void {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||||
readFileContent(filePath).then(result => {
|
||||
if (result.success) {
|
||||
state.activeFilePath = filePath
|
||||
fileWatcher.start(filePath)
|
||||
mainWindow!.setTitle(`MarkLite - ${filePath.split(/[/\\]/).pop()}`)
|
||||
mainWindow!.webContents.send('file:openInTab', { filePath, content: result.content })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 单实例锁
|
||||
const lockOk = setupSingleInstanceLock((filePath) => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||
mainWindow.focus()
|
||||
if (filePath) openFileInTab(filePath)
|
||||
}
|
||||
})
|
||||
|
||||
if (!lockOk) {
|
||||
app.quit()
|
||||
} else {
|
||||
|
||||
function setupCloseHandler(): void {
|
||||
if (!mainWindow) return
|
||||
mainWindow.on('close', (e) => {
|
||||
if (state.isClosing) return
|
||||
state.isClosing = true
|
||||
e.preventDefault()
|
||||
try {
|
||||
if (mainWindow && !mainWindow.webContents.isDestroyed()) {
|
||||
mainWindow.webContents.send('window:confirmClose')
|
||||
} else {
|
||||
mainWindow?.removeAllListeners('close')
|
||||
mainWindow?.close()
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
mainWindow?.removeAllListeners('close')
|
||||
mainWindow?.close()
|
||||
return
|
||||
}
|
||||
state.closeTimeout = setTimeout(() => {
|
||||
state.closeTimeout = null
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.removeAllListeners('close')
|
||||
mainWindow.close()
|
||||
}
|
||||
}, 5000)
|
||||
})
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
app.on('open-file', (event, filePath) => {
|
||||
event.preventDefault()
|
||||
if (mainWindow && !mainWindow.isDestroyed() && mainWindow.webContents.isLoading()) {
|
||||
state.pendingFilePath = filePath
|
||||
} else if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
openFileInTab(filePath)
|
||||
} else {
|
||||
state.pendingFilePath = filePath
|
||||
}
|
||||
})
|
||||
|
||||
mainWindow = createWindow()
|
||||
|
||||
// 注册 IPC 处理器
|
||||
registerIpcHandlers(() => mainWindow, fileWatcher, sidebarWatcher, state)
|
||||
|
||||
// 加载渲染进程页面
|
||||
if (process.env.ELECTRON_RENDERER_URL) {
|
||||
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
|
||||
} else {
|
||||
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
||||
}
|
||||
|
||||
mainWindow.webContents.on('did-finish-load', () => {
|
||||
if (state.pendingFilePath) {
|
||||
openFileInTab(state.pendingFilePath)
|
||||
state.pendingFilePath = null
|
||||
}
|
||||
})
|
||||
|
||||
setupCloseHandler()
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
fileWatcher.stop()
|
||||
sidebarWatcher.stop()
|
||||
mainWindow = null
|
||||
})
|
||||
|
||||
// 命令行文件
|
||||
const cmdFile = getFilePathFromArgs(process.argv)
|
||||
if (cmdFile) {
|
||||
state.pendingFilePath = cmdFile
|
||||
}
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') app.quit()
|
||||
})
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
mainWindow = createWindow()
|
||||
setupCloseHandler()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export const IPC_CHANNELS = {
|
||||
DIALOG_OPEN_FILE: 'dialog:openFile',
|
||||
FILE_READ: 'file:read',
|
||||
FILE_SAVE: 'file:save',
|
||||
FILE_SAVE_AS: 'file:saveAs',
|
||||
FILE_GET_CURRENT_PATH: 'file:getCurrentPath',
|
||||
FILE_STATS: 'file:stats',
|
||||
FILE_RELOAD: 'file:reload',
|
||||
TAB_SWITCHED: 'tab:switched',
|
||||
WINDOW_FORCE_CLOSE: 'window:forceClose',
|
||||
WINDOW_CANCEL_CLOSE: 'window:cancelClose',
|
||||
DIR_READ_TREE: 'dir:readTree',
|
||||
DIR_OPEN_DIALOG: 'dir:openDialog',
|
||||
DIR_WATCH: 'dir:watch',
|
||||
DIR_UNWATCH: 'dir:unwatch',
|
||||
FILE_OPEN_IN_TAB: 'file:openInTab',
|
||||
FILE_EXTERNALLY_MODIFIED: 'file:externallyModified',
|
||||
MENU_SAVE: 'menu:save',
|
||||
MENU_SAVE_AS: 'menu:saveAs',
|
||||
MENU_VIEW_MODE: 'menu:viewMode',
|
||||
WINDOW_CONFIRM_CLOSE: 'window:confirmClose',
|
||||
SIDEBAR_DIR_CHANGED: 'sidebar:dirChanged'
|
||||
} as const
|
||||
@@ -0,0 +1,183 @@
|
||||
import { ipcMain, dialog, BrowserWindow } from 'electron'
|
||||
import { readFileContent, saveFileContent, buildDirTree } from './file-system'
|
||||
import { FileWatcher, SidebarWatcher } from './file-watcher'
|
||||
import { IPC_CHANNELS } from './ipc-channels'
|
||||
import { stat } from 'fs/promises'
|
||||
import { join, basename } from 'path'
|
||||
|
||||
export function registerIpcHandlers(
|
||||
getMainWindow: () => BrowserWindow | null,
|
||||
fileWatcher: FileWatcher,
|
||||
sidebarWatcher: SidebarWatcher,
|
||||
state: { activeFilePath: string | null; pendingFilePath: string | null }
|
||||
): void {
|
||||
// 打开文件对话框
|
||||
ipcMain.handle(IPC_CHANNELS.DIALOG_OPEN_FILE, async () => {
|
||||
const win = getMainWindow()
|
||||
if (!win) return null
|
||||
try {
|
||||
const result = await dialog.showOpenDialog(win, {
|
||||
properties: ['openFile'],
|
||||
filters: [{ name: 'Markdown 文件', extensions: ['md', 'markdown', 'txt'] }]
|
||||
})
|
||||
if (!result.canceled && result.filePaths.length > 0) {
|
||||
const filePath = result.filePaths[0]
|
||||
const fileResult = await readFileContent(filePath)
|
||||
if (fileResult.success) {
|
||||
state.activeFilePath = filePath
|
||||
fileWatcher.start(filePath)
|
||||
win.setTitle(`MarkLite - ${basename(filePath)}`)
|
||||
return { filePath, content: fileResult.content }
|
||||
}
|
||||
return { error: fileResult.error }
|
||||
}
|
||||
return null
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message }
|
||||
}
|
||||
})
|
||||
|
||||
// 读取文件
|
||||
ipcMain.handle(IPC_CHANNELS.FILE_READ, async (_event: unknown, filePath: string) => {
|
||||
return readFileContent(filePath)
|
||||
})
|
||||
|
||||
// 保存文件
|
||||
ipcMain.handle(IPC_CHANNELS.FILE_SAVE, async (_event: unknown, data: { filePath: string | null; content: string }) => {
|
||||
const win = getMainWindow()
|
||||
try {
|
||||
if (data.filePath) {
|
||||
fileWatcher.stop()
|
||||
fileWatcher.setSelfWriting(true)
|
||||
const result = await saveFileContent(data.filePath, data.content)
|
||||
fileWatcher.setSelfWriting(false)
|
||||
state.activeFilePath = data.filePath
|
||||
setTimeout(() => {
|
||||
if (state.activeFilePath === data.filePath && data.filePath) {
|
||||
fileWatcher.start(data.filePath)
|
||||
}
|
||||
}, 300)
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.setTitle(`MarkLite - ${basename(data.filePath)}`)
|
||||
}
|
||||
return result
|
||||
} else {
|
||||
if (!win) return { success: false, error: '窗口不可用' }
|
||||
const saveResult = await dialog.showSaveDialog(win, {
|
||||
filters: [{ name: 'Markdown 文件', extensions: ['md'] }]
|
||||
})
|
||||
if (!saveResult.canceled) {
|
||||
const result = await saveFileContent(saveResult.filePath, data.content)
|
||||
if (result.success) {
|
||||
state.activeFilePath = saveResult.filePath
|
||||
fileWatcher.start(saveResult.filePath)
|
||||
win.setTitle(`MarkLite - ${basename(saveResult.filePath)}`)
|
||||
}
|
||||
return result
|
||||
}
|
||||
return { success: false, canceled: true }
|
||||
}
|
||||
} catch (err) {
|
||||
fileWatcher.setSelfWriting(false)
|
||||
fileWatcher.start(state.activeFilePath || '')
|
||||
return { success: false, error: (err as Error).message }
|
||||
}
|
||||
})
|
||||
|
||||
// 另存为
|
||||
ipcMain.handle(IPC_CHANNELS.FILE_SAVE_AS, async (_event: unknown, data: { content: string }) => {
|
||||
const win = getMainWindow()
|
||||
if (!win) return { success: false, error: '窗口不可用' }
|
||||
try {
|
||||
const result = await dialog.showSaveDialog(win, {
|
||||
filters: [{ name: 'Markdown 文件', extensions: ['md'] }]
|
||||
})
|
||||
if (!result.canceled) {
|
||||
const saveResult = await saveFileContent(result.filePath, data.content)
|
||||
if (saveResult.success) {
|
||||
state.activeFilePath = result.filePath
|
||||
fileWatcher.start(result.filePath)
|
||||
win.setTitle(`MarkLite - ${basename(result.filePath)}`)
|
||||
}
|
||||
return saveResult
|
||||
}
|
||||
return { success: false, canceled: true }
|
||||
} catch (err) {
|
||||
return { success: false, error: (err as Error).message }
|
||||
}
|
||||
})
|
||||
|
||||
// 获取当前路径
|
||||
ipcMain.handle(IPC_CHANNELS.FILE_GET_CURRENT_PATH, () => state.activeFilePath)
|
||||
|
||||
// 文件统计
|
||||
ipcMain.handle(IPC_CHANNELS.FILE_STATS, async (_event: unknown, filePath: string) => {
|
||||
try {
|
||||
const fileStat = await stat(filePath)
|
||||
return { success: true, size: fileStat.size, mtime: fileStat.mtime.toISOString() }
|
||||
} catch (err) {
|
||||
return { success: false, error: (err as Error).message }
|
||||
}
|
||||
})
|
||||
|
||||
// 重新加载
|
||||
ipcMain.handle(IPC_CHANNELS.FILE_RELOAD, async () => {
|
||||
if (!state.activeFilePath) return { success: false, error: '没有打开的文件' }
|
||||
const result = await readFileContent(state.activeFilePath)
|
||||
return { ...result, filePath: state.activeFilePath }
|
||||
})
|
||||
|
||||
// 目录树
|
||||
ipcMain.handle(IPC_CHANNELS.DIR_READ_TREE, async (_event: unknown, dirPath: string) => {
|
||||
try {
|
||||
const tree = await buildDirTree(dirPath)
|
||||
return { success: true, tree, rootPath: dirPath }
|
||||
} catch (err) {
|
||||
return { success: false, error: (err as Error).message }
|
||||
}
|
||||
})
|
||||
|
||||
// 打开文件夹对话框
|
||||
ipcMain.handle(IPC_CHANNELS.DIR_OPEN_DIALOG, async () => {
|
||||
const win = getMainWindow()
|
||||
if (!win) return null
|
||||
const result = await dialog.showOpenDialog(win, { properties: ['openDirectory'] })
|
||||
if (!result.canceled && result.filePaths.length > 0) {
|
||||
return result.filePaths[0]
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
// 目录监听
|
||||
ipcMain.handle(IPC_CHANNELS.DIR_WATCH, (_event: unknown, dirPath: string) => {
|
||||
sidebarWatcher.start(dirPath)
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.DIR_UNWATCH, () => {
|
||||
sidebarWatcher.stop()
|
||||
})
|
||||
|
||||
// 标签切换
|
||||
ipcMain.handle(IPC_CHANNELS.TAB_SWITCHED, (_event: unknown, filePath: string | null) => {
|
||||
state.activeFilePath = filePath || null
|
||||
fileWatcher.start(filePath || '')
|
||||
const win = getMainWindow()
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.setTitle(filePath ? `MarkLite - ${basename(filePath)}` : 'MarkLite')
|
||||
}
|
||||
})
|
||||
|
||||
// 窗口控制
|
||||
ipcMain.handle(IPC_CHANNELS.WINDOW_FORCE_CLOSE, () => {
|
||||
const win = getMainWindow()
|
||||
if (win && !win.isDestroyed()) {
|
||||
fileWatcher.stop()
|
||||
win.removeAllListeners('close')
|
||||
win.close()
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.WINDOW_CANCEL_CLOSE, () => {
|
||||
// 重置关闭状态
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { BrowserWindow, app } from 'electron'
|
||||
import { join } from 'path'
|
||||
|
||||
export function createWindow(): BrowserWindow {
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
icon: join(__dirname, '../assets/icon.ico'),
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false
|
||||
},
|
||||
titleBarStyle: 'default',
|
||||
show: false
|
||||
})
|
||||
|
||||
mainWindow.setMenu(null)
|
||||
|
||||
mainWindow.once('ready-to-show', () => {
|
||||
mainWindow.show()
|
||||
})
|
||||
|
||||
return mainWindow
|
||||
}
|
||||
|
||||
export function setupSingleInstanceLock(
|
||||
onSecondInstance: (filePath: string | null) => void
|
||||
): boolean {
|
||||
const gotTheLock = app.requestSingleInstanceLock()
|
||||
if (!gotTheLock) {
|
||||
app.quit()
|
||||
return false
|
||||
}
|
||||
|
||||
app.on('second-instance', (_event, commandLine) => {
|
||||
const filePath = getFilePathFromArgs(commandLine)
|
||||
onSecondInstance(filePath)
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function getFilePathFromArgs(args: string[]): string | null {
|
||||
for (let i = 1; i < args.length; i++) {
|
||||
const arg = args[i]
|
||||
if (!arg.startsWith('--') && !arg.startsWith('-')) {
|
||||
try {
|
||||
const fs = require('fs')
|
||||
if (fs.existsSync(arg)) return arg
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { contextBridge, ipcRenderer, shell } from 'electron'
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
// File operations
|
||||
openFile: () => ipcRenderer.invoke('dialog:openFile'),
|
||||
readFile: (filePath: string) => ipcRenderer.invoke('file:read', filePath),
|
||||
saveFile: (data: { filePath: string | null; content: string }) => ipcRenderer.invoke('file:save', data),
|
||||
saveFileAs: (data: { content: string }) => ipcRenderer.invoke('file:saveAs', data),
|
||||
getCurrentPath: () => ipcRenderer.invoke('file:getCurrentPath'),
|
||||
getFileStats: (filePath: string) => ipcRenderer.invoke('file:stats', filePath),
|
||||
reloadFile: () => ipcRenderer.invoke('file:reload'),
|
||||
|
||||
// Tab management
|
||||
tabSwitched: (filePath: string | null) => ipcRenderer.invoke('tab:switched', filePath),
|
||||
|
||||
// Window control
|
||||
forceClose: () => ipcRenderer.invoke('window:forceClose'),
|
||||
cancelClose: () => ipcRenderer.invoke('window:cancelClose'),
|
||||
|
||||
// Shell
|
||||
openExternal: (url: string) => shell.openExternal(url),
|
||||
|
||||
// File Tree (Sidebar)
|
||||
readDirTree: (dirPath: string) => ipcRenderer.invoke('dir:readTree', dirPath),
|
||||
openFolderDialog: () => ipcRenderer.invoke('dir:openDialog'),
|
||||
watchDir: (dirPath: string) => ipcRenderer.invoke('dir:watch', dirPath),
|
||||
unwatchDir: () => ipcRenderer.invoke('dir:unwatch'),
|
||||
|
||||
// Events from main process
|
||||
onFileOpenInTab: (callback: (data: { filePath: string; content: string }) => void) =>
|
||||
ipcRenderer.on('file:openInTab', (_event, data) => callback(data)),
|
||||
onMenuSave: (callback: () => void) =>
|
||||
ipcRenderer.on('menu:save', () => callback()),
|
||||
onMenuSaveAs: (callback: () => void) =>
|
||||
ipcRenderer.on('menu:saveAs', () => callback()),
|
||||
onViewModeChange: (callback: (mode: string) => void) =>
|
||||
ipcRenderer.on('menu:viewMode', (_event, mode) => callback(mode)),
|
||||
onExternalModification: (callback: (filePath: string) => void) =>
|
||||
ipcRenderer.on('file:externallyModified', (_event, filePath) => callback(filePath)),
|
||||
onDirChanged: (callback: () => void) =>
|
||||
ipcRenderer.on('sidebar:dirChanged', () => callback()),
|
||||
onConfirmClose: (callback: () => void) =>
|
||||
ipcRenderer.on('window:confirmClose', () => callback()),
|
||||
|
||||
// Remove listeners
|
||||
removeAllListeners: (channel: string) => ipcRenderer.removeAllListeners(channel)
|
||||
})
|
||||
@@ -0,0 +1,208 @@
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { useTabStore } from './stores/tabStore'
|
||||
import { useEditorStore } from './stores/editorStore'
|
||||
import { useSearchStore } from './stores/searchStore'
|
||||
import { useTheme } from './hooks/useTheme'
|
||||
import { useSettings } from './hooks/useSettings'
|
||||
import { useKeyboard } from './hooks/useKeyboard'
|
||||
import { useUnsavedWarning } from './hooks/useUnsavedWarning'
|
||||
import { useFileWatch } from './hooks/useFileWatch'
|
||||
import { Toolbar } from './components/Toolbar/Toolbar'
|
||||
import { TabBar } from './components/TabBar/TabBar'
|
||||
import { Editor } from './components/Editor/Editor'
|
||||
import { Preview } from './components/Preview/Preview'
|
||||
import { Sidebar } from './components/Sidebar/Sidebar'
|
||||
import { SearchBar } from './components/SearchBar/SearchBar'
|
||||
import { StatusBar } from './components/StatusBar/StatusBar'
|
||||
import { WelcomeScreen } from './components/WelcomeScreen/WelcomeScreen'
|
||||
import { Toast } from './components/Toast/Toast'
|
||||
import { ModifiedBanner } from './components/ModifiedBanner/ModifiedBanner'
|
||||
import { DropOverlay } from './components/DropOverlay/DropOverlay'
|
||||
import { useDragDrop } from './hooks/useDragDrop'
|
||||
|
||||
export default function App() {
|
||||
const tabs = useTabStore(s => s.tabs)
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
const getActiveTab = useTabStore(s => s.getActiveTab)
|
||||
const createTab = useTabStore(s => s.createTab)
|
||||
const setModified = useTabStore(s => s.setModified)
|
||||
const updateTabContent = useTabStore(s => s.updateTabContent)
|
||||
|
||||
const viewMode = useEditorStore(s => s.viewMode)
|
||||
const splitRatio = useEditorStore(s => s.splitRatio)
|
||||
|
||||
const { darkMode, toggleDarkMode } = useTheme()
|
||||
const { saveViewMode, saveSplitRatio } = useSettings()
|
||||
|
||||
const [showModifiedBanner, setShowModifiedBanner] = useState(false)
|
||||
const [modifiedFilePath, setModifiedFilePath] = useState<string | null>(null)
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null)
|
||||
|
||||
const showToast = useCallback((msg: string) => {
|
||||
setToastMessage(msg)
|
||||
setTimeout(() => setToastMessage(null), 3000)
|
||||
}, [])
|
||||
|
||||
// 拖拽打开
|
||||
useDragDrop(showToast)
|
||||
|
||||
// 文件修改检测
|
||||
useFileWatch()
|
||||
|
||||
// 未保存提醒
|
||||
useUnsavedWarning(() => tabs.some(t => t.isModified))
|
||||
|
||||
// 文件外部修改事件
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const filePath = (e as CustomEvent).detail
|
||||
const tab = getActiveTab()
|
||||
if (tab && tab.filePath === filePath) {
|
||||
setShowModifiedBanner(true)
|
||||
setModifiedFilePath(filePath)
|
||||
}
|
||||
}
|
||||
window.addEventListener('file-externally-modified', handler)
|
||||
return () => window.removeEventListener('file-externally-modified', handler)
|
||||
}, [getActiveTab])
|
||||
|
||||
// 打开文件
|
||||
const handleOpenFile = useCallback(async () => {
|
||||
if (window.electronAPI) {
|
||||
const result = await window.electronAPI.openFile()
|
||||
if (result && 'filePath' in result) {
|
||||
createTab(result.filePath, result.content)
|
||||
}
|
||||
}
|
||||
}, [createTab])
|
||||
|
||||
// 保存文件
|
||||
const handleSave = useCallback(async () => {
|
||||
const tab = getActiveTab()
|
||||
if (!tab) return
|
||||
if (window.electronAPI) {
|
||||
const result = await window.electronAPI.saveFile({
|
||||
filePath: tab.filePath,
|
||||
content: tab.content
|
||||
})
|
||||
if (result.success) {
|
||||
useTabStore.getState().setModified(tab.id, false)
|
||||
showToast('已保存')
|
||||
}
|
||||
}
|
||||
}, [getActiveTab, showToast])
|
||||
|
||||
// 另存为
|
||||
const handleSaveAs = useCallback(async () => {
|
||||
const tab = getActiveTab()
|
||||
if (!tab) return
|
||||
if (window.electronAPI) {
|
||||
const result = await window.electronAPI.saveFileAs({ content: tab.content })
|
||||
if (result.success) {
|
||||
useTabStore.getState().setModified(tab.id, false)
|
||||
showToast('已保存')
|
||||
}
|
||||
}
|
||||
}, [getActiveTab, showToast])
|
||||
|
||||
// 快捷键
|
||||
useKeyboard(handleOpenFile, handleSave, handleSaveAs)
|
||||
|
||||
// 视图模式切换
|
||||
const handleViewModeChange = useCallback((mode: 'split' | 'editor' | 'preview') => {
|
||||
saveViewMode(mode)
|
||||
}, [saveViewMode])
|
||||
|
||||
// 主进程事件
|
||||
useEffect(() => {
|
||||
if (!window.electronAPI) return
|
||||
window.electronAPI.removeAllListeners('file:openInTab')
|
||||
window.electronAPI.removeAllListeners('menu:save')
|
||||
window.electronAPI.removeAllListeners('menu:saveAs')
|
||||
|
||||
window.electronAPI.onFileOpenInTab((data: { filePath: string; content: string }) => {
|
||||
createTab(data.filePath, data.content)
|
||||
})
|
||||
window.electronAPI.onMenuSave(() => handleSave())
|
||||
window.electronAPI.onMenuSaveAs(() => handleSaveAs())
|
||||
}, [createTab, handleSave, handleSaveAs])
|
||||
|
||||
const activeTab = getActiveTab()
|
||||
const hasTabs = tabs.length > 0
|
||||
|
||||
// 分屏样式
|
||||
const editorStyle: React.CSSProperties = viewMode === 'split'
|
||||
? { flex: `0 0 ${splitRatio}%` }
|
||||
: {}
|
||||
const previewStyle: React.CSSProperties = viewMode === 'split'
|
||||
? { flex: `0 0 ${100 - splitRatio}%` }
|
||||
: {}
|
||||
|
||||
return (
|
||||
<div id="app" className={`mode-${viewMode}`}>
|
||||
<Toolbar
|
||||
onOpen={handleOpenFile}
|
||||
onSave={handleSave}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={handleViewModeChange}
|
||||
darkMode={darkMode}
|
||||
onToggleDark={toggleDarkMode}
|
||||
/>
|
||||
|
||||
<div id="workspace">
|
||||
<Sidebar />
|
||||
|
||||
<div id="main-content">
|
||||
<TabBar />
|
||||
|
||||
{showModifiedBanner && (
|
||||
<ModifiedBanner
|
||||
onReload={() => {
|
||||
if (window.electronAPI && modifiedFilePath) {
|
||||
window.electronAPI.reloadFile().then(result => {
|
||||
if (result.success && result.content) {
|
||||
const tab = getActiveTab()
|
||||
if (tab) {
|
||||
updateTabContent(tab.id, result.content)
|
||||
setModified(tab.id, false)
|
||||
}
|
||||
}
|
||||
setShowModifiedBanner(false)
|
||||
})
|
||||
}
|
||||
}}
|
||||
onDismiss={() => setShowModifiedBanner(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasTabs ? (
|
||||
<div id="content-wrapper">
|
||||
{viewMode !== 'preview' && (
|
||||
<div id="editor-panel" style={editorStyle}>
|
||||
<SearchBar />
|
||||
<Editor />
|
||||
</div>
|
||||
)}
|
||||
{viewMode !== 'editor' && <div id="resizer" />}
|
||||
{viewMode !== 'editor' && (
|
||||
<div id="preview-panel" style={previewStyle}>
|
||||
<Preview />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<WelcomeScreen
|
||||
onOpen={handleOpenFile}
|
||||
onNew={() => createTab(null, '')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StatusBar />
|
||||
|
||||
{toastMessage && <Toast message={toastMessage} />}
|
||||
<DropOverlay />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
|
||||
export function DropOverlay() {
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const [dragCounter, setDragCounter] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const handleDragEnter = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragCounter(prev => {
|
||||
const next = prev + 1
|
||||
if (next > 0) setIsVisible(true)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleDragLeave = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragCounter(prev => {
|
||||
const next = prev - 1
|
||||
if (next <= 0) {
|
||||
setIsVisible(false)
|
||||
return 0
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
const handleDrop = () => {
|
||||
setDragCounter(0)
|
||||
setIsVisible(false)
|
||||
}
|
||||
|
||||
document.addEventListener('dragenter', handleDragEnter)
|
||||
document.addEventListener('dragleave', handleDragLeave)
|
||||
document.addEventListener('dragover', handleDragOver)
|
||||
document.addEventListener('drop', handleDrop)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('dragenter', handleDragEnter)
|
||||
document.removeEventListener('dragleave', handleDragLeave)
|
||||
document.removeEventListener('dragover', handleDragOver)
|
||||
document.removeEventListener('drop', handleDrop)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!isVisible) return null
|
||||
|
||||
return (
|
||||
<div id="drop-overlay">
|
||||
<div className="drop-content">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
<p>释放文件以打开</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import React, { useEffect, useRef, useCallback, useState } from 'react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useEditorStore } from '../../stores/editorStore'
|
||||
import { useSearchStore } from '../../stores/searchStore'
|
||||
import { findMatches, buildLineStarts, getLineNumber } from '../../lib/searchEngine'
|
||||
import type { SearchMatch } from '../../types/search'
|
||||
|
||||
export function Editor() {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const lineNumbersRef = useRef<HTMLDivElement>(null)
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
const getActiveTab = useTabStore(s => s.getActiveTab)
|
||||
const updateTabContent = useTabStore(s => s.updateTabContent)
|
||||
const setModified = useTabStore(s => s.setModified)
|
||||
|
||||
const searchStore = useSearchStore()
|
||||
|
||||
const [lineCount, setLineCount] = useState(1)
|
||||
const [cursorPos, setCursorPos] = useState({ line: 1, col: 1 })
|
||||
const [fileSize, setFileSize] = useState('')
|
||||
const updateTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// 测量行高
|
||||
const lineHeightRef = useRef(20.8)
|
||||
useEffect(() => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
const style = getComputedStyle(textarea)
|
||||
const measure = document.createElement('div')
|
||||
measure.style.cssText = `position:absolute;visibility:hidden;font-family:${style.fontFamily};font-size:${style.fontSize};line-height:${style.lineHeight};white-space:pre;width:0;`
|
||||
measure.textContent = 'X'
|
||||
document.body.appendChild(measure)
|
||||
const singleHeight = measure.offsetHeight
|
||||
measure.textContent = 'X\nX'
|
||||
const doubleHeight = measure.offsetHeight
|
||||
document.body.removeChild(measure)
|
||||
lineHeightRef.current = doubleHeight - singleHeight || 20.8
|
||||
}, [])
|
||||
|
||||
// 加载标签内容到编辑器
|
||||
useEffect(() => {
|
||||
const tab = getActiveTab()
|
||||
const textarea = textareaRef.current
|
||||
if (!tab || !textarea) return
|
||||
|
||||
textarea.value = tab.content
|
||||
updateLineNumbers(tab.content)
|
||||
updateFileSizeDisplay(tab.content)
|
||||
updateCursorPos()
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
textarea.scrollTop = tab.scrollTop
|
||||
textarea.scrollLeft = tab.scrollLeft
|
||||
textarea.setSelectionRange(tab.selectionStart, tab.selectionEnd)
|
||||
if (lineNumbersRef.current) {
|
||||
lineNumbersRef.current.scrollTop = tab.scrollTop
|
||||
}
|
||||
})
|
||||
}, [activeTabId, getActiveTab])
|
||||
|
||||
// 保存当前标签状态
|
||||
const saveCurrentState = useCallback(() => {
|
||||
const tab = getActiveTab()
|
||||
const textarea = textareaRef.current
|
||||
if (!tab || !textarea) return
|
||||
useTabStore.getState().updateTabScroll(tab.id, {
|
||||
scrollTop: textarea.scrollTop,
|
||||
scrollLeft: textarea.scrollLeft,
|
||||
selectionStart: textarea.selectionStart,
|
||||
selectionEnd: textarea.selectionEnd,
|
||||
previewScrollTop: 0 // will be set by preview
|
||||
})
|
||||
}, [getActiveTab])
|
||||
|
||||
// 更新行号
|
||||
const updateLineNumbers = useCallback((content: string) => {
|
||||
const count = (content.match(/\n/g) || []).length + 1
|
||||
setLineCount(count)
|
||||
}, [])
|
||||
|
||||
// 更新文件大小
|
||||
const updateFileSizeDisplay = useCallback((content: string) => {
|
||||
const bytes = new Blob([content]).size
|
||||
if (bytes < 1024) setFileSize(bytes + ' B')
|
||||
else if (bytes < 1024 * 1024) setFileSize((bytes / 1024).toFixed(1) + ' KB')
|
||||
else setFileSize((bytes / (1024 * 1024)).toFixed(1) + ' MB')
|
||||
}, [])
|
||||
|
||||
// 更新光标位置
|
||||
const updateCursorPos = useCallback(() => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
const text = textarea.value
|
||||
const pos = textarea.selectionStart
|
||||
const textBefore = text.substring(0, pos)
|
||||
const lines = textBefore.split('\n')
|
||||
setCursorPos({ line: lines.length, col: lines[lines.length - 1].length + 1 })
|
||||
}, [])
|
||||
|
||||
// 输入事件
|
||||
const handleInput = useCallback(() => {
|
||||
const textarea = textareaRef.current
|
||||
const tab = getActiveTab()
|
||||
if (!textarea || !tab) return
|
||||
|
||||
const content = textarea.value
|
||||
updateTabContent(tab.id, content)
|
||||
setModified(tab.id, true)
|
||||
updateLineNumbers(content)
|
||||
updateFileSizeDisplay(content)
|
||||
|
||||
// 防抖更新
|
||||
if (updateTimerRef.current) clearTimeout(updateTimerRef.current)
|
||||
updateTimerRef.current = setTimeout(() => {
|
||||
// 触发预览更新(通过 store)
|
||||
}, 150)
|
||||
}, [getActiveTab, updateTabContent, setModified, updateLineNumbers, updateFileSizeDisplay])
|
||||
|
||||
// 滚动同步
|
||||
const handleScroll = useCallback(() => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
if (lineNumbersRef.current) {
|
||||
lineNumbersRef.current.scrollTop = textarea.scrollTop
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Tab 键支持
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
if (start !== end) {
|
||||
const lines = textarea.value.substring(start, end).split('\n')
|
||||
const indented = lines.map(line => ' ' + line).join('\n')
|
||||
document.execCommand('insertText', false, indented)
|
||||
} else {
|
||||
document.execCommand('insertText', false, ' ')
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索导航
|
||||
if (e.key === 'Enter' && searchStore.isVisible) {
|
||||
e.preventDefault()
|
||||
if (e.shiftKey) {
|
||||
searchStore.findPrev()
|
||||
} else {
|
||||
searchStore.findNext()
|
||||
}
|
||||
}
|
||||
}, [searchStore])
|
||||
|
||||
// 渲染行号
|
||||
const renderLineNumbers = () => {
|
||||
const lineHeight = lineHeightRef.current
|
||||
const LARGE_THRESHOLD = 2000
|
||||
if (lineCount > LARGE_THRESHOLD) {
|
||||
// 虚拟化
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return null
|
||||
const scrollTop = textarea.scrollTop
|
||||
const viewportHeight = textarea.clientHeight
|
||||
const buffer = 20
|
||||
const startLine = Math.max(0, Math.floor(scrollTop / lineHeight) - buffer)
|
||||
const endLine = Math.min(lineCount, Math.ceil((scrollTop + viewportHeight) / lineHeight) + buffer)
|
||||
const topPadding = startLine * lineHeight
|
||||
const bottomPadding = (lineCount - endLine) * lineHeight
|
||||
return (
|
||||
<>
|
||||
<div style={{ height: topPadding }} />
|
||||
{Array.from({ length: endLine - startLine }, (_, i) => (
|
||||
<div key={startLine + i} className="line-num" style={{ height: lineHeight }}>
|
||||
{startLine + i + 1}
|
||||
</div>
|
||||
))}
|
||||
<div style={{ height: bottomPadding }} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
return Array.from({ length: lineCount }, (_, i) => (
|
||||
<div key={i} className="line-num" style={{ height: lineHeight }}>
|
||||
{i + 1}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="editor-wrapper" ref={wrapperRef}>
|
||||
<div id="line-numbers" ref={lineNumbersRef}>
|
||||
{renderLineNumbers()}
|
||||
</div>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
id="editor"
|
||||
spellCheck={false}
|
||||
placeholder="在此输入 Markdown 内容,或拖拽 .md 文件到窗口打开..."
|
||||
onInput={handleInput}
|
||||
onScroll={handleScroll}
|
||||
onClick={updateCursorPos}
|
||||
onKeyUp={updateCursorPos}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react'
|
||||
|
||||
interface ModifiedBannerProps {
|
||||
onReload: () => void
|
||||
onDismiss: () => void
|
||||
}
|
||||
|
||||
export function ModifiedBanner({ onReload, onDismiss }: ModifiedBannerProps) {
|
||||
return (
|
||||
<div id="modified-banner">
|
||||
<span>文件已被外部程序修改</span>
|
||||
<button className="banner-btn" onClick={onReload}>重新加载</button>
|
||||
<button className="banner-btn" onClick={onDismiss}>忽略</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { renderMarkdown } from '../../lib/markdown'
|
||||
|
||||
export function Preview() {
|
||||
const [html, setHtml] = useState('')
|
||||
const previewRef = useRef<HTMLDivElement>(null)
|
||||
const getActiveTab = useTabStore(s => s.getActiveTab)
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
|
||||
// 渲染 Markdown
|
||||
useEffect(() => {
|
||||
const tab = getActiveTab()
|
||||
if (!tab) {
|
||||
setHtml('')
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
renderMarkdown(tab.content).then(result => {
|
||||
if (!cancelled) {
|
||||
setHtml(result)
|
||||
}
|
||||
})
|
||||
|
||||
return () => { cancelled = true }
|
||||
}, [activeTabId, getActiveTab])
|
||||
|
||||
// 监听编辑器内容变化
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
const tab = getActiveTab()
|
||||
if (!tab) return
|
||||
renderMarkdown(tab.content).then(result => {
|
||||
setHtml(result)
|
||||
})
|
||||
}, 300)
|
||||
return () => clearInterval(timer)
|
||||
}, [getActiveTab])
|
||||
|
||||
// 拦截链接点击
|
||||
const handleClick = useCallback((e: React.MouseEvent) => {
|
||||
const link = (e.target as HTMLElement).closest('a')
|
||||
if (!link) return
|
||||
e.preventDefault()
|
||||
const href = link.getAttribute('href')
|
||||
if (!href) return
|
||||
if (href.startsWith('#')) {
|
||||
const target = previewRef.current?.querySelector(href)
|
||||
if (target) target.scrollIntoView({ behavior: 'smooth' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const url = new URL(href)
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (window.electronAPI?.openExternal) {
|
||||
window.electronAPI.openExternal(href)
|
||||
} else {
|
||||
window.open(href, '_blank', 'noopener')
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={previewRef}
|
||||
id="preview"
|
||||
className="markdown-body"
|
||||
onClick={handleClick}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import React, { useCallback, useRef, useEffect } from 'react'
|
||||
import { useSearchStore } from '../../stores/searchStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { findMatches } from '../../lib/searchEngine'
|
||||
|
||||
export function SearchBar() {
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const isVisible = useSearchStore(s => s.isVisible)
|
||||
const showReplace = useSearchStore(s => s.showReplace)
|
||||
const searchText = useSearchStore(s => s.searchText)
|
||||
const replaceText = useSearchStore(s => s.replaceText)
|
||||
const matches = useSearchStore(s => s.matches)
|
||||
const currentIndex = useSearchStore(s => s.currentIndex)
|
||||
const options = useSearchStore(s => s.options)
|
||||
const setSearchText = useSearchStore(s => s.setSearchText)
|
||||
const setReplaceText = useSearchStore(s => s.setReplaceText)
|
||||
const setShowReplace = useSearchStore(s => s.setShowReplace)
|
||||
const setMatches = useSearchStore(s => s.setMatches)
|
||||
const findNext = useSearchStore(s => s.findNext)
|
||||
const findPrev = useSearchStore(s => s.findPrev)
|
||||
const toggleCaseSensitive = useSearchStore(s => s.toggleCaseSensitive)
|
||||
const toggleRegex = useSearchStore(s => s.toggleRegex)
|
||||
const close = useSearchStore(s => s.close)
|
||||
|
||||
const getActiveTab = useTabStore(s => s.getActiveTab)
|
||||
|
||||
// 执行搜索
|
||||
const doSearch = useCallback((text: string) => {
|
||||
const tab = getActiveTab()
|
||||
if (!tab || !text) {
|
||||
setMatches([])
|
||||
return
|
||||
}
|
||||
const found = findMatches(tab.content, text, options)
|
||||
setMatches(found)
|
||||
}, [getActiveTab, options, setMatches])
|
||||
|
||||
// 搜索文本变化
|
||||
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const text = e.target.value
|
||||
setSearchText(text)
|
||||
doSearch(text)
|
||||
}, [setSearchText, doSearch])
|
||||
|
||||
// 替换当前
|
||||
const handleReplaceCurrent = useCallback(() => {
|
||||
const tab = getActiveTab()
|
||||
if (!tab || matches.length === 0 || currentIndex < 0) return
|
||||
const m = matches[currentIndex]
|
||||
const newContent = tab.content.substring(0, m.start) + replaceText + tab.content.substring(m.end)
|
||||
useTabStore.getState().updateTabContent(tab.id, newContent)
|
||||
// 重新搜索
|
||||
doSearch(searchText)
|
||||
}, [getActiveTab, matches, currentIndex, replaceText, searchText, doSearch])
|
||||
|
||||
// 全部替换
|
||||
const handleReplaceAll = useCallback(() => {
|
||||
const tab = getActiveTab()
|
||||
if (!tab || matches.length === 0) return
|
||||
let result = ''
|
||||
let lastEnd = 0
|
||||
for (let i = matches.length - 1; i >= 0; i--) {
|
||||
const m = matches[i]
|
||||
result = tab.content.substring(lastEnd, m.start) + replaceText + result
|
||||
lastEnd = m.end
|
||||
}
|
||||
result = tab.content.substring(0, matches[0].start) + result
|
||||
useTabStore.getState().updateTabContent(tab.id, result)
|
||||
doSearch(searchText)
|
||||
}, [getActiveTab, matches, replaceText, searchText, doSearch])
|
||||
|
||||
// 打开时聚焦
|
||||
useEffect(() => {
|
||||
if (isVisible && searchInputRef.current) {
|
||||
searchInputRef.current.focus()
|
||||
}
|
||||
}, [isVisible])
|
||||
|
||||
if (!isVisible) return null
|
||||
|
||||
return (
|
||||
<div id="search-bar">
|
||||
<div className="search-row">
|
||||
<button
|
||||
id="btn-toggle-replace"
|
||||
className={`search-opt-btn ${showReplace ? 'expanded' : ''}`}
|
||||
onClick={() => setShowReplace(!showReplace)}
|
||||
title="展开替换行"
|
||||
>
|
||||
▾
|
||||
</button>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
id="search-input"
|
||||
placeholder="查找..."
|
||||
value={searchText}
|
||||
onChange={handleSearchChange}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
e.shiftKey ? findPrev() : findNext()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
close()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span id="search-count">
|
||||
{matches.length > 0 ? `${currentIndex + 1}/${matches.length}` : searchText ? '无结果' : ''}
|
||||
</span>
|
||||
<button
|
||||
className={`search-opt-btn ${options.caseSensitive ? 'active' : ''}`}
|
||||
onClick={toggleCaseSensitive}
|
||||
title="区分大小写 (Alt+C)"
|
||||
>
|
||||
Aa
|
||||
</button>
|
||||
<button
|
||||
className={`search-opt-btn ${options.useRegex ? 'active' : ''}`}
|
||||
onClick={toggleRegex}
|
||||
title="正则表达式 (Alt+R)"
|
||||
>
|
||||
.*
|
||||
</button>
|
||||
<button className="search-nav-btn" onClick={findPrev} title="上一个 (Shift+Enter)">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<polyline points="18 15 12 9 6 15" />
|
||||
</svg>
|
||||
</button>
|
||||
<button className="search-nav-btn" onClick={findNext} title="下一个 (Enter)">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
<button className="search-nav-btn" onClick={close} title="关闭 (Escape)">✕</button>
|
||||
</div>
|
||||
|
||||
{showReplace && (
|
||||
<div id="replace-row">
|
||||
<input
|
||||
type="text"
|
||||
id="replace-input"
|
||||
placeholder="替换..."
|
||||
value={replaceText}
|
||||
onChange={(e) => setReplaceText(e.target.value)}
|
||||
/>
|
||||
<button className="replace-btn" onClick={handleReplaceCurrent} title="替换 (Ctrl+Shift+G)">替换</button>
|
||||
<button className="replace-btn" onClick={handleReplaceAll} title="全部替换 (Ctrl+Shift+H)">全部</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import React, { useEffect, useCallback, useState, useRef } from 'react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useSidebarStore } from '../../stores/sidebarStore'
|
||||
import { getFileName } from '../../lib/fileUtils'
|
||||
import type { FileNode } from '../../types/file'
|
||||
|
||||
export function Sidebar() {
|
||||
const tabs = useTabStore(s => s.tabs)
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
const switchToTab = useTabStore(s => s.switchToTab)
|
||||
const createTab = useTabStore(s => s.createTab)
|
||||
|
||||
const rootPath = useSidebarStore(s => s.rootPath)
|
||||
const tree = useSidebarStore(s => s.tree)
|
||||
const expandedDirs = useSidebarStore(s => s.expandedDirs)
|
||||
const toggleDir = useSidebarStore(s => s.toggleDir)
|
||||
const setRootPath = useSidebarStore(s => s.setRootPath)
|
||||
const setTree = useSidebarStore(s => s.setTree)
|
||||
const isVisible = useSidebarStore(s => s.isVisible)
|
||||
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const sidebarRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// 打开文件夹
|
||||
const handleOpenFolder = useCallback(async () => {
|
||||
if (!window.electronAPI) return
|
||||
const dirPath = await window.electronAPI.openFolderDialog()
|
||||
if (dirPath) {
|
||||
setRootPath(dirPath)
|
||||
const result = await window.electronAPI.readDirTree(dirPath)
|
||||
if (result.success && result.tree) {
|
||||
setTree(result.tree)
|
||||
window.electronAPI.watchDir(dirPath)
|
||||
}
|
||||
}
|
||||
}, [setRootPath, setTree])
|
||||
|
||||
// 刷新目录树
|
||||
const refreshTree = useCallback(async () => {
|
||||
if (!rootPath || !window.electronAPI) return
|
||||
const result = await window.electronAPI.readDirTree(rootPath)
|
||||
if (result.success && result.tree) {
|
||||
setTree(result.tree)
|
||||
}
|
||||
}, [rootPath, setTree])
|
||||
|
||||
// 目录变化监听
|
||||
useEffect(() => {
|
||||
if (!window.electronAPI) return
|
||||
window.electronAPI.onDirChanged(() => {
|
||||
refreshTree()
|
||||
})
|
||||
return () => {
|
||||
window.electronAPI?.removeAllListeners('sidebar:dirChanged')
|
||||
}
|
||||
}, [refreshTree])
|
||||
|
||||
// 侧边栏宽度调节
|
||||
useEffect(() => {
|
||||
if (!isResizing) return
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (sidebarRef.current) {
|
||||
const newWidth = Math.max(180, Math.min(500, e.clientX))
|
||||
sidebarRef.current.style.width = newWidth + 'px'
|
||||
}
|
||||
}
|
||||
const handleMouseUp = () => setIsResizing(false)
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
}, [isResizing])
|
||||
|
||||
// 独立文件(不在当前文件夹内的已打开文件)
|
||||
const independentFiles = tabs.filter(t => {
|
||||
if (!t.filePath) return false
|
||||
if (!rootPath) return true
|
||||
return !t.filePath.startsWith(rootPath)
|
||||
})
|
||||
|
||||
if (!isVisible) return null
|
||||
|
||||
return (
|
||||
<div id="sidebar" ref={sidebarRef}>
|
||||
<div id="sidebar-header">
|
||||
<span id="sidebar-title">资源管理器</span>
|
||||
<button className="sidebar-header-btn" onClick={handleOpenFolder} title="打开文件夹">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="sidebar-tree">
|
||||
{/* 独立文件区 */}
|
||||
{independentFiles.length > 0 && (
|
||||
<div className="independent-files-section">
|
||||
<div className="independent-files-header">已打开的文件</div>
|
||||
{independentFiles.map(tab => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`tree-item independent-file-item ${tab.id === activeTabId ? 'active' : ''}`}
|
||||
style={{ paddingLeft: '8px' }}
|
||||
onClick={() => switchToTab(tab.id)}
|
||||
>
|
||||
<span className="tree-icon">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="tree-name">{getFileName(tab.filePath!)}</span>
|
||||
{tab.isModified && <span className="independent-modified-dot"> •</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 文件夹目录树 */}
|
||||
{rootPath && (
|
||||
<>
|
||||
<div className="sidebar-section-header">文件夹目录树</div>
|
||||
<FileTree
|
||||
nodes={tree}
|
||||
depth={0}
|
||||
expandedDirs={expandedDirs}
|
||||
toggleDir={toggleDir}
|
||||
activeTabId={activeTabId}
|
||||
onFileClick={async (path) => {
|
||||
const existing = tabs.find(t => t.filePath === path)
|
||||
if (existing) {
|
||||
switchToTab(existing.id)
|
||||
} else if (window.electronAPI) {
|
||||
const result = await window.electronAPI.readFile(path)
|
||||
if (result.success && result.content) {
|
||||
createTab(path, result.content)
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 调节手柄 */}
|
||||
<div
|
||||
className="sidebar-resize-handle"
|
||||
onMouseDown={() => setIsResizing(true)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 文件树递归组件
|
||||
function FileTree({ nodes, depth, expandedDirs, toggleDir, activeTabId, onFileClick }: {
|
||||
nodes: FileNode[]
|
||||
depth: number
|
||||
expandedDirs: Set<string>
|
||||
toggleDir: (path: string) => void
|
||||
activeTabId: string | null
|
||||
onFileClick: (path: string) => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{nodes.map(node => (
|
||||
<React.Fragment key={node.path}>
|
||||
<div
|
||||
className={`tree-item ${node.type === 'file' && activeTabId ? '' : ''}`}
|
||||
style={{ paddingLeft: (8 + depth * 16) + 'px' }}
|
||||
onClick={() => {
|
||||
if (node.type === 'dir') {
|
||||
toggleDir(node.path)
|
||||
} else {
|
||||
onFileClick(node.path)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{node.type === 'dir' ? (
|
||||
<>
|
||||
<span className={`tree-arrow ${expandedDirs.has(node.path) ? 'expanded' : ''}`}>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="tree-icon">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span style={{ width: '16px', flexShrink: 0 }} />
|
||||
<span className="tree-icon">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="tree-name">{node.name}</span>
|
||||
</div>
|
||||
{node.type === 'dir' && expandedDirs.has(node.path) && node.children && (
|
||||
<FileTree
|
||||
nodes={node.children}
|
||||
depth={depth + 1}
|
||||
expandedDirs={expandedDirs}
|
||||
toggleDir={toggleDir}
|
||||
activeTabId={activeTabId}
|
||||
onFileClick={onFileClick}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from 'react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { getFileName } from '../../lib/fileUtils'
|
||||
|
||||
export function StatusBar() {
|
||||
const getActiveTab = useTabStore(s => s.getActiveTab)
|
||||
const tab = getActiveTab()
|
||||
|
||||
return (
|
||||
<div id="statusbar">
|
||||
<div className="status-left">
|
||||
<span id="status-text">
|
||||
{tab ? (tab.filePath ? getFileName(tab.filePath) : '未命名') : '就绪'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="status-right">
|
||||
<span id="status-encoding">UTF-8</span>
|
||||
<span className="status-divider">|</span>
|
||||
<span id="status-lang">Markdown</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { getFileName } from '../../lib/fileUtils'
|
||||
|
||||
export function TabBar() {
|
||||
const tabs = useTabStore(s => s.tabs)
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
const switchToTab = useTabStore(s => s.switchToTab)
|
||||
const closeTab = useTabStore(s => s.closeTab)
|
||||
const createTab = useTabStore(s => s.createTab)
|
||||
|
||||
const handleClose = useCallback((e: React.MouseEvent, tabId: string) => {
|
||||
e.stopPropagation()
|
||||
const tab = tabs.find(t => t.id === tabId)
|
||||
if (tab?.isModified) {
|
||||
const name = tab.filePath ? getFileName(tab.filePath) : '未命名'
|
||||
if (!confirm(`"${name}" 尚未保存,确定要关闭吗?`)) return
|
||||
}
|
||||
closeTab(tabId)
|
||||
}, [tabs, closeTab])
|
||||
|
||||
if (tabs.length === 0) return null
|
||||
|
||||
return (
|
||||
<div id="tab-bar">
|
||||
<div id="tab-list">
|
||||
{tabs.map(tab => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`tab-item ${tab.id === activeTabId ? 'active' : ''} ${tab.isModified ? 'modified' : ''}`}
|
||||
onClick={() => switchToTab(tab.id)}
|
||||
>
|
||||
<span className="tab-name">
|
||||
{tab.filePath ? getFileName(tab.filePath) : '未命名'}
|
||||
</span>
|
||||
<button
|
||||
className="tab-close"
|
||||
onClick={(e) => handleClose(e, tab.id)}
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="tab-add-btn"
|
||||
onClick={() => createTab(null, '')}
|
||||
title="新建标签页 (Ctrl+T)"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react'
|
||||
|
||||
interface ToastProps {
|
||||
message: string
|
||||
}
|
||||
|
||||
export function Toast({ message }: ToastProps) {
|
||||
return (
|
||||
<div id="toast-notification" className="show">
|
||||
{message}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import React from 'react'
|
||||
|
||||
interface ToolbarProps {
|
||||
onOpen: () => void
|
||||
onSave: () => void
|
||||
viewMode: 'split' | 'editor' | 'preview'
|
||||
onViewModeChange: (mode: 'split' | 'editor' | 'preview') => void
|
||||
darkMode: boolean
|
||||
onToggleDark: () => void
|
||||
}
|
||||
|
||||
export function Toolbar({ onOpen, onSave, viewMode, onViewModeChange, darkMode, onToggleDark }: ToolbarProps) {
|
||||
return (
|
||||
<div id="toolbar">
|
||||
<div className="toolbar-left">
|
||||
<button className="toolbar-btn" onClick={onOpen} title="打开文件 (Ctrl+O)">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
<span>打开</span>
|
||||
</button>
|
||||
<button className="toolbar-btn" onClick={onSave} title="保存文件 (Ctrl+S)">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
|
||||
<polyline points="17 21 17 13 7 13 7 21" />
|
||||
<polyline points="7 3 7 8 15 8" />
|
||||
</svg>
|
||||
<span>保存</span>
|
||||
</button>
|
||||
<div className="toolbar-divider" />
|
||||
<button className={`toolbar-btn ${viewMode === 'split' ? 'active' : ''}`} onClick={() => onViewModeChange('split')} title="编辑+预览 (Ctrl+1)">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="12" y1="3" x2="12" y2="21" />
|
||||
</svg>
|
||||
<span>分屏</span>
|
||||
</button>
|
||||
<button className={`toolbar-btn ${viewMode === 'editor' ? 'active' : ''}`} onClick={() => onViewModeChange('editor')} title="纯编辑 (Ctrl+2)">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
<span>编辑</span>
|
||||
</button>
|
||||
<button className={`toolbar-btn ${viewMode === 'preview' ? 'active' : ''}`} onClick={() => onViewModeChange('preview')} title="纯预览 (Ctrl+3)">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
<span>预览</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="toolbar-right">
|
||||
<button className="toolbar-btn" onClick={onToggleDark} title="切换暗色主题">
|
||||
{darkMode ? (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="5" />
|
||||
<line x1="12" y1="1" x2="12" y2="3" />
|
||||
<line x1="12" y1="21" x2="12" y2="23" />
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
|
||||
<line x1="1" y1="12" x2="3" y2="12" />
|
||||
<line x1="21" y1="12" x2="23" y2="12" />
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from 'react'
|
||||
|
||||
interface WelcomeScreenProps {
|
||||
onOpen: () => void
|
||||
onNew: () => void
|
||||
}
|
||||
|
||||
export function WelcomeScreen({ onOpen, onNew }: WelcomeScreenProps) {
|
||||
return (
|
||||
<div id="welcome-screen">
|
||||
<div className="welcome-content">
|
||||
<div className="welcome-icon">
|
||||
<svg width="80" height="80" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="10" y="5" width="80" height="90" rx="8" fill="#f0f6ff" stroke="#1a73e8" strokeWidth="2" />
|
||||
<text x="50" y="45" textAnchor="middle" fill="#1a73e8" fontFamily="system-ui" fontWeight="bold" fontSize="32">M↓</text>
|
||||
<text x="50" y="70" textAnchor="middle" fill="#5f6368" fontFamily="system-ui" fontSize="12">MarkLite</text>
|
||||
</svg>
|
||||
</div>
|
||||
<h1>欢迎使用 MarkLite</h1>
|
||||
<p>一款轻量级的 Markdown 阅读器</p>
|
||||
<div className="welcome-actions">
|
||||
<button className="welcome-btn primary" onClick={onOpen}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
打开文件
|
||||
</button>
|
||||
<button className="welcome-btn secondary" onClick={onNew}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
新建文件
|
||||
</button>
|
||||
</div>
|
||||
<div className="welcome-tips">
|
||||
<p>💡 提示:可以直接拖拽 .md 文件到窗口打开</p>
|
||||
<p>⌨️ 快捷键:Ctrl+O 打开 | Ctrl+S 保存 | Ctrl+F 搜索 | Ctrl+1/2/3 视图</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { db } from './schema'
|
||||
|
||||
export const recentFilesRepository = {
|
||||
async add(filePath: string): Promise<void> {
|
||||
const existing = await db.recentFiles.where('filePath').equals(filePath).first()
|
||||
if (existing) {
|
||||
await db.recentFiles.update(existing.id!, { lastOpened: Date.now() })
|
||||
} else {
|
||||
await db.recentFiles.add({ filePath, lastOpened: Date.now() })
|
||||
}
|
||||
},
|
||||
|
||||
async getAll(limit = 20): Promise<string[]> {
|
||||
const files = await db.recentFiles
|
||||
.orderBy('lastOpened')
|
||||
.reverse()
|
||||
.limit(limit)
|
||||
.toArray()
|
||||
return files.map((f: { filePath: string; lastOpened: number }) => f.filePath)
|
||||
},
|
||||
|
||||
async remove(filePath: string): Promise<void> {
|
||||
await db.recentFiles.where('filePath').equals(filePath).delete()
|
||||
},
|
||||
|
||||
async clear(): Promise<void> {
|
||||
await db.recentFiles.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import Dexie, { type EntityTable } from 'dexie'
|
||||
|
||||
export interface TabSnapshot {
|
||||
id: string
|
||||
filePath: string | null
|
||||
content: string
|
||||
scrollTop: number
|
||||
scrollLeft: number
|
||||
selectionStart: number
|
||||
selectionEnd: number
|
||||
previewScrollTop: number
|
||||
isModified: boolean
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface SettingsRecord {
|
||||
id: string // 固定为 'default'
|
||||
darkMode: boolean
|
||||
viewMode: 'split' | 'editor' | 'preview'
|
||||
splitRatio: number
|
||||
sidebarCollapsed: boolean
|
||||
sidebarWidth: number
|
||||
}
|
||||
|
||||
export interface RecentFile {
|
||||
id?: number
|
||||
filePath: string
|
||||
lastOpened: number
|
||||
}
|
||||
|
||||
const db = new Dexie('MarkLite') as Dexie & {
|
||||
tabSnapshots: EntityTable<TabSnapshot, 'id'>
|
||||
settings: EntityTable<SettingsRecord, 'id'>
|
||||
recentFiles: EntityTable<RecentFile, 'id'>
|
||||
}
|
||||
|
||||
db.version(1).stores({
|
||||
tabSnapshots: 'id, filePath, updatedAt',
|
||||
settings: 'id',
|
||||
recentFiles: '++id, filePath, lastOpened'
|
||||
})
|
||||
|
||||
export { db }
|
||||
@@ -0,0 +1,29 @@
|
||||
import { db, type SettingsRecord } from './schema'
|
||||
import { DEFAULT_SETTINGS, type Settings } from '../types/settings'
|
||||
|
||||
export const settingsRepository = {
|
||||
async load(): Promise<Settings> {
|
||||
try {
|
||||
const record = await db.settings.get('default')
|
||||
if (record) {
|
||||
return {
|
||||
darkMode: record.darkMode,
|
||||
viewMode: record.viewMode,
|
||||
splitRatio: record.splitRatio,
|
||||
sidebarCollapsed: record.sidebarCollapsed,
|
||||
sidebarWidth: record.sidebarWidth
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// fallback
|
||||
}
|
||||
return { ...DEFAULT_SETTINGS }
|
||||
},
|
||||
|
||||
async save(settings: Partial<Settings>): Promise<void> {
|
||||
const current = await this.load()
|
||||
const merged = { ...current, ...settings }
|
||||
const record: SettingsRecord = { id: 'default', ...merged }
|
||||
await db.settings.put(record)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { db, type TabSnapshot } from './schema'
|
||||
|
||||
export const tabRepository = {
|
||||
async saveAll(tabs: TabSnapshot[]): Promise<void> {
|
||||
await db.transaction('rw', db.tabSnapshots, async () => {
|
||||
await db.tabSnapshots.clear()
|
||||
await db.tabSnapshots.bulkAdd(tabs)
|
||||
})
|
||||
},
|
||||
|
||||
async loadAll(): Promise<TabSnapshot[]> {
|
||||
return db.tabSnapshots.orderBy('updatedAt').toArray()
|
||||
},
|
||||
|
||||
async clearAll(): Promise<void> {
|
||||
await db.tabSnapshots.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useEffect, useCallback } from 'react'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import { isAllowedFile } from '../lib/fileUtils'
|
||||
import { MAX_FILE_SIZE } from '../lib/constants'
|
||||
|
||||
export function useDragDrop(showToast: (msg: string) => void) {
|
||||
const createTab = useTabStore(s => s.createTab)
|
||||
|
||||
const handleDrop = useCallback(async (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
const files = e.dataTransfer?.files
|
||||
if (!files) return
|
||||
|
||||
let rejected = 0
|
||||
for (const file of Array.from(files)) {
|
||||
const filePath = (file as File & { path?: string }).path || file.name
|
||||
if (!isAllowedFile(filePath)) {
|
||||
rejected++
|
||||
continue
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
showToast(`"${file.name}" 过大,暂不支持超过 20MB 的文件`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (window.electronAPI) {
|
||||
const result = await window.electronAPI.readFile(filePath)
|
||||
if (result.success && result.content) {
|
||||
createTab(filePath, result.content)
|
||||
}
|
||||
} else {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev) => {
|
||||
const content = ev.target?.result as string
|
||||
createTab(file.name, content)
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}
|
||||
}
|
||||
|
||||
if (rejected > 0) {
|
||||
showToast(`仅支持 .md / .markdown / .txt 文件,已忽略 ${rejected} 个文件`)
|
||||
}
|
||||
}, [createTab, showToast])
|
||||
|
||||
useEffect(() => {
|
||||
const prevent = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
document.addEventListener('dragenter', prevent)
|
||||
document.addEventListener('dragleave', prevent)
|
||||
document.addEventListener('dragover', prevent)
|
||||
document.addEventListener('drop', handleDrop)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('dragenter', prevent)
|
||||
document.removeEventListener('dragleave', prevent)
|
||||
document.removeEventListener('dragover', prevent)
|
||||
document.removeEventListener('drop', handleDrop)
|
||||
}
|
||||
}, [handleDrop])
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import { useSearchStore } from '../stores/searchStore'
|
||||
import { useEditorStore } from '../stores/editorStore'
|
||||
import { findMatches } from '../lib/searchEngine'
|
||||
import type { SearchMatch } from '../types/search'
|
||||
|
||||
export function useFileWatch() {
|
||||
const getActiveTab = useTabStore(s => s.getActiveTab)
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.electronAPI) return
|
||||
|
||||
window.electronAPI.onExternalModification((filePath: string) => {
|
||||
const tab = getActiveTab()
|
||||
if (tab && tab.filePath === filePath) {
|
||||
// 显示修改横幅(通过状态管理触发 UI 更新)
|
||||
window.dispatchEvent(new CustomEvent('file-externally-modified', { detail: filePath }))
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
window.electronAPI?.removeAllListeners('file:externallyModified')
|
||||
}
|
||||
}, [getActiveTab])
|
||||
}
|
||||
|
||||
// 搜索 Hook
|
||||
export function useSearch() {
|
||||
const store = useSearchStore()
|
||||
|
||||
const doSearch = (content: string) => {
|
||||
if (!store.searchText) {
|
||||
store.setMatches([])
|
||||
return
|
||||
}
|
||||
const matches = findMatches(content, store.searchText, store.options)
|
||||
store.setMatches(matches)
|
||||
if (matches.length > 0) {
|
||||
// 找到离光标最近的匹配
|
||||
store.setCurrentIndex(0)
|
||||
}
|
||||
}
|
||||
|
||||
const replaceCurrent = (content: string): string | null => {
|
||||
if (store.matches.length === 0 || store.currentIndex < 0) return null
|
||||
const m = store.matches[store.currentIndex]
|
||||
return content.substring(0, m.start) + store.replaceText + content.substring(m.end)
|
||||
}
|
||||
|
||||
const replaceAll = (content: string): string => {
|
||||
if (store.matches.length === 0) return content
|
||||
let result = ''
|
||||
let lastEnd = 0
|
||||
// 从后往前替换
|
||||
for (let i = store.matches.length - 1; i >= 0; i--) {
|
||||
const m = store.matches[i]
|
||||
result = content.substring(lastEnd, m.start) + store.replaceText + result
|
||||
lastEnd = m.end
|
||||
}
|
||||
result = content.substring(0, store.matches[0].start) + result
|
||||
return result
|
||||
}
|
||||
|
||||
return { ...store, doSearch, replaceCurrent, replaceAll }
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useEffect, useCallback } from 'react'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import { useEditorStore } from '../stores/editorStore'
|
||||
import { useSearchStore } from '../stores/searchStore'
|
||||
import type { ViewMode } from '../types/settings'
|
||||
|
||||
export function useKeyboard(handleOpenFile: () => void, handleSave: () => void, handleSaveAs: () => void) {
|
||||
const createTab = useTabStore(s => s.createTab)
|
||||
const closeTab = useTabStore(s => s.closeTab)
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
const tabs = useTabStore(s => s.tabs)
|
||||
const switchToTab = useTabStore(s => s.switchToTab)
|
||||
const mruStack = useTabStore(s => s.mruStack)
|
||||
const setViewMode = useEditorStore(s => s.setViewMode)
|
||||
const searchStore = useSearchStore
|
||||
|
||||
const handleKeydown = useCallback((e: KeyboardEvent) => {
|
||||
const isCtrl = e.ctrlKey || e.metaKey
|
||||
|
||||
if (isCtrl && e.key === 'o') {
|
||||
e.preventDefault()
|
||||
handleOpenFile()
|
||||
return
|
||||
}
|
||||
if (isCtrl && e.key === 's' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSave()
|
||||
return
|
||||
}
|
||||
if (isCtrl && e.shiftKey && e.key === 'S') {
|
||||
e.preventDefault()
|
||||
handleSaveAs()
|
||||
return
|
||||
}
|
||||
if (isCtrl && e.key === '1') {
|
||||
e.preventDefault()
|
||||
setViewMode('split')
|
||||
return
|
||||
}
|
||||
if (isCtrl && e.key === '2') {
|
||||
e.preventDefault()
|
||||
setViewMode('editor')
|
||||
return
|
||||
}
|
||||
if (isCtrl && e.key === '3') {
|
||||
e.preventDefault()
|
||||
setViewMode('preview')
|
||||
return
|
||||
}
|
||||
if (isCtrl && e.key === 't') {
|
||||
e.preventDefault()
|
||||
createTab(null, '')
|
||||
return
|
||||
}
|
||||
if (isCtrl && e.key === 'w') {
|
||||
e.preventDefault()
|
||||
if (activeTabId) closeTab(activeTabId)
|
||||
return
|
||||
}
|
||||
if (isCtrl && e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
if (tabs.length > 1) {
|
||||
if (e.shiftKey) {
|
||||
if (mruStack.length > 0) {
|
||||
const targetId = mruStack[0]
|
||||
if (tabs.find(t => t.id === targetId)) {
|
||||
switchToTab(targetId)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const idx = tabs.findIndex(t => t.id === activeTabId)
|
||||
const next = (idx + 1) % tabs.length
|
||||
switchToTab(tabs[next].id)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Search & Replace
|
||||
const state = searchStore.getState()
|
||||
if (isCtrl && e.key === 'f') {
|
||||
e.preventDefault()
|
||||
state.setVisible(true)
|
||||
state.setShowReplace(false)
|
||||
return
|
||||
}
|
||||
if (isCtrl && e.key === 'h') {
|
||||
e.preventDefault()
|
||||
state.setVisible(true)
|
||||
state.setShowReplace(true)
|
||||
return
|
||||
}
|
||||
if (e.key === 'Escape' && state.isVisible) {
|
||||
e.preventDefault()
|
||||
state.close()
|
||||
return
|
||||
}
|
||||
if (e.altKey && e.key === 'c') {
|
||||
e.preventDefault()
|
||||
state.toggleCaseSensitive()
|
||||
return
|
||||
}
|
||||
if (e.altKey && e.key === 'r') {
|
||||
e.preventDefault()
|
||||
state.toggleRegex()
|
||||
return
|
||||
}
|
||||
if (isCtrl && e.shiftKey && e.key === 'G') {
|
||||
e.preventDefault()
|
||||
state.findPrev()
|
||||
return
|
||||
}
|
||||
if (isCtrl && e.shiftKey && e.key === 'H') {
|
||||
e.preventDefault()
|
||||
// replaceAll 需要外部实现
|
||||
return
|
||||
}
|
||||
}, [handleOpenFile, handleSave, handleSaveAs, createTab, closeTab, activeTabId, tabs, switchToTab, mruStack, setViewMode, searchStore])
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
return () => document.removeEventListener('keydown', handleKeydown)
|
||||
}, [handleKeydown])
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useEditorStore } from '../stores/editorStore'
|
||||
import { settingsRepository } from '../db/settingsRepository'
|
||||
import type { ViewMode } from '../types/settings'
|
||||
|
||||
export function useSettings() {
|
||||
const viewMode = useEditorStore(s => s.viewMode)
|
||||
const splitRatio = useEditorStore(s => s.splitRatio)
|
||||
const setViewMode = useEditorStore(s => s.setViewMode)
|
||||
const setSplitRatio = useEditorStore(s => s.setSplitRatio)
|
||||
|
||||
// 初始化
|
||||
useEffect(() => {
|
||||
settingsRepository.load().then(settings => {
|
||||
setViewMode(settings.viewMode)
|
||||
setSplitRatio(settings.splitRatio)
|
||||
})
|
||||
}, [setViewMode, setSplitRatio])
|
||||
|
||||
const saveViewMode = (mode: ViewMode) => {
|
||||
setViewMode(mode)
|
||||
settingsRepository.save({ viewMode: mode })
|
||||
}
|
||||
|
||||
const saveSplitRatio = (ratio: number) => {
|
||||
setSplitRatio(ratio)
|
||||
settingsRepository.save({ splitRatio: ratio })
|
||||
}
|
||||
|
||||
return { viewMode, splitRatio, saveViewMode, saveSplitRatio }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useEditorStore } from '../stores/editorStore'
|
||||
import { settingsRepository } from '../db/settingsRepository'
|
||||
|
||||
export function useTheme() {
|
||||
const darkMode = useEditorStore(s => s.darkMode)
|
||||
const setDarkMode = useEditorStore(s => s.setDarkMode)
|
||||
|
||||
// 初始化:从 IndexedDB 加载主题设置
|
||||
useEffect(() => {
|
||||
settingsRepository.load().then(settings => {
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
setDarkMode(settings.darkMode !== undefined ? settings.darkMode : prefersDark)
|
||||
})
|
||||
}, [setDarkMode])
|
||||
|
||||
// 应用主题到 DOM
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle('dark', darkMode)
|
||||
settingsRepository.save({ darkMode })
|
||||
}, [darkMode])
|
||||
|
||||
return { darkMode, toggleDarkMode: useEditorStore(s => s.toggleDarkMode) }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export function useUnsavedWarning(hasUnsaved: () => boolean) {
|
||||
useEffect(() => {
|
||||
if (!window.electronAPI) {
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
if (hasUnsaved()) {
|
||||
e.preventDefault()
|
||||
e.returnValue = ''
|
||||
}
|
||||
}
|
||||
window.addEventListener('beforeunload', handler)
|
||||
return () => window.removeEventListener('beforeunload', handler)
|
||||
}
|
||||
|
||||
const api = window.electronAPI
|
||||
|
||||
api.onConfirmClose(() => {
|
||||
if (!hasUnsaved()) {
|
||||
api.forceClose()
|
||||
return
|
||||
}
|
||||
const shouldClose = confirm('有文件尚未保存,确定要关闭吗?')
|
||||
if (shouldClose) {
|
||||
api.forceClose()
|
||||
} else {
|
||||
api.cancelClose()
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
api.removeAllListeners('window:confirmClose')
|
||||
}
|
||||
}, [hasUnsaved])
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data: file:; object-src 'none'; base-uri 'self'; form-action 'self';" />
|
||||
<title>MarkLite</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
// 常量定义
|
||||
export const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB
|
||||
export const ALLOWED_EXTENSIONS = ['.md', '.markdown', '.txt'] as const
|
||||
export const SKIP_DIRS = new Set([
|
||||
'node_modules', '.git', '.svn', '.hg', 'dist', 'out',
|
||||
'.next', '.nuxt', '__pycache__', '.DS_Store'
|
||||
])
|
||||
export const MARKDOWN_EXTS = new Set(['.md', '.markdown', '.txt'])
|
||||
export const UPDATE_DEBOUNCE_MS = 150
|
||||
export const LARGE_FILE_LINE_THRESHOLD = 2000
|
||||
export const CLOSE_TIMEOUT_MS = 5000
|
||||
export const WATCH_RESTART_DELAY_MS = 300
|
||||
export const DIR_REFRESH_DEBOUNCE_MS = 300
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ALLOWED_EXTENSIONS } from './constants'
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
export function getFileName(filePath: string): string {
|
||||
return filePath.split(/[/\\]/).pop() || filePath
|
||||
}
|
||||
|
||||
export function isAllowedFile(filePath: string): boolean {
|
||||
const ext = filePath.substring(filePath.lastIndexOf('.')).toLowerCase()
|
||||
return (ALLOWED_EXTENSIONS as readonly string[]).includes(ext)
|
||||
}
|
||||
|
||||
export function getFileExtension(filePath: string): string {
|
||||
return filePath.substring(filePath.lastIndexOf('.')).toLowerCase()
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { unified } from 'unified'
|
||||
import remarkParse from 'remark-parse'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkRehype from 'remark-rehype'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
|
||||
import rehypeStringify from 'rehype-stringify'
|
||||
import rehypeHighlight from 'rehype-highlight'
|
||||
|
||||
const processor = unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkGfm)
|
||||
.use(remarkRehype, { allowDangerousHtml: true })
|
||||
.use(rehypeRaw)
|
||||
.use(rehypeSanitize, {
|
||||
...defaultSchema,
|
||||
attributes: {
|
||||
...defaultSchema.attributes,
|
||||
img: [...(defaultSchema.attributes?.img ?? []), ['src']]
|
||||
}
|
||||
})
|
||||
.use(rehypeHighlight)
|
||||
.use(rehypeStringify)
|
||||
|
||||
export async function renderMarkdown(content: string): Promise<string> {
|
||||
try {
|
||||
const result = await processor.process(content)
|
||||
return String(result)
|
||||
} catch (e) {
|
||||
return `<p style="color:red">渲染错误: ${e instanceof Error ? e.message : String(e)}</p>`
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// 滚动同步算法 — 基于块级元素 DOM 位置映射
|
||||
import type { SearchMatch } from '../types/search'
|
||||
|
||||
export interface ScrollMap {
|
||||
lineToPreviewMap: Float32Array
|
||||
}
|
||||
|
||||
// 识别块级元素起始行
|
||||
function identifyBlockStarts(lines: string[]): Set<number> {
|
||||
const starts = new Set<number>()
|
||||
let inCodeBlock = false
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trimStart()
|
||||
|
||||
// 围栏代码块边界
|
||||
if (line.startsWith('```')) {
|
||||
if (!inCodeBlock) starts.add(i)
|
||||
inCodeBlock = !inCodeBlock
|
||||
continue
|
||||
}
|
||||
if (inCodeBlock) continue
|
||||
|
||||
// 块级元素起始行
|
||||
if (
|
||||
line.startsWith('#') ||
|
||||
line.startsWith('- ') ||
|
||||
line.startsWith('* ') ||
|
||||
line.startsWith('+ ') ||
|
||||
/^\d+\.\s/.test(line) ||
|
||||
line.startsWith('> ') ||
|
||||
line.startsWith('|') ||
|
||||
line.startsWith('---') ||
|
||||
line.startsWith('***') ||
|
||||
line.startsWith('___') ||
|
||||
(line === '' && i > 0 && lines[i - 1].trim() === '')
|
||||
) {
|
||||
starts.add(i)
|
||||
}
|
||||
}
|
||||
|
||||
return starts
|
||||
}
|
||||
|
||||
export function buildScrollMap(
|
||||
markdown: string,
|
||||
previewContainer: HTMLElement
|
||||
): ScrollMap {
|
||||
const lines = markdown.split('\n')
|
||||
const totalLines = lines.length
|
||||
const lineToPreviewMap = new Float32Array(totalLines)
|
||||
|
||||
// 1. 识别块级元素起始行
|
||||
const blockStarts = identifyBlockStarts(lines)
|
||||
|
||||
// 2. 获取预览 DOM 元素位置
|
||||
const previewPositions: number[] = []
|
||||
for (let i = 0; i < previewContainer.children.length; i++) {
|
||||
previewPositions.push((previewContainer.children[i] as HTMLElement).offsetTop)
|
||||
}
|
||||
|
||||
if (previewPositions.length === 0 || blockStarts.size === 0) {
|
||||
// fallback: 线性映射
|
||||
const lastPos = previewPositions[previewPositions.length - 1] || 0
|
||||
for (let i = 0; i < totalLines; i++) {
|
||||
lineToPreviewMap[i] = (i / Math.max(1, totalLines - 1)) * lastPos
|
||||
}
|
||||
return { lineToPreviewMap }
|
||||
}
|
||||
|
||||
// 3. 块起始行 → 预览位置映射
|
||||
const uniqueStartsArr = [...blockStarts].sort((a, b) => a - b)
|
||||
const uniqueStartsSet = new Set(uniqueStartsArr)
|
||||
const domCount = previewPositions.length
|
||||
|
||||
for (let i = 0; i < uniqueStartsArr.length; i++) {
|
||||
const lineIdx = uniqueStartsArr[i]
|
||||
const domIdx = Math.min(Math.floor((i / uniqueStartsArr.length) * domCount), domCount - 1)
|
||||
lineToPreviewMap[lineIdx] = previewPositions[domIdx]
|
||||
}
|
||||
|
||||
// 4. 线性插值填充所有行
|
||||
for (let i = 0; i < totalLines; i++) {
|
||||
if (lineToPreviewMap[i] !== 0 && uniqueStartsSet.has(i)) continue
|
||||
|
||||
// 找到前后最近的已映射行
|
||||
let prevLine = -1
|
||||
let nextLine = -1
|
||||
for (let j = i - 1; j >= 0; j--) {
|
||||
if (lineToPreviewMap[j] !== 0 || uniqueStartsSet.has(j)) { prevLine = j; break }
|
||||
}
|
||||
for (let j = i + 1; j < totalLines; j++) {
|
||||
if (lineToPreviewMap[j] !== 0 || uniqueStartsSet.has(j)) { nextLine = j; break }
|
||||
}
|
||||
|
||||
if (prevLine === -1 && nextLine === -1) {
|
||||
lineToPreviewMap[i] = 0
|
||||
} else if (prevLine === -1) {
|
||||
lineToPreviewMap[i] = lineToPreviewMap[nextLine]
|
||||
} else if (nextLine === -1) {
|
||||
lineToPreviewMap[i] = lineToPreviewMap[prevLine]
|
||||
} else {
|
||||
const ratio = (i - prevLine) / (nextLine - prevLine)
|
||||
lineToPreviewMap[i] = lineToPreviewMap[prevLine] + ratio * (lineToPreviewMap[nextLine] - lineToPreviewMap[prevLine])
|
||||
}
|
||||
}
|
||||
|
||||
return { lineToPreviewMap }
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { SearchMatch, SearchOptions } from '../types/search'
|
||||
|
||||
export function findMatches(
|
||||
content: string,
|
||||
searchText: string,
|
||||
options: SearchOptions
|
||||
): SearchMatch[] {
|
||||
if (!searchText) return []
|
||||
|
||||
const matches: SearchMatch[] = []
|
||||
|
||||
if (options.useRegex) {
|
||||
try {
|
||||
const flags = options.caseSensitive ? 'g' : 'gi'
|
||||
const regex = new RegExp(searchText, flags)
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = regex.exec(content)) !== null) {
|
||||
matches.push({ start: m.index, end: m.index + m[0].length })
|
||||
if (m[0].length === 0) regex.lastIndex++
|
||||
}
|
||||
} catch {
|
||||
// invalid regex — return empty
|
||||
}
|
||||
} else {
|
||||
const haystack = options.caseSensitive ? content : content.toLowerCase()
|
||||
const needle = options.caseSensitive ? searchText : searchText.toLowerCase()
|
||||
let idx = 0
|
||||
while ((idx = haystack.indexOf(needle, idx)) !== -1) {
|
||||
matches.push({ start: idx, end: idx + needle.length })
|
||||
idx += needle.length
|
||||
}
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
// 构建行首位置缓存(用于 O(log N) 二分查找行号)
|
||||
export function buildLineStarts(content: string): number[] {
|
||||
const starts = [0]
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
if (content.charCodeAt(i) === 10) starts.push(i + 1) // '\n' = 0x0A
|
||||
}
|
||||
return starts
|
||||
}
|
||||
|
||||
// 二分查找:返回 pos 所在行号
|
||||
export function getLineNumber(lineStarts: number[], pos: number): number {
|
||||
let lo = 0
|
||||
let hi = lineStarts.length - 1
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi + 1) >> 1
|
||||
if (lineStarts[mid] <= pos) lo = mid
|
||||
else hi = mid - 1
|
||||
}
|
||||
return lo
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './styles/variables.css'
|
||||
import './styles/global.css'
|
||||
import './styles/markdown-body.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
import { create } from 'zustand'
|
||||
import type { ViewMode } from '../types/settings'
|
||||
|
||||
interface EditorState {
|
||||
viewMode: ViewMode
|
||||
darkMode: boolean
|
||||
splitRatio: number
|
||||
|
||||
setViewMode: (mode: ViewMode) => void
|
||||
setDarkMode: (dark: boolean) => void
|
||||
setSplitRatio: (ratio: number) => void
|
||||
toggleDarkMode: () => void
|
||||
}
|
||||
|
||||
export const useEditorStore = create<EditorState>((set) => ({
|
||||
viewMode: 'split',
|
||||
darkMode: false,
|
||||
splitRatio: 50,
|
||||
|
||||
setViewMode: (mode) => set({ viewMode: mode }),
|
||||
setDarkMode: (dark) => set({ darkMode: dark }),
|
||||
setSplitRatio: (ratio) => set({ splitRatio: ratio }),
|
||||
toggleDarkMode: () => set(state => ({ darkMode: !state.darkMode }))
|
||||
}))
|
||||
@@ -0,0 +1,64 @@
|
||||
import { create } from 'zustand'
|
||||
import type { SearchMatch, SearchOptions } from '../types/search'
|
||||
|
||||
interface SearchState {
|
||||
isVisible: boolean
|
||||
showReplace: boolean
|
||||
searchText: string
|
||||
replaceText: string
|
||||
matches: SearchMatch[]
|
||||
currentIndex: number
|
||||
options: SearchOptions
|
||||
|
||||
setVisible: (visible: boolean) => void
|
||||
setShowReplace: (show: boolean) => void
|
||||
setSearchText: (text: string) => void
|
||||
setReplaceText: (text: string) => void
|
||||
setMatches: (matches: SearchMatch[]) => void
|
||||
setCurrentIndex: (index: number) => void
|
||||
toggleCaseSensitive: () => void
|
||||
toggleRegex: () => void
|
||||
findNext: () => void
|
||||
findPrev: () => void
|
||||
close: () => void
|
||||
}
|
||||
|
||||
export const useSearchStore = create<SearchState>((set, get) => ({
|
||||
isVisible: false,
|
||||
showReplace: false,
|
||||
searchText: '',
|
||||
replaceText: '',
|
||||
matches: [],
|
||||
currentIndex: -1,
|
||||
options: { caseSensitive: false, useRegex: false },
|
||||
|
||||
setVisible: (visible) => set({ isVisible: visible }),
|
||||
setShowReplace: (show) => set({ showReplace: show }),
|
||||
setSearchText: (text) => set({ searchText: text }),
|
||||
setReplaceText: (text) => set({ replaceText: text }),
|
||||
setMatches: (matches) => set({ matches, currentIndex: matches.length > 0 ? 0 : -1 }),
|
||||
setCurrentIndex: (index) => set({ currentIndex: index }),
|
||||
toggleCaseSensitive: () =>
|
||||
set(state => ({ options: { ...state.options, caseSensitive: !state.options.caseSensitive } })),
|
||||
toggleRegex: () =>
|
||||
set(state => ({ options: { ...state.options, useRegex: !state.options.useRegex } })),
|
||||
findNext: () => {
|
||||
const { matches, currentIndex } = get()
|
||||
if (matches.length === 0) return
|
||||
set({ currentIndex: (currentIndex + 1) % matches.length })
|
||||
},
|
||||
findPrev: () => {
|
||||
const { matches, currentIndex } = get()
|
||||
if (matches.length === 0) return
|
||||
set({ currentIndex: (currentIndex - 1 + matches.length) % matches.length })
|
||||
},
|
||||
close: () =>
|
||||
set({
|
||||
isVisible: false,
|
||||
showReplace: false,
|
||||
searchText: '',
|
||||
replaceText: '',
|
||||
matches: [],
|
||||
currentIndex: -1
|
||||
})
|
||||
}))
|
||||
@@ -0,0 +1,39 @@
|
||||
import { create } from 'zustand'
|
||||
import type { FileNode } from '../types/file'
|
||||
|
||||
interface SidebarState {
|
||||
isVisible: boolean
|
||||
rootPath: string | null
|
||||
tree: FileNode[]
|
||||
expandedDirs: Set<string>
|
||||
sidebarWidth: number
|
||||
|
||||
setVisible: (visible: boolean) => void
|
||||
setRootPath: (path: string | null) => void
|
||||
setTree: (tree: FileNode[]) => void
|
||||
toggleDir: (path: string) => void
|
||||
setSidebarWidth: (width: number) => void
|
||||
}
|
||||
|
||||
export const useSidebarStore = create<SidebarState>((set) => ({
|
||||
isVisible: true,
|
||||
rootPath: null,
|
||||
tree: [],
|
||||
expandedDirs: new Set<string>(),
|
||||
sidebarWidth: 240,
|
||||
|
||||
setVisible: (visible) => set({ isVisible: visible }),
|
||||
setRootPath: (path) => set({ rootPath: path }),
|
||||
setTree: (tree) => set({ tree }),
|
||||
toggleDir: (path) =>
|
||||
set(state => {
|
||||
const newSet = new Set(state.expandedDirs)
|
||||
if (newSet.has(path)) {
|
||||
newSet.delete(path)
|
||||
} else {
|
||||
newSet.add(path)
|
||||
}
|
||||
return { expandedDirs: newSet }
|
||||
}),
|
||||
setSidebarWidth: (width) => set({ sidebarWidth: width })
|
||||
}))
|
||||
@@ -0,0 +1,127 @@
|
||||
import { create } from 'zustand'
|
||||
import type { Tab } from '../types/tab'
|
||||
|
||||
interface TabState {
|
||||
tabs: Tab[]
|
||||
activeTabId: string | null
|
||||
mruStack: string[]
|
||||
|
||||
createTab: (filePath?: string | null, content?: string) => Tab
|
||||
closeTab: (tabId: string) => void
|
||||
switchToTab: (tabId: string) => void
|
||||
updateTabContent: (tabId: string, content: string) => void
|
||||
setModified: (tabId: string, modified: boolean) => void
|
||||
getActiveTab: () => Tab | null
|
||||
getTabIndex: (tabId: string) => number
|
||||
updateTabScroll: (tabId: string, scroll: Partial<Pick<Tab, 'scrollTop' | 'scrollLeft' | 'selectionStart' | 'selectionEnd' | 'previewScrollTop'>>) => void
|
||||
}
|
||||
|
||||
let tabIdCounter = 0
|
||||
|
||||
export const useTabStore = create<TabState>((set, get) => ({
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
mruStack: [],
|
||||
|
||||
createTab: (filePath = null, content = '') => {
|
||||
// 检查是否已打开
|
||||
if (filePath) {
|
||||
const existing = get().tabs.find(t => t.filePath === filePath)
|
||||
if (existing) {
|
||||
get().switchToTab(existing.id)
|
||||
return existing
|
||||
}
|
||||
}
|
||||
|
||||
const tab: Tab = {
|
||||
id: String(++tabIdCounter),
|
||||
filePath,
|
||||
content,
|
||||
isModified: false,
|
||||
scrollTop: 0,
|
||||
scrollLeft: 0,
|
||||
selectionStart: 0,
|
||||
selectionEnd: 0,
|
||||
previewScrollTop: 0
|
||||
}
|
||||
|
||||
set(state => ({
|
||||
tabs: [...state.tabs, tab],
|
||||
activeTabId: tab.id
|
||||
}))
|
||||
|
||||
return tab
|
||||
},
|
||||
|
||||
closeTab: (tabId) => {
|
||||
set(state => {
|
||||
const index = state.tabs.findIndex(t => t.id === tabId)
|
||||
if (index === -1) return state
|
||||
|
||||
const newTabs = state.tabs.filter(t => t.id !== tabId)
|
||||
const newMru = state.mruStack.filter(id => id !== tabId)
|
||||
|
||||
let newActiveId = state.activeTabId
|
||||
if (state.activeTabId === tabId) {
|
||||
if (newTabs.length === 0) {
|
||||
newActiveId = null
|
||||
} else {
|
||||
const newIndex = Math.min(index, newTabs.length - 1)
|
||||
newActiveId = newTabs[newIndex].id
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tabs: newTabs,
|
||||
activeTabId: newActiveId,
|
||||
mruStack: newMru
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
switchToTab: (tabId) => {
|
||||
set(state => {
|
||||
if (state.activeTabId === tabId) return state
|
||||
const newMru = state.activeTabId
|
||||
? [state.activeTabId, ...state.mruStack.filter(id => id !== state.activeTabId)]
|
||||
: state.mruStack
|
||||
return {
|
||||
activeTabId: tabId,
|
||||
mruStack: newMru
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
updateTabContent: (tabId, content) => {
|
||||
set(state => ({
|
||||
tabs: state.tabs.map(t =>
|
||||
t.id === tabId ? { ...t, content, isModified: true } : t
|
||||
)
|
||||
}))
|
||||
},
|
||||
|
||||
setModified: (tabId, modified) => {
|
||||
set(state => ({
|
||||
tabs: state.tabs.map(t =>
|
||||
t.id === tabId ? { ...t, isModified: modified } : t
|
||||
)
|
||||
}))
|
||||
},
|
||||
|
||||
getActiveTab: () => {
|
||||
const { tabs, activeTabId } = get()
|
||||
return tabs.find(t => t.id === activeTabId) ?? null
|
||||
},
|
||||
|
||||
getTabIndex: (tabId) => {
|
||||
return get().tabs.findIndex(t => t.id === tabId)
|
||||
},
|
||||
|
||||
updateTabScroll: (tabId, scroll) => {
|
||||
set(state => ({
|
||||
tabs: state.tabs.map(t =>
|
||||
t.id === tabId ? { ...t, ...scroll } : t
|
||||
)
|
||||
}))
|
||||
}
|
||||
}))
|
||||
@@ -0,0 +1,792 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* Toolbar */
|
||||
#toolbar {
|
||||
height: var(--toolbar-height);
|
||||
background: var(--bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 8px;
|
||||
flex-shrink: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.toolbar-left, .toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-family: var(--font-ui);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toolbar-btn:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.toolbar-btn.active {
|
||||
background: var(--primary-light);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.toolbar-btn svg { flex-shrink: 0; }
|
||||
|
||||
.toolbar-divider {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: var(--border);
|
||||
margin: 0 6px;
|
||||
}
|
||||
|
||||
/* Workspace */
|
||||
#workspace {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
#main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#content-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Editor Panel */
|
||||
#editor-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
#editor-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#line-numbers {
|
||||
width: 50px;
|
||||
background: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border-light);
|
||||
padding: 12px 0;
|
||||
text-align: right;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-tertiary);
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#line-numbers .line-num {
|
||||
padding-right: 12px;
|
||||
height: 20.8px;
|
||||
}
|
||||
|
||||
#editor {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: none;
|
||||
outline: none;
|
||||
resize: none;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
tab-size: 4;
|
||||
overflow-y: auto;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
#editor::placeholder { color: var(--text-tertiary); }
|
||||
|
||||
/* Resizer */
|
||||
#resizer {
|
||||
width: 4px;
|
||||
background: var(--border);
|
||||
cursor: col-resize;
|
||||
transition: background 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#resizer:hover, #resizer.active {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
/* Preview Panel */
|
||||
#preview-panel {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-width: 0;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
#preview {
|
||||
padding: 24px 32px;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
user-select: text;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
/* Tab Bar */
|
||||
#tab-bar {
|
||||
height: 36px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
padding: 0 4px;
|
||||
flex-shrink: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
#tab-bar::-webkit-scrollbar { height: 0; }
|
||||
|
||||
#tab-list {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 1px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
#tab-list::-webkit-scrollbar { height: 0; }
|
||||
|
||||
.tab-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-ui);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
max-width: 180px;
|
||||
min-width: 60px;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab-item:hover {
|
||||
color: var(--text);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.tab-item.active {
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
|
||||
.tab-item.modified .tab-name::after {
|
||||
content: ' •';
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.tab-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
opacity: 0;
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
|
||||
.tab-item:hover .tab-close,
|
||||
.tab-item.active .tab-close { opacity: 1; }
|
||||
|
||||
.tab-close:hover {
|
||||
background: var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tab-add-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
margin-left: 2px;
|
||||
margin-bottom: 4px;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.tab-add-btn:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Modified Banner */
|
||||
#modified-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 6px 12px;
|
||||
background: #fff3cd;
|
||||
border-bottom: 1px solid #ffc107;
|
||||
font-size: 13px;
|
||||
color: #856404;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:root.dark #modified-banner {
|
||||
background: #3a3000;
|
||||
border-bottom-color: #665500;
|
||||
color: #ffd54f;
|
||||
}
|
||||
|
||||
.banner-btn {
|
||||
padding: 2px 10px;
|
||||
border: 1px solid #ffc107;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #856404;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
|
||||
:root.dark .banner-btn {
|
||||
border-color: #665500;
|
||||
color: #ffd54f;
|
||||
}
|
||||
|
||||
.banner-btn:hover {
|
||||
background: #ffc107;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
/* Status Bar */
|
||||
#statusbar {
|
||||
height: var(--statusbar-height);
|
||||
background: var(--bg-secondary);
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-left, .status-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-divider { color: var(--border); }
|
||||
|
||||
/* Drop Overlay */
|
||||
#drop-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(26, 115, 232, 0.1);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
border: 3px dashed var(--primary);
|
||||
margin: 8px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.drop-content {
|
||||
text-align: center;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.drop-content svg {
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.drop-content p {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Welcome Screen */
|
||||
#welcome-screen {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg);
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.welcome-content {
|
||||
text-align: center;
|
||||
max-width: 480px;
|
||||
padding: 40px;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.welcome-icon { margin-bottom: 24px; }
|
||||
|
||||
.welcome-content h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.welcome-content > p {
|
||||
font-size: 16px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.welcome-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.welcome-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 24px;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
font-size: 14px;
|
||||
font-family: var(--font-ui);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.welcome-btn.primary {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.welcome-btn.primary:hover {
|
||||
background: var(--primary-dark);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.welcome-btn.secondary {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.welcome-btn.secondary:hover { background: var(--bg-tertiary); }
|
||||
|
||||
.welcome-tips {
|
||||
text-align: left;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px 20px;
|
||||
border: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.welcome-tips p {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.welcome-tips p:last-child { margin-bottom: 0; }
|
||||
|
||||
/* Sidebar */
|
||||
#sidebar {
|
||||
width: var(--sidebar-width);
|
||||
min-width: 180px;
|
||||
max-width: 500px;
|
||||
background: var(--sidebar-bg);
|
||||
border-right: 1px solid var(--sidebar-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#sidebar.collapsed { display: none; }
|
||||
|
||||
#sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--sidebar-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#sidebar-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.sidebar-header-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.sidebar-header-btn:hover {
|
||||
background: var(--sidebar-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
#sidebar-tree {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.tree-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 3px 8px 3px 0;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
transition: background 0.1s ease;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.tree-item:hover { background: var(--sidebar-hover); }
|
||||
|
||||
.tree-item.active {
|
||||
background: var(--sidebar-active);
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tree-indent { flex-shrink: 0; }
|
||||
|
||||
.tree-arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-tertiary);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.tree-arrow.expanded { transform: rotate(90deg); }
|
||||
|
||||
.tree-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.tree-icon svg { width: 14px; height: 14px; }
|
||||
|
||||
.tree-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Independent Files Section */
|
||||
.independent-files-section {
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 4px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.sidebar-section-header, .independent-files-header {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 8px 12px 4px 12px;
|
||||
}
|
||||
|
||||
.independent-file-item .independent-modified-dot {
|
||||
color: var(--primary);
|
||||
font-size: 14px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.sidebar-resize-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -2px;
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.sidebar-resize-handle:hover,
|
||||
.sidebar-resize-handle:active {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
/* Search & Replace Bar */
|
||||
#btn-toggle-replace {
|
||||
font-size: 10px;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
#btn-toggle-replace.expanded { transform: rotate(90deg); }
|
||||
|
||||
#search-bar {
|
||||
background: var(--search-bg);
|
||||
border-bottom: 1px solid var(--search-border);
|
||||
padding: 6px 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-row, #replace-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
#replace-row { margin-top: 4px; }
|
||||
|
||||
#search-input, #replace-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 26px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-family: var(--font-ui);
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
#search-input:focus, #replace-input:focus { border-color: var(--primary); }
|
||||
|
||||
#search-count {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
min-width: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.search-opt-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 26px;
|
||||
height: 26px;
|
||||
padding: 0 4px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-mono);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.search-opt-btn:hover { background: var(--bg-tertiary); }
|
||||
|
||||
.search-opt-btn.active {
|
||||
background: var(--primary-light);
|
||||
color: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.search-nav-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.search-nav-btn:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.replace-btn {
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-ui);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.replace-btn:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* View Modes */
|
||||
#app.mode-preview #editor-panel,
|
||||
#app.mode-preview #resizer { display: none; }
|
||||
|
||||
/* Toast */
|
||||
#toast-notification {
|
||||
position: fixed;
|
||||
bottom: 40px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(20px);
|
||||
background: var(--text);
|
||||
color: var(--bg);
|
||||
padding: 8px 20px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-family: var(--font-ui);
|
||||
box-shadow: var(--shadow-lg);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
z-index: 9999;
|
||||
max-width: 480px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#toast-notification.show {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--text-tertiary); }
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/* Markdown Body Styles */
|
||||
.markdown-body {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
color: var(--text);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.markdown-body h1, .markdown-body h2, .markdown-body h3,
|
||||
.markdown-body h4, .markdown-body h5, .markdown-body h6 {
|
||||
margin-top: 24px;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.markdown-body h1 {
|
||||
font-size: 2em;
|
||||
padding-bottom: 0.3em;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.markdown-body h2 {
|
||||
font-size: 1.5em;
|
||||
padding-bottom: 0.3em;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.markdown-body h3 { font-size: 1.25em; }
|
||||
.markdown-body h4 { font-size: 1em; }
|
||||
.markdown-body h5 { font-size: 0.875em; }
|
||||
.markdown-body h6 { font-size: 0.85em; color: var(--text-secondary); }
|
||||
|
||||
.markdown-body p {
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.markdown-body a {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.markdown-body a:hover { text-decoration: underline; }
|
||||
.markdown-body strong { font-weight: 600; }
|
||||
|
||||
.markdown-body img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--radius);
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.markdown-body hr {
|
||||
height: 2px;
|
||||
background: var(--border);
|
||||
border: none;
|
||||
margin: 24px 0;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.markdown-body blockquote {
|
||||
margin: 0 0 16px 0;
|
||||
padding: 4px 16px;
|
||||
border-left: 4px solid var(--primary);
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 0 var(--radius) var(--radius) 0;
|
||||
}
|
||||
|
||||
.markdown-body blockquote p:last-child { margin-bottom: 0; }
|
||||
|
||||
.markdown-body ul, .markdown-body ol {
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
padding-left: 2em;
|
||||
}
|
||||
|
||||
.markdown-body li { margin-top: 4px; }
|
||||
.markdown-body li + li { margin-top: 4px; }
|
||||
|
||||
.markdown-body code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9em;
|
||||
background: var(--code-bg);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
color: #e83e8c;
|
||||
}
|
||||
|
||||
:root.dark .markdown-body code { color: #f48fb1; }
|
||||
|
||||
.markdown-body pre {
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
background: var(--code-bg);
|
||||
border-radius: var(--radius);
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.markdown-body pre code {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.markdown-body table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin-bottom: 16px;
|
||||
overflow-x: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.markdown-body table th, .markdown-body table td {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--border);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.markdown-body table th {
|
||||
font-weight: 600;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.markdown-body table tr:nth-child(even) { background: var(--bg-secondary); }
|
||||
|
||||
.markdown-body input[type="checkbox"] {
|
||||
margin-right: 6px;
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
:root {
|
||||
--primary: #1a73e8;
|
||||
--primary-light: #e8f0fe;
|
||||
--primary-dark: #1557b0;
|
||||
--bg: #ffffff;
|
||||
--bg-secondary: #f8f9fa;
|
||||
--bg-tertiary: #f1f3f4;
|
||||
--text: #333333;
|
||||
--text-secondary: #5f6368;
|
||||
--text-tertiary: #9aa0a6;
|
||||
--border: #e1e4e8;
|
||||
--border-light: #f0f0f0;
|
||||
--code-bg: #f6f8fa;
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
--shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
--radius: 6px;
|
||||
--toolbar-height: 44px;
|
||||
--statusbar-height: 28px;
|
||||
--sidebar-width: 240px;
|
||||
--sidebar-bg: var(--bg-secondary);
|
||||
--sidebar-border: var(--border);
|
||||
--sidebar-hover: var(--bg-tertiary);
|
||||
--sidebar-active: var(--primary-light);
|
||||
--search-bg: var(--bg-secondary);
|
||||
--search-border: var(--border);
|
||||
--font-ui: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--font-mono: "Cascadia Code", "Fira Code", "JetBrains Mono", Consolas, "Courier New", monospace;
|
||||
}
|
||||
|
||||
:root.dark {
|
||||
--primary: #8ab4f8;
|
||||
--primary-light: #1a3a5c;
|
||||
--primary-dark: #aecbfa;
|
||||
--bg: #1e1e1e;
|
||||
--bg-secondary: #252526;
|
||||
--bg-tertiary: #2d2d2d;
|
||||
--text: #d4d4d4;
|
||||
--text-secondary: #9e9e9e;
|
||||
--text-tertiary: #6e6e6e;
|
||||
--border: #3e3e3e;
|
||||
--border-light: #333333;
|
||||
--code-bg: #2d2d2d;
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
--shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export interface FileNode {
|
||||
name: string
|
||||
path: string
|
||||
type: 'file' | 'dir'
|
||||
children?: FileNode[]
|
||||
}
|
||||
|
||||
export interface DirTreeResult {
|
||||
success: boolean
|
||||
tree?: FileNode[]
|
||||
rootPath?: string
|
||||
error?: string
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type { Tab } from './tab'
|
||||
export type { FileNode, DirTreeResult } from './file'
|
||||
export type { ViewMode, Settings } from './settings'
|
||||
export type { SearchMatch, SearchOptions } from './search'
|
||||
export type { ElectronAPI, IpcInvokeMap } from './ipc'
|
||||
@@ -0,0 +1,59 @@
|
||||
import type {
|
||||
OpenFileResponse,
|
||||
ReadFileResult,
|
||||
SaveFilePayload,
|
||||
SaveFileResult,
|
||||
SaveAsPayload,
|
||||
ReloadFileResult,
|
||||
FileStatsResult,
|
||||
ReadDirTreeResult
|
||||
} from '../../shared/types'
|
||||
|
||||
export interface IpcInvokeMap {
|
||||
'dialog:openFile': [void, OpenFileResponse]
|
||||
'file:read': [string, ReadFileResult]
|
||||
'file:save': [SaveFilePayload, SaveFileResult]
|
||||
'file:saveAs': [SaveAsPayload, SaveFileResult]
|
||||
'file:getCurrentPath': [void, string | null]
|
||||
'file:stats': [string, FileStatsResult]
|
||||
'file:reload': [void, ReloadFileResult]
|
||||
'tab:switched': [string | null, void]
|
||||
'window:forceClose': [void, void]
|
||||
'window:cancelClose': [void, void]
|
||||
'dir:readTree': [string, ReadDirTreeResult]
|
||||
'dir:openDialog': [void, string | null]
|
||||
'dir:watch': [string, void]
|
||||
'dir:unwatch': [void, void]
|
||||
}
|
||||
|
||||
export interface ElectronAPI {
|
||||
openFile: () => Promise<OpenFileResponse>
|
||||
readFile: (filePath: string) => Promise<ReadFileResult>
|
||||
saveFile: (data: SaveFilePayload) => Promise<SaveFileResult>
|
||||
saveFileAs: (data: SaveAsPayload) => Promise<SaveFileResult>
|
||||
getCurrentPath: () => Promise<string | null>
|
||||
getFileStats: (filePath: string) => Promise<FileStatsResult>
|
||||
reloadFile: () => Promise<ReloadFileResult>
|
||||
tabSwitched: (filePath: string | null) => Promise<void>
|
||||
forceClose: () => Promise<void>
|
||||
cancelClose: () => Promise<void>
|
||||
openExternal: (url: string) => void
|
||||
readDirTree: (dirPath: string) => Promise<ReadDirTreeResult>
|
||||
openFolderDialog: () => Promise<string | null>
|
||||
watchDir: (dirPath: string) => Promise<void>
|
||||
unwatchDir: () => Promise<void>
|
||||
onFileOpenInTab: (callback: (data: { filePath: string; content: string }) => void) => void
|
||||
onMenuSave: (callback: () => void) => void
|
||||
onMenuSaveAs: (callback: () => void) => void
|
||||
onViewModeChange: (callback: (mode: string) => void) => void
|
||||
onExternalModification: (callback: (filePath: string) => void) => void
|
||||
onDirChanged: (callback: () => void) => void
|
||||
onConfirmClose: (callback: () => void) => void
|
||||
removeAllListeners: (channel: string) => void
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI?: ElectronAPI
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface SearchMatch {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
export interface SearchOptions {
|
||||
caseSensitive: boolean
|
||||
useRegex: boolean
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export type ViewMode = 'split' | 'editor' | 'preview'
|
||||
|
||||
export interface Settings {
|
||||
darkMode: boolean
|
||||
viewMode: ViewMode
|
||||
splitRatio: number
|
||||
sidebarCollapsed: boolean
|
||||
sidebarWidth: number
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
darkMode: false,
|
||||
viewMode: 'split',
|
||||
splitRatio: 50,
|
||||
sidebarCollapsed: false,
|
||||
sidebarWidth: 240
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface Tab {
|
||||
id: string
|
||||
filePath: string | null
|
||||
content: string
|
||||
isModified: boolean
|
||||
scrollTop: number
|
||||
scrollLeft: number
|
||||
selectionStart: number
|
||||
selectionEnd: number
|
||||
previewScrollTop: number
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// IPC 通道名常量 — 主进程和渲染进程共享
|
||||
export const IPC_CHANNELS = {
|
||||
// 渲染进程 → 主进程 (invoke)
|
||||
DIALOG_OPEN_FILE: 'dialog:openFile',
|
||||
FILE_READ: 'file:read',
|
||||
FILE_SAVE: 'file:save',
|
||||
FILE_SAVE_AS: 'file:saveAs',
|
||||
FILE_GET_CURRENT_PATH: 'file:getCurrentPath',
|
||||
FILE_STATS: 'file:stats',
|
||||
FILE_RELOAD: 'file:reload',
|
||||
TAB_SWITCHED: 'tab:switched',
|
||||
WINDOW_FORCE_CLOSE: 'window:forceClose',
|
||||
WINDOW_CANCEL_CLOSE: 'window:cancelClose',
|
||||
DIR_READ_TREE: 'dir:readTree',
|
||||
DIR_OPEN_DIALOG: 'dir:openDialog',
|
||||
DIR_WATCH: 'dir:watch',
|
||||
DIR_UNWATCH: 'dir:unwatch',
|
||||
|
||||
// 主进程 → 渲染进程 (send)
|
||||
FILE_OPEN_IN_TAB: 'file:openInTab',
|
||||
FILE_EXTERNALLY_MODIFIED: 'file:externallyModified',
|
||||
MENU_SAVE: 'menu:save',
|
||||
MENU_SAVE_AS: 'menu:saveAs',
|
||||
MENU_VIEW_MODE: 'menu:viewMode',
|
||||
WINDOW_CONFIRM_CLOSE: 'window:confirmClose',
|
||||
SIDEBAR_DIR_CHANGED: 'sidebar:dirChanged'
|
||||
} as const
|
||||
|
||||
export type IpcChannel = (typeof IPC_CHANNELS)[keyof typeof IPC_CHANNELS]
|
||||
@@ -0,0 +1,65 @@
|
||||
// 共享类型定义 — 主进程和渲染进程共用
|
||||
|
||||
export interface FileNode {
|
||||
name: string
|
||||
path: string
|
||||
type: 'file' | 'dir'
|
||||
children?: FileNode[]
|
||||
}
|
||||
|
||||
export interface ReadFileResult {
|
||||
success: boolean
|
||||
content?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface SaveFilePayload {
|
||||
filePath: string | null
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface SaveAsPayload {
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface SaveFileResult {
|
||||
success: boolean
|
||||
filePath?: string
|
||||
error?: string
|
||||
canceled?: boolean
|
||||
}
|
||||
|
||||
export interface ReloadFileResult {
|
||||
success: boolean
|
||||
content?: string
|
||||
filePath?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface FileStatsResult {
|
||||
success: boolean
|
||||
size?: number
|
||||
mtime?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface ReadDirTreeResult {
|
||||
success: boolean
|
||||
tree?: FileNode[]
|
||||
rootPath?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface OpenFileResult {
|
||||
filePath: string
|
||||
content: string
|
||||
error?: never
|
||||
}
|
||||
|
||||
export interface OpenFileError {
|
||||
error: string
|
||||
filePath?: never
|
||||
content?: never
|
||||
}
|
||||
|
||||
export type OpenFileResponse = OpenFileResult | OpenFileError | null
|
||||
Reference in New Issue
Block a user