import React from 'react'; import cls from 'classnames'; import PropTypes from 'prop-types'; import TextAreaFoundation from '@douyinfe/semi-foundation/input/textareaFoundation'; import { cssClasses } from '@douyinfe/semi-foundation/input/constants'; import BaseComponent, { ValidateStatus } from '../_base/baseComponent'; import '@douyinfe/semi-foundation/input/textarea.scss'; import { noop, omit, isFunction, isUndefined, isObject, throttle } from 'lodash'; import type { DebouncedFunc } from 'lodash'; import { IconClear } from '@douyinfe/semi-icons'; import ResizeObserver, { ResizeEntry } from '../resizeObserver'; import type { CSSProperties } from 'react'; const prefixCls = cssClasses.PREFIX; type OmitTextareaAttr = | 'onChange' | 'onInput' | 'prefix' | 'size' | 'onFocus' | 'onBlur' | 'onKeyDown' | 'onKeyPress' | 'onKeyUp' | 'onResize'; export type AutosizeRow = { minRows?: number; maxRows?: number }; export interface TextAreaProps extends Omit, OmitTextareaAttr> { autosize?: boolean | AutosizeRow; borderless?: boolean; placeholder?: string; value?: string; rows?: number; cols?: number; maxCount?: number; validateStatus?: ValidateStatus; defaultValue?: string; disabled?: boolean; readonly?: boolean; autoFocus?: boolean; showCounter?: boolean; showClear?: boolean; onClear?: (e: React.MouseEvent) => void; onChange?: (value: string, e: React.MouseEvent) => void; onBlur?: (e: React.FocusEvent) => void; onFocus?: (e: React.FocusEvent) => void; onInput?: (e: React.MouseEvent) => void; onKeyDown?: (e: React.KeyboardEvent) => void; onKeyUp?: (e: React.KeyboardEvent) => void; onKeyPress?: (e: React.KeyboardEvent) => void; onEnterPress?: (e: React.KeyboardEvent) => void; onPressEnter?: (e: React.KeyboardEvent) => void; /** * Callback invoked when textarea size changes. * - In `autosize` mode: triggered when autosize updates height * - In native `resize` mode: triggered when user drags the resize handle */ onResize?: (data: { height: number; width?: number }) => void; getValueLength?: (value: string) => number; forwardRef?: ((instance: HTMLTextAreaElement) => void) | React.MutableRefObject | null; /* Inner params for TextArea, Chat use it, 。 Used to disable line breaks by pressing the enter key。 Press enter + shift at the same time can start new line. */ disabledEnterStartNewLine?: boolean; /** Whether to show line numbers */ showLineNumber?: boolean; /** The starting line number, default is 1 */ lineNumberStart?: number; /** Custom className for line number area */ lineNumberClassName?: string; /** Custom style for line number area */ lineNumberStyle?: CSSProperties; /** The style of textarea element */ textareaStyle?: CSSProperties; /** Whether to enable composition mode. When enabled, onChange will not be triggered during IME composition, and will only be triggered once after composition ends */ composition?: boolean; /** * Whether the textarea is resizable, and in which direction. * When autosize is enabled, this property will be ignored. * Note: this prop only takes effect when explicitly provided. */ resize?: 'none' | 'both' | 'horizontal' | 'vertical' | 'block' | 'inline'; } export interface TextAreaState { value: string; isFocus: boolean; isHover: boolean; height: number; minLength: number; cachedValue?: string; // Used to trigger re-render of line numbers when textarea resizes textareaWidth: number; // Used to constrain line number panel height to textarea viewport textareaHeight: number; } class TextArea extends BaseComponent { static propTypes = { autosize: PropTypes.oneOfType([PropTypes.bool, PropTypes.object]), borderless: PropTypes.bool, placeholder: PropTypes.string, value: PropTypes.string, rows: PropTypes.number, cols: PropTypes.number, maxCount: PropTypes.number, onEnterPress: PropTypes.func, validateStatus: PropTypes.string, className: PropTypes.string, style: PropTypes.object, textareaStyle: PropTypes.object, showClear: PropTypes.bool, onClear: PropTypes.func, onResize: PropTypes.func, onCompositionStart: PropTypes.func, onCompositionEnd: PropTypes.func, onCompositionUpdate: PropTypes.func, getValueLength: PropTypes.func, disabledEnterStartNewLine: PropTypes.bool, composition: PropTypes.bool, showLineNumber: PropTypes.bool, lineNumberStart: PropTypes.number, lineNumberClassName: PropTypes.string, lineNumberStyle: PropTypes.object, resize: PropTypes.oneOf(['none', 'both', 'horizontal', 'vertical', 'block', 'inline']), }; static defaultProps = { autosize: false, borderless: false, rows: 4, cols: 20, showCounter: false, showClear: false, onEnterPress: noop, onChange: noop, onBlur: noop, onFocus: noop, onKeyDown: noop, onResize: noop, onClear: noop, onCompositionStart: noop, onCompositionEnd: noop, onCompositionUpdate: noop, composition: false, showLineNumber: false, lineNumberStart: 1, }; focusing: boolean; libRef: React.RefObject; foundation: TextAreaFoundation; throttledResizeTextarea: DebouncedFunc; throttledNotifyNativeResize: DebouncedFunc<(entries: ResizeEntry[]) => void>; lineNumberRef: React.RefObject; lineNumberResizeObserver: globalThis.ResizeObserver | null; private nativeResizeObservedOnce: boolean; private lastNativeSize: { width: number; height: number } | null; constructor(props: TextAreaProps) { super(props); const initValue = 'value' in props ? props.value : props.defaultValue; this.state = { value: initValue, isFocus: false, isHover: false, height: 0, minLength: props.minLength, cachedValue: props.value, textareaWidth: 0, textareaHeight: 0, }; this.focusing = false; this.foundation = new TextAreaFoundation(this.adapter); this.lineNumberResizeObserver = null; this.libRef = React.createRef(); this.lineNumberRef = React.createRef(); this.throttledResizeTextarea = throttle(this.foundation.resizeTextarea, 10); this.throttledNotifyNativeResize = throttle(this.handleNativeResize, 10); this.nativeResizeObservedOnce = false; this.lastNativeSize = null; } get adapter() { return { ...super.adapter, setValue: (value: string) => this.setState({ value }, () => { if (this.props.autosize) { this.foundation.resizeTextarea(); } }), getRef: () => this.libRef.current, toggleFocusing: (focusing: boolean) => this.setState({ isFocus: focusing }), toggleHovering: (hovering: boolean) => this.setState({ isHover: hovering }), notifyChange: (val: string, e: React.MouseEvent) => { this.props.onChange(val, e); }, notifyClear: (e: React.MouseEvent) => this.props.onClear(e), notifyBlur: (val: string, e: React.FocusEvent) => this.props.onBlur(e), notifyFocus: (val: string, e: React.FocusEvent) => this.props.onFocus(e), notifyKeyDown: (e: React.KeyboardEvent) => { this.props.onKeyDown(e); }, notifyHeightUpdate: (height: number) => { this.setState({ height }); this.props.onResize({ height }); }, notifyPressEnter: (e: React.KeyboardEvent) => { this.props.onEnterPress && this.props.onEnterPress(e); }, notifyCompositionStart: (e: React.CompositionEvent) => this.props.onCompositionStart(e), notifyCompositionEnd: (e: React.CompositionEvent) => this.props.onCompositionEnd(e), notifyCompositionUpdate: (e: React.CompositionEvent) => this.props.onCompositionUpdate(e), setMinLength: (minLength: number) => this.setState({ minLength }), focusInput: () => { const textarea = this.libRef && this.libRef.current; textarea && textarea.focus(); }, isEventTarget: (e: React.MouseEvent) => e && e.target === e.currentTarget, }; } static getDerivedStateFromProps(props: TextAreaProps, state: TextAreaState) { const willUpdateStates: Partial = {}; if (props.value !== state.cachedValue) { willUpdateStates.value = props.value; willUpdateStates.cachedValue = props.value; } return willUpdateStates; } componentDidMount(): void { // Setup resize observer for line number recalculation if (this.props.showLineNumber && this.libRef.current && typeof globalThis.ResizeObserver !== 'undefined') { const textarea = this.libRef.current as unknown as HTMLTextAreaElement; this.setState({ textareaWidth: textarea.clientWidth, textareaHeight: textarea.clientHeight }); this.lineNumberResizeObserver = new globalThis.ResizeObserver((entries) => { for (const entry of entries) { // contentRect does not include borders; align with textarea.clientHeight const nextWidth = entry.contentRect.width; const nextHeight = entry.contentRect.height; this.setState({ textareaWidth: nextWidth, textareaHeight: nextHeight }); } }); this.lineNumberResizeObserver.observe(textarea); } } componentWillUnmount(): void { if (this.throttledResizeTextarea) { this.throttledResizeTextarea?.cancel?.(); this.throttledResizeTextarea = null; } if (this.throttledNotifyNativeResize) { this.throttledNotifyNativeResize?.cancel?.(); this.throttledNotifyNativeResize = null; } if (this.lineNumberResizeObserver) { this.lineNumberResizeObserver.disconnect(); this.lineNumberResizeObserver = null; } } handleNativeResize = (entries: ResizeEntry[]) => { // Only used for native `resize` (non-autosize). Guard anyway. if (this.props.autosize) { return; } const entry = entries && entries[0]; const rect = entry && entry.contentRect; if (!rect) { return; } const width = rect.width; const height = rect.height; // ResizeObserver will fire immediately on observe; skip the first one // to avoid triggering `onResize` on initial mount. if (!this.nativeResizeObservedOnce) { this.nativeResizeObservedOnce = true; this.lastNativeSize = { width, height }; return; } const last = this.lastNativeSize; if (last && last.width === width && last.height === height) { return; } this.lastNativeSize = { width, height }; this.props.onResize?.({ height, width }); }; componentDidUpdate(prevProps: TextAreaProps, prevState: TextAreaState) { if ( (this.props.value !== prevProps.value || this.props.placeholder !== prevProps.placeholder) && this.props.autosize ) { this.foundation.resizeTextarea(); } // Setup/cleanup resize observer when showLineNumber changes if (this.props.showLineNumber !== prevProps.showLineNumber) { if (this.props.showLineNumber && this.libRef.current && typeof globalThis.ResizeObserver !== 'undefined') { const textarea = this.libRef.current as unknown as HTMLTextAreaElement; this.setState({ textareaWidth: textarea.clientWidth, textareaHeight: textarea.clientHeight }); if (!this.lineNumberResizeObserver) { this.lineNumberResizeObserver = new globalThis.ResizeObserver((entries) => { for (const entry of entries) { const nextWidth = entry.contentRect.width; const nextHeight = entry.contentRect.height; this.setState({ textareaWidth: nextWidth, textareaHeight: nextHeight }); } }); } this.lineNumberResizeObserver.observe(textarea); } else if (this.lineNumberResizeObserver) { this.lineNumberResizeObserver.disconnect(); this.lineNumberResizeObserver = null; } } } handleClear = (e: React.MouseEvent) => { this.foundation.handleClear(e); }; handleClick = (e: React.MouseEvent) => { this.foundation.handleClick(e); }; handleCounterClick = (e: React.MouseEvent) => { this.foundation.handleCounterClick(e); }; renderClearBtn() { const { showClear } = this.props; const displayClearBtn = this.foundation.isAllowClear(); const clearCls = cls(`${prefixCls}-clearbtn`, { [`${prefixCls}-clearbtn-hidden`]: !displayClearBtn, }); if (showClear) { return ( // eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions
); } return null; } renderCounter() { let counter: React.ReactNode, current: number, total: number, countCls: string; const { showCounter, maxCount, getValueLength } = this.props; if (showCounter || maxCount) { const { value } = this.state; // eslint-disable-next-line no-nested-ternary current = value ? (isFunction(getValueLength) ? getValueLength(value) : value.length) : 0; total = maxCount || null; countCls = cls(`${prefixCls}-textarea-counter`, { [`${prefixCls}-textarea-counter-exceed`]: current > total, }); counter = (
{current} {total ? '/' : null} {total}
); } else { counter = null; } return counter; } setRef = (node: HTMLTextAreaElement) => { (this.libRef as any).current = node; const { forwardRef } = this.props; if (typeof forwardRef === 'function') { forwardRef(node); } else if (forwardRef && typeof forwardRef === 'object') { forwardRef.current = node; } }; handleTextAreaScroll = (e: React.UIEvent) => { const { showLineNumber } = this.props; if (showLineNumber && this.lineNumberRef.current) { // Use rAF to avoid layout thrash requestAnimationFrame(() => { const panel = this.lineNumberRef.current; if (panel) { panel.scrollTop = (e.target as HTMLTextAreaElement).scrollTop; } }); } }; getTextareaLineHeightPx = (textarea: HTMLTextAreaElement): number => { const computedStyle = window.getComputedStyle(textarea); const lineHeightStr = computedStyle.lineHeight; const fontSize = parseFloat(computedStyle.fontSize) || 14; if (!lineHeightStr || lineHeightStr === 'normal') { // Browsers typically use ~1.2, but Semi textarea visually closer to 1.5 return fontSize * 1.5; } const parsed = parseFloat(lineHeightStr); return Number.isFinite(parsed) && parsed > 0 ? parsed : fontSize * 1.5; }; // Calculate the number of wrapped lines for a given text line calculateWrappedLines = (line: string, textarea: HTMLTextAreaElement): number => { if (!line) return 1; const computedStyle = window.getComputedStyle(textarea); const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); if (!ctx) return 1; // Set font to match textarea const fontSize = computedStyle.fontSize; const fontFamily = computedStyle.fontFamily; ctx.font = `${fontSize} ${fontFamily}`; // Calculate available width (excluding padding and scrollbar) const paddingLeft = parseFloat(computedStyle.paddingLeft) || 0; const paddingRight = parseFloat(computedStyle.paddingRight) || 0; const textareaWidth = textarea.clientWidth - paddingLeft - paddingRight; if (textareaWidth <= 0) return 1; // Measure text width const metrics = ctx.measureText(line); const textWidth = metrics.width; // Calculate wrapped lines const wrappedLines = Math.ceil(textWidth / textareaWidth); return Math.max(1, wrappedLines); }; renderLineNumbers() { const { showLineNumber, lineNumberStart = 1, lineNumberClassName, lineNumberStyle } = this.props; if (!showLineNumber) { return null; } // Reference textareaWidth to trigger re-render when textarea resizes const { value, textareaWidth, textareaHeight } = this.state; const textarea = this.libRef.current as unknown as HTMLTextAreaElement; const lines = value ? value.split('\n') : ['']; const lineNumberCls = cls(`${prefixCls}-textarea-lineNumber`, lineNumberClassName); const lineHeightPx = textarea ? this.getTextareaLineHeightPx(textarea) : 21; // Constrain panel height to textarea viewport height to prevent expanding textarea const mergedStyle: CSSProperties = { ...(lineNumberStyle || {}), height: textareaHeight ? `${textareaHeight}px` : undefined, maxHeight: textareaHeight ? `${textareaHeight}px` : undefined, }; return (
{lines.map((line, i) => { // Calculate wrapped lines for this line const wrappedLineCount = textarea ? this.calculateWrappedLines(line, textarea) : 1; return (
{lineNumberStart + i}
); })}
); } render() { const { autosize, placeholder, onEnterPress, onResize, resize, disabled, readonly, className, showCounter, validateStatus, maxCount, defaultValue, style, textareaStyle, forwardRef, getValueLength, maxLength, minLength, showClear, borderless, autoFocus, showLineNumber, lineNumberStart, lineNumberClassName, lineNumberStyle, composition, ...rest } = this.props; const { isFocus, value, minLength: stateMinLength } = this.state; // Only opt-in to the new resize behavior when `resize` prop is explicitly provided. // This guarantees the default width behavior remains identical to previous versions. const hasResizeProp = !isUndefined(resize); // Native CSS resize only changes the textarea box, but wrapper is `width: 100%` by default. // For horizontal resize, we need wrapper to shrink-to-fit so border/clear/counter follow. const isResizableX = !autosize && hasResizeProp && ['horizontal', 'both', 'inline'].includes(resize); const isResizableY = !autosize && hasResizeProp && ['vertical', 'both', 'block'].includes(resize); const wrapperCls = cls(className, `${prefixCls}-textarea-wrapper`, { [`${prefixCls}-textarea-borderless`]: borderless, [`${prefixCls}-textarea-wrapper-disabled`]: disabled, [`${prefixCls}-textarea-wrapper-readonly`]: readonly, [`${prefixCls}-textarea-wrapper-${validateStatus}`]: Boolean(validateStatus), [`${prefixCls}-textarea-wrapper-focus`]: isFocus, [`${prefixCls}-textarea-wrapper-withLineNumber`]: showLineNumber, [`${prefixCls}-textarea-wrapper-resizeX`]: isResizableX, [`${prefixCls}-textarea-wrapper-resizeY`]: isResizableY, }); // const ref = this.props.forwardRef || this.textAreaRef; const itemCls = cls(`${prefixCls}-textarea`, { [`${prefixCls}-textarea-disabled`]: disabled, [`${prefixCls}-textarea-readonly`]: readonly, [`${prefixCls}-textarea-autosize`]: isObject(autosize) ? isUndefined(autosize?.maxRows) : autosize, [`${prefixCls}-textarea-showClear`]: showClear, }); // Merge textarea style: // - autosize: force resize to none // - explicit resize prop: apply it // - otherwise: keep old behavior (do not touch `resize` inline style) const mergedTextareaStyle: CSSProperties = { ...(textareaStyle || {}), }; if (autosize) { mergedTextareaStyle.resize = 'none'; } else if (hasResizeProp) { mergedTextareaStyle.resize = resize; } const shouldObserveNativeResize = !autosize && hasResizeProp && resize && resize !== 'none'; const itemProps = { ...omit(rest, 'insetLabel', 'insetLabelId', 'getValueLength', 'onClear', 'showClear', 'disabledEnterStartNewLine', 'composition'), style: mergedTextareaStyle, autoFocus: autoFocus || this.props['autofocus'], className: itemCls, disabled, readOnly: readonly, placeholder: !placeholder ? null : placeholder, onChange: (e: React.ChangeEvent) => this.foundation.handleChange(e.target.value, e), onFocus: (e: React.FocusEvent) => this.foundation.handleFocus(e), onBlur: (e: React.FocusEvent) => this.foundation.handleBlur(e.nativeEvent), onKeyDown: (e: React.KeyboardEvent) => this.foundation.handleKeyDown(e), onScroll: this.handleTextAreaScroll, value: value === null || value === undefined ? '' : value, onCompositionStart: this.foundation.handleCompositionStart, onCompositionEnd: this.foundation.handleCompositionEnd, onCompositionUpdate: this.foundation.handleCompositionUpdate, }; if (!isFunction(getValueLength)) { (itemProps as any).maxLength = maxLength; } if (stateMinLength) { (itemProps as any).minLength = stateMinLength; } return (
this.foundation.handleMouseEnter(e)} onMouseLeave={e => this.foundation.handleMouseLeave(e)} onClick={e => this.handleClick(e)} > {this.renderLineNumbers()} {showLineNumber ? (
{autosize ? (