From 45d5d9e44fc4aa4a8e0aea2bd85cad96cfedaeb4 Mon Sep 17 00:00:00 2001 From: lyzno1 Date: Sat, 11 Oct 2025 13:37:43 +0800 Subject: [PATCH] fix: mention input cannot scroll --- .../workflow/comment/mention-input.tsx | 171 +++++++++++++++++- 1 file changed, 162 insertions(+), 9 deletions(-) diff --git a/web/app/components/workflow/comment/mention-input.tsx b/web/app/components/workflow/comment/mention-input.tsx index 6972d935fa..c369ae3a2e 100644 --- a/web/app/components/workflow/comment/mention-input.tsx +++ b/web/app/components/workflow/comment/mention-input.tsx @@ -1,7 +1,15 @@ 'use client' import type { FC, ReactNode } from 'react' -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + memo, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react' import { createPortal } from 'react-dom' import { useParams } from 'next/navigation' import { useTranslation } from 'react-i18next' @@ -42,6 +50,10 @@ export const MentionInput: FC = memo(({ const { t } = useTranslation() const appId = params.appId as string const textareaRef = useRef(null) + const highlightContentRef = useRef(null) + const actionContainerRef = useRef(null) + const actionRightRef = useRef(null) + const baseTextareaHeightRef = useRef(null) const workflowStore = useWorkflowStore() const mentionUsersFromStore = useStore(state => ( @@ -55,6 +67,11 @@ export const MentionInput: FC = memo(({ const [selectedMentionIndex, setSelectedMentionIndex] = useState(0) const [mentionedUserIds, setMentionedUserIds] = useState([]) const resolvedPlaceholder = placeholder ?? t('workflow.comments.placeholder.add') + const BASE_PADDING = 4 + const [shouldReserveButtonGap, setShouldReserveButtonGap] = useState(isEditing) + const [shouldReserveHorizontalSpace, setShouldReserveHorizontalSpace] = useState(() => !isEditing) + const [paddingRight, setPaddingRight] = useState(() => BASE_PADDING + (isEditing ? 0 : 48)) + const [paddingBottom, setPaddingBottom] = useState(() => BASE_PADDING + (isEditing ? 32 : 0)) const mentionNameList = useMemo(() => { const names = mentionUsers @@ -153,6 +170,104 @@ export const MentionInput: FC = memo(({ useEffect(() => { loadMentionableUsers() }, [loadMentionableUsers]) + const syncHighlightScroll = useCallback(() => { + const textarea = textareaRef.current + const highlightContent = highlightContentRef.current + if (!textarea || !highlightContent) + return + + const { scrollTop, scrollLeft } = textarea + highlightContent.style.transform = `translate(${-scrollLeft}px, ${-scrollTop}px)` + }, []) + + const evaluateContentLayout = useCallback(() => { + const textarea = textareaRef.current + if (!textarea) + return + + const extraBottom = Math.max(0, paddingBottom - BASE_PADDING) + const effectiveClientHeight = textarea.clientHeight - extraBottom + + if (baseTextareaHeightRef.current === null) + baseTextareaHeightRef.current = effectiveClientHeight + + const baseHeight = baseTextareaHeightRef.current ?? effectiveClientHeight + const hasMultiline = effectiveClientHeight > baseHeight + 1 + const shouldReserveVertical = isEditing ? true : hasMultiline + + setShouldReserveButtonGap(shouldReserveVertical) + setShouldReserveHorizontalSpace(!hasMultiline) + }, [isEditing, paddingBottom]) + + const updateLayoutPadding = useCallback(() => { + const actionEl = actionContainerRef.current + const rect = actionEl?.getBoundingClientRect() + const rightRect = actionRightRef.current?.getBoundingClientRect() + let actionWidth = 0 + if (rightRect) + actionWidth = Math.ceil(rightRect.width) + else if (rect) + actionWidth = Math.ceil(rect.width) + + const actionHeight = rect ? Math.ceil(rect.height) : 0 + const fallbackWidth = Math.max(0, paddingRight - BASE_PADDING) + const fallbackHeight = Math.max(0, paddingBottom - BASE_PADDING) + const effectiveWidth = actionWidth > 0 ? actionWidth : fallbackWidth + const effectiveHeight = actionHeight > 0 ? actionHeight : fallbackHeight + + const nextRight = BASE_PADDING + (shouldReserveHorizontalSpace ? effectiveWidth : 0) + const nextBottom = BASE_PADDING + (shouldReserveButtonGap ? effectiveHeight : 0) + + setPaddingRight(prev => (prev === nextRight ? prev : nextRight)) + setPaddingBottom(prev => (prev === nextBottom ? prev : nextBottom)) + }, [shouldReserveButtonGap, shouldReserveHorizontalSpace, paddingRight, paddingBottom]) + + const setActionContainerRef = useCallback((node: HTMLDivElement | null) => { + actionContainerRef.current = node + + if (!isEditing) + actionRightRef.current = node + else if (!node) + actionRightRef.current = null + + if (node && typeof window !== 'undefined') + window.requestAnimationFrame(() => updateLayoutPadding()) + }, [isEditing, updateLayoutPadding]) + + const setActionRightRef = useCallback((node: HTMLDivElement | null) => { + actionRightRef.current = node + + if (node && typeof window !== 'undefined') + window.requestAnimationFrame(() => updateLayoutPadding()) + }, [updateLayoutPadding]) + + useLayoutEffect(() => { + syncHighlightScroll() + }, [value, syncHighlightScroll]) + + useLayoutEffect(() => { + evaluateContentLayout() + }, [value, evaluateContentLayout]) + + useLayoutEffect(() => { + updateLayoutPadding() + }, [updateLayoutPadding, isEditing, shouldReserveButtonGap]) + + useEffect(() => { + const handleResize = () => { + evaluateContentLayout() + updateLayoutPadding() + } + + window.addEventListener('resize', handleResize) + return () => window.removeEventListener('resize', handleResize) + }, [evaluateContentLayout, updateLayoutPadding]) + + useEffect(() => { + baseTextareaHeightRef.current = null + evaluateContentLayout() + setShouldReserveHorizontalSpace(!isEditing) + }, [isEditing, evaluateContentLayout]) const filteredMentionUsers = useMemo(() => { if (!mentionQuery) return mentionUsers @@ -198,8 +313,15 @@ export const MentionInput: FC = memo(({ else { setShowMentionDropdown(false) } + + if (typeof window !== 'undefined') { + window.requestAnimationFrame(() => { + evaluateContentLayout() + syncHighlightScroll() + }) + } }, 0) - }, [onChange]) + }, [onChange, evaluateContentLayout, syncHighlightScroll]) const handleMentionButtonClick = useCallback((e: React.MouseEvent) => { e.preventDefault() @@ -222,8 +344,15 @@ export const MentionInput: FC = memo(({ setMentionPosition(cursorPosition) setShowMentionDropdown(true) setSelectedMentionIndex(0) + + if (typeof window !== 'undefined') { + window.requestAnimationFrame(() => { + evaluateContentLayout() + syncHighlightScroll() + }) + } }, 0) - }, [value, onChange]) + }, [value, onChange, evaluateContentLayout, syncHighlightScroll]) const insertMention = useCallback((user: UserProfile) => { const textarea = textareaRef.current @@ -247,8 +376,14 @@ export const MentionInput: FC = memo(({ const newCursorPos = mentionPosition + extraSpace + user.name.length + 2 // (space) + @ + name + space textarea.setSelectionRange(newCursorPos, newCursorPos) textarea.focus() + if (typeof window !== 'undefined') { + window.requestAnimationFrame(() => { + evaluateContentLayout() + syncHighlightScroll() + }) + } }, 0) - }, [value, mentionPosition, onChange, mentionedUserIds]) + }, [value, mentionPosition, onChange, mentionedUserIds, evaluateContentLayout, syncHighlightScroll]) const handleSubmit = useCallback((e?: React.MouseEvent) => { if (e) { @@ -330,9 +465,16 @@ export const MentionInput: FC = memo(({ 'pointer-events-none absolute inset-0 z-0 overflow-hidden whitespace-pre-wrap break-words p-1 leading-6', 'body-lg-regular text-text-primary', )} + style={{ paddingRight, paddingBottom }} > - {highlightedValue} - {'​'} +
+ {highlightedValue} + {'​'} +