v0.3.4: Bug修复+性能优化+文档大纲功能
🔴 Bug修复 (6): - Sidebar反斜杠正则修复 (Windows路径规范化) - FileWatcher文件删除后自动恢复监听 - SearchReplace replaceAll防过期匹配位置 - openFolderDialog null结果守卫 - 保存异常时空路径保护 - Markdown绝对路径图片拼接修复 🟡 性能优化: - Editor切换标签时内容比较,相同跳过replaceAll 🔵 代码质量: - 创建共享常量模块 src/shared/constants.ts,消除双副本 🎯 新增功能: - 文档大纲 (Table of Contents) — 侧边栏标题导航 🔧 换行符统一: CRLF → LF
This commit is contained in:
@@ -1,13 +1,9 @@
|
||||
import { readFile, stat, writeFile, rename, readdir, unlink } from 'fs/promises'
|
||||
import { join, extname, dirname, basename } from 'path'
|
||||
import type { ReadFileResult, SaveFileResult, FileNode } from '../shared/types'
|
||||
import { MAX_FILE_SIZE, ALLOWED_EXTENSIONS, SKIP_DIRS } from '../shared/constants'
|
||||
|
||||
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'
|
||||
])
|
||||
const ALLOWED_EXTENSIONS_SET = new Set<string>(ALLOWED_EXTENSIONS)
|
||||
|
||||
export async function readFileContent(filePath: string): Promise<ReadFileResult> {
|
||||
try {
|
||||
@@ -67,7 +63,7 @@ export async function buildDirTree(dirPath: string, depth = 0, maxDepth = 10): P
|
||||
}
|
||||
} else {
|
||||
const ext = extname(entry.name).toLowerCase()
|
||||
if (ALLOWED_EXTENSIONS.has(ext)) {
|
||||
if (ALLOWED_EXTENSIONS_SET.has(ext)) {
|
||||
children.push({ name: entry.name, path: childPath, type: 'file' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,12 +24,22 @@ export class FileWatcher {
|
||||
})
|
||||
// L-02: 监听 error 事件,文件被删除时显式关闭并清理状态
|
||||
this.watcher.on('error', () => {
|
||||
if (this.watcher) {
|
||||
this.watcher.close()
|
||||
this.watcher = null
|
||||
this.stop()
|
||||
const originalPath = this.currentPath
|
||||
if (originalPath) {
|
||||
const pollInterval = setInterval(() => {
|
||||
try {
|
||||
if (fs.existsSync(originalPath)) {
|
||||
clearInterval(pollInterval)
|
||||
this.start(originalPath)
|
||||
}
|
||||
} catch {
|
||||
clearInterval(pollInterval)
|
||||
}
|
||||
}, 2000)
|
||||
// 最多轮询30秒
|
||||
setTimeout(() => clearInterval(pollInterval), 30000)
|
||||
}
|
||||
this.currentPath = null
|
||||
this.isSelfWriting = false
|
||||
})
|
||||
} catch {
|
||||
// silently ignore
|
||||
|
||||
+140
-140
@@ -1,140 +1,140 @@
|
||||
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 && mainWindow && !mainWindow.isDestroyed()) {
|
||||
state.activeFilePath = filePath
|
||||
fileWatcher.start(filePath)
|
||||
mainWindow.setTitle(`MarkLite - ${filePath.split(/[/\\]/).pop()}`)
|
||||
mainWindow.webContents.send('file:openInTab', { filePath, content: result.content })
|
||||
}
|
||||
}).catch((err) => {
|
||||
// eslint-disable-next-line no-console -- IPC file open error
|
||||
console.error('openFileInTab failed:', err)
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
// C-05: 窗口重建时重置状态
|
||||
function initWindow(): void {
|
||||
state.isClosing = false
|
||||
if (state.closeTimeout) {
|
||||
clearTimeout(state.closeTimeout)
|
||||
state.closeTimeout = null
|
||||
}
|
||||
|
||||
mainWindow = createWindow()
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
})
|
||||
|
||||
// C-03: IPC 处理器只注册一次
|
||||
registerIpcHandlers(() => mainWindow, fileWatcher, sidebarWatcher, state)
|
||||
|
||||
initWindow()
|
||||
|
||||
const cmdFile = getFilePathFromArgs(process.argv)
|
||||
if (cmdFile) {
|
||||
state.pendingFilePath = cmdFile
|
||||
}
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') app.quit()
|
||||
})
|
||||
|
||||
// C-03: activate 只重建窗口,不重复注册 IPC
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
initWindow()
|
||||
}
|
||||
})
|
||||
}
|
||||
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 && mainWindow && !mainWindow.isDestroyed()) {
|
||||
state.activeFilePath = filePath
|
||||
fileWatcher.start(filePath)
|
||||
mainWindow.setTitle(`MarkLite - ${filePath.split(/[/\\]/).pop()}`)
|
||||
mainWindow.webContents.send('file:openInTab', { filePath, content: result.content })
|
||||
}
|
||||
}).catch((err) => {
|
||||
// eslint-disable-next-line no-console -- IPC file open error
|
||||
console.error('openFileInTab failed:', err)
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
// C-05: 窗口重建时重置状态
|
||||
function initWindow(): void {
|
||||
state.isClosing = false
|
||||
if (state.closeTimeout) {
|
||||
clearTimeout(state.closeTimeout)
|
||||
state.closeTimeout = null
|
||||
}
|
||||
|
||||
mainWindow = createWindow()
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
})
|
||||
|
||||
// C-03: IPC 处理器只注册一次
|
||||
registerIpcHandlers(() => mainWindow, fileWatcher, sidebarWatcher, state)
|
||||
|
||||
initWindow()
|
||||
|
||||
const cmdFile = getFilePathFromArgs(process.argv)
|
||||
if (cmdFile) {
|
||||
state.pendingFilePath = cmdFile
|
||||
}
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') app.quit()
|
||||
})
|
||||
|
||||
// C-03: activate 只重建窗口,不重复注册 IPC
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
initWindow()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+230
-228
@@ -1,228 +1,230 @@
|
||||
import { ipcMain, dialog, BrowserWindow, type IpcMainInvokeEvent } from 'electron'
|
||||
import { readFileContent, saveFileContent, buildDirTree } from './file-system'
|
||||
import { FileWatcher, SidebarWatcher } from './file-watcher'
|
||||
import { IPC_CHANNELS } from '../shared/ipc-channels'
|
||||
import { stat } from 'fs/promises'
|
||||
import { basename, isAbsolute } from 'path'
|
||||
|
||||
// 安全校验:拒绝路径遍历攻击
|
||||
function validatePath(filePath: string): boolean {
|
||||
if (!filePath || typeof filePath !== 'string') return false
|
||||
// 拒绝空字节
|
||||
if (filePath.includes('\0')) return false
|
||||
// 在 normalize 之前检查原始路径中的 .. 序列,防止 normalize 解析后绕过
|
||||
// 例如 '/valid/path/../../etc/shadow' normalize 后变为 '/etc/shadow',.. 已消失
|
||||
if (filePath.includes('..')) return false
|
||||
// 必须是绝对路径
|
||||
if (!isAbsolute(filePath)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export function registerIpcHandlers(
|
||||
getMainWindow: () => BrowserWindow | null,
|
||||
fileWatcher: FileWatcher,
|
||||
sidebarWatcher: SidebarWatcher,
|
||||
state: { activeFilePath: string | null; pendingFilePath: string | null; isClosing: boolean; closeTimeout: NodeJS.Timeout | 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: IpcMainInvokeEvent, filePath: string) => {
|
||||
if (!validatePath(filePath)) {
|
||||
return { success: false, error: '无效的文件路径' }
|
||||
}
|
||||
return readFileContent(filePath)
|
||||
})
|
||||
|
||||
// 保存文件
|
||||
ipcMain.handle(IPC_CHANNELS.FILE_SAVE, async (_event: IpcMainInvokeEvent, data: { filePath: string | null; content: string }) => {
|
||||
if (data.filePath && !validatePath(data.filePath)) {
|
||||
return { success: false, error: '无效的文件路径' }
|
||||
}
|
||||
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) {
|
||||
fileWatcher.setSelfWriting(true)
|
||||
const result = await saveFileContent(saveResult.filePath, data.content)
|
||||
fileWatcher.setSelfWriting(false)
|
||||
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: IpcMainInvokeEvent, 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) {
|
||||
fileWatcher.stop()
|
||||
fileWatcher.setSelfWriting(true)
|
||||
const saveResult = await saveFileContent(result.filePath, data.content)
|
||||
fileWatcher.setSelfWriting(false)
|
||||
if (saveResult.success) {
|
||||
state.activeFilePath = result.filePath
|
||||
setTimeout(() => {
|
||||
if (state.activeFilePath === result.filePath) {
|
||||
fileWatcher.start(result.filePath)
|
||||
}
|
||||
}, 300)
|
||||
win.setTitle(`MarkLite - ${basename(result.filePath)}`)
|
||||
}
|
||||
return saveResult
|
||||
}
|
||||
return { success: false, canceled: true }
|
||||
} catch (err) {
|
||||
fileWatcher.setSelfWriting(false)
|
||||
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: IpcMainInvokeEvent, filePath: string) => {
|
||||
if (!validatePath(filePath)) {
|
||||
return { success: false, error: '无效的文件路径' }
|
||||
}
|
||||
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: IpcMainInvokeEvent, dirPath: string) => {
|
||||
if (!validatePath(dirPath)) {
|
||||
return { success: false, error: '无效的目录路径' }
|
||||
}
|
||||
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: IpcMainInvokeEvent, dirPath: string) => {
|
||||
if (!validatePath(dirPath)) return
|
||||
sidebarWatcher.start(dirPath)
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.DIR_UNWATCH, () => {
|
||||
sidebarWatcher.stop()
|
||||
})
|
||||
|
||||
// 标签切换
|
||||
ipcMain.handle(IPC_CHANNELS.TAB_SWITCHED, (_event: IpcMainInvokeEvent, filePath: string | null) => {
|
||||
state.activeFilePath = filePath || null
|
||||
if (filePath && !validatePath(filePath)) {
|
||||
fileWatcher.stop()
|
||||
return
|
||||
}
|
||||
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()
|
||||
}
|
||||
})
|
||||
|
||||
// B-01: 重置关闭状态 + 清除超时定时器
|
||||
ipcMain.handle(IPC_CHANNELS.WINDOW_CANCEL_CLOSE, () => {
|
||||
state.isClosing = false
|
||||
if (state.closeTimeout) {
|
||||
clearTimeout(state.closeTimeout)
|
||||
state.closeTimeout = null
|
||||
}
|
||||
})
|
||||
}
|
||||
import { ipcMain, dialog, BrowserWindow, type IpcMainInvokeEvent } from 'electron'
|
||||
import { readFileContent, saveFileContent, buildDirTree } from './file-system'
|
||||
import { FileWatcher, SidebarWatcher } from './file-watcher'
|
||||
import { IPC_CHANNELS } from '../shared/ipc-channels'
|
||||
import { stat } from 'fs/promises'
|
||||
import { basename, isAbsolute } from 'path'
|
||||
|
||||
// 安全校验:拒绝路径遍历攻击
|
||||
function validatePath(filePath: string): boolean {
|
||||
if (!filePath || typeof filePath !== 'string') return false
|
||||
// 拒绝空字节
|
||||
if (filePath.includes('\0')) return false
|
||||
// 在 normalize 之前检查原始路径中的 .. 序列,防止 normalize 解析后绕过
|
||||
// 例如 '/valid/path/../../etc/shadow' normalize 后变为 '/etc/shadow',.. 已消失
|
||||
if (filePath.includes('..')) return false
|
||||
// 必须是绝对路径
|
||||
if (!isAbsolute(filePath)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export function registerIpcHandlers(
|
||||
getMainWindow: () => BrowserWindow | null,
|
||||
fileWatcher: FileWatcher,
|
||||
sidebarWatcher: SidebarWatcher,
|
||||
state: { activeFilePath: string | null; pendingFilePath: string | null; isClosing: boolean; closeTimeout: NodeJS.Timeout | 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: IpcMainInvokeEvent, filePath: string) => {
|
||||
if (!validatePath(filePath)) {
|
||||
return { success: false, error: '无效的文件路径' }
|
||||
}
|
||||
return readFileContent(filePath)
|
||||
})
|
||||
|
||||
// 保存文件
|
||||
ipcMain.handle(IPC_CHANNELS.FILE_SAVE, async (_event: IpcMainInvokeEvent, data: { filePath: string | null; content: string }) => {
|
||||
if (data.filePath && !validatePath(data.filePath)) {
|
||||
return { success: false, error: '无效的文件路径' }
|
||||
}
|
||||
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) {
|
||||
fileWatcher.setSelfWriting(true)
|
||||
const result = await saveFileContent(saveResult.filePath, data.content)
|
||||
fileWatcher.setSelfWriting(false)
|
||||
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)
|
||||
if (state.activeFilePath) {
|
||||
fileWatcher.start(state.activeFilePath)
|
||||
}
|
||||
return { success: false, error: (err as Error).message }
|
||||
}
|
||||
})
|
||||
|
||||
// 另存为
|
||||
ipcMain.handle(IPC_CHANNELS.FILE_SAVE_AS, async (_event: IpcMainInvokeEvent, 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) {
|
||||
fileWatcher.stop()
|
||||
fileWatcher.setSelfWriting(true)
|
||||
const saveResult = await saveFileContent(result.filePath, data.content)
|
||||
fileWatcher.setSelfWriting(false)
|
||||
if (saveResult.success) {
|
||||
state.activeFilePath = result.filePath
|
||||
setTimeout(() => {
|
||||
if (state.activeFilePath === result.filePath) {
|
||||
fileWatcher.start(result.filePath)
|
||||
}
|
||||
}, 300)
|
||||
win.setTitle(`MarkLite - ${basename(result.filePath)}`)
|
||||
}
|
||||
return saveResult
|
||||
}
|
||||
return { success: false, canceled: true }
|
||||
} catch (err) {
|
||||
fileWatcher.setSelfWriting(false)
|
||||
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: IpcMainInvokeEvent, filePath: string) => {
|
||||
if (!validatePath(filePath)) {
|
||||
return { success: false, error: '无效的文件路径' }
|
||||
}
|
||||
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: IpcMainInvokeEvent, dirPath: string) => {
|
||||
if (!validatePath(dirPath)) {
|
||||
return { success: false, error: '无效的目录路径' }
|
||||
}
|
||||
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: IpcMainInvokeEvent, dirPath: string) => {
|
||||
if (!validatePath(dirPath)) return
|
||||
sidebarWatcher.start(dirPath)
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.DIR_UNWATCH, () => {
|
||||
sidebarWatcher.stop()
|
||||
})
|
||||
|
||||
// 标签切换
|
||||
ipcMain.handle(IPC_CHANNELS.TAB_SWITCHED, (_event: IpcMainInvokeEvent, filePath: string | null) => {
|
||||
state.activeFilePath = filePath || null
|
||||
if (filePath && !validatePath(filePath)) {
|
||||
fileWatcher.stop()
|
||||
return
|
||||
}
|
||||
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()
|
||||
}
|
||||
})
|
||||
|
||||
// B-01: 重置关闭状态 + 清除超时定时器
|
||||
ipcMain.handle(IPC_CHANNELS.WINDOW_CANCEL_CLOSE, () => {
|
||||
state.isClosing = false
|
||||
if (state.closeTimeout) {
|
||||
clearTimeout(state.closeTimeout)
|
||||
state.closeTimeout = null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+121
-121
@@ -1,121 +1,121 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { useTabStore } from './stores/tabStore'
|
||||
import { useEditorStore } from './stores/editorStore'
|
||||
import { useTheme } from './hooks/useTheme'
|
||||
import { useSettings } from './hooks/useSettings'
|
||||
import { useSettingsInit } from './hooks/useSettingsInit'
|
||||
import { useKeyboard } from './hooks/useKeyboard'
|
||||
import { useUnsavedWarning } from './hooks/useUnsavedWarning'
|
||||
import { useFileWatch } from './hooks/useFileWatch'
|
||||
import { useFileOperations } from './hooks/useFileOperations'
|
||||
import { useDragDrop } from './hooks/useDragDrop'
|
||||
import { useIpcListeners } from './hooks/useIpcListeners'
|
||||
import { useToast } from './hooks/useToast'
|
||||
import { useConfirm } from './hooks/useConfirm'
|
||||
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 { StatusBar } from './components/StatusBar/StatusBar'
|
||||
import { WelcomeScreen } from './components/WelcomeScreen/WelcomeScreen'
|
||||
import { ToastContainer } from './components/Toast/Toast'
|
||||
import { ModifiedBanner } from './components/ModifiedBanner/ModifiedBanner'
|
||||
import { DropOverlay } from './components/DropOverlay/DropOverlay'
|
||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||
import { AboutDialog } from './components/AboutDialog'
|
||||
import { ConfirmDialog } from './components/ConfirmDialog/ConfirmDialog'
|
||||
|
||||
export function App() {
|
||||
const tabs = useTabStore(s => s.tabs)
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
const createTab = useTabStore(s => s.createTab)
|
||||
const setModified = useTabStore(s => s.setModified)
|
||||
const updateTabContent = useTabStore(s => s.updateTabContent)
|
||||
const loadFromDB = useTabStore(s => s.loadFromDB)
|
||||
const viewMode = useEditorStore(s => s.viewMode)
|
||||
const externallyModified = useEditorStore(s => s.externallyModified)
|
||||
const setExternallyModified = useEditorStore(s => s.setExternallyModified)
|
||||
const { darkMode, toggleDarkMode } = useTheme()
|
||||
const { saveViewMode } = useSettings()
|
||||
const { toasts, showToast, dismissToast, cleanup } = useToast()
|
||||
const { confirm, confirmDialogProps } = useConfirm()
|
||||
const [showAbout, setShowAbout] = useState(false)
|
||||
const handleCloseAbout = useCallback(() => setShowAbout(false), [])
|
||||
|
||||
useSettingsInit()
|
||||
useEffect(() => { loadFromDB(); return cleanup }, [loadFromDB, cleanup])
|
||||
|
||||
const { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent } = useFileOperations(showToast)
|
||||
useDragDrop(showToast)
|
||||
useFileWatch()
|
||||
|
||||
// UX-01: 传入 confirm 函数替代原生 confirm()
|
||||
const handleConfirmClose = useCallback(async (message: string): Promise<boolean> => {
|
||||
return confirm({
|
||||
title: '未保存的更改',
|
||||
message,
|
||||
variant: 'warning',
|
||||
confirmLabel: '不保存',
|
||||
cancelLabel: '取消'
|
||||
})
|
||||
}, [confirm])
|
||||
|
||||
useUnsavedWarning(() => tabs.some(t => t.isModified), handleConfirmClose)
|
||||
useKeyboard(handleOpenFile, handleSave, handleSaveAs)
|
||||
useIpcListeners(handleSave, handleSaveAs)
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.electronAPI) return
|
||||
const activeTab = tabs.find(t => t.id === activeTabId)
|
||||
window.electronAPI.tabSwitched(activeTab?.filePath ?? null)
|
||||
}, [activeTabId, tabs])
|
||||
|
||||
const handleReloadModified = useCallback(async () => {
|
||||
if (!externallyModified?.filePath || !window.electronAPI) return
|
||||
const result = await window.electronAPI.readFile(externallyModified.filePath)
|
||||
if (result.success && result.content) {
|
||||
const tab = tabs.find(t => t.filePath === externallyModified.filePath)
|
||||
if (tab) { updateTabContent(tab.id, result.content); setModified(tab.id, false) }
|
||||
}
|
||||
setExternallyModified(null)
|
||||
}, [externallyModified, tabs, updateTabContent, setModified, setExternallyModified])
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<div id="app" className={`mode-${viewMode}`}>
|
||||
<Toolbar
|
||||
onOpen={handleOpenFile} onSave={handleSave}
|
||||
viewMode={viewMode} onViewModeChange={saveViewMode}
|
||||
darkMode={darkMode} onToggleDark={toggleDarkMode}
|
||||
onShowAbout={() => setShowAbout(true)}
|
||||
/>
|
||||
<div id="workspace">
|
||||
<Sidebar />
|
||||
<div id="main-content">
|
||||
<TabBar />
|
||||
{externallyModified && (
|
||||
<ModifiedBanner onReload={handleReloadModified} onDismiss={() => setExternallyModified(null)} />
|
||||
)}
|
||||
{tabs.length > 0 ? (
|
||||
<div id="content-wrapper">
|
||||
{viewMode === 'editor' && <div id="editor-panel"><Editor darkMode={darkMode} /></div>}
|
||||
{viewMode === 'preview' && <div id="preview-panel"><Preview /></div>}
|
||||
</div>
|
||||
) : (
|
||||
<WelcomeScreen onOpen={handleOpenFile} onNew={() => createTab(null, '')} onOpenRecent={handleOpenRecent} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBar />
|
||||
<ToastContainer toasts={toasts} onDismiss={dismissToast} />
|
||||
<DropOverlay />
|
||||
{showAbout && <AboutDialog onClose={handleCloseAbout} />}
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
App.displayName = 'App'
|
||||
export default App
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { useTabStore } from './stores/tabStore'
|
||||
import { useEditorStore } from './stores/editorStore'
|
||||
import { useTheme } from './hooks/useTheme'
|
||||
import { useSettings } from './hooks/useSettings'
|
||||
import { useSettingsInit } from './hooks/useSettingsInit'
|
||||
import { useKeyboard } from './hooks/useKeyboard'
|
||||
import { useUnsavedWarning } from './hooks/useUnsavedWarning'
|
||||
import { useFileWatch } from './hooks/useFileWatch'
|
||||
import { useFileOperations } from './hooks/useFileOperations'
|
||||
import { useDragDrop } from './hooks/useDragDrop'
|
||||
import { useIpcListeners } from './hooks/useIpcListeners'
|
||||
import { useToast } from './hooks/useToast'
|
||||
import { useConfirm } from './hooks/useConfirm'
|
||||
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 { StatusBar } from './components/StatusBar/StatusBar'
|
||||
import { WelcomeScreen } from './components/WelcomeScreen/WelcomeScreen'
|
||||
import { ToastContainer } from './components/Toast/Toast'
|
||||
import { ModifiedBanner } from './components/ModifiedBanner/ModifiedBanner'
|
||||
import { DropOverlay } from './components/DropOverlay/DropOverlay'
|
||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||
import { AboutDialog } from './components/AboutDialog'
|
||||
import { ConfirmDialog } from './components/ConfirmDialog/ConfirmDialog'
|
||||
|
||||
export function App() {
|
||||
const tabs = useTabStore(s => s.tabs)
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
const createTab = useTabStore(s => s.createTab)
|
||||
const setModified = useTabStore(s => s.setModified)
|
||||
const updateTabContent = useTabStore(s => s.updateTabContent)
|
||||
const loadFromDB = useTabStore(s => s.loadFromDB)
|
||||
const viewMode = useEditorStore(s => s.viewMode)
|
||||
const externallyModified = useEditorStore(s => s.externallyModified)
|
||||
const setExternallyModified = useEditorStore(s => s.setExternallyModified)
|
||||
const { darkMode, toggleDarkMode } = useTheme()
|
||||
const { saveViewMode } = useSettings()
|
||||
const { toasts, showToast, dismissToast, cleanup } = useToast()
|
||||
const { confirm, confirmDialogProps } = useConfirm()
|
||||
const [showAbout, setShowAbout] = useState(false)
|
||||
const handleCloseAbout = useCallback(() => setShowAbout(false), [])
|
||||
|
||||
useSettingsInit()
|
||||
useEffect(() => { loadFromDB(); return cleanup }, [loadFromDB, cleanup])
|
||||
|
||||
const { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent } = useFileOperations(showToast)
|
||||
useDragDrop(showToast)
|
||||
useFileWatch()
|
||||
|
||||
// UX-01: 传入 confirm 函数替代原生 confirm()
|
||||
const handleConfirmClose = useCallback(async (message: string): Promise<boolean> => {
|
||||
return confirm({
|
||||
title: '未保存的更改',
|
||||
message,
|
||||
variant: 'warning',
|
||||
confirmLabel: '不保存',
|
||||
cancelLabel: '取消'
|
||||
})
|
||||
}, [confirm])
|
||||
|
||||
useUnsavedWarning(() => tabs.some(t => t.isModified), handleConfirmClose)
|
||||
useKeyboard(handleOpenFile, handleSave, handleSaveAs)
|
||||
useIpcListeners(handleSave, handleSaveAs)
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.electronAPI) return
|
||||
const activeTab = tabs.find(t => t.id === activeTabId)
|
||||
window.electronAPI.tabSwitched(activeTab?.filePath ?? null)
|
||||
}, [activeTabId, tabs])
|
||||
|
||||
const handleReloadModified = useCallback(async () => {
|
||||
if (!externallyModified?.filePath || !window.electronAPI) return
|
||||
const result = await window.electronAPI.readFile(externallyModified.filePath)
|
||||
if (result.success && result.content) {
|
||||
const tab = tabs.find(t => t.filePath === externallyModified.filePath)
|
||||
if (tab) { updateTabContent(tab.id, result.content); setModified(tab.id, false) }
|
||||
}
|
||||
setExternallyModified(null)
|
||||
}, [externallyModified, tabs, updateTabContent, setModified, setExternallyModified])
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<div id="app" className={`mode-${viewMode}`}>
|
||||
<Toolbar
|
||||
onOpen={handleOpenFile} onSave={handleSave}
|
||||
viewMode={viewMode} onViewModeChange={saveViewMode}
|
||||
darkMode={darkMode} onToggleDark={toggleDarkMode}
|
||||
onShowAbout={() => setShowAbout(true)}
|
||||
/>
|
||||
<div id="workspace">
|
||||
<Sidebar />
|
||||
<div id="main-content">
|
||||
<TabBar />
|
||||
{externallyModified && (
|
||||
<ModifiedBanner onReload={handleReloadModified} onDismiss={() => setExternallyModified(null)} />
|
||||
)}
|
||||
{tabs.length > 0 ? (
|
||||
<div id="content-wrapper">
|
||||
{viewMode === 'editor' && <div id="editor-panel"><Editor darkMode={darkMode} /></div>}
|
||||
{viewMode === 'preview' && <div id="preview-panel"><Preview /></div>}
|
||||
</div>
|
||||
) : (
|
||||
<WelcomeScreen onOpen={handleOpenFile} onNew={() => createTab(null, '')} onOpenRecent={handleOpenRecent} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBar />
|
||||
<ToastContainer toasts={toasts} onDismiss={dismissToast} />
|
||||
<DropOverlay />
|
||||
{showAbout && <AboutDialog onClose={handleCloseAbout} />}
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
App.displayName = 'App'
|
||||
export default App
|
||||
|
||||
@@ -1,55 +1,56 @@
|
||||
import React from 'react'
|
||||
import { AppIcon, Gitee } from '../Icons'
|
||||
|
||||
const APP_VERSION = 'v0.3.2'
|
||||
|
||||
interface AboutDialogProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export const AboutDialog = React.memo(function AboutDialog({ onClose }: AboutDialogProps) {
|
||||
const handleLinkClick = (e: React.MouseEvent<HTMLAnchorElement>): void => {
|
||||
e.preventDefault()
|
||||
if (window.electronAPI?.openExternal) {
|
||||
window.electronAPI.openExternal('https://gitee.com/thzxx/MarkLite')
|
||||
} else {
|
||||
window.open('https://gitee.com/thzxx/MarkLite', '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="about-overlay" onClick={onClose} role="dialog" aria-modal="true" aria-label="关于 MarkLite">
|
||||
<div className="about-dialog" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<div className="about-header">
|
||||
<AppIcon size={64} />
|
||||
<h2>MarkLite</h2>
|
||||
<span className="about-version">{APP_VERSION}</span>
|
||||
</div>
|
||||
<div className="about-body">
|
||||
<p>一款轻量级的 Windows 本地 Markdown 编辑器</p>
|
||||
<div className="about-features">
|
||||
<span>多标签页</span>
|
||||
<span>实时预览</span>
|
||||
<span>代码高亮</span>
|
||||
<span>暗色主题</span>
|
||||
<span>拖拽打开</span>
|
||||
<span>搜索替换</span>
|
||||
<span>文件树</span>
|
||||
<span>状态持久化</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="about-footer">
|
||||
<a className="about-link" href="#" onClick={handleLinkClick}>
|
||||
<Gitee size={16} />
|
||||
<span>gitee.com/thzxx/MarkLite</span>
|
||||
</a>
|
||||
<p>基于 Electron + React + TypeScript 构建</p>
|
||||
<p className="about-copyright">© 2026 thzxx</p>
|
||||
</div>
|
||||
<button className="about-close-btn" onClick={onClose}>关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
AboutDialog.displayName = 'AboutDialog'
|
||||
import React from 'react'
|
||||
import { AppIcon, Gitee } from '../Icons'
|
||||
|
||||
const APP_VERSION = 'v0.3.4'
|
||||
|
||||
interface AboutDialogProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export const AboutDialog = React.memo(function AboutDialog({ onClose }: AboutDialogProps) {
|
||||
const handleLinkClick = (e: React.MouseEvent<HTMLAnchorElement>): void => {
|
||||
e.preventDefault()
|
||||
if (window.electronAPI?.openExternal) {
|
||||
window.electronAPI.openExternal('https://gitee.com/thzxx/MarkLite')
|
||||
} else {
|
||||
window.open('https://gitee.com/thzxx/MarkLite', '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="about-overlay" onClick={onClose} role="dialog" aria-modal="true" aria-label="关于 MarkLite">
|
||||
<div className="about-dialog" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<div className="about-header">
|
||||
<AppIcon size={64} />
|
||||
<h2>MarkLite</h2>
|
||||
<span className="about-version">{APP_VERSION}</span>
|
||||
</div>
|
||||
<div className="about-body">
|
||||
<p>一款轻量级的 Windows 本地 Markdown 编辑器</p>
|
||||
<div className="about-features">
|
||||
<span>多标签页</span>
|
||||
<span>实时预览</span>
|
||||
<span>代码高亮</span>
|
||||
<span>暗色主题</span>
|
||||
<span>拖拽打开</span>
|
||||
<span>搜索替换</span>
|
||||
<span>文件树</span>
|
||||
<span>文档大纲</span>
|
||||
<span>状态持久化</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="about-footer">
|
||||
<a className="about-link" href="#" onClick={handleLinkClick}>
|
||||
<Gitee size={16} />
|
||||
<span>gitee.com/thzxx/MarkLite</span>
|
||||
</a>
|
||||
<p>基于 Electron + React + TypeScript 构建</p>
|
||||
<p className="about-copyright">© 2026 thzxx</p>
|
||||
</div>
|
||||
<button className="about-close-btn" onClick={onClose}>关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
AboutDialog.displayName = 'AboutDialog'
|
||||
|
||||
@@ -1,108 +1,108 @@
|
||||
import React, { useEffect, useRef, useCallback } from 'react'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean
|
||||
title: string
|
||||
message: string
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
variant?: 'danger' | 'warning' | 'info'
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export const ConfirmDialog = React.memo(function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = '确定',
|
||||
cancelLabel = '取消',
|
||||
variant = 'warning',
|
||||
onConfirm,
|
||||
onCancel
|
||||
}: ConfirmDialogProps) {
|
||||
const confirmRef = useRef<HTMLButtonElement>(null)
|
||||
const previousFocusRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
// 打开时保存焦点并聚焦确认按钮;关闭时恢复焦点
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
previousFocusRef.current?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
previousFocusRef.current = document.activeElement as HTMLElement
|
||||
const timer = setTimeout(() => confirmRef.current?.focus(), 50)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// ESC 键关闭
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onCancel()
|
||||
}
|
||||
}, [onCancel])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [open, handleKeyDown])
|
||||
|
||||
// 防止背景滚动
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const original = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => { document.body.style.overflow = original }
|
||||
}, [open])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="confirm-overlay"
|
||||
onClick={onCancel}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
className="confirm-dialog"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-title"
|
||||
aria-describedby="confirm-message"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className={`confirm-header confirm-${variant}`}>
|
||||
<h3 id="confirm-title">{title}</h3>
|
||||
</div>
|
||||
<div className="confirm-body">
|
||||
<p id="confirm-message">{message}</p>
|
||||
</div>
|
||||
<div className="confirm-actions">
|
||||
<button
|
||||
className="confirm-btn confirm-btn-cancel"
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
ref={confirmRef}
|
||||
className={`confirm-btn confirm-btn-${variant}`}
|
||||
onClick={onConfirm}
|
||||
type="button"
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
ConfirmDialog.displayName = 'ConfirmDialog'
|
||||
import React, { useEffect, useRef, useCallback } from 'react'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean
|
||||
title: string
|
||||
message: string
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
variant?: 'danger' | 'warning' | 'info'
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export const ConfirmDialog = React.memo(function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = '确定',
|
||||
cancelLabel = '取消',
|
||||
variant = 'warning',
|
||||
onConfirm,
|
||||
onCancel
|
||||
}: ConfirmDialogProps) {
|
||||
const confirmRef = useRef<HTMLButtonElement>(null)
|
||||
const previousFocusRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
// 打开时保存焦点并聚焦确认按钮;关闭时恢复焦点
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
previousFocusRef.current?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
previousFocusRef.current = document.activeElement as HTMLElement
|
||||
const timer = setTimeout(() => confirmRef.current?.focus(), 50)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// ESC 键关闭
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onCancel()
|
||||
}
|
||||
}, [onCancel])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [open, handleKeyDown])
|
||||
|
||||
// 防止背景滚动
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const original = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => { document.body.style.overflow = original }
|
||||
}, [open])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="confirm-overlay"
|
||||
onClick={onCancel}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
className="confirm-dialog"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-title"
|
||||
aria-describedby="confirm-message"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className={`confirm-header confirm-${variant}`}>
|
||||
<h3 id="confirm-title">{title}</h3>
|
||||
</div>
|
||||
<div className="confirm-body">
|
||||
<p id="confirm-message">{message}</p>
|
||||
</div>
|
||||
<div className="confirm-actions">
|
||||
<button
|
||||
className="confirm-btn confirm-btn-cancel"
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
ref={confirmRef}
|
||||
className={`confirm-btn confirm-btn-${variant}`}
|
||||
onClick={onConfirm}
|
||||
type="button"
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
ConfirmDialog.displayName = 'ConfirmDialog'
|
||||
|
||||
@@ -1,105 +1,116 @@
|
||||
import React, { useEffect, useCallback, useState } from 'react'
|
||||
import { callCommand } from '@milkdown/utils'
|
||||
import { toggleStrongCommand, toggleEmphasisCommand } from '@milkdown/preset-commonmark'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useMilkdown } from './useMilkdown'
|
||||
import { EditorToolbar } from './EditorToolbar'
|
||||
import { SearchReplace } from '../SearchReplace'
|
||||
|
||||
interface EditorProps {
|
||||
darkMode: boolean
|
||||
}
|
||||
|
||||
export const Editor = React.memo(function Editor({ darkMode }: EditorProps) {
|
||||
const activeTab = useTabStore(s => s.getActiveTab())
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
const updateTabContent = useTabStore(s => s.updateTabContent)
|
||||
const setModified = useTabStore(s => s.setModified)
|
||||
const updateTabScroll = useTabStore(s => s.updateTabScroll)
|
||||
const [showSearch, setShowSearch] = useState(false)
|
||||
|
||||
const {
|
||||
containerRef,
|
||||
action,
|
||||
setContent,
|
||||
getScrollTop,
|
||||
setScrollTop,
|
||||
getSelection,
|
||||
setSelection,
|
||||
getView
|
||||
} = useMilkdown({
|
||||
content: activeTab?.content ?? '',
|
||||
onChange: useCallback((value: string) => {
|
||||
if (!activeTabId) return
|
||||
updateTabContent(activeTabId, value)
|
||||
setModified(activeTabId, true)
|
||||
}, [activeTabId, updateTabContent, setModified]),
|
||||
darkMode
|
||||
})
|
||||
|
||||
// Load content when switching tabs (only trigger on tab switch, not content edits)
|
||||
useEffect(() => {
|
||||
if (!activeTab) return
|
||||
setContent(activeTab.content)
|
||||
requestAnimationFrame(() => {
|
||||
setScrollTop(activeTab.scrollTop)
|
||||
setSelection()
|
||||
})
|
||||
// stable refs: setContent, setScrollTop, setSelection are useCallback([]) - never change
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTabId])
|
||||
|
||||
// Save current tab state on unmount or tab switch
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (!activeTabId) return
|
||||
updateTabScroll(activeTabId, {
|
||||
scrollTop: getScrollTop(),
|
||||
selectionStart: getSelection().from,
|
||||
selectionEnd: getSelection().to
|
||||
})
|
||||
}
|
||||
// stable refs: getScrollTop, getSelection (useCallback([])), updateTabScroll (zustand) - never change
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTabId])
|
||||
|
||||
// Ctrl+B bold, Ctrl+I italic, Ctrl+F search, Ctrl+H replace
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const isCtrl = e.ctrlKey || e.metaKey
|
||||
if (isCtrl && e.key === 'b') {
|
||||
e.preventDefault()
|
||||
action((editor) => {
|
||||
editor.action(callCommand(toggleStrongCommand.key))
|
||||
})
|
||||
}
|
||||
if (isCtrl && e.key === 'i') {
|
||||
e.preventDefault()
|
||||
action((editor) => {
|
||||
editor.action(callCommand(toggleEmphasisCommand.key))
|
||||
})
|
||||
}
|
||||
if (isCtrl && (e.key === 'f' || e.key === 'h')) {
|
||||
e.preventDefault()
|
||||
setShowSearch(true)
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [action])
|
||||
|
||||
return (
|
||||
<div className="editor-container" role="region" aria-label="Markdown编辑器">
|
||||
<EditorToolbar action={action} />
|
||||
{showSearch && (
|
||||
<SearchReplace
|
||||
getView={getView}
|
||||
onClose={() => setShowSearch(false)}
|
||||
/>
|
||||
)}
|
||||
<div ref={containerRef} className="milkdown-wrapper" />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
Editor.displayName = 'Editor'
|
||||
import React, { useEffect, useCallback, useState, useRef } from 'react'
|
||||
import { callCommand } from '@milkdown/utils'
|
||||
import { toggleStrongCommand, toggleEmphasisCommand } from '@milkdown/preset-commonmark'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useMilkdown } from './useMilkdown'
|
||||
import { EditorToolbar } from './EditorToolbar'
|
||||
import { SearchReplace } from '../SearchReplace'
|
||||
import { setEditorViewGetter } from '../../stores/editorStore'
|
||||
|
||||
interface EditorProps {
|
||||
darkMode: boolean
|
||||
}
|
||||
|
||||
export const Editor = React.memo(function Editor({ darkMode }: EditorProps) {
|
||||
const activeTab = useTabStore(s => s.getActiveTab())
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
const updateTabContent = useTabStore(s => s.updateTabContent)
|
||||
const setModified = useTabStore(s => s.setModified)
|
||||
const updateTabScroll = useTabStore(s => s.updateTabScroll)
|
||||
const [showSearch, setShowSearch] = useState(false)
|
||||
const currentContentRef = useRef('')
|
||||
|
||||
const {
|
||||
containerRef,
|
||||
action,
|
||||
setContent,
|
||||
getScrollTop,
|
||||
setScrollTop,
|
||||
getSelection,
|
||||
setSelection,
|
||||
getView
|
||||
} = useMilkdown({
|
||||
content: activeTab?.content ?? '',
|
||||
onChange: useCallback((value: string) => {
|
||||
if (!activeTabId) return
|
||||
updateTabContent(activeTabId, value)
|
||||
setModified(activeTabId, true)
|
||||
}, [activeTabId, updateTabContent, setModified]),
|
||||
darkMode
|
||||
})
|
||||
|
||||
// Load content when switching tabs (only trigger on tab switch, not content edits)
|
||||
useEffect(() => {
|
||||
if (!activeTab) return
|
||||
if (activeTab.content !== currentContentRef.current) {
|
||||
currentContentRef.current = activeTab.content
|
||||
setContent(activeTab.content)
|
||||
requestAnimationFrame(() => {
|
||||
setScrollTop(activeTab.scrollTop)
|
||||
setSelection()
|
||||
})
|
||||
}
|
||||
// stable refs: setContent, setScrollTop, setSelection are useCallback([]) - never change
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTabId])
|
||||
|
||||
// Register getView for OutlinePanel navigation
|
||||
useEffect(() => {
|
||||
setEditorViewGetter(getView)
|
||||
return () => setEditorViewGetter(() => null)
|
||||
}, [getView])
|
||||
|
||||
// Save current tab state on unmount or tab switch
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (!activeTabId) return
|
||||
updateTabScroll(activeTabId, {
|
||||
scrollTop: getScrollTop(),
|
||||
selectionStart: getSelection().from,
|
||||
selectionEnd: getSelection().to
|
||||
})
|
||||
}
|
||||
// stable refs: getScrollTop, getSelection (useCallback([])), updateTabScroll (zustand) - never change
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTabId])
|
||||
|
||||
// Ctrl+B bold, Ctrl+I italic, Ctrl+F search, Ctrl+H replace
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const isCtrl = e.ctrlKey || e.metaKey
|
||||
if (isCtrl && e.key === 'b') {
|
||||
e.preventDefault()
|
||||
action((editor) => {
|
||||
editor.action(callCommand(toggleStrongCommand.key))
|
||||
})
|
||||
}
|
||||
if (isCtrl && e.key === 'i') {
|
||||
e.preventDefault()
|
||||
action((editor) => {
|
||||
editor.action(callCommand(toggleEmphasisCommand.key))
|
||||
})
|
||||
}
|
||||
if (isCtrl && (e.key === 'f' || e.key === 'h')) {
|
||||
e.preventDefault()
|
||||
setShowSearch(true)
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [action])
|
||||
|
||||
return (
|
||||
<div className="editor-container" role="region" aria-label="Markdown编辑器">
|
||||
<EditorToolbar action={action} />
|
||||
{showSearch && (
|
||||
<SearchReplace
|
||||
getView={getView}
|
||||
onClose={() => setShowSearch(false)}
|
||||
/>
|
||||
)}
|
||||
<div ref={containerRef} className="milkdown-wrapper" />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
Editor.displayName = 'Editor'
|
||||
|
||||
@@ -1,252 +1,252 @@
|
||||
import { useCallback, useRef, useEffect } from 'react'
|
||||
import {
|
||||
Editor,
|
||||
rootCtx,
|
||||
defaultValueCtx,
|
||||
editorViewCtx,
|
||||
prosePluginsCtx
|
||||
} from '@milkdown/core'
|
||||
import { replaceAll as milkdownReplaceAll } from '@milkdown/utils'
|
||||
import { commonmark } from '@milkdown/preset-commonmark'
|
||||
import { gfm } from '@milkdown/preset-gfm'
|
||||
import { history } from '@milkdown/plugin-history'
|
||||
import { listener, listenerCtx } from '@milkdown/plugin-listener'
|
||||
import { indent } from '@milkdown/plugin-indent'
|
||||
import { trailing } from '@milkdown/plugin-trailing'
|
||||
import { clipboard } from '@milkdown/plugin-clipboard'
|
||||
import { Plugin, PluginKey } from '@milkdown/prose/state'
|
||||
import { Decoration, DecorationSet } from '@milkdown/prose/view'
|
||||
import type { EditorState } from '@milkdown/prose/state'
|
||||
|
||||
// --- Search highlight plugin ---
|
||||
|
||||
export interface SearchMatch {
|
||||
from: number
|
||||
to: number
|
||||
}
|
||||
|
||||
export interface SearchPluginState {
|
||||
matches: SearchMatch[]
|
||||
currentIndex: number
|
||||
query: string
|
||||
}
|
||||
|
||||
export const searchPluginKey = new PluginKey<SearchPluginState>('search-highlight')
|
||||
|
||||
function createSearchPlugin(): Plugin<SearchPluginState> {
|
||||
return new Plugin<SearchPluginState>({
|
||||
key: searchPluginKey,
|
||||
state: {
|
||||
init(): SearchPluginState {
|
||||
return { matches: [], currentIndex: -1, query: '' }
|
||||
},
|
||||
apply(tr, value): SearchPluginState {
|
||||
const meta = tr.getMeta(searchPluginKey) as SearchPluginState | undefined
|
||||
if (meta) return meta
|
||||
// When the document changes, map existing match positions through the change
|
||||
// but only if there's an active search
|
||||
if (tr.docChanged && value.query) {
|
||||
// The SearchReplace component will re-search when doc changes,
|
||||
// but we map positions so decorations stay roughly correct in the interim
|
||||
return { ...value }
|
||||
}
|
||||
return value
|
||||
}
|
||||
},
|
||||
props: {
|
||||
decorations(state: EditorState) {
|
||||
const pluginState = searchPluginKey.getState(state)
|
||||
if (!pluginState || pluginState.matches.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const decos: Decoration[] = pluginState.matches.map((match, index) => {
|
||||
const cls =
|
||||
index === pluginState.currentIndex
|
||||
? 'search-match-active'
|
||||
: 'search-match-highlight'
|
||||
return Decoration.inline(match.from, match.to, { class: cls })
|
||||
})
|
||||
|
||||
return DecorationSet.create(state.doc, decos)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- Hook ---
|
||||
|
||||
interface UseMilkdownOptions {
|
||||
content: string
|
||||
onChange: (value: string) => void
|
||||
darkMode: boolean
|
||||
}
|
||||
|
||||
export function useMilkdown({ content, onChange, darkMode }: UseMilkdownOptions) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const editorRef = useRef<Editor | null>(null)
|
||||
const onChangeRef = useRef(onChange)
|
||||
const isExternalUpdate = useRef(false)
|
||||
const initialContentRef = useRef(content)
|
||||
|
||||
// Keep onChangeRef fresh
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange
|
||||
}, [onChange])
|
||||
|
||||
// Initialize editor
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
const editor = Editor.make()
|
||||
.config((ctx) => {
|
||||
ctx.set(rootCtx, containerRef.current!)
|
||||
ctx.set(defaultValueCtx, initialContentRef.current)
|
||||
|
||||
// Inject search highlight plugin into ProseMirror plugin list
|
||||
ctx.update(prosePluginsCtx, (plugins) => [...plugins, createSearchPlugin()])
|
||||
|
||||
// Configure listener for content changes
|
||||
const lm = ctx.get(listenerCtx)
|
||||
lm.markdownUpdated((_ctx, markdown, prevMarkdown) => {
|
||||
if (markdown === prevMarkdown) return
|
||||
if (!isExternalUpdate.current) {
|
||||
onChangeRef.current(markdown)
|
||||
}
|
||||
})
|
||||
|
||||
// Listen for blur events (used for potential future state saving)
|
||||
lm.blur(() => {
|
||||
// blur handler placeholder
|
||||
})
|
||||
})
|
||||
.use(commonmark)
|
||||
.use(gfm)
|
||||
.use(history)
|
||||
.use(listener)
|
||||
.use(indent)
|
||||
.use(trailing)
|
||||
.use(clipboard)
|
||||
|
||||
let cancelled = false
|
||||
|
||||
editor.create().then((created) => {
|
||||
if (cancelled) {
|
||||
// 组件已卸载,立即销毁以避免内存泄漏
|
||||
created.destroy()
|
||||
return
|
||||
}
|
||||
editorRef.current = created
|
||||
}).catch((err) => {
|
||||
// eslint-disable-next-line no-console -- editor creation failure must be reported
|
||||
console.error('Milkdown editor creation failed:', err)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
editorRef.current?.destroy()
|
||||
editorRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Dark mode: update CSS custom properties on the container
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
if (darkMode) {
|
||||
container.classList.add('milkdown-dark')
|
||||
} else {
|
||||
container.classList.remove('milkdown-dark')
|
||||
}
|
||||
}, [darkMode])
|
||||
|
||||
// External content update (tab switch)
|
||||
const setContent = useCallback((newContent: string) => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
|
||||
isExternalUpdate.current = true
|
||||
try {
|
||||
editor.action(milkdownReplaceAll(newContent))
|
||||
} catch {
|
||||
// replaceAll may fail if editor is not fully ready
|
||||
} finally {
|
||||
isExternalUpdate.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Scroll position management
|
||||
const getScrollTop = useCallback((): number => {
|
||||
const container = containerRef.current
|
||||
if (!container) return 0
|
||||
const scroller = container.querySelector('.milkdown .editor') as HTMLElement | null
|
||||
return scroller?.scrollTop ?? container.scrollTop ?? 0
|
||||
}, [])
|
||||
|
||||
const setScrollTop = useCallback((top: number) => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
const scroller = container.querySelector('.milkdown .editor') as HTMLElement | null
|
||||
if (scroller) {
|
||||
scroller.scrollTop = top
|
||||
} else {
|
||||
container.scrollTop = top
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Selection management
|
||||
const getSelection = useCallback((): { from: number; to: number } => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return { from: 0, to: 0 }
|
||||
try {
|
||||
return editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
if (view?.state?.selection) {
|
||||
return { from: view.state.selection.from, to: view.state.selection.to }
|
||||
}
|
||||
return { from: 0, to: 0 }
|
||||
})
|
||||
} catch {
|
||||
return { from: 0, to: 0 }
|
||||
}
|
||||
}, [])
|
||||
|
||||
const setSelection = useCallback(() => {
|
||||
// ProseMirror selection setting requires the view instance
|
||||
// which we access lazily; for now we focus the editor
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
const pmEditor = container.querySelector('.ProseMirror') as HTMLElement | null
|
||||
pmEditor?.focus()
|
||||
}, [])
|
||||
|
||||
// Get ProseMirror EditorView instance
|
||||
const getView = useCallback(() => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return null
|
||||
try {
|
||||
return editor.action((ctx) => ctx.get(editorViewCtx))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Execute a Milkdown action (for toolbar commands)
|
||||
const action = useCallback((fn: (editor: Editor) => void) => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
fn(editor)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
editorRef,
|
||||
action,
|
||||
setContent,
|
||||
getScrollTop,
|
||||
setScrollTop,
|
||||
getSelection,
|
||||
setSelection,
|
||||
getView
|
||||
}
|
||||
}
|
||||
import { useCallback, useRef, useEffect } from 'react'
|
||||
import {
|
||||
Editor,
|
||||
rootCtx,
|
||||
defaultValueCtx,
|
||||
editorViewCtx,
|
||||
prosePluginsCtx
|
||||
} from '@milkdown/core'
|
||||
import { replaceAll as milkdownReplaceAll } from '@milkdown/utils'
|
||||
import { commonmark } from '@milkdown/preset-commonmark'
|
||||
import { gfm } from '@milkdown/preset-gfm'
|
||||
import { history } from '@milkdown/plugin-history'
|
||||
import { listener, listenerCtx } from '@milkdown/plugin-listener'
|
||||
import { indent } from '@milkdown/plugin-indent'
|
||||
import { trailing } from '@milkdown/plugin-trailing'
|
||||
import { clipboard } from '@milkdown/plugin-clipboard'
|
||||
import { Plugin, PluginKey } from '@milkdown/prose/state'
|
||||
import { Decoration, DecorationSet } from '@milkdown/prose/view'
|
||||
import type { EditorState } from '@milkdown/prose/state'
|
||||
|
||||
// --- Search highlight plugin ---
|
||||
|
||||
export interface SearchMatch {
|
||||
from: number
|
||||
to: number
|
||||
}
|
||||
|
||||
export interface SearchPluginState {
|
||||
matches: SearchMatch[]
|
||||
currentIndex: number
|
||||
query: string
|
||||
}
|
||||
|
||||
export const searchPluginKey = new PluginKey<SearchPluginState>('search-highlight')
|
||||
|
||||
function createSearchPlugin(): Plugin<SearchPluginState> {
|
||||
return new Plugin<SearchPluginState>({
|
||||
key: searchPluginKey,
|
||||
state: {
|
||||
init(): SearchPluginState {
|
||||
return { matches: [], currentIndex: -1, query: '' }
|
||||
},
|
||||
apply(tr, value): SearchPluginState {
|
||||
const meta = tr.getMeta(searchPluginKey) as SearchPluginState | undefined
|
||||
if (meta) return meta
|
||||
// When the document changes, map existing match positions through the change
|
||||
// but only if there's an active search
|
||||
if (tr.docChanged && value.query) {
|
||||
// The SearchReplace component will re-search when doc changes,
|
||||
// but we map positions so decorations stay roughly correct in the interim
|
||||
return { ...value }
|
||||
}
|
||||
return value
|
||||
}
|
||||
},
|
||||
props: {
|
||||
decorations(state: EditorState) {
|
||||
const pluginState = searchPluginKey.getState(state)
|
||||
if (!pluginState || pluginState.matches.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const decos: Decoration[] = pluginState.matches.map((match, index) => {
|
||||
const cls =
|
||||
index === pluginState.currentIndex
|
||||
? 'search-match-active'
|
||||
: 'search-match-highlight'
|
||||
return Decoration.inline(match.from, match.to, { class: cls })
|
||||
})
|
||||
|
||||
return DecorationSet.create(state.doc, decos)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- Hook ---
|
||||
|
||||
interface UseMilkdownOptions {
|
||||
content: string
|
||||
onChange: (value: string) => void
|
||||
darkMode: boolean
|
||||
}
|
||||
|
||||
export function useMilkdown({ content, onChange, darkMode }: UseMilkdownOptions) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const editorRef = useRef<Editor | null>(null)
|
||||
const onChangeRef = useRef(onChange)
|
||||
const isExternalUpdate = useRef(false)
|
||||
const initialContentRef = useRef(content)
|
||||
|
||||
// Keep onChangeRef fresh
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange
|
||||
}, [onChange])
|
||||
|
||||
// Initialize editor
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
const editor = Editor.make()
|
||||
.config((ctx) => {
|
||||
ctx.set(rootCtx, containerRef.current!)
|
||||
ctx.set(defaultValueCtx, initialContentRef.current)
|
||||
|
||||
// Inject search highlight plugin into ProseMirror plugin list
|
||||
ctx.update(prosePluginsCtx, (plugins) => [...plugins, createSearchPlugin()])
|
||||
|
||||
// Configure listener for content changes
|
||||
const lm = ctx.get(listenerCtx)
|
||||
lm.markdownUpdated((_ctx, markdown, prevMarkdown) => {
|
||||
if (markdown === prevMarkdown) return
|
||||
if (!isExternalUpdate.current) {
|
||||
onChangeRef.current(markdown)
|
||||
}
|
||||
})
|
||||
|
||||
// Listen for blur events (used for potential future state saving)
|
||||
lm.blur(() => {
|
||||
// blur handler placeholder
|
||||
})
|
||||
})
|
||||
.use(commonmark)
|
||||
.use(gfm)
|
||||
.use(history)
|
||||
.use(listener)
|
||||
.use(indent)
|
||||
.use(trailing)
|
||||
.use(clipboard)
|
||||
|
||||
let cancelled = false
|
||||
|
||||
editor.create().then((created) => {
|
||||
if (cancelled) {
|
||||
// 组件已卸载,立即销毁以避免内存泄漏
|
||||
created.destroy()
|
||||
return
|
||||
}
|
||||
editorRef.current = created
|
||||
}).catch((err) => {
|
||||
// eslint-disable-next-line no-console -- editor creation failure must be reported
|
||||
console.error('Milkdown editor creation failed:', err)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
editorRef.current?.destroy()
|
||||
editorRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Dark mode: update CSS custom properties on the container
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
if (darkMode) {
|
||||
container.classList.add('milkdown-dark')
|
||||
} else {
|
||||
container.classList.remove('milkdown-dark')
|
||||
}
|
||||
}, [darkMode])
|
||||
|
||||
// External content update (tab switch)
|
||||
const setContent = useCallback((newContent: string) => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
|
||||
isExternalUpdate.current = true
|
||||
try {
|
||||
editor.action(milkdownReplaceAll(newContent))
|
||||
} catch {
|
||||
// replaceAll may fail if editor is not fully ready
|
||||
} finally {
|
||||
isExternalUpdate.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Scroll position management
|
||||
const getScrollTop = useCallback((): number => {
|
||||
const container = containerRef.current
|
||||
if (!container) return 0
|
||||
const scroller = container.querySelector('.milkdown .editor') as HTMLElement | null
|
||||
return scroller?.scrollTop ?? container.scrollTop ?? 0
|
||||
}, [])
|
||||
|
||||
const setScrollTop = useCallback((top: number) => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
const scroller = container.querySelector('.milkdown .editor') as HTMLElement | null
|
||||
if (scroller) {
|
||||
scroller.scrollTop = top
|
||||
} else {
|
||||
container.scrollTop = top
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Selection management
|
||||
const getSelection = useCallback((): { from: number; to: number } => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return { from: 0, to: 0 }
|
||||
try {
|
||||
return editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
if (view?.state?.selection) {
|
||||
return { from: view.state.selection.from, to: view.state.selection.to }
|
||||
}
|
||||
return { from: 0, to: 0 }
|
||||
})
|
||||
} catch {
|
||||
return { from: 0, to: 0 }
|
||||
}
|
||||
}, [])
|
||||
|
||||
const setSelection = useCallback(() => {
|
||||
// ProseMirror selection setting requires the view instance
|
||||
// which we access lazily; for now we focus the editor
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
const pmEditor = container.querySelector('.ProseMirror') as HTMLElement | null
|
||||
pmEditor?.focus()
|
||||
}, [])
|
||||
|
||||
// Get ProseMirror EditorView instance
|
||||
const getView = useCallback(() => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return null
|
||||
try {
|
||||
return editor.action((ctx) => ctx.get(editorViewCtx))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Execute a Milkdown action (for toolbar commands)
|
||||
const action = useCallback((fn: (editor: Editor) => void) => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
fn(editor)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
editorRef,
|
||||
action,
|
||||
setContent,
|
||||
getScrollTop,
|
||||
setScrollTop,
|
||||
getSelection,
|
||||
setSelection,
|
||||
getView
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,88 +1,88 @@
|
||||
import { Component, ErrorInfo, ReactNode } from 'react'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
fallback?: ReactNode
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = { hasError: false, error: null }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
||||
// eslint-disable-next-line no-console -- React error boundary standard pattern
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo)
|
||||
}
|
||||
|
||||
handleReset = (): void => {
|
||||
this.setState({ hasError: false, error: null })
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
padding: '2rem',
|
||||
textAlign: 'center',
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ marginBottom: '1rem', color: '#e74c3c' }}>
|
||||
应用遇到了错误
|
||||
</h2>
|
||||
<pre
|
||||
style={{
|
||||
padding: '1rem',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
maxWidth: '600px',
|
||||
overflow: 'auto',
|
||||
fontSize: '0.875rem',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
{this.state.error?.message}
|
||||
</pre>
|
||||
<button
|
||||
onClick={this.handleReset}
|
||||
style={{
|
||||
marginTop: '1rem',
|
||||
padding: '0.5rem 1.5rem',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
backgroundColor: '#3498db',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1rem',
|
||||
}}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
import { Component, ErrorInfo, ReactNode } from 'react'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
fallback?: ReactNode
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = { hasError: false, error: null }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
||||
// eslint-disable-next-line no-console -- React error boundary standard pattern
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo)
|
||||
}
|
||||
|
||||
handleReset = (): void => {
|
||||
this.setState({ hasError: false, error: null })
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
padding: '2rem',
|
||||
textAlign: 'center',
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ marginBottom: '1rem', color: '#e74c3c' }}>
|
||||
应用遇到了错误
|
||||
</h2>
|
||||
<pre
|
||||
style={{
|
||||
padding: '1rem',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
maxWidth: '600px',
|
||||
overflow: 'auto',
|
||||
fontSize: '0.875rem',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
{this.state.error?.message}
|
||||
</pre>
|
||||
<button
|
||||
onClick={this.handleReset}
|
||||
style={{
|
||||
marginTop: '1rem',
|
||||
padding: '0.5rem 1.5rem',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
backgroundColor: '#3498db',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1rem',
|
||||
}}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import React, { memo } from 'react'
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface Heading {
|
||||
level: number
|
||||
text: string
|
||||
/** Position in document (character offset from markdown source) */
|
||||
pos: number
|
||||
}
|
||||
|
||||
// --- Heading Parser ---
|
||||
|
||||
const HEADING_RE = /^(#{1,6})\s+(.+)$/gm
|
||||
|
||||
/**
|
||||
* Parse headings from raw markdown content using regex.
|
||||
*/
|
||||
export function parseHeadings(markdown: string): Heading[] {
|
||||
const headings: Heading[] = []
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
// Reset regex state
|
||||
HEADING_RE.lastIndex = 0
|
||||
|
||||
while ((match = HEADING_RE.exec(markdown)) !== null) {
|
||||
const level = match[1].length
|
||||
const text = match[2].trim()
|
||||
headings.push({ level, text, pos: match.index })
|
||||
}
|
||||
|
||||
return headings
|
||||
}
|
||||
|
||||
// --- Component ---
|
||||
|
||||
interface OutlinePanelProps {
|
||||
headings: Heading[]
|
||||
onNavigate: (pos: number) => void
|
||||
activeHeadingIndex: number | null
|
||||
}
|
||||
|
||||
interface OutlineItemProps {
|
||||
heading: Heading
|
||||
isActive: boolean
|
||||
onNavigate: (pos: number) => void
|
||||
}
|
||||
|
||||
const OutlineItem = memo(function OutlineItem({
|
||||
heading,
|
||||
isActive,
|
||||
onNavigate
|
||||
}: OutlineItemProps) {
|
||||
return (
|
||||
<button
|
||||
className={`outline-item outline-level-${heading.level}${isActive ? ' active' : ''}`}
|
||||
onClick={() => onNavigate(heading.pos)}
|
||||
title={heading.text}
|
||||
aria-label={`跳转到标题:${heading.text}`}
|
||||
style={{ paddingLeft: `${8 + (heading.level - 1) * 12}px` }}
|
||||
>
|
||||
<span className="outline-level-dot" />
|
||||
<span className="outline-item-text">{heading.text}</span>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
|
||||
export const OutlinePanel = memo(function OutlinePanel({
|
||||
headings,
|
||||
onNavigate,
|
||||
activeHeadingIndex
|
||||
}: OutlinePanelProps) {
|
||||
if (headings.length === 0) {
|
||||
return (
|
||||
<div className="outline-panel" role="region" aria-label="文档大纲">
|
||||
<div className="outline-header">文档大纲</div>
|
||||
<div className="outline-empty">当前文档无标题</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="outline-panel" role="region" aria-label="文档大纲">
|
||||
<div className="outline-header">文档大纲</div>
|
||||
<div className="outline-list" role="list" aria-label="标题列表">
|
||||
{headings.map((h, i) => (
|
||||
<OutlineItem
|
||||
key={`${h.text}-${h.pos}`}
|
||||
heading={h}
|
||||
isActive={i === activeHeadingIndex}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
OutlinePanel.displayName = 'OutlinePanel'
|
||||
@@ -0,0 +1,2 @@
|
||||
export { OutlinePanel, parseHeadings } from './OutlinePanel'
|
||||
export type { Heading } from './OutlinePanel'
|
||||
@@ -1,102 +1,102 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useEditorStore } from '../../stores/editorStore'
|
||||
import { renderMarkdown } from '../../lib/markdown'
|
||||
import { LoadingSpinner } from '../LoadingSpinner/LoadingSpinner'
|
||||
|
||||
// PF-07: 防抖延迟常量
|
||||
const PREVIEW_DEBOUNCE_MS = 150
|
||||
|
||||
export const Preview = React.memo(function Preview() {
|
||||
const [html, setHtml] = useState('')
|
||||
const [rendering, setRendering] = useState(false)
|
||||
const previewRef = useRef<HTMLDivElement>(null)
|
||||
const requestIdRef = useRef(0)
|
||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const activeTab = useTabStore(s => s.getActiveTab())
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
const setLoading = useEditorStore(s => s.setLoading)
|
||||
|
||||
// PF-07: 响应式渲染 + 防抖
|
||||
useEffect(() => {
|
||||
if (!activeTab) {
|
||||
setHtml('')
|
||||
setRendering(false)
|
||||
setLoading('markdown-render', false)
|
||||
return
|
||||
}
|
||||
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current)
|
||||
}
|
||||
|
||||
setRendering(true)
|
||||
setLoading('markdown-render', true)
|
||||
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
const requestId = ++requestIdRef.current
|
||||
renderMarkdown(activeTab.content, activeTab.filePath).then((result: string) => {
|
||||
if (requestId === requestIdRef.current) {
|
||||
setHtml(result)
|
||||
setRendering(false)
|
||||
setLoading('markdown-render', false)
|
||||
}
|
||||
})
|
||||
}, PREVIEW_DEBOUNCE_MS)
|
||||
|
||||
return () => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current)
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- activeTab is a derived reference (from getActiveTab()), we track content/filePath via optional chaining
|
||||
}, [activeTabId, activeTab?.content, activeTab?.filePath, setLoading])
|
||||
|
||||
// 拦截链接点击
|
||||
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const link = (e.target as HTMLElement).closest('a')
|
||||
if (!link) return
|
||||
e.preventDefault()
|
||||
const href: string | null = 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"
|
||||
role="region"
|
||||
aria-label="Markdown预览"
|
||||
aria-live="polite"
|
||||
aria-busy={rendering}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{rendering && html === '' && (
|
||||
<div className="preview-loading" aria-label="正在渲染Markdown">
|
||||
<LoadingSpinner size="medium" label="正在渲染..." />
|
||||
</div>
|
||||
)}
|
||||
<div dangerouslySetInnerHTML={{ __html: html }} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
Preview.displayName = 'Preview'
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useEditorStore } from '../../stores/editorStore'
|
||||
import { renderMarkdown } from '../../lib/markdown'
|
||||
import { LoadingSpinner } from '../LoadingSpinner/LoadingSpinner'
|
||||
|
||||
// PF-07: 防抖延迟常量
|
||||
const PREVIEW_DEBOUNCE_MS = 150
|
||||
|
||||
export const Preview = React.memo(function Preview() {
|
||||
const [html, setHtml] = useState('')
|
||||
const [rendering, setRendering] = useState(false)
|
||||
const previewRef = useRef<HTMLDivElement>(null)
|
||||
const requestIdRef = useRef(0)
|
||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const activeTab = useTabStore(s => s.getActiveTab())
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
const setLoading = useEditorStore(s => s.setLoading)
|
||||
|
||||
// PF-07: 响应式渲染 + 防抖
|
||||
useEffect(() => {
|
||||
if (!activeTab) {
|
||||
setHtml('')
|
||||
setRendering(false)
|
||||
setLoading('markdown-render', false)
|
||||
return
|
||||
}
|
||||
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current)
|
||||
}
|
||||
|
||||
setRendering(true)
|
||||
setLoading('markdown-render', true)
|
||||
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
const requestId = ++requestIdRef.current
|
||||
renderMarkdown(activeTab.content, activeTab.filePath).then((result: string) => {
|
||||
if (requestId === requestIdRef.current) {
|
||||
setHtml(result)
|
||||
setRendering(false)
|
||||
setLoading('markdown-render', false)
|
||||
}
|
||||
})
|
||||
}, PREVIEW_DEBOUNCE_MS)
|
||||
|
||||
return () => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current)
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- activeTab is a derived reference (from getActiveTab()), we track content/filePath via optional chaining
|
||||
}, [activeTabId, activeTab?.content, activeTab?.filePath, setLoading])
|
||||
|
||||
// 拦截链接点击
|
||||
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const link = (e.target as HTMLElement).closest('a')
|
||||
if (!link) return
|
||||
e.preventDefault()
|
||||
const href: string | null = 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"
|
||||
role="region"
|
||||
aria-label="Markdown预览"
|
||||
aria-live="polite"
|
||||
aria-busy={rendering}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{rendering && html === '' && (
|
||||
<div className="preview-loading" aria-label="正在渲染Markdown">
|
||||
<LoadingSpinner size="medium" label="正在渲染..." />
|
||||
</div>
|
||||
)}
|
||||
<div dangerouslySetInnerHTML={{ __html: html }} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
Preview.displayName = 'Preview'
|
||||
|
||||
@@ -205,17 +205,21 @@ export const SearchReplace = memo(function SearchReplace({
|
||||
const view = getView()
|
||||
if (!view) return
|
||||
|
||||
const matches = matchesRef.current
|
||||
if (matches.length === 0) return
|
||||
// 重新搜索以获取匹配的最新位置
|
||||
const freshMatches = findMatches(view.state.doc, queryRef.current, caseSensitiveRef.current)
|
||||
if (freshMatches.length === 0) return
|
||||
|
||||
// Process matches in reverse order to preserve positions
|
||||
// 从后往前替换以保持位置正确
|
||||
const tr = view.state.tr
|
||||
for (let i = matches.length - 1; i >= 0; i--) {
|
||||
tr.insertText(replacement, matches[i].from, matches[i].to)
|
||||
for (let i = freshMatches.length - 1; i >= 0; i--) {
|
||||
tr.insertText(replacement, freshMatches[i].from, freshMatches[i].to)
|
||||
}
|
||||
view.dispatch(tr)
|
||||
|
||||
// Re-search after replacement
|
||||
// 更新ref
|
||||
matchesRef.current = []
|
||||
|
||||
// 替换后重新搜索
|
||||
requestAnimationFrame(() => {
|
||||
doSearch(queryRef.current, caseSensitiveRef.current)
|
||||
})
|
||||
|
||||
@@ -1,108 +1,139 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useSidebarStore } from '../../stores/sidebarStore'
|
||||
import { getFileName } from '../../lib/fileUtils'
|
||||
import { recentFilesRepository } from '../../db/recentFilesRepository'
|
||||
import { FolderPlus, File } from '../Icons'
|
||||
import { FileTree } from '../FileTree'
|
||||
import { useSidebarResize } from '../../hooks/useSidebarResize'
|
||||
import { useFolderOperations } from '../../hooks/useFolderOperations'
|
||||
import { useAutoExpandDir } from '../../hooks/useAutoExpandDir'
|
||||
|
||||
const norm = (p: string) => p.replace(/\\\\/g, '/')
|
||||
|
||||
export const Sidebar = React.memo(function Sidebar() {
|
||||
const tabs = useTabStore(s => s.tabs)
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
const activeTab = useTabStore(s => s.getActiveTab())
|
||||
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 isVisible = useSidebarStore(s => s.isVisible)
|
||||
|
||||
const activeFilePath = activeTab?.filePath ?? null
|
||||
const { sidebarRef, startResize } = useSidebarResize()
|
||||
const { handleOpenFolder } = useFolderOperations()
|
||||
useAutoExpandDir(activeFilePath)
|
||||
|
||||
const handleFileClick = useCallback(async (path: string) => {
|
||||
const existing = tabs.find(t => t.filePath === path)
|
||||
if (existing) { switchToTab(existing.id); return }
|
||||
if (!window.electronAPI) return
|
||||
const result = await window.electronAPI.readFile(path)
|
||||
if (result.success && result.content) {
|
||||
createTab(path, result.content)
|
||||
recentFilesRepository.add(path)
|
||||
}
|
||||
}, [tabs, switchToTab, createTab])
|
||||
|
||||
const independentFiles = tabs.filter(t => {
|
||||
if (!t.filePath) return false
|
||||
if (!rootPath) return true
|
||||
return !norm(t.filePath).startsWith(norm(rootPath))
|
||||
})
|
||||
|
||||
if (!isVisible) return null
|
||||
|
||||
return (
|
||||
<aside id="sidebar" ref={sidebarRef} aria-label="文件资源管理器">
|
||||
<div id="sidebar-header">
|
||||
<span id="sidebar-title">资源管理器</span>
|
||||
<button
|
||||
className="sidebar-header-btn"
|
||||
onClick={handleOpenFolder}
|
||||
title="打开文件夹"
|
||||
aria-label="打开文件夹"
|
||||
>
|
||||
<FolderPlus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<nav id="sidebar-tree" role="tree" aria-label="文件树">
|
||||
{independentFiles.length > 0 && (
|
||||
<div className="independent-files-section" role="group" aria-label="已打开的文件">
|
||||
<div className="independent-files-header" id="independent-files-label">已打开的文件</div>
|
||||
{independentFiles.map(tab => (
|
||||
<div key={tab.id}
|
||||
className={`tree-item independent-file-item ${tab.id === activeTabId ? 'active' : ''}`}
|
||||
style={{ paddingLeft: '8px' }}
|
||||
role="treeitem"
|
||||
tabIndex={0}
|
||||
aria-selected={tab.id === activeTabId}
|
||||
aria-label={getFileName(tab.filePath!)}
|
||||
onClick={() => switchToTab(tab.id)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); switchToTab(tab.id) } }}
|
||||
>
|
||||
<span className="tree-icon"><File size={14} /></span>
|
||||
<span className="tree-name">{getFileName(tab.filePath!)}</span>
|
||||
{tab.isModified && <span className="independent-modified-dot" aria-label="已修改"> •</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{rootPath && (
|
||||
<>
|
||||
<div className="sidebar-section-header" id="folder-tree-label">文件夹目录树</div>
|
||||
<FileTree
|
||||
nodes={[{ name: rootPath.split(/[/\\\\]/).pop() || rootPath, path: rootPath, type: 'dir' as const, children: tree }]}
|
||||
depth={0} expandedDirs={expandedDirs} toggleDir={toggleDir}
|
||||
activeTabId={activeTabId} activeFilePath={activeFilePath} onFileClick={handleFileClick}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
<div
|
||||
className="sidebar-resize-handle"
|
||||
onMouseDown={startResize}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="调整侧边栏宽度"
|
||||
tabIndex={0}
|
||||
/>
|
||||
</aside>
|
||||
)
|
||||
})
|
||||
|
||||
Sidebar.displayName = 'Sidebar'
|
||||
import React, { useCallback, useMemo } from 'react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useSidebarStore } from '../../stores/sidebarStore'
|
||||
import { getFileName } from '../../lib/fileUtils'
|
||||
import { recentFilesRepository } from '../../db/recentFilesRepository'
|
||||
import { FolderPlus, File } from '../Icons'
|
||||
import { FileTree } from '../FileTree'
|
||||
import { useSidebarResize } from '../../hooks/useSidebarResize'
|
||||
import { useFolderOperations } from '../../hooks/useFolderOperations'
|
||||
import { useAutoExpandDir } from '../../hooks/useAutoExpandDir'
|
||||
import { OutlinePanel, parseHeadings } from '../OutlinePanel'
|
||||
import { getEditorView } from '../../stores/editorStore'
|
||||
import { TextSelection } from '@milkdown/prose/state'
|
||||
|
||||
const norm = (p: string) => p.replace(/\\/g, '/')
|
||||
|
||||
export const Sidebar = React.memo(function Sidebar() {
|
||||
const tabs = useTabStore(s => s.tabs)
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
const activeTab = useTabStore(s => s.getActiveTab())
|
||||
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 isVisible = useSidebarStore(s => s.isVisible)
|
||||
|
||||
const activeFilePath = activeTab?.filePath ?? null
|
||||
const { sidebarRef, startResize } = useSidebarResize()
|
||||
const { handleOpenFolder } = useFolderOperations()
|
||||
useAutoExpandDir(activeFilePath)
|
||||
|
||||
// Parse headings from active tab content
|
||||
const headings = useMemo(() => {
|
||||
if (!activeTab?.content) return []
|
||||
return parseHeadings(activeTab.content)
|
||||
}, [activeTab?.content])
|
||||
|
||||
// Navigate to heading position in editor
|
||||
const handleHeadingNavigate = useCallback((pos: number) => {
|
||||
const view = getEditorView()
|
||||
if (!view) return
|
||||
try {
|
||||
const tr = view.state.tr.setSelection(
|
||||
TextSelection.create(view.state.doc, pos, pos)
|
||||
)
|
||||
view.dispatch(tr)
|
||||
view.focus()
|
||||
} catch {
|
||||
// Position may be invalid if content has changed
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleFileClick = useCallback(async (path: string) => {
|
||||
const existing = tabs.find(t => t.filePath === path)
|
||||
if (existing) { switchToTab(existing.id); return }
|
||||
if (!window.electronAPI) return
|
||||
const result = await window.electronAPI.readFile(path)
|
||||
if (result.success && result.content) {
|
||||
createTab(path, result.content)
|
||||
recentFilesRepository.add(path)
|
||||
}
|
||||
}, [tabs, switchToTab, createTab])
|
||||
|
||||
const independentFiles = tabs.filter(t => {
|
||||
if (!t.filePath) return false
|
||||
if (!rootPath) return true
|
||||
return !norm(t.filePath).startsWith(norm(rootPath))
|
||||
})
|
||||
|
||||
if (!isVisible) return null
|
||||
|
||||
return (
|
||||
<aside id="sidebar" ref={sidebarRef} aria-label="文件资源管理器">
|
||||
<div id="sidebar-header">
|
||||
<span id="sidebar-title">资源管理器</span>
|
||||
<button
|
||||
className="sidebar-header-btn"
|
||||
onClick={handleOpenFolder}
|
||||
title="打开文件夹"
|
||||
aria-label="打开文件夹"
|
||||
>
|
||||
<FolderPlus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<nav id="sidebar-tree" role="tree" aria-label="文件树">
|
||||
{independentFiles.length > 0 && (
|
||||
<div className="independent-files-section" role="group" aria-label="已打开的文件">
|
||||
<div className="independent-files-header" id="independent-files-label">已打开的文件</div>
|
||||
{independentFiles.map(tab => (
|
||||
<div key={tab.id}
|
||||
className={`tree-item independent-file-item ${tab.id === activeTabId ? 'active' : ''}`}
|
||||
style={{ paddingLeft: '8px' }}
|
||||
role="treeitem"
|
||||
tabIndex={0}
|
||||
aria-selected={tab.id === activeTabId}
|
||||
aria-label={getFileName(tab.filePath!)}
|
||||
onClick={() => switchToTab(tab.id)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); switchToTab(tab.id) } }}
|
||||
>
|
||||
<span className="tree-icon"><File size={14} /></span>
|
||||
<span className="tree-name">{getFileName(tab.filePath!)}</span>
|
||||
{tab.isModified && <span className="independent-modified-dot" aria-label="已修改"> •</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{rootPath && (
|
||||
<>
|
||||
<div className="sidebar-section-header" id="folder-tree-label">文件夹目录树</div>
|
||||
<FileTree
|
||||
nodes={[{ name: rootPath.split(/[/\\]/).pop() || rootPath, path: rootPath, type: 'dir' as const, children: tree }]}
|
||||
depth={0} expandedDirs={expandedDirs} toggleDir={toggleDir}
|
||||
activeTabId={activeTabId} activeFilePath={activeFilePath} onFileClick={handleFileClick}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
<div className="sidebar-outline-section">
|
||||
<OutlinePanel
|
||||
headings={headings}
|
||||
onNavigate={handleHeadingNavigate}
|
||||
activeHeadingIndex={null}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="sidebar-resize-handle"
|
||||
onMouseDown={startResize}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="调整侧边栏宽度"
|
||||
tabIndex={0}
|
||||
/>
|
||||
</aside>
|
||||
)
|
||||
})
|
||||
|
||||
Sidebar.displayName = 'Sidebar'
|
||||
|
||||
@@ -1,253 +1,253 @@
|
||||
import React, { useCallback, useState, useEffect, useRef } from 'react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useConfirm } from '../../hooks/useConfirm'
|
||||
import { getFileName } from '../../lib/fileUtils'
|
||||
import { Close, Plus } from '../Icons'
|
||||
import { ConfirmDialog } from '../ConfirmDialog/ConfirmDialog'
|
||||
|
||||
interface ContextMenuState {
|
||||
visible: boolean
|
||||
x: number
|
||||
y: number
|
||||
tabId: string
|
||||
}
|
||||
|
||||
export const TabBar = React.memo(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 closeOtherTabs = useTabStore(s => s.closeOtherTabs)
|
||||
const closeAllTabs = useTabStore(s => s.closeAllTabs)
|
||||
const closeTabsToRight = useTabStore(s => s.closeTabsToRight)
|
||||
|
||||
const tabListRef = useRef<HTMLDivElement>(null)
|
||||
const [menu, setMenu] = useState<ContextMenuState>({ visible: false, x: 0, y: 0, tabId: '' })
|
||||
const { confirm, confirmDialogProps } = useConfirm()
|
||||
|
||||
// 滚动到活动标签
|
||||
const scrollToActiveTab = useCallback(() => {
|
||||
const tabList = tabListRef.current
|
||||
if (!tabList) return
|
||||
const activeTab = tabList.querySelector('.tab-item.active') as HTMLElement
|
||||
if (!activeTab) return
|
||||
|
||||
const listRect = tabList.getBoundingClientRect()
|
||||
const tabRect = activeTab.getBoundingClientRect()
|
||||
|
||||
// 如果标签在可视区域左侧之外
|
||||
if (tabRect.left < listRect.left) {
|
||||
tabList.scrollLeft -= (listRect.left - tabRect.left + 20)
|
||||
}
|
||||
// 如果标签在可视区域右侧之外
|
||||
else if (tabRect.right > listRect.right) {
|
||||
tabList.scrollLeft += (tabRect.right - listRect.right + 20)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 自动滚动到活动标签
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(scrollToActiveTab)
|
||||
}, [activeTabId, scrollToActiveTab])
|
||||
|
||||
// 支持鼠标滚轮水平滚动标签栏
|
||||
useEffect(() => {
|
||||
const tabList = tabListRef.current
|
||||
if (!tabList) return
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
// 检查是否有水平溢出
|
||||
if (tabList.scrollWidth <= tabList.clientWidth) return
|
||||
|
||||
// 阻止默认滚动
|
||||
e.preventDefault()
|
||||
|
||||
// 计算滚动量:支持触控板 deltaX 和鼠标滚轮 deltaY
|
||||
const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY
|
||||
tabList.scrollLeft += delta
|
||||
}
|
||||
|
||||
// 直接绑定到 tabList,使用 passive: false 允许 preventDefault
|
||||
tabList.addEventListener('wheel', handleWheel, { passive: false })
|
||||
return () => tabList.removeEventListener('wheel', handleWheel)
|
||||
}, [])
|
||||
|
||||
const handleClose = useCallback(async (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) : '未命名'
|
||||
const confirmed = await confirm({
|
||||
title: '关闭标签',
|
||||
message: `"${name}" 尚未保存,确定要关闭吗?`,
|
||||
variant: 'warning',
|
||||
confirmLabel: '关闭'
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
closeTab(tabId)
|
||||
}, [tabs, closeTab, confirm])
|
||||
|
||||
// C-06: 右键菜单(带边界修正)
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent, tabId: string) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const MENU_WIDTH = 170
|
||||
const MENU_HEIGHT = 140
|
||||
const x = Math.min(e.clientX, window.innerWidth - MENU_WIDTH)
|
||||
const y = Math.min(e.clientY, window.innerHeight - MENU_HEIGHT)
|
||||
setMenu({ visible: true, x: Math.max(0, x), y: Math.max(0, y), tabId })
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!menu.visible) return
|
||||
const handleClick = () => setMenu(prev => ({ ...prev, visible: false }))
|
||||
document.addEventListener('click', handleClick)
|
||||
return () => document.removeEventListener('click', handleClick)
|
||||
}, [menu.visible])
|
||||
|
||||
const handleMenuClose = useCallback(async () => {
|
||||
const tab = tabs.find(t => t.id === menu.tabId)
|
||||
if (tab?.isModified) {
|
||||
const name = tab.filePath ? getFileName(tab.filePath) : '未命名'
|
||||
const confirmed = await confirm({
|
||||
title: '关闭标签',
|
||||
message: `"${name}" 尚未保存,确定要关闭吗?`,
|
||||
variant: 'warning',
|
||||
confirmLabel: '关闭'
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
closeTab(menu.tabId)
|
||||
setMenu(prev => ({ ...prev, visible: false }))
|
||||
}, [tabs, menu.tabId, closeTab, confirm])
|
||||
|
||||
const handleMenuCloseOthers = useCallback(async () => {
|
||||
const otherModified = tabs.filter(t => t.id !== menu.tabId && t.isModified)
|
||||
if (otherModified.length > 0) {
|
||||
const names = otherModified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、')
|
||||
const confirmed = await confirm({
|
||||
title: '关闭其他标签',
|
||||
message: `以下文件尚未保存:${names},确定要关闭吗?`,
|
||||
variant: 'warning',
|
||||
confirmLabel: '关闭'
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
closeOtherTabs(menu.tabId)
|
||||
setMenu(prev => ({ ...prev, visible: false }))
|
||||
}, [tabs, menu.tabId, closeOtherTabs, confirm])
|
||||
|
||||
const handleMenuCloseAll = useCallback(async () => {
|
||||
const modified = tabs.filter(t => t.isModified)
|
||||
if (modified.length > 0) {
|
||||
const names = modified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、')
|
||||
const confirmed = await confirm({
|
||||
title: '关闭全部标签',
|
||||
message: `以下文件尚未保存:${names},确定要关闭吗?`,
|
||||
variant: 'warning',
|
||||
confirmLabel: '关闭'
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
closeAllTabs()
|
||||
setMenu(prev => ({ ...prev, visible: false }))
|
||||
}, [tabs, closeAllTabs, confirm])
|
||||
|
||||
const handleMenuCloseRight = useCallback(async () => {
|
||||
const index = tabs.findIndex(t => t.id === menu.tabId)
|
||||
const rightTabs = tabs.slice(index + 1)
|
||||
const modified = rightTabs.filter(t => t.isModified)
|
||||
if (modified.length > 0) {
|
||||
const names = modified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、')
|
||||
const confirmed = await confirm({
|
||||
title: '关闭右侧标签',
|
||||
message: `以下文件尚未保存:${names},确定要关闭吗?`,
|
||||
variant: 'warning',
|
||||
confirmLabel: '关闭'
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
closeTabsToRight(menu.tabId)
|
||||
setMenu(prev => ({ ...prev, visible: false }))
|
||||
}, [tabs, menu.tabId, closeTabsToRight, confirm])
|
||||
|
||||
const hasRightTabs = menu.visible && (() => {
|
||||
const index = tabs.findIndex(t => t.id === menu.tabId)
|
||||
return index < tabs.length - 1
|
||||
})()
|
||||
|
||||
if (tabs.length === 0) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div id="tab-bar">
|
||||
<div id="tab-list" ref={tabListRef} role="tablist" aria-label="标签页">
|
||||
{tabs.map(tab => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`tab-item ${tab.id === activeTabId ? 'active' : ''} ${tab.isModified ? 'modified' : ''}`}
|
||||
role="tab"
|
||||
aria-selected={tab.id === activeTabId}
|
||||
tabIndex={tab.id === activeTabId ? 0 : -1}
|
||||
onClick={() => switchToTab(tab.id)}
|
||||
onContextMenu={(e) => handleContextMenu(e, tab.id)}
|
||||
>
|
||||
<span className="tab-name">
|
||||
{tab.filePath ? getFileName(tab.filePath) : '未命名'}
|
||||
</span>
|
||||
<button
|
||||
className="tab-close"
|
||||
onClick={(e) => handleClose(e, tab.id)}
|
||||
aria-label={`关闭 ${tab.filePath ? getFileName(tab.filePath) : '未命名'}`}
|
||||
>
|
||||
<Close size={10} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="tab-add-btn"
|
||||
onClick={() => createTab(null, '')}
|
||||
title="新建标签页 (Ctrl+T)"
|
||||
aria-label="新建标签页"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
|
||||
{/* 右键菜单 */}
|
||||
{menu.visible && (
|
||||
<div
|
||||
className="tab-context-menu"
|
||||
style={{ left: menu.x, top: menu.y }}
|
||||
role="menu"
|
||||
aria-label="标签操作"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="tab-context-item" role="menuitem" onClick={handleMenuClose}>
|
||||
关闭
|
||||
</div>
|
||||
{tabs.length > 1 && (
|
||||
<div className="tab-context-item" role="menuitem" onClick={handleMenuCloseOthers}>
|
||||
关闭其他标签
|
||||
</div>
|
||||
)}
|
||||
{hasRightTabs && (
|
||||
<div className="tab-context-item" role="menuitem" onClick={handleMenuCloseRight}>
|
||||
关闭右侧标签
|
||||
</div>
|
||||
)}
|
||||
<div className="tab-context-divider" role="separator" />
|
||||
<div className="tab-context-item" role="menuitem" onClick={handleMenuCloseAll}>
|
||||
关闭全部标签
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
TabBar.displayName = 'TabBar'
|
||||
import React, { useCallback, useState, useEffect, useRef } from 'react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useConfirm } from '../../hooks/useConfirm'
|
||||
import { getFileName } from '../../lib/fileUtils'
|
||||
import { Close, Plus } from '../Icons'
|
||||
import { ConfirmDialog } from '../ConfirmDialog/ConfirmDialog'
|
||||
|
||||
interface ContextMenuState {
|
||||
visible: boolean
|
||||
x: number
|
||||
y: number
|
||||
tabId: string
|
||||
}
|
||||
|
||||
export const TabBar = React.memo(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 closeOtherTabs = useTabStore(s => s.closeOtherTabs)
|
||||
const closeAllTabs = useTabStore(s => s.closeAllTabs)
|
||||
const closeTabsToRight = useTabStore(s => s.closeTabsToRight)
|
||||
|
||||
const tabListRef = useRef<HTMLDivElement>(null)
|
||||
const [menu, setMenu] = useState<ContextMenuState>({ visible: false, x: 0, y: 0, tabId: '' })
|
||||
const { confirm, confirmDialogProps } = useConfirm()
|
||||
|
||||
// 滚动到活动标签
|
||||
const scrollToActiveTab = useCallback(() => {
|
||||
const tabList = tabListRef.current
|
||||
if (!tabList) return
|
||||
const activeTab = tabList.querySelector('.tab-item.active') as HTMLElement
|
||||
if (!activeTab) return
|
||||
|
||||
const listRect = tabList.getBoundingClientRect()
|
||||
const tabRect = activeTab.getBoundingClientRect()
|
||||
|
||||
// 如果标签在可视区域左侧之外
|
||||
if (tabRect.left < listRect.left) {
|
||||
tabList.scrollLeft -= (listRect.left - tabRect.left + 20)
|
||||
}
|
||||
// 如果标签在可视区域右侧之外
|
||||
else if (tabRect.right > listRect.right) {
|
||||
tabList.scrollLeft += (tabRect.right - listRect.right + 20)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 自动滚动到活动标签
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(scrollToActiveTab)
|
||||
}, [activeTabId, scrollToActiveTab])
|
||||
|
||||
// 支持鼠标滚轮水平滚动标签栏
|
||||
useEffect(() => {
|
||||
const tabList = tabListRef.current
|
||||
if (!tabList) return
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
// 检查是否有水平溢出
|
||||
if (tabList.scrollWidth <= tabList.clientWidth) return
|
||||
|
||||
// 阻止默认滚动
|
||||
e.preventDefault()
|
||||
|
||||
// 计算滚动量:支持触控板 deltaX 和鼠标滚轮 deltaY
|
||||
const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY
|
||||
tabList.scrollLeft += delta
|
||||
}
|
||||
|
||||
// 直接绑定到 tabList,使用 passive: false 允许 preventDefault
|
||||
tabList.addEventListener('wheel', handleWheel, { passive: false })
|
||||
return () => tabList.removeEventListener('wheel', handleWheel)
|
||||
}, [])
|
||||
|
||||
const handleClose = useCallback(async (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) : '未命名'
|
||||
const confirmed = await confirm({
|
||||
title: '关闭标签',
|
||||
message: `"${name}" 尚未保存,确定要关闭吗?`,
|
||||
variant: 'warning',
|
||||
confirmLabel: '关闭'
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
closeTab(tabId)
|
||||
}, [tabs, closeTab, confirm])
|
||||
|
||||
// C-06: 右键菜单(带边界修正)
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent, tabId: string) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const MENU_WIDTH = 170
|
||||
const MENU_HEIGHT = 140
|
||||
const x = Math.min(e.clientX, window.innerWidth - MENU_WIDTH)
|
||||
const y = Math.min(e.clientY, window.innerHeight - MENU_HEIGHT)
|
||||
setMenu({ visible: true, x: Math.max(0, x), y: Math.max(0, y), tabId })
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!menu.visible) return
|
||||
const handleClick = () => setMenu(prev => ({ ...prev, visible: false }))
|
||||
document.addEventListener('click', handleClick)
|
||||
return () => document.removeEventListener('click', handleClick)
|
||||
}, [menu.visible])
|
||||
|
||||
const handleMenuClose = useCallback(async () => {
|
||||
const tab = tabs.find(t => t.id === menu.tabId)
|
||||
if (tab?.isModified) {
|
||||
const name = tab.filePath ? getFileName(tab.filePath) : '未命名'
|
||||
const confirmed = await confirm({
|
||||
title: '关闭标签',
|
||||
message: `"${name}" 尚未保存,确定要关闭吗?`,
|
||||
variant: 'warning',
|
||||
confirmLabel: '关闭'
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
closeTab(menu.tabId)
|
||||
setMenu(prev => ({ ...prev, visible: false }))
|
||||
}, [tabs, menu.tabId, closeTab, confirm])
|
||||
|
||||
const handleMenuCloseOthers = useCallback(async () => {
|
||||
const otherModified = tabs.filter(t => t.id !== menu.tabId && t.isModified)
|
||||
if (otherModified.length > 0) {
|
||||
const names = otherModified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、')
|
||||
const confirmed = await confirm({
|
||||
title: '关闭其他标签',
|
||||
message: `以下文件尚未保存:${names},确定要关闭吗?`,
|
||||
variant: 'warning',
|
||||
confirmLabel: '关闭'
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
closeOtherTabs(menu.tabId)
|
||||
setMenu(prev => ({ ...prev, visible: false }))
|
||||
}, [tabs, menu.tabId, closeOtherTabs, confirm])
|
||||
|
||||
const handleMenuCloseAll = useCallback(async () => {
|
||||
const modified = tabs.filter(t => t.isModified)
|
||||
if (modified.length > 0) {
|
||||
const names = modified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、')
|
||||
const confirmed = await confirm({
|
||||
title: '关闭全部标签',
|
||||
message: `以下文件尚未保存:${names},确定要关闭吗?`,
|
||||
variant: 'warning',
|
||||
confirmLabel: '关闭'
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
closeAllTabs()
|
||||
setMenu(prev => ({ ...prev, visible: false }))
|
||||
}, [tabs, closeAllTabs, confirm])
|
||||
|
||||
const handleMenuCloseRight = useCallback(async () => {
|
||||
const index = tabs.findIndex(t => t.id === menu.tabId)
|
||||
const rightTabs = tabs.slice(index + 1)
|
||||
const modified = rightTabs.filter(t => t.isModified)
|
||||
if (modified.length > 0) {
|
||||
const names = modified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、')
|
||||
const confirmed = await confirm({
|
||||
title: '关闭右侧标签',
|
||||
message: `以下文件尚未保存:${names},确定要关闭吗?`,
|
||||
variant: 'warning',
|
||||
confirmLabel: '关闭'
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
closeTabsToRight(menu.tabId)
|
||||
setMenu(prev => ({ ...prev, visible: false }))
|
||||
}, [tabs, menu.tabId, closeTabsToRight, confirm])
|
||||
|
||||
const hasRightTabs = menu.visible && (() => {
|
||||
const index = tabs.findIndex(t => t.id === menu.tabId)
|
||||
return index < tabs.length - 1
|
||||
})()
|
||||
|
||||
if (tabs.length === 0) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div id="tab-bar">
|
||||
<div id="tab-list" ref={tabListRef} role="tablist" aria-label="标签页">
|
||||
{tabs.map(tab => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`tab-item ${tab.id === activeTabId ? 'active' : ''} ${tab.isModified ? 'modified' : ''}`}
|
||||
role="tab"
|
||||
aria-selected={tab.id === activeTabId}
|
||||
tabIndex={tab.id === activeTabId ? 0 : -1}
|
||||
onClick={() => switchToTab(tab.id)}
|
||||
onContextMenu={(e) => handleContextMenu(e, tab.id)}
|
||||
>
|
||||
<span className="tab-name">
|
||||
{tab.filePath ? getFileName(tab.filePath) : '未命名'}
|
||||
</span>
|
||||
<button
|
||||
className="tab-close"
|
||||
onClick={(e) => handleClose(e, tab.id)}
|
||||
aria-label={`关闭 ${tab.filePath ? getFileName(tab.filePath) : '未命名'}`}
|
||||
>
|
||||
<Close size={10} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="tab-add-btn"
|
||||
onClick={() => createTab(null, '')}
|
||||
title="新建标签页 (Ctrl+T)"
|
||||
aria-label="新建标签页"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
|
||||
{/* 右键菜单 */}
|
||||
{menu.visible && (
|
||||
<div
|
||||
className="tab-context-menu"
|
||||
style={{ left: menu.x, top: menu.y }}
|
||||
role="menu"
|
||||
aria-label="标签操作"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="tab-context-item" role="menuitem" onClick={handleMenuClose}>
|
||||
关闭
|
||||
</div>
|
||||
{tabs.length > 1 && (
|
||||
<div className="tab-context-item" role="menuitem" onClick={handleMenuCloseOthers}>
|
||||
关闭其他标签
|
||||
</div>
|
||||
)}
|
||||
{hasRightTabs && (
|
||||
<div className="tab-context-item" role="menuitem" onClick={handleMenuCloseRight}>
|
||||
关闭右侧标签
|
||||
</div>
|
||||
)}
|
||||
<div className="tab-context-divider" role="separator" />
|
||||
<div className="tab-context-item" role="menuitem" onClick={handleMenuCloseAll}>
|
||||
关闭全部标签
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
TabBar.displayName = 'TabBar'
|
||||
|
||||
@@ -1,84 +1,84 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import { useEditorStore } from '../stores/editorStore'
|
||||
import { recentFilesRepository } from '../db/recentFilesRepository'
|
||||
import { logError } from '../lib/errorHandler'
|
||||
import type { ToastType } from '../components/Toast/Toast'
|
||||
|
||||
/**
|
||||
* AR-01: 从 App.tsx 提取的文件操作逻辑
|
||||
* UX-02: 添加 loading 状态指示
|
||||
*/
|
||||
export function useFileOperations(showToast: (msg: string, type?: ToastType) => void) {
|
||||
const createTab = useTabStore(s => s.createTab)
|
||||
const getActiveTab = useTabStore(s => s.getActiveTab)
|
||||
const setLoading = useEditorStore(s => s.setLoading)
|
||||
|
||||
const handleOpenFile = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
if (!window.electronAPI) return
|
||||
setLoading('file-open', true)
|
||||
const result = await window.electronAPI.openFile()
|
||||
if (result && 'filePath' in result) {
|
||||
createTab(result.filePath, result.content)
|
||||
if (result.filePath) recentFilesRepository.add(result.filePath)
|
||||
setTimeout(() => useTabStore.getState().saveToDB(), 100)
|
||||
}
|
||||
} catch (error) {
|
||||
logError('打开文件失败', error)
|
||||
showToast('打开文件失败', 'error')
|
||||
} finally {
|
||||
setLoading('file-open', false)
|
||||
}
|
||||
}, [createTab, showToast, setLoading])
|
||||
|
||||
const handleSave = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const tab = getActiveTab()
|
||||
if (!tab || !window.electronAPI) return
|
||||
const result = await window.electronAPI.saveFile({
|
||||
filePath: tab.filePath,
|
||||
content: tab.content
|
||||
})
|
||||
if (result.success) {
|
||||
useTabStore.getState().setModified(tab.id, false)
|
||||
showToast('已保存', 'success')
|
||||
}
|
||||
} catch (error) {
|
||||
logError('保存文件失败', error)
|
||||
showToast('保存失败', 'error')
|
||||
}
|
||||
}, [getActiveTab, showToast])
|
||||
|
||||
const handleSaveAs = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const tab = getActiveTab()
|
||||
if (!tab || !window.electronAPI) return
|
||||
const result = await window.electronAPI.saveFileAs({ content: tab.content })
|
||||
if (result.success) {
|
||||
useTabStore.getState().setModified(tab.id, false)
|
||||
showToast('已保存', 'success')
|
||||
}
|
||||
} catch (error) {
|
||||
logError('另存为失败', error)
|
||||
showToast('另存为失败', 'error')
|
||||
}
|
||||
}, [getActiveTab, showToast])
|
||||
|
||||
const handleOpenRecent = useCallback(async (filePath: string): Promise<void> => {
|
||||
if (!window.electronAPI) return
|
||||
setLoading('file-open', true)
|
||||
try {
|
||||
const result = await window.electronAPI.readFile(filePath)
|
||||
if (result.success) {
|
||||
createTab(filePath, result.content)
|
||||
recentFilesRepository.add(filePath)
|
||||
setTimeout(() => useTabStore.getState().saveToDB(), 100)
|
||||
}
|
||||
} finally {
|
||||
setLoading('file-open', false)
|
||||
}
|
||||
}, [createTab, setLoading])
|
||||
|
||||
return { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent }
|
||||
}
|
||||
import { useCallback } from 'react'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import { useEditorStore } from '../stores/editorStore'
|
||||
import { recentFilesRepository } from '../db/recentFilesRepository'
|
||||
import { logError } from '../lib/errorHandler'
|
||||
import type { ToastType } from '../components/Toast/Toast'
|
||||
|
||||
/**
|
||||
* AR-01: 从 App.tsx 提取的文件操作逻辑
|
||||
* UX-02: 添加 loading 状态指示
|
||||
*/
|
||||
export function useFileOperations(showToast: (msg: string, type?: ToastType) => void) {
|
||||
const createTab = useTabStore(s => s.createTab)
|
||||
const getActiveTab = useTabStore(s => s.getActiveTab)
|
||||
const setLoading = useEditorStore(s => s.setLoading)
|
||||
|
||||
const handleOpenFile = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
if (!window.electronAPI) return
|
||||
setLoading('file-open', true)
|
||||
const result = await window.electronAPI.openFile()
|
||||
if (result && 'filePath' in result) {
|
||||
createTab(result.filePath, result.content)
|
||||
if (result.filePath) recentFilesRepository.add(result.filePath)
|
||||
setTimeout(() => useTabStore.getState().saveToDB(), 100)
|
||||
}
|
||||
} catch (error) {
|
||||
logError('打开文件失败', error)
|
||||
showToast('打开文件失败', 'error')
|
||||
} finally {
|
||||
setLoading('file-open', false)
|
||||
}
|
||||
}, [createTab, showToast, setLoading])
|
||||
|
||||
const handleSave = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const tab = getActiveTab()
|
||||
if (!tab || !window.electronAPI) return
|
||||
const result = await window.electronAPI.saveFile({
|
||||
filePath: tab.filePath,
|
||||
content: tab.content
|
||||
})
|
||||
if (result.success) {
|
||||
useTabStore.getState().setModified(tab.id, false)
|
||||
showToast('已保存', 'success')
|
||||
}
|
||||
} catch (error) {
|
||||
logError('保存文件失败', error)
|
||||
showToast('保存失败', 'error')
|
||||
}
|
||||
}, [getActiveTab, showToast])
|
||||
|
||||
const handleSaveAs = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const tab = getActiveTab()
|
||||
if (!tab || !window.electronAPI) return
|
||||
const result = await window.electronAPI.saveFileAs({ content: tab.content })
|
||||
if (result.success) {
|
||||
useTabStore.getState().setModified(tab.id, false)
|
||||
showToast('已保存', 'success')
|
||||
}
|
||||
} catch (error) {
|
||||
logError('另存为失败', error)
|
||||
showToast('另存为失败', 'error')
|
||||
}
|
||||
}, [getActiveTab, showToast])
|
||||
|
||||
const handleOpenRecent = useCallback(async (filePath: string): Promise<void> => {
|
||||
if (!window.electronAPI) return
|
||||
setLoading('file-open', true)
|
||||
try {
|
||||
const result = await window.electronAPI.readFile(filePath)
|
||||
if (result.success) {
|
||||
createTab(filePath, result.content)
|
||||
recentFilesRepository.add(filePath)
|
||||
setTimeout(() => useTabStore.getState().saveToDB(), 100)
|
||||
}
|
||||
} finally {
|
||||
setLoading('file-open', false)
|
||||
}
|
||||
}, [createTab, setLoading])
|
||||
|
||||
return { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent }
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export function useFolderOperations() {
|
||||
const handleOpenFolder = useCallback(async () => {
|
||||
if (!window.electronAPI) return
|
||||
const result = await window.electronAPI.openFolderDialog()
|
||||
if (!result) return
|
||||
if (!result.canceled && result.filePaths[0]) {
|
||||
const dirPath = result.filePaths[0]
|
||||
setRootPath(dirPath)
|
||||
|
||||
@@ -1,8 +1,2 @@
|
||||
// 常量定义
|
||||
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 { MAX_FILE_SIZE, ALLOWED_EXTENSIONS, SKIP_DIRS } from '../../shared/constants'
|
||||
|
||||
@@ -1,43 +1,43 @@
|
||||
/**
|
||||
* CQ-05: 统一错误处理模块
|
||||
* 提供一致的错误处理模式,替换分散的 try-catch
|
||||
*/
|
||||
|
||||
export class AppError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly code: string,
|
||||
public readonly cause?: unknown
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'AppError'
|
||||
}
|
||||
}
|
||||
|
||||
/** 日志级别的错误处理(仅 console.error,不中断流程) */
|
||||
export function logError(context: string, error: unknown): void {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
// eslint-disable-next-line no-console -- intentional logging utility
|
||||
console.error(`[${context}] ${message}`, error)
|
||||
}
|
||||
|
||||
/** 用户操作级错误处理(返回友好的错误信息给 showToast) */
|
||||
export function getErrorMessage(error: unknown, fallback: string): string {
|
||||
if (error instanceof AppError) return error.message
|
||||
if (error instanceof Error) return `${fallback}: ${error.message}`
|
||||
return fallback
|
||||
}
|
||||
|
||||
/** 通用 try-catch 包装器,返回 [result, error] */
|
||||
export async function tryAsync<T>(
|
||||
fn: () => Promise<T>,
|
||||
context: string
|
||||
): Promise<[T | null, null] | [null, AppError]> {
|
||||
try {
|
||||
const result = await fn()
|
||||
return [result, null]
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return [null, new AppError(`${context}: ${message}`, context, error)]
|
||||
}
|
||||
}
|
||||
/**
|
||||
* CQ-05: 统一错误处理模块
|
||||
* 提供一致的错误处理模式,替换分散的 try-catch
|
||||
*/
|
||||
|
||||
export class AppError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly code: string,
|
||||
public readonly cause?: unknown
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'AppError'
|
||||
}
|
||||
}
|
||||
|
||||
/** 日志级别的错误处理(仅 console.error,不中断流程) */
|
||||
export function logError(context: string, error: unknown): void {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
// eslint-disable-next-line no-console -- intentional logging utility
|
||||
console.error(`[${context}] ${message}`, error)
|
||||
}
|
||||
|
||||
/** 用户操作级错误处理(返回友好的错误信息给 showToast) */
|
||||
export function getErrorMessage(error: unknown, fallback: string): string {
|
||||
if (error instanceof AppError) return error.message
|
||||
if (error instanceof Error) return `${fallback}: ${error.message}`
|
||||
return fallback
|
||||
}
|
||||
|
||||
/** 通用 try-catch 包装器,返回 [result, error] */
|
||||
export async function tryAsync<T>(
|
||||
fn: () => Promise<T>,
|
||||
context: string
|
||||
): Promise<[T | null, null] | [null, AppError]> {
|
||||
try {
|
||||
const result = await fn()
|
||||
return [result, null]
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return [null, new AppError(`${context}: ${message}`, context, error)]
|
||||
}
|
||||
}
|
||||
|
||||
+123
-115
@@ -1,115 +1,123 @@
|
||||
import { unified, type Plugin } 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'
|
||||
import type { Element, Root } from 'hast'
|
||||
|
||||
// 简单的路径解析(Electron renderer 中没有 path 模块)
|
||||
function resolveRelativePath(base: string, rel: string): string {
|
||||
const sep = base.includes('\\') ? '\\' : '/'
|
||||
const parts = base.split(sep)
|
||||
parts.pop() // 移除文件名
|
||||
const relParts = rel.split('/')
|
||||
for (const p of relParts) {
|
||||
if (p === '.' || p === '') continue
|
||||
if (p === '..') {
|
||||
if (parts.length > 0) parts.pop()
|
||||
} else {
|
||||
parts.push(p)
|
||||
}
|
||||
}
|
||||
return parts.join(sep)
|
||||
}
|
||||
|
||||
// 自定义 rehype 插件:将相对路径图片转为 file:// 绝对路径
|
||||
function rehypeFixImages(filePath: string | null): Plugin<[], Root> {
|
||||
return () => (tree: Root) => {
|
||||
if (!filePath) return
|
||||
|
||||
const dir: string = filePath.replace(/[/\\][^/\\]+$/, '')
|
||||
const sep: string = dir.includes('\\') ? '\\' : '/'
|
||||
|
||||
function visit(node: Element | Root): void {
|
||||
if (!node.children) return
|
||||
for (const child of node.children) {
|
||||
if (child.type === 'element' && child.tagName === 'img') {
|
||||
const src = child.properties?.src as string | undefined
|
||||
if (src && !src.startsWith('http://') && !src.startsWith('https://') && !src.startsWith('data:') && !src.startsWith('file://')) {
|
||||
// 解析完整路径并检查是否越界到 markdown 文件所在目录之外
|
||||
const resolvedPath = resolveRelativePath(dir, src)
|
||||
if (!resolvedPath.startsWith(dir + sep)) return
|
||||
child.properties = {
|
||||
...child.properties,
|
||||
src: 'file://' + (dir + sep + src).replace(/\\/g, '/')
|
||||
}
|
||||
}
|
||||
}
|
||||
if (child.type === 'element') {
|
||||
visit(child as Element)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
visit(tree)
|
||||
}
|
||||
}
|
||||
|
||||
// PF-01: Markdown处理器LRU缓存
|
||||
const MAX_CACHE_SIZE = 10
|
||||
const processorCache = new Map<string, ReturnType<typeof buildProcessor>>()
|
||||
|
||||
function buildProcessor(filePath: string | null) {
|
||||
return unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkGfm)
|
||||
.use(remarkRehype, { allowDangerousHtml: true })
|
||||
.use(rehypeRaw)
|
||||
.use(rehypeSanitize, {
|
||||
...defaultSchema,
|
||||
attributes: {
|
||||
...defaultSchema.attributes,
|
||||
img: [...(defaultSchema.attributes?.img ?? []), ['src']],
|
||||
}
|
||||
})
|
||||
.use(rehypeFixImages(filePath ?? null))
|
||||
.use(rehypeHighlight)
|
||||
.use(rehypeStringify)
|
||||
}
|
||||
|
||||
function getCachedProcessor(filePath: string | null): ReturnType<typeof buildProcessor> {
|
||||
const key: string = filePath ?? '__null__'
|
||||
|
||||
if (processorCache.has(key)) {
|
||||
const cached = processorCache.get(key)!
|
||||
processorCache.delete(key)
|
||||
processorCache.set(key, cached)
|
||||
return cached
|
||||
}
|
||||
|
||||
const processor = buildProcessor(filePath)
|
||||
|
||||
if (processorCache.size >= MAX_CACHE_SIZE) {
|
||||
const oldestKey = processorCache.keys().next().value
|
||||
if (oldestKey !== undefined) {
|
||||
processorCache.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
processorCache.set(key, processor)
|
||||
return processor
|
||||
}
|
||||
|
||||
export async function renderMarkdown(content: string, filePath?: string | null): Promise<string> {
|
||||
try {
|
||||
const processor = getCachedProcessor(filePath ?? null)
|
||||
const result = await processor.process(content)
|
||||
return String(result)
|
||||
} catch (e) {
|
||||
const errorMsg: string = e instanceof Error ? e.message : String(e)
|
||||
return `<p style="color:red">渲染错误: ${errorMsg}</p>`
|
||||
}
|
||||
}
|
||||
import { unified, type Plugin } 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'
|
||||
import type { Element, Root } from 'hast'
|
||||
|
||||
// 简单的路径解析(Electron renderer 中没有 path 模块)
|
||||
function resolveRelativePath(base: string, rel: string): string {
|
||||
const sep = base.includes('\\') ? '\\' : '/'
|
||||
const parts = base.split(sep)
|
||||
parts.pop() // 移除文件名
|
||||
const relParts = rel.split('/')
|
||||
for (const p of relParts) {
|
||||
if (p === '.' || p === '') continue
|
||||
if (p === '..') {
|
||||
if (parts.length > 0) parts.pop()
|
||||
} else {
|
||||
parts.push(p)
|
||||
}
|
||||
}
|
||||
return parts.join(sep)
|
||||
}
|
||||
|
||||
// 自定义 rehype 插件:将相对路径图片转为 file:// 绝对路径
|
||||
function rehypeFixImages(filePath: string | null): Plugin<[], Root> {
|
||||
return () => (tree: Root) => {
|
||||
if (!filePath) return
|
||||
|
||||
const dir: string = filePath.replace(/[/\\][^/\\]+$/, '')
|
||||
const sep: string = dir.includes('\\') ? '\\' : '/'
|
||||
|
||||
function visit(node: Element | Root): void {
|
||||
if (!node.children) return
|
||||
for (const child of node.children) {
|
||||
if (child.type === 'element' && child.tagName === 'img') {
|
||||
const src = child.properties?.src as string | undefined
|
||||
if (src && !src.startsWith('http://') && !src.startsWith('https://') && !src.startsWith('data:') && !src.startsWith('file://')) {
|
||||
// 处理Unix风格绝对路径(以/开头)
|
||||
if (src.startsWith('/')) {
|
||||
child.properties = {
|
||||
...child.properties,
|
||||
src: 'file://' + src
|
||||
}
|
||||
continue
|
||||
}
|
||||
// 解析完整路径并检查是否越界到 markdown 文件所在目录之外
|
||||
const resolvedPath = resolveRelativePath(dir, src)
|
||||
if (!resolvedPath.startsWith(dir + sep)) return
|
||||
child.properties = {
|
||||
...child.properties,
|
||||
src: 'file://' + (dir + sep + src).replace(/\\/g, '/')
|
||||
}
|
||||
}
|
||||
}
|
||||
if (child.type === 'element') {
|
||||
visit(child as Element)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
visit(tree)
|
||||
}
|
||||
}
|
||||
|
||||
// PF-01: Markdown处理器LRU缓存
|
||||
const MAX_CACHE_SIZE = 10
|
||||
const processorCache = new Map<string, ReturnType<typeof buildProcessor>>()
|
||||
|
||||
function buildProcessor(filePath: string | null) {
|
||||
return unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkGfm)
|
||||
.use(remarkRehype, { allowDangerousHtml: true })
|
||||
.use(rehypeRaw)
|
||||
.use(rehypeSanitize, {
|
||||
...defaultSchema,
|
||||
attributes: {
|
||||
...defaultSchema.attributes,
|
||||
img: [...(defaultSchema.attributes?.img ?? []), ['src']],
|
||||
}
|
||||
})
|
||||
.use(rehypeFixImages(filePath ?? null))
|
||||
.use(rehypeHighlight)
|
||||
.use(rehypeStringify)
|
||||
}
|
||||
|
||||
function getCachedProcessor(filePath: string | null): ReturnType<typeof buildProcessor> {
|
||||
const key: string = filePath ?? '__null__'
|
||||
|
||||
if (processorCache.has(key)) {
|
||||
const cached = processorCache.get(key)!
|
||||
processorCache.delete(key)
|
||||
processorCache.set(key, cached)
|
||||
return cached
|
||||
}
|
||||
|
||||
const processor = buildProcessor(filePath)
|
||||
|
||||
if (processorCache.size >= MAX_CACHE_SIZE) {
|
||||
const oldestKey = processorCache.keys().next().value
|
||||
if (oldestKey !== undefined) {
|
||||
processorCache.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
processorCache.set(key, processor)
|
||||
return processor
|
||||
}
|
||||
|
||||
export async function renderMarkdown(content: string, filePath?: string | null): Promise<string> {
|
||||
try {
|
||||
const processor = getCachedProcessor(filePath ?? null)
|
||||
const result = await processor.process(content)
|
||||
return String(result)
|
||||
} catch (e) {
|
||||
const errorMsg: string = e instanceof Error ? e.message : String(e)
|
||||
return `<p style="color:red">渲染错误: ${errorMsg}</p>`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import { create } from 'zustand'
|
||||
import type { EditorView } from '@milkdown/prose/view'
|
||||
import type { ViewMode } from '../types/settings'
|
||||
|
||||
// Module-level getter/setter for the ProseMirror EditorView
|
||||
// Used by OutlinePanel for heading navigation
|
||||
let _getView: (() => EditorView | null) | null = null
|
||||
|
||||
export function setEditorViewGetter(fn: () => EditorView | null) {
|
||||
_getView = fn
|
||||
}
|
||||
|
||||
export function getEditorView(): EditorView | null {
|
||||
return _getView ? _getView() : null
|
||||
}
|
||||
|
||||
interface EditorState {
|
||||
viewMode: ViewMode
|
||||
darkMode: boolean
|
||||
|
||||
+246
-246
@@ -1,246 +1,246 @@
|
||||
import { create } from 'zustand'
|
||||
import { nanoid } from 'nanoid'
|
||||
import type { Tab } from '../types/tab'
|
||||
import { tabRepository } from '../db/tabRepository'
|
||||
|
||||
// PF-08: 防抖工具函数
|
||||
function debounce<F extends (...args: unknown[]) => void>(fn: F, delay: number): F & { cancel: () => void } {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
const debounced = ((...args: unknown[]) => {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = setTimeout(() => {
|
||||
fn(...args)
|
||||
timer = null
|
||||
}, delay)
|
||||
}) as F & { cancel: () => void }
|
||||
debounced.cancel = () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
return debounced
|
||||
}
|
||||
|
||||
interface TabState {
|
||||
tabs: Tab[]
|
||||
activeTabId: string | null
|
||||
mruStack: string[]
|
||||
_loaded: boolean
|
||||
|
||||
createTab: (filePath?: string | null, content?: string) => Tab
|
||||
closeTab: (tabId: string) => void
|
||||
closeOtherTabs: (tabId: string) => void
|
||||
closeAllTabs: () => void
|
||||
closeTabsToRight: (tabId: string) => void
|
||||
switchToTab: (tabId: string) => void
|
||||
updateTabContent: (tabId: string, content: string) => void
|
||||
setModified: (tabId: string, modified: boolean) => void
|
||||
getActiveTab: () => Tab | null
|
||||
updateTabScroll: (tabId: string, scroll: Partial<Pick<Tab, 'scrollTop' | 'selectionStart' | 'selectionEnd'>>) => void
|
||||
loadFromDB: () => Promise<void>
|
||||
saveToDB: () => Promise<void>
|
||||
}
|
||||
|
||||
// PF-08: 模块级防抖保存函数(500ms延迟)
|
||||
let _debouncedSaveToDB: (() => void) & { cancel: () => void } | null = null
|
||||
|
||||
function getActualSaveToDB(get: () => TabState) {
|
||||
return async () => {
|
||||
const { tabs } = get()
|
||||
if (tabs.length === 0) {
|
||||
await tabRepository.clearAll()
|
||||
return
|
||||
}
|
||||
const snapshots = tabs.map(t => ({
|
||||
id: t.id,
|
||||
filePath: t.filePath,
|
||||
content: t.content,
|
||||
isModified: t.isModified,
|
||||
scrollTop: t.scrollTop,
|
||||
selectionStart: t.selectionStart,
|
||||
selectionEnd: t.selectionEnd,
|
||||
updatedAt: Date.now()
|
||||
}))
|
||||
await tabRepository.saveAll(snapshots)
|
||||
await tabRepository.saveActiveTabId(get().activeTabId)
|
||||
}
|
||||
}
|
||||
|
||||
export const useTabStore = create<TabState>((set, get) => {
|
||||
const actualSave = getActualSaveToDB(get)
|
||||
_debouncedSaveToDB = debounce(actualSave, 500)
|
||||
|
||||
return {
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
mruStack: [],
|
||||
_loaded: false,
|
||||
|
||||
loadFromDB: async () => {
|
||||
if (get()._loaded) return
|
||||
try {
|
||||
const [snapshots, savedActiveTabId] = await Promise.all([
|
||||
tabRepository.loadAll(),
|
||||
tabRepository.loadActiveTabId()
|
||||
])
|
||||
if (snapshots.length > 0) {
|
||||
const tabs: Tab[] = snapshots.map(s => ({
|
||||
id: s.id,
|
||||
filePath: s.filePath,
|
||||
content: s.content,
|
||||
isModified: s.isModified,
|
||||
scrollTop: s.scrollTop,
|
||||
selectionStart: s.selectionStart,
|
||||
selectionEnd: s.selectionEnd
|
||||
}))
|
||||
const activeTabId = (savedActiveTabId && tabs.find(t => t.id === savedActiveTabId))
|
||||
? savedActiveTabId
|
||||
: tabs[tabs.length - 1].id
|
||||
set({ tabs, activeTabId, _loaded: true })
|
||||
} else {
|
||||
set({ _loaded: true })
|
||||
}
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console -- DB load error
|
||||
console.error('Failed to load tabs from DB:', err)
|
||||
set({ _loaded: true })
|
||||
}
|
||||
},
|
||||
|
||||
saveToDB: async () => {
|
||||
if (_debouncedSaveToDB) {
|
||||
_debouncedSaveToDB()
|
||||
}
|
||||
},
|
||||
|
||||
createTab: (filePath: string | null = null, content: string = ''): Tab => {
|
||||
if (filePath) {
|
||||
const existing = get().tabs.find(t => t.filePath === filePath)
|
||||
if (existing) {
|
||||
get().switchToTab(existing.id)
|
||||
return existing
|
||||
}
|
||||
}
|
||||
|
||||
const tab: Tab = {
|
||||
id: nanoid(),
|
||||
filePath,
|
||||
content,
|
||||
isModified: false,
|
||||
scrollTop: 0,
|
||||
selectionStart: 0,
|
||||
selectionEnd: 0
|
||||
}
|
||||
|
||||
set(state => ({
|
||||
tabs: [...state.tabs, tab],
|
||||
activeTabId: tab.id
|
||||
}))
|
||||
|
||||
_debouncedSaveToDB?.()
|
||||
return tab
|
||||
},
|
||||
|
||||
closeTab: (tabId: string) => {
|
||||
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 mruCandidate = newMru.find(id => newTabs.some(t => t.id === id))
|
||||
if (mruCandidate) {
|
||||
newActiveId = mruCandidate
|
||||
newMru.splice(newMru.indexOf(mruCandidate), 1)
|
||||
} else {
|
||||
const newIndex = Math.min(index, newTabs.length - 1)
|
||||
newActiveId = newTabs[newIndex].id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { tabs: newTabs, activeTabId: newActiveId, mruStack: newMru }
|
||||
})
|
||||
|
||||
_debouncedSaveToDB?.()
|
||||
},
|
||||
|
||||
closeOtherTabs: (tabId: string) => {
|
||||
set(state => {
|
||||
const target = state.tabs.find(t => t.id === tabId)
|
||||
if (!target) return state
|
||||
return { tabs: [target], activeTabId: tabId, mruStack: [] }
|
||||
})
|
||||
_debouncedSaveToDB?.()
|
||||
},
|
||||
|
||||
closeAllTabs: () => {
|
||||
set({ tabs: [], activeTabId: null, mruStack: [] })
|
||||
_debouncedSaveToDB?.()
|
||||
},
|
||||
|
||||
closeTabsToRight: (tabId: string) => {
|
||||
set(state => {
|
||||
const index = state.tabs.findIndex(t => t.id === tabId)
|
||||
if (index === -1) return state
|
||||
const newTabs = state.tabs.slice(0, index + 1)
|
||||
const newActiveId = state.activeTabId && newTabs.find(t => t.id === state.activeTabId)
|
||||
? state.activeTabId
|
||||
: tabId
|
||||
return {
|
||||
tabs: newTabs,
|
||||
activeTabId: newActiveId,
|
||||
mruStack: state.mruStack.filter(id => newTabs.some(t => t.id === id))
|
||||
}
|
||||
})
|
||||
_debouncedSaveToDB?.()
|
||||
},
|
||||
|
||||
switchToTab: (tabId: string) => {
|
||||
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 }
|
||||
})
|
||||
setTimeout(() => tabRepository.saveActiveTabId(tabId), 0)
|
||||
},
|
||||
|
||||
updateTabContent: (tabId: string, content: string) => {
|
||||
set(state => ({
|
||||
tabs: state.tabs.map(t =>
|
||||
t.id === tabId ? { ...t, content, isModified: true } : t
|
||||
)
|
||||
}))
|
||||
},
|
||||
|
||||
setModified: (tabId: string, modified: boolean) => {
|
||||
set(state => ({
|
||||
tabs: state.tabs.map(t =>
|
||||
t.id === tabId ? { ...t, isModified: modified } : t
|
||||
)
|
||||
}))
|
||||
},
|
||||
|
||||
getActiveTab: (): Tab | null => {
|
||||
const { tabs, activeTabId } = get()
|
||||
return tabs.find(t => t.id === activeTabId) ?? null
|
||||
},
|
||||
|
||||
updateTabScroll: (tabId: string, scroll: Partial<Pick<Tab, 'scrollTop' | 'selectionStart' | 'selectionEnd'>>) => {
|
||||
set(state => ({
|
||||
tabs: state.tabs.map(t =>
|
||||
t.id === tabId ? { ...t, ...scroll } : t
|
||||
)
|
||||
}))
|
||||
}
|
||||
}
|
||||
})
|
||||
import { create } from 'zustand'
|
||||
import { nanoid } from 'nanoid'
|
||||
import type { Tab } from '../types/tab'
|
||||
import { tabRepository } from '../db/tabRepository'
|
||||
|
||||
// PF-08: 防抖工具函数
|
||||
function debounce<F extends (...args: unknown[]) => void>(fn: F, delay: number): F & { cancel: () => void } {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
const debounced = ((...args: unknown[]) => {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = setTimeout(() => {
|
||||
fn(...args)
|
||||
timer = null
|
||||
}, delay)
|
||||
}) as F & { cancel: () => void }
|
||||
debounced.cancel = () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
return debounced
|
||||
}
|
||||
|
||||
interface TabState {
|
||||
tabs: Tab[]
|
||||
activeTabId: string | null
|
||||
mruStack: string[]
|
||||
_loaded: boolean
|
||||
|
||||
createTab: (filePath?: string | null, content?: string) => Tab
|
||||
closeTab: (tabId: string) => void
|
||||
closeOtherTabs: (tabId: string) => void
|
||||
closeAllTabs: () => void
|
||||
closeTabsToRight: (tabId: string) => void
|
||||
switchToTab: (tabId: string) => void
|
||||
updateTabContent: (tabId: string, content: string) => void
|
||||
setModified: (tabId: string, modified: boolean) => void
|
||||
getActiveTab: () => Tab | null
|
||||
updateTabScroll: (tabId: string, scroll: Partial<Pick<Tab, 'scrollTop' | 'selectionStart' | 'selectionEnd'>>) => void
|
||||
loadFromDB: () => Promise<void>
|
||||
saveToDB: () => Promise<void>
|
||||
}
|
||||
|
||||
// PF-08: 模块级防抖保存函数(500ms延迟)
|
||||
let _debouncedSaveToDB: (() => void) & { cancel: () => void } | null = null
|
||||
|
||||
function getActualSaveToDB(get: () => TabState) {
|
||||
return async () => {
|
||||
const { tabs } = get()
|
||||
if (tabs.length === 0) {
|
||||
await tabRepository.clearAll()
|
||||
return
|
||||
}
|
||||
const snapshots = tabs.map(t => ({
|
||||
id: t.id,
|
||||
filePath: t.filePath,
|
||||
content: t.content,
|
||||
isModified: t.isModified,
|
||||
scrollTop: t.scrollTop,
|
||||
selectionStart: t.selectionStart,
|
||||
selectionEnd: t.selectionEnd,
|
||||
updatedAt: Date.now()
|
||||
}))
|
||||
await tabRepository.saveAll(snapshots)
|
||||
await tabRepository.saveActiveTabId(get().activeTabId)
|
||||
}
|
||||
}
|
||||
|
||||
export const useTabStore = create<TabState>((set, get) => {
|
||||
const actualSave = getActualSaveToDB(get)
|
||||
_debouncedSaveToDB = debounce(actualSave, 500)
|
||||
|
||||
return {
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
mruStack: [],
|
||||
_loaded: false,
|
||||
|
||||
loadFromDB: async () => {
|
||||
if (get()._loaded) return
|
||||
try {
|
||||
const [snapshots, savedActiveTabId] = await Promise.all([
|
||||
tabRepository.loadAll(),
|
||||
tabRepository.loadActiveTabId()
|
||||
])
|
||||
if (snapshots.length > 0) {
|
||||
const tabs: Tab[] = snapshots.map(s => ({
|
||||
id: s.id,
|
||||
filePath: s.filePath,
|
||||
content: s.content,
|
||||
isModified: s.isModified,
|
||||
scrollTop: s.scrollTop,
|
||||
selectionStart: s.selectionStart,
|
||||
selectionEnd: s.selectionEnd
|
||||
}))
|
||||
const activeTabId = (savedActiveTabId && tabs.find(t => t.id === savedActiveTabId))
|
||||
? savedActiveTabId
|
||||
: tabs[tabs.length - 1].id
|
||||
set({ tabs, activeTabId, _loaded: true })
|
||||
} else {
|
||||
set({ _loaded: true })
|
||||
}
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console -- DB load error
|
||||
console.error('Failed to load tabs from DB:', err)
|
||||
set({ _loaded: true })
|
||||
}
|
||||
},
|
||||
|
||||
saveToDB: async () => {
|
||||
if (_debouncedSaveToDB) {
|
||||
_debouncedSaveToDB()
|
||||
}
|
||||
},
|
||||
|
||||
createTab: (filePath: string | null = null, content: string = ''): Tab => {
|
||||
if (filePath) {
|
||||
const existing = get().tabs.find(t => t.filePath === filePath)
|
||||
if (existing) {
|
||||
get().switchToTab(existing.id)
|
||||
return existing
|
||||
}
|
||||
}
|
||||
|
||||
const tab: Tab = {
|
||||
id: nanoid(),
|
||||
filePath,
|
||||
content,
|
||||
isModified: false,
|
||||
scrollTop: 0,
|
||||
selectionStart: 0,
|
||||
selectionEnd: 0
|
||||
}
|
||||
|
||||
set(state => ({
|
||||
tabs: [...state.tabs, tab],
|
||||
activeTabId: tab.id
|
||||
}))
|
||||
|
||||
_debouncedSaveToDB?.()
|
||||
return tab
|
||||
},
|
||||
|
||||
closeTab: (tabId: string) => {
|
||||
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 mruCandidate = newMru.find(id => newTabs.some(t => t.id === id))
|
||||
if (mruCandidate) {
|
||||
newActiveId = mruCandidate
|
||||
newMru.splice(newMru.indexOf(mruCandidate), 1)
|
||||
} else {
|
||||
const newIndex = Math.min(index, newTabs.length - 1)
|
||||
newActiveId = newTabs[newIndex].id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { tabs: newTabs, activeTabId: newActiveId, mruStack: newMru }
|
||||
})
|
||||
|
||||
_debouncedSaveToDB?.()
|
||||
},
|
||||
|
||||
closeOtherTabs: (tabId: string) => {
|
||||
set(state => {
|
||||
const target = state.tabs.find(t => t.id === tabId)
|
||||
if (!target) return state
|
||||
return { tabs: [target], activeTabId: tabId, mruStack: [] }
|
||||
})
|
||||
_debouncedSaveToDB?.()
|
||||
},
|
||||
|
||||
closeAllTabs: () => {
|
||||
set({ tabs: [], activeTabId: null, mruStack: [] })
|
||||
_debouncedSaveToDB?.()
|
||||
},
|
||||
|
||||
closeTabsToRight: (tabId: string) => {
|
||||
set(state => {
|
||||
const index = state.tabs.findIndex(t => t.id === tabId)
|
||||
if (index === -1) return state
|
||||
const newTabs = state.tabs.slice(0, index + 1)
|
||||
const newActiveId = state.activeTabId && newTabs.find(t => t.id === state.activeTabId)
|
||||
? state.activeTabId
|
||||
: tabId
|
||||
return {
|
||||
tabs: newTabs,
|
||||
activeTabId: newActiveId,
|
||||
mruStack: state.mruStack.filter(id => newTabs.some(t => t.id === id))
|
||||
}
|
||||
})
|
||||
_debouncedSaveToDB?.()
|
||||
},
|
||||
|
||||
switchToTab: (tabId: string) => {
|
||||
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 }
|
||||
})
|
||||
setTimeout(() => tabRepository.saveActiveTabId(tabId), 0)
|
||||
},
|
||||
|
||||
updateTabContent: (tabId: string, content: string) => {
|
||||
set(state => ({
|
||||
tabs: state.tabs.map(t =>
|
||||
t.id === tabId ? { ...t, content, isModified: true } : t
|
||||
)
|
||||
}))
|
||||
},
|
||||
|
||||
setModified: (tabId: string, modified: boolean) => {
|
||||
set(state => ({
|
||||
tabs: state.tabs.map(t =>
|
||||
t.id === tabId ? { ...t, isModified: modified } : t
|
||||
)
|
||||
}))
|
||||
},
|
||||
|
||||
getActiveTab: (): Tab | null => {
|
||||
const { tabs, activeTabId } = get()
|
||||
return tabs.find(t => t.id === activeTabId) ?? null
|
||||
},
|
||||
|
||||
updateTabScroll: (tabId: string, scroll: Partial<Pick<Tab, 'scrollTop' | 'selectionStart' | 'selectionEnd'>>) => {
|
||||
set(state => ({
|
||||
tabs: state.tabs.map(t =>
|
||||
t.id === tabId ? { ...t, ...scroll } : t
|
||||
)
|
||||
}))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1600,3 +1600,107 @@ button:focus-visible,
|
||||
background: rgba(255, 180, 0, 0.4);
|
||||
outline-color: rgba(255, 180, 0, 0.6);
|
||||
}
|
||||
|
||||
/* ===== Outline Panel (Table of Contents) ===== */
|
||||
|
||||
.sidebar-outline-section {
|
||||
flex-shrink: 0;
|
||||
max-height: 40%;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.outline-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.outline-header {
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.outline-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.outline-list::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.outline-list::-webkit-scrollbar-thumb {
|
||||
background: var(--text-tertiary);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.outline-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 3px 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-ui);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all 0.1s ease;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.outline-item:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.outline-item.active {
|
||||
color: var(--primary);
|
||||
background: var(--primary-light);
|
||||
}
|
||||
|
||||
.outline-item:focus-visible {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.outline-level-dot {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.outline-item:hover .outline-level-dot,
|
||||
.outline-item.active .outline-level-dot {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.outline-item-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.outline-empty {
|
||||
padding: 13px 12px;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// 共享常量 — 主进程和渲染进程共用
|
||||
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'
|
||||
])
|
||||
Reference in New Issue
Block a user