[WIKI-402] feat: emoji support for all editors (#7275)

* feat: basic emoji

* feat:emoji slash command

* update slice command

* refactor: emoji storage

* refactor:types

* refactor: emoji list

* refactor: restructure extension

* chore: add comments

* chore: update comments

* fix: fallback image
This commit is contained in:
Vipin Chaudhary 2025-07-02 15:32:07 +05:30 committed by GitHub
parent 757019bf43
commit ba6b822f60
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 969 additions and 21 deletions

View file

@ -1,21 +1,21 @@
import { CharacterCountStorage } from "@tiptap/extension-character-count";
// constants
import type { EmojiStorage } from "@tiptap/extension-emoji";
import { CORE_EXTENSIONS } from "@/constants/extension";
// extensions
import { type HeadingExtensionStorage } from "@/extensions";
import { type CustomImageExtensionStorage } from "@/extensions/custom-image/types";
import { type CustomLinkStorage } from "@/extensions/custom-link";
import { type ImageExtensionStorage } from "@/extensions/image";
import { type MentionExtensionStorage } from "@/extensions/mentions";
import { type UtilityExtensionStorage } from "@/extensions/utility";
import type { HeadingExtensionStorage } from "@/extensions";
import type { CustomImageExtensionStorage } from "@/extensions/custom-image/types";
import type { CustomLinkStorage } from "@/extensions/custom-link";
import type { ImageExtensionStorage } from "@/extensions/image";
import type { UtilityExtensionStorage } from "@/extensions/utility";
export type ExtensionStorageMap = {
[CORE_EXTENSIONS.CUSTOM_IMAGE]: CustomImageExtensionStorage;
[CORE_EXTENSIONS.IMAGE]: ImageExtensionStorage;
[CORE_EXTENSIONS.CUSTOM_LINK]: CustomLinkStorage;
[CORE_EXTENSIONS.HEADINGS_LIST]: HeadingExtensionStorage;
[CORE_EXTENSIONS.MENTION]: MentionExtensionStorage;
[CORE_EXTENSIONS.UTILITY]: UtilityExtensionStorage;
[CORE_EXTENSIONS.EMOJI]: EmojiStorage;
[CORE_EXTENSIONS.CHARACTER_COUNT]: CharacterCountStorage;
};

View file

@ -0,0 +1 @@
export type TAdditionalActiveDropbarExtensions = never;

View file

@ -41,4 +41,5 @@ export enum CORE_EXTENSIONS {
UNDERLINE = "underline",
UTILITY = "utility",
WORK_ITEM_EMBED = "issue-embed-component",
EMOJI = "emoji",
}

View file

@ -14,6 +14,7 @@ import { CustomCodeInlineExtension } from "./code-inline";
import { CustomColorExtension } from "./custom-color";
import { CustomImageExtensionConfig } from "./custom-image/extension-config";
import { CustomLinkExtension } from "./custom-link";
import { EmojiExtension } from "./emoji/extension";
import { CustomHorizontalRule } from "./horizontal-rule";
import { ImageExtensionConfig } from "./image";
import { CustomMentionExtensionConfig } from "./mentions/extension-config";
@ -55,6 +56,7 @@ export const CoreEditorExtensionsWithoutProps = [
},
dropcursor: false,
}),
EmojiExtension,
CustomQuoteExtension,
CustomHorizontalRule.configure({
HTMLAttributes: {

View file

@ -0,0 +1,151 @@
import { Editor } from "@tiptap/react";
import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
// plane imports
import { cn } from "@plane/utils";
export interface EmojiItem {
name: string;
emoji: string;
shortcodes: string[];
tags: string[];
fallbackImage?: string;
}
export interface EmojiListProps {
items: EmojiItem[];
command: (item: { name: string }) => void;
editor: Editor;
}
export interface EmojiListRef {
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
}
export const EmojiList = forwardRef<EmojiListRef, EmojiListProps>((props, ref) => {
const { items, command } = props;
const [selectedIndex, setSelectedIndex] = useState<number>(0);
// refs
const emojiListContainer = useRef<HTMLDivElement>(null);
const selectItem = useCallback(
(index: number): void => {
const item = items[index];
if (item) {
command({ name: item.name });
}
},
[command, items]
);
const upHandler = useCallback(() => {
setSelectedIndex((prevIndex) => (prevIndex + items.length - 1) % items.length);
}, [items.length]);
const downHandler = useCallback(() => {
setSelectedIndex((prevIndex) => (prevIndex + 1) % items.length);
}, [items.length]);
const enterHandler = useCallback(() => {
setSelectedIndex((prevIndex) => {
selectItem(prevIndex);
return prevIndex;
});
}, [selectItem]);
useEffect(() => setSelectedIndex(0), [items]);
// scroll to the dropdown item when navigating via keyboard
useLayoutEffect(() => {
const container = emojiListContainer?.current;
if (!container) return;
const item = container.querySelector(`#emoji-item-${selectedIndex}`) as HTMLElement;
if (item) {
const containerRect = container.getBoundingClientRect();
const itemRect = item.getBoundingClientRect();
const isItemInView = itemRect.top >= containerRect.top && itemRect.bottom <= containerRect.bottom;
if (!isItemInView) {
item.scrollIntoView({ block: "nearest" });
}
}
}, [selectedIndex]);
useImperativeHandle(
ref,
() => ({
onKeyDown: ({ event }: { event: KeyboardEvent }): boolean => {
if (event.key === "ArrowUp") {
upHandler();
return true;
}
if (event.key === "ArrowDown") {
downHandler();
return true;
}
if (event.key === "Enter") {
enterHandler();
event.preventDefault();
event.stopPropagation();
return true;
}
return false;
},
}),
[upHandler, downHandler, enterHandler]
);
return (
<div
ref={emojiListContainer}
role="listbox"
aria-label="Emoji suggestions"
className="z-10 max-h-[90vh] w-[16rem] overflow-y-auto rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg space-y-1"
>
{items.length ? (
items.map((item, index) => {
const isSelected = index === selectedIndex;
const emojiKey = item.shortcodes.join(" - ");
return (
<button
key={emojiKey}
role="option"
aria-selected={isSelected}
aria-label={`${item.name} emoji`}
id={`emoji-item-${index}`}
type="button"
className={cn(
"flex items-center gap-2 w-full rounded px-2 py-1.5 text-sm text-left truncate text-custom-text-200 hover:bg-custom-background-80 transition-colors duration-150",
{
"bg-custom-background-80": isSelected,
}
)}
onClick={() => selectItem(index)}
onMouseEnter={() => setSelectedIndex(index)}
>
<span className="size-5 grid place-items-center flex-shrink-0 text-base">
{item.fallbackImage ? (
<img src={item.fallbackImage} alt={item.name} className="size-4 object-contain" />
) : (
item.emoji
)}
</span>
<span className="flex-grow truncate">
<span className="font-medium">:{item.name}:</span>
</span>
</button>
);
})
) : (
<div className="text-center text-sm text-custom-text-400 py-2">No emojis found</div>
)}
</div>
);
});
EmojiList.displayName = "EmojiList";

View file

@ -0,0 +1,30 @@
import Emoji, { EmojiItem, gitHubEmojis, shortcodeToEmoji } from "@tiptap/extension-emoji";
// local imports
import { MarkdownSerializerState } from "@tiptap/pm/markdown";
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
import suggestion from "./suggestion";
export const EmojiExtension = Emoji.extend({
addStorage() {
return {
...this.parent?.(),
markdown: {
serialize(state: MarkdownSerializerState, node: ProseMirrorNode) {
const emojiItem = shortcodeToEmoji(node.attrs.name, this.options.emojis)
if(emojiItem?.emoji) {
state.write(emojiItem?.emoji);
} else if(emojiItem?.fallbackImage) {
state.write(`\n![${emojiItem.name}-${emojiItem.shortcodes[0]}](${emojiItem?.fallbackImage})\n`);
} else {
state.write(`:${node.attrs.name}:`);
}
},
},
};
},
}).configure({
emojis: gitHubEmojis,
suggestion: suggestion,
enableEmoticons: true,
});

View file

@ -0,0 +1,126 @@
import type { EmojiOptions } from "@tiptap/extension-emoji";
import { ReactRenderer, Editor } from "@tiptap/react";
import { SuggestionProps, SuggestionKeyDownProps } from "@tiptap/suggestion";
import tippy, { Instance as TippyInstance } from "tippy.js";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// helpers
import { getExtensionStorage } from "@/helpers/get-extension-storage";
// local imports
import { EmojiItem, EmojiList, EmojiListRef, EmojiListProps } from "./components/emojis-list";
const DEFAULT_EMOJIS = ["+1", "-1", "smile", "orange_heart", "eyes"];
const emojiSuggestion: EmojiOptions["suggestion"] = {
items: ({ editor, query }: { editor: Editor; query: string }): EmojiItem[] => {
if (query.trim() === "") {
const { emojis } = getExtensionStorage(editor, CORE_EXTENSIONS.EMOJI);
const defaultEmojis = DEFAULT_EMOJIS.map((name) =>
emojis.find((emoji: EmojiItem) => emoji.shortcodes.includes(name) || emoji.name === name)
)
.filter(Boolean)
.slice(0, 5);
return defaultEmojis as EmojiItem[];
}
return getExtensionStorage(editor, CORE_EXTENSIONS.EMOJI)
.emojis.filter(({ shortcodes, tags }) => {
const lowerQuery = query.toLowerCase();
return (
shortcodes.find((shortcode: string) => shortcode.startsWith(lowerQuery)) ||
tags.find((tag: string) => tag.startsWith(lowerQuery))
);
})
.slice(0, 5) as EmojiItem[];
},
allowSpaces: false,
render: () => {
let component: ReactRenderer<EmojiListRef, EmojiListProps>;
let popup: TippyInstance[] | null = null;
return {
onStart: (props: SuggestionProps): void => {
const emojiListProps: EmojiListProps = {
items: props.items,
command: props.command,
editor: props.editor,
};
getExtensionStorage(props.editor, CORE_EXTENSIONS.UTILITY).activeDropbarExtensions.push(CORE_EXTENSIONS.EMOJI);
component = new ReactRenderer(EmojiList, {
props: emojiListProps,
editor: props.editor,
});
if (!props.clientRect) return;
popup = tippy("body", {
getReferenceClientRect: props.clientRect as () => DOMRect,
appendTo: () =>
document.querySelector(".active-editor") ??
document.querySelector('[id^="editor-container"]') ??
document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
hideOnClick: false,
sticky: "reference",
animation: false,
duration: 0,
offset: [0, 8],
});
},
onUpdate: (props: SuggestionProps): void => {
const emojiListProps: EmojiListProps = {
items: props.items,
command: props.command,
editor: props.editor,
};
component.updateProps(emojiListProps);
if (popup && props.clientRect) {
popup[0]?.setProps({
getReferenceClientRect: props.clientRect as () => DOMRect,
});
}
},
onKeyDown: (props: SuggestionKeyDownProps): boolean => {
if (props.event.key === "Escape") {
if (popup) {
popup[0]?.hide();
}
if (component) {
component.destroy();
}
return true;
}
return component.ref?.onKeyDown(props) || false;
},
onExit: (props: SuggestionProps): void => {
const utilityStorage = getExtensionStorage(props.editor, CORE_EXTENSIONS.UTILITY);
const index = utilityStorage.activeDropbarExtensions.indexOf(CORE_EXTENSIONS.EMOJI);
if (index > -1) {
utilityStorage.activeDropbarExtensions.splice(index, 1);
}
if (popup) {
popup[0]?.destroy();
}
if (component) {
component.destroy();
}
},
};
},
};
export default emojiSuggestion;

View file

@ -11,11 +11,13 @@ export const EnterKeyExtension = (onEnterKeyPress?: () => void) =>
addKeyboardShortcuts(this) {
return {
Enter: () => {
const isMentionOpen = getExtensionStorage(this.editor, CORE_EXTENSIONS.MENTION)?.mentionsOpen;
if (!isMentionOpen) {
const { activeDropbarExtensions } = getExtensionStorage(this.editor, CORE_EXTENSIONS.UTILITY);
if (activeDropbarExtensions.length === 0) {
onEnterKeyPress?.();
return true;
}
return false;
},
"Shift-Enter": ({ editor }) =>

View file

@ -39,6 +39,7 @@ import { CoreEditorAdditionalExtensions } from "@/plane-editor/extensions";
import type { IEditorProps } from "@/types";
// local imports
import { CustomImageExtension } from "./custom-image/extension";
import { EmojiExtension } from "./emoji/extension";
type TArguments = Pick<
IEditorProps,
@ -97,6 +98,7 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
},
...(enableHistory ? {} : { history: false }),
}),
EmojiExtension,
CustomQuoteExtension,
CustomHorizontalRule.configure({
HTMLAttributes: {

View file

@ -12,11 +12,7 @@ export type TMentionExtensionOptions = MentionOptions & {
getMentionedEntityDetails: TMentionHandler["getMentionedEntityDetails"];
};
export type MentionExtensionStorage = {
mentionsOpen: boolean;
};
export const CustomMentionExtensionConfig = Mention.extend<TMentionExtensionOptions, MentionExtensionStorage>({
export const CustomMentionExtensionConfig = Mention.extend<TMentionExtensionOptions>({
addAttributes() {
return {
[EMentionComponentAttributeNames.ID]: {
@ -54,7 +50,6 @@ export const CustomMentionExtensionConfig = Mention.extend<TMentionExtensionOpti
addStorage() {
const options = this.options;
return {
mentionsOpen: false,
markdown: {
serialize(state: MarkdownSerializerState, node: NodeType) {
state.write(getMentionDisplayText(options, node));

View file

@ -8,6 +8,8 @@ import { CommandListInstance } from "@/helpers/tippy";
import { TMentionHandler } from "@/types";
// local components
import { MentionsListDropdown, MentionsListDropdownProps } from "./mentions-list-dropdown";
import { getExtensionStorage } from "@/helpers/get-extension-storage";
import { CORE_EXTENSIONS } from "@/constants/extension";
export const renderMentionsDropdown =
(props: Pick<TMentionHandler, "searchCallback">): SuggestionOptions["render"] =>
@ -28,7 +30,9 @@ export const renderMentionsDropdown =
},
editor: props.editor,
});
props.editor.storage.mentionsOpen = true;
getExtensionStorage(props.editor, CORE_EXTENSIONS.UTILITY).activeDropbarExtensions.push(
CORE_EXTENSIONS.MENTION
);
// @ts-expect-error - Tippy types are incorrect
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
@ -64,7 +68,11 @@ export const renderMentionsDropdown =
return false;
},
onExit: (props: { editor: Editor; event: KeyboardEvent }) => {
props.editor.storage.mentionsOpen = false;
const utilityStorage = getExtensionStorage(props.editor, CORE_EXTENSIONS.UTILITY);
const index = utilityStorage.activeDropbarExtensions.indexOf(CORE_EXTENSIONS.MENTION);
if (index > -1) {
utilityStorage.activeDropbarExtensions.splice(index, 1);
}
popup?.[0]?.destroy();
component?.destroy();
},

View file

@ -14,6 +14,7 @@ import {
ListTodo,
MessageSquareText,
MinusSquare,
Smile,
Table,
TextQuote,
} from "lucide-react";
@ -189,6 +190,17 @@ export const getSlashCommandFilteredSections =
icon: <MinusSquare className="size-3.5" />,
command: ({ editor, range }) => editor.chain().focus().deleteRange(range).setHorizontalRule().run(),
},
{
commandKey: "emoji",
key: "emoji",
title: "Emoji",
description: "Insert an emoji",
searchTerms: ["emoji", "icons", "reaction", "emoticon", "emotags"],
icon: <Smile className="size-3.5" />,
command: ({ editor, range }) => {
editor.chain().focus().insertContentAt(range, "<p>:</p>").run();
},
},
],
},
{

View file

@ -1,13 +1,17 @@
import { Extension } from "@tiptap/core";
import codemark from "prosemirror-codemark";
// helpers
import { CORE_EXTENSIONS } from "@/constants/extension";
import { restorePublicImages } from "@/helpers/image-helpers";
// plugins
import { TAdditionalActiveDropbarExtensions } from "@/plane-editor/types/utils";
import { DropHandlerPlugin } from "@/plugins/drop";
import { FilePlugins } from "@/plugins/file/root";
import { MarkdownClipboardPlugin } from "@/plugins/markdown-clipboard";
// types
import type { IEditorProps, TEditorAsset, TFileHandler, TReadOnlyFileHandler } from "@/types";
type TActiveDropbarExtensions = CORE_EXTENSIONS.MENTION | CORE_EXTENSIONS.EMOJI | TAdditionalActiveDropbarExtensions;
declare module "@tiptap/core" {
interface Commands {
@ -30,6 +34,7 @@ export interface UtilityExtensionStorage {
assetsList: TEditorAsset[];
assetsUploadStatus: TFileHandler["assetsUploadStatus"];
uploadInProgress: boolean;
activeDropbarExtensions: TActiveDropbarExtensions[];
}
type Props = Pick<IEditorProps, "disabledExtensions"> & {
@ -70,6 +75,7 @@ export const UtilityExtension = (props: Props) => {
assetsList: [],
assetsUploadStatus: isEditable && "assetsUploadStatus" in fileHandler ? fileHandler.assetsUploadStatus : {},
uploadInProgress: false,
activeDropbarExtensions: [],
};
},

View file

@ -47,7 +47,8 @@ export type TEditorCommands =
| "background-color"
| "text-align"
| "callout"
| "attachment";
| "attachment"
| "emoji";
export type TCommandExtraProps = {
image: {

View file

@ -490,3 +490,13 @@ p.editor-paragraph-block + p.editor-paragraph-block {
background-color: var(--editor-colors-purple-background);
}
/* end background colors */
/* emoji styles */
span[data-name][data-type="emoji"] img {
display: inline !important;
vertical-align: middle;
margin: 0;
padding: 0;
max-width: 1.25em;
max-height: 1.25em;
}