[WIKI-524] fix: emoji fall back image (#7354)

This commit is contained in:
Vipin Chaudhary 2025-07-08 03:21:03 +05:30 committed by GitHub
parent a2a62e2731
commit ab79a5da10
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 467 additions and 9 deletions

View file

@ -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<ReturnType> {
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<string, any>;
emojis: EmojiItem[];
enableEmoticons: boolean;
forceFallbackImages: boolean;
suggestion: Omit<SuggestionOptions, "editor">;
};
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<EmojiOptions, EmojiStorage>({
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<number, boolean> = 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 doesnt work by default
// thats 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 dont 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;
},
}),
];
},
});

View file

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

View file

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

View file

@ -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<IReadOnlyEditorProps, "disabledExtensions" | "flaggedExtensions" | "fileHandler" | "mentionHandler">;
@ -73,6 +74,7 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
dropcursor: false,
gapcursor: false,
}),
EmojiExtension,
CustomQuoteExtension,
CustomHorizontalRule.configure({
HTMLAttributes: {