diff --git a/apps/client/src/features/editor/atoms/editor-atoms.ts b/apps/client/src/features/editor/atoms/editor-atoms.ts index d4f133f7..25d9332b 100644 --- a/apps/client/src/features/editor/atoms/editor-atoms.ts +++ b/apps/client/src/features/editor/atoms/editor-atoms.ts @@ -8,3 +8,5 @@ export const titleEditorAtom = atom(null); export const readOnlyEditorAtom = atom(null); export const yjsConnectionStatusAtom = atom(""); + +export const showAiMenuAtom = atom(false); diff --git a/apps/client/src/features/editor/components/ai-menu/ai-menu.module.css b/apps/client/src/features/editor/components/ai-menu/ai-menu.module.css new file mode 100644 index 00000000..17b90a22 --- /dev/null +++ b/apps/client/src/features/editor/components/ai-menu/ai-menu.module.css @@ -0,0 +1,14 @@ +.aiMenu { + display: flex; + flex-direction: column; + width: 100%; + min-height: 2.25rem; +} +.resultPreviewWrapper { + *:first-child { + margin-top: 0; + } + *:last-child { + margin-bottom: 0; + } +} diff --git a/apps/client/src/features/editor/components/ai-menu/ai-menu.tsx b/apps/client/src/features/editor/components/ai-menu/ai-menu.tsx new file mode 100644 index 00000000..ffc3f622 --- /dev/null +++ b/apps/client/src/features/editor/components/ai-menu/ai-menu.tsx @@ -0,0 +1,287 @@ +import { Editor } from "@tiptap/react"; +import { ActionIcon, TextInput, Tooltip } from "@mantine/core"; +import { useDebouncedCallback, useMediaQuery } from "@mantine/hooks"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { useAtom } from "jotai"; +import { IconSend } from "@tabler/icons-react"; +import { showAiMenuAtom } from "@/features/editor/atoms/editor-atoms"; +import { useAiGenerateStreamMutation } from "@/ee/ai/queries/ai-query"; +import { AiAction } from "@/ee/ai/types/ai.types"; +import { CommandItem, commandItems, CommandSet } from "./command-items"; +import { CommandSelector } from "./command-selector"; +import { ResultPreview } from "./result-preview"; +import classes from "./ai-menu.module.css"; +import { marked } from "marked"; + +interface EditorAiMenuProps { + editor: Editor | null; +} + +const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => { + const aiGenerateStreamMutation = useAiGenerateStreamMutation(); + const isSmBreakpoint = useMediaQuery("(max-width: 48em)"); + const [showAiMenu, setShowAiMenu] = useAtom(showAiMenuAtom); + const containerRef = useRef(null); + const inputRef = useRef(null); + const [prompt, setPrompt] = useState(""); + const [output, setOutput] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(-1); + const [activeCommandSet, setActiveCommandSet] = useState("main"); + const [lastAction, setLastAction] = useState(null); + const [menuPlacement, setMenuPlacement] = useState<{ + top: number; + left: number; + width: number; + }>({ + top: 0, + left: 0, + width: 0, + }); + const currentItems = useMemo(() => { + return commandItems[activeCommandSet].filter((item) => { + return item.name.toLowerCase().includes(prompt.toLowerCase()); + }); + }, [prompt, output, activeCommandSet]); + const updateMenuPlacement = useCallback(() => { + if (!editor || !showAiMenu) return; + + const { view } = editor; + const { to } = editor.state.selection; + const editorRect = view.dom.getBoundingClientRect(); + const cursorCoords = view.coordsAtPos(to); + const topOffset = 8; + const editorPadding = isSmBreakpoint ? 16 : 48; + + setMenuPlacement({ + top: cursorCoords.bottom + topOffset, + left: editorRect.left + editorPadding, + width: editorRect.width - editorPadding * 2, + }); + }, [editor, showAiMenu, isSmBreakpoint]); + const resetMenu = useCallback(() => { + setPrompt(""); + setOutput(""); + setActiveCommandSet("main"); + setLastAction(null); + aiGenerateStreamMutation.reset(); + }, [aiGenerateStreamMutation.reset]); + const debouncedUpdateMenuPlacement = useDebouncedCallback( + updateMenuPlacement, + 60, + ); + const handleGenerate = useCallback( + (item?: CommandItem) => { + if (!editor || isLoading) return; + + let command: CommandItem | null = item || null; + + if (!command) { + if (!prompt) return; + + command = { + id: "custom", + name: "Custom", + action: AiAction.CUSTOM, + prompt, + }; + } + + const { from, to } = editor.state.selection; + const content = editor.state.doc.textBetween(from, to); + + setIsLoading(true); + aiGenerateStreamMutation.mutate({ + action: command.action, + prompt: command.prompt, + content, + onChunk: (chunk) => { + setOutput((output) => output + chunk.content); + }, + onComplete: () => { + setIsLoading(false); + setActiveCommandSet("result"); + }, + onError: () => { + setIsLoading(false); + resetMenu(); + }, + }); + setLastAction(command); + }, + [ + editor, + prompt, + isLoading, + aiGenerateStreamMutation.mutateAsync, + resetMenu, + ], + ); + const handleCommand = useCallback( + (item?: CommandItem) => { + setPrompt(""); + + if (!item) { + return handleGenerate(); + } + if (item.id === "back") { + return setActiveCommandSet("main"); + } + if (item.id === "result-insert") { + const chain = editor.chain().focus(); + + if (lastAction.action === AiAction.CONTINUE_WRITING) { + chain.setTextSelection(editor.state.selection.to); + } + + chain.insertContent(marked.parse(output)).run(); + + return setShowAiMenu(false); + } + if (item.id === "result-discard") { + setOutput(""); + + return resetMenu(); + } + if (item.id === "result-try-again" && lastAction) { + return handleGenerate(lastAction); + } + if (item.subCommandSet) { + return setActiveCommandSet(item.subCommandSet); + } + + return handleGenerate(item); + }, + [editor, output, lastAction, handleGenerate, resetMenu], + ); + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + const totalItems = currentItems.length; + const cycleSize = totalItems + 1; + + if (event.key === "Escape") { + return setShowAiMenu(false); + } + + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + + return setSelectedIndex((selectedIndex) => { + const direction = event.key === "ArrowDown" ? 1 : -1; + const newIndex = selectedIndex + direction; + + if (newIndex < -1) return cycleSize - 1; + if (newIndex >= cycleSize) return 0; + + return newIndex; + }); + } + + if (event.key === "Enter") { + event.preventDefault(); + + return handleCommand(currentItems[selectedIndex]); + } + }, + [currentItems, selectedIndex], + ); + + useEffect(() => { + if (!editor) return; + + const handleClose = () => setShowAiMenu(false); + const observer = new ResizeObserver(() => { + debouncedUpdateMenuPlacement(); + }); + + updateMenuPlacement(); + editor.on("focus", handleClose); + editor.on("blur", handleClose); + window.addEventListener("resize", debouncedUpdateMenuPlacement); + window.addEventListener("scroll", debouncedUpdateMenuPlacement, true); + observer.observe(editor.view.dom); + + return () => { + editor.off("focus", handleClose); + editor.off("blur", handleClose); + window.removeEventListener("resize", debouncedUpdateMenuPlacement); + window.removeEventListener("scroll", debouncedUpdateMenuPlacement, true); + observer.disconnect(); + }; + }, [editor, updateMenuPlacement, debouncedUpdateMenuPlacement]); + + useEffect(() => { + if (showAiMenu) { + resetMenu(); + } + }, [showAiMenu, resetMenu]); + useEffect(() => { + // Focus input when menu opens or command set changes + requestAnimationFrame(() => { + inputRef.current?.focus(); + }); + }, [showAiMenu, isLoading, currentItems]); + useEffect(() => { + if (!currentItems.length) { + setSelectedIndex(-1); + } + setSelectedIndex(prompt || activeCommandSet !== "main" ? 0 : -1); + }, [prompt, activeCommandSet, currentItems]); + + if (!showAiMenu) return null; + + return createPortal( +
+
+ + + setPrompt(e.currentTarget.value)} + rightSection={ + + handleGenerate()} + > + + + + } + onKeyDown={handleKeyDown} + /> + +
+
, + document.body, + ); +}; + +export { EditorAiMenu }; diff --git a/apps/client/src/features/editor/components/ai-menu/command-items.ts b/apps/client/src/features/editor/components/ai-menu/command-items.ts new file mode 100644 index 00000000..f9ffd76f --- /dev/null +++ b/apps/client/src/features/editor/components/ai-menu/command-items.ts @@ -0,0 +1,158 @@ +import { AiAction } from "@/ee/ai/types/ai.types"; +import { + IconSparkles, + IconCheck, + IconArrowsMaximize, + IconArrowsMinimize, + IconWriting, + IconHelp, + IconList, + IconMoodSmile, + IconLanguage, + IconTrash, + IconRefresh, + IconChevronLeft, +} from "@tabler/icons-react"; + +interface CommandItem { + name: string; + id: string; + icon?: typeof IconSparkles; + action?: AiAction; + prompt?: string; + subCommandSet?: CommandSet; +} + +type CommandSet = "main" | "tone" | "translate" | "result"; + +const mainItems: CommandItem[] = [ + { + id: "improve-writing", + name: "Improve writing", + icon: IconSparkles, + action: AiAction.IMPROVE_WRITING, + }, + { + id: "fix-spelling-grammar", + name: "Fix spelling & grammar", + icon: IconCheck, + action: AiAction.FIX_SPELLING_GRAMMAR, + }, + { + id: "make-longer", + name: "Make longer", + icon: IconArrowsMaximize, + action: AiAction.MAKE_LONGER, + }, + { + id: "make-shorter", + name: "Make shorter", + icon: IconArrowsMinimize, + action: AiAction.MAKE_SHORTER, + }, + { + id: "continue-writing", + name: "Continue writing", + icon: IconWriting, + action: AiAction.CONTINUE_WRITING, + }, + { + id: "explain", + name: "Explain", + icon: IconHelp, + action: AiAction.CUSTOM, + prompt: "Explain this text", + }, + { + id: "summarize", + name: "Summarize", + icon: IconList, + action: AiAction.SUMMARIZE, + }, + { + id: "change-tone", + name: "Change tone...", + icon: IconMoodSmile, + subCommandSet: "tone", + }, + { + id: "translate", + name: "Translate...", + icon: IconLanguage, + subCommandSet: "translate", + }, +]; +const toneItems: CommandItem[] = [ + { + id: "back", + name: "Back", + icon: IconChevronLeft, + }, + { + id: "tone-professional", + name: "Professional", + icon: IconMoodSmile, + action: AiAction.CHANGE_TONE, + prompt: "Professional", + }, + { + id: "tone-casual", + name: "Casual", + icon: IconMoodSmile, + action: AiAction.CHANGE_TONE, + prompt: "Casual", + }, + { + id: "tone-friendly", + name: "Friendly", + icon: IconMoodSmile, + action: AiAction.CHANGE_TONE, + prompt: "Friendly", + }, +]; +const translateItems: CommandItem[] = [ + { + id: "back", + name: "Back", + icon: IconChevronLeft, + }, + { + id: "translate-english", + name: "English", + icon: IconLanguage, + action: AiAction.TRANSLATE, + prompt: "English", + }, + { + id: "translate-french", + name: "French", + icon: IconLanguage, + action: AiAction.TRANSLATE, + prompt: "French", + }, + { + id: "translate-german", + name: "German", + icon: IconLanguage, + action: AiAction.TRANSLATE, + prompt: "German", + }, +]; +const resultItems: CommandItem[] = [ + { id: "result-insert", name: "Insert", icon: IconCheck }, + { id: "result-discard", name: "Discard", icon: IconTrash }, + { + id: "result-try-again", + name: "Try again", + icon: IconRefresh, + }, +]; +const commandItems: Record = { + main: mainItems, + tone: toneItems, + translate: translateItems, + result: resultItems, +}; + +export type { CommandItem, CommandSet }; +export { commandItems }; diff --git a/apps/client/src/features/editor/components/ai-menu/command-selector.tsx b/apps/client/src/features/editor/components/ai-menu/command-selector.tsx new file mode 100644 index 00000000..baf7800c --- /dev/null +++ b/apps/client/src/features/editor/components/ai-menu/command-selector.tsx @@ -0,0 +1,64 @@ +import { Button, Menu, ScrollArea } from "@mantine/core"; +import { ReactNode } from "react"; +import { CommandItem } from "./command-items"; + +interface CommandSelectorProps { + selectedIndex: number; + + isLoading: boolean; + output: string; + currentItems: CommandItem[]; + children: ReactNode; + handleCommand(item: CommandItem): void; +} + +const CommandSelector = ({ + selectedIndex, + children, + isLoading, + output, + currentItems, + handleCommand, +}: CommandSelectorProps) => { + return ( + 0} + position="bottom-start" + offset={4} + width={300} + trapFocus={false} + > + {children} + + + + {currentItems.map((item, index) => { + const unselectedVariant = + item.id === "back" ? "subtle" : "default"; + + return ( + + ); + })} + + + + + ); +}; + +export { CommandSelector }; diff --git a/apps/client/src/features/editor/components/ai-menu/result-preview.tsx b/apps/client/src/features/editor/components/ai-menu/result-preview.tsx new file mode 100644 index 00000000..330dfaf0 --- /dev/null +++ b/apps/client/src/features/editor/components/ai-menu/result-preview.tsx @@ -0,0 +1,30 @@ +import { Loader, Paper, Text } from "@mantine/core"; +import { marked } from "marked"; +import { memo } from "react"; +import classes from "./ai-menu.module.css"; + +interface ResultPreviewProps { + output: string; + isLoading: boolean; +} +const ResultPreview = memo(({ output, isLoading }: ResultPreviewProps) => { + if (!output && !isLoading) return; + + const parsedOutput = `${marked.parse(output)}`; + + return ( + + + {parsedOutput && ( +
+ )} + {isLoading && } + + + ); +}); + +export { ResultPreview }; diff --git a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx index a6d143ff..d6a166de 100644 --- a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx +++ b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx @@ -9,10 +9,11 @@ import { IconStrikethrough, IconUnderline, IconMessage, + IconSparkles, } from "@tabler/icons-react"; import clsx from "clsx"; import classes from "./bubble-menu.module.css"; -import { ActionIcon, rem, Tooltip } from "@mantine/core"; +import { ActionIcon, Button, rem, Tooltip } from "@mantine/core"; import { ColorSelector } from "./color-selector"; import { NodeSelector } from "./node-selector"; import { TextAlignmentSelector } from "./text-alignment-selector"; @@ -25,6 +26,7 @@ import { v7 as uuid7 } from "uuid"; import { isCellSelection, isTextSelected } from "@docmost/editor-ext"; import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx"; import { useTranslation } from "react-i18next"; +import { showAiMenuAtom } from "@/features/editor/atoms/editor-atoms"; export interface BubbleMenuItem { name: string; @@ -39,14 +41,20 @@ type EditorBubbleMenuProps = Omit & { export const EditorBubbleMenu: FC = (props) => { const { t } = useTranslation(); + const [showAiMenu, setShowAiMenu] = useAtom(showAiMenuAtom); const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom); const [, setDraftCommentId] = useAtom(draftCommentIdAtom); const showCommentPopupRef = useRef(showCommentPopup); + const showAiMenuRef = useRef(showAiMenu); useEffect(() => { showCommentPopupRef.current = showCommentPopup; }, [showCommentPopup]); + useEffect(() => { + showAiMenuRef.current = showAiMenu; + }, [showAiMenu]); + const editorState = useEditorState({ editor: props.editor, selector: (ctx) => { @@ -123,6 +131,7 @@ export const EditorBubbleMenu: FC = (props) => { empty || isNodeSelection(selection) || isCellSelection(selection) || + showAiMenuRef.current || showCommentPopupRef?.current ) { return false; @@ -146,9 +155,26 @@ export const EditorBubbleMenu: FC = (props) => { const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false); const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false); + // Hide the bubble menu immediately when AI menu is shown + if (showAiMenu) return; + return ( - +
+ +