From 7f818c732938a5cdc8c5763792af893cd06ca1ad Mon Sep 17 00:00:00 2001 From: Hemachandar <132386067+hmacr@users.noreply.github.com> Date: Sat, 30 Aug 2025 20:26:07 +0530 Subject: [PATCH] chore: Replace custom `toPlainText` serialization with `leafText` (#10039) --- .../extensions/ClipboardTextSerializer.ts | 4 +--- app/editor/index.tsx | 7 ++---- app/models/Document.ts | 3 +-- server/models/Comment.ts | 2 +- server/models/helpers/DocumentHelper.tsx | 6 +---- server/models/validators/TextLength.ts | 5 +--- shared/editor/commands/table.ts | 2 +- shared/editor/lib/textBetween.ts | 10 +++----- shared/editor/lib/textSerializers.ts | 14 ----------- shared/editor/nodes/Attachment.tsx | 2 +- shared/editor/nodes/Embed.tsx | 2 +- shared/editor/nodes/Emoji.tsx | 2 +- shared/editor/nodes/HardBreak.ts | 2 +- shared/editor/nodes/Image.tsx | 2 +- shared/editor/nodes/Mention.tsx | 1 - shared/editor/nodes/Video.tsx | 2 +- shared/typings/prosemirror-model.d.ts | 7 ------ shared/utils/ProsemirrorHelper.ts | 23 +++++++------------ 18 files changed, 25 insertions(+), 71 deletions(-) delete mode 100644 shared/editor/lib/textSerializers.ts diff --git a/app/editor/extensions/ClipboardTextSerializer.ts b/app/editor/extensions/ClipboardTextSerializer.ts index 44e0f983ce..542a1d89c4 100644 --- a/app/editor/extensions/ClipboardTextSerializer.ts +++ b/app/editor/extensions/ClipboardTextSerializer.ts @@ -44,9 +44,7 @@ export default class ClipboardTextSerializer extends Extension { softBreak: true, }) : slice.content.content - .map((node) => - ProsemirrorHelper.toPlainText(node, this.editor.schema) - ) + .map((node) => ProsemirrorHelper.toPlainText(node)) .join(""); }, }, diff --git a/app/editor/index.tsx b/app/editor/index.tsx index 6e6296757f..cec05cc02b 100644 --- a/app/editor/index.tsx +++ b/app/editor/index.tsx @@ -35,7 +35,6 @@ import Extension, { import ExtensionManager from "@shared/editor/lib/ExtensionManager"; import { MarkdownSerializer } from "@shared/editor/lib/markdown/serializer"; import textBetween from "@shared/editor/lib/textBetween"; -import { getTextSerializers } from "@shared/editor/lib/textSerializers"; import Mark from "@shared/editor/marks/Mark"; import { basicExtensions as extensions } from "@shared/editor/nodes"; import Node from "@shared/editor/nodes/Node"; @@ -627,8 +626,7 @@ export class Editor extends React.PureComponent< * * @returns A list of headings in the document */ - public getHeadings = () => - ProsemirrorHelper.getHeadings(this.view.state.doc, this.schema); + public getHeadings = () => ProsemirrorHelper.getHeadings(this.view.state.doc); /** * Return the images in the current editor. @@ -721,9 +719,8 @@ export class Editor extends React.PureComponent< */ public getPlainText = () => { const { doc } = this.view.state; - const textSerializers = getTextSerializers(this.schema); - return textBetween(doc, 0, doc.content.size, textSerializers); + return textBetween(doc, 0, doc.content.size); }; private dispatchThemeChanged = (event: CustomEvent) => { diff --git a/app/models/Document.ts b/app/models/Document.ts index 357f05670f..e8ee8d6abd 100644 --- a/app/models/Document.ts +++ b/app/models/Document.ts @@ -723,8 +723,7 @@ export default class Document extends ArchivableModel implements Searchable { marks: extensionManager.marks, }); const text = ProsemirrorHelper.toPlainText( - Node.fromJSON(schema, this.data), - schema + Node.fromJSON(schema, this.data) ); return text; }; diff --git a/server/models/Comment.ts b/server/models/Comment.ts index c2a8cbf132..349dc03969 100644 --- a/server/models/Comment.ts +++ b/server/models/Comment.ts @@ -136,7 +136,7 @@ class Comment extends ParanoidModel< */ public toPlainText() { const node = Node.fromJSON(schema, this.data); - return ProsemirrorHelper.toPlainText(node, schema); + return ProsemirrorHelper.toPlainText(node); } // hooks diff --git a/server/models/helpers/DocumentHelper.tsx b/server/models/helpers/DocumentHelper.tsx index 4e81d79f83..a0e0fec346 100644 --- a/server/models/helpers/DocumentHelper.tsx +++ b/server/models/helpers/DocumentHelper.tsx @@ -4,7 +4,6 @@ import ukkonen from "ukkonen"; import { updateYFragment, yDocToProsemirrorJSON } from "y-prosemirror"; import * as Y from "yjs"; import textBetween from "@shared/editor/lib/textBetween"; -import { getTextSerializers } from "@shared/editor/lib/textSerializers"; import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper"; import { IconType, ProsemirrorData } from "@shared/types"; import { determineIconType } from "@shared/utils/icon"; @@ -141,8 +140,7 @@ export class DocumentHelper { */ static toPlainText(document: Document | Revision | ProsemirrorData) { const node = DocumentHelper.toProsemirror(document); - - return textBetween(node, 0, node.content.size, this.textSerializers); + return textBetween(node, 0, node.content.size); } /** @@ -523,6 +521,4 @@ export class DocumentHelper { const distance = ukkonen(first, second, threshold + 1); return distance > threshold; } - - private static textSerializers = getTextSerializers(schema); } diff --git a/server/models/validators/TextLength.ts b/server/models/validators/TextLength.ts index d4b2ac4954..d21d692d59 100644 --- a/server/models/validators/TextLength.ts +++ b/server/models/validators/TextLength.ts @@ -25,10 +25,7 @@ export default function TextLength({ let text; try { - text = ProsemirrorHelper.toPlainText( - Node.fromJSON(schema, value), - schema - ); + text = ProsemirrorHelper.toPlainText(Node.fromJSON(schema, value)); } catch (_err) { throw new Error("Invalid data"); } diff --git a/shared/editor/commands/table.ts b/shared/editor/commands/table.ts index c8645ca2ec..4fdc291701 100644 --- a/shared/editor/commands/table.ts +++ b/shared/editor/commands/table.ts @@ -143,7 +143,7 @@ export function exportTable({ .map((row) => row .map((cell) => { - let value = ProsemirrorHelper.toPlainText(cell, state.schema); + let value = ProsemirrorHelper.toPlainText(cell); // Escape double quotes by doubling them if (value.includes('"')) { diff --git a/shared/editor/lib/textBetween.ts b/shared/editor/lib/textBetween.ts index 89ba7db890..c3290ee02c 100644 --- a/shared/editor/lib/textBetween.ts +++ b/shared/editor/lib/textBetween.ts @@ -1,5 +1,4 @@ import { Node as ProseMirrorNode } from "prosemirror-model"; -import { PlainTextSerializer } from "../types"; /** * Returns the text content between two positions. @@ -7,25 +6,22 @@ import { PlainTextSerializer } from "../types"; * @param doc The Prosemirror document to use * @param from A start point * @param to An end point - * @param plainTextSerializers A map of node names to PlainTextSerializers which convert a node to plain text * @returns A string of plain text */ export default function textBetween( doc: ProseMirrorNode, from: number, - to: number, - plainTextSerializers: Record + to: number ): string { let text = ""; let first = true; const blockSeparator = "\n"; doc.nodesBetween(from, to, (node, pos) => { - const toPlainText = plainTextSerializers[node.type.name]; let nodeText = ""; - if (toPlainText) { - nodeText += toPlainText(node); + if (node.type.spec.leafText) { + nodeText += node.type.spec.leafText(node); } else if (node.isText) { nodeText += node.textBetween( Math.max(from, pos) - pos, diff --git a/shared/editor/lib/textSerializers.ts b/shared/editor/lib/textSerializers.ts deleted file mode 100644 index cda799326c..0000000000 --- a/shared/editor/lib/textSerializers.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Schema } from "prosemirror-model"; - -/** - * Generate a map of text serializers for a given schema - * @param schema - * @returns Text serializers - */ -export function getTextSerializers(schema: Schema) { - return Object.fromEntries( - Object.entries(schema.nodes) - .filter(([, node]) => node.spec.toPlainText) - .map(([name, node]) => [name, node.spec.toPlainText]) - ); -} diff --git a/shared/editor/nodes/Attachment.tsx b/shared/editor/nodes/Attachment.tsx index ab88e93087..727d203095 100644 --- a/shared/editor/nodes/Attachment.tsx +++ b/shared/editor/nodes/Attachment.tsx @@ -65,7 +65,7 @@ export default class Attachment extends Node { }, String(node.attrs.title), ], - toPlainText: (node) => node.attrs.title, + leafText: (node) => node.attrs.title, }; } diff --git a/shared/editor/nodes/Embed.tsx b/shared/editor/nodes/Embed.tsx index e0b4c85de8..2748b8ed0d 100644 --- a/shared/editor/nodes/Embed.tsx +++ b/shared/editor/nodes/Embed.tsx @@ -87,7 +87,7 @@ export default class Embed extends Node { ]; } }, - toPlainText: (node) => node.attrs.href, + leafText: (node) => node.attrs.href, }; } diff --git a/shared/editor/nodes/Emoji.tsx b/shared/editor/nodes/Emoji.tsx index eab12c9494..bf92b36717 100644 --- a/shared/editor/nodes/Emoji.tsx +++ b/shared/editor/nodes/Emoji.tsx @@ -58,7 +58,7 @@ export default class Emoji extends Extension { getEmojiFromName(name), ]; }, - toPlainText: (node) => getEmojiFromName(node.attrs["data-name"]), + leafText: (node) => getEmojiFromName(node.attrs["data-name"]), }; } diff --git a/shared/editor/nodes/HardBreak.ts b/shared/editor/nodes/HardBreak.ts index 225b47d043..4d6a30a410 100644 --- a/shared/editor/nodes/HardBreak.ts +++ b/shared/editor/nodes/HardBreak.ts @@ -19,7 +19,7 @@ export default class HardBreak extends Node { selectable: false, parseDOM: [{ tag: "br" }], toDOM: () => ["br"], - toPlainText: () => "\n", + leafText: () => "\n", }; } diff --git a/shared/editor/nodes/Image.tsx b/shared/editor/nodes/Image.tsx index e5a7fd9abd..627b96d70c 100644 --- a/shared/editor/nodes/Image.tsx +++ b/shared/editor/nodes/Image.tsx @@ -204,7 +204,7 @@ export default class Image extends SimpleImage { ...children, ]; }, - toPlainText: (node) => + leafText: (node) => node.attrs.alt ? `(image: ${node.attrs.alt})` : "(image)", }; } diff --git a/shared/editor/nodes/Mention.tsx b/shared/editor/nodes/Mention.tsx index 0717957858..ce0b2518dc 100644 --- a/shared/editor/nodes/Mention.tsx +++ b/shared/editor/nodes/Mention.tsx @@ -119,7 +119,6 @@ export default class Mention extends Node { }, toPlainText(node), ], - toPlainText, leafText: toPlainText, }; } diff --git a/shared/editor/nodes/Video.tsx b/shared/editor/nodes/Video.tsx index e002660c46..999e03e919 100644 --- a/shared/editor/nodes/Video.tsx +++ b/shared/editor/nodes/Video.tsx @@ -77,7 +77,7 @@ export default class Video extends Node { String(node.attrs.title), ], ], - toPlainText: (node) => node.attrs.title, + leafText: (node) => node.attrs.title, }; } diff --git a/shared/typings/prosemirror-model.d.ts b/shared/typings/prosemirror-model.d.ts index 99750021b2..eddca0393d 100644 --- a/shared/typings/prosemirror-model.d.ts +++ b/shared/typings/prosemirror-model.d.ts @@ -8,11 +8,4 @@ declare module "prosemirror-model" { // https://github.com/ProseMirror/prosemirror-model/blob/bd13a2329fda39f1c4d09abd8f0db2032bdc8014/src/replace.js#L51 removeBetween(from: number, to: number): Slice; } - - interface NodeSpec { - /** - * Defines the text representation of the node when copying to clipboard. - */ - toPlainText?: PlainTextSerializer; - } } diff --git a/shared/utils/ProsemirrorHelper.ts b/shared/utils/ProsemirrorHelper.ts index 122c31ca38..6f77827602 100644 --- a/shared/utils/ProsemirrorHelper.ts +++ b/shared/utils/ProsemirrorHelper.ts @@ -1,7 +1,6 @@ import { Node, Schema } from "prosemirror-model"; import headingToSlug from "../editor/lib/headingToSlug"; import textBetween from "../editor/lib/textBetween"; -import { getTextSerializers } from "../editor/lib/textSerializers"; import { ProsemirrorData } from "../types"; import { TextHelper } from "./TextHelper"; import env from "../env"; @@ -91,9 +90,8 @@ export class ProsemirrorHelper { * @param schema The schema to use. * @returns The document content as plain text without formatting. */ - static toPlainText(root: Node, schema: Schema) { - const textSerializers = getTextSerializers(schema); - return textBetween(root, 0, root.content.size, textSerializers); + static toPlainText(root: Node) { + return textBetween(root, 0, root.content.size); } /** @@ -102,7 +100,6 @@ export class ProsemirrorHelper { * @returns True if the editor is empty */ static trim(doc: Node) { - const { schema } = doc.type; let index = 0, start = 0, end = doc.nodeSize - 2, @@ -118,7 +115,7 @@ export class ProsemirrorHelper { if (!node) { break; } - isEmpty = ProsemirrorHelper.toPlainText(node, schema).trim() === ""; + isEmpty = ProsemirrorHelper.toPlainText(node).trim() === ""; if (isEmpty) { start += node.nodeSize; } @@ -131,7 +128,7 @@ export class ProsemirrorHelper { if (!node) { break; } - isEmpty = ProsemirrorHelper.toPlainText(node, schema).trim() === ""; + isEmpty = ProsemirrorHelper.toPlainText(node).trim() === ""; if (isEmpty) { end -= node.nodeSize; } @@ -150,8 +147,6 @@ export class ProsemirrorHelper { return !doc || doc.textContent.trim() === ""; } - const textSerializers = getTextSerializers(schema); - let empty = true; doc.descendants((child: Node) => { // If we've already found non-empty data, we can stop descending further @@ -159,9 +154,8 @@ export class ProsemirrorHelper { return false; } - const toPlainText = textSerializers[child.type.name]; - if (toPlainText) { - empty = !toPlainText(child).trim(); + if (child.type.spec.leafText) { + empty = !child.type.spec.leafText(child).trim(); } else if (child.isText) { empty = !child.text?.trim(); } @@ -331,10 +325,9 @@ export class ProsemirrorHelper { * Iterates through the document to find all of the headings and their level. * * @param doc Prosemirror document node - * @param schema Prosemirror schema * @returns Array */ - static getHeadings(doc: Node, schema: Schema) { + static getHeadings(doc: Node) { const headings: Heading[] = []; const previouslySeen: Record = {}; @@ -356,7 +349,7 @@ export class ProsemirrorHelper { previouslySeen[id] !== undefined ? previouslySeen[id] + 1 : 1; headings.push({ - title: ProsemirrorHelper.toPlainText(node, schema), + title: ProsemirrorHelper.toPlainText(node), level: node.attrs.level, id: name, });