Capability to mention users in a document (#4838)
* feat: mention user * fix: trigger api call on every letter typed * fix: this allows command menu to re-render upon props change, shouldComponentUpdate prevented re-rendering when necessary * fix: add node * fix: mention node styling * fix: Caret not visible after inserting mention * fix: apply mentionRule * fix: label is to be obtained from content, not attrs * feat: add mentions table and model * fix: typo * fix: make all mention nodes visible in shared doc * feat: parse mention ids from doc text * feat: MentionsProcessor * feat: documents.publish tests * feat: tests for MentionsProcessor * feat: schedule notifs for mentions * fix: get rid of Mention model * fix: put actor id and mention id in raw md * Revert "fix: put actor id and mention id in raw md" This reverts commit 3bb8a22e3c560971dccad6d2f82266256bcb2d96. * Revert "Revert "fix: put actor id and mention id in raw md"" This reverts commit 3c5b36c40cebf147663908cf27d0dce6488adfad. * fix: review * fix: no need of set * fix: show avatar * fix: get rid of eventName * fix: font-weight * fix: prioritize mention notifs * fix: store id in md * fix: no need of prepending m * fix: fetchPage * fix: Avatars incorrect color * fix: remove scanRE * fix: test * fix: include alphabet other than latin * lockfile * fix: regex should test for letters, marks and digits --------- Co-authored-by: Tom Moor <tom.moor@gmail.com>
This commit is contained in:
@@ -62,7 +62,10 @@ type State = {
|
||||
selectedIndex: number;
|
||||
};
|
||||
|
||||
class CommandMenu<T extends MenuItem> extends React.Component<Props<T>, State> {
|
||||
class CommandMenu<T extends MenuItem> extends React.PureComponent<
|
||||
Props<T>,
|
||||
State
|
||||
> {
|
||||
menuRef = React.createRef<HTMLDivElement>();
|
||||
inputRef = React.createRef<HTMLInputElement>();
|
||||
|
||||
@@ -80,14 +83,6 @@ class CommandMenu<T extends MenuItem> extends React.Component<Props<T>, State> {
|
||||
window.addEventListener("keydown", this.handleKeyDown);
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps: Props<T>, nextState: State) {
|
||||
return (
|
||||
nextProps.search !== this.props.search ||
|
||||
nextProps.isActive !== this.props.isActive ||
|
||||
nextState !== this.state
|
||||
);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props<T>) {
|
||||
if (!prevProps.isActive && this.props.isActive) {
|
||||
// reset scroll position to top when opening menu as the contents are
|
||||
@@ -210,7 +205,7 @@ class CommandMenu<T extends MenuItem> extends React.Component<Props<T>, State> {
|
||||
return;
|
||||
}
|
||||
default:
|
||||
this.insertBlock(item);
|
||||
this.insertNode(item);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -239,7 +234,7 @@ class CommandMenu<T extends MenuItem> extends React.Component<Props<T>, State> {
|
||||
return;
|
||||
}
|
||||
|
||||
this.insertBlock({
|
||||
this.insertNode({
|
||||
name: "embed",
|
||||
attrs: {
|
||||
href,
|
||||
@@ -268,7 +263,7 @@ class CommandMenu<T extends MenuItem> extends React.Component<Props<T>, State> {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
this.insertBlock({
|
||||
this.insertNode({
|
||||
name: "embed",
|
||||
attrs: {
|
||||
href,
|
||||
@@ -331,7 +326,7 @@ class CommandMenu<T extends MenuItem> extends React.Component<Props<T>, State> {
|
||||
this.props.onClearSearch();
|
||||
};
|
||||
|
||||
insertBlock(item: MenuItem) {
|
||||
insertNode(item: MenuItem) {
|
||||
this.clearSearch();
|
||||
|
||||
const command = item.name ? this.props.commands[item.name] : undefined;
|
||||
@@ -341,6 +336,11 @@ class CommandMenu<T extends MenuItem> extends React.Component<Props<T>, State> {
|
||||
} else {
|
||||
this.props.commands[`create${capitalize(item.name)}`](item.attrs);
|
||||
}
|
||||
if (item.appendSpace) {
|
||||
const { view } = this.props;
|
||||
const { dispatch } = view;
|
||||
dispatch(view.state.tr.insertText(" "));
|
||||
}
|
||||
|
||||
this.props.onClose();
|
||||
}
|
||||
|
||||
113
app/editor/components/MentionMenu.tsx
Normal file
113
app/editor/components/MentionMenu.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { v4 } from "uuid";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { MentionType } from "@shared/types";
|
||||
import User from "~/models/User";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import CommandMenu, { Props } from "./CommandMenu";
|
||||
import MentionMenuItem from "./MentionMenuItem";
|
||||
|
||||
interface MentionItem extends MenuItem {
|
||||
name: string;
|
||||
user: User;
|
||||
appendSpace: boolean;
|
||||
attrs: {
|
||||
id: string;
|
||||
type: MentionType;
|
||||
modelId: string;
|
||||
label: string;
|
||||
actorId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
type MentionMenuProps = Omit<
|
||||
Props<MentionItem>,
|
||||
"renderMenuItem" | "items" | "onLinkToolbarOpen" | "embeds" | "onClearSearch"
|
||||
>;
|
||||
|
||||
function MentionMenu({ search, ...rest }: MentionMenuProps) {
|
||||
const [items, setItems] = React.useState<MentionItem[]>([]);
|
||||
const { t } = useTranslation();
|
||||
const { users, auth } = useStores();
|
||||
const { data, request } = useRequest(
|
||||
React.useCallback(() => users.fetchPage({ query: search }), [users, search])
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
request();
|
||||
}, [request]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (data) {
|
||||
setItems(
|
||||
data.map((user) => ({
|
||||
name: "mention",
|
||||
user,
|
||||
title: user.name,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: v4(),
|
||||
type: MentionType.User,
|
||||
modelId: user.id,
|
||||
actorId: auth.user?.id,
|
||||
label: user.name,
|
||||
},
|
||||
}))
|
||||
);
|
||||
}
|
||||
}, [auth.user?.id, data]);
|
||||
|
||||
const clearSearch = () => {
|
||||
const { state, dispatch } = rest.view;
|
||||
|
||||
// clear search input
|
||||
dispatch(
|
||||
state.tr.insertText(
|
||||
"",
|
||||
state.selection.$from.pos - (search ?? "").length - 1,
|
||||
state.selection.to
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const containerId = "mention-menu-container";
|
||||
return (
|
||||
<CommandMenu
|
||||
{...rest}
|
||||
id={containerId}
|
||||
filterable={false}
|
||||
onClearSearch={clearSearch}
|
||||
search={search}
|
||||
renderMenuItem={(item, _index, options) => (
|
||||
<MentionMenuItem
|
||||
onClick={options.onClick}
|
||||
selected={options.selected}
|
||||
title={item.title}
|
||||
label={item.attrs.label}
|
||||
containerId={containerId}
|
||||
icon={
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
style={{ width: 24, height: 24 }}
|
||||
>
|
||||
<Avatar
|
||||
model={item.user}
|
||||
showBorder={false}
|
||||
alt={t("Profile picture")}
|
||||
size={16}
|
||||
/>
|
||||
</Flex>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
items={items}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default MentionMenu;
|
||||
15
app/editor/components/MentionMenuItem.tsx
Normal file
15
app/editor/components/MentionMenuItem.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import * as React from "react";
|
||||
import CommandMenuItem, {
|
||||
Props as CommandMenuItemProps,
|
||||
} from "./CommandMenuItem";
|
||||
|
||||
type MentionMenuItemProps = Omit<CommandMenuItemProps, "shortcut" | "theme"> & {
|
||||
label: string;
|
||||
};
|
||||
|
||||
export default function MentionMenuItem({
|
||||
label,
|
||||
...rest
|
||||
}: MentionMenuItemProps) {
|
||||
return <CommandMenuItem {...rest} title={label} />;
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import EditorContext from "./components/EditorContext";
|
||||
import EmojiMenu from "./components/EmojiMenu";
|
||||
import { SearchResult } from "./components/LinkEditor";
|
||||
import LinkToolbar from "./components/LinkToolbar";
|
||||
import MentionMenu from "./components/MentionMenu";
|
||||
import SelectionToolbar from "./components/SelectionToolbar";
|
||||
import WithTheme from "./components/WithTheme";
|
||||
|
||||
@@ -142,6 +143,8 @@ type State = {
|
||||
blockMenuSearch: string;
|
||||
/** If the emoji insert menu is visible */
|
||||
emojiMenuOpen: boolean;
|
||||
/** If the mention user menu is visible */
|
||||
mentionMenuOpen: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -175,6 +178,7 @@ export class Editor extends React.PureComponent<
|
||||
linkMenuOpen: false,
|
||||
blockMenuSearch: "",
|
||||
emojiMenuOpen: false,
|
||||
mentionMenuOpen: false,
|
||||
};
|
||||
|
||||
isBlurred: boolean;
|
||||
@@ -214,6 +218,8 @@ export class Editor extends React.PureComponent<
|
||||
this.events.on(EventType.blockMenuClose, this.handleCloseBlockMenu);
|
||||
this.events.on(EventType.emojiMenuOpen, this.handleOpenEmojiMenu);
|
||||
this.events.on(EventType.emojiMenuClose, this.handleCloseEmojiMenu);
|
||||
this.events.on(EventType.mentionMenuOpen, this.handleOpenMentionMenu);
|
||||
this.events.on(EventType.mentionMenuClose, this.handleCloseMentionMenu);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -673,6 +679,10 @@ export class Editor extends React.PureComponent<
|
||||
this.setState({ emojiMenuOpen: true, blockMenuSearch: search });
|
||||
};
|
||||
|
||||
private handleOpenMentionMenu = (search: string) => {
|
||||
this.setState({ mentionMenuOpen: true, blockMenuSearch: search });
|
||||
};
|
||||
|
||||
private handleCloseEmojiMenu = () => {
|
||||
if (!this.state.emojiMenuOpen) {
|
||||
return;
|
||||
@@ -680,6 +690,13 @@ export class Editor extends React.PureComponent<
|
||||
this.setState({ emojiMenuOpen: false });
|
||||
};
|
||||
|
||||
private handleCloseMentionMenu = () => {
|
||||
if (!this.state.mentionMenuOpen) {
|
||||
return;
|
||||
}
|
||||
this.setState({ mentionMenuOpen: false });
|
||||
};
|
||||
|
||||
private handleOpenLinkMenu = () => {
|
||||
this.setState({ blockMenuOpen: false, linkMenuOpen: true });
|
||||
};
|
||||
@@ -772,6 +789,16 @@ export class Editor extends React.PureComponent<
|
||||
search={this.state.blockMenuSearch}
|
||||
onClose={this.handleCloseEmojiMenu}
|
||||
/>
|
||||
<MentionMenu
|
||||
view={this.view}
|
||||
commands={this.commands}
|
||||
dictionary={dictionary}
|
||||
rtl={isRTL}
|
||||
onShowToast={this.props.onShowToast}
|
||||
isActive={this.state.mentionMenuOpen}
|
||||
search={this.state.blockMenuSearch}
|
||||
onClose={this.handleCloseMentionMenu}
|
||||
/>
|
||||
<BlockMenu
|
||||
view={this.view}
|
||||
commands={this.commands}
|
||||
|
||||
@@ -231,13 +231,14 @@ export default abstract class BaseStore<T extends BaseModel> {
|
||||
const res = await client.post(`/${this.apiEndpoint}.list`, params);
|
||||
invariant(res?.data, "Data not available");
|
||||
|
||||
let response: T[] = [];
|
||||
|
||||
runInAction(`list#${this.modelName}`, () => {
|
||||
this.addPolicies(res.policies);
|
||||
res.data.forEach(this.add);
|
||||
response = res.data.map(this.add);
|
||||
this.isLoaded = true;
|
||||
});
|
||||
|
||||
const response = res.data;
|
||||
response[PAGINATION_SYMBOL] = res.pagination;
|
||||
return response;
|
||||
} finally {
|
||||
|
||||
1
app/typings/styled-components.d.ts
vendored
1
app/typings/styled-components.d.ts
vendored
@@ -158,6 +158,7 @@ declare module "styled-components" {
|
||||
inputBorder: string;
|
||||
inputBorderFocused: string;
|
||||
listItemHoverBackground: string;
|
||||
mentionBackground: string;
|
||||
buttonNeutralBackground: string;
|
||||
buttonNeutralText: string;
|
||||
buttonNeutralBorder: string;
|
||||
|
||||
83
server/emails/templates/MentionNotificationEmail.tsx
Normal file
83
server/emails/templates/MentionNotificationEmail.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import * as React from "react";
|
||||
import { Document } from "@server/models";
|
||||
import BaseEmail from "./BaseEmail";
|
||||
import Body from "./components/Body";
|
||||
import Button from "./components/Button";
|
||||
import EmailTemplate from "./components/EmailLayout";
|
||||
import Header from "./components/Header";
|
||||
import Heading from "./components/Heading";
|
||||
|
||||
type InputProps = {
|
||||
to: string;
|
||||
documentId: string;
|
||||
actorName: string;
|
||||
teamUrl: string;
|
||||
mentionId: string;
|
||||
};
|
||||
|
||||
type BeforeSend = {
|
||||
document: Document;
|
||||
};
|
||||
|
||||
type Props = InputProps & BeforeSend;
|
||||
|
||||
/**
|
||||
* Email sent to a user when someone mentions them in a doucment
|
||||
*/
|
||||
export default class MentionNotificationEmail extends BaseEmail<
|
||||
InputProps,
|
||||
BeforeSend
|
||||
> {
|
||||
protected async beforeSend({ documentId }: InputProps) {
|
||||
const document = await Document.unscoped().findByPk(documentId);
|
||||
if (!document) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return { document };
|
||||
}
|
||||
|
||||
protected subject({ actorName, document }: Props) {
|
||||
return `${actorName} mentioned you in "${document.title}"`;
|
||||
}
|
||||
|
||||
protected preview({ actorName }: Props): string {
|
||||
return `${actorName} mentioned you`;
|
||||
}
|
||||
|
||||
protected renderAsText({
|
||||
actorName,
|
||||
teamUrl,
|
||||
document,
|
||||
mentionId,
|
||||
}: Props): string {
|
||||
return `
|
||||
You were mentioned
|
||||
|
||||
${actorName} mentioned you in the document "${document.title}".
|
||||
|
||||
Open Document: ${teamUrl}${document.url}?mentionId=${mentionId}
|
||||
`;
|
||||
}
|
||||
|
||||
protected render({ document, actorName, teamUrl, mentionId }: Props) {
|
||||
const link = `${teamUrl}${document.url}?ref=notification-email&mentionId=${mentionId}`;
|
||||
|
||||
return (
|
||||
<EmailTemplate>
|
||||
<Header />
|
||||
|
||||
<Body>
|
||||
<Heading>You were mentioned</Heading>
|
||||
<p>
|
||||
{actorName} mentioned you in the document{" "}
|
||||
<a href={link}>{document.title}</a>.
|
||||
</p>
|
||||
<p>
|
||||
<Button href={link}>Open Document</Button>
|
||||
</p>
|
||||
</Body>
|
||||
</EmailTemplate>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { subHours } from "date-fns";
|
||||
import { differenceBy } from "lodash";
|
||||
import { Op } from "sequelize";
|
||||
import { Minute } from "@shared/utils/time";
|
||||
import subscriptionCreator from "@server/commands/subscriptionCreator";
|
||||
import { sequelize } from "@server/database/sequelize";
|
||||
import CollectionNotificationEmail from "@server/emails/templates/CollectionNotificationEmail";
|
||||
import DocumentNotificationEmail from "@server/emails/templates/DocumentNotificationEmail";
|
||||
import MentionNotificationEmail from "@server/emails/templates/MentionNotificationEmail";
|
||||
import env from "@server/env";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import {
|
||||
@@ -25,6 +27,7 @@ import {
|
||||
DocumentEvent,
|
||||
CommentEvent,
|
||||
} from "@server/types";
|
||||
import parseMentions from "@server/utils/parseMentions";
|
||||
import CommentCreatedNotificationTask from "../tasks/CommentCreatedNotificationTask";
|
||||
import BaseProcessor from "./BaseProcessor";
|
||||
|
||||
@@ -68,7 +71,7 @@ export default class NotificationsProcessor extends BaseProcessor {
|
||||
|
||||
const [collection, document, team] = await Promise.all([
|
||||
Collection.findByPk(event.collectionId),
|
||||
Document.findByPk(event.documentId),
|
||||
Document.findByPk(event.documentId, { includeState: true }),
|
||||
Team.findByPk(event.teamId),
|
||||
]);
|
||||
|
||||
@@ -85,6 +88,34 @@ export default class NotificationsProcessor extends BaseProcessor {
|
||||
false
|
||||
);
|
||||
|
||||
// send notifs to mentioned users
|
||||
const mentions = parseMentions(document);
|
||||
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: document.updatedBy.id,
|
||||
teamId: team.id,
|
||||
documentId: document.id,
|
||||
});
|
||||
await MentionNotificationEmail.schedule(
|
||||
{
|
||||
to: recipient.email,
|
||||
documentId: event.documentId,
|
||||
actorName: actor.name,
|
||||
teamUrl: team.url,
|
||||
mentionId: mention.id,
|
||||
},
|
||||
{ notificationId: notification.id }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const recipient of recipients) {
|
||||
const notify = await this.shouldNotify(document, recipient.user);
|
||||
|
||||
@@ -115,7 +146,7 @@ export default class NotificationsProcessor extends BaseProcessor {
|
||||
async revisionCreated(event: RevisionEvent) {
|
||||
const [collection, document, revision, team] = await Promise.all([
|
||||
Collection.findByPk(event.collectionId),
|
||||
Document.findByPk(event.documentId),
|
||||
Document.findByPk(event.documentId, { includeState: true }),
|
||||
Revision.findByPk(event.modelId),
|
||||
Team.findByPk(event.teamId),
|
||||
]);
|
||||
@@ -147,6 +178,37 @@ export default class NotificationsProcessor extends BaseProcessor {
|
||||
return;
|
||||
}
|
||||
|
||||
// send notifs to newly mentioned users
|
||||
const prev = await revision.previous();
|
||||
const oldMentions = prev ? parseMentions(prev) : [];
|
||||
const newMentions = parseMentions(document);
|
||||
const mentions = differenceBy(newMentions, oldMentions, "id");
|
||||
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: document.updatedBy.id,
|
||||
teamId: team.id,
|
||||
documentId: document.id,
|
||||
});
|
||||
await MentionNotificationEmail.schedule(
|
||||
{
|
||||
to: recipient.email,
|
||||
documentId: event.documentId,
|
||||
actorName: actor.name,
|
||||
teamUrl: team.url,
|
||||
mentionId: mention.id,
|
||||
},
|
||||
{ notificationId: notification.id }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const recipient of recipients) {
|
||||
const notify = await this.shouldNotify(document, recipient.user);
|
||||
|
||||
|
||||
30
server/utils/parseMentions.test.ts
Normal file
30
server/utils/parseMentions.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
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");
|
||||
});
|
||||
35
server/utils/parseMentions.ts
Normal file
35
server/utils/parseMentions.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
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;
|
||||
}
|
||||
@@ -56,7 +56,7 @@ math-inline {
|
||||
|
||||
}
|
||||
|
||||
math-inline .math-render {
|
||||
math-inline .math-render {
|
||||
display: inline-block;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
@@ -120,6 +120,17 @@ font-size: 1em;
|
||||
line-height: 1.6em;
|
||||
width: 100%;
|
||||
|
||||
.mention {
|
||||
background: ${props.theme.mentionBackground};
|
||||
border-radius: 12px;
|
||||
padding-bottom: 2px;
|
||||
padding-top: 1px;
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
> div {
|
||||
background: transparent;
|
||||
}
|
||||
@@ -978,7 +989,7 @@ mark {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
&.code-hidden {
|
||||
&.code-hidden {
|
||||
button,
|
||||
select,
|
||||
button.show-diagram-button {
|
||||
|
||||
193
shared/editor/nodes/Mention.ts
Normal file
193
shared/editor/nodes/Mention.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import Token from "markdown-it/lib/token";
|
||||
import { InputRule } from "prosemirror-inputrules";
|
||||
import { NodeSpec, Node as ProsemirrorNode, NodeType } from "prosemirror-model";
|
||||
import { EditorState, TextSelection, Plugin } from "prosemirror-state";
|
||||
import { MarkdownSerializerState } from "../lib/markdown/serializer";
|
||||
import { run } from "../plugins/BlockMenuTrigger";
|
||||
import isInCode from "../queries/isInCode";
|
||||
import mentionRule from "../rules/mention";
|
||||
import { Dispatch, EventType } from "../types";
|
||||
import Node from "./Node";
|
||||
|
||||
// ported from https://github.com/tc39/proposal-regexp-unicode-property-escapes#unicode-aware-version-of-w
|
||||
const OPEN_REGEX = /(?:^|\s)@([\p{L}\p{M}\d]+)?$/u;
|
||||
const CLOSE_REGEX = /(?:^|\s)@(([\p{L}\p{M}\d]*\s+)|(\s+[\p{L}\p{M}\d]+))$/u;
|
||||
|
||||
export default class Mention extends Node {
|
||||
get name() {
|
||||
return "mention";
|
||||
}
|
||||
|
||||
get schema(): NodeSpec {
|
||||
return {
|
||||
attrs: {
|
||||
type: {},
|
||||
label: {},
|
||||
modelId: {},
|
||||
actorId: {
|
||||
default: undefined,
|
||||
},
|
||||
id: {},
|
||||
},
|
||||
inline: true,
|
||||
content: "text*",
|
||||
marks: "",
|
||||
group: "inline",
|
||||
atom: true,
|
||||
parseDOM: [
|
||||
{
|
||||
tag: `span.${this.name}`,
|
||||
preserveWhitespace: "full",
|
||||
getAttrs: (dom: HTMLElement) => ({
|
||||
type: dom.dataset.type,
|
||||
modelId: dom.dataset.id,
|
||||
actorId: dom.dataset.actorId,
|
||||
label: dom.innerText,
|
||||
id: dom.id,
|
||||
}),
|
||||
},
|
||||
],
|
||||
toDOM: (node) => {
|
||||
return [
|
||||
"span",
|
||||
{
|
||||
class: `${node.type.name}`,
|
||||
id: node.attrs.id,
|
||||
"data-type": node.attrs.type,
|
||||
"data-id": node.attrs.modelId,
|
||||
"data-actorId": node.attrs.actorId,
|
||||
},
|
||||
node.attrs.label,
|
||||
];
|
||||
},
|
||||
toPlainText: (node) => `@${node.attrs.label}`,
|
||||
};
|
||||
}
|
||||
|
||||
get rulePlugins() {
|
||||
return [mentionRule];
|
||||
}
|
||||
|
||||
get plugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
props: {
|
||||
handleClick: () => {
|
||||
this.editor.events.emit(EventType.mentionMenuClose);
|
||||
return false;
|
||||
},
|
||||
handleKeyDown: (view, event) => {
|
||||
// Prosemirror input rules are not triggered on backspace, however
|
||||
// we need them to be evaluted for the filter trigger to work
|
||||
// correctly. This additional handler adds inputrules-like handling.
|
||||
if (event.key === "Backspace") {
|
||||
// timeout ensures that the delete has been handled by prosemirror
|
||||
// and any characters removed, before we evaluate the rule.
|
||||
setTimeout(() => {
|
||||
const { pos } = view.state.selection.$from;
|
||||
return run(view, pos, pos, OPEN_REGEX, (state, match) => {
|
||||
if (match) {
|
||||
this.editor.events.emit(
|
||||
EventType.mentionMenuOpen,
|
||||
match[1]
|
||||
);
|
||||
} else {
|
||||
this.editor.events.emit(EventType.mentionMenuClose);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// If the query is active and we're navigating the block menu then
|
||||
// just ignore the key events in the editor itself until we're done
|
||||
if (
|
||||
event.key === "Enter" ||
|
||||
event.key === "ArrowUp" ||
|
||||
event.key === "ArrowDown" ||
|
||||
event.key === "Tab"
|
||||
) {
|
||||
const { pos } = view.state.selection.$from;
|
||||
|
||||
return run(view, pos, pos, OPEN_REGEX, (state, match) => {
|
||||
// just tell Prosemirror we handled it and not to do anything
|
||||
return match ? true : null;
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
commands({ type }: { type: NodeType }) {
|
||||
return (attrs: Record<string, string>) => (
|
||||
state: EditorState,
|
||||
dispatch: Dispatch
|
||||
) => {
|
||||
const { selection } = state;
|
||||
const position =
|
||||
selection instanceof TextSelection
|
||||
? selection.$cursor?.pos
|
||||
: selection.$to.pos;
|
||||
if (position === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const node = type.create(attrs);
|
||||
const transaction = state.tr.insert(position, node);
|
||||
dispatch(transaction);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
inputRules(): InputRule[] {
|
||||
return [
|
||||
// main regex should match only:
|
||||
// @word
|
||||
new InputRule(OPEN_REGEX, (state, match) => {
|
||||
if (
|
||||
match &&
|
||||
state.selection.$from.parent.type.name === "paragraph" &&
|
||||
!isInCode(state)
|
||||
) {
|
||||
this.editor.events.emit(EventType.mentionMenuOpen, match[1]);
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
// invert regex should match some of these scenarios:
|
||||
// @<space>word
|
||||
// @<space>
|
||||
// @word<space>
|
||||
new InputRule(CLOSE_REGEX, (state, match) => {
|
||||
if (match) {
|
||||
this.editor.events.emit(EventType.mentionMenuClose);
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) {
|
||||
const mType = node.attrs.type;
|
||||
const mId = node.attrs.modelId;
|
||||
const label = node.attrs.label;
|
||||
const id = node.attrs.id;
|
||||
|
||||
state.write(`@[${label}](mention://${id}/${mType}/${mId})`);
|
||||
}
|
||||
|
||||
parseMarkdown() {
|
||||
return {
|
||||
node: "mention",
|
||||
getAttrs: (tok: Token) => ({
|
||||
id: tok.attrGet("id"),
|
||||
type: tok.attrGet("type"),
|
||||
modelId: tok.attrGet("modelId"),
|
||||
label: tok.content,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ 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";
|
||||
@@ -43,6 +44,7 @@ const basicPackage: (typeof Node | typeof Mark | typeof Extension)[] = [
|
||||
DateTime,
|
||||
Keys,
|
||||
ClipboardTextSerializer,
|
||||
Mention,
|
||||
];
|
||||
|
||||
export default basicPackage;
|
||||
|
||||
102
shared/editor/rules/mention.ts
Normal file
102
shared/editor/rules/mention.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import MarkdownIt from "markdown-it";
|
||||
import StateCore from "markdown-it/lib/rules_core/state_core";
|
||||
import Token from "markdown-it/lib/token";
|
||||
|
||||
function renderMention(tokens: Token[], idx: number) {
|
||||
const id = tokens[idx].attrGet("id");
|
||||
const mType = tokens[idx].attrGet("type");
|
||||
const mId = tokens[idx].attrGet("modelId");
|
||||
const label = tokens[idx].content;
|
||||
|
||||
return `<span id="${id}" class="mention" data-type="${mType}" data-id="${mId}">${label}</span>`;
|
||||
}
|
||||
|
||||
function parseMentions(state: StateCore) {
|
||||
const hrefRE = /^mention:\/\/([a-z0-9-]+)\/([a-z]+)\/([a-z0-9-]+)$/;
|
||||
|
||||
for (let i = 0; i < state.tokens.length; i++) {
|
||||
const tok = state.tokens[i];
|
||||
if (!(tok.type === "inline" && tok.children)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const canChunkComposeMentionToken = (chunk: Token[]) => {
|
||||
// no group of tokens of size less than 4 can compose a mention token
|
||||
if (chunk.length < 4) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [precToken, openToken, textToken, closeToken] = chunk;
|
||||
|
||||
// check for the valid order of tokens required to compose a mention token
|
||||
if (
|
||||
!(
|
||||
precToken.type === "text" &&
|
||||
precToken.content &&
|
||||
precToken.content.endsWith("@") &&
|
||||
openToken.type === "link_open" &&
|
||||
textToken.content &&
|
||||
closeToken.type === "link_close"
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// "link_open" token should have valid href
|
||||
const attr = openToken.attrs?.[0];
|
||||
if (!(attr && attr[0] === "href" && hrefRE.test(attr[1]))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// can probably compose a mention token if arrived here
|
||||
return true;
|
||||
};
|
||||
|
||||
const chunkWithMentionToken = (chunk: Token[]) => {
|
||||
const [precToken, openToken, textToken] = chunk;
|
||||
|
||||
// remove "@" from preceding token
|
||||
precToken.content = precToken.content.slice(0, -1);
|
||||
|
||||
// href must be present, otherwise the hrefRE test in canChunkComposeMentionToken would've failed
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const href = openToken.attrs![0][1];
|
||||
const matches = href.match(hrefRE);
|
||||
const [id, mType, mId] = matches!.slice(1);
|
||||
|
||||
const mentionToken = new Token("mention", "", 0);
|
||||
mentionToken.attrSet("id", id);
|
||||
mentionToken.attrSet("type", mType);
|
||||
mentionToken.attrSet("modelId", mId);
|
||||
mentionToken.content = textToken.content;
|
||||
|
||||
// "link_open", followed by "text" and "link_close" tokens are coalesced
|
||||
// into "mention" token, hence removed
|
||||
return [precToken, mentionToken];
|
||||
};
|
||||
|
||||
let newChildren: Token[] = [];
|
||||
let j = 0;
|
||||
while (j < tok.children.length) {
|
||||
// attempt to grab next four tokens that could potentially construct a mention token
|
||||
const chunk = tok.children.slice(j, j + 4);
|
||||
if (canChunkComposeMentionToken(chunk)) {
|
||||
newChildren = newChildren.concat(chunkWithMentionToken(chunk));
|
||||
// skip by 4 since mention token for this group of tokens has been composed
|
||||
// and the group cannot compose mention tokens any further
|
||||
j += 4;
|
||||
} else {
|
||||
// push the tokens which do not participate in composing a mention token as it is
|
||||
newChildren.push(tok.children[j]);
|
||||
j++;
|
||||
}
|
||||
}
|
||||
|
||||
state.tokens[i].children = newChildren;
|
||||
}
|
||||
}
|
||||
|
||||
export default function mention(md: MarkdownIt) {
|
||||
md.renderer.rules.mention = renderMention;
|
||||
md.core.ruler.after("inline", "mention", parseMentions);
|
||||
}
|
||||
@@ -13,6 +13,8 @@ export enum EventType {
|
||||
emojiMenuClose = "emojiMenuClose",
|
||||
linkMenuOpen = "linkMenuOpen",
|
||||
linkMenuClose = "linkMenuClose",
|
||||
mentionMenuOpen = "mentionMenuOpen",
|
||||
mentionMenuClose = "mentionMenuClose",
|
||||
}
|
||||
|
||||
export type MenuItem = {
|
||||
@@ -26,6 +28,7 @@ export type MenuItem = {
|
||||
attrs?: Record<string, any>;
|
||||
visible?: boolean;
|
||||
active?: (state: EditorState) => boolean;
|
||||
appendSpace?: boolean;
|
||||
};
|
||||
|
||||
export type ComponentProps = {
|
||||
|
||||
@@ -232,6 +232,7 @@
|
||||
"Are you sure you want to make {{ userName }} a member?": "Are you sure you want to make {{ userName }} a member?",
|
||||
"Are you sure you want to make {{ userName }} an admin? Admins can modify team and billing information.": "Are you sure you want to make {{ userName }} an admin? Admins can modify team and billing information.",
|
||||
"Are you sure you want to suspend {{ userName }}? Suspended users will be prevented from logging in.": "Are you sure you want to suspend {{ userName }}? Suspended users will be prevented from logging in.",
|
||||
"Profile picture": "Profile picture",
|
||||
"Insert column after": "Insert column after",
|
||||
"Insert column before": "Insert column before",
|
||||
"Insert row after": "Insert row after",
|
||||
@@ -808,7 +809,6 @@
|
||||
"<em>Note:</em> Signing back in will cause a new account to be automatically reprovisioned.": "<em>Note:</em> Signing back in will cause a new account to be automatically reprovisioned.",
|
||||
"Are you sure? Deleting your account will destroy identifying data associated with your user and cannot be undone. You will be immediately logged out of {{appName}} and all your API tokens will be revoked.": "Are you sure? Deleting your account will destroy identifying data associated with your user and cannot be undone. You will be immediately logged out of {{appName}} and all your API tokens will be revoked.",
|
||||
"Delete My Account": "Delete My Account",
|
||||
"Profile picture": "Profile picture",
|
||||
"You joined": "You joined",
|
||||
"Joined": "Joined",
|
||||
"{{ time }} ago.": "{{ time }} ago.",
|
||||
|
||||
@@ -142,6 +142,7 @@ export const buildLightTheme = (input: Partial<Colors>): DefaultTheme => {
|
||||
inputBorder: colors.slateLight,
|
||||
inputBorderFocused: colors.slate,
|
||||
listItemHoverBackground: colors.warmGrey,
|
||||
mentionBackground: colors.warmGrey,
|
||||
toolbarHoverBackground: colors.black,
|
||||
toolbarBackground: colors.almostBlack,
|
||||
toolbarInput: colors.white10,
|
||||
@@ -210,6 +211,7 @@ export const buildDarkTheme = (input: Partial<Colors>): DefaultTheme => {
|
||||
inputBorder: colors.slateDark,
|
||||
inputBorderFocused: colors.slate,
|
||||
listItemHoverBackground: colors.white10,
|
||||
mentionBackground: colors.white10,
|
||||
toolbarHoverBackground: colors.slate,
|
||||
toolbarBackground: colors.white,
|
||||
toolbarInput: colors.black10,
|
||||
|
||||
@@ -34,6 +34,10 @@ export enum FileOperationState {
|
||||
Expired = "expired",
|
||||
}
|
||||
|
||||
export enum MentionType {
|
||||
User = "user",
|
||||
}
|
||||
|
||||
export type PublicEnv = {
|
||||
URL: string;
|
||||
CDN_URL: string;
|
||||
|
||||
Reference in New Issue
Block a user