multica/apps/web/components/common/readonly-editor.tsx
Naiyuan Qing 097630c733 fix(editor): reliable markdown rendering via marked HTML pipeline
Replace @tiptap/markdown's beta contentType: "markdown" parser with a
dedicated marked-based HTML pipeline for loading markdown content.

The @tiptap/markdown parser silently drops content in complex documents
(tables, nested lists, mentions). Instead, we now:

1. Pre-convert mention links to <span data-type="mention"> HTML
2. Render markdown to HTML via a dedicated Marked instance with a custom
   renderer that wraps table cell content in <p> tags (required by
   Tiptap's TableCell block+ content spec)
3. Load as HTML — Tiptap's ProseMirror HTML parser handles everything
4. Keep @tiptap/markdown extension only for getMarkdown() serialization

Also adds Table extension support and aligns CSS with the old Markdown
component's minimal mode styling.

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

124 lines
3.9 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 TableRow from "@tiptap/extension-table-row";
import TableHeader from "@tiptap/extension-table-header";
import TableCell from "@tiptap/extension-table-cell";
import { Table } from "@tiptap/extension-table";
import { Markdown } from "@tiptap/markdown";
import { cn } from "@/lib/utils";
import { BaseMentionExtension } from "./mention-extension";
import { CodeBlockView } from "./code-block-view";
import { markdownToHtml } from "./markdown-to-html";
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;",
},
}),
Table.configure({ resizable: false }),
TableRow,
TableHeader,
TableCell,
Markdown,
];
// ---------------------------------------------------------------------------
// ReadonlyEditor
// ---------------------------------------------------------------------------
interface ReadonlyEditorProps {
content: string;
className?: string;
}
/**
* ReadonlyEditor — lightweight Tiptap wrapper for displaying markdown content.
*
* Content is converted from markdown to HTML via `marked` before loading,
* bypassing @tiptap/markdown's beta parser which drops complex content.
* The Markdown extension is kept for getMarkdown() serialization only.
*/
const ReadonlyEditor = memo(function ReadonlyEditor({
content,
className,
}: ReadonlyEditorProps) {
const prevContentRef = useRef(content);
const editor = useEditor({
immediatelyRender: false,
editable: false,
content: markdownToHtml(content),
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;
editor.commands.setContent(markdownToHtml(content));
}, [editor, content]);
if (!editor) return null;
return <EditorContent editor={editor} />;
});
export { ReadonlyEditor, type ReadonlyEditorProps };