223 lines
7.5 KiB
TypeScript
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>
|
|
);
|
|
}
|