shared/utils/ProsemirrorHelper.ts
Rama main · réplica de lectura del repositorio original.
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=(?<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})\/(?<id>[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<CommentMark>
*/
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<CommentMark>;
}[]
).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<Record<string, unknown>>((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<Node> 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<NodeWithPos> 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<Node> 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<Node> 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<Task>
*/
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<Heading>
*/
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;
}
}