import { useEffect, useMemo, useState, type ReactNode } from "react"; import { App, Button, ColorPicker, Dropdown, Input, Modal, Popover, Tooltip } from "antd"; import type { Editor, JSONContent } from "@tiptap/core"; import { EditorContent, useEditor } from "@tiptap/react"; import { AlignCenter, AlignJustify, AlignLeft, AlignRight, Bold, ChevronDown, Code2, Eraser, Highlighter, Italic, Link2, List, ListOrdered, Minus, MoreHorizontal, Quote, Redo2, Save, Strikethrough, Underline, Undo2, X, } from "lucide-react"; import { canvasThemes } from "@/lib/canvas-theme"; import { createCanvasRichTextExtensions, isSafeCanvasRichTextLink } from "@/lib/canvas/canvas-rich-text"; import { useThemeStore } from "@/stores/use-theme-store"; import type { CanvasNodeData } from "@/types/canvas"; type CanvasTextEditorModalProps = { node: CanvasNodeData | null; open: boolean; onClose: () => void; onSave: (nodeId: string, title: string, content: string, richText: Record) => Promise | void; }; export function CanvasTextEditorModal({ node, open, onClose, onSave }: CanvasTextEditorModalProps) { const { message, modal } = App.useApp(); const theme = canvasThemes[useThemeStore((state) => state.theme)]; const [title, setTitle] = useState(""); const [dirty, setDirty] = useState(false); const [saving, setSaving] = useState(false); const editor = useEditor({ immediatelyRender: false, extensions: createCanvasRichTextExtensions("输入文本内容…"), content: emptyTextDocument(), editorProps: { attributes: { class: "min-h-full px-8 py-7 text-[15px] leading-7 outline-none sm:px-12 lg:px-16", "aria-label": "文本节点富文本编辑区", }, }, onUpdate: () => setDirty(true), }); useEffect(() => { if (!open || !node || !editor) return; editor.commands.setContent((node.metadata?.richText as JSONContent | undefined) || plainTextDocument(node.metadata?.content || ""), { emitUpdate: false }); setTitle(node.title || "文本"); setDirty(false); setSaving(false); window.setTimeout(() => editor.commands.focus("start"), 0); }, [editor, node?.id, open]); const characterCount = editor?.storage.characterCount?.characters?.() || 0; const wordCount = useMemo(() => countWords(editor?.getText() || ""), [characterCount, editor]); const save = async () => { if (!node || !editor || saving) return; setSaving(true); try { await onSave(node.id, title.trim() || "文本", editor.getText({ blockSeparator: "\n" }).trimEnd(), editor.getJSON() as Record); setDirty(false); message.success("文本节点已保存"); } catch (error) { message.error(error instanceof Error ? error.message : "文本保存失败"); } finally { setSaving(false); } }; const close = () => { if (!dirty || saving) return onClose(); modal.confirm({ title: "放弃未保存的修改?", content: "关闭后,本次富文本编辑不会写回画布节点。", okText: "放弃修改", cancelText: "继续编辑", onOk: onClose, }); }; useEffect(() => { if (!open) return; const handleKeyDown = (event: KeyboardEvent) => { if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "s") return; event.preventDefault(); void save(); }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [dirty, editor, node?.id, open, saving, title]); return (
{ setTitle(event.target.value); setDirty(true); }} className="!h-9 min-w-0 max-w-[360px] flex-1 !px-1 text-sm font-semibold" placeholder="文本节点标题" aria-label="文本节点标题" /> {characterCount.toLocaleString("zh-CN")} 字 · {wordCount.toLocaleString("zh-CN")} 词 {dirty ? "有未保存修改" : "已保存"}
支持标题、列表、引用、链接、代码和颜色格式 Ctrl/⌘S 保存
); } function TextEditorToolbar({ editor }: { editor: Editor | null }) { const { message } = App.useApp(); const theme = canvasThemes[useThemeStore((state) => state.theme)]; const [, setToolbarVersion] = useState(0); const [linkOpen, setLinkOpen] = useState(false); const [linkValue, setLinkValue] = useState(""); useEffect(() => { if (!editor) return; const update = () => setToolbarVersion((value) => value + 1); editor.on("selectionUpdate", update); editor.on("transaction", update); return () => { editor.off("selectionUpdate", update); editor.off("transaction", update); }; }, [editor]); const blockLabel = editor?.isActive("heading", { level: 1 }) ? "标题 1" : editor?.isActive("heading", { level: 2 }) ? "标题 2" : editor?.isActive("heading", { level: 3 }) ? "标题 3" : "正文"; const alignment = editor?.isActive({ textAlign: "center" }) ? "center" : editor?.isActive({ textAlign: "right" }) ? "right" : editor?.isActive({ textAlign: "justify" }) ? "justify" : "left"; const alignmentIcon = alignment === "center" ? : alignment === "right" ? : alignment === "justify" ? : ; const applyLink = () => { const href = linkValue.trim(); if (!editor) return; if (href && !isSafeCanvasRichTextLink(href)) { message.error("链接仅支持 http、https、mailto 或 tel 协议"); return; } if (href) editor.chain().focus().extendMarkRange("link").setLink({ href }).run(); else editor.chain().focus().unsetLink().run(); setLinkOpen(false); }; return (
editor?.chain().focus().undo().run()}> editor?.chain().focus().redo().run()}> ({ key: label, label })), onClick: ({ key }) => key === "正文" ? editor?.chain().focus().setParagraph().run() : editor?.chain().focus().toggleHeading({ level: Number(key.slice(-1)) as 1 | 2 | 3 }).run(), }} > editor?.chain().focus().toggleBold().run()}> editor?.chain().focus().toggleItalic().run()}> editor?.chain().focus().toggleUnderline().run()}> editor?.chain().focus().toggleStrike().run()}> , label: "左对齐" }, { key: "center", icon: , label: "居中" }, { key: "right", icon: , label: "右对齐" }, { key: "justify", icon: , label: "两端对齐" }, ], onClick: ({ key }) => editor?.chain().focus().setTextAlign(key).run(), }} > editor?.chain().focus().toggleBulletList().run()}> editor?.chain().focus().toggleOrderedList().run()}> editor?.chain().focus().toggleBlockquote().run()}> { setLinkOpen(next); if (next) setLinkValue(String(editor?.getAttributes("link").href || "")); }} trigger="click" placement="bottom" content={
setLinkValue(event.target.value)} onPressEnter={applyLink} />
} > setLinkOpen(true)}>
editor?.chain().focus().setColor(color.toHexString()).run()}> editor?.chain().focus().toggleHighlight({ color: color.toHexString() }).run()}> , label: "行内代码" }, { key: "codeBlock", icon: {"<>"}, label: "代码块" }, { key: "rule", icon: , label: "插入分隔线" }, { type: "divider" }, { key: "clear", icon: , label: "清除格式" }, ], onClick: ({ key }) => { if (key === "code") editor?.chain().focus().toggleCode().run(); else if (key === "codeBlock") editor?.chain().focus().toggleCodeBlock().run(); else if (key === "rule") editor?.chain().focus().setHorizontalRule().run(); else if (key === "clear") editor?.chain().focus().clearNodes().unsetAllMarks().run(); }, }} >
); } function EditorToolButton({ label, active = false, children, onClick }: { label: string; active?: boolean; children: ReactNode; onClick: () => void }) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; return ( ); } function ToolbarDivider() { return ; } function emptyTextDocument(): JSONContent { return { type: "doc", content: [{ type: "paragraph" }] }; } function plainTextDocument(content: string): JSONContent { if (!content) return emptyTextDocument(); return { type: "doc", content: content.split("\n").map((line) => ({ type: "paragraph", content: line ? [{ type: "text", text: line }] : undefined })), }; } function countWords(content: string) { const latinWords = content.match(/[A-Za-z0-9]+(?:['’-][A-Za-z0-9]+)*/g)?.length || 0; const cjkCharacters = content.match(/[\u3400-\u9fff\uf900-\ufaff]/g)?.length || 0; return latinWords + cjkCharacters; }