|
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' |
2 | 10 | import { isEqual } from 'lodash' |
3 | 11 | import { ChevronDown, ChevronsUpDown, ChevronUp, Plus } from 'lucide-react' |
4 | 12 | import { Button, Popover, PopoverContent, PopoverItem, PopoverTrigger } from '@/components/emcn' |
@@ -382,87 +390,133 @@ export function MessagesInput({ |
382 | 390 | textareaRefs.current[fieldId]?.focus() |
383 | 391 | }, []) |
384 | 392 |
|
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) => { |
386 | 398 | const textarea = textareaRefs.current[fieldId] |
387 | | - if (!textarea) return |
388 | 399 | const overlay = overlayRefs.current[fieldId] |
| 400 | + if (!textarea || !overlay) return |
389 | 401 |
|
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 |
398 | 425 | } |
399 | | - return |
400 | | - } |
401 | 426 |
|
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 | + } |
409 | 435 |
|
410 | | - if (overlay) { |
411 | | - overlay.style.height = `${nextHeight}px` |
412 | | - } |
413 | | - }, []) |
| 436 | + // Get scrollHeight - represents full content height |
| 437 | + const scrollHeight = textarea.scrollHeight |
414 | 438 |
|
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 | + ) |
418 | 444 |
|
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 | + } |
421 | 450 |
|
422 | | - const startHeight = textarea.offsetHeight || textarea.scrollHeight || MIN_TEXTAREA_HEIGHT_PX |
| 451 | + // Sync overlay dimensions and scroll |
| 452 | + syncOverlay(fieldId) |
| 453 | + }, |
| 454 | + [syncOverlay] |
| 455 | + ) |
423 | 456 |
|
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() |
430 | 461 |
|
431 | | - const handleMouseMove = (moveEvent: MouseEvent) => { |
432 | | - if (!isResizingRef.current || !resizeStateRef.current) return |
| 462 | + const textarea = textareaRefs.current[fieldId] |
| 463 | + if (!textarea) return |
433 | 464 |
|
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 |
437 | 466 |
|
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, |
441 | 472 | } |
442 | 473 |
|
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) |
448 | 480 |
|
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 | + } |
453 | 496 | } |
454 | 497 |
|
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 | + } |
460 | 505 |
|
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 | + } |
464 | 511 |
|
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(() => { |
466 | 520 | currentMessages.forEach((_, index) => { |
467 | 521 | const fieldId = `message-${index}` |
468 | 522 | autoResizeTextarea(fieldId) |
@@ -621,19 +675,15 @@ export function MessagesInput({ |
621 | 675 | </div> |
622 | 676 |
|
623 | 677 | {/* Content Input with overlay for variable highlighting */} |
624 | | - <div className='relative w-full'> |
| 678 | + <div className='relative w-full overflow-hidden'> |
625 | 679 | <textarea |
626 | 680 | ref={(el) => { |
627 | 681 | textareaRefs.current[fieldId] = el |
628 | 682 | }} |
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' |
631 | 684 | placeholder='Enter message content...' |
632 | 685 | value={message.content} |
633 | | - onChange={(e) => { |
634 | | - fieldHandlers.onChange(e) |
635 | | - autoResizeTextarea(fieldId) |
636 | | - }} |
| 686 | + onChange={fieldHandlers.onChange} |
637 | 687 | onKeyDown={(e) => { |
638 | 688 | if (e.key === 'Tab' && !isPreview && !disabled) { |
639 | 689 | e.preventDefault() |
@@ -670,12 +720,14 @@ export function MessagesInput({ |
670 | 720 | ref={(el) => { |
671 | 721 | overlayRefs.current[fieldId] = el |
672 | 722 | }} |
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' |
674 | 724 | > |
675 | 725 | {formatDisplayText(message.content, { |
676 | 726 | accessiblePrefixes, |
677 | 727 | highlightAll: !accessiblePrefixes, |
678 | 728 | })} |
| 729 | + {/* Add zero-width space when content ends with newline to match textarea height */} |
| 730 | + {message.content.endsWith('\n') && '\u200B'} |
679 | 731 | </div> |
680 | 732 |
|
681 | 733 | {/* Env var dropdown for this message */} |
@@ -705,7 +757,7 @@ export function MessagesInput({ |
705 | 757 |
|
706 | 758 | {!isPreview && !disabled && ( |
707 | 759 | <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)]' |
709 | 761 | onMouseDown={(e) => handleResizeStart(fieldId, e)} |
710 | 762 | onDragStart={(e) => { |
711 | 763 | e.preventDefault() |
|
0 commit comments