overleaf/services/web/frontend/js/shared/components/auto-expanding-text-area.tsx

157 lines
4.3 KiB
TypeScript
Raw Normal View History

import { useCallback, useEffect, useRef } from 'react'
import { callFnsInSequence } from '../../utils/functions'
import { MergeAndOverride } from '../../../../types/utils'
type AutoExpandingTextAreaProps = MergeAndOverride<
React.ComponentProps<'textarea'>,
{
onResize?: () => void
onAutoFocus?: (textarea: HTMLTextAreaElement) => void
}
>
function AutoExpandingTextArea({
onChange,
onResize,
autoFocus,
onAutoFocus,
...rest
}: AutoExpandingTextAreaProps) {
const ref = useRef<HTMLTextAreaElement>(null)
const previousHeightRef = useRef<number | null>(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)) {
return
}
const resizeObserver = new ResizeObserver(() => {
if (!ref.current) {
return
}
const newHeight = ref.current.offsetHeight
// Ignore the resize when the height of the element is less than or equal to 0
if (newHeight <= 0) {
return
}
const heightChanged = newHeight !== previousHeightRef.current
previousHeightRef.current = newHeight
if (heightChanged) {
// Prevent errors like "ResizeObserver loop completed with undelivered
// notifications" that occur if onResize triggers another repaint. The
// cost of this is that onResize lags one frame behind, but it's
// unlikely to matter.
// Wrap onResize to prevent extra parameters being passed
window.requestAnimationFrame(() => onResize())
}
})
resizeObserver.observe(ref.current)
return () => {
resizeObserver.disconnect()
}
}, [onResize])
// Maintain a copy onAutoFocus in a ref for use in the autofocus effect
// below so that the effect doesn't run when onAutoFocus changes
const onAutoFocusRef = useRef(onAutoFocus)
useEffect(() => {
onAutoFocusRef.current = onAutoFocus
}, [onAutoFocus])
// 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
const timer = window.setTimeout(() => {
el.focus()
el.setSelectionRange(cursorPos, cursorPos)
if (onAutoFocusRef.current) {
onAutoFocusRef.current(el)
}
}, 100)
return () => {
window.clearTimeout(timer)
}
}
}, [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 (
<textarea
onChange={callFnsInSequence(onChange, resetHeight)}
{...rest}
ref={ref}
/>
)
}
export default AutoExpandingTextArea