diff --git a/packages/editor/src/core/extensions/emoji/emoji.ts b/packages/editor/src/core/extensions/emoji/emoji.ts new file mode 100644 index 000000000..12fe7e06f --- /dev/null +++ b/packages/editor/src/core/extensions/emoji/emoji.ts @@ -0,0 +1,444 @@ +import { + combineTransactionSteps, + escapeForRegEx, + findChildrenInRange, + getChangedRanges, + InputRule, + mergeAttributes, + Node, + nodeInputRule, + PasteRule, + removeDuplicates, +} from "@tiptap/core"; +import { emojis, emojiToShortcode, shortcodeToEmoji } from "@tiptap/extension-emoji"; +import { Plugin, PluginKey, Transaction } from "@tiptap/pm/state"; +import Suggestion, { SuggestionOptions } from "@tiptap/suggestion"; +import emojiRegex from "emoji-regex"; +import { isEmojiSupported } from "is-emoji-supported"; + +declare module "@tiptap/core" { + interface Commands { + emoji: { + /** + * Add an emoji + */ + setEmoji: (shortcode: string) => ReturnType; + }; + } +} + +export type EmojiItem = { + /** + * A unique name of the emoji which will be stored as attribute + */ + name: string; + /** + * The emoji unicode character + */ + emoji?: string; + /** + * A list of unique shortcodes that are used by input rules to find the emoji + */ + shortcodes: string[]; + /** + * A list of tags that can help for searching emojis + */ + tags: string[]; + /** + * A name that can help to group emojis + */ + group?: string; + /** + * A list of unique emoticons + */ + emoticons?: string[]; + /** + * The unicode version the emoji was introduced + */ + version?: number; + /** + * A fallback image if the current system doesn't support the emoji or for custom emojis + */ + fallbackImage?: string; + /** + * Store some custom data + */ + [key: string]: any; +}; + +export type EmojiOptions = { + HTMLAttributes: Record; + emojis: EmojiItem[]; + enableEmoticons: boolean; + forceFallbackImages: boolean; + suggestion: Omit; +}; + +export type EmojiStorage = { + emojis: EmojiItem[]; + isSupported: (item: EmojiItem) => boolean; +}; + +export const EmojiSuggestionPluginKey = new PluginKey("emojiSuggestion"); + +export const inputRegex = /:([a-zA-Z0-9_+-]+):$/; + +export const pasteRegex = /:([a-zA-Z0-9_+-]+):/g; + +export const Emoji = Node.create({ + name: "emoji", + + inline: true, + + group: "inline", + + selectable: false, + + addOptions() { + return { + HTMLAttributes: {}, + // emojis: , + emojis: emojis, + enableEmoticons: false, + forceFallbackImages: false, + suggestion: { + char: ":", + pluginKey: EmojiSuggestionPluginKey, + command: ({ editor, range, props }) => { + // increase range.to by one when the next node is of type "text" + // and starts with a space character + const nodeAfter = editor.view.state.selection.$to.nodeAfter; + const overrideSpace = nodeAfter?.text?.startsWith(" "); + + if (overrideSpace) { + range.to += 1; + } + + editor + .chain() + .focus() + .insertContentAt(range, [ + { + type: this.name, + attrs: props, + }, + { + type: "text", + text: " ", + }, + ]) + .command(({ tr, state }) => { + tr.setStoredMarks(state.doc.resolve(state.selection.to - 2).marks()); + return true; + }) + .run(); + }, + allow: ({ state, range }) => { + const $from = state.doc.resolve(range.from); + const type = state.schema.nodes[this.name]; + const allow = !!$from.parent.type.contentMatch.matchType(type); + + return allow; + }, + }, + }; + }, + + addStorage() { + const { emojis } = this.options; + const supportMap: Record = removeDuplicates(emojis.map((item) => item.version)) + .filter((version) => typeof version === "number") + .reduce((versions, version) => { + const emoji = emojis.find((item) => item.version === version && item.emoji); + + return { + ...versions, + [version as number]: emoji ? isEmojiSupported(emoji.emoji as string) : false, + }; + }, {}); + + return { + emojis: this.options.emojis, + isSupported: (emojiItem) => (emojiItem.version ? supportMap[emojiItem.version] : false), + }; + }, + + addAttributes() { + return { + name: { + default: null, + parseHTML: (element) => element.dataset.name, + renderHTML: (attributes) => ({ + "data-name": attributes.name, + }), + }, + }; + }, + + parseHTML() { + return [ + { + tag: `span[data-type="${this.name}"]`, + }, + ]; + }, + + renderHTML({ HTMLAttributes, node }) { + const emojiItem = shortcodeToEmoji(node.attrs.name, this.options.emojis); + const attributes = mergeAttributes(HTMLAttributes, this.options.HTMLAttributes, { "data-type": this.name }); + + if (!emojiItem) { + return ["span", attributes, `:${node.attrs.name}:`]; + } + + const renderFallbackImage = false; + + return [ + "span", + attributes, + renderFallbackImage + ? [ + "img", + { + src: emojiItem.fallbackImage, + draggable: "false", + loading: "lazy", + align: "absmiddle", + }, + ] + : emojiItem.emoji || `:${emojiItem.shortcodes[0]}:`, + ]; + }, + + renderText({ node }) { + const emojiItem = shortcodeToEmoji(node.attrs.name, this.options.emojis); + + return emojiItem?.emoji || `:${node.attrs.name}:`; + }, + + addCommands() { + return { + setEmoji: + (shortcode) => + ({ chain }) => { + const emojiItem = shortcodeToEmoji(shortcode, this.options.emojis); + + if (!emojiItem) { + return false; + } + + chain() + .insertContent({ + type: this.name, + attrs: { + name: emojiItem.name, + }, + }) + .command(({ tr, state }) => { + tr.setStoredMarks(state.doc.resolve(state.selection.to - 1).marks()); + return true; + }) + .run(); + + return true; + }, + }; + }, + + addInputRules() { + const inputRules: InputRule[] = []; + + inputRules.push( + new InputRule({ + find: inputRegex, + handler: ({ range, match, chain }) => { + const name = match[1]; + + if (!shortcodeToEmoji(name, this.options.emojis)) { + return; + } + + chain() + .insertContentAt(range, { + type: this.name, + attrs: { + name, + }, + }) + .command(({ tr, state }) => { + tr.setStoredMarks(state.doc.resolve(state.selection.to - 1).marks()); + return true; + }) + .run(); + }, + }) + ); + + if (this.options.enableEmoticons) { + // get the list of supported emoticons + const emoticons = this.options.emojis + .map((item) => item.emoticons) + .flat() + .filter((item) => item) as string[]; + + const emoticonRegex = new RegExp(`(?:^|\\s)(${emoticons.map((item) => escapeForRegEx(item)).join("|")}) $`); + + inputRules.push( + nodeInputRule({ + find: emoticonRegex, + type: this.type, + getAttributes: (match) => { + const emoji = this.options.emojis.find((item) => item.emoticons?.includes(match[1])); + + if (!emoji) { + return; + } + + return { + name: emoji.name, + }; + }, + }) + ); + } + + return inputRules; + }, + + addPasteRules() { + return [ + new PasteRule({ + find: pasteRegex, + handler: ({ range, match, chain }) => { + const name = match[1]; + + if (!shortcodeToEmoji(name, this.options.emojis)) { + return; + } + + chain() + .insertContentAt( + range, + { + type: this.name, + attrs: { + name, + }, + }, + { + updateSelection: false, + } + ) + .command(({ tr, state }) => { + tr.setStoredMarks(state.doc.resolve(state.selection.to - 1).marks()); + return true; + }) + .run(); + }, + }), + ]; + }, + + addProseMirrorPlugins() { + return [ + Suggestion({ + editor: this.editor, + ...this.options.suggestion, + }), + + new Plugin({ + key: new PluginKey("emoji"), + props: { + // double click to select emoji doesn’t work by default + // that’s why we simulate this behavior + handleDoubleClickOn: (view, pos, node) => { + if (node.type !== this.type) { + return false; + } + + const from = pos; + const to = from + node.nodeSize; + + this.editor.commands.setTextSelection({ + from, + to, + }); + + return true; + }, + }, + + // replace text emojis with emoji node on any change + appendTransaction: (transactions, oldState, newState) => { + const docChanges = + transactions.some((transaction) => transaction.docChanged) && !oldState.doc.eq(newState.doc); + + if (!docChanges) { + return; + } + + const { tr } = newState; + const transform = combineTransactionSteps(oldState.doc, transactions as Transaction[]); + const changes = getChangedRanges(transform); + + changes.forEach(({ newRange }) => { + // We don’t want to add emoji inline nodes within code blocks. + // Because this would split the code block. + + // This only works if the range of changes is within a code node. + // For all other cases (e.g. the whole document is set/pasted and the parent of the range is `doc`) + // it doesn't and we have to double check later. + if (newState.doc.resolve(newRange.from).parent.type.spec.code) { + return; + } + + const textNodes = findChildrenInRange(newState.doc, newRange, (node) => node.type.isText); + + textNodes.forEach(({ node, pos }) => { + if (!node.text) { + return; + } + + const matches = [...node.text.matchAll(emojiRegex())]; + + matches.forEach((match) => { + if (match.index === undefined) { + return; + } + + const emoji = match[0]; + const name = emojiToShortcode(emoji, this.options.emojis); + + if (!name) { + return; + } + + const from = tr.mapping.map(pos + match.index); + + // Double check parent node is not a code block. + if (newState.doc.resolve(from).parent.type.spec.code) { + return; + } + + const to = from + emoji.length; + const emojiNode = this.type.create({ + name, + }); + + tr.replaceRangeWith(from, to, emojiNode); + + tr.setStoredMarks(newState.doc.resolve(from).marks()); + }); + }); + }); + + if (!tr.steps.length) { + return; + } + + return tr; + }, + }), + ]; + }, +}); diff --git a/packages/editor/src/core/extensions/emoji/extension.ts b/packages/editor/src/core/extensions/emoji/extension.ts index 362b02449..7e35038cc 100644 --- a/packages/editor/src/core/extensions/emoji/extension.ts +++ b/packages/editor/src/core/extensions/emoji/extension.ts @@ -1,7 +1,8 @@ -import Emoji, { EmojiItem, gitHubEmojis, shortcodeToEmoji } from "@tiptap/extension-emoji"; // local imports +import { gitHubEmojis, shortcodeToEmoji } from "@tiptap/extension-emoji"; import { MarkdownSerializerState } from "@tiptap/pm/markdown"; import { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { Emoji } from "./emoji"; import suggestion from "./suggestion"; export const EmojiExtension = Emoji.extend({ @@ -10,17 +11,16 @@ export const EmojiExtension = Emoji.extend({ ...this.parent?.(), markdown: { serialize(state: MarkdownSerializerState, node: ProseMirrorNode) { - const emojiItem = shortcodeToEmoji(node.attrs.name, this.options.emojis) - if(emojiItem?.emoji) { + const emojiItem = shortcodeToEmoji(node.attrs.name, this.options.emojis); + if (emojiItem?.emoji) { state.write(emojiItem?.emoji); - } else if(emojiItem?.fallbackImage) { + } else if (emojiItem?.fallbackImage) { state.write(`\n![${emojiItem.name}-${emojiItem.shortcodes[0]}](${emojiItem?.fallbackImage})\n`); } else { state.write(`:${node.attrs.name}:`); } }, }, - }; }, }).configure({ diff --git a/packages/editor/src/core/extensions/emoji/suggestion.ts b/packages/editor/src/core/extensions/emoji/suggestion.ts index a75e93fb0..caadb483c 100644 --- a/packages/editor/src/core/extensions/emoji/suggestion.ts +++ b/packages/editor/src/core/extensions/emoji/suggestion.ts @@ -12,17 +12,29 @@ const DEFAULT_EMOJIS = ["+1", "-1", "smile", "orange_heart", "eyes"]; const emojiSuggestion: EmojiOptions["suggestion"] = { items: ({ editor, query }: { editor: Editor; query: string }): EmojiItem[] => { + const { emojis } = getExtensionStorage(editor, CORE_EXTENSIONS.EMOJI); + const { isSupported } = getExtensionStorage(editor, CORE_EXTENSIONS.EMOJI); + const filteredEmojis = emojis.filter((emoji) => { + const hasEmoji = !!emoji?.emoji; + const hasFallbackImage = !!emoji?.fallbackImage; + const renderFallbackImage = + (emoji.forceFallbackImages && !hasEmoji) || + (emoji.forceFallbackImages && hasFallbackImage) || + (emoji.forceFallbackImages && !isSupported(emoji) && hasFallbackImage) || + ((!isSupported(emoji) || !hasEmoji) && hasFallbackImage); + return !renderFallbackImage; + }); + 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) + filteredEmojis.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 }) => { + return filteredEmojis + .filter(({ shortcodes, tags }) => { const lowerQuery = query.toLowerCase(); return ( shortcodes.find((shortcode: string) => shortcode.startsWith(lowerQuery)) || diff --git a/packages/editor/src/core/extensions/read-only-extensions.ts b/packages/editor/src/core/extensions/read-only-extensions.ts index c99b02312..25541dd3c 100644 --- a/packages/editor/src/core/extensions/read-only-extensions.ts +++ b/packages/editor/src/core/extensions/read-only-extensions.ts @@ -33,6 +33,7 @@ import { CoreReadOnlyEditorAdditionalExtensions } from "@/plane-editor/extension import type { IReadOnlyEditorProps } from "@/types"; // local imports import { CustomImageExtension } from "./custom-image/extension"; +import { EmojiExtension } from "./emoji/extension"; type Props = Pick; @@ -73,6 +74,7 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => { dropcursor: false, gapcursor: false, }), + EmojiExtension, CustomQuoteExtension, CustomHorizontalRule.configure({ HTMLAttributes: {