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} />;
|
||||
}
|
||||
Reference in New Issue
Block a user