diff --git a/apps/web/components/common/markdown-to-html.ts b/apps/web/components/common/markdown-to-html.ts
new file mode 100644
index 00000000..aff8ca22
--- /dev/null
+++ b/apps/web/components/common/markdown-to-html.ts
@@ -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
/ | , which
+ // ProseMirror silently drops. Wrap in so it's valid block content.
+ tablecell({ tokens, header }) {
+ const tag = header ? "th" : "td";
+ const content = this.parser.parseInline(tokens);
+ return `<${tag}> ${content} ${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 (
+ `${prefix}${label}`
+ );
+ },
+ );
+}
+
+/**
+ * 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 = {};
+ 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 → 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;
+}
diff --git a/apps/web/components/common/readonly-editor.tsx b/apps/web/components/common/readonly-editor.tsx
index e4b5d585..c2a6690a 100644
--- a/apps/web/components/common/readonly-editor.tsx
+++ b/apps/web/components/common/readonly-editor.tsx
@@ -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 = {};
- 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;
diff --git a/apps/web/components/common/rich-text-editor.css b/apps/web/components/common/rich-text-editor.css
index 5a0a9ba7..110de520 100644
--- a/apps/web/components/common/rich-text-editor.css
+++ b/apps/web/components/common/rich-text-editor.css
@@ -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 {
diff --git a/apps/web/components/common/rich-text-editor.tsx b/apps/web/components/common/rich-text-editor.tsx
index fbdd862c..0af17464 100644
--- a/apps/web/components/common/rich-text-editor.tsx
+++ b/apps/web/components/common/rich-text-editor.tsx
@@ -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(
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(
allowBase64: false,
HTMLAttributes: { style: "max-width: 100%; height: auto;" },
}),
+ Table.configure({ resizable: false }),
+ TableRow,
+ TableHeader,
+ TableCell,
Markdown,
createMarkdownPasteExtension(),
createSubmitExtension(() => onSubmitRef.current?.()),
diff --git a/apps/web/package.json b/apps/web/package.json
index a3e75923..f2c10735 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -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",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index fe3b325d..544ce989 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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)
|