Browse Source

fix(ui): 修复章节目录重载定位

章节目录异步渲染完成后定位当前打开章节。
未打开章节时滚动到目录末尾,并覆盖延迟恢复竞态。
darknessomi 2 weeks ago
parent
commit
f7b9729d0c

+ 7 - 0
src/components/layout/knowledge-tree.spec.tsx

@@ -6,6 +6,13 @@ const source = readFileSync(resolve(__dirname, "knowledge-tree.tsx"), "utf8")
 const previewSource = readFileSync(resolve(__dirname, "preview-panel.tsx"), "utf8")
 
 describe("KnowledgeTree chapter memory extraction menu", () => {
+  it("positions the chapter directory once its async file tree is rendered", () => {
+    expect(source).toContain("scrollChapterDirectory(container, selectedChapterPath)")
+    expect(source).toContain('filterType !== "chapter" || !project || sectionNodes.length === 0')
+    expect(source).toContain("isChapterPathInProject(selectedFile, projectPath)")
+    expect(source).toContain("previousScroll.selectedChapterPath !== null || selectedChapterPath === null")
+  })
+
   it("places one-click all chapter memory extraction in the chapter right-click menu", () => {
     expect(source).toContain("handleExtractAllChapterMemories")
     expect(source).toContain("一键提取所有章节")

+ 28 - 1
src/components/layout/knowledge-tree.tsx

@@ -7,8 +7,9 @@ import { useWikiStore } from "@/stores/wiki-store"
 import { createDirectory, deleteFile, fileExists, listDirectory, readFile, writeFile, openFileLocation, copyFile } from "@/commands/fs"
 import type { FileNode } from "@/types/wiki"
 import { buildChapterWordCountLabel, getChapterStatusLabel } from "@/lib/chapter-display"
-import { normalizePath } from "@/lib/path-utils"
+import { isChapterPathInProject, normalizePath } from "@/lib/path-utils"
 import { countChapterBodyWords } from "@/lib/chapter-word-count"
+import { scrollChapterDirectory } from "@/lib/chapter-directory-scroll"
 import { normalizeChapterStatus, type ChapterStatus } from "@/lib/novel/chapter-meta"
 import { moveFileToTrash } from "@/lib/trash"
 import { makeChapterFileName, makeDefaultChapterTitle, makeSafeFileSlug } from "@/lib/wiki-filename"
@@ -382,6 +383,10 @@ export function KnowledgeTree({
   const lastPointerTypeRef = useRef<string>("mouse")
   const removeGlobalPointerListenersRef = useRef<(() => void) | null>(null)
   const containerRef = useRef<HTMLDivElement>(null)
+  const initialChapterScrollRef = useRef<{
+    projectPath: string
+    selectedChapterPath: string | null
+  } | null>(null)
 
   useEffect(() => { dragSourceRef.current = dragSource }, [dragSource])
   useEffect(() => { dragInsertIndexRef.current = dragInsertIndex }, [dragInsertIndex])
@@ -565,6 +570,28 @@ export function KnowledgeTree({
     return sectionNode?.children ?? []
   }, [fileTree, sectionRootPath])
 
+  useEffect(() => {
+    if (filterType !== "chapter" || !project || sectionNodes.length === 0) return
+
+    const projectPath = normalizePath(project.path)
+    const selectedChapterPath = selectedFile && isChapterPathInProject(selectedFile, projectPath)
+      ? normalizePath(selectedFile)
+      : null
+    const previousScroll = initialChapterScrollRef.current
+    if (
+      previousScroll?.projectPath === projectPath
+      && (previousScroll.selectedChapterPath !== null || selectedChapterPath === null)
+    ) return
+
+    const container = containerRef.current
+    if (!container) return
+
+    const result = scrollChapterDirectory(container, selectedChapterPath)
+    if (result) {
+      initialChapterScrollRef.current = { projectPath, selectedChapterPath }
+    }
+  }, [filterType, project, sectionNodes, selectedFile])
+
   const volumeFolders = useMemo(() => {
     return sectionNodes.filter((node) => node.is_dir).map((node) => ({
       name: node.name,

+ 58 - 0
src/lib/chapter-directory-scroll.spec.ts

@@ -0,0 +1,58 @@
+// @vitest-environment jsdom
+
+import { describe, expect, it, vi } from "vitest"
+import { scrollChapterDirectory } from "./chapter-directory-scroll"
+
+function createDirectory() {
+  const viewport = document.createElement("div")
+  viewport.dataset.slot = "scroll-area-viewport"
+  Object.defineProperty(viewport, "scrollHeight", { configurable: true, value: 1200 })
+
+  const container = document.createElement("div")
+  const firstRow = document.createElement("div")
+  firstRow.dataset.pagePath = "C:/Novel/wiki/chapters/第1章.md"
+  const currentRow = document.createElement("div")
+  currentRow.dataset.pagePath = "C:/Novel/wiki/chapters/第80章.md"
+  currentRow.scrollIntoView = vi.fn()
+
+  container.append(firstRow, currentRow)
+  viewport.append(container)
+
+  return { viewport, container, currentRow }
+}
+
+describe("scrollChapterDirectory", () => {
+  it("scrolls the restored current chapter into the center", () => {
+    const { viewport, container, currentRow } = createDirectory()
+
+    const result = scrollChapterDirectory(
+      container,
+      "C:\\Novel\\wiki\\chapters\\第80章.md",
+    )
+
+    expect(result).toBe("selected")
+    expect(currentRow.scrollIntoView).toHaveBeenCalledWith({
+      behavior: "auto",
+      block: "center",
+      inline: "nearest",
+    })
+    expect(viewport.scrollTop).toBe(0)
+  })
+
+  it("scrolls to the end when no chapter is open", () => {
+    const { viewport, container } = createDirectory()
+
+    expect(scrollChapterDirectory(container, null)).toBe("end")
+    expect(viewport.scrollTop).toBe(1200)
+  })
+
+  it("waits for a selected chapter row instead of falling back to the end", () => {
+    const { viewport, container } = createDirectory()
+
+    expect(scrollChapterDirectory(
+      container,
+      "C:/Novel/wiki/chapters/第100章.md",
+    )).toBeNull()
+    expect(viewport.scrollTop).toBe(0)
+  })
+})

+ 30 - 0
src/lib/chapter-directory-scroll.ts

@@ -0,0 +1,30 @@
+import { normalizePath } from "@/lib/path-utils"
+
+export type ChapterDirectoryScrollResult = "selected" | "end" | null
+
+/**
+ * Position the chapter directory after its async rows have been rendered.
+ * A missing selected row returns null so the caller can retry on the next tree update.
+ */
+export function scrollChapterDirectory(
+  container: HTMLElement,
+  selectedChapterPath: string | null,
+): ChapterDirectoryScrollResult {
+  const viewport = container.closest<HTMLElement>('[data-slot="scroll-area-viewport"]')
+  if (!viewport) return null
+
+  if (selectedChapterPath) {
+    const normalizedSelectedPath = normalizePath(selectedChapterPath)
+    const selectedRow = Array.from(
+      container.querySelectorAll<HTMLElement>("[data-page-path]"),
+    ).find((row) => normalizePath(row.dataset.pagePath ?? "") === normalizedSelectedPath)
+
+    if (!selectedRow) return null
+
+    selectedRow.scrollIntoView({ behavior: "auto", block: "center", inline: "nearest" })
+    return "selected"
+  }
+
+  viewport.scrollTop = viewport.scrollHeight
+  return "end"
+}