mirror of
https://github.com/discourse/discourse.git
synced 2025-04-26 11:04:31 +08:00
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:
parent
5a21f74716
commit
6168636de8
@ -56,7 +56,7 @@ export async function parseMentions(markdown, options) {
|
|||||||
return await withEngine("parseMentions", markdown, options);
|
return await withEngine("parseMentions", markdown, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
function emojiOptions() {
|
export function emojiOptions() {
|
||||||
let siteSettings = helperContext().siteSettings;
|
let siteSettings = helperContext().siteSettings;
|
||||||
let context = helperContext();
|
let context = helperContext();
|
||||||
if (!siteSettings.enable_emoji) {
|
if (!siteSettings.enable_emoji) {
|
||||||
|
@ -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;
|
@ -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;
|
@ -1,5 +1,7 @@
|
|||||||
import { registerRichEditorExtension } from "discourse/lib/composer/rich-editor-extensions";
|
import { registerRichEditorExtension } from "discourse/lib/composer/rich-editor-extensions";
|
||||||
|
import emoji from "./emoji";
|
||||||
import hashtag from "./hashtag";
|
import hashtag from "./hashtag";
|
||||||
|
import heading from "./heading";
|
||||||
import mention from "./mention";
|
import mention from "./mention";
|
||||||
import underline from "./underline";
|
import underline from "./underline";
|
||||||
|
|
||||||
@ -9,7 +11,7 @@ import underline from "./underline";
|
|||||||
*
|
*
|
||||||
* @type {RichEditorExtension[]}
|
* @type {RichEditorExtension[]}
|
||||||
*/
|
*/
|
||||||
const defaultExtensions = [hashtag, mention, underline];
|
const defaultExtensions = [emoji, heading, hashtag, mention, underline];
|
||||||
|
|
||||||
defaultExtensions.forEach(registerRichEditorExtension);
|
defaultExtensions.forEach(registerRichEditorExtension);
|
||||||
|
|
||||||
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
Loading…
x
Reference in New Issue
Block a user