mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
improvement(home): position @ mention popup at caret and fix icon consistency (#3831)
* improvement(home): position @ mention popup at caret and fix icon consistency * fix(home): pin mirror div to document origin and guard button anchor * chore(auth): restore hybrid.ts to staging
This commit is contained in:
@@ -52,7 +52,7 @@ function WorkflowDropdownItem({ item }: DropdownItemRenderProps) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className='mr-[0px] h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
|
||||
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
borderColor: `${color}60`,
|
||||
@@ -72,7 +72,16 @@ function FileDropdownItem({ item }: DropdownItemRenderProps) {
|
||||
const DocIcon = getDocumentIcon('', item.name)
|
||||
return (
|
||||
<>
|
||||
<DocIcon className='mr-2 h-[14px] w-[14px] text-[var(--text-icon)]' />
|
||||
<DocIcon className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<span className='truncate'>{item.name}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function IconDropdownItem({ item, icon: Icon }: DropdownItemRenderProps & { icon: ElementType }) {
|
||||
return (
|
||||
<>
|
||||
<Icon className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<span className='truncate'>{item.name}</span>
|
||||
</>
|
||||
)
|
||||
@@ -104,7 +113,7 @@ export const RESOURCE_REGISTRY: Record<MothershipResourceType, ResourceTypeConfi
|
||||
renderTabIcon: (_resource, className) => (
|
||||
<TableIcon className={cn(className, 'text-[var(--text-icon)]')} />
|
||||
),
|
||||
renderDropdownItem: (props) => <DefaultDropdownItem {...props} />,
|
||||
renderDropdownItem: (props) => <IconDropdownItem {...props} icon={TableIcon} />,
|
||||
},
|
||||
file: {
|
||||
type: 'file',
|
||||
@@ -123,7 +132,7 @@ export const RESOURCE_REGISTRY: Record<MothershipResourceType, ResourceTypeConfi
|
||||
renderTabIcon: (_resource, className) => (
|
||||
<Database className={cn(className, 'text-[var(--text-icon)]')} />
|
||||
),
|
||||
renderDropdownItem: (props) => <DefaultDropdownItem {...props} />,
|
||||
renderDropdownItem: (props) => <IconDropdownItem {...props} icon={Database} />,
|
||||
},
|
||||
} as const
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<HTMLButtonElement>(null)
|
||||
const searchRef = useRef<HTMLInputElement>(null)
|
||||
const contentRef = useRef<HTMLDivElement>(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<HTMLInputElement>) => {
|
||||
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<HTMLElement>('[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<HTMLDivElement>) => {
|
||||
if (e.key === 'ArrowUp') {
|
||||
const items = Array.from(
|
||||
contentRef.current?.querySelectorAll<HTMLElement>('[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 (
|
||||
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
className='flex h-[28px] w-[28px] cursor-pointer items-center justify-center rounded-full border border-[#F0F0F0] transition-colors hover:bg-[#F7F7F7] dark:border-[#3d3d3d] dark:hover:bg-[#303030]'
|
||||
title='Add attachments or resources'
|
||||
<>
|
||||
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: anchorPos?.left ?? 0,
|
||||
top: anchorPos?.top ?? 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
ref={contentRef}
|
||||
align='start'
|
||||
side='top'
|
||||
sideOffset={8}
|
||||
className='flex w-[240px] flex-col overflow-hidden'
|
||||
onCloseAutoFocus={handleCloseAutoFocus}
|
||||
onKeyDown={handleContentKeyDown}
|
||||
>
|
||||
<Plus className='h-[16px] w-[16px] text-[var(--text-icon)]' />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align='start'
|
||||
side='top'
|
||||
sideOffset={8}
|
||||
className='flex w-[240px] flex-col overflow-hidden'
|
||||
onCloseAutoFocus={handleCloseAutoFocus}
|
||||
>
|
||||
<DropdownMenuSearchInput
|
||||
placeholder='Search resources...'
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value)
|
||||
setActiveIndex(0)
|
||||
}}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
/>
|
||||
<div className='min-h-0 flex-1 overflow-y-auto'>
|
||||
{filteredItems ? (
|
||||
filteredItems.length > 0 ? (
|
||||
filteredItems.map(({ type, item }, index) => {
|
||||
const config = getResourceConfig(type)
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={`${type}:${item.id}`}
|
||||
className={cn(index === activeIndex && 'bg-[var(--surface-active)]')}
|
||||
onMouseEnter={() => setActiveIndex(index)}
|
||||
onClick={() => {
|
||||
handleSelect({
|
||||
type,
|
||||
id: item.id,
|
||||
title: item.name,
|
||||
})
|
||||
}}
|
||||
>
|
||||
{config.renderDropdownItem({ item })}
|
||||
<span className='ml-auto pl-2 text-[11px] text-[var(--text-tertiary)]'>
|
||||
{config.label}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})
|
||||
<DropdownMenuSearchInput
|
||||
ref={searchRef}
|
||||
placeholder='Search resources...'
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
/>
|
||||
<div className='min-h-0 flex-1 overflow-y-auto'>
|
||||
{filteredItems ? (
|
||||
filteredItems.length > 0 ? (
|
||||
filteredItems.map(({ type, item }, index) => {
|
||||
const config = getResourceConfig(type)
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={`${type}:${item.id}`}
|
||||
onClick={() => {
|
||||
handleSelect({
|
||||
type,
|
||||
id: item.id,
|
||||
title: item.name,
|
||||
})
|
||||
}}
|
||||
>
|
||||
{config.renderDropdownItem({ item })}
|
||||
<span className='ml-auto pl-2 text-[11px] text-[var(--text-tertiary)]'>
|
||||
{config.label}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className='px-2 py-[5px] text-center font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
No results
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className='px-2 py-[5px] text-center font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
No results
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
onFileSelect()
|
||||
}}
|
||||
>
|
||||
<Paperclip className='h-[14px] w-[14px]' strokeWidth={2} />
|
||||
<span>Attachments</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Sim className='h-[14px] w-[14px]' fill='currentColor' />
|
||||
<span>Workspace</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
{availableResources.map(({ type, items }) => {
|
||||
if (items.length === 0) return null
|
||||
const config = getResourceConfig(type)
|
||||
const Icon = config.icon
|
||||
return (
|
||||
<DropdownMenuSub key={type}>
|
||||
<DropdownMenuSubTrigger>
|
||||
{type === 'workflow' ? (
|
||||
<div
|
||||
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
|
||||
style={{
|
||||
backgroundColor: '#808080',
|
||||
borderColor: '#80808060',
|
||||
backgroundClip: 'padding-box',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Icon className='h-[14px] w-[14px]' />
|
||||
)}
|
||||
<span>{config.label}</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
{items.map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
handleSelect({
|
||||
type,
|
||||
id: item.id,
|
||||
title: item.name,
|
||||
})
|
||||
}}
|
||||
>
|
||||
{config.renderDropdownItem({ item })}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
onFileSelect()
|
||||
}}
|
||||
>
|
||||
<Paperclip className='h-[14px] w-[14px]' strokeWidth={2} />
|
||||
<span>Attachments</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Sim className='h-[14px] w-[14px]' fill='currentColor' />
|
||||
<span>Workspace</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
{availableResources.map(({ type, items }) => {
|
||||
if (items.length === 0) return null
|
||||
const config = getResourceConfig(type)
|
||||
const Icon = config.icon
|
||||
return (
|
||||
<DropdownMenuSub key={type}>
|
||||
<DropdownMenuSubTrigger>
|
||||
{type === 'workflow' ? (
|
||||
<div
|
||||
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
|
||||
style={{
|
||||
backgroundColor: '#808080',
|
||||
borderColor: '#80808060',
|
||||
backgroundClip: 'padding-box',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Icon className='h-[14px] w-[14px]' />
|
||||
)}
|
||||
<span>{config.label}</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
{items.map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
handleSelect({
|
||||
type,
|
||||
id: item.id,
|
||||
title: item.name,
|
||||
})
|
||||
}}
|
||||
>
|
||||
{config.renderDropdownItem({ item })}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type='button'
|
||||
onClick={() => doOpen()}
|
||||
className='flex h-[28px] w-[28px] cursor-pointer items-center justify-center rounded-full border border-[#F0F0F0] transition-colors hover:bg-[#F7F7F7] dark:border-[#3d3d3d] dark:hover:bg-[#303030]'
|
||||
title='Add attachments or resources'
|
||||
>
|
||||
<Plus className='h-[16px] w-[16px] text-[var(--text-icon)]' />
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user