From 4cd312f3c01ed14cbfcdd8deb89e4fa85642e767 Mon Sep 17 00:00:00 2001 From: donfaquir <1458918806@qq.com> Date: Tue, 14 Jul 2026 16:55:01 +0800 Subject: [PATCH 01/16] refactor(app): extract ContentArea, EditorView, AppTopToolbar and hooks from App.tsx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reduce App.tsx from 1788 to 1034 lines (-42%) by extracting: - ContentArea: view routing (graph/thoughts/files) + right panel - EditorView: editor rendering (doc bar, markdown editor, find bar) - AppTopToolbar: top toolbar (tab bar, save button, window controls) - useGlobalShortcuts: Cmd+L/Cmd+Shift+P/Cmd+Shift+W/Cmd+F - useWindowLifecycle: beforeunload + Tauri onCloseRequested flush - useHeadingNavigation: ProseMirror heading scroll with retry This prepares a clean insertion point in ContentArea for the upcoming Practice Mode (leftPanelView === "practice") without touching App.tsx. No behavioral changes — pure structural extraction. --- src/App.tsx | 994 ++++-------------------------- src/components/AppTopToolbar.tsx | 328 ++++++++++ src/components/ContentArea.tsx | 282 +++++++++ src/components/EditorView.tsx | 319 ++++++++++ src/hooks/useGlobalShortcuts.ts | 94 +++ src/hooks/useHeadingNavigation.ts | 96 +++ src/hooks/useWindowLifecycle.ts | 89 +++ 7 files changed, 1328 insertions(+), 874 deletions(-) create mode 100644 src/components/AppTopToolbar.tsx create mode 100644 src/components/ContentArea.tsx create mode 100644 src/components/EditorView.tsx create mode 100644 src/hooks/useGlobalShortcuts.ts create mode 100644 src/hooks/useHeadingNavigation.ts create mode 100644 src/hooks/useWindowLifecycle.ts diff --git a/src/App.tsx b/src/App.tsx index 8eb5d0a..e0b38e3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,7 +2,7 @@ import { invoke, isTauri } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { ask } from "@tauri-apps/plugin-dialog"; import { getCurrentWindow } from "@tauri-apps/api/window"; -import GithubSlugger from "github-slugger"; + import { lazy, Suspense, @@ -16,36 +16,27 @@ import { } from "react"; import { useTranslation } from "react-i18next"; import { AiNoteContextProvider } from "./contexts/AiNoteContext"; -import { AiConversationSessionProvider } from "./contexts/AiConversationSessionContext"; -import { ThoughtMgmtAiConversationSessionProvider } from "./contexts/ThoughtMgmtAiConversationSessionContext"; import type { DepthMode } from "./types/cognitiveTypes"; -import { AiConversationPanel } from "./components/AiConversationPanel"; -import { AiConversationToolbar } from "./components/AiConversationToolbar"; -const CrepeMarkdownEditor = lazy(() => import("./components/CrepeMarkdownEditor")); const AiLlmSettingsModal = lazy(() => import("./components/AiLlmSettingsModal")); -import { EditorTabBar, MARKDOWN_TAB_PANEL_ID, editorTabDomId } from "./components/EditorTabBar"; + import { FileTree, collectKfPrivateRelPaths } from "./components/FileTree"; -import { KfPrivateLockIcon } from "./components/KfPrivateLockIcon"; -import { OutlineBulkToolbar } from "./components/OutlineBulkToolbar"; -import { OutlinePanel } from "./components/OutlinePanel"; import type { CrepeMarkdownEditorApi } from "./components/CrepeMarkdownEditor"; import { CognitiveReportPanel } from "./components/cognitive-report/CognitiveReportPanel"; import { CommandPalette } from "./components/CommandPalette"; -import { EditorThoughtsPanel } from "./components/EditorThoughtsPanel"; -import { EditorWritingCoachHost, type EditorWritingCoachHostHandle } from "./components/EditorWritingCoachHost"; -import { RightPanelReviewTab } from "./components/RightPanelReviewTab"; -import { LinkRecommendationPanel } from "./components/LinkRecommendationPanel"; -import { RightPanelShell, type RightPanelTab } from "./components/RightPanelShell"; +import { AppTopToolbar } from "./components/AppTopToolbar"; +import { ContentArea } from "./components/ContentArea"; +import { type EditorWritingCoachHostHandle } from "./components/EditorWritingCoachHost"; +import { type RightPanelTab } from "./components/RightPanelShell"; import { ThoughtMaturityToastHost } from "./components/ThoughtMaturityToastHost"; -import { ThoughtManagementPanel } from "./components/ThoughtManagementPanel"; import { ThoughtSavePopover } from "./components/ThoughtSavePopover"; import { ThoughtVaultHubModal } from "./components/ThoughtVaultHubModal"; import { WorkspaceSearchModal } from "./components/WorkspaceSearchModal"; -import { EditorFindBar } from "./components/EditorFindBar"; -import { GraphTabShell } from "./components/GraphTabShell"; import { ActivityBar, type LeftPanelView } from "./components/ActivityBar"; import { OnboardingOverlay } from "./components/OnboardingOverlay"; -import { KF_PRIVATE_LOCK_ICON_DOC_BAR_PX } from "./constants/kfPrivateUi"; + +import { useGlobalShortcuts } from "./hooks/useGlobalShortcuts"; +import { useHeadingNavigation } from "./hooks/useHeadingNavigation"; +import { useWindowLifecycle } from "./hooks/useWindowLifecycle"; import { useKfPrivateForPath } from "./hooks/useKfPrivateForPath"; import { useOpenDocs } from "./hooks/useOpenDocs"; import { useOutline } from "./hooks/useOutline"; @@ -67,41 +58,7 @@ import "./App.css"; /** 与会话恢复配套:须与 knowforge:lastWorkspace 指向同一工作区根路径 */ const LAST_SESSION_KEY = "knowforge:lastSession"; -/** Wikilink `#` 标题定位:等 ProseMirror 挂载的最长等待(毫秒);弱设备/大文档下避免无限 rAF */ -const WIKI_HEADING_NAV_RETRY_BUDGET_MS = 2500; -const PM_HEADING_SELECTOR = - ".ProseMirror h1, .ProseMirror h2, .ProseMirror h3, .ProseMirror h4, .ProseMirror h5, .ProseMirror h6"; - -/** 在 Milkdown 滚动容器内按 GitHub slug 查找标题 DOM(与 extractOutline / navigateToHeading 一致) */ -function findProseMirrorHeadingBySlug( - scrollEl: HTMLElement | null, - slug: string, -): HTMLElement | null { - if (!scrollEl) { - return null; - } - const slugger = new GithubSlugger(); - const headings = scrollEl.querySelectorAll(PM_HEADING_SELECTOR); - for (const heading of headings) { - if (!(heading instanceof HTMLElement)) { - continue; - } - const text = heading.textContent?.trim() ?? ""; - if (slugger.slug(text) === slug) { - return heading; - } - } - return null; -} - -function scrollMilkdownHeadingIntoView(scrollEl: HTMLElement, headingEl: HTMLElement) { - const pad = 12; - const cRect = scrollEl.getBoundingClientRect(); - const eRect = headingEl.getBoundingClientRect(); - const top = scrollEl.scrollTop + (eRect.top - cRect.top) - pad; - scrollEl.scrollTo({ top: Math.max(0, top), behavior: "smooth" }); -} /** 与 src-tauri 中 rel_path_components_ok 规则一致,禁止空段、.、.. */ function isValidStoredRelPath(relPath: string): boolean { @@ -203,8 +160,7 @@ function App() { const anyDragging = leftResizable.isDragging || rightResizable.isDragging; const editorScrollRef = useRef(null); - /** 递增代数:新一次 wikilink 标题定位或卸载时作废仍在排队的 rAF */ - const wikiHeadingNavRetryGenerationRef = useRef(0); + const rawSourceTextareaRef = useRef(null); const crepeEditorApiRef = useRef(null); const writingCoachRef = useRef(null); @@ -213,8 +169,7 @@ function App() { const docState = useOpenDocs(workspaceReady); const activePathSwitchTraceRef = useRef(null); const markdownBodyCacheRef = useRef>(new Map()); - const flushDirtyBeforeExitRef = useRef(docState.flushDirtyDocumentsBeforeExit); - flushDirtyBeforeExitRef.current = docState.flushDirtyDocumentsBeforeExit; + const openOrFocusRef = useRef(docState.openOrFocusTab); openOrFocusRef.current = docState.openOrFocusTab; @@ -465,16 +420,7 @@ function App() { void invoke("sync_open_markdown_watchers", { relPaths: docState.tabPaths }).catch(() => {}); }, [workspaceReady, docState.tabPaths]); - /** 浏览器预览:关闭页面前提示未保存 */ - useEffect(() => { - const onBeforeUnload = (e: BeforeUnloadEvent) => { - if (docState.hasAnyDirtyTab()) { - e.preventDefault(); - } - }; - window.addEventListener("beforeunload", onBeforeUnload); - return () => window.removeEventListener("beforeunload", onBeforeUnload); - }, [docState.hasAnyDirtyTab]); + useEffect(() => { /** 切换活动文档或标签时默认回到 Markdown 预览 */ @@ -656,80 +602,15 @@ function App() { const editorUsable = !!docState.activePath && !loadingDoc && !loadError && !!current; - const workspaceReadyForShortcutRef = useRef(workspaceReady); - const editorUsableForShortcutRef = useRef(editorUsable); - workspaceReadyForShortcutRef.current = workspaceReady; - editorUsableForShortcutRef.current = editorUsable; - - /** - * 全局快捷键:单一 window keydown,避免多段 useEffect 在依赖抖动或 StrictMode 下重复注册; - * ⌘F 条件用 ref 读最新 workspace/editor 状态,监听本身空依赖只挂载一次。 - */ - useEffect(() => { - const inEditableField = (t: EventTarget | null) => - t instanceof HTMLElement && t.closest("input, textarea, select, [contenteditable='true']"); - - const onKey = (e: KeyboardEvent) => { - const mod = e.metaKey || e.ctrlKey; - if (!mod) { - return; - } - - // ⌘L / Ctrl+L:打开侧栏并切到 AI(输入框内不触发) - if (!e.shiftKey && (e.key === "l" || e.key === "L")) { - if (inEditableField(e.target)) { - return; - } - e.preventDefault(); - setRightPanelOpen(true); - setRightPanelTab("ai"); - return; - } - - // ⌘⇧P / Ctrl+Shift+P:命令面板(输入框内不触发) - if (e.shiftKey && (e.key === "p" || e.key === "P")) { - if (inEditableField(e.target)) { - return; - } - e.preventDefault(); - setCognitiveReportOpen(false); - setCommandPaletteOpen((o) => !o); - return; - } - - // ⌘⇧W / Ctrl+Shift+W:手动触发写作教练(编辑器内也需响应) - if (e.shiftKey && (e.key === "w" || e.key === "W")) { - if (!editorUsableForShortcutRef.current) { - return; - } - e.preventDefault(); - writingCoachRef.current?.triggerManually(); - return; - } - - // ⌘F / Ctrl+F:篇内查找(焦点在正文或原文区时) - if (!e.shiftKey && (e.key === "f" || e.key === "F")) { - const el = e.target; - if (!(el instanceof HTMLElement)) { - return; - } - if (el.closest("[data-editor-find-input]")) { - return; - } - if (!workspaceReadyForShortcutRef.current || !editorUsableForShortcutRef.current) { - return; - } - const inDoc = el.closest("[data-milkdown-root], .main__raw-doc-source, .editor-scroll__body"); - if (!inDoc) { - return; - } - e.preventDefault(); - setEditorFindOpen(true); - } - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, []); + useGlobalShortcuts( + { + openAiPanel: () => { setRightPanelOpen(true); setRightPanelTab("ai"); }, + toggleCommandPalette: () => { setCognitiveReportOpen(false); setCommandPaletteOpen((o) => !o); }, + triggerWritingCoach: () => { writingCoachRef.current?.triggerManually(); }, + openEditorFind: () => { setEditorFindOpen(true); }, + }, + { workspaceReady, editorUsable }, + ); const saveDisabled = !workspaceReady || !editorUsable || !docState.dirty || docState.saving; @@ -773,47 +654,7 @@ function App() { refreshTree, ]); - const navigateToHeading = useCallback((slug: string) => { - requestAnimationFrame(() => { - const outer = editorScrollRef.current; - const scrollEl = outer?.querySelector("[data-milkdown-root]") as HTMLElement | null; - const el = findProseMirrorHeadingBySlug(scrollEl, slug); - if (!(el instanceof HTMLElement) || !scrollEl) { - return; - } - scrollMilkdownHeadingIntoView(scrollEl, el); - }); - }, []); - - /** 换文注入后再定位标题(wikilink 带 # 片段) */ - const navigateToHeadingWithRetry = useCallback((slug: string) => { - const myGen = (wikiHeadingNavRetryGenerationRef.current += 1); - const t0 = performance.now(); - - const step = () => { - if (wikiHeadingNavRetryGenerationRef.current !== myGen) { - return; - } - if (performance.now() - t0 > WIKI_HEADING_NAV_RETRY_BUDGET_MS) { - return; - } - const outer = editorScrollRef.current; - const scrollEl = outer?.querySelector("[data-milkdown-root]") as HTMLElement | null; - const el = findProseMirrorHeadingBySlug(scrollEl, slug); - if (el && scrollEl) { - scrollMilkdownHeadingIntoView(scrollEl, el); - return; - } - requestAnimationFrame(step); - }; - requestAnimationFrame(step); - }, []); - - useEffect(() => { - return () => { - wikiHeadingNavRetryGenerationRef.current += 1; - }; - }, []); + const { navigateToHeading, navigateToHeadingWithRetry } = useHeadingNavigation(editorScrollRef); const onOpenCoachMarkdownPath = useCallback( async (relPath: string, meta?: { headingFragment?: string | null }) => { @@ -832,6 +673,25 @@ function App() { [docState.openOrFocusTab, getCachedMarkdownBodyForEditor, navigateToHeadingWithRetry], ); + const handleToolbarTabSelect = useCallback((p: string) => { + if (p === docState.activePath && leftPanelView === "files") { + return; + } + void changeView("files").then((ok) => { + if (!ok) return; + logPerfMark("markdown.tab_switch.select", { + from: docState.activePath, + to: p, + }); + activePathSwitchTraceRef.current = startPerfTrace("markdown.tab_switch.to_next_frame", { + from: docState.activePath, + to: p, + }); + docState.setSaveError(null); + docState.setActivePath(p); + }); + }, [docState.activePath, leftPanelView, changeView, docState.setSaveError, docState.setActivePath]); + const openCognitiveReportFromPalette = useCallback(() => { setCommandPaletteOpen(false); setCognitiveReportOpen(true); @@ -846,56 +706,12 @@ function App() { [tauriRuntime], ); - /** 关闭窗口前刷盘;磁盘冲突或未写入失败时拦截或二次确认 */ - useEffect(() => { - if (!tauriRuntime || !appWindow) { - return; - } - let cancelled = false; - let unlisten: (() => void) | undefined; - void appWindow - .onCloseRequested(async (event) => { - event.preventDefault(); - try { - const { conflictDirtyPaths, saveFailed } = await flushDirtyBeforeExitRef.current(); - if (saveFailed) { - return; - } - if (conflictDirtyPaths.length > 0) { - const ok = await ask( - t("dialogs.closeWindowDiskConflict", { count: conflictDirtyPaths.length }), - { - title: t("dialogs.close"), - kind: "warning", - }, - ); - if (!ok) { - return; - } - } - await appWindow.destroy(); - } catch (e) { - // 已 preventDefault:异常时必须尽力 destroy,否则窗口永远无法关闭 - console.error(e); - try { - await appWindow.destroy(); - } catch (e2) { - console.error(e2); - } - } - }) - .then((fn) => { - if (cancelled) { - fn(); - return; - } - unlisten = fn; - }); - return () => { - cancelled = true; - unlisten?.(); - }; - }, [appWindow, tauriRuntime, t]); + useWindowLifecycle({ + tauriRuntime, + appWindow, + flushDirtyBeforeExit: docState.flushDirtyDocumentsBeforeExit, + hasAnyDirtyTab: docState.hasAnyDirtyTab, + }); const isMacPlatform = /Mac/i.test(navigator.userAgent); const isWindowsPlatform = /Windows/i.test(navigator.userAgent); @@ -935,98 +751,7 @@ function App() { [appWindow], ); - const renderWindowControls = (placement: "leading" | "trailing") => { - if (!tauriRuntime) { - return null; - } - - const renderMacControls = isMacPlatform && placement === "leading"; - const renderDesktopControls = !isMacPlatform && placement === "trailing"; - if (!renderMacControls && !renderDesktopControls) { - return null; - } - /* macOS:系统交通灯在标题栏内(tauri.macos.conf.json:Transparent + decorations) */ - if (renderMacControls) { - return null; - } - - return ( -
- - - -
- ); - }; return ( ) : null} -
-
- {renderWindowControls("leading")} - - {tauriRuntime && workspaceReady ? ( - - ) : null} -
-
-
- { - if (p === docState.activePath && leftPanelView === "files") { - return; - } - void changeView("files").then((ok) => { - if (!ok) return; - logPerfMark("markdown.tab_switch.select", { - from: docState.activePath, - to: p, - }); - activePathSwitchTraceRef.current = startPerfTrace("markdown.tab_switch.to_next_frame", { - from: docState.activePath, - to: p, - }); - docState.setSaveError(null); - docState.setActivePath(p); - }); - }} - onClose={(p) => void docState.closeTab(p)} - onCloseAll={() => void docState.closeAllTabs()} - /> -
-
- {tauriRuntime && workspaceReady && editorUsable && docState.saveFeedback !== "idle" ? ( - - {docState.saveFeedback === "pending_auto" - ? t("toolbar.autoSavePending") - : docState.saveFeedback === "saving" - ? t("toolbar.saving") - : docState.saveFeedback === "saved" - ? t("toolbar.saved") - : null} - - ) : null} - - - {renderWindowControls("trailing")} -
-
-
+ void docState.closeTab(p)} + onCloseAllTabs={() => void docState.closeAllTabs()} + onRenameTab={onRenameTabFromBar} + tauriDragExclude={tauriRuntime} + sidebarOpen={sidebarOpen} + onToggleSidebar={() => setSidebarOpen((o) => !o)} + rightPanelOpen={rightPanelOpen} + onToggleRightPanel={() => setRightPanelOpen((o) => !o)} + workspaceReady={workspaceReady} + editorUsable={editorUsable} + saveDisabled={saveDisabled} + saving={docState.saving} + saveFeedback={docState.saveFeedback} + onSave={() => void docState.handleSave()} + onOpenWorkspaceSearch={() => setWorkspaceSearchOpen(true)} + tauriRuntime={tauriRuntime} + isMacPlatform={isMacPlatform} + tauriDragExcludeProps={tauriDragExcludeProps} + tauriWindowDragProps={tauriWindowDragProps} + onTitlebarMouseDown={handleTitlebarMouseDown} + onTitlebarDoubleClick={handleTitlebarDoubleClick} + appWindow={appWindow} + />
)}
-
- {leftPanelView === "graph" ? ( -
- { - setLeftPanelView("files"); - void onOpenCoachMarkdownPath(relPath); - }} - /> -
- ) : leftPanelView === "thoughts" ? ( - thoughtManagementSessionActive ? ( -
- - { - setLeftPanelView("files"); - void onOpenCoachMarkdownPath(relPath); - }} - isPathKfPrivate={isPathKfPrivate} - /> - -
- ) : null - ) : ( -
- {docState.activePath != null && - docState.hasDiskStaleConflict(docState.activePath) && - editorUsable && ( -
- {t("diskNotice.text")} -
- - -
-
- )} - {docState.saveError ? ( -
- {docState.saveError} - -
- ) : null} -
- {docState.activePath ? ( -
- {loadingDoc &&

{t("main.loading")}

} - {!loadingDoc && loadError && ( -

{t("main.loadError", { details: loadError })}

- )} - {!loadingDoc && !loadError && current && ( - <> -
- - - {docState.activePath} - -
- -
-
-
-
-
- - {t("settings.loading")} -
- } - > - { - crepeEditorApiRef.current = api; - }} - onEditorDispose={() => { - crepeEditorApiRef.current = null; - }} - onSaveAsThought={setEditorSaveThoughtText} - /> - -
- {showMarkdownSource ? ( -