v0.3.10: 全面优化增强 — 17项Bug修复/稳定性/功能改进
P0 致命Bug修复: - A1: openFolderDialog 类型/运行时崩溃(文件夹打开功能完全失效) - A2: SourceEditor 受控textarea手动改DOM反模式 - A3: tabStore.updateTabContent 内容相同时误标isModified 死代码清理: - B1: 删除menu:*/save/as/viewMode 三个永不触发的IPC通道 健壮性修复: - E1: rehypeFixImages 路径规范化+防越界加固 - E2: getFileName 尾斜杠返回正确文件名 - E3: EditorToolbar 行内代码按钮改用toggleInlineCodeCommand - E4: ErrorBoundary 内联样式抽为CSS类 功能增强: - D1: 标签页拖拽排序(tabStore.moveTab + TabBar DnD + CSS) - D2: Milkdown自动配对括号/引号(ProseMirror插件) - D3: 状态栏自动保存开关可点击 - D4: 文档大纲活跃标题高亮(useActiveHeading hook) - D5: 关闭窗口前flush防抖数据(flushSaveToDB) - D6: 搜索替换支持正则表达式 类型/架构: - C2: preload类型集中定义(ElectronAPI契约) - __pycache__ typo修复 文档/版本: - README/DESIGN同步为Milkdown + v0.3.10 - 项目结构树/技术栈/快捷键表更新 验证: typecheck(仅7预存), lint✅, test 96/96, vite build✅
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
|
||||
/**
|
||||
* D4: 基于视口的活跃标题追踪 hook
|
||||
* 在 preview 模式下监听目标容器的滚动事件,
|
||||
* 根据文档中标题元素的 offsetTop 判断当前可见的标题索引。
|
||||
*
|
||||
* 仅在目标容器 ref 存在时生效(preview 面板挂载后)。
|
||||
*
|
||||
* @param containerRef - 包含 Markdown 渲染结果的 DOM 元素 ref
|
||||
* @param headings - 解析出的标题列表
|
||||
* @returns - 当前活跃标题的索引(null 表示无法判定或不在范围内)
|
||||
*/
|
||||
export function useActiveHeading(
|
||||
containerRef: React.RefObject<HTMLElement | null>,
|
||||
headings: { level: number; text: string }[]
|
||||
): number | null {
|
||||
const [activeIndex, setActiveIndex] = useState<number | null>(null)
|
||||
const observerRef = useRef<IntersectionObserver | null>(null)
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const container = containerRef.current
|
||||
if (!container || headings.length === 0) {
|
||||
setActiveIndex(null)
|
||||
return
|
||||
}
|
||||
|
||||
// 收集容器内所有 h1-h6 元素的 offsetTop
|
||||
const headingElements = Array.from(
|
||||
container.querySelectorAll('h1, h2, h3, h4, h5, h6')
|
||||
) as HTMLElement[]
|
||||
|
||||
if (headingElements.length === 0) {
|
||||
setActiveIndex(null)
|
||||
return
|
||||
}
|
||||
|
||||
const scrollTop = container.scrollTop
|
||||
const containerHeight = container.clientHeight
|
||||
const threshold = scrollTop + containerHeight * 0.3 // 上方 30% 位置视为"到达"
|
||||
|
||||
let bestIndex: number | null = null
|
||||
|
||||
for (let i = 0; i < headingElements.length; i++) {
|
||||
const el = headingElements[i]
|
||||
// 使用容器顶部的相对偏移而非 getBoundingClientRect(滚动容器不是 window)
|
||||
const top = el.offsetTop - (container.offsetTop || 0)
|
||||
if (top <= threshold) {
|
||||
// 找到 headings 中匹配的索引
|
||||
const text = el.textContent?.trim() ?? ''
|
||||
const matchIdx = headings.findIndex(
|
||||
h => h.text.trim() === text && el.tagName.slice(-1) === String(h.level)
|
||||
)
|
||||
if (matchIdx >= 0) bestIndex = matchIdx
|
||||
}
|
||||
}
|
||||
|
||||
setActiveIndex(bestIndex)
|
||||
}, [containerRef, headings])
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container || headings.length === 0) {
|
||||
setActiveIndex(null)
|
||||
return
|
||||
}
|
||||
|
||||
// 监听滚动事件
|
||||
container.addEventListener('scroll', handleScroll, { passive: true })
|
||||
|
||||
// 初始计算
|
||||
handleScroll()
|
||||
|
||||
const observer = observerRef.current
|
||||
return () => {
|
||||
container.removeEventListener('scroll', handleScroll)
|
||||
observer?.disconnect()
|
||||
}
|
||||
}, [containerRef, headings, handleScroll])
|
||||
|
||||
return activeIndex
|
||||
}
|
||||
@@ -15,22 +15,19 @@ export function useFolderOperations() {
|
||||
|
||||
const handleOpenFolder = useCallback(async () => {
|
||||
if (!window.electronAPI) return
|
||||
const result = await window.electronAPI.openFolderDialog()
|
||||
if (!result) return
|
||||
if (!result.canceled && result.filePaths[0]) {
|
||||
const dirPath = result.filePaths[0]
|
||||
setRootPath(dirPath)
|
||||
expandDirs([dirPath])
|
||||
setLoading('dir-load', true)
|
||||
try {
|
||||
const dirTree = await window.electronAPI.readDirTree(dirPath)
|
||||
if (dirTree.success && dirTree.tree) {
|
||||
setTree(dirTree.tree)
|
||||
window.electronAPI.watchDir(dirPath)
|
||||
}
|
||||
} finally {
|
||||
setLoading('dir-load', false)
|
||||
const dirPath = await window.electronAPI.openFolderDialog()
|
||||
if (!dirPath) return
|
||||
setRootPath(dirPath)
|
||||
expandDirs([dirPath])
|
||||
setLoading('dir-load', true)
|
||||
try {
|
||||
const dirTree = await window.electronAPI.readDirTree(dirPath)
|
||||
if (dirTree.success && dirTree.tree) {
|
||||
setTree(dirTree.tree)
|
||||
window.electronAPI.watchDir(dirPath)
|
||||
}
|
||||
} finally {
|
||||
setLoading('dir-load', false)
|
||||
}
|
||||
}, [setRootPath, setTree, expandDirs, setLoading])
|
||||
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import { recentFilesRepository } from '../db/recentFilesRepository'
|
||||
|
||||
/**
|
||||
* 主进程事件注册 hook
|
||||
* 处理菜单触发的文件打开、保存、另存为事件
|
||||
* 处理通过命令行或文件关联打开的文件
|
||||
*/
|
||||
export function useIpcListeners(handleSave: () => void, handleSaveAs: () => void) {
|
||||
export function useIpcListeners() {
|
||||
const createTab = useTabStore(s => s.createTab)
|
||||
const handleSaveRef = useRef(handleSave)
|
||||
const handleSaveAsRef = useRef(handleSaveAs)
|
||||
handleSaveRef.current = handleSave
|
||||
handleSaveAsRef.current = handleSaveAs
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.electronAPI) return
|
||||
@@ -21,9 +17,6 @@ export function useIpcListeners(handleSave: () => void, handleSaveAs: () => void
|
||||
if (data.filePath) recentFilesRepository.add(data.filePath)
|
||||
setTimeout(() => useTabStore.getState().saveToDB(), 100)
|
||||
}
|
||||
const unsub1 = api.onFileOpenInTab(onOpen)
|
||||
const unsub2 = api.onMenuSave(() => handleSaveRef.current())
|
||||
const unsub3 = api.onMenuSaveAs(() => handleSaveAsRef.current())
|
||||
return () => { unsub1(); unsub2(); unsub3() }
|
||||
return api.onFileOpenInTab(onOpen)
|
||||
}, [createTab])
|
||||
}
|
||||
|
||||
@@ -6,10 +6,14 @@ import { useEffect, useCallback, useRef } from 'react'
|
||||
*/
|
||||
export function useUnsavedWarning(
|
||||
hasUnsaved: () => boolean,
|
||||
confirmFn?: (message: string) => Promise<boolean>
|
||||
confirmFn?: (message: string) => Promise<boolean>,
|
||||
// D5: 关闭前回调(flush 待保存数据)
|
||||
onBeforeForceClose?: () => Promise<void>
|
||||
) {
|
||||
const confirmFnRef = useRef(confirmFn)
|
||||
confirmFnRef.current = confirmFn
|
||||
const onBeforeForceCloseRef = useRef(onBeforeForceClose)
|
||||
onBeforeForceCloseRef.current = onBeforeForceClose
|
||||
|
||||
const doConfirm = useCallback(async (message: string): Promise<boolean> => {
|
||||
if (confirmFnRef.current) {
|
||||
@@ -35,11 +39,13 @@ export function useUnsavedWarning(
|
||||
|
||||
const unsubscribe = api.onConfirmClose(async () => {
|
||||
if (!hasUnsaved()) {
|
||||
await onBeforeForceCloseRef.current?.()
|
||||
api.forceClose()
|
||||
return
|
||||
}
|
||||
const shouldClose = await doConfirm('有文件尚未保存,确定要关闭吗?')
|
||||
if (shouldClose) {
|
||||
await onBeforeForceCloseRef.current?.()
|
||||
api.forceClose()
|
||||
} else {
|
||||
api.cancelClose()
|
||||
|
||||
Reference in New Issue
Block a user