diff --git a/packages/mantine/src/components.tsx b/packages/mantine/src/components.tsx index 7d01c5c936..a3f8095bf3 100644 --- a/packages/mantine/src/components.tsx +++ b/packages/mantine/src/components.tsx @@ -82,7 +82,7 @@ export const components: Components = { Group: BadgeGroup, }, Form: { - Root: (props) =>
{props.children}
, + Root: (props) => <>{props.children}, TextInput: TextInput, }, Menu: { diff --git a/packages/mantine/src/form/TextInput.tsx b/packages/mantine/src/form/TextInput.tsx index b781073588..265ed819af 100644 --- a/packages/mantine/src/form/TextInput.tsx +++ b/packages/mantine/src/form/TextInput.tsx @@ -33,7 +33,7 @@ export const TextInput = forwardRef< size={"xs"} className={mergeCSSClasses( className || "", - variant === "large" ? "bn-mt-input-large" : "" + variant === "large" ? "bn-mt-input-large" : "", )} ref={ref} name={name} diff --git a/packages/mantine/src/style.css b/packages/mantine/src/style.css index e2a2273aa4..9f665e8f67 100644 --- a/packages/mantine/src/style.css +++ b/packages/mantine/src/style.css @@ -766,26 +766,73 @@ } /* Combobox styling */ -.bn-mantine .bn-combobox-input, +.bn-mantine .bn-combobox-input-wrapper, .bn-mantine .bn-combobox-items:not(:empty) { background-color: var(--bn-colors-menu-background); border: var(--bn-border); border-radius: var(--bn-border-radius-medium); box-shadow: var(--bn-shadow-medium); color: var(--bn-colors-menu-text); - gap: 4px; min-width: 145px; padding: 2px; } -.bn-mantine .bn-combobox-input .bn-combobox-icon, -.bn-mantine .bn-combobox-input .bn-combobox-right-section { +.bn-mantine .bn-combobox-input-wrapper, +.bn-mantine .bn-combobox-input, +.bn-mantine .bn-combobox-loader, +.bn-mantine .bn-combobox-left-section, +.bn-mantine .bn-combobox-right-section { align-items: center; display: flex; justify-content: center; } -.bn-mantine .bn-combobox-input .bn-combobox-error { +.bn-mantine .bn-combobox-input, +.bn-mantine .bn-combobox-loader { + flex: 1; + justify-content: flex-start; + width: 0; +} + +.bn-mantine .bn-combobox-input > div { + width: 100%; +} + +.bn-mantine .bn-combobox-input input { + padding: 0; +} + +.bn-mantine .bn-ai-loader { + align-items: center; + color: var(--bn-colors-side-menu); + display: flex; + font-family: var(--bn-font-family); + font-size: 14px; + gap: 4px; + height: 52px; + justify-content: flex-start; + width: 100%; +} + +.bn-mantine .bn-ai-loader-icon { + width: fit-content; +} + +.bn-mantine .bn-ai-icon, +.bn-mantine .bn-ai-stop, +.bn-mantine .bn-ai-error { + align-items: center; + color: var(--bn-colors-menu-text); + display: flex; + justify-content: center; + width: 28px; +} + +.bn-mantine .bn-ai-stop { + cursor: pointer; +} + +.bn-mantine .bn-ai-error { color: var(--bn-colors-highlights-red-background); } diff --git a/packages/react/src/editor/ComponentsContext.tsx b/packages/react/src/editor/ComponentsContext.tsx index 2eb161d828..80486df5b5 100644 --- a/packages/react/src/editor/ComponentsContext.tsx +++ b/packages/react/src/editor/ComponentsContext.tsx @@ -262,7 +262,7 @@ export type ComponentProps = { name: string; label?: string; variant?: "default" | "large"; - icon: ReactNode; + icon?: ReactNode; rightSection?: ReactNode; autoFocus?: boolean; placeholder?: string; diff --git a/packages/xl-ai/src/components/AIMenu/AIMenu.tsx b/packages/xl-ai/src/components/AIMenu/AIMenu.tsx index 3fc4db4782..cb6d6daf79 100644 --- a/packages/xl-ai/src/components/AIMenu/AIMenu.tsx +++ b/packages/xl-ai/src/components/AIMenu/AIMenu.tsx @@ -1,7 +1,11 @@ import { BlockNoteEditor } from "@blocknote/core"; import { useBlockNoteEditor, useComponentsContext } from "@blocknote/react"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { RiSparkling2Fill } from "react-icons/ri"; +import { + RiErrorWarningFill, + RiSparkling2Fill, + RiStopCircleFill, +} from "react-icons/ri"; import { useStore } from "zustand"; import { getAIExtension } from "../../AIExtension.js"; @@ -80,66 +84,55 @@ export const AIMenu = (props: AIMenuProps) => { }, [aiResponseStatus]); const placeholder = useMemo(() => { - if (aiResponseStatus === "thinking") { - return dict.ai_menu.status.thinking; - } else if (aiResponseStatus === "ai-writing") { - return dict.ai_menu.status.editing; - } else if (aiResponseStatus === "error") { + if (aiResponseStatus === "error") { return dict.ai_menu.status.error; } return dict.ai_menu.input_placeholder; }, [aiResponseStatus, dict]); - const rightSection = useMemo(() => { - if (aiResponseStatus === "thinking" || aiResponseStatus === "ai-writing") { - return ( - // TODO -
ai.abort()}> + return ( + setPrompt(e.target.value), + onSubmit: () => + props.onManualPromptSubmit?.(prompt) || + onManualPromptSubmitDefault(prompt), + placeholder, + }} + loader={ +
+ + {aiResponseStatus === "ai-writing" + ? dict.ai_menu.status.editing + : dict.ai_menu.status.thinking} +
- ); - } else if (aiResponseStatus === "error") { - return ( -
- {/* Taken from Google Material Icons */} - {/* https://fonts.google.com/icons?selected=Material+Symbols+Rounded:error:FILL@0;wght@400;GRAD@0;opsz@24&icon.query=error&icon.size=24&icon.color=%23e8eaed&icon.set=Material+Symbols&icon.style=Rounded&icon.platform=web */} - - - -
- ); - } - - return undefined; - }, [Components, aiResponseStatus, ai]); - - return ( - + leftSection={ +
} - rightSection={rightSection} + rightSection={ + aiResponseStatus === "thinking" || aiResponseStatus === "ai-writing" ? ( +
+ +
+ ) : aiResponseStatus === "error" ? ( +
ai.abort()}> + +
+ ) : undefined + } /> ); }; diff --git a/packages/xl-ai/src/components/AIMenu/PromptSuggestionMenu.tsx b/packages/xl-ai/src/components/AIMenu/PromptSuggestionMenu.tsx index b18e423a8a..9244d33731 100644 --- a/packages/xl-ai/src/components/AIMenu/PromptSuggestionMenu.tsx +++ b/packages/xl-ai/src/components/AIMenu/PromptSuggestionMenu.tsx @@ -1,5 +1,6 @@ import { filterSuggestionItems, mergeCSSClasses } from "@blocknote/core"; import { + ComponentProps, DefaultReactSuggestionItem, useComponentsContext, useSuggestionMenuKeyboardHandler, @@ -16,48 +17,56 @@ import { export type PromptSuggestionMenuProps = { items: DefaultReactSuggestionItem[]; - onManualPromptSubmit: (userPrompt: string) => void; - promptText?: string; - onPromptTextChange?: (userPrompt: string) => void; - icon?: ReactNode; + inputProps: Partial< + Omit< + ComponentProps["Generic"]["Form"]["TextInput"], + "name" | "label" | "variant" | "autoFocus" | "autoComplete" + > + >; + // This loader element was added as a prop to mimic Notion's UX for the AI + // menu. When the AI is generating, Notion puts a loading indicator right + // after the "Thinking" text. While it would be better to do this by just + // setting the `placeholder` and `rightSection` props of the loader, text + // input width can't be constrained to the size of its content, so we can't + // place the loading indicator right after. This prop therefore exists so + // that we don't have to show the text input while `isLoading` is `true`, and + // can instead render whatever we want. + loader?: ReactNode; + isLoading?: boolean; + leftSection?: ReactNode; rightSection?: ReactNode; - placeholder?: string; - disabled?: boolean; }; export const PromptSuggestionMenu = (props: PromptSuggestionMenuProps) => { // const dict = useAIDictionary(); const Components = useComponentsContext()!; - const { onManualPromptSubmit, promptText, onPromptTextChange } = props; + const { value, onKeyDown, onChange, onSubmit, ...rest } = props.inputProps; - // Only used internal state when `props.prompText` is undefined (i.e., uncontrolled mode) + // Only used internal state when `promptText` is undefined (i.e., uncontrolled mode) const [internalPromptText, setInternalPromptText] = useState(""); - const promptTextToUse = promptText || internalPromptText; + const promptTextToUse = value || internalPromptText; const handleEnter = useCallback( - async (event: KeyboardEvent) => { + async (event: KeyboardEvent) => { + onKeyDown?.(event); if (event.key === "Enter") { - // console.log("ENTER", currentEditingPrompt); - onManualPromptSubmit(promptTextToUse); + onSubmit?.(); } }, - [promptTextToUse, onManualPromptSubmit], + [onKeyDown, onSubmit], ); const handleChange = useCallback( (event: ChangeEvent) => { - const newValue = event.currentTarget.value; - if (onPromptTextChange) { - onPromptTextChange(newValue); - } + onChange?.(event); // Only update internal state if it's uncontrolled - if (promptText === undefined) { - setInternalPromptText(newValue); + if (value === undefined) { + setInternalPromptText(event.currentTarget.value); } }, - [onPromptTextChange, setInternalPromptText, promptText], + [onChange, value], ); const items: DefaultReactSuggestionItem[] = useMemo(() => { @@ -68,7 +77,7 @@ export const PromptSuggestionMenu = (props: PromptSuggestionMenuProps) => { useSuggestionMenuKeyboardHandler(items, (item) => item.onItemClick()); const handleKeyDown = useCallback( - (event: KeyboardEvent) => { + (event: KeyboardEvent) => { // TODO: handle backspace to close if (event.key === "Enter") { if (items.length > 0) { @@ -91,27 +100,38 @@ export const PromptSuggestionMenu = (props: PromptSuggestionMenuProps) => { return (
- - - +
+ {props.leftSection && ( +
{props.leftSection}
+ )} + {!props.isLoading || !props.loader ? ( +
+ + + +
+ ) : ( +
{props.loader}
+ )} + {props.rightSection && ( +
{props.rightSection}
+ )} +
+ id={"ai-suggestion-menu"} + > {items.map((item, i) => (