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:
thzxx
2026-05-27 20:29:23 +08:00
parent c2cdba546b
commit 5e1c89d280
59 changed files with 4674 additions and 314 deletions
+75
View File
@@ -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'
}
+83
View File
@@ -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
}
}
+129
View File
@@ -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()
}
})
}
+23
View File
@@ -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
+183
View File
@@ -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, () => {
// 重置关闭状态
})
}
+59
View File
@@ -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
}
+47
View File
@@ -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)
})
+208
View File
@@ -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>
)
}
+210
View File
@@ -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>
)
}
+220
View File
@@ -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>
)
}
+60
View File
@@ -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>
)
}
+13
View File
@@ -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>
)
}
+29
View File
@@ -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()
}
}
+43
View File
@@ -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 }
+29
View File
@@ -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)
}
}
+18
View File
@@ -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()
}
}
+66
View File
@@ -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])
}
+66
View File
@@ -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 }
}
+124
View File
@@ -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])
}
+31
View File
@@ -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 }
}
+24
View File
@@ -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) }
}
+35
View File
@@ -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])
}
+13
View File
@@ -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>
+13
View File
@@ -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
+20
View File
@@ -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()
}
+32
View File
@@ -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>`
}
}
+109
View File
@@ -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 }
}
+56
View File
@@ -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
}
+12
View File
@@ -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>
)
+24
View File
@@ -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 }))
}))
+64
View File
@@ -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
})
}))
+39
View File
@@ -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 })
}))
+127
View File
@@ -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
)
}))
}
}))
+792
View File
@@ -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); }
}
+138
View File
@@ -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);
}
+45
View File
@@ -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);
}
+13
View File
@@ -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
}
+5
View File
@@ -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'
+59
View File
@@ -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
}
}
+9
View File
@@ -0,0 +1,9 @@
export interface SearchMatch {
start: number
end: number
}
export interface SearchOptions {
caseSensitive: boolean
useRegex: boolean
}
+17
View File
@@ -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
}
+11
View File
@@ -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
}
+29
View File
@@ -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]
+65
View File
@@ -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