Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions assets/index.less
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,10 @@
border: 1px solid #1677ff;
}
}

.@{textarea-prefix-cls}-out-of-range {
&,
& textarea {
color: red;
}
}
24 changes: 24 additions & 0 deletions docs/demo/showCount.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,30 @@ export default function App() {
showCount
style={{ height: 200, width: '100%', resize: 'vertical' }}
/>
<hr />
<p>Count.exceedFormatter</p>
<Textarea
defaultValue="👨‍👨‍👧‍👦"
count={{
show: true,
max: 5,
}}
/>
<Textarea
defaultValue="🔥"
count={{
show: true,
max: 5,
exceedFormatter: (val, { max }) => {
const segments = [...new Intl.Segmenter().segment(val)];

return segments
.filter((seg) => seg.index + seg.segment.length <= max)
.map((seg) => seg.segment)
.join('');
},
}}
/>
</div>
);
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
"dependencies": {
"@babel/runtime": "^7.10.1",
"classnames": "^2.2.1",
"rc-input": "~1.2.1",
"rc-input": "~1.3.3",
"rc-resize-observer": "^1.0.0",
"rc-util": "^5.27.0"
},
Expand Down
222 changes: 130 additions & 92 deletions src/TextArea.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import clsx from 'classnames';
import { BaseInput } from 'rc-input';
import {
fixControlledValue,
resolveOnChange,
} from 'rc-input/lib/utils/commonUtils';
import useCount from 'rc-input/lib/hooks/useCount';
import { resolveOnChange } from 'rc-input/lib/utils/commonUtils';
import useMergedState from 'rc-util/lib/hooks/useMergedState';
import type { ReactNode } from 'react';
import React, { useEffect, useImperativeHandle, useRef } from 'react';
Expand All @@ -14,29 +12,29 @@ import type {
TextAreaRef,
} from './interface';

function fixEmojiLength(value: string, maxLength: number) {
return [...(value || '')].slice(0, maxLength).join('');
}

function setTriggerValue(
isCursorInEnd: boolean,
preValue: string,
triggerValue: string,
maxLength: number,
) {
let newTriggerValue = triggerValue;
if (isCursorInEnd) {
// 光标在尾部,直接截断
newTriggerValue = fixEmojiLength(triggerValue, maxLength!);
} else if (
[...(preValue || '')].length < triggerValue.length &&
[...(triggerValue || '')].length > maxLength!
) {
// 光标在中间,如果最后的值超过最大值,则采用原先的值
newTriggerValue = preValue;
}
return newTriggerValue;
}
// function fixEmojiLength(value: string, maxLength: number) {
// return [...(value || '')].slice(0, maxLength).join('');
// }

// function setTriggerValue(
// isCursorInEnd: boolean,
// preValue: string,
// triggerValue: string,
// maxLength: number,
// ) {
// let newTriggerValue = triggerValue;
// if (isCursorInEnd) {
// // 光标在尾部,直接截断
// newTriggerValue = fixEmojiLength(triggerValue, maxLength!);
// } else if (
// [...(preValue || '')].length < triggerValue.length &&
// [...(triggerValue || '')].length > maxLength!
// ) {
// // 光标在中间,如果最后的值超过最大值,则采用原先的值
// newTriggerValue = preValue;
// }
// return newTriggerValue;
// }

const TextArea = React.forwardRef<TextAreaRef, TextAreaProps>(
(
Expand All @@ -54,6 +52,7 @@ const TextArea = React.forwardRef<TextAreaRef, TextAreaProps>(
prefixCls = 'rc-textarea',
classes,
showCount,
count,
className,
style,
disabled,
Expand All @@ -69,11 +68,16 @@ const TextArea = React.forwardRef<TextAreaRef, TextAreaProps>(
value: customValue,
defaultValue,
});
const formatValue =
value === undefined || value === null ? '' : String(value);

const resizableTextAreaRef = useRef<ResizableTextAreaRef>(null);

const [focused, setFocused] = React.useState<boolean>(false);

const [compositing, setCompositing] = React.useState(false);
const compositionRef = React.useRef(false);

const oldCompositionValueRef = React.useRef<string>();
const oldSelectionStartRef = React.useRef<number>(0);
const [textareaResized, setTextareaResized] = React.useState<boolean>(null);
Expand All @@ -94,65 +98,101 @@ const TextArea = React.forwardRef<TextAreaRef, TextAreaProps>(
setFocused((prev) => !disabled && prev);
}, [disabled]);

// =========================== Value Update ===========================
// ============================== Count ===============================
const countConfig = useCount(count, showCount);
const mergedMax = countConfig.max ?? maxLength;

// Max length value
const hasMaxLength = Number(maxLength) > 0;
const hasMaxLength = Number(mergedMax) > 0;

const valueLength = countConfig.strategy(formatValue);

const isOutOfRange = !!mergedMax && valueLength > mergedMax;

// ============================== Change ==============================
const triggerChange = (
e:
| React.ChangeEvent<HTMLTextAreaElement>
| React.CompositionEvent<HTMLTextAreaElement>,
currentValue: string,
) => {
let cutValue = currentValue;

if (
!compositionRef.current &&
countConfig.exceedFormatter &&
countConfig.max &&
countConfig.strategy(currentValue) > countConfig.max
) {
cutValue = countConfig.exceedFormatter(currentValue, {
max: countConfig.max,
});
}
setValue(cutValue);

resolveOnChange(e.currentTarget, e, onChange, cutValue);
};

// =========================== Value Update ===========================
const onInternalCompositionStart: React.CompositionEventHandler<
HTMLTextAreaElement
> = (e) => {
setCompositing(true);
// 拼音输入前保存一份旧值
oldCompositionValueRef.current = value as string;
// 保存旧的光标位置
oldSelectionStartRef.current = e.currentTarget.selectionStart;
// setCompositing(true);
// // 拼音输入前保存一份旧值
// oldCompositionValueRef.current = value as string;
// // 保存旧的光标位置
// oldSelectionStartRef.current = e.currentTarget.selectionStart;
compositionRef.current = true;
onCompositionStart?.(e);
};

const onInternalCompositionEnd: React.CompositionEventHandler<
HTMLTextAreaElement
> = (e) => {
setCompositing(false);

let triggerValue = e.currentTarget.value;
if (hasMaxLength) {
const isCursorInEnd =
oldSelectionStartRef.current >= maxLength! + 1 ||
oldSelectionStartRef.current ===
oldCompositionValueRef.current?.length;
triggerValue = setTriggerValue(
isCursorInEnd,
oldCompositionValueRef.current as string,
triggerValue,
maxLength!,
);
}
// Patch composition onChange when value changed
if (triggerValue !== value) {
setValue(triggerValue);
resolveOnChange(e.currentTarget, e, onChange, triggerValue);
}
// setCompositing(false);

// let triggerValue = e.currentTarget.value;
// if (hasMaxLength) {
// const isCursorInEnd =
// oldSelectionStartRef.current >= maxLength! + 1 ||
// oldSelectionStartRef.current ===
// oldCompositionValueRef.current?.length;
// triggerValue = setTriggerValue(
// isCursorInEnd,
// oldCompositionValueRef.current as string,
// triggerValue,
// maxLength!,
// );
// }
// // Patch composition onChange when value changed
// if (triggerValue !== value) {
// setValue(triggerValue);
// resolveOnChange(e.currentTarget, e, onChange, triggerValue);
// }

compositionRef.current = false;
triggerChange(e, e.currentTarget.value);
onCompositionEnd?.(e);
};

const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
let triggerValue = e.target.value;
if (!compositing && hasMaxLength) {
// 1. 复制粘贴超过maxlength的情况 2.未超过maxlength的情况
const isCursorInEnd =
e.target.selectionStart >= maxLength! + 1 ||
e.target.selectionStart === triggerValue.length ||
!e.target.selectionStart;
triggerValue = setTriggerValue(
isCursorInEnd,
value as string,
triggerValue,
maxLength!,
);
}
setValue(triggerValue);
resolveOnChange(e.currentTarget, e, onChange, triggerValue);
const onInternalChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
// let triggerValue = e.target.value;
// if (!compositing && hasMaxLength) {
// // 1. 复制粘贴超过maxlength的情况 2.未超过maxlength的情况
// const isCursorInEnd =
// e.target.selectionStart >= maxLength! + 1 ||
// e.target.selectionStart === triggerValue.length ||
// !e.target.selectionStart;
// triggerValue = setTriggerValue(
// isCursorInEnd,
// value as string,
// triggerValue,
// maxLength!,
// );
// }
// setValue(triggerValue);
// resolveOnChange(e.currentTarget, e, onChange, triggerValue);
triggerChange(e, e.target.value);
};

const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
Expand Down Expand Up @@ -180,30 +220,28 @@ const TextArea = React.forwardRef<TextAreaRef, TextAreaProps>(
resolveOnChange(resizableTextAreaRef.current?.textArea, e, onChange);
};

let val = fixControlledValue(value);
// let val = value === null || value === undefined ? '' : String(value);

if (
!compositing &&
hasMaxLength &&
(customValue === null || customValue === undefined)
) {
// fix #27612 将value转为数组进行截取,解决 '😂'.length === 2 等emoji表情导致的截取乱码的问题
val = fixEmojiLength(val, maxLength!);
}
// if (
// !compositing &&
// hasMaxLength &&
// (customValue === null || customValue === undefined)
// ) {
// // fix #27612 将value转为数组进行截取,解决 '😂'.length === 2 等emoji表情导致的截取乱码的问题
// val = fixEmojiLength(val, maxLength!);
// }

let suffixNode = suffix;
let dataCount: ReactNode;
if (showCount) {
const valueLength = [...val].length;

if (typeof showCount === 'object') {
dataCount = showCount.formatter({
value: val,
if (countConfig.show) {
if (countConfig.showFormatter) {
dataCount = countConfig.showFormatter({
value: formatValue,
count: valueLength,
maxLength,
maxLength: mergedMax,
});
} else {
dataCount = `${valueLength}${hasMaxLength ? ` / ${maxLength}` : ''}`;
dataCount = `${valueLength}${hasMaxLength ? ` / ${mergedMax}` : ''}`;
}

suffixNode = (
Expand All @@ -230,7 +268,7 @@ const TextArea = React.forwardRef<TextAreaRef, TextAreaProps>(

const textarea = (
<BaseInput
value={val}
value={formatValue}
allowClear={allowClear}
handleReset={handleReset}
suffix={suffixNode}
Expand All @@ -243,7 +281,7 @@ const TextArea = React.forwardRef<TextAreaRef, TextAreaProps>(
}}
disabled={disabled}
focused={focused}
className={className}
className={clsx(className, isOutOfRange && `${prefixCls}-out-of-range`)}
style={{
...style,
...(textareaResized && !isPureTextArea ? { height: 'auto' } : {}),
Expand All @@ -258,12 +296,12 @@ const TextArea = React.forwardRef<TextAreaRef, TextAreaProps>(
<ResizableTextArea
{...rest}
onKeyDown={handleKeyDown}
onChange={handleChange}
onChange={onInternalChange}
onFocus={handleFocus}
onBlur={handleBlur}
onCompositionStart={onInternalCompositionStart}
onCompositionEnd={onInternalCompositionEnd}
className={classNames?.textarea}
className={clsx(classNames?.textarea)}
style={{ ...styles?.textarea, resize: style?.resize }}
disabled={disabled}
prefixCls={prefixCls}
Expand Down
6 changes: 3 additions & 3 deletions src/interface.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { BaseInputProps, ShowCountProps } from 'rc-input/lib/interface';
import type { BaseInputProps, InputProps } from 'rc-input/lib/interface';
import type React from 'react';
import type { CSSProperties } from 'react';

Expand All @@ -23,7 +23,6 @@ export type TextAreaProps = Omit<HTMLTextareaProps, 'onResize' | 'value'> & {
autoSize?: boolean | AutoSizeType;
onPressEnter?: React.KeyboardEventHandler<HTMLTextAreaElement>;
onResize?: (size: { width: number; height: number }) => void;
showCount?: boolean | ShowCountProps;
classes?: {
countWrapper?: string;
affixWrapper?: string;
Expand All @@ -36,7 +35,8 @@ export type TextAreaProps = Omit<HTMLTextareaProps, 'onResize' | 'value'> & {
textarea?: CSSProperties;
count?: CSSProperties;
};
} & Pick<BaseInputProps, 'allowClear' | 'suffix'>;
} & Pick<BaseInputProps, 'allowClear' | 'suffix'> &
Pick<InputProps, 'showCount' | 'count'>;

export type TextAreaRef = {
resizableTextArea: ResizableTextAreaRef;
Expand Down
Loading