diff --git a/packages/x/components/sender/SlotTextArea.tsx b/packages/x/components/sender/SlotTextArea.tsx index f9e2ea503..c0ead26be 100644 --- a/packages/x/components/sender/SlotTextArea.tsx +++ b/packages/x/components/sender/SlotTextArea.tsx @@ -64,6 +64,7 @@ const SlotTextArea = React.forwardRef((_, ref) => { onFocus, onBlur, slotConfig, + maxLength, skill, ...restProps } = React.useContext(SenderContext); @@ -627,6 +628,16 @@ const SlotTextArea = React.forwardRef((_, ref) => { onBlur?.(e as unknown as React.FocusEvent); }; + // 获取当前选中文本长度 + const getSelectedTextLength = (): number => { + const selection = window.getSelection(); + if (selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + return range.toString().length; + } + return 0; + }; + const onInternalInput = (e: React.FormEvent) => { const newValue = getEditorValue(); removeSpecificBRs(editableRef?.current); @@ -647,7 +658,21 @@ const SlotTextArea = React.forwardRef((_, ref) => { } if (text) { - insert([{ type: 'text', value: text.replace(/\n/g, '') }]); + const currentValue = getEditorValue().value; + const newText = text.replace(/\n/g, ''); + + // 检查最大长度限制 + if (maxLength !== undefined) { + const selectedLength = getSelectedTextLength(); + const remainingLength = maxLength - (currentValue.length - selectedLength); + if (remainingLength <= 0) { + return; // 已达到最大长度,不再插入 + } + const truncatedText = newText.slice(0, remainingLength); + insert([{ type: 'text', value: truncatedText.replace(/\n/g, '') }]); + } else { + insert([{ type: 'text', value: newText.replace(/\n/g, '') }]); + } } onPaste?.(e as unknown as React.ClipboardEvent); @@ -705,6 +730,37 @@ const SlotTextArea = React.forwardRef((_, ref) => { const editableDom = editableRef.current; const selection = window.getSelection(); if (!editableDom || !selection) return; + + // 检查最大长度限制 + if (maxLength !== undefined) { + const currentValue = getEditorValue().value; + const selectedLength = getSelectedTextLength(); + const remainingLength = maxLength - (currentValue.length - selectedLength); + + if (remainingLength <= 0) { + return; + } + + // 渐进式截断文本slot + let remainingChars = remainingLength; + slotConfig = slotConfig.map((item) => { + if (item.type === 'text' && remainingChars > 0) { + const textLength = (item.value || '').length; + if (textLength <= remainingChars) { + remainingChars -= textLength; + return item; + } + const truncated = (item.value || '').slice(0, remainingChars); + remainingChars = 0; + return { ...item, value: truncated }; + } + if (item.type === 'text') { + return { ...item, value: '' }; + } + return item; + }); + } + const slotNode = getSlotListNode(slotConfig); const { type, range: lastRage } = getInsertPosition(position); let range: Range = document.createRange(); @@ -881,6 +937,18 @@ const SlotTextArea = React.forwardRef((_, ref) => { onBlur={onInternalBlur} onSelect={onInternalSelect} onInput={onInternalInput} + onBeforeInput={(e) => { + if (maxLength !== undefined) { + const currentValue = getEditorValue().value; + const selectedLength = getSelectedTextLength(); + const inputLength = (e as any).data?.length ?? 0; + const newLength = currentValue.length - selectedLength + inputLength; + if (newLength > maxLength) { + e.preventDefault(); + return; + } + } + }} {...(restProps as React.HTMLAttributes)} />
{ + const [value, setValue] = React.useState(''); + + return ( +
+ Sender with Length Limit + { + console.log('Submit:', msg); + setValue(''); + }} + /> + Custom Count Display + ( + maxLength * 0.8 ? 'red' : '#ccc', + fontSize: 10, + marginBottom: 4, + }} + > + {maxLength + ? `Entered ${count} characters, limit ${maxLength} characters` + : `Entered ${count} characters`} + + )} + placeholder="Custom count display..." + onSubmit={(msg) => { + console.log('Submit:', msg); + setValue(''); + }} + /> + Count Only, No Length Limit + { + console.log('Submit:', msg); + setValue(''); + }} + /> +
+ ); +}; + +export default App; diff --git a/packages/x/components/sender/index.en-US.md b/packages/x/components/sender/index.en-US.md index 25e48bbea..a7a8ecf9a 100644 --- a/packages/x/components/sender/index.en-US.md +++ b/packages/x/components/sender/index.en-US.md @@ -31,6 +31,7 @@ coverDark: https://mdn.alipayobjects.com/huamei_iwk9zp/afts/img/A*cOfrS4fVkOMAAA Custom Footer Content Style Adjustment Paste Files +Max Length ## API @@ -50,6 +51,8 @@ Common props ref:[Common props](/docs/react/common-props) | header | Header panel | React.ReactNode \| false \| (oriNode: React.ReactNode, info: { components: ActionsComponents; }) => React.ReactNode \| false | false | - | | prefix | Prefix content | React.ReactNode \| false \| (oriNode: React.ReactNode, info: { components: ActionsComponents; }) => React.ReactNode \| false | false | - | | footer | Footer content | React.ReactNode \| false \| (oriNode: React.ReactNode, info: { components: ActionsComponents; }) => React.ReactNode \| false | false | - | +| maxLength | Maximum length of input content | number | - | - | +| showCount | Whether to display character count, supports custom rendering | boolean \| ((info: { value: string; count: number; maxLength?: number }) => React.ReactNode) | false | - | | readOnly | Whether to make the input box read-only | boolean | false | - | | rootClassName | Root element style class | string | - | - | | styles | Semantic style definition | [See below](#semantic-dom) | - | - | diff --git a/packages/x/components/sender/index.tsx b/packages/x/components/sender/index.tsx index 2e7f71cd1..941b18f5f 100644 --- a/packages/x/components/sender/index.tsx +++ b/packages/x/components/sender/index.tsx @@ -79,6 +79,7 @@ const ForwardSender = React.forwardRef((props, ref) => { placeholder, onFocus, onBlur, + showCount, skill, ...restProps } = props; @@ -141,7 +142,7 @@ const ForwardSender = React.forwardRef((props, ref) => { const [innerValue, setInnerValue] = useMergedState(defaultValue || '', { value, }); - + const currentCount = innerValue.length; const triggerValueChange: SenderProps['onChange'] = (nextValue, event, slotConfig) => { if (slotConfig) { setInnerValue(nextValue); @@ -221,6 +222,26 @@ const ForwardSender = React.forwardRef((props, ref) => { : header || null; // ============================ Footer ============================ + const renderCount = () => { + if (!showCount) return null; + + const countInfo = { + value: innerValue, + count: currentCount, + maxLength: restProps.maxLength, + }; + + if (typeof showCount === 'function') { + return showCount(countInfo); + } + + return ( +
+ {restProps.maxLength ? `${currentCount}/${restProps.maxLength}` : currentCount} +
+ ); + }; + const footerNode = typeof footer === 'function' ? footer(actionNode, { components: sharedRenderComponents }) @@ -383,6 +404,7 @@ const ForwardSender = React.forwardRef((props, ref) => { )} + {showCount && renderCount()}
); }); diff --git a/packages/x/components/sender/index.zh-CN.md b/packages/x/components/sender/index.zh-CN.md index 9f2ed72ae..69bff4018 100644 --- a/packages/x/components/sender/index.zh-CN.md +++ b/packages/x/components/sender/index.zh-CN.md @@ -32,6 +32,7 @@ coverDark: https://mdn.alipayobjects.com/huamei_iwk9zp/afts/img/A*cOfrS4fVkOMAAA 自定义底部内容 调整样式 黏贴文件 +长度限制 ## API @@ -51,6 +52,8 @@ coverDark: https://mdn.alipayobjects.com/huamei_iwk9zp/afts/img/A*cOfrS4fVkOMAAA | header | 头部面板 | React.ReactNode \| false \|(oriNode: React.ReactNode,info: { components: ActionsComponents;}) => React.ReactNode \| false; | false | - | | prefix | 前缀内容 | React.ReactNode \| false \|(oriNode: React.ReactNode,info: { components: ActionsComponents;}) => React.ReactNode \| false; | false | - | | footer | 底部内容 | React.ReactNode \| false \|(oriNode: React.ReactNode,info: { components: ActionsComponents;}) => React.ReactNode \| false; | false | - | +| maxLength | 输入内容最大长度 | number | - | - | +| showCount | 是否显示字符计数,支持自定义渲染 | boolean \| ((info: { value: string; count: number; maxLength?: number }) => React.ReactNode) | false | - | | readOnly | 是否让输入框只读 | boolean | false | - | | rootClassName | 根元素样式类 | string | - | - | | styles | 语义化定义样式 | [见下](#semantic-dom) | - | - | diff --git a/packages/x/components/sender/interface.ts b/packages/x/components/sender/interface.ts index 58b7bd199..45a9dab0a 100644 --- a/packages/x/components/sender/interface.ts +++ b/packages/x/components/sender/interface.ts @@ -154,6 +154,10 @@ export interface SenderProps suffix?: BaseNode | NodeRender; header?: BaseNode | NodeRender; autoSize?: boolean | { minRows?: number; maxRows?: number }; + maxLength?: number; + showCount?: + | boolean + | ((info: { value: string; count: number; maxLength?: number }) => React.ReactNode); skill?: SkillType; } diff --git a/packages/x/components/sender/style/index.ts b/packages/x/components/sender/style/index.ts index 03171c6a3..7b73d0571 100644 --- a/packages/x/components/sender/style/index.ts +++ b/packages/x/components/sender/style/index.ts @@ -156,6 +156,15 @@ const genSenderStyle: GenerateStyle = (token) => { paddingBlockStart: paddingXXS, boxSizing: 'border-box', }, + // ============================ Count ============================ + [`${componentCls}-count`]: { + color: token.colorTextDescription, + fontSize: token.fontSizeSM, + lineHeight: token.lineHeightSM, + position: 'absolute', + bottom: -token.paddingXXS, + insetInlineEnd: token.paddingSM, + }, }, }; };