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:
commit
a98f165458
6 changed files with 246 additions and 67 deletions
93
apps/web/components/common/markdown-to-html.ts
Normal file
93
apps/web/components/common/markdown-to-html.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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?.()),
|
||||
|
|
|
|||
|
|
@ -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
50
pnpm-lock.yaml
generated
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue