refactor: 删除分屏功能 + 代码审计修复 + 安全加固

## 删除分屏功能
- 删除 ViewMode 'split' 类型,仅保留 editor/preview
- 删除 splitRatio 状态和 Resizer 组件
- 删除滚动同步模块 (scrollSync.ts, rehypeSourceLine.ts)
- 更新快捷键: Ctrl+1 编辑, Ctrl+2 预览

## Bug 修复
- 修复 Toast setTimeout 内存泄漏 (App.tsx)
- 修复 ModifiedBanner reload 更新错误标签 (改用 modifiedFilePath 匹配)
- 修复 FileWatcher error 未重置 isSelfWriting (file-watcher.ts)
- 修复另存为时未 stop watcher (ipc-handlers.ts)

## 死代码清理 (10 项)
- 删除 main/ipc-channels.ts (与 shared/ 重复)
- 删除 main/file-system.ts 未使用的 formatBytes
- 删除 constants.ts 6 个未使用常量
- 删除 fileUtils.ts 未使用的 formatBytes
- 删除 Icons.tsx 5 个未使用图标 (SplitView/ChevronUp/ChevronDown/X/ArrowUp/ArrowDown)
- 删除 useCodeMirror 未使用的 getContent/scrollTo
- 删除 TabBar 未使用的 menuRef
- 删除 Tab.scrollLeft/previewScrollTop 字段
- 删除 tabStore 未使用的 getTabIndex
- 删除 Toast/ModifiedBanner 多余 React import

## 安全加固
- ipc-handlers: 添加路径遍历防护 (validatePath 函数)
- preload: openExternal 仅允许 http/https 协议
- window-manager: 启用 sandbox: true
- preload: removeAllListeners 改为精确取消订阅 (返回 Unsubscribe 函数)

## 状态持久化
- activeTabId 持久化到 IndexedDB (刷新后恢复正确标签)
- sidebar 状态持久化到 IndexedDB (isVisible/sidebarWidth)

## 代码优化
- preload 使用 IPC_CHANNELS 常量替代硬编码字符串
- ipc-handlers _event 类型改为 IpcMainInvokeEvent
- settingsRepository 删除 splitRatio 字段
This commit is contained in:
thzxx
2026-05-28 13:42:11 +08:00
parent 3c6e4ac5ce
commit 9c92dcfa9d
37 changed files with 242 additions and 655 deletions
-6
View File
@@ -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'
}
+1
View File
@@ -29,6 +29,7 @@ export class FileWatcher {
this.watcher = null
}
this.currentPath = null
this.isSelfWriting = false
})
} catch {
// silently ignore
-23
View File
@@ -1,23 +0,0 @@
export const IPC_CHANNELS = {
DIALOG_OPEN_FILE: 'dialog:openFile',
FILE_READ: 'file:read',
FILE_SAVE: 'file:save',
FILE_SAVE_AS: 'file:saveAs',
FILE_GET_CURRENT_PATH: 'file:getCurrentPath',
FILE_STATS: 'file:stats',
FILE_RELOAD: 'file:reload',
TAB_SWITCHED: 'tab:switched',
WINDOW_FORCE_CLOSE: 'window:forceClose',
WINDOW_CANCEL_CLOSE: 'window:cancelClose',
DIR_READ_TREE: 'dir:readTree',
DIR_OPEN_DIALOG: 'dir:openDialog',
DIR_WATCH: 'dir:watch',
DIR_UNWATCH: 'dir:unwatch',
FILE_OPEN_IN_TAB: 'file:openInTab',
FILE_EXTERNALLY_MODIFIED: 'file:externallyModified',
MENU_SAVE: 'menu:save',
MENU_SAVE_AS: 'menu:saveAs',
MENU_VIEW_MODE: 'menu:viewMode',
WINDOW_CONFIRM_CLOSE: 'window:confirmClose',
SIDEBAR_DIR_CHANGED: 'sidebar:dirChanged'
} as const
+44 -11
View File
@@ -1,9 +1,22 @@
import { ipcMain, dialog, BrowserWindow } from 'electron'
import { ipcMain, dialog, BrowserWindow, type IpcMainInvokeEvent } from 'electron'
import { readFileContent, saveFileContent, buildDirTree } from './file-system'
import { 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()
+2 -1
View File
@@ -12,7 +12,8 @@ export function createWindow(): BrowserWindow {
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
contextIsolation: true,
nodeIntegration: false
nodeIntegration: false,
sandbox: true
},
titleBarStyle: 'default',
show: false
+61 -48
View File
@@ -1,62 +1,75 @@
import { contextBridge, ipcRenderer, shell } from 'electron'
// M-01: 限制 removeAllListeners 只能操作白名单通道
const ALLOWED_REMOVE_CHANNELS = new Set([
'file:openInTab',
'menu:save',
'menu:saveAs',
'menu:viewMode',
'file:externallyModified',
'sidebar:dirChanged',
'window:confirmClose'
])
import { IPC_CHANNELS } from '../shared/ipc-channels'
contextBridge.exposeInMainWorld('electronAPI', {
// File operations
openFile: () => ipcRenderer.invoke('dialog:openFile'),
readFile: (filePath: string) => ipcRenderer.invoke('file:read', filePath),
saveFile: (data: { filePath: string | null; content: string }) => ipcRenderer.invoke('file:save', data),
saveFileAs: (data: { content: string }) => ipcRenderer.invoke('file:saveAs', data),
getCurrentPath: () => ipcRenderer.invoke('file:getCurrentPath'),
getFileStats: (filePath: string) => ipcRenderer.invoke('file:stats', filePath),
reloadFile: () => ipcRenderer.invoke('file:reload'),
openFile: () => ipcRenderer.invoke(IPC_CHANNELS.DIALOG_OPEN_FILE),
readFile: (filePath: string) => ipcRenderer.invoke(IPC_CHANNELS.FILE_READ, filePath),
saveFile: (data: { filePath: string | null; content: string }) => ipcRenderer.invoke(IPC_CHANNELS.FILE_SAVE, data),
saveFileAs: (data: { content: string }) => ipcRenderer.invoke(IPC_CHANNELS.FILE_SAVE_AS, data),
getCurrentPath: () => ipcRenderer.invoke(IPC_CHANNELS.FILE_GET_CURRENT_PATH),
getFileStats: (filePath: string) => ipcRenderer.invoke(IPC_CHANNELS.FILE_STATS, filePath),
reloadFile: () => ipcRenderer.invoke(IPC_CHANNELS.FILE_RELOAD),
// Tab management
tabSwitched: (filePath: string | null) => ipcRenderer.invoke('tab:switched', filePath),
tabSwitched: (filePath: string | null) => ipcRenderer.invoke(IPC_CHANNELS.TAB_SWITCHED, filePath),
// Window control
forceClose: () => ipcRenderer.invoke('window:forceClose'),
cancelClose: () => ipcRenderer.invoke('window:cancelClose'),
forceClose: () => ipcRenderer.invoke(IPC_CHANNELS.WINDOW_FORCE_CLOSE),
cancelClose: () => ipcRenderer.invoke(IPC_CHANNELS.WINDOW_CANCEL_CLOSE),
// Shell
openExternal: (url: string) => shell.openExternal(url),
// Shell — 仅允许 http/https 协议
openExternal: (url: string) => {
try {
const parsed = new URL(url)
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
shell.openExternal(url)
}
} catch {
// 无效 URL,忽略
}
},
// File Tree (Sidebar)
readDirTree: (dirPath: string) => ipcRenderer.invoke('dir:readTree', dirPath),
openFolderDialog: () => ipcRenderer.invoke('dir:openDialog'),
watchDir: (dirPath: string) => ipcRenderer.invoke('dir:watch', dirPath),
unwatchDir: () => ipcRenderer.invoke('dir:unwatch'),
readDirTree: (dirPath: string) => ipcRenderer.invoke(IPC_CHANNELS.DIR_READ_TREE, dirPath),
openFolderDialog: () => ipcRenderer.invoke(IPC_CHANNELS.DIR_OPEN_DIALOG),
watchDir: (dirPath: string) => ipcRenderer.invoke(IPC_CHANNELS.DIR_WATCH, dirPath),
unwatchDir: () => ipcRenderer.invoke(IPC_CHANNELS.DIR_UNWATCH),
// Events from main process
onFileOpenInTab: (callback: (data: { filePath: string; content: string }) => void) =>
ipcRenderer.on('file:openInTab', (_event, data) => callback(data)),
onMenuSave: (callback: () => void) =>
ipcRenderer.on('menu:save', () => callback()),
onMenuSaveAs: (callback: () => void) =>
ipcRenderer.on('menu:saveAs', () => callback()),
onViewModeChange: (callback: (mode: string) => void) =>
ipcRenderer.on('menu:viewMode', (_event, mode) => callback(mode)),
onExternalModification: (callback: (filePath: string) => void) =>
ipcRenderer.on('file:externallyModified', (_event, filePath) => callback(filePath)),
onDirChanged: (callback: () => void) =>
ipcRenderer.on('sidebar:dirChanged', () => callback()),
onConfirmClose: (callback: () => void) =>
ipcRenderer.on('window:confirmClose', () => callback()),
// M-01: 只允许移除白名单通道的监听器
removeAllListeners: (channel: string) => {
if (ALLOWED_REMOVE_CHANNELS.has(channel)) {
ipcRenderer.removeAllListeners(channel)
}
// Events from main process — 返回取消订阅函数
onFileOpenInTab: (callback: (data: { filePath: string; content: string }) => void) => {
const handler = (_event: Electron.IpcRendererEvent, data: { filePath: string; content: string }) => callback(data)
ipcRenderer.on(IPC_CHANNELS.FILE_OPEN_IN_TAB, handler)
return () => { ipcRenderer.removeListener(IPC_CHANNELS.FILE_OPEN_IN_TAB, handler) }
},
onMenuSave: (callback: () => void) => {
const handler = () => callback()
ipcRenderer.on(IPC_CHANNELS.MENU_SAVE, handler)
return () => { ipcRenderer.removeListener(IPC_CHANNELS.MENU_SAVE, handler) }
},
onMenuSaveAs: (callback: () => void) => {
const handler = () => callback()
ipcRenderer.on(IPC_CHANNELS.MENU_SAVE_AS, handler)
return () => { ipcRenderer.removeListener(IPC_CHANNELS.MENU_SAVE_AS, handler) }
},
onViewModeChange: (callback: (mode: string) => void) => {
const handler = (_event: Electron.IpcRendererEvent, mode: string) => callback(mode)
ipcRenderer.on(IPC_CHANNELS.MENU_VIEW_MODE, handler)
return () => { ipcRenderer.removeListener(IPC_CHANNELS.MENU_VIEW_MODE, handler) }
},
onExternalModification: (callback: (filePath: string) => void) => {
const handler = (_event: Electron.IpcRendererEvent, filePath: string) => callback(filePath)
ipcRenderer.on(IPC_CHANNELS.FILE_EXTERNALLY_MODIFIED, handler)
return () => { ipcRenderer.removeListener(IPC_CHANNELS.FILE_EXTERNALLY_MODIFIED, handler) }
},
onDirChanged: (callback: () => void) => {
const handler = () => callback()
ipcRenderer.on(IPC_CHANNELS.SIDEBAR_DIR_CHANGED, handler)
return () => { ipcRenderer.removeListener(IPC_CHANNELS.SIDEBAR_DIR_CHANGED, handler) }
},
onConfirmClose: (callback: () => void) => {
const handler = () => callback()
ipcRenderer.on(IPC_CHANNELS.WINDOW_CONFIRM_CLOSE, handler)
return () => { ipcRenderer.removeListener(IPC_CHANNELS.WINDOW_CONFIRM_CLOSE, handler) }
}
})
+23 -66
View File
@@ -1,6 +1,7 @@
import React, { useState, useCallback, useEffect, useRef } from 'react'
import { useTabStore } from './stores/tabStore'
import { useEditorStore } from './stores/editorStore'
import { useSidebarStore } from './stores/sidebarStore'
import { useTheme } from './hooks/useTheme'
import { useSettings } from './hooks/useSettings'
import { useKeyboard } from './hooks/useKeyboard'
@@ -27,25 +28,29 @@ export default function App() {
const setModified = useTabStore(s => s.setModified)
const updateTabContent = useTabStore(s => s.updateTabContent)
const loadFromDB = useTabStore(s => s.loadFromDB)
const loadSidebarFromDB = useSidebarStore(s => s.loadFromDB)
const viewMode = useEditorStore(s => s.viewMode)
const splitRatio = useEditorStore(s => s.splitRatio)
const { darkMode, toggleDarkMode } = useTheme()
const { saveViewMode, saveSplitRatio } = useSettings()
const { saveViewMode } = useSettings()
const [showModifiedBanner, setShowModifiedBanner] = useState(false)
const [modifiedFilePath, setModifiedFilePath] = useState<string | null>(null)
const [toastMessage, setToastMessage] = useState<string | null>(null)
const toastTimer = useRef<number>(0)
// 启动时从 IndexedDB 加载标签页
// 启动时从 IndexedDB 加载标签页和 sidebar 设置
useEffect(() => {
loadFromDB()
}, [loadFromDB])
loadSidebarFromDB()
return () => clearTimeout(toastTimer.current)
}, [loadFromDB, loadSidebarFromDB])
const showToast = useCallback((msg: string) => {
clearTimeout(toastTimer.current)
setToastMessage(msg)
setTimeout(() => setToastMessage(null), 3000)
toastTimer.current = window.setTimeout(() => setToastMessage(null), 3000)
}, [])
// 拖拽打开
@@ -128,7 +133,7 @@ export default function App() {
}, [createTab])
// 视图模式切换
const handleViewModeChange = useCallback((mode: 'split' | 'editor' | 'preview') => {
const handleViewModeChange = useCallback((mode: 'editor' | 'preview') => {
saveViewMode(mode)
}, [saveViewMode])
@@ -150,28 +155,19 @@ export default function App() {
const onSave = () => handleSaveRef.current()
const onSaveAs = () => handleSaveAsRef.current()
api.onFileOpenInTab(onOpen)
api.onMenuSave(onSave)
api.onMenuSaveAs(onSaveAs)
const unsub1 = api.onFileOpenInTab(onOpen)
const unsub2 = api.onMenuSave(onSave)
const unsub3 = api.onMenuSaveAs(onSaveAs)
return () => {
api.removeAllListeners('file:openInTab')
api.removeAllListeners('menu:save')
api.removeAllListeners('menu:saveAs')
unsub1()
unsub2()
unsub3()
}
}, [createTab])
const activeTab = getActiveTab()
const hasTabs = tabs.length > 0
// 分屏样式
const editorStyle: React.CSSProperties = viewMode === 'split'
? { flex: `0 0 ${splitRatio}%` }
: {}
const previewStyle: React.CSSProperties = viewMode === 'split'
? { flex: `0 0 ${100 - splitRatio}%` }
: {}
return (
<div id="app" className={`mode-${viewMode}`}>
<Toolbar
@@ -193,9 +189,9 @@ export default function App() {
<ModifiedBanner
onReload={() => {
if (window.electronAPI && modifiedFilePath) {
window.electronAPI.reloadFile().then(result => {
window.electronAPI.readFile(modifiedFilePath).then(result => {
if (result.success && result.content) {
const tab = getActiveTab()
const tab = tabs.find(t => t.filePath === modifiedFilePath)
if (tab) {
updateTabContent(tab.id, result.content)
setModified(tab.id, false)
@@ -211,14 +207,13 @@ export default function App() {
{hasTabs ? (
<div id="content-wrapper">
{viewMode !== 'preview' && (
<div id="editor-panel" style={editorStyle}>
{viewMode === 'editor' && (
<div id="editor-panel">
<Editor darkMode={darkMode} />
</div>
)}
{viewMode !== 'editor' && <Resizer onResize={saveSplitRatio} />}
{viewMode !== 'editor' && (
<div id="preview-panel" style={previewStyle}>
{viewMode === 'preview' && (
<div id="preview-panel">
<Preview />
</div>
)}
@@ -240,41 +235,3 @@ export default function App() {
</div>
)
}
// B-08: 分屏调节器组件
function Resizer({ onResize }: { onResize: (ratio: number) => void }) {
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault()
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
const handleMouseMove = (ev: MouseEvent) => {
const wrapper = document.getElementById('content-wrapper')
if (!wrapper) return
const rect = wrapper.getBoundingClientRect()
const pct = Math.max(20, Math.min(80, ((ev.clientX - rect.left) / rect.width) * 100))
const editorPanel = document.getElementById('editor-panel')
const previewPanel = document.getElementById('preview-panel')
if (editorPanel) editorPanel.style.flex = `0 0 ${pct}%`
if (previewPanel) previewPanel.style.flex = `0 0 ${100 - pct}%`
}
const handleMouseUp = (ev: MouseEvent) => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
document.body.style.cursor = ''
document.body.style.userSelect = ''
const wrapper = document.getElementById('content-wrapper')
if (wrapper) {
const rect = wrapper.getBoundingClientRect()
const pct = Math.max(20, Math.min(80, ((ev.clientX - rect.left) / rect.width) * 100))
onResize(pct)
}
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}, [onResize])
return <div id="resizer" onMouseDown={handleMouseDown} />
}
+2 -19
View File
@@ -22,8 +22,7 @@ export function Editor({ darkMode }: EditorProps) {
getScrollTop,
setScrollTop,
getSelection,
setSelection,
setLineAtTop
setSelection
} = useCodeMirror({
content: activeTab?.content ?? '',
onChange: useCallback((value: string) => {
@@ -31,25 +30,9 @@ export function Editor({ darkMode }: EditorProps) {
updateTabContent(activeTabId, value)
setModified(activeTabId, true)
}, [activeTabId, updateTabContent, setModified]),
darkMode,
onScroll: useCallback((line: number) => {
// 派发行号给预览侧做滚动同步
window.dispatchEvent(new CustomEvent('editor-scroll', { detail: { line } }))
}, [])
darkMode
})
// 反向同步:监听预览滚动事件
useEffect(() => {
const handlePreviewScroll = (e: Event) => {
const line = (e as CustomEvent).detail?.line
if (typeof line === 'number') {
setLineAtTop(Math.floor(line) + 1) // setLineAtTop 是 1-indexed
}
}
window.addEventListener('preview-scroll', handlePreviewScroll)
return () => window.removeEventListener('preview-scroll', handlePreviewScroll)
}, [setLineAtTop])
// 切换标签时加载内容
useEffect(() => {
if (!activeTab) return
@@ -11,10 +11,9 @@ interface UseCodeMirrorOptions {
content: string
onChange: (value: string) => void
darkMode: boolean
onScroll?: (line: number) => void
}
export function useCodeMirror({ content, onChange, darkMode, onScroll }: UseCodeMirrorOptions) {
export function useCodeMirror({ content, onChange, darkMode }: UseCodeMirrorOptions) {
const containerRef = useRef<HTMLDivElement>(null)
const viewRef = useRef<EditorView | null>(null)
const isExternalUpdate = useRef(false)
@@ -81,23 +80,7 @@ export function useCodeMirror({ content, onChange, darkMode, onScroll }: UseCode
viewRef.current = view
// 滚动事件监听:派发行号(支持小数表示行内偏移)
const handleScroll = () => {
if (onScroll) {
const dom = view.scrollDOM
const scrollTop = dom.scrollTop
const block = view.lineBlockAtHeight(scrollTop)
const line = view.state.doc.lineAt(block.from)
const fraction = block.height > 0
? (scrollTop - block.top) / block.height
: 0
onScroll(line.number - 1 + Math.max(0, fraction)) // 0-indexed + 行内比例
}
}
view.scrollDOM.addEventListener('scroll', handleScroll)
return () => {
view.scrollDOM.removeEventListener('scroll', handleScroll)
view.destroy()
viewRef.current = null
}
@@ -116,12 +99,6 @@ export function useCodeMirror({ content, onChange, darkMode, onScroll }: UseCode
isExternalUpdate.current = false
}
}, [])
// 获取当前内容
const getContent = useCallback(() => {
return viewRef.current?.state.doc.toString() ?? ''
}, [])
// 获取/设置滚动位置
const getScrollTop = useCallback(() => {
return viewRef.current?.scrollDOM.scrollTop ?? 0
@@ -147,35 +124,13 @@ export function useCodeMirror({ content, onChange, darkMode, onScroll }: UseCode
view.dispatch({ selection: { anchor: from, head: to } })
view.focus()
}, [])
// 滚动到指定行
const scrollTo = useCallback((pos: number) => {
const view = viewRef.current
if (!view) return
view.dispatch({ effects: EditorView.scrollIntoView(pos, { y: 'center' }) })
}, [])
// 滚动使指定行出现在编辑器顶部(反向同步用)
const setLineAtTop = useCallback((lineNumber: number) => {
const view = viewRef.current
if (!view) return
const line = Math.max(1, Math.min(lineNumber, view.state.doc.lines))
const lineInfo = view.state.doc.line(line)
const block = view.lineBlockAt(lineInfo.from)
const currentTop = view.lineBlockAtHeight(view.scrollDOM.scrollTop).top
view.scrollDOM.scrollTop += block.top - currentTop
}, [])
return {
containerRef,
viewRef,
setContent,
getContent,
getScrollTop,
setScrollTop,
getSelection,
setSelection,
scrollTo,
setLineAtTop
setSelection
}
}
-58
View File
@@ -58,19 +58,6 @@ export function Save({ size = defaultProps.size }: IconProps) {
)
}
export function SplitView({ size = defaultProps.size }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="3"/>
<line x1="12" y1="3" x2="12" y2="21"/>
<rect x="5" y="7" width="5" height="2" rx="1" fill="currentColor" opacity="0.4"/>
<rect x="5" y="11" width="3" height="2" rx="1" fill="currentColor" opacity="0.3"/>
<rect x="14" y="7" width="5" height="2" rx="1" fill="currentColor" opacity="0.4"/>
<rect x="14" y="11" width="3" height="2" rx="1" fill="currentColor" opacity="0.3"/>
</svg>
)
}
export function EditMode({ size = defaultProps.size }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
@@ -175,32 +162,6 @@ export function FolderPlus({ size = 14 }: IconProps) {
)
}
// ===== 搜索栏图标 =====
export function ChevronUp({ size = 12 }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="18 15 12 9 6 15"/>
</svg>
)
}
export function ChevronDown({ size = 12 }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="6 9 12 15 18 9"/>
</svg>
)
}
export function X({ size = 14 }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
)
}
// ===== 拖拽覆盖层图标 =====
export function UploadCloud({ size = 64 }: IconProps) {
return (
@@ -213,25 +174,6 @@ export function UploadCloud({ size = 64 }: IconProps) {
)
}
// ===== 箭头/导航图标 =====
export function ArrowUp({ size = 12 }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="19" x2="12" y2="5"/>
<polyline points="5 12 12 5 19 12"/>
</svg>
)
}
export function ArrowDown({ size = 12 }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="5" x2="12" y2="19"/>
<polyline points="19 12 12 19 5 12"/>
</svg>
)
}
// ===== 欢迎屏幕图标 =====
export function WelcomeFile({ size = 20 }: IconProps) {
return (
@@ -1,5 +1,3 @@
import React from 'react'
interface ModifiedBannerProps {
onReload: () => void
onDismiss: () => void
@@ -1,13 +1,11 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { useTabStore } from '../../stores/tabStore'
import { renderMarkdown } from '../../lib/markdown'
import { scrollPreviewToLine, getLineAtScrollOffset, invalidateScrollCache } from '../../lib/scrollSync'
export function Preview() {
const [html, setHtml] = useState('')
const previewRef = useRef<HTMLDivElement>(null)
const requestIdRef = useRef(0)
const isSyncingRef = useRef(false)
const activeTabId = useTabStore(s => s.activeTabId)
const tabs = useTabStore(s => s.tabs)
@@ -24,49 +22,10 @@ export function Preview() {
renderMarkdown(activeTab.content, activeTab.filePath).then(result => {
if (requestId === requestIdRef.current) {
setHtml(result)
invalidateScrollCache()
}
})
}, [activeTabId, activeTab?.content])
// 滚动同步:监听编辑器滚动事件(VS Code 方案:行号 → 二分查找)
useEffect(() => {
const handleEditorScroll = (e: Event) => {
const line = (e as CustomEvent).detail?.line
if (typeof line !== 'number') return
const container = previewRef.current?.parentElement
if (!container || isSyncingRef.current) return
scrollPreviewToLine(line, container, isSyncingRef)
}
window.addEventListener('editor-scroll', handleEditorScroll)
return () => window.removeEventListener('editor-scroll', handleEditorScroll)
}, [])
// 反向同步:预览滚动 → 通知编辑器
useEffect(() => {
const container = previewRef.current?.parentElement
if (!container) return
const handleScroll = () => {
if (isSyncingRef.current) return
const line = getLineAtScrollOffset(container.scrollTop, container)
if (line !== null) {
isSyncingRef.current = true
window.dispatchEvent(new CustomEvent('preview-scroll', { detail: { line } }))
requestAnimationFrame(() => {
isSyncingRef.current = false
})
}
}
container.addEventListener('scroll', handleScroll, { passive: true })
return () => container.removeEventListener('scroll', handleScroll)
}, [activeTabId])
// 拦截链接点击
const handleClick = useCallback((e: React.MouseEvent) => {
const link = (e.target as HTMLElement).closest('a')
+2 -4
View File
@@ -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(() => {
+2 -4
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useState, useEffect, useRef } from 'react'
import React, { useCallback, useState, useEffect } from 'react'
import { useTabStore } from '../../stores/tabStore'
import { getFileName } from '../../lib/fileUtils'
import { Close, Plus } from '../Icons'
@@ -21,7 +21,6 @@ export function TabBar() {
const closeTabsToRight = useTabStore(s => s.closeTabsToRight)
const [menu, setMenu] = useState<ContextMenuState>({ visible: false, x: 0, y: 0, tabId: '' })
const menuRef = useRef<HTMLDivElement>(null)
const handleClose = useCallback((e: React.MouseEvent, tabId: string) => {
e.stopPropagation()
@@ -133,8 +132,7 @@ export function TabBar() {
{/* 右键菜单 */}
{menu.visible && (
<div
ref={menuRef}
className="tab-context-menu"
className="tab-context-menu"
style={{ left: menu.x, top: menu.y }}
onClick={(e) => e.stopPropagation()}
>
-2
View File
@@ -1,5 +1,3 @@
import React from 'react'
interface ToastProps {
message: string
}
+5 -9
View File
@@ -1,11 +1,11 @@
import React from 'react'
import { FolderOpen, Save, SplitView, EditMode, PreviewMode, Moon, Sun } from '../Icons'
import { FolderOpen, Save, EditMode, PreviewMode, Moon, Sun } from '../Icons'
interface ToolbarProps {
onOpen: () => void
onSave: () => void
viewMode: 'split' | 'editor' | 'preview'
onViewModeChange: (mode: 'split' | 'editor' | 'preview') => void
viewMode: 'editor' | 'preview'
onViewModeChange: (mode: 'editor' | 'preview') => void
darkMode: boolean
onToggleDark: () => void
}
@@ -23,15 +23,11 @@ export function Toolbar({ onOpen, onSave, viewMode, onViewModeChange, darkMode,
<span></span>
</button>
<div className="toolbar-divider" />
<button className={`toolbar-btn ${viewMode === 'split' ? 'active' : ''}`} onClick={() => onViewModeChange('split')} title="编辑+预览 (Ctrl+1)">
<SplitView size={18} />
<span></span>
</button>
<button className={`toolbar-btn ${viewMode === 'editor' ? 'active' : ''}`} onClick={() => onViewModeChange('editor')} title="纯编辑 (Ctrl+2)">
<button className={`toolbar-btn ${viewMode === 'editor' ? 'active' : ''}`} onClick={() => onViewModeChange('editor')} title="编辑 (Ctrl+1)">
<EditMode size={18} />
<span></span>
</button>
<button className={`toolbar-btn ${viewMode === 'preview' ? 'active' : ''}`} onClick={() => onViewModeChange('preview')} title="预览 (Ctrl+3)">
<button className={`toolbar-btn ${viewMode === 'preview' ? 'active' : ''}`} onClick={() => onViewModeChange('preview')} title="预览 (Ctrl+2)">
<PreviewMode size={18} />
<span></span>
</button>
+9 -5
View File
@@ -5,19 +5,21 @@ export interface TabSnapshot {
filePath: string | null
content: string
scrollTop: number
scrollLeft: number
selectionStart: number
selectionEnd: number
previewScrollTop: number
isModified: boolean
updatedAt: number
}
export interface ActiveTabRecord {
id: string // 固定为 'current'
activeTabId: string | null
}
export interface SettingsRecord {
id: string // 固定为 'default'
darkMode: boolean
viewMode: 'split' | 'editor' | 'preview'
splitRatio: number
viewMode: 'editor' | 'preview'
sidebarCollapsed: boolean
sidebarWidth: number
}
@@ -32,12 +34,14 @@ const db = new Dexie('MarkLite') as Dexie & {
tabSnapshots: EntityTable<TabSnapshot, 'id'>
settings: EntityTable<SettingsRecord, 'id'>
recentFiles: EntityTable<RecentFile, 'id'>
activeTab: EntityTable<ActiveTabRecord, 'id'>
}
db.version(1).stores({
tabSnapshots: 'id, filePath, updatedAt',
settings: 'id',
recentFiles: '++id, filePath, lastOpened'
recentFiles: '++id, filePath, lastOpened',
activeTab: 'id'
})
export { db }
-1
View File
@@ -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
}
+9
View File
@@ -14,5 +14,14 @@ export const tabRepository = {
async clearAll(): Promise<void> {
await db.tabSnapshots.clear()
},
async saveActiveTabId(tabId: string | null): Promise<void> {
await db.activeTab.put({ id: 'current', activeTabId: tabId })
},
async loadActiveTabId(): Promise<string | null> {
const record = await db.activeTab.get('current')
return record?.activeTabId ?? null
}
}
+2 -4
View File
@@ -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])
}
+2 -3
View File
@@ -12,9 +12,8 @@ export function useKeyboard(handleOpenFile: () => void, handleSave: () => void,
if (isCtrl && e.key === 'o') { e.preventDefault(); handleOpenFile(); return }
if (isCtrl && e.key === '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 }
+3 -11
View File
@@ -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 }
}
+2 -4
View File
@@ -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])
}
+1 -6
View File
@@ -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
-6
View File
@@ -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
}
-2
View File
@@ -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)
-36
View File
@@ -1,36 +0,0 @@
import { visit } from 'unist-util-visit'
import type { Root, Element } from 'hast'
/**
* Rehype 插件:给块级元素注入 data-line 属性
*
* 利用 unified/remark 管线自动维护的 position 信息,
* 将源文件行号附加到渲染后的 HTML 元素上,
* 用于编辑器↔预览的滚动同步(VS Code 方案)。
*/
export function rehypeSourceLine() {
return (tree: Root) => {
visit(tree, 'element', (node: Element) => {
const pos = node.position
if (!pos?.start?.line) return
// position.start.line 是 1-indexed,转为 0-indexed
const line = pos.start.line - 1
node.properties = {
...node.properties,
'data-line': String(line),
}
// 追加 code-line class(保留原有 class
const existing = node.properties?.class
if (typeof existing === 'string') {
node.properties.class = `${existing} code-line`
} else if (Array.isArray(existing)) {
node.properties.class = [...existing, 'code-line']
} else {
node.properties.class = 'code-line'
}
})
}
}
-164
View File
@@ -1,164 +0,0 @@
/**
* 滚动同步 — VS Code 方案
*
* 核心思路:
* 1. 渲染时 rehypeSourceLine 插件给块级元素注入 data-line 属性
* 2. 预览侧收集所有 [data-line] 元素,按行号排序
* 3. 编辑器派发当前行号 → 二分查找对应元素 → 线性插值滚动
* 4. 预览侧反向映射:像素偏移 → 行号 → 通知编辑器
*/
interface CodeLineElement {
element: HTMLElement
line: number
top: number
height: number
}
let cachedElements: CodeLineElement[] | null = null
let cachedHTML = ''
/**
* 收集并缓存所有带 data-line 的元素,按行号排序
*/
function getCodeLineElements(container: HTMLElement): CodeLineElement[] {
const html = container.innerHTML
if (cachedElements && cachedHTML === html) return cachedElements
cachedHTML = html
const els = container.querySelectorAll<HTMLElement>('[data-line]')
const result: CodeLineElement[] = []
for (const el of els) {
const line = parseInt(el.getAttribute('data-line')!, 10)
if (isNaN(line)) continue
// 跳过不可见元素
if (el.offsetHeight === 0) continue
result.push({
element: el,
line,
top: el.offsetTop,
height: el.offsetHeight,
})
}
result.sort((a, b) => a.line - b.line)
cachedElements = result
return result
}
/**
* 使缓存失效(内容变化后调用)
*/
export function invalidateScrollCache(): void {
cachedElements = null
cachedHTML = ''
}
/**
* 二分查找:找到目标行对应的元素(精确匹配或前后夹逼)
*/
function findElementsForLine(
elements: CodeLineElement[],
targetLine: number,
): { previous: CodeLineElement; next: CodeLineElement | null } {
let lo = 0
let hi = elements.length - 1
// 二分查找最后一个 line <= targetLine 的元素
while (lo < hi) {
const mid = Math.floor((lo + hi + 1) / 2)
if (elements[mid].line <= targetLine) {
lo = mid
} else {
hi = mid - 1
}
}
const previous = elements[lo]
const next = lo < elements.length - 1 ? elements[lo + 1] : null
return { previous, next }
}
/**
* 编辑器滚动 → 预览滚动
*
* @param line 编辑器当前可见区域顶部对应的源文件行号(0-indexed,支持小数表示行内偏移)
* @param previewContainer 预览面板的滚动容器(#preview-panel
* @param isSyncingRef 双向同步锁,防止循环触发
*/
export function scrollPreviewToLine(
line: number,
previewContainer: HTMLElement,
isSyncingRef: { current: boolean },
): void {
const elements = getCodeLineElements(previewContainer)
if (elements.length === 0) return
isSyncingRef.current = true
const lineNumber = Math.floor(line)
const fraction = line - lineNumber
const { previous, next } = findElementsForLine(elements, lineNumber)
let scrollTo: number
if (previous.line === lineNumber) {
// 精确匹配:滚动到该元素顶部
scrollTo = previous.top + fraction * previous.height
} else if (next && next.line !== previous.line) {
// 在两个元素之间:线性插值
const progress = (lineNumber - previous.line + fraction) / (next.line - previous.line)
const gapTop = previous.top + previous.height
const gapHeight = next.top - gapTop
scrollTo = gapTop + progress * (previous.height + gapHeight)
} else {
// 最后一个元素之后:按行内比例偏移
scrollTo = previous.top + fraction * previous.height
}
previewContainer.scrollTo(0, Math.max(0, scrollTo))
requestAnimationFrame(() => {
isSyncingRef.current = false
})
}
/**
* 预览滚动 → 编辑器行号(用于反向同步)
*
* @param scrollTop 预览面板的 scrollTop
* @param previewContainer 预览面板的滚动容器
* @returns 对应的源文件行号(0-indexed),无元素时返回 null
*/
export function getLineAtScrollOffset(
scrollTop: number,
previewContainer: HTMLElement,
): number | null {
const elements = getCodeLineElements(previewContainer)
if (elements.length === 0) return null
const position = scrollTop
// 二分查找最后一个 top <= position 的元素
let lo = 0
let hi = elements.length - 1
while (lo < hi) {
const mid = Math.floor((lo + hi + 1) / 2)
if (elements[mid].top <= position) {
lo = mid
} else {
hi = mid - 1
}
}
const el = elements[lo]
if (el.height === 0) return el.line
const progress = Math.min(1, Math.max(0, (position - el.top) / el.height))
const nextLine = lo < elements.length - 1 ? elements[lo + 1].line : el.line + 1
return el.line + progress * (nextLine - el.line)
}
+1 -5
View File
@@ -4,21 +4,17 @@ import type { ViewMode } from '../types/settings'
interface EditorState {
viewMode: ViewMode
darkMode: boolean
splitRatio: number
setViewMode: (mode: ViewMode) => void
setDarkMode: (dark: boolean) => void
setSplitRatio: (ratio: number) => void
toggleDarkMode: () => void
}
export const useEditorStore = create<EditorState>((set) => ({
viewMode: 'split',
viewMode: 'editor',
darkMode: false,
splitRatio: 50,
setViewMode: (mode) => set({ viewMode: mode }),
setDarkMode: (dark) => set({ darkMode: dark }),
setSplitRatio: (ratio) => set({ splitRatio: ratio }),
toggleDarkMode: () => set(state => ({ darkMode: !state.darkMode }))
}))
+30 -3
View File
@@ -1,5 +1,6 @@
import { create } from 'zustand'
import type { FileNode } from '../types/file'
import { settingsRepository } from '../db/settingsRepository'
interface SidebarState {
isVisible: boolean
@@ -7,6 +8,7 @@ interface SidebarState {
tree: FileNode[]
expandedDirs: Set<string>
sidebarWidth: number
_loaded: boolean
setVisible: (visible: boolean) => void
setRootPath: (path: string | null) => void
@@ -14,16 +16,37 @@ interface SidebarState {
toggleDir: (path: string) => void
expandDirs: (paths: string[]) => void
setSidebarWidth: (width: number) => void
loadFromDB: () => Promise<void>
}
export const useSidebarStore = create<SidebarState>((set) => ({
export const useSidebarStore = create<SidebarState>((set, get) => ({
isVisible: true,
rootPath: null,
tree: [],
expandedDirs: new Set<string>(),
sidebarWidth: 240,
_loaded: false,
setVisible: (visible) => set({ isVisible: visible }),
// 从 IndexedDB 加载 sidebar 设置
loadFromDB: async () => {
if (get()._loaded) return
try {
const settings = await settingsRepository.load()
set({
isVisible: !settings.sidebarCollapsed,
sidebarWidth: settings.sidebarWidth,
_loaded: true
})
} catch {
set({ _loaded: true })
}
},
setVisible: (visible) => {
set({ isVisible: visible })
// 异步保存到 DB
settingsRepository.save({ sidebarCollapsed: !visible })
},
setRootPath: (path) => set({ rootPath: path }),
setTree: (tree) => set({ tree }),
toggleDir: (path) =>
@@ -48,5 +71,9 @@ export const useSidebarStore = create<SidebarState>((set) => ({
}
return changed ? { expandedDirs: newSet } : state
}),
setSidebarWidth: (width) => set({ sidebarWidth: width })
setSidebarWidth: (width) => {
set({ sidebarWidth: width })
// 异步保存到 DB
settingsRepository.save({ sidebarWidth: width })
}
}))
+15 -16
View File
@@ -18,8 +18,7 @@ interface TabState {
updateTabContent: (tabId: string, content: string) => void
setModified: (tabId: string, modified: boolean) => void
getActiveTab: () => Tab | null
getTabIndex: (tabId: string) => number
updateTabScroll: (tabId: string, scroll: Partial<Pick<Tab, 'scrollTop' | 'scrollLeft' | 'selectionStart' | 'selectionEnd' | 'previewScrollTop'>>) => void
updateTabScroll: (tabId: string, scroll: Partial<Pick<Tab, 'scrollTop' | 'selectionStart' | 'selectionEnd'>>) => void
loadFromDB: () => Promise<void>
saveToDB: () => Promise<void>
}
@@ -34,7 +33,10 @@ export const useTabStore = create<TabState>((set, get) => ({
loadFromDB: async () => {
if (get()._loaded) return
try {
const snapshots = await tabRepository.loadAll()
const [snapshots, savedActiveTabId] = await Promise.all([
tabRepository.loadAll(),
tabRepository.loadActiveTabId()
])
if (snapshots.length > 0) {
const tabs: Tab[] = snapshots.map(s => ({
id: s.id,
@@ -42,14 +44,16 @@ export const useTabStore = create<TabState>((set, get) => ({
content: s.content,
isModified: s.isModified,
scrollTop: s.scrollTop,
scrollLeft: s.scrollLeft,
selectionStart: s.selectionStart,
selectionEnd: s.selectionEnd,
previewScrollTop: s.previewScrollTop
selectionEnd: s.selectionEnd
}))
// 使用保存的 activeTabId,如果不存在则取最后一个
const activeTabId = (savedActiveTabId && tabs.find(t => t.id === savedActiveTabId))
? savedActiveTabId
: tabs[tabs.length - 1].id
set({
tabs,
activeTabId: tabs[tabs.length - 1].id,
activeTabId,
_loaded: true
})
} else {
@@ -73,13 +77,12 @@ export const useTabStore = create<TabState>((set, get) => ({
content: t.content,
isModified: t.isModified,
scrollTop: t.scrollTop,
scrollLeft: t.scrollLeft,
selectionStart: t.selectionStart,
selectionEnd: t.selectionEnd,
previewScrollTop: t.previewScrollTop,
updatedAt: Date.now()
}))
await tabRepository.saveAll(snapshots)
await tabRepository.saveActiveTabId(get().activeTabId)
},
createTab: (filePath = null, content = '') => {
@@ -97,10 +100,8 @@ export const useTabStore = create<TabState>((set, get) => ({
content,
isModified: false,
scrollTop: 0,
scrollLeft: 0,
selectionStart: 0,
selectionEnd: 0,
previewScrollTop: 0
selectionEnd: 0
}
set(state => ({
@@ -187,6 +188,8 @@ export const useTabStore = create<TabState>((set, get) => ({
: state.mruStack
return { activeTabId: tabId, mruStack: newMru }
})
// 异步保存 activeTabId
setTimeout(() => tabRepository.saveActiveTabId(tabId), 0)
},
updateTabContent: (tabId, content) => {
@@ -210,10 +213,6 @@ export const useTabStore = create<TabState>((set, get) => ({
return tabs.find(t => t.id === activeTabId) ?? null
},
getTabIndex: (tabId) => {
return get().tabs.findIndex(t => t.id === tabId)
},
updateTabScroll: (tabId, scroll) => {
set(state => ({
tabs: state.tabs.map(t =>
+2 -18
View File
@@ -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 {
+9 -8
View File
@@ -26,6 +26,8 @@ export interface IpcInvokeMap {
'dir:unwatch': [void, void]
}
export type Unsubscribe = () => void
export interface ElectronAPI {
openFile: () => Promise<OpenFileResponse>
readFile: (filePath: string) => Promise<ReadFileResult>
@@ -42,14 +44,13 @@ export interface ElectronAPI {
openFolderDialog: () => Promise<string | null>
watchDir: (dirPath: string) => Promise<void>
unwatchDir: () => Promise<void>
onFileOpenInTab: (callback: (data: { filePath: string; content: string }) => void) => void
onMenuSave: (callback: () => void) => void
onMenuSaveAs: (callback: () => void) => void
onViewModeChange: (callback: (mode: string) => void) => void
onExternalModification: (callback: (filePath: string) => void) => void
onDirChanged: (callback: () => void) => void
onConfirmClose: (callback: () => void) => void
removeAllListeners: (channel: string) => void
onFileOpenInTab: (callback: (data: { filePath: string; content: string }) => void) => Unsubscribe
onMenuSave: (callback: () => void) => Unsubscribe
onMenuSaveAs: (callback: () => void) => Unsubscribe
onViewModeChange: (callback: (mode: string) => void) => Unsubscribe
onExternalModification: (callback: (filePath: string) => void) => Unsubscribe
onDirChanged: (callback: () => void) => Unsubscribe
onConfirmClose: (callback: () => void) => Unsubscribe
}
declare global {
+2 -4
View File
@@ -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
}
-2
View File
@@ -4,8 +4,6 @@ export interface Tab {
content: string
isModified: boolean
scrollTop: number
scrollLeft: number
selectionStart: number
selectionEnd: number
previewScrollTop: number
}