feat: Strip comments from presentation mode (#11860)

* feat: Strip comment marks from documents in presentation mode

Move removeMarks to shared ProsemirrorHelper and use it to strip comment
marks before rendering slides. Make server ProsemirrorHelper extend the
shared class to eliminate duplication and remove SharedProsemirrorHelper
imports from server code.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: Use Set for mark lookup and cloneDeep for browser compat

Use a Set for O(1) mark lookups in removeMarks traversal. Replace
structuredClone with lodash/cloneDeep to support older browsers
that lack the native API.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: Add tests for ProsemirrorHelper.removeMarks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Moor
2026-03-23 20:21:43 -04:00
committed by GitHub
parent f1e5a7cfa7
commit 793804cd0d
9 changed files with 196 additions and 45 deletions
@@ -7,7 +7,9 @@ import Icon from "@shared/components/Icon";
import { richExtensions } from "@shared/editor/nodes";
import { canUseElementFullscreen } from "@shared/utils/browser";
import { s, depths, hover } from "@shared/styles";
import cloneDeep from "lodash/cloneDeep";
import type { ProsemirrorData } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { colorPalette } from "@shared/utils/collections";
import Editor from "~/components/Editor";
import NudeButton from "~/components/NudeButton";
@@ -130,8 +132,16 @@ function PresentationMode({ title, icon, iconColor, data, onClose }: Props) {
const supportsFullscreen = React.useMemo(() => canUseElementFullscreen(), []);
const isIdle = useIdle(3000, idleEvents);
const strippedData = React.useMemo(
() =>
ProsemirrorHelper.removeMarks(cloneDeep(data), [
"comment",
]) as ProsemirrorData,
[data]
);
const slides = React.useMemo(() => {
const result = splitIntoSlides(data, title, icon, iconColor);
const result = splitIntoSlides(strippedData, title, icon, iconColor);
const contentSlides = result.filter((s) => s.type === "content");
const hasContent =
contentSlides.length > 0 &&
@@ -144,7 +154,7 @@ function PresentationMode({ title, icon, iconColor, data, onClose }: Props) {
}
return result;
}, [data, title, icon, iconColor]);
}, [strippedData, title, icon, iconColor]);
const totalSlides = slides.length;
+1 -2
View File
@@ -1,5 +1,4 @@
import type { Optional } from "utility-types";
import { ProsemirrorHelper as SharedProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { TextHelper } from "@shared/utils/TextHelper";
import { Document, type Template } from "@server/models";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
@@ -94,7 +93,7 @@ export default async function documentCreator(
: text
? ProsemirrorHelper.toProsemirror(text).toJSON()
: template
? SharedProsemirrorHelper.replaceTemplateVariables(
? ProsemirrorHelper.replaceTemplateVariables(
await DocumentHelper.toJSON(template),
user
)
@@ -2,7 +2,6 @@ import { faker } from "@faker-js/faker";
import type { DeepPartial } from "utility-types";
import type { ProsemirrorData } from "@shared/types";
import { MentionType } from "@shared/types";
import { ProsemirrorHelper as SharedProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { createContext } from "@server/context";
import { buildProseMirrorDoc, buildUser } from "@server/test/factories";
import type { MentionAttrs } from "./ProsemirrorHelper";
@@ -973,7 +972,7 @@ describe("ProsemirrorHelper", () => {
},
]);
const images = SharedProsemirrorHelper.getImages(doc);
const images = ProsemirrorHelper.getImages(doc);
expect(images.length).toBe(1);
expect(images[0].attrs.src).toBe("https://example.com/image.png");
expect(images[0].attrs.alt).toBe("Test image");
+4 -30
View File
@@ -21,6 +21,7 @@ import {
attachmentRedirectRegex,
ProsemirrorHelper as SharedProsemirrorHelper,
} from "@shared/utils/ProsemirrorHelper";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isRTL } from "@shared/utils/rtl";
import { isInternalUrl } from "@shared/utils/urls";
@@ -62,7 +63,7 @@ export type MentionAttrs = {
};
@trace()
export class ProsemirrorHelper {
export class ProsemirrorHelper extends SharedProsemirrorHelper {
/**
* Returns the input text as a Y.Doc.
*
@@ -255,33 +256,6 @@ export class ProsemirrorHelper {
return blockNode ? doc.copy(Fragment.fromArray([blockNode])) : undefined;
}
/**
* Removes all marks from the node that match the given types.
*
* @param data The ProsemirrorData object to remove marks from
* @param marks The mark types to remove
* @returns The content with marks removed
*/
static removeMarks(doc: Node | ProsemirrorData, marks: string[]) {
const json = "toJSON" in doc ? (doc.toJSON() as ProsemirrorData) : doc;
function removeMarksInner(node: ProsemirrorData) {
if (node.marks) {
node.marks = node.marks.filter((mark) => !marks.includes(mark.type));
}
if (node.attrs?.marks) {
node.attrs.marks = (node.attrs.marks as { type: string }[])?.filter(
(mark) => !marks.includes(mark.type)
);
}
if (node.content) {
node.content.forEach(removeMarksInner);
}
return node;
}
return removeMarksInner(json);
}
static async replaceInternalUrls(
doc: Node | ProsemirrorData,
basePath: string
@@ -875,8 +849,8 @@ export class ProsemirrorHelper {
doc: Node,
user: User
): Promise<Node> {
const images = SharedProsemirrorHelper.getImages(doc);
const videos = SharedProsemirrorHelper.getVideos(doc);
const images = ProsemirrorHelper.getImages(doc);
const videos = ProsemirrorHelper.getVideos(doc);
const nodes = [...images, ...videos];
if (!nodes.length) {
+1 -2
View File
@@ -1,4 +1,3 @@
import { ProsemirrorHelper as SharedProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { createContext } from "@server/context";
import { buildProseMirrorDoc, buildUser } from "@server/test/factories";
import { ProsemirrorHelper } from "./ProsemirrorHelper";
@@ -43,7 +42,7 @@ describe("ProsemirrorHelper", () => {
},
]);
const images = SharedProsemirrorHelper.getImages(doc);
const images = ProsemirrorHelper.getImages(doc);
expect(images.length).toBe(1);
expect(images[0].attrs.src).toBe("https://example.com/image.png");
expect(images[0].attrs.alt).toBe("Test image");
+3 -4
View File
@@ -13,7 +13,6 @@ import type {
ProsemirrorDoc,
} from "@shared/types";
import { AttachmentPreset, ImportState, ImportTaskState } from "@shared/types";
import { ProsemirrorHelper as SharedProseMirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { createContext } from "@server/context";
import { schema } from "@server/editor";
import Logger from "@server/logging/Logger";
@@ -262,9 +261,9 @@ export default abstract class APIImportTask<
}): Promise<ProsemirrorDoc> {
const docNode = ProsemirrorHelper.toProsemirror(doc);
const nodes = [
...SharedProseMirrorHelper.getImages(docNode),
...SharedProseMirrorHelper.getVideos(docNode),
...SharedProseMirrorHelper.getAttachments(docNode),
...ProsemirrorHelper.getImages(docNode),
...ProsemirrorHelper.getVideos(docNode),
...ProsemirrorHelper.getAttachments(docNode),
];
if (!nodes.length) {
+1 -2
View File
@@ -6,7 +6,6 @@ import mammoth from "mammoth";
import type { Node } from "prosemirror-model";
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
import yaml from "js-yaml";
import { ProsemirrorHelper as SharedProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { schema, serializer } from "@server/editor";
import { FileImportError } from "@server/errors";
import { trace, traceFunction } from "@server/logging/tracing";
@@ -55,7 +54,7 @@ export class DocumentConverter {
// Extract title from first H1 heading
let title = "";
const headings = SharedProsemirrorHelper.getHeadings(doc);
const headings = ProsemirrorHelper.getHeadings(doc);
if (headings.length > 0 && headings[0].level === 1) {
title = headings[0].title;
doc = ProsemirrorHelper.removeFirstHeading(doc);
+144
View File
@@ -234,4 +234,148 @@ describe("ProsemirrorHelper", () => {
]);
});
});
describe("removeMarks", () => {
it("should remove specified mark types from text nodes", () => {
const doc: ProsemirrorData = {
type: "doc",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: "hello",
marks: [
{ type: "comment", attrs: { id: "c1" } },
{ type: "bold" },
],
},
],
},
],
};
const result = ProsemirrorHelper.removeMarks(doc, ["comment"]);
expect(result.content![0].content![0].marks).toEqual([{ type: "bold" }]);
});
it("should remove marks from nested content", () => {
const doc: ProsemirrorData = {
type: "doc",
content: [
{
type: "blockquote",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: "nested",
marks: [{ type: "comment", attrs: { id: "c1" } }],
},
],
},
],
},
],
};
const result = ProsemirrorHelper.removeMarks(doc, ["comment"]);
expect(result.content![0].content![0].content![0].marks).toEqual([]);
});
it("should remove marks from node attrs.marks", () => {
const doc: ProsemirrorData = {
type: "doc",
content: [
{
type: "image",
attrs: {
src: "test.png",
marks: [
{ type: "comment", attrs: { id: "c1" } },
{ type: "link", attrs: { href: "url" } },
],
},
},
],
};
const result = ProsemirrorHelper.removeMarks(doc, ["comment"]);
expect(result.content![0].attrs!.marks).toEqual([
{ type: "link", attrs: { href: "url" } },
]);
});
it("should remove multiple mark types at once", () => {
const doc: ProsemirrorData = {
type: "doc",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: "hello",
marks: [
{ type: "comment", attrs: { id: "c1" } },
{ type: "bold" },
{ type: "highlight" },
],
},
],
},
],
};
const result = ProsemirrorHelper.removeMarks(doc, [
"comment",
"highlight",
]);
expect(result.content![0].content![0].marks).toEqual([{ type: "bold" }]);
});
it("should leave nodes unchanged when no marks match", () => {
const doc: ProsemirrorData = {
type: "doc",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: "hello",
marks: [{ type: "bold" }],
},
],
},
],
};
const result = ProsemirrorHelper.removeMarks(doc, ["comment"]);
expect(result.content![0].content![0].marks).toEqual([{ type: "bold" }]);
});
it("should handle nodes with no marks", () => {
const doc: ProsemirrorData = {
type: "doc",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: "plain",
},
],
},
],
};
const result = ProsemirrorHelper.removeMarks(doc, ["comment"]);
expect(result.content![0].content![0].marks).toBeUndefined();
});
});
});
+29 -1
View File
@@ -48,10 +48,38 @@ 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.
*
* @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) {
if (node.marks) {
node.marks = node.marks.filter((mark) => !markSet.has(mark.type));
}
if (node.attrs?.marks) {
node.attrs.marks = (node.attrs.marks as { type: string }[])?.filter(
(mark) => !markSet.has(mark.type)
);
}
if (node.content) {
node.content.forEach(removeMarksInner);
}
return node;
}
return removeMarksInner(json);
}
/**
* Get a new empty document.
*
* @returns A new empty document as JSON.
* @returns a new empty document as JSON.
*/
static getEmptyDocument(): ProsemirrorData {
return {