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}

\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)