refactor: 删除分屏功能 + 代码审计修复 + 安全加固
## 删除分屏功能 - 删除 ViewMode 'split' 类型,仅保留 editor/preview - 删除 splitRatio 状态和 Resizer 组件 - 删除滚动同步模块 (scrollSync.ts, rehypeSourceLine.ts) - 更新快捷键: Ctrl+1 编辑, Ctrl+2 预览 ## Bug 修复 - 修复 Toast setTimeout 内存泄漏 (App.tsx) - 修复 ModifiedBanner reload 更新错误标签 (改用 modifiedFilePath 匹配) - 修复 FileWatcher error 未重置 isSelfWriting (file-watcher.ts) - 修复另存为时未 stop watcher (ipc-handlers.ts) ## 死代码清理 (10 项) - 删除 main/ipc-channels.ts (与 shared/ 重复) - 删除 main/file-system.ts 未使用的 formatBytes - 删除 constants.ts 6 个未使用常量 - 删除 fileUtils.ts 未使用的 formatBytes - 删除 Icons.tsx 5 个未使用图标 (SplitView/ChevronUp/ChevronDown/X/ArrowUp/ArrowDown) - 删除 useCodeMirror 未使用的 getContent/scrollTo - 删除 TabBar 未使用的 menuRef - 删除 Tab.scrollLeft/previewScrollTop 字段 - 删除 tabStore 未使用的 getTabIndex - 删除 Toast/ModifiedBanner 多余 React import ## 安全加固 - ipc-handlers: 添加路径遍历防护 (validatePath 函数) - preload: openExternal 仅允许 http/https 协议 - window-manager: 启用 sandbox: true - preload: removeAllListeners 改为精确取消订阅 (返回 Unsubscribe 函数) ## 状态持久化 - activeTabId 持久化到 IndexedDB (刷新后恢复正确标签) - sidebar 状态持久化到 IndexedDB (isVisible/sidebarWidth) ## 代码优化 - preload 使用 IPC_CHANNELS 常量替代硬编码字符串 - ipc-handlers _event 类型改为 IpcMainInvokeEvent - settingsRepository 删除 splitRatio 字段
This commit is contained in:
@@ -112,7 +112,7 @@ MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程
|
||||
│ │ tabStore │ │editorStore│ │sidebarStore│ │searchStore│ │
|
||||
│ │ - tabs │ │- viewMode│ │- tree │ │- matches │ │
|
||||
│ │- activeId│ │- darkMode│ │- expanded │ │- index │ │
|
||||
│ │ - mru │ │- splitPct│ │- rootPath │ │- options │ │
|
||||
│ │ - mru │ │- viewMode│ │- rootPath │ │- options │ │
|
||||
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └──────────┘ │
|
||||
│ │ │ │ │
|
||||
│ ┌────▼────────────▼────────────▼────────────────────┐ │
|
||||
@@ -144,9 +144,7 @@ interface TabState {
|
||||
|
||||
```typescript
|
||||
interface EditorState {
|
||||
viewMode: 'split' | 'editor' | 'preview' // 视图模式
|
||||
darkMode: boolean // 暗色主题
|
||||
splitRatio: number // 分屏比例 (20~80)
|
||||
viewMode: 'editor' | 'preview' // 视图模式
|
||||
}
|
||||
```
|
||||
|
||||
@@ -198,7 +196,7 @@ db.version(1).stores({
|
||||
| Store | 字段 | 说明 |
|
||||
|-------|------|------|
|
||||
| `tabSnapshots` | id, filePath, content, scrollTop, scrollLeft, selectionStart, selectionEnd, previewScrollTop, isModified, updatedAt | 标签页状态快照,关闭时保存,启动时恢复 |
|
||||
| `settings` | id(固定'default'), darkMode, viewMode, splitRatio, sidebarCollapsed, sidebarWidth | 用户偏好设置 |
|
||||
| `settings` | id(固定'default'), darkMode, viewMode, sidebarCollapsed, sidebarWidth | 用户偏好设置 |
|
||||
| `recentFiles` | ++id, filePath, lastOpened | 最近打开文件列表 |
|
||||
|
||||
### 4.3 IndexedDB 优势(vs localStorage)
|
||||
@@ -418,7 +416,7 @@ dangerouslySetInnerHTML 渲染到 React DOM
|
||||
┌──────────────────────────────────────────────────────────────────────────┐
|
||||
│ MarkLite - filename.md ─ □ ✕ │
|
||||
├──────────────────────────────────────────────────────────────────────────┤
|
||||
│ 📁 打开 │ 💾 保存 │ ⬜ 分屏 │ ✏️ 编辑 │ 👁 预览 │ 🌙 │
|
||||
│ 📁 打开 │ 💾 保存 │ ✏️ 编辑 │ 👁 预览 │ 🌙 │
|
||||
├──────────────────────────────────────────────────────────────────────────┤
|
||||
│ [file1.md] [file2.md] [未命名] [+] │
|
||||
├──────────────────────────────────────────────────────────────────────────┤
|
||||
@@ -448,9 +446,8 @@ dangerouslySetInnerHTML 渲染到 React DOM
|
||||
| `Ctrl + O` | 打开文件 |
|
||||
| `Ctrl + S` | 保存文件 |
|
||||
| `Ctrl + Shift + S` | 另存为 |
|
||||
| `Ctrl + 1` | 编辑 + 预览(分屏) |
|
||||
| `Ctrl + 2` | 纯编辑模式 |
|
||||
| `Ctrl + 3` | 纯预览模式 |
|
||||
| `Ctrl + 1` | 编辑模式 |
|
||||
| `Ctrl + 2` | 预览模式 |
|
||||
| `Ctrl + F` | 搜索 |
|
||||
| `Ctrl + H` | 搜索并替换 |
|
||||
| `Enter` / `Shift+Enter` | 下一个 / 上一个匹配 |
|
||||
|
||||
@@ -33,8 +33,7 @@
|
||||
| ✏️ **实时编辑** | 左侧编辑器,支持 Tab 缩进、行号显示、光标位置、大文件虚拟化行号 |
|
||||
| 👁 **实时预览** | 右侧预览面板,基于 unified/rehype 管线渲染,编辑即更新 |
|
||||
| 🔤 **代码高亮** | 基于 rehype-highlight,支持 180+ 种编程语言语法高亮 |
|
||||
| 🎨 **三种视图** | 分屏模式 / 纯编辑 / 纯预览,自由切换 |
|
||||
| 📐 **分屏调节** | 拖拽中间分隔条,自由调整编辑区与预览区比例 |
|
||||
| 🎨 **两种视图** | 编辑模式 / 预览模式,自由切换 |
|
||||
| 🌙 **暗色主题** | 一键切换亮色/暗色主题,偏好自动记忆(IndexedDB) |
|
||||
| 🔔 **文件监听** | 外部修改文件时自动提示,支持重新加载或忽略 |
|
||||
| 💾 **文件保存** | 保存 / 另存为,支持 .md / .markdown / .txt 格式 |
|
||||
@@ -50,7 +49,7 @@
|
||||
┌──────────────────────────────────────────────────────────────────────────┐
|
||||
│ MarkLite - README.md ─ □ ✕ │
|
||||
├──────────────────────────────────────────────────────────────────────────┤
|
||||
│ 📁 打开 │ 💾 保存 │ ⬜ 分屏 │ ✏️ 编辑 │ 👁 预览 │ 🌙 │
|
||||
│ 📁 打开 │ 💾 保存 │ ✏️ 编辑 │ 👁 预览 │ 🌙 │
|
||||
├──────────────────────────────────────────────────────────────────────────┤
|
||||
│ [README.md] [DESIGN.md] [main.ts] [+] │
|
||||
├──────────┬─────────────────────────┬─────────────────────────────────────┤
|
||||
@@ -125,9 +124,8 @@ npm run lint
|
||||
| `Ctrl + O` | 打开文件 |
|
||||
| `Ctrl + S` | 保存文件 |
|
||||
| `Ctrl + Shift + S` | 另存为 |
|
||||
| `Ctrl + 1` | 编辑 + 预览(分屏) |
|
||||
| `Ctrl + 2` | 纯编辑模式 |
|
||||
| `Ctrl + 3` | 纯预览模式 |
|
||||
| `Ctrl + 1` | 编辑模式 |
|
||||
| `Ctrl + 2` | 预览模式 |
|
||||
| `Ctrl + F` | 搜索 |
|
||||
| `Ctrl + H` | 搜索并替换 |
|
||||
| `Enter` / `Shift+Enter` | 下一个 / 上一个匹配 |
|
||||
@@ -194,7 +192,7 @@ MarkLite/
|
||||
│ │ │
|
||||
│ │ ├── stores/ # ----- Zustand 状态管理 -----
|
||||
│ │ │ ├── tabStore.ts # 标签页状态(tabs、activeTabId、mruStack)
|
||||
│ │ │ ├── editorStore.ts # 编辑器状态(viewMode、darkMode、splitRatio)
|
||||
│ │ │ ├── editorStore.ts # 编辑器状态(viewMode、darkMode)
|
||||
│ │ │ ├── sidebarStore.ts # 侧边栏状态(tree、expandedDirs、rootPath)
|
||||
│ │ │ └── searchStore.ts # 搜索替换状态(matches、currentIndex、options)
|
||||
│ │ │
|
||||
|
||||
@@ -74,9 +74,3 @@ export async function buildDirTree(dirPath: string, depth = 0, maxDepth = 10): P
|
||||
}
|
||||
return children
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ export class FileWatcher {
|
||||
this.watcher = null
|
||||
}
|
||||
this.currentPath = null
|
||||
this.isSelfWriting = false
|
||||
})
|
||||
} catch {
|
||||
// silently ignore
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
export const IPC_CHANNELS = {
|
||||
DIALOG_OPEN_FILE: 'dialog:openFile',
|
||||
FILE_READ: 'file:read',
|
||||
FILE_SAVE: 'file:save',
|
||||
FILE_SAVE_AS: 'file:saveAs',
|
||||
FILE_GET_CURRENT_PATH: 'file:getCurrentPath',
|
||||
FILE_STATS: 'file:stats',
|
||||
FILE_RELOAD: 'file:reload',
|
||||
TAB_SWITCHED: 'tab:switched',
|
||||
WINDOW_FORCE_CLOSE: 'window:forceClose',
|
||||
WINDOW_CANCEL_CLOSE: 'window:cancelClose',
|
||||
DIR_READ_TREE: 'dir:readTree',
|
||||
DIR_OPEN_DIALOG: 'dir:openDialog',
|
||||
DIR_WATCH: 'dir:watch',
|
||||
DIR_UNWATCH: 'dir:unwatch',
|
||||
FILE_OPEN_IN_TAB: 'file:openInTab',
|
||||
FILE_EXTERNALLY_MODIFIED: 'file:externallyModified',
|
||||
MENU_SAVE: 'menu:save',
|
||||
MENU_SAVE_AS: 'menu:saveAs',
|
||||
MENU_VIEW_MODE: 'menu:viewMode',
|
||||
WINDOW_CONFIRM_CLOSE: 'window:confirmClose',
|
||||
SIDEBAR_DIR_CHANGED: 'sidebar:dirChanged'
|
||||
} as const
|
||||
+43
-10
@@ -1,9 +1,22 @@
|
||||
import { ipcMain, dialog, BrowserWindow } from 'electron'
|
||||
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 './ipc-channels'
|
||||
import { IPC_CHANNELS } from '../shared/ipc-channels'
|
||||
import { stat } from 'fs/promises'
|
||||
import { join, basename } from 'path'
|
||||
import { join, basename, resolve, normalize, isAbsolute } from 'path'
|
||||
|
||||
// 安全校验:拒绝路径遍历攻击
|
||||
function validatePath(filePath: string): boolean {
|
||||
if (!filePath || typeof filePath !== 'string') return false
|
||||
// 拒绝空字节
|
||||
if (filePath.includes('\0')) return false
|
||||
// 规范化路径后检查是否包含 ..
|
||||
const normalized = normalize(filePath)
|
||||
if (normalized.includes('..')) return false
|
||||
// 必须是绝对路径
|
||||
if (!isAbsolute(normalized)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export function registerIpcHandlers(
|
||||
getMainWindow: () => BrowserWindow | null,
|
||||
@@ -38,12 +51,18 @@ export function registerIpcHandlers(
|
||||
})
|
||||
|
||||
// 读取文件
|
||||
ipcMain.handle(IPC_CHANNELS.FILE_READ, async (_event: unknown, filePath: string) => {
|
||||
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: unknown, data: { filePath: string | null; content: string }) => {
|
||||
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) {
|
||||
@@ -87,7 +106,7 @@ export function registerIpcHandlers(
|
||||
})
|
||||
|
||||
// 另存为
|
||||
ipcMain.handle(IPC_CHANNELS.FILE_SAVE_AS, async (_event: unknown, data: { content: string }) => {
|
||||
ipcMain.handle(IPC_CHANNELS.FILE_SAVE_AS, async (_event: IpcMainInvokeEvent, data: { content: string }) => {
|
||||
const win = getMainWindow()
|
||||
if (!win) return { success: false, error: '窗口不可用' }
|
||||
try {
|
||||
@@ -95,16 +114,24 @@ export function registerIpcHandlers(
|
||||
filters: [{ name: 'Markdown 文件', extensions: ['md'] }]
|
||||
})
|
||||
if (!result.canceled) {
|
||||
fileWatcher.stop()
|
||||
fileWatcher.setSelfWriting(true)
|
||||
const saveResult = await saveFileContent(result.filePath, data.content)
|
||||
fileWatcher.setSelfWriting(false)
|
||||
if (saveResult.success) {
|
||||
state.activeFilePath = result.filePath
|
||||
setTimeout(() => {
|
||||
if (state.activeFilePath === result.filePath) {
|
||||
fileWatcher.start(result.filePath)
|
||||
}
|
||||
}, 300)
|
||||
win.setTitle(`MarkLite - ${basename(result.filePath)}`)
|
||||
}
|
||||
return saveResult
|
||||
}
|
||||
return { success: false, canceled: true }
|
||||
} catch (err) {
|
||||
fileWatcher.setSelfWriting(false)
|
||||
return { success: false, error: (err as Error).message }
|
||||
}
|
||||
})
|
||||
@@ -113,7 +140,10 @@ export function registerIpcHandlers(
|
||||
ipcMain.handle(IPC_CHANNELS.FILE_GET_CURRENT_PATH, () => state.activeFilePath)
|
||||
|
||||
// 文件统计
|
||||
ipcMain.handle(IPC_CHANNELS.FILE_STATS, async (_event: unknown, filePath: string) => {
|
||||
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() }
|
||||
@@ -130,7 +160,10 @@ export function registerIpcHandlers(
|
||||
})
|
||||
|
||||
// 目录树
|
||||
ipcMain.handle(IPC_CHANNELS.DIR_READ_TREE, async (_event: unknown, dirPath: string) => {
|
||||
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 }
|
||||
@@ -151,7 +184,7 @@ export function registerIpcHandlers(
|
||||
})
|
||||
|
||||
// 目录监听
|
||||
ipcMain.handle(IPC_CHANNELS.DIR_WATCH, (_event: unknown, dirPath: string) => {
|
||||
ipcMain.handle(IPC_CHANNELS.DIR_WATCH, (_event: IpcMainInvokeEvent, dirPath: string) => {
|
||||
sidebarWatcher.start(dirPath)
|
||||
})
|
||||
|
||||
@@ -160,7 +193,7 @@ export function registerIpcHandlers(
|
||||
})
|
||||
|
||||
// 标签切换
|
||||
ipcMain.handle(IPC_CHANNELS.TAB_SWITCHED, (_event: unknown, filePath: string | null) => {
|
||||
ipcMain.handle(IPC_CHANNELS.TAB_SWITCHED, (_event: IpcMainInvokeEvent, filePath: string | null) => {
|
||||
state.activeFilePath = filePath || null
|
||||
fileWatcher.start(filePath || '')
|
||||
const win = getMainWindow()
|
||||
|
||||
@@ -12,7 +12,8 @@ export function createWindow(): BrowserWindow {
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false
|
||||
nodeIntegration: false,
|
||||
sandbox: true
|
||||
},
|
||||
titleBarStyle: 'default',
|
||||
show: false
|
||||
|
||||
+61
-48
@@ -1,62 +1,75 @@
|
||||
import { contextBridge, ipcRenderer, shell } from 'electron'
|
||||
|
||||
// M-01: 限制 removeAllListeners 只能操作白名单通道
|
||||
const ALLOWED_REMOVE_CHANNELS = new Set([
|
||||
'file:openInTab',
|
||||
'menu:save',
|
||||
'menu:saveAs',
|
||||
'menu:viewMode',
|
||||
'file:externallyModified',
|
||||
'sidebar:dirChanged',
|
||||
'window:confirmClose'
|
||||
])
|
||||
import { IPC_CHANNELS } from '../shared/ipc-channels'
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
// File operations
|
||||
openFile: () => ipcRenderer.invoke('dialog:openFile'),
|
||||
readFile: (filePath: string) => ipcRenderer.invoke('file:read', filePath),
|
||||
saveFile: (data: { filePath: string | null; content: string }) => ipcRenderer.invoke('file:save', data),
|
||||
saveFileAs: (data: { content: string }) => ipcRenderer.invoke('file:saveAs', data),
|
||||
getCurrentPath: () => ipcRenderer.invoke('file:getCurrentPath'),
|
||||
getFileStats: (filePath: string) => ipcRenderer.invoke('file:stats', filePath),
|
||||
reloadFile: () => ipcRenderer.invoke('file:reload'),
|
||||
openFile: () => ipcRenderer.invoke(IPC_CHANNELS.DIALOG_OPEN_FILE),
|
||||
readFile: (filePath: string) => ipcRenderer.invoke(IPC_CHANNELS.FILE_READ, filePath),
|
||||
saveFile: (data: { filePath: string | null; content: string }) => ipcRenderer.invoke(IPC_CHANNELS.FILE_SAVE, data),
|
||||
saveFileAs: (data: { content: string }) => 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('tab:switched', filePath),
|
||||
tabSwitched: (filePath: string | null) => ipcRenderer.invoke(IPC_CHANNELS.TAB_SWITCHED, filePath),
|
||||
|
||||
// Window control
|
||||
forceClose: () => ipcRenderer.invoke('window:forceClose'),
|
||||
cancelClose: () => ipcRenderer.invoke('window:cancelClose'),
|
||||
forceClose: () => ipcRenderer.invoke(IPC_CHANNELS.WINDOW_FORCE_CLOSE),
|
||||
cancelClose: () => ipcRenderer.invoke(IPC_CHANNELS.WINDOW_CANCEL_CLOSE),
|
||||
|
||||
// Shell
|
||||
openExternal: (url: string) => shell.openExternal(url),
|
||||
// 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('dir:readTree', dirPath),
|
||||
openFolderDialog: () => ipcRenderer.invoke('dir:openDialog'),
|
||||
watchDir: (dirPath: string) => ipcRenderer.invoke('dir:watch', dirPath),
|
||||
unwatchDir: () => ipcRenderer.invoke('dir:unwatch'),
|
||||
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: (data: { filePath: string; content: string }) => void) =>
|
||||
ipcRenderer.on('file:openInTab', (_event, data) => callback(data)),
|
||||
onMenuSave: (callback: () => void) =>
|
||||
ipcRenderer.on('menu:save', () => callback()),
|
||||
onMenuSaveAs: (callback: () => void) =>
|
||||
ipcRenderer.on('menu:saveAs', () => callback()),
|
||||
onViewModeChange: (callback: (mode: string) => void) =>
|
||||
ipcRenderer.on('menu:viewMode', (_event, mode) => callback(mode)),
|
||||
onExternalModification: (callback: (filePath: string) => void) =>
|
||||
ipcRenderer.on('file:externallyModified', (_event, filePath) => callback(filePath)),
|
||||
onDirChanged: (callback: () => void) =>
|
||||
ipcRenderer.on('sidebar:dirChanged', () => callback()),
|
||||
onConfirmClose: (callback: () => void) =>
|
||||
ipcRenderer.on('window:confirmClose', () => callback()),
|
||||
|
||||
// M-01: 只允许移除白名单通道的监听器
|
||||
removeAllListeners: (channel: string) => {
|
||||
if (ALLOWED_REMOVE_CHANNELS.has(channel)) {
|
||||
ipcRenderer.removeAllListeners(channel)
|
||||
}
|
||||
// Events from main process — 返回取消订阅函数
|
||||
onFileOpenInTab: (callback: (data: { filePath: string; content: string }) => void) => {
|
||||
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) }
|
||||
},
|
||||
onMenuSave: (callback: () => void) => {
|
||||
const handler = () => callback()
|
||||
ipcRenderer.on(IPC_CHANNELS.MENU_SAVE, handler)
|
||||
return () => { ipcRenderer.removeListener(IPC_CHANNELS.MENU_SAVE, handler) }
|
||||
},
|
||||
onMenuSaveAs: (callback: () => void) => {
|
||||
const handler = () => callback()
|
||||
ipcRenderer.on(IPC_CHANNELS.MENU_SAVE_AS, handler)
|
||||
return () => { ipcRenderer.removeListener(IPC_CHANNELS.MENU_SAVE_AS, handler) }
|
||||
},
|
||||
onViewModeChange: (callback: (mode: string) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, mode: string) => callback(mode)
|
||||
ipcRenderer.on(IPC_CHANNELS.MENU_VIEW_MODE, handler)
|
||||
return () => { ipcRenderer.removeListener(IPC_CHANNELS.MENU_VIEW_MODE, handler) }
|
||||
},
|
||||
onExternalModification: (callback: (filePath: string) => void) => {
|
||||
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: () => void) => {
|
||||
const handler = () => callback()
|
||||
ipcRenderer.on(IPC_CHANNELS.SIDEBAR_DIR_CHANGED, handler)
|
||||
return () => { ipcRenderer.removeListener(IPC_CHANNELS.SIDEBAR_DIR_CHANGED, handler) }
|
||||
},
|
||||
onConfirmClose: (callback: () => void) => {
|
||||
const handler = () => callback()
|
||||
ipcRenderer.on(IPC_CHANNELS.WINDOW_CONFIRM_CLOSE, handler)
|
||||
return () => { ipcRenderer.removeListener(IPC_CHANNELS.WINDOW_CONFIRM_CLOSE, handler) }
|
||||
}
|
||||
})
|
||||
|
||||
+23
-66
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { useTabStore } from './stores/tabStore'
|
||||
import { useEditorStore } from './stores/editorStore'
|
||||
import { useSidebarStore } from './stores/sidebarStore'
|
||||
import { useTheme } from './hooks/useTheme'
|
||||
import { useSettings } from './hooks/useSettings'
|
||||
import { useKeyboard } from './hooks/useKeyboard'
|
||||
@@ -27,25 +28,29 @@ export default function App() {
|
||||
const setModified = useTabStore(s => s.setModified)
|
||||
const updateTabContent = useTabStore(s => s.updateTabContent)
|
||||
const loadFromDB = useTabStore(s => s.loadFromDB)
|
||||
const loadSidebarFromDB = useSidebarStore(s => s.loadFromDB)
|
||||
|
||||
const viewMode = useEditorStore(s => s.viewMode)
|
||||
const splitRatio = useEditorStore(s => s.splitRatio)
|
||||
|
||||
const { darkMode, toggleDarkMode } = useTheme()
|
||||
const { saveViewMode, saveSplitRatio } = useSettings()
|
||||
const { saveViewMode } = useSettings()
|
||||
|
||||
const [showModifiedBanner, setShowModifiedBanner] = useState(false)
|
||||
const [modifiedFilePath, setModifiedFilePath] = useState<string | null>(null)
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null)
|
||||
const toastTimer = useRef<number>(0)
|
||||
|
||||
// 启动时从 IndexedDB 加载标签页
|
||||
// 启动时从 IndexedDB 加载标签页和 sidebar 设置
|
||||
useEffect(() => {
|
||||
loadFromDB()
|
||||
}, [loadFromDB])
|
||||
loadSidebarFromDB()
|
||||
return () => clearTimeout(toastTimer.current)
|
||||
}, [loadFromDB, loadSidebarFromDB])
|
||||
|
||||
const showToast = useCallback((msg: string) => {
|
||||
clearTimeout(toastTimer.current)
|
||||
setToastMessage(msg)
|
||||
setTimeout(() => setToastMessage(null), 3000)
|
||||
toastTimer.current = window.setTimeout(() => setToastMessage(null), 3000)
|
||||
}, [])
|
||||
|
||||
// 拖拽打开
|
||||
@@ -128,7 +133,7 @@ export default function App() {
|
||||
}, [createTab])
|
||||
|
||||
// 视图模式切换
|
||||
const handleViewModeChange = useCallback((mode: 'split' | 'editor' | 'preview') => {
|
||||
const handleViewModeChange = useCallback((mode: 'editor' | 'preview') => {
|
||||
saveViewMode(mode)
|
||||
}, [saveViewMode])
|
||||
|
||||
@@ -150,28 +155,19 @@ export default function App() {
|
||||
const onSave = () => handleSaveRef.current()
|
||||
const onSaveAs = () => handleSaveAsRef.current()
|
||||
|
||||
api.onFileOpenInTab(onOpen)
|
||||
api.onMenuSave(onSave)
|
||||
api.onMenuSaveAs(onSaveAs)
|
||||
const unsub1 = api.onFileOpenInTab(onOpen)
|
||||
const unsub2 = api.onMenuSave(onSave)
|
||||
const unsub3 = api.onMenuSaveAs(onSaveAs)
|
||||
|
||||
return () => {
|
||||
api.removeAllListeners('file:openInTab')
|
||||
api.removeAllListeners('menu:save')
|
||||
api.removeAllListeners('menu:saveAs')
|
||||
unsub1()
|
||||
unsub2()
|
||||
unsub3()
|
||||
}
|
||||
}, [createTab])
|
||||
|
||||
const activeTab = getActiveTab()
|
||||
const hasTabs = tabs.length > 0
|
||||
|
||||
// 分屏样式
|
||||
const editorStyle: React.CSSProperties = viewMode === 'split'
|
||||
? { flex: `0 0 ${splitRatio}%` }
|
||||
: {}
|
||||
const previewStyle: React.CSSProperties = viewMode === 'split'
|
||||
? { flex: `0 0 ${100 - splitRatio}%` }
|
||||
: {}
|
||||
|
||||
return (
|
||||
<div id="app" className={`mode-${viewMode}`}>
|
||||
<Toolbar
|
||||
@@ -193,9 +189,9 @@ export default function App() {
|
||||
<ModifiedBanner
|
||||
onReload={() => {
|
||||
if (window.electronAPI && modifiedFilePath) {
|
||||
window.electronAPI.reloadFile().then(result => {
|
||||
window.electronAPI.readFile(modifiedFilePath).then(result => {
|
||||
if (result.success && result.content) {
|
||||
const tab = getActiveTab()
|
||||
const tab = tabs.find(t => t.filePath === modifiedFilePath)
|
||||
if (tab) {
|
||||
updateTabContent(tab.id, result.content)
|
||||
setModified(tab.id, false)
|
||||
@@ -211,14 +207,13 @@ export default function App() {
|
||||
|
||||
{hasTabs ? (
|
||||
<div id="content-wrapper">
|
||||
{viewMode !== 'preview' && (
|
||||
<div id="editor-panel" style={editorStyle}>
|
||||
{viewMode === 'editor' && (
|
||||
<div id="editor-panel">
|
||||
<Editor darkMode={darkMode} />
|
||||
</div>
|
||||
)}
|
||||
{viewMode !== 'editor' && <Resizer onResize={saveSplitRatio} />}
|
||||
{viewMode !== 'editor' && (
|
||||
<div id="preview-panel" style={previewStyle}>
|
||||
{viewMode === 'preview' && (
|
||||
<div id="preview-panel">
|
||||
<Preview />
|
||||
</div>
|
||||
)}
|
||||
@@ -240,41 +235,3 @@ export default function App() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// B-08: 分屏调节器组件
|
||||
function Resizer({ onResize }: { onResize: (ratio: number) => void }) {
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
document.body.style.cursor = 'col-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
|
||||
const handleMouseMove = (ev: MouseEvent) => {
|
||||
const wrapper = document.getElementById('content-wrapper')
|
||||
if (!wrapper) return
|
||||
const rect = wrapper.getBoundingClientRect()
|
||||
const pct = Math.max(20, Math.min(80, ((ev.clientX - rect.left) / rect.width) * 100))
|
||||
const editorPanel = document.getElementById('editor-panel')
|
||||
const previewPanel = document.getElementById('preview-panel')
|
||||
if (editorPanel) editorPanel.style.flex = `0 0 ${pct}%`
|
||||
if (previewPanel) previewPanel.style.flex = `0 0 ${100 - pct}%`
|
||||
}
|
||||
|
||||
const handleMouseUp = (ev: MouseEvent) => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
document.body.style.cursor = ''
|
||||
document.body.style.userSelect = ''
|
||||
const wrapper = document.getElementById('content-wrapper')
|
||||
if (wrapper) {
|
||||
const rect = wrapper.getBoundingClientRect()
|
||||
const pct = Math.max(20, Math.min(80, ((ev.clientX - rect.left) / rect.width) * 100))
|
||||
onResize(pct)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
}, [onResize])
|
||||
|
||||
return <div id="resizer" onMouseDown={handleMouseDown} />
|
||||
}
|
||||
|
||||
@@ -22,8 +22,7 @@ export function Editor({ darkMode }: EditorProps) {
|
||||
getScrollTop,
|
||||
setScrollTop,
|
||||
getSelection,
|
||||
setSelection,
|
||||
setLineAtTop
|
||||
setSelection
|
||||
} = useCodeMirror({
|
||||
content: activeTab?.content ?? '',
|
||||
onChange: useCallback((value: string) => {
|
||||
@@ -31,25 +30,9 @@ export function Editor({ darkMode }: EditorProps) {
|
||||
updateTabContent(activeTabId, value)
|
||||
setModified(activeTabId, true)
|
||||
}, [activeTabId, updateTabContent, setModified]),
|
||||
darkMode,
|
||||
onScroll: useCallback((line: number) => {
|
||||
// 派发行号给预览侧做滚动同步
|
||||
window.dispatchEvent(new CustomEvent('editor-scroll', { detail: { line } }))
|
||||
}, [])
|
||||
darkMode
|
||||
})
|
||||
|
||||
// 反向同步:监听预览滚动事件
|
||||
useEffect(() => {
|
||||
const handlePreviewScroll = (e: Event) => {
|
||||
const line = (e as CustomEvent).detail?.line
|
||||
if (typeof line === 'number') {
|
||||
setLineAtTop(Math.floor(line) + 1) // setLineAtTop 是 1-indexed
|
||||
}
|
||||
}
|
||||
window.addEventListener('preview-scroll', handlePreviewScroll)
|
||||
return () => window.removeEventListener('preview-scroll', handlePreviewScroll)
|
||||
}, [setLineAtTop])
|
||||
|
||||
// 切换标签时加载内容
|
||||
useEffect(() => {
|
||||
if (!activeTab) return
|
||||
|
||||
@@ -11,10 +11,9 @@ interface UseCodeMirrorOptions {
|
||||
content: string
|
||||
onChange: (value: string) => void
|
||||
darkMode: boolean
|
||||
onScroll?: (line: number) => void
|
||||
}
|
||||
|
||||
export function useCodeMirror({ content, onChange, darkMode, onScroll }: UseCodeMirrorOptions) {
|
||||
export function useCodeMirror({ content, onChange, darkMode }: UseCodeMirrorOptions) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const viewRef = useRef<EditorView | null>(null)
|
||||
const isExternalUpdate = useRef(false)
|
||||
@@ -81,23 +80,7 @@ export function useCodeMirror({ content, onChange, darkMode, onScroll }: UseCode
|
||||
|
||||
viewRef.current = view
|
||||
|
||||
// 滚动事件监听:派发行号(支持小数表示行内偏移)
|
||||
const handleScroll = () => {
|
||||
if (onScroll) {
|
||||
const dom = view.scrollDOM
|
||||
const scrollTop = dom.scrollTop
|
||||
const block = view.lineBlockAtHeight(scrollTop)
|
||||
const line = view.state.doc.lineAt(block.from)
|
||||
const fraction = block.height > 0
|
||||
? (scrollTop - block.top) / block.height
|
||||
: 0
|
||||
onScroll(line.number - 1 + Math.max(0, fraction)) // 0-indexed + 行内比例
|
||||
}
|
||||
}
|
||||
view.scrollDOM.addEventListener('scroll', handleScroll)
|
||||
|
||||
return () => {
|
||||
view.scrollDOM.removeEventListener('scroll', handleScroll)
|
||||
view.destroy()
|
||||
viewRef.current = null
|
||||
}
|
||||
@@ -116,12 +99,6 @@ export function useCodeMirror({ content, onChange, darkMode, onScroll }: UseCode
|
||||
isExternalUpdate.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 获取当前内容
|
||||
const getContent = useCallback(() => {
|
||||
return viewRef.current?.state.doc.toString() ?? ''
|
||||
}, [])
|
||||
|
||||
// 获取/设置滚动位置
|
||||
const getScrollTop = useCallback(() => {
|
||||
return viewRef.current?.scrollDOM.scrollTop ?? 0
|
||||
@@ -147,35 +124,13 @@ export function useCodeMirror({ content, onChange, darkMode, onScroll }: UseCode
|
||||
view.dispatch({ selection: { anchor: from, head: to } })
|
||||
view.focus()
|
||||
}, [])
|
||||
|
||||
// 滚动到指定行
|
||||
const scrollTo = useCallback((pos: number) => {
|
||||
const view = viewRef.current
|
||||
if (!view) return
|
||||
view.dispatch({ effects: EditorView.scrollIntoView(pos, { y: 'center' }) })
|
||||
}, [])
|
||||
|
||||
// 滚动使指定行出现在编辑器顶部(反向同步用)
|
||||
const setLineAtTop = useCallback((lineNumber: number) => {
|
||||
const view = viewRef.current
|
||||
if (!view) return
|
||||
const line = Math.max(1, Math.min(lineNumber, view.state.doc.lines))
|
||||
const lineInfo = view.state.doc.line(line)
|
||||
const block = view.lineBlockAt(lineInfo.from)
|
||||
const currentTop = view.lineBlockAtHeight(view.scrollDOM.scrollTop).top
|
||||
view.scrollDOM.scrollTop += block.top - currentTop
|
||||
}, [])
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
viewRef,
|
||||
setContent,
|
||||
getContent,
|
||||
getScrollTop,
|
||||
setScrollTop,
|
||||
getSelection,
|
||||
setSelection,
|
||||
scrollTo,
|
||||
setLineAtTop
|
||||
setSelection
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,19 +58,6 @@ export function Save({ size = defaultProps.size }: IconProps) {
|
||||
)
|
||||
}
|
||||
|
||||
export function SplitView({ 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">
|
||||
<rect x="3" y="3" width="18" height="18" rx="3"/>
|
||||
<line x1="12" y1="3" x2="12" y2="21"/>
|
||||
<rect x="5" y="7" width="5" height="2" rx="1" fill="currentColor" opacity="0.4"/>
|
||||
<rect x="5" y="11" width="3" height="2" rx="1" fill="currentColor" opacity="0.3"/>
|
||||
<rect x="14" y="7" width="5" height="2" rx="1" fill="currentColor" opacity="0.4"/>
|
||||
<rect x="14" y="11" width="3" height="2" rx="1" fill="currentColor" opacity="0.3"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function EditMode({ 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">
|
||||
@@ -175,32 +162,6 @@ export function FolderPlus({ size = 14 }: IconProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 搜索栏图标 =====
|
||||
export function ChevronUp({ size = 12 }: 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="18 15 12 9 6 15"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChevronDown({ size = 12 }: 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="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function X({ size = 14 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 拖拽覆盖层图标 =====
|
||||
export function UploadCloud({ size = 64 }: IconProps) {
|
||||
return (
|
||||
@@ -213,25 +174,6 @@ export function UploadCloud({ size = 64 }: IconProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 箭头/导航图标 =====
|
||||
export function ArrowUp({ size = 12 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" y1="19" x2="12" y2="5"/>
|
||||
<polyline points="5 12 12 5 19 12"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ArrowDown({ size = 12 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||
<polyline points="19 12 12 19 5 12"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 欢迎屏幕图标 =====
|
||||
export function WelcomeFile({ size = 20 }: IconProps) {
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import React from 'react'
|
||||
|
||||
interface ModifiedBannerProps {
|
||||
onReload: () => void
|
||||
onDismiss: () => void
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { renderMarkdown } from '../../lib/markdown'
|
||||
import { scrollPreviewToLine, getLineAtScrollOffset, invalidateScrollCache } from '../../lib/scrollSync'
|
||||
|
||||
export function Preview() {
|
||||
const [html, setHtml] = useState('')
|
||||
const previewRef = useRef<HTMLDivElement>(null)
|
||||
const requestIdRef = useRef(0)
|
||||
const isSyncingRef = useRef(false)
|
||||
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
const tabs = useTabStore(s => s.tabs)
|
||||
@@ -24,49 +22,10 @@ export function Preview() {
|
||||
renderMarkdown(activeTab.content, activeTab.filePath).then(result => {
|
||||
if (requestId === requestIdRef.current) {
|
||||
setHtml(result)
|
||||
invalidateScrollCache()
|
||||
}
|
||||
})
|
||||
}, [activeTabId, activeTab?.content])
|
||||
|
||||
// 滚动同步:监听编辑器滚动事件(VS Code 方案:行号 → 二分查找)
|
||||
useEffect(() => {
|
||||
const handleEditorScroll = (e: Event) => {
|
||||
const line = (e as CustomEvent).detail?.line
|
||||
if (typeof line !== 'number') return
|
||||
|
||||
const container = previewRef.current?.parentElement
|
||||
if (!container || isSyncingRef.current) return
|
||||
|
||||
scrollPreviewToLine(line, container, isSyncingRef)
|
||||
}
|
||||
|
||||
window.addEventListener('editor-scroll', handleEditorScroll)
|
||||
return () => window.removeEventListener('editor-scroll', handleEditorScroll)
|
||||
}, [])
|
||||
|
||||
// 反向同步:预览滚动 → 通知编辑器
|
||||
useEffect(() => {
|
||||
const container = previewRef.current?.parentElement
|
||||
if (!container) return
|
||||
|
||||
const handleScroll = () => {
|
||||
if (isSyncingRef.current) return
|
||||
|
||||
const line = getLineAtScrollOffset(container.scrollTop, container)
|
||||
if (line !== null) {
|
||||
isSyncingRef.current = true
|
||||
window.dispatchEvent(new CustomEvent('preview-scroll', { detail: { line } }))
|
||||
requestAnimationFrame(() => {
|
||||
isSyncingRef.current = false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
container.addEventListener('scroll', handleScroll, { passive: true })
|
||||
return () => container.removeEventListener('scroll', handleScroll)
|
||||
}, [activeTabId])
|
||||
|
||||
// 拦截链接点击
|
||||
const handleClick = useCallback((e: React.MouseEvent) => {
|
||||
const link = (e.target as HTMLElement).closest('a')
|
||||
|
||||
@@ -74,12 +74,10 @@ export function Sidebar() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.electronAPI) return
|
||||
window.electronAPI.onDirChanged(() => {
|
||||
const unsubscribe = window.electronAPI.onDirChanged(() => {
|
||||
refreshTree()
|
||||
})
|
||||
return () => {
|
||||
window.electronAPI?.removeAllListeners('sidebar:dirChanged')
|
||||
}
|
||||
return unsubscribe
|
||||
}, [refreshTree])
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useState, useEffect, useRef } from 'react'
|
||||
import React, { useCallback, useState, useEffect } from 'react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { getFileName } from '../../lib/fileUtils'
|
||||
import { Close, Plus } from '../Icons'
|
||||
@@ -21,7 +21,6 @@ export function TabBar() {
|
||||
const closeTabsToRight = useTabStore(s => s.closeTabsToRight)
|
||||
|
||||
const [menu, setMenu] = useState<ContextMenuState>({ visible: false, x: 0, y: 0, tabId: '' })
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleClose = useCallback((e: React.MouseEvent, tabId: string) => {
|
||||
e.stopPropagation()
|
||||
@@ -133,7 +132,6 @@ export function TabBar() {
|
||||
{/* 右键菜单 */}
|
||||
{menu.visible && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="tab-context-menu"
|
||||
style={{ left: menu.x, top: menu.y }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import React from 'react'
|
||||
|
||||
interface ToastProps {
|
||||
message: string
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React from 'react'
|
||||
import { FolderOpen, Save, SplitView, EditMode, PreviewMode, Moon, Sun } from '../Icons'
|
||||
import { FolderOpen, Save, EditMode, PreviewMode, Moon, Sun } from '../Icons'
|
||||
|
||||
interface ToolbarProps {
|
||||
onOpen: () => void
|
||||
onSave: () => void
|
||||
viewMode: 'split' | 'editor' | 'preview'
|
||||
onViewModeChange: (mode: 'split' | 'editor' | 'preview') => void
|
||||
viewMode: 'editor' | 'preview'
|
||||
onViewModeChange: (mode: 'editor' | 'preview') => void
|
||||
darkMode: boolean
|
||||
onToggleDark: () => void
|
||||
}
|
||||
@@ -23,15 +23,11 @@ export function Toolbar({ onOpen, onSave, viewMode, onViewModeChange, darkMode,
|
||||
<span>保存</span>
|
||||
</button>
|
||||
<div className="toolbar-divider" />
|
||||
<button className={`toolbar-btn ${viewMode === 'split' ? 'active' : ''}`} onClick={() => onViewModeChange('split')} title="编辑+预览 (Ctrl+1)">
|
||||
<SplitView size={18} />
|
||||
<span>分屏</span>
|
||||
</button>
|
||||
<button className={`toolbar-btn ${viewMode === 'editor' ? 'active' : ''}`} onClick={() => onViewModeChange('editor')} title="纯编辑 (Ctrl+2)">
|
||||
<button className={`toolbar-btn ${viewMode === 'editor' ? 'active' : ''}`} onClick={() => onViewModeChange('editor')} title="编辑 (Ctrl+1)">
|
||||
<EditMode size={18} />
|
||||
<span>编辑</span>
|
||||
</button>
|
||||
<button className={`toolbar-btn ${viewMode === 'preview' ? 'active' : ''}`} onClick={() => onViewModeChange('preview')} title="纯预览 (Ctrl+3)">
|
||||
<button className={`toolbar-btn ${viewMode === 'preview' ? 'active' : ''}`} onClick={() => onViewModeChange('preview')} title="预览 (Ctrl+2)">
|
||||
<PreviewMode size={18} />
|
||||
<span>预览</span>
|
||||
</button>
|
||||
|
||||
@@ -5,19 +5,21 @@ export interface TabSnapshot {
|
||||
filePath: string | null
|
||||
content: string
|
||||
scrollTop: number
|
||||
scrollLeft: number
|
||||
selectionStart: number
|
||||
selectionEnd: number
|
||||
previewScrollTop: number
|
||||
isModified: boolean
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface ActiveTabRecord {
|
||||
id: string // 固定为 'current'
|
||||
activeTabId: string | null
|
||||
}
|
||||
|
||||
export interface SettingsRecord {
|
||||
id: string // 固定为 'default'
|
||||
darkMode: boolean
|
||||
viewMode: 'split' | 'editor' | 'preview'
|
||||
splitRatio: number
|
||||
viewMode: 'editor' | 'preview'
|
||||
sidebarCollapsed: boolean
|
||||
sidebarWidth: number
|
||||
}
|
||||
@@ -32,12 +34,14 @@ const db = new Dexie('MarkLite') as Dexie & {
|
||||
tabSnapshots: EntityTable<TabSnapshot, 'id'>
|
||||
settings: EntityTable<SettingsRecord, 'id'>
|
||||
recentFiles: EntityTable<RecentFile, 'id'>
|
||||
activeTab: EntityTable<ActiveTabRecord, 'id'>
|
||||
}
|
||||
|
||||
db.version(1).stores({
|
||||
tabSnapshots: 'id, filePath, updatedAt',
|
||||
settings: 'id',
|
||||
recentFiles: '++id, filePath, lastOpened'
|
||||
recentFiles: '++id, filePath, lastOpened',
|
||||
activeTab: 'id'
|
||||
})
|
||||
|
||||
export { db }
|
||||
|
||||
@@ -9,7 +9,6 @@ export const settingsRepository = {
|
||||
return {
|
||||
darkMode: record.darkMode ?? DEFAULT_SETTINGS.darkMode,
|
||||
viewMode: record.viewMode ?? DEFAULT_SETTINGS.viewMode,
|
||||
splitRatio: record.splitRatio ?? DEFAULT_SETTINGS.splitRatio,
|
||||
sidebarCollapsed: record.sidebarCollapsed ?? DEFAULT_SETTINGS.sidebarCollapsed,
|
||||
sidebarWidth: record.sidebarWidth ?? DEFAULT_SETTINGS.sidebarWidth
|
||||
}
|
||||
|
||||
@@ -14,5 +14,14 @@ export const tabRepository = {
|
||||
|
||||
async clearAll(): Promise<void> {
|
||||
await db.tabSnapshots.clear()
|
||||
},
|
||||
|
||||
async saveActiveTabId(tabId: string | null): Promise<void> {
|
||||
await db.activeTab.put({ id: 'current', activeTabId: tabId })
|
||||
},
|
||||
|
||||
async loadActiveTabId(): Promise<string | null> {
|
||||
const record = await db.activeTab.get('current')
|
||||
return record?.activeTabId ?? null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,15 +7,13 @@ export function useFileWatch() {
|
||||
useEffect(() => {
|
||||
if (!window.electronAPI) return
|
||||
|
||||
window.electronAPI.onExternalModification((filePath: string) => {
|
||||
const unsubscribe = window.electronAPI.onExternalModification((filePath: string) => {
|
||||
const tab = getActiveTab()
|
||||
if (tab && tab.filePath === filePath) {
|
||||
window.dispatchEvent(new CustomEvent('file-externally-modified', { detail: filePath }))
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
window.electronAPI?.removeAllListeners('file:externallyModified')
|
||||
}
|
||||
return unsubscribe
|
||||
}, [getActiveTab])
|
||||
}
|
||||
|
||||
@@ -12,9 +12,8 @@ export function useKeyboard(handleOpenFile: () => void, handleSave: () => void,
|
||||
if (isCtrl && e.key === 'o') { e.preventDefault(); handleOpenFile(); return }
|
||||
if (isCtrl && e.key === 's' && !e.shiftKey) { e.preventDefault(); handleSave(); return }
|
||||
if (isCtrl && e.shiftKey && e.key === 'S') { e.preventDefault(); handleSaveAs(); return }
|
||||
if (isCtrl && e.key === '1') { e.preventDefault(); setViewMode('split'); return }
|
||||
if (isCtrl && e.key === '2') { e.preventDefault(); setViewMode('editor'); return }
|
||||
if (isCtrl && e.key === '3') { e.preventDefault(); setViewMode('preview'); return }
|
||||
if (isCtrl && e.key === '1') { e.preventDefault(); setViewMode('editor'); return }
|
||||
if (isCtrl && e.key === '2') { e.preventDefault(); setViewMode('preview'); return }
|
||||
|
||||
const tabState = useTabStore.getState()
|
||||
if (isCtrl && e.key === 't') { e.preventDefault(); tabState.createTab(null, ''); return }
|
||||
|
||||
@@ -5,17 +5,14 @@ import type { ViewMode } from '../types/settings'
|
||||
|
||||
export function useSettings() {
|
||||
const viewMode = useEditorStore(s => s.viewMode)
|
||||
const splitRatio = useEditorStore(s => s.splitRatio)
|
||||
const setViewMode = useEditorStore(s => s.setViewMode)
|
||||
const setSplitRatio = useEditorStore(s => s.setSplitRatio)
|
||||
|
||||
// 初始化
|
||||
useEffect(() => {
|
||||
settingsRepository.load().then(settings => {
|
||||
setViewMode(settings.viewMode ?? 'split')
|
||||
setSplitRatio(settings.splitRatio ?? 50)
|
||||
setViewMode(settings.viewMode ?? 'editor')
|
||||
})
|
||||
}, [setViewMode, setSplitRatio])
|
||||
}, [setViewMode])
|
||||
|
||||
// M-10: 使用 useCallback 稳定函数引用
|
||||
const saveViewMode = useCallback((mode: ViewMode) => {
|
||||
@@ -23,10 +20,5 @@ export function useSettings() {
|
||||
settingsRepository.save({ viewMode: mode })
|
||||
}, [setViewMode])
|
||||
|
||||
const saveSplitRatio = useCallback((ratio: number) => {
|
||||
setSplitRatio(ratio)
|
||||
settingsRepository.save({ splitRatio: ratio })
|
||||
}, [setSplitRatio])
|
||||
|
||||
return { viewMode, splitRatio, saveViewMode, saveSplitRatio }
|
||||
return { viewMode, saveViewMode }
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export function useUnsavedWarning(hasUnsaved: () => boolean) {
|
||||
|
||||
const api = window.electronAPI
|
||||
|
||||
api.onConfirmClose(() => {
|
||||
const unsubscribe = api.onConfirmClose(() => {
|
||||
if (!hasUnsaved()) {
|
||||
api.forceClose()
|
||||
return
|
||||
@@ -28,8 +28,6 @@ export function useUnsavedWarning(hasUnsaved: () => boolean) {
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
api.removeAllListeners('window:confirmClose')
|
||||
}
|
||||
return unsubscribe
|
||||
}, [hasUnsaved])
|
||||
}
|
||||
|
||||
@@ -5,9 +5,4 @@ export const SKIP_DIRS = new Set([
|
||||
'node_modules', '.git', '.svn', '.hg', 'dist', 'out',
|
||||
'.next', '.nuxt', '__pycache__', '.DS_Store'
|
||||
])
|
||||
export const MARKDOWN_EXTS = new Set(['.md', '.markdown', '.txt'])
|
||||
export const UPDATE_DEBOUNCE_MS = 150
|
||||
export const LARGE_FILE_LINE_THRESHOLD = 2000
|
||||
export const CLOSE_TIMEOUT_MS = 5000
|
||||
export const WATCH_RESTART_DELAY_MS = 300
|
||||
export const DIR_REFRESH_DEBOUNCE_MS = 300
|
||||
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { ALLOWED_EXTENSIONS } from './constants'
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
export function getFileName(filePath: string): string {
|
||||
return filePath.split(/[/\\]/).pop() || filePath
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
|
||||
import rehypeStringify from 'rehype-stringify'
|
||||
import rehypeHighlight from 'rehype-highlight'
|
||||
import type { Element, Root } from 'hast'
|
||||
import { rehypeSourceLine } from './rehypeSourceLine'
|
||||
|
||||
// 自定义 rehype 插件:将相对路径图片转为 file:// 绝对路径
|
||||
function rehypeFixImages(filePath: string | null): Plugin<[], Root> {
|
||||
@@ -58,7 +57,6 @@ export async function renderMarkdown(content: string, filePath?: string | null):
|
||||
}
|
||||
})
|
||||
.use(rehypeFixImages(filePath ?? null))
|
||||
.use(rehypeSourceLine)
|
||||
.use(rehypeHighlight)
|
||||
.use(rehypeStringify)
|
||||
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { visit } from 'unist-util-visit'
|
||||
import type { Root, Element } from 'hast'
|
||||
|
||||
/**
|
||||
* Rehype 插件:给块级元素注入 data-line 属性
|
||||
*
|
||||
* 利用 unified/remark 管线自动维护的 position 信息,
|
||||
* 将源文件行号附加到渲染后的 HTML 元素上,
|
||||
* 用于编辑器↔预览的滚动同步(VS Code 方案)。
|
||||
*/
|
||||
export function rehypeSourceLine() {
|
||||
return (tree: Root) => {
|
||||
visit(tree, 'element', (node: Element) => {
|
||||
const pos = node.position
|
||||
if (!pos?.start?.line) return
|
||||
|
||||
// position.start.line 是 1-indexed,转为 0-indexed
|
||||
const line = pos.start.line - 1
|
||||
|
||||
node.properties = {
|
||||
...node.properties,
|
||||
'data-line': String(line),
|
||||
}
|
||||
|
||||
// 追加 code-line class(保留原有 class)
|
||||
const existing = node.properties?.class
|
||||
if (typeof existing === 'string') {
|
||||
node.properties.class = `${existing} code-line`
|
||||
} else if (Array.isArray(existing)) {
|
||||
node.properties.class = [...existing, 'code-line']
|
||||
} else {
|
||||
node.properties.class = 'code-line'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
/**
|
||||
* 滚动同步 — VS Code 方案
|
||||
*
|
||||
* 核心思路:
|
||||
* 1. 渲染时 rehypeSourceLine 插件给块级元素注入 data-line 属性
|
||||
* 2. 预览侧收集所有 [data-line] 元素,按行号排序
|
||||
* 3. 编辑器派发当前行号 → 二分查找对应元素 → 线性插值滚动
|
||||
* 4. 预览侧反向映射:像素偏移 → 行号 → 通知编辑器
|
||||
*/
|
||||
|
||||
interface CodeLineElement {
|
||||
element: HTMLElement
|
||||
line: number
|
||||
top: number
|
||||
height: number
|
||||
}
|
||||
|
||||
let cachedElements: CodeLineElement[] | null = null
|
||||
let cachedHTML = ''
|
||||
|
||||
/**
|
||||
* 收集并缓存所有带 data-line 的元素,按行号排序
|
||||
*/
|
||||
function getCodeLineElements(container: HTMLElement): CodeLineElement[] {
|
||||
const html = container.innerHTML
|
||||
if (cachedElements && cachedHTML === html) return cachedElements
|
||||
|
||||
cachedHTML = html
|
||||
const els = container.querySelectorAll<HTMLElement>('[data-line]')
|
||||
const result: CodeLineElement[] = []
|
||||
|
||||
for (const el of els) {
|
||||
const line = parseInt(el.getAttribute('data-line')!, 10)
|
||||
if (isNaN(line)) continue
|
||||
// 跳过不可见元素
|
||||
if (el.offsetHeight === 0) continue
|
||||
result.push({
|
||||
element: el,
|
||||
line,
|
||||
top: el.offsetTop,
|
||||
height: el.offsetHeight,
|
||||
})
|
||||
}
|
||||
|
||||
result.sort((a, b) => a.line - b.line)
|
||||
cachedElements = result
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 使缓存失效(内容变化后调用)
|
||||
*/
|
||||
export function invalidateScrollCache(): void {
|
||||
cachedElements = null
|
||||
cachedHTML = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 二分查找:找到目标行对应的元素(精确匹配或前后夹逼)
|
||||
*/
|
||||
function findElementsForLine(
|
||||
elements: CodeLineElement[],
|
||||
targetLine: number,
|
||||
): { previous: CodeLineElement; next: CodeLineElement | null } {
|
||||
let lo = 0
|
||||
let hi = elements.length - 1
|
||||
|
||||
// 二分查找最后一个 line <= targetLine 的元素
|
||||
while (lo < hi) {
|
||||
const mid = Math.floor((lo + hi + 1) / 2)
|
||||
if (elements[mid].line <= targetLine) {
|
||||
lo = mid
|
||||
} else {
|
||||
hi = mid - 1
|
||||
}
|
||||
}
|
||||
|
||||
const previous = elements[lo]
|
||||
const next = lo < elements.length - 1 ? elements[lo + 1] : null
|
||||
|
||||
return { previous, next }
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑器滚动 → 预览滚动
|
||||
*
|
||||
* @param line 编辑器当前可见区域顶部对应的源文件行号(0-indexed,支持小数表示行内偏移)
|
||||
* @param previewContainer 预览面板的滚动容器(#preview-panel)
|
||||
* @param isSyncingRef 双向同步锁,防止循环触发
|
||||
*/
|
||||
export function scrollPreviewToLine(
|
||||
line: number,
|
||||
previewContainer: HTMLElement,
|
||||
isSyncingRef: { current: boolean },
|
||||
): void {
|
||||
const elements = getCodeLineElements(previewContainer)
|
||||
if (elements.length === 0) return
|
||||
|
||||
isSyncingRef.current = true
|
||||
|
||||
const lineNumber = Math.floor(line)
|
||||
const fraction = line - lineNumber
|
||||
|
||||
const { previous, next } = findElementsForLine(elements, lineNumber)
|
||||
|
||||
let scrollTo: number
|
||||
|
||||
if (previous.line === lineNumber) {
|
||||
// 精确匹配:滚动到该元素顶部
|
||||
scrollTo = previous.top + fraction * previous.height
|
||||
} else if (next && next.line !== previous.line) {
|
||||
// 在两个元素之间:线性插值
|
||||
const progress = (lineNumber - previous.line + fraction) / (next.line - previous.line)
|
||||
const gapTop = previous.top + previous.height
|
||||
const gapHeight = next.top - gapTop
|
||||
scrollTo = gapTop + progress * (previous.height + gapHeight)
|
||||
} else {
|
||||
// 最后一个元素之后:按行内比例偏移
|
||||
scrollTo = previous.top + fraction * previous.height
|
||||
}
|
||||
|
||||
previewContainer.scrollTo(0, Math.max(0, scrollTo))
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
isSyncingRef.current = false
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览滚动 → 编辑器行号(用于反向同步)
|
||||
*
|
||||
* @param scrollTop 预览面板的 scrollTop
|
||||
* @param previewContainer 预览面板的滚动容器
|
||||
* @returns 对应的源文件行号(0-indexed),无元素时返回 null
|
||||
*/
|
||||
export function getLineAtScrollOffset(
|
||||
scrollTop: number,
|
||||
previewContainer: HTMLElement,
|
||||
): number | null {
|
||||
const elements = getCodeLineElements(previewContainer)
|
||||
if (elements.length === 0) return null
|
||||
|
||||
const position = scrollTop
|
||||
|
||||
// 二分查找最后一个 top <= position 的元素
|
||||
let lo = 0
|
||||
let hi = elements.length - 1
|
||||
while (lo < hi) {
|
||||
const mid = Math.floor((lo + hi + 1) / 2)
|
||||
if (elements[mid].top <= position) {
|
||||
lo = mid
|
||||
} else {
|
||||
hi = mid - 1
|
||||
}
|
||||
}
|
||||
|
||||
const el = elements[lo]
|
||||
if (el.height === 0) return el.line
|
||||
|
||||
const progress = Math.min(1, Math.max(0, (position - el.top) / el.height))
|
||||
const nextLine = lo < elements.length - 1 ? elements[lo + 1].line : el.line + 1
|
||||
|
||||
return el.line + progress * (nextLine - el.line)
|
||||
}
|
||||
@@ -4,21 +4,17 @@ import type { ViewMode } from '../types/settings'
|
||||
interface EditorState {
|
||||
viewMode: ViewMode
|
||||
darkMode: boolean
|
||||
splitRatio: number
|
||||
|
||||
setViewMode: (mode: ViewMode) => void
|
||||
setDarkMode: (dark: boolean) => void
|
||||
setSplitRatio: (ratio: number) => void
|
||||
toggleDarkMode: () => void
|
||||
}
|
||||
|
||||
export const useEditorStore = create<EditorState>((set) => ({
|
||||
viewMode: 'split',
|
||||
viewMode: 'editor',
|
||||
darkMode: false,
|
||||
splitRatio: 50,
|
||||
|
||||
setViewMode: (mode) => set({ viewMode: mode }),
|
||||
setDarkMode: (dark) => set({ darkMode: dark }),
|
||||
setSplitRatio: (ratio) => set({ splitRatio: ratio }),
|
||||
toggleDarkMode: () => set(state => ({ darkMode: !state.darkMode }))
|
||||
}))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { create } from 'zustand'
|
||||
import type { FileNode } from '../types/file'
|
||||
import { settingsRepository } from '../db/settingsRepository'
|
||||
|
||||
interface SidebarState {
|
||||
isVisible: boolean
|
||||
@@ -7,6 +8,7 @@ interface SidebarState {
|
||||
tree: FileNode[]
|
||||
expandedDirs: Set<string>
|
||||
sidebarWidth: number
|
||||
_loaded: boolean
|
||||
|
||||
setVisible: (visible: boolean) => void
|
||||
setRootPath: (path: string | null) => void
|
||||
@@ -14,16 +16,37 @@ interface SidebarState {
|
||||
toggleDir: (path: string) => void
|
||||
expandDirs: (paths: string[]) => void
|
||||
setSidebarWidth: (width: number) => void
|
||||
loadFromDB: () => Promise<void>
|
||||
}
|
||||
|
||||
export const useSidebarStore = create<SidebarState>((set) => ({
|
||||
export const useSidebarStore = create<SidebarState>((set, get) => ({
|
||||
isVisible: true,
|
||||
rootPath: null,
|
||||
tree: [],
|
||||
expandedDirs: new Set<string>(),
|
||||
sidebarWidth: 240,
|
||||
_loaded: false,
|
||||
|
||||
setVisible: (visible) => set({ isVisible: visible }),
|
||||
// 从 IndexedDB 加载 sidebar 设置
|
||||
loadFromDB: async () => {
|
||||
if (get()._loaded) return
|
||||
try {
|
||||
const settings = await settingsRepository.load()
|
||||
set({
|
||||
isVisible: !settings.sidebarCollapsed,
|
||||
sidebarWidth: settings.sidebarWidth,
|
||||
_loaded: true
|
||||
})
|
||||
} catch {
|
||||
set({ _loaded: true })
|
||||
}
|
||||
},
|
||||
|
||||
setVisible: (visible) => {
|
||||
set({ isVisible: visible })
|
||||
// 异步保存到 DB
|
||||
settingsRepository.save({ sidebarCollapsed: !visible })
|
||||
},
|
||||
setRootPath: (path) => set({ rootPath: path }),
|
||||
setTree: (tree) => set({ tree }),
|
||||
toggleDir: (path) =>
|
||||
@@ -48,5 +71,9 @@ export const useSidebarStore = create<SidebarState>((set) => ({
|
||||
}
|
||||
return changed ? { expandedDirs: newSet } : state
|
||||
}),
|
||||
setSidebarWidth: (width) => set({ sidebarWidth: width })
|
||||
setSidebarWidth: (width) => {
|
||||
set({ sidebarWidth: width })
|
||||
// 异步保存到 DB
|
||||
settingsRepository.save({ sidebarWidth: width })
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -18,8 +18,7 @@ interface TabState {
|
||||
updateTabContent: (tabId: string, content: string) => void
|
||||
setModified: (tabId: string, modified: boolean) => void
|
||||
getActiveTab: () => Tab | null
|
||||
getTabIndex: (tabId: string) => number
|
||||
updateTabScroll: (tabId: string, scroll: Partial<Pick<Tab, 'scrollTop' | 'scrollLeft' | 'selectionStart' | 'selectionEnd' | 'previewScrollTop'>>) => void
|
||||
updateTabScroll: (tabId: string, scroll: Partial<Pick<Tab, 'scrollTop' | 'selectionStart' | 'selectionEnd'>>) => void
|
||||
loadFromDB: () => Promise<void>
|
||||
saveToDB: () => Promise<void>
|
||||
}
|
||||
@@ -34,7 +33,10 @@ export const useTabStore = create<TabState>((set, get) => ({
|
||||
loadFromDB: async () => {
|
||||
if (get()._loaded) return
|
||||
try {
|
||||
const snapshots = await tabRepository.loadAll()
|
||||
const [snapshots, savedActiveTabId] = await Promise.all([
|
||||
tabRepository.loadAll(),
|
||||
tabRepository.loadActiveTabId()
|
||||
])
|
||||
if (snapshots.length > 0) {
|
||||
const tabs: Tab[] = snapshots.map(s => ({
|
||||
id: s.id,
|
||||
@@ -42,14 +44,16 @@ export const useTabStore = create<TabState>((set, get) => ({
|
||||
content: s.content,
|
||||
isModified: s.isModified,
|
||||
scrollTop: s.scrollTop,
|
||||
scrollLeft: s.scrollLeft,
|
||||
selectionStart: s.selectionStart,
|
||||
selectionEnd: s.selectionEnd,
|
||||
previewScrollTop: s.previewScrollTop
|
||||
selectionEnd: s.selectionEnd
|
||||
}))
|
||||
// 使用保存的 activeTabId,如果不存在则取最后一个
|
||||
const activeTabId = (savedActiveTabId && tabs.find(t => t.id === savedActiveTabId))
|
||||
? savedActiveTabId
|
||||
: tabs[tabs.length - 1].id
|
||||
set({
|
||||
tabs,
|
||||
activeTabId: tabs[tabs.length - 1].id,
|
||||
activeTabId,
|
||||
_loaded: true
|
||||
})
|
||||
} else {
|
||||
@@ -73,13 +77,12 @@ export const useTabStore = create<TabState>((set, get) => ({
|
||||
content: t.content,
|
||||
isModified: t.isModified,
|
||||
scrollTop: t.scrollTop,
|
||||
scrollLeft: t.scrollLeft,
|
||||
selectionStart: t.selectionStart,
|
||||
selectionEnd: t.selectionEnd,
|
||||
previewScrollTop: t.previewScrollTop,
|
||||
updatedAt: Date.now()
|
||||
}))
|
||||
await tabRepository.saveAll(snapshots)
|
||||
await tabRepository.saveActiveTabId(get().activeTabId)
|
||||
},
|
||||
|
||||
createTab: (filePath = null, content = '') => {
|
||||
@@ -97,10 +100,8 @@ export const useTabStore = create<TabState>((set, get) => ({
|
||||
content,
|
||||
isModified: false,
|
||||
scrollTop: 0,
|
||||
scrollLeft: 0,
|
||||
selectionStart: 0,
|
||||
selectionEnd: 0,
|
||||
previewScrollTop: 0
|
||||
selectionEnd: 0
|
||||
}
|
||||
|
||||
set(state => ({
|
||||
@@ -187,6 +188,8 @@ export const useTabStore = create<TabState>((set, get) => ({
|
||||
: state.mruStack
|
||||
return { activeTabId: tabId, mruStack: newMru }
|
||||
})
|
||||
// 异步保存 activeTabId
|
||||
setTimeout(() => tabRepository.saveActiveTabId(tabId), 0)
|
||||
},
|
||||
|
||||
updateTabContent: (tabId, content) => {
|
||||
@@ -210,10 +213,6 @@ export const useTabStore = create<TabState>((set, get) => ({
|
||||
return tabs.find(t => t.id === activeTabId) ?? null
|
||||
},
|
||||
|
||||
getTabIndex: (tabId) => {
|
||||
return get().tabs.findIndex(t => t.id === tabId)
|
||||
},
|
||||
|
||||
updateTabScroll: (tabId, scroll) => {
|
||||
set(state => ({
|
||||
tabs: state.tabs.map(t =>
|
||||
|
||||
@@ -447,19 +447,6 @@ html, body {
|
||||
border-left-color: var(--text);
|
||||
}
|
||||
|
||||
/* Resizer */
|
||||
#resizer {
|
||||
width: 4px;
|
||||
background: var(--border);
|
||||
cursor: col-resize;
|
||||
transition: background 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#resizer:hover, #resizer.active {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
/* Preview Panel */
|
||||
#preview-panel {
|
||||
flex: 1;
|
||||
@@ -1031,11 +1018,8 @@ html, body {
|
||||
}
|
||||
|
||||
/* View Modes */
|
||||
#app.mode-preview #editor-panel,
|
||||
#app.mode-preview #resizer { display: none; }
|
||||
|
||||
#app.mode-editor #preview-panel,
|
||||
#app.mode-editor #resizer { display: none; }
|
||||
#app.mode-preview #editor-panel { display: none; }
|
||||
#app.mode-editor #preview-panel { display: none; }
|
||||
|
||||
/* Toast */
|
||||
#toast-notification {
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface IpcInvokeMap {
|
||||
'dir:unwatch': [void, void]
|
||||
}
|
||||
|
||||
export type Unsubscribe = () => void
|
||||
|
||||
export interface ElectronAPI {
|
||||
openFile: () => Promise<OpenFileResponse>
|
||||
readFile: (filePath: string) => Promise<ReadFileResult>
|
||||
@@ -42,14 +44,13 @@ export interface ElectronAPI {
|
||||
openFolderDialog: () => Promise<string | null>
|
||||
watchDir: (dirPath: string) => Promise<void>
|
||||
unwatchDir: () => Promise<void>
|
||||
onFileOpenInTab: (callback: (data: { filePath: string; content: string }) => void) => void
|
||||
onMenuSave: (callback: () => void) => void
|
||||
onMenuSaveAs: (callback: () => void) => void
|
||||
onViewModeChange: (callback: (mode: string) => void) => void
|
||||
onExternalModification: (callback: (filePath: string) => void) => void
|
||||
onDirChanged: (callback: () => void) => void
|
||||
onConfirmClose: (callback: () => void) => void
|
||||
removeAllListeners: (channel: string) => void
|
||||
onFileOpenInTab: (callback: (data: { filePath: string; content: string }) => void) => Unsubscribe
|
||||
onMenuSave: (callback: () => void) => Unsubscribe
|
||||
onMenuSaveAs: (callback: () => void) => Unsubscribe
|
||||
onViewModeChange: (callback: (mode: string) => void) => Unsubscribe
|
||||
onExternalModification: (callback: (filePath: string) => void) => Unsubscribe
|
||||
onDirChanged: (callback: () => void) => Unsubscribe
|
||||
onConfirmClose: (callback: () => void) => Unsubscribe
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
export type ViewMode = 'split' | 'editor' | 'preview'
|
||||
export type ViewMode = 'editor' | 'preview'
|
||||
|
||||
export interface Settings {
|
||||
darkMode: boolean
|
||||
viewMode: ViewMode
|
||||
splitRatio: number
|
||||
sidebarCollapsed: boolean
|
||||
sidebarWidth: number
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
darkMode: false,
|
||||
viewMode: 'split',
|
||||
splitRatio: 50,
|
||||
viewMode: 'editor',
|
||||
sidebarCollapsed: false,
|
||||
sidebarWidth: 240
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ export interface Tab {
|
||||
content: string
|
||||
isModified: boolean
|
||||
scrollTop: number
|
||||
scrollLeft: number
|
||||
selectionStart: number
|
||||
selectionEnd: number
|
||||
previewScrollTop: number
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user