multica/apps/web/components/common/readonly-editor.tsx
Naiyuan Qing fe975fb2bb feat(editor): unify Tiptap editor for editing and readonly display
Replace react-markdown in comment/reply display with Tiptap readonly mode,
ensuring visual consistency between editing and viewing. Extract shared
BaseMentionExtension with MentionView NodeView used in both modes — issue
mentions render as inline cards with StatusIcon, clickable to open in new tab.
Redesign mention suggestion popup with grouped sections (Users/Issues),
agent badges, and StatusIcon for issues.

- New: mention-extension.ts (shared mention core)
- New: mention-view.tsx (shared NodeView for both modes)
- New: readonly-editor.tsx (lightweight Tiptap readonly wrapper)
- Modified: rich-text-editor.tsx (import from shared mention-extension)
- Modified: rich-text-editor.css (readonly + issue-mention overrides)
- Modified: comment-card.tsx (Markdown → ReadonlyEditor)
- Modified: mention-suggestion.tsx (grouped UI, StatusIcon, agent badge)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 16:35:52 +08:00

157 lines
5 KiB
TypeScript

"use client";
import { useEffect, useRef, memo } from "react";
import { useEditor, EditorContent, ReactNodeViewRenderer } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
import { common, createLowlight } from "lowlight";
import Link from "@tiptap/extension-link";
import Image from "@tiptap/extension-image";
import { Markdown } from "@tiptap/markdown";
import { cn } from "@/lib/utils";
import { BaseMentionExtension } from "./mention-extension";
import { CodeBlockView } from "./code-block-view";
import { preprocessLinks } from "@/components/markdown/linkify";
import "./rich-text-editor.css";
const lowlight = createLowlight(common);
// ---------------------------------------------------------------------------
// Module-level extension singletons (prevent useEditor re-creation)
// ---------------------------------------------------------------------------
const extensions = [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
link: false,
codeBlock: false,
}),
CodeBlockLowlight.extend({
addNodeView() {
return ReactNodeViewRenderer(CodeBlockView);
},
}).configure({ lowlight }),
Link.configure({
openOnClick: false,
autolink: false,
HTMLAttributes: {
class: "text-primary hover:underline cursor-pointer",
},
}),
BaseMentionExtension.configure({
HTMLAttributes: { class: "mention" },
}),
Image.configure({
inline: false,
allowBase64: false,
HTMLAttributes: {
class: "rounded-md my-2",
style: "max-width: 100%; height: auto;",
},
}),
Markdown,
];
// ---------------------------------------------------------------------------
// Content preprocessing
// ---------------------------------------------------------------------------
/**
* Convert legacy mention shortcodes [@ id="UUID" label="LABEL"] to markdown
* link format [@LABEL](mention://member/UUID).
*/
function preprocessMentionShortcodes(text: string): string {
if (!text.includes("[@ ")) return text;
return text.replace(
/\[@\s+([^\]]*)\]/g,
(match: string, attrString: string) => {
const attrs: Record<string, string> = {};
const re = /(\w+)="([^"]*)"/g;
let m;
while ((m = re.exec(attrString)) !== null) {
if (m[1] && m[2] !== undefined) attrs[m[1]] = m[2];
}
const { id, label } = attrs;
if (!id || !label) return match;
return `[@${label}](mention://member/${id})`;
},
);
}
function preprocess(content: string): string {
return preprocessLinks(preprocessMentionShortcodes(content));
}
// ---------------------------------------------------------------------------
// ReadonlyEditor
// ---------------------------------------------------------------------------
interface ReadonlyEditorProps {
content: string;
className?: string;
}
/**
* ReadonlyEditor — lightweight Tiptap wrapper for displaying markdown content.
*
* Uses the same ProseMirror engine and CSS as the editing RichTextEditor,
* ensuring visual consistency between edit and display modes.
*
* Features:
* - Issue mentions render as IssueMentionCard (inline card with status icon)
* - Links are clickable (open in new tab)
* - Code blocks have syntax highlighting and copy button
* - Content is preprocessed: raw URL linkification + legacy mention format conversion
*/
const ReadonlyEditor = memo(function ReadonlyEditor({
content,
className,
}: ReadonlyEditorProps) {
const prevContentRef = useRef(content);
const editor = useEditor({
immediatelyRender: false,
editable: false,
content: preprocess(content),
contentType: content ? "markdown" : undefined,
extensions,
editorProps: {
attributes: {
class: cn("rich-text-editor readonly text-sm", className),
},
handleDOMEvents: {
click(_view, event) {
const target = event.target as HTMLElement;
// Skip links inside NodeView wrappers — they handle their own clicks
// (e.g. IssueMentionCard uses Next.js Link for client-side navigation)
if (target.closest("[data-node-view-wrapper]")) return false;
const link = target.closest("a");
const href = link?.getAttribute("href");
if (href && !href.startsWith("mention://")) {
event.preventDefault();
window.open(href, "_blank", "noopener,noreferrer");
return true;
}
return false;
},
},
},
});
// Update content when prop changes (e.g. after editing a comment)
useEffect(() => {
if (!editor || content === prevContentRef.current) return;
prevContentRef.current = content;
const processed = preprocess(content);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const parsed = (editor.storage as any).markdown?.parse?.(processed);
if (parsed) {
editor.commands.setContent(parsed);
}
}, [editor, content]);
if (!editor) return null;
return <EditorContent editor={editor} />;
});
export { ReadonlyEditor, type ReadonlyEditorProps };