@milkdown/plugin-emoji 4.11.2 → 4.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "@milkdown/plugin-emoji",
3
- "version": "4.11.2",
3
+ "version": "4.13.2",
4
4
  "main": "lib/index.js",
5
5
  "module": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
7
7
  "sideEffects": false,
8
8
  "license": "MIT",
9
9
  "files": [
10
- "lib"
10
+ "lib",
11
+ "src"
11
12
  ],
12
13
  "keywords": [
13
14
  "milkdown",
@@ -0,0 +1,5 @@
1
+ /* Copyright 2021, Milkdown by Mirone. */
2
+ export const part = /:\+1|:-1|:[\w-]+/;
3
+ export const full = /:\+1:|:-1:|:[\w-]+:/;
4
+ export const input = /(:([^:\s]+):)$/;
5
+ export const keyword = ':emoji:';
@@ -0,0 +1,84 @@
1
+ /* Copyright 2021, Milkdown by Mirone. */
2
+
3
+ import { Emoji } from 'node-emoji';
4
+ import { EditorView } from 'prosemirror-view';
5
+
6
+ import { full, part } from '../constant';
7
+ import { parse } from '../parse';
8
+
9
+ export const checkTrigger = (
10
+ view: EditorView,
11
+ from: number,
12
+ to: number,
13
+ text: string,
14
+ setRange: (from: number, to: number) => void,
15
+ setSearch: (words: string) => void,
16
+ ) => {
17
+ if (view.composing) return false;
18
+ const { state } = view;
19
+ const $from = state.doc.resolve(from);
20
+ if ($from.parent.type.spec.code) return false;
21
+ const textBefore =
22
+ $from.parent.textBetween(Math.max(0, $from.parentOffset - 10), $from.parentOffset, undefined, '\ufffc') + text;
23
+ if (full.test(textBefore)) {
24
+ return false;
25
+ }
26
+ const regex = part.exec(textBefore);
27
+ if (regex && textBefore.endsWith(regex[0])) {
28
+ const match = regex[0];
29
+ setRange(from - (match.length - text.length), to);
30
+ setSearch(match);
31
+ return true;
32
+ }
33
+ return false;
34
+ };
35
+
36
+ export const renderDropdownList = (
37
+ list: Emoji[],
38
+ dropDown: HTMLElement,
39
+ $active: HTMLElement | null,
40
+ onConfirm: () => void,
41
+ setActive: (active: HTMLElement | null) => void,
42
+ ) => {
43
+ dropDown.innerHTML = '';
44
+ list.forEach(({ emoji, key }, i) => {
45
+ const container = document.createElement('div');
46
+ container.className = 'milkdown-emoji-filter_item';
47
+
48
+ const emojiSpan = document.createElement('span');
49
+ emojiSpan.innerHTML = parse(emoji);
50
+
51
+ emojiSpan.className = 'milkdown-emoji-filter_item-emoji';
52
+ const keySpan = document.createElement('span');
53
+ keySpan.textContent = ':' + key + ':';
54
+ keySpan.className = 'milkdown-emoji-filter_item-key';
55
+
56
+ container.appendChild(emojiSpan);
57
+ container.appendChild(keySpan);
58
+ dropDown.appendChild(container);
59
+
60
+ if (i === 0) {
61
+ container.classList.add('active');
62
+ setActive(container);
63
+ }
64
+
65
+ container.addEventListener('mouseenter', (e) => {
66
+ if ($active) {
67
+ $active.classList.remove('active');
68
+ }
69
+ const { target } = e;
70
+ if (!(target instanceof HTMLElement)) return;
71
+ target.classList.add('active');
72
+ setActive(target);
73
+ });
74
+ container.addEventListener('mouseleave', (e) => {
75
+ const { target } = e;
76
+ if (!(target instanceof HTMLElement)) return;
77
+ target.classList.remove('active');
78
+ });
79
+ container.addEventListener('mousedown', (e) => {
80
+ onConfirm();
81
+ e.preventDefault();
82
+ });
83
+ });
84
+ };
@@ -0,0 +1,157 @@
1
+ /* Copyright 2021, Milkdown by Mirone. */
2
+
3
+ import { calculateNodePosition, createProsePlugin, Utils } from '@milkdown/utils';
4
+ import { search } from 'node-emoji';
5
+ import { Plugin } from 'prosemirror-state';
6
+
7
+ import { checkTrigger, renderDropdownList } from './helper';
8
+ import { injectStyle } from './style';
9
+
10
+ const filterPlugin = (utils: Utils) => {
11
+ let trigger = false;
12
+ let _from = 0;
13
+ let _search = '';
14
+ let $active: null | HTMLElement = null;
15
+
16
+ const off = () => {
17
+ trigger = false;
18
+ _from = 0;
19
+ _search = '';
20
+ $active = null;
21
+ };
22
+
23
+ return new Plugin({
24
+ props: {
25
+ handleKeyDown(_, event) {
26
+ if (['Delete', 'Backspace'].includes(event.key)) {
27
+ _search = _search.slice(0, -1);
28
+ if (_search.length <= 1) {
29
+ off();
30
+ }
31
+ return false;
32
+ }
33
+ if (!trigger) return false;
34
+ if (!['ArrowUp', 'ArrowDown', 'Enter'].includes(event.key)) {
35
+ return false;
36
+ }
37
+ return true;
38
+ },
39
+ handleTextInput(view, from, to, text) {
40
+ trigger = checkTrigger(
41
+ view,
42
+ from,
43
+ to,
44
+ text,
45
+ (from) => {
46
+ _from = from;
47
+ },
48
+ (search) => {
49
+ _search = search;
50
+ },
51
+ );
52
+ if (!trigger) {
53
+ off();
54
+ }
55
+ return false;
56
+ },
57
+ },
58
+ view: (editorView) => {
59
+ const { parentNode } = editorView.dom;
60
+ if (!parentNode) {
61
+ throw new Error();
62
+ }
63
+
64
+ const dropDown = document.createElement('div');
65
+ const style = utils.getStyle(injectStyle);
66
+
67
+ if (style) {
68
+ dropDown.classList.add(style);
69
+ }
70
+
71
+ dropDown.classList.add('milkdown-emoji-filter', 'hide');
72
+
73
+ const replace = () => {
74
+ if (!$active) return;
75
+
76
+ const { tr } = editorView.state;
77
+ const node = editorView.state.schema.node('emoji', { html: $active.firstElementChild?.innerHTML });
78
+
79
+ editorView.dispatch(tr.delete(_from, _from + _search.length).insert(_from, node));
80
+ off();
81
+ dropDown.classList.add('hide');
82
+ };
83
+
84
+ parentNode.appendChild(dropDown);
85
+ parentNode.addEventListener('keydown', (e) => {
86
+ if (!trigger || !(e instanceof KeyboardEvent)) return;
87
+
88
+ const { key } = e;
89
+
90
+ if (key === 'Enter') {
91
+ replace();
92
+ return;
93
+ }
94
+
95
+ if (['ArrowDown', 'ArrowUp'].includes(key)) {
96
+ const next =
97
+ key === 'ArrowDown'
98
+ ? $active?.nextElementSibling || dropDown.firstElementChild
99
+ : $active?.previousElementSibling || dropDown.lastElementChild;
100
+ if ($active) {
101
+ $active.classList.remove('active');
102
+ }
103
+ if (!next) return;
104
+ next.classList.add('active');
105
+ $active = next as HTMLElement;
106
+
107
+ return;
108
+ }
109
+ });
110
+ parentNode.addEventListener('mousedown', (e) => {
111
+ if (!trigger) return;
112
+
113
+ e.stopPropagation();
114
+ off();
115
+ dropDown.classList.add('hide');
116
+ });
117
+
118
+ return {
119
+ update: (view) => {
120
+ if (!trigger) {
121
+ dropDown.classList.add('hide');
122
+ return null;
123
+ }
124
+ const result = search(_search).slice(0, 5);
125
+ const { node } = view.domAtPos(_from);
126
+ if (result.length === 0 || !node) {
127
+ dropDown.classList.add('hide');
128
+ return null;
129
+ }
130
+
131
+ dropDown.classList.remove('hide');
132
+ renderDropdownList(result, dropDown, $active, replace, (a) => {
133
+ $active = a;
134
+ });
135
+ calculateNodePosition(view, dropDown, (selected, target, parent) => {
136
+ const start = view.coordsAtPos(_from);
137
+ let left = start.left - parent.left;
138
+ let top = selected.bottom - parent.top + 14;
139
+
140
+ if (left < 0) {
141
+ left = 0;
142
+ }
143
+
144
+ if (window.innerHeight - start.bottom < target.height) {
145
+ top = selected.top - parent.top - target.height - 14;
146
+ }
147
+ return [top, left];
148
+ });
149
+
150
+ return null;
151
+ },
152
+ };
153
+ },
154
+ });
155
+ };
156
+
157
+ export const filter = createProsePlugin((_, utils) => filterPlugin(utils));
@@ -0,0 +1,41 @@
1
+ /* Copyright 2021, Milkdown by Mirone. */
2
+ import { css } from '@emotion/css';
3
+ import type { ThemeTool } from '@milkdown/core';
4
+
5
+ export const injectStyle = ({ size, mixin, palette, font }: ThemeTool) => {
6
+ return css`
7
+ position: absolute;
8
+ &.hide {
9
+ display: none;
10
+ }
11
+
12
+ ${mixin.border?.()};
13
+ border-radius: ${size.radius};
14
+ background: ${palette('surface')};
15
+ ${mixin.shadow?.()};
16
+
17
+ .milkdown-emoji-filter_item {
18
+ display: flex;
19
+ gap: 0.5rem;
20
+ height: 2.25rem;
21
+ padding: 0 1rem;
22
+ align-items: center;
23
+ justify-content: flex-start;
24
+ cursor: pointer;
25
+ line-height: 2;
26
+ font-family: ${font.typography};
27
+ font-size: 0.875rem;
28
+ &.active {
29
+ background: ${palette('secondary', 0.12)};
30
+ color: ${palette('primary')};
31
+ }
32
+ }
33
+
34
+ .emoji {
35
+ height: 1em;
36
+ width: 1em;
37
+ margin: 0 0.05em 0 0.1em;
38
+ vertical-align: -0.1em;
39
+ }
40
+ `;
41
+ };
package/src/index.ts ADDED
@@ -0,0 +1,16 @@
1
+ /* Copyright 2021, Milkdown by Mirone. */
2
+ import { remarkPluginFactory } from '@milkdown/core';
3
+ import { AtomList } from '@milkdown/utils';
4
+ import remarkEmoji from 'remark-emoji';
5
+
6
+ import { filter } from './filter';
7
+ import { emojiNode } from './node';
8
+ import { picker } from './picker';
9
+ import { twemojiPlugin } from './remark-twemoji';
10
+
11
+ export const remarkPlugin = remarkPluginFactory([remarkEmoji, twemojiPlugin]);
12
+ export const emoji = AtomList.create([remarkPlugin, emojiNode(), filter(), picker()]);
13
+
14
+ export { filter } from './filter';
15
+ export { emojiNode } from './node';
16
+ export { picker } from './picker';
package/src/node.ts ADDED
@@ -0,0 +1,89 @@
1
+ /* Copyright 2021, Milkdown by Mirone. */
2
+ import { css } from '@emotion/css';
3
+ import { createNode } from '@milkdown/utils';
4
+ import nodeEmoji from 'node-emoji';
5
+ import { InputRule } from 'prosemirror-inputrules';
6
+
7
+ import { input } from './constant';
8
+ import { parse } from './parse';
9
+
10
+ export const emojiNode = createNode((_, utils) => {
11
+ const style = utils.getStyle(
12
+ () => css`
13
+ display: inline-flex;
14
+ justify-content: center;
15
+ align-items: center;
16
+
17
+ .emoji {
18
+ height: 1em;
19
+ width: 1em;
20
+ margin: 0 0.05em 0 0.1em;
21
+ vertical-align: -0.1em;
22
+ }
23
+ `,
24
+ );
25
+ return {
26
+ id: 'emoji',
27
+ schema: {
28
+ group: 'inline',
29
+ inline: true,
30
+ selectable: false,
31
+ marks: '',
32
+ attrs: {
33
+ html: {
34
+ default: '',
35
+ },
36
+ },
37
+ parseDOM: [
38
+ {
39
+ tag: 'span[data-type="emoji"]',
40
+ getAttrs: (dom) => {
41
+ if (!(dom instanceof HTMLElement)) {
42
+ throw new Error();
43
+ }
44
+ return { html: dom.innerHTML };
45
+ },
46
+ },
47
+ ],
48
+ toDOM: (node) => {
49
+ const span = document.createElement('span');
50
+ span.dataset.type = 'emoji';
51
+ if (style) {
52
+ span.classList.add(style);
53
+ }
54
+ span.classList.add('emoji-wrapper');
55
+ span.innerHTML = node.attrs.html;
56
+ return { dom: span };
57
+ },
58
+ },
59
+ parser: {
60
+ match: ({ type }) => type === 'emoji',
61
+ runner: (state, node, type) => {
62
+ state.addNode(type, { html: node.value as string });
63
+ },
64
+ },
65
+ serializer: {
66
+ match: (node) => node.type.name === 'emoji',
67
+ runner: (state, node) => {
68
+ const span = document.createElement('span');
69
+ span.innerHTML = node.attrs.html;
70
+ const img = span.querySelector('img');
71
+ const title = img?.title;
72
+ span.remove();
73
+ state.addNode('text', undefined, title);
74
+ },
75
+ },
76
+ inputRules: (nodeType) => [
77
+ new InputRule(input, (state, match, start, end) => {
78
+ const content = match[0];
79
+ if (!content) return null;
80
+ const got = nodeEmoji.get(content);
81
+ if (!got || content.includes(got)) return null;
82
+
83
+ const html = parse(got);
84
+
85
+ return state.tr.replaceRangeWith(start, end, nodeType.create({ html })).scrollIntoView();
86
+ }),
87
+ ],
88
+ };
89
+ });
package/src/parse.ts ADDED
@@ -0,0 +1,4 @@
1
+ /* Copyright 2021, Milkdown by Mirone. */
2
+ import twemoji from 'twemoji';
3
+
4
+ export const parse = (emoji: string) => twemoji.parse(emoji, { attributes: (text) => ({ title: text }) });
package/src/picker.ts ADDED
@@ -0,0 +1,129 @@
1
+ /* Copyright 2021, Milkdown by Mirone. */
2
+ import { injectGlobal } from '@emotion/css';
3
+ import { EmojiButton } from '@joeattardi/emoji-button';
4
+ import { createProsePlugin, Utils } from '@milkdown/utils';
5
+ import { Plugin } from 'prosemirror-state';
6
+ import { Decoration, DecorationSet, EditorView } from 'prosemirror-view';
7
+
8
+ import { parse } from './parse';
9
+
10
+ const keyword = ':emoji:';
11
+
12
+ const checkTrigger = (
13
+ view: EditorView,
14
+ from: number,
15
+ to: number,
16
+ text: string,
17
+ setRange: (from: number, to: number) => void,
18
+ ) => {
19
+ if (view.composing) return false;
20
+ const { state } = view;
21
+ const $from = state.doc.resolve(from);
22
+ if ($from.parent.type.spec.code) return false;
23
+ const textBefore =
24
+ $from.parent.textBetween($from.parentOffset - keyword.length + 1, $from.parentOffset, undefined, '\ufffc') +
25
+ text;
26
+ if (textBefore === keyword) {
27
+ setRange(from - keyword.length + 1, to + 1);
28
+ return true;
29
+ }
30
+ return false;
31
+ };
32
+
33
+ const pickerPlugin = (utils: Utils) => {
34
+ let trigger = false;
35
+ const holder = document.createElement('span');
36
+ let _from = 0;
37
+ let _to = 0;
38
+ const off = () => {
39
+ trigger = false;
40
+ _from = 0;
41
+ _to = 0;
42
+ };
43
+
44
+ const plugin = new Plugin({
45
+ props: {
46
+ handleKeyDown() {
47
+ off();
48
+ return false;
49
+ },
50
+ handleClick() {
51
+ off();
52
+ return false;
53
+ },
54
+ handleTextInput(view, from, to, text) {
55
+ trigger = checkTrigger(view, from, to, text, (from, to) => {
56
+ _from = from;
57
+ _to = to;
58
+ });
59
+
60
+ if (!trigger) {
61
+ off();
62
+ }
63
+ return false;
64
+ },
65
+ decorations(state) {
66
+ if (!trigger) return null;
67
+
68
+ return DecorationSet.create(state.doc, [Decoration.widget(state.selection.to, holder)]);
69
+ },
70
+ },
71
+ view: (editorView) => {
72
+ const { parentNode } = editorView.dom;
73
+ if (!parentNode) {
74
+ throw new Error();
75
+ }
76
+ utils.getStyle(({ palette, font }) => {
77
+ const css = injectGlobal;
78
+ css`
79
+ .emoji-picker {
80
+ --dark-search-background-color: ${palette('surface')} !important;
81
+ --dark-text-color: ${palette('neutral', 0.87)} !important;
82
+ --dark-background-color: ${palette('background')} !important;
83
+ --dark-border-color: ${palette('shadow')} !important;
84
+ --dark-hover-color: ${palette('secondary', 0.12)} !important;
85
+ --dark-blue-color: ${palette('primary')} !important;
86
+ --dark-search-icon-color: ${palette('primary')} !important;
87
+ --dark-category-button-color: ${palette('secondary', 0.4)} !important;
88
+ --font: ${font.typography} !important;
89
+ --font-size: 1rem !important;
90
+ }
91
+ `;
92
+ });
93
+ const emojiPicker = new EmojiButton({
94
+ rootElement: parentNode as HTMLElement,
95
+ autoFocusSearch: false,
96
+ style: 'twemoji',
97
+ theme: 'dark',
98
+ zIndex: 99,
99
+ });
100
+ emojiPicker.on('emoji', (selection) => {
101
+ const start = _from;
102
+ const end = _to;
103
+ off();
104
+ const html = parse(selection.emoji);
105
+ const node = editorView.state.schema.node('emoji', { html });
106
+ const { tr } = editorView.state;
107
+
108
+ editorView.dispatch(tr.replaceRangeWith(start, end, node));
109
+ });
110
+ return {
111
+ update: () => {
112
+ if (!trigger) {
113
+ emojiPicker.hidePicker();
114
+ return null;
115
+ }
116
+ emojiPicker.showPicker(holder);
117
+ return null;
118
+ },
119
+ destroy: () => {
120
+ emojiPicker.destroyPicker();
121
+ },
122
+ };
123
+ },
124
+ });
125
+
126
+ return plugin;
127
+ };
128
+
129
+ export const picker = createProsePlugin((_, utils) => pickerPlugin(utils));
@@ -0,0 +1,59 @@
1
+ /* Copyright 2021, Milkdown by Mirone. */
2
+ import emojiRegex from 'emoji-regex';
3
+ import { Literal, Node, Parent } from 'unist';
4
+
5
+ import { parse } from './parse';
6
+
7
+ const regex = emojiRegex();
8
+
9
+ const isParent = (node: Node): node is Parent => !!(node as Parent).children;
10
+ const isLiteral = (node: Node): node is Literal => !!(node as Literal).value;
11
+
12
+ function flatMap(ast: Node, fn: (node: Node, index: number, parent: Node | null) => Node[]) {
13
+ return transform(ast, 0, null)[0];
14
+
15
+ function transform(node: Node, index: number, parent: Node | null) {
16
+ if (isParent(node)) {
17
+ const out = [];
18
+ for (let i = 0, n = node.children.length; i < n; i++) {
19
+ const xs = transform(node.children[i], i, node);
20
+ if (xs) {
21
+ for (let j = 0, m = xs.length; j < m; j++) {
22
+ out.push(xs[j]);
23
+ }
24
+ }
25
+ }
26
+ node.children = out;
27
+ }
28
+
29
+ return fn(node, index, parent);
30
+ }
31
+ }
32
+
33
+ export const twemojiPlugin = () => {
34
+ function transformer(tree: Node) {
35
+ flatMap(tree, (node) => {
36
+ if (!isLiteral(node)) {
37
+ return [node];
38
+ }
39
+ const value = node.value as string;
40
+ const output: Literal<string>[] = [];
41
+ let match;
42
+ let str = value;
43
+ while ((match = regex.exec(str))) {
44
+ const { index } = match;
45
+ const emoji = match[0];
46
+ if (index > 0) {
47
+ output.push({ ...node, value: str.slice(0, index) });
48
+ }
49
+ output.push({ ...node, value: parse(emoji), type: 'emoji' });
50
+ str = str.slice(index + emoji.length);
51
+ }
52
+ if (str.length) {
53
+ output.push({ ...node, value: str });
54
+ }
55
+ return output;
56
+ });
57
+ }
58
+ return transformer;
59
+ };