mirror of
https://github.com/discourse/discourse.git
synced 2025-06-26 20:22:25 +00:00
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:
parent
a4b2b57db7
commit
12dffc5f7d
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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]) => {
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user