diff --git a/packages/x/components/sender/Sender.tsx b/packages/x/components/sender/Sender.tsx index 7ac6597c0..db235a381 100644 --- a/packages/x/components/sender/Sender.tsx +++ b/packages/x/components/sender/Sender.tsx @@ -57,6 +57,7 @@ const ForwardSender = React.forwardRef((props, ref) => { header, onPaste, onPasteFile, + pasteFilter, autoSize = { maxRows: 8 }, placeholder, onFocus, @@ -226,6 +227,7 @@ const ForwardSender = React.forwardRef((props, ref) => { onKeyDown, onPaste, onPasteFile, + pasteFilter, disabled, readOnly, submitType, @@ -249,6 +251,7 @@ const ForwardSender = React.forwardRef((props, ref) => { onKeyDown, onPaste, onPasteFile, + pasteFilter, disabled, readOnly, submitType, diff --git a/packages/x/components/sender/__tests__/slot.test.tsx b/packages/x/components/sender/__tests__/slot.test.tsx index 1dfa46c1c..b715796f3 100644 --- a/packages/x/components/sender/__tests__/slot.test.tsx +++ b/packages/x/components/sender/__tests__/slot.test.tsx @@ -640,6 +640,54 @@ describe('Sender Slot Component', () => { expect(onPaste).toHaveBeenCalled(); }); + it('should call pasteFilter with the raw pasted text and use its result', () => { + document.execCommand = jest.fn(); + // pasteFilter replaces the default getCleanedText: it receives the raw + // pasted text (line breaks intact) and its return value is what's inserted. + const pasteFilter = jest.fn((text: string) => `<<${text}>>`); + const slotConfig = [textSlotConfig]; + const { container } = render(); + + const inputArea = container.querySelector('[role="textbox"]') as HTMLElement; + + fireEvent.paste(inputArea, { + clipboardData: { + // multi-line raw text: newlines are NOT stripped before pasteFilter. + getData: () => 'pasted\nmultiline\n', + files: [], + }, + }); + + expect(pasteFilter).toHaveBeenCalledTimes(1); + // Input to pasteFilter is the raw pasted text, with newlines preserved. + expect(pasteFilter.mock.calls[0][0]).toBe('pasted\nmultiline\n'); + // The filter's return value is what gets inserted. + expect(document.execCommand).toHaveBeenCalledWith( + 'insertText', + false, + '<>', + ); + }); + it('should apply pasteFilter that preserves line breaks', () => { + document.execCommand = jest.fn(); + // Returning the text as-is keeps the original line breaks in the + // inserted content, which the default getCleanedText would have stripped. + const pasteFilter = jest.fn((text: string) => text); + const slotConfig = [textSlotConfig]; + const { container } = render(); + + const inputArea = container.querySelector('[role="textbox"]') as HTMLElement; + + fireEvent.paste(inputArea, { + clipboardData: { + getData: () => 'a\nb\nc', + files: [], + }, + }); + + expect(pasteFilter).toHaveBeenCalledTimes(1); + expect(document.execCommand).toHaveBeenCalledWith('insertText', false, 'a\nb\nc'); + }); it('should handle select all keyboard shortcut', () => { const onKeyDown = jest.fn(); const onSubmit = jest.fn(); diff --git a/packages/x/components/sender/components/SlotTextArea.tsx b/packages/x/components/sender/components/SlotTextArea.tsx index 47963b01d..8fb283889 100644 --- a/packages/x/components/sender/components/SlotTextArea.tsx +++ b/packages/x/components/sender/components/SlotTextArea.tsx @@ -61,6 +61,7 @@ const SlotTextArea = React.forwardRef((_, ref) => { onKeyDown, onPaste, onPasteFile, + pasteFilter, disabled, readOnly, submitType = 'enter', @@ -647,7 +648,7 @@ const SlotTextArea = React.forwardRef((_, ref) => { } if (text) { let success = false; - const cleanedText = getCleanedText(text); + const cleanedText = pasteFilter ? pasteFilter(text) : getCleanedText(text); try { success = document.execCommand('insertText', false, cleanedText); } catch (err) { diff --git a/packages/x/components/sender/demo/paste-filter.md b/packages/x/components/sender/demo/paste-filter.md new file mode 100644 index 000000000..83c2bccd4 --- /dev/null +++ b/packages/x/components/sender/demo/paste-filter.md @@ -0,0 +1,7 @@ +## zh-CN + +通过 `pasteFilter` 自定义 slot 模式下粘贴文本的清洗逻辑,默认清洗会移除换行,传 `pasteFilter={(text) => text}` 即可保留原始排版。 + +## en-US + +Customize how pasted text is cleaned in slot mode with `pasteFilter`. The default cleaning strips line breaks; pass `pasteFilter={(text) => text}` to preserve the original formatting. diff --git a/packages/x/components/sender/demo/paste-filter.tsx b/packages/x/components/sender/demo/paste-filter.tsx new file mode 100644 index 000000000..72c36c724 --- /dev/null +++ b/packages/x/components/sender/demo/paste-filter.tsx @@ -0,0 +1,31 @@ +import { Sender, type SenderProps } from '@ant-design/x'; +import { Flex, Typography } from 'antd'; +import React from 'react'; + +type SlotConfig = SenderProps['slotConfig']; + +// A simple slot config so pasting lands inside the editable slot area. +const slotConfig: SlotConfig = [ + { type: 'text', value: 'Paste a code snippet or structured text: ' }, +]; + +const App: React.FC = () => { + return ( + +
+ + 默认情况下,slot 模式粘贴会移除换行。使用 pasteFilter 可保留原始排版。 + +
+ text} + /> +
+ ); +}; + +export default () => ; diff --git a/packages/x/components/sender/interface.ts b/packages/x/components/sender/interface.ts index 871d8aad0..9f94c8fc7 100644 --- a/packages/x/components/sender/interface.ts +++ b/packages/x/components/sender/interface.ts @@ -142,6 +142,14 @@ export interface SenderProps onKeyDown?: (event: React.KeyboardEvent) => void | false; onPaste?: React.ClipboardEventHandler; onPasteFile?: (files: FileList) => void; + /** + * Custom paste text filter. When provided, it replaces the default + * `getCleanedText` cleaning (which strips line breaks) and receives the raw + * pasted text. Should return the final text to insert. Useful to preserve + * formatting (e.g. line breaks); e.g. `pasteFilter={(text) => text}` keeps + * the original text as-is. Slot mode only. + */ + pasteFilter?: (text: string) => string; components?: SenderComponents; classNames?: Partial>; styles?: Partial>;