Commit 27836b59 authored by Enrique Alcantara's avatar Enrique Alcantara

Render emojis in the Content Editor

Render GFM emojis in the Content Editor
and allows inserting an emoji using
input rules

Changelog: added
parent 0dd2dcdc
import { Node } from '@tiptap/core';
import { InputRule } from 'prosemirror-inputrules';
import { initEmojiMap, getAllEmoji } from '~/emoji';
export const emojiInputRegex = /(?:^|\s)((?::)((?:\w+))(?::))$/;
export default Node.create({
name: 'emoji',
inline: true,
group: 'inline',
draggable: true,
addAttributes() {
return {
moji: {
default: null,
parseHTML: (element) => {
return {
moji: element.textContent,
};
},
},
name: {
default: null,
parseHTML: (element) => {
return {
name: element.dataset.name,
};
},
},
title: {
default: null,
},
unicodeVersion: {
default: '6.0',
parseHTML: (element) => {
return {
unicodeVersion: element.dataset.unicodeVersion,
};
},
},
};
},
parseHTML() {
return [
{
tag: 'gl-emoji',
},
];
},
renderHTML({ node }) {
return [
'gl-emoji',
{
'data-name': node.attrs.name,
title: node.attrs.title,
'data-unicode-version': node.attrs.unicodeVersion,
},
node.attrs.moji,
];
},
addInputRules() {
return [
new InputRule(emojiInputRegex, (state, match, start, end) => {
const [, , name] = match;
const emojis = getAllEmoji();
const emoji = emojis[name];
const { tr } = state;
if (emoji) {
tr.replaceWith(start, end, [
state.schema.text(' '),
this.type.create({ name, moji: emoji.e, unicodeVersion: emoji.u, title: emoji.d }),
]);
return tr;
}
return null;
}),
];
},
onCreate() {
initEmojiMap();
},
});
...@@ -9,6 +9,7 @@ import Code from '../extensions/code'; ...@@ -9,6 +9,7 @@ import Code from '../extensions/code';
import CodeBlockHighlight from '../extensions/code_block_highlight'; import CodeBlockHighlight from '../extensions/code_block_highlight';
import Document from '../extensions/document'; import Document from '../extensions/document';
import Dropcursor from '../extensions/dropcursor'; import Dropcursor from '../extensions/dropcursor';
import Emoji from '../extensions/emoji';
import Gapcursor from '../extensions/gapcursor'; import Gapcursor from '../extensions/gapcursor';
import HardBreak from '../extensions/hard_break'; import HardBreak from '../extensions/hard_break';
import Heading from '../extensions/heading'; import Heading from '../extensions/heading';
...@@ -62,6 +63,7 @@ export const createContentEditor = ({ ...@@ -62,6 +63,7 @@ export const createContentEditor = ({
CodeBlockHighlight, CodeBlockHighlight,
Document, Document,
Dropcursor, Dropcursor,
Emoji,
Gapcursor, Gapcursor,
HardBreak, HardBreak,
Heading, Heading,
......
...@@ -8,6 +8,7 @@ import Bold from '../extensions/bold'; ...@@ -8,6 +8,7 @@ import Bold from '../extensions/bold';
import BulletList from '../extensions/bullet_list'; import BulletList from '../extensions/bullet_list';
import Code from '../extensions/code'; import Code from '../extensions/code';
import CodeBlockHighlight from '../extensions/code_block_highlight'; import CodeBlockHighlight from '../extensions/code_block_highlight';
import Emoji from '../extensions/emoji';
import HardBreak from '../extensions/hard_break'; import HardBreak from '../extensions/hard_break';
import Heading from '../extensions/heading'; import Heading from '../extensions/heading';
import HorizontalRule from '../extensions/horizontal_rule'; import HorizontalRule from '../extensions/horizontal_rule';
...@@ -51,6 +52,11 @@ const defaultSerializerConfig = { ...@@ -51,6 +52,11 @@ const defaultSerializerConfig = {
[Blockquote.name]: defaultMarkdownSerializer.nodes.blockquote, [Blockquote.name]: defaultMarkdownSerializer.nodes.blockquote,
[BulletList.name]: defaultMarkdownSerializer.nodes.bullet_list, [BulletList.name]: defaultMarkdownSerializer.nodes.bullet_list,
[CodeBlockHighlight.name]: defaultMarkdownSerializer.nodes.code_block, [CodeBlockHighlight.name]: defaultMarkdownSerializer.nodes.code_block,
[Emoji.name]: (state, node) => {
const { name } = node.attrs;
state.write(`:${name}:`);
},
[HardBreak.name]: defaultMarkdownSerializer.nodes.hard_break, [HardBreak.name]: defaultMarkdownSerializer.nodes.hard_break,
[Heading.name]: defaultMarkdownSerializer.nodes.heading, [Heading.name]: defaultMarkdownSerializer.nodes.heading,
[HorizontalRule.name]: defaultMarkdownSerializer.nodes.horizontal_rule, [HorizontalRule.name]: defaultMarkdownSerializer.nodes.horizontal_rule,
......
...@@ -6,6 +6,8 @@ import ContentEditor from '~/content_editor/components/content_editor.vue'; ...@@ -6,6 +6,8 @@ import ContentEditor from '~/content_editor/components/content_editor.vue';
import TopToolbar from '~/content_editor/components/top_toolbar.vue'; import TopToolbar from '~/content_editor/components/top_toolbar.vue';
import { createContentEditor } from '~/content_editor/services/create_content_editor'; import { createContentEditor } from '~/content_editor/services/create_content_editor';
jest.mock('~/emoji');
describe('ContentEditor', () => { describe('ContentEditor', () => {
let wrapper; let wrapper;
let editor; let editor;
......
import { initEmojiMock } from 'helpers/emoji';
import Emoji from '~/content_editor/extensions/emoji';
import { createTestEditor, createDocBuilder } from '../test_utils';
describe('content_editor/extensions/emoji', () => {
let tiptapEditor;
let doc;
let p;
let emoji;
let eq;
beforeEach(async () => {
await initEmojiMock();
});
beforeEach(() => {
tiptapEditor = createTestEditor({ extensions: [Emoji] });
({
builders: { doc, p, emoji },
eq,
} = createDocBuilder({
tiptapEditor,
names: {
loading: { nodeType: Emoji.name },
},
}));
});
describe('when typing a valid emoji input rule', () => {
it('inserts an emoji node', () => {
const { view } = tiptapEditor;
const { selection } = view.state;
const expectedDoc = doc(
p(
' ',
emoji({ moji: '', name: 'heart', title: 'heavy black heart', unicodeVersion: '1.1' }),
),
);
// Triggers the event handler that input rules listen to
view.someProp('handleTextInput', (f) => f(view, selection.from, selection.to, ':heart:'));
expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true);
});
});
describe('when typing a invalid emoji input rule', () => {
it('does not insert an emoji node', () => {
const { view } = tiptapEditor;
const { selection } = view.state;
const invalidEmoji = ':invalid:';
const expectedDoc = doc(p());
// Triggers the event handler that input rules listen to
view.someProp('handleTextInput', (f) => f(view, selection.from, selection.to, invalidEmoji));
expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true);
});
});
});
import { createContentEditor } from '~/content_editor'; import { createContentEditor } from '~/content_editor';
import { loadMarkdownApiExamples, loadMarkdownApiResult } from './markdown_processing_examples'; import { loadMarkdownApiExamples, loadMarkdownApiResult } from './markdown_processing_examples';
jest.mock('~/emoji');
describe('markdown processing', () => { describe('markdown processing', () => {
// Ensure we generate same markdown that was provided to Markdown API. // Ensure we generate same markdown that was provided to Markdown API.
it.each(loadMarkdownApiExamples())( it.each(loadMarkdownApiExamples())(
......
...@@ -2,7 +2,9 @@ import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '~/content_editor/constants ...@@ -2,7 +2,9 @@ import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '~/content_editor/constants
import { createContentEditor } from '~/content_editor/services/create_content_editor'; import { createContentEditor } from '~/content_editor/services/create_content_editor';
import { createTestContentEditorExtension } from '../test_utils'; import { createTestContentEditorExtension } from '../test_utils';
describe('content_editor/services/create_editor', () => { jest.mock('~/emoji');
describe('content_editor/services/create_content_editor', () => {
let renderMarkdown; let renderMarkdown;
let editor; let editor;
const uploadsPath = '/uploads'; const uploadsPath = '/uploads';
......
...@@ -86,4 +86,5 @@ ...@@ -86,4 +86,5 @@
|--------|------------|----------| |--------|------------|----------|
| cell | cell | cell | | cell | cell | cell |
| cell | cell | cell | | cell | cell | cell |
- name: emoji
markdown: ':sparkles: :heart: :100:'
...@@ -15,6 +15,8 @@ import { ...@@ -15,6 +15,8 @@ import {
import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import MarkdownField from '~/vue_shared/components/markdown/field.vue';
jest.mock('~/emoji');
describe('WikiForm', () => { describe('WikiForm', () => {
let wrapper; let wrapper;
let mock; let mock;
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment