From 96ba8a92f4e815be08848e22cfc3cad85961200a Mon Sep 17 00:00:00 2001 From: Mathias Jakobsen Date: Wed, 13 Sep 2023 10:06:30 +0100 Subject: [PATCH] Merge pull request #14757 from overleaf/td-review-panel-auto-expand-textarea-cursor React review panel: place cursor at the end when autofocussing textarea GitOrigin-RevId: c277e949dadc16bef2ed1a9ace69285e99ba29ad --- .../review-panel/entries/comment-entry.tsx | 5 +- .../components/auto-expanding-text-area.tsx | 98 ++++++++++++++----- 2 files changed, 77 insertions(+), 26 deletions(-) diff --git a/services/web/frontend/js/features/source-editor/components/review-panel/entries/comment-entry.tsx b/services/web/frontend/js/features/source-editor/components/review-panel/entries/comment-entry.tsx index 52937e66a7..fbf8388a80 100644 --- a/services/web/frontend/js/features/source-editor/components/review-panel/entries/comment-entry.tsx +++ b/services/web/frontend/js/features/source-editor/components/review-panel/entries/comment-entry.tsx @@ -4,9 +4,7 @@ import EntryContainer from './entry-container' import EntryCallout from './entry-callout' import EntryActions from './entry-actions' import Comment from './comment' -import AutoExpandingTextArea, { - resetHeight, -} from '../../../../../shared/components/auto-expanding-text-area' +import AutoExpandingTextArea from '../../../../../shared/components/auto-expanding-text-area' import Icon from '../../../../../shared/components/icon' import { useReviewPanelUpdaterFnsContext } from '../../../context/review-panel/review-panel-context' import classnames from 'classnames' @@ -93,7 +91,6 @@ function CommentEntry({ ;(e.target as HTMLTextAreaElement).blur() submitReply(threadId, replyContent) setReplyContent('') - resetHeight(e) } } } diff --git a/services/web/frontend/js/shared/components/auto-expanding-text-area.tsx b/services/web/frontend/js/shared/components/auto-expanding-text-area.tsx index 1c7af774d7..b278727171 100644 --- a/services/web/frontend/js/shared/components/auto-expanding-text-area.tsx +++ b/services/web/frontend/js/shared/components/auto-expanding-text-area.tsx @@ -1,28 +1,7 @@ -import { useEffect, useRef } from 'react' +import { useCallback, useEffect, useRef } from 'react' import { callFnsInSequence } from '../../utils/functions' import { MergeAndOverride } from '../../../../types/utils' -export const resetHeight = ( - e: - | React.ChangeEvent - | React.KeyboardEvent -) => { - const el = e.target as HTMLTextAreaElement - - window.requestAnimationFrame(() => { - const curHeight = el.offsetHeight - const fitHeight = el.scrollHeight - // clear height if text area is empty - if (!el.value.length) { - el.style.removeProperty('height') - } - // otherwise expand to fit text - else if (fitHeight > curHeight) { - el.style.height = `${fitHeight}px` - } - }) -} - type AutoExpandingTextAreaProps = MergeAndOverride< React.ComponentProps<'textarea'>, { @@ -33,10 +12,61 @@ type AutoExpandingTextAreaProps = MergeAndOverride< function AutoExpandingTextArea({ onChange, onResize, + autoFocus, ...rest }: AutoExpandingTextAreaProps) { const ref = useRef(null) const previousHeightRef = useRef(null) + const previousMeasurementRef = useRef<{ + heightAdjustment: number + value: string + } | null>(null) + + const resetHeight = useCallback(() => { + const el = ref.current + if (!el) { + return + } + + const { value } = el + const previousMeasurement = previousMeasurementRef.current + + // Do nothing if the textarea value hasn't changed since the last reset + if (previousMeasurement !== null && value === previousMeasurement.value) { + return + } + + let heightAdjustment + if (previousMeasurement === null) { + const computedStyle = window.getComputedStyle(el) + heightAdjustment = + computedStyle.boxSizing === 'border-box' + ? Math.ceil( + parseFloat(computedStyle.borderTopWidth) + + parseFloat(computedStyle.borderBottomWidth) + ) + : -Math.floor( + parseFloat(computedStyle.paddingTop) + + parseFloat(computedStyle.paddingBottom) + ) + } else { + heightAdjustment = previousMeasurement.heightAdjustment + } + + const curHeight = el.clientHeight + const fitHeight = el.scrollHeight + + // Clear height if text area is empty + if (value === '') { + el.style.removeProperty('height') + } + // Otherwise, expand to fit text + else if (fitHeight > curHeight) { + el.style.height = fitHeight + heightAdjustment + 'px' + } + + previousMeasurementRef.current = { heightAdjustment, value } + }, []) useEffect(() => { if (!ref.current || !onResize || !('ResizeObserver' in window)) { @@ -70,6 +100,30 @@ function AutoExpandingTextArea({ } }, [onResize]) + // Implement autofocus manually so that the cursor is placed at the end of + // the textarea content + useEffect(() => { + const el = ref.current + if (!el) { + return + } + + resetHeight() + if (autoFocus) { + const cursorPos = el.value.length + el.focus() + el.setSelectionRange(cursorPos, cursorPos) + } + }, [autoFocus, resetHeight]) + + // Reset height when the value changes via the `value` prop. If the textarea + // is controlled, this means resetHeight is called twice per keypress, but + // this is mitigated by a check on whether the value has actually changed in + // resetHeight() + useEffect(() => { + resetHeight() + }, [rest.value, resetHeight]) + return (