FEATURE: add emoji rich editor extension (#31144)

Continues the work done on
https://github.com/discourse/discourse/pull/30815.

Adds an `emoji` node, its Markdown serializing/parsing logic, and input
rules for `:emoji:`s and `:-)` emoticon replacements.

[prosemirror-markdown's
heading](99b6f0a6c3/src/schema.ts (L30))
only allows `(text | image)*` content, we override it to allow `inline*`
to be compatible with all our inline (including emoji) nodes.
This commit is contained in:
Renato Atilio 2025-03-04 11:31:47 -03:00 committed by GitHub
parent 5a21f74716
commit 6168636de8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 164 additions and 2 deletions

View File

@ -56,7 +56,7 @@ export async function parseMentions(markdown, options) {
return await withEngine("parseMentions", markdown, options);
}
function emojiOptions() {
export function emojiOptions() {
let siteSettings = helperContext().siteSettings;
let context = helperContext();
if (!siteSettings.enable_emoji) {

View File

@ -0,0 +1,98 @@
import { buildEmojiUrl, emojiExists, isCustomEmoji } from "pretty-text/emoji";
import { translations } from "pretty-text/emoji/data";
import escapeRegExp from "discourse/lib/escape-regexp";
import { emojiOptions } from "discourse/lib/text";
import { isBoundary } from "discourse/static/prosemirror/lib/markdown-it";
/** @type {RichEditorExtension} */
const extension = {
nodeSpec: {
emoji: {
attrs: { code: {} },
inline: true,
group: "inline",
draggable: true,
selectable: false,
parseDOM: [
{
tag: "img.emoji",
getAttrs: (dom) => {
return { code: dom.getAttribute("alt").replace(/:/g, "") };
},
priority: 60,
},
],
toDOM: (node) => {
const opts = emojiOptions();
const code = node.attrs.code.toLowerCase();
const title = `:${code}:`;
const src = buildEmojiUrl(code, opts);
return [
"img",
{
class: isCustomEmoji(code, opts) ? "emoji emoji-custom" : "emoji",
alt: title,
title,
src,
},
];
},
},
},
inputRules: [
{
match: /(^|\W):([^:]+):$/,
handler: (state, match, start, end) => {
if (emojiExists(match[2])) {
const emojiStart = start + match[1].length;
return state.tr.replaceWith(
emojiStart,
end,
state.schema.nodes.emoji.create({ code: match[2] })
);
}
},
options: { undoable: false },
},
{
match: new RegExp(
"(^|\\W)(" +
Object.keys(translations).map(escapeRegExp).join("|") +
") $"
),
handler: (state, match, start, end) => {
const emojiStart = start + match[1].length;
return state.tr
.replaceWith(
emojiStart,
end,
state.schema.nodes.emoji.create({ code: translations[match[2]] })
)
.insertText(" ");
},
},
],
parse: {
emoji: {
node: "emoji",
getAttrs: (token) => ({
code: token.attrGet("alt").slice(1, -1),
}),
},
},
serializeNode: {
emoji(state, node) {
if (!isBoundary(state.out, state.out.length - 1)) {
state.write(" ");
}
state.write(`:${node.attrs.code}:`);
},
},
};
export default extension;

View File

@ -0,0 +1,25 @@
/** @type {RichEditorExtension} */
const extension = {
nodeSpec: {
heading: {
attrs: { level: { default: 1 } },
// Overriding ProseMirror's default to allow inline content
content: "inline*",
group: "block",
defining: true,
parseDOM: [
{ tag: "h1", attrs: { level: 1 } },
{ tag: "h2", attrs: { level: 2 } },
{ tag: "h3", attrs: { level: 3 } },
{ tag: "h4", attrs: { level: 4 } },
{ tag: "h5", attrs: { level: 5 } },
{ tag: "h6", attrs: { level: 6 } },
],
toDOM(node) {
return ["h" + node.attrs.level, 0];
},
},
},
};
export default extension;

View File

@ -1,5 +1,7 @@
import { registerRichEditorExtension } from "discourse/lib/composer/rich-editor-extensions";
import emoji from "./emoji";
import hashtag from "./hashtag";
import heading from "./heading";
import mention from "./mention";
import underline from "./underline";
@ -9,7 +11,7 @@ import underline from "./underline";
*
* @type {RichEditorExtension[]}
*/
const defaultExtensions = [hashtag, mention, underline];
const defaultExtensions = [emoji, heading, hashtag, mention, underline];
defaultExtensions.forEach(registerRichEditorExtension);

View File

@ -0,0 +1,37 @@
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { testMarkdown } from "discourse/tests/helpers/rich-editor-helper";
module(
"Integration | Component | prosemirror-editor - emoji extension",
function (hooks) {
setupRenderingTest(hooks);
const testCases = {
emoji: [
[
"Hey :tada:!",
'<p>Hey <img class="emoji" alt=":tada:" title=":tada:" src="/images/emoji/twitter/tada.png?v=13" contenteditable="false" draggable="true">!</p>',
"Hey :tada:!",
],
],
"emoji in heading": [
[
"# Heading :information_source:",
'<h1>Heading <img class="emoji" alt=":information_source:" title=":information_source:" src="/images/emoji/twitter/information_source.png?v=13" contenteditable="false" draggable="true"></h1>',
"# Heading :information_source:",
],
],
};
Object.entries(testCases).forEach(([name, tests]) => {
tests.forEach(([markdown, expectedHtml, expectedMarkdown]) => {
test(name, async function (assert) {
this.siteSettings.rich_editor = true;
await testMarkdown(assert, markdown, expectedHtml, expectedMarkdown);
});
});
});
}
);