Skip to content

Commit 9655e8e

Browse files
authored
improvement(home): anchor @-mention popup at caret and right-size dropdown widths (#4393)
* improvement(home): anchor @-mention popup at caret and right-size dropdown widths * fix(home): align caret-anchor marker to text-top so mention popup clears glyph * fix(home): enable collision avoidance for @-mention popup so it fits in narrow chats
1 parent 5863276 commit 9655e8e

2 files changed

Lines changed: 53 additions & 11 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -245,13 +245,14 @@ export const PlusMenuDropdown = React.memo(
245245
align='start'
246246
side='top'
247247
sideOffset={8}
248-
avoidCollisions={!isMention}
248+
avoidCollisions
249+
collisionPadding={8}
249250
className={cn(
250251
'flex flex-col overflow-hidden',
251252
// Plus-click shows short fixed labels (Workflows, Tables, …) — let it size
252253
// to its content via the emcn DropdownMenuContent default max-w.
253254
// Mention mode renders resource names directly, so widen for breathing room.
254-
isMention && 'w-[300px] max-w-[calc(100vw-32px)]'
255+
isMention && 'max-w-[min(300px,calc(100vw-32px))]'
255256
)}
256257
onCloseAutoFocus={handleCloseAutoFocus}
257258
onOpenAutoFocus={handleOpenAutoFocus}
@@ -286,7 +287,7 @@ export const PlusMenuDropdown = React.memo(
286287
/>
287288
<span>Workflows</span>
288289
</DropdownMenuSubTrigger>
289-
<DropdownMenuSubContent className='w-[300px] max-w-[calc(100vw-32px)]'>
290+
<DropdownMenuSubContent className='max-w-[min(300px,calc(100vw-32px))]'>
290291
<WorkflowFolderTreeItems nodes={workflowTree} onSelect={handleSelect} />
291292
</DropdownMenuSubContent>
292293
</DropdownMenuSub>
@@ -303,7 +304,7 @@ export const PlusMenuDropdown = React.memo(
303304
<Icon className='h-[14px] w-[14px]' />
304305
<span>{config.label}</span>
305306
</DropdownMenuSubTrigger>
306-
<DropdownMenuSubContent className='w-[300px] max-w-[calc(100vw-32px)]'>
307+
<DropdownMenuSubContent className='max-w-[min(300px,calc(100vw-32px))]'>
307308
{items.map((item) => (
308309
<DropdownMenuItem
309310
key={item.id}

apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,51 @@ import type { ChatContext } from '@/stores/panel'
5858

5959
export type { FileAttachmentForApi } from '@/app/workspace/[workspaceId]/home/types'
6060

61+
function getCaretAnchor(
62+
textarea: HTMLTextAreaElement,
63+
caretPos: number
64+
): { left: number; top: number } {
65+
const textareaRect = textarea.getBoundingClientRect()
66+
const style = window.getComputedStyle(textarea)
67+
68+
const mirror = document.createElement('div')
69+
mirror.style.position = 'absolute'
70+
mirror.style.top = '0'
71+
mirror.style.left = '0'
72+
mirror.style.visibility = 'hidden'
73+
mirror.style.whiteSpace = 'pre-wrap'
74+
mirror.style.overflowWrap = 'break-word'
75+
mirror.style.font = style.font
76+
mirror.style.padding = style.padding
77+
mirror.style.border = style.border
78+
mirror.style.width = style.width
79+
mirror.style.lineHeight = style.lineHeight
80+
mirror.style.boxSizing = style.boxSizing
81+
mirror.style.letterSpacing = style.letterSpacing
82+
mirror.style.textTransform = style.textTransform
83+
mirror.style.textIndent = style.textIndent
84+
mirror.style.textAlign = style.textAlign
85+
mirror.textContent = textarea.value.substring(0, caretPos)
86+
87+
const marker = document.createElement('span')
88+
marker.style.display = 'inline-block'
89+
marker.style.width = '0px'
90+
marker.style.padding = '0'
91+
marker.style.border = '0'
92+
marker.style.verticalAlign = 'text-top'
93+
mirror.appendChild(marker)
94+
95+
document.body.appendChild(mirror)
96+
const markerRect = marker.getBoundingClientRect()
97+
const mirrorRect = mirror.getBoundingClientRect()
98+
document.body.removeChild(mirror)
99+
100+
return {
101+
left: textareaRect.left + (markerRect.left - mirrorRect.left) - textarea.scrollLeft,
102+
top: textareaRect.top + (markerRect.top - mirrorRect.top) - textarea.scrollTop,
103+
}
104+
}
105+
61106
interface UserInputProps {
62107
defaultValue?: string
63108
draftScopeKey?: string
@@ -304,7 +349,6 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
304349
const pendingCursorRef = useRef<number | null>(null)
305350
const mentionRangeRef = useRef<{ start: number; end: number } | null>(null)
306351
const [mentionQuery, setMentionQuery] = useState<string | null>(null)
307-
const containerRef = useRef<HTMLDivElement>(null)
308352

309353
useImperativeHandle(
310354
ref,
@@ -664,7 +708,7 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
664708
getActiveMentionAtRef.current = mentionMenu.getActiveMentionQueryAtPosition
665709

666710
const syncMentionState = useCallback(
667-
(_textarea: HTMLTextAreaElement, text: string, caret: number) => {
711+
(textarea: HTMLTextAreaElement, text: string, caret: number) => {
668712
const active = getActiveMentionAtRef.current(caret, text)
669713
// Treat any whitespace inside the query as a closer — typing a space
670714
// after `@foo` should leave the raw `@foo` text and dismiss the menu.
@@ -682,10 +726,8 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
682726
mentionRangeRef.current = { start: active.start, end: active.end }
683727
setMentionQuery(active.query)
684728
if (!wasActive) {
685-
// Anchor above the whole input box (not at the caret) so the menu can never
686-
// overlap the user's typing.
687-
const rect = containerRef.current?.getBoundingClientRect()
688-
const anchor = rect ? { left: rect.left, top: rect.top } : { left: 0, top: 0 }
729+
// Anchor at the caret so the menu floats above the user's cursor.
730+
const anchor = getCaretAnchor(textarea, active.start)
689731
plusMenuRef.current?.open(anchor, { mention: true })
690732
}
691733
},
@@ -834,7 +876,6 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
834876

835877
return (
836878
<div
837-
ref={containerRef}
838879
onClick={handleContainerClick}
839880
className={cn(
840881
'relative z-10 mx-auto w-full max-w-[42rem] cursor-text rounded-[20px] border border-[var(--border-1)] bg-[var(--white)] px-2.5 py-2 dark:bg-[var(--surface-4)]',

0 commit comments

Comments
 (0)