Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/frontend/editors/slate/slate-emojis/hook.ts
1697 views
1
/*
2
* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.
3
* License: MIT (same as slate uses https://github.com/ianstormtaylor/slate/blob/master/License.md)
4
*/
5
6
import { debounce } from "lodash";
7
import React, { useCallback, useMemo, useState } from "react";
8
import { Editor, Range, Text, Transforms } from "slate";
9
10
import { useIsMountedRef } from "@cocalc/frontend/app-framework";
11
import {
12
Complete,
13
Item,
14
} from "@cocalc/frontend/editors/markdown-input/complete";
15
import { field_cmp } from "@cocalc/util/misc";
16
import emojis from "markdown-it-emoji/lib/data/full.json";
17
import lite from "markdown-it-emoji/lib/data/light.json";
18
import { ReactEditor } from "../slate-react";
19
20
const MAX_MATCHES = 200;
21
const EMOJIS_ALL: Item[] = [];
22
const EMOJIS_LITE: Item[] = [];
23
function init() {
24
for (const value in emojis) {
25
EMOJIS_ALL.push({ label: `${emojis[value]}\t ${value}`, value });
26
}
27
EMOJIS_ALL.sort(field_cmp("value"));
28
29
for (const value in lite) {
30
EMOJIS_LITE.push({ label: `${lite[value]}\t ${value}`, value });
31
}
32
EMOJIS_LITE.sort(field_cmp("value"));
33
EMOJIS_LITE.push({
34
label: "(type to search thousands of emojis)",
35
value: "",
36
});
37
}
38
39
interface Options {
40
editor: ReactEditor;
41
insertEmoji: (editor: Editor, content: string, markup: string) => void;
42
}
43
44
interface EmojisControl {
45
onChange: () => void;
46
onKeyDown: (event) => void;
47
Emojis: React.JSX.Element | undefined;
48
}
49
50
export const useEmojis: (Options) => EmojisControl = ({
51
editor,
52
insertEmoji,
53
}) => {
54
if (EMOJIS_ALL.length == 0) {
55
init();
56
}
57
const [target, setTarget] = useState<Range | undefined>();
58
const [search, setSearch] = useState("");
59
const isMountedRef = useIsMountedRef();
60
61
const items: Item[] = useMemo(() => {
62
if (!search) {
63
// just show most popular
64
return EMOJIS_LITE;
65
}
66
// actual search: show MAX_MATCHES matches
67
const v: Item[] = [];
68
for (const x of EMOJIS_ALL) {
69
if (x.value.includes(search)) {
70
v.push(x);
71
if (v.length > MAX_MATCHES) {
72
v.push({ label: "(type more to search emojis)", value: "" });
73
return v;
74
}
75
}
76
}
77
return v;
78
}, [search]);
79
80
const onKeyDown = useCallback(
81
(event) => {
82
if (target == null) return;
83
switch (event.key) {
84
case "ArrowDown":
85
case "ArrowUp":
86
case "Tab":
87
case "Enter":
88
event.preventDefault();
89
break;
90
case "Escape":
91
event.preventDefault();
92
setTarget(undefined);
93
break;
94
}
95
},
96
[target],
97
);
98
99
// we debounce this onChange, since it is VERY expensive and can make typing feel
100
// very laggy on a large document!
101
// Also, we only show the emoji dialog on :[something] rather than just :, since
102
// it is incredibly annoying and common to do the following: something here. See
103
// what I just did? For the @ mentions, there's no common use in english of @[space].
104
const onChange = useCallback(
105
debounce(() => {
106
try {
107
if (!isMountedRef.current) return;
108
const { selection } = editor;
109
if (!selection || !Range.isCollapsed(selection)) return;
110
const { focus } = selection;
111
let current;
112
try {
113
[current] = Editor.node(editor, focus);
114
} catch (_err) {
115
// I think due to debounce, somehow this Editor.node above is
116
// often invalid while user is typing.
117
return;
118
}
119
if (!Text.isText(current)) return;
120
121
const charBeforeCursor = current.text[focus.offset - 1];
122
let afterMatch, beforeMatch, beforeRange, search;
123
if (charBeforeCursor == ":") {
124
return;
125
}
126
const wordBefore = Editor.before(editor, focus, { unit: "word" });
127
const before = wordBefore && Editor.before(editor, wordBefore);
128
beforeRange = before && Editor.range(editor, before, focus);
129
const beforeText = beforeRange && Editor.string(editor, beforeRange);
130
if (beforeText == ":") {
131
return;
132
}
133
beforeMatch = beforeText && beforeText.match(/^:(\w*)$/);
134
search = beforeMatch?.[1];
135
const after = Editor.after(editor, focus);
136
const afterRange = Editor.range(editor, focus, after);
137
const afterText = Editor.string(editor, afterRange);
138
afterMatch = afterText.match(/^(\s|$)/);
139
if (charBeforeCursor == ":" || (beforeMatch && afterMatch)) {
140
search = search.toLowerCase().trim();
141
setSearch(search);
142
setTarget(beforeRange);
143
return;
144
}
145
146
setTarget(undefined);
147
} catch (err) {
148
console.log("WARNING -- slate.emojis", err);
149
}
150
}, 250),
151
[editor],
152
);
153
154
const renderEmojis = useCallback(() => {
155
if (target == null) return;
156
let domRange;
157
try {
158
domRange = ReactEditor.toDOMRange(editor, target);
159
} catch (_err) {
160
// target gets set by the onChange handler above, so editor could
161
// have changed by the time we call toDOMRange here, making
162
// the target no longer meaningful. Thus this try/catch is
163
// completely reasonable (alternatively, when we deduce the target,
164
// we also immediately set the domRange in a ref).
165
return;
166
}
167
168
const onSelect = (markup) => {
169
Transforms.select(editor, target);
170
insertEmoji(editor, emojis[markup] ?? "?", markup);
171
setTarget(undefined);
172
ReactEditor.focus(editor);
173
// Move the cursor forward 2 spaces:
174
Transforms.move(editor, { distance: 2, unit: "character" });
175
};
176
177
const rect = domRange.getBoundingClientRect();
178
return React.createElement(Complete, {
179
items,
180
onSelect,
181
onCancel: () => setTarget(undefined),
182
position: {
183
top: rect.bottom,
184
left: rect.left + rect.width,
185
},
186
});
187
}, [search, target]);
188
189
return {
190
onChange,
191
onKeyDown,
192
Emojis: renderEmojis(),
193
};
194
};
195
196