audit(docs/29): 全项目审计——修分片载入卸载截断高危、Sidebar memo 击穿与 Rust 资源/搜索/恢复上限等 20 余处
三线并行审计(编辑器核心/Rust 后端/服务与组件)+ App 编排层自审。 高危:分片载入未收尾时窗格卸载回写部分序列化,干净标签会被静默 截断——cleanup 补 muted 守卫,create 完成回调补取消守卫。 性能:gitSync/outline/收起回调稳定化保住 Sidebar memo;光标行列 rAF 合并;图片显示解析加缓存;导出图片并发解析;chunkLoader 偏移前缀表;围栏切点只认顶格(列表内嵌围栏等价回归实测)。 Rust:资源命令 async 化 + 32MB 上限;搜索 junction 越界校验; recovery/workspace 条数与单条上限;启动坏库改名重建 + WAL; validate 补 ext:: 黑名单与 ref 形态;init 先种 .gitignore (含 .*.tmp-* 防崩溃残留被提交);游离 HEAD 不再猜分支。 验证:vitest 122+2、tsc、构建通过;Rust 过 rustfmt 解析、 validate 纯函数抽出实跑断言、命令对账 28=28。
This commit is contained in:
71
src/App.tsx
71
src/App.tsx
@@ -73,6 +73,12 @@ function toDiskContent(content: string, lineEnding: string): string {
|
||||
return lineEnding === 'CRLF' ? content.replace(/\n/g, '\r\n') : content
|
||||
}
|
||||
|
||||
/** 编辑区图片显示解析缓存:(文档路径|src) → data URL Promise,上限 60 条 FIFO。 */
|
||||
const imageDisplayCache = new Map<string, Promise<string | null>>()
|
||||
|
||||
/** 非大纲模式下传给 Sidebar 的稳定空大纲(保住 memo,避免正文变化连带整栏重渲染)。 */
|
||||
const EMPTY_OUTLINE: never[] = []
|
||||
|
||||
export default function App() {
|
||||
const [state, dispatch] = useReducer(tabsReducer, undefined, (): TabsState => {
|
||||
const welcome = createTab({ title: '欢迎', content: WELCOME_MD })
|
||||
@@ -734,9 +740,13 @@ export default function App() {
|
||||
// ——— 阶段 4:外部修改检测(4 秒轮询 stat) ———
|
||||
const conflictRef = useRef<string | null>(null)
|
||||
conflictRef.current = conflict?.tabId ?? null
|
||||
const externalCheckBusyRef = useRef(false)
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
if (externalCheckBusyRef.current) return
|
||||
externalCheckBusyRef.current = true
|
||||
void (async () => {
|
||||
try {
|
||||
for (const t of stateRef.current.tabs) {
|
||||
if (!t.path) continue
|
||||
const base = baselinesRef.current.get(t.id)
|
||||
@@ -756,6 +766,9 @@ export default function App() {
|
||||
setConflict({ tabId: t.id })
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
externalCheckBusyRef.current = false
|
||||
}
|
||||
})()
|
||||
}, 4000)
|
||||
return () => window.clearInterval(timer)
|
||||
@@ -1184,6 +1197,10 @@ export default function App() {
|
||||
|
||||
/** 编辑区图片显示解析:相对/本地路径按文档目录归一化后读成 data URL(仅显示层)。 */
|
||||
const resolveDisplayImage = useCallback(async (docPath: string, src: string): Promise<string | null> => {
|
||||
const cacheKey = `${docPath}|${src}`
|
||||
const cached = imageDisplayCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
const promise = (async (): Promise<string | null> => {
|
||||
try {
|
||||
if (/^(https?:|data:|blob:)/i.test(src)) return null
|
||||
let decoded = src
|
||||
@@ -1207,6 +1224,13 @@ export default function App() {
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})()
|
||||
imageDisplayCache.set(cacheKey, promise)
|
||||
if (imageDisplayCache.size > 60) {
|
||||
const oldest = imageDisplayCache.keys().next().value
|
||||
if (oldest) imageDisplayCache.delete(oldest)
|
||||
}
|
||||
return promise
|
||||
}, [])
|
||||
|
||||
const buildExportHtml = useCallback(async (
|
||||
@@ -1464,6 +1488,29 @@ export default function App() {
|
||||
? state.tabs.filter((t) => pendingClose.ids.includes(t.id) && isDirty(t)).map((t) => t.title).join('、')
|
||||
: ''
|
||||
|
||||
// Sidebar 是 memo 组件:gitSync 对象与收起回调必须保持引用稳定,
|
||||
// 否则每次击键的 App 重渲染都会连带整棵文件树重渲染(docs/29)
|
||||
const collapseSidebar = useCallback(() => setUi((u) => ({ ...u, sidebar: false })), [])
|
||||
const gitSyncUi = useMemo(() => (tauriMode && workspaceRoot ? {
|
||||
isRepo: Boolean(gitInfo?.isRepo),
|
||||
label: gitStatusLabel(gitInfo, gitSyncing),
|
||||
syncing: gitSyncing,
|
||||
lastMessage: gitMsg?.text ?? null,
|
||||
lastIsError: gitMsg?.isError ?? false,
|
||||
onSyncNow: () => { void runGitSync(false) },
|
||||
onOpenSettings: () => {
|
||||
// 仓库状态还没读出来时不开弹窗:否则会把已有仓库误判成
|
||||
// 未初始化(远程地址显示为空、保存走 init 流程报错)
|
||||
if (!gitInfo) {
|
||||
setGitMsg({ text: '正在读取仓库状态,请稍候再打开设置', isError: false })
|
||||
if (workspaceRoot) void refreshGitStatus(workspaceRoot)
|
||||
return
|
||||
}
|
||||
setGitSettingsError(null)
|
||||
setGitSettingsOpen(true)
|
||||
},
|
||||
} : undefined), [tauriMode, workspaceRoot, gitInfo, gitSyncing, gitMsg, runGitSync, refreshGitStatus])
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<TitleBar docTitle={tab?.title ?? 'new-markdown'} dirty={tab ? isDirty(tab) : false} />
|
||||
@@ -1488,31 +1535,13 @@ export default function App() {
|
||||
onSaveAsEntry={saveAsEntry}
|
||||
onExportEntry={exportEntry}
|
||||
onPrintEntry={printEntry}
|
||||
onCollapseSidebar={() => setUi((u) => ({ ...u, sidebar: false }))}
|
||||
onCollapseSidebar={collapseSidebar}
|
||||
refreshSignal={treeRefresh}
|
||||
outline={outline}
|
||||
outline={sidebarMode === 'outline' ? outline : EMPTY_OUTLINE}
|
||||
onJumpHeading={jumpHeading}
|
||||
onOpenSearchResult={openSearchResult}
|
||||
tauriMode={tauriMode}
|
||||
gitSync={tauriMode && workspaceRoot ? {
|
||||
isRepo: Boolean(gitInfo?.isRepo),
|
||||
label: gitStatusLabel(gitInfo, gitSyncing),
|
||||
syncing: gitSyncing,
|
||||
lastMessage: gitMsg?.text ?? null,
|
||||
lastIsError: gitMsg?.isError ?? false,
|
||||
onSyncNow: () => { void runGitSync(false) },
|
||||
onOpenSettings: () => {
|
||||
// 仓库状态还没读出来时不开弹窗:否则会把已有仓库误判成
|
||||
// 未初始化(远程地址显示为空、保存走 init 流程报错)
|
||||
if (!gitInfo) {
|
||||
setGitMsg({ text: '正在读取仓库状态,请稍候再打开设置', isError: false })
|
||||
if (workspaceRoot) void refreshGitStatus(workspaceRoot)
|
||||
return
|
||||
}
|
||||
setGitSettingsError(null)
|
||||
setGitSettingsOpen(true)
|
||||
},
|
||||
} : undefined}
|
||||
gitSync={gitSyncUi}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// 自绘下拉(替代原生 select:原生展开层是系统样式,与应用风格脱节)。
|
||||
// 外观对齐 .input;展开层沿用应用菜单语言(面板底、6px 圆角、悬停 --hover、
|
||||
// 选中 --accent-weak);点别处/Esc 关闭走统一的 useDismiss;支持上下键选行。
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useDismiss } from '../hooks/useDismiss'
|
||||
|
||||
export interface DropdownOption { value: string; label: string }
|
||||
@@ -16,7 +16,8 @@ export function Dropdown({ value, options, onChange, disabled, ariaLabel }: {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
useDismiss(ref, open, () => setOpen(false))
|
||||
const close = useCallback(() => setOpen(false), [])
|
||||
useDismiss(ref, open, close)
|
||||
|
||||
const current = options.find((o) => o.value === value) ?? options[0]
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useRef } from 'react'
|
||||
import { useDismiss } from '../hooks/useDismiss'
|
||||
import { clampMenuPosition } from '../utils/menuPos'
|
||||
|
||||
export type EditorMenuAction =
|
||||
| 'cut' | 'copy' | 'paste' | 'selectAll'
|
||||
@@ -26,9 +27,7 @@ export default function EditorContextMenu({ x, y, hasSelection, sourceMode, onAc
|
||||
useDismiss(ref, true, onClose)
|
||||
const act = (a: EditorMenuAction) => { onClose(); onAction(a) }
|
||||
// 位置夹取,避免菜单溢出窗口右/下边(尺寸为估算值:宽约 190,高随模式)
|
||||
const left = Math.max(4, Math.min(x, window.innerWidth - 196))
|
||||
const estHeight = sourceMode ? 236 : 388
|
||||
const top = Math.max(4, Math.min(y, window.innerHeight - estHeight - 8))
|
||||
const { left, top } = clampMenuPosition(x, y, 188, sourceMode ? 236 : 388)
|
||||
return (
|
||||
<div
|
||||
className="tree-context-menu editor-context-menu"
|
||||
|
||||
@@ -70,7 +70,8 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal
|
||||
if (!wrap || !el) { setTableBar(null); return }
|
||||
const wr = wrap.getBoundingClientRect()
|
||||
const tr = el.getBoundingClientRect()
|
||||
setTableBar({ top: Math.max(0, tr.top - wr.top - 38), left: Math.max(0, tr.left - wr.left) })
|
||||
const next = { top: Math.max(0, tr.top - wr.top - 38), left: Math.max(0, tr.left - wr.left) }
|
||||
setTableBar((cur) => (cur && cur.top === next.top && cur.left === next.left ? cur : next))
|
||||
}, [])
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const initialRef = useRef(initialMarkdown)
|
||||
@@ -97,7 +98,7 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal
|
||||
const lineCenter = (coords.top + coords.bottom) / 2 - rect.top + container.scrollTop
|
||||
const target = lineCenter - container.clientHeight / 2
|
||||
if (Math.abs(target - container.scrollTop) > 6) {
|
||||
container.scrollTo({ top: target, behavior: 'smooth' })
|
||||
container.scrollTo({ top: target, behavior: 'auto' })
|
||||
}
|
||||
})
|
||||
} catch { /* 视图未就绪时跳过 */ }
|
||||
@@ -232,6 +233,9 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal
|
||||
}
|
||||
editor.create()
|
||||
.then(() => {
|
||||
// 已卸载(关标签/保活淘汰/epoch 重挂)时不再注册 API:cleanup 已
|
||||
// 注销并 destroy,这里若继续会把已销毁编辑器的 API 注册回 App
|
||||
if (cancelled) { void editor.destroy().catch(() => undefined); return }
|
||||
setReady(true)
|
||||
setFocusModeEnabled(modesRef.current.focusMode)
|
||||
try {
|
||||
@@ -267,7 +271,11 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal
|
||||
})
|
||||
.catch((e) => console.error('编辑器创建失败', e))
|
||||
const scrollParent = wrapRef.current?.closest('.editor-scroll')
|
||||
const onScroll = () => { if (editorObjRef.current) updateTableBar() }
|
||||
let scrollRaf = 0
|
||||
const onScroll = () => {
|
||||
if (!editorObjRef.current || scrollRaf) return
|
||||
scrollRaf = requestAnimationFrame(() => { scrollRaf = 0; updateTableBar() })
|
||||
}
|
||||
scrollParent?.addEventListener('scroll', onScroll, { passive: true })
|
||||
return () => {
|
||||
cancelled = true
|
||||
@@ -275,9 +283,15 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal
|
||||
editorObjRef.current = null
|
||||
setTableBar(null)
|
||||
try {
|
||||
const finalMarkdown = getMarkdown(editor)
|
||||
if (callbacksRef.current.onFinalMarkdown) callbacksRef.current.onFinalMarkdown(tabId, finalMarkdown)
|
||||
else callbacksRef.current.onMarkdown(tabId, finalMarkdown)
|
||||
// 分片载入未收尾(muted)时编辑器里只有部分文档:跳过最终回写。
|
||||
// 状态里存的是磁盘完整原文,回写部分序列化会把文档静默截断
|
||||
// (syncEditorContent 对干净标签会把 savedContent 一起对齐)。
|
||||
// 载入失败路径已主动解除 muted 并如实回写,不受此守卫影响。
|
||||
if (!muted) {
|
||||
const finalMarkdown = getMarkdown(editor)
|
||||
if (callbacksRef.current.onFinalMarkdown) callbacksRef.current.onFinalMarkdown(tabId, finalMarkdown)
|
||||
else callbacksRef.current.onMarkdown(tabId, finalMarkdown)
|
||||
}
|
||||
} catch { /* 编辑器未就绪时跳过回写 */ }
|
||||
callbacksRef.current.onReady(EMPTY_API)
|
||||
editor.destroy().catch(() => undefined)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import Tooltip from './Tooltip'
|
||||
import type { FindOptions } from '../editor/search'
|
||||
|
||||
interface Props {
|
||||
@@ -29,21 +30,26 @@ export default function FindBar(p: Props) {
|
||||
}, [])
|
||||
|
||||
const toggle = (key: keyof FindOptions, label: string, title: string) => (
|
||||
<button
|
||||
className={`find-toggle ${p.opts[key] ? 'on' : ''}`}
|
||||
title={title}
|
||||
onClick={() => p.onOptsChange({ ...p.opts, [key]: !p.opts[key] })}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
<Tooltip label={title}>
|
||||
<button
|
||||
className={`find-toggle ${p.opts[key] ? 'on' : ''}`}
|
||||
aria-label={title}
|
||||
aria-pressed={Boolean(p.opts[key])}
|
||||
onClick={() => p.onOptsChange({ ...p.opts, [key]: !p.opts[key] })}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="findbar">
|
||||
<div className="findbar-row">
|
||||
<button className="find-icon" title={p.showReplace ? '收起替换' : '展开替换'} onClick={p.onToggleReplace}>
|
||||
{p.showReplace ? '▾' : '▸'}
|
||||
</button>
|
||||
<Tooltip label={p.showReplace ? '收起替换' : '展开替换'}>
|
||||
<button className="find-icon" aria-label={p.showReplace ? '收起替换' : '展开替换'} onClick={p.onToggleReplace}>
|
||||
{p.showReplace ? '▾' : '▸'}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<input
|
||||
ref={queryRef}
|
||||
className="input find-input"
|
||||
@@ -61,9 +67,9 @@ export default function FindBar(p: Props) {
|
||||
<span className="find-count">
|
||||
{p.error ? p.error : p.query ? (p.count === 0 ? '无结果' : `${p.current + 1} / ${p.count}`) : ''}
|
||||
</span>
|
||||
<button className="find-icon" title="上一个 (Shift+Enter)" onClick={p.onPrev}>↑</button>
|
||||
<button className="find-icon" title="下一个 (Enter)" onClick={p.onNext}>↓</button>
|
||||
<button className="find-icon" title="关闭 (Esc)" onClick={p.onClose}>✕</button>
|
||||
<Tooltip label="上一个 (Shift+Enter)"><button className="find-icon" aria-label="上一个" onClick={p.onPrev}>↑</button></Tooltip>
|
||||
<Tooltip label="下一个 (Enter)"><button className="find-icon" aria-label="下一个" onClick={p.onNext}>↓</button></Tooltip>
|
||||
<Tooltip label="关闭 (Esc)"><button className="find-icon" aria-label="关闭" onClick={p.onClose}>✕</button></Tooltip>
|
||||
</div>
|
||||
{p.showReplace && (
|
||||
<div className="findbar-row">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Git 同步设置:远程仓库地址、(初始化时的)分支、定时同步间隔。
|
||||
// 工作区还不是仓库时,保存=初始化仓库并绑定远程;已是仓库时只更新设置。
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Dropdown } from './Dropdown'
|
||||
|
||||
const INTERVAL_OPTIONS = [
|
||||
@@ -21,13 +21,18 @@ export function GitSyncModal({ isRepo, branch, initialRemoteUrl, initialInterval
|
||||
onSave: (remoteUrl: string, branch: string, intervalMin: number) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape' && !busy) onClose() }
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => document.removeEventListener('keydown', onKey)
|
||||
}, [busy, onClose])
|
||||
const [remoteUrl, setRemoteUrl] = useState(initialRemoteUrl)
|
||||
const [branchInput, setBranchInput] = useState('main')
|
||||
const [intervalMin, setIntervalMin] = useState(String(initialIntervalMin))
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop">
|
||||
<div className="modal">
|
||||
<div className="modal-backdrop" onClick={() => { if (!busy) onClose() }}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>Git 同步设置</h3>
|
||||
<p className="modal-note">
|
||||
同步顺序:提交本地更改 → 拉取远程更新(变基)→ 推送。若本地与远程改了相同内容,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useDismiss } from '../hooks/useDismiss'
|
||||
import Tooltip from './Tooltip'
|
||||
import { clampMenuPosition } from '../utils/menuPos'
|
||||
import { listDir, workspaceSearch, type DirEntry, type WorkspaceSearchFile } from '../services/platform'
|
||||
import { isPathInside, parentDirOf, pathsEqual } from '../utils/text'
|
||||
import type { OutlineItem } from '../utils/text'
|
||||
@@ -83,6 +84,7 @@ function Sidebar(p: Props) {
|
||||
<>
|
||||
{p.gitSync && <GitSyncBar ui={p.gitSync} />}
|
||||
<FileTree
|
||||
key={p.workspaceRoot}
|
||||
onOpenInNewWindowEntry={p.onOpenInNewWindowEntry}
|
||||
onSaveAsEntry={p.onSaveAsEntry}
|
||||
onExportEntry={p.onExportEntry}
|
||||
@@ -217,7 +219,7 @@ function FileTree({ root, activePath, onOpenFile, onCreateFileIn, onCreateFolder
|
||||
dnd={dnd}
|
||||
/>
|
||||
{menu && (
|
||||
<div className="tree-context-menu" ref={menuRef} style={{ left: menu.x, top: menu.y }} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="tree-context-menu" ref={menuRef} style={clampMenuPosition(menu.x, menu.y, 208, 340)} onClick={(e) => e.stopPropagation()}>
|
||||
{!menu.entry.isDir && <button onClick={() => { onOpenFile(menu.entry.path); setMenu(null) }}>打开</button>}
|
||||
{!menu.entry.isDir && onOpenInNewWindowEntry && <button onClick={() => { onOpenInNewWindowEntry(menu.entry); setMenu(null) }}>在新窗口打开</button>}
|
||||
{onRevealEntry && <button onClick={() => { onRevealEntry(menu.entry); setMenu(null) }}>打开所在位置</button>}
|
||||
@@ -277,7 +279,7 @@ function TreeLevel({ dir, depth, activePath, onOpenFile, refreshKey, onEntryMenu
|
||||
{entries.map((entry) => (
|
||||
<div key={entry.path}>
|
||||
<button
|
||||
className={`tree-item${entry.isDir ? ' dir' : ''}${!entry.isDir && entry.path === activePath ? ' active' : ''}${dnd && dnd.draggingPath === entry.path ? ' dragging' : ''}${dnd && entry.isDir && dnd.dropDir === entry.path ? ' drop-target' : ''}`}
|
||||
className={`tree-item${entry.isDir ? ' dir' : ''}${!entry.isDir && pathsEqual(entry.path, activePath) ? ' active' : ''}${dnd && dnd.draggingPath === entry.path ? ' dragging' : ''}${dnd && entry.isDir && dnd.dropDir === entry.path ? ' drop-target' : ''}`}
|
||||
style={{ paddingLeft: 8 + depth * 13 }}
|
||||
onClick={() => (entry.isDir ? toggle(entry.path) : onOpenFile(entry.path))}
|
||||
onContextMenu={(e) => { e.preventDefault(); onEntryMenu(entry, e.clientX, e.clientY) }}
|
||||
@@ -313,18 +315,22 @@ function WorkspaceSearch({ root, tauriMode, onOpen }: {
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const runSeqRef = useRef(0)
|
||||
const run = async () => {
|
||||
if (!root || !query.trim()) return
|
||||
const seq = ++runSeqRef.current
|
||||
setSearching(true)
|
||||
setError(null)
|
||||
try {
|
||||
const word = query.trim()
|
||||
setResults(await workspaceSearch(root, word, caseSensitive))
|
||||
const found = await workspaceSearch(root, word, caseSensitive)
|
||||
if (runSeqRef.current !== seq) return // 已有更新的搜索,旧结果作废
|
||||
setResults(found)
|
||||
setSearchedQuery(word)
|
||||
} catch (e) {
|
||||
setError(String(e))
|
||||
if (runSeqRef.current === seq) setError(String(e))
|
||||
} finally {
|
||||
setSearching(false)
|
||||
if (runSeqRef.current === seq) setSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { clampMenuPosition } from '../utils/menuPos'
|
||||
import type { DocTab } from '../stores/tabs'
|
||||
import { isDirty } from '../stores/tabs'
|
||||
import { useDismiss } from '../hooks/useDismiss'
|
||||
@@ -23,6 +24,7 @@ interface Props {
|
||||
export default function TabBar({ tabs, activeId, onActivate, onClose, onCloseOthers, onCloseRight, onNew, onMove, onSaveTab, onSaveAsTab, onOpenInNewWindowTab, onExportTab, onPrintTab }: Props) {
|
||||
const [menuFor, setMenuFor] = useState<{ id: string; x: number; y: number } | null>(null)
|
||||
const [dragId, setDragId] = useState<string | null>(null)
|
||||
const lastMoveRef = useRef<string | null>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const closeMenu = useCallback(() => setMenuFor(null), [])
|
||||
useDismiss(menuRef, menuFor !== null, closeMenu)
|
||||
@@ -46,11 +48,12 @@ export default function TabBar({ tabs, activeId, onActivate, onClose, onCloseOth
|
||||
onDragStart={() => setDragId(tab.id)}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
if (dragId && dragId !== tab.id) {
|
||||
if (dragId && dragId !== tab.id && lastMoveRef.current !== `${dragId}:${index}`) {
|
||||
lastMoveRef.current = `${dragId}:${index}`
|
||||
onMove(dragId, index)
|
||||
}
|
||||
}}
|
||||
onDragEnd={() => setDragId(null)}
|
||||
onDragEnd={() => { setDragId(null); lastMoveRef.current = null }}
|
||||
onClick={() => onActivate(tab.id)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault()
|
||||
@@ -70,7 +73,7 @@ export default function TabBar({ tabs, activeId, onActivate, onClose, onCloseOth
|
||||
))}
|
||||
<button className="tab-new" aria-label="新建文档" onClick={onNew}>+</button>
|
||||
{menuFor && menuTab && (
|
||||
<div className="context-menu" ref={menuRef} style={{ left: menuFor.x, top: menuFor.y }}>
|
||||
<div className="context-menu" ref={menuRef} style={clampMenuPosition(menuFor.x, menuFor.y, 200, 330)}>
|
||||
<button disabled={!isDirty(menuTab)} onClick={() => act(onSaveTab)}>保存</button>
|
||||
<button onClick={() => act(onSaveAsTab)}>另存为…</button>
|
||||
{onOpenInNewWindowTab && <button onClick={() => act(onOpenInNewWindowTab)}>在新窗口打开</button>}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Editor } from '@milkdown/kit/core'
|
||||
import { editorCommands } from '../editor/commands'
|
||||
import Tooltip from './Tooltip'
|
||||
|
||||
interface Props {
|
||||
editor: Editor
|
||||
@@ -10,7 +11,9 @@ interface Props {
|
||||
/** 表格浮动工具栏:光标在表格内时出现在表格上方(EditorPane 负责定位与显隐)。 */
|
||||
export default function TableToolbar({ editor, top, left }: Props) {
|
||||
const btn = (label: string, fn: () => void, title?: string) => (
|
||||
<button onMouseDown={(e) => e.preventDefault()} onClick={fn} title={title ?? label}>{label}</button>
|
||||
<Tooltip label={title ?? label}>
|
||||
<button onMouseDown={(e) => e.preventDefault()} onClick={fn} aria-label={title ?? label}>{label}</button>
|
||||
</Tooltip>
|
||||
)
|
||||
return (
|
||||
<div className="table-toolbar" style={{ top, left }} role="toolbar" aria-label="表格工具">
|
||||
|
||||
@@ -12,6 +12,8 @@ export const ASYNC_LOAD_THRESHOLD = 60_000
|
||||
const CHUNK_TARGET = 24_000
|
||||
|
||||
const FENCE_RE = /^\s{0,3}(```+|~~~+)/
|
||||
/** 围栏作切点时只认顶格:列表项内缩进的围栏是列表内容,切开会改变归属(docs/29) */
|
||||
const FENCE_CUT_RE = /^(`{3,}|~{3,})/
|
||||
const HEADING_RE = /^#{1,6}\s/
|
||||
const HR_RE = /^\s{0,3}(\*\*\*+|---+|___+)\s*$/
|
||||
const LINK_DEF_RE = /^\s{0,3}\[[^\]]+\]:\s*\S+/
|
||||
@@ -44,20 +46,18 @@ export function splitMarkdownChunks(markdown: string): string[] {
|
||||
if (inFence) continue
|
||||
if (line.trim() !== '' || i + 1 >= lines.length) continue
|
||||
const next = lines[i + 1]
|
||||
if (HEADING_RE.test(next) || FENCE_RE.test(next) || HR_RE.test(next)) cuts.push(i + 1)
|
||||
if (HEADING_RE.test(next) || FENCE_CUT_RE.test(next) || HR_RE.test(next)) cuts.push(i + 1)
|
||||
}
|
||||
if (cuts.length === 0) return [markdown]
|
||||
// 按目标大小合并相邻片段:切点很多时攒够一片再切,避免碎成几百片
|
||||
const chunks: string[] = []
|
||||
let startLine = 0
|
||||
let startOffset = 0
|
||||
const lineOffset = (idx: number) => {
|
||||
let off = 0
|
||||
for (let i = 0; i < idx; i++) off += lines[i].length + 1
|
||||
return off
|
||||
}
|
||||
// 行偏移前缀表(原实现每个切点都从头累加,切点密集时 O(切点×行数))
|
||||
const lineOffsets: number[] = [0]
|
||||
for (let i = 0; i < lines.length; i++) lineOffsets.push(lineOffsets[i] + lines[i].length + 1)
|
||||
for (const cut of cuts) {
|
||||
const off = lineOffset(cut)
|
||||
const off = lineOffsets[cut]
|
||||
if (off - startOffset >= CHUNK_TARGET && cut > startLine) {
|
||||
chunks.push(markdown.slice(startOffset, off))
|
||||
startLine = cut
|
||||
|
||||
@@ -44,21 +44,34 @@ export function createMarkdownEditor(root: HTMLElement, initialMarkdown: string,
|
||||
lm.markdownUpdated((_ctx, markdown) => callbacks.onMarkdown?.(markdown))
|
||||
}
|
||||
if (callbacks.onCursor || callbacks.onTableContext) {
|
||||
// 选区事件在每个事务后同步分发,而行列计算要从文档头逐块数到
|
||||
// 光标(大文档 O(光标位置)):用 rAF 合并,逐帧最多算一次(docs/29)
|
||||
let cursorRaf = 0
|
||||
let pendingRun: (() => void) | null = null
|
||||
lm.selectionUpdated((selCtx, selection) => {
|
||||
callbacks.onCursor?.(cursorFromSelection(selection))
|
||||
if (callbacks.onTableContext) {
|
||||
const inTable = selectionInTable(selection as never)
|
||||
let tableEl: HTMLElement | null = null
|
||||
if (inTable) {
|
||||
try {
|
||||
const view = selCtx.get(editorViewCtx)
|
||||
const at = view.domAtPos(selection.from)
|
||||
const base = at.node.nodeType === Node.ELEMENT_NODE ? (at.node as Element) : at.node.parentElement
|
||||
tableEl = (base?.closest('table') as HTMLElement | null) ?? null
|
||||
} catch { /* 定位失败时工具栏不显示 */ }
|
||||
pendingRun = () => {
|
||||
callbacks.onCursor?.(cursorFromSelection(selection))
|
||||
if (callbacks.onTableContext) {
|
||||
const inTable = selectionInTable(selection as never)
|
||||
let tableEl: HTMLElement | null = null
|
||||
if (inTable) {
|
||||
try {
|
||||
const view = selCtx.get(editorViewCtx)
|
||||
const at = view.domAtPos(selection.from)
|
||||
const base = at.node.nodeType === Node.ELEMENT_NODE ? (at.node as Element) : at.node.parentElement
|
||||
tableEl = (base?.closest('table') as HTMLElement | null) ?? null
|
||||
} catch { /* 定位失败时工具栏不显示 */ }
|
||||
}
|
||||
callbacks.onTableContext(inTable, tableEl)
|
||||
}
|
||||
callbacks.onTableContext(inTable, tableEl)
|
||||
}
|
||||
if (cursorRaf) return
|
||||
cursorRaf = requestAnimationFrame(() => {
|
||||
cursorRaf = 0
|
||||
const run = pendingRun
|
||||
pendingRun = null
|
||||
run?.()
|
||||
})
|
||||
})
|
||||
}
|
||||
ctx.update(uploadConfig.key, (prev: UploadOptions) => ({
|
||||
|
||||
@@ -16,6 +16,8 @@ interface ViewLike {
|
||||
* 不产生空段落(上一版 replaceSelectionWith + insert 在段中粘贴会留一个 <br /> 空段)。 */
|
||||
export function insertPlainTextIntoView(view: ViewLike, text: string): boolean {
|
||||
const lines = text.replace(/\r\n?/g, '\n').split('\n')
|
||||
// 原文以换行结尾时末项是空串:丢弃,否则粘贴后会多一个空段落
|
||||
if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop()
|
||||
const paragraph = view.state.schema.nodes.paragraph
|
||||
const parentType = view.state.selection.$from.parent.type.name
|
||||
if (lines.length <= 1 || !paragraph || (parentType !== 'paragraph' && parentType !== 'heading')) {
|
||||
|
||||
@@ -67,13 +67,14 @@ async function postProcess(tree: Root, opts: ExportOptions): Promise<void> {
|
||||
|
||||
// 图片内嵌
|
||||
if (opts.imageMode === 'embed' && opts.resolveImage) {
|
||||
for (const { node } of images) {
|
||||
// 并发解析(原先逐张串行 IPC,图片多的文档导出线性变慢)
|
||||
await Promise.all(images.map(async ({ node }) => {
|
||||
const src = node.properties?.src
|
||||
if (typeof src === 'string' && !/^(https?:|data:)/i.test(src)) {
|
||||
const data = await opts.resolveImage(src)
|
||||
const data = await opts.resolveImage?.(src)
|
||||
if (data) node.properties.src = data
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// 相对模式按导出文件位置重写图片路径(验收 E3:原先原样保留文档相对路径,
|
||||
@@ -291,6 +292,9 @@ function loadHighlighter(): Promise<import('shiki').HighlighterCore> {
|
||||
})
|
||||
})()
|
||||
}
|
||||
// 初始化失败时不永久缓存 rejected Promise(否则整会话高亮失效):
|
||||
// 本次照常抛给调用方兜底,下次导出重新尝试初始化
|
||||
highlighterPromise.catch(() => { highlighterPromise = null })
|
||||
return highlighterPromise
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// 定时同步用 localStorage 租约保证多窗口只有一个执行(与草稿认领同一思路)。
|
||||
|
||||
import { dbPrefGet, dbPrefSet, type GitStatusInfo } from './platform'
|
||||
import { pathCompareKey } from '../utils/text'
|
||||
|
||||
export interface GitSyncSettings {
|
||||
/** 定时同步间隔(分钟);0=只手动 */
|
||||
@@ -23,7 +24,7 @@ export function parseGitSyncSettings(raw: string | null): GitSyncSettings {
|
||||
|
||||
export async function loadGitSyncSettings(root: string): Promise<GitSyncSettings> {
|
||||
try {
|
||||
return parseGitSyncSettings(await dbPrefGet(`gitsync:${root}`))
|
||||
return parseGitSyncSettings(await dbPrefGet(`gitsync:${pathCompareKey(root)}`))
|
||||
} catch {
|
||||
return { ...DEFAULT_GIT_SYNC_SETTINGS }
|
||||
}
|
||||
@@ -31,7 +32,7 @@ export async function loadGitSyncSettings(root: string): Promise<GitSyncSettings
|
||||
|
||||
export async function saveGitSyncSettings(root: string, settings: GitSyncSettings): Promise<void> {
|
||||
try {
|
||||
await dbPrefSet(`gitsync:${root}`, JSON.stringify(settings))
|
||||
await dbPrefSet(`gitsync:${pathCompareKey(root)}`, JSON.stringify(settings))
|
||||
} catch {
|
||||
// 偏好写入失败不挡同步本身
|
||||
}
|
||||
@@ -72,7 +73,7 @@ interface Lease {
|
||||
/** 尝试取得/续租该工作区的定时同步租约;被别的窗口新鲜占用时返回 false。 */
|
||||
export function acquireGitSyncLease(root: string, windowId: string, now = Date.now()): boolean {
|
||||
try {
|
||||
const key = LEASE_PREFIX + root
|
||||
const key = LEASE_PREFIX + pathCompareKey(root)
|
||||
const raw = localStorage.getItem(key)
|
||||
if (raw) {
|
||||
const lease = JSON.parse(raw) as Lease
|
||||
@@ -80,7 +81,14 @@ export function acquireGitSyncLease(root: string, windowId: string, now = Date.n
|
||||
}
|
||||
const mine: Lease = { win: windowId, ts: now }
|
||||
localStorage.setItem(key, JSON.stringify(mine))
|
||||
return true
|
||||
// 回读确认:双窗口同一瞬间都通过检查时,后写者为准,先写者回读会发现
|
||||
// 持有者不是自己,放弃本次(避免并发跑 git 撞 index.lock)
|
||||
try {
|
||||
const back = JSON.parse(localStorage.getItem(key) ?? 'null') as Lease | null
|
||||
return back?.win === windowId
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
return true // 存储不可用时不拦(单窗口场景)
|
||||
}
|
||||
@@ -88,7 +96,7 @@ export function acquireGitSyncLease(root: string, windowId: string, now = Date.n
|
||||
|
||||
export function releaseGitSyncLease(root: string, windowId: string): void {
|
||||
try {
|
||||
const key = LEASE_PREFIX + root
|
||||
const key = LEASE_PREFIX + pathCompareKey(root)
|
||||
const raw = localStorage.getItem(key)
|
||||
if (raw && (JSON.parse(raw) as Lease).win === windowId) localStorage.removeItem(key)
|
||||
} catch {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
dbPrefGet, dbPrefSet, dbRecoveryList, dbRecoverySave, dbRecoveryDelete,
|
||||
type RecoveryEntry,
|
||||
} from './platform'
|
||||
import { pathsEqual } from '../utils/text'
|
||||
|
||||
export type { RecoveryEntry }
|
||||
|
||||
@@ -52,7 +53,7 @@ export function addRecent(path: string, name: string): void {
|
||||
void dbRecentAdd(path, name, Date.now()).catch(() => undefined)
|
||||
return
|
||||
}
|
||||
const list = readLegacyRecent().filter((r) => r.path !== path)
|
||||
const list = readLegacyRecent().filter((r) => !pathsEqual(r.path, path))
|
||||
list.unshift({ path, name })
|
||||
localStorage.setItem(LS_RECENT, JSON.stringify(list.slice(0, 10)))
|
||||
}
|
||||
@@ -62,7 +63,7 @@ export function removeRecent(path: string): void {
|
||||
void dbRecentRemove(path).catch(() => undefined)
|
||||
return
|
||||
}
|
||||
localStorage.setItem(LS_RECENT, JSON.stringify(readLegacyRecent().filter((r) => r.path !== path)))
|
||||
localStorage.setItem(LS_RECENT, JSON.stringify(readLegacyRecent().filter((r) => !pathsEqual(r.path, path))))
|
||||
}
|
||||
|
||||
export function addWorkspace(path: string): void {
|
||||
@@ -90,7 +91,9 @@ export async function loadLastWorkspace(): Promise<string | null> {
|
||||
}
|
||||
|
||||
export async function loadUiPref(): Promise<string | null> {
|
||||
if (!isTauri()) return null
|
||||
if (!isTauri()) {
|
||||
try { return localStorage.getItem(LS_UI) } catch { return null }
|
||||
}
|
||||
try {
|
||||
const fromDb = await dbPrefGet('ui')
|
||||
if (fromDb) {
|
||||
|
||||
@@ -47,7 +47,10 @@ export function createWindowClaims(
|
||||
const t = now()
|
||||
const out: Record<string, ClaimEntry> = {}
|
||||
for (const [id, entry] of Object.entries(reg)) {
|
||||
if (entry && typeof entry.ts === 'number' && t - entry.ts <= CLAIM_STALE_MS) out[id] = entry
|
||||
// keys 必须是字符串数组:注册表被写坏(旧版本/外部改动)时整条丢弃,
|
||||
// 避免 liveClaimedKeys 逐字迭代字符串或对不可迭代值抛错
|
||||
const keysOk = Array.isArray(entry?.keys) && entry.keys.every((k) => typeof k === 'string')
|
||||
if (entry && typeof entry.ts === 'number' && keysOk && t - entry.ts <= CLAIM_STALE_MS) out[id] = entry
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
7
src/utils/menuPos.ts
Normal file
7
src/utils/menuPos.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/** 右键菜单位置夹取:不让菜单溢出窗口右/下边(尺寸为估算值,调用方按条目数给)。 */
|
||||
export function clampMenuPosition(x: number, y: number, width: number, estHeight: number): { left: number; top: number } {
|
||||
return {
|
||||
left: Math.max(4, Math.min(x, window.innerWidth - width - 8)),
|
||||
top: Math.max(4, Math.min(y, window.innerHeight - estHeight - 8)),
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// 崩溃恢复的纯逻辑(阶段 4):恢复记录的键、启动时该提供/该清理的划分。
|
||||
import type { RecoveryEntry } from '../services/platform'
|
||||
import { pathCompareKey } from './text'
|
||||
|
||||
export interface RecoveryTabLike {
|
||||
id: string
|
||||
@@ -8,7 +9,7 @@ export interface RecoveryTabLike {
|
||||
|
||||
/** 恢复记录的稳定键:文件文档按路径(跨会话一致),未命名文档按标签 id(仅本机延续)。 */
|
||||
export function recoveryKey(tab: RecoveryTabLike): string {
|
||||
return tab.path ? `path:${tab.path}` : `untitled:${tab.id}`
|
||||
return tab.path ? `path:${pathCompareKey(tab.path)}` : `untitled:${tab.id}`
|
||||
}
|
||||
|
||||
export interface RecoveryPlan {
|
||||
|
||||
@@ -20,14 +20,27 @@ export interface OutlineItem {
|
||||
line: number
|
||||
}
|
||||
|
||||
/** 从 Markdown 文本提取大纲。注意跳过代码块内的 # 行。 */
|
||||
/** 从 Markdown 文本提取大纲。注意跳过代码块内的 # 行。
|
||||
* 围栏按字符+长度跟踪:四反引号围栏内的三反引号行不算闭合(docs/29)。 */
|
||||
export function extractOutline(markdown: string): OutlineItem[] {
|
||||
const items: OutlineItem[] = []
|
||||
const lines = markdown.split('\n')
|
||||
let inFence = false
|
||||
let fenceChar = ''
|
||||
let fenceLen = 0
|
||||
lines.forEach((raw, i) => {
|
||||
if (/^\s*(```|~~~)/.test(raw)) inFence = !inFence
|
||||
if (inFence) return
|
||||
const fence = /^\s{0,3}(`{3,}|~{3,})/.exec(raw)
|
||||
if (fence) {
|
||||
const run = fence[1]
|
||||
if (!fenceChar) {
|
||||
fenceChar = run[0]
|
||||
fenceLen = run.length
|
||||
} else if (run[0] === fenceChar && run.length >= fenceLen) {
|
||||
fenceChar = ''
|
||||
fenceLen = 0
|
||||
}
|
||||
return
|
||||
}
|
||||
if (fenceChar) return
|
||||
const m = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(raw)
|
||||
if (m) items.push({ level: m[1].length, text: m[2].trim(), line: i })
|
||||
})
|
||||
@@ -48,21 +61,28 @@ export function normalizeSlashes(path: string): string {
|
||||
return path.replace(/\\/g, '/')
|
||||
}
|
||||
|
||||
/** 归一化后比较两条路径是否相同。 */
|
||||
/** 路径比较键:统一斜杠;Windows 风格路径(盘符/UNC)大小写不敏感,一并折叠小写。 */
|
||||
export function pathCompareKey(path: string): string {
|
||||
const slashed = normalizeSlashes(path).replace(/\/+$/, '')
|
||||
return /^[a-zA-Z]:/.test(slashed) || slashed.startsWith('//') ? slashed.toLowerCase() : slashed
|
||||
}
|
||||
|
||||
/** 归一化后比较两条路径是否相同(Windows 路径忽略大小写)。 */
|
||||
export function pathsEqual(a?: string | null, b?: string | null): boolean {
|
||||
return Boolean(a && b && normalizeSlashes(a) === normalizeSlashes(b))
|
||||
return Boolean(a && b && pathCompareKey(a) === pathCompareKey(b))
|
||||
}
|
||||
|
||||
/** child 是否位于 parent 目录之内(不含 parent 本身)。 */
|
||||
export function isPathInside(child: string, parent: string): boolean {
|
||||
const c = normalizeSlashes(child)
|
||||
const p = normalizeSlashes(parent).replace(/\/+$/, '')
|
||||
return c.startsWith(`${p}/`)
|
||||
return pathCompareKey(child).startsWith(`${pathCompareKey(parent)}/`)
|
||||
}
|
||||
|
||||
/** 取父目录(保持原分隔符风格)。 */
|
||||
/** 取父目录(保持原分隔符风格);盘符根下的文件返回盘符根(C:/ 而非相对的 C:)。 */
|
||||
export function parentDirOf(path: string): string {
|
||||
return path.replace(/[\\/][^\\/]*$/, '')
|
||||
const parent = path.replace(/[\\/][^\\/]*$/, '')
|
||||
if (/^[a-zA-Z]:$/.test(parent)) return `${parent}/`
|
||||
if (parent === '' && path.startsWith('/')) return '/'
|
||||
return parent
|
||||
}
|
||||
|
||||
/** 拼接路径(分隔符跟随 dir 的风格)。 */
|
||||
|
||||
Reference in New Issue
Block a user