检索逻辑修改
This commit is contained in:
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;
|
||||
}
|
||||
Reference in new issue
Block a user