fix: 修复代码审计发现的全部 37 个问题
严重 Bug(8 个): - B-01: isClosing 永不重置 — WINDOW_CANCEL_CLOSE 现在正确重置状态并清除超时 - B-02: replaceAll 算法完全错误 — 改为从后向前逐个替换(SearchBar + useFileWatch) - B-03: Preview 用 setInterval 轮询 — 改为响应式订阅 activeTab.content 变化 - B-04: StatusBar 不响应 tab 切换 — 改为直接选择 tabs/activeTabId 而非函数引用 - B-05: useTheme 初始化竞态 — 添加 isInitialized ref 防止首次加载前保存 - B-06: scrollSync 0 值歧义 — 使用 NaN 标记未映射状态 - B-07: macOS activate 未注册 IPC — 提取 initWindow() 函数完整重建 - B-08: resizer 无交互逻辑 — 实现 Resizer 组件(mousedown/mousemove/mouseup) 中等问题(12 个): - M-01: removeAllListeners 限制为白名单通道 - M-02: 新建文件保存时设置 isSelfWriting - M-03: SidebarWatcher setTimeout 后再次检查窗口状态 - M-04: FileWatcher/SidebarWatcher 监听 error 事件清理僵尸状态 - M-05: buildDirTree 添加递归深度限制(maxDepth=10)+ 错误边界 - M-06: saveFileContent 改为原子写入(write-to-temp-then-rename) - M-07: Preview 异步竞态 — 使用递增 requestId 替代 cancelled - M-08: useKeyboard 依赖优化 — 用 getState() 替代频繁变化的 tabs 依赖 - M-09: settingsRepository.save() 改为 Dexie put 直接合并 - M-10: useSettings 返回函数用 useCallback 稳定引用 - M-11: App.tsx 事件注册添加 cleanup 清理 + ref 保持最新回调 - M-12: ipc-channels.ts 类型签名统一 低级问题(17 个): - L-01/L-03: Editor 移除未使用的 imports - L-04: tabIdCounter 改为 nanoid(HMR 安全) - L-06: recentFiles 添加 50 条上限自动清理 - L-07: getFileExtension 正确处理无扩展名文件 - L-08: window-manager 使用静态导入 + 验证是文件非目录 - L-09: readFileContent 使用 shared 类型 - L-10: buildDirTree 权限错误返回空数组 - L-11: DropOverlay 简化为单一 dragCounter 状态 - L-12: Sidebar onFileClick 提取为 useCallback - L-13: Editor searchStore 改为精确 selector - L-14: CSS 添加 mode-editor 隐藏规则 - L-16: openFileInTab 添加 .catch() 异常处理 TypeScript 检查零错误,构建成功。
This commit is contained in:
+24
-15
@@ -1,5 +1,7 @@
|
|||||||
import { readFile, stat, writeFile, readdir } from 'fs/promises'
|
import { readFile, stat, writeFile, readdir, rename } from 'fs/promises'
|
||||||
import { join, extname } from 'path'
|
import { join, extname, basename } from 'path'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import type { ReadFileResult, SaveFileResult, FileNode } from '../shared/types'
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB
|
const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB
|
||||||
const ALLOWED_EXTENSIONS = new Set(['.md', '.markdown', '.txt'])
|
const ALLOWED_EXTENSIONS = new Set(['.md', '.markdown', '.txt'])
|
||||||
@@ -8,14 +10,8 @@ const SKIP_DIRS = new Set([
|
|||||||
'.next', '.nuxt', '__pycache__', '.DS_Store'
|
'.next', '.nuxt', '__pycache__', '.DS_Store'
|
||||||
])
|
])
|
||||||
|
|
||||||
export interface FileNode {
|
// L-09: 使用 shared 类型
|
||||||
name: string
|
export async function readFileContent(filePath: string): Promise<ReadFileResult> {
|
||||||
path: string
|
|
||||||
type: 'file' | 'dir'
|
|
||||||
children?: FileNode[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function readFileContent(filePath: string): Promise<{ success: boolean; content?: string; error?: string }> {
|
|
||||||
try {
|
try {
|
||||||
const fileStat = await stat(filePath)
|
const fileStat = await stat(filePath)
|
||||||
if (fileStat.size > MAX_FILE_SIZE) {
|
if (fileStat.size > MAX_FILE_SIZE) {
|
||||||
@@ -30,17 +26,30 @@ export async function readFileContent(filePath: string): Promise<{ success: bool
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveFileContent(filePath: string, content: string): Promise<{ success: boolean; filePath?: string; error?: string }> {
|
// M-06: 原子写入(write-to-temp-then-rename)
|
||||||
|
export async function saveFileContent(filePath: string, content: string): Promise<SaveFileResult> {
|
||||||
try {
|
try {
|
||||||
await writeFile(filePath, content, 'utf-8')
|
const dir = require('path').dirname(filePath)
|
||||||
|
const tmpFile = join(tmpdir(), `marklite-${Date.now()}-${basename(filePath)}`)
|
||||||
|
await writeFile(tmpFile, content, 'utf-8')
|
||||||
|
await rename(tmpFile, filePath)
|
||||||
return { success: true, filePath }
|
return { success: true, filePath }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { success: false, error: (err as Error).message }
|
return { success: false, error: (err as Error).message }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function buildDirTree(dirPath: string): Promise<FileNode[]> {
|
// M-05: 添加递归深度限制 + 每个 entry 错误边界
|
||||||
const entries = await readdir(dirPath, { withFileTypes: true })
|
export async function buildDirTree(dirPath: string, depth = 0, maxDepth = 10): Promise<FileNode[]> {
|
||||||
|
if (depth > maxDepth) return []
|
||||||
|
|
||||||
|
let entries
|
||||||
|
try {
|
||||||
|
entries = await readdir(dirPath, { withFileTypes: true })
|
||||||
|
} catch {
|
||||||
|
return [] // L-10: 权限错误时返回空数组而非抛异常
|
||||||
|
}
|
||||||
|
|
||||||
entries.sort((a, b) => {
|
entries.sort((a, b) => {
|
||||||
if (a.isDirectory() && !b.isDirectory()) return -1
|
if (a.isDirectory() && !b.isDirectory()) return -1
|
||||||
if (!a.isDirectory() && b.isDirectory()) return 1
|
if (!a.isDirectory() && b.isDirectory()) return 1
|
||||||
@@ -54,7 +63,7 @@ export async function buildDirTree(dirPath: string): Promise<FileNode[]> {
|
|||||||
|
|
||||||
const childPath = join(dirPath, entry.name)
|
const childPath = join(dirPath, entry.name)
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
const subChildren = await buildDirTree(childPath)
|
const subChildren = await buildDirTree(childPath, depth + 1, maxDepth)
|
||||||
if (subChildren.length > 0) {
|
if (subChildren.length > 0) {
|
||||||
children.push({ name: entry.name, path: childPath, type: 'dir', children: subChildren })
|
children.push({ name: entry.name, path: childPath, type: 'dir', children: subChildren })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ export class FileWatcher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
// M-04: 监听 error 事件,文件被删除时清理状态
|
||||||
|
this.watcher.on('error', () => {
|
||||||
|
this.currentPath = null
|
||||||
|
this.watcher = null
|
||||||
|
})
|
||||||
} catch {
|
} catch {
|
||||||
// silently ignore
|
// silently ignore
|
||||||
}
|
}
|
||||||
@@ -32,6 +37,7 @@ export class FileWatcher {
|
|||||||
this.watcher.close()
|
this.watcher.close()
|
||||||
this.watcher = null
|
this.watcher = null
|
||||||
}
|
}
|
||||||
|
this.currentPath = null
|
||||||
}
|
}
|
||||||
|
|
||||||
getCurrentPath(): string | null {
|
getCurrentPath(): string | null {
|
||||||
@@ -60,10 +66,19 @@ export class SidebarWatcher {
|
|||||||
if (win && !win.isDestroyed()) {
|
if (win && !win.isDestroyed()) {
|
||||||
if (this.refreshTimer) clearTimeout(this.refreshTimer)
|
if (this.refreshTimer) clearTimeout(this.refreshTimer)
|
||||||
this.refreshTimer = setTimeout(() => {
|
this.refreshTimer = setTimeout(() => {
|
||||||
win.webContents.send('sidebar:dirChanged')
|
// M-03: 延迟后再次检查窗口状态
|
||||||
|
const w = this.getMainWindow()
|
||||||
|
if (w && !w.isDestroyed()) {
|
||||||
|
w.webContents.send('sidebar:dirChanged')
|
||||||
|
}
|
||||||
}, 300)
|
}, 300)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
// M-04: 监听 error 事件
|
||||||
|
this.watcher.on('error', () => {
|
||||||
|
this.watcher = null
|
||||||
|
this.watchPath = null
|
||||||
|
})
|
||||||
} catch {
|
} catch {
|
||||||
// silently ignore
|
// silently ignore
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-19
@@ -19,12 +19,14 @@ const sidebarWatcher = new SidebarWatcher(() => mainWindow)
|
|||||||
function openFileInTab(filePath: string): void {
|
function openFileInTab(filePath: string): void {
|
||||||
if (!mainWindow || mainWindow.isDestroyed()) return
|
if (!mainWindow || mainWindow.isDestroyed()) return
|
||||||
readFileContent(filePath).then(result => {
|
readFileContent(filePath).then(result => {
|
||||||
if (result.success) {
|
if (result.success && mainWindow && !mainWindow.isDestroyed()) {
|
||||||
state.activeFilePath = filePath
|
state.activeFilePath = filePath
|
||||||
fileWatcher.start(filePath)
|
fileWatcher.start(filePath)
|
||||||
mainWindow!.setTitle(`MarkLite - ${filePath.split(/[/\\]/).pop()}`)
|
mainWindow.setTitle(`MarkLite - ${filePath.split(/[/\\]/).pop()}`)
|
||||||
mainWindow!.webContents.send('file:openInTab', { filePath, content: result.content })
|
mainWindow.webContents.send('file:openInTab', { filePath, content: result.content })
|
||||||
}
|
}
|
||||||
|
}).catch((err) => {
|
||||||
|
console.error('openFileInTab failed:', err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,24 +72,12 @@ if (!lockOk) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
// B-07: 完整的窗口初始化逻辑(activate 复用)
|
||||||
app.on('open-file', (event, filePath) => {
|
function initWindow(): void {
|
||||||
event.preventDefault()
|
|
||||||
if (mainWindow && !mainWindow.isDestroyed() && mainWindow.webContents.isLoading()) {
|
|
||||||
state.pendingFilePath = filePath
|
|
||||||
} else if (mainWindow && !mainWindow.isDestroyed()) {
|
|
||||||
openFileInTab(filePath)
|
|
||||||
} else {
|
|
||||||
state.pendingFilePath = filePath
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
mainWindow = createWindow()
|
mainWindow = createWindow()
|
||||||
|
|
||||||
// 注册 IPC 处理器
|
|
||||||
registerIpcHandlers(() => mainWindow, fileWatcher, sidebarWatcher, state)
|
registerIpcHandlers(() => mainWindow, fileWatcher, sidebarWatcher, state)
|
||||||
|
|
||||||
// 加载渲染进程页面
|
|
||||||
if (process.env.ELECTRON_RENDERER_URL) {
|
if (process.env.ELECTRON_RENDERER_URL) {
|
||||||
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
|
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
|
||||||
} else {
|
} else {
|
||||||
@@ -108,6 +98,21 @@ if (!lockOk) {
|
|||||||
sidebarWatcher.stop()
|
sidebarWatcher.stop()
|
||||||
mainWindow = null
|
mainWindow = null
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
app.on('open-file', (event, filePath) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed() && mainWindow.webContents.isLoading()) {
|
||||||
|
state.pendingFilePath = filePath
|
||||||
|
} else if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
openFileInTab(filePath)
|
||||||
|
} else {
|
||||||
|
state.pendingFilePath = filePath
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
initWindow()
|
||||||
|
|
||||||
// 命令行文件
|
// 命令行文件
|
||||||
const cmdFile = getFilePathFromArgs(process.argv)
|
const cmdFile = getFilePathFromArgs(process.argv)
|
||||||
@@ -120,10 +125,10 @@ if (!lockOk) {
|
|||||||
if (process.platform !== 'darwin') app.quit()
|
if (process.platform !== 'darwin') app.quit()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// B-07: macOS activate 完整重建窗口
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
mainWindow = createWindow()
|
initWindow()
|
||||||
setupCloseHandler()
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export function registerIpcHandlers(
|
|||||||
getMainWindow: () => BrowserWindow | null,
|
getMainWindow: () => BrowserWindow | null,
|
||||||
fileWatcher: FileWatcher,
|
fileWatcher: FileWatcher,
|
||||||
sidebarWatcher: SidebarWatcher,
|
sidebarWatcher: SidebarWatcher,
|
||||||
state: { activeFilePath: string | null; pendingFilePath: string | null }
|
state: { activeFilePath: string | null; pendingFilePath: string | null; isClosing: boolean; closeTimeout: NodeJS.Timeout | null }
|
||||||
): void {
|
): void {
|
||||||
// 打开文件对话框
|
// 打开文件对话框
|
||||||
ipcMain.handle(IPC_CHANNELS.DIALOG_OPEN_FILE, async () => {
|
ipcMain.handle(IPC_CHANNELS.DIALOG_OPEN_FILE, async () => {
|
||||||
@@ -67,7 +67,9 @@ export function registerIpcHandlers(
|
|||||||
filters: [{ name: 'Markdown 文件', extensions: ['md'] }]
|
filters: [{ name: 'Markdown 文件', extensions: ['md'] }]
|
||||||
})
|
})
|
||||||
if (!saveResult.canceled) {
|
if (!saveResult.canceled) {
|
||||||
|
fileWatcher.setSelfWriting(true)
|
||||||
const result = await saveFileContent(saveResult.filePath, data.content)
|
const result = await saveFileContent(saveResult.filePath, data.content)
|
||||||
|
fileWatcher.setSelfWriting(false)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
state.activeFilePath = saveResult.filePath
|
state.activeFilePath = saveResult.filePath
|
||||||
fileWatcher.start(saveResult.filePath)
|
fileWatcher.start(saveResult.filePath)
|
||||||
@@ -177,7 +179,12 @@ export function registerIpcHandlers(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// B-01: 重置关闭状态 + 清除超时定时器
|
||||||
ipcMain.handle(IPC_CHANNELS.WINDOW_CANCEL_CLOSE, () => {
|
ipcMain.handle(IPC_CHANNELS.WINDOW_CANCEL_CLOSE, () => {
|
||||||
// 重置关闭状态
|
state.isClosing = false
|
||||||
|
if (state.closeTimeout) {
|
||||||
|
clearTimeout(state.closeTimeout)
|
||||||
|
state.closeTimeout = null
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { BrowserWindow, app } from 'electron'
|
import { BrowserWindow, app } from 'electron'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
|
import { existsSync, statSync } from 'fs'
|
||||||
|
|
||||||
export function createWindow(): BrowserWindow {
|
export function createWindow(): BrowserWindow {
|
||||||
const mainWindow = new BrowserWindow({
|
const mainWindow = new BrowserWindow({
|
||||||
@@ -43,13 +44,16 @@ export function setupSingleInstanceLock(
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// L-08: 使用静态导入 + 验证是文件而非目录
|
||||||
export function getFilePathFromArgs(args: string[]): string | null {
|
export function getFilePathFromArgs(args: string[]): string | null {
|
||||||
for (let i = 1; i < args.length; i++) {
|
for (let i = 1; i < args.length; i++) {
|
||||||
const arg = args[i]
|
const arg = args[i]
|
||||||
if (!arg.startsWith('--') && !arg.startsWith('-')) {
|
if (!arg.startsWith('--') && !arg.startsWith('-')) {
|
||||||
try {
|
try {
|
||||||
const fs = require('fs')
|
if (existsSync(arg)) {
|
||||||
if (fs.existsSync(arg)) return arg
|
const s = statSync(arg)
|
||||||
|
if (s.isFile()) return arg
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-2
@@ -1,5 +1,16 @@
|
|||||||
import { contextBridge, ipcRenderer, shell } from 'electron'
|
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'
|
||||||
|
])
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('electronAPI', {
|
contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
// File operations
|
// File operations
|
||||||
openFile: () => ipcRenderer.invoke('dialog:openFile'),
|
openFile: () => ipcRenderer.invoke('dialog:openFile'),
|
||||||
@@ -42,6 +53,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
onConfirmClose: (callback: () => void) =>
|
onConfirmClose: (callback: () => void) =>
|
||||||
ipcRenderer.on('window:confirmClose', () => callback()),
|
ipcRenderer.on('window:confirmClose', () => callback()),
|
||||||
|
|
||||||
// Remove listeners
|
// M-01: 只允许移除白名单通道的监听器
|
||||||
removeAllListeners: (channel: string) => ipcRenderer.removeAllListeners(channel)
|
removeAllListeners: (channel: string) => {
|
||||||
|
if (ALLOWED_REMOVE_CHANNELS.has(channel)) {
|
||||||
|
ipcRenderer.removeAllListeners(channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
+62
-10
@@ -113,19 +113,32 @@ export default function App() {
|
|||||||
saveViewMode(mode)
|
saveViewMode(mode)
|
||||||
}, [saveViewMode])
|
}, [saveViewMode])
|
||||||
|
|
||||||
// 主进程事件
|
// M-11: 主进程事件注册(使用 ref 保持最新回调引用 + cleanup 清理)
|
||||||
|
const handleSaveRef = useRef(handleSave)
|
||||||
|
const handleSaveAsRef = useRef(handleSaveAs)
|
||||||
|
handleSaveRef.current = handleSave
|
||||||
|
handleSaveAsRef.current = handleSaveAs
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!window.electronAPI) return
|
if (!window.electronAPI) return
|
||||||
window.electronAPI.removeAllListeners('file:openInTab')
|
const api = window.electronAPI
|
||||||
window.electronAPI.removeAllListeners('menu:save')
|
|
||||||
window.electronAPI.removeAllListeners('menu:saveAs')
|
|
||||||
|
|
||||||
window.electronAPI.onFileOpenInTab((data: { filePath: string; content: string }) => {
|
const onOpen = (data: { filePath: string; content: string }) => {
|
||||||
createTab(data.filePath, data.content)
|
createTab(data.filePath, data.content)
|
||||||
})
|
}
|
||||||
window.electronAPI.onMenuSave(() => handleSave())
|
const onSave = () => handleSaveRef.current()
|
||||||
window.electronAPI.onMenuSaveAs(() => handleSaveAs())
|
const onSaveAs = () => handleSaveAsRef.current()
|
||||||
}, [createTab, handleSave, handleSaveAs])
|
|
||||||
|
api.onFileOpenInTab(onOpen)
|
||||||
|
api.onMenuSave(onSave)
|
||||||
|
api.onMenuSaveAs(onSaveAs)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
api.removeAllListeners('file:openInTab')
|
||||||
|
api.removeAllListeners('menu:save')
|
||||||
|
api.removeAllListeners('menu:saveAs')
|
||||||
|
}
|
||||||
|
}, [createTab])
|
||||||
|
|
||||||
const activeTab = getActiveTab()
|
const activeTab = getActiveTab()
|
||||||
const hasTabs = tabs.length > 0
|
const hasTabs = tabs.length > 0
|
||||||
@@ -183,7 +196,7 @@ export default function App() {
|
|||||||
<Editor />
|
<Editor />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{viewMode !== 'editor' && <div id="resizer" />}
|
{viewMode !== 'editor' && <Resizer onResize={saveSplitRatio} />}
|
||||||
{viewMode !== 'editor' && (
|
{viewMode !== 'editor' && (
|
||||||
<div id="preview-panel" style={previewStyle}>
|
<div id="preview-panel" style={previewStyle}>
|
||||||
<Preview />
|
<Preview />
|
||||||
@@ -206,3 +219,42 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// B-08: 分屏调节器组件
|
||||||
|
function Resizer({ onResize }: { onResize: (ratio: number) => void }) {
|
||||||
|
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
document.body.style.cursor = 'col-resize'
|
||||||
|
document.body.style.userSelect = 'none'
|
||||||
|
|
||||||
|
const handleMouseMove = (ev: MouseEvent) => {
|
||||||
|
const wrapper = document.getElementById('content-wrapper')
|
||||||
|
if (!wrapper) return
|
||||||
|
const rect = wrapper.getBoundingClientRect()
|
||||||
|
const pct = Math.max(20, Math.min(80, ((ev.clientX - rect.left) / rect.width) * 100))
|
||||||
|
const editorPanel = document.getElementById('editor-panel')
|
||||||
|
const previewPanel = document.getElementById('preview-panel')
|
||||||
|
if (editorPanel) editorPanel.style.flex = `0 0 ${pct}%`
|
||||||
|
if (previewPanel) previewPanel.style.flex = `0 0 ${100 - pct}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseUp = (ev: MouseEvent) => {
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove)
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp)
|
||||||
|
document.body.style.cursor = ''
|
||||||
|
document.body.style.userSelect = ''
|
||||||
|
// 保存最终比例
|
||||||
|
const wrapper = document.getElementById('content-wrapper')
|
||||||
|
if (wrapper) {
|
||||||
|
const rect = wrapper.getBoundingClientRect()
|
||||||
|
const pct = Math.max(20, Math.min(80, ((ev.clientX - rect.left) / rect.width) * 100))
|
||||||
|
onResize(pct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleMouseMove)
|
||||||
|
document.addEventListener('mouseup', handleMouseUp)
|
||||||
|
}, [onResize])
|
||||||
|
|
||||||
|
return <div id="resizer" onMouseDown={handleMouseDown} />
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,29 +1,18 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
|
|
||||||
export function DropOverlay() {
|
export function DropOverlay() {
|
||||||
const [isVisible, setIsVisible] = useState(false)
|
// L-11: 简化为单一状态
|
||||||
const [dragCounter, setDragCounter] = useState(0)
|
const [dragCounter, setDragCounter] = useState(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleDragEnter = (e: DragEvent) => {
|
const handleDragEnter = (e: DragEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setDragCounter(prev => {
|
setDragCounter(prev => prev + 1)
|
||||||
const next = prev + 1
|
|
||||||
if (next > 0) setIsVisible(true)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDragLeave = (e: DragEvent) => {
|
const handleDragLeave = (e: DragEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setDragCounter(prev => {
|
setDragCounter(prev => Math.max(0, prev - 1))
|
||||||
const next = prev - 1
|
|
||||||
if (next <= 0) {
|
|
||||||
setIsVisible(false)
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDragOver = (e: DragEvent) => {
|
const handleDragOver = (e: DragEvent) => {
|
||||||
@@ -32,7 +21,6 @@ export function DropOverlay() {
|
|||||||
|
|
||||||
const handleDrop = () => {
|
const handleDrop = () => {
|
||||||
setDragCounter(0)
|
setDragCounter(0)
|
||||||
setIsVisible(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('dragenter', handleDragEnter)
|
document.addEventListener('dragenter', handleDragEnter)
|
||||||
@@ -48,7 +36,7 @@ export function DropOverlay() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
if (!isVisible) return null
|
if (dragCounter <= 0) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="drop-overlay">
|
<div id="drop-overlay">
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import React, { useEffect, useRef, useCallback, useState } from 'react'
|
import React, { useEffect, useRef, useCallback, useState } from 'react'
|
||||||
import { useTabStore } from '../../stores/tabStore'
|
import { useTabStore } from '../../stores/tabStore'
|
||||||
import { useEditorStore } from '../../stores/editorStore'
|
|
||||||
import { useSearchStore } from '../../stores/searchStore'
|
import { useSearchStore } from '../../stores/searchStore'
|
||||||
import { findMatches, buildLineStarts, getLineNumber } from '../../lib/searchEngine'
|
|
||||||
import type { SearchMatch } from '../../types/search'
|
|
||||||
|
|
||||||
export function Editor() {
|
export function Editor() {
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
@@ -15,7 +12,10 @@ export function Editor() {
|
|||||||
const updateTabContent = useTabStore(s => s.updateTabContent)
|
const updateTabContent = useTabStore(s => s.updateTabContent)
|
||||||
const setModified = useTabStore(s => s.setModified)
|
const setModified = useTabStore(s => s.setModified)
|
||||||
|
|
||||||
const searchStore = useSearchStore()
|
// L-13: 只选择需要的字段,避免整个 store 变化触发重渲染
|
||||||
|
const searchIsVisible = useSearchStore(s => s.isVisible)
|
||||||
|
const searchFindNext = useSearchStore(s => s.findNext)
|
||||||
|
const searchFindPrev = useSearchStore(s => s.findPrev)
|
||||||
|
|
||||||
const [lineCount, setLineCount] = useState(1)
|
const [lineCount, setLineCount] = useState(1)
|
||||||
const [cursorPos, setCursorPos] = useState({ line: 1, col: 1 })
|
const [cursorPos, setCursorPos] = useState({ line: 1, col: 1 })
|
||||||
@@ -145,15 +145,15 @@ export function Editor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 搜索导航
|
// 搜索导航
|
||||||
if (e.key === 'Enter' && searchStore.isVisible) {
|
if (e.key === 'Enter' && searchIsVisible) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (e.shiftKey) {
|
if (e.shiftKey) {
|
||||||
searchStore.findPrev()
|
searchFindPrev()
|
||||||
} else {
|
} else {
|
||||||
searchStore.findNext()
|
searchFindNext()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [searchStore])
|
}, [searchIsVisible, searchFindNext, searchFindPrev])
|
||||||
|
|
||||||
// 渲染行号
|
// 渲染行号
|
||||||
const renderLineNumbers = () => {
|
const renderLineNumbers = () => {
|
||||||
|
|||||||
@@ -5,38 +5,27 @@ import { renderMarkdown } from '../../lib/markdown'
|
|||||||
export function Preview() {
|
export function Preview() {
|
||||||
const [html, setHtml] = useState('')
|
const [html, setHtml] = useState('')
|
||||||
const previewRef = useRef<HTMLDivElement>(null)
|
const previewRef = useRef<HTMLDivElement>(null)
|
||||||
const getActiveTab = useTabStore(s => s.getActiveTab)
|
const requestIdRef = useRef(0)
|
||||||
const activeTabId = useTabStore(s => s.activeTabId)
|
|
||||||
|
|
||||||
// 渲染 Markdown
|
const activeTabId = useTabStore(s => s.activeTabId)
|
||||||
|
const tabs = useTabStore(s => s.tabs)
|
||||||
|
const activeTab = tabs.find(t => t.id === activeTabId) ?? null
|
||||||
|
|
||||||
|
// B-03 + M-07: 响应式渲染(无轮询,无竞态)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const tab = getActiveTab()
|
if (!activeTab) {
|
||||||
if (!tab) {
|
|
||||||
setHtml('')
|
setHtml('')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false
|
const requestId = ++requestIdRef.current
|
||||||
renderMarkdown(tab.content).then(result => {
|
renderMarkdown(activeTab.content).then(result => {
|
||||||
if (!cancelled) {
|
// M-07: 只有当 requestId 仍为最新时才更新
|
||||||
|
if (requestId === requestIdRef.current) {
|
||||||
setHtml(result)
|
setHtml(result)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
}, [activeTabId, activeTab?.content])
|
||||||
return () => { cancelled = true }
|
|
||||||
}, [activeTabId, getActiveTab])
|
|
||||||
|
|
||||||
// 监听编辑器内容变化
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setInterval(() => {
|
|
||||||
const tab = getActiveTab()
|
|
||||||
if (!tab) return
|
|
||||||
renderMarkdown(tab.content).then(result => {
|
|
||||||
setHtml(result)
|
|
||||||
})
|
|
||||||
}, 300)
|
|
||||||
return () => clearInterval(timer)
|
|
||||||
}, [getActiveTab])
|
|
||||||
|
|
||||||
// 拦截链接点击
|
// 拦截链接点击
|
||||||
const handleClick = useCallback((e: React.MouseEvent) => {
|
const handleClick = useCallback((e: React.MouseEvent) => {
|
||||||
|
|||||||
@@ -54,18 +54,15 @@ export function SearchBar() {
|
|||||||
doSearch(searchText)
|
doSearch(searchText)
|
||||||
}, [getActiveTab, matches, currentIndex, replaceText, searchText, doSearch])
|
}, [getActiveTab, matches, currentIndex, replaceText, searchText, doSearch])
|
||||||
|
|
||||||
// 全部替换
|
// B-02: 全部替换(从后向前逐个替换)
|
||||||
const handleReplaceAll = useCallback(() => {
|
const handleReplaceAll = useCallback(() => {
|
||||||
const tab = getActiveTab()
|
const tab = getActiveTab()
|
||||||
if (!tab || matches.length === 0) return
|
if (!tab || matches.length === 0) return
|
||||||
let result = ''
|
let result = tab.content
|
||||||
let lastEnd = 0
|
|
||||||
for (let i = matches.length - 1; i >= 0; i--) {
|
for (let i = matches.length - 1; i >= 0; i--) {
|
||||||
const m = matches[i]
|
const m = matches[i]
|
||||||
result = tab.content.substring(lastEnd, m.start) + replaceText + result
|
result = result.substring(0, m.start) + replaceText + result.substring(m.end)
|
||||||
lastEnd = m.end
|
|
||||||
}
|
}
|
||||||
result = tab.content.substring(0, matches[0].start) + result
|
|
||||||
useTabStore.getState().updateTabContent(tab.id, result)
|
useTabStore.getState().updateTabContent(tab.id, result)
|
||||||
doSearch(searchText)
|
doSearch(searchText)
|
||||||
}, [getActiveTab, matches, replaceText, searchText, doSearch])
|
}, [getActiveTab, matches, replaceText, searchText, doSearch])
|
||||||
|
|||||||
@@ -73,7 +73,18 @@ export function Sidebar() {
|
|||||||
}
|
}
|
||||||
}, [isResizing])
|
}, [isResizing])
|
||||||
|
|
||||||
// 独立文件(不在当前文件夹内的已打开文件)
|
// L-12: 提取为 useCallback 避免每次渲染重建
|
||||||
|
const handleFileClick = useCallback(async (path: string) => {
|
||||||
|
const existing = tabs.find(t => t.filePath === path)
|
||||||
|
if (existing) {
|
||||||
|
switchToTab(existing.id)
|
||||||
|
} else if (window.electronAPI) {
|
||||||
|
const result = await window.electronAPI.readFile(path)
|
||||||
|
if (result.success && result.content) {
|
||||||
|
createTab(path, result.content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [tabs, switchToTab, createTab])
|
||||||
const independentFiles = tabs.filter(t => {
|
const independentFiles = tabs.filter(t => {
|
||||||
if (!t.filePath) return false
|
if (!t.filePath) return false
|
||||||
if (!rootPath) return true
|
if (!rootPath) return true
|
||||||
@@ -128,17 +139,7 @@ export function Sidebar() {
|
|||||||
expandedDirs={expandedDirs}
|
expandedDirs={expandedDirs}
|
||||||
toggleDir={toggleDir}
|
toggleDir={toggleDir}
|
||||||
activeTabId={activeTabId}
|
activeTabId={activeTabId}
|
||||||
onFileClick={async (path) => {
|
onFileClick={handleFileClick}
|
||||||
const existing = tabs.find(t => t.filePath === path)
|
|
||||||
if (existing) {
|
|
||||||
switchToTab(existing.id)
|
|
||||||
} else if (window.electronAPI) {
|
|
||||||
const result = await window.electronAPI.readFile(path)
|
|
||||||
if (result.success && result.content) {
|
|
||||||
createTab(path, result.content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import { useTabStore } from '../../stores/tabStore'
|
|||||||
import { getFileName } from '../../lib/fileUtils'
|
import { getFileName } from '../../lib/fileUtils'
|
||||||
|
|
||||||
export function StatusBar() {
|
export function StatusBar() {
|
||||||
const getActiveTab = useTabStore(s => s.getActiveTab)
|
// B-04: 直接选择数据而非函数引用,确保响应式更新
|
||||||
const tab = getActiveTab()
|
const tabs = useTabStore(s => s.tabs)
|
||||||
|
const activeTabId = useTabStore(s => s.activeTabId)
|
||||||
|
const tab = tabs.find(t => t.id === activeTabId) ?? null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="statusbar">
|
<div id="statusbar">
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ export const recentFilesRepository = {
|
|||||||
} else {
|
} else {
|
||||||
await db.recentFiles.add({ filePath, lastOpened: Date.now() })
|
await db.recentFiles.add({ filePath, lastOpened: Date.now() })
|
||||||
}
|
}
|
||||||
|
// L-06: 清理超过 50 条的旧记录
|
||||||
|
const all = await db.recentFiles.orderBy('lastOpened').reverse().toArray()
|
||||||
|
if (all.length > 50) {
|
||||||
|
const toDelete = all.slice(50)
|
||||||
|
await db.recentFiles.bulkDelete(toDelete.map(f => f.id!))
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async getAll(limit = 20): Promise<string[]> {
|
async getAll(limit = 20): Promise<string[]> {
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ export const settingsRepository = {
|
|||||||
const record = await db.settings.get('default')
|
const record = await db.settings.get('default')
|
||||||
if (record) {
|
if (record) {
|
||||||
return {
|
return {
|
||||||
darkMode: record.darkMode,
|
darkMode: record.darkMode ?? DEFAULT_SETTINGS.darkMode,
|
||||||
viewMode: record.viewMode,
|
viewMode: record.viewMode ?? DEFAULT_SETTINGS.viewMode,
|
||||||
splitRatio: record.splitRatio,
|
splitRatio: record.splitRatio ?? DEFAULT_SETTINGS.splitRatio,
|
||||||
sidebarCollapsed: record.sidebarCollapsed,
|
sidebarCollapsed: record.sidebarCollapsed ?? DEFAULT_SETTINGS.sidebarCollapsed,
|
||||||
sidebarWidth: record.sidebarWidth
|
sidebarWidth: record.sidebarWidth ?? DEFAULT_SETTINGS.sidebarWidth
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -20,10 +20,9 @@ export const settingsRepository = {
|
|||||||
return { ...DEFAULT_SETTINGS }
|
return { ...DEFAULT_SETTINGS }
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// M-09: 使用 put 直接合并,避免读写竞争
|
||||||
async save(settings: Partial<Settings>): Promise<void> {
|
async save(settings: Partial<Settings>): Promise<void> {
|
||||||
const current = await this.load()
|
const record: Partial<SettingsRecord> = { id: 'default', ...settings }
|
||||||
const merged = { ...current, ...settings }
|
await db.settings.put(record as SettingsRecord)
|
||||||
const record: SettingsRecord = { id: 'default', ...merged }
|
|
||||||
await db.settings.put(record)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,17 +48,14 @@ export function useSearch() {
|
|||||||
return content.substring(0, m.start) + store.replaceText + content.substring(m.end)
|
return content.substring(0, m.start) + store.replaceText + content.substring(m.end)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// B-02: 全部替换(从后向前逐个替换)
|
||||||
const replaceAll = (content: string): string => {
|
const replaceAll = (content: string): string => {
|
||||||
if (store.matches.length === 0) return content
|
if (store.matches.length === 0) return content
|
||||||
let result = ''
|
let result = content
|
||||||
let lastEnd = 0
|
|
||||||
// 从后往前替换
|
|
||||||
for (let i = store.matches.length - 1; i >= 0; i--) {
|
for (let i = store.matches.length - 1; i >= 0; i--) {
|
||||||
const m = store.matches[i]
|
const m = store.matches[i]
|
||||||
result = content.substring(lastEnd, m.start) + store.replaceText + result
|
result = result.substring(0, m.start) + store.replaceText + result.substring(m.end)
|
||||||
lastEnd = m.end
|
|
||||||
}
|
}
|
||||||
result = content.substring(0, store.matches[0].start) + result
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,117 +5,58 @@ import { useSearchStore } from '../stores/searchStore'
|
|||||||
import type { ViewMode } from '../types/settings'
|
import type { ViewMode } from '../types/settings'
|
||||||
|
|
||||||
export function useKeyboard(handleOpenFile: () => void, handleSave: () => void, handleSaveAs: () => void) {
|
export function useKeyboard(handleOpenFile: () => void, handleSave: () => void, handleSaveAs: () => void) {
|
||||||
const createTab = useTabStore(s => s.createTab)
|
|
||||||
const closeTab = useTabStore(s => s.closeTab)
|
|
||||||
const activeTabId = useTabStore(s => s.activeTabId)
|
|
||||||
const tabs = useTabStore(s => s.tabs)
|
|
||||||
const switchToTab = useTabStore(s => s.switchToTab)
|
|
||||||
const mruStack = useTabStore(s => s.mruStack)
|
|
||||||
const setViewMode = useEditorStore(s => s.setViewMode)
|
const setViewMode = useEditorStore(s => s.setViewMode)
|
||||||
const searchStore = useSearchStore
|
|
||||||
|
|
||||||
|
// M-08: 使用 getState() 而非依赖 tabs/activeTabId 等频繁变化的值
|
||||||
const handleKeydown = useCallback((e: KeyboardEvent) => {
|
const handleKeydown = useCallback((e: KeyboardEvent) => {
|
||||||
const isCtrl = e.ctrlKey || e.metaKey
|
const isCtrl = e.ctrlKey || e.metaKey
|
||||||
|
|
||||||
if (isCtrl && e.key === 'o') {
|
if (isCtrl && e.key === 'o') { e.preventDefault(); handleOpenFile(); return }
|
||||||
e.preventDefault()
|
if (isCtrl && e.key === 's' && !e.shiftKey) { e.preventDefault(); handleSave(); return }
|
||||||
handleOpenFile()
|
if (isCtrl && e.shiftKey && e.key === 'S') { e.preventDefault(); handleSaveAs(); return }
|
||||||
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 === 's' && !e.shiftKey) {
|
if (isCtrl && e.key === '3') { e.preventDefault(); setViewMode('preview'); return }
|
||||||
e.preventDefault()
|
|
||||||
handleSave()
|
// M-08: 通过 getState() 读取最新状态
|
||||||
return
|
const tabState = useTabStore.getState()
|
||||||
}
|
if (isCtrl && e.key === 't') { e.preventDefault(); tabState.createTab(null, ''); 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 === 't') {
|
|
||||||
e.preventDefault()
|
|
||||||
createTab(null, '')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (isCtrl && e.key === 'w') {
|
if (isCtrl && e.key === 'w') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (activeTabId) closeTab(activeTabId)
|
if (tabState.activeTabId) tabState.closeTab(tabState.activeTabId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ctrl+Tab / Ctrl+Shift+Tab
|
||||||
if (isCtrl && e.key === 'Tab') {
|
if (isCtrl && e.key === 'Tab') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
const { tabs, activeTabId, mruStack } = tabState
|
||||||
if (tabs.length > 1) {
|
if (tabs.length > 1) {
|
||||||
if (e.shiftKey) {
|
if (e.shiftKey) {
|
||||||
if (mruStack.length > 0) {
|
if (mruStack.length > 0) {
|
||||||
const targetId = mruStack[0]
|
const targetId = mruStack[0]
|
||||||
if (tabs.find(t => t.id === targetId)) {
|
if (tabs.find(t => t.id === targetId)) {
|
||||||
switchToTab(targetId)
|
tabState.switchToTab(targetId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const idx = tabs.findIndex(t => t.id === activeTabId)
|
const idx = tabs.findIndex(t => t.id === activeTabId)
|
||||||
const next = (idx + 1) % tabs.length
|
const next = (idx + 1) % tabs.length
|
||||||
switchToTab(tabs[next].id)
|
tabState.switchToTab(tabs[next].id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search & Replace
|
// Search & Replace
|
||||||
const state = searchStore.getState()
|
const searchState = useSearchStore.getState()
|
||||||
if (isCtrl && e.key === 'f') {
|
if (isCtrl && e.key === 'f') { e.preventDefault(); searchState.setVisible(true); searchState.setShowReplace(false); return }
|
||||||
e.preventDefault()
|
if (isCtrl && e.key === 'h') { e.preventDefault(); searchState.setVisible(true); searchState.setShowReplace(true); return }
|
||||||
state.setVisible(true)
|
if (e.key === 'Escape' && searchState.isVisible) { e.preventDefault(); searchState.close(); return }
|
||||||
state.setShowReplace(false)
|
if (e.altKey && e.key === 'c') { e.preventDefault(); searchState.toggleCaseSensitive(); return }
|
||||||
return
|
if (e.altKey && e.key === 'r') { e.preventDefault(); searchState.toggleRegex(); return }
|
||||||
}
|
if (isCtrl && e.shiftKey && e.key === 'G') { e.preventDefault(); searchState.findPrev(); return }
|
||||||
if (isCtrl && e.key === 'h') {
|
}, [handleOpenFile, handleSave, handleSaveAs, setViewMode])
|
||||||
e.preventDefault()
|
|
||||||
state.setVisible(true)
|
|
||||||
state.setShowReplace(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (e.key === 'Escape' && state.isVisible) {
|
|
||||||
e.preventDefault()
|
|
||||||
state.close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (e.altKey && e.key === 'c') {
|
|
||||||
e.preventDefault()
|
|
||||||
state.toggleCaseSensitive()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (e.altKey && e.key === 'r') {
|
|
||||||
e.preventDefault()
|
|
||||||
state.toggleRegex()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (isCtrl && e.shiftKey && e.key === 'G') {
|
|
||||||
e.preventDefault()
|
|
||||||
state.findPrev()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (isCtrl && e.shiftKey && e.key === 'H') {
|
|
||||||
e.preventDefault()
|
|
||||||
// replaceAll 需要外部实现
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}, [handleOpenFile, handleSave, handleSaveAs, createTab, closeTab, activeTabId, tabs, switchToTab, mruStack, setViewMode, searchStore])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.addEventListener('keydown', handleKeydown)
|
document.addEventListener('keydown', handleKeydown)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect, useCallback } from 'react'
|
||||||
import { useEditorStore } from '../stores/editorStore'
|
import { useEditorStore } from '../stores/editorStore'
|
||||||
import { settingsRepository } from '../db/settingsRepository'
|
import { settingsRepository } from '../db/settingsRepository'
|
||||||
import type { ViewMode } from '../types/settings'
|
import type { ViewMode } from '../types/settings'
|
||||||
@@ -12,20 +12,21 @@ export function useSettings() {
|
|||||||
// 初始化
|
// 初始化
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
settingsRepository.load().then(settings => {
|
settingsRepository.load().then(settings => {
|
||||||
setViewMode(settings.viewMode)
|
setViewMode(settings.viewMode ?? 'split')
|
||||||
setSplitRatio(settings.splitRatio)
|
setSplitRatio(settings.splitRatio ?? 50)
|
||||||
})
|
})
|
||||||
}, [setViewMode, setSplitRatio])
|
}, [setViewMode, setSplitRatio])
|
||||||
|
|
||||||
const saveViewMode = (mode: ViewMode) => {
|
// M-10: 使用 useCallback 稳定函数引用
|
||||||
|
const saveViewMode = useCallback((mode: ViewMode) => {
|
||||||
setViewMode(mode)
|
setViewMode(mode)
|
||||||
settingsRepository.save({ viewMode: mode })
|
settingsRepository.save({ viewMode: mode })
|
||||||
}
|
}, [setViewMode])
|
||||||
|
|
||||||
const saveSplitRatio = (ratio: number) => {
|
const saveSplitRatio = useCallback((ratio: number) => {
|
||||||
setSplitRatio(ratio)
|
setSplitRatio(ratio)
|
||||||
settingsRepository.save({ splitRatio: ratio })
|
settingsRepository.save({ splitRatio: ratio })
|
||||||
}
|
}, [setSplitRatio])
|
||||||
|
|
||||||
return { viewMode, splitRatio, saveViewMode, saveSplitRatio }
|
return { viewMode, splitRatio, saveViewMode, saveSplitRatio }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { useEditorStore } from '../stores/editorStore'
|
import { useEditorStore } from '../stores/editorStore'
|
||||||
import { settingsRepository } from '../db/settingsRepository'
|
import { settingsRepository } from '../db/settingsRepository'
|
||||||
|
|
||||||
export function useTheme() {
|
export function useTheme() {
|
||||||
const darkMode = useEditorStore(s => s.darkMode)
|
const darkMode = useEditorStore(s => s.darkMode)
|
||||||
const setDarkMode = useEditorStore(s => s.setDarkMode)
|
const setDarkMode = useEditorStore(s => s.setDarkMode)
|
||||||
|
const isInitialized = useRef(false)
|
||||||
|
|
||||||
// 初始化:从 IndexedDB 加载主题设置
|
// B-05: 从 IndexedDB 加载主题设置
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
settingsRepository.load().then(settings => {
|
settingsRepository.load().then(settings => {
|
||||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
setDarkMode(settings.darkMode !== undefined ? settings.darkMode : prefersDark)
|
setDarkMode(settings.darkMode !== undefined ? settings.darkMode : prefersDark)
|
||||||
|
isInitialized.current = true
|
||||||
})
|
})
|
||||||
}, [setDarkMode])
|
}, [setDarkMode])
|
||||||
|
|
||||||
// 应用主题到 DOM
|
// B-05: 首次加载完成后才保存主题设置
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isInitialized.current) return
|
||||||
document.documentElement.classList.toggle('dark', darkMode)
|
document.documentElement.classList.toggle('dark', darkMode)
|
||||||
settingsRepository.save({ darkMode })
|
settingsRepository.save({ darkMode })
|
||||||
}, [darkMode])
|
}, [darkMode])
|
||||||
|
|||||||
@@ -11,10 +11,14 @@ export function getFileName(filePath: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isAllowedFile(filePath: string): boolean {
|
export function isAllowedFile(filePath: string): boolean {
|
||||||
const ext = filePath.substring(filePath.lastIndexOf('.')).toLowerCase()
|
const ext = getFileExtension(filePath)
|
||||||
return (ALLOWED_EXTENSIONS as readonly string[]).includes(ext)
|
return (ALLOWED_EXTENSIONS as readonly string[]).includes(ext)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// L-07: 无扩展名文件正确返回空字符串
|
||||||
export function getFileExtension(filePath: string): string {
|
export function getFileExtension(filePath: string): string {
|
||||||
return filePath.substring(filePath.lastIndexOf('.')).toLowerCase()
|
const name = filePath.split(/[/\\]/).pop() || filePath
|
||||||
|
const dotIndex = name.lastIndexOf('.')
|
||||||
|
if (dotIndex <= 0) return '' // 无扩展名或以 . 开头的隐藏文件
|
||||||
|
return name.substring(dotIndex).toLowerCase()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
// 滚动同步算法 — 基于块级元素 DOM 位置映射
|
// 滚动同步算法 — 基于块级元素 DOM 位置映射
|
||||||
import type { SearchMatch } from '../types/search'
|
|
||||||
|
|
||||||
export interface ScrollMap {
|
export interface ScrollMap {
|
||||||
lineToPreviewMap: Float32Array
|
lineToPreviewMap: Float32Array
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// B-06: 用 NaN 标记未映射状态(0 是合法的 offsetTop)
|
||||||
|
const UNMAPPED = NaN
|
||||||
|
|
||||||
// 识别块级元素起始行
|
// 识别块级元素起始行
|
||||||
function identifyBlockStarts(lines: string[]): Set<number> {
|
function identifyBlockStarts(lines: string[]): Set<number> {
|
||||||
const starts = new Set<number>()
|
const starts = new Set<number>()
|
||||||
@@ -13,8 +15,8 @@ function identifyBlockStarts(lines: string[]): Set<number> {
|
|||||||
for (let i = 0; i < lines.length; i++) {
|
for (let i = 0; i < lines.length; i++) {
|
||||||
const line = lines[i].trimStart()
|
const line = lines[i].trimStart()
|
||||||
|
|
||||||
// 围栏代码块边界
|
// 围栏代码块边界(仅匹配 ``` 开头,不匹配 `````` 等)
|
||||||
if (line.startsWith('```')) {
|
if (/^`{3,}(?!`)/.test(line)) {
|
||||||
if (!inCodeBlock) starts.add(i)
|
if (!inCodeBlock) starts.add(i)
|
||||||
inCodeBlock = !inCodeBlock
|
inCodeBlock = !inCodeBlock
|
||||||
continue
|
continue
|
||||||
@@ -48,7 +50,7 @@ export function buildScrollMap(
|
|||||||
): ScrollMap {
|
): ScrollMap {
|
||||||
const lines = markdown.split('\n')
|
const lines = markdown.split('\n')
|
||||||
const totalLines = lines.length
|
const totalLines = lines.length
|
||||||
const lineToPreviewMap = new Float32Array(totalLines)
|
const lineToPreviewMap = new Float32Array(totalLines).fill(UNMAPPED)
|
||||||
|
|
||||||
// 1. 识别块级元素起始行
|
// 1. 识别块级元素起始行
|
||||||
const blockStarts = identifyBlockStarts(lines)
|
const blockStarts = identifyBlockStarts(lines)
|
||||||
@@ -81,16 +83,16 @@ export function buildScrollMap(
|
|||||||
|
|
||||||
// 4. 线性插值填充所有行
|
// 4. 线性插值填充所有行
|
||||||
for (let i = 0; i < totalLines; i++) {
|
for (let i = 0; i < totalLines; i++) {
|
||||||
if (lineToPreviewMap[i] !== 0 && uniqueStartsSet.has(i)) continue
|
if (!isNaN(lineToPreviewMap[i]) && uniqueStartsSet.has(i)) continue
|
||||||
|
|
||||||
// 找到前后最近的已映射行
|
// 找到前后最近的已映射行
|
||||||
let prevLine = -1
|
let prevLine = -1
|
||||||
let nextLine = -1
|
let nextLine = -1
|
||||||
for (let j = i - 1; j >= 0; j--) {
|
for (let j = i - 1; j >= 0; j--) {
|
||||||
if (lineToPreviewMap[j] !== 0 || uniqueStartsSet.has(j)) { prevLine = j; break }
|
if (!isNaN(lineToPreviewMap[j]) || uniqueStartsSet.has(j)) { prevLine = j; break }
|
||||||
}
|
}
|
||||||
for (let j = i + 1; j < totalLines; j++) {
|
for (let j = i + 1; j < totalLines; j++) {
|
||||||
if (lineToPreviewMap[j] !== 0 || uniqueStartsSet.has(j)) { nextLine = j; break }
|
if (!isNaN(lineToPreviewMap[j]) || uniqueStartsSet.has(j)) { nextLine = j; break }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (prevLine === -1 && nextLine === -1) {
|
if (prevLine === -1 && nextLine === -1) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
|
import { nanoid } from 'nanoid'
|
||||||
import type { Tab } from '../types/tab'
|
import type { Tab } from '../types/tab'
|
||||||
|
|
||||||
interface TabState {
|
interface TabState {
|
||||||
@@ -16,8 +17,7 @@ interface TabState {
|
|||||||
updateTabScroll: (tabId: string, scroll: Partial<Pick<Tab, 'scrollTop' | 'scrollLeft' | 'selectionStart' | 'selectionEnd' | 'previewScrollTop'>>) => void
|
updateTabScroll: (tabId: string, scroll: Partial<Pick<Tab, 'scrollTop' | 'scrollLeft' | 'selectionStart' | 'selectionEnd' | 'previewScrollTop'>>) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
let tabIdCounter = 0
|
// L-04: 使用 nanoid 替代计数器(HMR 安全)
|
||||||
|
|
||||||
export const useTabStore = create<TabState>((set, get) => ({
|
export const useTabStore = create<TabState>((set, get) => ({
|
||||||
tabs: [],
|
tabs: [],
|
||||||
activeTabId: null,
|
activeTabId: null,
|
||||||
@@ -34,7 +34,7 @@ export const useTabStore = create<TabState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tab: Tab = {
|
const tab: Tab = {
|
||||||
id: String(++tabIdCounter),
|
id: nanoid(),
|
||||||
filePath,
|
filePath,
|
||||||
content,
|
content,
|
||||||
isModified: false,
|
isModified: false,
|
||||||
|
|||||||
@@ -752,6 +752,9 @@ html, body {
|
|||||||
#app.mode-preview #editor-panel,
|
#app.mode-preview #editor-panel,
|
||||||
#app.mode-preview #resizer { display: none; }
|
#app.mode-preview #resizer { display: none; }
|
||||||
|
|
||||||
|
#app.mode-editor #preview-panel,
|
||||||
|
#app.mode-editor #resizer { display: none; }
|
||||||
|
|
||||||
/* Toast */
|
/* Toast */
|
||||||
#toast-notification {
|
#toast-notification {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
Reference in New Issue
Block a user