diff --git a/app/components/IconPicker/components/GridTemplate.tsx b/app/components/IconPicker/components/GridTemplate.tsx
index 6f304622f7..b4e4ff62a5 100644
--- a/app/components/IconPicker/components/GridTemplate.tsx
+++ b/app/components/IconPicker/components/GridTemplate.tsx
@@ -89,10 +89,7 @@ const GridTemplate = (
>
{item.type === IconType.Custom ? (
-
+
) : (
item.value
)}
diff --git a/app/editor/components/EmojiMenu.tsx b/app/editor/components/EmojiMenu.tsx
index 17367c8ba9..f02adce3d8 100644
--- a/app/editor/components/EmojiMenu.tsx
+++ b/app/editor/components/EmojiMenu.tsx
@@ -1,11 +1,14 @@
import capitalize from "lodash/capitalize";
-import { useCallback, useMemo } from "react";
+import { useCallback, useMemo, useEffect } from "react";
import { emojiMartToGemoji, snakeCase } from "@shared/editor/lib/emoji";
import { search as emojiSearch } from "@shared/utils/emoji";
import EmojiMenuItem from "./EmojiMenuItem";
import SuggestionsMenu, {
Props as SuggestionsMenuProps,
} from "./SuggestionsMenu";
+import useStores from "~/hooks/useStores";
+import { determineIconType } from "@shared/utils/icon";
+import { IconType } from "@shared/types";
type Emoji = {
name: string;
@@ -21,28 +24,40 @@ type Props = Omit<
>;
const EmojiMenu = (props: Props) => {
+ const { emojis } = useStores();
const { search = "" } = props;
+ useEffect(() => {
+ if (search) {
+ void emojis.fetchPage({ query: search });
+ }
+ }, [emojis, search]);
+
const items = useMemo(
() =>
- emojiSearch({ query: search })
+ emojiSearch({ customEmojis: emojis.orderedData, query: search })
.map((item) => {
// We snake_case the shortcode for backwards compatability with gemoji to
// avoid multiple formats being written into documents.
// @ts-expect-error emojiMartToGemoji key
- const shortcode = snakeCase(emojiMartToGemoji[item.id] || item.id);
+ const id = emojiMartToGemoji[item.id] || item.id;
+ const type = determineIconType(id);
+ const shortcode = type === IconType.Custom ? id : snakeCase(id);
const emoji = item.value;
return {
name: "emoji",
title: emoji,
- description: capitalize(item.name.toLowerCase()),
+ description:
+ type === IconType.Custom
+ ? item.name
+ : capitalize(item.name.toLowerCase()),
emoji,
attrs: { markup: shortcode, "data-name": shortcode },
};
})
.slice(0, 15),
- [search]
+ [search, emojis.orderedData]
);
const renderMenuItem = useCallback(
diff --git a/app/editor/components/EmojiMenuItem.tsx b/app/editor/components/EmojiMenuItem.tsx
index 9b771c7083..aef074f47b 100644
--- a/app/editor/components/EmojiMenuItem.tsx
+++ b/app/editor/components/EmojiMenuItem.tsx
@@ -1,12 +1,9 @@
-import styled from "styled-components";
import SuggestionsMenuItem, {
Props as SuggestionsMenuItemProps,
} from "./SuggestionsMenuItem";
-
-const Emoji = styled.span`
- font-size: 16px;
- line-height: 1.6em;
-`;
+import { Emoji } from "~/components/Emoji";
+import { CustomEmoji } from "@shared/components/CustomEmoji";
+import { isUUID } from "validator";
type EmojiMenuItemProps = Omit<
SuggestionsMenuItemProps,
@@ -19,7 +16,9 @@ export default function EmojiMenuItem({ emoji, ...rest }: EmojiMenuItemProps) {
return (
{emoji}}
+ icon={
+ isUUID(emoji) ? : {emoji}
+ }
/>
);
}
diff --git a/app/scenes/Settings/components/EmojisTable.tsx b/app/scenes/Settings/components/EmojisTable.tsx
index 48b914cd26..f2f0b61fdf 100644
--- a/app/scenes/Settings/components/EmojisTable.tsx
+++ b/app/scenes/Settings/components/EmojisTable.tsx
@@ -41,7 +41,7 @@ const EmojisTable = observer(function EmojisTable({
accessor: (emoji) => emoji.url,
component: (emoji) => (
-
+
:{emoji.name}:
),
diff --git a/server/routes/api/emojis/emojis.ts b/server/routes/api/emojis/emojis.ts
index 1f6c6d79e2..8536bdb4f7 100644
--- a/server/routes/api/emojis/emojis.ts
+++ b/server/routes/api/emojis/emojis.ts
@@ -15,7 +15,6 @@ import * as T from "./schema";
import { getTeamFromContext } from "@server/utils/passport";
import { loadPublicShare } from "@server/commands/shareLoader";
import { AuthorizationError } from "@server/errors";
-import { flattenTree } from "@shared/utils/tree";
const router = new Router();
@@ -87,18 +86,15 @@ router.get(
const teamFromCtx = await getTeamFromContext(ctx, {
includeStateCookie: false,
});
-
- const { sharedTree } = await loadPublicShare({
+ const { share } = await loadPublicShare({
id: shareId,
teamId: teamFromCtx?.id,
});
- // collect all icons from sharedTree
- const isEmojiInSharedTree =
- sharedTree &&
- flattenTree(sharedTree).some((node) => node.icon === emoji.id);
-
- if (!isEmojiInSharedTree) {
+ // Note: This is purposefully using a somewhat looser authorization check.
+ // In order to load a custom emoji you must have a valid emoji ID and a
+ // valid share ID from the same team.
+ if (share.teamId !== emoji.teamId) {
throw AuthorizationError();
}
} else {
diff --git a/shared/components/CustomEmoji.tsx b/shared/components/CustomEmoji.tsx
index dfed5387ce..b4665b05cb 100644
--- a/shared/components/CustomEmoji.tsx
+++ b/shared/components/CustomEmoji.tsx
@@ -1,7 +1,17 @@
-import styled from "styled-components";
+import useShare from "@shared/hooks/useShare";
-export const CustomEmoji = styled.img<{ size?: number }>`
- width: ${(props) => (props.size ? `${props.size}px` : "16px")};
- height: ${(props) => (props.size ? `${props.size}px` : "16px")};
- object-fit: contain;
-`;
+type Props = React.ImgHTMLAttributes & {
+ value: string;
+ size?: number | string;
+};
+
+export const CustomEmoji = ({ value, size = 16, ...props }: Props) => {
+ const { shareId } = useShare();
+ return (
+
+ );
+};
diff --git a/shared/components/Icon.tsx b/shared/components/Icon.tsx
index 884219dc1a..4fc04d1dd6 100644
--- a/shared/components/Icon.tsx
+++ b/shared/components/Icon.tsx
@@ -2,7 +2,6 @@ import { observer } from "mobx-react";
import { getLuminance } from "polished";
import styled from "styled-components";
import useStores from "../hooks/useStores";
-import useShare from "../hooks/useShare";
import { IconType } from "../types";
import { IconLibrary } from "../utils/IconLibrary";
import { colorPalette } from "../utils/collections";
@@ -37,7 +36,6 @@ const Icon = ({
forceColor,
className,
}: Props) => {
- const { shareId } = useShare();
const iconType = determineIconType(icon);
if (!iconType) {
@@ -63,11 +61,9 @@ const Icon = ({
if (iconType === IconType.Custom) {
return (
-
-
-
+
+
+
);
}
@@ -128,21 +124,11 @@ export const IconTitleWrapper = styled(Flex)<{ dir?: string }>`
props.dir === "rtl" ? "right: -44px" : "left: -44px"};
`;
-const EmojiImageWrapper = styled(Flex)`
- width: 24px;
- height: 24px;
+const Span = styled(Flex)<{ size: number }>`
+ width: ${(props) => props.size}px;
+ height: ${(props) => props.size}px;
align-items: center;
justify-content: center;
-
- ${IconTitleWrapper} & {
- width: auto;
- height: auto;
-
- ${CustomEmoji} {
- width: 26px;
- height: 26px;
- }
- }
`;
export default Icon;
diff --git a/shared/editor/components/Styles.ts b/shared/editor/components/Styles.ts
index 4d2dffb0eb..cc1354336b 100644
--- a/shared/editor/components/Styles.ts
+++ b/shared/editor/components/Styles.ts
@@ -892,6 +892,14 @@ h6:not(.placeholder)::before {
margin-${props.rtl ? "right" : "left"}: -1em;
}
+.emoji img {
+ width: 1em;
+ height: 1em;
+ vertical-align: middle;
+ position: relative;
+ top: -0.1em;
+}
+
.heading-anchor,
.heading-fold {
display: inline-block;
diff --git a/shared/editor/nodes/Emoji.tsx b/shared/editor/nodes/Emoji.tsx
index bf92b36717..aec9789f3a 100644
--- a/shared/editor/nodes/Emoji.tsx
+++ b/shared/editor/nodes/Emoji.tsx
@@ -5,12 +5,15 @@ import {
NodeType,
Schema,
} from "prosemirror-model";
-import { Command, TextSelection } from "prosemirror-state";
+import { Command, Plugin, TextSelection } from "prosemirror-state";
import { Primitive } from "utility-types";
import Extension from "../lib/Extension";
import { getEmojiFromName } from "../lib/emoji";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import emojiRule from "../rules/emoji";
+import { isUUID } from "validator";
+import { ComponentProps } from "../types";
+import { CustomEmoji } from "../../components/CustomEmoji";
export default class Emoji extends Extension {
get type() {
@@ -36,6 +39,7 @@ export default class Emoji extends Extension {
selectable: false,
parseDOM: [
{
+ priority: 100,
tag: "strong.emoji",
preserveWhitespace: "full",
getAttrs: (dom: HTMLElement) =>
@@ -66,6 +70,53 @@ export default class Emoji extends Extension {
return [emojiRule];
}
+ get plugins() {
+ return [
+ new Plugin({
+ props: {
+ // Placing the caret infront of an emoji is tricky as click events directly
+ // on the emoji will not behave the same way as clicks on text characters, this
+ // plugin ensures that clicking on an emoji behaves more naturally.
+ handleClickOn: (view, _pos, node, nodePos, event) => {
+ if (node.type.name === this.name) {
+ const element = event.target as HTMLElement;
+ const rect = element.getBoundingClientRect();
+ const clickX = event.clientX - rect.left;
+ const side = clickX < rect.width / 2 ? -1 : 1;
+
+ // If the click is in the left half of the emoji, place the caret before it
+ const tr = view.state.tr.setSelection(
+ TextSelection.near(
+ view.state.doc.resolve(
+ side === -1 ? nodePos : nodePos + node.nodeSize
+ ),
+ side
+ )
+ );
+ view.dispatch(tr);
+ return true;
+ }
+
+ return false;
+ },
+ },
+ }),
+ ];
+ }
+
+ component = (props: ComponentProps) => {
+ const name = props.node.attrs["data-name"];
+ return (
+
+ {isUUID(name) ? (
+
+ ) : (
+ getEmojiFromName(name)
+ )}
+
+ );
+ };
+
commands({ type }: { type: NodeType; schema: Schema }) {
return (attrs: Record): Command =>
(state, dispatch) => {
diff --git a/shared/editor/nodes/Heading.ts b/shared/editor/nodes/Heading.ts
index bf34fb9bb6..067dc9fd14 100644
--- a/shared/editor/nodes/Heading.ts
+++ b/shared/editor/nodes/Heading.ts
@@ -221,12 +221,26 @@ export default class Heading extends Node {
container.appendChild(fold);
decorations.push(
+ // Contains the heading actions
Decoration.widget(pos + 1, container, {
side: -1,
ignoreSelection: true,
relaxedSide: true,
key: pos.toString(),
- })
+ }),
+ // Creates a "space" for the caret to move to before the widget.
+ // Without this it is very hard to place the caret at the beginning
+ // of the heading when it begins with an atom element.
+ Decoration.widget(
+ pos + 1,
+ () => document.createElement("span"),
+ {
+ side: -1,
+ ignoreSelection: true,
+ relaxedSide: true,
+ key: "span",
+ }
+ )
);
}
});
diff --git a/shared/editor/nodes/Image.tsx b/shared/editor/nodes/Image.tsx
index 21714c7bdc..90a22051a5 100644
--- a/shared/editor/nodes/Image.tsx
+++ b/shared/editor/nodes/Image.tsx
@@ -162,7 +162,10 @@ export default class Image extends SimpleImage {
tag: "img",
getAttrs: (dom: HTMLImageElement) => {
// Don't parse images from our own editor with this rule.
- if (dom.parentElement?.classList.contains("image")) {
+ if (
+ dom.parentElement?.classList.contains("image") ||
+ dom.parentElement?.classList.contains("emoji")
+ ) {
return false;
}
diff --git a/shared/utils/emoji.ts b/shared/utils/emoji.ts
index 10c35f6a87..e2ef9cbe3c 100644
--- a/shared/utils/emoji.ts
+++ b/shared/utils/emoji.ts
@@ -191,16 +191,25 @@ export const getEmojisWithCategory = ({
export const getEmojiVariants = ({ id }: { id: string }) =>
EMOJI_ID_TO_VARIANTS[id];
+type CustomEmoji = {
+ id: string;
+ name: string;
+ url: string;
+};
+
export const search = ({
query,
skinTone,
+ customEmojis = [],
}: {
query: string;
skinTone?: EmojiSkinTone;
+ customEmojis?: CustomEmoji[];
}) => {
const queryLowercase = query.toLowerCase();
const emojiSkinTone = skinTone ?? EmojiSkinTone.Default;
+ // Search built-in emojis
const matchedEmojis = searcher
.search(queryLowercase)
.map(
@@ -208,7 +217,29 @@ export const search = ({
EMOJI_ID_TO_VARIANTS[emoji.id][emojiSkinTone] ??
EMOJI_ID_TO_VARIANTS[emoji.id][EmojiSkinTone.Default]
);
- return sortBy(matchedEmojis, (emoji) => {
+
+ // Search custom emojis
+ const matchedCustomEmojis = customEmojis
+ .filter((emoji) => {
+ const nameLower = emoji.name.toLowerCase();
+ const idLower = emoji.id.toLowerCase();
+ return (
+ nameLower.includes(queryLowercase) || idLower.includes(queryLowercase)
+ );
+ })
+ .map(
+ (customEmoji) =>
+ ({
+ id: customEmoji.id,
+ name: customEmoji.name,
+ value: customEmoji.id,
+ }) as Emoji
+ );
+
+ // Combine and sort all results
+ const allEmojis = [...matchedEmojis, ...matchedCustomEmojis];
+
+ return sortBy(allEmojis, (emoji) => {
const nlc = emoji.name.toLowerCase();
return query === nlc ? -1 : nlc.startsWith(queryLowercase) ? 0 : 1;
});