Files
deerflow-code/frontend-web/src/open-canvas/components/artifact/MarkdownAiEditor.tsx
T
2026-08-16 19:17:45 +08:00

223 lines
7.5 KiB
TypeScript

import { useCallback, useRef, useState } from "react";
import { CanvasThreadProvider } from "../../contexts/CanvasThreadContext";
import { CanvasUIProvider, useCanvasUIContext } from "../../contexts/CanvasUIContext";
import { TiptapEditor } from "../../editor/TiptapEditor";
import { useWritingEditor } from "../../hooks/useWritingEditor";
import { useWritingRewrite } from "../../hooks/useWritingRewrite";
import {
applyInsert,
applyReplace,
createRewriteSelectionSnapshot,
isRewriteSelectionSnapshotCurrent,
type RewriteSelectionSnapshot,
} from "../../utils/apply-rewrite";
import type { RewriteAction, RewriteTone } from "../../api/rewrite";
import { toast } from "sonner";
import { WritingAiMenu } from "./WritingAiMenu";
import { WritingBubbleMenu } from "./WritingBubbleMenu";
import { WritingParagraphMenu } from "./WritingParagraphMenu";
import { WritingRewritePreview } from "./WritingRewritePreview";
interface MarkdownAiEditorProps {
/** 原始 markdown(编辑缓冲),由父级持有;编辑时通过 onChange 回写。 */
markdown: string;
/** 编辑回调:返回最新 markdown,父级据此 setDraftContent 并标记有改动。 */
onChange?: (markdown: string) => void;
/** 当前线程 id:透传给 AI 改写接口,便于后端结合上下文。 */
threadId?: string | null;
/** 只读:禁用编辑、AI 改写。 */
readOnly?: boolean;
className?: string;
}
interface PanelPos {
x: number;
y: number;
}
type FlowPhase = "idle" | "menu" | "preview";
interface LastAction {
action: RewriteAction;
tone?: RewriteTone;
customInstruction?: string;
referenceText?: string;
}
/**
* 沙箱 md 的「AI 写作」编辑器:复用 AI 写作 那套 TipTap 编辑器 + 划词「AI 改写」气泡/段落
* 菜单 + 改写流式预览(润色/扩写/缩写/改语气/仿写/自定义指令)。自带 CanvasThread/CanvasUI
* Provider,故可直接嵌入任意页面,无需在外层包裹整页 Provider。
*
* 与 AI 写作 整页编辑器(TextArtifactRenderer)的差异:去掉对 CanvasGraphContext 的耦合(不
* 走全局 artifact 状态),去掉自带的工具栏/目录栏(保存/导出由宿主 ArtifactFileDetail 头部按钮
* 负责),更贴合侧边栏内的紧凑展示。
*/
export function MarkdownAiEditor(props: MarkdownAiEditorProps) {
return (
<CanvasThreadProvider initialThreadId={props.threadId ?? null}>
<CanvasUIProvider>
<MarkdownAiEditorInner {...props} />
</CanvasUIProvider>
</CanvasThreadProvider>
);
}
function MarkdownAiEditorInner({
markdown,
onChange,
readOnly = false,
className,
}: MarkdownAiEditorProps) {
const { writingSelection } = useCanvasUIContext();
const [phase, setPhase] = useState<FlowPhase>("idle");
const [panelPos, setPanelPos] = useState<PanelPos>({ x: 0, y: 0 });
const lastActionRef = useRef<LastAction | null>(null);
const candidateSnapshotRef = useRef<RewriteSelectionSnapshot | null>(null);
const editor = useWritingEditor({
markdown,
onChange,
editable: !readOnly,
placeholder: "空白文档…",
});
const { status, result, thinking, error, rewrite, rewriteFollowUp, reset } =
useWritingRewrite(editor);
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();
candidateSnapshotRef.current = null;
setPhase("idle");
reset();
}, [reset, editor]);
const snapshotCurrentSelection = useCallback(() => {
if (!editor || !writingSelection) return false;
candidateSnapshotRef.current = createRewriteSelectionSnapshot(editor, writingSelection);
return true;
}, [editor, writingSelection]);
const handleAction = useCallback(
(
action: RewriteAction,
tone?: RewriteTone,
customInstruction?: string,
referenceText?: string,
) => {
if (!snapshotCurrentSelection()) return;
lastActionRef.current = { action, tone, customInstruction, referenceText };
rewrite(action, tone, customInstruction, referenceText);
setPhase("preview");
},
[rewrite, snapshotCurrentSelection],
);
const handleRetry = useCallback(() => {
if (!lastActionRef.current) return;
if (!snapshotCurrentSelection()) return;
const { action, tone, customInstruction, referenceText } = lastActionRef.current;
rewrite(action, tone, customInstruction, referenceText);
}, [rewrite, snapshotCurrentSelection]);
const handleFollowUp = useCallback(
(instruction: string) => {
if (!lastActionRef.current) return;
if (!snapshotCurrentSelection()) return;
const { action, tone, referenceText } = lastActionRef.current;
rewriteFollowUp(instruction, action, tone, referenceText);
},
[rewriteFollowUp, snapshotCurrentSelection],
);
const canApplyCandidate = useCallback(() => {
if (!editor || !candidateSnapshotRef.current) return false;
if (isRewriteSelectionSnapshotCurrent(editor, candidateSnapshotRef.current)) return true;
toast.error("原文已变化,请重新生成候选稿。", {
description: "为避免将过期候选替换到错误位置,本次内容没有写入文档。",
});
return false;
}, [editor]);
const handleReplace = useCallback(() => {
const snapshot = candidateSnapshotRef.current;
if (!editor || !snapshot || !result || !canApplyCandidate()) return;
applyReplace(editor, snapshot, result);
closeAll();
}, [editor, result, canApplyCandidate, closeAll]);
const handleInsert = useCallback(() => {
const snapshot = candidateSnapshotRef.current;
if (!editor || !snapshot || !result || !canApplyCandidate()) return;
applyInsert(editor, snapshot, result);
closeAll();
}, [editor, result, canApplyCandidate, closeAll]);
return (
<div className={className ?? "h-full flex flex-col overflow-hidden"}>
<div className="flex-1 overflow-y-auto">
<div className="max-w-3xl mx-auto px-6 py-6">
{editor && !readOnly && (
<>
<WritingBubbleMenu editor={editor} onAiRewrite={openAiMenu} />
<WritingParagraphMenu editor={editor} onAiRewrite={openAiMenu} />
</>
)}
<TiptapEditor editor={editor} className="tiptap-writing-editor" />
</div>
</div>
{!readOnly && phase === "menu" && (
<WritingAiMenu
pos={panelPos}
status={status}
error={error}
onAction={handleAction}
onAbort={reset}
onRetry={handleRetry}
onClose={closeAll}
/>
)}
{!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>
);
}