diff --git a/DESIGN.md b/DESIGN.md index 20b877b..33e55dc 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -112,7 +112,7 @@ MarkLite 是一款轻量级的 Windows 本地 Markdown 编辑器桌面应用程 │ │ tabStore │ │editorStore│ │sidebarStore│ │searchStore│ │ │ │ - tabs │ │- viewMode│ │- tree │ │- matches │ │ │ │- activeId│ │- darkMode│ │- expanded │ │- index │ │ -│ │ - mru │ │- splitPct│ │- rootPath │ │- options │ │ +│ │ - mru │ │- viewMode│ │- rootPath │ │- options │ │ │ └────┬─────┘ └────┬─────┘ └────┬─────┘ └──────────┘ │ │ │ │ │ │ │ ┌────▼────────────▼────────────▼────────────────────┐ │ @@ -144,9 +144,7 @@ interface TabState { ```typescript interface EditorState { - viewMode: 'split' | 'editor' | 'preview' // 视图模式 - darkMode: boolean // 暗色主题 - splitRatio: number // 分屏比例 (20~80) + viewMode: 'editor' | 'preview' // 视图模式 } ``` @@ -198,7 +196,7 @@ db.version(1).stores({ | Store | 字段 | 说明 | |-------|------|------| | `tabSnapshots` | id, filePath, content, scrollTop, scrollLeft, selectionStart, selectionEnd, previewScrollTop, isModified, updatedAt | 标签页状态快照,关闭时保存,启动时恢复 | -| `settings` | id(固定'default'), darkMode, viewMode, splitRatio, sidebarCollapsed, sidebarWidth | 用户偏好设置 | +| `settings` | id(固定'default'), darkMode, viewMode, sidebarCollapsed, sidebarWidth | 用户偏好设置 | | `recentFiles` | ++id, filePath, lastOpened | 最近打开文件列表 | ### 4.3 IndexedDB 优势(vs localStorage) @@ -418,7 +416,7 @@ dangerouslySetInnerHTML 渲染到 React DOM ┌──────────────────────────────────────────────────────────────────────────┐ │ MarkLite - filename.md ─ □ ✕ │ ├──────────────────────────────────────────────────────────────────────────┤ -│ 📁 打开 │ 💾 保存 │ ⬜ 分屏 │ ✏️ 编辑 │ 👁 预览 │ 🌙 │ +│ 📁 打开 │ 💾 保存 │ ✏️ 编辑 │ 👁 预览 │ 🌙 │ ├──────────────────────────────────────────────────────────────────────────┤ │ [file1.md] [file2.md] [未命名] [+] │ ├──────────────────────────────────────────────────────────────────────────┤ @@ -448,9 +446,8 @@ dangerouslySetInnerHTML 渲染到 React DOM | `Ctrl + O` | 打开文件 | | `Ctrl + S` | 保存文件 | | `Ctrl + Shift + S` | 另存为 | -| `Ctrl + 1` | 编辑 + 预览(分屏) | -| `Ctrl + 2` | 纯编辑模式 | -| `Ctrl + 3` | 纯预览模式 | +| `Ctrl + 1` | 编辑模式 | +| `Ctrl + 2` | 预览模式 | | `Ctrl + F` | 搜索 | | `Ctrl + H` | 搜索并替换 | | `Enter` / `Shift+Enter` | 下一个 / 上一个匹配 | diff --git a/README.md b/README.md index 5c192da..da290e2 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,7 @@ | ✏️ **实时编辑** | 左侧编辑器,支持 Tab 缩进、行号显示、光标位置、大文件虚拟化行号 | | 👁 **实时预览** | 右侧预览面板,基于 unified/rehype 管线渲染,编辑即更新 | | 🔤 **代码高亮** | 基于 rehype-highlight,支持 180+ 种编程语言语法高亮 | -| 🎨 **三种视图** | 分屏模式 / 纯编辑 / 纯预览,自由切换 | -| 📐 **分屏调节** | 拖拽中间分隔条,自由调整编辑区与预览区比例 | +| 🎨 **两种视图** | 编辑模式 / 预览模式,自由切换 | | 🌙 **暗色主题** | 一键切换亮色/暗色主题,偏好自动记忆(IndexedDB) | | 🔔 **文件监听** | 外部修改文件时自动提示,支持重新加载或忽略 | | 💾 **文件保存** | 保存 / 另存为,支持 .md / .markdown / .txt 格式 | @@ -50,7 +49,7 @@ ┌──────────────────────────────────────────────────────────────────────────┐ │ MarkLite - README.md ─ □ ✕ │ ├──────────────────────────────────────────────────────────────────────────┤ -│ 📁 打开 │ 💾 保存 │ ⬜ 分屏 │ ✏️ 编辑 │ 👁 预览 │ 🌙 │ +│ 📁 打开 │ 💾 保存 │ ✏️ 编辑 │ 👁 预览 │ 🌙 │ ├──────────────────────────────────────────────────────────────────────────┤ │ [README.md] [DESIGN.md] [main.ts] [+] │ ├──────────┬─────────────────────────┬─────────────────────────────────────┤ @@ -125,9 +124,8 @@ npm run lint | `Ctrl + O` | 打开文件 | | `Ctrl + S` | 保存文件 | | `Ctrl + Shift + S` | 另存为 | -| `Ctrl + 1` | 编辑 + 预览(分屏) | -| `Ctrl + 2` | 纯编辑模式 | -| `Ctrl + 3` | 纯预览模式 | +| `Ctrl + 1` | 编辑模式 | +| `Ctrl + 2` | 预览模式 | | `Ctrl + F` | 搜索 | | `Ctrl + H` | 搜索并替换 | | `Enter` / `Shift+Enter` | 下一个 / 上一个匹配 | @@ -194,7 +192,7 @@ MarkLite/ │ │ │ │ │ ├── stores/ # ----- Zustand 状态管理 ----- │ │ │ ├── tabStore.ts # 标签页状态(tabs、activeTabId、mruStack) -│ │ │ ├── editorStore.ts # 编辑器状态(viewMode、darkMode、splitRatio) +│ │ │ ├── editorStore.ts # 编辑器状态(viewMode、darkMode) │ │ │ ├── sidebarStore.ts # 侧边栏状态(tree、expandedDirs、rootPath) │ │ │ └── searchStore.ts # 搜索替换状态(matches、currentIndex、options) │ │ │ diff --git a/src/main/file-system.ts b/src/main/file-system.ts index caf5c80..be4b9fe 100644 --- a/src/main/file-system.ts +++ b/src/main/file-system.ts @@ -74,9 +74,3 @@ export async function buildDirTree(dirPath: string, depth = 0, maxDepth = 10): P } return children } - -export function formatBytes(bytes: number): string { - if (bytes < 1024) return bytes + ' B' - if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB' - return (bytes / (1024 * 1024)).toFixed(1) + ' MB' -} diff --git a/src/main/file-watcher.ts b/src/main/file-watcher.ts index 392d843..1143ad4 100644 --- a/src/main/file-watcher.ts +++ b/src/main/file-watcher.ts @@ -29,6 +29,7 @@ export class FileWatcher { this.watcher = null } this.currentPath = null + this.isSelfWriting = false }) } catch { // silently ignore diff --git a/src/main/ipc-channels.ts b/src/main/ipc-channels.ts deleted file mode 100644 index 0ac90a1..0000000 --- a/src/main/ipc-channels.ts +++ /dev/null @@ -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 diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index 8a1d959..9b3dc7c 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -1,9 +1,22 @@ -import { ipcMain, dialog, BrowserWindow } from 'electron' +import { ipcMain, dialog, BrowserWindow, type IpcMainInvokeEvent } from 'electron' import { readFileContent, saveFileContent, buildDirTree } from './file-system' import { FileWatcher, SidebarWatcher } from './file-watcher' -import { IPC_CHANNELS } from './ipc-channels' +import { IPC_CHANNELS } from '../shared/ipc-channels' import { stat } from 'fs/promises' -import { join, basename } from 'path' +import { join, basename, resolve, normalize, isAbsolute } from 'path' + +// 安全校验:拒绝路径遍历攻击 +function validatePath(filePath: string): boolean { + if (!filePath || typeof filePath !== 'string') return false + // 拒绝空字节 + if (filePath.includes('\0')) return false + // 规范化路径后检查是否包含 .. + const normalized = normalize(filePath) + if (normalized.includes('..')) return false + // 必须是绝对路径 + if (!isAbsolute(normalized)) return false + return true +} export function registerIpcHandlers( getMainWindow: () => BrowserWindow | null, @@ -38,12 +51,18 @@ export function registerIpcHandlers( }) // 读取文件 - ipcMain.handle(IPC_CHANNELS.FILE_READ, async (_event: unknown, filePath: string) => { + ipcMain.handle(IPC_CHANNELS.FILE_READ, async (_event: IpcMainInvokeEvent, filePath: string) => { + if (!validatePath(filePath)) { + return { success: false, error: '无效的文件路径' } + } return readFileContent(filePath) }) // 保存文件 - ipcMain.handle(IPC_CHANNELS.FILE_SAVE, async (_event: unknown, data: { filePath: string | null; content: string }) => { + ipcMain.handle(IPC_CHANNELS.FILE_SAVE, async (_event: IpcMainInvokeEvent, data: { filePath: string | null; content: string }) => { + if (data.filePath && !validatePath(data.filePath)) { + return { success: false, error: '无效的文件路径' } + } const win = getMainWindow() try { if (data.filePath) { @@ -87,7 +106,7 @@ export function registerIpcHandlers( }) // 另存为 - ipcMain.handle(IPC_CHANNELS.FILE_SAVE_AS, async (_event: unknown, data: { content: string }) => { + ipcMain.handle(IPC_CHANNELS.FILE_SAVE_AS, async (_event: IpcMainInvokeEvent, data: { content: string }) => { const win = getMainWindow() if (!win) return { success: false, error: '窗口不可用' } try { @@ -95,16 +114,24 @@ export function registerIpcHandlers( filters: [{ name: 'Markdown 文件', extensions: ['md'] }] }) if (!result.canceled) { + fileWatcher.stop() + fileWatcher.setSelfWriting(true) const saveResult = await saveFileContent(result.filePath, data.content) + fileWatcher.setSelfWriting(false) if (saveResult.success) { state.activeFilePath = result.filePath - fileWatcher.start(result.filePath) + setTimeout(() => { + if (state.activeFilePath === result.filePath) { + fileWatcher.start(result.filePath) + } + }, 300) win.setTitle(`MarkLite - ${basename(result.filePath)}`) } return saveResult } return { success: false, canceled: true } } catch (err) { + fileWatcher.setSelfWriting(false) return { success: false, error: (err as Error).message } } }) @@ -113,7 +140,10 @@ export function registerIpcHandlers( ipcMain.handle(IPC_CHANNELS.FILE_GET_CURRENT_PATH, () => state.activeFilePath) // 文件统计 - ipcMain.handle(IPC_CHANNELS.FILE_STATS, async (_event: unknown, filePath: string) => { + ipcMain.handle(IPC_CHANNELS.FILE_STATS, async (_event: IpcMainInvokeEvent, filePath: string) => { + if (!validatePath(filePath)) { + return { success: false, error: '无效的文件路径' } + } try { const fileStat = await stat(filePath) return { success: true, size: fileStat.size, mtime: fileStat.mtime.toISOString() } @@ -130,7 +160,10 @@ export function registerIpcHandlers( }) // 目录树 - ipcMain.handle(IPC_CHANNELS.DIR_READ_TREE, async (_event: unknown, dirPath: string) => { + ipcMain.handle(IPC_CHANNELS.DIR_READ_TREE, async (_event: IpcMainInvokeEvent, dirPath: string) => { + if (!validatePath(dirPath)) { + return { success: false, error: '无效的目录路径' } + } try { const tree = await buildDirTree(dirPath) return { success: true, tree, rootPath: dirPath } @@ -151,7 +184,7 @@ export function registerIpcHandlers( }) // 目录监听 - ipcMain.handle(IPC_CHANNELS.DIR_WATCH, (_event: unknown, dirPath: string) => { + ipcMain.handle(IPC_CHANNELS.DIR_WATCH, (_event: IpcMainInvokeEvent, dirPath: string) => { sidebarWatcher.start(dirPath) }) @@ -160,7 +193,7 @@ export function registerIpcHandlers( }) // 标签切换 - ipcMain.handle(IPC_CHANNELS.TAB_SWITCHED, (_event: unknown, filePath: string | null) => { + ipcMain.handle(IPC_CHANNELS.TAB_SWITCHED, (_event: IpcMainInvokeEvent, filePath: string | null) => { state.activeFilePath = filePath || null fileWatcher.start(filePath || '') const win = getMainWindow() diff --git a/src/main/window-manager.ts b/src/main/window-manager.ts index 5d12774..0f1efa4 100644 --- a/src/main/window-manager.ts +++ b/src/main/window-manager.ts @@ -12,7 +12,8 @@ export function createWindow(): BrowserWindow { webPreferences: { preload: join(__dirname, '../preload/index.js'), contextIsolation: true, - nodeIntegration: false + nodeIntegration: false, + sandbox: true }, titleBarStyle: 'default', show: false diff --git a/src/preload/index.ts b/src/preload/index.ts index 48ad9e9..d301e9b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,62 +1,75 @@ import { contextBridge, ipcRenderer, shell } from 'electron' - -// M-01: 限制 removeAllListeners 只能操作白名单通道 -const ALLOWED_REMOVE_CHANNELS = new Set([ - 'file:openInTab', - 'menu:save', - 'menu:saveAs', - 'menu:viewMode', - 'file:externallyModified', - 'sidebar:dirChanged', - 'window:confirmClose' -]) +import { IPC_CHANNELS } from '../shared/ipc-channels' contextBridge.exposeInMainWorld('electronAPI', { // File operations - openFile: () => ipcRenderer.invoke('dialog:openFile'), - readFile: (filePath: string) => ipcRenderer.invoke('file:read', filePath), - saveFile: (data: { filePath: string | null; content: string }) => ipcRenderer.invoke('file:save', data), - saveFileAs: (data: { content: string }) => ipcRenderer.invoke('file:saveAs', data), - getCurrentPath: () => ipcRenderer.invoke('file:getCurrentPath'), - getFileStats: (filePath: string) => ipcRenderer.invoke('file:stats', filePath), - reloadFile: () => ipcRenderer.invoke('file:reload'), + openFile: () => ipcRenderer.invoke(IPC_CHANNELS.DIALOG_OPEN_FILE), + readFile: (filePath: string) => ipcRenderer.invoke(IPC_CHANNELS.FILE_READ, filePath), + saveFile: (data: { filePath: string | null; content: string }) => ipcRenderer.invoke(IPC_CHANNELS.FILE_SAVE, data), + saveFileAs: (data: { content: string }) => ipcRenderer.invoke(IPC_CHANNELS.FILE_SAVE_AS, data), + getCurrentPath: () => ipcRenderer.invoke(IPC_CHANNELS.FILE_GET_CURRENT_PATH), + getFileStats: (filePath: string) => ipcRenderer.invoke(IPC_CHANNELS.FILE_STATS, filePath), + reloadFile: () => ipcRenderer.invoke(IPC_CHANNELS.FILE_RELOAD), // Tab management - tabSwitched: (filePath: string | null) => ipcRenderer.invoke('tab:switched', filePath), + tabSwitched: (filePath: string | null) => ipcRenderer.invoke(IPC_CHANNELS.TAB_SWITCHED, filePath), // Window control - forceClose: () => ipcRenderer.invoke('window:forceClose'), - cancelClose: () => ipcRenderer.invoke('window:cancelClose'), + forceClose: () => ipcRenderer.invoke(IPC_CHANNELS.WINDOW_FORCE_CLOSE), + cancelClose: () => ipcRenderer.invoke(IPC_CHANNELS.WINDOW_CANCEL_CLOSE), - // Shell - openExternal: (url: string) => shell.openExternal(url), + // Shell — 仅允许 http/https 协议 + openExternal: (url: string) => { + try { + const parsed = new URL(url) + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { + shell.openExternal(url) + } + } catch { + // 无效 URL,忽略 + } + }, // File Tree (Sidebar) - readDirTree: (dirPath: string) => ipcRenderer.invoke('dir:readTree', dirPath), - openFolderDialog: () => ipcRenderer.invoke('dir:openDialog'), - watchDir: (dirPath: string) => ipcRenderer.invoke('dir:watch', dirPath), - unwatchDir: () => ipcRenderer.invoke('dir:unwatch'), + readDirTree: (dirPath: string) => ipcRenderer.invoke(IPC_CHANNELS.DIR_READ_TREE, dirPath), + openFolderDialog: () => ipcRenderer.invoke(IPC_CHANNELS.DIR_OPEN_DIALOG), + watchDir: (dirPath: string) => ipcRenderer.invoke(IPC_CHANNELS.DIR_WATCH, dirPath), + unwatchDir: () => ipcRenderer.invoke(IPC_CHANNELS.DIR_UNWATCH), - // Events from main process - onFileOpenInTab: (callback: (data: { filePath: string; content: string }) => void) => - ipcRenderer.on('file:openInTab', (_event, data) => callback(data)), - onMenuSave: (callback: () => void) => - ipcRenderer.on('menu:save', () => callback()), - onMenuSaveAs: (callback: () => void) => - ipcRenderer.on('menu:saveAs', () => callback()), - onViewModeChange: (callback: (mode: string) => void) => - ipcRenderer.on('menu:viewMode', (_event, mode) => callback(mode)), - onExternalModification: (callback: (filePath: string) => void) => - ipcRenderer.on('file:externallyModified', (_event, filePath) => callback(filePath)), - onDirChanged: (callback: () => void) => - ipcRenderer.on('sidebar:dirChanged', () => callback()), - onConfirmClose: (callback: () => void) => - ipcRenderer.on('window:confirmClose', () => callback()), - - // M-01: 只允许移除白名单通道的监听器 - removeAllListeners: (channel: string) => { - if (ALLOWED_REMOVE_CHANNELS.has(channel)) { - ipcRenderer.removeAllListeners(channel) - } + // Events from main process — 返回取消订阅函数 + onFileOpenInTab: (callback: (data: { filePath: string; content: string }) => void) => { + const handler = (_event: Electron.IpcRendererEvent, data: { filePath: string; content: string }) => callback(data) + ipcRenderer.on(IPC_CHANNELS.FILE_OPEN_IN_TAB, handler) + return () => { ipcRenderer.removeListener(IPC_CHANNELS.FILE_OPEN_IN_TAB, handler) } + }, + onMenuSave: (callback: () => void) => { + const handler = () => callback() + ipcRenderer.on(IPC_CHANNELS.MENU_SAVE, handler) + return () => { ipcRenderer.removeListener(IPC_CHANNELS.MENU_SAVE, handler) } + }, + onMenuSaveAs: (callback: () => void) => { + const handler = () => callback() + ipcRenderer.on(IPC_CHANNELS.MENU_SAVE_AS, handler) + return () => { ipcRenderer.removeListener(IPC_CHANNELS.MENU_SAVE_AS, handler) } + }, + onViewModeChange: (callback: (mode: string) => void) => { + const handler = (_event: Electron.IpcRendererEvent, mode: string) => callback(mode) + ipcRenderer.on(IPC_CHANNELS.MENU_VIEW_MODE, handler) + return () => { ipcRenderer.removeListener(IPC_CHANNELS.MENU_VIEW_MODE, handler) } + }, + onExternalModification: (callback: (filePath: string) => void) => { + const handler = (_event: Electron.IpcRendererEvent, filePath: string) => callback(filePath) + ipcRenderer.on(IPC_CHANNELS.FILE_EXTERNALLY_MODIFIED, handler) + return () => { ipcRenderer.removeListener(IPC_CHANNELS.FILE_EXTERNALLY_MODIFIED, handler) } + }, + onDirChanged: (callback: () => void) => { + const handler = () => callback() + ipcRenderer.on(IPC_CHANNELS.SIDEBAR_DIR_CHANGED, handler) + return () => { ipcRenderer.removeListener(IPC_CHANNELS.SIDEBAR_DIR_CHANGED, handler) } + }, + onConfirmClose: (callback: () => void) => { + const handler = () => callback() + ipcRenderer.on(IPC_CHANNELS.WINDOW_CONFIRM_CLOSE, handler) + return () => { ipcRenderer.removeListener(IPC_CHANNELS.WINDOW_CONFIRM_CLOSE, handler) } } }) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index a5dc9e1..f3b2f6e 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,6 +1,7 @@ import React, { useState, useCallback, useEffect, useRef } from 'react' import { useTabStore } from './stores/tabStore' import { useEditorStore } from './stores/editorStore' +import { useSidebarStore } from './stores/sidebarStore' import { useTheme } from './hooks/useTheme' import { useSettings } from './hooks/useSettings' import { useKeyboard } from './hooks/useKeyboard' @@ -27,25 +28,29 @@ export default function App() { const setModified = useTabStore(s => s.setModified) const updateTabContent = useTabStore(s => s.updateTabContent) const loadFromDB = useTabStore(s => s.loadFromDB) + const loadSidebarFromDB = useSidebarStore(s => s.loadFromDB) const viewMode = useEditorStore(s => s.viewMode) - const splitRatio = useEditorStore(s => s.splitRatio) const { darkMode, toggleDarkMode } = useTheme() - const { saveViewMode, saveSplitRatio } = useSettings() + const { saveViewMode } = useSettings() const [showModifiedBanner, setShowModifiedBanner] = useState(false) const [modifiedFilePath, setModifiedFilePath] = useState(null) const [toastMessage, setToastMessage] = useState(null) + const toastTimer = useRef(0) - // 启动时从 IndexedDB 加载标签页 + // 启动时从 IndexedDB 加载标签页和 sidebar 设置 useEffect(() => { loadFromDB() - }, [loadFromDB]) + loadSidebarFromDB() + return () => clearTimeout(toastTimer.current) + }, [loadFromDB, loadSidebarFromDB]) const showToast = useCallback((msg: string) => { + clearTimeout(toastTimer.current) setToastMessage(msg) - setTimeout(() => setToastMessage(null), 3000) + toastTimer.current = window.setTimeout(() => setToastMessage(null), 3000) }, []) // 拖拽打开 @@ -128,7 +133,7 @@ export default function App() { }, [createTab]) // 视图模式切换 - const handleViewModeChange = useCallback((mode: 'split' | 'editor' | 'preview') => { + const handleViewModeChange = useCallback((mode: 'editor' | 'preview') => { saveViewMode(mode) }, [saveViewMode]) @@ -150,28 +155,19 @@ export default function App() { const onSave = () => handleSaveRef.current() const onSaveAs = () => handleSaveAsRef.current() - api.onFileOpenInTab(onOpen) - api.onMenuSave(onSave) - api.onMenuSaveAs(onSaveAs) + const unsub1 = api.onFileOpenInTab(onOpen) + const unsub2 = api.onMenuSave(onSave) + const unsub3 = api.onMenuSaveAs(onSaveAs) return () => { - api.removeAllListeners('file:openInTab') - api.removeAllListeners('menu:save') - api.removeAllListeners('menu:saveAs') + unsub1() + unsub2() + unsub3() } }, [createTab]) - const activeTab = getActiveTab() const hasTabs = tabs.length > 0 - // 分屏样式 - const editorStyle: React.CSSProperties = viewMode === 'split' - ? { flex: `0 0 ${splitRatio}%` } - : {} - const previewStyle: React.CSSProperties = viewMode === 'split' - ? { flex: `0 0 ${100 - splitRatio}%` } - : {} - return (
{ if (window.electronAPI && modifiedFilePath) { - window.electronAPI.reloadFile().then(result => { + window.electronAPI.readFile(modifiedFilePath).then(result => { if (result.success && result.content) { - const tab = getActiveTab() + const tab = tabs.find(t => t.filePath === modifiedFilePath) if (tab) { updateTabContent(tab.id, result.content) setModified(tab.id, false) @@ -211,14 +207,13 @@ export default function App() { {hasTabs ? (
- {viewMode !== 'preview' && ( -
+ {viewMode === 'editor' && ( +
)} - {viewMode !== 'editor' && } - {viewMode !== 'editor' && ( -
+ {viewMode === 'preview' && ( +
)} @@ -240,41 +235,3 @@ export default function App() {
) } - -// 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
-} diff --git a/src/renderer/components/Editor/Editor.tsx b/src/renderer/components/Editor/Editor.tsx index 520c401..e4cf33f 100644 --- a/src/renderer/components/Editor/Editor.tsx +++ b/src/renderer/components/Editor/Editor.tsx @@ -22,8 +22,7 @@ export function Editor({ darkMode }: EditorProps) { getScrollTop, setScrollTop, getSelection, - setSelection, - setLineAtTop + setSelection } = useCodeMirror({ content: activeTab?.content ?? '', onChange: useCallback((value: string) => { @@ -31,25 +30,9 @@ export function Editor({ darkMode }: EditorProps) { updateTabContent(activeTabId, value) setModified(activeTabId, true) }, [activeTabId, updateTabContent, setModified]), - darkMode, - onScroll: useCallback((line: number) => { - // 派发行号给预览侧做滚动同步 - window.dispatchEvent(new CustomEvent('editor-scroll', { detail: { line } })) - }, []) + darkMode }) - // 反向同步:监听预览滚动事件 - useEffect(() => { - const handlePreviewScroll = (e: Event) => { - const line = (e as CustomEvent).detail?.line - if (typeof line === 'number') { - setLineAtTop(Math.floor(line) + 1) // setLineAtTop 是 1-indexed - } - } - window.addEventListener('preview-scroll', handlePreviewScroll) - return () => window.removeEventListener('preview-scroll', handlePreviewScroll) - }, [setLineAtTop]) - // 切换标签时加载内容 useEffect(() => { if (!activeTab) return diff --git a/src/renderer/components/Editor/useCodeMirror.ts b/src/renderer/components/Editor/useCodeMirror.ts index 5d35435..f2a4eb7 100644 --- a/src/renderer/components/Editor/useCodeMirror.ts +++ b/src/renderer/components/Editor/useCodeMirror.ts @@ -11,10 +11,9 @@ interface UseCodeMirrorOptions { content: string onChange: (value: string) => void darkMode: boolean - onScroll?: (line: number) => void } -export function useCodeMirror({ content, onChange, darkMode, onScroll }: UseCodeMirrorOptions) { +export function useCodeMirror({ content, onChange, darkMode }: UseCodeMirrorOptions) { const containerRef = useRef(null) const viewRef = useRef(null) const isExternalUpdate = useRef(false) @@ -81,23 +80,7 @@ export function useCodeMirror({ content, onChange, darkMode, onScroll }: UseCode viewRef.current = view - // 滚动事件监听:派发行号(支持小数表示行内偏移) - const handleScroll = () => { - if (onScroll) { - const dom = view.scrollDOM - const scrollTop = dom.scrollTop - const block = view.lineBlockAtHeight(scrollTop) - const line = view.state.doc.lineAt(block.from) - const fraction = block.height > 0 - ? (scrollTop - block.top) / block.height - : 0 - onScroll(line.number - 1 + Math.max(0, fraction)) // 0-indexed + 行内比例 - } - } - view.scrollDOM.addEventListener('scroll', handleScroll) - return () => { - view.scrollDOM.removeEventListener('scroll', handleScroll) view.destroy() viewRef.current = null } @@ -116,12 +99,6 @@ export function useCodeMirror({ content, onChange, darkMode, onScroll }: UseCode isExternalUpdate.current = false } }, []) - - // 获取当前内容 - const getContent = useCallback(() => { - return viewRef.current?.state.doc.toString() ?? '' - }, []) - // 获取/设置滚动位置 const getScrollTop = useCallback(() => { return viewRef.current?.scrollDOM.scrollTop ?? 0 @@ -147,35 +124,13 @@ export function useCodeMirror({ content, onChange, darkMode, onScroll }: UseCode view.dispatch({ selection: { anchor: from, head: to } }) view.focus() }, []) - - // 滚动到指定行 - const scrollTo = useCallback((pos: number) => { - const view = viewRef.current - if (!view) return - view.dispatch({ effects: EditorView.scrollIntoView(pos, { y: 'center' }) }) - }, []) - - // 滚动使指定行出现在编辑器顶部(反向同步用) - const setLineAtTop = useCallback((lineNumber: number) => { - const view = viewRef.current - if (!view) return - const line = Math.max(1, Math.min(lineNumber, view.state.doc.lines)) - const lineInfo = view.state.doc.line(line) - const block = view.lineBlockAt(lineInfo.from) - const currentTop = view.lineBlockAtHeight(view.scrollDOM.scrollTop).top - view.scrollDOM.scrollTop += block.top - currentTop - }, []) - return { containerRef, viewRef, setContent, - getContent, getScrollTop, setScrollTop, getSelection, - setSelection, - scrollTo, - setLineAtTop + setSelection } } diff --git a/src/renderer/components/Icons.tsx b/src/renderer/components/Icons.tsx index 4de2ca2..a0fffeb 100644 --- a/src/renderer/components/Icons.tsx +++ b/src/renderer/components/Icons.tsx @@ -58,19 +58,6 @@ export function Save({ size = defaultProps.size }: IconProps) { ) } -export function SplitView({ size = defaultProps.size }: IconProps) { - return ( - - - - - - - - - ) -} - export function EditMode({ size = defaultProps.size }: IconProps) { return ( @@ -175,32 +162,6 @@ export function FolderPlus({ size = 14 }: IconProps) { ) } -// ===== 搜索栏图标 ===== -export function ChevronUp({ size = 12 }: IconProps) { - return ( - - - - ) -} - -export function ChevronDown({ size = 12 }: IconProps) { - return ( - - - - ) -} - -export function X({ size = 14 }: IconProps) { - return ( - - - - - ) -} - // ===== 拖拽覆盖层图标 ===== export function UploadCloud({ size = 64 }: IconProps) { return ( @@ -213,25 +174,6 @@ export function UploadCloud({ size = 64 }: IconProps) { ) } -// ===== 箭头/导航图标 ===== -export function ArrowUp({ size = 12 }: IconProps) { - return ( - - - - - ) -} - -export function ArrowDown({ size = 12 }: IconProps) { - return ( - - - - - ) -} - // ===== 欢迎屏幕图标 ===== export function WelcomeFile({ size = 20 }: IconProps) { return ( diff --git a/src/renderer/components/ModifiedBanner/ModifiedBanner.tsx b/src/renderer/components/ModifiedBanner/ModifiedBanner.tsx index ab7a4ca..8f37bff 100644 --- a/src/renderer/components/ModifiedBanner/ModifiedBanner.tsx +++ b/src/renderer/components/ModifiedBanner/ModifiedBanner.tsx @@ -1,5 +1,3 @@ -import React from 'react' - interface ModifiedBannerProps { onReload: () => void onDismiss: () => void diff --git a/src/renderer/components/Preview/Preview.tsx b/src/renderer/components/Preview/Preview.tsx index ac9f96a..e7cf783 100644 --- a/src/renderer/components/Preview/Preview.tsx +++ b/src/renderer/components/Preview/Preview.tsx @@ -1,13 +1,11 @@ import React, { useState, useEffect, useCallback, useRef } from 'react' import { useTabStore } from '../../stores/tabStore' import { renderMarkdown } from '../../lib/markdown' -import { scrollPreviewToLine, getLineAtScrollOffset, invalidateScrollCache } from '../../lib/scrollSync' export function Preview() { const [html, setHtml] = useState('') const previewRef = useRef(null) const requestIdRef = useRef(0) - const isSyncingRef = useRef(false) const activeTabId = useTabStore(s => s.activeTabId) const tabs = useTabStore(s => s.tabs) @@ -24,49 +22,10 @@ export function Preview() { renderMarkdown(activeTab.content, activeTab.filePath).then(result => { if (requestId === requestIdRef.current) { setHtml(result) - invalidateScrollCache() } }) }, [activeTabId, activeTab?.content]) - // 滚动同步:监听编辑器滚动事件(VS Code 方案:行号 → 二分查找) - useEffect(() => { - const handleEditorScroll = (e: Event) => { - const line = (e as CustomEvent).detail?.line - if (typeof line !== 'number') return - - const container = previewRef.current?.parentElement - if (!container || isSyncingRef.current) return - - scrollPreviewToLine(line, container, isSyncingRef) - } - - window.addEventListener('editor-scroll', handleEditorScroll) - return () => window.removeEventListener('editor-scroll', handleEditorScroll) - }, []) - - // 反向同步:预览滚动 → 通知编辑器 - useEffect(() => { - const container = previewRef.current?.parentElement - if (!container) return - - const handleScroll = () => { - if (isSyncingRef.current) return - - const line = getLineAtScrollOffset(container.scrollTop, container) - if (line !== null) { - isSyncingRef.current = true - window.dispatchEvent(new CustomEvent('preview-scroll', { detail: { line } })) - requestAnimationFrame(() => { - isSyncingRef.current = false - }) - } - } - - container.addEventListener('scroll', handleScroll, { passive: true }) - return () => container.removeEventListener('scroll', handleScroll) - }, [activeTabId]) - // 拦截链接点击 const handleClick = useCallback((e: React.MouseEvent) => { const link = (e.target as HTMLElement).closest('a') diff --git a/src/renderer/components/Sidebar/Sidebar.tsx b/src/renderer/components/Sidebar/Sidebar.tsx index f861934..57c6b80 100644 --- a/src/renderer/components/Sidebar/Sidebar.tsx +++ b/src/renderer/components/Sidebar/Sidebar.tsx @@ -74,12 +74,10 @@ export function Sidebar() { useEffect(() => { if (!window.electronAPI) return - window.electronAPI.onDirChanged(() => { + const unsubscribe = window.electronAPI.onDirChanged(() => { refreshTree() }) - return () => { - window.electronAPI?.removeAllListeners('sidebar:dirChanged') - } + return unsubscribe }, [refreshTree]) useEffect(() => { diff --git a/src/renderer/components/TabBar/TabBar.tsx b/src/renderer/components/TabBar/TabBar.tsx index a7074b3..2be86a6 100644 --- a/src/renderer/components/TabBar/TabBar.tsx +++ b/src/renderer/components/TabBar/TabBar.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState, useEffect, useRef } from 'react' +import React, { useCallback, useState, useEffect } from 'react' import { useTabStore } from '../../stores/tabStore' import { getFileName } from '../../lib/fileUtils' import { Close, Plus } from '../Icons' @@ -21,7 +21,6 @@ export function TabBar() { const closeTabsToRight = useTabStore(s => s.closeTabsToRight) const [menu, setMenu] = useState({ visible: false, x: 0, y: 0, tabId: '' }) - const menuRef = useRef(null) const handleClose = useCallback((e: React.MouseEvent, tabId: string) => { e.stopPropagation() @@ -133,8 +132,7 @@ export function TabBar() { {/* 右键菜单 */} {menu.visible && (
e.stopPropagation()} > diff --git a/src/renderer/components/Toast/Toast.tsx b/src/renderer/components/Toast/Toast.tsx index d4a5003..101ac47 100644 --- a/src/renderer/components/Toast/Toast.tsx +++ b/src/renderer/components/Toast/Toast.tsx @@ -1,5 +1,3 @@ -import React from 'react' - interface ToastProps { message: string } diff --git a/src/renderer/components/Toolbar/Toolbar.tsx b/src/renderer/components/Toolbar/Toolbar.tsx index b204b71..ec3b262 100644 --- a/src/renderer/components/Toolbar/Toolbar.tsx +++ b/src/renderer/components/Toolbar/Toolbar.tsx @@ -1,11 +1,11 @@ import React from 'react' -import { FolderOpen, Save, SplitView, EditMode, PreviewMode, Moon, Sun } from '../Icons' +import { FolderOpen, Save, EditMode, PreviewMode, Moon, Sun } from '../Icons' interface ToolbarProps { onOpen: () => void onSave: () => void - viewMode: 'split' | 'editor' | 'preview' - onViewModeChange: (mode: 'split' | 'editor' | 'preview') => void + viewMode: 'editor' | 'preview' + onViewModeChange: (mode: 'editor' | 'preview') => void darkMode: boolean onToggleDark: () => void } @@ -23,15 +23,11 @@ export function Toolbar({ onOpen, onSave, viewMode, onViewModeChange, darkMode, 保存
- - - diff --git a/src/renderer/db/schema.ts b/src/renderer/db/schema.ts index 97a3eb5..5e45531 100644 --- a/src/renderer/db/schema.ts +++ b/src/renderer/db/schema.ts @@ -5,19 +5,21 @@ export interface TabSnapshot { filePath: string | null content: string scrollTop: number - scrollLeft: number selectionStart: number selectionEnd: number - previewScrollTop: number isModified: boolean updatedAt: number } +export interface ActiveTabRecord { + id: string // 固定为 'current' + activeTabId: string | null +} + export interface SettingsRecord { id: string // 固定为 'default' darkMode: boolean - viewMode: 'split' | 'editor' | 'preview' - splitRatio: number + viewMode: 'editor' | 'preview' sidebarCollapsed: boolean sidebarWidth: number } @@ -32,12 +34,14 @@ const db = new Dexie('MarkLite') as Dexie & { tabSnapshots: EntityTable settings: EntityTable recentFiles: EntityTable + activeTab: EntityTable } db.version(1).stores({ tabSnapshots: 'id, filePath, updatedAt', settings: 'id', - recentFiles: '++id, filePath, lastOpened' + recentFiles: '++id, filePath, lastOpened', + activeTab: 'id' }) export { db } diff --git a/src/renderer/db/settingsRepository.ts b/src/renderer/db/settingsRepository.ts index c135e7c..4174d68 100644 --- a/src/renderer/db/settingsRepository.ts +++ b/src/renderer/db/settingsRepository.ts @@ -9,7 +9,6 @@ export const settingsRepository = { return { darkMode: record.darkMode ?? DEFAULT_SETTINGS.darkMode, viewMode: record.viewMode ?? DEFAULT_SETTINGS.viewMode, - splitRatio: record.splitRatio ?? DEFAULT_SETTINGS.splitRatio, sidebarCollapsed: record.sidebarCollapsed ?? DEFAULT_SETTINGS.sidebarCollapsed, sidebarWidth: record.sidebarWidth ?? DEFAULT_SETTINGS.sidebarWidth } diff --git a/src/renderer/db/tabRepository.ts b/src/renderer/db/tabRepository.ts index d3291e0..a207012 100644 --- a/src/renderer/db/tabRepository.ts +++ b/src/renderer/db/tabRepository.ts @@ -14,5 +14,14 @@ export const tabRepository = { async clearAll(): Promise { await db.tabSnapshots.clear() + }, + + async saveActiveTabId(tabId: string | null): Promise { + await db.activeTab.put({ id: 'current', activeTabId: tabId }) + }, + + async loadActiveTabId(): Promise { + const record = await db.activeTab.get('current') + return record?.activeTabId ?? null } } diff --git a/src/renderer/hooks/useFileWatch.ts b/src/renderer/hooks/useFileWatch.ts index 093ea66..1ea36aa 100644 --- a/src/renderer/hooks/useFileWatch.ts +++ b/src/renderer/hooks/useFileWatch.ts @@ -7,15 +7,13 @@ export function useFileWatch() { useEffect(() => { if (!window.electronAPI) return - window.electronAPI.onExternalModification((filePath: string) => { + const unsubscribe = window.electronAPI.onExternalModification((filePath: string) => { const tab = getActiveTab() if (tab && tab.filePath === filePath) { window.dispatchEvent(new CustomEvent('file-externally-modified', { detail: filePath })) } }) - return () => { - window.electronAPI?.removeAllListeners('file:externallyModified') - } + return unsubscribe }, [getActiveTab]) } diff --git a/src/renderer/hooks/useKeyboard.ts b/src/renderer/hooks/useKeyboard.ts index cf9b57a..6829cad 100644 --- a/src/renderer/hooks/useKeyboard.ts +++ b/src/renderer/hooks/useKeyboard.ts @@ -12,9 +12,8 @@ export function useKeyboard(handleOpenFile: () => void, handleSave: () => void, if (isCtrl && e.key === 'o') { e.preventDefault(); handleOpenFile(); return } if (isCtrl && e.key === 's' && !e.shiftKey) { e.preventDefault(); handleSave(); return } if (isCtrl && e.shiftKey && e.key === 'S') { e.preventDefault(); handleSaveAs(); return } - if (isCtrl && e.key === '1') { e.preventDefault(); setViewMode('split'); return } - if (isCtrl && e.key === '2') { e.preventDefault(); setViewMode('editor'); return } - if (isCtrl && e.key === '3') { e.preventDefault(); setViewMode('preview'); return } + if (isCtrl && e.key === '1') { e.preventDefault(); setViewMode('editor'); return } + if (isCtrl && e.key === '2') { e.preventDefault(); setViewMode('preview'); return } const tabState = useTabStore.getState() if (isCtrl && e.key === 't') { e.preventDefault(); tabState.createTab(null, ''); return } diff --git a/src/renderer/hooks/useSettings.ts b/src/renderer/hooks/useSettings.ts index d9dbbb0..643c4e3 100644 --- a/src/renderer/hooks/useSettings.ts +++ b/src/renderer/hooks/useSettings.ts @@ -5,17 +5,14 @@ import type { ViewMode } from '../types/settings' export function useSettings() { const viewMode = useEditorStore(s => s.viewMode) - const splitRatio = useEditorStore(s => s.splitRatio) const setViewMode = useEditorStore(s => s.setViewMode) - const setSplitRatio = useEditorStore(s => s.setSplitRatio) // 初始化 useEffect(() => { settingsRepository.load().then(settings => { - setViewMode(settings.viewMode ?? 'split') - setSplitRatio(settings.splitRatio ?? 50) + setViewMode(settings.viewMode ?? 'editor') }) - }, [setViewMode, setSplitRatio]) + }, [setViewMode]) // M-10: 使用 useCallback 稳定函数引用 const saveViewMode = useCallback((mode: ViewMode) => { @@ -23,10 +20,5 @@ export function useSettings() { settingsRepository.save({ viewMode: mode }) }, [setViewMode]) - const saveSplitRatio = useCallback((ratio: number) => { - setSplitRatio(ratio) - settingsRepository.save({ splitRatio: ratio }) - }, [setSplitRatio]) - - return { viewMode, splitRatio, saveViewMode, saveSplitRatio } + return { viewMode, saveViewMode } } diff --git a/src/renderer/hooks/useUnsavedWarning.ts b/src/renderer/hooks/useUnsavedWarning.ts index ff0180f..c51b171 100644 --- a/src/renderer/hooks/useUnsavedWarning.ts +++ b/src/renderer/hooks/useUnsavedWarning.ts @@ -15,7 +15,7 @@ export function useUnsavedWarning(hasUnsaved: () => boolean) { const api = window.electronAPI - api.onConfirmClose(() => { + const unsubscribe = api.onConfirmClose(() => { if (!hasUnsaved()) { api.forceClose() return @@ -28,8 +28,6 @@ export function useUnsavedWarning(hasUnsaved: () => boolean) { } }) - return () => { - api.removeAllListeners('window:confirmClose') - } + return unsubscribe }, [hasUnsaved]) } diff --git a/src/renderer/lib/constants.ts b/src/renderer/lib/constants.ts index 10bca30..f22357b 100644 --- a/src/renderer/lib/constants.ts +++ b/src/renderer/lib/constants.ts @@ -5,9 +5,4 @@ export const SKIP_DIRS = new Set([ 'node_modules', '.git', '.svn', '.hg', 'dist', 'out', '.next', '.nuxt', '__pycache__', '.DS_Store' ]) -export const MARKDOWN_EXTS = new Set(['.md', '.markdown', '.txt']) -export const UPDATE_DEBOUNCE_MS = 150 -export const LARGE_FILE_LINE_THRESHOLD = 2000 -export const CLOSE_TIMEOUT_MS = 5000 -export const WATCH_RESTART_DELAY_MS = 300 -export const DIR_REFRESH_DEBOUNCE_MS = 300 + diff --git a/src/renderer/lib/fileUtils.ts b/src/renderer/lib/fileUtils.ts index 94bced0..e592684 100644 --- a/src/renderer/lib/fileUtils.ts +++ b/src/renderer/lib/fileUtils.ts @@ -1,11 +1,5 @@ import { ALLOWED_EXTENSIONS } from './constants' -export function formatBytes(bytes: number): string { - if (bytes < 1024) return bytes + ' B' - if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB' - return (bytes / (1024 * 1024)).toFixed(1) + ' MB' -} - export function getFileName(filePath: string): string { return filePath.split(/[/\\]/).pop() || filePath } diff --git a/src/renderer/lib/markdown.ts b/src/renderer/lib/markdown.ts index 4eb8ebd..eb5b5e9 100644 --- a/src/renderer/lib/markdown.ts +++ b/src/renderer/lib/markdown.ts @@ -7,7 +7,6 @@ import rehypeSanitize, { defaultSchema } from 'rehype-sanitize' import rehypeStringify from 'rehype-stringify' import rehypeHighlight from 'rehype-highlight' import type { Element, Root } from 'hast' -import { rehypeSourceLine } from './rehypeSourceLine' // 自定义 rehype 插件:将相对路径图片转为 file:// 绝对路径 function rehypeFixImages(filePath: string | null): Plugin<[], Root> { @@ -58,7 +57,6 @@ export async function renderMarkdown(content: string, filePath?: string | null): } }) .use(rehypeFixImages(filePath ?? null)) - .use(rehypeSourceLine) .use(rehypeHighlight) .use(rehypeStringify) diff --git a/src/renderer/lib/rehypeSourceLine.ts b/src/renderer/lib/rehypeSourceLine.ts deleted file mode 100644 index 104157d..0000000 --- a/src/renderer/lib/rehypeSourceLine.ts +++ /dev/null @@ -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' - } - }) - } -} diff --git a/src/renderer/lib/scrollSync.ts b/src/renderer/lib/scrollSync.ts deleted file mode 100644 index c21651b..0000000 --- a/src/renderer/lib/scrollSync.ts +++ /dev/null @@ -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('[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) -} diff --git a/src/renderer/stores/editorStore.ts b/src/renderer/stores/editorStore.ts index 86f5805..78ee5b3 100644 --- a/src/renderer/stores/editorStore.ts +++ b/src/renderer/stores/editorStore.ts @@ -4,21 +4,17 @@ import type { ViewMode } from '../types/settings' interface EditorState { viewMode: ViewMode darkMode: boolean - splitRatio: number setViewMode: (mode: ViewMode) => void setDarkMode: (dark: boolean) => void - setSplitRatio: (ratio: number) => void toggleDarkMode: () => void } export const useEditorStore = create((set) => ({ - viewMode: 'split', + viewMode: 'editor', darkMode: false, - splitRatio: 50, setViewMode: (mode) => set({ viewMode: mode }), setDarkMode: (dark) => set({ darkMode: dark }), - setSplitRatio: (ratio) => set({ splitRatio: ratio }), toggleDarkMode: () => set(state => ({ darkMode: !state.darkMode })) })) diff --git a/src/renderer/stores/sidebarStore.ts b/src/renderer/stores/sidebarStore.ts index 01ae671..a0b7254 100644 --- a/src/renderer/stores/sidebarStore.ts +++ b/src/renderer/stores/sidebarStore.ts @@ -1,5 +1,6 @@ import { create } from 'zustand' import type { FileNode } from '../types/file' +import { settingsRepository } from '../db/settingsRepository' interface SidebarState { isVisible: boolean @@ -7,6 +8,7 @@ interface SidebarState { tree: FileNode[] expandedDirs: Set sidebarWidth: number + _loaded: boolean setVisible: (visible: boolean) => void setRootPath: (path: string | null) => void @@ -14,16 +16,37 @@ interface SidebarState { toggleDir: (path: string) => void expandDirs: (paths: string[]) => void setSidebarWidth: (width: number) => void + loadFromDB: () => Promise } -export const useSidebarStore = create((set) => ({ +export const useSidebarStore = create((set, get) => ({ isVisible: true, rootPath: null, tree: [], expandedDirs: new Set(), sidebarWidth: 240, + _loaded: false, - setVisible: (visible) => set({ isVisible: visible }), + // 从 IndexedDB 加载 sidebar 设置 + loadFromDB: async () => { + if (get()._loaded) return + try { + const settings = await settingsRepository.load() + set({ + isVisible: !settings.sidebarCollapsed, + sidebarWidth: settings.sidebarWidth, + _loaded: true + }) + } catch { + set({ _loaded: true }) + } + }, + + setVisible: (visible) => { + set({ isVisible: visible }) + // 异步保存到 DB + settingsRepository.save({ sidebarCollapsed: !visible }) + }, setRootPath: (path) => set({ rootPath: path }), setTree: (tree) => set({ tree }), toggleDir: (path) => @@ -48,5 +71,9 @@ export const useSidebarStore = create((set) => ({ } return changed ? { expandedDirs: newSet } : state }), - setSidebarWidth: (width) => set({ sidebarWidth: width }) + setSidebarWidth: (width) => { + set({ sidebarWidth: width }) + // 异步保存到 DB + settingsRepository.save({ sidebarWidth: width }) + } })) diff --git a/src/renderer/stores/tabStore.ts b/src/renderer/stores/tabStore.ts index 5bbfe37..2f02608 100644 --- a/src/renderer/stores/tabStore.ts +++ b/src/renderer/stores/tabStore.ts @@ -18,8 +18,7 @@ interface TabState { updateTabContent: (tabId: string, content: string) => void setModified: (tabId: string, modified: boolean) => void getActiveTab: () => Tab | null - getTabIndex: (tabId: string) => number - updateTabScroll: (tabId: string, scroll: Partial>) => void + updateTabScroll: (tabId: string, scroll: Partial>) => void loadFromDB: () => Promise saveToDB: () => Promise } @@ -34,7 +33,10 @@ export const useTabStore = create((set, get) => ({ loadFromDB: async () => { if (get()._loaded) return try { - const snapshots = await tabRepository.loadAll() + const [snapshots, savedActiveTabId] = await Promise.all([ + tabRepository.loadAll(), + tabRepository.loadActiveTabId() + ]) if (snapshots.length > 0) { const tabs: Tab[] = snapshots.map(s => ({ id: s.id, @@ -42,14 +44,16 @@ export const useTabStore = create((set, get) => ({ content: s.content, isModified: s.isModified, scrollTop: s.scrollTop, - scrollLeft: s.scrollLeft, selectionStart: s.selectionStart, - selectionEnd: s.selectionEnd, - previewScrollTop: s.previewScrollTop + selectionEnd: s.selectionEnd })) + // 使用保存的 activeTabId,如果不存在则取最后一个 + const activeTabId = (savedActiveTabId && tabs.find(t => t.id === savedActiveTabId)) + ? savedActiveTabId + : tabs[tabs.length - 1].id set({ tabs, - activeTabId: tabs[tabs.length - 1].id, + activeTabId, _loaded: true }) } else { @@ -73,13 +77,12 @@ export const useTabStore = create((set, get) => ({ content: t.content, isModified: t.isModified, scrollTop: t.scrollTop, - scrollLeft: t.scrollLeft, selectionStart: t.selectionStart, selectionEnd: t.selectionEnd, - previewScrollTop: t.previewScrollTop, updatedAt: Date.now() })) await tabRepository.saveAll(snapshots) + await tabRepository.saveActiveTabId(get().activeTabId) }, createTab: (filePath = null, content = '') => { @@ -97,10 +100,8 @@ export const useTabStore = create((set, get) => ({ content, isModified: false, scrollTop: 0, - scrollLeft: 0, selectionStart: 0, - selectionEnd: 0, - previewScrollTop: 0 + selectionEnd: 0 } set(state => ({ @@ -187,6 +188,8 @@ export const useTabStore = create((set, get) => ({ : state.mruStack return { activeTabId: tabId, mruStack: newMru } }) + // 异步保存 activeTabId + setTimeout(() => tabRepository.saveActiveTabId(tabId), 0) }, updateTabContent: (tabId, content) => { @@ -210,10 +213,6 @@ export const useTabStore = create((set, get) => ({ return tabs.find(t => t.id === activeTabId) ?? null }, - getTabIndex: (tabId) => { - return get().tabs.findIndex(t => t.id === tabId) - }, - updateTabScroll: (tabId, scroll) => { set(state => ({ tabs: state.tabs.map(t => diff --git a/src/renderer/styles/global.css b/src/renderer/styles/global.css index 0547bd6..53c5e7b 100644 --- a/src/renderer/styles/global.css +++ b/src/renderer/styles/global.css @@ -447,19 +447,6 @@ html, body { border-left-color: var(--text); } -/* Resizer */ -#resizer { - width: 4px; - background: var(--border); - cursor: col-resize; - transition: background 0.15s ease; - flex-shrink: 0; -} - -#resizer:hover, #resizer.active { - background: var(--primary); -} - /* Preview Panel */ #preview-panel { flex: 1; @@ -1031,11 +1018,8 @@ html, body { } /* View Modes */ -#app.mode-preview #editor-panel, -#app.mode-preview #resizer { display: none; } - -#app.mode-editor #preview-panel, -#app.mode-editor #resizer { display: none; } +#app.mode-preview #editor-panel { display: none; } +#app.mode-editor #preview-panel { display: none; } /* Toast */ #toast-notification { diff --git a/src/renderer/types/ipc.ts b/src/renderer/types/ipc.ts index d994a82..c73a636 100644 --- a/src/renderer/types/ipc.ts +++ b/src/renderer/types/ipc.ts @@ -26,6 +26,8 @@ export interface IpcInvokeMap { 'dir:unwatch': [void, void] } +export type Unsubscribe = () => void + export interface ElectronAPI { openFile: () => Promise readFile: (filePath: string) => Promise @@ -42,14 +44,13 @@ export interface ElectronAPI { openFolderDialog: () => Promise watchDir: (dirPath: string) => Promise unwatchDir: () => Promise - onFileOpenInTab: (callback: (data: { filePath: string; content: string }) => void) => void - onMenuSave: (callback: () => void) => void - onMenuSaveAs: (callback: () => void) => void - onViewModeChange: (callback: (mode: string) => void) => void - onExternalModification: (callback: (filePath: string) => void) => void - onDirChanged: (callback: () => void) => void - onConfirmClose: (callback: () => void) => void - removeAllListeners: (channel: string) => void + onFileOpenInTab: (callback: (data: { filePath: string; content: string }) => void) => Unsubscribe + onMenuSave: (callback: () => void) => Unsubscribe + onMenuSaveAs: (callback: () => void) => Unsubscribe + onViewModeChange: (callback: (mode: string) => void) => Unsubscribe + onExternalModification: (callback: (filePath: string) => void) => Unsubscribe + onDirChanged: (callback: () => void) => Unsubscribe + onConfirmClose: (callback: () => void) => Unsubscribe } declare global { diff --git a/src/renderer/types/settings.ts b/src/renderer/types/settings.ts index a687fe0..8da0c09 100644 --- a/src/renderer/types/settings.ts +++ b/src/renderer/types/settings.ts @@ -1,17 +1,15 @@ -export type ViewMode = 'split' | 'editor' | 'preview' +export type ViewMode = 'editor' | 'preview' export interface Settings { darkMode: boolean viewMode: ViewMode - splitRatio: number sidebarCollapsed: boolean sidebarWidth: number } export const DEFAULT_SETTINGS: Settings = { darkMode: false, - viewMode: 'split', - splitRatio: 50, + viewMode: 'editor', sidebarCollapsed: false, sidebarWidth: 240 } diff --git a/src/renderer/types/tab.ts b/src/renderer/types/tab.ts index 4bb738f..4df65ef 100644 --- a/src/renderer/types/tab.ts +++ b/src/renderer/types/tab.ts @@ -4,8 +4,6 @@ export interface Tab { content: string isModified: boolean scrollTop: number - scrollLeft: number selectionStart: number selectionEnd: number - previewScrollTop: number }