import { StarterKit } from '@tiptap/starter-kit'; import { TextAlign } from '@tiptap/extension-text-align'; import { Superscript } from '@tiptap/extension-superscript'; import SubScript from '@tiptap/extension-subscript'; import { Typography } from '@tiptap/extension-typography'; import { TextStyle } from '@tiptap/extension-text-style'; import { Color } from '@tiptap/extension-color'; import { Youtube } from '@tiptap/extension-youtube'; import { TaskList, TaskItem } from '@tiptap/extension-list'; import { Heading, Callout, Comment, CustomCodeBlock, Details, DetailsContent, DetailsSummary, LinkExtension, MathBlock, MathInline, TableHeader, TableCell, TableRow, CustomTable, TiptapImage, TiptapVideo, TiptapAudio, TiptapPdf, TrailingNode, Attachment, Drawio, Excalidraw, Embed, Mention, Subpages, Highlight, UniqueID, Columns, Column, Status, addUniqueIdsToDoc, htmlToMarkdown, } from '@docmost/editor-ext'; import { generateText, getSchema, JSONContent } from '@tiptap/core'; import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html'; // @tiptap/html library works best for generating prosemirror json state but not HTML // see: https://github.com/ueberdosis/tiptap/issues/5352 // see:https://github.com/ueberdosis/tiptap/issues/4089 //import { generateJSON } from '@tiptap/html'; import { Node, Schema } from '@tiptap/pm/model'; import * as Y from 'yjs'; import { Logger } from '@nestjs/common'; export const tiptapExtensions = [ StarterKit.configure({ codeBlock: false, link: false, trailingNode: false, heading: false, }), Heading, UniqueID.configure({ types: ['heading', 'paragraph'], }), Comment, TextAlign.configure({ types: ['heading', 'paragraph'] }), TaskList, TaskItem.configure({ nested: true, }), LinkExtension, Superscript, SubScript, Highlight, Typography, TrailingNode, TextStyle, Color, MathInline, MathBlock, Details, DetailsContent, DetailsSummary, CustomTable, TableCell, TableRow, TableHeader, Youtube, TiptapImage, TiptapVideo, TiptapAudio, TiptapPdf, Callout, Attachment, CustomCodeBlock, Drawio, Excalidraw, Embed, Mention, Subpages, Columns, Column, Status, ] as any; export function jsonToHtml(tiptapJson: any) { return generateHTML(tiptapJson, tiptapExtensions); } export function htmlToJson(html: string) { const pmJson = generateJSON(html, tiptapExtensions); try { return addUniqueIdsToDoc(pmJson, tiptapExtensions); } catch (error) { console.warn('failed to add unique ids to doc', error); return pmJson; } } export function jsonToText(tiptapJson: JSONContent) { return generateText(tiptapJson, tiptapExtensions); } export function jsonToNode(tiptapJson: JSONContent) { const schema = getSchema(tiptapExtensions); try { return Node.fromJSON(schema, tiptapJson); } catch (error) { if ( error instanceof RangeError && error.message.includes('Unknown node type') ) { Logger.warn('Stripping unknown node types from document:', error.message); const cleanedJson = stripUnknownNodes(tiptapJson, schema); return Node.fromJSON(schema, cleanedJson); } throw error; } } export function getPageId(documentName: string) { return documentName.split('.')[1]; } export function isEmptyParagraphDoc(tiptapJson: JSONContent): boolean { if (!tiptapJson || tiptapJson.type !== 'doc') return false; const content = tiptapJson.content; if (!Array.isArray(content) || content.length !== 1) return false; const child = content[0]; if (!child || child.type !== 'paragraph') return false; return ( !child.content || (Array.isArray(child.content) && child.content.length === 0) ); } function stripUnknownNodes( json: JSONContent, schema: Schema, ): JSONContent | null { if (!json || typeof json !== 'object') return json; // Recursively clean children first, flattening any unwrapped content if (json.content && Array.isArray(json.content)) { const newContent: JSONContent[] = []; for (const child of json.content) { const cleaned = stripUnknownNodes(child, schema); if (Array.isArray(cleaned)) { newContent.push(...cleaned); } else if (cleaned) { newContent.push(cleaned); } } json.content = newContent; } // Check if this node is unknown AFTER processing children if (json.type && !schema.nodes[json.type]) { // Unwrap: return cleaned children directly instead of wrapping return ( json.content && json.content.length > 0 ? json.content : null ) as any; } return json; } export function prosemirrorNodeToYElement(node: any): Y.XmlElement | Y.XmlText { if (node.type === 'text') { const ytext = new Y.XmlText(); ytext.insert(0, node.text || ''); if (node.marks?.length > 0) { const attrs: Record = {}; for (const mark of node.marks) { attrs[mark.type] = mark.attrs || true; } ytext.format(0, node.text?.length || 0, attrs); } return ytext; } const element = new Y.XmlElement(node.type); if (node.attrs) { for (const [key, value] of Object.entries(node.attrs)) { if (value !== null && value !== undefined) { element.setAttribute(key, value as any); } } } if (node.content?.length > 0) { const children = node.content.map(prosemirrorNodeToYElement); element.insert(0, children); } return element; } export function jsonToMarkdown(tiptapJson: any): string { const html = jsonToHtml(tiptapJson); return htmlToMarkdown(html); }