import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { Editor, Location, Transforms } from "slate"; import { ReactEditor, useSlate, useSlateSelection } from "slate-react"; import { getFullEditorTextWithNewlines, getTextAroundSelection, } from "../../lib/get-text-around-cursor"; import { EditingEditorState, InsertionEditorApiConfig, } from "../../types/base/autosuggestions-bare-function"; import { useHoveringEditorContext } from "./hovering-editor-provider"; import { Menu, Portal } from "./hovering-toolbar-components"; import { HoveringInsertionPromptBox } from "./text-insertion-prompt-box"; export interface HoveringToolbarProps { apiConfig: InsertionEditorApiConfig; contextCategories: string[]; hoverMenuClassname: string | undefined; } export const HoveringToolbar = (props: HoveringToolbarProps) => { const ref = useRef(null); const editor = useSlate(); const selection = useSlateSelection(); const { isDisplayed, setIsDisplayed } = useHoveringEditorContext(); // only render on client const [isClient, setIsClient] = useState(false); useEffect(() => { setIsClient(true); }, []); const isShown = isClient && isDisplayed && selection; useLayoutEffect(() => { const el = ref.current; const { selection } = editor; if (!el || !isShown) { return; } if (!selection) { el.removeAttribute("style"); return; } const domSelection = window.getSelection(); if (!domSelection || domSelection.rangeCount === 0) { return; } const domRange = domSelection.getRangeAt(0); const rect = domRange.getBoundingClientRect(); // We use window = (0,0,0,0) as a signal that the selection is not in the original copilot-textarea, // but inside the hovering window. // // in such case, we simply do nothing. if ( rect.top === 0 && rect.left === 0 && rect.width === 0 && rect.height === 0 ) { return; } const verticalOffsetFromCorner = 0; const horizontalOffsetFromCorner = 0; // position the toolbar below the selection let top = rect.bottom + window.scrollY + verticalOffsetFromCorner; // no space left at bottom, move up if ( rect.bottom + el.offsetHeight > window.innerHeight - verticalOffsetFromCorner ) { top = rect.top + window.scrollY - el.offsetHeight - verticalOffsetFromCorner; } // position the toolbar in the center of the selection let left = rect.left + window.scrollX - el.offsetWidth / 2 + rect.width / 2 + horizontalOffsetFromCorner; // no space left at left, move right if (left < horizontalOffsetFromCorner) { left = horizontalOffsetFromCorner; } // no space left at right, move left else if ( left + el.offsetWidth > window.innerWidth - horizontalOffsetFromCorner ) { left = window.innerWidth - el.offsetWidth - horizontalOffsetFromCorner; } el.style.opacity = "1"; el.style.position = "absolute"; el.style.top = `${top}px`; el.style.left = `${left}px`; }, [isShown]); useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (ref.current && !ref.current.contains(event.target as Node)) { setIsDisplayed(false); } }; document.addEventListener("mousedown", handleClickOutside); return () => { document.removeEventListener("mousedown", handleClickOutside); }; }, [ref, setIsDisplayed]); // Close the hovering editor on Escape and restore focus to the textarea. // This complements the Escape handler in HoveringInsertionPromptBoxCore's // onKeyDown. That handler covers Escape when the prompt input has focus; // this document-level listener covers Escape from anywhere else in the popup. // Both may fire when the input is focused (double setIsDisplayed(false) is // harmless). If nested dismissible UI is ever added inside this toolbar, // this listener will need stopPropagation guards to avoid stealing Escape. useEffect(() => { const handleEscapeKey = (event: KeyboardEvent) => { if (event.key === "Escape") { event.preventDefault(); setIsDisplayed(false); // Re-focus the Slate editor after closing try { ReactEditor.focus(editor); } catch { // Editor may not be mounted, ignore } } }; if (isDisplayed) { document.addEventListener("keydown", handleEscapeKey); } return () => { document.removeEventListener("keydown", handleEscapeKey); }; }, [isDisplayed, setIsDisplayed, editor]); if (!isShown) { return null; } return ( { // replace the selection with the inserted text Transforms.delete(editor, { at: selection }); Transforms.insertText(editor, insertedText, { at: selection, }); setIsDisplayed(false); }} contextCategories={props.contextCategories} /> ); }; function editorState(editor: Editor, selection: Location): EditingEditorState { const textAroundCursor = getTextAroundSelection(editor); if (textAroundCursor) { return textAroundCursor; } return { textBeforeCursor: getFullEditorTextWithNewlines(editor), textAfterCursor: "", selectedText: "", }; }