From 23e7a5d5d7fd215235c598203d28fab12f206542 Mon Sep 17 00:00:00 2001 From: duanfuxiang Date: Wed, 12 Mar 2025 21:38:31 +0800 Subject: [PATCH] update chat view, handl tool & new md component --- src/components/chat-view/Chat.tsx | 321 +++++++++++++----- src/components/chat-view/ReactMarkdown.tsx | 147 ++++++-- .../chat-input/LexicalContentEditable.tsx | 10 +- .../chat-input/PromptInputWithActions.tsx | 3 - .../template/CreateTemplatePopoverPlugin.tsx | 146 -------- .../plugins/template/TemplatePlugin.tsx | 182 ---------- 6 files changed, 360 insertions(+), 449 deletions(-) delete mode 100644 src/components/chat-view/chat-input/plugins/template/CreateTemplatePopoverPlugin.tsx delete mode 100644 src/components/chat-view/chat-input/plugins/template/TemplatePlugin.tsx diff --git a/src/components/chat-view/Chat.tsx b/src/components/chat-view/Chat.tsx index 7553ad4..4369624 100644 --- a/src/components/chat-view/Chat.tsx +++ b/src/components/chat-view/Chat.tsx @@ -1,3 +1,5 @@ +import * as path from 'path' + import { useMutation } from '@tanstack/react-query' import { CircleStop, History, Plus } from 'lucide-react' import { App, Notice } from 'obsidian' @@ -24,23 +26,31 @@ import { LLMBaseUrlNotSetException, LLMModelNotSetException, } from '../../core/llm/exception' +import { regexSearchFiles } from '../../core/services/ripgrep' import { useChatHistory } from '../../hooks/use-chat-history' +import { ApplyStatus, ToolArgs } from '../../types/apply' import { ChatMessage, ChatUserMessage } from '../../types/chat' import { MentionableBlock, MentionableBlockData, MentionableCurrentFile, } from '../../types/mentionable' -import { manualApplyChangesToFile } from '../../utils/apply' +import { ApplyEditToFile, SearchAndReplace } from '../../utils/apply' +import { listFilesAndFolders } from '../../utils/glob-utils' import { getMentionableKey, serializeMentionable, } from '../../utils/mentionable' import { readTFileContent } from '../../utils/obsidian' import { openSettingsModalWithError } from '../../utils/open-settings-modal' -import { PromptGenerator } from '../../utils/prompt-generator' +import { PromptGenerator, addLineNumbers } from '../../utils/prompt-generator' + +// Simple file reading function that returns a placeholder content for testing +const readFileContent = (filePath: string): string => { + // In a real implementation, this would use filePath to read the actual file + return `Content of file: ${filePath}`; +} -import AssistantMessageActions from './AssistantMessageActions' import PromptInputWithActions, { ChatUserInputRef } from './chat-input/PromptInputWithActions' import { editorStateToPlainText } from './chat-input/utils/editor-state-to-plain-text' import { ChatHistory } from './ChatHistory' @@ -54,6 +64,7 @@ import SimilaritySearchResults from './SimilaritySearchResults' const getNewInputMessage = (app: App): ChatUserMessage => { return { role: 'user', + applyStatus: ApplyStatus.Idle, content: null, promptContent: null, id: uuidv4(), @@ -239,19 +250,6 @@ const Chat = forwardRef((props, ref) => { }) const responseMessageId = uuidv4() - setChatMessages([ - ...newChatHistory, - { - role: 'assistant', - content: '', - reasoningContent: '', - id: responseMessageId, - metadata: { - usage: undefined, - model: undefined, - }, - }, - ]) try { const abortController = new AbortController() @@ -271,6 +269,7 @@ const Chat = forwardRef((props, ref) => { ...compiledMessages, { role: 'assistant', + applyStatus: ApplyStatus.Idle, content: '', reasoningContent: '', id: responseMessageId, @@ -284,6 +283,7 @@ const Chat = forwardRef((props, ref) => { chatModel, { model: chatModel.modelId, + temperature: 0.5, messages: requestMessages, stream: true, }, @@ -348,45 +348,214 @@ const Chat = forwardRef((props, ref) => { submitMutation.mutate({ newChatHistory, useVaultSearch }) } - const applyMutation = useMutation({ - mutationFn: async ({ - blockInfo, - }: { - blockInfo: { - content: string - filename?: string - startLine?: number - endLine?: number - } - }) => { - const activeFile = app.workspace.getActiveFile() - if (!activeFile) { - throw new Error( - 'No file is currently open to apply changes. Please open a file and try again.', - ) - } - const activeFileContent = await readTFileContent(activeFile, app.vault) + const applyMutation = useMutation< + { + type: string; + applyMsgId: string; + applyStatus: ApplyStatus; + returnMsg?: ChatUserMessage + }, + Error, + { applyMsgId: string, toolArgs: ToolArgs } + >({ + mutationFn: async ({ applyMsgId, toolArgs }) => { + try { + const activeFile = app.workspace.getActiveFile() + if (!activeFile) { + throw new Error( + 'No file is currently open to apply changes. Please open a file and try again.', + ) + } - const updatedFileContent = await manualApplyChangesToFile( - blockInfo.content, - activeFile, - activeFileContent, - blockInfo.startLine, - blockInfo.endLine - ) - if (!updatedFileContent) { - throw new Error('Failed to apply changes') - } + const activeFileContent = await readTFileContent(activeFile, app.vault) - await app.workspace.getLeaf(true).setViewState({ - type: APPLY_VIEW_TYPE, - active: true, - state: { - file: activeFile, - originalContent: activeFileContent, - newContent: updatedFileContent, - } satisfies ApplyViewState, - }) + if (toolArgs.type === 'write_to_file' || toolArgs.type === 'insert_content') { + const applyRes = await ApplyEditToFile( + activeFile, + activeFileContent, + toolArgs.content, + toolArgs.startLine, + toolArgs.endLine + ) + if (!applyRes) { + throw new Error('Failed to apply edit changes') + } + // 返回一个Promise,该Promise会在用户做出选择后解析 + return new Promise<{ type: string; applyMsgId: string; applyStatus: ApplyStatus; returnMsg?: ChatUserMessage }>((resolve) => { + app.workspace.getLeaf(true).setViewState({ + type: APPLY_VIEW_TYPE, + active: true, + state: { + file: activeFile, + originalContent: activeFileContent, + newContent: applyRes, + onClose: (applied: boolean) => { + const applyStatus = applied ? ApplyStatus.Applied : ApplyStatus.Rejected + const applyEditContent = applied ? 'Changes successfully applied' + : 'User rejected changes' + resolve({ + type: 'write_to_file', + applyMsgId, + applyStatus, + returnMsg: { + role: 'user', + applyStatus: ApplyStatus.Idle, + content: null, + promptContent: `[write_to_file for '${toolArgs.filepath}'] Result:\n${applyEditContent}\n`, + id: uuidv4(), + mentionables: [], + } + }); + } + } satisfies ApplyViewState, + }) + }) + } else if (toolArgs.type === 'search_and_replace') { + const fileContent = activeFile.path === toolArgs.filepath ? activeFileContent : readFileContent(toolArgs.filepath) + const applyRes = await SearchAndReplace( + activeFile, + fileContent, + toolArgs.operations + ) + // 返回一个Promise,该Promise会在用户做出选择后解析 + return new Promise<{ type: string; applyMsgId: string; applyStatus: ApplyStatus; returnMsg?: ChatUserMessage }>((resolve) => { + app.workspace.getLeaf(true).setViewState({ + type: APPLY_VIEW_TYPE, + active: true, + state: { + file: activeFile, + originalContent: activeFileContent, + newContent: applyRes, + onClose: (applied: boolean) => { + const applyStatus = applied ? ApplyStatus.Applied : ApplyStatus.Rejected + const applyEditContent = applied ? 'Changes successfully applied' + : 'User rejected changes' + resolve({ + type: 'search_and_replace', + applyMsgId, + applyStatus, + returnMsg: { + role: 'user', + applyStatus: ApplyStatus.Idle, + content: null, + promptContent: `[search_and_replace for '${toolArgs.filepath}'] Result:\n${applyEditContent}\n`, + id: uuidv4(), + mentionables: [], + } + }); + } + } satisfies ApplyViewState, + }) + }) + } else if (toolArgs.type === 'read_file') { + const fileContent = activeFile.path === toolArgs.filepath ? activeFileContent : readFileContent(toolArgs.filepath) + const formattedContent = `[read_file for '${toolArgs.filepath}'] Result:\n${addLineNumbers(fileContent)}\n`; + return { + type: 'read_file', + applyMsgId, + applyStatus: ApplyStatus.Applied, + returnMsg: { + role: 'user', + applyStatus: ApplyStatus.Idle, + content: null, + promptContent: formattedContent, + id: uuidv4(), + mentionables: [], + } + }; + } else if (toolArgs.type === 'list_files') { + const files = await listFilesAndFolders(app.vault, toolArgs.filepath) + const formattedContent = `[list_files for '${toolArgs.filepath}'] Result:\n${files.join('\n')}\n`; + return { + type: 'list_files', + applyMsgId, + applyStatus: ApplyStatus.Applied, + returnMsg: { + role: 'user', + applyStatus: ApplyStatus.Idle, + content: null, + promptContent: formattedContent, + id: uuidv4(), + mentionables: [], + } + } + } else if (toolArgs.type === 'regex_search_files') { + const baseVaultPath = app.vault.adapter.getBasePath() + const absolutePath = path.join(baseVaultPath, toolArgs.filepath) + console.log("absolutePath", absolutePath) + const results = await regexSearchFiles(absolutePath, toolArgs.regex) + console.log("results", results) + const formattedContent = `[regex_search_files for '${toolArgs.filepath}'] Result:\n${results}\n`; + return { + type: 'regex_search_files', + applyMsgId, + applyStatus: ApplyStatus.Applied, + returnMsg: { + role: 'user', + applyStatus: ApplyStatus.Idle, + content: null, + promptContent: formattedContent, + id: uuidv4(), + mentionables: [], + } + } + } else if (toolArgs.type === 'semantic_search_files') { + const scope_folders = toolArgs.filepath + && toolArgs.filepath !== '' + && toolArgs.filepath !== '.' + && toolArgs.filepath !== '/' + ? { files: [], folders: [toolArgs.filepath] } + : undefined + const results = await (await getRAGEngine()).processQuery({ + query: toolArgs.query, + scope: scope_folders, + }) + console.log("results", results) + let snippets = results.map(({ path, content, metadata }) => { + const contentWithLineNumbers = addLineNumbers(content, metadata.startLine) + return `\n${contentWithLineNumbers}\n` + }).join('\n\n') + if (snippets.length === 0) { + snippets = `No results found for '${toolArgs.query}'` + } + const formattedContent = `[semantic_search_files for '${toolArgs.filepath}'] Result:\n${snippets}\n`; + return { + type: 'semantic_search_files', + applyMsgId, + applyStatus: ApplyStatus.Applied, + returnMsg: { + role: 'user', + applyStatus: ApplyStatus.Idle, + content: null, + promptContent: formattedContent, + id: uuidv4(), + mentionables: [], + } + } + } + } catch (error) { + console.error('Failed to apply changes', error) + throw error + } + }, + onSuccess: (result) => { + if (result.applyMsgId || result.returnMsg) { + let newChatMessages = [...chatMessages]; + + if (result.applyMsgId) { + newChatMessages = newChatMessages.map((message) => + message.role === 'assistant' && message.id === result.applyMsgId ? { + ...message, + applyStatus: result.applyStatus + } : message, + ); + } + setChatMessages(newChatMessages); + + if (result.returnMsg) { + handleSubmit([...newChatMessages, result.returnMsg], false); + } + } }, onError: (error) => { if ( @@ -404,13 +573,8 @@ const Chat = forwardRef((props, ref) => { }) const handleApply = useCallback( - (blockInfo: { - content: string - filename?: string - startLine?: number - endLine?: number - }) => { - applyMutation.mutate({ blockInfo }) + (applyMsgId: string, toolArgs: ToolArgs) => { + applyMutation.mutate({ applyMsgId, toolArgs }) }, [applyMutation], ) @@ -420,7 +584,6 @@ const Chat = forwardRef((props, ref) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []) - // useEffect(() => { const updateConversationAsync = async () => { try { @@ -597,8 +760,10 @@ const Chat = forwardRef((props, ref) => { } {chatMessages.map((message, index) => message.role === 'user' ? ( -
+ message.content && +
registerChatUserInputRef(message.id, ref)} initialSerializedEditorState={message.content} onSubmit={(content, useVaultSearch) => { @@ -608,6 +773,7 @@ const Chat = forwardRef((props, ref) => { ...chatMessages.slice(0, index), { role: 'user', + applyStatus: ApplyStatus.Idle, content: content, promptContent: null, id: message.id, @@ -632,20 +798,24 @@ const Chat = forwardRef((props, ref) => { /> {message.similaritySearchResults && ( )}
) : ( -
- +
+ handleApply(message.id, toolArgs)} + applyStatus={message.applyStatus} > {message.content} - {message.content && } + {/* {message.content && } */}
), )} @@ -690,20 +860,19 @@ const Chat = forwardRef((props, ref) => { function ReactMarkdownItem({ handleApply, - isApplying, + applyStatus, + // applyMutation, children, }: { - handleApply: (blockInfo: { - content: string - filename?: string - startLine?: number - endLine?: number - }) => void - isApplying: boolean + handleApply: (toolArgs: ToolArgs) => void + applyStatus: ApplyStatus children: string }) { return ( - + {children} ) diff --git a/src/components/chat-view/ReactMarkdown.tsx b/src/components/chat-view/ReactMarkdown.tsx index 21a8c1b..aa103c9 100644 --- a/src/components/chat-view/ReactMarkdown.tsx +++ b/src/components/chat-view/ReactMarkdown.tsx @@ -1,67 +1,140 @@ import React, { useMemo } from 'react' import Markdown from 'react-markdown' +import { ApplyStatus, ToolArgs } from '../../types/apply' import { - InfioBlockAction, - ParsedInfioBlock, - parseinfioBlocks, + ParsedMsgBlock, + parseMsgBlocks, } from '../../utils/parse-infio-block' -import MarkdownActionBlock from './MarkdownActionBlock' -import MarkdownReferenceBlock from './MarkdownReferenceBlock' +import MarkdownEditFileBlock from './MarkdownEditFileBlock' +import MarkdownListFilesBlock from './MarkdownListFilesBlock' +import MarkdownReadFileBlock from './MarkdownReadFileBlock' import MarkdownReasoningBlock from './MarkdownReasoningBlock' +import MarkdownRegexSearchFilesBlock from './MarkdownRegexSearchFilesBlock' +import MarkdownSearchAndReplace from './MarkdownSearchAndReplace' +import MarkdownSemanticSearchFilesBlock from './MarkdownSemanticSearchFilesBlock' +import MarkdownWithIcons from './MarkdownWithIcon' function ReactMarkdown({ + applyStatus, onApply, - isApplying, children, }: { - onApply: (blockInfo: { - content: string - filename?: string - startLine?: number - endLine?: number - }) => void + applyStatus: ApplyStatus + onApply: (toolArgs: ToolArgs) => void children: string - isApplying: boolean }) { - const blocks: ParsedInfioBlock[] = useMemo( - () => parseinfioBlocks(children), + const blocks: ParsedMsgBlock[] = useMemo( + () => parseMsgBlocks(children), [children], ) return ( <> {blocks.map((block, index) => - block.type === 'string' ? ( - + block.type === 'thinking' ? ( + {block.content} ) : block.type === 'think' ? ( - - ) : block.startLine && block.endLine && block.filename && block.action === InfioBlockAction.Reference ? ( - - ) : ( - {block.content} - + + ) : block.type === 'insert_content' ? ( + + {block.content} + + ) : block.type === 'search_and_replace' ? ( + ({ + search: op.search, + replace: op.replace, + startLine: op.start_line, + endLine: op.end_line, + useRegex: op.use_regex, + ignoreCase: op.ignore_case, + regexFlags: op.regex_flags, + }))} + finish={block.finish} + /> + ) : block.type === 'read_file' ? ( + + ) : block.type === 'list_files' ? ( + + ) : block.type === 'regex_search_files' ? ( + + ) : block.type === 'semantic_search_files' ? ( + + ) : block.type === 'attempt_completion' ? ( + + ${block.result && block.result.trimStart()}`} /> + ) : block.type === 'ask_followup_question' ? ( + + ${block.question && block.question.trimStart()}`} /> + ) : ( + + {block.content} + ), )} diff --git a/src/components/chat-view/chat-input/LexicalContentEditable.tsx b/src/components/chat-view/chat-input/LexicalContentEditable.tsx index 25af938..2973d21 100644 --- a/src/components/chat-view/chat-input/LexicalContentEditable.tsx +++ b/src/components/chat-view/chat-input/LexicalContentEditable.tsx @@ -26,8 +26,8 @@ import OnEnterPlugin from './plugins/on-enter/OnEnterPlugin' import OnMutationPlugin, { NodeMutations, } from './plugins/on-mutation/OnMutationPlugin' -import CreateTemplatePopoverPlugin from './plugins/template/CreateTemplatePopoverPlugin' -import TemplatePlugin from './plugins/template/TemplatePlugin' +// import CreateTemplatePopoverPlugin from './plugins/template/CreateTemplatePopoverPlugin' +// import TemplatePlugin from './plugins/template/TemplatePlugin' export type LexicalContentEditableProps = { editorRef: RefObject @@ -141,13 +141,13 @@ export default function LexicalContentEditable({ - - {plugins?.templatePopover && ( + {/* */} + {/* {plugins?.templatePopover && ( - )} + )} */} ) } diff --git a/src/components/chat-view/chat-input/PromptInputWithActions.tsx b/src/components/chat-view/chat-input/PromptInputWithActions.tsx index cdb0624..725ac70 100644 --- a/src/components/chat-view/chat-input/PromptInputWithActions.tsx +++ b/src/components/chat-view/chat-input/PromptInputWithActions.tsx @@ -265,9 +265,6 @@ const PromptInputWithActions = forwardRef( handleSubmit({ useVaultSearch: true }) }, }, - templatePopover: { - anchorElement: containerRef.current, - }, }} /> diff --git a/src/components/chat-view/chat-input/plugins/template/CreateTemplatePopoverPlugin.tsx b/src/components/chat-view/chat-input/plugins/template/CreateTemplatePopoverPlugin.tsx deleted file mode 100644 index d370fd6..0000000 --- a/src/components/chat-view/chat-input/plugins/template/CreateTemplatePopoverPlugin.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { $generateJSONFromSelectedNodes } from '@lexical/clipboard' -import { BaseSerializedNode } from '@lexical/clipboard/clipboard' -import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' -import * as Dialog from '@radix-ui/react-dialog' -import { - $getSelection, - COMMAND_PRIORITY_LOW, - SELECTION_CHANGE_COMMAND, -} from 'lexical' -import { CSSProperties, useCallback, useEffect, useRef, useState } from 'react' - -import CreateTemplateDialogContent from '../../../CreateTemplateDialog' - -export default function CreateTemplatePopoverPlugin({ - anchorElement, - contentEditableElement, -}: { - anchorElement: HTMLElement | null - contentEditableElement: HTMLElement | null -}): JSX.Element | null { - const [editor] = useLexicalComposerContext() - - const [popoverStyle, setPopoverStyle] = useState(null) - const [isPopoverOpen, setIsPopoverOpen] = useState(false) - const [isDialogOpen, setIsDialogOpen] = useState(false) - const [selectedSerializedNodes, setSelectedSerializedNodes] = useState< - BaseSerializedNode[] | null - >(null) - - const popoverRef = useRef(null) - - const getSelectedSerializedNodes = useCallback((): - | BaseSerializedNode[] - | null => { - if (!editor) return null - let selectedNodes: BaseSerializedNode[] | null = null - editor.update(() => { - const selection = $getSelection() - if (!selection) return - selectedNodes = $generateJSONFromSelectedNodes(editor, selection).nodes - if (selectedNodes.length === 0) return null - }) - return selectedNodes - }, [editor]) - - const updatePopoverPosition = useCallback(() => { - if (!anchorElement || !contentEditableElement) return - const nativeSelection = document.getSelection() - const range = nativeSelection?.getRangeAt(0) - if (!range || range.collapsed) { - setIsPopoverOpen(false) - return - } - if (!contentEditableElement.contains(range.commonAncestorContainer)) { - setIsPopoverOpen(false) - return - } - const rects = Array.from(range.getClientRects()) - if (rects.length === 0) { - setIsPopoverOpen(false) - return - } - const anchorRect = anchorElement.getBoundingClientRect() - const idealLeft = rects[rects.length - 1].right - anchorRect.left - const paddingX = 8 - const paddingY = 4 - const minLeft = (popoverRef.current?.offsetWidth ?? 0) + paddingX - const finalLeft = Math.max(minLeft, idealLeft) - setPopoverStyle({ - top: rects[rects.length - 1].bottom - anchorRect.top + paddingY, - left: finalLeft, - transform: 'translate(-100%, 0)', - }) - setIsPopoverOpen(true) - }, [anchorElement, contentEditableElement]) - - useEffect(() => { - const removeSelectionChangeListener = editor.registerCommand( - SELECTION_CHANGE_COMMAND, - () => { - updatePopoverPosition() - return false - }, - COMMAND_PRIORITY_LOW, - ) - return () => { - removeSelectionChangeListener() - } - }, [editor, updatePopoverPosition]) - - useEffect(() => { - // Update popover position when the content is cleared - // (Selection change event doesn't fire in this case) - if (!isPopoverOpen) return - const removeTextContentChangeListener = editor.registerTextContentListener( - () => { - updatePopoverPosition() - }, - ) - return () => { - removeTextContentChangeListener() - } - }, [editor, isPopoverOpen, updatePopoverPosition]) - - useEffect(() => { - if (!contentEditableElement) return - const handleScroll = () => { - updatePopoverPosition() - } - contentEditableElement.addEventListener('scroll', handleScroll) - return () => { - contentEditableElement.removeEventListener('scroll', handleScroll) - } - }, [contentEditableElement, updatePopoverPosition]) - - return ( - { - if (open) { - setSelectedSerializedNodes(getSelectedSerializedNodes()) - } - setIsDialogOpen(open) - setIsPopoverOpen(false) - }} - > - - - - setIsDialogOpen(false)} - /> - - ) -} diff --git a/src/components/chat-view/chat-input/plugins/template/TemplatePlugin.tsx b/src/components/chat-view/chat-input/plugins/template/TemplatePlugin.tsx deleted file mode 100644 index 69b4596..0000000 --- a/src/components/chat-view/chat-input/plugins/template/TemplatePlugin.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' -import clsx from 'clsx' -import { - $parseSerializedNode, - COMMAND_PRIORITY_NORMAL, - TextNode, -} from 'lexical' -import { Trash2 } from 'lucide-react' -import { useCallback, useEffect, useMemo, useState } from 'react' -import { createPortal } from 'react-dom' - -import { useDatabase } from '../../../../../contexts/DatabaseContext' -import { SelectTemplate } from '../../../../../database/schema' -import { MenuOption } from '../shared/LexicalMenu' -import { - LexicalTypeaheadMenuPlugin, - useBasicTypeaheadTriggerMatch, -} from '../typeahead-menu/LexicalTypeaheadMenuPlugin' - -class TemplateTypeaheadOption extends MenuOption { - name: string - template: SelectTemplate - - constructor(name: string, template: SelectTemplate) { - super(name) - this.name = name - this.template = template - } -} - -function TemplateMenuItem({ - index, - isSelected, - onClick, - onDelete, - onMouseEnter, - option, -}: { - index: number - isSelected: boolean - onClick: () => void - onDelete: () => void - onMouseEnter: () => void - option: TemplateTypeaheadOption -}) { - return ( -
  • option.setRefElement(el)} - role="option" - aria-selected={isSelected} - id={`typeahead-item-${index}`} - onMouseEnter={onMouseEnter} - onClick={onClick} - > -
    -
    {option.name}
    -
    { - evt.stopPropagation() - evt.preventDefault() - onDelete() - }} - className="infio-chat-template-menu-item-delete" - > - -
    -
    -
  • - ) -} - -export default function TemplatePlugin() { - const [editor] = useLexicalComposerContext() - const { getTemplateManager } = useDatabase() - - const [queryString, setQueryString] = useState(null) - const [searchResults, setSearchResults] = useState([]) - - useEffect(() => { - if (queryString == null) return - getTemplateManager().then((templateManager) => - templateManager.searchTemplates(queryString).then(setSearchResults), - ) - }, [queryString, getTemplateManager]) - - const options = useMemo( - () => - searchResults.map( - (result) => new TemplateTypeaheadOption(result.name, result), - ), - [searchResults], - ) - - const checkForTriggerMatch = useBasicTypeaheadTriggerMatch('/', { - minLength: 0, - }) - - const onSelectOption = useCallback( - ( - selectedOption: TemplateTypeaheadOption, - nodeToRemove: TextNode | null, - closeMenu: () => void, - ) => { - editor.update(() => { - const parsedNodes = selectedOption.template.content.nodes.map((node) => - $parseSerializedNode(node), - ) - if (nodeToRemove) { - const parent = nodeToRemove.getParentOrThrow() - parent.splice(nodeToRemove.getIndexWithinParent(), 1, parsedNodes) - const lastNode = parsedNodes[parsedNodes.length - 1] - lastNode.selectEnd() - } - closeMenu() - }) - }, - [editor], - ) - - const handleDelete = useCallback( - async (option: TemplateTypeaheadOption) => { - await (await getTemplateManager()).deleteTemplate(option.template.id) - if (queryString !== null) { - const updatedResults = await ( - await getTemplateManager() - ).searchTemplates(queryString) - setSearchResults(updatedResults) - } - }, - [getTemplateManager, queryString], - ) - - return ( - - onQueryChange={setQueryString} - onSelectOption={onSelectOption} - triggerFn={checkForTriggerMatch} - options={options} - commandPriority={COMMAND_PRIORITY_NORMAL} - menuRenderFn={( - anchorElementRef, - { selectedIndex, selectOptionAndCleanUp, setHighlightedIndex }, - ) => - anchorElementRef.current && searchResults.length - ? createPortal( -
    -
      - {options.map((option, i: number) => ( - { - setHighlightedIndex(i) - selectOptionAndCleanUp(option) - }} - onDelete={() => { - handleDelete(option) - }} - onMouseEnter={() => { - setHighlightedIndex(i) - }} - key={option.key} - option={option} - /> - ))} -
    -
    , - anchorElementRef.current, - ) - : null - } - /> - ) -}