DEV: supports bigger emoji in rich composer (#31933)

The rules are:
- between 1 and 3 emojis: bigger emoji
- more than 3 or any text or node in the same paragraph: regular emoji

This is implemented through a prose mirror plugin, which try to be smart
and recompute only edited paragraphs. Full scan on first load.

---------

Co-authored-by: Renato Atilio <renato@discourse.org>
This commit is contained in:
Joffrey JAFFEUX 2025-03-28 17:10:00 +01:00 committed by GitHub
parent a4b2b57db7
commit 12dffc5f7d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 168 additions and 0 deletions

View File

@ -1,8 +1,92 @@
import { buildEmojiUrl, emojiExists, isCustomEmoji } from "pretty-text/emoji";
import { translations } from "pretty-text/emoji/data";
import { Plugin } from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";
import escapeRegExp from "discourse/lib/escape-regexp";
import { emojiOptions } from "discourse/lib/text";
import { isBoundary } from "discourse/static/prosemirror/lib/markdown-it";
import { getChangedRanges } from "discourse/static/prosemirror/lib/plugin-utils";
/**
* Plugin that adds the only-emoji class to emojis
* @returns {Plugin} ProseMirror plugin
*/
function createOnlyEmojiPlugin() {
return new Plugin({
state: {
init() {
return DecorationSet.empty;
},
apply(tr, oldSet, oldState, newState) {
if (!tr.docChanged) {
return oldSet.map(tr.mapping, tr.doc);
}
const changedRanges = getChangedRanges(tr);
let newSet = oldSet.map(tr.mapping, tr.doc);
changedRanges.forEach(({ new: { from, to } }) => {
// traverse all text blocks in the changed range
newState.doc.nodesBetween(from, to, (node, pos) => {
if (!node.isTextblock) {
return true;
}
const blockFrom = pos;
const blockTo = pos + node.nodeSize;
const existingDecorations = newSet.find(blockFrom, blockTo);
newSet = newSet.remove(existingDecorations);
const emojiNodes = [];
let hasOnlyEmojis = true;
// collect emojis in the current text block
node.descendants((child, childPos) => {
if (child.type.name === "emoji") {
emojiNodes.push({
from: blockFrom + 1 + childPos,
to: blockFrom + 1 + childPos + child.nodeSize,
});
return true;
}
if (child.type.name === "text" && !child.text?.trim()) {
return true;
}
hasOnlyEmojis = false;
return false;
});
if (
emojiNodes.length > 0 &&
emojiNodes.length <= 3 &&
hasOnlyEmojis
) {
const decorations = emojiNodes.map((emoji) =>
Decoration.inline(emoji.from, emoji.to, {
class: "only-emoji",
})
);
newSet = newSet.add(newState.doc, decorations);
}
return false;
});
});
return newSet;
},
},
props: {
decorations(state) {
return this.getState(state);
},
},
});
}
/** @type {RichEditorExtension} */
const extension = {
@ -93,6 +177,8 @@ const extension = {
state.write(`:${node.attrs.code}:`);
},
},
plugins: () => [createOnlyEmojiPlugin()],
};
export default extension;

View File

@ -1,4 +1,5 @@
import { InputRule } from "prosemirror-inputrules";
import { StepMap } from "prosemirror-transform";
export { getLinkify, isBoundary, isWhiteSpace } from "../lib/markdown-it";
@ -44,3 +45,34 @@ export function markInputRule(regexp, markType, getAttrs) {
{ inCodeMark: false }
);
}
export function getChangedRanges(tr) {
const { steps, mapping } = tr;
const changes = [];
mapping.maps.forEach((stepMap, index) => {
const ranges = [];
if (stepMap === StepMap.empty) {
if (steps[index].from === undefined || steps[index].to === undefined) {
return;
}
ranges.push(steps[index]);
} else {
stepMap.forEach((from, to) => ranges.push({ from, to }));
}
ranges.forEach(({ from, to }) => {
const change = { new: {}, old: {} };
change.new.from = mapping.slice(index).map(from, -1);
change.new.to = mapping.slice(index).map(to);
change.old.from = mapping.invert().map(change.new.from, -1);
change.old.to = mapping.invert().map(change.new.to);
changes.push(change);
});
});
return changes;
}

View File

@ -23,6 +23,34 @@ module(
"# Heading :information_source:",
],
],
"single emoji in paragraph gets only-emoji class": [
[
":tada:",
`<p><img class="emoji only-emoji" alt=":tada:" title=":tada:" src="/images/emoji/twitter/tada.png?v=${v}" contenteditable="false" draggable="true"></p>`,
":tada:",
],
],
"three emojis in paragraph get only-emoji class": [
[
":tada: :smile: :heart:",
`<p><img class="emoji only-emoji" alt=":tada:" title=":tada:" src="/images/emoji/twitter/tada.png?v=${v}" contenteditable="false" draggable="true"> <img class="emoji only-emoji" alt=":smile:" title=":smile:" src="/images/emoji/twitter/smile.png?v=${v}" contenteditable="false" draggable="true"> <img class="emoji only-emoji" alt=":heart:" title=":heart:" src="/images/emoji/twitter/heart.png?v=${v}" contenteditable="false" draggable="true"></p>`,
":tada: :smile: :heart:",
],
],
"more than three emojis don't get only-emoji class": [
[
":tada: :smile: :heart: :+1:",
`<p><img class="emoji" alt=":tada:" title=":tada:" src="/images/emoji/twitter/tada.png?v=${v}" contenteditable="false" draggable="true"> <img class="emoji" alt=":smile:" title=":smile:" src="/images/emoji/twitter/smile.png?v=${v}" contenteditable="false" draggable="true"> <img class="emoji" alt=":heart:" title=":heart:" src="/images/emoji/twitter/heart.png?v=${v}" contenteditable="false" draggable="true"> <img class="emoji" alt=":+1:" title=":+1:" src="/images/emoji/twitter/+1.png?v=${v}" contenteditable="false" draggable="true"></p>`,
":tada: :smile: :heart: :+1:",
],
],
"emoji with text doesn't get only-emoji class": [
[
"Hello :tada:",
`<p>Hello <img class="emoji" alt=":tada:" title=":tada:" src="/images/emoji/twitter/tada.png?v=${v}" contenteditable="false" draggable="true"></p>`,
"Hello :tada:",
],
],
};
Object.entries(testCases).forEach(([name, tests]) => {

View File

@ -542,4 +542,26 @@ describe "Composer - ProseMirror editor", type: :system do
expect(rich).to have_css("img", count: 1)
end
end
describe "emojis" do
it "has the only-emoji class if 1-3 emojis are 'alone'" do
open_composer_and_toggle_rich_editor
composer.type_content("> :smile: ")
expect(rich).to have_css(".only-emoji", count: 1)
composer.type_content(":P ")
expect(rich).to have_css(".only-emoji", count: 2)
composer.type_content(":D ")
expect(rich).to have_css(".only-emoji", count: 3)
composer.type_content("Hey!")
expect(rich).to have_no_css(".only-emoji")
end
end
end