diff --git a/app/editor/index.tsx b/app/editor/index.tsx index 6573c6e7d..22da3668b 100644 --- a/app/editor/index.tsx +++ b/app/editor/index.tsx @@ -24,9 +24,9 @@ import ExtensionManager from "@shared/editor/lib/ExtensionManager"; import { MarkdownSerializer } from "@shared/editor/lib/markdown/serializer"; import textBetween from "@shared/editor/lib/textBetween"; import Mark from "@shared/editor/marks/Mark"; +import { richExtensions, withComments } from "@shared/editor/nodes"; import Node from "@shared/editor/nodes/Node"; import ReactNode from "@shared/editor/nodes/ReactNode"; -import fullExtensionsPackage from "@shared/editor/packages/full"; import { EventType } from "@shared/editor/types"; import { UserPreferences } from "@shared/types"; import ProsemirrorHelper from "@shared/utils/ProsemirrorHelper"; @@ -45,6 +45,8 @@ import MentionMenu from "./components/MentionMenu"; import SelectionToolbar from "./components/SelectionToolbar"; import WithTheme from "./components/WithTheme"; +const extensions = withComments(richExtensions); + export { default as Extension } from "@shared/editor/lib/Extension"; export type Props = { @@ -167,7 +169,7 @@ export class Editor extends React.PureComponent< // no default behavior }, embeds: [], - extensions: fullExtensionsPackage, + extensions, }; state = { diff --git a/app/scenes/Document/components/CommentEditor.tsx b/app/scenes/Document/components/CommentEditor.tsx index 660fe2f49..737abfbac 100644 --- a/app/scenes/Document/components/CommentEditor.tsx +++ b/app/scenes/Document/components/CommentEditor.tsx @@ -1,8 +1,10 @@ import * as React from "react"; -import extensions from "@shared/editor/packages/basic"; +import { basicExtensions, withComments } from "@shared/editor/nodes"; import Editor, { Props as EditorProps } from "~/components/Editor"; import type { Editor as SharedEditor } from "~/editor"; +const extensions = withComments(basicExtensions); + const CommentEditor = ( props: EditorProps, ref: React.RefObject diff --git a/app/scenes/Document/components/Editor.tsx b/app/scenes/Document/components/Editor.tsx index 4058701d3..b9fe8831b 100644 --- a/app/scenes/Document/components/Editor.tsx +++ b/app/scenes/Document/components/Editor.tsx @@ -3,7 +3,7 @@ import * as React from "react"; import { useTranslation } from "react-i18next"; import { mergeRefs } from "react-merge-refs"; import { useHistory, useRouteMatch } from "react-router-dom"; -import fullWithCommentsPackage from "@shared/editor/packages/fullWithComments"; +import { richExtensions, withComments } from "@shared/editor/nodes"; import { TeamPreference } from "@shared/types"; import Comment from "~/models/Comment"; import Document from "~/models/Document"; @@ -22,6 +22,8 @@ import MultiplayerEditor from "./AsyncMultiplayerEditor"; import DocumentMeta from "./DocumentMeta"; import EditableTitle from "./EditableTitle"; +const extensions = withComments(richExtensions); + type Props = Omit & { onChangeTitle: (text: string) => void; id: string; @@ -185,7 +187,7 @@ function DocumentEditor(props: Props, ref: React.RefObject) { ? handleRemoveComment : undefined } - extensions={fullWithCommentsPackage} + extensions={extensions} bottomPadding={`calc(50vh - ${childRef.current?.offsetHeight || 0}px)`} {...rest} /> diff --git a/build.sh b/build.sh index d456f56e1..97626a736 100755 --- a/build.sh +++ b/build.sh @@ -8,7 +8,7 @@ rm -rf ./build/plugins yarn concurrently "yarn babel --extensions .ts,.tsx --quiet -d ./build/server ./server" \ "yarn babel --extensions .ts,.tsx --quiet -d ./build/shared ./shared" -# Compile code in packages +# Compile code in plugins for d in ./plugins/*; do # Get the name of the folder package=$(basename "$d") diff --git a/server/editor/index.ts b/server/editor/index.ts index 0af5678e1..1690b41b3 100644 --- a/server/editor/index.ts +++ b/server/editor/index.ts @@ -1,17 +1,18 @@ import { Schema } from "prosemirror-model"; import ExtensionManager from "@shared/editor/lib/ExtensionManager"; -import extensionsPackage from "@shared/editor/packages/fullWithComments"; +import { richExtensions, withComments } from "@shared/editor/nodes"; -const extensions = new ExtensionManager(extensionsPackage); +const extensions = withComments(richExtensions); +const extensionManager = new ExtensionManager(extensions); export const schema = new Schema({ - nodes: extensions.nodes, - marks: extensions.marks, + nodes: extensionManager.nodes, + marks: extensionManager.marks, }); -export const parser = extensions.parser({ +export const parser = extensionManager.parser({ schema, - plugins: extensions.rulePlugins, + plugins: extensionManager.rulePlugins, }); -export const serializer = extensions.serializer(); +export const serializer = extensionManager.serializer(); diff --git a/server/models/helpers/DocumentHelper.test.ts b/server/models/helpers/DocumentHelper.test.ts index b61920781..c0aa37340 100644 --- a/server/models/helpers/DocumentHelper.test.ts +++ b/server/models/helpers/DocumentHelper.test.ts @@ -1,7 +1,38 @@ import Revision from "@server/models/Revision"; +import { buildDocument } from "@server/test/factories"; import DocumentHelper from "./DocumentHelper"; describe("DocumentHelper", () => { + describe("parseMentions", () => { + it("should not parse normal links as mentions", async () => { + const document = await buildDocument({ + text: `# Header + +[link not mention](http://google.com)`, + }); + const result = DocumentHelper.parseMentions(document); + expect(result.length).toBe(0); + }); + + it("should return an array of mentions", async () => { + const document = await buildDocument({ + text: `# Header + +@[Alan Kay](mention://2767ba0e-ac5c-4533-b9cf-4f5fc456600e/user/34095ac1-c808-45c0-8c6e-6c554497de64) :wink: + +More text + +@[Bret Victor](mention://34095ac1-c808-45c0-8c6e-6c554497de64/user/2767ba0e-ac5c-4533-b9cf-4f5fc456600e) :fire:`, + }); + const result = DocumentHelper.parseMentions(document); + expect(result.length).toBe(2); + expect(result[0].id).toBe("2767ba0e-ac5c-4533-b9cf-4f5fc456600e"); + expect(result[1].id).toBe("34095ac1-c808-45c0-8c6e-6c554497de64"); + expect(result[0].modelId).toBe("34095ac1-c808-45c0-8c6e-6c554497de64"); + expect(result[1].modelId).toBe("2767ba0e-ac5c-4533-b9cf-4f5fc456600e"); + }); + }); + describe("toEmailDiff", () => { it("should render a compact diff", async () => { const before = new Revision({ diff --git a/server/models/helpers/DocumentHelper.tsx b/server/models/helpers/DocumentHelper.tsx index bc4fa193b..0377165a5 100644 --- a/server/models/helpers/DocumentHelper.tsx +++ b/server/models/helpers/DocumentHelper.tsx @@ -119,6 +119,17 @@ export default class DocumentHelper { return output; } + /** + * Parse a list of mentions contained in a document or revision + * + * @param document Document or Revision + * @returns An array of mentions in passed document or revision + */ + static parseMentions(document: Document | Revision) { + const node = DocumentHelper.toProsemirror(document); + return ProsemirrorHelper.parseMentions(node); + } + /** * Generates a HTML diff between documents or revisions. * diff --git a/server/models/helpers/ProsemirrorHelper.tsx b/server/models/helpers/ProsemirrorHelper.tsx index 70856f2e4..82adb82be 100644 --- a/server/models/helpers/ProsemirrorHelper.tsx +++ b/server/models/helpers/ProsemirrorHelper.tsx @@ -20,8 +20,55 @@ export type HTMLOptions = { centered?: boolean; }; +type MentionAttrs = { + type: string; + label: string; + modelId: string; + actorId: string | undefined; + id: string; +}; + @trace() export default class ProsemirrorHelper { + /** + * Returns the data as a Prosemirror Node. + * + * @param node The node to parse + * @returns The content as a Prosemirror Node + */ + static toProsemirror(data: Record) { + return Node.fromJSON(schema, data); + } + + /** + * Returns an array of attributes of all mentions in the node. + * + * @param node The node to parse mentions from + * @returns An array of mention attributes + */ + static parseMentions(node: Node) { + const mentions: MentionAttrs[] = []; + + function findMentions(node: Node) { + if ( + node.type.name === "mention" && + !mentions.some((m) => m.id === node.attrs.id) + ) { + mentions.push(node.attrs as MentionAttrs); + } + + if (!node.content.size) { + return; + } + + node.content.descendants(findMentions); + } + + findMentions(node); + + return mentions; + } + /** * Returns the node as HTML. This is a lossy conversion and should only be used * for export. diff --git a/server/queues/processors/NotificationsProcessor.ts b/server/queues/processors/NotificationsProcessor.ts index db58d7ad2..513e97c47 100644 --- a/server/queues/processors/NotificationsProcessor.ts +++ b/server/queues/processors/NotificationsProcessor.ts @@ -27,7 +27,6 @@ import { DocumentEvent, CommentEvent, } from "@server/types"; -import parseMentions from "@server/utils/parseMentions"; import CommentCreatedNotificationTask from "../tasks/CommentCreatedNotificationTask"; import BaseProcessor from "./BaseProcessor"; @@ -82,7 +81,7 @@ export default class NotificationsProcessor extends BaseProcessor { await this.createDocumentSubscriptions(document, event); // Send notifications to mentioned users first - const mentions = parseMentions(document); + const mentions = DocumentHelper.parseMentions(document); const userIdsSentNotifications: string[] = []; for (const mention of mentions) { @@ -166,8 +165,8 @@ export default class NotificationsProcessor extends BaseProcessor { // Send notifications to mentioned users first const prev = await revision.previous(); - const oldMentions = prev ? parseMentions(prev) : []; - const newMentions = parseMentions(document); + const oldMentions = prev ? DocumentHelper.parseMentions(prev) : []; + const newMentions = DocumentHelper.parseMentions(document); const mentions = differenceBy(newMentions, oldMentions, "id"); const userIdsSentNotifications: string[] = []; diff --git a/server/queues/tasks/CommentCreatedNotificationTask.ts b/server/queues/tasks/CommentCreatedNotificationTask.ts index c9e6515d2..2bb6e3e07 100644 --- a/server/queues/tasks/CommentCreatedNotificationTask.ts +++ b/server/queues/tasks/CommentCreatedNotificationTask.ts @@ -3,7 +3,7 @@ import subscriptionCreator from "@server/commands/subscriptionCreator"; import { sequelize } from "@server/database/sequelize"; import { schema } from "@server/editor"; import CommentCreatedEmail from "@server/emails/templates/CommentCreatedEmail"; -import { Comment, Document, Notification, Team } from "@server/models"; +import { Comment, Document, Notification, Team, User } from "@server/models"; import DocumentHelper from "@server/models/helpers/DocumentHelper"; import NotificationHelper from "@server/models/helpers/NotificationHelper"; import ProsemirrorHelper from "@server/models/helpers/ProsemirrorHelper"; @@ -40,15 +40,6 @@ export default class CommentCreatedNotificationTask extends BaseTask< }); }); - const recipients = await NotificationHelper.getCommentNotificationRecipients( - document, - comment, - comment.createdById - ); - if (!recipients.length) { - return; - } - let content = ProsemirrorHelper.toHTML( Node.fromJSON(schema, comment.data), { @@ -65,6 +56,54 @@ export default class CommentCreatedNotificationTask extends BaseTask< 86400 * 4 ); + const mentions = ProsemirrorHelper.parseMentions( + ProsemirrorHelper.toProsemirror(comment.data) + ); + const userIdsSentNotifications: string[] = []; + + for (const mention of mentions) { + const [recipient, actor] = await Promise.all([ + User.findByPk(mention.modelId), + User.findByPk(mention.actorId), + ]); + if (recipient && actor && recipient.id !== actor.id) { + const notification = await Notification.create({ + event: event.name, + userId: recipient.id, + actorId: actor.id, + teamId: team.id, + documentId: document.id, + }); + userIdsSentNotifications.push(recipient.id); + await CommentCreatedEmail.schedule( + { + to: recipient.email, + documentId: document.id, + teamUrl: team.url, + isReply: !!comment.parentCommentId, + actorName: comment.createdBy.name, + commentId: comment.id, + content, + collectionName: document.collection?.name, + }, + { notificationId: notification.id } + ); + } + } + + const recipients = ( + await NotificationHelper.getCommentNotificationRecipients( + document, + comment, + comment.createdById + ) + ).filter( + (recipient) => !userIdsSentNotifications.includes(recipient.userId) + ); + if (!recipients.length) { + return; + } + for (const recipient of recipients) { const notification = await Notification.create({ event: event.name, diff --git a/server/utils/parseMentions.test.ts b/server/utils/parseMentions.test.ts deleted file mode 100644 index bd8fa67cb..000000000 --- a/server/utils/parseMentions.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { buildDocument } from "@server/test/factories"; -import parseMentions from "./parseMentions"; - -it("should not parse normal links as mentions", async () => { - const document = await buildDocument({ - text: `# Header - -[link not mention](http://google.com)`, - }); - const result = parseMentions(document); - expect(result.length).toBe(0); -}); - -it("should return an array of mentions", async () => { - const document = await buildDocument({ - text: `# Header - -@[Alan Kay](mention://2767ba0e-ac5c-4533-b9cf-4f5fc456600e/user/34095ac1-c808-45c0-8c6e-6c554497de64) :wink: - -More text - -@[Bret Victor](mention://34095ac1-c808-45c0-8c6e-6c554497de64/user/2767ba0e-ac5c-4533-b9cf-4f5fc456600e) :fire:`, - }); - const result = parseMentions(document); - expect(result.length).toBe(2); - expect(result[0].id).toBe("2767ba0e-ac5c-4533-b9cf-4f5fc456600e"); - expect(result[1].id).toBe("34095ac1-c808-45c0-8c6e-6c554497de64"); - expect(result[0].modelId).toBe("34095ac1-c808-45c0-8c6e-6c554497de64"); - expect(result[1].modelId).toBe("2767ba0e-ac5c-4533-b9cf-4f5fc456600e"); -}); diff --git a/server/utils/parseMentions.ts b/server/utils/parseMentions.ts deleted file mode 100644 index cd78459fe..000000000 --- a/server/utils/parseMentions.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Node } from "prosemirror-model"; -import { Document, Revision } from "@server/models"; -import DocumentHelper from "@server/models/helpers/DocumentHelper"; - -/** - * Parse a list of mentions contained in a document or revision - * - * @param document Document or Revision - * @returns An array of mentions in passed document or revision - */ -export default function parseMentions( - document: Document | Revision -): Record[] { - const node = DocumentHelper.toProsemirror(document); - const mentions: Record[] = []; - - function findMentions(node: Node) { - if ( - node.type.name === "mention" && - !mentions.some((m) => m.id === node.attrs.id) - ) { - mentions.push(node.attrs); - } - - if (!node.content.size) { - return; - } - - node.content.descendants(findMentions); - } - - findMentions(node); - - return mentions; -} diff --git a/shared/editor/nodes/index.ts b/shared/editor/nodes/index.ts new file mode 100644 index 000000000..ccc5cf697 --- /dev/null +++ b/shared/editor/nodes/index.ts @@ -0,0 +1,117 @@ +import Extension from "../lib/Extension"; +import Bold from "../marks/Bold"; +import Code from "../marks/Code"; +import Comment from "../marks/Comment"; +import Highlight from "../marks/Highlight"; +import Italic from "../marks/Italic"; +import Link from "../marks/Link"; +import Mark from "../marks/Mark"; +import TemplatePlaceholder from "../marks/Placeholder"; +import Strikethrough from "../marks/Strikethrough"; +import Underline from "../marks/Underline"; +import BlockMenuTrigger from "../plugins/BlockMenuTrigger"; +import ClipboardTextSerializer from "../plugins/ClipboardTextSerializer"; +import DateTime from "../plugins/DateTime"; +import Folding from "../plugins/Folding"; +import History from "../plugins/History"; +import Keys from "../plugins/Keys"; +import MaxLength from "../plugins/MaxLength"; +import PasteHandler from "../plugins/PasteHandler"; +import Placeholder from "../plugins/Placeholder"; +import PreventTab from "../plugins/PreventTab"; +import SmartText from "../plugins/SmartText"; +import TrailingNode from "../plugins/TrailingNode"; +import Attachment from "./Attachment"; +import Blockquote from "./Blockquote"; +import BulletList from "./BulletList"; +import CheckboxItem from "./CheckboxItem"; +import CheckboxList from "./CheckboxList"; +import CodeBlock from "./CodeBlock"; +import CodeFence from "./CodeFence"; +import Doc from "./Doc"; +import Embed from "./Embed"; +import Emoji from "./Emoji"; +import HardBreak from "./HardBreak"; +import Heading from "./Heading"; +import HorizontalRule from "./HorizontalRule"; +import Image from "./Image"; +import ListItem from "./ListItem"; +import Math from "./Math"; +import MathBlock from "./MathBlock"; +import Mention from "./Mention"; +import Node from "./Node"; +import Notice from "./Notice"; +import OrderedList from "./OrderedList"; +import Paragraph from "./Paragraph"; +import Table from "./Table"; +import TableCell from "./TableCell"; +import TableHeadCell from "./TableHeadCell"; +import TableRow from "./TableRow"; +import Text from "./Text"; + +type Nodes = (typeof Node | typeof Mark | typeof Extension)[]; + +/** + * The basic set of nodes that are used in the editor. This is used for simple + * editors that need basic formatting. + */ +export const basicExtensions: Nodes = [ + Doc, + Paragraph, + Emoji, + Text, + Image, + Bold, + Code, + Italic, + Underline, + Link, + Strikethrough, + History, + SmartText, + TrailingNode, + PasteHandler, + Placeholder, + MaxLength, + DateTime, + Keys, + ClipboardTextSerializer, +]; + +/** + * The full set of nodes that are used in the editor. This is used for rich + * editors that need advanced formatting. + */ +export const richExtensions: Nodes = [ + ...basicExtensions, + HardBreak, + CodeBlock, + CodeFence, + CheckboxList, + CheckboxItem, + Blockquote, + BulletList, + OrderedList, + Embed, + ListItem, + Attachment, + Notice, + Heading, + HorizontalRule, + Table, + TableCell, + TableHeadCell, + TableRow, + Highlight, + TemplatePlaceholder, + Folding, + BlockMenuTrigger, + Math, + MathBlock, + PreventTab, +]; + +/** + * Add commenting and mentions to a set of nodes + */ +export const withComments = (nodes: Nodes) => [...nodes, Mention, Comment]; diff --git a/shared/editor/packages/README.md b/shared/editor/packages/README.md deleted file mode 100644 index 3b7948580..000000000 --- a/shared/editor/packages/README.md +++ /dev/null @@ -1,2 +0,0 @@ -Packages are preselected collections of extensions that form the different types -of editors within Outline. diff --git a/shared/editor/packages/basic.ts b/shared/editor/packages/basic.ts deleted file mode 100644 index ab7d50a8d..000000000 --- a/shared/editor/packages/basic.ts +++ /dev/null @@ -1,50 +0,0 @@ -import Extension from "../lib/Extension"; -import Bold from "../marks/Bold"; -import Code from "../marks/Code"; -import Italic from "../marks/Italic"; -import Link from "../marks/Link"; -import Mark from "../marks/Mark"; -import Strikethrough from "../marks/Strikethrough"; -import Underline from "../marks/Underline"; -import Doc from "../nodes/Doc"; -import Emoji from "../nodes/Emoji"; -import Image from "../nodes/Image"; -import Mention from "../nodes/Mention"; -import Node from "../nodes/Node"; -import Paragraph from "../nodes/Paragraph"; -import Text from "../nodes/Text"; -import ClipboardTextSerializer from "../plugins/ClipboardTextSerializer"; -import DateTime from "../plugins/DateTime"; -import History from "../plugins/History"; -import Keys from "../plugins/Keys"; -import MaxLength from "../plugins/MaxLength"; -import PasteHandler from "../plugins/PasteHandler"; -import Placeholder from "../plugins/Placeholder"; -import SmartText from "../plugins/SmartText"; -import TrailingNode from "../plugins/TrailingNode"; - -const basicPackage: (typeof Node | typeof Mark | typeof Extension)[] = [ - Doc, - Paragraph, - Emoji, - Text, - Image, - Bold, - Code, - Italic, - Underline, - Link, - Strikethrough, - History, - SmartText, - TrailingNode, - PasteHandler, - Placeholder, - MaxLength, - DateTime, - Keys, - ClipboardTextSerializer, - Mention, -]; - -export default basicPackage; diff --git a/shared/editor/packages/full.ts b/shared/editor/packages/full.ts deleted file mode 100644 index 398afa046..000000000 --- a/shared/editor/packages/full.ts +++ /dev/null @@ -1,60 +0,0 @@ -import Extension from "../lib/Extension"; -import Highlight from "../marks/Highlight"; -import Mark from "../marks/Mark"; -import TemplatePlaceholder from "../marks/Placeholder"; -import Attachment from "../nodes/Attachment"; -import Blockquote from "../nodes/Blockquote"; -import BulletList from "../nodes/BulletList"; -import CheckboxItem from "../nodes/CheckboxItem"; -import CheckboxList from "../nodes/CheckboxList"; -import CodeBlock from "../nodes/CodeBlock"; -import CodeFence from "../nodes/CodeFence"; -import Embed from "../nodes/Embed"; -import HardBreak from "../nodes/HardBreak"; -import Heading from "../nodes/Heading"; -import HorizontalRule from "../nodes/HorizontalRule"; -import ListItem from "../nodes/ListItem"; -import Math from "../nodes/Math"; -import MathBlock from "../nodes/MathBlock"; -import Node from "../nodes/Node"; -import Notice from "../nodes/Notice"; -import OrderedList from "../nodes/OrderedList"; -import Table from "../nodes/Table"; -import TableCell from "../nodes/TableCell"; -import TableHeadCell from "../nodes/TableHeadCell"; -import TableRow from "../nodes/TableRow"; -import BlockMenuTrigger from "../plugins/BlockMenuTrigger"; -import Folding from "../plugins/Folding"; -import PreventTab from "../plugins/PreventTab"; -import basicPackage from "./basic"; - -const fullPackage: (typeof Node | typeof Mark | typeof Extension)[] = [ - ...basicPackage, - HardBreak, - CodeBlock, - CodeFence, - CheckboxList, - CheckboxItem, - Blockquote, - BulletList, - OrderedList, - Embed, - ListItem, - Attachment, - Notice, - Heading, - HorizontalRule, - Table, - TableCell, - TableHeadCell, - TableRow, - Highlight, - TemplatePlaceholder, - Folding, - BlockMenuTrigger, - Math, - MathBlock, - PreventTab, -]; - -export default fullPackage; diff --git a/shared/editor/packages/fullWithComments.ts b/shared/editor/packages/fullWithComments.ts deleted file mode 100644 index 06dff7322..000000000 --- a/shared/editor/packages/fullWithComments.ts +++ /dev/null @@ -1,13 +0,0 @@ -import Extension from "../lib/Extension"; -import Comment from "../marks/Comment"; -import Mark from "../marks/Mark"; -import Node from "../nodes/Node"; -import fullPackage from "./full"; - -const fullWithCommentsPackage: ( - | typeof Node - | typeof Mark - | typeof Extension -)[] = [...fullPackage, Comment]; - -export default fullWithCommentsPackage; diff --git a/shared/utils/ProsemirrorHelper.ts b/shared/utils/ProsemirrorHelper.ts index 41a103723..2d4cde4fd 100644 --- a/shared/utils/ProsemirrorHelper.ts +++ b/shared/utils/ProsemirrorHelper.ts @@ -1,4 +1,5 @@ -import { Node } from "prosemirror-model"; +import { Node, Schema } from "prosemirror-model"; +import textBetween from "@shared/editor/lib/textBetween"; import headingToSlug from "../editor/lib/headingToSlug"; export type Heading = { @@ -25,6 +26,23 @@ export type Task = { }; export default class ProsemirrorHelper { + /** + * 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(node: Node, schema: Schema) { + const textSerializers = Object.fromEntries( + Object.entries(schema.nodes) + .filter(([, node]) => node.spec.toPlainText) + .map(([name, node]) => [name, node.spec.toPlainText]) + ); + + return textBetween(node, 0, node.content.size, textSerializers); + } + /** * Removes any empty paragraphs from the beginning and end of the document. * @@ -34,9 +52,11 @@ export default class ProsemirrorHelper { const first = doc.firstChild; const last = doc.lastChild; const firstIsEmpty = - first?.type.name === "paragraph" && !first.textContent.trim(); + first && + ProsemirrorHelper.toPlainText(first, doc.type.schema).trim() === ""; const lastIsEmpty = - last?.type.name === "paragraph" && !last.textContent.trim(); + last && + ProsemirrorHelper.toPlainText(last, doc.type.schema).trim() === ""; const firstIsLast = first === last; return doc.cut(