Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/frontend/editors/slate/slate-mentions/hook.ts
1698 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
/* Adapted from
7
https://github.com/ianstormtaylor/slate/blob/master/site/examples/mentions.tsx
8
One thing that makes this implementation more complicated is that if you just type
9
the @ symbol and nothing else, it immediately pops up the mentions dialog. In
10
the demo above, it does not, which is EXTREMELY disconcerting.
11
*/
12
13
import { Editor, Range, Text, Transforms } from "slate";
14
import { ReactEditor } from "../slate-react";
15
import React from "react";
16
import { useIsMountedRef } from "@cocalc/frontend/app-framework";
17
import { useCallback, useEffect, useMemo, useState } from "react";
18
import {
19
Complete,
20
Item,
21
} from "@cocalc/frontend/editors/markdown-input/complete";
22
import { debounce } from "lodash";
23
24
interface Options {
25
editor: ReactEditor;
26
insertMention: (Editor, string) => void;
27
matchingUsers: (search: string) => (string | React.JSX.Element)[];
28
isVisible?: boolean;
29
}
30
31
interface MentionsControl {
32
onChange: () => void;
33
onKeyDown: (event) => void;
34
Mentions: React.JSX.Element | undefined;
35
}
36
37
export const useMentions: (Options) => MentionsControl = ({
38
isVisible,
39
editor,
40
insertMention,
41
matchingUsers,
42
}) => {
43
const [target, setTarget] = useState<Range | undefined>();
44
const [search, setSearch] = useState("");
45
const isMountedRef = useIsMountedRef();
46
47
useEffect(() => {
48
if (!isVisible && target) {
49
setTarget(undefined);
50
}
51
}, [isVisible]);
52
53
const items: Item[] = useMemo(() => {
54
return matchingUsers(search.toLowerCase());
55
}, [search]);
56
57
const onKeyDown = useCallback(
58
(event) => {
59
if (target == null) return;
60
switch (event.key) {
61
case "ArrowDown":
62
case "ArrowUp":
63
case "ArrowLeft":
64
case "ArrowRight":
65
case "Tab":
66
case "Enter":
67
event.preventDefault();
68
break;
69
case "Escape":
70
event.preventDefault();
71
setTarget(undefined);
72
break;
73
}
74
},
75
[target],
76
);
77
78
// we debounce this onChange, since it is VERY expensive and can make typing feel
79
// very laggy on a large document!
80
const onChange = useCallback(
81
debounce(() => {
82
try {
83
if (!isMountedRef.current) return;
84
const { selection } = editor;
85
if (selection && Range.isCollapsed(selection)) {
86
const { focus } = selection;
87
let current;
88
try {
89
[current] = Editor.node(editor, focus);
90
} catch (_err) {
91
// I think due to debounce, somehow this Editor.node above is
92
// often invalid while user is typing.
93
return;
94
}
95
if (Text.isText(current)) {
96
const charBeforeCursor = current.text[focus.offset - 1];
97
// keep use of this consistent with before stuff in frontend/editors/markdown-input/component.tsx
98
const charBeforeBefore = current.text[focus.offset - 2]?.trim();
99
let afterMatch, beforeMatch, beforeRange, search;
100
if (charBeforeCursor == "@") {
101
beforeRange = {
102
focus: editor.selection.focus,
103
anchor: {
104
path: editor.selection.anchor.path,
105
offset: editor.selection.anchor.offset - 1,
106
},
107
};
108
search = "";
109
afterMatch = beforeMatch = null;
110
} else {
111
const wordBefore = Editor.before(editor, focus, { unit: "word" });
112
const before = wordBefore && Editor.before(editor, wordBefore);
113
beforeRange = before && Editor.range(editor, before, focus);
114
const beforeText =
115
beforeRange && Editor.string(editor, beforeRange);
116
beforeMatch = beforeText && beforeText.match(/^@(\w*)$/);
117
search = beforeMatch?.[1];
118
const after = Editor.after(editor, focus);
119
const afterRange = Editor.range(editor, focus, after);
120
const afterText = Editor.string(editor, afterRange);
121
afterMatch = afterText.match(/^(\s|$)/);
122
}
123
if (
124
(charBeforeCursor == "@" &&
125
(!charBeforeBefore ||
126
charBeforeBefore == "(" ||
127
charBeforeBefore == "[")) ||
128
(beforeMatch && afterMatch)
129
) {
130
setTarget(beforeRange);
131
setSearch(search);
132
return;
133
}
134
}
135
}
136
137
setTarget(undefined);
138
} catch (err) {
139
console.log("WARNING -- slate.mentions", err);
140
}
141
}, 250),
142
[editor],
143
);
144
145
const renderMentions = useCallback(() => {
146
if (target == null) return;
147
let domRange;
148
try {
149
domRange = ReactEditor.toDOMRange(editor, target);
150
} catch (_err) {
151
// target gets set by the onChange handler above, so editor could
152
// have changed by the time we call toDOMRange here, making
153
// the target no longer meaningful. Thus this try/catch is
154
// completely reasonable (alternatively, when we deduce the target,
155
// we also immediately set the domRange in a ref).
156
return;
157
}
158
159
const onSelect = (value) => {
160
Transforms.select(editor, target);
161
insertMention(editor, value);
162
setTarget(undefined);
163
ReactEditor.focus(editor);
164
// Move the cursor forward 2 spaces:
165
Transforms.move(editor, { distance: 2, unit: "character" });
166
};
167
168
const rect = domRange.getBoundingClientRect();
169
return React.createElement(Complete, {
170
items,
171
onSelect,
172
onCancel: () => setTarget(undefined),
173
position: {
174
top: rect.bottom,
175
left: rect.left + rect.width,
176
},
177
});
178
}, [search, target]);
179
180
return {
181
onChange,
182
onKeyDown,
183
Mentions: renderMentions(),
184
};
185
};
186
187