Merge pull request #353 from multica-ai/fix/editor-markdown-rendering

fix(editor): reliable markdown rendering via marked HTML pipeline
This commit is contained in:
Naiyuan Qing 2026-04-02 19:26:18 +08:00 committed by GitHub
commit a98f165458
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 246 additions and 67 deletions

View file

@ -0,0 +1,93 @@
import { Marked } from "marked";
import { preprocessLinks } from "@/components/markdown/linkify";
/**
* Dedicated Marked instance for converting markdown Tiptap-compatible HTML.
*
* Uses a separate instance (not the global `marked`) to avoid interfering with
* @tiptap/markdown's internal marked instance. Custom renderer ensures output
* matches Tiptap's ProseMirror schema requirements (e.g. block content in cells).
*/
const tiptapMarked = new Marked();
tiptapMarked.use({
renderer: {
// Tiptap's TableCell/TableHeader nodes require `content: "block+"`.
// Default marked outputs bare inline content in <td>/<th>, which
// ProseMirror silently drops. Wrap in <p> so it's valid block content.
tablecell({ tokens, header }) {
const tag = header ? "th" : "td";
const content = this.parser.parseInline(tokens);
return `<${tag}><p>${content}</p></${tag}>\n`;
},
},
});
// ---------------------------------------------------------------------------
// Mention preprocessing
// ---------------------------------------------------------------------------
/**
* Convert mention link syntax to HTML spans matching Tiptap's Mention
* extension parseHTML expectations (data-type, data-id, data-label, data-mention-type).
*/
function mentionsToHtml(text: string): string {
return text.replace(
/\[@?([^\]]+)\]\(mention:\/\/(\w+)\/([^)]+)\)/g,
(_match, label: string, type: string, id: string) => {
const prefix = type === "issue" ? "" : "@";
return (
`<span data-type="mention" data-id="${id}" data-label="${label}"` +
` data-mention-type="${type}">${prefix}${label}</span>`
);
},
);
}
/**
* Convert legacy mention shortcodes [@ id="UUID" label="LABEL"] to the
* standard markdown link format before further processing.
*/
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})`;
},
);
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Convert a markdown string to Tiptap-compatible HTML.
*
* Pipeline:
* 1. Legacy mention shortcodes standard mention links
* 2. Raw URLs markdown links (linkify)
* 3. Mention links <span data-type="mention" ...> HTML
* 4. Marked renders everything else (tables, lists, headings, code, hr)
* with custom renderer ensuring ProseMirror schema compatibility
*
* The result is loaded into Tiptap as HTML (no contentType: "markdown"),
* bypassing @tiptap/markdown's beta parser entirely. The Markdown extension
* is still loaded for getMarkdown() serialization on save.
*/
export function markdownToHtml(markdown: string): string {
if (!markdown) return "";
const step1 = preprocessMentionShortcodes(markdown);
const step2 = preprocessLinks(step1);
const step3 = mentionsToHtml(step2);
return tiptapMarked.parse(step3) as string;
}

View file

@ -7,11 +7,15 @@ 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 { preprocessLinks } from "@/components/markdown/linkify";
import { markdownToHtml } from "./markdown-to-html";
import "./rich-text-editor.css";
const lowlight = createLowlight(common);
@ -49,39 +53,13 @@ const extensions = [
style: "max-width: 100%; height: auto;",
},
}),
Table.configure({ resizable: false }),
TableRow,
TableHeader,
TableCell,
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
// ---------------------------------------------------------------------------
@ -94,14 +72,9 @@ interface ReadonlyEditorProps {
/**
* 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
* 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,
@ -112,8 +85,7 @@ const ReadonlyEditor = memo(function ReadonlyEditor({
const editor = useEditor({
immediatelyRender: false,
editable: false,
content: preprocess(content),
contentType: content ? "markdown" : undefined,
content: markdownToHtml(content),
extensions,
editorProps: {
attributes: {
@ -142,12 +114,7 @@ const ReadonlyEditor = memo(function ReadonlyEditor({
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.commands.setContent(markdownToHtml(content));
}, [editor, content]);
if (!editor) return null;

View file

@ -18,12 +18,12 @@
height: 0;
}
/* Headings */
/* Headings — aligned with old Markdown minimal mode */
.rich-text-editor h1 {
font-size: 1.125rem;
font-size: 1rem;
font-weight: 700;
margin-top: 1.25rem;
margin-bottom: 0.5rem;
margin-bottom: 0.75rem;
line-height: 1.4;
}
@ -31,22 +31,22 @@
font-size: 1rem;
font-weight: 600;
margin-top: 1rem;
margin-bottom: 0.5rem;
margin-bottom: 0.75rem;
line-height: 1.4;
}
.rich-text-editor h3 {
font-size: 0.875rem;
font-weight: 600;
margin-top: 0.75rem;
margin-bottom: 0.25rem;
margin-top: 1rem;
margin-bottom: 0.5rem;
line-height: 1.4;
}
/* Paragraphs */
.rich-text-editor p {
margin-top: 0.375rem;
margin-bottom: 0.375rem;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
line-height: 1.625;
}
@ -63,14 +63,15 @@
/* Lists */
.rich-text-editor ul {
list-style-type: disc;
padding-inline-start: 1.25rem;
margin: 0.375rem 0;
padding-inline-start: 1rem;
padding-inline-end: 0.5rem;
margin: 0.5rem 0;
}
.rich-text-editor ol {
list-style-type: decimal;
padding-inline-start: 1.25rem;
margin: 0.375rem 0;
padding-inline-start: 1.5rem;
margin: 0.5rem 0;
}
.rich-text-editor li {
@ -78,6 +79,10 @@
line-height: 1.625;
}
.rich-text-editor li + li {
margin-top: 0.25rem;
}
.rich-text-editor li::marker {
color: var(--muted-foreground);
}
@ -85,10 +90,11 @@
/* Inline code */
.rich-text-editor code {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.8em;
background: var(--muted);
color: var(--foreground);
padding: 0.15em 0.35em;
font-size: 0.875rem;
background: color-mix(in srgb, var(--foreground) 3%, transparent);
border: 1px solid color-mix(in srgb, var(--foreground) 5%, transparent);
color: color-mix(in srgb, var(--foreground) 75%, transparent);
padding: 0.125rem 0.375rem;
border-radius: calc(var(--radius) * 0.6);
}
@ -104,6 +110,8 @@
.rich-text-editor pre code {
background: none;
border: none;
color: var(--foreground);
padding: 0;
font-size: 0.8125rem;
line-height: 1.6;
@ -166,12 +174,60 @@
.dark .rich-text-editor .hljs-deletion { color: oklch(0.7 0.18 25); }
/* Tables */
.rich-text-editor .tableWrapper {
overflow-x: auto;
margin: 1rem 0;
border: 1px solid var(--border);
border-radius: var(--radius);
}
.rich-text-editor table {
min-width: 100%;
border-collapse: collapse;
}
.rich-text-editor colgroup {
display: none;
}
.rich-text-editor thead {
background: color-mix(in srgb, var(--muted) 50%, transparent);
}
.rich-text-editor tbody tr {
border-top: 1px solid var(--border);
}
.rich-text-editor tr:hover td {
background: color-mix(in srgb, var(--muted) 30%, transparent);
transition: background 0.15s;
}
.rich-text-editor th,
.rich-text-editor td {
text-align: left;
padding: 0.625rem 1rem;
font-size: 0.875rem;
}
.rich-text-editor th {
font-weight: 600;
}
/* Remove paragraph margin inside table cells */
.rich-text-editor th p,
.rich-text-editor td p {
margin: 0;
}
/* Blockquotes */
.rich-text-editor blockquote {
border-left: 2px solid var(--border);
border-left: 2px solid color-mix(in srgb, var(--muted-foreground) 30%, transparent);
padding-left: 0.75rem;
margin: 0.5rem 0;
color: var(--muted-foreground);
font-style: italic;
}
/* Horizontal rules */
@ -183,8 +239,9 @@
/* Links */
.rich-text-editor a {
color: var(--brand);
color: var(--primary);
text-decoration: none;
cursor: pointer;
}
.rich-text-editor a:hover {

View file

@ -14,6 +14,10 @@ import Placeholder from "@tiptap/extension-placeholder";
import Link from "@tiptap/extension-link";
import Typography from "@tiptap/extension-typography";
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 { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
@ -23,6 +27,7 @@ import type { UploadResult } from "@/shared/hooks/use-file-upload";
import { BaseMentionExtension } from "./mention-extension";
import { createMentionSuggestion } from "./mention-suggestion";
import { CodeBlockView } from "./code-block-view";
import { markdownToHtml } from "./markdown-to-html";
import "./rich-text-editor.css";
const lowlight = createLowlight(common);
@ -307,8 +312,7 @@ const RichTextEditor = forwardRef<RichTextEditorRef, RichTextEditorProps>(
const editor = useEditor({
immediatelyRender: false,
editable,
content: defaultValue || "",
contentType: defaultValue ? "markdown" : undefined,
content: defaultValue ? markdownToHtml(defaultValue) : "",
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
@ -342,6 +346,10 @@ const RichTextEditor = forwardRef<RichTextEditorRef, RichTextEditorProps>(
allowBase64: false,
HTMLAttributes: { style: "max-width: 100%; height: auto;" },
}),
Table.configure({ resizable: false }),
TableRow,
TableHeader,
TableCell,
Markdown,
createMarkdownPasteExtension(),
createSubmitExtension(() => onSubmitRef.current?.()),

View file

@ -23,6 +23,10 @@
"@tiptap/extension-link": "^3.20.5",
"@tiptap/extension-mention": "^3.20.5",
"@tiptap/extension-placeholder": "^3.20.5",
"@tiptap/extension-table": "^3.20.5",
"@tiptap/extension-table-cell": "^3.20.5",
"@tiptap/extension-table-header": "^3.20.5",
"@tiptap/extension-table-row": "^3.20.5",
"@tiptap/extension-typography": "^3.20.5",
"@tiptap/markdown": "^3.20.5",
"@tiptap/pm": "^3.20.5",

50
pnpm-lock.yaml generated
View file

@ -90,6 +90,18 @@ importers:
'@tiptap/extension-placeholder':
specifier: ^3.20.5
version: 3.20.5(@tiptap/extensions@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))
'@tiptap/extension-table':
specifier: ^3.20.5
version: 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
'@tiptap/extension-table-cell':
specifier: ^3.20.5
version: 3.20.5(@tiptap/extension-table@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))
'@tiptap/extension-table-header':
specifier: ^3.20.5
version: 3.20.5(@tiptap/extension-table@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))
'@tiptap/extension-table-row':
specifier: ^3.20.5
version: 3.20.5(@tiptap/extension-table@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))
'@tiptap/extension-typography':
specifier: ^3.20.5
version: 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))
@ -1445,6 +1457,27 @@ packages:
peerDependencies:
'@tiptap/core': ^3.20.5
'@tiptap/extension-table-cell@3.20.5':
resolution: {integrity: sha512-NEobjpZ9f9CpQjnqTAsUHgcjWjTXcgWxqVfMmOWMyZLVh5kmEzDb7V8+lNplLnUUOFYynJcnzPTV7WieaD6Reg==}
peerDependencies:
'@tiptap/extension-table': ^3.20.5
'@tiptap/extension-table-header@3.20.5':
resolution: {integrity: sha512-pGKVMPpfvKYIIerCUdGXD9OavFRriKd8+9PSoCR1+wtPsD8EhFbGRR3d8InLFq/G7V77pmsO6Tbws5b+M2LGNQ==}
peerDependencies:
'@tiptap/extension-table': ^3.20.5
'@tiptap/extension-table-row@3.20.5':
resolution: {integrity: sha512-zDW4GtnWnKPW3EdPHY5LOhW6ztuIlMxGRUYS7KGVWj9Qm8JWMPWSRsluNwajQacuZOo4ODVfG1GUooFibkjZLA==}
peerDependencies:
'@tiptap/extension-table': ^3.20.5
'@tiptap/extension-table@3.20.5':
resolution: {integrity: sha512-YvTB5OfGqjqHqutkSyywplouFvJwlsDTpZAjtAh5TzKfOan42aiVepmHVpteoQP6LH0mSjw69RndFMIYhIGmSQ==}
peerDependencies:
'@tiptap/core': ^3.20.5
'@tiptap/pm': ^3.20.5
'@tiptap/extension-text@3.20.5':
resolution: {integrity: sha512-DMa9g5cH2d/Gx1KXtV7txTxaa6FBqgG8glmfug+N93VMb8sEZR1Yu1az++yAep4SGGq9GWIGZCUS3H6W66et6Q==}
peerDependencies:
@ -5036,6 +5069,23 @@ snapshots:
dependencies:
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
'@tiptap/extension-table-cell@3.20.5(@tiptap/extension-table@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))':
dependencies:
'@tiptap/extension-table': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
'@tiptap/extension-table-header@3.20.5(@tiptap/extension-table@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))':
dependencies:
'@tiptap/extension-table': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
'@tiptap/extension-table-row@3.20.5(@tiptap/extension-table@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))':
dependencies:
'@tiptap/extension-table': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
'@tiptap/extension-table@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)':
dependencies:
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
'@tiptap/pm': 3.20.5
'@tiptap/extension-text@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))':
dependencies:
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)