diff --git a/package-lock.json b/package-lock.json index c9cc29685..7e83e5b5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3262,6 +3262,10 @@ "resolved": "packages/n8n-node", "link": true }, + "node_modules/@probo/prosemirror": { + "resolved": "packages/prosemirror", + "link": true + }, "node_modules/@probo/react-lazy": { "resolved": "packages/react-lazy", "link": true @@ -18215,7 +18219,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } @@ -19701,6 +19704,17 @@ "n8n-workflow": "*" } }, + "packages/prosemirror": { + "name": "@probo/prosemirror", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@tiptap/pm": "^3.20.2" + }, + "devDependencies": { + "typescript": "~5.8.3" + } + }, "packages/react-lazy": { "name": "@probo/react-lazy", "version": "1.0.0", @@ -19755,6 +19769,7 @@ "@phosphor-icons/react": "^2.1.10", "@probo/helpers": "1.0.0", "@probo/i18n": "1.0.0", + "@probo/prosemirror": "1.0.0", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", diff --git a/packages/prosemirror/package.json b/packages/prosemirror/package.json new file mode 100644 index 000000000..6840edff4 --- /dev/null +++ b/packages/prosemirror/package.json @@ -0,0 +1,18 @@ +{ + "name": "@probo/prosemirror", + "version": "1.0.0", + "private": true, + "type": "module", + "sideEffects": false, + "main": "src/index.ts", + "license": "ISC", + "scripts": { + "check": "tsc --noEmit" + }, + "dependencies": { + "@tiptap/pm": "^3.20.2" + }, + "devDependencies": { + "typescript": "~5.8.3" + } +} diff --git a/packages/prosemirror/src/index.ts b/packages/prosemirror/src/index.ts new file mode 100644 index 000000000..928898f8a --- /dev/null +++ b/packages/prosemirror/src/index.ts @@ -0,0 +1,5 @@ +// Copyright (c) 2026 Probo Inc . +// Use of this source code is governed by the ISC license +// that can be found in the LICENSE file. + +export { hasMarkdown, parseInlineContent, parseMarkdown } from "./markdown"; diff --git a/packages/prosemirror/src/markdown.ts b/packages/prosemirror/src/markdown.ts new file mode 100644 index 000000000..9cd79fb3f --- /dev/null +++ b/packages/prosemirror/src/markdown.ts @@ -0,0 +1,288 @@ +// Copyright (c) 2026 Probo Inc . +// Use of this source code is governed by the ISC license +// that can be found in the LICENSE file. + +import type { Node as ProseMirrorNode, Schema } from "@tiptap/pm/model"; + +const codeBlockOpenPattern = /^```(\w*)$/; +const tableSeparatorPattern + = /^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$/m; +const markdownLinePattern + = /(?:^```\w*$|^#{1,6}\s|^>\s|^[-+*]\s|^\d+\.\s|^(?:---|___|\*\*\*)\s*$)/m; + +function parseCells(line: string): string[] { + return line + .replace(/^\|/, "") + .replace(/\|\s*$/, "") + .split("|") + .map(cell => cell.trim()); +} + +function applyMarks( + schema: Schema, + markNames: string[], + inner: string, +): ProseMirrorNode[] { + const marks = markNames.map(name => schema.marks[name].create()); + return parseInlineContent(schema, inner).map((node) => { + let combined = node.marks; + for (const mark of marks) { + combined = mark.addToSet(combined); + } + return node.mark(combined); + }); +} + +export function parseInlineContent( + schema: Schema, + text: string, +): ProseMirrorNode[] { + const nodes: ProseMirrorNode[] = []; + const pattern + = /`([^`]+)`|\*\*\*(.+?)\*\*\*|\*\*(.+?)\*\*|~~(.+?)~~|(.+?)<\/u>|\[([^\]]+)\]\(([^)]+)\)|\*(.+?)\*/g; + let lastIndex = 0; + let match; + + while ((match = pattern.exec(text)) !== null) { + if (match.index > lastIndex) { + nodes.push(schema.text(text.slice(lastIndex, match.index))); + } + + if (match[1] !== undefined) { + nodes.push(schema.text(match[1], [schema.marks.code.create()])); + } else if (match[2] !== undefined) { + nodes.push(...applyMarks(schema, ["bold", "italic"], match[2])); + } else if (match[3] !== undefined) { + nodes.push(...applyMarks(schema, ["bold"], match[3])); + } else if (match[4] !== undefined) { + nodes.push(...applyMarks(schema, ["strike"], match[4])); + } else if (match[5] !== undefined) { + nodes.push(...applyMarks(schema, ["underline"], match[5])); + } else if (match[6] !== undefined) { + const linkMark = schema.marks.link.create({ href: match[7] }); + for (const node of parseInlineContent(schema, match[6])) { + nodes.push(node.mark(linkMark.addToSet(node.marks))); + } + } else if (match[8] !== undefined) { + nodes.push(...applyMarks(schema, ["italic"], match[8])); + } + + lastIndex = match.index + match[0].length; + } + + if (lastIndex < text.length) { + nodes.push(schema.text(text.slice(lastIndex))); + } + + return nodes; +} + +function buildTable( + schema: Schema, + headerLine: string, + dataLines: string[], +): ProseMirrorNode { + const headers = parseCells(headerLine); + const columnCount = headers.length; + + const headerCells = headers.map((h) => { + const content = h ? parseInlineContent(schema, h) : []; + return schema.nodes.tableHeader.create( + null, + schema.nodes.paragraph.create( + null, + content.length > 0 ? content : undefined, + ), + ); + }); + + const tableRows: ProseMirrorNode[] = [ + schema.nodes.tableRow.create(null, headerCells), + ]; + + for (const line of dataLines) { + const cells = parseCells(line); + const rowCells: ProseMirrorNode[] = []; + for (let c = 0; c < columnCount; c++) { + const value = cells[c]?.trim() ?? ""; + const content = value ? parseInlineContent(schema, value) : []; + rowCells.push( + schema.nodes.tableCell.create( + null, + schema.nodes.paragraph.create( + null, + content.length > 0 ? content : undefined, + ), + ), + ); + } + tableRows.push(schema.nodes.tableRow.create(null, rowCells)); + } + + return schema.nodes.table.create(null, tableRows); +} + +export function hasMarkdown(text: string): boolean { + return markdownLinePattern.test(text) || tableSeparatorPattern.test(text); +} + +export function parseMarkdown( + text: string, + schema: Schema, +): ProseMirrorNode[] { + const lines = text.split("\n"); + const nodes: ProseMirrorNode[] = []; + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + + const codeMatch = line.match(codeBlockOpenPattern); + if (codeMatch) { + const language = codeMatch[1] || null; + const contentLines: string[] = []; + i++; + while (i < lines.length && !lines[i].startsWith("```")) { + contentLines.push(lines[i]); + i++; + } + i++; + + const codeContent = contentLines.join("\n").replace(/\n$/, ""); + nodes.push( + schema.nodes.codeBlock.create( + { language }, + codeContent ? schema.text(codeContent) : undefined, + ), + ); + continue; + } + + if ( + line.includes("|") + && i + 1 < lines.length + && tableSeparatorPattern.test(lines[i + 1].trim()) + ) { + const headerLine = line; + i += 2; + const dataLines: string[] = []; + while (i < lines.length) { + const row = lines[i].trim(); + if (!row || !row.includes("|")) break; + dataLines.push(lines[i]); + i++; + } + nodes.push(buildTable(schema, headerLine, dataLines)); + continue; + } + + const trimmed = line.trim(); + if (trimmed === "---" || trimmed === "___" || trimmed === "***") { + nodes.push(schema.nodes.horizontalRule.create()); + i++; + continue; + } + + const headingMatch = line.match(/^(#{1,6})\s(.+)$/); + if (headingMatch) { + const level = headingMatch[1].length; + const content = parseInlineContent(schema, headingMatch[2]); + nodes.push( + schema.nodes.heading.create( + { level }, + content.length > 0 ? content : undefined, + ), + ); + i++; + continue; + } + + if (line.startsWith("> ")) { + const paragraphs: ProseMirrorNode[] = []; + while (i < lines.length) { + const bqMatch = lines[i].match(/^>\s(.*)$/); + if (!bqMatch) break; + const bqText = bqMatch[1]; + const content = bqText + ? parseInlineContent(schema, bqText) + : []; + paragraphs.push( + schema.nodes.paragraph.create( + null, + content.length > 0 ? content : undefined, + ), + ); + i++; + } + nodes.push(schema.nodes.blockquote.create(null, paragraphs)); + continue; + } + + if ( + line.startsWith("- ") + || line.startsWith("+ ") + || line.startsWith("* ") + ) { + const items: ProseMirrorNode[] = []; + while (i < lines.length) { + const blMatch = lines[i].match(/^[-+*]\s(.*)$/); + if (!blMatch) break; + const blText = blMatch[1]; + const content = blText + ? parseInlineContent(schema, blText) + : []; + items.push( + schema.nodes.listItem.create( + null, + schema.nodes.paragraph.create( + null, + content.length > 0 ? content : undefined, + ), + ), + ); + i++; + } + nodes.push(schema.nodes.bulletList.create(null, items)); + continue; + } + + const olMatch = line.match(/^(\d+)\.\s(.*)$/); + if (olMatch) { + const start = +olMatch[1]; + const items: ProseMirrorNode[] = []; + while (i < lines.length) { + const itemMatch = lines[i].match(/^\d+\.\s(.*)$/); + if (!itemMatch) break; + const olText = itemMatch[1]; + const content = olText + ? parseInlineContent(schema, olText) + : []; + items.push( + schema.nodes.listItem.create( + null, + schema.nodes.paragraph.create( + null, + content.length > 0 ? content : undefined, + ), + ), + ); + i++; + } + nodes.push(schema.nodes.orderedList.create({ start }, items)); + continue; + } + + if (line.trim()) { + const content = parseInlineContent(schema, line); + nodes.push( + schema.nodes.paragraph.create( + null, + content.length > 0 ? content : undefined, + ), + ); + } + i++; + } + + return nodes; +} diff --git a/packages/prosemirror/tsconfig.json b/packages/prosemirror/tsconfig.json new file mode 100644 index 000000000..b4a8406d5 --- /dev/null +++ b/packages/prosemirror/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": false, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/packages/ui/package.json b/packages/ui/package.json index d8bea4ddc..773a34d25 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -17,6 +17,7 @@ "@phosphor-icons/react": "^2.1.10", "@probo/helpers": "1.0.0", "@probo/i18n": "1.0.0", + "@probo/prosemirror": "1.0.0", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", diff --git a/packages/ui/src/RichEditor/MarkdownPasteExtension.ts b/packages/ui/src/RichEditor/MarkdownPasteExtension.ts index 23d902d3a..287f593f9 100644 --- a/packages/ui/src/RichEditor/MarkdownPasteExtension.ts +++ b/packages/ui/src/RichEditor/MarkdownPasteExtension.ts @@ -2,308 +2,13 @@ // Use of this source code is governed by the ISC license // that can be found in the LICENSE file. +import { hasMarkdown, parseMarkdown } from "@probo/prosemirror"; import { Extension } from "@tiptap/core"; -import { - Fragment, - type Node as ProseMirrorNode, - type Schema, - Slice, -} from "@tiptap/pm/model"; +import { Fragment, Slice } from "@tiptap/pm/model"; import { Plugin, PluginKey } from "@tiptap/pm/state"; const markdownPasteKey = new PluginKey("markdownPaste"); -const codeBlockOpenPattern = /^```(\w*)$/; -const tableSeparatorPattern - = /^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$/m; -const markdownLinePattern - = /(?:^```\w*$|^#{1,6}\s|^>\s|^[-+*]\s|^\d+\.\s|^(?:---|___|\*\*\*)\s*$)/m; - -function parseCells(line: string): string[] { - return line - .replace(/^\|/, "") - .replace(/\|\s*$/, "") - .split("|") - .map(cell => cell.trim()); -} - -function applyMarks( - schema: Schema, - markNames: string[], - inner: string, -): ProseMirrorNode[] { - const marks = markNames.map(name => schema.marks[name].create()); - return parseInlineContent(schema, inner).map((node) => { - let combined = node.marks; - for (const mark of marks) { - combined = mark.addToSet(combined); - } - return node.mark(combined); - }); -} - -function parseInlineContent( - schema: Schema, - text: string, -): ProseMirrorNode[] { - const nodes: ProseMirrorNode[] = []; - const pattern - = /`([^`]+)`|\*\*\*(.+?)\*\*\*|\*\*(.+?)\*\*|~~(.+?)~~|(.+?)<\/u>|\[([^\]]+)\]\(([^)]+)\)|\*(.+?)\*/g; - let lastIndex = 0; - let match; - - while ((match = pattern.exec(text)) !== null) { - if (match.index > lastIndex) { - nodes.push(schema.text(text.slice(lastIndex, match.index))); - } - - if (match[1] !== undefined) { - nodes.push(schema.text(match[1], [schema.marks.code.create()])); - } else if (match[2] !== undefined) { - nodes.push(...applyMarks(schema, ["bold", "italic"], match[2])); - } else if (match[3] !== undefined) { - nodes.push(...applyMarks(schema, ["bold"], match[3])); - } else if (match[4] !== undefined) { - nodes.push(...applyMarks(schema, ["strike"], match[4])); - } else if (match[5] !== undefined) { - nodes.push(...applyMarks(schema, ["underline"], match[5])); - } else if (match[6] !== undefined) { - const linkMark = schema.marks.link.create({ href: match[7] }); - for (const node of parseInlineContent(schema, match[6])) { - nodes.push(node.mark(linkMark.addToSet(node.marks))); - } - } else if (match[8] !== undefined) { - nodes.push(...applyMarks(schema, ["italic"], match[8])); - } - - lastIndex = match.index + match[0].length; - } - - if (lastIndex < text.length) { - nodes.push(schema.text(text.slice(lastIndex))); - } - - return nodes; -} - -function buildTable( - schema: Schema, - headerLine: string, - dataLines: string[], -): ProseMirrorNode { - const headers = parseCells(headerLine); - const columnCount = headers.length; - - const headerCells = headers.map((h) => { - const content = h ? parseInlineContent(schema, h) : []; - return schema.nodes.tableHeader.create( - null, - schema.nodes.paragraph.create( - null, - content.length > 0 ? content : undefined, - ), - ); - }); - - const tableRows: ProseMirrorNode[] = [ - schema.nodes.tableRow.create(null, headerCells), - ]; - - for (const line of dataLines) { - const cells = parseCells(line); - const rowCells: ProseMirrorNode[] = []; - for (let c = 0; c < columnCount; c++) { - const value = cells[c]?.trim() ?? ""; - const content = value ? parseInlineContent(schema, value) : []; - rowCells.push( - schema.nodes.tableCell.create( - null, - schema.nodes.paragraph.create( - null, - content.length > 0 ? content : undefined, - ), - ), - ); - } - tableRows.push(schema.nodes.tableRow.create(null, rowCells)); - } - - return schema.nodes.table.create(null, tableRows); -} - -function hasMarkdown(text: string): boolean { - return markdownLinePattern.test(text) || tableSeparatorPattern.test(text); -} - -function parseMarkdown( - text: string, - schema: Schema, -): ProseMirrorNode[] { - const lines = text.split("\n"); - const nodes: ProseMirrorNode[] = []; - let i = 0; - - while (i < lines.length) { - const line = lines[i]; - - // Code blocks - const codeMatch = line.match(codeBlockOpenPattern); - if (codeMatch) { - const language = codeMatch[1] || null; - const contentLines: string[] = []; - i++; - while (i < lines.length && !lines[i].startsWith("```")) { - contentLines.push(lines[i]); - i++; - } - i++; - - const codeContent = contentLines.join("\n").replace(/\n$/, ""); - nodes.push( - schema.nodes.codeBlock.create( - { language }, - codeContent ? schema.text(codeContent) : undefined, - ), - ); - continue; - } - - // Tables - if ( - line.includes("|") - && i + 1 < lines.length - && tableSeparatorPattern.test(lines[i + 1].trim()) - ) { - const headerLine = line; - i += 2; - const dataLines: string[] = []; - while (i < lines.length) { - const row = lines[i].trim(); - if (!row || !row.includes("|")) break; - dataLines.push(lines[i]); - i++; - } - nodes.push(buildTable(schema, headerLine, dataLines)); - continue; - } - - // Horizontal rules (before bullet lists since --- / *** could overlap) - const trimmed = line.trim(); - if (trimmed === "---" || trimmed === "___" || trimmed === "***") { - nodes.push(schema.nodes.horizontalRule.create()); - i++; - continue; - } - - // Headings - const headingMatch = line.match(/^(#{1,6})\s(.+)$/); - if (headingMatch) { - const level = headingMatch[1].length; - const content = parseInlineContent(schema, headingMatch[2]); - nodes.push( - schema.nodes.heading.create( - { level }, - content.length > 0 ? content : undefined, - ), - ); - i++; - continue; - } - - // Blockquotes (group consecutive lines) - if (line.startsWith("> ")) { - const paragraphs: ProseMirrorNode[] = []; - while (i < lines.length) { - const bqMatch = lines[i].match(/^>\s(.*)$/); - if (!bqMatch) break; - const bqText = bqMatch[1]; - const content = bqText - ? parseInlineContent(schema, bqText) - : []; - paragraphs.push( - schema.nodes.paragraph.create( - null, - content.length > 0 ? content : undefined, - ), - ); - i++; - } - nodes.push(schema.nodes.blockquote.create(null, paragraphs)); - continue; - } - - // Bullet lists (group consecutive items) - if ( - line.startsWith("- ") - || line.startsWith("+ ") - || line.startsWith("* ") - ) { - const items: ProseMirrorNode[] = []; - while (i < lines.length) { - const blMatch = lines[i].match(/^[-+*]\s(.*)$/); - if (!blMatch) break; - const blText = blMatch[1]; - const content = blText - ? parseInlineContent(schema, blText) - : []; - items.push( - schema.nodes.listItem.create( - null, - schema.nodes.paragraph.create( - null, - content.length > 0 ? content : undefined, - ), - ), - ); - i++; - } - nodes.push(schema.nodes.bulletList.create(null, items)); - continue; - } - - // Ordered lists (group consecutive items) - const olMatch = line.match(/^(\d+)\.\s(.*)$/); - if (olMatch) { - const start = +olMatch[1]; - const items: ProseMirrorNode[] = []; - while (i < lines.length) { - const itemMatch = lines[i].match(/^\d+\.\s(.*)$/); - if (!itemMatch) break; - const olText = itemMatch[1]; - const content = olText - ? parseInlineContent(schema, olText) - : []; - items.push( - schema.nodes.listItem.create( - null, - schema.nodes.paragraph.create( - null, - content.length > 0 ? content : undefined, - ), - ), - ); - i++; - } - nodes.push(schema.nodes.orderedList.create({ start }, items)); - continue; - } - - // Plain paragraph - if (line.trim()) { - const content = parseInlineContent(schema, line); - nodes.push( - schema.nodes.paragraph.create( - null, - content.length > 0 ? content : undefined, - ), - ); - } - i++; - } - - return nodes; -} - export const MarkdownPasteExtension = Extension.create({ name: "markdownPaste",