[WIKI-524] fix: emoji fall back image (#7354)
This commit is contained in:
parent
a2a62e2731
commit
ab79a5da10
4 changed files with 467 additions and 9 deletions
444
packages/editor/src/core/extensions/emoji/emoji.ts
Normal file
444
packages/editor/src/core/extensions/emoji/emoji.ts
Normal 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 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;
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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)) ||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue