Path: blob/master/src/packages/frontend/editors/markdown-input/complete.tsx
1691 views
/*1* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45/*6I started with a copy of jupyter/complete.tsx, and will rewrite it7to be much more generically usable here, then hopefully use this8for Jupyter, code editors, (etc.'s) complete. E.g., I already9rewrote this to use the Antd dropdown, which is more dynamic.10*/1112import type { MenuProps } from "antd";13import { Dropdown } from "antd";14import { ReactNode, useCallback, useEffect, useRef, useState } from "react";15import { CSS } from "@cocalc/frontend/app-framework";16import ReactDOM from "react-dom";17import type { MenuItems } from "@cocalc/frontend/components";18import AIAvatar from "@cocalc/frontend/components/ai-avatar";19import { strictMod } from "@cocalc/util/misc";20import { COLORS } from "@cocalc/util/theme";2122export interface Item {23label?: ReactNode;24value: string;25search?: string; // useful for clients26is_llm?: boolean; // if true, then this is an LLM in a sub-menu27show_llm_main_menu?: boolean; // if true, then this LLM is also show in the main menu (not just the sub-menu)28}29interface Props0 {30items: Item[]; // we assume at least one item31onSelect: (value: string) => void;32onCancel: () => void;33}3435interface Props1 extends Props0 {36offset: { left: number; top: number }; // offset relative to wherever you placed this in DOM37position?: undefined;38}3940interface Props2 extends Props0 {41offset?: undefined;42position: { left: number; top: number }; // or absolute position (doesn't matter where you put this in DOM).43}4445type Props = Props1 | Props2;4647// WARNING: Complete closing when clicking outside the complete box48// is handled in cell-list on_click. This is ugly code (since not localized),49// but seems to work well for now. Could move.50export function Complete({51items,52onSelect,53onCancel,54offset,55position,56}: Props) {57const items_user = items.filter((item) => !(item.is_llm ?? false));5859// All other LLMs that should not show up in the main menu60const items_llm = items.filter(61(item) =>62(item.is_llm ?? false) &&63// if search eliminates all users, we show all LLMs64(items_user.length === 0 || !item.show_llm_main_menu),65);6667const haveLLMs = items_llm.length > 0;68// note: if onlyLLMs is true, we treat LLMs as if they're users and do not show a sub-menu69// this causes the sub-menu to "collapse" if there are no users left to show70const onlyLLMs = haveLLMs && items_user.length === 0;7172// If we render a sub-menu, add LLMs that should should show up in the main menu73if (!onlyLLMs) {74for (const item of items) {75if (item.is_llm && item.show_llm_main_menu) {76items_user.unshift(item);77}78}79}8081const [selectedUser, setSelectedUser] = useState<number>(0);82const [selectedLLM, setSelectedLLM] = useState<number>(0);83const [llm, setLLM] = useState<boolean>(false);8485const llm_ref = useRef<boolean>(llm);86const selected_user_ref = useRef<number>(selectedUser);87const selected_llm_ref = useRef<number>(selectedLLM);88const selected_key_ref = useRef<string | undefined>(undefined);8990useEffect(() => {91selected_user_ref.current = selectedUser;92}, [selectedUser]);9394useEffect(() => {95selected_llm_ref.current = selectedLLM;96}, [selectedLLM]);9798useEffect(() => {99llm_ref.current = llm || onlyLLMs;100}, [llm, onlyLLMs]);101102useEffect(() => {103// if we show the LLM sub-menu and we scroll to it using the keyboard, we pop it open104// Hint: these can be equal, if there is one more virtual entry in selectedUser!105if (selectedUser === items_user.length) {106setLLM(true);107}108}, [selectedUser]);109110const select = useCallback(111(e?) => {112const key = e?.key ?? selected_key_ref.current;113if (typeof key === "string" && key !== "sub_llm") {114onSelect(key);115}116if (key === "sub_llm") {117setLLM(!llm);118} else {119// best to just cancel.120onCancel();121}122},123[onSelect, onCancel],124);125126const onKeyDown = useCallback(127(e) => {128const isLLM = llm_ref.current;129const n = (isLLM ? selected_llm_ref : selected_user_ref).current;130switch (e.keyCode) {131case 27: // escape key132onCancel();133break;134135case 13: // enter key136select();137break;138139case 38: // up arrow key140(isLLM ? setSelectedLLM : setSelectedUser)(n - 1);141// @ts-ignore142$(".ant-dropdown-menu-item-selected").scrollintoview();143break;144145case 40: // down arrow146(isLLM ? setSelectedLLM : setSelectedUser)(n + 1);147// @ts-ignore148$(".ant-dropdown-menu-item-selected").scrollintoview();149break;150151case 39: // right arrow key152if (haveLLMs) setLLM(true);153// @ts-ignore154$(".ant-dropdown-menu-item-selected").scrollintoview();155break;156157case 37: // left arrow key158setLLM(false);159// @ts-ignore160$(".ant-dropdown-menu-item-selected").scrollintoview();161break;162}163},164[onCancel, onSelect],165);166167useEffect(() => {168// for clicks, we only listen on the root of the app – otherwise clicks on169// e.g. the menu items and the sub-menu always trigger a close action170// (that popup menu is outside the root in the DOM)171const root = document.getElementById("cocalc-webapp-container");172document.addEventListener("keydown", onKeyDown);173root?.addEventListener("click", onCancel);174return () => {175document.removeEventListener("keydown", onKeyDown);176root?.removeEventListener("click", onCancel);177};178}, [onKeyDown, onCancel]);179180selected_key_ref.current = (() => {181if (llm || onlyLLMs) {182const len: number = items_llm.length ?? 1;183const i = strictMod(selectedLLM, len);184return items_llm[i]?.value;185} else {186let len: number = items_user.length ?? 1;187if (!onlyLLMs && haveLLMs) {188len += 1;189}190const i = strictMod(selectedUser, len);191if (i < len) {192return items_user[i]?.value;193} else {194return "sub_llm";195}196}197})();198199const style: CSS = { fontSize: "115%" } as const;200201// we collapse to just showing the LLMs if the search ended up only showing LLMs202const menuItems: MenuItems = (onlyLLMs ? items_llm : items_user).map(203({ label, value }) => {204return {205key: value,206label: label ?? value,207style,208};209},210);211212if (haveLLMs && !onlyLLMs) {213// we put this at the very end – the default LLM (there is always one) is at the start, then are the users, then this214menuItems.push({215key: "sub_llm",216label: (217<div style={{ ...style, display: "flex", alignItems: "center" }}>218<AIAvatar size={22} />{" "}219<span style={{ marginLeft: "5px" }}>More AI Models</span>220</div>221),222style,223children: items_llm.map(({ label, value }) => {224return {225key: value,226label: label ?? value,227style: { fontSize: "90%" }, // not as large as the normal user items228};229}),230});231}232233if (menuItems.length == 0) {234menuItems.push({ key: "nothing", label: "No items found", disabled: true });235}236237// NOTE: the AI LLM sub-menu is either opened by hovering (clicking closes immediately) or by right-arrow key238const menu: MenuProps = {239selectedKeys: [selected_key_ref.current],240onClick: (e) => {241if (e.key !== "sub_llm") {242select(e);243}244},245items: menuItems,246openKeys: llm ? ["sub_llm"] : [],247onOpenChange: (openKeys) => {248// this, and the right-left-arrow keys control opening the LLM sub-menu249setLLM(openKeys.includes("sub_llm"));250},251mode: "vertical",252subMenuCloseDelay: 3,253style: {254border: `1px solid ${COLORS.GRAY_L}`,255maxHeight: "45vh", // so can always position menu above/below current line not obscuring it.256overflow: "auto",257},258};259260function renderDropdown(): React.JSX.Element {261return (262<Dropdown263menu={menu}264open265trigger={["click", "hover"]}266placement="top" // always on top, and paddingBottom makes the entire line visible267overlayStyle={{ paddingBottom: "1em" }}268>269<span />270</Dropdown>271);272}273274if (offset != null) {275// Relative positioning of the popup (this is in the same React tree).276return (277<div style={{ position: "relative" }}>278<div style={{ ...offset, position: "absolute" }}>279{renderDropdown()}280</div>281</div>282);283} else if (position != null) {284// Absolute position of the popup (this uses a totally different React tree)285return (286<Portal>287<div style={{ ...STYLE, ...position }}>{renderDropdown()}</div>288</Portal>289);290} else {291throw Error("bug -- not possible");292}293}294295const Portal = ({ children }) => {296return ReactDOM.createPortal(children, document.body);297};298299const STYLE: CSS = {300top: "-9999px",301left: "-9999px",302position: "absolute",303zIndex: 1,304padding: "3px",305background: "white",306borderRadius: "4px",307boxShadow: "0 1px 5px rgba(0,0,0,.2)",308overflowY: "auto",309maxHeight: "50vh",310} as const;311312313