feat: Add @mention support to comments (#5001)
* Refactor, remove confusing 'packages' language * Basic notifications when mentioned in comment * fix: Incorrect trimming of comments * test
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -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<SharedEditor>
|
||||
|
||||
@@ -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<EditorProps, "extensions"> & {
|
||||
onChangeTitle: (text: string) => void;
|
||||
id: string;
|
||||
@@ -185,7 +187,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
? handleRemoveComment
|
||||
: undefined
|
||||
}
|
||||
extensions={fullWithCommentsPackage}
|
||||
extensions={extensions}
|
||||
bottomPadding={`calc(50vh - ${childRef.current?.offsetHeight || 0}px)`}
|
||||
{...rest}
|
||||
/>
|
||||
|
||||
2
build.sh
2
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")
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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<string, any>) {
|
||||
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.
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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<string, string>[] {
|
||||
const node = DocumentHelper.toProsemirror(document);
|
||||
const mentions: Record<string, string>[] = [];
|
||||
|
||||
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;
|
||||
}
|
||||
117
shared/editor/nodes/index.ts
Normal file
117
shared/editor/nodes/index.ts
Normal file
@@ -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];
|
||||
@@ -1,2 +0,0 @@
|
||||
Packages are preselected collections of extensions that form the different types
|
||||
of editors within Outline.
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user