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:
Apoorv Mishra
2023-03-07 04:24:57 +05:30
committed by GitHub
parent 09435ed798
commit de031b365c
18 changed files with 704 additions and 20 deletions

View File

@@ -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 {

View 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,
}),
};
}
}

View File

@@ -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;

View 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);
}

View File

@@ -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 = {