检索逻辑修改

This commit is contained in:
董宏凯 committed 2026-05-19 15:14:51 +08:00
1 parent 294d06f22b
commit 2402a84c71
5 files changed
+346 -15

No files matched your search

@@ -0,0 +1,192 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { Editor } from "@tiptap/react";
import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { cn } from "@/lib/utils";
import { useDocumentOutline } from "../../hooks/useDocumentOutline";
import type { OutlineHeading } from "../../hooks/useDocumentOutline";
interface DraftOutlineSidebarProps {
/** Tiptap 编辑器实例,用于提取标题并解析标题 DOM */
editor: Editor | null;
/** 文档滚动容器,点击目录项 / scroll-spy 时在其内部滚动与定位 */
scrollEl: HTMLElement | null;
}
/** 点击跳转时标题距容器顶部预留的间距(px) */
const SCROLL_TOP_OFFSET = 16;
/** scroll-spy:标题顶部进入此阈值内即视为「当前章节」(px) */
const ACTIVE_THRESHOLD = 80;
/**
* 目录项的展示层级:
* - 文档 H1 = 文章标题,单独作顶部标题块(不进列表)
* - 文档 H2 = 章节名称 → 列表一级项
* - 文档 H3 = 要点 → 列表二级项
*/
function displayLevel(h: OutlineHeading): 1 | 2 {
return h.level >= 3 ? 2 : 1;
}
/**
* 草稿文档左侧目录栏(仿豆包 / Word 目录窗格):
* 顶部独立标题块 + 章节/要点两级列表,支持点击平滑跳转、滚动高亮、折叠。
*/
export function DraftOutlineSidebar({ editor, scrollEl }: DraftOutlineSidebarProps) {
const headings = useDocumentOutline(editor);
const [activeId, setActiveId] = useState<string | null>(null);
const [collapsed, setCollapsed] = useState(false);
// 第一个 H1 作为文章标题,其余标题进目录列表
const { titleHeading, items } = useMemo(() => {
const titleIdx = headings.findIndex((h) => h.level === 1);
return {
titleHeading: titleIdx >= 0 ? headings[titleIdx] : null,
items: headings.filter((_, i) => i !== titleIdx),
};
}, [headings]);
const scrollToHeading = useCallback(
(pos: number, id: string) => {
if (!editor || !scrollEl) return;
const dom = editor.view.nodeDOM(pos) as HTMLElement | null;
if (!dom) return;
const elTop = dom.getBoundingClientRect().top;
const containerTop = scrollEl.getBoundingClientRect().top;
const target = scrollEl.scrollTop + (elTop - containerTop) - SCROLL_TOP_OFFSET;
scrollEl.scrollTo({ top: Math.max(0, target), behavior: "smooth" });
setActiveId(id);
},
[editor, scrollEl],
);
// scroll-spy:随文档滚动高亮当前章节(rAF 节流)
useEffect(() => {
if (!editor || !scrollEl || headings.length === 0) return;
let raf = 0;
const recompute = () => {
raf = 0;
const containerTop = scrollEl.getBoundingClientRect().top;
let current = headings[0].id;
for (const h of headings) {
const dom = editor.view.nodeDOM(h.pos) as HTMLElement | null;
if (!dom) continue;
const relTop = dom.getBoundingClientRect().top - containerTop;
// 标题在阈值内或之上 → 当前章节;首个落在阈值下方时其余皆下方,停止
if (relTop <= ACTIVE_THRESHOLD) {
current = h.id;
} else {
break;
}
}
setActiveId(current);
};
const onScroll = () => {
if (raf) return;
raf = requestAnimationFrame(recompute);
};
recompute();
scrollEl.addEventListener("scroll", onScroll, { passive: true });
return () => {
if (raf) cancelAnimationFrame(raf);
scrollEl.removeEventListener("scroll", onScroll);
};
}, [editor, scrollEl, headings]);
// 没有可导航的章节时不渲染目录栏
if (items.length === 0) return null;
// 折叠态:仅留一条窄轨 + 展开按钮
if (collapsed) {
return (
<div className="w-9 shrink-0 border-r bg-muted/10 flex flex-col items-center pt-2">
<button
type="button"
onClick={() => setCollapsed(false)}
title="展开目录"
className="size-6 flex items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
>
<PanelLeftOpen className="size-4" />
</button>
</div>
);
}
return (
<div className="w-60 shrink-0 border-r bg-muted/10 flex flex-col overflow-hidden">
{/* 头部:目录标签 + 折叠按钮 */}
<div className="h-9 flex items-center justify-between pl-3 pr-1.5 shrink-0">
<span className="text-[11px] font-medium tracking-wide text-muted-foreground/80">目录</span>
<button
type="button"
onClick={() => setCollapsed(true)}
title="收起目录"
className="size-6 flex items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
>
<PanelLeftClose className="size-3.5" />
</button>
</div>
{/* 文章标题块 */}
{titleHeading && (
<button
type="button"
onClick={() => scrollToHeading(titleHeading.pos, titleHeading.id)}
title={titleHeading.text}
className={cn(
"shrink-0 w-full text-left px-3 pb-2.5 pt-0.5 border-b transition-colors",
"text-[13px] font-semibold leading-snug line-clamp-2",
activeId === titleHeading.id
? "text-primary"
: "text-foreground hover:text-primary",
)}
>
{titleHeading.text}
</button>
)}
{/* 章节 / 要点列表 */}
<nav className="flex-1 overflow-y-auto py-1.5">
{items.map((h) => {
const lvl = displayLevel(h);
const isActive = h.id === activeId;
return (
<button
key={h.id}
type="button"
onClick={() => scrollToHeading(h.pos, h.id)}
title={h.text}
className={cn(
"group flex items-center w-full text-left py-1.5 pr-2 border-l-2 transition-colors",
lvl === 1 ? "pl-3 text-xs" : "pl-6 text-[11px]",
isActive
? "border-primary bg-primary/[0.07] text-primary font-medium"
: cn(
"border-transparent hover:bg-muted/50",
lvl === 1
? "text-foreground/85 font-medium hover:text-foreground"
: "text-muted-foreground hover:text-foreground",
),
)}
>
{/* 二级(要点)项前的小圆点 */}
{lvl === 2 && (
<span
className={cn(
"shrink-0 size-1 rounded-full mr-1.5",
isActive ? "bg-primary" : "bg-muted-foreground/40",
)}
/>
)}
<span className="truncate">{h.text}</span>
</button>
);
})}
</nav>
</div>
);
}
@@ -13,6 +13,7 @@ import { WritingBubbleMenu } from "./WritingBubbleMenu";
import { WritingParagraphMenu } from "./WritingParagraphMenu";
import { WritingAiMenu } from "./WritingAiMenu";
import { WritingRewritePreview } from "./WritingRewritePreview";
import { DraftOutlineSidebar } from "./DraftOutlineSidebar";
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
import { useCanvasUIContext } from "../../contexts/CanvasUIContext";
import { applyReplace, applyInsert } from "../../utils/apply-rewrite";
@@ -45,6 +46,9 @@ export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRende
const [showScrollBtn, setShowScrollBtn] = useState(false);
const autoScrollRef = useRef(true);
// 编辑模式文档滚动容器:用 state 而非 ref,使目录栏在容器挂载后能拿到它
const [docScrollEl, setDocScrollEl] = useState<HTMLDivElement | null>(null);
// ── AI rewrite flow state ──────────────────────────────────────────────
const [phase, setPhase] = useState<FlowPhase>("idle");
const [panelPos, setPanelPos] = useState<PanelPos>({ x: 0, y: 0 });
@@ -271,15 +275,21 @@ export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRende
<div className="h-full flex flex-col overflow-hidden">
<WritingToolbar editor={editor} />
<div className="flex-1 overflow-y-auto">
<div className="max-w-2xl mx-auto px-10 pt-10">
{editor && (
<>
<WritingBubbleMenu editor={editor} onAiRewrite={openAiMenu} />
<WritingParagraphMenu editor={editor} onAiRewrite={openAiMenu} />
</>
)}
<TiptapEditor editor={editor} className="tiptap-writing-editor" />
<div className="flex-1 flex min-h-0 overflow-hidden">
{/* 左侧目录栏(仿豆包):文档标题层级导航 */}
<DraftOutlineSidebar editor={editor} scrollEl={docScrollEl} />
{/* 文档滚动区 */}
<div ref={setDocScrollEl} className="flex-1 overflow-y-auto">
<div className="max-w-2xl mx-auto px-10 pt-10">
{editor && (
<>
<WritingBubbleMenu editor={editor} onAiRewrite={openAiMenu} />
<WritingParagraphMenu editor={editor} onAiRewrite={openAiMenu} />
</>
)}
<TiptapEditor editor={editor} className="tiptap-writing-editor" />
</div>
</div>
</div>
@@ -0,0 +1,72 @@
import { useEffect, useState } from "react";
import type { Editor } from "@tiptap/react";
/** 文档目录中的一个标题项 */
export interface OutlineHeading {
/** React key + scroll-spy 标识,按 ProseMirror 位置生成 */
id: string;
/** 标题层级,仅保留 1~3 */
level: 1 | 2 | 3;
/** 标题纯文本 */
text: string;
/** 标题节点在 ProseMirror 文档中的位置,用于解析 DOM 元素 */
pos: number;
}
/** 仅纳入目录的最大标题层级(H1~H3) */
const MAX_LEVEL = 3;
/** 内容变化后重建目录的防抖间隔(ms) */
const DEBOUNCE_MS = 200;
/**
* 从 Tiptap 编辑器实时提取 H1~H3 标题,构成文档目录。
*
* 首次挂载立即构建一次;之后监听 `editor.on('update')`,按 DEBOUNCE_MS 防抖重建,
* 避免逐字输入时频繁全量遍历。
*/
export function useDocumentOutline(editor: Editor | null): OutlineHeading[] {
const [headings, setHeadings] = useState<OutlineHeading[]>([]);
useEffect(() => {
if (!editor) {
setHeadings([]);
return;
}
const compute = () => {
const out: OutlineHeading[] = [];
editor.state.doc.descendants((node, pos) => {
if (node.type.name !== "heading") return true;
const level = node.attrs.level as number;
if (level >= 1 && level <= MAX_LEVEL) {
out.push({
id: `heading-${pos}`,
level: level as 1 | 2 | 3,
text: node.textContent.trim() || "(无标题)",
pos,
});
}
return true;
});
setHeadings(out);
};
// 首次立即构建
compute();
// 内容变化时防抖重建
let timer: ReturnType<typeof setTimeout> | undefined;
const onUpdate = () => {
clearTimeout(timer);
timer = setTimeout(compute, DEBOUNCE_MS);
};
editor.on("update", onUpdate);
return () => {
clearTimeout(timer);
editor.off("update", onUpdate);
};
}, [editor]);
return headings;
}