import type { Schema } from "prosemirror-model"; import type { Node } from "prosemirror-model"; import { HeadingSlugger } from "../editor/lib/headingToSlug"; import textBetween from "../editor/lib/textBetween"; import type { ProsemirrorData } from "../types"; import { hashString } from "./string"; import { TextHelper } from "./TextHelper"; import env from "../env"; import { findChildren } from "@shared/editor/queries/findChildren"; import { isLightboxNode } from "@shared/editor/lib/Lightbox"; import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper"; export type Heading = { /* The heading in plain text */ title: string; /* The level of the heading */ level: number; /* The unique id of the heading */ id: string; /* Whether the heading is nested inside a table */ inTable?: boolean; }; export type CommentMark = { /* The unique id of the comment */ id: string; /* The id of the user who created the comment */ userId: string; /* The text of the comment */ text: string; }; export type NodeAnchor = { pos: number; id: string; className: string }; export type Task = { /* The text of the task */ text: string; /* Whether the task is completed or not */ completed: boolean; }; interface User { name: string; language: string | null; } export const attachmentRedirectRegex = /\/api\/attachments\.redirect\?id=(?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/gi; export const attachmentPublicRegex = /public\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/(?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/gi; export class ProsemirrorHelper { /** * Remove specific mark types from all nodes in the document. Mark lists left * empty are omitted, matching the schema default. * * @param doc the prosemirror document or JSON data. * @param marks the mark type names to remove. * @returns the document data with specified marks removed. */ static removeMarks(doc: Node | ProsemirrorData, marks: string[]) { const json = "toJSON" in doc ? (doc.toJSON() as ProsemirrorData) : doc; const markSet = new Set(marks); function removeMarksInner(node: ProsemirrorData) { // Node.toJSON shares attrs with the node, so replace them rather than mutate. if (node.marks) { const remaining = node.marks.filter((mark) => !markSet.has(mark.type)); if (remaining.length) { node.marks = remaining; } else { delete node.marks; } } if (node.attrs?.marks) { const remaining = (node.attrs.marks as { type: string }[]).filter( (mark) => !markSet.has(mark.type) ); node.attrs = { ...node.attrs, marks: remaining.length ? remaining : undefined, }; } if (node.content) { node.content.forEach(removeMarksInner); } return node; } return removeMarksInner(json); } /** * Returns the node as plain text. * * @param node The node to convert. * @param schema The schema to use. * @returns The document content as plain text without formatting. */ static toPlainText(root: Node) { return textBetween(root, 0, root.content.size); } /** * Removes any empty paragraphs from the beginning and end of the document. * * @returns True if the editor is empty */ static trim(doc: Node) { let index = 0, start = 0, end = doc.nodeSize - 2, isEmpty; if (doc.childCount <= 1) { return doc; } isEmpty = true; while (isEmpty) { const node = doc.maybeChild(index++); if (!node) { break; } isEmpty = !ProsemirrorHelper.hasText(node); if (isEmpty) { start += node.nodeSize; } } index = doc.childCount - 1; isEmpty = true; while (isEmpty) { const node = doc.maybeChild(index--); if (!node) { break; } isEmpty = !ProsemirrorHelper.hasText(node); if (isEmpty) { end -= node.nodeSize; } } return doc.cut(start, end); } /** * Returns true if the trimmed content of the passed document is an empty string. * * @returns True if the editor is empty */ static isEmpty(doc: Node, schema?: Schema) { if (!schema) { return !doc || doc.textContent.trim() === ""; } return !ProsemirrorHelper.hasText(doc); } /** * Iterates through the document to find all of the comments that exist as marks. * * @param doc Prosemirror document node * @returns Array */ static getComments(doc: Node): CommentMark[] { const comments: CommentMark[] = []; doc.descendants((node) => { node.marks.forEach((mark) => { if (mark.type.name === "comment") { comments.push({ ...mark.attrs, text: node.textContent, } as CommentMark); } }); ( (node.attrs.marks ?? []) as { type: string; attrs: Partial; }[] ).forEach((mark) => { if (mark.type === "comment") { comments.push({ ...mark.attrs, // For image nodes, we don't have any text content, so we set it to an empty string text: "", } as CommentMark); } }); return true; }); return comments; } /** * Returns the anchors for headings and for nodes with comments in the * document, headings first. * * @param doc Prosemirror document node. * @returns array of anchors. */ static getAnchors(doc: Node): NodeAnchor[] { const slugger = new HeadingSlugger(); const headingAnchors: NodeAnchor[] = []; const imageAnchors: NodeAnchor[] = []; doc.descendants((node, pos) => { if (node.type.name === "heading") { headingAnchors.push({ pos, id: slugger.slug(node), className: EditorStyleHelper.headingPositionAnchor, }); } if (Array.isArray(node.attrs?.marks)) { ( node.attrs.marks as { type?: string; attrs?: { id?: string } }[] ).forEach((mark) => { if (mark?.type === "comment" && mark?.attrs?.id) { imageAnchors.push({ pos, id: `comment-${mark.attrs.id}`, className: EditorStyleHelper.imagePositionAnchor, }); } }); } }); return [...headingAnchors, ...imageAnchors]; } /** * Computes a stable identifier for a node derived from its type and * attributes. The `marks` attribute is excluded as it changes when comments * are added or removed from the node. * * @param node the node to compute an identifier for. * @returns a hex-encoded hash identifying the node. */ static getNodeHash(node: Node): string { const { marks: _marks, ...attrs } = node.attrs; const sorted = Object.keys(attrs) .sort() .reduce>((acc, key) => { acc[key] = attrs[key]; return acc; }, {}); return hashString(`${node.type.name}:${JSON.stringify(sorted)}`); } /** * Finds a node in the document by the hash of its attributes, see * `getNodeHash`. When multiple nodes share the same hash the first * occurrence in document order is returned. * * @param doc Prosemirror document node. * @param hash the node hash to search for. * @returns the matching node and its position, or null if no match. */ static findNodeByHash( doc: Node, hash: string ): { node: Node; pos: number } | null { let result: { node: Node; pos: number } | null = null; doc.descendants((node, pos) => { if (result) { return false; } if (!node.isText && ProsemirrorHelper.getNodeHash(node) === hash) { result = { node, pos }; return false; } return true; }); return result; } /** * Returns the ids of comment marks attached to the node at the given position. * * @param doc Prosemirror document node. * @param pos Position of the node within the document. * @returns array of comment ids anchored to the node. */ static getCommentIdsAtPos(doc: Node, pos: number): string[] { const node = doc.nodeAt(pos); if (!node || !Array.isArray(node.attrs?.marks)) { return []; } return (node.attrs.marks as { type?: string; attrs?: { id?: string } }[]) .filter( (mark): mark is { type: "comment"; attrs: { id: string } } => mark?.type === "comment" && !!mark?.attrs?.id ) .map((mark) => mark.attrs.id); } /** * Builds the consolidated anchor text for the given comment-id. * * @param marks all available comment marks in a document. * @param commentId the comment-id to build the anchor text. * @returns consolidated anchor text. */ static getAnchorTextForComment( marks: CommentMark[], commentId: string ): string | undefined { const anchorTexts = marks .filter((mark) => mark.id === commentId) .map((mark) => mark.text); return anchorTexts.length ? anchorTexts.join("") : undefined; } /** * Iterates through the document to find all of the images. * * @param doc Prosemirror document node * @returns Array of images */ static getImages(doc: Node): Node[] { const images: Node[] = []; doc.descendants((node) => { if (node.type.name === "image") { images.push(node); } return true; }); return images; } /** * Iterates through the document to find all valid Lightbox nodes. * * @param doc Prosemirror document node * @returns Array of nodes allowed in Lightbox */ static getLightboxNodes = (doc: Node) => findChildren(doc, isLightboxNode, true); /** * Iterates through the document to find all of the videos. * * @param doc Prosemirror document node * @returns Array of videos */ static getVideos(doc: Node): Node[] { const videos: Node[] = []; doc.descendants((node) => { if (node.type.name === "video") { videos.push(node); } // Videos are block nodes, so they are never inside a textblock. return !node.isTextblock; }); return videos; } /** * Iterates through the document to find all of the attachments. * * @param doc Prosemirror document node * @returns Array of attachments */ static getAttachments(doc: Node): Node[] { const attachments: Node[] = []; doc.descendants((node) => { if (node.type.name === "attachment") { attachments.push(node); } // Attachments are block nodes, so they are never inside a textblock. return !node.isTextblock; }); return attachments; } /** * Iterates through the document to find all of the tasks and their completion state. * * @param doc Prosemirror document node * @returns Array */ static getTasks(doc: Node): Task[] { const tasks: Task[] = []; doc.descendants((node) => { if (!node.isBlock || node.isTextblock) { return false; } if (node.type.name === "checkbox_list") { node.content.forEach((listItem) => { let text = ""; listItem.forEach((contentNode) => { if (contentNode.type.name === "paragraph") { text += contentNode.textContent; } }); tasks.push({ text, completed: listItem.attrs.checked, }); }); } return true; }); return tasks; } /** * Returns a summary of total and completed tasks in the node. * * @param doc Prosemirror document node * @returns Object with completed and total keys */ static getTasksSummary(doc: Node): { completed: number; total: number } { let completed = 0; let total = 0; doc.descendants((node) => { if (!node.isBlock || node.isTextblock) { return false; } if (node.type.name === "checkbox_list") { node.content.forEach((listItem) => { total++; if (listItem.attrs.checked) { completed++; } }); } return true; }); return { completed, total }; } /** * Iterates through the document to find all of the headings and their level. * * @param doc Prosemirror document node * @returns Array */ static getHeadings(doc: Node) { const headings: Heading[] = []; const slugger = new HeadingSlugger(); let tableEnd = 0; doc.descendants((node, pos) => { if (node.type.name === "table") { // A nested table must not shrink the range of its outer table. tableEnd = Math.max(tableEnd, pos + node.nodeSize); } if (node.type.name === "heading") { headings.push({ title: ProsemirrorHelper.toPlainText(node), level: node.attrs.level, id: slugger.slug(node), inTable: pos < tableEnd, }); } // Headings are never nested in a textblock. return !node.isTextblock; }); return headings; } /** * Converts all attachment URLs in the ProsemirrorData to absolute URLs. * This is useful for ensuring that attachments can be accessed correctly * when the document is rendered in a different context or environment. * * @param data The ProsemirrorData object to process * @returns The ProsemirrorData with absolute URLs for attachments */ static attachmentsToAbsoluteUrls(data: ProsemirrorData): ProsemirrorData { const regex = new RegExp("^" + attachmentRedirectRegex.source); function replace(node: ProsemirrorData) { if ( node.type === "image" && node.attrs?.src && regex.test(node.attrs.src as string) ) { node.attrs.src = env.URL + node.attrs.src; } else if ( node.type === "video" && node.attrs?.src && regex.test(node.attrs.src as string) ) { node.attrs.src = env.URL + node.attrs.src; } else if ( node.type === "attachment" && node.attrs?.href && regex.test(node.attrs.href as string) ) { node.attrs.href = env.URL + node.attrs.href; } if (node.content) { node.content = node.content.filter(Boolean); node.content.forEach(replace); } return node; } return replace(data); } /** * Replaces all template variables in the node. * * @param data The ProsemirrorData object to replace variables in * @param user The user to use for replacing variables * @returns The content with variables replaced */ static replaceTemplateVariables(data: ProsemirrorData, user: User) { function replace(node: ProsemirrorData) { if (node.type === "text" && node.text) { node.text = TextHelper.replaceTemplateVariables(node.text, user); } if (node.content) { node.content = node.content.filter(Boolean); node.content.forEach(replace); } return node; } return replace(data); } /** * Returns true when the predicate holds for every node in the data, * including the root and all descendants. * * @param data The ProsemirrorData object to walk * @param predicate The predicate to test each node against * @returns true when every node passes the predicate */ static everyNode( data: ProsemirrorData, predicate: (node: ProsemirrorData) => boolean ): boolean { if (!predicate(data)) { return false; } return (data.content ?? []).every( (node) => !!node && ProsemirrorHelper.everyNode(node, predicate) ); } /** * Returns true if the node contains any non-whitespace text, including the * text of leaf nodes such as mentions. Stops at the first text found. */ private static hasText(node: Node): boolean { let found = false; node.descendants((child: Node) => { if (found) { return false; } if (child.type.spec.leafText) { found = !!String(child.type.spec.leafText(child)).trim(); } else if (child.isText) { found = !!child.text?.trim(); } return !found; }); return found; } }