diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx index bf5c3029d3d..4b545dc298b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx @@ -52,7 +52,7 @@ function WorkflowDropdownItem({ item }: DropdownItemRenderProps) { return ( <>
- + + {item.name} + + ) +} + +function IconDropdownItem({ item, icon: Icon }: DropdownItemRenderProps & { icon: ElementType }) { + return ( + <> + {item.name} ) @@ -104,7 +113,7 @@ export const RESOURCE_REGISTRY: Record ( ), - renderDropdownItem: (props) => , + renderDropdownItem: (props) => , }, file: { type: 'file', @@ -123,7 +132,7 @@ export const RESOURCE_REGISTRY: Record ( ), - renderDropdownItem: (props) => , + renderDropdownItem: (props) => , }, } as const diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts index dc6fa0aab58..93c3d79fa39 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts @@ -34,7 +34,7 @@ export type WindowWithSpeech = Window & { } export interface PlusMenuHandle { - open: () => void + open: (anchor?: { left: number; top: number }) => void } export const TEXTAREA_BASE_CLASSES = cn( diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown.tsx index 1b7606b7822..54a0142fc81 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown.tsx @@ -1,6 +1,6 @@ 'use client' -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import React, { useCallback, useMemo, useRef, useState } from 'react' import { Paperclip } from 'lucide-react' import { DropdownMenu, @@ -13,7 +13,6 @@ import { DropdownMenuTrigger, } from '@/components/emcn' import { Plus, Sim } from '@/components/emcn/icons' -import { cn } from '@/lib/core/utils/cn' import type { useAvailableResources } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown' import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' import type { PlusMenuHandle } from '@/app/workspace/[workspaceId]/home/components/user-input/components/constants' @@ -37,24 +36,24 @@ export const PlusMenuDropdown = React.memo( ) { const [open, setOpen] = useState(false) const [search, setSearch] = useState('') - const [activeIndex, setActiveIndex] = useState(0) - const activeIndexRef = useRef(activeIndex) + const [anchorPos, setAnchorPos] = useState<{ left: number; top: number } | null>(null) + const buttonRef = useRef(null) + const searchRef = useRef(null) + const contentRef = useRef(null) - useEffect(() => { - activeIndexRef.current = activeIndex - }, [activeIndex]) + const doOpen = useCallback((anchor?: { left: number; top: number }) => { + if (anchor) { + setAnchorPos(anchor) + } else { + const rect = buttonRef.current?.getBoundingClientRect() + if (!rect) return + setAnchorPos({ left: rect.left, top: rect.top }) + } + setOpen(true) + setSearch('') + }, []) - React.useImperativeHandle( - ref, - () => ({ - open: () => { - setOpen(true) - setSearch('') - setActiveIndex(0) - }, - }), - [] - ) + React.useImperativeHandle(ref, () => ({ open: doOpen }), [doOpen]) const filteredItems = useMemo(() => { const q = search.toLowerCase().trim() @@ -69,7 +68,6 @@ export const PlusMenuDropdown = React.memo( onResourceSelect(resource) setOpen(false) setSearch('') - setActiveIndex(0) }, [onResourceSelect] ) @@ -79,32 +77,37 @@ export const PlusMenuDropdown = React.memo( const handleSearchKeyDown = useCallback( (e: React.KeyboardEvent) => { - const items = filteredItemsRef.current - if (!items) return if (e.key === 'ArrowDown') { e.preventDefault() - setActiveIndex((prev) => Math.min(prev + 1, items.length - 1)) - } else if (e.key === 'ArrowUp') { - e.preventDefault() - setActiveIndex((prev) => Math.max(prev - 1, 0)) + const firstItem = contentRef.current?.querySelector('[role="menuitem"]') + firstItem?.focus() } else if (e.key === 'Enter') { e.preventDefault() - const idx = activeIndexRef.current - if (items.length > 0 && items[idx]) { - const { type, item } = items[idx] - handleSelect({ type, id: item.id, title: item.name }) - } + const first = filteredItemsRef.current?.[0] + if (first) handleSelect({ type: first.type, id: first.item.id, title: first.item.name }) } }, [handleSelect] ) + const handleContentKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'ArrowUp') { + const items = Array.from( + contentRef.current?.querySelectorAll('[role="menuitem"]') ?? [] + ) + if (items[0] && items[0] === document.activeElement) { + e.preventDefault() + searchRef.current?.focus() + } + } + }, []) + const handleOpenChange = useCallback( (isOpen: boolean) => { setOpen(isOpen) if (!isOpen) { setSearch('') - setActiveIndex(0) + setAnchorPos(null) onClose() } }, @@ -126,126 +129,138 @@ export const PlusMenuDropdown = React.memo( ) return ( - - - - - - { - setSearch(e.target.value) - setActiveIndex(0) - }} - onKeyDown={handleSearchKeyDown} - /> -
- {filteredItems ? ( - filteredItems.length > 0 ? ( - filteredItems.map(({ type, item }, index) => { - const config = getResourceConfig(type) - return ( - setActiveIndex(index)} - onClick={() => { - handleSelect({ - type, - id: item.id, - title: item.name, - }) - }} - > - {config.renderDropdownItem({ item })} - - {config.label} - - - ) - }) + setSearch(e.target.value)} + onKeyDown={handleSearchKeyDown} + /> +
+ {filteredItems ? ( + filteredItems.length > 0 ? ( + filteredItems.map(({ type, item }, index) => { + const config = getResourceConfig(type) + return ( + { + handleSelect({ + type, + id: item.id, + title: item.name, + }) + }} + > + {config.renderDropdownItem({ item })} + + {config.label} + + + ) + }) + ) : ( +
+ No results +
+ ) ) : ( -
- No results -
- ) - ) : ( - <> - { - setOpen(false) - onFileSelect() - }} - > - - Attachments - - - - - Workspace - - - {availableResources.map(({ type, items }) => { - if (items.length === 0) return null - const config = getResourceConfig(type) - const Icon = config.icon - return ( - - - {type === 'workflow' ? ( -
- ) : ( - - )} - {config.label} - - - {items.map((item) => ( - { - handleSelect({ - type, - id: item.id, - title: item.name, - }) - }} - > - {config.renderDropdownItem({ item })} - - ))} - - - ) - })} - - - - )} -
- - + <> + { + setOpen(false) + onFileSelect() + }} + > + + Attachments + + + + + Workspace + + + {availableResources.map(({ type, items }) => { + if (items.length === 0) return null + const config = getResourceConfig(type) + const Icon = config.icon + return ( + + + {type === 'workflow' ? ( +
+ ) : ( + + )} + {config.label} + + + {items.map((item) => ( + { + handleSelect({ + type, + id: item.id, + title: item.name, + }) + }} + > + {config.renderDropdownItem({ item })} + + ))} + + + ) + })} + + + + )} +
+ + + + ) }) ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx index 3fb58aceb18..b830123d1a0 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx @@ -50,6 +50,50 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store' export type { FileAttachmentForApi } from '@/app/workspace/[workspaceId]/home/types' +function getCaretAnchor( + textarea: HTMLTextAreaElement, + caretPos: number +): { left: number; top: number } { + const textareaRect = textarea.getBoundingClientRect() + const style = window.getComputedStyle(textarea) + + const mirror = document.createElement('div') + mirror.style.position = 'absolute' + mirror.style.top = '0' + mirror.style.left = '0' + mirror.style.visibility = 'hidden' + mirror.style.whiteSpace = 'pre-wrap' + mirror.style.overflowWrap = 'break-word' + mirror.style.font = style.font + mirror.style.padding = style.padding + mirror.style.border = style.border + mirror.style.width = style.width + mirror.style.lineHeight = style.lineHeight + mirror.style.boxSizing = style.boxSizing + mirror.style.letterSpacing = style.letterSpacing + mirror.style.textTransform = style.textTransform + mirror.style.textIndent = style.textIndent + mirror.style.textAlign = style.textAlign + mirror.textContent = textarea.value.substring(0, caretPos) + + const marker = document.createElement('span') + marker.style.display = 'inline-block' + marker.style.width = '0px' + marker.style.padding = '0' + marker.style.border = '0' + mirror.appendChild(marker) + + document.body.appendChild(mirror) + const markerRect = marker.getBoundingClientRect() + const mirrorRect = mirror.getBoundingClientRect() + document.body.removeChild(mirror) + + return { + left: textareaRect.left + (markerRect.left - mirrorRect.left) - textarea.scrollLeft, + top: textareaRect.top + (markerRect.top - mirrorRect.top) - textarea.scrollTop, + } +} + interface UserInputProps { defaultValue?: string editValue?: string @@ -486,7 +530,8 @@ export function UserInput({ const adjusted = `${before}${after}` setValue(adjusted) atInsertPosRef.current = caret - 1 - plusMenuRef.current?.open() + const anchor = getCaretAnchor(e.target, caret - 1) + plusMenuRef.current?.open(anchor) restartRecognition(adjusted) return }