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│ │
|
│ │ 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` | 下一个 / 上一个匹配 |
|
||||||
|
|||||||
@@ -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)
|
||||||
│ │ │
|
│ │ │
|
||||||
|
|||||||
@@ -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'
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
+44
-11
@@ -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
|
||||||
fileWatcher.start(result.filePath)
|
setTimeout(() => {
|
||||||
|
if (state.activeFilePath === 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()
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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} />
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,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')
|
||||||
|
|||||||
@@ -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,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,8 +132,7 @@ 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()}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import React from 'react'
|
|
||||||
|
|
||||||
interface ToastProps {
|
interface ToastProps {
|
||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
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 }))
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -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 =>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user