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:
thzxx
2026-05-28 13:42:11 +08:00
parent 3c6e4ac5ce
commit 9c92dcfa9d
37 changed files with 242 additions and 655 deletions
+6 -9
View File
@@ -112,7 +112,7 @@ MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程
│ │ tabStore │ │editorStore│ │sidebarStore│ │searchStore│ │ │ │ tabStore │ │editorStore│ │sidebarStore│ │searchStore│ │
│ │ - tabs │ │- viewMode│ │- tree │ │- matches │ │ │ │ - tabs │ │- viewMode│ │- tree │ │- matches │ │
│ │- activeId│ │- darkMode│ │- expanded │ │- index │ │ │ │- activeId│ │- darkMode│ │- expanded │ │- index │ │
│ │ - mru │ │- splitPct│ │- rootPath │ │- options │ │ │ │ - mru │ │- viewMode│ │- rootPath │ │- options │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └──────────┘ │ │ └────┬─────┘ └────┬─────┘ └────┬─────┘ └──────────┘ │
│ │ │ │ │ │ │ │ │ │
│ ┌────▼────────────▼────────────▼────────────────────┐ │ │ ┌────▼────────────▼────────────▼────────────────────┐ │
@@ -144,9 +144,7 @@ interface TabState {
```typescript ```typescript
interface EditorState { interface EditorState {
viewMode: 'split' | 'editor' | 'preview' // 视图模式 viewMode: 'editor' | 'preview' // 视图模式
darkMode: boolean // 暗色主题
splitRatio: number // 分屏比例 (20~80)
} }
``` ```
@@ -198,7 +196,7 @@ db.version(1).stores({
| Store | 字段 | 说明 | | Store | 字段 | 说明 |
|-------|------|------| |-------|------|------|
| `tabSnapshots` | id, filePath, content, scrollTop, scrollLeft, selectionStart, selectionEnd, previewScrollTop, isModified, updatedAt | 标签页状态快照,关闭时保存,启动时恢复 | | `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 | 最近打开文件列表 | | `recentFiles` | ++id, filePath, lastOpened | 最近打开文件列表 |
### 4.3 IndexedDB 优势(vs localStorage ### 4.3 IndexedDB 优势(vs localStorage
@@ -418,7 +416,7 @@ dangerouslySetInnerHTML 渲染到 React DOM
┌──────────────────────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────────────────────┐
│ MarkLite - filename.md ─ □ ✕ │ │ MarkLite - filename.md ─ □ ✕ │
├──────────────────────────────────────────────────────────────────────────┤ ├──────────────────────────────────────────────────────────────────────────┤
│ 📁 打开 │ 💾 保存 │ ⬜ 分屏 │ ✏️ 编辑 │ 👁 预览 │ 🌙 │ │ 📁 打开 │ 💾 保存 │ ✏️ 编辑 │ 👁 预览 │ 🌙 │
├──────────────────────────────────────────────────────────────────────────┤ ├──────────────────────────────────────────────────────────────────────────┤
│ [file1.md] [file2.md] [未命名] [+] │ │ [file1.md] [file2.md] [未命名] [+] │
├──────────────────────────────────────────────────────────────────────────┤ ├──────────────────────────────────────────────────────────────────────────┤
@@ -448,9 +446,8 @@ dangerouslySetInnerHTML 渲染到 React DOM
| `Ctrl + O` | 打开文件 | | `Ctrl + O` | 打开文件 |
| `Ctrl + S` | 保存文件 | | `Ctrl + S` | 保存文件 |
| `Ctrl + Shift + S` | 另存为 | | `Ctrl + Shift + S` | 另存为 |
| `Ctrl + 1` | 编辑 + 预览(分屏) | | `Ctrl + 1` | 编辑模式 |
| `Ctrl + 2` | 纯编辑模式 | | `Ctrl + 2` | 预览模式 |
| `Ctrl + 3` | 纯预览模式 |
| `Ctrl + F` | 搜索 | | `Ctrl + F` | 搜索 |
| `Ctrl + H` | 搜索并替换 | | `Ctrl + H` | 搜索并替换 |
| `Enter` / `Shift+Enter` | 下一个 / 上一个匹配 | | `Enter` / `Shift+Enter` | 下一个 / 上一个匹配 |
+5 -7
View File
@@ -33,8 +33,7 @@
| ✏️ **实时编辑** | 左侧编辑器,支持 Tab 缩进、行号显示、光标位置、大文件虚拟化行号 | | ✏️ **实时编辑** | 左侧编辑器,支持 Tab 缩进、行号显示、光标位置、大文件虚拟化行号 |
| 👁 **实时预览** | 右侧预览面板,基于 unified/rehype 管线渲染,编辑即更新 | | 👁 **实时预览** | 右侧预览面板,基于 unified/rehype 管线渲染,编辑即更新 |
| 🔤 **代码高亮** | 基于 rehype-highlight,支持 180+ 种编程语言语法高亮 | | 🔤 **代码高亮** | 基于 rehype-highlight,支持 180+ 种编程语言语法高亮 |
| 🎨 **种视图** | 分屏模式 / 纯编辑 / 纯预览,自由切换 | | 🎨 **种视图** | 编辑模式 / 预览模式,自由切换 |
| 📐 **分屏调节** | 拖拽中间分隔条,自由调整编辑区与预览区比例 |
| 🌙 **暗色主题** | 一键切换亮色/暗色主题,偏好自动记忆(IndexedDB) | | 🌙 **暗色主题** | 一键切换亮色/暗色主题,偏好自动记忆(IndexedDB) |
| 🔔 **文件监听** | 外部修改文件时自动提示,支持重新加载或忽略 | | 🔔 **文件监听** | 外部修改文件时自动提示,支持重新加载或忽略 |
| 💾 **文件保存** | 保存 / 另存为,支持 .md / .markdown / .txt 格式 | | 💾 **文件保存** | 保存 / 另存为,支持 .md / .markdown / .txt 格式 |
@@ -50,7 +49,7 @@
┌──────────────────────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────────────────────┐
│ MarkLite - README.md ─ □ ✕ │ │ MarkLite - README.md ─ □ ✕ │
├──────────────────────────────────────────────────────────────────────────┤ ├──────────────────────────────────────────────────────────────────────────┤
│ 📁 打开 │ 💾 保存 │ ⬜ 分屏 │ ✏️ 编辑 │ 👁 预览 │ 🌙 │ │ 📁 打开 │ 💾 保存 │ ✏️ 编辑 │ 👁 预览 │ 🌙 │
├──────────────────────────────────────────────────────────────────────────┤ ├──────────────────────────────────────────────────────────────────────────┤
│ [README.md] [DESIGN.md] [main.ts] [+] │ │ [README.md] [DESIGN.md] [main.ts] [+] │
├──────────┬─────────────────────────┬─────────────────────────────────────┤ ├──────────┬─────────────────────────┬─────────────────────────────────────┤
@@ -125,9 +124,8 @@ npm run lint
| `Ctrl + O` | 打开文件 | | `Ctrl + O` | 打开文件 |
| `Ctrl + S` | 保存文件 | | `Ctrl + S` | 保存文件 |
| `Ctrl + Shift + S` | 另存为 | | `Ctrl + Shift + S` | 另存为 |
| `Ctrl + 1` | 编辑 + 预览(分屏) | | `Ctrl + 1` | 编辑模式 |
| `Ctrl + 2` | 纯编辑模式 | | `Ctrl + 2` | 预览模式 |
| `Ctrl + 3` | 纯预览模式 |
| `Ctrl + F` | 搜索 | | `Ctrl + F` | 搜索 |
| `Ctrl + H` | 搜索并替换 | | `Ctrl + H` | 搜索并替换 |
| `Enter` / `Shift+Enter` | 下一个 / 上一个匹配 | | `Enter` / `Shift+Enter` | 下一个 / 上一个匹配 |
@@ -194,7 +192,7 @@ MarkLite/
│ │ │ │ │ │
│ │ ├── stores/ # ----- Zustand 状态管理 ----- │ │ ├── stores/ # ----- Zustand 状态管理 -----
│ │ │ ├── tabStore.ts # 标签页状态(tabs、activeTabId、mruStack │ │ │ ├── tabStore.ts # 标签页状态(tabs、activeTabId、mruStack
│ │ │ ├── editorStore.ts # 编辑器状态(viewMode、darkMode、splitRatio │ │ │ ├── editorStore.ts # 编辑器状态(viewMode、darkMode
│ │ │ ├── sidebarStore.ts # 侧边栏状态(tree、expandedDirs、rootPath │ │ │ ├── sidebarStore.ts # 侧边栏状态(tree、expandedDirs、rootPath
│ │ │ └── searchStore.ts # 搜索替换状态(matches、currentIndex、options │ │ │ └── searchStore.ts # 搜索替换状态(matches、currentIndex、options
│ │ │ │ │ │
-6
View File
@@ -74,9 +74,3 @@ export async function buildDirTree(dirPath: string, depth = 0, maxDepth = 10): P
} }
return children 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'
}
+1
View File
@@ -29,6 +29,7 @@ export class FileWatcher {
this.watcher = null this.watcher = null
} }
this.currentPath = null this.currentPath = null
this.isSelfWriting = false
}) })
} catch { } catch {
// silently ignore // silently ignore
-23
View File
@@ -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
View File
@@ -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 { readFileContent, saveFileContent, buildDirTree } from './file-system'
import { FileWatcher, SidebarWatcher } from './file-watcher' 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 { 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( export function registerIpcHandlers(
getMainWindow: () => BrowserWindow | null, 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) 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() const win = getMainWindow()
try { try {
if (data.filePath) { 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() const win = getMainWindow()
if (!win) return { success: false, error: '窗口不可用' } if (!win) return { success: false, error: '窗口不可用' }
try { try {
@@ -95,16 +114,24 @@ export function registerIpcHandlers(
filters: [{ name: 'Markdown 文件', extensions: ['md'] }] filters: [{ name: 'Markdown 文件', extensions: ['md'] }]
}) })
if (!result.canceled) { if (!result.canceled) {
fileWatcher.stop()
fileWatcher.setSelfWriting(true)
const saveResult = await saveFileContent(result.filePath, data.content) const saveResult = await saveFileContent(result.filePath, data.content)
fileWatcher.setSelfWriting(false)
if (saveResult.success) { if (saveResult.success) {
state.activeFilePath = result.filePath state.activeFilePath = result.filePath
setTimeout(() => {
if (state.activeFilePath === result.filePath) {
fileWatcher.start(result.filePath) fileWatcher.start(result.filePath)
}
}, 300)
win.setTitle(`MarkLite - ${basename(result.filePath)}`) win.setTitle(`MarkLite - ${basename(result.filePath)}`)
} }
return saveResult return saveResult
} }
return { success: false, canceled: true } return { success: false, canceled: true }
} catch (err) { } catch (err) {
fileWatcher.setSelfWriting(false)
return { success: false, error: (err as Error).message } 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_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 { try {
const fileStat = await stat(filePath) const fileStat = await stat(filePath)
return { success: true, size: fileStat.size, mtime: fileStat.mtime.toISOString() } 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 { try {
const tree = await buildDirTree(dirPath) const tree = await buildDirTree(dirPath)
return { success: true, tree, rootPath: 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) 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 state.activeFilePath = filePath || null
fileWatcher.start(filePath || '') fileWatcher.start(filePath || '')
const win = getMainWindow() const win = getMainWindow()
+2 -1
View File
@@ -12,7 +12,8 @@ export function createWindow(): BrowserWindow {
webPreferences: { webPreferences: {
preload: join(__dirname, '../preload/index.js'), preload: join(__dirname, '../preload/index.js'),
contextIsolation: true, contextIsolation: true,
nodeIntegration: false nodeIntegration: false,
sandbox: true
}, },
titleBarStyle: 'default', titleBarStyle: 'default',
show: false show: false
+61 -48
View File
@@ -1,62 +1,75 @@
import { contextBridge, ipcRenderer, shell } from 'electron' import { contextBridge, ipcRenderer, shell } from 'electron'
import { IPC_CHANNELS } from '../shared/ipc-channels'
// M-01: 限制 removeAllListeners 只能操作白名单通道
const ALLOWED_REMOVE_CHANNELS = new Set([
'file:openInTab',
'menu:save',
'menu:saveAs',
'menu:viewMode',
'file:externallyModified',
'sidebar:dirChanged',
'window:confirmClose'
])
contextBridge.exposeInMainWorld('electronAPI', { contextBridge.exposeInMainWorld('electronAPI', {
// File operations // File operations
openFile: () => ipcRenderer.invoke('dialog:openFile'), openFile: () => ipcRenderer.invoke(IPC_CHANNELS.DIALOG_OPEN_FILE),
readFile: (filePath: string) => ipcRenderer.invoke('file:read', filePath), readFile: (filePath: string) => ipcRenderer.invoke(IPC_CHANNELS.FILE_READ, filePath),
saveFile: (data: { filePath: string | null; content: string }) => ipcRenderer.invoke('file:save', data), saveFile: (data: { filePath: string | null; content: string }) => ipcRenderer.invoke(IPC_CHANNELS.FILE_SAVE, data),
saveFileAs: (data: { content: string }) => ipcRenderer.invoke('file:saveAs', data), saveFileAs: (data: { content: string }) => ipcRenderer.invoke(IPC_CHANNELS.FILE_SAVE_AS, data),
getCurrentPath: () => ipcRenderer.invoke('file:getCurrentPath'), getCurrentPath: () => ipcRenderer.invoke(IPC_CHANNELS.FILE_GET_CURRENT_PATH),
getFileStats: (filePath: string) => ipcRenderer.invoke('file:stats', filePath), getFileStats: (filePath: string) => ipcRenderer.invoke(IPC_CHANNELS.FILE_STATS, filePath),
reloadFile: () => ipcRenderer.invoke('file:reload'), reloadFile: () => ipcRenderer.invoke(IPC_CHANNELS.FILE_RELOAD),
// Tab management // Tab management
tabSwitched: (filePath: string | null) => ipcRenderer.invoke('tab:switched', filePath), tabSwitched: (filePath: string | null) => ipcRenderer.invoke(IPC_CHANNELS.TAB_SWITCHED, filePath),
// Window control // Window control
forceClose: () => ipcRenderer.invoke('window:forceClose'), forceClose: () => ipcRenderer.invoke(IPC_CHANNELS.WINDOW_FORCE_CLOSE),
cancelClose: () => ipcRenderer.invoke('window:cancelClose'), cancelClose: () => ipcRenderer.invoke(IPC_CHANNELS.WINDOW_CANCEL_CLOSE),
// Shell // Shell — 仅允许 http/https 协议
openExternal: (url: string) => shell.openExternal(url), openExternal: (url: string) => {
try {
const parsed = new URL(url)
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
shell.openExternal(url)
}
} catch {
// 无效 URL,忽略
}
},
// File Tree (Sidebar) // File Tree (Sidebar)
readDirTree: (dirPath: string) => ipcRenderer.invoke('dir:readTree', dirPath), readDirTree: (dirPath: string) => ipcRenderer.invoke(IPC_CHANNELS.DIR_READ_TREE, dirPath),
openFolderDialog: () => ipcRenderer.invoke('dir:openDialog'), openFolderDialog: () => ipcRenderer.invoke(IPC_CHANNELS.DIR_OPEN_DIALOG),
watchDir: (dirPath: string) => ipcRenderer.invoke('dir:watch', dirPath), watchDir: (dirPath: string) => ipcRenderer.invoke(IPC_CHANNELS.DIR_WATCH, dirPath),
unwatchDir: () => ipcRenderer.invoke('dir:unwatch'), unwatchDir: () => ipcRenderer.invoke(IPC_CHANNELS.DIR_UNWATCH),
// Events from main process // Events from main process — 返回取消订阅函数
onFileOpenInTab: (callback: (data: { filePath: string; content: string }) => void) => onFileOpenInTab: (callback: (data: { filePath: string; content: string }) => void) => {
ipcRenderer.on('file:openInTab', (_event, data) => callback(data)), const handler = (_event: Electron.IpcRendererEvent, data: { filePath: string; content: string }) => callback(data)
onMenuSave: (callback: () => void) => ipcRenderer.on(IPC_CHANNELS.FILE_OPEN_IN_TAB, handler)
ipcRenderer.on('menu:save', () => callback()), return () => { ipcRenderer.removeListener(IPC_CHANNELS.FILE_OPEN_IN_TAB, handler) }
onMenuSaveAs: (callback: () => void) => },
ipcRenderer.on('menu:saveAs', () => callback()), onMenuSave: (callback: () => void) => {
onViewModeChange: (callback: (mode: string) => void) => const handler = () => callback()
ipcRenderer.on('menu:viewMode', (_event, mode) => callback(mode)), ipcRenderer.on(IPC_CHANNELS.MENU_SAVE, handler)
onExternalModification: (callback: (filePath: string) => void) => return () => { ipcRenderer.removeListener(IPC_CHANNELS.MENU_SAVE, handler) }
ipcRenderer.on('file:externallyModified', (_event, filePath) => callback(filePath)), },
onDirChanged: (callback: () => void) => onMenuSaveAs: (callback: () => void) => {
ipcRenderer.on('sidebar:dirChanged', () => callback()), const handler = () => callback()
onConfirmClose: (callback: () => void) => ipcRenderer.on(IPC_CHANNELS.MENU_SAVE_AS, handler)
ipcRenderer.on('window:confirmClose', () => callback()), return () => { ipcRenderer.removeListener(IPC_CHANNELS.MENU_SAVE_AS, handler) }
},
// M-01: 只允许移除白名单通道的监听器 onViewModeChange: (callback: (mode: string) => void) => {
removeAllListeners: (channel: string) => { const handler = (_event: Electron.IpcRendererEvent, mode: string) => callback(mode)
if (ALLOWED_REMOVE_CHANNELS.has(channel)) { ipcRenderer.on(IPC_CHANNELS.MENU_VIEW_MODE, handler)
ipcRenderer.removeAllListeners(channel) 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
View File
@@ -1,6 +1,7 @@
import React, { useState, useCallback, useEffect, useRef } from 'react' import React, { useState, useCallback, useEffect, useRef } from 'react'
import { useTabStore } from './stores/tabStore' import { useTabStore } from './stores/tabStore'
import { useEditorStore } from './stores/editorStore' import { useEditorStore } from './stores/editorStore'
import { useSidebarStore } from './stores/sidebarStore'
import { useTheme } from './hooks/useTheme' import { useTheme } from './hooks/useTheme'
import { useSettings } from './hooks/useSettings' import { useSettings } from './hooks/useSettings'
import { useKeyboard } from './hooks/useKeyboard' import { useKeyboard } from './hooks/useKeyboard'
@@ -27,25 +28,29 @@ export default function App() {
const setModified = useTabStore(s => s.setModified) const setModified = useTabStore(s => s.setModified)
const updateTabContent = useTabStore(s => s.updateTabContent) const updateTabContent = useTabStore(s => s.updateTabContent)
const loadFromDB = useTabStore(s => s.loadFromDB) const loadFromDB = useTabStore(s => s.loadFromDB)
const loadSidebarFromDB = useSidebarStore(s => s.loadFromDB)
const viewMode = useEditorStore(s => s.viewMode) const viewMode = useEditorStore(s => s.viewMode)
const splitRatio = useEditorStore(s => s.splitRatio)
const { darkMode, toggleDarkMode } = useTheme() const { darkMode, toggleDarkMode } = useTheme()
const { saveViewMode, saveSplitRatio } = useSettings() const { saveViewMode } = useSettings()
const [showModifiedBanner, setShowModifiedBanner] = useState(false) const [showModifiedBanner, setShowModifiedBanner] = useState(false)
const [modifiedFilePath, setModifiedFilePath] = useState<string | null>(null) const [modifiedFilePath, setModifiedFilePath] = useState<string | null>(null)
const [toastMessage, setToastMessage] = useState<string | null>(null) const [toastMessage, setToastMessage] = useState<string | null>(null)
const toastTimer = useRef<number>(0)
// 启动时从 IndexedDB 加载标签页 // 启动时从 IndexedDB 加载标签页和 sidebar 设置
useEffect(() => { useEffect(() => {
loadFromDB() loadFromDB()
}, [loadFromDB]) loadSidebarFromDB()
return () => clearTimeout(toastTimer.current)
}, [loadFromDB, loadSidebarFromDB])
const showToast = useCallback((msg: string) => { const showToast = useCallback((msg: string) => {
clearTimeout(toastTimer.current)
setToastMessage(msg) setToastMessage(msg)
setTimeout(() => setToastMessage(null), 3000) toastTimer.current = window.setTimeout(() => setToastMessage(null), 3000)
}, []) }, [])
// 拖拽打开 // 拖拽打开
@@ -128,7 +133,7 @@ export default function App() {
}, [createTab]) }, [createTab])
// 视图模式切换 // 视图模式切换
const handleViewModeChange = useCallback((mode: 'split' | 'editor' | 'preview') => { const handleViewModeChange = useCallback((mode: 'editor' | 'preview') => {
saveViewMode(mode) saveViewMode(mode)
}, [saveViewMode]) }, [saveViewMode])
@@ -150,28 +155,19 @@ export default function App() {
const onSave = () => handleSaveRef.current() const onSave = () => handleSaveRef.current()
const onSaveAs = () => handleSaveAsRef.current() const onSaveAs = () => handleSaveAsRef.current()
api.onFileOpenInTab(onOpen) const unsub1 = api.onFileOpenInTab(onOpen)
api.onMenuSave(onSave) const unsub2 = api.onMenuSave(onSave)
api.onMenuSaveAs(onSaveAs) const unsub3 = api.onMenuSaveAs(onSaveAs)
return () => { return () => {
api.removeAllListeners('file:openInTab') unsub1()
api.removeAllListeners('menu:save') unsub2()
api.removeAllListeners('menu:saveAs') unsub3()
} }
}, [createTab]) }, [createTab])
const activeTab = getActiveTab()
const hasTabs = tabs.length > 0 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 ( return (
<div id="app" className={`mode-${viewMode}`}> <div id="app" className={`mode-${viewMode}`}>
<Toolbar <Toolbar
@@ -193,9 +189,9 @@ export default function App() {
<ModifiedBanner <ModifiedBanner
onReload={() => { onReload={() => {
if (window.electronAPI && modifiedFilePath) { if (window.electronAPI && modifiedFilePath) {
window.electronAPI.reloadFile().then(result => { window.electronAPI.readFile(modifiedFilePath).then(result => {
if (result.success && result.content) { if (result.success && result.content) {
const tab = getActiveTab() const tab = tabs.find(t => t.filePath === modifiedFilePath)
if (tab) { if (tab) {
updateTabContent(tab.id, result.content) updateTabContent(tab.id, result.content)
setModified(tab.id, false) setModified(tab.id, false)
@@ -211,14 +207,13 @@ export default function App() {
{hasTabs ? ( {hasTabs ? (
<div id="content-wrapper"> <div id="content-wrapper">
{viewMode !== 'preview' && ( {viewMode === 'editor' && (
<div id="editor-panel" style={editorStyle}> <div id="editor-panel">
<Editor darkMode={darkMode} /> <Editor darkMode={darkMode} />
</div> </div>
)} )}
{viewMode !== 'editor' && <Resizer onResize={saveSplitRatio} />} {viewMode === 'preview' && (
{viewMode !== 'editor' && ( <div id="preview-panel">
<div id="preview-panel" style={previewStyle}>
<Preview /> <Preview />
</div> </div>
)} )}
@@ -240,41 +235,3 @@ export default function App() {
</div> </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} />
}
+2 -19
View File
@@ -22,8 +22,7 @@ export function Editor({ darkMode }: EditorProps) {
getScrollTop, getScrollTop,
setScrollTop, setScrollTop,
getSelection, getSelection,
setSelection, setSelection
setLineAtTop
} = useCodeMirror({ } = useCodeMirror({
content: activeTab?.content ?? '', content: activeTab?.content ?? '',
onChange: useCallback((value: string) => { onChange: useCallback((value: string) => {
@@ -31,25 +30,9 @@ export function Editor({ darkMode }: EditorProps) {
updateTabContent(activeTabId, value) updateTabContent(activeTabId, value)
setModified(activeTabId, true) setModified(activeTabId, true)
}, [activeTabId, updateTabContent, setModified]), }, [activeTabId, updateTabContent, setModified]),
darkMode, darkMode
onScroll: useCallback((line: number) => {
// 派发行号给预览侧做滚动同步
window.dispatchEvent(new CustomEvent('editor-scroll', { detail: { line } }))
}, [])
}) })
// 反向同步:监听预览滚动事件
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(() => { useEffect(() => {
if (!activeTab) return if (!activeTab) return
@@ -11,10 +11,9 @@ interface UseCodeMirrorOptions {
content: string content: string
onChange: (value: string) => void onChange: (value: string) => void
darkMode: boolean 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 containerRef = useRef<HTMLDivElement>(null)
const viewRef = useRef<EditorView | null>(null) const viewRef = useRef<EditorView | null>(null)
const isExternalUpdate = useRef(false) const isExternalUpdate = useRef(false)
@@ -81,23 +80,7 @@ export function useCodeMirror({ content, onChange, darkMode, onScroll }: UseCode
viewRef.current = view 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 () => { return () => {
view.scrollDOM.removeEventListener('scroll', handleScroll)
view.destroy() view.destroy()
viewRef.current = null viewRef.current = null
} }
@@ -116,12 +99,6 @@ export function useCodeMirror({ content, onChange, darkMode, onScroll }: UseCode
isExternalUpdate.current = false isExternalUpdate.current = false
} }
}, []) }, [])
// 获取当前内容
const getContent = useCallback(() => {
return viewRef.current?.state.doc.toString() ?? ''
}, [])
// 获取/设置滚动位置 // 获取/设置滚动位置
const getScrollTop = useCallback(() => { const getScrollTop = useCallback(() => {
return viewRef.current?.scrollDOM.scrollTop ?? 0 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.dispatch({ selection: { anchor: from, head: to } })
view.focus() 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 { return {
containerRef, containerRef,
viewRef, viewRef,
setContent, setContent,
getContent,
getScrollTop, getScrollTop,
setScrollTop, setScrollTop,
getSelection, getSelection,
setSelection, setSelection
scrollTo,
setLineAtTop
} }
} }
-58
View File
@@ -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) { export function EditMode({ size = defaultProps.size }: IconProps) {
return ( return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"> <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) { export function UploadCloud({ size = 64 }: IconProps) {
return ( 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) { export function WelcomeFile({ size = 20 }: IconProps) {
return ( return (
@@ -1,5 +1,3 @@
import React from 'react'
interface ModifiedBannerProps { interface ModifiedBannerProps {
onReload: () => void onReload: () => void
onDismiss: () => void onDismiss: () => void
@@ -1,13 +1,11 @@
import React, { useState, useEffect, useCallback, useRef } from 'react' import React, { useState, useEffect, useCallback, useRef } from 'react'
import { useTabStore } from '../../stores/tabStore' import { useTabStore } from '../../stores/tabStore'
import { renderMarkdown } from '../../lib/markdown' import { renderMarkdown } from '../../lib/markdown'
import { scrollPreviewToLine, getLineAtScrollOffset, invalidateScrollCache } from '../../lib/scrollSync'
export function Preview() { export function Preview() {
const [html, setHtml] = useState('') const [html, setHtml] = useState('')
const previewRef = useRef<HTMLDivElement>(null) const previewRef = useRef<HTMLDivElement>(null)
const requestIdRef = useRef(0) const requestIdRef = useRef(0)
const isSyncingRef = useRef(false)
const activeTabId = useTabStore(s => s.activeTabId) const activeTabId = useTabStore(s => s.activeTabId)
const tabs = useTabStore(s => s.tabs) const tabs = useTabStore(s => s.tabs)
@@ -24,49 +22,10 @@ export function Preview() {
renderMarkdown(activeTab.content, activeTab.filePath).then(result => { renderMarkdown(activeTab.content, activeTab.filePath).then(result => {
if (requestId === requestIdRef.current) { if (requestId === requestIdRef.current) {
setHtml(result) setHtml(result)
invalidateScrollCache()
} }
}) })
}, [activeTabId, activeTab?.content]) }, [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 handleClick = useCallback((e: React.MouseEvent) => {
const link = (e.target as HTMLElement).closest('a') const link = (e.target as HTMLElement).closest('a')
+2 -4
View File
@@ -74,12 +74,10 @@ export function Sidebar() {
useEffect(() => { useEffect(() => {
if (!window.electronAPI) return if (!window.electronAPI) return
window.electronAPI.onDirChanged(() => { const unsubscribe = window.electronAPI.onDirChanged(() => {
refreshTree() refreshTree()
}) })
return () => { return unsubscribe
window.electronAPI?.removeAllListeners('sidebar:dirChanged')
}
}, [refreshTree]) }, [refreshTree])
useEffect(() => { useEffect(() => {
+1 -3
View File
@@ -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 { useTabStore } from '../../stores/tabStore'
import { getFileName } from '../../lib/fileUtils' import { getFileName } from '../../lib/fileUtils'
import { Close, Plus } from '../Icons' import { Close, Plus } from '../Icons'
@@ -21,7 +21,6 @@ export function TabBar() {
const closeTabsToRight = useTabStore(s => s.closeTabsToRight) const closeTabsToRight = useTabStore(s => s.closeTabsToRight)
const [menu, setMenu] = useState<ContextMenuState>({ visible: false, x: 0, y: 0, tabId: '' }) 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) => { const handleClose = useCallback((e: React.MouseEvent, tabId: string) => {
e.stopPropagation() e.stopPropagation()
@@ -133,7 +132,6 @@ export function TabBar() {
{/* 右键菜单 */} {/* 右键菜单 */}
{menu.visible && ( {menu.visible && (
<div <div
ref={menuRef}
className="tab-context-menu" className="tab-context-menu"
style={{ left: menu.x, top: menu.y }} style={{ left: menu.x, top: menu.y }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
-2
View File
@@ -1,5 +1,3 @@
import React from 'react'
interface ToastProps { interface ToastProps {
message: string message: string
} }
+5 -9
View File
@@ -1,11 +1,11 @@
import React from 'react' 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 { interface ToolbarProps {
onOpen: () => void onOpen: () => void
onSave: () => void onSave: () => void
viewMode: 'split' | 'editor' | 'preview' viewMode: 'editor' | 'preview'
onViewModeChange: (mode: 'split' | 'editor' | 'preview') => void onViewModeChange: (mode: 'editor' | 'preview') => void
darkMode: boolean darkMode: boolean
onToggleDark: () => void onToggleDark: () => void
} }
@@ -23,15 +23,11 @@ export function Toolbar({ onOpen, onSave, viewMode, onViewModeChange, darkMode,
<span></span> <span></span>
</button> </button>
<div className="toolbar-divider" /> <div className="toolbar-divider" />
<button className={`toolbar-btn ${viewMode === 'split' ? 'active' : ''}`} onClick={() => onViewModeChange('split')} title="编辑+预览 (Ctrl+1)"> <button className={`toolbar-btn ${viewMode === 'editor' ? 'active' : ''}`} onClick={() => onViewModeChange('editor')} title="编辑 (Ctrl+1)">
<SplitView size={18} />
<span></span>
</button>
<button className={`toolbar-btn ${viewMode === 'editor' ? 'active' : ''}`} onClick={() => onViewModeChange('editor')} title="纯编辑 (Ctrl+2)">
<EditMode size={18} /> <EditMode size={18} />
<span></span> <span></span>
</button> </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} /> <PreviewMode size={18} />
<span></span> <span></span>
</button> </button>
+9 -5
View File
@@ -5,19 +5,21 @@ export interface TabSnapshot {
filePath: string | null filePath: string | null
content: string content: string
scrollTop: number scrollTop: number
scrollLeft: number
selectionStart: number selectionStart: number
selectionEnd: number selectionEnd: number
previewScrollTop: number
isModified: boolean isModified: boolean
updatedAt: number updatedAt: number
} }
export interface ActiveTabRecord {
id: string // 固定为 'current'
activeTabId: string | null
}
export interface SettingsRecord { export interface SettingsRecord {
id: string // 固定为 'default' id: string // 固定为 'default'
darkMode: boolean darkMode: boolean
viewMode: 'split' | 'editor' | 'preview' viewMode: 'editor' | 'preview'
splitRatio: number
sidebarCollapsed: boolean sidebarCollapsed: boolean
sidebarWidth: number sidebarWidth: number
} }
@@ -32,12 +34,14 @@ const db = new Dexie('MarkLite') as Dexie & {
tabSnapshots: EntityTable<TabSnapshot, 'id'> tabSnapshots: EntityTable<TabSnapshot, 'id'>
settings: EntityTable<SettingsRecord, 'id'> settings: EntityTable<SettingsRecord, 'id'>
recentFiles: EntityTable<RecentFile, 'id'> recentFiles: EntityTable<RecentFile, 'id'>
activeTab: EntityTable<ActiveTabRecord, 'id'>
} }
db.version(1).stores({ db.version(1).stores({
tabSnapshots: 'id, filePath, updatedAt', tabSnapshots: 'id, filePath, updatedAt',
settings: 'id', settings: 'id',
recentFiles: '++id, filePath, lastOpened' recentFiles: '++id, filePath, lastOpened',
activeTab: 'id'
}) })
export { db } export { db }
-1
View File
@@ -9,7 +9,6 @@ export const settingsRepository = {
return { return {
darkMode: record.darkMode ?? DEFAULT_SETTINGS.darkMode, darkMode: record.darkMode ?? DEFAULT_SETTINGS.darkMode,
viewMode: record.viewMode ?? DEFAULT_SETTINGS.viewMode, viewMode: record.viewMode ?? DEFAULT_SETTINGS.viewMode,
splitRatio: record.splitRatio ?? DEFAULT_SETTINGS.splitRatio,
sidebarCollapsed: record.sidebarCollapsed ?? DEFAULT_SETTINGS.sidebarCollapsed, sidebarCollapsed: record.sidebarCollapsed ?? DEFAULT_SETTINGS.sidebarCollapsed,
sidebarWidth: record.sidebarWidth ?? DEFAULT_SETTINGS.sidebarWidth sidebarWidth: record.sidebarWidth ?? DEFAULT_SETTINGS.sidebarWidth
} }
+9
View File
@@ -14,5 +14,14 @@ export const tabRepository = {
async clearAll(): Promise<void> { async clearAll(): Promise<void> {
await db.tabSnapshots.clear() 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
} }
} }
+2 -4
View File
@@ -7,15 +7,13 @@ export function useFileWatch() {
useEffect(() => { useEffect(() => {
if (!window.electronAPI) return if (!window.electronAPI) return
window.electronAPI.onExternalModification((filePath: string) => { const unsubscribe = window.electronAPI.onExternalModification((filePath: string) => {
const tab = getActiveTab() const tab = getActiveTab()
if (tab && tab.filePath === filePath) { if (tab && tab.filePath === filePath) {
window.dispatchEvent(new CustomEvent('file-externally-modified', { detail: filePath })) window.dispatchEvent(new CustomEvent('file-externally-modified', { detail: filePath }))
} }
}) })
return () => { return unsubscribe
window.electronAPI?.removeAllListeners('file:externallyModified')
}
}, [getActiveTab]) }, [getActiveTab])
} }
+2 -3
View File
@@ -12,9 +12,8 @@ export function useKeyboard(handleOpenFile: () => void, handleSave: () => void,
if (isCtrl && e.key === 'o') { e.preventDefault(); handleOpenFile(); return } if (isCtrl && e.key === 'o') { e.preventDefault(); handleOpenFile(); return }
if (isCtrl && e.key === 's' && !e.shiftKey) { e.preventDefault(); handleSave(); 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.shiftKey && e.key === 'S') { e.preventDefault(); handleSaveAs(); return }
if (isCtrl && e.key === '1') { e.preventDefault(); setViewMode('split'); return } if (isCtrl && e.key === '1') { e.preventDefault(); setViewMode('editor'); return }
if (isCtrl && e.key === '2') { e.preventDefault(); setViewMode('editor'); return } if (isCtrl && e.key === '2') { e.preventDefault(); setViewMode('preview'); return }
if (isCtrl && e.key === '3') { e.preventDefault(); setViewMode('preview'); return }
const tabState = useTabStore.getState() const tabState = useTabStore.getState()
if (isCtrl && e.key === 't') { e.preventDefault(); tabState.createTab(null, ''); return } if (isCtrl && e.key === 't') { e.preventDefault(); tabState.createTab(null, ''); return }
+3 -11
View File
@@ -5,17 +5,14 @@ import type { ViewMode } from '../types/settings'
export function useSettings() { export function useSettings() {
const viewMode = useEditorStore(s => s.viewMode) const viewMode = useEditorStore(s => s.viewMode)
const splitRatio = useEditorStore(s => s.splitRatio)
const setViewMode = useEditorStore(s => s.setViewMode) const setViewMode = useEditorStore(s => s.setViewMode)
const setSplitRatio = useEditorStore(s => s.setSplitRatio)
// 初始化 // 初始化
useEffect(() => { useEffect(() => {
settingsRepository.load().then(settings => { settingsRepository.load().then(settings => {
setViewMode(settings.viewMode ?? 'split') setViewMode(settings.viewMode ?? 'editor')
setSplitRatio(settings.splitRatio ?? 50)
}) })
}, [setViewMode, setSplitRatio]) }, [setViewMode])
// M-10: 使用 useCallback 稳定函数引用 // M-10: 使用 useCallback 稳定函数引用
const saveViewMode = useCallback((mode: ViewMode) => { const saveViewMode = useCallback((mode: ViewMode) => {
@@ -23,10 +20,5 @@ export function useSettings() {
settingsRepository.save({ viewMode: mode }) settingsRepository.save({ viewMode: mode })
}, [setViewMode]) }, [setViewMode])
const saveSplitRatio = useCallback((ratio: number) => { return { viewMode, saveViewMode }
setSplitRatio(ratio)
settingsRepository.save({ splitRatio: ratio })
}, [setSplitRatio])
return { viewMode, splitRatio, saveViewMode, saveSplitRatio }
} }
+2 -4
View File
@@ -15,7 +15,7 @@ export function useUnsavedWarning(hasUnsaved: () => boolean) {
const api = window.electronAPI const api = window.electronAPI
api.onConfirmClose(() => { const unsubscribe = api.onConfirmClose(() => {
if (!hasUnsaved()) { if (!hasUnsaved()) {
api.forceClose() api.forceClose()
return return
@@ -28,8 +28,6 @@ export function useUnsavedWarning(hasUnsaved: () => boolean) {
} }
}) })
return () => { return unsubscribe
api.removeAllListeners('window:confirmClose')
}
}, [hasUnsaved]) }, [hasUnsaved])
} }
+1 -6
View File
@@ -5,9 +5,4 @@ export const SKIP_DIRS = new Set([
'node_modules', '.git', '.svn', '.hg', 'dist', 'out', 'node_modules', '.git', '.svn', '.hg', 'dist', 'out',
'.next', '.nuxt', '__pycache__', '.DS_Store' '.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
-6
View File
@@ -1,11 +1,5 @@
import { ALLOWED_EXTENSIONS } from './constants' 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 { export function getFileName(filePath: string): string {
return filePath.split(/[/\\]/).pop() || filePath return filePath.split(/[/\\]/).pop() || filePath
} }
-2
View File
@@ -7,7 +7,6 @@ import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
import rehypeStringify from 'rehype-stringify' import rehypeStringify from 'rehype-stringify'
import rehypeHighlight from 'rehype-highlight' import rehypeHighlight from 'rehype-highlight'
import type { Element, Root } from 'hast' import type { Element, Root } from 'hast'
import { rehypeSourceLine } from './rehypeSourceLine'
// 自定义 rehype 插件:将相对路径图片转为 file:// 绝对路径 // 自定义 rehype 插件:将相对路径图片转为 file:// 绝对路径
function rehypeFixImages(filePath: string | null): Plugin<[], Root> { 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(rehypeFixImages(filePath ?? null))
.use(rehypeSourceLine)
.use(rehypeHighlight) .use(rehypeHighlight)
.use(rehypeStringify) .use(rehypeStringify)
-36
View File
@@ -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'
}
})
}
}
-164
View File
@@ -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)
}
+1 -5
View File
@@ -4,21 +4,17 @@ import type { ViewMode } from '../types/settings'
interface EditorState { interface EditorState {
viewMode: ViewMode viewMode: ViewMode
darkMode: boolean darkMode: boolean
splitRatio: number
setViewMode: (mode: ViewMode) => void setViewMode: (mode: ViewMode) => void
setDarkMode: (dark: boolean) => void setDarkMode: (dark: boolean) => void
setSplitRatio: (ratio: number) => void
toggleDarkMode: () => void toggleDarkMode: () => void
} }
export const useEditorStore = create<EditorState>((set) => ({ export const useEditorStore = create<EditorState>((set) => ({
viewMode: 'split', viewMode: 'editor',
darkMode: false, darkMode: false,
splitRatio: 50,
setViewMode: (mode) => set({ viewMode: mode }), setViewMode: (mode) => set({ viewMode: mode }),
setDarkMode: (dark) => set({ darkMode: dark }), setDarkMode: (dark) => set({ darkMode: dark }),
setSplitRatio: (ratio) => set({ splitRatio: ratio }),
toggleDarkMode: () => set(state => ({ darkMode: !state.darkMode })) toggleDarkMode: () => set(state => ({ darkMode: !state.darkMode }))
})) }))
+30 -3
View File
@@ -1,5 +1,6 @@
import { create } from 'zustand' import { create } from 'zustand'
import type { FileNode } from '../types/file' import type { FileNode } from '../types/file'
import { settingsRepository } from '../db/settingsRepository'
interface SidebarState { interface SidebarState {
isVisible: boolean isVisible: boolean
@@ -7,6 +8,7 @@ interface SidebarState {
tree: FileNode[] tree: FileNode[]
expandedDirs: Set<string> expandedDirs: Set<string>
sidebarWidth: number sidebarWidth: number
_loaded: boolean
setVisible: (visible: boolean) => void setVisible: (visible: boolean) => void
setRootPath: (path: string | null) => void setRootPath: (path: string | null) => void
@@ -14,16 +16,37 @@ interface SidebarState {
toggleDir: (path: string) => void toggleDir: (path: string) => void
expandDirs: (paths: string[]) => void expandDirs: (paths: string[]) => void
setSidebarWidth: (width: number) => void setSidebarWidth: (width: number) => void
loadFromDB: () => Promise<void>
} }
export const useSidebarStore = create<SidebarState>((set) => ({ export const useSidebarStore = create<SidebarState>((set, get) => ({
isVisible: true, isVisible: true,
rootPath: null, rootPath: null,
tree: [], tree: [],
expandedDirs: new Set<string>(), expandedDirs: new Set<string>(),
sidebarWidth: 240, 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 }), setRootPath: (path) => set({ rootPath: path }),
setTree: (tree) => set({ tree }), setTree: (tree) => set({ tree }),
toggleDir: (path) => toggleDir: (path) =>
@@ -48,5 +71,9 @@ export const useSidebarStore = create<SidebarState>((set) => ({
} }
return changed ? { expandedDirs: newSet } : state return changed ? { expandedDirs: newSet } : state
}), }),
setSidebarWidth: (width) => set({ sidebarWidth: width }) setSidebarWidth: (width) => {
set({ sidebarWidth: width })
// 异步保存到 DB
settingsRepository.save({ sidebarWidth: width })
}
})) }))
+15 -16
View File
@@ -18,8 +18,7 @@ interface TabState {
updateTabContent: (tabId: string, content: string) => void updateTabContent: (tabId: string, content: string) => void
setModified: (tabId: string, modified: boolean) => void setModified: (tabId: string, modified: boolean) => void
getActiveTab: () => Tab | null getActiveTab: () => Tab | null
getTabIndex: (tabId: string) => number updateTabScroll: (tabId: string, scroll: Partial<Pick<Tab, 'scrollTop' | 'selectionStart' | 'selectionEnd'>>) => void
updateTabScroll: (tabId: string, scroll: Partial<Pick<Tab, 'scrollTop' | 'scrollLeft' | 'selectionStart' | 'selectionEnd' | 'previewScrollTop'>>) => void
loadFromDB: () => Promise<void> loadFromDB: () => Promise<void>
saveToDB: () => Promise<void> saveToDB: () => Promise<void>
} }
@@ -34,7 +33,10 @@ export const useTabStore = create<TabState>((set, get) => ({
loadFromDB: async () => { loadFromDB: async () => {
if (get()._loaded) return if (get()._loaded) return
try { try {
const snapshots = await tabRepository.loadAll() const [snapshots, savedActiveTabId] = await Promise.all([
tabRepository.loadAll(),
tabRepository.loadActiveTabId()
])
if (snapshots.length > 0) { if (snapshots.length > 0) {
const tabs: Tab[] = snapshots.map(s => ({ const tabs: Tab[] = snapshots.map(s => ({
id: s.id, id: s.id,
@@ -42,14 +44,16 @@ export const useTabStore = create<TabState>((set, get) => ({
content: s.content, content: s.content,
isModified: s.isModified, isModified: s.isModified,
scrollTop: s.scrollTop, scrollTop: s.scrollTop,
scrollLeft: s.scrollLeft,
selectionStart: s.selectionStart, selectionStart: s.selectionStart,
selectionEnd: s.selectionEnd, selectionEnd: s.selectionEnd
previewScrollTop: s.previewScrollTop
})) }))
// 使用保存的 activeTabId,如果不存在则取最后一个
const activeTabId = (savedActiveTabId && tabs.find(t => t.id === savedActiveTabId))
? savedActiveTabId
: tabs[tabs.length - 1].id
set({ set({
tabs, tabs,
activeTabId: tabs[tabs.length - 1].id, activeTabId,
_loaded: true _loaded: true
}) })
} else { } else {
@@ -73,13 +77,12 @@ export const useTabStore = create<TabState>((set, get) => ({
content: t.content, content: t.content,
isModified: t.isModified, isModified: t.isModified,
scrollTop: t.scrollTop, scrollTop: t.scrollTop,
scrollLeft: t.scrollLeft,
selectionStart: t.selectionStart, selectionStart: t.selectionStart,
selectionEnd: t.selectionEnd, selectionEnd: t.selectionEnd,
previewScrollTop: t.previewScrollTop,
updatedAt: Date.now() updatedAt: Date.now()
})) }))
await tabRepository.saveAll(snapshots) await tabRepository.saveAll(snapshots)
await tabRepository.saveActiveTabId(get().activeTabId)
}, },
createTab: (filePath = null, content = '') => { createTab: (filePath = null, content = '') => {
@@ -97,10 +100,8 @@ export const useTabStore = create<TabState>((set, get) => ({
content, content,
isModified: false, isModified: false,
scrollTop: 0, scrollTop: 0,
scrollLeft: 0,
selectionStart: 0, selectionStart: 0,
selectionEnd: 0, selectionEnd: 0
previewScrollTop: 0
} }
set(state => ({ set(state => ({
@@ -187,6 +188,8 @@ export const useTabStore = create<TabState>((set, get) => ({
: state.mruStack : state.mruStack
return { activeTabId: tabId, mruStack: newMru } return { activeTabId: tabId, mruStack: newMru }
}) })
// 异步保存 activeTabId
setTimeout(() => tabRepository.saveActiveTabId(tabId), 0)
}, },
updateTabContent: (tabId, content) => { updateTabContent: (tabId, content) => {
@@ -210,10 +213,6 @@ export const useTabStore = create<TabState>((set, get) => ({
return tabs.find(t => t.id === activeTabId) ?? null return tabs.find(t => t.id === activeTabId) ?? null
}, },
getTabIndex: (tabId) => {
return get().tabs.findIndex(t => t.id === tabId)
},
updateTabScroll: (tabId, scroll) => { updateTabScroll: (tabId, scroll) => {
set(state => ({ set(state => ({
tabs: state.tabs.map(t => tabs: state.tabs.map(t =>
+2 -18
View File
@@ -447,19 +447,6 @@ html, body {
border-left-color: var(--text); 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 */
#preview-panel { #preview-panel {
flex: 1; flex: 1;
@@ -1031,11 +1018,8 @@ html, body {
} }
/* View Modes */ /* View Modes */
#app.mode-preview #editor-panel, #app.mode-preview #editor-panel { display: none; }
#app.mode-preview #resizer { display: none; } #app.mode-editor #preview-panel { display: none; }
#app.mode-editor #preview-panel,
#app.mode-editor #resizer { display: none; }
/* Toast */ /* Toast */
#toast-notification { #toast-notification {
+9 -8
View File
@@ -26,6 +26,8 @@ export interface IpcInvokeMap {
'dir:unwatch': [void, void] 'dir:unwatch': [void, void]
} }
export type Unsubscribe = () => void
export interface ElectronAPI { export interface ElectronAPI {
openFile: () => Promise<OpenFileResponse> openFile: () => Promise<OpenFileResponse>
readFile: (filePath: string) => Promise<ReadFileResult> readFile: (filePath: string) => Promise<ReadFileResult>
@@ -42,14 +44,13 @@ export interface ElectronAPI {
openFolderDialog: () => Promise<string | null> openFolderDialog: () => Promise<string | null>
watchDir: (dirPath: string) => Promise<void> watchDir: (dirPath: string) => Promise<void>
unwatchDir: () => Promise<void> unwatchDir: () => Promise<void>
onFileOpenInTab: (callback: (data: { filePath: string; content: string }) => void) => void onFileOpenInTab: (callback: (data: { filePath: string; content: string }) => void) => Unsubscribe
onMenuSave: (callback: () => void) => void onMenuSave: (callback: () => void) => Unsubscribe
onMenuSaveAs: (callback: () => void) => void onMenuSaveAs: (callback: () => void) => Unsubscribe
onViewModeChange: (callback: (mode: string) => void) => void onViewModeChange: (callback: (mode: string) => void) => Unsubscribe
onExternalModification: (callback: (filePath: string) => void) => void onExternalModification: (callback: (filePath: string) => void) => Unsubscribe
onDirChanged: (callback: () => void) => void onDirChanged: (callback: () => void) => Unsubscribe
onConfirmClose: (callback: () => void) => void onConfirmClose: (callback: () => void) => Unsubscribe
removeAllListeners: (channel: string) => void
} }
declare global { declare global {
+2 -4
View File
@@ -1,17 +1,15 @@
export type ViewMode = 'split' | 'editor' | 'preview' export type ViewMode = 'editor' | 'preview'
export interface Settings { export interface Settings {
darkMode: boolean darkMode: boolean
viewMode: ViewMode viewMode: ViewMode
splitRatio: number
sidebarCollapsed: boolean sidebarCollapsed: boolean
sidebarWidth: number sidebarWidth: number
} }
export const DEFAULT_SETTINGS: Settings = { export const DEFAULT_SETTINGS: Settings = {
darkMode: false, darkMode: false,
viewMode: 'split', viewMode: 'editor',
splitRatio: 50,
sidebarCollapsed: false, sidebarCollapsed: false,
sidebarWidth: 240 sidebarWidth: 240
} }
-2
View File
@@ -4,8 +4,6 @@ export interface Tab {
content: string content: string
isModified: boolean isModified: boolean
scrollTop: number scrollTop: number
scrollLeft: number
selectionStart: number selectionStart: number
selectionEnd: number selectionEnd: number
previewScrollTop: number
} }