Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/frontend/editors/markdown-input/complete.tsx
1691 views
1
/*
2
* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.
3
* License: MS-RSL – see LICENSE.md for details
4
*/
5
6
/*
7
I started with a copy of jupyter/complete.tsx, and will rewrite it
8
to be much more generically usable here, then hopefully use this
9
for Jupyter, code editors, (etc.'s) complete. E.g., I already
10
rewrote this to use the Antd dropdown, which is more dynamic.
11
*/
12
13
import type { MenuProps } from "antd";
14
import { Dropdown } from "antd";
15
import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
16
import { CSS } from "@cocalc/frontend/app-framework";
17
import ReactDOM from "react-dom";
18
import type { MenuItems } from "@cocalc/frontend/components";
19
import AIAvatar from "@cocalc/frontend/components/ai-avatar";
20
import { strictMod } from "@cocalc/util/misc";
21
import { COLORS } from "@cocalc/util/theme";
22
23
export interface Item {
24
label?: ReactNode;
25
value: string;
26
search?: string; // useful for clients
27
is_llm?: boolean; // if true, then this is an LLM in a sub-menu
28
show_llm_main_menu?: boolean; // if true, then this LLM is also show in the main menu (not just the sub-menu)
29
}
30
interface Props0 {
31
items: Item[]; // we assume at least one item
32
onSelect: (value: string) => void;
33
onCancel: () => void;
34
}
35
36
interface Props1 extends Props0 {
37
offset: { left: number; top: number }; // offset relative to wherever you placed this in DOM
38
position?: undefined;
39
}
40
41
interface Props2 extends Props0 {
42
offset?: undefined;
43
position: { left: number; top: number }; // or absolute position (doesn't matter where you put this in DOM).
44
}
45
46
type Props = Props1 | Props2;
47
48
// WARNING: Complete closing when clicking outside the complete box
49
// is handled in cell-list on_click. This is ugly code (since not localized),
50
// but seems to work well for now. Could move.
51
export function Complete({
52
items,
53
onSelect,
54
onCancel,
55
offset,
56
position,
57
}: Props) {
58
const items_user = items.filter((item) => !(item.is_llm ?? false));
59
60
// All other LLMs that should not show up in the main menu
61
const items_llm = items.filter(
62
(item) =>
63
(item.is_llm ?? false) &&
64
// if search eliminates all users, we show all LLMs
65
(items_user.length === 0 || !item.show_llm_main_menu),
66
);
67
68
const haveLLMs = items_llm.length > 0;
69
// note: if onlyLLMs is true, we treat LLMs as if they're users and do not show a sub-menu
70
// this causes the sub-menu to "collapse" if there are no users left to show
71
const onlyLLMs = haveLLMs && items_user.length === 0;
72
73
// If we render a sub-menu, add LLMs that should should show up in the main menu
74
if (!onlyLLMs) {
75
for (const item of items) {
76
if (item.is_llm && item.show_llm_main_menu) {
77
items_user.unshift(item);
78
}
79
}
80
}
81
82
const [selectedUser, setSelectedUser] = useState<number>(0);
83
const [selectedLLM, setSelectedLLM] = useState<number>(0);
84
const [llm, setLLM] = useState<boolean>(false);
85
86
const llm_ref = useRef<boolean>(llm);
87
const selected_user_ref = useRef<number>(selectedUser);
88
const selected_llm_ref = useRef<number>(selectedLLM);
89
const selected_key_ref = useRef<string | undefined>(undefined);
90
91
useEffect(() => {
92
selected_user_ref.current = selectedUser;
93
}, [selectedUser]);
94
95
useEffect(() => {
96
selected_llm_ref.current = selectedLLM;
97
}, [selectedLLM]);
98
99
useEffect(() => {
100
llm_ref.current = llm || onlyLLMs;
101
}, [llm, onlyLLMs]);
102
103
useEffect(() => {
104
// if we show the LLM sub-menu and we scroll to it using the keyboard, we pop it open
105
// Hint: these can be equal, if there is one more virtual entry in selectedUser!
106
if (selectedUser === items_user.length) {
107
setLLM(true);
108
}
109
}, [selectedUser]);
110
111
const select = useCallback(
112
(e?) => {
113
const key = e?.key ?? selected_key_ref.current;
114
if (typeof key === "string" && key !== "sub_llm") {
115
onSelect(key);
116
}
117
if (key === "sub_llm") {
118
setLLM(!llm);
119
} else {
120
// best to just cancel.
121
onCancel();
122
}
123
},
124
[onSelect, onCancel],
125
);
126
127
const onKeyDown = useCallback(
128
(e) => {
129
const isLLM = llm_ref.current;
130
const n = (isLLM ? selected_llm_ref : selected_user_ref).current;
131
switch (e.keyCode) {
132
case 27: // escape key
133
onCancel();
134
break;
135
136
case 13: // enter key
137
select();
138
break;
139
140
case 38: // up arrow key
141
(isLLM ? setSelectedLLM : setSelectedUser)(n - 1);
142
// @ts-ignore
143
$(".ant-dropdown-menu-item-selected").scrollintoview();
144
break;
145
146
case 40: // down arrow
147
(isLLM ? setSelectedLLM : setSelectedUser)(n + 1);
148
// @ts-ignore
149
$(".ant-dropdown-menu-item-selected").scrollintoview();
150
break;
151
152
case 39: // right arrow key
153
if (haveLLMs) setLLM(true);
154
// @ts-ignore
155
$(".ant-dropdown-menu-item-selected").scrollintoview();
156
break;
157
158
case 37: // left arrow key
159
setLLM(false);
160
// @ts-ignore
161
$(".ant-dropdown-menu-item-selected").scrollintoview();
162
break;
163
}
164
},
165
[onCancel, onSelect],
166
);
167
168
useEffect(() => {
169
// for clicks, we only listen on the root of the app – otherwise clicks on
170
// e.g. the menu items and the sub-menu always trigger a close action
171
// (that popup menu is outside the root in the DOM)
172
const root = document.getElementById("cocalc-webapp-container");
173
document.addEventListener("keydown", onKeyDown);
174
root?.addEventListener("click", onCancel);
175
return () => {
176
document.removeEventListener("keydown", onKeyDown);
177
root?.removeEventListener("click", onCancel);
178
};
179
}, [onKeyDown, onCancel]);
180
181
selected_key_ref.current = (() => {
182
if (llm || onlyLLMs) {
183
const len: number = items_llm.length ?? 1;
184
const i = strictMod(selectedLLM, len);
185
return items_llm[i]?.value;
186
} else {
187
let len: number = items_user.length ?? 1;
188
if (!onlyLLMs && haveLLMs) {
189
len += 1;
190
}
191
const i = strictMod(selectedUser, len);
192
if (i < len) {
193
return items_user[i]?.value;
194
} else {
195
return "sub_llm";
196
}
197
}
198
})();
199
200
const style: CSS = { fontSize: "115%" } as const;
201
202
// we collapse to just showing the LLMs if the search ended up only showing LLMs
203
const menuItems: MenuItems = (onlyLLMs ? items_llm : items_user).map(
204
({ label, value }) => {
205
return {
206
key: value,
207
label: label ?? value,
208
style,
209
};
210
},
211
);
212
213
if (haveLLMs && !onlyLLMs) {
214
// we put this at the very end – the default LLM (there is always one) is at the start, then are the users, then this
215
menuItems.push({
216
key: "sub_llm",
217
label: (
218
<div style={{ ...style, display: "flex", alignItems: "center" }}>
219
<AIAvatar size={22} />{" "}
220
<span style={{ marginLeft: "5px" }}>More AI Models</span>
221
</div>
222
),
223
style,
224
children: items_llm.map(({ label, value }) => {
225
return {
226
key: value,
227
label: label ?? value,
228
style: { fontSize: "90%" }, // not as large as the normal user items
229
};
230
}),
231
});
232
}
233
234
if (menuItems.length == 0) {
235
menuItems.push({ key: "nothing", label: "No items found", disabled: true });
236
}
237
238
// NOTE: the AI LLM sub-menu is either opened by hovering (clicking closes immediately) or by right-arrow key
239
const menu: MenuProps = {
240
selectedKeys: [selected_key_ref.current],
241
onClick: (e) => {
242
if (e.key !== "sub_llm") {
243
select(e);
244
}
245
},
246
items: menuItems,
247
openKeys: llm ? ["sub_llm"] : [],
248
onOpenChange: (openKeys) => {
249
// this, and the right-left-arrow keys control opening the LLM sub-menu
250
setLLM(openKeys.includes("sub_llm"));
251
},
252
mode: "vertical",
253
subMenuCloseDelay: 3,
254
style: {
255
border: `1px solid ${COLORS.GRAY_L}`,
256
maxHeight: "45vh", // so can always position menu above/below current line not obscuring it.
257
overflow: "auto",
258
},
259
};
260
261
function renderDropdown(): React.JSX.Element {
262
return (
263
<Dropdown
264
menu={menu}
265
open
266
trigger={["click", "hover"]}
267
placement="top" // always on top, and paddingBottom makes the entire line visible
268
overlayStyle={{ paddingBottom: "1em" }}
269
>
270
<span />
271
</Dropdown>
272
);
273
}
274
275
if (offset != null) {
276
// Relative positioning of the popup (this is in the same React tree).
277
return (
278
<div style={{ position: "relative" }}>
279
<div style={{ ...offset, position: "absolute" }}>
280
{renderDropdown()}
281
</div>
282
</div>
283
);
284
} else if (position != null) {
285
// Absolute position of the popup (this uses a totally different React tree)
286
return (
287
<Portal>
288
<div style={{ ...STYLE, ...position }}>{renderDropdown()}</div>
289
</Portal>
290
);
291
} else {
292
throw Error("bug -- not possible");
293
}
294
}
295
296
const Portal = ({ children }) => {
297
return ReactDOM.createPortal(children, document.body);
298
};
299
300
const STYLE: CSS = {
301
top: "-9999px",
302
left: "-9999px",
303
position: "absolute",
304
zIndex: 1,
305
padding: "3px",
306
background: "white",
307
borderRadius: "4px",
308
boxShadow: "0 1px 5px rgba(0,0,0,.2)",
309
overflowY: "auto",
310
maxHeight: "50vh",
311
} as const;
312
313