feat: Custom emojis in editor (#10758)

* Working pass, needs refactor

* revert

* fix: Copy/paste behavior

* fix: Public share rendering

* fixes

* fix: Click around emoji atom behavior

* fix: Cannot position caret next to heading

* Update app/scenes/Settings/components/EmojisTable.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Tom Moor
2025-12-02 20:17:17 -05:00
committed by GitHub
parent 977e01e96a
commit f009236144
12 changed files with 166 additions and 56 deletions

View File

@@ -89,10 +89,7 @@ const GridTemplate = (
>
<Emoji width={24} height={24}>
{item.type === IconType.Custom ? (
<CustomEmoji
src={`/api/emojis.redirect?id=${item.value}`}
title={item.name}
/>
<CustomEmoji value={item.value} title={item.name} />
) : (
item.value
)}

View File

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

View File

@@ -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 (
<SuggestionsMenuItem
{...rest}
icon={<Emoji className="emoji">{emoji}</Emoji>}
icon={
isUUID(emoji) ? <CustomEmoji value={emoji} /> : <Emoji>{emoji}</Emoji>
}
/>
);
}

View File

@@ -41,7 +41,7 @@ const EmojisTable = observer(function EmojisTable({
accessor: (emoji) => emoji.url,
component: (emoji) => (
<EmojiPreview>
<CustomEmoji src={emoji.url} alt={emoji.name} size={28} />
<CustomEmoji value={emoji.id} alt={emoji.name} size={28} />
<span>:{emoji.name}:</span>
</EmojiPreview>
),

View File

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

View File

@@ -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<HTMLImageElement> & {
value: string;
size?: number | string;
};
export const CustomEmoji = ({ value, size = 16, ...props }: Props) => {
const { shareId } = useShare();
return (
<img
src={`/api/emojis.redirect?id=${value}${shareId ? `&shareId=${shareId}` : ""}`}
style={{ width: size, height: size, objectFit: "contain" }}
{...props}
/>
);
};

View File

@@ -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 (
<EmojiImageWrapper>
<CustomEmoji
src={`/api/emojis.redirect?id=${icon}${shareId ? `&shareId=${shareId}` : ""}`}
/>
</EmojiImageWrapper>
<Span size={size} className={className}>
<CustomEmoji value={icon} size={size - size / 4} />
</Span>
);
}
@@ -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;

View File

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

View File

@@ -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 (
<strong className="emoji" data-name={name}>
{isUUID(name) ? (
<CustomEmoji value={name} size="1em" />
) : (
getEmojiFromName(name)
)}
</strong>
);
};
commands({ type }: { type: NodeType; schema: Schema }) {
return (attrs: Record<string, Primitive>): Command =>
(state, dispatch) => {

View File

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

View File

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

View File

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