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 { MarkdownSerializer } from "@shared/editor/lib/markdown/serializer";
|
||||||
import textBetween from "@shared/editor/lib/textBetween";
|
import textBetween from "@shared/editor/lib/textBetween";
|
||||||
import Mark from "@shared/editor/marks/Mark";
|
import Mark from "@shared/editor/marks/Mark";
|
||||||
|
import { richExtensions, withComments } from "@shared/editor/nodes";
|
||||||
import Node from "@shared/editor/nodes/Node";
|
import Node from "@shared/editor/nodes/Node";
|
||||||
import ReactNode from "@shared/editor/nodes/ReactNode";
|
import ReactNode from "@shared/editor/nodes/ReactNode";
|
||||||
import fullExtensionsPackage from "@shared/editor/packages/full";
|
|
||||||
import { EventType } from "@shared/editor/types";
|
import { EventType } from "@shared/editor/types";
|
||||||
import { UserPreferences } from "@shared/types";
|
import { UserPreferences } from "@shared/types";
|
||||||
import ProsemirrorHelper from "@shared/utils/ProsemirrorHelper";
|
import ProsemirrorHelper from "@shared/utils/ProsemirrorHelper";
|
||||||
@@ -45,6 +45,8 @@ import MentionMenu from "./components/MentionMenu";
|
|||||||
import SelectionToolbar from "./components/SelectionToolbar";
|
import SelectionToolbar from "./components/SelectionToolbar";
|
||||||
import WithTheme from "./components/WithTheme";
|
import WithTheme from "./components/WithTheme";
|
||||||
|
|
||||||
|
const extensions = withComments(richExtensions);
|
||||||
|
|
||||||
export { default as Extension } from "@shared/editor/lib/Extension";
|
export { default as Extension } from "@shared/editor/lib/Extension";
|
||||||
|
|
||||||
export type Props = {
|
export type Props = {
|
||||||
@@ -167,7 +169,7 @@ export class Editor extends React.PureComponent<
|
|||||||
// no default behavior
|
// no default behavior
|
||||||
},
|
},
|
||||||
embeds: [],
|
embeds: [],
|
||||||
extensions: fullExtensionsPackage,
|
extensions,
|
||||||
};
|
};
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import * as React from "react";
|
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 Editor, { Props as EditorProps } from "~/components/Editor";
|
||||||
import type { Editor as SharedEditor } from "~/editor";
|
import type { Editor as SharedEditor } from "~/editor";
|
||||||
|
|
||||||
|
const extensions = withComments(basicExtensions);
|
||||||
|
|
||||||
const CommentEditor = (
|
const CommentEditor = (
|
||||||
props: EditorProps,
|
props: EditorProps,
|
||||||
ref: React.RefObject<SharedEditor>
|
ref: React.RefObject<SharedEditor>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import * as React from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { mergeRefs } from "react-merge-refs";
|
import { mergeRefs } from "react-merge-refs";
|
||||||
import { useHistory, useRouteMatch } from "react-router-dom";
|
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 { TeamPreference } from "@shared/types";
|
||||||
import Comment from "~/models/Comment";
|
import Comment from "~/models/Comment";
|
||||||
import Document from "~/models/Document";
|
import Document from "~/models/Document";
|
||||||
@@ -22,6 +22,8 @@ import MultiplayerEditor from "./AsyncMultiplayerEditor";
|
|||||||
import DocumentMeta from "./DocumentMeta";
|
import DocumentMeta from "./DocumentMeta";
|
||||||
import EditableTitle from "./EditableTitle";
|
import EditableTitle from "./EditableTitle";
|
||||||
|
|
||||||
|
const extensions = withComments(richExtensions);
|
||||||
|
|
||||||
type Props = Omit<EditorProps, "extensions"> & {
|
type Props = Omit<EditorProps, "extensions"> & {
|
||||||
onChangeTitle: (text: string) => void;
|
onChangeTitle: (text: string) => void;
|
||||||
id: string;
|
id: string;
|
||||||
@@ -185,7 +187,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
|||||||
? handleRemoveComment
|
? handleRemoveComment
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
extensions={fullWithCommentsPackage}
|
extensions={extensions}
|
||||||
bottomPadding={`calc(50vh - ${childRef.current?.offsetHeight || 0}px)`}
|
bottomPadding={`calc(50vh - ${childRef.current?.offsetHeight || 0}px)`}
|
||||||
{...rest}
|
{...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 concurrently "yarn babel --extensions .ts,.tsx --quiet -d ./build/server ./server" \
|
||||||
"yarn babel --extensions .ts,.tsx --quiet -d ./build/shared ./shared"
|
"yarn babel --extensions .ts,.tsx --quiet -d ./build/shared ./shared"
|
||||||
|
|
||||||
# Compile code in packages
|
# Compile code in plugins
|
||||||
for d in ./plugins/*; do
|
for d in ./plugins/*; do
|
||||||
# Get the name of the folder
|
# Get the name of the folder
|
||||||
package=$(basename "$d")
|
package=$(basename "$d")
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import { Schema } from "prosemirror-model";
|
import { Schema } from "prosemirror-model";
|
||||||
import ExtensionManager from "@shared/editor/lib/ExtensionManager";
|
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({
|
export const schema = new Schema({
|
||||||
nodes: extensions.nodes,
|
nodes: extensionManager.nodes,
|
||||||
marks: extensions.marks,
|
marks: extensionManager.marks,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const parser = extensions.parser({
|
export const parser = extensionManager.parser({
|
||||||
schema,
|
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 Revision from "@server/models/Revision";
|
||||||
|
import { buildDocument } from "@server/test/factories";
|
||||||
import DocumentHelper from "./DocumentHelper";
|
import DocumentHelper from "./DocumentHelper";
|
||||||
|
|
||||||
describe("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", () => {
|
describe("toEmailDiff", () => {
|
||||||
it("should render a compact diff", async () => {
|
it("should render a compact diff", async () => {
|
||||||
const before = new Revision({
|
const before = new Revision({
|
||||||
|
|||||||
@@ -119,6 +119,17 @@ export default class DocumentHelper {
|
|||||||
return output;
|
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.
|
* Generates a HTML diff between documents or revisions.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -20,8 +20,55 @@ export type HTMLOptions = {
|
|||||||
centered?: boolean;
|
centered?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type MentionAttrs = {
|
||||||
|
type: string;
|
||||||
|
label: string;
|
||||||
|
modelId: string;
|
||||||
|
actorId: string | undefined;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
@trace()
|
@trace()
|
||||||
export default class ProsemirrorHelper {
|
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
|
* Returns the node as HTML. This is a lossy conversion and should only be used
|
||||||
* for export.
|
* for export.
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import {
|
|||||||
DocumentEvent,
|
DocumentEvent,
|
||||||
CommentEvent,
|
CommentEvent,
|
||||||
} from "@server/types";
|
} from "@server/types";
|
||||||
import parseMentions from "@server/utils/parseMentions";
|
|
||||||
import CommentCreatedNotificationTask from "../tasks/CommentCreatedNotificationTask";
|
import CommentCreatedNotificationTask from "../tasks/CommentCreatedNotificationTask";
|
||||||
import BaseProcessor from "./BaseProcessor";
|
import BaseProcessor from "./BaseProcessor";
|
||||||
|
|
||||||
@@ -82,7 +81,7 @@ export default class NotificationsProcessor extends BaseProcessor {
|
|||||||
await this.createDocumentSubscriptions(document, event);
|
await this.createDocumentSubscriptions(document, event);
|
||||||
|
|
||||||
// Send notifications to mentioned users first
|
// Send notifications to mentioned users first
|
||||||
const mentions = parseMentions(document);
|
const mentions = DocumentHelper.parseMentions(document);
|
||||||
const userIdsSentNotifications: string[] = [];
|
const userIdsSentNotifications: string[] = [];
|
||||||
|
|
||||||
for (const mention of mentions) {
|
for (const mention of mentions) {
|
||||||
@@ -166,8 +165,8 @@ export default class NotificationsProcessor extends BaseProcessor {
|
|||||||
|
|
||||||
// Send notifications to mentioned users first
|
// Send notifications to mentioned users first
|
||||||
const prev = await revision.previous();
|
const prev = await revision.previous();
|
||||||
const oldMentions = prev ? parseMentions(prev) : [];
|
const oldMentions = prev ? DocumentHelper.parseMentions(prev) : [];
|
||||||
const newMentions = parseMentions(document);
|
const newMentions = DocumentHelper.parseMentions(document);
|
||||||
const mentions = differenceBy(newMentions, oldMentions, "id");
|
const mentions = differenceBy(newMentions, oldMentions, "id");
|
||||||
const userIdsSentNotifications: string[] = [];
|
const userIdsSentNotifications: string[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import subscriptionCreator from "@server/commands/subscriptionCreator";
|
|||||||
import { sequelize } from "@server/database/sequelize";
|
import { sequelize } from "@server/database/sequelize";
|
||||||
import { schema } from "@server/editor";
|
import { schema } from "@server/editor";
|
||||||
import CommentCreatedEmail from "@server/emails/templates/CommentCreatedEmail";
|
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 DocumentHelper from "@server/models/helpers/DocumentHelper";
|
||||||
import NotificationHelper from "@server/models/helpers/NotificationHelper";
|
import NotificationHelper from "@server/models/helpers/NotificationHelper";
|
||||||
import ProsemirrorHelper from "@server/models/helpers/ProsemirrorHelper";
|
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(
|
let content = ProsemirrorHelper.toHTML(
|
||||||
Node.fromJSON(schema, comment.data),
|
Node.fromJSON(schema, comment.data),
|
||||||
{
|
{
|
||||||
@@ -65,6 +56,54 @@ export default class CommentCreatedNotificationTask extends BaseTask<
|
|||||||
86400 * 4
|
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) {
|
for (const recipient of recipients) {
|
||||||
const notification = await Notification.create({
|
const notification = await Notification.create({
|
||||||
event: event.name,
|
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";
|
import headingToSlug from "../editor/lib/headingToSlug";
|
||||||
|
|
||||||
export type Heading = {
|
export type Heading = {
|
||||||
@@ -25,6 +26,23 @@ export type Task = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default class ProsemirrorHelper {
|
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.
|
* 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 first = doc.firstChild;
|
||||||
const last = doc.lastChild;
|
const last = doc.lastChild;
|
||||||
const firstIsEmpty =
|
const firstIsEmpty =
|
||||||
first?.type.name === "paragraph" && !first.textContent.trim();
|
first &&
|
||||||
|
ProsemirrorHelper.toPlainText(first, doc.type.schema).trim() === "";
|
||||||
const lastIsEmpty =
|
const lastIsEmpty =
|
||||||
last?.type.name === "paragraph" && !last.textContent.trim();
|
last &&
|
||||||
|
ProsemirrorHelper.toPlainText(last, doc.type.schema).trim() === "";
|
||||||
const firstIsLast = first === last;
|
const firstIsLast = first === last;
|
||||||
|
|
||||||
return doc.cut(
|
return doc.cut(
|
||||||
|
|||||||
Reference in New Issue
Block a user