release: v0.6.0 — 内置解析器迁移 / Toast 全面接入 / Sqlark 深度集成

- 渲染管线迁移至 MetonaEditor 内置解析器,移除 unified/rehype 全家桶(9 个依赖)
- 修复相对路径图片修复的目录前缀碰撞与路径解析 bug
- ConfirmDialog/useConfirm/LoadingSpinner/useDocStats 移除,改用 MeToast.confirm/loading/promise
- 新增状态栏(字数/行数/阅读时间/光标位置)、Zen 模式、数据备份导出导入
- Sqlark: 版本化迁移(addMigration/migrateTo)、subscribe 表变更、备份 exportAll/importTable
- 数据库损坏自愈:异常退出残留残缺 SSTable 导致打开失败时自动重建
- 大纲导航改用 scrollToLine 官方 API
- 修复 rollup 平台包互删(postinstall 自动补齐)+ 集成测试(fake-indexeddb)
This commit is contained in:
thzxx
2026-08-09 16:50:06 +08:00
parent 20eb39efc5
commit ee5f35177e
71 changed files with 4837 additions and 4345 deletions
+15 -8
View File
@@ -10,26 +10,29 @@ export async function readFileContent(filePath: string): Promise<ReadFileResult>
try {
const fileStat = await stat(filePath)
if (fileStat.size > MAX_FILE_SIZE) {
return { success: false, error: `文件过大(${(fileStat.size / 1024 / 1024).toFixed(1)} MB),暂不支持超过 20MB 的文件` }
return {
success: false,
error: `文件过大(${(fileStat.size / 1024 / 1024).toFixed(1)} MB),暂不支持超过 20MB 的文件`,
}
}
// L-01: 检测并剥离 BOMUTF-8 FEFF / UTF-16 LE FFFE / UTF-16 BE FEFF
const buffer = await readFile(filePath)
let content: string
if (buffer.length >= 2 && buffer[0] === 0xFE && buffer[1] === 0xFF) {
if (buffer.length >= 2 && buffer[0] === 0xfe && buffer[1] === 0xff) {
// UTF-16 BE: swap bytes to LE 再解码
const swapped = Buffer.allocUnsafe(buffer.length)
buffer.copy(swapped)
swapped.swap16()
content = swapped.toString('utf-16le')
if (content.charCodeAt(0) === 0xFEFF) content = content.slice(1)
} else if (buffer.length >= 2 && buffer[0] === 0xFF && buffer[1] === 0xFE) {
if (content.charCodeAt(0) === 0xfeff) content = content.slice(1)
} else if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) {
// UTF-16 LE
content = buffer.toString('utf-16le')
if (content.charCodeAt(0) === 0xFEFF) content = content.slice(1)
if (content.charCodeAt(0) === 0xfeff) content = content.slice(1)
} else {
// UTF-8 (含 FEFF BOM 剥离)
content = buffer.toString('utf-8')
if (content.charCodeAt(0) === 0xFEFF) content = content.slice(1)
if (content.charCodeAt(0) === 0xfeff) content = content.slice(1)
}
return { success: true, content }
} catch (err) {
@@ -48,7 +51,11 @@ export async function saveFileContent(filePath: string, content: string): Promis
return { success: true, filePath }
} catch (err) {
// 清理残留临时文件
try { await unlink(tmpFile) } catch { /* ignore */ }
try {
await unlink(tmpFile)
} catch {
/* ignore */
}
return { success: false, error: (err as Error).message }
}
}
@@ -58,7 +65,7 @@ export async function buildDirTree(
dirPath: string,
depth = 0,
maxDepth = 10,
visited?: Set<string>
visited?: Set<string>,
): Promise<FileNode[]> {
if (depth > maxDepth) return []
+1 -1
View File
@@ -15,7 +15,7 @@ export class FileWatcher {
if (!filePath) return
try {
this.currentPath = filePath
this.watcher = fs.watch(filePath, (eventType) => {
this.watcher = fs.watch(filePath, eventType => {
if (eventType === 'change') {
if (this.isSelfWriting) return
const win = this.getMainWindow()
+141 -140
View File
@@ -1,140 +1,141 @@
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()
}
})
}
+284 -228
View File
@@ -1,228 +1,284 @@
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
// 检查路径遍历:以路径分隔符分割后检查是否存在完整的 '..' 段
// H-02: 用段检查替代全局 includes('..'),避免误伤含 '..' 的合法路径
const sepPattern = /[/\\]/
const parts = filePath.split(sepPattern)
if (parts.some(part => part === '..')) 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) {
// Mark self-writing before stopping watcher to suppress any events
// that arrive between stop and start
fileWatcher.setSelfWriting(true)
fileWatcher.stop()
const result = await saveFileContent(data.filePath, data.content)
// Restart watcher while selfWriting is still true so any immediate 'change'
// event from the restart is suppressed — prevents overwriting editor content
fileWatcher.start(data.filePath)
fileWatcher.setSelfWriting(false)
state.activeFilePath = data.filePath
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)
if (result.success) {
state.activeFilePath = saveResult.filePath
fileWatcher.start(saveResult.filePath)
win.setTitle(`MarkLite - ${basename(saveResult.filePath)}`)
}
fileWatcher.setSelfWriting(false)
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.setSelfWriting(true)
const saveResult = await saveFileContent(result.filePath, data.content)
if (saveResult.success) {
state.activeFilePath = result.filePath
fileWatcher.start(result.filePath)
win.setTitle(`MarkLite - ${basename(result.filePath)}`)
}
fileWatcher.setSelfWriting(false)
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) => {
const normalizedPath = filePath || null
if (normalizedPath && !validatePath(normalizedPath)) {
fileWatcher.stop()
return
}
state.activeFilePath = normalizedPath
fileWatcher.start(normalizedPath || '')
const win = getMainWindow()
if (win && !win.isDestroyed()) {
win.setTitle(normalizedPath ? `MarkLite - ${basename(normalizedPath)}` : '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, readFile, writeFile } 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
// 检查路径遍历:以路径分隔符分割后检查是否存在完整的 '..' 段
// H-02: 用段检查替代全局 includes('..'),避免误伤含 '..' 的合法路径
const sepPattern = /[/\\]/
const parts = filePath.split(sepPattern)
if (parts.some(part => part === '..')) 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) {
// Mark self-writing before stopping watcher to suppress any events
// that arrive between stop and start
fileWatcher.setSelfWriting(true)
fileWatcher.stop()
const result = await saveFileContent(data.filePath, data.content)
// Restart watcher while selfWriting is still true so any immediate 'change'
// event from the restart is suppressed — prevents overwriting editor content
fileWatcher.start(data.filePath)
fileWatcher.setSelfWriting(false)
state.activeFilePath = data.filePath
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)
if (result.success) {
state.activeFilePath = saveResult.filePath
fileWatcher.start(saveResult.filePath)
win.setTitle(`MarkLite - ${basename(saveResult.filePath)}`)
}
fileWatcher.setSelfWriting(false)
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.setSelfWriting(true)
const saveResult = await saveFileContent(result.filePath, data.content)
if (saveResult.success) {
state.activeFilePath = result.filePath
fileWatcher.start(result.filePath)
win.setTitle(`MarkLite - ${basename(result.filePath)}`)
}
fileWatcher.setSelfWriting(false)
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) => {
const normalizedPath = filePath || null
if (normalizedPath && !validatePath(normalizedPath)) {
fileWatcher.stop()
return
}
state.activeFilePath = normalizedPath
fileWatcher.start(normalizedPath || '')
const win = getMainWindow()
if (win && !win.isDestroyed()) {
win.setTitle(normalizedPath ? `MarkLite - ${basename(normalizedPath)}` : '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
}
})
// v0.6.0: 数据备份导出 — 保存对话框 + 写 JSON 文件
ipcMain.handle(IPC_CHANNELS.DATA_EXPORT, async (_event: IpcMainInvokeEvent, content: string) => {
const win = getMainWindow()
if (!win) return { success: false, error: '窗口不可用' }
try {
const dateStr = new Date().toISOString().slice(0, 10)
const result = await dialog.showSaveDialog(win, {
title: '导出数据备份',
defaultPath: `marklite-backup-${dateStr}.json`,
filters: [{ name: 'JSON 备份文件', extensions: ['json'] }],
})
if (result.canceled) return { success: false, canceled: true }
await writeFile(result.filePath, content, 'utf8')
return { success: true, filePath: result.filePath }
} catch (err) {
return { success: false, error: (err as Error).message }
}
})
// v0.6.0: 数据备份导入 — 打开对话框 + 读 JSON 文件
ipcMain.handle(IPC_CHANNELS.DATA_IMPORT, async () => {
const win = getMainWindow()
if (!win) return { success: false, error: '窗口不可用' }
try {
const result = await dialog.showOpenDialog(win, {
title: '导入数据备份',
properties: ['openFile'],
filters: [{ name: 'JSON 备份文件', extensions: ['json'] }],
})
if (result.canceled || result.filePaths.length === 0) {
return { success: false, canceled: true }
}
const content = await readFile(result.filePaths[0], 'utf8')
return { success: true, content }
} catch (err) {
return { success: false, error: (err as Error).message }
}
})
}
+4 -4
View File
@@ -13,16 +13,16 @@ export function createWindow(): BrowserWindow {
preload: join(__dirname, '../preload/index.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true
sandbox: true,
},
titleBarStyle: 'default',
show: false
show: false,
})
mainWindow.setMenu(null)
// H-04: 阻止窗口导航和弹出窗口,防止渲染进程绕过 CSP
mainWindow.webContents.on('will-navigate', (event) => {
mainWindow.webContents.on('will-navigate', event => {
event.preventDefault()
})
mainWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
@@ -35,7 +35,7 @@ export function createWindow(): BrowserWindow {
}
export function setupSingleInstanceLock(
onSecondInstance: (filePath: string | null) => void
onSecondInstance: (filePath: string | null) => void,
): boolean {
const gotTheLock = app.requestSingleInstanceLock()
if (!gotTheLock) {
+79 -64
View File
@@ -1,64 +1,79 @@
import { contextBridge, ipcRenderer, shell } from 'electron'
import { IPC_CHANNELS } from '../shared/ipc-channels'
import type { ElectronAPI } from '../renderer/types/ipc'
// C-02: 运行时实现受 ElectronAPI 类型约束,编译期保证 preload 与渲染进程契约一致
const api: ElectronAPI = {
// File operations
openFile: () => ipcRenderer.invoke(IPC_CHANNELS.DIALOG_OPEN_FILE),
readFile: (filePath: string) => ipcRenderer.invoke(IPC_CHANNELS.FILE_READ, filePath),
saveFile: (data) => ipcRenderer.invoke(IPC_CHANNELS.FILE_SAVE, data),
saveFileAs: (data) => ipcRenderer.invoke(IPC_CHANNELS.FILE_SAVE_AS, data),
getCurrentPath: () => ipcRenderer.invoke(IPC_CHANNELS.FILE_GET_CURRENT_PATH),
getFileStats: (filePath: string) => ipcRenderer.invoke(IPC_CHANNELS.FILE_STATS, filePath),
reloadFile: () => ipcRenderer.invoke(IPC_CHANNELS.FILE_RELOAD),
// Tab management
tabSwitched: (filePath: string | null) => ipcRenderer.invoke(IPC_CHANNELS.TAB_SWITCHED, filePath),
// Window control
forceClose: () => ipcRenderer.invoke(IPC_CHANNELS.WINDOW_FORCE_CLOSE),
cancelClose: () => ipcRenderer.invoke(IPC_CHANNELS.WINDOW_CANCEL_CLOSE),
// Shell — 仅允许 http/https 协议
openExternal: (url: string) => {
try {
const parsed = new URL(url)
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
shell.openExternal(url)
}
} catch {
// 无效 URL,忽略
}
},
// File Tree (Sidebar)
readDirTree: (dirPath: string) => ipcRenderer.invoke(IPC_CHANNELS.DIR_READ_TREE, dirPath),
openFolderDialog: () => ipcRenderer.invoke(IPC_CHANNELS.DIR_OPEN_DIALOG),
watchDir: (dirPath: string) => ipcRenderer.invoke(IPC_CHANNELS.DIR_WATCH, dirPath),
unwatchDir: () => ipcRenderer.invoke(IPC_CHANNELS.DIR_UNWATCH),
// Events from main process — 返回取消订阅函数
onFileOpenInTab: (callback) => {
const handler = (_event: Electron.IpcRendererEvent, data: { filePath: string; content: string }) => callback(data)
ipcRenderer.on(IPC_CHANNELS.FILE_OPEN_IN_TAB, handler)
return () => { ipcRenderer.removeListener(IPC_CHANNELS.FILE_OPEN_IN_TAB, handler) }
},
onExternalModification: (callback) => {
const handler = (_event: Electron.IpcRendererEvent, filePath: string) => callback(filePath)
ipcRenderer.on(IPC_CHANNELS.FILE_EXTERNALLY_MODIFIED, handler)
return () => { ipcRenderer.removeListener(IPC_CHANNELS.FILE_EXTERNALLY_MODIFIED, handler) }
},
onDirChanged: (callback) => {
const handler = () => callback()
ipcRenderer.on(IPC_CHANNELS.SIDEBAR_DIR_CHANGED, handler)
return () => { ipcRenderer.removeListener(IPC_CHANNELS.SIDEBAR_DIR_CHANGED, handler) }
},
onConfirmClose: (callback) => {
const handler = () => callback()
ipcRenderer.on(IPC_CHANNELS.WINDOW_CONFIRM_CLOSE, handler)
return () => { ipcRenderer.removeListener(IPC_CHANNELS.WINDOW_CONFIRM_CLOSE, handler) }
}
}
contextBridge.exposeInMainWorld('electronAPI', api)
import { contextBridge, ipcRenderer, shell } from 'electron'
import { IPC_CHANNELS } from '../shared/ipc-channels'
import type { ElectronAPI } from '../renderer/types/ipc'
// C-02: 运行时实现受 ElectronAPI 类型约束,编译期保证 preload 与渲染进程契约一致
const api: ElectronAPI = {
// File operations
openFile: () => ipcRenderer.invoke(IPC_CHANNELS.DIALOG_OPEN_FILE),
readFile: (filePath: string) => ipcRenderer.invoke(IPC_CHANNELS.FILE_READ, filePath),
saveFile: data => ipcRenderer.invoke(IPC_CHANNELS.FILE_SAVE, data),
saveFileAs: data => ipcRenderer.invoke(IPC_CHANNELS.FILE_SAVE_AS, data),
getCurrentPath: () => ipcRenderer.invoke(IPC_CHANNELS.FILE_GET_CURRENT_PATH),
getFileStats: (filePath: string) => ipcRenderer.invoke(IPC_CHANNELS.FILE_STATS, filePath),
reloadFile: () => ipcRenderer.invoke(IPC_CHANNELS.FILE_RELOAD),
// Tab management
tabSwitched: (filePath: string | null) => ipcRenderer.invoke(IPC_CHANNELS.TAB_SWITCHED, filePath),
// Window control
forceClose: () => ipcRenderer.invoke(IPC_CHANNELS.WINDOW_FORCE_CLOSE),
cancelClose: () => ipcRenderer.invoke(IPC_CHANNELS.WINDOW_CANCEL_CLOSE),
// Shell — 仅允许 http/https 协议
openExternal: (url: string) => {
try {
const parsed = new URL(url)
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
shell.openExternal(url)
}
} catch {
// 无效 URL,忽略
}
},
// File Tree (Sidebar)
readDirTree: (dirPath: string) => ipcRenderer.invoke(IPC_CHANNELS.DIR_READ_TREE, dirPath),
openFolderDialog: () => ipcRenderer.invoke(IPC_CHANNELS.DIR_OPEN_DIALOG),
watchDir: (dirPath: string) => ipcRenderer.invoke(IPC_CHANNELS.DIR_WATCH, dirPath),
unwatchDir: () => ipcRenderer.invoke(IPC_CHANNELS.DIR_UNWATCH),
// v0.6.0: 数据备份导出/导入
exportData: (content: string) => ipcRenderer.invoke(IPC_CHANNELS.DATA_EXPORT, content),
importData: () => ipcRenderer.invoke(IPC_CHANNELS.DATA_IMPORT),
// Events from main process — 返回取消订阅函数
onFileOpenInTab: callback => {
const handler = (
_event: Electron.IpcRendererEvent,
data: { filePath: string; content: string },
) => callback(data)
ipcRenderer.on(IPC_CHANNELS.FILE_OPEN_IN_TAB, handler)
return () => {
ipcRenderer.removeListener(IPC_CHANNELS.FILE_OPEN_IN_TAB, handler)
}
},
onExternalModification: callback => {
const handler = (_event: Electron.IpcRendererEvent, filePath: string) => callback(filePath)
ipcRenderer.on(IPC_CHANNELS.FILE_EXTERNALLY_MODIFIED, handler)
return () => {
ipcRenderer.removeListener(IPC_CHANNELS.FILE_EXTERNALLY_MODIFIED, handler)
}
},
onDirChanged: callback => {
const handler = () => callback()
ipcRenderer.on(IPC_CHANNELS.SIDEBAR_DIR_CHANGED, handler)
return () => {
ipcRenderer.removeListener(IPC_CHANNELS.SIDEBAR_DIR_CHANGED, handler)
}
},
onConfirmClose: callback => {
const handler = () => callback()
ipcRenderer.on(IPC_CHANNELS.WINDOW_CONFIRM_CLOSE, handler)
return () => {
ipcRenderer.removeListener(IPC_CHANNELS.WINDOW_CONFIRM_CLOSE, handler)
}
},
}
contextBridge.exposeInMainWorld('electronAPI', api)
+16 -16
View File
@@ -1,16 +1,16 @@
/**
* DX-02: Preload 类型安全声明
* ElectronAPI 类型现在由 ../renderer/types/ipc.ts 集中定义,
* preload/index.ts 引入该类型做编译期契约检查。
* 本文件仅保留全局 Window 增强声明。
*/
import type { ElectronAPI } from '../renderer/types/ipc'
declare global {
interface Window {
electronAPI: ElectronAPI
}
}
export {}
/**
* DX-02: Preload 类型安全声明
* ElectronAPI 类型现在由 ../renderer/types/ipc.ts 集中定义,
* preload/index.ts 引入该类型做编译期契约检查。
* 本文件仅保留全局 Window 增强声明。
*/
import type { ElectronAPI } from '../renderer/types/ipc'
declare global {
interface Window {
electronAPI: ElectronAPI
}
}
export {}
+131 -120
View File
@@ -1,120 +1,131 @@
import React, { useState, useEffect, useCallback } from 'react'
import { useTabStore } from './stores/tabStore'
import { flushSaveToDB } from './stores/tabStore'
import { useEditorStore } from './stores/editorStore'
import { useTheme } from './hooks/useTheme'
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 { useAutoSave } from './hooks/useAutoSave'
import { useIpcListeners } from './hooks/useIpcListeners'
import { useConfirm } from './hooks/useConfirm'
import { Toolbar } from './components/Toolbar/Toolbar'
import { TabBar } from './components/TabBar/TabBar'
import { Editor } from './components/Editor/Editor'
import { Sidebar } from './components/Sidebar/Sidebar'
import { WelcomeScreen } from './components/WelcomeScreen/WelcomeScreen'
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 { themeMode, cycleTheme } = useTheme()
const { confirm, confirmDialogProps } = useConfirm()
const [showAbout, setShowAbout] = useState(false)
const handleCloseAbout = useCallback(() => setShowAbout(false), [])
useSettingsInit()
useEffect(() => { loadFromDB() }, [loadFromDB])
const { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent } = useFileOperations()
const { isAutoSaving, autoSaveEnabled, toggleAutoSave } = useAutoSave()
useDragDrop()
useFileWatch()
// useAutoSave() already called above to get state for toolbar
// 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, flushSaveToDB)
useKeyboard(handleOpenFile, handleSave, handleSaveAs)
useIpcListeners()
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 tab = tabs.find(t => t.filePath === externallyModified.filePath)
if (tab?.isModified) return
if (!tab) return
const result = await window.electronAPI.readFile(externallyModified.filePath)
if (result.success && result.content !== undefined) {
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}
themeMode={themeMode} onCycleTheme={cycleTheme}
onShowAbout={() => setShowAbout(true)}
isAutoSaving={isAutoSaving}
autoSaveEnabled={autoSaveEnabled}
onToggleAutoSave={toggleAutoSave}
/>
<div id="workspace">
<Sidebar />
<div id="main-content">
<TabBar />
{externallyModified && (
<ModifiedBanner onReload={handleReloadModified} onDismiss={() => setExternallyModified(null)} />
)}
{tabs.length > 0 ? (
<div id="content-wrapper">
<div id="editor-panel"><Editor themeMode={themeMode} onAppSave={handleSave} /></div>
</div>
) : (
<WelcomeScreen onOpen={handleOpenFile} onNew={() => createTab(null, '')} onOpenRecent={handleOpenRecent} />
)}
</div>
</div>
<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 { flushSaveToDB } from './stores/tabStore'
import { useEditorStore } from './stores/editorStore'
import { useTheme } from './hooks/useTheme'
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 { useAutoSave } from './hooks/useAutoSave'
import { useIpcListeners } from './hooks/useIpcListeners'
import { MeToast } from './lib/toast'
import { Toolbar } from './components/Toolbar/Toolbar'
import { StatusBar } from './components/StatusBar'
import { TabBar } from './components/TabBar/TabBar'
import { Editor } from './components/Editor/Editor'
import { Sidebar } from './components/Sidebar/Sidebar'
import { WelcomeScreen } from './components/WelcomeScreen/WelcomeScreen'
import { ModifiedBanner } from './components/ModifiedBanner/ModifiedBanner'
import { DropOverlay } from './components/DropOverlay/DropOverlay'
import { ErrorBoundary } from './components/ErrorBoundary'
import { AboutDialog } from './components/AboutDialog'
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 { themeMode, cycleTheme } = useTheme()
const [showAbout, setShowAbout] = useState(false)
const handleCloseAbout = useCallback(() => setShowAbout(false), [])
useSettingsInit()
useEffect(() => {
loadFromDB()
}, [loadFromDB])
const { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent } = useFileOperations()
const { isAutoSaving, autoSaveEnabled, toggleAutoSave } = useAutoSave()
useDragDrop()
useFileWatch()
// useAutoSave() already called above to get state for toolbar
// UX-01: 传入 confirm 函数替代原生 confirm()
// v0.6.0: 使用 MeToast.confirm(内置 10 秒安全超时,超时自动 resolve(false)
const handleConfirmClose = useCallback(async (message: string): Promise<boolean> => {
return MeToast.confirm(message, {
title: '未保存的更改',
confirmText: '不保存',
cancelText: '取消',
})
}, [])
useUnsavedWarning(() => tabs.some(t => t.isModified), handleConfirmClose, flushSaveToDB)
useKeyboard(handleOpenFile, handleSave, handleSaveAs)
useIpcListeners()
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 tab = tabs.find(t => t.filePath === externallyModified.filePath)
if (tab?.isModified) return
if (!tab) return
const result = await window.electronAPI.readFile(externallyModified.filePath)
if (result.success && result.content !== undefined) {
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}
themeMode={themeMode}
onCycleTheme={cycleTheme}
onShowAbout={() => setShowAbout(true)}
isAutoSaving={isAutoSaving}
autoSaveEnabled={autoSaveEnabled}
onToggleAutoSave={toggleAutoSave}
/>
<div id="workspace">
<Sidebar />
<div id="main-content">
<TabBar />
{externallyModified && (
<ModifiedBanner
onReload={handleReloadModified}
onDismiss={() => setExternallyModified(null)}
/>
)}
{tabs.length > 0 ? (
<div id="content-wrapper">
<div id="editor-panel">
<Editor themeMode={themeMode} onAppSave={handleSave} />
</div>
</div>
) : (
<WelcomeScreen
onOpen={handleOpenFile}
onNew={() => createTab(null, '')}
onOpenRecent={handleOpenRecent}
/>
)}
</div>
</div>
<StatusBar />
<DropOverlay />
{showAbout && <AboutDialog onClose={handleCloseAbout} />}
</div>
</ErrorBoundary>
)
}
App.displayName = 'App'
export default App
+14 -14
View File
@@ -1,14 +1,14 @@
declare module '*.png' {
const src: string
export default src
}
declare module '*.svg' {
const src: string
export default src
}
declare module '*.ico' {
const src: string
export default src
}
declare module '*.png' {
const src: string
export default src
}
declare module '*.svg' {
const src: string
export default src
}
declare module '*.ico' {
const src: string
export default src
}
@@ -1,57 +1,67 @@
import React from 'react'
import { AppIcon, Gitee } from '../Icons'
import { APP_VERSION } from '../../lib/constants'
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://git.metona.cn/MetonaTeam/MarkLite')
} else {
window.open('https://git.metona.cn/MetonaTeam/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>
<span></span>
</div>
</div>
<div className="about-footer">
<a className="about-link" href="#" onClick={handleLinkClick}>
<Gitee size={16} />
<span>git.metona.cn/MetonaTeam/MarkLite</span>
</a>
<p> Electron + React + TypeScript </p>
<p>MetonaEditor 0.4.0 · MetonaToast 0.5.0 · MetonaSqlark 0.4.1</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'
import { APP_VERSION } from '../../lib/constants'
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://git.metona.cn/MetonaTeam/MarkLite')
} else {
window.open('https://git.metona.cn/MetonaTeam/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>
<span></span>
<span></span>
<span></span>
</div>
</div>
<div className="about-footer">
<a className="about-link" href="#" onClick={handleLinkClick}>
<Gitee size={16} />
<span>git.metona.cn/MetonaTeam/MarkLite</span>
</a>
<p> Electron + React + TypeScript </p>
<p>MetonaEditor 0.4.0 · MetonaToast 0.5.0 · MetonaSqlark 0.4.1</p>
<p className="about-copyright">© 2026 thzxx</p>
</div>
<button className="about-close-btn" onClick={onClose}>
</button>
</div>
</div>
)
})
AboutDialog.displayName = 'AboutDialog'
@@ -1,111 +0,0 @@
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) {
// Only restore focus if the element is still in the DOM
if (previousFocusRef.current && previousFocusRef.current.isConnected) {
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 +0,0 @@
export { ConfirmDialog } from './ConfirmDialog'
+310 -249
View File
@@ -1,249 +1,310 @@
import React, { useEffect, useMemo, useRef } from 'react'
import MeEditor from '@metona-team/metona-editor'
import type { MarkdownEditor } from '@metona-team/metona-editor'
import mermaid from 'mermaid'
import { useTabStore } from '../../stores/tabStore'
import { setMetonaEditorGetter, useEditorStore } from '../../stores/editorStore'
import { settingsRepository } from '../../db/settingsRepository'
import { renderMarkdownSync } from '../../lib/markdown'
import type { ThemeMode } from '../../types/settings'
// v0.4.0: 这些类型不再作为命名导出暴露,本地声明以保持类型安全
type EditMode = 'edit' | 'split' | 'preview'
type ThemeName = 'light' | 'dark' | 'auto' | 'warm' | string
// v0.4.5: Mermaid 初始化(全局一次性配置)
mermaid.initialize({ startOnLoad: false, theme: 'default' })
interface EditorProps {
themeMode: ThemeMode
onAppSave?: () => void
}
/** 将应用 viewMode 映射到 MetonaEditor 的 mode */
function mapViewMode(vm: string): EditMode {
if (vm === 'source') return 'edit'
if (vm === 'preview') return 'preview'
return 'split'
}
/** 将 MetonaEditor mode 反向映射到应用 viewMode */
function reverseMapMode(mode: EditMode): 'editor' | 'preview' | 'source' {
if (mode === 'edit') return 'source'
if (mode === 'preview') return 'preview'
return 'editor'
}
/** 预设插件(字符串形式,与 demo 一致) */
const EDITOR_PLUGINS: string[] = [
'searchReplace', // Ctrl+F/Ctrl+H
'imagePaste', // Ctrl+V 粘贴图片
'exportTool', // 导出 MD/HTML
'shortcutHelp', // 按 ? 弹出快捷键面板
]
/**
* Editor 组件 — 基于 MetonaEditor 的 Markdown 编辑器。
*/
export const Editor = React.memo(function Editor({ themeMode, onAppSave }: EditorProps) {
const tabs = useTabStore(s => s.tabs)
const activeTabId = useTabStore(s => s.activeTabId)
// B-03: 用 activeTabId + tabs 推导 activeTab 而非 s.getActiveTab()
// 后者每次返回新对象引用导致 Zustand 无条件重渲染
const activeTab = useMemo(
() => tabs.find(t => t.id === activeTabId) ?? null,
[tabs, activeTabId]
)
const updateTabContent = useTabStore(s => s.updateTabContent)
const setModified = useTabStore(s => s.setModified)
const updateTabScroll = useTabStore(s => s.updateTabScroll)
const viewMode = useEditorStore(s => s.viewMode)
const setViewMode = useEditorStore(s => s.setViewMode)
const containerRef = useRef<HTMLDivElement>(null)
const editorRef = useRef<MarkdownEditor | null>(null)
const currentContentRef = useRef('')
// 稳定的回调引用
const activeTabIdRef = useRef(activeTabId)
activeTabIdRef.current = activeTabId
// ── 初始化 MetonaEditor ──────────────────────────────────
useEffect(() => {
const container = containerRef.current
if (!container) return
const config = {
value: activeTab?.content ?? '',
mode: mapViewMode(viewMode),
height: '100%',
toolbar: [
'bold', 'italic', 'strikethrough', 'underline', 'code', '|',
'h1', 'h2', 'h3', '|',
'quote', 'ul', 'ol', 'indent', 'outdent', '|',
'link', 'image', 'table', 'hr', '|',
'undo', 'redo', '|',
'edit', 'split', 'preview', 'fullscreen'
],
locale: 'zh-CN',
theme: themeMode as ThemeName,
placeholder: '在此输入 Markdown 内容...',
spellcheck: false,
tabSize: 2,
wordCount: true,
autofocus: true,
lineNumbers: true,
autoBrackets: true,
readOnly: viewMode === 'preview',
plugins: EDITOR_PLUGINS,
// v0.5.0: 启用 0.4.0 浮动格式工具栏(选中文本弹出格式化按钮)
floatingToolbar: true,
// v0.2.4 新增配置项
syncScroll: true,
wordWrap: true,
outline: false, // 使用自研 OutlinePanel
historyLimit: 100,
historyDebounce: 400,
// 使用 unified 管线渲染,保留图片路径修复能力
render: (md: string) => {
const tabId = activeTabIdRef.current
const filePath = tabId
? (useTabStore.getState().tabs.find(t => t.id === tabId)?.filePath ?? null)
: null
return renderMarkdownSync(md, filePath)
},
// 内容变化 → 同步到 tabStore
onChange: (value: string) => {
const tabId = activeTabIdRef.current
if (!tabId) return
const tab = useTabStore.getState().tabs.find(t => t.id === tabId)
if (tab?.content === value) return
currentContentRef.current = value
updateTabContent(tabId, value)
setModified(tabId, true)
},
// 模式切换 → 同步到 editorStore 并持久化
onModeChange: (mode: string) => {
const mapped = reverseMapMode(mode as EditMode)
setViewMode(mapped)
settingsRepository.save({ viewMode: mapped })
},
// Ctrl+S → 触发应用层保存(Electron IPC 写文件系统)
onSave: () => {
onAppSave?.()
},
// v0.2.4 新增回调: 链接点击 → 安全打开外部链接
onLinkClick: (href: string) => {
if (window.electronAPI?.openExternal) {
window.electronAPI.openExternal(href)
}
},
// v0.2.4 新增回调: 焦点事件(预留扩展点)
onFocus: () => {
// 编辑器获得焦点
},
onBlur: () => {
// 编辑器失去焦点
},
}
const editor = MeEditor.create(container, config)
editorRef.current = editor
setMetonaEditorGetter(() => editor)
currentContentRef.current = activeTab?.content ?? ''
// v0.5.0: 绑定 afterRender → 触发 Mermaid 图表渲染
const renderMermaid = () => {
try { mermaid.run({ querySelector: '.me-mermaid .mermaid' }) } catch { /* 容错 */ }
}
editor.on('afterRender', renderMermaid)
// 首次渲染后延迟触发一次
setTimeout(renderMermaid, 300)
return () => {
editor.destroy()
editorRef.current = null
setMetonaEditorGetter(() => null)
}
// 仅在挂载时创建一次
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// ── 标签切换:同步内容到编辑器 ──────────────────────────
useEffect(() => {
if (!activeTab || !editorRef.current) return
if (activeTab.content === currentContentRef.current) return
currentContentRef.current = activeTab.content
// silent: true — 不触发 onChange,避免重复更新 tabStore
editorRef.current.setValue(activeTab.content, { silent: true })
// 恢复滚动位置
requestAnimationFrame(() => {
const c = containerRef.current
if (!c) return
const textarea = c.querySelector('textarea')
if (textarea) {
textarea.scrollTop = activeTab.scrollTop
}
const preview = c.querySelector('.me-preview') as HTMLElement | null
if (preview) {
preview.scrollTop = activeTab.scrollTop
}
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTabId])
// ── 取消挂载/标签切换前保存滚动位置 ──────────────────────
useEffect(() => {
const c = containerRef.current
return () => {
if (!activeTabId || !editorRef.current || !c) return
const textarea = c.querySelector('textarea')
const preview = c.querySelector('.me-preview') as HTMLElement | null
const scrollTop = textarea?.scrollTop ?? preview?.scrollTop ?? 0
updateTabScroll(activeTabId, { scrollTop })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTabId])
// ── 主题同步 ──────────────────────────────────────────
useEffect(() => {
try {
// 全局主题(documentElement + localStorage
MeEditor.setTheme(themeMode)
// v0.1.5+: 实例级主题,自动处理 wrapper CSS 变量
editorRef.current?.setTheme(themeMode)
} catch {
// 容错
}
}, [themeMode])
// ── 视图模式同步 ──────────────────────────────────────────
useEffect(() => {
const editor = editorRef.current
if (!editor) return
const targetMode = mapViewMode(viewMode)
if (editor.getMode() !== targetMode) {
editor.setMode(targetMode)
}
// 预览模式设为只读
editor.setReadOnly(viewMode === 'preview')
}, [viewMode])
return (
<div className="editor-container" role="region" aria-label="Markdown编辑器">
<div ref={containerRef} className="metona-editor-wrapper" />
</div>
)
})
Editor.displayName = 'Editor'
import React, { useEffect, useMemo, useRef } from 'react'
import MeEditor from '@metona-team/metona-editor'
import type { MarkdownEditor } from '@metona-team/metona-editor'
import mermaid from 'mermaid'
import { useTabStore } from '../../stores/tabStore'
import { setMetonaEditorGetter, useEditorStore } from '../../stores/editorStore'
import { settingsRepository } from '../../db/settingsRepository'
import { renderMarkdownSync } from '../../lib/markdown'
import type { ThemeMode } from '../../types/settings'
// v0.4.0: 这些类型不再作为命名导出暴露,本地声明以保持类型安全
type EditMode = 'edit' | 'split' | 'preview'
type ThemeName = 'light' | 'dark' | 'auto' | 'warm' | string
// v0.4.5: Mermaid 初始化(全局一次性配置)
mermaid.initialize({ startOnLoad: false, theme: 'default' })
interface EditorProps {
themeMode: ThemeMode
onAppSave?: () => void
}
/** 将应用 viewMode 映射到 MetonaEditor 的 mode */
function mapViewMode(vm: string): EditMode {
if (vm === 'source') return 'edit'
if (vm === 'preview') return 'preview'
return 'split'
}
/** 将 MetonaEditor mode 反向映射到应用 viewMode */
function reverseMapMode(mode: EditMode): 'editor' | 'preview' | 'source' {
if (mode === 'edit') return 'source'
if (mode === 'preview') return 'preview'
return 'editor'
}
/** 预设插件(字符串形式,与 demo 一致) */
const EDITOR_PLUGINS: string[] = [
'searchReplace', // Ctrl+F/Ctrl+H
'imagePaste', // Ctrl+V 粘贴图片
'exportTool', // 导出 MD/HTML
'shortcutHelp', // 按 ? 弹出快捷键面板
]
/**
* Editor 组件 — 基于 MetonaEditor 的 Markdown 编辑器。
*/
export const Editor = React.memo(function Editor({ themeMode, onAppSave }: EditorProps) {
const tabs = useTabStore(s => s.tabs)
const activeTabId = useTabStore(s => s.activeTabId)
// B-03: 用 activeTabId + tabs 推导 activeTab 而非 s.getActiveTab()
// 后者每次返回新对象引用导致 Zustand 无条件重渲染
const activeTab = useMemo(() => tabs.find(t => t.id === activeTabId) ?? null, [tabs, activeTabId])
const updateTabContent = useTabStore(s => s.updateTabContent)
const setModified = useTabStore(s => s.setModified)
const updateTabScroll = useTabStore(s => s.updateTabScroll)
const viewMode = useEditorStore(s => s.viewMode)
const setViewMode = useEditorStore(s => s.setViewMode)
const setStats = useEditorStore(s => s.setStats)
const setCursor = useEditorStore(s => s.setCursor)
const setZenMode = useEditorStore(s => s.setZenMode)
const containerRef = useRef<HTMLDivElement>(null)
const editorRef = useRef<MarkdownEditor | null>(null)
const currentContentRef = useRef('')
// 稳定的回调引用
const activeTabIdRef = useRef(activeTabId)
activeTabIdRef.current = activeTabId
// ── 初始化 MetonaEditor ──────────────────────────────────
useEffect(() => {
const container = containerRef.current
if (!container) return
const config = {
value: activeTab?.content ?? '',
mode: mapViewMode(viewMode),
height: '100%',
toolbar: [
'bold',
'italic',
'strikethrough',
'underline',
'code',
'|',
'h1',
'h2',
'h3',
'|',
'quote',
'ul',
'ol',
'indent',
'outdent',
'|',
'link',
'image',
'table',
'hr',
'|',
'undo',
'redo',
'|',
'edit',
'split',
'preview',
'fullscreen',
],
locale: 'zh-CN',
theme: themeMode as ThemeName,
placeholder: '在此输入 Markdown 内容...',
spellcheck: false,
tabSize: 2,
wordCount: true,
autofocus: true,
lineNumbers: true,
autoBrackets: true,
readOnly: viewMode === 'preview',
plugins: EDITOR_PLUGINS,
// v0.5.0: 启用 0.4.0 浮动格式工具栏(选中文本弹出格式化按钮)
floatingToolbar: true,
// v0.2.4 新增配置项
syncScroll: true,
wordWrap: true,
outline: false, // 使用自研 OutlinePanel
historyLimit: 100,
historyDebounce: 400,
// 使用内置解析器渲染(unified 管线已移除),保留图片路径修复能力
// v0.6.0: highlight 使用内置零依赖高亮器(16 种语言)
highlight: MeEditor.highlight,
render: (md: string) => {
const tabId = activeTabIdRef.current
const filePath = tabId
? (useTabStore.getState().tabs.find(t => t.id === tabId)?.filePath ?? null)
: null
return renderMarkdownSync(md, filePath)
},
// 内容变化 → 同步到 tabStore
onChange: (value: string) => {
const tabId = activeTabIdRef.current
if (!tabId) return
const tab = useTabStore.getState().tabs.find(t => t.id === tabId)
if (tab?.content === value) return
currentContentRef.current = value
updateTabContent(tabId, value)
setModified(tabId, true)
},
// 模式切换 → 同步到 editorStore 并持久化
onModeChange: (mode: string) => {
const mapped = reverseMapMode(mode as EditMode)
setViewMode(mapped)
settingsRepository.save({ viewMode: mapped })
},
// Ctrl+S → 触发应用层保存(Electron IPC 写文件系统)
onSave: () => {
onAppSave?.()
},
// v0.2.4 新增回调: 链接点击 → 安全打开外部链接
onLinkClick: (href: string) => {
if (window.electronAPI?.openExternal) {
window.electronAPI.openExternal(href)
}
},
// v0.2.4 新增回调: 焦点事件(预留扩展点)
onFocus: () => {
// 编辑器获得焦点
},
onBlur: () => {
// 编辑器失去焦点
},
}
const editor = MeEditor.create(container, config)
editorRef.current = editor
setMetonaEditorGetter(() => editor)
currentContentRef.current = activeTab?.content ?? ''
// v0.5.0: 绑定 afterRender → 触发 Mermaid 图表渲染
const renderMermaid = () => {
try {
mermaid.run({ querySelector: '.me-mermaid .mermaid' })
} catch {
/* 容错 */
}
}
editor.on('afterRender', renderMermaid)
// 首次渲染后延迟触发一次
setTimeout(renderMermaid, 300)
// v0.6.0: 实时状态同步 — getStats + 光标/zen 事件 → editorStore(状态栏消费)
const syncStats = () => {
try {
const s = editor.getStats()
setStats({
characters: s.characters,
words: s.words,
chineseChars: s.chineseChars,
englishWords: s.englishWords,
lines: s.lines,
readingTime: s.readingTime,
})
} catch {
/* 容错 */
}
}
const syncCursor = (pos?: { line: number; column: number }) => {
try {
setCursor(pos ?? editor.getCursorPosition())
} catch {
/* 容错 */
}
}
const syncZen = (zen: boolean) => {
setZenMode(Boolean(zen))
}
editor.on('change', syncStats)
editor.on('input', syncStats)
editor.on('cursorMove', syncCursor)
editor.on('zenChange', syncZen)
syncStats()
syncCursor()
return () => {
editor.destroy()
editorRef.current = null
setMetonaEditorGetter(() => null)
}
// 仅在挂载时创建一次
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// ── 标签切换:同步内容到编辑器 ──────────────────────────
useEffect(() => {
if (!activeTab || !editorRef.current) return
if (activeTab.content === currentContentRef.current) return
currentContentRef.current = activeTab.content
// silent: true — 不触发 onChange,避免重复更新 tabStore
editorRef.current.setValue(activeTab.content, { silent: true })
// 恢复滚动位置
requestAnimationFrame(() => {
const c = containerRef.current
if (!c) return
const textarea = c.querySelector('textarea')
if (textarea) {
textarea.scrollTop = activeTab.scrollTop
}
const preview = c.querySelector('.me-preview') as HTMLElement | null
if (preview) {
preview.scrollTop = activeTab.scrollTop
}
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTabId])
// ── 取消挂载/标签切换前保存滚动位置 ──────────────────────
useEffect(() => {
const c = containerRef.current
return () => {
if (!activeTabId || !editorRef.current || !c) return
const textarea = c.querySelector('textarea')
const preview = c.querySelector('.me-preview') as HTMLElement | null
const scrollTop = textarea?.scrollTop ?? preview?.scrollTop ?? 0
updateTabScroll(activeTabId, { scrollTop })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTabId])
// ── 主题同步 ──────────────────────────────────────────
useEffect(() => {
try {
// 全局主题(documentElement + localStorage
MeEditor.setTheme(themeMode)
// v0.1.5+: 实例级主题,自动处理 wrapper CSS 变量
editorRef.current?.setTheme(themeMode)
} catch {
// 容错
}
}, [themeMode])
// ── 视图模式同步 ──────────────────────────────────────────
useEffect(() => {
const editor = editorRef.current
if (!editor) return
const targetMode = mapViewMode(viewMode)
if (editor.getMode() !== targetMode) {
editor.setMode(targetMode)
}
// 预览模式设为只读
editor.setReadOnly(viewMode === 'preview')
}, [viewMode])
return (
<div className="editor-container" role="region" aria-label="Markdown编辑器">
<div ref={containerRef} className="metona-editor-wrapper" />
</div>
)
})
Editor.displayName = 'Editor'
@@ -1,60 +1,56 @@
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
}
const isDev =
(typeof import.meta !== 'undefined' &&
(import.meta as { env?: { DEV?: boolean } }).env?.DEV) ??
false
return (
<div className="error-boundary-root" role="alert">
<h2 className="error-boundary-title"></h2>
{isDev && (
<pre className="error-boundary-detail">
{this.state.error?.message}
</pre>
)}
<button className="error-boundary-reset" onClick={this.handleReset}>
</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
}
const isDev =
(typeof import.meta !== 'undefined' &&
(import.meta as { env?: { DEV?: boolean } }).env?.DEV) ??
false
return (
<div className="error-boundary-root" role="alert">
<h2 className="error-boundary-title"></h2>
{isDev && <pre className="error-boundary-detail">{this.state.error?.message}</pre>}
<button className="error-boundary-reset" onClick={this.handleReset}>
</button>
</div>
)
}
return this.props.children
}
}
@@ -18,7 +18,7 @@ export const FileTree = React.memo(function FileTree({
expandedDirs,
toggleDir,
activeFilePath,
onFileClick
onFileClick,
}: FileTreeProps) {
return (
<>
@@ -30,7 +30,7 @@ export const FileTree = React.memo(function FileTree({
<React.Fragment key={node.path}>
<div
className={`tree-item ${isActive ? 'active' : ''}`}
style={{ paddingLeft: (8 + depth * 16) + 'px' }}
style={{ paddingLeft: 8 + depth * 16 + 'px' }}
role="treeitem"
aria-expanded={node.type === 'dir' ? isExpanded : undefined}
aria-selected={isActive}
@@ -43,7 +43,7 @@ export const FileTree = React.memo(function FileTree({
onFileClick(node.path)
}
}}
onKeyDown={(e) => {
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
if (node.type === 'dir') {
+337 -176
View File
@@ -1,176 +1,337 @@
import React from 'react'
import appIconUrl from '../assets/icon.png'
// 统一图标 Props
interface IconProps {
size?: number
className?: string
style?: React.CSSProperties
}
const defaultProps: Partial<IconProps> = { size: 18 }
// ===== 应用图标 =====
export function AppIcon({ size = 80 }: IconProps) {
return (
<img src={appIconUrl} alt="MarkLite" width={size} height={size} draggable={false} />
)
}
// ===== 工具栏图标 =====
export function FolderOpen({ size = defaultProps.size }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 19a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v1"/>
<path d="M20.5 15H5a2 2 0 0 0-2 2l1.5 7h18l2-7a2 2 0 0 0-2-2h-2.5z" fill="none"/>
<path d="M12 11h4" strokeDasharray="2 2"/>
</svg>
)
}
export function Save({ size = defaultProps.size }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
<polyline points="7 3 7 8 15 8"/>
</svg>
)
}
export function Moon({ size = defaultProps.size }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
<circle cx="19" cy="5" r="1" fill="currentColor" opacity="0.5"/>
</svg>
)
}
export function Sun({ size = defaultProps.size }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="5"/>
<circle cx="12" cy="12" r="2" fill="currentColor" opacity="0.3"/>
<line x1="12" y1="1" x2="12" y2="3"/>
<line x1="12" y1="21" x2="12" y2="23"/>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
<line x1="1" y1="12" x2="3" y2="12"/>
<line x1="21" y1="12" x2="23" y2="12"/>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg>
)
}
// ===== 工具栏右侧图标 =====
export function Gitee({ size = defaultProps.size }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
<path d="M11.984 0C5.372 0 0 5.372 0 11.984c0 6.612 5.372 11.984 11.984 11.984s11.984-5.372 11.984-11.984C23.968 5.372 18.596 0 11.984 0zm6.78 18.272c-.224.448-.832.672-1.344.448l-3.584-1.792c-.448-.224-.672-.448-.672-.896v-4.704c0-.448.448-.896.896-.896h4.704c.448 0 .896.448.896.896v4.704c0 .896-.448 1.568-1.344 1.792l.448.64zm-6.112-2.688c-.896 0-1.568-.672-1.568-1.568s.672-1.568 1.568-1.568 1.568.672 1.568 1.568-.672 1.568-1.568 1.568z"/>
</svg>
)
}
export function Info({ size = defaultProps.size }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="16" x2="12" y2="12"/>
<line x1="12" y1="8" x2="12.01" y2="8"/>
</svg>
)
}
// ===== 标签栏图标 =====
export function Close({ size = 10 }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
)
}
export function Plus({ size = 14 }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
)
}
// ===== 侧边栏图标 =====
export function Folder({ size = 14 }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
<line x1="9" y1="13" x2="15" y2="13" opacity="0.4"/>
</svg>
)
}
export function File({ size = 14 }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="8" y1="13" x2="16" y2="13" opacity="0.4"/>
<line x1="8" y1="17" x2="13" y2="17" opacity="0.3"/>
</svg>
)
}
export function ChevronRight({ size = 10 }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6"/>
</svg>
)
}
export function FolderPlus({ size = 14 }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
<line x1="12" y1="11" x2="12" y2="17"/>
<line x1="9" y1="14" x2="15" y2="14"/>
</svg>
)
}
// ===== 拖拽覆盖层图标 =====
export function UploadCloud({ size = 64 }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242"/>
<polyline points="12 16 12 8"/>
<polyline points="8 12 12 8 16 12"/>
<line x1="12" y1="16" x2="12" y2="22"/>
</svg>
)
}
// ===== 欢迎屏幕图标 =====
export function WelcomeFile({ size = 20 }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
<line x1="12" y1="11" x2="12" y2="17"/>
<line x1="9" y1="14" x2="15" y2="14"/>
</svg>
)
}
export function WelcomeNew({ size = 20 }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="12" y1="18" x2="12" y2="12"/>
<line x1="9" y1="15" x2="15" y2="15"/>
</svg>
)
}
import React from 'react'
import appIconUrl from '../assets/icon.png'
// 统一图标 Props
interface IconProps {
size?: number
className?: string
style?: React.CSSProperties
}
const defaultProps: Partial<IconProps> = { size: 18 }
// ===== 应用图标 =====
export function AppIcon({ size = 80 }: IconProps) {
return <img src={appIconUrl} alt="MarkLite" width={size} height={size} draggable={false} />
}
// ===== 工具栏图标 =====
export function FolderOpen({ size = defaultProps.size }: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5 19a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v1" />
<path d="M20.5 15H5a2 2 0 0 0-2 2l1.5 7h18l2-7a2 2 0 0 0-2-2h-2.5z" fill="none" />
<path d="M12 11h4" strokeDasharray="2 2" />
</svg>
)
}
export function Save({ size = defaultProps.size }: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
<polyline points="17 21 17 13 7 13 7 21" />
<polyline points="7 3 7 8 15 8" />
</svg>
)
}
export function Moon({ size = defaultProps.size }: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
<circle cx="19" cy="5" r="1" fill="currentColor" opacity="0.5" />
</svg>
)
}
export function Sun({ size = defaultProps.size }: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="5" />
<circle cx="12" cy="12" r="2" fill="currentColor" opacity="0.3" />
<line x1="12" y1="1" x2="12" y2="3" />
<line x1="12" y1="21" x2="12" y2="23" />
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
<line x1="1" y1="12" x2="3" y2="12" />
<line x1="21" y1="12" x2="23" y2="12" />
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
</svg>
)
}
// ===== 工具栏右侧图标 =====
export function Gitee({ size = defaultProps.size }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
<path d="M11.984 0C5.372 0 0 5.372 0 11.984c0 6.612 5.372 11.984 11.984 11.984s11.984-5.372 11.984-11.984C23.968 5.372 18.596 0 11.984 0zm6.78 18.272c-.224.448-.832.672-1.344.448l-3.584-1.792c-.448-.224-.672-.448-.672-.896v-4.704c0-.448.448-.896.896-.896h4.704c.448 0 .896.448.896.896v4.704c0 .896-.448 1.568-1.344 1.792l.448.64zm-6.112-2.688c-.896 0-1.568-.672-1.568-1.568s.672-1.568 1.568-1.568 1.568.672 1.568 1.568-.672 1.568-1.568 1.568z" />
</svg>
)
}
export function Info({ size = defaultProps.size }: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
)
}
// ===== 标签栏图标 =====
export function Close({ size = 10 }: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
)
}
export function Plus({ size = 14 }: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
)
}
// ===== 侧边栏图标 =====
export function Folder({ size = 14 }: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
<line x1="9" y1="13" x2="15" y2="13" opacity="0.4" />
</svg>
)
}
export function File({ size = 14 }: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="8" y1="13" x2="16" y2="13" opacity="0.4" />
<line x1="8" y1="17" x2="13" y2="17" opacity="0.3" />
</svg>
)
}
export function ChevronRight({ size = 10 }: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="9 18 15 12 9 6" />
</svg>
)
}
export function FolderPlus({ size = 14 }: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
<line x1="12" y1="11" x2="12" y2="17" />
<line x1="9" y1="14" x2="15" y2="14" />
</svg>
)
}
// ===== 拖拽覆盖层图标 =====
export function UploadCloud({ size = 64 }: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242" />
<polyline points="12 16 12 8" />
<polyline points="8 12 12 8 16 12" />
<line x1="12" y1="16" x2="12" y2="22" />
</svg>
)
}
// ===== v0.6.0: 数据备份图标 =====
export function Download({ size = defaultProps.size }: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
)
}
export function Upload({ size = defaultProps.size }: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
)
}
// ===== 欢迎屏幕图标 =====
export function WelcomeFile({ size = 20 }: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
<line x1="12" y1="11" x2="12" y2="17" />
<line x1="9" y1="14" x2="15" y2="14" />
</svg>
)
}
export function WelcomeNew({ size = 20 }: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="12" y1="18" x2="12" y2="12" />
<line x1="9" y1="15" x2="15" y2="15" />
</svg>
)
}
@@ -1,70 +0,0 @@
import React from 'react'
interface LoadingSpinnerProps {
size?: 'small' | 'medium' | 'large'
label?: string
/** 是否全屏覆盖 */
overlay?: boolean
}
const sizeMap = {
small: 16,
medium: 24,
large: 36
}
/**
* UX-02: 通用加载指示器组件
*/
export const LoadingSpinner = React.memo(function LoadingSpinner({
size = 'medium',
label,
overlay = false
}: LoadingSpinnerProps) {
const px = sizeMap[size]
const spinner = (
<div
className={`loading-spinner loading-spinner-${size}`}
role="status"
aria-label={label || '加载中'}
>
<svg
className="loading-spinner-svg"
width={px}
height={px}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<circle
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
opacity="0.2"
/>
<path
d="M12 2a10 10 0 0 1 10 10"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
/>
</svg>
{label && <span className="loading-spinner-label">{label}</span>}
</div>
)
if (!overlay) return spinner
return (
<div className="loading-overlay" aria-busy="true">
{spinner}
</div>
)
})
LoadingSpinner.displayName = 'LoadingSpinner'
@@ -1 +0,0 @@
export { LoadingSpinner } from './LoadingSpinner'
@@ -5,12 +5,19 @@ interface ModifiedBannerProps {
onDismiss: () => void
}
export const ModifiedBanner = React.memo(function ModifiedBanner({ onReload, onDismiss }: ModifiedBannerProps) {
export const ModifiedBanner = React.memo(function ModifiedBanner({
onReload,
onDismiss,
}: ModifiedBannerProps) {
return (
<div id="modified-banner" role="alert" aria-live="assertive">
<span></span>
<button className="banner-btn" onClick={onReload} aria-label="重新加载文件"></button>
<button className="banner-btn" onClick={onDismiss} aria-label="忽略外部修改"></button>
<button className="banner-btn" onClick={onReload} aria-label="重新加载文件">
</button>
<button className="banner-btn" onClick={onDismiss} aria-label="忽略外部修改">
</button>
</div>
)
})
@@ -1,71 +1,71 @@
import React, { memo } from 'react'
import type { Heading } from './outlineUtils'
// --- Component ---
interface OutlinePanelProps {
headings: Heading[]
onNavigate: (heading: Heading, index: number) => void
activeHeadingIndex: number | null
}
interface OutlineItemProps {
heading: Heading
index: number
isActive: boolean
onNavigate: (heading: Heading, index: number) => void
}
const OutlineItem = memo(function OutlineItem({
heading,
index,
isActive,
onNavigate
}: OutlineItemProps) {
return (
<button
className={`outline-item outline-level-${heading.level}${isActive ? ' active' : ''}`}
onClick={() => onNavigate(heading, index)}
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}-${i}`}
heading={h}
index={i}
isActive={i === activeHeadingIndex}
onNavigate={onNavigate}
/>
))}
</div>
</div>
)
})
OutlinePanel.displayName = 'OutlinePanel'
import React, { memo } from 'react'
import type { Heading } from './outlineUtils'
// --- Component ---
interface OutlinePanelProps {
headings: Heading[]
onNavigate: (heading: Heading, index: number) => void
activeHeadingIndex: number | null
}
interface OutlineItemProps {
heading: Heading
index: number
isActive: boolean
onNavigate: (heading: Heading, index: number) => void
}
const OutlineItem = memo(function OutlineItem({
heading,
index,
isActive,
onNavigate,
}: OutlineItemProps) {
return (
<button
className={`outline-item outline-level-${heading.level}${isActive ? ' active' : ''}`}
onClick={() => onNavigate(heading, index)}
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}-${i}`}
heading={h}
index={i}
isActive={i === activeHeadingIndex}
onNavigate={onNavigate}
/>
))}
</div>
</div>
)
})
OutlinePanel.displayName = 'OutlinePanel'
@@ -1,3 +1,3 @@
export { OutlinePanel } from './OutlinePanel'
export { parseHeadings } from './outlineUtils'
export type { Heading } from './outlineUtils'
export { OutlinePanel } from './OutlinePanel'
export { parseHeadings } from './outlineUtils'
export type { Heading } from './outlineUtils'
@@ -1,27 +1,27 @@
export interface Heading {
level: number
text: string
/** Position in document (character offset from markdown source) */
pos: number
}
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
}
export interface Heading {
level: number
text: string
/** Position in document (character offset from markdown source) */
pos: number
}
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
}
+209 -190
View File
@@ -1,190 +1,209 @@
import React, { useCallback, useMemo, useEffect, useRef } 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 { useActiveHeading } from '../../hooks/useActiveHeading'
import { OutlinePanel, parseHeadings } from '../OutlinePanel'
import type { Heading } from '../OutlinePanel'
import { getMetonaEditor, useEditorStore } from '../../stores/editorStore'
const norm = (p: string) => p.replace(/[/\\]+$/, '').replace(/\\/g, '/')
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
export const Sidebar = React.memo(function Sidebar() {
const tabs = useTabStore(s => s.tabs)
const activeTabId = useTabStore(s => s.activeTabId)
// B-03: 用 activeTabId + tabs 推导 activeTab 而非 s.getActiveTab()
// 后者每次返回新对象引用导致 Zustand 无条件重渲染
const activeTab = useMemo(() => tabs.find(t => t.id === activeTabId) ?? null, [tabs, activeTabId])
const switchToTab = useTabStore(s => s.switchToTab)
const createTab = useTabStore(s => s.createTab)
const rootPath = useSidebarStore(s => s.rootPath)
const tree = useSidebarStore(s => s.tree)
const expandedDirs = useSidebarStore(s => s.expandedDirs)
const toggleDir = useSidebarStore(s => s.toggleDir)
const isVisible = useSidebarStore(s => s.isVisible)
const activeFilePath = activeTab?.filePath ?? null
const { sidebarRef, startResize } = useSidebarResize()
const { handleOpenFolder } = useFolderOperations()
const setLoading = useEditorStore(s => s.setLoading)
const viewMode = useEditorStore(s => s.viewMode)
useAutoExpandDir(activeFilePath)
// Parse headings from active tab content
const headings = useMemo(() => {
if (!activeTab?.content) return []
return parseHeadings(activeTab.content)
}, [activeTab?.content])
// D4: 追踪预览面板中的活跃标题(适配 MetonaEditor 的 .me-preview
const previewRef = useRef<HTMLElement | null>(null)
useEffect(() => {
if (viewMode === 'preview') {
previewRef.current = document.querySelector('.me-preview') as HTMLElement | null
} else {
previewRef.current = null
}
}, [viewMode])
const activeHeadingIndex = useActiveHeading(
viewMode === 'preview' ? previewRef : { current: null },
headings
)
// Navigate to heading in MetonaEditor
const handleHeadingNavigate = useCallback((heading: Heading) => {
const editor = getMetonaEditor()
if (!editor) return
try {
// 获取当前内容,查找标题文本在源代码中的位置
const content = editor.getValue()
const headingPattern = new RegExp(
`^#{1,6}\\s+${escapeRegex(heading.text)}\\s*$`,
'm'
)
const match = headingPattern.exec(content)
if (!match) return
const pos = match.index
// 通过 DOM 操作滚动 textarea 到对应位置
const container = document.querySelector('.metona-editor-wrapper') as HTMLElement | null
if (!container) return
const textarea = container.querySelector('textarea')
if (!textarea) return
// 估算滚动位置(简单方法:按行数比例)
const linesBefore = content.substring(0, pos).split('\n').length
const lineHeight = 24 // 估算行高
textarea.scrollTop = linesBefore * lineHeight
// 设置光标位置
textarea.focus()
textarea.setSelectionRange(pos, pos)
} catch {
// 导航失败,静默忽略
}
}, [])
const handleFileClick = useCallback(async (path: string) => {
const existing = tabs.find(t => t.filePath === path)
if (existing) { switchToTab(existing.id); return }
if (!window.electronAPI) return
setLoading('file-open', true)
try {
const result = await window.electronAPI.readFile(path)
if (result.success && result.content !== undefined) {
createTab(path, result.content)
recentFilesRepository.add(path)
}
} finally {
setLoading('file-open', false)
}
}, [tabs, switchToTab, createTab, setLoading])
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}
activeFilePath={activeFilePath} onFileClick={handleFileClick}
/>
</>
)}
</nav>
<div className="sidebar-outline-section">
<OutlinePanel
headings={headings}
onNavigate={handleHeadingNavigate}
activeHeadingIndex={activeHeadingIndex}
/>
</div>
<div
className="sidebar-resize-handle"
onMouseDown={startResize}
role="separator"
aria-orientation="vertical"
aria-label="调整侧边栏宽度"
tabIndex={0}
/>
</aside>
)
})
Sidebar.displayName = 'Sidebar'
import React, { useCallback, useMemo, useEffect, useRef } 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 { useActiveHeading } from '../../hooks/useActiveHeading'
import { OutlinePanel, parseHeadings } from '../OutlinePanel'
import type { Heading } from '../OutlinePanel'
import { getMetonaEditor, useEditorStore } from '../../stores/editorStore'
const norm = (p: string) => p.replace(/[/\\]+$/, '').replace(/\\/g, '/')
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
export const Sidebar = React.memo(function Sidebar() {
const tabs = useTabStore(s => s.tabs)
const activeTabId = useTabStore(s => s.activeTabId)
// B-03: 用 activeTabId + tabs 推导 activeTab 而非 s.getActiveTab()
// 后者每次返回新对象引用导致 Zustand 无条件重渲染
const activeTab = useMemo(() => tabs.find(t => t.id === activeTabId) ?? null, [tabs, activeTabId])
const switchToTab = useTabStore(s => s.switchToTab)
const createTab = useTabStore(s => s.createTab)
const rootPath = useSidebarStore(s => s.rootPath)
const tree = useSidebarStore(s => s.tree)
const expandedDirs = useSidebarStore(s => s.expandedDirs)
const toggleDir = useSidebarStore(s => s.toggleDir)
const isVisible = useSidebarStore(s => s.isVisible)
const activeFilePath = activeTab?.filePath ?? null
const { sidebarRef, startResize } = useSidebarResize()
const { handleOpenFolder } = useFolderOperations()
const setLoading = useEditorStore(s => s.setLoading)
const viewMode = useEditorStore(s => s.viewMode)
useAutoExpandDir(activeFilePath)
// Parse headings from active tab content
const headings = useMemo(() => {
if (!activeTab?.content) return []
return parseHeadings(activeTab.content)
}, [activeTab?.content])
// D4: 追踪预览面板中的活跃标题(适配 MetonaEditor 的 .me-preview
const previewRef = useRef<HTMLElement | null>(null)
useEffect(() => {
if (viewMode === 'preview') {
previewRef.current = document.querySelector('.me-preview') as HTMLElement | null
} else {
previewRef.current = null
}
}, [viewMode])
const activeHeadingIndex = useActiveHeading(
viewMode === 'preview' ? previewRef : { current: null },
headings,
)
// Navigate to heading in MetonaEditor
// v0.6.0: 使用官方 APIscrollToLine + setCursorPosition)替代 DOM hack
const handleHeadingNavigate = useCallback((heading: Heading) => {
const editor = getMetonaEditor()
if (!editor) return
try {
// 获取当前内容,查找标题文本在源代码中的位置
const content = editor.getValue()
const headingPattern = new RegExp(`^#{1,6}\\s+${escapeRegex(heading.text)}\\s*$`, 'm')
const match = headingPattern.exec(content)
if (!match) return
// 按行号导航 — 官方 API 处理滚动与光标
const line = content.substring(0, match.index).split('\n').length
editor.scrollToLine(line)
editor.setCursorPosition(line, 0)
editor.focus()
} catch {
// 导航失败,静默忽略
}
}, [])
const handleFileClick = useCallback(
async (path: string) => {
const existing = tabs.find(t => t.filePath === path)
if (existing) {
switchToTab(existing.id)
return
}
if (!window.electronAPI) return
setLoading('file-open', true)
try {
const result = await window.electronAPI.readFile(path)
if (result.success && result.content !== undefined) {
createTab(path, result.content)
recentFilesRepository.add(path)
}
} finally {
setLoading('file-open', false)
}
},
[tabs, switchToTab, createTab, setLoading],
)
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}
activeFilePath={activeFilePath}
onFileClick={handleFileClick}
/>
</>
)}
</nav>
<div className="sidebar-outline-section">
<OutlinePanel
headings={headings}
onNavigate={handleHeadingNavigate}
activeHeadingIndex={activeHeadingIndex}
/>
</div>
<div
className="sidebar-resize-handle"
onMouseDown={startResize}
role="separator"
aria-orientation="vertical"
aria-label="调整侧边栏宽度"
tabIndex={0}
/>
</aside>
)
})
Sidebar.displayName = 'Sidebar'
@@ -0,0 +1,38 @@
import React from 'react'
import { useEditorStore } from '../../stores/editorStore'
import { useAutoSaveStore } from '../../stores/autoSaveStore'
/**
* v0.6.0: 状态栏 — 展示文档统计 / 光标位置 / 自动保存状态。
* 数据来自 editorStoreEditor 组件绑定 MetonaEditor 事件实时写入)。
*/
export const StatusBar = React.memo(function StatusBar() {
const stats = useEditorStore(s => s.stats)
const cursor = useEditorStore(s => s.cursor)
const zenMode = useEditorStore(s => s.zenMode)
const isAutoSaving = useAutoSaveStore(s => s.isAutoSaving)
const autoSaveEnabled = useAutoSaveStore(s => s.autoSaveEnabled)
return (
<footer id="statusbar" role="status" aria-label="状态栏">
{zenMode && <span className="statusbar-item statusbar-zen">🧘 Zen</span>}
{stats && (
<span className="statusbar-item" title="字数 / 词数 / 行数 / 阅读时间">
{stats.words} · {stats.lines} · {stats.readingTime}
</span>
)}
<span className="statusbar-spacer" />
{cursor && (
// getCursorPosition: line 为 1-basedcolumn 为 0-based
<span className="statusbar-item">
{cursor.line}, {cursor.column + 1}
</span>
)}
<span className={`statusbar-item statusbar-autosave${isAutoSaving ? ' saving' : ''}`}>
{isAutoSaving ? '保存中...' : autoSaveEnabled ? '自动保存' : '手动保存'}
</span>
</footer>
)
})
StatusBar.displayName = 'StatusBar'
@@ -0,0 +1 @@
export { StatusBar } from './StatusBar'
+306 -304
View File
@@ -1,304 +1,306 @@
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 moveTab = useTabStore(s => s.moveTab)
const tabListRef = useRef<HTMLDivElement>(null)
const [menu, setMenu] = useState<ContextMenuState>({ visible: false, x: 0, y: 0, tabId: '' })
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
const dragTabIdRef = useRef<string | null>(null)
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])
// D1: 拖拽排序事件处理
const handleDragStart = useCallback((e: React.DragEvent, tabId: string) => {
dragTabIdRef.current = tabId
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', tabId)
// 延迟添加 dragging 类,避免拖拽图像被 CSS 捕获
requestAnimationFrame(() => {
const el = document.querySelector(`[data-tab-id="${tabId}"]`) as HTMLElement
el?.classList.add('dragging')
})
}, [])
const handleDragOver = useCallback((e: React.DragEvent, index: number) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
setDragOverIndex(index)
}, [])
const handleDragLeave = useCallback(() => {
setDragOverIndex(null)
}, [])
const handleDrop = useCallback((e: React.DragEvent, toIndex: number) => {
e.preventDefault()
setDragOverIndex(null)
const fromId = dragTabIdRef.current
if (fromId) {
moveTab(fromId, toIndex)
}
dragTabIdRef.current = null
// 清理 dragging 类
document.querySelectorAll('.tab-item.dragging').forEach(el => el.classList.remove('dragging'))
}, [moveTab])
const handleDragEnd = useCallback(() => {
setDragOverIndex(null)
document.querySelectorAll('.tab-item.dragging').forEach(el => el.classList.remove('dragging'))
dragTabIdRef.current = null
}, [])
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, index) => (
<div
key={tab.id}
className={`tab-item ${tab.id === activeTabId ? 'active' : ''} ${tab.isModified ? 'modified' : ''} ${dragOverIndex === index ? 'drag-over' : ''}`}
role="tab"
aria-selected={tab.id === activeTabId}
tabIndex={tab.id === activeTabId ? 0 : -1}
data-tab-id={tab.id}
draggable
onClick={() => switchToTab(tab.id)}
onContextMenu={(e) => handleContextMenu(e, tab.id)}
onDragStart={(e) => handleDragStart(e, tab.id)}
onDragOver={(e) => handleDragOver(e, index)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, index)}
onDragEnd={handleDragEnd}
>
<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()}
onMouseDown={(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 { getFileName } from '../../lib/fileUtils'
import { MeToast } from '../../lib/toast'
import { Close, Plus } from '../Icons'
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 moveTab = useTabStore(s => s.moveTab)
const tabListRef = useRef<HTMLDivElement>(null)
const [menu, setMenu] = useState<ContextMenuState>({ visible: false, x: 0, y: 0, tabId: '' })
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
const dragTabIdRef = useRef<string | null>(null)
// 滚动到活动标签
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 MeToast.confirm(`"${name}" 尚未保存,确定要关闭吗?`, {
title: '关闭标签',
confirmText: '关闭',
cancelText: '取消',
})
if (!confirmed) return
}
closeTab(tabId)
},
[tabs, closeTab],
)
// 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 MeToast.confirm(`"${name}" 尚未保存,确定要关闭吗?`, {
title: '关闭标签',
confirmText: '关闭',
cancelText: '取消',
})
if (!confirmed) return
}
closeTab(menu.tabId)
setMenu(prev => ({ ...prev, visible: false }))
}, [tabs, menu.tabId, closeTab])
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 MeToast.confirm(`以下文件尚未保存:${names},确定要关闭吗?`, {
title: '关闭其他标签',
confirmText: '关闭',
cancelText: '取消',
})
if (!confirmed) return
}
closeOtherTabs(menu.tabId)
setMenu(prev => ({ ...prev, visible: false }))
}, [tabs, menu.tabId, closeOtherTabs])
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 MeToast.confirm(`以下文件尚未保存:${names},确定要关闭吗?`, {
title: '关闭全部标签',
confirmText: '关闭',
cancelText: '取消',
})
if (!confirmed) return
}
closeAllTabs()
setMenu(prev => ({ ...prev, visible: false }))
}, [tabs, closeAllTabs])
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 MeToast.confirm(`以下文件尚未保存:${names},确定要关闭吗?`, {
title: '关闭右侧标签',
confirmText: '关闭',
cancelText: '取消',
})
if (!confirmed) return
}
closeTabsToRight(menu.tabId)
setMenu(prev => ({ ...prev, visible: false }))
}, [tabs, menu.tabId, closeTabsToRight])
// D1: 拖拽排序事件处理
const handleDragStart = useCallback((e: React.DragEvent, tabId: string) => {
dragTabIdRef.current = tabId
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', tabId)
// 延迟添加 dragging 类,避免拖拽图像被 CSS 捕获
requestAnimationFrame(() => {
const el = document.querySelector(`[data-tab-id="${tabId}"]`) as HTMLElement
el?.classList.add('dragging')
})
}, [])
const handleDragOver = useCallback((e: React.DragEvent, index: number) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
setDragOverIndex(index)
}, [])
const handleDragLeave = useCallback(() => {
setDragOverIndex(null)
}, [])
const handleDrop = useCallback(
(e: React.DragEvent, toIndex: number) => {
e.preventDefault()
setDragOverIndex(null)
const fromId = dragTabIdRef.current
if (fromId) {
moveTab(fromId, toIndex)
}
dragTabIdRef.current = null
// 清理 dragging 类
document.querySelectorAll('.tab-item.dragging').forEach(el => el.classList.remove('dragging'))
},
[moveTab],
)
const handleDragEnd = useCallback(() => {
setDragOverIndex(null)
document.querySelectorAll('.tab-item.dragging').forEach(el => el.classList.remove('dragging'))
dragTabIdRef.current = null
}, [])
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, index) => (
<div
key={tab.id}
className={`tab-item ${tab.id === activeTabId ? 'active' : ''} ${tab.isModified ? 'modified' : ''} ${dragOverIndex === index ? 'drag-over' : ''}`}
role="tab"
aria-selected={tab.id === activeTabId}
tabIndex={tab.id === activeTabId ? 0 : -1}
data-tab-id={tab.id}
draggable
onClick={() => switchToTab(tab.id)}
onContextMenu={e => handleContextMenu(e, tab.id)}
onDragStart={e => handleDragStart(e, tab.id)}
onDragOver={e => handleDragOver(e, index)}
onDragLeave={handleDragLeave}
onDrop={e => handleDrop(e, index)}
onDragEnd={handleDragEnd}
>
<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()}
onMouseDown={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>
</>
)
})
TabBar.displayName = 'TabBar'
+173 -71
View File
@@ -1,71 +1,173 @@
import React from 'react'
import { FolderOpen, Save, Moon, Sun, Info } from '../Icons'
import type { ThemeMode } from '../../types/settings'
interface ToolbarProps {
onOpen: () => void
onSave: () => void
themeMode: ThemeMode
onCycleTheme: () => void
onShowAbout: () => void
isAutoSaving: boolean
autoSaveEnabled: boolean
onToggleAutoSave: () => void
}
const THEME_LABELS: Record<ThemeMode, string> = {
light: '亮色',
dark: '暗色',
warm: '暖色',
}
/**
* 应用顶层工具栏 — 文件操作、自动保存、主题循环、关于。
* 编辑器格式化和模式切换由 MetonaEditor 内置工具栏处理。
*/
export const Toolbar = React.memo(function Toolbar({
onOpen, onSave, themeMode, onCycleTheme, onShowAbout,
isAutoSaving, autoSaveEnabled, onToggleAutoSave
}: ToolbarProps) {
const nextLabel = THEME_LABELS[themeMode] ?? '主题'
return (
<div id="toolbar" role="toolbar" aria-label="工具栏">
<div className="toolbar-left" role="group" aria-label="文件操作">
<button className="toolbar-btn" onClick={onOpen} title="打开文件 (Ctrl+O)" aria-label="打开文件">
<FolderOpen size={18} />
<span></span>
</button>
<button className="toolbar-btn" onClick={onSave} title="保存文件 (Ctrl+S)" aria-label="保存文件">
<Save size={18} />
<span></span>
</button>
<div className="toolbar-divider" role="separator" />
<button
className={`toolbar-btn toolbar-autosave${isAutoSaving ? ' saving' : ''}`}
onClick={onToggleAutoSave}
title={isAutoSaving ? '正在自动保存...' : (autoSaveEnabled ? '自动保存已开启 — 点击关闭' : '自动保存已关闭 — 点击开启')}
aria-label={isAutoSaving ? '正在自动保存' : (autoSaveEnabled ? '关闭自动保存' : '开启自动保存')}
>
<span>{isAutoSaving ? '保存中...' : (autoSaveEnabled ? '自动' : '手动')}</span>
</button>
</div>
<div className="toolbar-right" role="group" aria-label="设置">
<button
className="toolbar-btn"
onClick={onCycleTheme}
title={`当前 ${nextLabel} — 点击切换`}
aria-label={`当前${nextLabel}主题,点击切换`}
>
{themeMode === 'dark' ? <Moon size={18} /> : <Sun size={18} />}
<span style={{ fontSize: 12, marginLeft: 2 }}>{nextLabel}</span>
</button>
<button className="toolbar-btn" onClick={onShowAbout} title="关于" aria-label="关于 MarkLite">
<Info size={18} />
</button>
</div>
</div>
)
})
Toolbar.displayName = 'Toolbar'
import React, { useCallback } from 'react'
import { FolderOpen, Save, Moon, Sun, Info, Download, Upload } from '../Icons'
import { getMetonaEditor, useEditorStore } from '../../stores/editorStore'
import { backupRepository } from '../../db/backupRepository'
import { showToast } from '../../lib/toast'
import { logError } from '../../lib/errorHandler'
import type { ThemeMode } from '../../types/settings'
interface ToolbarProps {
onOpen: () => void
onSave: () => void
themeMode: ThemeMode
onCycleTheme: () => void
onShowAbout: () => void
isAutoSaving: boolean
autoSaveEnabled: boolean
onToggleAutoSave: () => void
}
const THEME_LABELS: Record<ThemeMode, string> = {
light: '亮色',
dark: '暗色',
warm: '暖色',
}
/**
* 应用顶层工具栏 — 文件操作、自动保存、主题循环、数据备份、关于。
* 编辑器格式化和模式切换由 MetonaEditor 内置工具栏处理。
*/
export const Toolbar = React.memo(function Toolbar({
onOpen,
onSave,
themeMode,
onCycleTheme,
onShowAbout,
isAutoSaving,
autoSaveEnabled,
onToggleAutoSave,
}: ToolbarProps) {
const nextLabel = THEME_LABELS[themeMode] ?? '主题'
const zenMode = useEditorStore(s => s.zenMode)
// v0.6.0: Zen 专注模式切换(MetonaEditor 内置能力)
const handleToggleZen = useCallback(() => {
const editor = getMetonaEditor()
if (!editor) return
editor.toggleZen()
}, [])
// v0.6.0: 数据备份导出(sqlark exportAll → JSON 文件)
const handleExport = useCallback(async () => {
if (!window.electronAPI) return
const data = await backupRepository.exportAll()
if (!data) return
const result = await window.electronAPI.exportData(JSON.stringify(data, null, 2))
if (result.success) {
showToast('备份已导出', 'success')
} else if (!result.canceled) {
showToast(`导出失败: ${result.error ?? '未知错误'}`, 'error')
}
}, [])
// v0.6.0: 数据备份导入(JSON 文件 → sqlark importTable
const handleImport = useCallback(async () => {
if (!window.electronAPI) return
const result = await window.electronAPI.importData()
if (!result.success) return
if (result.canceled || !result.content) return
try {
const data = JSON.parse(result.content) as Record<string, Record<string, unknown>[]>
const ok = await backupRepository.importAll(data)
if (ok) {
// 恢复后刷新页面 — 所有 store 从新数据库重新加载(loadFromDB 有 _loaded 守卫,
// 且 settings/sidebar 也只在初始化时读取,直接重载页面最可靠)
showToast('备份已恢复', 'success')
setTimeout(() => window.location.reload(), 800)
} else {
showToast('恢复备份失败', 'error')
}
} catch (error) {
logError('解析备份文件失败', error)
showToast('备份文件格式无效', 'error')
}
}, [])
return (
<div id="toolbar" role="toolbar" aria-label="工具栏">
<div className="toolbar-left" role="group" aria-label="文件操作">
<button
className="toolbar-btn"
onClick={onOpen}
title="打开文件 (Ctrl+O)"
aria-label="打开文件"
>
<FolderOpen size={18} />
<span></span>
</button>
<button
className="toolbar-btn"
onClick={onSave}
title="保存文件 (Ctrl+S)"
aria-label="保存文件"
>
<Save size={18} />
<span></span>
</button>
<div className="toolbar-divider" role="separator" />
<button
className={`toolbar-btn toolbar-autosave${isAutoSaving ? ' saving' : ''}`}
onClick={onToggleAutoSave}
title={
isAutoSaving
? '正在自动保存...'
: autoSaveEnabled
? '自动保存已开启 — 点击关闭'
: '自动保存已关闭 — 点击开启'
}
aria-label={
isAutoSaving ? '正在自动保存' : autoSaveEnabled ? '关闭自动保存' : '开启自动保存'
}
>
<span>{isAutoSaving ? '保存中...' : autoSaveEnabled ? '自动' : '手动'}</span>
</button>
</div>
<div className="toolbar-right" role="group" aria-label="设置">
<button
className={`toolbar-btn${zenMode ? ' active' : ''}`}
onClick={handleToggleZen}
title={zenMode ? '退出专注模式' : '专注模式 — 隐藏编辑器工具栏'}
aria-label={zenMode ? '退出专注模式' : '进入专注模式'}
aria-pressed={zenMode}
>
<span>🧘 {zenMode ? '专注中' : '专注'}</span>
</button>
<button
className="toolbar-btn"
onClick={handleExport}
title="导出数据备份"
aria-label="导出数据备份"
>
<Download size={16} />
</button>
<button
className="toolbar-btn"
onClick={handleImport}
title="导入数据备份"
aria-label="导入数据备份"
>
<Upload size={16} />
</button>
<button
className="toolbar-btn"
onClick={onCycleTheme}
title={`当前 ${nextLabel} — 点击切换`}
aria-label={`当前${nextLabel}主题,点击切换`}
>
{themeMode === 'dark' ? <Moon size={18} /> : <Sun size={18} />}
<span style={{ fontSize: 12, marginLeft: 2 }}>{nextLabel}</span>
</button>
<button
className="toolbar-btn"
onClick={onShowAbout}
title="关于"
aria-label="关于 MarkLite"
>
<Info size={18} />
</button>
</div>
</div>
)
})
Toolbar.displayName = 'Toolbar'
@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react'
import { AppIcon, WelcomeFile, WelcomeNew } from '../Icons'
import { recentFilesRepository } from '../../db/recentFilesRepository'
import { getDb } from '../../db/schema'
import { getFileName } from '../../lib/fileUtils'
interface WelcomeScreenProps {
@@ -9,13 +10,47 @@ interface WelcomeScreenProps {
onOpenRecent?: (filePath: string) => void
}
export const WelcomeScreen = React.memo(function WelcomeScreen({ onOpen, onNew, onOpenRecent }: WelcomeScreenProps) {
export const WelcomeScreen = React.memo(function WelcomeScreen({
onOpen,
onNew,
onOpenRecent,
}: WelcomeScreenProps) {
const [recentFiles, setRecentFiles] = useState<string[]>([])
// v0.6.0: 订阅 recentFiles 表变更,文件打开/删除时自动刷新
useEffect(() => {
recentFilesRepository.getAll(10).then((files: string[]) => {
setRecentFiles(files)
})
let cancelled = false
let unsubscribe: (() => void) | null = null
const refresh = async () => {
const files = await recentFilesRepository.getAll(10)
if (!cancelled) setRecentFiles(files)
}
refresh()
getDb()
.then(db => {
if (cancelled) return
unsubscribe = db.subscribe('recentFiles', event => {
if (
event.type === 'insert' ||
event.type === 'update' ||
event.type === 'delete' ||
event.type === 'external'
) {
refresh()
}
})
})
.catch(() => {
/* 订阅失败不阻塞页面 */
})
return () => {
cancelled = true
unsubscribe?.()
}
}, [])
return (
@@ -51,7 +86,14 @@ export const WelcomeScreen = React.memo(function WelcomeScreen({ onOpen, onNew,
aria-label={`打开 ${getFileName(filePath)}`}
>
<span className="recent-icon" aria-hidden="true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
@@ -0,0 +1,34 @@
import 'fake-indexeddb/auto'
import { describe, it, expect } from 'vitest'
import { recentFilesRepository } from '../recentFilesRepository'
describe('recentFilesRepository (真实 AriaEngine + IndexedDB)', () => {
it('should add and read recent files', async () => {
await recentFilesRepository.add('/test/a.md')
const files = await recentFilesRepository.getAll(10)
expect(files).toContain('/test/a.md')
})
it('should update lastOpened on re-add', async () => {
await recentFilesRepository.add('/test/b.md')
await new Promise(r => setTimeout(r, 5))
await recentFilesRepository.add('/test/b.md')
const files = await recentFilesRepository.getAll(10)
expect(files[0]).toBe('/test/b.md')
})
it('should enforce 50 item limit', async () => {
for (let i = 0; i < 55; i++) {
await recentFilesRepository.add(`/test/f${String(i).padStart(2, '0')}.md`)
}
const files = await recentFilesRepository.getAll(60)
expect(files.length).toBeLessThanOrEqual(50)
})
it('should remove a file', async () => {
await recentFilesRepository.add('/test/remove-me.md')
await recentFilesRepository.remove('/test/remove-me.md')
const files = await recentFilesRepository.getAll(60)
expect(files).not.toContain('/test/remove-me.md')
})
})
+44
View File
@@ -0,0 +1,44 @@
import { getDb } from './schema'
import { logError } from '../lib/errorHandler'
/** v0.6.0: 备份数据格式 — { 表名: 行[] }(与 db.exportAll() 一致) */
export type BackupData = Record<string, Record<string, unknown>[]>
const BACKUP_TABLES = ['tabSnapshots', 'settings', 'recentFiles', 'activeTab']
/**
* v0.6.0: 数据备份/恢复 — 基于 sqlark 的 exportAll / importTable。
*/
export const backupRepository = {
async exportAll(): Promise<BackupData | null> {
try {
const db = await getDb()
return (await db.exportAll()) as BackupData
} catch (error) {
logError('导出备份失败', error)
return null
}
},
async importAll(data: BackupData): Promise<boolean> {
try {
const db = await getDb()
// 先清空现有四表,再按表导入(清空走事务保证原子性)
await db.transaction(async trx => {
for (const tableName of BACKUP_TABLES) {
await trx.table(tableName).clear()
}
})
for (const tableName of BACKUP_TABLES) {
const rows = data[tableName]
if (Array.isArray(rows) && rows.length > 0) {
await db.importTable(tableName, rows)
}
}
return true
} catch (error) {
logError('导入备份失败', error)
return false
}
},
}
+22 -3
View File
@@ -1,7 +1,20 @@
import { type Table } from '@metona-team/metona-sqlark'
import { type MetonaSqlark, type Table } from '@metona-team/metona-sqlark'
import { getDb, type RecentFile } from './schema'
import { logError } from '../lib/errorHandler'
/**
* v0.6.0: 本地写操作完成后 emit 表变更事件 —
* sqlark 的 Table 写操作只通过 BroadcastChannel 通知其他标签页,
* 本地订阅(subscribe)需要手动 emitWelcomeScreen 借此自动刷新最近文件。
*/
async function emitChange(db: MetonaSqlark, type: string): Promise<void> {
try {
db.emit('recentFiles', { type })
} catch {
// 通知失败不影响主流程
}
}
export const recentFilesRepository = {
async add(filePath: string): Promise<void> {
try {
@@ -17,8 +30,12 @@ export const recentFilesRepository = {
const all = (await tbl.select().orderBy('lastOpened', 'desc').execute()) as RecentFile[]
if (all.length > 50) {
const toDelete = all.slice(50).map((f: RecentFile) => f.filePath)
await tbl.delete().where({ filePath: { $in: toDelete } }).execute()
await tbl
.delete()
.where({ filePath: { $in: toDelete } })
.execute()
}
await emitChange(db, 'update')
} catch (error) {
logError('添加最近文件失败', error)
}
@@ -44,6 +61,7 @@ export const recentFilesRepository = {
try {
const db = await getDb()
await db.table('recentFiles').delete().where({ filePath }).execute()
await emitChange(db, 'delete')
} catch (error) {
logError('删除最近文件失败', error)
}
@@ -53,8 +71,9 @@ export const recentFilesRepository = {
try {
const db = await getDb()
await db.table('recentFiles').clear()
await emitChange(db, 'delete')
} catch (error) {
logError('清空最近文件失败', error)
}
}
},
}
+110 -19
View File
@@ -1,5 +1,6 @@
import { create, type ColumnDef, type MetonaSqlark } from '@metona-team/metona-sqlark'
import { logError } from '../lib/errorHandler'
import { MeToast } from '../lib/toast'
// 注意: 使用 type 别名而非 interface —
// sqlark 的 Table<T> 泛型约束 T & Record<string, unknown>
@@ -38,7 +39,15 @@ export type RecentFile = {
// v0.5.0: 库名更换为 MarkLiteV2,与旧 Dexie 库(MarkLite)彻底隔离,旧数据已放弃
// v0.5.0: 存储引擎选用 AriaEngine(自研 LSM-Tree + WAL + MVCC,对标 SQLite
const DB_NAME = 'MarkLiteV2'
const DB_VERSION = 1
/**
* v0.6.0: 版本化 Schema 迁移。
* create 时 version 传 0(表示尚未应用任何 schema),migrateTo(SCHEMA_VERSION)
* 会依次执行所有 version > 0 的迁移。注意 _version 不持久化(每次启动从配置值
* 开始),因此每个迁移内部必须幂等(表存在检查)。
* 未来升级: 新增 addMigration(N) + 提升 SCHEMA_VERSION,迁移内部做增量变更。
*/
const SCHEMA_VERSION = 1
const TAB_SNAPSHOTS_COLUMNS: Record<string, ColumnDef> = {
id: { type: 'string', primaryKey: true },
@@ -70,33 +79,115 @@ const ACTIVE_TAB_COLUMNS: Record<string, ColumnDef> = {
}
/**
* v0.5.0: MetonaSqlark 初始化 — 懒加载单例。
* v0.6.0: MetonaSqlark 初始化 — 懒加载单例。
* create() 为异步,无法像 Dexie 那样模块顶层同步实例化;
* 首次调用时创建,之后复用同一 Promise。
*
* v0.6.0: 数据损坏自愈 —
* AriaEngine 在异常退出(强杀/断电)时可能留下残缺的 SSTable 文件,
* 重新打开解析块偏移会抛 RangeErroroffset is out of bounds)导致初始化失败。
* 检测到打开失败后:标记 localStorage → 重载页面(干净环境无残留 IDB 连接)
* → 删除损坏库 → 重建。create 成功后才清除标记,避免自愈失败死循环。
*/
let dbPromise: Promise<MetonaSqlark> | null = null
let initFailedNotified = false
async function defineTablesIfNeeded(db: MetonaSqlark): Promise<void> {
const tables = await db.getTableNames()
if (!tables.includes('tabSnapshots')) await db.defineTable('tabSnapshots', TAB_SNAPSHOTS_COLUMNS)
if (!tables.includes('settings')) await db.defineTable('settings', SETTINGS_COLUMNS)
if (!tables.includes('recentFiles')) await db.defineTable('recentFiles', RECENT_FILES_COLUMNS)
if (!tables.includes('activeTab')) await db.defineTable('activeTab', ACTIVE_TAB_COLUMNS)
// IndexedDBBackend 的内部库名 = `aria-${name}`sqlark 源码约定)
const DB_STORAGE_NAME = `aria-${DB_NAME}`
const RESET_PENDING_KEY = 'marklite-db-reset-pending'
/** 删除损坏数据库(无活动连接时立即成功) */
function deleteStorageDatabase(): Promise<void> {
return new Promise<void>(resolve => {
const req = indexedDB.deleteDatabase(DB_STORAGE_NAME)
req.onsuccess = () => resolve()
req.onerror = () => resolve()
req.onblocked = () => resolve()
})
}
/** 自愈入口:存在重置标记时删除损坏库(保留标记,create 成功后才清除) */
async function resetCorruptDatabaseIfNeeded(): Promise<void> {
try {
if (localStorage.getItem(RESET_PENDING_KEY) !== '1') return
await deleteStorageDatabase()
try {
MeToast?.info('数据库已重置(原数据损坏,无法恢复)')
} catch {
/* 提示失败不影响主流程 */
}
} catch {
/* 重置失败不阻塞启动 */
}
}
async function createDatabase(): Promise<MetonaSqlark> {
const db = await create({
name: DB_NAME,
mode: 'aria', // AriaEngine: LSM-Tree + WAL + MVCC 快照隔离
diskEngine: 'indexeddb', // 底层存储后端(indexeddb | opfs | memory
version: 0, // 0 = 尚未应用任何 schema 迁移
onError: (err: Error) => logError('数据库错误', err),
})
// v1: 初始四表结构
db.addMigration(1, async d => {
const tables = await d.getTableNames()
if (!tables.includes('tabSnapshots')) await d.defineTable('tabSnapshots', TAB_SNAPSHOTS_COLUMNS)
if (!tables.includes('settings')) await d.defineTable('settings', SETTINGS_COLUMNS)
if (!tables.includes('recentFiles')) await d.defineTable('recentFiles', RECENT_FILES_COLUMNS)
if (!tables.includes('activeTab')) await d.defineTable('activeTab', ACTIVE_TAB_COLUMNS)
})
await db.migrateTo(SCHEMA_VERSION)
return db
}
async function initDb(): Promise<MetonaSqlark> {
await resetCorruptDatabaseIfNeeded()
try {
const db = await createDatabase()
// 创建成功:清除重置标记(自愈完成)
try {
localStorage.removeItem(RESET_PENDING_KEY)
} catch {
/* 忽略 */
}
return db
} catch (err) {
logError('数据库打开失败', err)
// 数据损坏自愈:首次失败 → 标记 + 重载页面(干净环境删除损坏库重建)
try {
if (localStorage.getItem(RESET_PENDING_KEY) !== '1') {
localStorage.setItem(RESET_PENDING_KEY, '1')
window.location.reload()
}
} catch {
/* 忽略 */
}
throw err
}
}
export function getDb(): Promise<MetonaSqlark> {
if (!dbPromise) {
dbPromise = (async () => {
const db = await create({
name: DB_NAME,
mode: 'aria', // AriaEngine: LSM-Tree + WAL + MVCC 快照隔离
diskEngine: 'indexeddb', // 底层存储后端(indexeddb | opfs | memory
version: DB_VERSION,
onError: (err: Error) => logError('数据库错误', err),
})
await defineTablesIfNeeded(db)
return db
})()
dbPromise = initDb().catch(err => {
// v0.6.0: 初始化失败不再静默 — 记录日志并提示用户(否则所有数据持久化静默失效)
logError('数据库初始化失败', err)
if (!initFailedNotified) {
initFailedNotified = true
try {
const code = (err as { code?: string })?.code ?? 'UNKNOWN'
const msg = err instanceof Error ? err.message : String(err)
setTimeout(() => {
MeToast?.error(`数据库初始化失败(${code}):${msg}`)
}, 0)
} catch {
/* 提示失败不影响主流程 */
}
}
throw err
})
// 初始化失败时允许下次重试
dbPromise.catch(() => {
dbPromise = null
+7 -8
View File
@@ -7,18 +7,14 @@ export const settingsRepository = {
async load(): Promise<Settings> {
try {
const db = await getDb()
const rows = await db
.table('settings')
.select()
.where({ id: 'default' })
.execute()
const rows = await db.table('settings').select().where({ id: 'default' }).execute()
const record = rows[0] as SettingsRecord | undefined
if (record) {
return {
themeMode: record.themeMode ?? DEFAULT_SETTINGS.themeMode,
viewMode: record.viewMode ?? DEFAULT_SETTINGS.viewMode,
sidebarCollapsed: record.sidebarCollapsed ?? DEFAULT_SETTINGS.sidebarCollapsed,
sidebarWidth: record.sidebarWidth ?? DEFAULT_SETTINGS.sidebarWidth
sidebarWidth: record.sidebarWidth ?? DEFAULT_SETTINGS.sidebarWidth,
}
}
} catch (error) {
@@ -37,12 +33,15 @@ export const settingsRepository = {
const tbl = db.table('settings') as Table<SettingsRecord>
const rows = await tbl.select().where({ id: 'default' }).execute()
if (rows.length > 0) {
await tbl.update({ ...merged }).where({ id: 'default' }).execute()
await tbl
.update({ ...merged })
.where({ id: 'default' })
.execute()
} else {
await tbl.insert(merged)
}
} catch (error) {
logError('保存设置失败', error)
}
}
},
}
+3 -11
View File
@@ -7,7 +7,7 @@ export const tabRepository = {
try {
const db = await getDb()
// v0.5.0: sqlark 事务 — 失败自动回滚(AriaEngine MVCC 快照隔离)
await db.transaction(async (trx) => {
await db.transaction(async trx => {
await trx.table('tabSnapshots').clear()
if (tabs.length > 0) {
await trx.table('tabSnapshots').insertMany(tabs)
@@ -21,11 +21,7 @@ export const tabRepository = {
async loadAll(): Promise<TabSnapshot[]> {
try {
const db = await getDb()
const rows = await db
.table('tabSnapshots')
.select()
.orderBy('updatedAt', 'asc')
.execute()
const rows = await db.table('tabSnapshots').select().orderBy('updatedAt', 'asc').execute()
return rows as TabSnapshot[]
} catch (error) {
logError('加载标签快照失败', error)
@@ -60,11 +56,7 @@ export const tabRepository = {
async loadActiveTabId(): Promise<string | null> {
try {
const db = await getDb()
const rows = await db
.table('activeTab')
.select()
.where({ id: 'current' })
.execute()
const rows = await db.table('activeTab').select().where({ id: 'current' }).execute()
return (rows[0] as ActiveTabRecord | undefined)?.activeTabId ?? null
} catch (error) {
logError('加载活动标签ID失败', error)
@@ -1,76 +0,0 @@
import { describe, it, expect } from 'vitest'
import { computeDocStats } from '../useDocStats'
describe('computeDocStats', () => {
it('should return zeros for undefined content', () => {
expect(computeDocStats(undefined)).toEqual({ words: 0, chars: 0, charsNoSpace: 0, lines: 0 })
})
it('should return zeros for null content', () => {
expect(computeDocStats(null)).toEqual({ words: 0, chars: 0, charsNoSpace: 0, lines: 0 })
})
it('should return zeros for empty string', () => {
expect(computeDocStats('')).toEqual({ words: 0, chars: 0, charsNoSpace: 0, lines: 0 })
})
it('should count single word', () => {
const result = computeDocStats('hello')
expect(result.words).toBe(1)
expect(result.chars).toBe(5)
expect(result.charsNoSpace).toBe(5)
expect(result.lines).toBe(1)
})
it('should count multiple words', () => {
const result = computeDocStats('hello world')
expect(result.words).toBe(2)
expect(result.chars).toBe(11) // 'hello world' = 11 chars
expect(result.charsNoSpace).toBe(10) // without the space
})
it('should count characters without spaces', () => {
const result = computeDocStats('a b c')
expect(result.chars).toBe(5)
expect(result.charsNoSpace).toBe(3)
})
it('should count lines', () => {
const result = computeDocStats('line1\nline2\nline3')
expect(result.lines).toBe(3)
expect(result.words).toBe(3)
})
it('should handle Windows line endings', () => {
const result = computeDocStats('line1\r\nline2\r\nline3')
expect(result.lines).toBe(3)
})
it('should handle trailing newline', () => {
const result = computeDocStats('hello\n')
expect(result.lines).toBe(2)
expect(result.words).toBe(1)
})
it('should count markdown content correctly', () => {
const md = '# Title\n\nThis is a **paragraph** with some *text*.\n\n- List item 1\n- List item 2\n'
const result = computeDocStats(md)
expect(result.words).toBe(17)
expect(result.lines).toBe(7)
expect(result.chars).toBe(md.length)
})
it('should handle whitespace-only content', () => {
const result = computeDocStats(' \n \n ')
expect(result.words).toBe(0)
expect(result.chars).toBe(9)
expect(result.charsNoSpace).toBe(0)
expect(result.lines).toBe(3)
})
it('should handle content with non-ASCII characters', () => {
const result = computeDocStats('中文测试 日本語 한국어')
expect(result.words).toBe(3)
expect(result.chars).toBe(12)
})
})
+79 -79
View File
@@ -1,79 +1,79 @@
import { useEffect, useState, useCallback } from 'react'
/**
* D4: 基于视口的活跃标题追踪 hook
* 在 preview 模式下监听目标容器的滚动事件,
* 根据文档中标题元素的 offsetTop 判断当前可见的标题索引。
*
* 仅在目标容器 ref 存在时生效(preview 面板挂载后)。
*
* @param containerRef - 包含 Markdown 渲染结果的 DOM 元素 ref
* @param headings - 解析出的标题列表
* @returns - 当前活跃标题的索引(null 表示无法判定或不在范围内)
*/
export function useActiveHeading(
containerRef: React.RefObject<HTMLElement | null>,
headings: { level: number; text: string }[]
): number | null {
const [activeIndex, setActiveIndex] = useState<number | null>(null)
const handleScroll = useCallback(() => {
const container = containerRef.current
if (!container || headings.length === 0) {
setActiveIndex(null)
return
}
// 收集容器内所有 h1-h6 元素的 offsetTop
const headingElements = Array.from(
container.querySelectorAll('h1, h2, h3, h4, h5, h6')
) as HTMLElement[]
if (headingElements.length === 0) {
setActiveIndex(null)
return
}
const scrollTop = container.scrollTop
const containerHeight = container.clientHeight
const threshold = scrollTop + containerHeight * 0.3 // 上方 30% 位置视为"到达"
let bestIndex: number | null = null
for (let i = 0; i < headingElements.length; i++) {
const el = headingElements[i]
// 使用容器顶部的相对偏移而非 getBoundingClientRect(滚动容器不是 window
const top = el.offsetTop - (container.offsetTop || 0)
if (top <= threshold) {
// 找到 headings 中匹配的索引
const text = el.textContent?.trim() ?? ''
const matchIdx = headings.findIndex(
h => h.text.trim() === text && el.tagName.slice(-1) === String(h.level)
)
if (matchIdx >= 0) bestIndex = matchIdx
}
}
setActiveIndex(bestIndex)
}, [containerRef, headings])
useEffect(() => {
const container = containerRef.current
if (!container || headings.length === 0) {
setActiveIndex(null)
return
}
// 监听滚动事件
container.addEventListener('scroll', handleScroll, { passive: true })
// 初始计算
handleScroll()
return () => {
container.removeEventListener('scroll', handleScroll)
}
}, [containerRef, headings, handleScroll])
return activeIndex
}
import { useEffect, useState, useCallback } from 'react'
/**
* D4: 基于视口的活跃标题追踪 hook
* 在 preview 模式下监听目标容器的滚动事件,
* 根据文档中标题元素的 offsetTop 判断当前可见的标题索引。
*
* 仅在目标容器 ref 存在时生效(preview 面板挂载后)。
*
* @param containerRef - 包含 Markdown 渲染结果的 DOM 元素 ref
* @param headings - 解析出的标题列表
* @returns - 当前活跃标题的索引(null 表示无法判定或不在范围内)
*/
export function useActiveHeading(
containerRef: React.RefObject<HTMLElement | null>,
headings: { level: number; text: string }[],
): number | null {
const [activeIndex, setActiveIndex] = useState<number | null>(null)
const handleScroll = useCallback(() => {
const container = containerRef.current
if (!container || headings.length === 0) {
setActiveIndex(null)
return
}
// 收集容器内所有 h1-h6 元素的 offsetTop
const headingElements = Array.from(
container.querySelectorAll('h1, h2, h3, h4, h5, h6'),
) as HTMLElement[]
if (headingElements.length === 0) {
setActiveIndex(null)
return
}
const scrollTop = container.scrollTop
const containerHeight = container.clientHeight
const threshold = scrollTop + containerHeight * 0.3 // 上方 30% 位置视为"到达"
let bestIndex: number | null = null
for (let i = 0; i < headingElements.length; i++) {
const el = headingElements[i]
// 使用容器顶部的相对偏移而非 getBoundingClientRect(滚动容器不是 window
const top = el.offsetTop - (container.offsetTop || 0)
if (top <= threshold) {
// 找到 headings 中匹配的索引
const text = el.textContent?.trim() ?? ''
const matchIdx = headings.findIndex(
h => h.text.trim() === text && el.tagName.slice(-1) === String(h.level),
)
if (matchIdx >= 0) bestIndex = matchIdx
}
}
setActiveIndex(bestIndex)
}, [containerRef, headings])
useEffect(() => {
const container = containerRef.current
if (!container || headings.length === 0) {
setActiveIndex(null)
return
}
// 监听滚动事件
container.addEventListener('scroll', handleScroll, { passive: true })
// 初始计算
handleScroll()
return () => {
container.removeEventListener('scroll', handleScroll)
}
}, [containerRef, headings, handleScroll])
return activeIndex
}
+120 -116
View File
@@ -1,116 +1,120 @@
import { useEffect, useRef, useState, useCallback } from 'react'
import { useTabStore } from '../stores/tabStore'
import { useAutoSaveStore } from '../stores/autoSaveStore'
import { logError } from '../lib/errorHandler'
/** Debounce delay for auto-save (ms) */
const AUTO_SAVE_DELAY = 2000
/** 模块级 ref — 状态栏等外部组件通过此函数切换自动保存 */
let _toggleAutoSave: (() => void) | null = null
export function toggleAutoSaveExternal(): void {
_toggleAutoSave?.()
}
/**
* Auto-save hook: subscribes to Zustand tab store, debounces content changes
* and saves automatically after a pause in editing for file-backed tabs.
*
* Uses `useTabStore.getState()` and `.subscribe()` so it doesn't need to
* re-render on every keystroke — the effect is triggered once and runs
* reactively via the store subscription.
*
* Captures the tabId at debounce start so the timeout always saves the
* correct tab even if the user switches tabs during the debounce window.
*/
export function useAutoSave(): { isAutoSaving: boolean; autoSaveEnabled: boolean; toggleAutoSave: () => void } {
const [isAutoSaving, setIsAutoSaving] = useState(false)
const [autoSaveEnabled, setAutoSaveEnabled] = useState(true)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const isSavingRef = useRef(false)
const enabledRef = useRef(true)
const mountedRef = useRef(true)
const toggleAutoSave = useCallback(() => {
setAutoSaveEnabled(prev => {
const next = !prev
enabledRef.current = next
if (!next && timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
return next
})
}, [])
// 暴露给外部(状态栏按钮)
_toggleAutoSave = toggleAutoSave
useEffect(() => {
mountedRef.current = true
// Subscribe to Zustand store — fires on every state change
const unsub = useTabStore.subscribe((state) => {
if (!enabledRef.current) return
const tab = state.getActiveTab()
if (!tab || !tab.filePath || !tab.isModified) return
// Capture the tabId so the timeout saves the correct tab
const tabIdToSave = tab.id
// Debounce: clear previous timer, start new one
if (timerRef.current) {
clearTimeout(timerRef.current)
}
timerRef.current = setTimeout(async () => {
if (isSavingRef.current) return
// Re-read latest state; verify the captured tab still exists and is modified
const currentState = useTabStore.getState()
const tabToSave = currentState.tabs.find(t => t.id === tabIdToSave)
if (!tabToSave || !tabToSave.filePath || !tabToSave.isModified) return
isSavingRef.current = true
if (mountedRef.current) setIsAutoSaving(true)
try {
if (!window.electronAPI) return
const result = await window.electronAPI.saveFile({
filePath: tabToSave.filePath,
content: tabToSave.content
})
if (result.success && mountedRef.current) {
currentState.setModified(tabToSave.id, false)
}
} catch (error) {
logError('自动保存失败', error)
} finally {
if (mountedRef.current) setIsAutoSaving(false)
isSavingRef.current = false
}
}, AUTO_SAVE_DELAY)
})
return () => {
mountedRef.current = false
unsub()
if (timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
}
}, [])
// 同步 autoSaveEnabled / isAutoSaving 到 autoSaveStore(供状态栏读取)
useEffect(() => {
useAutoSaveStore.getState().setState({ autoSaveEnabled })
}, [autoSaveEnabled])
useEffect(() => {
useAutoSaveStore.getState().setState({ isAutoSaving })
}, [isAutoSaving])
return { isAutoSaving, autoSaveEnabled, toggleAutoSave }
}
import { useEffect, useRef, useState, useCallback } from 'react'
import { useTabStore } from '../stores/tabStore'
import { useAutoSaveStore } from '../stores/autoSaveStore'
import { logError } from '../lib/errorHandler'
/** Debounce delay for auto-save (ms) */
const AUTO_SAVE_DELAY = 2000
/** 模块级 ref — 状态栏等外部组件通过此函数切换自动保存 */
let _toggleAutoSave: (() => void) | null = null
export function toggleAutoSaveExternal(): void {
_toggleAutoSave?.()
}
/**
* Auto-save hook: subscribes to Zustand tab store, debounces content changes
* and saves automatically after a pause in editing for file-backed tabs.
*
* Uses `useTabStore.getState()` and `.subscribe()` so it doesn't need to
* re-render on every keystroke — the effect is triggered once and runs
* reactively via the store subscription.
*
* Captures the tabId at debounce start so the timeout always saves the
* correct tab even if the user switches tabs during the debounce window.
*/
export function useAutoSave(): {
isAutoSaving: boolean
autoSaveEnabled: boolean
toggleAutoSave: () => void
} {
const [isAutoSaving, setIsAutoSaving] = useState(false)
const [autoSaveEnabled, setAutoSaveEnabled] = useState(true)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const isSavingRef = useRef(false)
const enabledRef = useRef(true)
const mountedRef = useRef(true)
const toggleAutoSave = useCallback(() => {
setAutoSaveEnabled(prev => {
const next = !prev
enabledRef.current = next
if (!next && timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
return next
})
}, [])
// 暴露给外部(状态栏按钮)
_toggleAutoSave = toggleAutoSave
useEffect(() => {
mountedRef.current = true
// Subscribe to Zustand store — fires on every state change
const unsub = useTabStore.subscribe(state => {
if (!enabledRef.current) return
const tab = state.getActiveTab()
if (!tab || !tab.filePath || !tab.isModified) return
// Capture the tabId so the timeout saves the correct tab
const tabIdToSave = tab.id
// Debounce: clear previous timer, start new one
if (timerRef.current) {
clearTimeout(timerRef.current)
}
timerRef.current = setTimeout(async () => {
if (isSavingRef.current) return
// Re-read latest state; verify the captured tab still exists and is modified
const currentState = useTabStore.getState()
const tabToSave = currentState.tabs.find(t => t.id === tabIdToSave)
if (!tabToSave || !tabToSave.filePath || !tabToSave.isModified) return
isSavingRef.current = true
if (mountedRef.current) setIsAutoSaving(true)
try {
if (!window.electronAPI) return
const result = await window.electronAPI.saveFile({
filePath: tabToSave.filePath,
content: tabToSave.content,
})
if (result.success && mountedRef.current) {
currentState.setModified(tabToSave.id, false)
}
} catch (error) {
logError('自动保存失败', error)
} finally {
if (mountedRef.current) setIsAutoSaving(false)
isSavingRef.current = false
}
}, AUTO_SAVE_DELAY)
})
return () => {
mountedRef.current = false
unsub()
if (timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
}
}, [])
// 同步 autoSaveEnabled / isAutoSaving 到 autoSaveStore(供状态栏读取)
useEffect(() => {
useAutoSaveStore.getState().setState({ autoSaveEnabled })
}, [autoSaveEnabled])
useEffect(() => {
useAutoSaveStore.getState().setState({ isAutoSaving })
}, [isAutoSaving])
return { isAutoSaving, autoSaveEnabled, toggleAutoSave }
}
-71
View File
@@ -1,71 +0,0 @@
import { useState, useCallback, useRef } from 'react'
interface ConfirmOptions {
title: string
message: string
confirmLabel?: string
cancelLabel?: string
variant?: 'danger' | 'warning' | 'info'
}
interface ConfirmState extends ConfirmOptions {
open: boolean
resolve: ((value: boolean) => void) | null
}
/**
* UX-01: Promise-based 确认对话框 hook
* 替代原生 confirm(),返回 Promise<boolean>
*/
export function useConfirm() {
const [state, setState] = useState<ConfirmState>({
open: false,
title: '',
message: '',
resolve: null
})
// 使用 ref 确保回调中能拿到最新的 resolve
const resolveRef = useRef<((value: boolean) => void) | null>(null)
const confirm = useCallback((options: ConfirmOptions): Promise<boolean> => {
return new Promise<boolean>((resolve) => {
resolveRef.current = resolve
setState({
open: true,
title: options.title,
message: options.message,
confirmLabel: options.confirmLabel,
cancelLabel: options.cancelLabel,
variant: options.variant,
resolve
})
})
}, [])
const handleConfirm = useCallback(() => {
resolveRef.current?.(true)
setState(prev => ({ ...prev, open: false, resolve: null }))
resolveRef.current = null
}, [])
const handleCancel = useCallback(() => {
resolveRef.current?.(false)
setState(prev => ({ ...prev, open: false, resolve: null }))
resolveRef.current = null
}, [])
return {
confirm,
confirmDialogProps: {
open: state.open,
title: state.title,
message: state.message,
confirmLabel: state.confirmLabel,
cancelLabel: state.cancelLabel,
variant: state.variant,
onConfirm: handleConfirm,
onCancel: handleCancel
}
}
}
-39
View File
@@ -1,39 +0,0 @@
import { useMemo } from 'react'
export interface DocStats {
/** 单词数(按空白字符分割) */
words: number
/** 总字符数(含空白字符) */
chars: number
/** 字符数(不含空白字符) */
charsNoSpace: number
/** 行数 */
lines: number
}
/**
* 计算文档统计信息:单词数、字符数(含/不含空格)、行数。
* 对空文档或 undefined 返回全零值。
*/
export function computeDocStats(content: string | undefined | null): DocStats {
if (!content) {
return { words: 0, chars: 0, charsNoSpace: 0, lines: 0 }
}
const chars = content.length
const charsNoSpace = content.replace(/\s/g, '').length
const words = content.trim()
? content.trim().split(/\s+/).length
: 0
const lines = content === '' ? 0 : content.split(/\r?\n/).length
return { words, chars, charsNoSpace, lines }
}
/**
* Hook:根据文档内容实时计算统计信息。
* 使用 useMemo 避免不必要的重新计算。
*/
export function useDocStats(content: string | undefined | null): DocStats {
return useMemo(() => computeDocStats(content), [content])
}
+77 -74
View File
@@ -1,74 +1,77 @@
import { useEffect, useCallback } from 'react'
import { useTabStore } from '../stores/tabStore'
import { isAllowedFile } from '../lib/fileUtils'
import { MAX_FILE_SIZE } from '../lib/constants'
import { logError } from '../lib/errorHandler'
import { showToast } from '../lib/toast'
export function useDragDrop() {
const createTab = useTabStore(s => s.createTab)
const handleDrop = useCallback(async (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
const files = e.dataTransfer?.files
if (!files) return
let rejected = 0
for (const file of Array.from(files)) {
// Electron adds a `path` property to File objects
const electronFile = file as File & { path?: string }
const filePath: string = electronFile.path || file.name
if (!isAllowedFile(filePath)) {
rejected++
continue
}
if (file.size > MAX_FILE_SIZE) {
showToast(`"${file.name}" 过大,暂不支持超过 20MB 的文件`)
continue
}
if (window.electronAPI) {
try {
const result = await window.electronAPI.readFile(filePath)
if (result.success && result.content !== undefined) {
createTab(filePath, result.content)
}
} catch (error) {
logError('拖拽读取文件失败', error)
}
} else {
const reader = new FileReader()
reader.onload = (ev: ProgressEvent<FileReader>) => {
const content = ev.target?.result as string
createTab(file.name, content)
}
reader.readAsText(file)
}
}
if (rejected > 0) {
showToast(`仅支持 .md / .markdown / .txt 文件,已忽略 ${rejected} 个文件`)
}
}, [createTab])
useEffect(() => {
const prevent = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
}
document.addEventListener('dragenter', prevent)
document.addEventListener('dragleave', prevent)
document.addEventListener('dragover', prevent)
document.addEventListener('drop', handleDrop)
return () => {
document.removeEventListener('dragenter', prevent)
document.removeEventListener('dragleave', prevent)
document.removeEventListener('dragover', prevent)
document.removeEventListener('drop', handleDrop)
}
}, [handleDrop])
}
import { useEffect, useCallback } from 'react'
import { useTabStore } from '../stores/tabStore'
import { isAllowedFile } from '../lib/fileUtils'
import { MAX_FILE_SIZE } from '../lib/constants'
import { logError } from '../lib/errorHandler'
import { showToast } from '../lib/toast'
export function useDragDrop() {
const createTab = useTabStore(s => s.createTab)
const handleDrop = useCallback(
async (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
const files = e.dataTransfer?.files
if (!files) return
let rejected = 0
for (const file of Array.from(files)) {
// Electron adds a `path` property to File objects
const electronFile = file as File & { path?: string }
const filePath: string = electronFile.path || file.name
if (!isAllowedFile(filePath)) {
rejected++
continue
}
if (file.size > MAX_FILE_SIZE) {
showToast(`"${file.name}" 过大,暂不支持超过 20MB 的文件`)
continue
}
if (window.electronAPI) {
try {
const result = await window.electronAPI.readFile(filePath)
if (result.success && result.content !== undefined) {
createTab(filePath, result.content)
}
} catch (error) {
logError('拖拽读取文件失败', error)
}
} else {
const reader = new FileReader()
reader.onload = (ev: ProgressEvent<FileReader>) => {
const content = ev.target?.result as string
createTab(file.name, content)
}
reader.readAsText(file)
}
}
if (rejected > 0) {
showToast(`仅支持 .md / .markdown / .txt 文件,已忽略 ${rejected} 个文件`)
}
},
[createTab],
)
useEffect(() => {
const prevent = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
}
document.addEventListener('dragenter', prevent)
document.addEventListener('dragleave', prevent)
document.addEventListener('dragover', prevent)
document.addEventListener('drop', handleDrop)
return () => {
document.removeEventListener('dragenter', prevent)
document.removeEventListener('dragleave', prevent)
document.removeEventListener('dragover', prevent)
document.removeEventListener('drop', handleDrop)
}
}, [handleDrop])
}
+120 -94
View File
@@ -1,94 +1,120 @@
import { useCallback, useRef } from 'react'
import { useTabStore } from '../stores/tabStore'
import { useEditorStore } from '../stores/editorStore'
import { recentFilesRepository } from '../db/recentFilesRepository'
import { logError } from '../lib/errorHandler'
import { showToast } from '../lib/toast'
/**
* AR-01: 从 App.tsx 提取的文件操作逻辑
* UX-02: 添加 loading 状态指示
* v0.1.9: handleSave 添加防重入锁,避免编辑器 onSave + 全局 Ctrl+S 双重触发
*/
export function useFileOperations() {
const createTab = useTabStore(s => s.createTab)
const getActiveTab = useTabStore(s => s.getActiveTab)
const setLoading = useEditorStore(s => s.setLoading)
// 防重入:onSave 回调 + 全局 Ctrl+S handler 可能在 300ms 内双重触发
const savingGate = useRef(false)
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, setLoading])
const handleSave = useCallback(async (): Promise<void> => {
// 防重入:300ms 内忽略重复调用(编辑器 onSave + 全局 Ctrl+S 双重触发)
if (savingGate.current) return
savingGate.current = true
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')
} finally {
// 300ms 后释放锁,允许下次保存
setTimeout(() => { savingGate.current = false }, 300)
}
}, [getActiveTab])
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])
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 && result.content !== undefined) {
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, useRef } from 'react'
import { useTabStore } from '../stores/tabStore'
import { useEditorStore } from '../stores/editorStore'
import { recentFilesRepository } from '../db/recentFilesRepository'
import { logError } from '../lib/errorHandler'
import { MeToast, showToast } from '../lib/toast'
/**
* AR-01: 从 App.tsx 提取的文件操作逻辑
* UX-02: 添加 loading 状态指示
* v0.1.9: handleSave 添加防重入锁,避免编辑器 onSave + 全局 Ctrl+S 双重触发
* v0.6.0: loading 改用 MeToast.loading 链式转换,保存结果用 MeToast.promise 提示
*/
export function useFileOperations() {
const createTab = useTabStore(s => s.createTab)
const getActiveTab = useTabStore(s => s.getActiveTab)
const setLoading = useEditorStore(s => s.setLoading)
// 防重入:onSave 回调 + 全局 Ctrl+S handler 可能在 300ms 内双重触发
const savingGate = useRef(false)
const handleOpenFile = useCallback(async (): Promise<void> => {
if (!window.electronAPI) return
const loading = MeToast.loading('打开文件...')
try {
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)
loading.success('文件已打开')
} else {
loading.dismiss()
}
} catch (error) {
logError('打开文件失败', error)
loading.error('打开文件失败')
} finally {
setLoading('file-open', false)
}
}, [createTab, setLoading])
const handleSave = useCallback(async (): Promise<void> => {
// 防重入:300ms 内忽略重复调用(编辑器 onSave + 全局 Ctrl+S 双重触发)
if (savingGate.current) return
savingGate.current = true
try {
const tab = getActiveTab()
if (!tab || !window.electronAPI) return
// v0.6.0: promise 监听保存生命周期 — 自动 loading → success/error,返回原 Promise
const result = await MeToast.promise(
window.electronAPI.saveFile({
filePath: tab.filePath,
content: tab.content,
}),
{
loading: '保存中...',
success: '已保存',
error: '保存失败',
},
)
if (result.success) {
useTabStore.getState().setModified(tab.id, false)
} else {
// IPC 返回 success:false 不 rejectpromise 的 error 文案不会触发,需显式提示
showToast('保存失败', 'error')
}
} catch (error) {
logError('保存文件失败', error)
} finally {
// 300ms 后释放锁,允许下次保存
setTimeout(() => {
savingGate.current = false
}, 300)
}
}, [getActiveTab])
const handleSaveAs = useCallback(async (): Promise<void> => {
try {
const tab = getActiveTab()
if (!tab || !window.electronAPI) return
const result = await MeToast.promise(
window.electronAPI.saveFileAs({ content: tab.content }),
{
loading: '另存为...',
success: '已保存',
error: '另存为失败',
},
)
if (result.success) {
useTabStore.getState().setModified(tab.id, false)
} else if (!result.canceled) {
showToast('另存为失败', 'error')
}
} catch (error) {
logError('另存为失败', error)
}
}, [getActiveTab])
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 && result.content !== undefined) {
createTab(filePath, result.content)
recentFilesRepository.add(filePath)
setTimeout(() => useTabStore.getState().saveToDB(), 100)
}
} finally {
setLoading('file-open', false)
}
},
[createTab, setLoading],
)
return { handleOpenFile, handleSave, handleSaveAs, handleOpenRecent }
}
+52 -52
View File
@@ -1,52 +1,52 @@
import { useEffect, useCallback } from 'react'
import { useSidebarStore } from '../stores/sidebarStore'
import { useEditorStore } from '../stores/editorStore'
/**
* AR-02: 从 Sidebar.tsx 提取的文件夹操作逻辑
* UX-02: 添加目录加载 loading 状态
*/
export function useFolderOperations() {
const rootPath = useSidebarStore(s => s.rootPath)
const setRootPath = useSidebarStore(s => s.setRootPath)
const setTree = useSidebarStore(s => s.setTree)
const expandDirs = useSidebarStore(s => s.expandDirs)
const setLoading = useEditorStore(s => s.setLoading)
const handleOpenFolder = useCallback(async () => {
if (!window.electronAPI) return
const dirPath = await window.electronAPI.openFolderDialog()
if (!dirPath) return
setRootPath(dirPath)
expandDirs([dirPath])
setLoading('dir-load', true)
try {
const dirTree = await window.electronAPI.readDirTree(dirPath)
if (dirTree.success && dirTree.tree) {
setTree(dirTree.tree)
window.electronAPI.watchDir(dirPath)
}
} finally {
setLoading('dir-load', false)
}
}, [setRootPath, setTree, expandDirs, setLoading])
const refreshTree = useCallback(async () => {
if (!rootPath || !window.electronAPI) return
setLoading('dir-load', true)
try {
const result = await window.electronAPI.readDirTree(rootPath)
if (result.success && result.tree) setTree(result.tree)
} finally {
setLoading('dir-load', false)
}
}, [rootPath, setTree, setLoading])
useEffect(() => {
if (!window.electronAPI) return
const unsubscribe = window.electronAPI.onDirChanged(() => refreshTree())
return unsubscribe
}, [refreshTree])
return { handleOpenFolder }
}
import { useEffect, useCallback } from 'react'
import { useSidebarStore } from '../stores/sidebarStore'
import { useEditorStore } from '../stores/editorStore'
/**
* AR-02: 从 Sidebar.tsx 提取的文件夹操作逻辑
* UX-02: 添加目录加载 loading 状态
*/
export function useFolderOperations() {
const rootPath = useSidebarStore(s => s.rootPath)
const setRootPath = useSidebarStore(s => s.setRootPath)
const setTree = useSidebarStore(s => s.setTree)
const expandDirs = useSidebarStore(s => s.expandDirs)
const setLoading = useEditorStore(s => s.setLoading)
const handleOpenFolder = useCallback(async () => {
if (!window.electronAPI) return
const dirPath = await window.electronAPI.openFolderDialog()
if (!dirPath) return
setRootPath(dirPath)
expandDirs([dirPath])
setLoading('dir-load', true)
try {
const dirTree = await window.electronAPI.readDirTree(dirPath)
if (dirTree.success && dirTree.tree) {
setTree(dirTree.tree)
window.electronAPI.watchDir(dirPath)
}
} finally {
setLoading('dir-load', false)
}
}, [setRootPath, setTree, expandDirs, setLoading])
const refreshTree = useCallback(async () => {
if (!rootPath || !window.electronAPI) return
setLoading('dir-load', true)
try {
const result = await window.electronAPI.readDirTree(rootPath)
if (result.success && result.tree) setTree(result.tree)
} finally {
setLoading('dir-load', false)
}
}, [rootPath, setTree, setLoading])
useEffect(() => {
if (!window.electronAPI) return
const unsubscribe = window.electronAPI.onDirChanged(() => refreshTree())
return unsubscribe
}, [refreshTree])
return { handleOpenFolder }
}
+22 -22
View File
@@ -1,22 +1,22 @@
import { useEffect } from 'react'
import { useTabStore } from '../stores/tabStore'
import { recentFilesRepository } from '../db/recentFilesRepository'
/**
* 主进程事件注册 hook
* 处理通过命令行或文件关联打开的文件
*/
export function useIpcListeners() {
const createTab = useTabStore(s => s.createTab)
useEffect(() => {
if (!window.electronAPI) return
const api = window.electronAPI
const onOpen = (data: { filePath: string; content: string }) => {
createTab(data.filePath, data.content)
if (data.filePath) recentFilesRepository.add(data.filePath)
setTimeout(() => useTabStore.getState().saveToDB(), 100)
}
return api.onFileOpenInTab(onOpen)
}, [createTab])
}
import { useEffect } from 'react'
import { useTabStore } from '../stores/tabStore'
import { recentFilesRepository } from '../db/recentFilesRepository'
/**
* 主进程事件注册 hook
* 处理通过命令行或文件关联打开的文件
*/
export function useIpcListeners() {
const createTab = useTabStore(s => s.createTab)
useEffect(() => {
if (!window.electronAPI) return
const api = window.electronAPI
const onOpen = (data: { filePath: string; content: string }) => {
createTab(data.filePath, data.content)
if (data.filePath) recentFilesRepository.add(data.filePath)
setTimeout(() => useTabStore.getState().saveToDB(), 100)
}
return api.onFileOpenInTab(onOpen)
}, [createTab])
}
+74 -53
View File
@@ -1,53 +1,74 @@
import { useEffect, useCallback } from 'react'
import { useTabStore } from '../stores/tabStore'
/**
* 全局键盘快捷键 hook。
* MetonaEditor 内置工具栏处理格式化和模式切换(Ctrl+B/I/1/2/3),
* v0.1.9 onSave 回调处理编辑器聚焦时的 Ctrl+S,
* 全局 handler 作为焦点外兜底(工具栏/侧边栏聚焦时仍可保存)。
*/
export function useKeyboard(handleOpenFile: () => void, handleSave: () => void, handleSaveAs: () => void) {
const handleKeydown = useCallback((e: KeyboardEvent) => {
const isCtrl = e.ctrlKey || e.metaKey
if (isCtrl && e.key === 'o') { e.preventDefault(); handleOpenFile(); return }
// 全局兜底:编辑器未聚焦时仍可保存(编辑器聚焦时由 onSave 回调处理)
if (isCtrl && e.key === 's' && !e.shiftKey) { e.preventDefault(); handleSave(); return }
if (isCtrl && e.shiftKey && e.key === 'S') { e.preventDefault(); handleSaveAs(); return }
const tabState = useTabStore.getState()
if (isCtrl && e.key === 't') { e.preventDefault(); tabState.createTab(null, ''); return }
if (isCtrl && e.key === 'w') {
e.preventDefault()
if (tabState.activeTabId) tabState.closeTab(tabState.activeTabId)
return
}
// Ctrl+Tab / Ctrl+Shift+Tab — MRU 顺序切换
if (isCtrl && e.key === 'Tab') {
e.preventDefault()
const { tabs, activeTabId, mruStack } = tabState
if (tabs.length > 1) {
if (mruStack.length > 0) {
const targetId = mruStack[0]
if (tabs.find(t => t.id === targetId)) {
tabState.switchToTab(targetId)
return
}
}
const idx = tabs.findIndex(t => t.id === activeTabId)
const next = e.shiftKey
? (idx - 1 + tabs.length) % tabs.length
: (idx + 1) % tabs.length
tabState.switchToTab(tabs[next].id)
}
return
}
}, [handleOpenFile, handleSave, handleSaveAs])
useEffect(() => {
document.addEventListener('keydown', handleKeydown)
return () => document.removeEventListener('keydown', handleKeydown)
}, [handleKeydown])
}
import { useEffect, useCallback } from 'react'
import { useTabStore } from '../stores/tabStore'
/**
* 全局键盘快捷键 hook。
* MetonaEditor 内置工具栏处理格式化和模式切换(Ctrl+B/I/1/2/3),
* v0.1.9 onSave 回调处理编辑器聚焦时的 Ctrl+S,
* 全局 handler 作为焦点外兜底(工具栏/侧边栏聚焦时仍可保存)。
*/
export function useKeyboard(
handleOpenFile: () => void,
handleSave: () => void,
handleSaveAs: () => void,
) {
const handleKeydown = useCallback(
(e: KeyboardEvent) => {
const isCtrl = e.ctrlKey || e.metaKey
if (isCtrl && e.key === 'o') {
e.preventDefault()
handleOpenFile()
return
}
// 全局兜底:编辑器未聚焦时仍可保存(编辑器聚焦时由 onSave 回调处理)
if (isCtrl && e.key === 's' && !e.shiftKey) {
e.preventDefault()
handleSave()
return
}
if (isCtrl && e.shiftKey && e.key === 'S') {
e.preventDefault()
handleSaveAs()
return
}
const tabState = useTabStore.getState()
if (isCtrl && e.key === 't') {
e.preventDefault()
tabState.createTab(null, '')
return
}
if (isCtrl && e.key === 'w') {
e.preventDefault()
if (tabState.activeTabId) tabState.closeTab(tabState.activeTabId)
return
}
// Ctrl+Tab / Ctrl+Shift+Tab — MRU 顺序切换
if (isCtrl && e.key === 'Tab') {
e.preventDefault()
const { tabs, activeTabId, mruStack } = tabState
if (tabs.length > 1) {
if (mruStack.length > 0) {
const targetId = mruStack[0]
if (tabs.find(t => t.id === targetId)) {
tabState.switchToTab(targetId)
return
}
}
const idx = tabs.findIndex(t => t.id === activeTabId)
const next = e.shiftKey ? (idx - 1 + tabs.length) % tabs.length : (idx + 1) % tabs.length
tabState.switchToTab(tabs[next].id)
}
return
}
},
[handleOpenFile, handleSave, handleSaveAs],
)
useEffect(() => {
document.addEventListener('keydown', handleKeydown)
return () => document.removeEventListener('keydown', handleKeydown)
}, [handleKeydown])
}
+48 -47
View File
@@ -1,47 +1,48 @@
import { useEffect, useRef } from 'react'
import { useEditorStore } from '../stores/editorStore'
import { useSidebarStore } from '../stores/sidebarStore'
import { settingsRepository } from '../db/settingsRepository'
import { logError } from '../lib/errorHandler'
/**
* AR-04: 统一设置加载 hook
* 一次性从 MetonaSqlark 加载所有设置,分发到各 store,
* 替代各 hook 各自独立加载设置的模式。
*/
export function useSettingsInit() {
const setThemeMode = useEditorStore(s => s.setThemeMode)
const setViewMode = useEditorStore(s => s.setViewMode)
const isInitialized = useRef(false)
useEffect(() => {
if (isInitialized.current) return
isInitialized.current = true
settingsRepository.load()
.then((settings) => {
// 主题:优先使用保存的设置,否则跟随系统偏好
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
const themeMode = settings.themeMode ?? (prefersDark ? 'dark' : 'light')
setThemeMode(themeMode)
// 视图模式
setViewMode(settings.viewMode ?? 'editor')
// Sidebar 设置(直接分发,避免 sidebarStore 再次读取 IndexedDB
const sidebarStore = useSidebarStore.getState()
if (!sidebarStore._loaded) {
useSidebarStore.setState({
isVisible: !settings.sidebarCollapsed,
sidebarWidth: settings.sidebarWidth,
_loaded: true
})
}
})
.catch((error: unknown) => {
logError('加载设置失败', error)
})
}, [setThemeMode, setViewMode])
return { isInitialized }
}
import { useEffect, useRef } from 'react'
import { useEditorStore } from '../stores/editorStore'
import { useSidebarStore } from '../stores/sidebarStore'
import { settingsRepository } from '../db/settingsRepository'
import { logError } from '../lib/errorHandler'
/**
* AR-04: 统一设置加载 hook
* 一次性从 MetonaSqlark 加载所有设置,分发到各 store,
* 替代各 hook 各自独立加载设置的模式。
*/
export function useSettingsInit() {
const setThemeMode = useEditorStore(s => s.setThemeMode)
const setViewMode = useEditorStore(s => s.setViewMode)
const isInitialized = useRef(false)
useEffect(() => {
if (isInitialized.current) return
isInitialized.current = true
settingsRepository
.load()
.then(settings => {
// 主题:优先使用保存的设置,否则跟随系统偏好
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
const themeMode = settings.themeMode ?? (prefersDark ? 'dark' : 'light')
setThemeMode(themeMode)
// 视图模式
setViewMode(settings.viewMode ?? 'editor')
// Sidebar 设置(直接分发,避免 sidebarStore 再次读取 IndexedDB
const sidebarStore = useSidebarStore.getState()
if (!sidebarStore._loaded) {
useSidebarStore.setState({
isVisible: !settings.sidebarCollapsed,
sidebarWidth: settings.sidebarWidth,
_loaded: true,
})
}
})
.catch((error: unknown) => {
logError('加载设置失败', error)
})
}, [setThemeMode, setViewMode])
return { isInitialized }
}
+45 -45
View File
@@ -1,45 +1,45 @@
import { useState, useRef, useEffect } from 'react'
import { useSidebarStore } from '../stores/sidebarStore'
/**
* AR-02: 从 Sidebar.tsx 提取的 resize 逻辑
*
* 拖拽过程中直接操作 DOM 宽度(避免每次 mousemove 触发 store 更新+重渲染),
* 拖拽结束时将最终宽度同步到 sidebarStore(持久化到 IndexedDB)。
*/
export function useSidebarResize() {
const [isResizing, setIsResizing] = useState(false)
const sidebarRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!isResizing) return
const handleMouseMove = (e: MouseEvent): void => {
if (sidebarRef.current) {
const newWidth = Math.max(180, Math.min(500, e.clientX))
sidebarRef.current.style.width = newWidth + 'px'
}
}
const handleMouseUp = (): void => {
// 拖拽结束时将最终宽度持久化到 store(→ IndexedDB
if (sidebarRef.current) {
const width = sidebarRef.current.offsetWidth
const clamped = Math.max(180, Math.min(500, width))
useSidebarStore.getState().setSidebarWidth(clamped)
}
setIsResizing(false)
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
return () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
}, [isResizing])
const startResize = (): void => setIsResizing(true)
return { sidebarRef, isResizing, startResize }
}
import { useState, useRef, useEffect } from 'react'
import { useSidebarStore } from '../stores/sidebarStore'
/**
* AR-02: 从 Sidebar.tsx 提取的 resize 逻辑
*
* 拖拽过程中直接操作 DOM 宽度(避免每次 mousemove 触发 store 更新+重渲染),
* 拖拽结束时将最终宽度同步到 sidebarStore(持久化到 IndexedDB)。
*/
export function useSidebarResize() {
const [isResizing, setIsResizing] = useState(false)
const sidebarRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!isResizing) return
const handleMouseMove = (e: MouseEvent): void => {
if (sidebarRef.current) {
const newWidth = Math.max(180, Math.min(500, e.clientX))
sidebarRef.current.style.width = newWidth + 'px'
}
}
const handleMouseUp = (): void => {
// 拖拽结束时将最终宽度持久化到 store(→ IndexedDB
if (sidebarRef.current) {
const width = sidebarRef.current.offsetWidth
const clamped = Math.max(180, Math.min(500, width))
useSidebarStore.getState().setSidebarWidth(clamped)
}
setIsResizing(false)
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
return () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
}, [isResizing])
const startResize = (): void => setIsResizing(true)
return { sidebarRef, isResizing, startResize }
}
+97 -91
View File
@@ -1,91 +1,97 @@
import { useEffect, useCallback } from 'react'
import MeEditor from '@metona-team/metona-editor'
import { useEditorStore } from '../stores/editorStore'
import { settingsRepository } from '../db/settingsRepository'
import type { ThemeMode } from '../types/settings'
/** 将 hex 颜色转为带 alpha 的版本,用于背景色 */
function hexWithAlpha(hex: string, alpha: number): string {
if (!hex || !hex.startsWith('#')) return hex
const a = Math.round(alpha * 255).toString(16).padStart(2, '0')
return hex.length === 7 ? hex + a : hex.slice(0, 7) + a
}
/** 将编辑器 CSS 变量同步到应用根元素,保持颜色一致 */
function syncAppColorsToEditor(): void {
try {
// v0.5.0: MeEditor.themes.exportCSSVars() 在 0.4.0 中仍保留(themeUtils 别名)
const vars = MeEditor.themes.exportCSSVars()
if (!vars || typeof vars !== 'object') return
const root = document.documentElement
const set = (name: string, value: string | undefined) => {
if (value) root.style.setProperty(name, value)
}
const accent = vars['--md-accent'] ?? '#1a73e8'
// 直接映射 — 文本/边框用纯色
set('--bg', vars['--md-bg'])
set('--text', vars['--md-text'])
set('--border', vars['--md-border'])
set('--primary', accent)
set('--primary-dark', accent)
set('--text-secondary', vars['--md-muted'])
set('--text-tertiary', vars['--md-muted'])
set('--code-bg', vars['--md-code-bg'])
// 背景派生 — 用 accent 的低透明度版本
const accentBg = hexWithAlpha(accent, 0.12)
set('--primary-light', accentBg)
set('--sidebar-active', accentBg)
set('--sidebar-bg', vars['--md-bg'])
set('--sidebar-border', vars['--md-border'])
set('--sidebar-hover', hexWithAlpha(vars['--md-border'] ?? '#e1e4e8', 0.4))
set('--bg-secondary', hexWithAlpha(vars['--md-text'] ?? '#333', 0.04))
set('--bg-tertiary', hexWithAlpha(vars['--md-text'] ?? '#333', 0.08))
set('--border-light', hexWithAlpha(vars['--md-border'] ?? '#e1e4e8', 0.5))
set('--search-bg', hexWithAlpha(vars['--md-text'] ?? '#333', 0.04))
set('--search-border', vars['--md-border'])
// 阴影根据主题适配
const isDark = vars['--md-bg'] && vars['--md-bg'] !== '#ffffff' && vars['--md-bg'] !== '#fff'
root.style.setProperty('--shadow', isDark
? '0 1px 3px rgba(0,0,0,0.3)'
: '0 1px 3px rgba(0,0,0,0.08)')
root.style.setProperty('--shadow-lg', isDark
? '0 4px 12px rgba(0,0,0,0.4)'
: '0 4px 12px rgba(0,0,0,0.1)')
} catch { /* 容错 */ }
}
const THEME_TO_TOAST: Record<ThemeMode, string> = {
light: 'light',
dark: 'dark',
warm: 'warm',
}
/**
* 主题 hook — 三主题循环(亮色 → 暗色 → 暖色),
* 应用颜色自动同步编辑器主题,保持一致。
*/
export function useTheme() {
const themeMode = useEditorStore(s => s.themeMode)
const cycleTheme = useEditorStore(s => s.cycleTheme)
const handleCycleTheme = useCallback(() => {
const next = cycleTheme()
// 同步编辑器全局主题
MeEditor.setTheme(next)
// 同步 Toast 主题
import('../lib/toast').then(({ MeToast }) => {
MeToast.themes.switchTheme(THEME_TO_TOAST[next] ?? 'auto')
})
// 持久化
settingsRepository.save({ themeMode: next })
}, [cycleTheme])
// themeMode 变化时同步应用颜色
useEffect(() => {
// 暗色/暖色统一加 .dark class(兼容 global.css 中的 :root.dark 硬编码规则)
document.documentElement.classList.toggle('dark', themeMode === 'dark' || themeMode === 'warm')
syncAppColorsToEditor()
}, [themeMode])
return { themeMode, cycleTheme: handleCycleTheme }
}
import { useEffect, useCallback } from 'react'
import MeEditor from '@metona-team/metona-editor'
import { useEditorStore } from '../stores/editorStore'
import { settingsRepository } from '../db/settingsRepository'
import type { ThemeMode } from '../types/settings'
/** 将 hex 颜色转为带 alpha 的版本,用于背景色 */
function hexWithAlpha(hex: string, alpha: number): string {
if (!hex || !hex.startsWith('#')) return hex
const a = Math.round(alpha * 255)
.toString(16)
.padStart(2, '0')
return hex.length === 7 ? hex + a : hex.slice(0, 7) + a
}
/** 将编辑器 CSS 变量同步到应用根元素,保持颜色一致 */
function syncAppColorsToEditor(): void {
try {
// v0.5.0: MeEditor.themes.exportCSSVars() 在 0.4.0 中仍保留(themeUtils 别名)
const vars = MeEditor.themes.exportCSSVars()
if (!vars || typeof vars !== 'object') return
const root = document.documentElement
const set = (name: string, value: string | undefined) => {
if (value) root.style.setProperty(name, value)
}
const accent = vars['--md-accent'] ?? '#1a73e8'
// 直接映射 — 文本/边框用纯色
set('--bg', vars['--md-bg'])
set('--text', vars['--md-text'])
set('--border', vars['--md-border'])
set('--primary', accent)
set('--primary-dark', accent)
set('--text-secondary', vars['--md-muted'])
set('--text-tertiary', vars['--md-muted'])
set('--code-bg', vars['--md-code-bg'])
// 背景派生 — 用 accent 的低透明度版本
const accentBg = hexWithAlpha(accent, 0.12)
set('--primary-light', accentBg)
set('--sidebar-active', accentBg)
set('--sidebar-bg', vars['--md-bg'])
set('--sidebar-border', vars['--md-border'])
set('--sidebar-hover', hexWithAlpha(vars['--md-border'] ?? '#e1e4e8', 0.4))
set('--bg-secondary', hexWithAlpha(vars['--md-text'] ?? '#333', 0.04))
set('--bg-tertiary', hexWithAlpha(vars['--md-text'] ?? '#333', 0.08))
set('--border-light', hexWithAlpha(vars['--md-border'] ?? '#e1e4e8', 0.5))
set('--search-bg', hexWithAlpha(vars['--md-text'] ?? '#333', 0.04))
set('--search-border', vars['--md-border'])
// 阴影根据主题适配
const isDark = vars['--md-bg'] && vars['--md-bg'] !== '#ffffff' && vars['--md-bg'] !== '#fff'
root.style.setProperty(
'--shadow',
isDark ? '0 1px 3px rgba(0,0,0,0.3)' : '0 1px 3px rgba(0,0,0,0.08)',
)
root.style.setProperty(
'--shadow-lg',
isDark ? '0 4px 12px rgba(0,0,0,0.4)' : '0 4px 12px rgba(0,0,0,0.1)',
)
} catch {
/* 容错 */
}
}
const THEME_TO_TOAST: Record<ThemeMode, string> = {
light: 'light',
dark: 'dark',
warm: 'warm',
}
/**
* 主题 hook — 三主题循环(亮色 → 暗色 → 暖色),
* 应用颜色自动同步编辑器主题,保持一致。
*/
export function useTheme() {
const themeMode = useEditorStore(s => s.themeMode)
const cycleTheme = useEditorStore(s => s.cycleTheme)
const handleCycleTheme = useCallback(() => {
const next = cycleTheme()
// 同步编辑器全局主题
MeEditor.setTheme(next)
// 同步 Toast 主题
import('../lib/toast').then(({ MeToast }) => {
MeToast.themes.switchTheme(THEME_TO_TOAST[next] ?? 'auto')
})
// 持久化
settingsRepository.save({ themeMode: next })
}, [cycleTheme])
// themeMode 变化时同步应用颜色
useEffect(() => {
// 暗色/暖色统一加 .dark class(兼容 global.css 中的 :root.dark 硬编码规则)
document.documentElement.classList.toggle('dark', themeMode === 'dark' || themeMode === 'warm')
syncAppColorsToEditor()
}, [themeMode])
return { themeMode, cycleTheme: handleCycleTheme }
}
+57 -57
View File
@@ -1,57 +1,57 @@
import { useEffect, useCallback, useRef } from 'react'
/**
* 未保存提醒 hook
* UX-01: 接受外部 confirm 函数,替代原生 confirm()
*/
export function useUnsavedWarning(
hasUnsaved: () => boolean,
confirmFn?: (message: string) => Promise<boolean>,
// D5: 关闭前回调(flush 待保存数据)
onBeforeForceClose?: () => Promise<void>
) {
const confirmFnRef = useRef(confirmFn)
confirmFnRef.current = confirmFn
const onBeforeForceCloseRef = useRef(onBeforeForceClose)
onBeforeForceCloseRef.current = onBeforeForceClose
const doConfirm = useCallback(async (message: string): Promise<boolean> => {
if (confirmFnRef.current) {
return confirmFnRef.current(message)
}
// 后备方案:如果没有提供 confirmFn,使用 beforeunload 行为
return true
}, [])
useEffect(() => {
if (!window.electronAPI) {
const handler = (e: BeforeUnloadEvent) => {
if (hasUnsaved()) {
e.preventDefault()
e.returnValue = ''
}
}
window.addEventListener('beforeunload', handler)
return () => window.removeEventListener('beforeunload', handler)
}
const api = window.electronAPI
const unsubscribe = api.onConfirmClose(async () => {
if (!hasUnsaved()) {
await onBeforeForceCloseRef.current?.()
api.forceClose()
return
}
const shouldClose = await doConfirm('有文件尚未保存,确定要关闭吗?')
if (shouldClose) {
await onBeforeForceCloseRef.current?.()
api.forceClose()
} else {
api.cancelClose()
}
})
return unsubscribe
}, [hasUnsaved, doConfirm])
}
import { useEffect, useCallback, useRef } from 'react'
/**
* 未保存提醒 hook
* UX-01: 接受外部 confirm 函数,替代原生 confirm()
*/
export function useUnsavedWarning(
hasUnsaved: () => boolean,
confirmFn?: (message: string) => Promise<boolean>,
// D5: 关闭前回调(flush 待保存数据)
onBeforeForceClose?: () => Promise<void>,
) {
const confirmFnRef = useRef(confirmFn)
confirmFnRef.current = confirmFn
const onBeforeForceCloseRef = useRef(onBeforeForceClose)
onBeforeForceCloseRef.current = onBeforeForceClose
const doConfirm = useCallback(async (message: string): Promise<boolean> => {
if (confirmFnRef.current) {
return confirmFnRef.current(message)
}
// 后备方案:如果没有提供 confirmFn,使用 beforeunload 行为
return true
}, [])
useEffect(() => {
if (!window.electronAPI) {
const handler = (e: BeforeUnloadEvent) => {
if (hasUnsaved()) {
e.preventDefault()
e.returnValue = ''
}
}
window.addEventListener('beforeunload', handler)
return () => window.removeEventListener('beforeunload', handler)
}
const api = window.electronAPI
const unsubscribe = api.onConfirmClose(async () => {
if (!hasUnsaved()) {
await onBeforeForceCloseRef.current?.()
api.forceClose()
return
}
const shouldClose = await doConfirm('有文件尚未保存,确定要关闭吗?')
if (shouldClose) {
await onBeforeForceCloseRef.current?.()
api.forceClose()
} else {
api.cancelClose()
}
})
return unsubscribe
}, [hasUnsaved, doConfirm])
}
+93 -19
View File
@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest'
import { renderMarkdown } from '../markdown'
import { renderMarkdown, renderMarkdownSync } from '../markdown'
describe('renderMarkdown', () => {
describe('renderMarkdown (MetonaEditor 内置解析器)', () => {
it('should render a simple heading', async () => {
const result = await renderMarkdown('# Hello World')
expect(result).toContain('<h1')
@@ -26,7 +26,7 @@ describe('renderMarkdown', () => {
expect(result).toContain('code')
})
it('should render code blocks with syntax highlighting', async () => {
it('should render code blocks', async () => {
const md = '```javascript\nconst x = 1;\n```'
const result = await renderMarkdown(md)
expect(result).toContain('<pre')
@@ -75,32 +75,106 @@ describe('renderMarkdown', () => {
expect(result).toContain('deleted')
})
it('should render task list', async () => {
const result = await renderMarkdown('- [x] done\n- [ ] todo')
expect(result).toContain('me-task-item')
expect(result).toContain('checked')
})
it('should render mermaid as .me-mermaid container', async () => {
const md = '```mermaid\ngraph TD\nA-->B\n```'
const result = await renderMarkdown(md)
expect(result).toContain('me-mermaid')
expect(result).toContain('class="mermaid"')
expect(result).toContain('graph TD')
})
it('should handle empty content', async () => {
const result = await renderMarkdown('')
expect(result).toBe('')
})
it('should return error HTML on rendering failure with invalid input', async () => {
// unified should still handle gracefully, but verify error path works
const result = await renderMarkdown('normal text')
expect(result).not.toContain('渲染错误')
it('should handle null and undefined filePath', async () => {
expect(await renderMarkdown('# No File', null)).toContain('No File')
expect(await renderMarkdown('# No File')).toContain('No File')
})
it('should cache processors for same filePath', async () => {
// Call twice with same filePath to test caching
const result1 = await renderMarkdown('# Test', '/test/file.md')
const result2 = await renderMarkdown('# Test 2', '/test/file.md')
expect(result1).toContain('<h1')
expect(result2).toContain('Test 2')
it('should be sync-callable via renderMarkdownSync', () => {
const result = renderMarkdownSync('# Sync')
expect(result).toContain('<h1')
})
})
it('should handle null filePath', async () => {
const result = await renderMarkdown('# No File', null)
expect(result).toContain('No File')
describe('fixImageSrcs — 相对路径图片修复', () => {
const FILE = '/home/user/docs/note.md'
it('should fix relative image paths to file://', async () => {
const result = await renderMarkdown('![img](./pic.png)', FILE)
expect(result).toContain('src="file:///home/user/docs/pic.png"')
})
it('should fix nested relative paths', async () => {
const result = await renderMarkdown('![img](assets/a.png)', FILE)
expect(result).toContain('src="file:///home/user/docs/assets/a.png"')
})
it('should fix unix absolute paths', async () => {
const result = await renderMarkdown('![img](/abs/pic.png)', FILE)
expect(result).toContain('src="file:///abs/pic.png"')
})
it('should not touch http/https/data:/file: URLs', async () => {
const md = '![a](https://x.com/a.png) ![b](data:image/png;base64,xx) ![c](file:///c.png)'
const result = await renderMarkdown(md, FILE)
expect(result).toContain('https://x.com/a.png')
expect(result).toContain('data:image/png;base64,xx')
expect(result).toContain('file:///c.png')
})
it('should skip out-of-directory traversal (../ and ../../)', async () => {
const md = '![a](../outside.png) ![b](../../outside.png)'
const result = await renderMarkdown(md, FILE)
// 越界路径保持原样(不注入 file://)
expect(result).not.toContain('file:')
expect(result).toContain('../outside.png')
expect(result).toContain('../../outside.png')
})
it('should not treat sibling directory with same prefix as inside', async () => {
// /home/user/docs-other/ 与 /home/user/docs/ 前缀相似但目录不同
const result = await renderMarkdown('![img](../docs-other/pic.png)', FILE)
expect(result).not.toContain('file:')
expect(result).toContain('../docs-other/pic.png')
})
it('should not touch images when filePath is null', async () => {
const result = await renderMarkdown('![img](./pic.png)')
expect(result).toContain('./pic.png')
expect(result).not.toContain('file:')
})
it('should fix relative image paths from root-level file', async () => {
const result = await renderMarkdown('![img](./pic.png)', '/note.md')
expect(result).toContain('src="file:///pic.png"')
})
it('should escape special chars in fixed path', async () => {
const result = await renderMarkdown('![img](./a&b.png)', FILE)
expect(result).toContain('src="file:///home/user/docs/a&amp;b.png"')
})
})
describe('XSS 防护(内置 safeUrl', () => {
it('should not emit anchor for javascript: URLs', async () => {
const result = await renderMarkdown('[x](javascript:alert(1))')
// 内置解析器将危险 URL 转义原样输出(不生成 <a>),文本不构成可执行链接
expect(result).not.toContain('<a')
expect(result).not.toContain('href=')
})
it('should handle undefined filePath', async () => {
const result = await renderMarkdown('# No File')
expect(result).toContain('No File')
it('should not emit img for javascript: image src', async () => {
const result = await renderMarkdown('![x](javascript:alert(1))')
expect(result).not.toContain('<img')
expect(result).not.toContain('src=')
})
})
+99 -198
View File
@@ -1,198 +1,99 @@
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 { visit } from 'unist-util-visit'
import type { Element, Root } from 'hast'
// 简单的路径解析(Electron renderer 中没有 path 模块)
// E1: 统一规范化 — 解析前先全部转为 /,避免混合分隔符导致的误判
function normPath(p: string): string {
return p.replace(/\\/g, '/').replace(/\/+$/, '')
}
function resolveRelativePath(base: string, rel: string): string {
const parts = normPath(base).split('/')
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('/')
}
// 自定义 rehype 插件:将相对路径图片转为 file:// 绝对路径
function rehypeFixImages(filePath: string | null): Plugin<[], Root> {
return () => (tree: Root) => {
if (!filePath) return
const dir: string = filePath.replace(/[/\\][^/\\]+$/, '')
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)
// E1: 统一规范化后比较,防止 ../ 路径越界
const normalizedBase = normPath(dir)
if (!normPath(resolvedPath).startsWith(normalizedBase)) return
child.properties = {
...child.properties,
src: 'file://' + (normPath(dir) + '/' + src).replace(/\/+/g, '/')
}
}
}
if (child.type === 'element') {
visit(child as Element)
}
}
}
visit(tree)
}
}
// 自定义 rehype 插件:将 mermaid 代码块转为 .me-mermaid 容器
// 在 rehypeHighlight 之后运行,此时 code 元素已有 language-mermaid 类名。
// 输出结构需与 MetonaEditor 内置解析器一致,以便 mermaid.run() 统一查询。
function rehypeMermaid(): Plugin<[], Root> {
return () => (tree: Root) => {
visit(tree, 'element', (node: Element, index, parent) => {
if (node.tagName !== 'pre') return
if (parent === null || parent === undefined || index === undefined) return
const code = node.children.find(
(child): child is Element => child.type === 'element' && child.tagName === 'code'
)
if (!code) return
const className: string[] = (code.properties?.className as string[] | undefined) ?? []
if (!className.some(c => c === 'language-mermaid' || c === 'mermaid')) return
// 提取代码文本内容
const text = (code.children ?? [])
.filter(c => c.type === 'text')
.map(c => (c as { type: 'text'; value: string }).value)
.join('')
// 替换为 mermaid 渲染容器(与 MetonaEditor 内置输出一致)
const mermaidContainer: Element = {
type: 'element',
tagName: 'div',
properties: { className: ['me-mermaid'] },
children: [{
type: 'element',
tagName: 'div',
properties: { className: ['mermaid'] },
children: [{ type: 'text', value: text }]
}]
}
parent.children[index] = mermaidContainer
})
}
}
// PF-01: Markdown处理器LRU缓存
const MAX_CACHE_SIZE = 20
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)
// CQ-09: rehypeFixImages 必须在 rehypeSanitize 之前运行,
// 以保证 file:// URL 接受 sanitize 协议检查而非绕过。
.use(rehypeFixImages(filePath ?? null))
.use(rehypeSanitize, {
...defaultSchema,
attributes: {
...defaultSchema.attributes,
// 允许 img 的 src 属性(fixImages 注入 file:// 后 sanitize 需要放行)
img: [...(defaultSchema.attributes?.img ?? []), ['src']],
},
protocols: {
...(defaultSchema.protocols ?? {}),
src: [
...((defaultSchema.protocols as Record<string, string[]> | undefined)?.src ?? []),
'file:',
],
},
// H-05: 显式 strip 事件处理器属性,纵深防御
strip: ['script', 'on*', 'javascript:'],
})
.use(rehypeHighlight)
.use(rehypeMermaid())
.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>`
}
}
/**
* 同步版本 — 供 MetonaEditor 的 render 钩子使用。
* 所有 unified 插件均为同步转换器,可以在 render 钩子中同步调用。
*/
export function renderMarkdownSync(content: string, filePath?: string | null): string {
try {
const processor = getCachedProcessor(filePath ?? null)
const result = processor.processSync(content)
return String(result)
} catch (e) {
const errorMsg: string = e instanceof Error ? e.message : String(e)
return `<p style="color:red">渲染错误: ${errorMsg}</p>`
}
}
import { parseMarkdown } from '@metona-team/metona-editor'
// v0.6.0: 渲染管线迁移至 MetonaEditor 内置解析器(parseMarkdown),
// 移除 unified/remark/rehype 自研管线。
// 内置解析器原生支持:GFM / 任务列表 / 脚注 / 数学公式 / 定义列表 / emoji /
// mermaid(输出 .me-mermaid 容器)/ XSS 防护(escapeHTML + safeUrl)。
// 简单的路径解析(Electron renderer 中没有 path 模块)
// E1: 统一规范化 — 解析前先全部转为 /,避免混合分隔符导致的误判
function normPath(p: string): string {
return p.replace(/\\/g, '/').replace(/\/+$/, '')
}
function resolveRelativePath(base: string, rel: string): string {
const parts = normPath(base).split('/')
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('/')
}
/** 属性值转义(与内置解析器 escapeAttr 行为一致) */
function escapeAttr(s: string): string {
return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
/**
* 图片路径修复 — HTML 后处理。
*
* v0.6.0: 内置解析器的 safeUrl 会过滤 file: 协议,且 sanitize 钩子不参与预览渲染,
* 因此相对路径图片的修复必须在 parseMarkdown 之后、输出到 DOM 之前完成。
* 内置解析器输出的 img 标签格式固定(src 属性在最前、属性值经 escapeAttr 转义),
* 用正则精确匹配 src 属性即可安全替换。
*
* CQ-09: 保持与原 rehypeFixImages 一致的安全策略 —
* 解析完整路径并检查是否越界到 markdown 文件所在目录之外。
*/
function fixImageSrcs(html: string, filePath: string | null): string {
if (!filePath) return html
const dir: string = filePath.replace(/[/\\][^/\\]+$/, '')
return html.replace(/<img\b[^>]*\bsrc="([^"]*)"/g, (full, src) => {
const raw = String(src).replace(/&amp;/g, '&') // 先还原转义,避免二次转义
if (!raw) return full
if (
raw.startsWith('http://') ||
raw.startsWith('https://') ||
raw.startsWith('data:') ||
raw.startsWith('file:')
) {
return full
}
// 处理 Unix 风格绝对路径(以 / 开头)
if (raw.startsWith('/')) {
return full.replace(`src="${src}"`, `src="${escapeAttr('file://' + raw)}"`)
}
// 解析完整路径并检查是否越界
// 注意: resolveRelativePath 的 base 语义是"文件路径"(内部 pop 掉文件名),
// 因此传入完整 filePath 而非 dir,否则会多弹掉一级目录
const resolvedPath = resolveRelativePath(filePath, raw)
const normalizedBase = normPath(dir)
// 用 base + '/' 前缀判断,避免 /home/docs-other/ 误判为在 /home/docs/ 内
const boundary = normalizedBase === '' ? '/' : normalizedBase + '/'
if (!normPath(resolvedPath).startsWith(boundary)) return full
const fixed = 'file://' + resolvedPath
return full.replace(`src="${src}"`, `src="${escapeAttr(fixed)}"`)
})
}
/**
* 渲染 Markdown — 使用 MetonaEditor 内置解析器。
*
* @param content Markdown 源码
* @param filePath 文件路径(用于修复相对路径图片;null 时不做图片修复)
*/
export function renderMarkdownSync(content: string, filePath?: string | null): string {
try {
const html = parseMarkdown(content)
return fixImageSrcs(html, filePath ?? null)
} catch (e) {
const errorMsg: string = e instanceof Error ? e.message : String(e)
return `<p style="color:red">渲染错误: ${errorMsg}</p>`
}
}
/**
* 异步版本 — 内置解析器为同步实现,此函数仅保持兼容签名。
*/
export async function renderMarkdown(content: string, filePath?: string | null): Promise<string> {
return renderMarkdownSync(content, filePath)
}
+56 -50
View File
@@ -1,50 +1,56 @@
import MeToast from '@metona-team/metona-toast'
/**
* MeToast 全局配置
*
* 替代原自研 Toast 组件(components/Toast),由 MeToast 接管全部通知渲染。
* MeToast 自管理 DOM 与样式,无需在 React 树中挂载容器组件
*
* v0.5.0: 升级 MeToast 0.5.0,安装 keyboard / accessibility 插件。
*/
MeToast.configure({
position: 'top-right',
duration: 3000,
max: 5,
theme: 'auto',
animation: 'slide',
pauseOnHover: true,
closeOnClick: true,
showProgress: true,
draggable: true,
locale: 'zh-CN',
width: 360,
})
// 安装内置插件
// eslint-disable-next-line react-hooks/rules-of-hooks -- MeToast.use() 是插件安装方法,非 React Hook
MeToast.use('keyboard') // ESC 关闭所有 Toast
// eslint-disable-next-line react-hooks/rules-of-hooks
MeToast.use('accessibility') // 屏幕阅读器实时朗读 Toast 内容
/** 与原 showToast 保持兼容的类型子集 */
export type ToastType = 'success' | 'error' | 'warning' | 'info'
/**
* 显示 Toast 通知 — 与原 useToast().showToast 签名兼容。
*
* @param msg 消息内容
* @param type 通知类型(默认 info
* @param duration 显示时长(ms),省略则用全局默认值
*/
export function showToast(msg: string, type: ToastType = 'info', duration?: number): void {
MeToast[type](msg, duration !== undefined ? { duration } : undefined)
}
/** 同步 MeToast 主题到当前暗色模式 */
export function syncToastTheme(darkMode: boolean): void {
MeToast.themes.switchTheme(darkMode ? 'dark' : 'light')
}
export { MeToast }
import MeToast from '@metona-team/metona-toast'
import { logError } from './errorHandler'
/**
* MeToast 全局配置
*
* 替代原自研 Toast 组件(components/Toast),由 MeToast 接管全部通知渲染
* MeToast 自管理 DOM 与样式,无需在 React 树中挂载容器组件。
*
* v0.5.0: 升级 MeToast 0.5.0,安装 keyboard / accessibility 插件。
* v0.6.0: 接入 dedupe 去重插件与 onError 全局错误回调。
*/
MeToast.configure({
position: 'top-right',
duration: 3000,
max: 5,
theme: 'auto',
animation: 'slide',
pauseOnHover: true,
closeOnClick: true,
showProgress: true,
draggable: true,
locale: 'zh-CN',
width: 360,
// v0.6.0: 钩子/定时器异常统一走应用错误日志
onError: ({ hook, error }) => logError(`Toast ${hook} 异常`, error),
})
// 安装内置插件
// eslint-disable-next-line react-hooks/rules-of-hooks -- MeToast.use() 是插件安装方法,非 React Hook
MeToast.use('keyboard') // ESC 关闭所有 Toast
// eslint-disable-next-line react-hooks/rules-of-hooks
MeToast.use('accessibility') // 屏幕阅读器实时朗读 Toast 内容
// eslint-disable-next-line react-hooks/rules-of-hooks
MeToast.use('dedupe') // 相同 type+message 自动去重(避免重复弹窗)
/** 与原 showToast 保持兼容的类型子集 */
export type ToastType = 'success' | 'error' | 'warning' | 'info'
/**
* 显示 Toast 通知 — 与原 useToast().showToast 签名兼容。
*
* @param msg 消息内容
* @param type 通知类型(默认 info)
* @param duration 显示时长(ms),省略则用全局默认值
*/
export function showToast(msg: string, type: ToastType = 'info', duration?: number): void {
MeToast[type](msg, duration !== undefined ? { duration } : undefined)
}
/** 同步 MeToast 主题到当前暗色模式 */
export function syncToastTheme(darkMode: boolean): void {
MeToast.themes.switchTheme(darkMode ? 'dark' : 'light')
}
export { MeToast }
+1 -1
View File
@@ -8,5 +8,5 @@ import './styles/markdown-body.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
</React.StrictMode>,
)
+139 -103
View File
@@ -1,103 +1,139 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { useEditorStore } from '../editorStore'
describe('editorStore', () => {
beforeEach(() => {
useEditorStore.setState({
viewMode: 'editor',
themeMode: 'light',
externallyModified: null,
loadingStates: {},
})
})
describe('setViewMode', () => {
it('should set view mode to preview', () => {
useEditorStore.getState().setViewMode('preview')
expect(useEditorStore.getState().viewMode).toBe('preview')
})
it('should set view mode to editor', () => {
useEditorStore.setState({ viewMode: 'preview' })
useEditorStore.getState().setViewMode('editor')
expect(useEditorStore.getState().viewMode).toBe('editor')
})
})
describe('themeMode', () => {
it('should default to light', () => {
expect(useEditorStore.getState().themeMode).toBe('light')
})
it('should set theme mode', () => {
useEditorStore.getState().setThemeMode('dark')
expect(useEditorStore.getState().themeMode).toBe('dark')
})
it('should set warm theme', () => {
useEditorStore.getState().setThemeMode('warm')
expect(useEditorStore.getState().themeMode).toBe('warm')
})
})
describe('cycleTheme', () => {
it('should cycle light → dark', () => {
expect(useEditorStore.getState().cycleTheme()).toBe('dark')
expect(useEditorStore.getState().themeMode).toBe('dark')
})
it('should cycle dark → warm', () => {
useEditorStore.setState({ themeMode: 'dark' })
expect(useEditorStore.getState().cycleTheme()).toBe('warm')
})
it('should cycle warm → light', () => {
useEditorStore.setState({ themeMode: 'warm' })
expect(useEditorStore.getState().cycleTheme()).toBe('light')
})
})
describe('setExternallyModified', () => {
it('should set externally modified info', () => {
useEditorStore.getState().setExternallyModified({ filePath: '/test.md' })
expect(useEditorStore.getState().externallyModified).toEqual({ filePath: '/test.md' })
})
it('should clear externally modified info', () => {
useEditorStore.setState({ externallyModified: { filePath: '/test.md' } })
useEditorStore.getState().setExternallyModified(null)
expect(useEditorStore.getState().externallyModified).toBeNull()
})
})
describe('loading states', () => {
it('should set loading state', () => {
useEditorStore.getState().setLoading('file-open', true)
expect(useEditorStore.getState().isLoading('file-open')).toBe(true)
})
it('should return false for unset loading key', () => {
expect(useEditorStore.getState().isLoading('non-existent')).toBe(false)
})
it('should update loading state to false', () => {
useEditorStore.getState().setLoading('file-open', true)
useEditorStore.getState().setLoading('file-open', false)
expect(useEditorStore.getState().isLoading('file-open')).toBe(false)
})
it('should handle multiple independent loading states', () => {
const { setLoading } = useEditorStore.getState()
setLoading('file-open', true)
setLoading('markdown-render', true)
const state = useEditorStore.getState()
expect(state.isLoading('file-open')).toBe(true)
expect(state.isLoading('markdown-render')).toBe(true)
setLoading('file-open', false)
expect(useEditorStore.getState().isLoading('file-open')).toBe(false)
expect(useEditorStore.getState().isLoading('markdown-render')).toBe(true)
})
})
})
import { describe, it, expect, beforeEach } from 'vitest'
import { useEditorStore } from '../editorStore'
describe('editorStore', () => {
beforeEach(() => {
useEditorStore.setState({
viewMode: 'editor',
themeMode: 'light',
externallyModified: null,
loadingStates: {},
})
})
describe('setViewMode', () => {
it('should set view mode to preview', () => {
useEditorStore.getState().setViewMode('preview')
expect(useEditorStore.getState().viewMode).toBe('preview')
})
it('should set view mode to editor', () => {
useEditorStore.setState({ viewMode: 'preview' })
useEditorStore.getState().setViewMode('editor')
expect(useEditorStore.getState().viewMode).toBe('editor')
})
})
describe('themeMode', () => {
it('should default to light', () => {
expect(useEditorStore.getState().themeMode).toBe('light')
})
it('should set theme mode', () => {
useEditorStore.getState().setThemeMode('dark')
expect(useEditorStore.getState().themeMode).toBe('dark')
})
it('should set warm theme', () => {
useEditorStore.getState().setThemeMode('warm')
expect(useEditorStore.getState().themeMode).toBe('warm')
})
})
describe('cycleTheme', () => {
it('should cycle light → dark', () => {
expect(useEditorStore.getState().cycleTheme()).toBe('dark')
expect(useEditorStore.getState().themeMode).toBe('dark')
})
it('should cycle dark → warm', () => {
useEditorStore.setState({ themeMode: 'dark' })
expect(useEditorStore.getState().cycleTheme()).toBe('warm')
})
it('should cycle warm → light', () => {
useEditorStore.setState({ themeMode: 'warm' })
expect(useEditorStore.getState().cycleTheme()).toBe('light')
})
})
describe('setExternallyModified', () => {
it('should set externally modified info', () => {
useEditorStore.getState().setExternallyModified({ filePath: '/test.md' })
expect(useEditorStore.getState().externallyModified).toEqual({ filePath: '/test.md' })
})
it('should clear externally modified info', () => {
useEditorStore.setState({ externallyModified: { filePath: '/test.md' } })
useEditorStore.getState().setExternallyModified(null)
expect(useEditorStore.getState().externallyModified).toBeNull()
})
})
describe('loading states', () => {
it('should set loading state', () => {
useEditorStore.getState().setLoading('file-open', true)
expect(useEditorStore.getState().isLoading('file-open')).toBe(true)
})
it('should return false for unset loading key', () => {
expect(useEditorStore.getState().isLoading('non-existent')).toBe(false)
})
it('should update loading state to false', () => {
useEditorStore.getState().setLoading('file-open', true)
useEditorStore.getState().setLoading('file-open', false)
expect(useEditorStore.getState().isLoading('file-open')).toBe(false)
})
it('should handle multiple independent loading states', () => {
const { setLoading } = useEditorStore.getState()
setLoading('file-open', true)
setLoading('markdown-render', true)
const state = useEditorStore.getState()
expect(state.isLoading('file-open')).toBe(true)
expect(state.isLoading('markdown-render')).toBe(true)
setLoading('file-open', false)
expect(useEditorStore.getState().isLoading('file-open')).toBe(false)
expect(useEditorStore.getState().isLoading('markdown-render')).toBe(true)
})
})
describe('editor live state (v0.6.0)', () => {
beforeEach(() => {
useEditorStore.setState({ stats: null, cursor: null, zenMode: false })
})
it('should set stats', () => {
useEditorStore.getState().setStats({
characters: 10,
words: 2,
chineseChars: 5,
englishWords: 1,
lines: 3,
readingTime: 1,
})
expect(useEditorStore.getState().stats?.words).toBe(2)
expect(useEditorStore.getState().stats?.lines).toBe(3)
})
it('should clear stats', () => {
useEditorStore.getState().setStats(null)
expect(useEditorStore.getState().stats).toBeNull()
})
it('should set cursor position', () => {
useEditorStore.getState().setCursor({ line: 5, column: 12 })
expect(useEditorStore.getState().cursor).toEqual({ line: 5, column: 12 })
})
it('should set zen mode', () => {
useEditorStore.getState().setZenMode(true)
expect(useEditorStore.getState().zenMode).toBe(true)
useEditorStore.getState().setZenMode(false)
expect(useEditorStore.getState().zenMode).toBe(false)
})
})
})
+317 -317
View File
@@ -1,317 +1,317 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock external dependencies before importing store
vi.mock('../../db/tabRepository', () => ({
tabRepository: {
loadAll: vi.fn().mockResolvedValue([]),
loadActiveTabId: vi.fn().mockResolvedValue(null),
saveAll: vi.fn().mockResolvedValue(undefined),
saveActiveTabId: vi.fn().mockResolvedValue(undefined),
clearAll: vi.fn().mockResolvedValue(undefined),
},
}))
vi.mock('nanoid', () => {
let counter = 0
return {
nanoid: vi.fn(() => `test-id-${++counter}`),
}
})
import { useTabStore } from '../tabStore'
describe('tabStore', () => {
beforeEach(() => {
// Reset store state between tests
useTabStore.setState({
tabs: [],
activeTabId: null,
mruStack: [],
_loaded: false,
})
})
describe('createTab', () => {
it('should create a new tab with default values', () => {
const tab = useTabStore.getState().createTab()
const state = useTabStore.getState()
expect(tab.id).toBeTruthy()
expect(tab.filePath).toBeNull()
expect(tab.content).toBe('')
expect(tab.isModified).toBe(false)
expect(state.tabs).toHaveLength(1)
expect(state.activeTabId).toBe(tab.id)
})
it('should create a tab with file path and content', () => {
const tab = useTabStore.getState().createTab('/test.md', '# Hello')
expect(tab.filePath).toBe('/test.md')
expect(tab.content).toBe('# Hello')
})
it('should switch to existing tab if same filePath is opened', () => {
useTabStore.getState().createTab('/test.md', '# First')
// Create another tab to make it active
useTabStore.getState().createTab('/other.md', '# Other')
// Now try to open the first file again
const existing = useTabStore.getState().createTab('/test.md', '# Updated')
const state = useTabStore.getState()
expect(state.tabs).toHaveLength(2)
expect(state.activeTabId).toBe(existing.id)
})
})
describe('closeTab', () => {
it('should close a tab and update activeTabId', () => {
const { createTab, closeTab } = useTabStore.getState()
const tab1 = createTab('/file1.md')
const tab2 = createTab('/file2.md')
closeTab(tab1.id)
const state = useTabStore.getState()
expect(state.tabs).toHaveLength(1)
expect(state.tabs[0].id).toBe(tab2.id)
expect(state.activeTabId).toBe(tab2.id)
})
it('should set activeTabId to null when closing last tab', () => {
const { createTab, closeTab } = useTabStore.getState()
const tab = createTab('/file1.md')
closeTab(tab.id)
const state = useTabStore.getState()
expect(state.tabs).toHaveLength(0)
expect(state.activeTabId).toBeNull()
})
it('should handle closing non-existent tab gracefully', () => {
const { createTab, closeTab } = useTabStore.getState()
createTab('/file1.md')
closeTab('non-existent')
const state = useTabStore.getState()
expect(state.tabs).toHaveLength(1)
})
})
describe('closeOtherTabs', () => {
it('should close all tabs except the specified one', () => {
const { createTab, closeOtherTabs } = useTabStore.getState()
const tab1 = createTab('/file1.md')
createTab('/file2.md')
createTab('/file3.md')
closeOtherTabs(tab1.id)
const state = useTabStore.getState()
expect(state.tabs).toHaveLength(1)
expect(state.tabs[0].id).toBe(tab1.id)
expect(state.activeTabId).toBe(tab1.id)
})
})
describe('closeAllTabs', () => {
it('should close all tabs', () => {
const { createTab, closeAllTabs } = useTabStore.getState()
createTab('/file1.md')
createTab('/file2.md')
closeAllTabs()
const state = useTabStore.getState()
expect(state.tabs).toHaveLength(0)
expect(state.activeTabId).toBeNull()
expect(state.mruStack).toHaveLength(0)
})
})
describe('closeTabsToRight', () => {
it('should close tabs to the right of the specified tab', () => {
const { createTab, closeTabsToRight } = useTabStore.getState()
const tab1 = createTab('/file1.md')
createTab('/file2.md')
createTab('/file3.md')
closeTabsToRight(tab1.id)
const state = useTabStore.getState()
expect(state.tabs).toHaveLength(1)
expect(state.tabs[0].id).toBe(tab1.id)
})
it('should keep active tab if it is in the remaining set', () => {
const { createTab, switchToTab, closeTabsToRight } = useTabStore.getState()
const tab1 = createTab('/file1.md')
createTab('/file2.md')
createTab('/file3.md')
switchToTab(tab1.id)
closeTabsToRight(tab1.id)
const state = useTabStore.getState()
expect(state.activeTabId).toBe(tab1.id)
})
})
describe('switchToTab', () => {
it('should switch active tab and update MRU stack', () => {
const { createTab, switchToTab } = useTabStore.getState()
const tab1 = createTab('/file1.md')
const tab2 = createTab('/file2.md')
switchToTab(tab1.id)
const state = useTabStore.getState()
expect(state.activeTabId).toBe(tab1.id)
expect(state.mruStack).toContain(tab2.id)
})
it('should not update if switching to already active tab', () => {
const { createTab, switchToTab } = useTabStore.getState()
createTab('/file1.md')
const stateBefore = useTabStore.getState()
switchToTab(stateBefore.activeTabId!)
const stateAfter = useTabStore.getState()
expect(stateAfter.mruStack).toEqual(stateBefore.mruStack)
})
})
describe('updateTabContent', () => {
it('should update content and mark as modified', () => {
const { createTab, updateTabContent } = useTabStore.getState()
const tab = createTab('/file.md', '# Old')
updateTabContent(tab.id, '# New Content')
const state = useTabStore.getState()
const updated = state.tabs.find(t => t.id === tab.id)!
expect(updated.content).toBe('# New Content')
expect(updated.isModified).toBe(true)
})
})
describe('getActiveTab', () => {
it('should return the active tab', () => {
const { createTab, getActiveTab } = useTabStore.getState()
const tab = createTab('/file.md')
const active = getActiveTab()
expect(active?.id).toBe(tab.id)
})
it('should return null when no tabs exist', () => {
const { getActiveTab } = useTabStore.getState()
expect(getActiveTab()).toBeNull()
})
})
describe('setModified', () => {
it('should set modified flag on a tab', () => {
const { createTab, setModified } = useTabStore.getState()
const tab = createTab('/file.md')
setModified(tab.id, true)
const state = useTabStore.getState()
const updated = state.tabs.find(t => t.id === tab.id)!
expect(updated.isModified).toBe(true)
setModified(tab.id, false)
const state2 = useTabStore.getState()
const updated2 = state2.tabs.find(t => t.id === tab.id)!
expect(updated2.isModified).toBe(false)
})
})
describe('updateTabScroll', () => {
it('should update scroll position', () => {
const { createTab, updateTabScroll } = useTabStore.getState()
const tab = createTab('/file.md')
updateTabScroll(tab.id, { scrollTop: 100, selectionStart: 10, selectionEnd: 20 })
const state = useTabStore.getState()
const updated = state.tabs.find(t => t.id === tab.id)!
expect(updated.scrollTop).toBe(100)
expect(updated.selectionStart).toBe(10)
expect(updated.selectionEnd).toBe(20)
})
})
describe('updateTabContent same-content guard (A3)', () => {
it('should not mark as modified when content is unchanged', () => {
const { createTab, updateTabContent } = useTabStore.getState()
const tab = createTab('/file.md', '# Same Content')
updateTabContent(tab.id, '# Same Content')
const state = useTabStore.getState()
const updated = state.tabs.find(t => t.id === tab.id)!
expect(updated.content).toBe('# Same Content')
expect(updated.isModified).toBe(false)
})
it('should mark as modified when content changes', () => {
const { createTab, updateTabContent } = useTabStore.getState()
const tab = createTab('/file.md', '# Old')
updateTabContent(tab.id, '# New')
const state = useTabStore.getState()
const updated = state.tabs.find(t => t.id === tab.id)!
expect(updated.content).toBe('# New')
expect(updated.isModified).toBe(true)
})
})
describe('moveTab (D1)', () => {
it('should move tab to a new position', () => {
const { createTab, moveTab } = useTabStore.getState()
const tab1 = createTab('/file1.md')
const tab2 = createTab('/file2.md')
const tab3 = createTab('/file3.md')
// tab1 移到末尾:期望 [tab2, tab3, tab1]
moveTab(tab1.id, 2)
const state = useTabStore.getState()
// 用引用相等而非基于全局计数器 ID
expect(state.tabs[0]).toBe(tab2)
expect(state.tabs[1]).toBe(tab3)
expect(state.tabs[2]).toBe(tab1)
})
it('should handle moving to same position (no-op)', () => {
const { createTab, moveTab } = useTabStore.getState()
const tab1 = createTab('/file1.md')
createTab('/file2.md')
moveTab(tab1.id, 0)
const state = useTabStore.getState()
expect(state.tabs[0].id).toBe(tab1.id)
expect(state.tabs.length).toBe(2)
})
it('should handle moving non-existent tab gracefully', () => {
const { createTab, moveTab } = useTabStore.getState()
createTab('/file1.md')
moveTab('non-existent', 0)
const state = useTabStore.getState()
expect(state.tabs.length).toBe(1)
})
})
})
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock external dependencies before importing store
vi.mock('../../db/tabRepository', () => ({
tabRepository: {
loadAll: vi.fn().mockResolvedValue([]),
loadActiveTabId: vi.fn().mockResolvedValue(null),
saveAll: vi.fn().mockResolvedValue(undefined),
saveActiveTabId: vi.fn().mockResolvedValue(undefined),
clearAll: vi.fn().mockResolvedValue(undefined),
},
}))
vi.mock('nanoid', () => {
let counter = 0
return {
nanoid: vi.fn(() => `test-id-${++counter}`),
}
})
import { useTabStore } from '../tabStore'
describe('tabStore', () => {
beforeEach(() => {
// Reset store state between tests
useTabStore.setState({
tabs: [],
activeTabId: null,
mruStack: [],
_loaded: false,
})
})
describe('createTab', () => {
it('should create a new tab with default values', () => {
const tab = useTabStore.getState().createTab()
const state = useTabStore.getState()
expect(tab.id).toBeTruthy()
expect(tab.filePath).toBeNull()
expect(tab.content).toBe('')
expect(tab.isModified).toBe(false)
expect(state.tabs).toHaveLength(1)
expect(state.activeTabId).toBe(tab.id)
})
it('should create a tab with file path and content', () => {
const tab = useTabStore.getState().createTab('/test.md', '# Hello')
expect(tab.filePath).toBe('/test.md')
expect(tab.content).toBe('# Hello')
})
it('should switch to existing tab if same filePath is opened', () => {
useTabStore.getState().createTab('/test.md', '# First')
// Create another tab to make it active
useTabStore.getState().createTab('/other.md', '# Other')
// Now try to open the first file again
const existing = useTabStore.getState().createTab('/test.md', '# Updated')
const state = useTabStore.getState()
expect(state.tabs).toHaveLength(2)
expect(state.activeTabId).toBe(existing.id)
})
})
describe('closeTab', () => {
it('should close a tab and update activeTabId', () => {
const { createTab, closeTab } = useTabStore.getState()
const tab1 = createTab('/file1.md')
const tab2 = createTab('/file2.md')
closeTab(tab1.id)
const state = useTabStore.getState()
expect(state.tabs).toHaveLength(1)
expect(state.tabs[0].id).toBe(tab2.id)
expect(state.activeTabId).toBe(tab2.id)
})
it('should set activeTabId to null when closing last tab', () => {
const { createTab, closeTab } = useTabStore.getState()
const tab = createTab('/file1.md')
closeTab(tab.id)
const state = useTabStore.getState()
expect(state.tabs).toHaveLength(0)
expect(state.activeTabId).toBeNull()
})
it('should handle closing non-existent tab gracefully', () => {
const { createTab, closeTab } = useTabStore.getState()
createTab('/file1.md')
closeTab('non-existent')
const state = useTabStore.getState()
expect(state.tabs).toHaveLength(1)
})
})
describe('closeOtherTabs', () => {
it('should close all tabs except the specified one', () => {
const { createTab, closeOtherTabs } = useTabStore.getState()
const tab1 = createTab('/file1.md')
createTab('/file2.md')
createTab('/file3.md')
closeOtherTabs(tab1.id)
const state = useTabStore.getState()
expect(state.tabs).toHaveLength(1)
expect(state.tabs[0].id).toBe(tab1.id)
expect(state.activeTabId).toBe(tab1.id)
})
})
describe('closeAllTabs', () => {
it('should close all tabs', () => {
const { createTab, closeAllTabs } = useTabStore.getState()
createTab('/file1.md')
createTab('/file2.md')
closeAllTabs()
const state = useTabStore.getState()
expect(state.tabs).toHaveLength(0)
expect(state.activeTabId).toBeNull()
expect(state.mruStack).toHaveLength(0)
})
})
describe('closeTabsToRight', () => {
it('should close tabs to the right of the specified tab', () => {
const { createTab, closeTabsToRight } = useTabStore.getState()
const tab1 = createTab('/file1.md')
createTab('/file2.md')
createTab('/file3.md')
closeTabsToRight(tab1.id)
const state = useTabStore.getState()
expect(state.tabs).toHaveLength(1)
expect(state.tabs[0].id).toBe(tab1.id)
})
it('should keep active tab if it is in the remaining set', () => {
const { createTab, switchToTab, closeTabsToRight } = useTabStore.getState()
const tab1 = createTab('/file1.md')
createTab('/file2.md')
createTab('/file3.md')
switchToTab(tab1.id)
closeTabsToRight(tab1.id)
const state = useTabStore.getState()
expect(state.activeTabId).toBe(tab1.id)
})
})
describe('switchToTab', () => {
it('should switch active tab and update MRU stack', () => {
const { createTab, switchToTab } = useTabStore.getState()
const tab1 = createTab('/file1.md')
const tab2 = createTab('/file2.md')
switchToTab(tab1.id)
const state = useTabStore.getState()
expect(state.activeTabId).toBe(tab1.id)
expect(state.mruStack).toContain(tab2.id)
})
it('should not update if switching to already active tab', () => {
const { createTab, switchToTab } = useTabStore.getState()
createTab('/file1.md')
const stateBefore = useTabStore.getState()
switchToTab(stateBefore.activeTabId!)
const stateAfter = useTabStore.getState()
expect(stateAfter.mruStack).toEqual(stateBefore.mruStack)
})
})
describe('updateTabContent', () => {
it('should update content and mark as modified', () => {
const { createTab, updateTabContent } = useTabStore.getState()
const tab = createTab('/file.md', '# Old')
updateTabContent(tab.id, '# New Content')
const state = useTabStore.getState()
const updated = state.tabs.find(t => t.id === tab.id)!
expect(updated.content).toBe('# New Content')
expect(updated.isModified).toBe(true)
})
})
describe('getActiveTab', () => {
it('should return the active tab', () => {
const { createTab, getActiveTab } = useTabStore.getState()
const tab = createTab('/file.md')
const active = getActiveTab()
expect(active?.id).toBe(tab.id)
})
it('should return null when no tabs exist', () => {
const { getActiveTab } = useTabStore.getState()
expect(getActiveTab()).toBeNull()
})
})
describe('setModified', () => {
it('should set modified flag on a tab', () => {
const { createTab, setModified } = useTabStore.getState()
const tab = createTab('/file.md')
setModified(tab.id, true)
const state = useTabStore.getState()
const updated = state.tabs.find(t => t.id === tab.id)!
expect(updated.isModified).toBe(true)
setModified(tab.id, false)
const state2 = useTabStore.getState()
const updated2 = state2.tabs.find(t => t.id === tab.id)!
expect(updated2.isModified).toBe(false)
})
})
describe('updateTabScroll', () => {
it('should update scroll position', () => {
const { createTab, updateTabScroll } = useTabStore.getState()
const tab = createTab('/file.md')
updateTabScroll(tab.id, { scrollTop: 100, selectionStart: 10, selectionEnd: 20 })
const state = useTabStore.getState()
const updated = state.tabs.find(t => t.id === tab.id)!
expect(updated.scrollTop).toBe(100)
expect(updated.selectionStart).toBe(10)
expect(updated.selectionEnd).toBe(20)
})
})
describe('updateTabContent same-content guard (A3)', () => {
it('should not mark as modified when content is unchanged', () => {
const { createTab, updateTabContent } = useTabStore.getState()
const tab = createTab('/file.md', '# Same Content')
updateTabContent(tab.id, '# Same Content')
const state = useTabStore.getState()
const updated = state.tabs.find(t => t.id === tab.id)!
expect(updated.content).toBe('# Same Content')
expect(updated.isModified).toBe(false)
})
it('should mark as modified when content changes', () => {
const { createTab, updateTabContent } = useTabStore.getState()
const tab = createTab('/file.md', '# Old')
updateTabContent(tab.id, '# New')
const state = useTabStore.getState()
const updated = state.tabs.find(t => t.id === tab.id)!
expect(updated.content).toBe('# New')
expect(updated.isModified).toBe(true)
})
})
describe('moveTab (D1)', () => {
it('should move tab to a new position', () => {
const { createTab, moveTab } = useTabStore.getState()
const tab1 = createTab('/file1.md')
const tab2 = createTab('/file2.md')
const tab3 = createTab('/file3.md')
// tab1 移到末尾:期望 [tab2, tab3, tab1]
moveTab(tab1.id, 2)
const state = useTabStore.getState()
// 用引用相等而非基于全局计数器 ID
expect(state.tabs[0]).toBe(tab2)
expect(state.tabs[1]).toBe(tab3)
expect(state.tabs[2]).toBe(tab1)
})
it('should handle moving to same position (no-op)', () => {
const { createTab, moveTab } = useTabStore.getState()
const tab1 = createTab('/file1.md')
createTab('/file2.md')
moveTab(tab1.id, 0)
const state = useTabStore.getState()
expect(state.tabs[0].id).toBe(tab1.id)
expect(state.tabs.length).toBe(2)
})
it('should handle moving non-existent tab gracefully', () => {
const { createTab, moveTab } = useTabStore.getState()
createTab('/file1.md')
moveTab('non-existent', 0)
const state = useTabStore.getState()
expect(state.tabs.length).toBe(1)
})
})
})
+24 -24
View File
@@ -1,24 +1,24 @@
import { create } from 'zustand'
/**
* 轻量 auto-save 状态 store
*
* 解耦 useAutoSave hook 与状态栏组件——状态栏的 auto-save 项通过
* 本 store 读取状态,无需在 render 函数中重复调用 useAutoSave()。
* useAutoSave hook 负责写入本 store。
*/
interface AutoSaveState {
isAutoSaving: boolean
autoSaveEnabled: boolean
}
interface AutoSaveStore extends AutoSaveState {
setState: (partial: Partial<AutoSaveState>) => void
}
export const useAutoSaveStore = create<AutoSaveStore>((set) => ({
isAutoSaving: false,
autoSaveEnabled: true,
setState: (partial) => set(partial)
}))
import { create } from 'zustand'
/**
* 轻量 auto-save 状态 store
*
* 解耦 useAutoSave hook 与状态栏组件——状态栏的 auto-save 项通过
* 本 store 读取状态,无需在 render 函数中重复调用 useAutoSave()。
* useAutoSave hook 负责写入本 store。
*/
interface AutoSaveState {
isAutoSaving: boolean
autoSaveEnabled: boolean
}
interface AutoSaveStore extends AutoSaveState {
setState: (partial: Partial<AutoSaveState>) => void
}
export const useAutoSaveStore = create<AutoSaveStore>(set => ({
isAutoSaving: false,
autoSaveEnabled: true,
setState: partial => set(partial),
}))
+86 -56
View File
@@ -1,56 +1,86 @@
import { create } from 'zustand'
import type { ViewMode, ThemeMode } from '../types/settings'
import type { MarkdownEditor } from '@metona-team/metona-editor'
// Module-level getter for the MetonaEditor instance
// Used by OutlinePanel for heading navigation
let _getEditor: (() => MarkdownEditor | null) | null = null
export function setMetonaEditorGetter(fn: () => MarkdownEditor | null) {
_getEditor = fn
}
export function getMetonaEditor(): MarkdownEditor | null {
return _getEditor ? _getEditor() : null
}
const THEME_CYCLE: ThemeMode[] = ['light', 'dark', 'warm']
interface EditorState {
viewMode: ViewMode
themeMode: ThemeMode
// AR-03: 外部修改检测状态,替代 DOM CustomEvent
externallyModified: { filePath: string } | null
// UX-02: 全局加载状态
loadingStates: Record<string, boolean>
setViewMode: (mode: ViewMode) => void
setThemeMode: (mode: ThemeMode) => void
cycleTheme: () => ThemeMode
setExternallyModified: (info: { filePath: string } | null) => void
// UX-02: 加载状态管理
setLoading: (key: string, loading: boolean) => void
isLoading: (key: string) => boolean
}
export const useEditorStore = create<EditorState>((set, get) => ({
viewMode: 'editor',
themeMode: 'light',
externallyModified: null,
loadingStates: {},
setViewMode: (mode: ViewMode) => set({ viewMode: mode }),
setThemeMode: (mode: ThemeMode) => set({ themeMode: mode }),
cycleTheme: () => {
const current = get().themeMode
const idx = THEME_CYCLE.indexOf(current)
const next = THEME_CYCLE[(idx + 1) % THEME_CYCLE.length]
set({ themeMode: next })
return next
},
setExternallyModified: (info: { filePath: string } | null) => set({ externallyModified: info }),
setLoading: (key: string, loading: boolean) => set(state => ({
loadingStates: { ...state.loadingStates, [key]: loading }
})),
isLoading: (key: string) => get().loadingStates[key] ?? false
}))
import { create } from 'zustand'
import type { ViewMode, ThemeMode } from '../types/settings'
import type { MarkdownEditor } from '@metona-team/metona-editor'
// Module-level getter for the MetonaEditor instance
// Used by OutlinePanel for heading navigation
let _getEditor: (() => MarkdownEditor | null) | null = null
export function setMetonaEditorGetter(fn: () => MarkdownEditor | null) {
_getEditor = fn
}
export function getMetonaEditor(): MarkdownEditor | null {
return _getEditor ? _getEditor() : null
}
const THEME_CYCLE: ThemeMode[] = ['light', 'dark', 'warm']
/** v0.6.0: 编辑器实时状态(由 Editor 组件绑定事件写入,状态栏消费) */
export interface EditorLiveStats {
characters: number
words: number
chineseChars: number
englishWords: number
lines: number
readingTime: number
}
export interface EditorCursor {
line: number
column: number
}
interface EditorState {
viewMode: ViewMode
themeMode: ThemeMode
// AR-03: 外部修改检测状态,替代 DOM CustomEvent
externallyModified: { filePath: string } | null
// UX-02: 全局加载状态
loadingStates: Record<string, boolean>
// v0.6.0: 编辑器实时状态
stats: EditorLiveStats | null
cursor: EditorCursor | null
zenMode: boolean
setViewMode: (mode: ViewMode) => void
setThemeMode: (mode: ThemeMode) => void
cycleTheme: () => ThemeMode
setExternallyModified: (info: { filePath: string } | null) => void
// UX-02: 加载状态管理
setLoading: (key: string, loading: boolean) => void
isLoading: (key: string) => boolean
// v0.6.0: 编辑器状态同步
setStats: (stats: EditorLiveStats | null) => void
setCursor: (cursor: EditorCursor | null) => void
setZenMode: (zen: boolean) => void
}
export const useEditorStore = create<EditorState>((set, get) => ({
viewMode: 'editor',
themeMode: 'light',
externallyModified: null,
loadingStates: {},
stats: null,
cursor: null,
zenMode: false,
setViewMode: (mode: ViewMode) => set({ viewMode: mode }),
setThemeMode: (mode: ThemeMode) => set({ themeMode: mode }),
cycleTheme: () => {
const current = get().themeMode
const idx = THEME_CYCLE.indexOf(current)
const next = THEME_CYCLE[(idx + 1) % THEME_CYCLE.length]
set({ themeMode: next })
return next
},
setExternallyModified: (info: { filePath: string } | null) => set({ externallyModified: info }),
setLoading: (key: string, loading: boolean) =>
set(state => ({
loadingStates: { ...state.loadingStates, [key]: loading },
})),
isLoading: (key: string) => get().loadingStates[key] ?? false,
setStats: (stats: EditorLiveStats | null) => set({ stats }),
setCursor: (cursor: EditorCursor | null) => set({ cursor }),
setZenMode: (zenMode: boolean) => set({ zenMode }),
}))
+4 -6
View File
@@ -18,7 +18,7 @@ interface SidebarState {
setSidebarWidth: (width: number) => void
}
export const useSidebarStore = create<SidebarState>((set) => ({
export const useSidebarStore = create<SidebarState>(set => ({
isVisible: true,
rootPath: null,
tree: [],
@@ -36,17 +36,15 @@ export const useSidebarStore = create<SidebarState>((set) => ({
set(state => ({
expandedDirs: state.expandedDirs.includes(path)
? state.expandedDirs.filter(p => p !== path)
: [...state.expandedDirs, path]
: [...state.expandedDirs, path],
})),
expandDirs: (paths: string[]) =>
set(state => {
const newDirs = paths.filter(p => !state.expandedDirs.includes(p))
return newDirs.length > 0
? { expandedDirs: [...state.expandedDirs, ...newDirs] }
: state
return newDirs.length > 0 ? { expandedDirs: [...state.expandedDirs, ...newDirs] } : state
}),
setSidebarWidth: (width: number) => {
set({ sidebarWidth: width })
settingsRepository.save({ sidebarWidth: width })
}
},
}))
+278 -271
View File
@@ -1,271 +1,278 @@
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
moveTab: (fromId: string, toIndex: number) => 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?.()
},
// D1: 拖拽排序 — 将 fromId 标签移动到新的数组位置
moveTab: (fromId: string, toIndex: number) => {
set(state => {
const fromIndex = state.tabs.findIndex(t => t.id === fromId)
if (fromIndex === -1 || fromIndex === toIndex) return state
const newTabs = [...state.tabs]
const [moved] = newTabs.splice(fromIndex, 1)
// 移除后直接插入目标位置即可(splice 在移除元素后数组已缩短,
// toIndex 相对于原数组位置在移除后无需调整)
newTabs.splice(toIndex, 0, moved)
return { tabs: newTabs }
})
_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 => {
if (t.id !== tabId) return t
// A3: 内容未变时不变更对象引用(避免误标记 isModified
if (t.content === content) return t
return { ...t, content, isModified: true }
})
}))
},
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
)
}))
}
}
})
// D5: 立即 flush 待保存的标签状态到 IndexedDB(取消防抖后同步写入)
export function flushSaveToDB(): Promise<void> {
_debouncedSaveToDB?.cancel()
return getActualSaveToDB(useTabStore.getState)()
}
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
moveTab: (fromId: string, toIndex: number) => 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?.()
},
// D1: 拖拽排序 — 将 fromId 标签移动到新的数组位置
moveTab: (fromId: string, toIndex: number) => {
set(state => {
const fromIndex = state.tabs.findIndex(t => t.id === fromId)
if (fromIndex === -1 || fromIndex === toIndex) return state
const newTabs = [...state.tabs]
const [moved] = newTabs.splice(fromIndex, 1)
// 移除后直接插入目标位置即可(splice 在移除元素后数组已缩短,
// toIndex 相对于原数组的位置在移除后无需调整)
newTabs.splice(toIndex, 0, moved)
return { tabs: newTabs }
})
_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 => {
if (t.id !== tabId) return t
// A3: 内容未变时不变更对象引用(避免误标记 isModified
if (t.content === content) return t
return { ...t, content, isModified: true }
}),
}))
},
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)),
}))
},
}
})
// D5: 立即 flush 待保存的标签状态到 IndexedDB(取消防抖后同步写入)
export function flushSaveToDB(): Promise<void> {
_debouncedSaveToDB?.cancel()
return getActualSaveToDB(useTabStore.getState)()
}
+37 -168
View File
@@ -87,6 +87,38 @@ body {
min-height: 0;
}
/* v0.6.0: StatusBar */
#statusbar {
height: var(--statusbar-height);
background: var(--bg);
border-top: 1px solid var(--border);
display: flex;
align-items: center;
gap: 16px;
padding: 0 12px;
font-size: 12px;
color: var(--text-tertiary);
flex-shrink: 0;
user-select: none;
}
.statusbar-item {
white-space: nowrap;
}
.statusbar-spacer {
flex: 1;
}
.statusbar-zen {
color: var(--primary);
font-weight: 500;
}
.statusbar-autosave.saving {
color: var(--primary);
}
#main-content {
flex: 1;
display: flex;
@@ -128,6 +160,11 @@ body {
height: 100%;
}
/* v0.6.0: Zen 专注模式加宽 — 覆盖内置 820px(同优先级下运行时注入的样式会赢,用更高 specificity */
.metona-editor-wrapper .me-wrapper.me-zen .me-body {
max-width: 1200px;
}
/* FIX: Allow text selection in the preview pane.
global.css sets user-select:none on html/body to prevent
selection in UI chrome (toolbar, tabs, sidebar), but this
@@ -906,174 +943,6 @@ body {
background: var(--primary-dark);
}
/* ===== UX-01: ConfirmDialog ===== */
.confirm-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 10001;
animation: fadeIn 0.15s ease;
}
.confirm-dialog {
background: var(--bg);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
width: 380px;
max-width: 90vw;
overflow: hidden;
animation: fadeIn 0.15s ease;
}
.confirm-header {
padding: 20px 24px 0;
}
.confirm-header h3 {
font-size: 16px;
font-weight: 600;
color: var(--text);
margin: 0;
}
.confirm-header.confirm-danger h3 {
color: #d93025;
}
.confirm-header.confirm-warning h3 {
color: #e37400;
}
:root.dark .confirm-header.confirm-warning h3 {
color: #fdd663;
}
.confirm-body {
padding: 12px 24px 20px;
}
.confirm-body p {
font-size: 14px;
color: var(--text-secondary);
margin: 0;
line-height: 1.5;
}
.confirm-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 24px 20px;
}
.confirm-btn {
padding: 8px 20px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 13px;
font-family: var(--font-ui);
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
background: var(--bg);
color: var(--text);
}
.confirm-btn-cancel:hover {
background: var(--bg-tertiary);
}
.confirm-btn-danger {
background: #d93025;
border-color: #d93025;
color: white;
}
.confirm-btn-danger:hover {
background: #b5271d;
}
.confirm-btn-warning {
background: var(--primary);
border-color: var(--primary);
color: white;
}
.confirm-btn-warning:hover {
background: var(--primary-dark);
}
.confirm-btn-info {
background: var(--primary);
border-color: var(--primary);
color: white;
}
.confirm-btn-info:hover {
background: var(--primary-dark);
}
.confirm-btn:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
/* ===== UX-02: LoadingSpinner ===== */
.loading-spinner {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--text-secondary);
}
.loading-spinner-svg {
animation: spin 0.8s linear infinite;
}
.loading-spinner-label {
font-size: 13px;
font-family: var(--font-ui);
color: var(--text-secondary);
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
:root.dark .loading-overlay {
background: rgba(30, 30, 30, 0.8);
}
.preview-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 40px 0;
color: var(--text-tertiary);
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* ===== UX-07: 通用可访问性增强 ===== */
/* Focus visible 为所有交互元素提供清晰的焦点指示 */
+45 -16
View File
@@ -7,8 +7,12 @@
word-wrap: break-word;
}
.markdown-body h1, .markdown-body h2, .markdown-body h3,
.markdown-body h4, .markdown-body h5, .markdown-body h6 {
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4,
.markdown-body h5,
.markdown-body h6 {
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
@@ -28,10 +32,19 @@
border-bottom: 1px solid var(--border-light);
}
.markdown-body h3 { font-size: 1.25em; }
.markdown-body h4 { font-size: 1em; }
.markdown-body h5 { font-size: 0.875em; }
.markdown-body h6 { font-size: 0.85em; color: var(--text-secondary); }
.markdown-body h3 {
font-size: 1.25em;
}
.markdown-body h4 {
font-size: 1em;
}
.markdown-body h5 {
font-size: 0.875em;
}
.markdown-body h6 {
font-size: 0.85em;
color: var(--text-secondary);
}
.markdown-body p {
margin-top: 0;
@@ -44,8 +57,12 @@
cursor: pointer;
}
.markdown-body a:hover { text-decoration: underline; }
.markdown-body strong { font-weight: 600; }
.markdown-body a:hover {
text-decoration: underline;
}
.markdown-body strong {
font-weight: 600;
}
.markdown-body img {
max-width: 100%;
@@ -71,16 +88,23 @@
border-radius: 0 var(--radius) var(--radius) 0;
}
.markdown-body blockquote p:last-child { margin-bottom: 0; }
.markdown-body blockquote p:last-child {
margin-bottom: 0;
}
.markdown-body ul, .markdown-body ol {
.markdown-body ul,
.markdown-body ol {
margin-top: 0;
margin-bottom: 16px;
padding-left: 2em;
}
.markdown-body li { margin-top: 4px; }
.markdown-body li + li { margin-top: 4px; }
.markdown-body li {
margin-top: 4px;
}
.markdown-body li + li {
margin-top: 4px;
}
.markdown-body code {
font-family: var(--font-mono);
@@ -91,7 +115,9 @@
color: #e83e8c;
}
:root.dark .markdown-body code { color: #f48fb1; }
:root.dark .markdown-body code {
color: #f48fb1;
}
.markdown-body pre {
margin-top: 0;
@@ -119,7 +145,8 @@
display: block;
}
.markdown-body table th, .markdown-body table td {
.markdown-body table th,
.markdown-body table td {
padding: 8px 16px;
border: 1px solid var(--border);
text-align: left;
@@ -130,9 +157,11 @@
background: var(--bg-secondary);
}
.markdown-body table tr:nth-child(even) { background: var(--bg-secondary); }
.markdown-body table tr:nth-child(even) {
background: var(--bg-secondary);
}
.markdown-body input[type="checkbox"] {
.markdown-body input[type='checkbox'] {
margin-right: 6px;
accent-color: var(--primary);
}
+65 -51
View File
@@ -1,51 +1,65 @@
import type {
OpenFileResponse,
ReadFileResult,
SaveFilePayload,
SaveFileResult,
SaveAsPayload,
ReloadFileResult,
FileStatsResult,
ReadDirTreeResult
} from '../../shared/types'
export interface IpcInvokeMap {
'dialog:openFile': [void, OpenFileResponse]
'file:read': [string, ReadFileResult]
'file:save': [SaveFilePayload, SaveFileResult]
'file:saveAs': [SaveAsPayload, SaveFileResult]
'file:getCurrentPath': [void, string | null]
'file:stats': [string, FileStatsResult]
'file:reload': [void, ReloadFileResult]
'tab:switched': [string | null, void]
'window:forceClose': [void, void]
'window:cancelClose': [void, void]
'dir:readTree': [string, ReadDirTreeResult]
'dir:openDialog': [void, string | null]
'dir:watch': [string, void]
'dir:unwatch': [void, void]
}
export type Unsubscribe = () => void
export interface ElectronAPI {
openFile: () => Promise<OpenFileResponse>
readFile: (filePath: string) => Promise<ReadFileResult>
saveFile: (data: SaveFilePayload) => Promise<SaveFileResult>
saveFileAs: (data: SaveAsPayload) => Promise<SaveFileResult>
getCurrentPath: () => Promise<string | null>
getFileStats: (filePath: string) => Promise<FileStatsResult>
reloadFile: () => Promise<ReloadFileResult>
tabSwitched: (filePath: string | null) => Promise<void>
forceClose: () => Promise<void>
cancelClose: () => Promise<void>
openExternal: (url: string) => void
readDirTree: (dirPath: string) => Promise<ReadDirTreeResult>
openFolderDialog: () => Promise<string | null>
watchDir: (dirPath: string) => Promise<void>
unwatchDir: () => Promise<void>
onFileOpenInTab: (callback: (data: { filePath: string; content: string }) => void) => Unsubscribe
onExternalModification: (callback: (filePath: string) => void) => Unsubscribe
onDirChanged: (callback: () => void) => Unsubscribe
onConfirmClose: (callback: () => void) => Unsubscribe
}
import type {
OpenFileResponse,
ReadFileResult,
SaveFilePayload,
SaveFileResult,
SaveAsPayload,
ReloadFileResult,
FileStatsResult,
ReadDirTreeResult,
} from '../../shared/types'
export interface IpcInvokeMap {
'dialog:openFile': [void, OpenFileResponse]
'file:read': [string, ReadFileResult]
'file:save': [SaveFilePayload, SaveFileResult]
'file:saveAs': [SaveAsPayload, SaveFileResult]
'file:getCurrentPath': [void, string | null]
'file:stats': [string, FileStatsResult]
'file:reload': [void, ReloadFileResult]
'tab:switched': [string | null, void]
'window:forceClose': [void, void]
'window:cancelClose': [void, void]
'dir:readTree': [string, ReadDirTreeResult]
'dir:openDialog': [void, string | null]
'dir:watch': [string, void]
'dir:unwatch': [void, void]
'data:export': [
string,
{ success: boolean; canceled?: boolean; filePath?: string; error?: string },
]
'data:import': [void, { success: boolean; canceled?: boolean; content?: string; error?: string }]
}
export type Unsubscribe = () => void
export interface ElectronAPI {
openFile: () => Promise<OpenFileResponse>
readFile: (filePath: string) => Promise<ReadFileResult>
saveFile: (data: SaveFilePayload) => Promise<SaveFileResult>
saveFileAs: (data: SaveAsPayload) => Promise<SaveFileResult>
getCurrentPath: () => Promise<string | null>
getFileStats: (filePath: string) => Promise<FileStatsResult>
reloadFile: () => Promise<ReloadFileResult>
tabSwitched: (filePath: string | null) => Promise<void>
forceClose: () => Promise<void>
cancelClose: () => Promise<void>
openExternal: (url: string) => void
readDirTree: (dirPath: string) => Promise<ReadDirTreeResult>
openFolderDialog: () => Promise<string | null>
watchDir: (dirPath: string) => Promise<void>
unwatchDir: () => Promise<void>
exportData: (
content: string,
) => Promise<{ success: boolean; canceled?: boolean; filePath?: string; error?: string }>
importData: () => Promise<{
success: boolean
canceled?: boolean
content?: string
error?: string
}>
onFileOpenInTab: (callback: (data: { filePath: string; content: string }) => void) => Unsubscribe
onExternalModification: (callback: (filePath: string) => void) => Unsubscribe
onDirChanged: (callback: () => void) => Unsubscribe
onConfirmClose: (callback: () => void) => Unsubscribe
}
+16 -16
View File
@@ -1,16 +1,16 @@
export type ThemeMode = 'light' | 'dark' | 'warm'
export type ViewMode = 'editor' | 'preview' | 'source'
export interface Settings {
themeMode: ThemeMode
viewMode: ViewMode
sidebarCollapsed: boolean
sidebarWidth: number
}
export const DEFAULT_SETTINGS: Settings = {
themeMode: 'light',
viewMode: 'editor',
sidebarCollapsed: false,
sidebarWidth: 240
}
export type ThemeMode = 'light' | 'dark' | 'warm'
export type ViewMode = 'editor' | 'preview' | 'source'
export interface Settings {
themeMode: ThemeMode
viewMode: ViewMode
sidebarCollapsed: boolean
sidebarWidth: number
}
export const DEFAULT_SETTINGS: Settings = {
themeMode: 'light',
viewMode: 'editor',
sidebarCollapsed: false,
sidebarWidth: 240,
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+16 -8
View File
@@ -1,8 +1,16 @@
// 共享常量 — 主进程和渲染进程共用
export const APP_VERSION = 'v0.5.0'
export const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB
export const ALLOWED_EXTENSIONS = ['.md', '.markdown', '.txt'] as const
export const SKIP_DIRS = new Set([
'node_modules', '.git', '.svn', '.hg', 'dist', 'out',
'.next', '.nuxt', '__pycache__', '.DS_Store'
])
// 共享常量 — 主进程和渲染进程共用
export const APP_VERSION = 'v0.6.0'
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',
])
+30 -26
View File
@@ -1,26 +1,30 @@
// IPC 通道名常量 — 主进程和渲染进程共享
export const IPC_CHANNELS = {
// 渲染进程 → 主进程 (invoke)
DIALOG_OPEN_FILE: 'dialog:openFile',
FILE_READ: 'file:read',
FILE_SAVE: 'file:save',
FILE_SAVE_AS: 'file:saveAs',
FILE_GET_CURRENT_PATH: 'file:getCurrentPath',
FILE_STATS: 'file:stats',
FILE_RELOAD: 'file:reload',
TAB_SWITCHED: 'tab:switched',
WINDOW_FORCE_CLOSE: 'window:forceClose',
WINDOW_CANCEL_CLOSE: 'window:cancelClose',
DIR_READ_TREE: 'dir:readTree',
DIR_OPEN_DIALOG: 'dir:openDialog',
DIR_WATCH: 'dir:watch',
DIR_UNWATCH: 'dir:unwatch',
// 主进程 → 渲染进程 (send)
FILE_OPEN_IN_TAB: 'file:openInTab',
FILE_EXTERNALLY_MODIFIED: 'file:externallyModified',
WINDOW_CONFIRM_CLOSE: 'window:confirmClose',
SIDEBAR_DIR_CHANGED: 'sidebar:dirChanged'
} as const
export type IpcChannel = (typeof IPC_CHANNELS)[keyof typeof IPC_CHANNELS]
// IPC 通道名常量 — 主进程和渲染进程共享
export const IPC_CHANNELS = {
// 渲染进程 → 主进程 (invoke)
DIALOG_OPEN_FILE: 'dialog:openFile',
FILE_READ: 'file:read',
FILE_SAVE: 'file:save',
FILE_SAVE_AS: 'file:saveAs',
FILE_GET_CURRENT_PATH: 'file:getCurrentPath',
FILE_STATS: 'file:stats',
FILE_RELOAD: 'file:reload',
TAB_SWITCHED: 'tab:switched',
WINDOW_FORCE_CLOSE: 'window:forceClose',
WINDOW_CANCEL_CLOSE: 'window:cancelClose',
DIR_READ_TREE: 'dir:readTree',
DIR_OPEN_DIALOG: 'dir:openDialog',
DIR_WATCH: 'dir:watch',
DIR_UNWATCH: 'dir:unwatch',
// v0.6.0: 数据备份导出/导入(JSON)
DATA_EXPORT: 'data:export',
DATA_IMPORT: 'data:import',
// 主进程 → 渲染进程 (send)
FILE_OPEN_IN_TAB: 'file:openInTab',
FILE_EXTERNALLY_MODIFIED: 'file:externallyModified',
WINDOW_CONFIRM_CLOSE: 'window:confirmClose',
SIDEBAR_DIR_CHANGED: 'sidebar:dirChanged',
} as const
export type IpcChannel = (typeof IPC_CHANNELS)[keyof typeof IPC_CHANNELS]