From f009236144185b4dd19afa5046380774ecee7438 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Tue, 2 Dec 2025 20:17:17 -0500 Subject: [PATCH] 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> --- .../IconPicker/components/GridTemplate.tsx | 5 +- app/editor/components/EmojiMenu.tsx | 25 +++++++-- app/editor/components/EmojiMenuItem.tsx | 13 +++-- .../Settings/components/EmojisTable.tsx | 2 +- server/routes/api/emojis/emojis.ts | 14 ++--- shared/components/CustomEmoji.tsx | 22 +++++--- shared/components/Icon.tsx | 26 +++------ shared/editor/components/Styles.ts | 8 +++ shared/editor/nodes/Emoji.tsx | 53 ++++++++++++++++++- shared/editor/nodes/Heading.ts | 16 +++++- shared/editor/nodes/Image.tsx | 5 +- shared/utils/emoji.ts | 33 +++++++++++- 12 files changed, 166 insertions(+), 56 deletions(-) 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; });