338 lines
12 KiB
TypeScript
338 lines
12 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { ArrowDown } from "lucide-react";
|
|
import ReactMarkdown from "react-markdown";
|
|
import remarkGfm from "remark-gfm";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import type { CanvasArtifactText } from "../../types/artifact";
|
|
import { TiptapEditor } from "../../editor/TiptapEditor";
|
|
import { useWritingEditor } from "../../hooks/useWritingEditor";
|
|
import { useWritingRewrite } from "../../hooks/useWritingRewrite";
|
|
import { WritingToolbar } from "./WritingToolbar";
|
|
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";
|
|
import type { RewriteAction, RewriteTone } from "../../api/rewrite";
|
|
|
|
interface TextArtifactRendererProps {
|
|
content: CanvasArtifactText;
|
|
isStreaming?: boolean;
|
|
/** 完成态只读(如历史回看):保留目录 + 排版,禁用编辑与 AI 润色 */
|
|
readOnly?: boolean;
|
|
}
|
|
|
|
interface PanelPos {
|
|
x: number;
|
|
y: number;
|
|
}
|
|
|
|
/** Phase of the AI rewrite flow */
|
|
type FlowPhase = "idle" | "menu" | "preview";
|
|
|
|
interface LastAction {
|
|
action: RewriteAction;
|
|
tone?: RewriteTone;
|
|
customInstruction?: string;
|
|
}
|
|
|
|
export function TextArtifactRenderer({ content, isStreaming, readOnly = false }: TextArtifactRendererProps) {
|
|
const { artifact, setArtifact } = useCanvasGraphContext();
|
|
const { writingSelection } = useCanvasUIContext();
|
|
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
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 });
|
|
const lastActionRef = useRef<LastAction | null>(null);
|
|
|
|
const handleEditorChange = useCallback(
|
|
(markdown: string) => {
|
|
if (!artifact) return;
|
|
setArtifact({
|
|
...artifact,
|
|
contents: artifact.contents.map((c) =>
|
|
c.index === artifact.currentIndex && c.type === "text"
|
|
? { ...c, fullMarkdown: markdown }
|
|
: c,
|
|
),
|
|
});
|
|
},
|
|
[artifact, setArtifact],
|
|
);
|
|
|
|
const editor = useWritingEditor({
|
|
markdown: content.fullMarkdown,
|
|
onChange: handleEditorChange,
|
|
editable: !isStreaming && !readOnly,
|
|
placeholder: "开始写作…",
|
|
});
|
|
|
|
const { status, result, thinking, error, rewrite, rewriteFollowUp, reset } = useWritingRewrite(editor);
|
|
|
|
// ── Streaming auto-scroll ──────────────────────────────────────────────
|
|
useEffect(() => {
|
|
if (!isStreaming) {
|
|
autoScrollRef.current = true;
|
|
setShowScrollBtn(false);
|
|
return;
|
|
}
|
|
if (!autoScrollRef.current) return;
|
|
const el = scrollRef.current;
|
|
if (el) el.scrollTop = el.scrollHeight;
|
|
}, [isStreaming, content.fullMarkdown]);
|
|
|
|
const handleScroll = useCallback(() => {
|
|
if (!isStreaming) return;
|
|
const el = scrollRef.current;
|
|
if (!el) return;
|
|
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
|
autoScrollRef.current = atBottom;
|
|
setShowScrollBtn(!atBottom);
|
|
}, [isStreaming]);
|
|
|
|
const handleScrollToBottom = useCallback(() => {
|
|
const el = scrollRef.current;
|
|
if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
|
autoScrollRef.current = true;
|
|
setShowScrollBtn(false);
|
|
}, []);
|
|
|
|
// ── AI rewrite flow handlers ───────────────────────────────────────────
|
|
|
|
/** Compute anchor position from current DOM selection */
|
|
const computePos = useCallback((): PanelPos => {
|
|
const sel = window.getSelection();
|
|
if (sel && sel.rangeCount > 0) {
|
|
const rect = sel.getRangeAt(0).getBoundingClientRect();
|
|
if (rect.width || rect.height) {
|
|
return { x: rect.left, y: rect.bottom + 6 };
|
|
}
|
|
}
|
|
return { x: window.innerWidth / 2 - 90, y: window.innerHeight / 2 };
|
|
}, []);
|
|
|
|
const openAiMenu = useCallback(() => {
|
|
reset();
|
|
setPanelPos(computePos());
|
|
setPhase("menu");
|
|
}, [reset, computePos]);
|
|
|
|
const closeAll = useCallback(() => {
|
|
editor?.commands.clearAiHighlight();
|
|
setPhase("idle");
|
|
reset();
|
|
}, [reset, editor]);
|
|
|
|
const handleAction = useCallback(
|
|
(action: RewriteAction, tone?: RewriteTone, customInstruction?: string) => {
|
|
lastActionRef.current = { action, tone, customInstruction };
|
|
rewrite(action, tone, customInstruction);
|
|
setPhase("preview");
|
|
},
|
|
[rewrite],
|
|
);
|
|
|
|
const handleRetry = useCallback(() => {
|
|
if (!lastActionRef.current) return;
|
|
const { action, tone, customInstruction } = lastActionRef.current;
|
|
rewrite(action, tone, customInstruction);
|
|
}, [rewrite]);
|
|
|
|
const handleFollowUp = useCallback(
|
|
(instruction: string) => {
|
|
if (!lastActionRef.current) return;
|
|
const { action, tone } = lastActionRef.current;
|
|
rewriteFollowUp(instruction, action, tone);
|
|
},
|
|
[rewriteFollowUp],
|
|
);
|
|
|
|
const handleReplace = useCallback(() => {
|
|
if (!editor || !writingSelection || !result) return;
|
|
applyReplace(editor, writingSelection, result);
|
|
closeAll();
|
|
}, [editor, writingSelection, result, closeAll]);
|
|
|
|
const handleInsert = useCallback(() => {
|
|
if (!editor || !writingSelection || !result) return;
|
|
applyInsert(editor, writingSelection, result);
|
|
closeAll();
|
|
}, [editor, writingSelection, result, closeAll]);
|
|
|
|
// ── Empty state ────────────────────────────────────────────────────────
|
|
if (!content.fullMarkdown && !isStreaming) {
|
|
return (
|
|
<div className="h-full flex items-center justify-center">
|
|
<p className="text-sm text-muted-foreground">
|
|
空白文档,发送消息让 AI 填充内容
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Streaming mode ────────────────────────────────────────────────────
|
|
if (isStreaming) {
|
|
return (
|
|
<div className="relative h-full flex flex-col overflow-hidden">
|
|
<div
|
|
ref={scrollRef}
|
|
className="flex-1 overflow-y-auto"
|
|
onScroll={handleScroll}
|
|
>
|
|
<div className="max-w-2xl mx-auto px-10 pt-10 pb-32">
|
|
<ReactMarkdown
|
|
remarkPlugins={[remarkGfm]}
|
|
components={{
|
|
h1: ({ children }) => (
|
|
<h1 className="text-[1.625rem] font-bold tracking-tight mb-3 mt-8 first:mt-0 text-foreground border-b border-border pb-2 leading-tight">
|
|
{children}
|
|
</h1>
|
|
),
|
|
h2: ({ children }) => (
|
|
<h2 className="text-[1.25rem] font-semibold tracking-tight mb-2.5 mt-7 text-foreground leading-snug">
|
|
{children}
|
|
</h2>
|
|
),
|
|
h3: ({ children }) => (
|
|
<h3 className="text-[1.0625rem] font-semibold tracking-tight mb-2 mt-5 text-foreground leading-snug">
|
|
{children}
|
|
</h3>
|
|
),
|
|
p: ({ children }) => (
|
|
<p className="mb-4 leading-[1.8] text-[15px] text-foreground indent-[2em]">
|
|
{children}
|
|
</p>
|
|
),
|
|
ul: ({ children }) => (
|
|
<ul className="mb-4 ml-6 list-disc space-y-1.5 text-[15px] text-foreground">
|
|
{children}
|
|
</ul>
|
|
),
|
|
ol: ({ children }) => (
|
|
<ol className="mb-4 ml-6 list-decimal space-y-1.5 text-[15px] text-foreground">
|
|
{children}
|
|
</ol>
|
|
),
|
|
li: ({ children }) => <li className="leading-7">{children}</li>,
|
|
blockquote: ({ children }) => (
|
|
<blockquote className="border-l-4 border-primary/30 pl-4 my-4 text-muted-foreground italic text-sm">
|
|
{children}
|
|
</blockquote>
|
|
),
|
|
pre: ({ children }) => (
|
|
<pre className="mb-4 rounded-md bg-muted/60 overflow-x-auto">
|
|
{children}
|
|
</pre>
|
|
),
|
|
code: ({ className, children }) => {
|
|
const isBlock = Boolean(className?.startsWith("language-"));
|
|
return isBlock ? (
|
|
<code className="block p-4 text-xs font-mono text-foreground leading-relaxed">
|
|
{children}
|
|
</code>
|
|
) : (
|
|
<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono text-foreground">
|
|
{children}
|
|
</code>
|
|
);
|
|
},
|
|
}}
|
|
>
|
|
{content.fullMarkdown}
|
|
</ReactMarkdown>
|
|
<span className="inline-block w-0.5 h-4 bg-primary animate-pulse align-middle ml-0.5" />
|
|
</div>
|
|
</div>
|
|
|
|
{showScrollBtn && (
|
|
<div className="absolute bottom-4 left-0 right-0 flex justify-center z-10 pointer-events-none">
|
|
<Button
|
|
size="sm"
|
|
variant="secondary"
|
|
className="pointer-events-auto shadow-md gap-1.5"
|
|
onClick={handleScrollToBottom}
|
|
>
|
|
<ArrowDown className="w-3.5 h-3.5" />
|
|
回到底部
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Edit mode(写作完成态 / 历史回看只读)────────────────────────────────
|
|
return (
|
|
<div className="h-full flex flex-col overflow-hidden">
|
|
<WritingToolbar
|
|
editor={editor}
|
|
markdown={content.fullMarkdown}
|
|
title={content.title}
|
|
readOnly={readOnly}
|
|
/>
|
|
|
|
<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 && !readOnly && (
|
|
<>
|
|
<WritingBubbleMenu editor={editor} onAiRewrite={openAiMenu} />
|
|
<WritingParagraphMenu editor={editor} onAiRewrite={openAiMenu} />
|
|
</>
|
|
)}
|
|
<TiptapEditor editor={editor} className="tiptap-writing-editor" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Phase: AI action menu */}
|
|
{!readOnly && phase === "menu" && (
|
|
<WritingAiMenu
|
|
pos={panelPos}
|
|
status={status}
|
|
error={error}
|
|
onAction={handleAction}
|
|
onAbort={reset}
|
|
onRetry={handleRetry}
|
|
onClose={closeAll}
|
|
/>
|
|
)}
|
|
|
|
{/* Phase: result preview */}
|
|
{!readOnly && phase === "preview" && (
|
|
<WritingRewritePreview
|
|
pos={panelPos}
|
|
status={status}
|
|
originalText={writingSelection?.text ?? ""}
|
|
result={result}
|
|
thinking={thinking}
|
|
error={error}
|
|
action={lastActionRef.current?.action ?? "polish"}
|
|
tone={lastActionRef.current?.tone}
|
|
onReplace={handleReplace}
|
|
onInsert={handleInsert}
|
|
onRetry={handleRetry}
|
|
onAbort={reset}
|
|
onClose={closeAll}
|
|
onFollowUp={handleFollowUp}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|