import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Descendant, Editor } from "slate"; import { Editable, Slate } from "slate-react"; import { twMerge } from "tailwind-merge"; import { useAutosuggestions } from "../../hooks/base-copilot-textarea-implementation/use-autosuggestions"; import { useCopilotTextareaEditor } from "../../hooks/base-copilot-textarea-implementation/use-copilot-textarea-editor"; import { usePopulateCopilotTextareaRef } from "../../hooks/base-copilot-textarea-implementation/use-populate-copilot-textarea-ref"; import { getFullEditorTextWithNewlines, getTextAroundCollapsedCursor, } from "../../lib/get-text-around-cursor"; import { addAutocompletionsToEditor } from "../../lib/slatejs-edits/add-autocompletions"; import { clearAutocompletionsFromEditor } from "../../lib/slatejs-edits/clear-autocompletions"; import { replaceEditorText } from "../../lib/slatejs-edits/replace-text"; import { BaseAutosuggestionsConfig, defaultBaseAutosuggestionsConfig, } from "../../types/base"; import { AutosuggestionState } from "../../types/base/autosuggestion-state"; import { BaseCopilotTextareaProps } from "../../types/base/base-copilot-textarea-props"; import "./base-copilot-textarea.css"; import { HoveringToolbar } from "../hovering-toolbar/hovering-toolbar"; import { makeRenderElementFunction } from "./render-element"; import { makeRenderPlaceholderFunction } from "./render-placeholder"; import { useAddBrandingCss } from "./use-add-branding-css"; import { HoveringEditorProvider, useHoveringEditorContext, } from "../hovering-toolbar/hovering-editor-provider"; import { TrackerTextEditedSinceLastCursorMovement } from "./track-cursor-moved-since-last-text-change"; /** * Purpose: to be used as the `ref` type for `CopilotTextarea` and `BaseCopilotTextarea`. * * This interface extends `HTMLElement`, and is the subset of `HTMLTextAreaElement` that "actually matters". * It provides the core functionality that consumers of `HTMLTextAreaElement` need 99.9% of the time: * - `value`: the current value of the textarea * - `focus`: make the textarea focused * - `blur`: make the textarea unfocused */ export interface HTMLCopilotTextAreaElement extends HTMLElement { /** * The current value of the textarea. */ value: string; /** * focus on the textarea */ focus: () => void; /** * unfocus the textarea. * * Called `blur` for syntactic compatibility with `HTMLTextAreaElement`. */ blur: () => void; } /** * Not intended for direct use. Use CopilotTextarea instead. * * The `BaseCopilotTextarea` includes the basic UX component, * without the business logic / AI logic that makes the content useful and coherent. * * It is useful if you want to build your own backend, with fully custom business logic * for figuring out which contnet to fill in. */ export const BaseCopilotTextarea = React.forwardRef( ( props: BaseCopilotTextareaProps, ref: React.Ref, ) => { return ( ); }, ); /** * Not intended for direct use. Use `CopilotTextarea` instead. * * This is the private core of the `BaseCopilotTextarea` component. * For practical purposes the implementation is cleaner assuming containment in a `HoveringEditorProviderContext`. * * Therefore we separate the core logic into this component, * and wrap it in a `HoveringEditorProviderContext` in `BaseCopilotTextarea`. */ const BaseCopilotTextareaWithHoveringContext = React.forwardRef( ( props: BaseCopilotTextareaProps, ref: React.Ref, ) => { const autosuggestionsConfig: BaseAutosuggestionsConfig = { ...defaultBaseAutosuggestionsConfig, ...props.baseAutosuggestionsConfig, }; const valueOnInitialRender = useMemo(() => props.value ?? "", []); const [lastKnownFullEditorText, setLastKnownFullEditorText] = useState(valueOnInitialRender); const [cursorMovedSinceLastTextChange, setCursorMovedSinceLastTextChange] = useState(false); const [isUserInputActive, setIsUserInputActive] = useState(false); // // When the editor text changes, we want to reset the `textEditedSinceLastCursorMovement` state. // useEffect(() => { // setCursorMovedSinceLastTextChange(false); // }, [lastKnownFullEditorText]); const initialValue: Descendant[] = useMemo(() => { return [ { type: "paragraph", children: [{ text: valueOnInitialRender }], }, ]; }, [valueOnInitialRender]); const editor = useCopilotTextareaEditor(); const { isDisplayed: hoveringEditorIsDisplayed, setIsDisplayed: setHoveringEditorIsDisplayed, } = useHoveringEditorContext(); const insertText = useCallback( (autosuggestion: AutosuggestionState) => { Editor.insertText(editor, autosuggestion.text, { at: autosuggestion.point, }); }, [editor], ); const shouldDisableAutosuggestions = // textarea is manually disabled: autosuggestionsConfig.disabled || // hovering editor is displayed: hoveringEditorIsDisplayed || // the cursor has moved since the last text change AND we are configured to disable autosuggestions in this case: (cursorMovedSinceLastTextChange && autosuggestionsConfig.temporarilyDisableWhenMovingCursorWithoutChangingText) || // not user input and we want to disable non-trusted events (like text insertion from autocomplete plugins): (!isUserInputActive && autosuggestionsConfig.temporarilyDisableNotTrustedEvents); const { currentAutocompleteSuggestion, onChangeHandler: onChangeHandlerForAutocomplete, onKeyDownHandler: onKeyDownHandlerForAutocomplete, onTouchStartHandler: onTouchStartHandlerForAutocomplete, } = useAutosuggestions( autosuggestionsConfig.debounceTime, autosuggestionsConfig.shouldAcceptAutosuggestionOnKeyPress, autosuggestionsConfig.shouldAcceptAutosuggestionOnTouch, autosuggestionsConfig.apiConfig.autosuggestionsFunction, insertText, autosuggestionsConfig.disableWhenEmpty, shouldDisableAutosuggestions, ); const onKeyDownHandlerForHoveringEditor = useCallback( (event: React.KeyboardEvent) => { if ( autosuggestionsConfig.shouldToggleHoveringEditorOnKeyPress( event, props.shortcut ?? "k", ) ) { event.preventDefault(); setHoveringEditorIsDisplayed(!hoveringEditorIsDisplayed); } }, [ hoveringEditorIsDisplayed, setHoveringEditorIsDisplayed, autosuggestionsConfig.shouldToggleHoveringEditorOnKeyPress, ], ); // sync autosuggestions state with the editor useEffect(() => { clearAutocompletionsFromEditor(editor); if (currentAutocompleteSuggestion) { addAutocompletionsToEditor( editor, currentAutocompleteSuggestion.text, currentAutocompleteSuggestion.point, ); } }, [currentAutocompleteSuggestion]); const suggestionStyleAugmented: React.CSSProperties = useMemo(() => { return { fontStyle: "italic", color: "gray", ...props.suggestionsStyle, }; }, [props.suggestionsStyle]); const renderElementMemoized = useMemo(() => { return makeRenderElementFunction(suggestionStyleAugmented); }, [suggestionStyleAugmented]); const renderPlaceholderMemoized = useMemo(() => { // For some reason slateJS specifies a top value of 0, which makes for strange styling. We override this here. const placeholderStyleSlatejsOverrides: React.CSSProperties = { top: undefined, }; const placeholderStyleAugmented: React.CSSProperties = { ...placeholderStyleSlatejsOverrides, ...props.placeholderStyle, }; return makeRenderPlaceholderFunction(placeholderStyleAugmented); }, [props.placeholderStyle]); // update the editor text, but only when the value changes from outside the component useEffect(() => { if (props.value === lastKnownFullEditorText) { return; } setLastKnownFullEditorText(props.value ?? ""); replaceEditorText(editor, props.value ?? ""); }, [props.value]); // separate into TextareaHTMLAttributes and CopilotTextareaProps const { placeholderStyle, value, hoverMenuClassname, onValueChange, baseAutosuggestionsConfig: autosuggestionsConfigFromProps, className, onChange, onKeyDown, disableBranding, ...propsToForward } = props; useAddBrandingCss(suggestionStyleAugmented, disableBranding); usePopulateCopilotTextareaRef(editor, ref); const moddedClassName = (() => { const baseClassName = "copilot-textarea"; const brandingClass = disableBranding ? "no-branding" : "with-branding"; const defaultTailwindClassName = "bg-white overflow-y-auto resize-y"; const mergedClassName = twMerge( defaultTailwindClassName, className ?? "", ); return `${baseClassName} ${brandingClass} ${mergedClassName}`; })(); return ( { const newEditorState = getTextAroundCollapsedCursor(editor); const fullEditorText = newEditorState ? newEditorState.textBeforeCursor + newEditorState.textAfterCursor : getFullEditorTextWithNewlines(editor); // we don't double-parse the editor. When `newEditorState` is null, we didn't parse the editor yet. setLastKnownFullEditorText((prev) => { if (prev !== fullEditorText) { setCursorMovedSinceLastTextChange(false); } return fullEditorText; }); onChangeHandlerForAutocomplete(newEditorState); props.onValueChange?.(fullEditorText); props.onChange?.(makeSemiFakeReactTextAreaEvent(fullEditorText)); }} > { setIsUserInputActive(true); onKeyDownHandlerForHoveringEditor(event); // forward the event for internal use onKeyDownHandlerForAutocomplete(event); // forward the event for internal use props.onKeyDown?.(event); // forward the event for external use }} onTouchStart={(event) => { onTouchStartHandlerForAutocomplete(event); // forward the event for internal use }} data-testid="copilot-textarea-editable" className={moddedClassName} onBlur={(ev) => { // clear autocompletion on blur props.onBlur?.(ev); clearAutocompletionsFromEditor(editor); setIsUserInputActive(false); }} {...propsToForward} /> ); }, ); // Consumers of