v0.3.4: Bug修复+性能优化+文档大纲功能
🔴 Bug修复 (6): - Sidebar反斜杠正则修复 (Windows路径规范化) - FileWatcher文件删除后自动恢复监听 - SearchReplace replaceAll防过期匹配位置 - openFolderDialog null结果守卫 - 保存异常时空路径保护 - Markdown绝对路径图片拼接修复 🟡 性能优化: - Editor切换标签时内容比较,相同跳过replaceAll 🔵 代码质量: - 创建共享常量模块 src/shared/constants.ts,消除双副本 🎯 新增功能: - 文档大纲 (Table of Contents) — 侧边栏标题导航 🔧 换行符统一: CRLF → LF
This commit is contained in:
@@ -1,55 +1,56 @@
|
||||
import React from 'react'
|
||||
import { AppIcon, Gitee } from '../Icons'
|
||||
|
||||
const APP_VERSION = 'v0.3.2'
|
||||
|
||||
interface AboutDialogProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export const AboutDialog = React.memo(function AboutDialog({ onClose }: AboutDialogProps) {
|
||||
const handleLinkClick = (e: React.MouseEvent<HTMLAnchorElement>): void => {
|
||||
e.preventDefault()
|
||||
if (window.electronAPI?.openExternal) {
|
||||
window.electronAPI.openExternal('https://gitee.com/thzxx/MarkLite')
|
||||
} else {
|
||||
window.open('https://gitee.com/thzxx/MarkLite', '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="about-overlay" onClick={onClose} role="dialog" aria-modal="true" aria-label="关于 MarkLite">
|
||||
<div className="about-dialog" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<div className="about-header">
|
||||
<AppIcon size={64} />
|
||||
<h2>MarkLite</h2>
|
||||
<span className="about-version">{APP_VERSION}</span>
|
||||
</div>
|
||||
<div className="about-body">
|
||||
<p>一款轻量级的 Windows 本地 Markdown 编辑器</p>
|
||||
<div className="about-features">
|
||||
<span>多标签页</span>
|
||||
<span>实时预览</span>
|
||||
<span>代码高亮</span>
|
||||
<span>暗色主题</span>
|
||||
<span>拖拽打开</span>
|
||||
<span>搜索替换</span>
|
||||
<span>文件树</span>
|
||||
<span>状态持久化</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="about-footer">
|
||||
<a className="about-link" href="#" onClick={handleLinkClick}>
|
||||
<Gitee size={16} />
|
||||
<span>gitee.com/thzxx/MarkLite</span>
|
||||
</a>
|
||||
<p>基于 Electron + React + TypeScript 构建</p>
|
||||
<p className="about-copyright">© 2026 thzxx</p>
|
||||
</div>
|
||||
<button className="about-close-btn" onClick={onClose}>关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
AboutDialog.displayName = 'AboutDialog'
|
||||
import React from 'react'
|
||||
import { AppIcon, Gitee } from '../Icons'
|
||||
|
||||
const APP_VERSION = 'v0.3.4'
|
||||
|
||||
interface AboutDialogProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export const AboutDialog = React.memo(function AboutDialog({ onClose }: AboutDialogProps) {
|
||||
const handleLinkClick = (e: React.MouseEvent<HTMLAnchorElement>): void => {
|
||||
e.preventDefault()
|
||||
if (window.electronAPI?.openExternal) {
|
||||
window.electronAPI.openExternal('https://gitee.com/thzxx/MarkLite')
|
||||
} else {
|
||||
window.open('https://gitee.com/thzxx/MarkLite', '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="about-overlay" onClick={onClose} role="dialog" aria-modal="true" aria-label="关于 MarkLite">
|
||||
<div className="about-dialog" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<div className="about-header">
|
||||
<AppIcon size={64} />
|
||||
<h2>MarkLite</h2>
|
||||
<span className="about-version">{APP_VERSION}</span>
|
||||
</div>
|
||||
<div className="about-body">
|
||||
<p>一款轻量级的 Windows 本地 Markdown 编辑器</p>
|
||||
<div className="about-features">
|
||||
<span>多标签页</span>
|
||||
<span>实时预览</span>
|
||||
<span>代码高亮</span>
|
||||
<span>暗色主题</span>
|
||||
<span>拖拽打开</span>
|
||||
<span>搜索替换</span>
|
||||
<span>文件树</span>
|
||||
<span>文档大纲</span>
|
||||
<span>状态持久化</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="about-footer">
|
||||
<a className="about-link" href="#" onClick={handleLinkClick}>
|
||||
<Gitee size={16} />
|
||||
<span>gitee.com/thzxx/MarkLite</span>
|
||||
</a>
|
||||
<p>基于 Electron + React + TypeScript 构建</p>
|
||||
<p className="about-copyright">© 2026 thzxx</p>
|
||||
</div>
|
||||
<button className="about-close-btn" onClick={onClose}>关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
AboutDialog.displayName = 'AboutDialog'
|
||||
|
||||
@@ -1,108 +1,108 @@
|
||||
import React, { useEffect, useRef, useCallback } from 'react'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean
|
||||
title: string
|
||||
message: string
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
variant?: 'danger' | 'warning' | 'info'
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export const ConfirmDialog = React.memo(function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = '确定',
|
||||
cancelLabel = '取消',
|
||||
variant = 'warning',
|
||||
onConfirm,
|
||||
onCancel
|
||||
}: ConfirmDialogProps) {
|
||||
const confirmRef = useRef<HTMLButtonElement>(null)
|
||||
const previousFocusRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
// 打开时保存焦点并聚焦确认按钮;关闭时恢复焦点
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
previousFocusRef.current?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
previousFocusRef.current = document.activeElement as HTMLElement
|
||||
const timer = setTimeout(() => confirmRef.current?.focus(), 50)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// ESC 键关闭
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onCancel()
|
||||
}
|
||||
}, [onCancel])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [open, handleKeyDown])
|
||||
|
||||
// 防止背景滚动
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const original = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => { document.body.style.overflow = original }
|
||||
}, [open])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="confirm-overlay"
|
||||
onClick={onCancel}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
className="confirm-dialog"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-title"
|
||||
aria-describedby="confirm-message"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className={`confirm-header confirm-${variant}`}>
|
||||
<h3 id="confirm-title">{title}</h3>
|
||||
</div>
|
||||
<div className="confirm-body">
|
||||
<p id="confirm-message">{message}</p>
|
||||
</div>
|
||||
<div className="confirm-actions">
|
||||
<button
|
||||
className="confirm-btn confirm-btn-cancel"
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
ref={confirmRef}
|
||||
className={`confirm-btn confirm-btn-${variant}`}
|
||||
onClick={onConfirm}
|
||||
type="button"
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
ConfirmDialog.displayName = 'ConfirmDialog'
|
||||
import React, { useEffect, useRef, useCallback } from 'react'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean
|
||||
title: string
|
||||
message: string
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
variant?: 'danger' | 'warning' | 'info'
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export const ConfirmDialog = React.memo(function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = '确定',
|
||||
cancelLabel = '取消',
|
||||
variant = 'warning',
|
||||
onConfirm,
|
||||
onCancel
|
||||
}: ConfirmDialogProps) {
|
||||
const confirmRef = useRef<HTMLButtonElement>(null)
|
||||
const previousFocusRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
// 打开时保存焦点并聚焦确认按钮;关闭时恢复焦点
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
previousFocusRef.current?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
previousFocusRef.current = document.activeElement as HTMLElement
|
||||
const timer = setTimeout(() => confirmRef.current?.focus(), 50)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// ESC 键关闭
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onCancel()
|
||||
}
|
||||
}, [onCancel])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [open, handleKeyDown])
|
||||
|
||||
// 防止背景滚动
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const original = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => { document.body.style.overflow = original }
|
||||
}, [open])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="confirm-overlay"
|
||||
onClick={onCancel}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
className="confirm-dialog"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-title"
|
||||
aria-describedby="confirm-message"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className={`confirm-header confirm-${variant}`}>
|
||||
<h3 id="confirm-title">{title}</h3>
|
||||
</div>
|
||||
<div className="confirm-body">
|
||||
<p id="confirm-message">{message}</p>
|
||||
</div>
|
||||
<div className="confirm-actions">
|
||||
<button
|
||||
className="confirm-btn confirm-btn-cancel"
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
ref={confirmRef}
|
||||
className={`confirm-btn confirm-btn-${variant}`}
|
||||
onClick={onConfirm}
|
||||
type="button"
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
ConfirmDialog.displayName = 'ConfirmDialog'
|
||||
|
||||
@@ -1,105 +1,116 @@
|
||||
import React, { useEffect, useCallback, useState } from 'react'
|
||||
import { callCommand } from '@milkdown/utils'
|
||||
import { toggleStrongCommand, toggleEmphasisCommand } from '@milkdown/preset-commonmark'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useMilkdown } from './useMilkdown'
|
||||
import { EditorToolbar } from './EditorToolbar'
|
||||
import { SearchReplace } from '../SearchReplace'
|
||||
|
||||
interface EditorProps {
|
||||
darkMode: boolean
|
||||
}
|
||||
|
||||
export const Editor = React.memo(function Editor({ darkMode }: EditorProps) {
|
||||
const activeTab = useTabStore(s => s.getActiveTab())
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
const updateTabContent = useTabStore(s => s.updateTabContent)
|
||||
const setModified = useTabStore(s => s.setModified)
|
||||
const updateTabScroll = useTabStore(s => s.updateTabScroll)
|
||||
const [showSearch, setShowSearch] = useState(false)
|
||||
|
||||
const {
|
||||
containerRef,
|
||||
action,
|
||||
setContent,
|
||||
getScrollTop,
|
||||
setScrollTop,
|
||||
getSelection,
|
||||
setSelection,
|
||||
getView
|
||||
} = useMilkdown({
|
||||
content: activeTab?.content ?? '',
|
||||
onChange: useCallback((value: string) => {
|
||||
if (!activeTabId) return
|
||||
updateTabContent(activeTabId, value)
|
||||
setModified(activeTabId, true)
|
||||
}, [activeTabId, updateTabContent, setModified]),
|
||||
darkMode
|
||||
})
|
||||
|
||||
// Load content when switching tabs (only trigger on tab switch, not content edits)
|
||||
useEffect(() => {
|
||||
if (!activeTab) return
|
||||
setContent(activeTab.content)
|
||||
requestAnimationFrame(() => {
|
||||
setScrollTop(activeTab.scrollTop)
|
||||
setSelection()
|
||||
})
|
||||
// stable refs: setContent, setScrollTop, setSelection are useCallback([]) - never change
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTabId])
|
||||
|
||||
// Save current tab state on unmount or tab switch
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (!activeTabId) return
|
||||
updateTabScroll(activeTabId, {
|
||||
scrollTop: getScrollTop(),
|
||||
selectionStart: getSelection().from,
|
||||
selectionEnd: getSelection().to
|
||||
})
|
||||
}
|
||||
// stable refs: getScrollTop, getSelection (useCallback([])), updateTabScroll (zustand) - never change
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTabId])
|
||||
|
||||
// Ctrl+B bold, Ctrl+I italic, Ctrl+F search, Ctrl+H replace
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const isCtrl = e.ctrlKey || e.metaKey
|
||||
if (isCtrl && e.key === 'b') {
|
||||
e.preventDefault()
|
||||
action((editor) => {
|
||||
editor.action(callCommand(toggleStrongCommand.key))
|
||||
})
|
||||
}
|
||||
if (isCtrl && e.key === 'i') {
|
||||
e.preventDefault()
|
||||
action((editor) => {
|
||||
editor.action(callCommand(toggleEmphasisCommand.key))
|
||||
})
|
||||
}
|
||||
if (isCtrl && (e.key === 'f' || e.key === 'h')) {
|
||||
e.preventDefault()
|
||||
setShowSearch(true)
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [action])
|
||||
|
||||
return (
|
||||
<div className="editor-container" role="region" aria-label="Markdown编辑器">
|
||||
<EditorToolbar action={action} />
|
||||
{showSearch && (
|
||||
<SearchReplace
|
||||
getView={getView}
|
||||
onClose={() => setShowSearch(false)}
|
||||
/>
|
||||
)}
|
||||
<div ref={containerRef} className="milkdown-wrapper" />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
Editor.displayName = 'Editor'
|
||||
import React, { useEffect, useCallback, useState, useRef } from 'react'
|
||||
import { callCommand } from '@milkdown/utils'
|
||||
import { toggleStrongCommand, toggleEmphasisCommand } from '@milkdown/preset-commonmark'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useMilkdown } from './useMilkdown'
|
||||
import { EditorToolbar } from './EditorToolbar'
|
||||
import { SearchReplace } from '../SearchReplace'
|
||||
import { setEditorViewGetter } from '../../stores/editorStore'
|
||||
|
||||
interface EditorProps {
|
||||
darkMode: boolean
|
||||
}
|
||||
|
||||
export const Editor = React.memo(function Editor({ darkMode }: EditorProps) {
|
||||
const activeTab = useTabStore(s => s.getActiveTab())
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
const updateTabContent = useTabStore(s => s.updateTabContent)
|
||||
const setModified = useTabStore(s => s.setModified)
|
||||
const updateTabScroll = useTabStore(s => s.updateTabScroll)
|
||||
const [showSearch, setShowSearch] = useState(false)
|
||||
const currentContentRef = useRef('')
|
||||
|
||||
const {
|
||||
containerRef,
|
||||
action,
|
||||
setContent,
|
||||
getScrollTop,
|
||||
setScrollTop,
|
||||
getSelection,
|
||||
setSelection,
|
||||
getView
|
||||
} = useMilkdown({
|
||||
content: activeTab?.content ?? '',
|
||||
onChange: useCallback((value: string) => {
|
||||
if (!activeTabId) return
|
||||
updateTabContent(activeTabId, value)
|
||||
setModified(activeTabId, true)
|
||||
}, [activeTabId, updateTabContent, setModified]),
|
||||
darkMode
|
||||
})
|
||||
|
||||
// Load content when switching tabs (only trigger on tab switch, not content edits)
|
||||
useEffect(() => {
|
||||
if (!activeTab) return
|
||||
if (activeTab.content !== currentContentRef.current) {
|
||||
currentContentRef.current = activeTab.content
|
||||
setContent(activeTab.content)
|
||||
requestAnimationFrame(() => {
|
||||
setScrollTop(activeTab.scrollTop)
|
||||
setSelection()
|
||||
})
|
||||
}
|
||||
// stable refs: setContent, setScrollTop, setSelection are useCallback([]) - never change
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTabId])
|
||||
|
||||
// Register getView for OutlinePanel navigation
|
||||
useEffect(() => {
|
||||
setEditorViewGetter(getView)
|
||||
return () => setEditorViewGetter(() => null)
|
||||
}, [getView])
|
||||
|
||||
// Save current tab state on unmount or tab switch
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (!activeTabId) return
|
||||
updateTabScroll(activeTabId, {
|
||||
scrollTop: getScrollTop(),
|
||||
selectionStart: getSelection().from,
|
||||
selectionEnd: getSelection().to
|
||||
})
|
||||
}
|
||||
// stable refs: getScrollTop, getSelection (useCallback([])), updateTabScroll (zustand) - never change
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTabId])
|
||||
|
||||
// Ctrl+B bold, Ctrl+I italic, Ctrl+F search, Ctrl+H replace
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const isCtrl = e.ctrlKey || e.metaKey
|
||||
if (isCtrl && e.key === 'b') {
|
||||
e.preventDefault()
|
||||
action((editor) => {
|
||||
editor.action(callCommand(toggleStrongCommand.key))
|
||||
})
|
||||
}
|
||||
if (isCtrl && e.key === 'i') {
|
||||
e.preventDefault()
|
||||
action((editor) => {
|
||||
editor.action(callCommand(toggleEmphasisCommand.key))
|
||||
})
|
||||
}
|
||||
if (isCtrl && (e.key === 'f' || e.key === 'h')) {
|
||||
e.preventDefault()
|
||||
setShowSearch(true)
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [action])
|
||||
|
||||
return (
|
||||
<div className="editor-container" role="region" aria-label="Markdown编辑器">
|
||||
<EditorToolbar action={action} />
|
||||
{showSearch && (
|
||||
<SearchReplace
|
||||
getView={getView}
|
||||
onClose={() => setShowSearch(false)}
|
||||
/>
|
||||
)}
|
||||
<div ref={containerRef} className="milkdown-wrapper" />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
Editor.displayName = 'Editor'
|
||||
|
||||
@@ -1,252 +1,252 @@
|
||||
import { useCallback, useRef, useEffect } from 'react'
|
||||
import {
|
||||
Editor,
|
||||
rootCtx,
|
||||
defaultValueCtx,
|
||||
editorViewCtx,
|
||||
prosePluginsCtx
|
||||
} from '@milkdown/core'
|
||||
import { replaceAll as milkdownReplaceAll } from '@milkdown/utils'
|
||||
import { commonmark } from '@milkdown/preset-commonmark'
|
||||
import { gfm } from '@milkdown/preset-gfm'
|
||||
import { history } from '@milkdown/plugin-history'
|
||||
import { listener, listenerCtx } from '@milkdown/plugin-listener'
|
||||
import { indent } from '@milkdown/plugin-indent'
|
||||
import { trailing } from '@milkdown/plugin-trailing'
|
||||
import { clipboard } from '@milkdown/plugin-clipboard'
|
||||
import { Plugin, PluginKey } from '@milkdown/prose/state'
|
||||
import { Decoration, DecorationSet } from '@milkdown/prose/view'
|
||||
import type { EditorState } from '@milkdown/prose/state'
|
||||
|
||||
// --- Search highlight plugin ---
|
||||
|
||||
export interface SearchMatch {
|
||||
from: number
|
||||
to: number
|
||||
}
|
||||
|
||||
export interface SearchPluginState {
|
||||
matches: SearchMatch[]
|
||||
currentIndex: number
|
||||
query: string
|
||||
}
|
||||
|
||||
export const searchPluginKey = new PluginKey<SearchPluginState>('search-highlight')
|
||||
|
||||
function createSearchPlugin(): Plugin<SearchPluginState> {
|
||||
return new Plugin<SearchPluginState>({
|
||||
key: searchPluginKey,
|
||||
state: {
|
||||
init(): SearchPluginState {
|
||||
return { matches: [], currentIndex: -1, query: '' }
|
||||
},
|
||||
apply(tr, value): SearchPluginState {
|
||||
const meta = tr.getMeta(searchPluginKey) as SearchPluginState | undefined
|
||||
if (meta) return meta
|
||||
// When the document changes, map existing match positions through the change
|
||||
// but only if there's an active search
|
||||
if (tr.docChanged && value.query) {
|
||||
// The SearchReplace component will re-search when doc changes,
|
||||
// but we map positions so decorations stay roughly correct in the interim
|
||||
return { ...value }
|
||||
}
|
||||
return value
|
||||
}
|
||||
},
|
||||
props: {
|
||||
decorations(state: EditorState) {
|
||||
const pluginState = searchPluginKey.getState(state)
|
||||
if (!pluginState || pluginState.matches.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const decos: Decoration[] = pluginState.matches.map((match, index) => {
|
||||
const cls =
|
||||
index === pluginState.currentIndex
|
||||
? 'search-match-active'
|
||||
: 'search-match-highlight'
|
||||
return Decoration.inline(match.from, match.to, { class: cls })
|
||||
})
|
||||
|
||||
return DecorationSet.create(state.doc, decos)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- Hook ---
|
||||
|
||||
interface UseMilkdownOptions {
|
||||
content: string
|
||||
onChange: (value: string) => void
|
||||
darkMode: boolean
|
||||
}
|
||||
|
||||
export function useMilkdown({ content, onChange, darkMode }: UseMilkdownOptions) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const editorRef = useRef<Editor | null>(null)
|
||||
const onChangeRef = useRef(onChange)
|
||||
const isExternalUpdate = useRef(false)
|
||||
const initialContentRef = useRef(content)
|
||||
|
||||
// Keep onChangeRef fresh
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange
|
||||
}, [onChange])
|
||||
|
||||
// Initialize editor
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
const editor = Editor.make()
|
||||
.config((ctx) => {
|
||||
ctx.set(rootCtx, containerRef.current!)
|
||||
ctx.set(defaultValueCtx, initialContentRef.current)
|
||||
|
||||
// Inject search highlight plugin into ProseMirror plugin list
|
||||
ctx.update(prosePluginsCtx, (plugins) => [...plugins, createSearchPlugin()])
|
||||
|
||||
// Configure listener for content changes
|
||||
const lm = ctx.get(listenerCtx)
|
||||
lm.markdownUpdated((_ctx, markdown, prevMarkdown) => {
|
||||
if (markdown === prevMarkdown) return
|
||||
if (!isExternalUpdate.current) {
|
||||
onChangeRef.current(markdown)
|
||||
}
|
||||
})
|
||||
|
||||
// Listen for blur events (used for potential future state saving)
|
||||
lm.blur(() => {
|
||||
// blur handler placeholder
|
||||
})
|
||||
})
|
||||
.use(commonmark)
|
||||
.use(gfm)
|
||||
.use(history)
|
||||
.use(listener)
|
||||
.use(indent)
|
||||
.use(trailing)
|
||||
.use(clipboard)
|
||||
|
||||
let cancelled = false
|
||||
|
||||
editor.create().then((created) => {
|
||||
if (cancelled) {
|
||||
// 组件已卸载,立即销毁以避免内存泄漏
|
||||
created.destroy()
|
||||
return
|
||||
}
|
||||
editorRef.current = created
|
||||
}).catch((err) => {
|
||||
// eslint-disable-next-line no-console -- editor creation failure must be reported
|
||||
console.error('Milkdown editor creation failed:', err)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
editorRef.current?.destroy()
|
||||
editorRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Dark mode: update CSS custom properties on the container
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
if (darkMode) {
|
||||
container.classList.add('milkdown-dark')
|
||||
} else {
|
||||
container.classList.remove('milkdown-dark')
|
||||
}
|
||||
}, [darkMode])
|
||||
|
||||
// External content update (tab switch)
|
||||
const setContent = useCallback((newContent: string) => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
|
||||
isExternalUpdate.current = true
|
||||
try {
|
||||
editor.action(milkdownReplaceAll(newContent))
|
||||
} catch {
|
||||
// replaceAll may fail if editor is not fully ready
|
||||
} finally {
|
||||
isExternalUpdate.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Scroll position management
|
||||
const getScrollTop = useCallback((): number => {
|
||||
const container = containerRef.current
|
||||
if (!container) return 0
|
||||
const scroller = container.querySelector('.milkdown .editor') as HTMLElement | null
|
||||
return scroller?.scrollTop ?? container.scrollTop ?? 0
|
||||
}, [])
|
||||
|
||||
const setScrollTop = useCallback((top: number) => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
const scroller = container.querySelector('.milkdown .editor') as HTMLElement | null
|
||||
if (scroller) {
|
||||
scroller.scrollTop = top
|
||||
} else {
|
||||
container.scrollTop = top
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Selection management
|
||||
const getSelection = useCallback((): { from: number; to: number } => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return { from: 0, to: 0 }
|
||||
try {
|
||||
return editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
if (view?.state?.selection) {
|
||||
return { from: view.state.selection.from, to: view.state.selection.to }
|
||||
}
|
||||
return { from: 0, to: 0 }
|
||||
})
|
||||
} catch {
|
||||
return { from: 0, to: 0 }
|
||||
}
|
||||
}, [])
|
||||
|
||||
const setSelection = useCallback(() => {
|
||||
// ProseMirror selection setting requires the view instance
|
||||
// which we access lazily; for now we focus the editor
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
const pmEditor = container.querySelector('.ProseMirror') as HTMLElement | null
|
||||
pmEditor?.focus()
|
||||
}, [])
|
||||
|
||||
// Get ProseMirror EditorView instance
|
||||
const getView = useCallback(() => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return null
|
||||
try {
|
||||
return editor.action((ctx) => ctx.get(editorViewCtx))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Execute a Milkdown action (for toolbar commands)
|
||||
const action = useCallback((fn: (editor: Editor) => void) => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
fn(editor)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
editorRef,
|
||||
action,
|
||||
setContent,
|
||||
getScrollTop,
|
||||
setScrollTop,
|
||||
getSelection,
|
||||
setSelection,
|
||||
getView
|
||||
}
|
||||
}
|
||||
import { useCallback, useRef, useEffect } from 'react'
|
||||
import {
|
||||
Editor,
|
||||
rootCtx,
|
||||
defaultValueCtx,
|
||||
editorViewCtx,
|
||||
prosePluginsCtx
|
||||
} from '@milkdown/core'
|
||||
import { replaceAll as milkdownReplaceAll } from '@milkdown/utils'
|
||||
import { commonmark } from '@milkdown/preset-commonmark'
|
||||
import { gfm } from '@milkdown/preset-gfm'
|
||||
import { history } from '@milkdown/plugin-history'
|
||||
import { listener, listenerCtx } from '@milkdown/plugin-listener'
|
||||
import { indent } from '@milkdown/plugin-indent'
|
||||
import { trailing } from '@milkdown/plugin-trailing'
|
||||
import { clipboard } from '@milkdown/plugin-clipboard'
|
||||
import { Plugin, PluginKey } from '@milkdown/prose/state'
|
||||
import { Decoration, DecorationSet } from '@milkdown/prose/view'
|
||||
import type { EditorState } from '@milkdown/prose/state'
|
||||
|
||||
// --- Search highlight plugin ---
|
||||
|
||||
export interface SearchMatch {
|
||||
from: number
|
||||
to: number
|
||||
}
|
||||
|
||||
export interface SearchPluginState {
|
||||
matches: SearchMatch[]
|
||||
currentIndex: number
|
||||
query: string
|
||||
}
|
||||
|
||||
export const searchPluginKey = new PluginKey<SearchPluginState>('search-highlight')
|
||||
|
||||
function createSearchPlugin(): Plugin<SearchPluginState> {
|
||||
return new Plugin<SearchPluginState>({
|
||||
key: searchPluginKey,
|
||||
state: {
|
||||
init(): SearchPluginState {
|
||||
return { matches: [], currentIndex: -1, query: '' }
|
||||
},
|
||||
apply(tr, value): SearchPluginState {
|
||||
const meta = tr.getMeta(searchPluginKey) as SearchPluginState | undefined
|
||||
if (meta) return meta
|
||||
// When the document changes, map existing match positions through the change
|
||||
// but only if there's an active search
|
||||
if (tr.docChanged && value.query) {
|
||||
// The SearchReplace component will re-search when doc changes,
|
||||
// but we map positions so decorations stay roughly correct in the interim
|
||||
return { ...value }
|
||||
}
|
||||
return value
|
||||
}
|
||||
},
|
||||
props: {
|
||||
decorations(state: EditorState) {
|
||||
const pluginState = searchPluginKey.getState(state)
|
||||
if (!pluginState || pluginState.matches.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const decos: Decoration[] = pluginState.matches.map((match, index) => {
|
||||
const cls =
|
||||
index === pluginState.currentIndex
|
||||
? 'search-match-active'
|
||||
: 'search-match-highlight'
|
||||
return Decoration.inline(match.from, match.to, { class: cls })
|
||||
})
|
||||
|
||||
return DecorationSet.create(state.doc, decos)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- Hook ---
|
||||
|
||||
interface UseMilkdownOptions {
|
||||
content: string
|
||||
onChange: (value: string) => void
|
||||
darkMode: boolean
|
||||
}
|
||||
|
||||
export function useMilkdown({ content, onChange, darkMode }: UseMilkdownOptions) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const editorRef = useRef<Editor | null>(null)
|
||||
const onChangeRef = useRef(onChange)
|
||||
const isExternalUpdate = useRef(false)
|
||||
const initialContentRef = useRef(content)
|
||||
|
||||
// Keep onChangeRef fresh
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange
|
||||
}, [onChange])
|
||||
|
||||
// Initialize editor
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
const editor = Editor.make()
|
||||
.config((ctx) => {
|
||||
ctx.set(rootCtx, containerRef.current!)
|
||||
ctx.set(defaultValueCtx, initialContentRef.current)
|
||||
|
||||
// Inject search highlight plugin into ProseMirror plugin list
|
||||
ctx.update(prosePluginsCtx, (plugins) => [...plugins, createSearchPlugin()])
|
||||
|
||||
// Configure listener for content changes
|
||||
const lm = ctx.get(listenerCtx)
|
||||
lm.markdownUpdated((_ctx, markdown, prevMarkdown) => {
|
||||
if (markdown === prevMarkdown) return
|
||||
if (!isExternalUpdate.current) {
|
||||
onChangeRef.current(markdown)
|
||||
}
|
||||
})
|
||||
|
||||
// Listen for blur events (used for potential future state saving)
|
||||
lm.blur(() => {
|
||||
// blur handler placeholder
|
||||
})
|
||||
})
|
||||
.use(commonmark)
|
||||
.use(gfm)
|
||||
.use(history)
|
||||
.use(listener)
|
||||
.use(indent)
|
||||
.use(trailing)
|
||||
.use(clipboard)
|
||||
|
||||
let cancelled = false
|
||||
|
||||
editor.create().then((created) => {
|
||||
if (cancelled) {
|
||||
// 组件已卸载,立即销毁以避免内存泄漏
|
||||
created.destroy()
|
||||
return
|
||||
}
|
||||
editorRef.current = created
|
||||
}).catch((err) => {
|
||||
// eslint-disable-next-line no-console -- editor creation failure must be reported
|
||||
console.error('Milkdown editor creation failed:', err)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
editorRef.current?.destroy()
|
||||
editorRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Dark mode: update CSS custom properties on the container
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
if (darkMode) {
|
||||
container.classList.add('milkdown-dark')
|
||||
} else {
|
||||
container.classList.remove('milkdown-dark')
|
||||
}
|
||||
}, [darkMode])
|
||||
|
||||
// External content update (tab switch)
|
||||
const setContent = useCallback((newContent: string) => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
|
||||
isExternalUpdate.current = true
|
||||
try {
|
||||
editor.action(milkdownReplaceAll(newContent))
|
||||
} catch {
|
||||
// replaceAll may fail if editor is not fully ready
|
||||
} finally {
|
||||
isExternalUpdate.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Scroll position management
|
||||
const getScrollTop = useCallback((): number => {
|
||||
const container = containerRef.current
|
||||
if (!container) return 0
|
||||
const scroller = container.querySelector('.milkdown .editor') as HTMLElement | null
|
||||
return scroller?.scrollTop ?? container.scrollTop ?? 0
|
||||
}, [])
|
||||
|
||||
const setScrollTop = useCallback((top: number) => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
const scroller = container.querySelector('.milkdown .editor') as HTMLElement | null
|
||||
if (scroller) {
|
||||
scroller.scrollTop = top
|
||||
} else {
|
||||
container.scrollTop = top
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Selection management
|
||||
const getSelection = useCallback((): { from: number; to: number } => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return { from: 0, to: 0 }
|
||||
try {
|
||||
return editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
if (view?.state?.selection) {
|
||||
return { from: view.state.selection.from, to: view.state.selection.to }
|
||||
}
|
||||
return { from: 0, to: 0 }
|
||||
})
|
||||
} catch {
|
||||
return { from: 0, to: 0 }
|
||||
}
|
||||
}, [])
|
||||
|
||||
const setSelection = useCallback(() => {
|
||||
// ProseMirror selection setting requires the view instance
|
||||
// which we access lazily; for now we focus the editor
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
const pmEditor = container.querySelector('.ProseMirror') as HTMLElement | null
|
||||
pmEditor?.focus()
|
||||
}, [])
|
||||
|
||||
// Get ProseMirror EditorView instance
|
||||
const getView = useCallback(() => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return null
|
||||
try {
|
||||
return editor.action((ctx) => ctx.get(editorViewCtx))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Execute a Milkdown action (for toolbar commands)
|
||||
const action = useCallback((fn: (editor: Editor) => void) => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
fn(editor)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
editorRef,
|
||||
action,
|
||||
setContent,
|
||||
getScrollTop,
|
||||
setScrollTop,
|
||||
getSelection,
|
||||
setSelection,
|
||||
getView
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,88 +1,88 @@
|
||||
import { Component, ErrorInfo, ReactNode } from 'react'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
fallback?: ReactNode
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = { hasError: false, error: null }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
||||
// eslint-disable-next-line no-console -- React error boundary standard pattern
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo)
|
||||
}
|
||||
|
||||
handleReset = (): void => {
|
||||
this.setState({ hasError: false, error: null })
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
padding: '2rem',
|
||||
textAlign: 'center',
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ marginBottom: '1rem', color: '#e74c3c' }}>
|
||||
应用遇到了错误
|
||||
</h2>
|
||||
<pre
|
||||
style={{
|
||||
padding: '1rem',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
maxWidth: '600px',
|
||||
overflow: 'auto',
|
||||
fontSize: '0.875rem',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
{this.state.error?.message}
|
||||
</pre>
|
||||
<button
|
||||
onClick={this.handleReset}
|
||||
style={{
|
||||
marginTop: '1rem',
|
||||
padding: '0.5rem 1.5rem',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
backgroundColor: '#3498db',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1rem',
|
||||
}}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
import { Component, ErrorInfo, ReactNode } from 'react'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
fallback?: ReactNode
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = { hasError: false, error: null }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
||||
// eslint-disable-next-line no-console -- React error boundary standard pattern
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo)
|
||||
}
|
||||
|
||||
handleReset = (): void => {
|
||||
this.setState({ hasError: false, error: null })
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
padding: '2rem',
|
||||
textAlign: 'center',
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ marginBottom: '1rem', color: '#e74c3c' }}>
|
||||
应用遇到了错误
|
||||
</h2>
|
||||
<pre
|
||||
style={{
|
||||
padding: '1rem',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
maxWidth: '600px',
|
||||
overflow: 'auto',
|
||||
fontSize: '0.875rem',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
{this.state.error?.message}
|
||||
</pre>
|
||||
<button
|
||||
onClick={this.handleReset}
|
||||
style={{
|
||||
marginTop: '1rem',
|
||||
padding: '0.5rem 1.5rem',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
backgroundColor: '#3498db',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1rem',
|
||||
}}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import React, { memo } from 'react'
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface Heading {
|
||||
level: number
|
||||
text: string
|
||||
/** Position in document (character offset from markdown source) */
|
||||
pos: number
|
||||
}
|
||||
|
||||
// --- Heading Parser ---
|
||||
|
||||
const HEADING_RE = /^(#{1,6})\s+(.+)$/gm
|
||||
|
||||
/**
|
||||
* Parse headings from raw markdown content using regex.
|
||||
*/
|
||||
export function parseHeadings(markdown: string): Heading[] {
|
||||
const headings: Heading[] = []
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
// Reset regex state
|
||||
HEADING_RE.lastIndex = 0
|
||||
|
||||
while ((match = HEADING_RE.exec(markdown)) !== null) {
|
||||
const level = match[1].length
|
||||
const text = match[2].trim()
|
||||
headings.push({ level, text, pos: match.index })
|
||||
}
|
||||
|
||||
return headings
|
||||
}
|
||||
|
||||
// --- Component ---
|
||||
|
||||
interface OutlinePanelProps {
|
||||
headings: Heading[]
|
||||
onNavigate: (pos: number) => void
|
||||
activeHeadingIndex: number | null
|
||||
}
|
||||
|
||||
interface OutlineItemProps {
|
||||
heading: Heading
|
||||
isActive: boolean
|
||||
onNavigate: (pos: number) => void
|
||||
}
|
||||
|
||||
const OutlineItem = memo(function OutlineItem({
|
||||
heading,
|
||||
isActive,
|
||||
onNavigate
|
||||
}: OutlineItemProps) {
|
||||
return (
|
||||
<button
|
||||
className={`outline-item outline-level-${heading.level}${isActive ? ' active' : ''}`}
|
||||
onClick={() => onNavigate(heading.pos)}
|
||||
title={heading.text}
|
||||
aria-label={`跳转到标题:${heading.text}`}
|
||||
style={{ paddingLeft: `${8 + (heading.level - 1) * 12}px` }}
|
||||
>
|
||||
<span className="outline-level-dot" />
|
||||
<span className="outline-item-text">{heading.text}</span>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
|
||||
export const OutlinePanel = memo(function OutlinePanel({
|
||||
headings,
|
||||
onNavigate,
|
||||
activeHeadingIndex
|
||||
}: OutlinePanelProps) {
|
||||
if (headings.length === 0) {
|
||||
return (
|
||||
<div className="outline-panel" role="region" aria-label="文档大纲">
|
||||
<div className="outline-header">文档大纲</div>
|
||||
<div className="outline-empty">当前文档无标题</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="outline-panel" role="region" aria-label="文档大纲">
|
||||
<div className="outline-header">文档大纲</div>
|
||||
<div className="outline-list" role="list" aria-label="标题列表">
|
||||
{headings.map((h, i) => (
|
||||
<OutlineItem
|
||||
key={`${h.text}-${h.pos}`}
|
||||
heading={h}
|
||||
isActive={i === activeHeadingIndex}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
OutlinePanel.displayName = 'OutlinePanel'
|
||||
@@ -0,0 +1,2 @@
|
||||
export { OutlinePanel, parseHeadings } from './OutlinePanel'
|
||||
export type { Heading } from './OutlinePanel'
|
||||
@@ -1,102 +1,102 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useEditorStore } from '../../stores/editorStore'
|
||||
import { renderMarkdown } from '../../lib/markdown'
|
||||
import { LoadingSpinner } from '../LoadingSpinner/LoadingSpinner'
|
||||
|
||||
// PF-07: 防抖延迟常量
|
||||
const PREVIEW_DEBOUNCE_MS = 150
|
||||
|
||||
export const Preview = React.memo(function Preview() {
|
||||
const [html, setHtml] = useState('')
|
||||
const [rendering, setRendering] = useState(false)
|
||||
const previewRef = useRef<HTMLDivElement>(null)
|
||||
const requestIdRef = useRef(0)
|
||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const activeTab = useTabStore(s => s.getActiveTab())
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
const setLoading = useEditorStore(s => s.setLoading)
|
||||
|
||||
// PF-07: 响应式渲染 + 防抖
|
||||
useEffect(() => {
|
||||
if (!activeTab) {
|
||||
setHtml('')
|
||||
setRendering(false)
|
||||
setLoading('markdown-render', false)
|
||||
return
|
||||
}
|
||||
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current)
|
||||
}
|
||||
|
||||
setRendering(true)
|
||||
setLoading('markdown-render', true)
|
||||
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
const requestId = ++requestIdRef.current
|
||||
renderMarkdown(activeTab.content, activeTab.filePath).then((result: string) => {
|
||||
if (requestId === requestIdRef.current) {
|
||||
setHtml(result)
|
||||
setRendering(false)
|
||||
setLoading('markdown-render', false)
|
||||
}
|
||||
})
|
||||
}, PREVIEW_DEBOUNCE_MS)
|
||||
|
||||
return () => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current)
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- activeTab is a derived reference (from getActiveTab()), we track content/filePath via optional chaining
|
||||
}, [activeTabId, activeTab?.content, activeTab?.filePath, setLoading])
|
||||
|
||||
// 拦截链接点击
|
||||
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const link = (e.target as HTMLElement).closest('a')
|
||||
if (!link) return
|
||||
e.preventDefault()
|
||||
const href: string | null = link.getAttribute('href')
|
||||
if (!href) return
|
||||
if (href.startsWith('#')) {
|
||||
const target = previewRef.current?.querySelector(href)
|
||||
if (target) target.scrollIntoView({ behavior: 'smooth' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const url = new URL(href)
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (window.electronAPI?.openExternal) {
|
||||
window.electronAPI.openExternal(href)
|
||||
} else {
|
||||
window.open(href, '_blank', 'noopener')
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={previewRef}
|
||||
id="preview"
|
||||
className="markdown-body"
|
||||
role="region"
|
||||
aria-label="Markdown预览"
|
||||
aria-live="polite"
|
||||
aria-busy={rendering}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{rendering && html === '' && (
|
||||
<div className="preview-loading" aria-label="正在渲染Markdown">
|
||||
<LoadingSpinner size="medium" label="正在渲染..." />
|
||||
</div>
|
||||
)}
|
||||
<div dangerouslySetInnerHTML={{ __html: html }} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
Preview.displayName = 'Preview'
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useEditorStore } from '../../stores/editorStore'
|
||||
import { renderMarkdown } from '../../lib/markdown'
|
||||
import { LoadingSpinner } from '../LoadingSpinner/LoadingSpinner'
|
||||
|
||||
// PF-07: 防抖延迟常量
|
||||
const PREVIEW_DEBOUNCE_MS = 150
|
||||
|
||||
export const Preview = React.memo(function Preview() {
|
||||
const [html, setHtml] = useState('')
|
||||
const [rendering, setRendering] = useState(false)
|
||||
const previewRef = useRef<HTMLDivElement>(null)
|
||||
const requestIdRef = useRef(0)
|
||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const activeTab = useTabStore(s => s.getActiveTab())
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
const setLoading = useEditorStore(s => s.setLoading)
|
||||
|
||||
// PF-07: 响应式渲染 + 防抖
|
||||
useEffect(() => {
|
||||
if (!activeTab) {
|
||||
setHtml('')
|
||||
setRendering(false)
|
||||
setLoading('markdown-render', false)
|
||||
return
|
||||
}
|
||||
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current)
|
||||
}
|
||||
|
||||
setRendering(true)
|
||||
setLoading('markdown-render', true)
|
||||
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
const requestId = ++requestIdRef.current
|
||||
renderMarkdown(activeTab.content, activeTab.filePath).then((result: string) => {
|
||||
if (requestId === requestIdRef.current) {
|
||||
setHtml(result)
|
||||
setRendering(false)
|
||||
setLoading('markdown-render', false)
|
||||
}
|
||||
})
|
||||
}, PREVIEW_DEBOUNCE_MS)
|
||||
|
||||
return () => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current)
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- activeTab is a derived reference (from getActiveTab()), we track content/filePath via optional chaining
|
||||
}, [activeTabId, activeTab?.content, activeTab?.filePath, setLoading])
|
||||
|
||||
// 拦截链接点击
|
||||
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const link = (e.target as HTMLElement).closest('a')
|
||||
if (!link) return
|
||||
e.preventDefault()
|
||||
const href: string | null = link.getAttribute('href')
|
||||
if (!href) return
|
||||
if (href.startsWith('#')) {
|
||||
const target = previewRef.current?.querySelector(href)
|
||||
if (target) target.scrollIntoView({ behavior: 'smooth' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const url = new URL(href)
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (window.electronAPI?.openExternal) {
|
||||
window.electronAPI.openExternal(href)
|
||||
} else {
|
||||
window.open(href, '_blank', 'noopener')
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={previewRef}
|
||||
id="preview"
|
||||
className="markdown-body"
|
||||
role="region"
|
||||
aria-label="Markdown预览"
|
||||
aria-live="polite"
|
||||
aria-busy={rendering}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{rendering && html === '' && (
|
||||
<div className="preview-loading" aria-label="正在渲染Markdown">
|
||||
<LoadingSpinner size="medium" label="正在渲染..." />
|
||||
</div>
|
||||
)}
|
||||
<div dangerouslySetInnerHTML={{ __html: html }} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
Preview.displayName = 'Preview'
|
||||
|
||||
@@ -205,17 +205,21 @@ export const SearchReplace = memo(function SearchReplace({
|
||||
const view = getView()
|
||||
if (!view) return
|
||||
|
||||
const matches = matchesRef.current
|
||||
if (matches.length === 0) return
|
||||
// 重新搜索以获取匹配的最新位置
|
||||
const freshMatches = findMatches(view.state.doc, queryRef.current, caseSensitiveRef.current)
|
||||
if (freshMatches.length === 0) return
|
||||
|
||||
// Process matches in reverse order to preserve positions
|
||||
// 从后往前替换以保持位置正确
|
||||
const tr = view.state.tr
|
||||
for (let i = matches.length - 1; i >= 0; i--) {
|
||||
tr.insertText(replacement, matches[i].from, matches[i].to)
|
||||
for (let i = freshMatches.length - 1; i >= 0; i--) {
|
||||
tr.insertText(replacement, freshMatches[i].from, freshMatches[i].to)
|
||||
}
|
||||
view.dispatch(tr)
|
||||
|
||||
// Re-search after replacement
|
||||
// 更新ref
|
||||
matchesRef.current = []
|
||||
|
||||
// 替换后重新搜索
|
||||
requestAnimationFrame(() => {
|
||||
doSearch(queryRef.current, caseSensitiveRef.current)
|
||||
})
|
||||
|
||||
@@ -1,108 +1,139 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useSidebarStore } from '../../stores/sidebarStore'
|
||||
import { getFileName } from '../../lib/fileUtils'
|
||||
import { recentFilesRepository } from '../../db/recentFilesRepository'
|
||||
import { FolderPlus, File } from '../Icons'
|
||||
import { FileTree } from '../FileTree'
|
||||
import { useSidebarResize } from '../../hooks/useSidebarResize'
|
||||
import { useFolderOperations } from '../../hooks/useFolderOperations'
|
||||
import { useAutoExpandDir } from '../../hooks/useAutoExpandDir'
|
||||
|
||||
const norm = (p: string) => p.replace(/\\\\/g, '/')
|
||||
|
||||
export const Sidebar = React.memo(function Sidebar() {
|
||||
const tabs = useTabStore(s => s.tabs)
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
const activeTab = useTabStore(s => s.getActiveTab())
|
||||
const switchToTab = useTabStore(s => s.switchToTab)
|
||||
const createTab = useTabStore(s => s.createTab)
|
||||
const rootPath = useSidebarStore(s => s.rootPath)
|
||||
const tree = useSidebarStore(s => s.tree)
|
||||
const expandedDirs = useSidebarStore(s => s.expandedDirs)
|
||||
const toggleDir = useSidebarStore(s => s.toggleDir)
|
||||
const isVisible = useSidebarStore(s => s.isVisible)
|
||||
|
||||
const activeFilePath = activeTab?.filePath ?? null
|
||||
const { sidebarRef, startResize } = useSidebarResize()
|
||||
const { handleOpenFolder } = useFolderOperations()
|
||||
useAutoExpandDir(activeFilePath)
|
||||
|
||||
const handleFileClick = useCallback(async (path: string) => {
|
||||
const existing = tabs.find(t => t.filePath === path)
|
||||
if (existing) { switchToTab(existing.id); return }
|
||||
if (!window.electronAPI) return
|
||||
const result = await window.electronAPI.readFile(path)
|
||||
if (result.success && result.content) {
|
||||
createTab(path, result.content)
|
||||
recentFilesRepository.add(path)
|
||||
}
|
||||
}, [tabs, switchToTab, createTab])
|
||||
|
||||
const independentFiles = tabs.filter(t => {
|
||||
if (!t.filePath) return false
|
||||
if (!rootPath) return true
|
||||
return !norm(t.filePath).startsWith(norm(rootPath))
|
||||
})
|
||||
|
||||
if (!isVisible) return null
|
||||
|
||||
return (
|
||||
<aside id="sidebar" ref={sidebarRef} aria-label="文件资源管理器">
|
||||
<div id="sidebar-header">
|
||||
<span id="sidebar-title">资源管理器</span>
|
||||
<button
|
||||
className="sidebar-header-btn"
|
||||
onClick={handleOpenFolder}
|
||||
title="打开文件夹"
|
||||
aria-label="打开文件夹"
|
||||
>
|
||||
<FolderPlus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<nav id="sidebar-tree" role="tree" aria-label="文件树">
|
||||
{independentFiles.length > 0 && (
|
||||
<div className="independent-files-section" role="group" aria-label="已打开的文件">
|
||||
<div className="independent-files-header" id="independent-files-label">已打开的文件</div>
|
||||
{independentFiles.map(tab => (
|
||||
<div key={tab.id}
|
||||
className={`tree-item independent-file-item ${tab.id === activeTabId ? 'active' : ''}`}
|
||||
style={{ paddingLeft: '8px' }}
|
||||
role="treeitem"
|
||||
tabIndex={0}
|
||||
aria-selected={tab.id === activeTabId}
|
||||
aria-label={getFileName(tab.filePath!)}
|
||||
onClick={() => switchToTab(tab.id)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); switchToTab(tab.id) } }}
|
||||
>
|
||||
<span className="tree-icon"><File size={14} /></span>
|
||||
<span className="tree-name">{getFileName(tab.filePath!)}</span>
|
||||
{tab.isModified && <span className="independent-modified-dot" aria-label="已修改"> •</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{rootPath && (
|
||||
<>
|
||||
<div className="sidebar-section-header" id="folder-tree-label">文件夹目录树</div>
|
||||
<FileTree
|
||||
nodes={[{ name: rootPath.split(/[/\\\\]/).pop() || rootPath, path: rootPath, type: 'dir' as const, children: tree }]}
|
||||
depth={0} expandedDirs={expandedDirs} toggleDir={toggleDir}
|
||||
activeTabId={activeTabId} activeFilePath={activeFilePath} onFileClick={handleFileClick}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
<div
|
||||
className="sidebar-resize-handle"
|
||||
onMouseDown={startResize}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="调整侧边栏宽度"
|
||||
tabIndex={0}
|
||||
/>
|
||||
</aside>
|
||||
)
|
||||
})
|
||||
|
||||
Sidebar.displayName = 'Sidebar'
|
||||
import React, { useCallback, useMemo } from 'react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useSidebarStore } from '../../stores/sidebarStore'
|
||||
import { getFileName } from '../../lib/fileUtils'
|
||||
import { recentFilesRepository } from '../../db/recentFilesRepository'
|
||||
import { FolderPlus, File } from '../Icons'
|
||||
import { FileTree } from '../FileTree'
|
||||
import { useSidebarResize } from '../../hooks/useSidebarResize'
|
||||
import { useFolderOperations } from '../../hooks/useFolderOperations'
|
||||
import { useAutoExpandDir } from '../../hooks/useAutoExpandDir'
|
||||
import { OutlinePanel, parseHeadings } from '../OutlinePanel'
|
||||
import { getEditorView } from '../../stores/editorStore'
|
||||
import { TextSelection } from '@milkdown/prose/state'
|
||||
|
||||
const norm = (p: string) => p.replace(/\\/g, '/')
|
||||
|
||||
export const Sidebar = React.memo(function Sidebar() {
|
||||
const tabs = useTabStore(s => s.tabs)
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
const activeTab = useTabStore(s => s.getActiveTab())
|
||||
const switchToTab = useTabStore(s => s.switchToTab)
|
||||
const createTab = useTabStore(s => s.createTab)
|
||||
const rootPath = useSidebarStore(s => s.rootPath)
|
||||
const tree = useSidebarStore(s => s.tree)
|
||||
const expandedDirs = useSidebarStore(s => s.expandedDirs)
|
||||
const toggleDir = useSidebarStore(s => s.toggleDir)
|
||||
const isVisible = useSidebarStore(s => s.isVisible)
|
||||
|
||||
const activeFilePath = activeTab?.filePath ?? null
|
||||
const { sidebarRef, startResize } = useSidebarResize()
|
||||
const { handleOpenFolder } = useFolderOperations()
|
||||
useAutoExpandDir(activeFilePath)
|
||||
|
||||
// Parse headings from active tab content
|
||||
const headings = useMemo(() => {
|
||||
if (!activeTab?.content) return []
|
||||
return parseHeadings(activeTab.content)
|
||||
}, [activeTab?.content])
|
||||
|
||||
// Navigate to heading position in editor
|
||||
const handleHeadingNavigate = useCallback((pos: number) => {
|
||||
const view = getEditorView()
|
||||
if (!view) return
|
||||
try {
|
||||
const tr = view.state.tr.setSelection(
|
||||
TextSelection.create(view.state.doc, pos, pos)
|
||||
)
|
||||
view.dispatch(tr)
|
||||
view.focus()
|
||||
} catch {
|
||||
// Position may be invalid if content has changed
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleFileClick = useCallback(async (path: string) => {
|
||||
const existing = tabs.find(t => t.filePath === path)
|
||||
if (existing) { switchToTab(existing.id); return }
|
||||
if (!window.electronAPI) return
|
||||
const result = await window.electronAPI.readFile(path)
|
||||
if (result.success && result.content) {
|
||||
createTab(path, result.content)
|
||||
recentFilesRepository.add(path)
|
||||
}
|
||||
}, [tabs, switchToTab, createTab])
|
||||
|
||||
const independentFiles = tabs.filter(t => {
|
||||
if (!t.filePath) return false
|
||||
if (!rootPath) return true
|
||||
return !norm(t.filePath).startsWith(norm(rootPath))
|
||||
})
|
||||
|
||||
if (!isVisible) return null
|
||||
|
||||
return (
|
||||
<aside id="sidebar" ref={sidebarRef} aria-label="文件资源管理器">
|
||||
<div id="sidebar-header">
|
||||
<span id="sidebar-title">资源管理器</span>
|
||||
<button
|
||||
className="sidebar-header-btn"
|
||||
onClick={handleOpenFolder}
|
||||
title="打开文件夹"
|
||||
aria-label="打开文件夹"
|
||||
>
|
||||
<FolderPlus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<nav id="sidebar-tree" role="tree" aria-label="文件树">
|
||||
{independentFiles.length > 0 && (
|
||||
<div className="independent-files-section" role="group" aria-label="已打开的文件">
|
||||
<div className="independent-files-header" id="independent-files-label">已打开的文件</div>
|
||||
{independentFiles.map(tab => (
|
||||
<div key={tab.id}
|
||||
className={`tree-item independent-file-item ${tab.id === activeTabId ? 'active' : ''}`}
|
||||
style={{ paddingLeft: '8px' }}
|
||||
role="treeitem"
|
||||
tabIndex={0}
|
||||
aria-selected={tab.id === activeTabId}
|
||||
aria-label={getFileName(tab.filePath!)}
|
||||
onClick={() => switchToTab(tab.id)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); switchToTab(tab.id) } }}
|
||||
>
|
||||
<span className="tree-icon"><File size={14} /></span>
|
||||
<span className="tree-name">{getFileName(tab.filePath!)}</span>
|
||||
{tab.isModified && <span className="independent-modified-dot" aria-label="已修改"> •</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{rootPath && (
|
||||
<>
|
||||
<div className="sidebar-section-header" id="folder-tree-label">文件夹目录树</div>
|
||||
<FileTree
|
||||
nodes={[{ name: rootPath.split(/[/\\]/).pop() || rootPath, path: rootPath, type: 'dir' as const, children: tree }]}
|
||||
depth={0} expandedDirs={expandedDirs} toggleDir={toggleDir}
|
||||
activeTabId={activeTabId} activeFilePath={activeFilePath} onFileClick={handleFileClick}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
<div className="sidebar-outline-section">
|
||||
<OutlinePanel
|
||||
headings={headings}
|
||||
onNavigate={handleHeadingNavigate}
|
||||
activeHeadingIndex={null}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="sidebar-resize-handle"
|
||||
onMouseDown={startResize}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="调整侧边栏宽度"
|
||||
tabIndex={0}
|
||||
/>
|
||||
</aside>
|
||||
)
|
||||
})
|
||||
|
||||
Sidebar.displayName = 'Sidebar'
|
||||
|
||||
@@ -1,253 +1,253 @@
|
||||
import React, { useCallback, useState, useEffect, useRef } from 'react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useConfirm } from '../../hooks/useConfirm'
|
||||
import { getFileName } from '../../lib/fileUtils'
|
||||
import { Close, Plus } from '../Icons'
|
||||
import { ConfirmDialog } from '../ConfirmDialog/ConfirmDialog'
|
||||
|
||||
interface ContextMenuState {
|
||||
visible: boolean
|
||||
x: number
|
||||
y: number
|
||||
tabId: string
|
||||
}
|
||||
|
||||
export const TabBar = React.memo(function TabBar() {
|
||||
const tabs = useTabStore(s => s.tabs)
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
const switchToTab = useTabStore(s => s.switchToTab)
|
||||
const closeTab = useTabStore(s => s.closeTab)
|
||||
const createTab = useTabStore(s => s.createTab)
|
||||
const closeOtherTabs = useTabStore(s => s.closeOtherTabs)
|
||||
const closeAllTabs = useTabStore(s => s.closeAllTabs)
|
||||
const closeTabsToRight = useTabStore(s => s.closeTabsToRight)
|
||||
|
||||
const tabListRef = useRef<HTMLDivElement>(null)
|
||||
const [menu, setMenu] = useState<ContextMenuState>({ visible: false, x: 0, y: 0, tabId: '' })
|
||||
const { confirm, confirmDialogProps } = useConfirm()
|
||||
|
||||
// 滚动到活动标签
|
||||
const scrollToActiveTab = useCallback(() => {
|
||||
const tabList = tabListRef.current
|
||||
if (!tabList) return
|
||||
const activeTab = tabList.querySelector('.tab-item.active') as HTMLElement
|
||||
if (!activeTab) return
|
||||
|
||||
const listRect = tabList.getBoundingClientRect()
|
||||
const tabRect = activeTab.getBoundingClientRect()
|
||||
|
||||
// 如果标签在可视区域左侧之外
|
||||
if (tabRect.left < listRect.left) {
|
||||
tabList.scrollLeft -= (listRect.left - tabRect.left + 20)
|
||||
}
|
||||
// 如果标签在可视区域右侧之外
|
||||
else if (tabRect.right > listRect.right) {
|
||||
tabList.scrollLeft += (tabRect.right - listRect.right + 20)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 自动滚动到活动标签
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(scrollToActiveTab)
|
||||
}, [activeTabId, scrollToActiveTab])
|
||||
|
||||
// 支持鼠标滚轮水平滚动标签栏
|
||||
useEffect(() => {
|
||||
const tabList = tabListRef.current
|
||||
if (!tabList) return
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
// 检查是否有水平溢出
|
||||
if (tabList.scrollWidth <= tabList.clientWidth) return
|
||||
|
||||
// 阻止默认滚动
|
||||
e.preventDefault()
|
||||
|
||||
// 计算滚动量:支持触控板 deltaX 和鼠标滚轮 deltaY
|
||||
const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY
|
||||
tabList.scrollLeft += delta
|
||||
}
|
||||
|
||||
// 直接绑定到 tabList,使用 passive: false 允许 preventDefault
|
||||
tabList.addEventListener('wheel', handleWheel, { passive: false })
|
||||
return () => tabList.removeEventListener('wheel', handleWheel)
|
||||
}, [])
|
||||
|
||||
const handleClose = useCallback(async (e: React.MouseEvent, tabId: string) => {
|
||||
e.stopPropagation()
|
||||
const tab = tabs.find(t => t.id === tabId)
|
||||
if (tab?.isModified) {
|
||||
const name = tab.filePath ? getFileName(tab.filePath) : '未命名'
|
||||
const confirmed = await confirm({
|
||||
title: '关闭标签',
|
||||
message: `"${name}" 尚未保存,确定要关闭吗?`,
|
||||
variant: 'warning',
|
||||
confirmLabel: '关闭'
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
closeTab(tabId)
|
||||
}, [tabs, closeTab, confirm])
|
||||
|
||||
// C-06: 右键菜单(带边界修正)
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent, tabId: string) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const MENU_WIDTH = 170
|
||||
const MENU_HEIGHT = 140
|
||||
const x = Math.min(e.clientX, window.innerWidth - MENU_WIDTH)
|
||||
const y = Math.min(e.clientY, window.innerHeight - MENU_HEIGHT)
|
||||
setMenu({ visible: true, x: Math.max(0, x), y: Math.max(0, y), tabId })
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!menu.visible) return
|
||||
const handleClick = () => setMenu(prev => ({ ...prev, visible: false }))
|
||||
document.addEventListener('click', handleClick)
|
||||
return () => document.removeEventListener('click', handleClick)
|
||||
}, [menu.visible])
|
||||
|
||||
const handleMenuClose = useCallback(async () => {
|
||||
const tab = tabs.find(t => t.id === menu.tabId)
|
||||
if (tab?.isModified) {
|
||||
const name = tab.filePath ? getFileName(tab.filePath) : '未命名'
|
||||
const confirmed = await confirm({
|
||||
title: '关闭标签',
|
||||
message: `"${name}" 尚未保存,确定要关闭吗?`,
|
||||
variant: 'warning',
|
||||
confirmLabel: '关闭'
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
closeTab(menu.tabId)
|
||||
setMenu(prev => ({ ...prev, visible: false }))
|
||||
}, [tabs, menu.tabId, closeTab, confirm])
|
||||
|
||||
const handleMenuCloseOthers = useCallback(async () => {
|
||||
const otherModified = tabs.filter(t => t.id !== menu.tabId && t.isModified)
|
||||
if (otherModified.length > 0) {
|
||||
const names = otherModified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、')
|
||||
const confirmed = await confirm({
|
||||
title: '关闭其他标签',
|
||||
message: `以下文件尚未保存:${names},确定要关闭吗?`,
|
||||
variant: 'warning',
|
||||
confirmLabel: '关闭'
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
closeOtherTabs(menu.tabId)
|
||||
setMenu(prev => ({ ...prev, visible: false }))
|
||||
}, [tabs, menu.tabId, closeOtherTabs, confirm])
|
||||
|
||||
const handleMenuCloseAll = useCallback(async () => {
|
||||
const modified = tabs.filter(t => t.isModified)
|
||||
if (modified.length > 0) {
|
||||
const names = modified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、')
|
||||
const confirmed = await confirm({
|
||||
title: '关闭全部标签',
|
||||
message: `以下文件尚未保存:${names},确定要关闭吗?`,
|
||||
variant: 'warning',
|
||||
confirmLabel: '关闭'
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
closeAllTabs()
|
||||
setMenu(prev => ({ ...prev, visible: false }))
|
||||
}, [tabs, closeAllTabs, confirm])
|
||||
|
||||
const handleMenuCloseRight = useCallback(async () => {
|
||||
const index = tabs.findIndex(t => t.id === menu.tabId)
|
||||
const rightTabs = tabs.slice(index + 1)
|
||||
const modified = rightTabs.filter(t => t.isModified)
|
||||
if (modified.length > 0) {
|
||||
const names = modified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、')
|
||||
const confirmed = await confirm({
|
||||
title: '关闭右侧标签',
|
||||
message: `以下文件尚未保存:${names},确定要关闭吗?`,
|
||||
variant: 'warning',
|
||||
confirmLabel: '关闭'
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
closeTabsToRight(menu.tabId)
|
||||
setMenu(prev => ({ ...prev, visible: false }))
|
||||
}, [tabs, menu.tabId, closeTabsToRight, confirm])
|
||||
|
||||
const hasRightTabs = menu.visible && (() => {
|
||||
const index = tabs.findIndex(t => t.id === menu.tabId)
|
||||
return index < tabs.length - 1
|
||||
})()
|
||||
|
||||
if (tabs.length === 0) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div id="tab-bar">
|
||||
<div id="tab-list" ref={tabListRef} role="tablist" aria-label="标签页">
|
||||
{tabs.map(tab => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`tab-item ${tab.id === activeTabId ? 'active' : ''} ${tab.isModified ? 'modified' : ''}`}
|
||||
role="tab"
|
||||
aria-selected={tab.id === activeTabId}
|
||||
tabIndex={tab.id === activeTabId ? 0 : -1}
|
||||
onClick={() => switchToTab(tab.id)}
|
||||
onContextMenu={(e) => handleContextMenu(e, tab.id)}
|
||||
>
|
||||
<span className="tab-name">
|
||||
{tab.filePath ? getFileName(tab.filePath) : '未命名'}
|
||||
</span>
|
||||
<button
|
||||
className="tab-close"
|
||||
onClick={(e) => handleClose(e, tab.id)}
|
||||
aria-label={`关闭 ${tab.filePath ? getFileName(tab.filePath) : '未命名'}`}
|
||||
>
|
||||
<Close size={10} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="tab-add-btn"
|
||||
onClick={() => createTab(null, '')}
|
||||
title="新建标签页 (Ctrl+T)"
|
||||
aria-label="新建标签页"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
|
||||
{/* 右键菜单 */}
|
||||
{menu.visible && (
|
||||
<div
|
||||
className="tab-context-menu"
|
||||
style={{ left: menu.x, top: menu.y }}
|
||||
role="menu"
|
||||
aria-label="标签操作"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="tab-context-item" role="menuitem" onClick={handleMenuClose}>
|
||||
关闭
|
||||
</div>
|
||||
{tabs.length > 1 && (
|
||||
<div className="tab-context-item" role="menuitem" onClick={handleMenuCloseOthers}>
|
||||
关闭其他标签
|
||||
</div>
|
||||
)}
|
||||
{hasRightTabs && (
|
||||
<div className="tab-context-item" role="menuitem" onClick={handleMenuCloseRight}>
|
||||
关闭右侧标签
|
||||
</div>
|
||||
)}
|
||||
<div className="tab-context-divider" role="separator" />
|
||||
<div className="tab-context-item" role="menuitem" onClick={handleMenuCloseAll}>
|
||||
关闭全部标签
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
TabBar.displayName = 'TabBar'
|
||||
import React, { useCallback, useState, useEffect, useRef } from 'react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useConfirm } from '../../hooks/useConfirm'
|
||||
import { getFileName } from '../../lib/fileUtils'
|
||||
import { Close, Plus } from '../Icons'
|
||||
import { ConfirmDialog } from '../ConfirmDialog/ConfirmDialog'
|
||||
|
||||
interface ContextMenuState {
|
||||
visible: boolean
|
||||
x: number
|
||||
y: number
|
||||
tabId: string
|
||||
}
|
||||
|
||||
export const TabBar = React.memo(function TabBar() {
|
||||
const tabs = useTabStore(s => s.tabs)
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
const switchToTab = useTabStore(s => s.switchToTab)
|
||||
const closeTab = useTabStore(s => s.closeTab)
|
||||
const createTab = useTabStore(s => s.createTab)
|
||||
const closeOtherTabs = useTabStore(s => s.closeOtherTabs)
|
||||
const closeAllTabs = useTabStore(s => s.closeAllTabs)
|
||||
const closeTabsToRight = useTabStore(s => s.closeTabsToRight)
|
||||
|
||||
const tabListRef = useRef<HTMLDivElement>(null)
|
||||
const [menu, setMenu] = useState<ContextMenuState>({ visible: false, x: 0, y: 0, tabId: '' })
|
||||
const { confirm, confirmDialogProps } = useConfirm()
|
||||
|
||||
// 滚动到活动标签
|
||||
const scrollToActiveTab = useCallback(() => {
|
||||
const tabList = tabListRef.current
|
||||
if (!tabList) return
|
||||
const activeTab = tabList.querySelector('.tab-item.active') as HTMLElement
|
||||
if (!activeTab) return
|
||||
|
||||
const listRect = tabList.getBoundingClientRect()
|
||||
const tabRect = activeTab.getBoundingClientRect()
|
||||
|
||||
// 如果标签在可视区域左侧之外
|
||||
if (tabRect.left < listRect.left) {
|
||||
tabList.scrollLeft -= (listRect.left - tabRect.left + 20)
|
||||
}
|
||||
// 如果标签在可视区域右侧之外
|
||||
else if (tabRect.right > listRect.right) {
|
||||
tabList.scrollLeft += (tabRect.right - listRect.right + 20)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 自动滚动到活动标签
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(scrollToActiveTab)
|
||||
}, [activeTabId, scrollToActiveTab])
|
||||
|
||||
// 支持鼠标滚轮水平滚动标签栏
|
||||
useEffect(() => {
|
||||
const tabList = tabListRef.current
|
||||
if (!tabList) return
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
// 检查是否有水平溢出
|
||||
if (tabList.scrollWidth <= tabList.clientWidth) return
|
||||
|
||||
// 阻止默认滚动
|
||||
e.preventDefault()
|
||||
|
||||
// 计算滚动量:支持触控板 deltaX 和鼠标滚轮 deltaY
|
||||
const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY
|
||||
tabList.scrollLeft += delta
|
||||
}
|
||||
|
||||
// 直接绑定到 tabList,使用 passive: false 允许 preventDefault
|
||||
tabList.addEventListener('wheel', handleWheel, { passive: false })
|
||||
return () => tabList.removeEventListener('wheel', handleWheel)
|
||||
}, [])
|
||||
|
||||
const handleClose = useCallback(async (e: React.MouseEvent, tabId: string) => {
|
||||
e.stopPropagation()
|
||||
const tab = tabs.find(t => t.id === tabId)
|
||||
if (tab?.isModified) {
|
||||
const name = tab.filePath ? getFileName(tab.filePath) : '未命名'
|
||||
const confirmed = await confirm({
|
||||
title: '关闭标签',
|
||||
message: `"${name}" 尚未保存,确定要关闭吗?`,
|
||||
variant: 'warning',
|
||||
confirmLabel: '关闭'
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
closeTab(tabId)
|
||||
}, [tabs, closeTab, confirm])
|
||||
|
||||
// C-06: 右键菜单(带边界修正)
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent, tabId: string) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const MENU_WIDTH = 170
|
||||
const MENU_HEIGHT = 140
|
||||
const x = Math.min(e.clientX, window.innerWidth - MENU_WIDTH)
|
||||
const y = Math.min(e.clientY, window.innerHeight - MENU_HEIGHT)
|
||||
setMenu({ visible: true, x: Math.max(0, x), y: Math.max(0, y), tabId })
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!menu.visible) return
|
||||
const handleClick = () => setMenu(prev => ({ ...prev, visible: false }))
|
||||
document.addEventListener('click', handleClick)
|
||||
return () => document.removeEventListener('click', handleClick)
|
||||
}, [menu.visible])
|
||||
|
||||
const handleMenuClose = useCallback(async () => {
|
||||
const tab = tabs.find(t => t.id === menu.tabId)
|
||||
if (tab?.isModified) {
|
||||
const name = tab.filePath ? getFileName(tab.filePath) : '未命名'
|
||||
const confirmed = await confirm({
|
||||
title: '关闭标签',
|
||||
message: `"${name}" 尚未保存,确定要关闭吗?`,
|
||||
variant: 'warning',
|
||||
confirmLabel: '关闭'
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
closeTab(menu.tabId)
|
||||
setMenu(prev => ({ ...prev, visible: false }))
|
||||
}, [tabs, menu.tabId, closeTab, confirm])
|
||||
|
||||
const handleMenuCloseOthers = useCallback(async () => {
|
||||
const otherModified = tabs.filter(t => t.id !== menu.tabId && t.isModified)
|
||||
if (otherModified.length > 0) {
|
||||
const names = otherModified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、')
|
||||
const confirmed = await confirm({
|
||||
title: '关闭其他标签',
|
||||
message: `以下文件尚未保存:${names},确定要关闭吗?`,
|
||||
variant: 'warning',
|
||||
confirmLabel: '关闭'
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
closeOtherTabs(menu.tabId)
|
||||
setMenu(prev => ({ ...prev, visible: false }))
|
||||
}, [tabs, menu.tabId, closeOtherTabs, confirm])
|
||||
|
||||
const handleMenuCloseAll = useCallback(async () => {
|
||||
const modified = tabs.filter(t => t.isModified)
|
||||
if (modified.length > 0) {
|
||||
const names = modified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、')
|
||||
const confirmed = await confirm({
|
||||
title: '关闭全部标签',
|
||||
message: `以下文件尚未保存:${names},确定要关闭吗?`,
|
||||
variant: 'warning',
|
||||
confirmLabel: '关闭'
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
closeAllTabs()
|
||||
setMenu(prev => ({ ...prev, visible: false }))
|
||||
}, [tabs, closeAllTabs, confirm])
|
||||
|
||||
const handleMenuCloseRight = useCallback(async () => {
|
||||
const index = tabs.findIndex(t => t.id === menu.tabId)
|
||||
const rightTabs = tabs.slice(index + 1)
|
||||
const modified = rightTabs.filter(t => t.isModified)
|
||||
if (modified.length > 0) {
|
||||
const names = modified.map(t => t.filePath ? getFileName(t.filePath) : '未命名').join('、')
|
||||
const confirmed = await confirm({
|
||||
title: '关闭右侧标签',
|
||||
message: `以下文件尚未保存:${names},确定要关闭吗?`,
|
||||
variant: 'warning',
|
||||
confirmLabel: '关闭'
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
closeTabsToRight(menu.tabId)
|
||||
setMenu(prev => ({ ...prev, visible: false }))
|
||||
}, [tabs, menu.tabId, closeTabsToRight, confirm])
|
||||
|
||||
const hasRightTabs = menu.visible && (() => {
|
||||
const index = tabs.findIndex(t => t.id === menu.tabId)
|
||||
return index < tabs.length - 1
|
||||
})()
|
||||
|
||||
if (tabs.length === 0) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div id="tab-bar">
|
||||
<div id="tab-list" ref={tabListRef} role="tablist" aria-label="标签页">
|
||||
{tabs.map(tab => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`tab-item ${tab.id === activeTabId ? 'active' : ''} ${tab.isModified ? 'modified' : ''}`}
|
||||
role="tab"
|
||||
aria-selected={tab.id === activeTabId}
|
||||
tabIndex={tab.id === activeTabId ? 0 : -1}
|
||||
onClick={() => switchToTab(tab.id)}
|
||||
onContextMenu={(e) => handleContextMenu(e, tab.id)}
|
||||
>
|
||||
<span className="tab-name">
|
||||
{tab.filePath ? getFileName(tab.filePath) : '未命名'}
|
||||
</span>
|
||||
<button
|
||||
className="tab-close"
|
||||
onClick={(e) => handleClose(e, tab.id)}
|
||||
aria-label={`关闭 ${tab.filePath ? getFileName(tab.filePath) : '未命名'}`}
|
||||
>
|
||||
<Close size={10} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="tab-add-btn"
|
||||
onClick={() => createTab(null, '')}
|
||||
title="新建标签页 (Ctrl+T)"
|
||||
aria-label="新建标签页"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
|
||||
{/* 右键菜单 */}
|
||||
{menu.visible && (
|
||||
<div
|
||||
className="tab-context-menu"
|
||||
style={{ left: menu.x, top: menu.y }}
|
||||
role="menu"
|
||||
aria-label="标签操作"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="tab-context-item" role="menuitem" onClick={handleMenuClose}>
|
||||
关闭
|
||||
</div>
|
||||
{tabs.length > 1 && (
|
||||
<div className="tab-context-item" role="menuitem" onClick={handleMenuCloseOthers}>
|
||||
关闭其他标签
|
||||
</div>
|
||||
)}
|
||||
{hasRightTabs && (
|
||||
<div className="tab-context-item" role="menuitem" onClick={handleMenuCloseRight}>
|
||||
关闭右侧标签
|
||||
</div>
|
||||
)}
|
||||
<div className="tab-context-divider" role="separator" />
|
||||
<div className="tab-context-item" role="menuitem" onClick={handleMenuCloseAll}>
|
||||
关闭全部标签
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
TabBar.displayName = 'TabBar'
|
||||
|
||||
Reference in New Issue
Block a user