Skip to content

Commit 7d3c2a7

Browse files
committed
fix(messages-input): fix cursor alignment and auto-resize with overlay
1 parent 0ea0256 commit 7d3c2a7

File tree

1 file changed

+123
-71
lines changed
  • apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/messages-input

1 file changed

+123
-71
lines changed

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/messages-input/messages-input.tsx

Lines changed: 123 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
1+
import {
2+
useCallback,
3+
useEffect,
4+
useImperativeHandle,
5+
useLayoutEffect,
6+
useMemo,
7+
useRef,
8+
useState,
9+
} from 'react'
210
import { isEqual } from 'lodash'
311
import { ChevronDown, ChevronsUpDown, ChevronUp, Plus } from 'lucide-react'
412
import { Button, Popover, PopoverContent, PopoverItem, PopoverTrigger } from '@/components/emcn'
@@ -382,87 +390,133 @@ export function MessagesInput({
382390
textareaRefs.current[fieldId]?.focus()
383391
}, [])
384392

385-
const autoResizeTextarea = useCallback((fieldId: string) => {
393+
/**
394+
* Syncs overlay dimensions and scroll position with textarea.
395+
* CSS classes are already matched, so we only need to sync dynamic values.
396+
*/
397+
const syncOverlay = useCallback((fieldId: string) => {
386398
const textarea = textareaRefs.current[fieldId]
387-
if (!textarea) return
388399
const overlay = overlayRefs.current[fieldId]
400+
if (!textarea || !overlay) return
389401

390-
// If user has manually resized, respect their chosen height and only sync overlay.
391-
if (userResizedRef.current[fieldId]) {
392-
const currentHeight =
393-
textarea.offsetHeight || Number.parseFloat(textarea.style.height) || MIN_TEXTAREA_HEIGHT_PX
394-
const clampedHeight = Math.max(MIN_TEXTAREA_HEIGHT_PX, currentHeight)
395-
textarea.style.height = `${clampedHeight}px`
396-
if (overlay) {
397-
overlay.style.height = `${clampedHeight}px`
402+
// Sync width (height is set explicitly by autoResizeTextarea/resize handler)
403+
overlay.style.width = `${textarea.clientWidth}px`
404+
405+
// Sync scroll position
406+
overlay.scrollTop = textarea.scrollTop
407+
overlay.scrollLeft = textarea.scrollLeft
408+
}, [])
409+
410+
/**
411+
* Auto-resize textarea to fit content, capped at max height.
412+
* Also syncs overlay dimensions and scroll.
413+
*
414+
* IMPORTANT: Avoid aggressive DOM manipulation that disrupts cursor state.
415+
*/
416+
const autoResizeTextarea = useCallback(
417+
(fieldId: string) => {
418+
const textarea = textareaRefs.current[fieldId]
419+
const overlay = overlayRefs.current[fieldId]
420+
if (!textarea) return
421+
422+
// Reset manual resize flag if content is empty (re-enable auto-resize)
423+
if (!textarea.value.trim()) {
424+
userResizedRef.current[fieldId] = false
398425
}
399-
return
400-
}
401426

402-
textarea.style.height = 'auto'
403-
const naturalHeight = textarea.scrollHeight || MIN_TEXTAREA_HEIGHT_PX
404-
const nextHeight = Math.min(
405-
MAX_TEXTAREA_HEIGHT_PX,
406-
Math.max(MIN_TEXTAREA_HEIGHT_PX, naturalHeight)
407-
)
408-
textarea.style.height = `${nextHeight}px`
427+
// Skip auto-resize if user manually resized (but still sync overlay)
428+
if (userResizedRef.current[fieldId]) {
429+
if (overlay) {
430+
overlay.style.height = `${textarea.offsetHeight}px`
431+
}
432+
syncOverlay(fieldId)
433+
return
434+
}
409435

410-
if (overlay) {
411-
overlay.style.height = `${nextHeight}px`
412-
}
413-
}, [])
436+
// Get scrollHeight - represents full content height
437+
const scrollHeight = textarea.scrollHeight
414438

415-
const handleResizeStart = useCallback((fieldId: string, e: React.MouseEvent<HTMLDivElement>) => {
416-
e.preventDefault()
417-
e.stopPropagation()
439+
// Calculate target height (clamped between min and max)
440+
const height = Math.min(
441+
MAX_TEXTAREA_HEIGHT_PX,
442+
Math.max(MIN_TEXTAREA_HEIGHT_PX, scrollHeight)
443+
)
418444

419-
const textarea = textareaRefs.current[fieldId]
420-
if (!textarea) return
445+
// Set heights on both elements
446+
textarea.style.height = `${height}px`
447+
if (overlay) {
448+
overlay.style.height = `${height}px`
449+
}
421450

422-
const startHeight = textarea.offsetHeight || textarea.scrollHeight || MIN_TEXTAREA_HEIGHT_PX
451+
// Sync overlay dimensions and scroll
452+
syncOverlay(fieldId)
453+
},
454+
[syncOverlay]
455+
)
423456

424-
isResizingRef.current = true
425-
resizeStateRef.current = {
426-
fieldId,
427-
startY: e.clientY,
428-
startHeight,
429-
}
457+
const handleResizeStart = useCallback(
458+
(fieldId: string, e: React.MouseEvent<HTMLDivElement>) => {
459+
e.preventDefault()
460+
e.stopPropagation()
430461

431-
const handleMouseMove = (moveEvent: MouseEvent) => {
432-
if (!isResizingRef.current || !resizeStateRef.current) return
462+
const textarea = textareaRefs.current[fieldId]
463+
if (!textarea) return
433464

434-
const { fieldId: activeFieldId, startY, startHeight } = resizeStateRef.current
435-
const deltaY = moveEvent.clientY - startY
436-
const nextHeight = Math.max(MIN_TEXTAREA_HEIGHT_PX, startHeight + deltaY)
465+
const startHeight = textarea.offsetHeight || textarea.scrollHeight || MIN_TEXTAREA_HEIGHT_PX
437466

438-
const activeTextarea = textareaRefs.current[activeFieldId]
439-
if (activeTextarea) {
440-
activeTextarea.style.height = `${nextHeight}px`
467+
isResizingRef.current = true
468+
resizeStateRef.current = {
469+
fieldId,
470+
startY: e.clientY,
471+
startHeight,
441472
}
442473

443-
const overlay = overlayRefs.current[activeFieldId]
444-
if (overlay) {
445-
overlay.style.height = `${nextHeight}px`
446-
}
447-
}
474+
const handleMouseMove = (moveEvent: MouseEvent) => {
475+
if (!isResizingRef.current || !resizeStateRef.current) return
476+
477+
const { fieldId: activeFieldId, startY, startHeight } = resizeStateRef.current
478+
const deltaY = moveEvent.clientY - startY
479+
const nextHeight = Math.max(MIN_TEXTAREA_HEIGHT_PX, startHeight + deltaY)
448480

449-
const handleMouseUp = () => {
450-
if (resizeStateRef.current) {
451-
const { fieldId: activeFieldId } = resizeStateRef.current
452-
userResizedRef.current[activeFieldId] = true
481+
const activeTextarea = textareaRefs.current[activeFieldId]
482+
const overlay = overlayRefs.current[activeFieldId]
483+
484+
if (activeTextarea) {
485+
activeTextarea.style.height = `${nextHeight}px`
486+
}
487+
488+
if (overlay) {
489+
overlay.style.height = `${nextHeight}px`
490+
// Sync scroll position during drag
491+
if (activeTextarea) {
492+
overlay.scrollTop = activeTextarea.scrollTop
493+
overlay.scrollLeft = activeTextarea.scrollLeft
494+
}
495+
}
453496
}
454497

455-
isResizingRef.current = false
456-
resizeStateRef.current = null
457-
document.removeEventListener('mousemove', handleMouseMove)
458-
document.removeEventListener('mouseup', handleMouseUp)
459-
}
498+
const handleMouseUp = () => {
499+
if (resizeStateRef.current) {
500+
const { fieldId: activeFieldId } = resizeStateRef.current
501+
userResizedRef.current[activeFieldId] = true
502+
// Sync all styles after resize completes
503+
syncOverlay(activeFieldId)
504+
}
460505

461-
document.addEventListener('mousemove', handleMouseMove)
462-
document.addEventListener('mouseup', handleMouseUp)
463-
}, [])
506+
isResizingRef.current = false
507+
resizeStateRef.current = null
508+
document.removeEventListener('mousemove', handleMouseMove)
509+
document.removeEventListener('mouseup', handleMouseUp)
510+
}
464511

465-
useEffect(() => {
512+
document.addEventListener('mousemove', handleMouseMove)
513+
document.addEventListener('mouseup', handleMouseUp)
514+
},
515+
[syncOverlay]
516+
)
517+
518+
// Auto-resize textareas and sync overlays after content changes
519+
useLayoutEffect(() => {
466520
currentMessages.forEach((_, index) => {
467521
const fieldId = `message-${index}`
468522
autoResizeTextarea(fieldId)
@@ -621,19 +675,15 @@ export function MessagesInput({
621675
</div>
622676

623677
{/* Content Input with overlay for variable highlighting */}
624-
<div className='relative w-full'>
678+
<div className='relative w-full overflow-hidden'>
625679
<textarea
626680
ref={(el) => {
627681
textareaRefs.current[fieldId] = el
628682
}}
629-
className='allow-scroll box-border min-h-[80px] w-full resize-none whitespace-pre-wrap break-words border-none bg-transparent px-[8px] pt-[8px] font-[inherit] font-medium text-sm text-transparent leading-[inherit] caret-[var(--text-primary)] outline-none placeholder:text-[var(--text-muted)] focus:outline-none focus-visible:outline-none disabled:cursor-not-allowed'
630-
rows={3}
683+
className='relative z-[2] m-0 box-border h-auto min-h-[80px] w-full resize-none overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words border-none bg-transparent px-[8px] py-[8px] font-medium font-sans text-sm text-transparent leading-[1.5] caret-[var(--text-primary)] outline-none [-ms-overflow-style:none] [scrollbar-width:none] placeholder:text-[var(--text-muted)] focus:outline-none focus-visible:outline-none disabled:cursor-not-allowed [&::-webkit-scrollbar]:hidden'
631684
placeholder='Enter message content...'
632685
value={message.content}
633-
onChange={(e) => {
634-
fieldHandlers.onChange(e)
635-
autoResizeTextarea(fieldId)
636-
}}
686+
onChange={fieldHandlers.onChange}
637687
onKeyDown={(e) => {
638688
if (e.key === 'Tab' && !isPreview && !disabled) {
639689
e.preventDefault()
@@ -670,12 +720,14 @@ export function MessagesInput({
670720
ref={(el) => {
671721
overlayRefs.current[fieldId] = el
672722
}}
673-
className='scrollbar-none pointer-events-none absolute top-0 left-0 box-border w-full overflow-auto whitespace-pre-wrap break-words border-none bg-transparent px-[8px] pt-[8px] font-[inherit] font-medium text-[var(--text-primary)] text-sm leading-[inherit]'
723+
className='pointer-events-none absolute top-0 left-0 z-[1] m-0 box-border w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words border-none bg-transparent px-[8px] py-[8px] font-medium font-sans text-[var(--text-primary)] text-sm leading-[1.5] [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
674724
>
675725
{formatDisplayText(message.content, {
676726
accessiblePrefixes,
677727
highlightAll: !accessiblePrefixes,
678728
})}
729+
{/* Add zero-width space when content ends with newline to match textarea height */}
730+
{message.content.endsWith('\n') && '\u200B'}
679731
</div>
680732

681733
{/* Env var dropdown for this message */}
@@ -705,7 +757,7 @@ export function MessagesInput({
705757

706758
{!isPreview && !disabled && (
707759
<div
708-
className='absolute right-1 bottom-1 flex h-4 w-4 cursor-ns-resize items-center justify-center rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] dark:bg-[var(--surface-5)]'
760+
className='absolute right-1 bottom-1 z-[3] flex h-4 w-4 cursor-ns-resize items-center justify-center rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] dark:bg-[var(--surface-5)]'
709761
onMouseDown={(e) => handleResizeStart(fieldId, e)}
710762
onDragStart={(e) => {
711763
e.preventDefault()

0 commit comments

Comments
 (0)