Files
outline/shared/utils/ProsemirrorHelper.ts
Apoorv Mishra 06ec6fdfbb Enable commenting on images (#10474)
* feat: enable commenting on image nodes

* chore: make anchorPlugin a top level plugin

* fix: className

* fix: review

* fix: tsc

* fix: checks

* Tweak menu order to match

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2025-10-28 11:34:40 +05:30

543 lines
14 KiB
TypeScript

import { Node, Schema } from "prosemirror-model";
import headingToSlug from "../editor/lib/headingToSlug";
import textBetween from "../editor/lib/textBetween";
import { ProsemirrorData } from "../types";
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;
};
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 {
/**
* Get a new empty document.
*
* @returns A new empty document as JSON.
*/
static getEmptyDocument(): ProsemirrorData {
return {
type: "doc",
content: [
{
content: [],
type: "paragraph",
},
],
};
}
/**
* Returns true if the data looks like an empty document.
*
* @param data The ProsemirrorData to check.
* @returns True if the document is empty.
*/
static isEmptyData(data: ProsemirrorData): boolean {
if (data.type !== "doc") {
return false;
}
if (data.content?.length === 1) {
const node = data.content[0];
return (
node.type === "paragraph" &&
(node.content === null ||
node.content === undefined ||
node.content.length === 0)
);
}
return !data.content || data.content.length === 0;
}
/**
* 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.toPlainText(node).trim() === "";
if (isEmpty) {
start += node.nodeSize;
}
}
index = doc.childCount - 1;
isEmpty = true;
while (isEmpty) {
const node = doc.maybeChild(index--);
if (!node) {
break;
}
isEmpty = ProsemirrorHelper.toPlainText(node).trim() === "";
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() === "";
}
let empty = true;
doc.descendants((child: Node) => {
// If we've already found non-empty data, we can stop descending further
if (!empty) {
return false;
}
if (child.type.spec.leafText) {
empty = !child.type.spec.leafText(child).trim();
} else if (child.isText) {
empty = !child.text?.trim();
}
return empty;
});
return empty;
}
/**
* 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 ?? []).forEach((mark: any) => {
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;
}
private static getAnchorsForHeadingNodes(doc: Node): NodeAnchor[] {
const previouslySeen: Record<string, number> = {};
const anchors: NodeAnchor[] = [];
doc.descendants((node, pos) => {
if (node.type.name !== "heading") {
return;
}
// calculate the optimal id
const slug = headingToSlug(node);
let id = slug;
// check if we've already used it, and if so how many times?
// Make the new id based on that number ensuring that we have
// unique ID's even when headings are identical
if (previouslySeen[slug] > 0) {
id = headingToSlug(node, previouslySeen[slug]);
}
// record that we've seen this slug for the next loop
previouslySeen[slug] =
previouslySeen[slug] !== undefined ? previouslySeen[slug] + 1 : 1;
anchors.push({
pos,
id,
className: EditorStyleHelper.headingPositionAnchor,
});
});
return anchors;
}
private static getAnchorsForImageNodes(doc: Node): NodeAnchor[] {
const anchors: NodeAnchor[] = [];
doc.descendants((node, pos) => {
if (Array.isArray(node.attrs?.marks)) {
node.attrs.marks.forEach((mark: any) => {
if (mark?.type === "comment" && mark?.attrs?.id) {
anchors.push({
pos,
id: `comment-${mark.attrs.id}`,
className: EditorStyleHelper.imagePositionAnchor,
});
}
});
}
});
return anchors;
}
static getAnchors(doc: Node): NodeAnchor[] {
return [
...ProsemirrorHelper.getAnchorsForHeadingNodes(doc),
...ProsemirrorHelper.getAnchorsForImageNodes(doc),
];
}
/**
* 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);
}
return true;
});
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);
}
return true;
});
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) {
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 } {
const tasks = ProsemirrorHelper.getTasks(doc);
return {
completed: tasks.filter((t) => t.completed).length,
total: tasks.length,
};
}
/**
* 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 previouslySeen: Record<string, number> = {};
doc.forEach((node) => {
if (node.type.name === "heading") {
// calculate the optimal id
const id = headingToSlug(node);
let name = id;
// check if we've already used it, and if so how many times?
// Make the new id based on that number ensuring that we have
// unique ID's even when headings are identical
if (previouslySeen[id] > 0) {
name = headingToSlug(node, previouslySeen[id]);
}
// record that we've seen this id for the next loop
previouslySeen[id] =
previouslySeen[id] !== undefined ? previouslySeen[id] + 1 : 1;
headings.push({
title: ProsemirrorHelper.toPlainText(node),
level: node.attrs.level,
id: name,
});
}
});
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 {
function replace(node: ProsemirrorData) {
if (
node.type === "image" &&
node.attrs?.src &&
String(node.attrs.src).match(
new RegExp("^" + attachmentRedirectRegex.source)
)
) {
node.attrs.src = env.URL + node.attrs.src;
}
if (
node.type === "video" &&
node.attrs?.src &&
String(node.attrs.src).match(
new RegExp("^" + attachmentRedirectRegex.source)
)
) {
node.attrs.src = env.URL + node.attrs.src;
}
if (
node.type === "attachment" &&
node.attrs?.href &&
String(node.attrs.src).match(
new RegExp("^" + attachmentRedirectRegex.source)
)
) {
node.attrs.href = env.URL + node.attrs.href;
}
if (node.content) {
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.forEach(replace);
}
return node;
}
return replace(data);
}
/**
* Returns the paragraphs from the data if there are only plain paragraphs
* without any formatting. Otherwise returns undefined.
*
* @param data The ProsemirrorData object
* @returns An array of paragraph nodes or undefined
*/
static getPlainParagraphs(data: ProsemirrorData) {
const paragraphs: ProsemirrorData[] = [];
if (!data.content) {
return paragraphs;
}
for (const node of data.content) {
if (
node.type === "paragraph" &&
(!node.content ||
!node.content.some(
(item) =>
item.type !== "text" || (item.marks && item.marks.length > 0)
))
) {
paragraphs.push(node);
} else {
return undefined;
}
}
return paragraphs;
}
}