From 8d052ad59b913e437a2e4dc83810f6364c96add6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=80=90=E6=9C=88?= <203219348@qq.com> Date: Tue, 25 Aug 2026 13:05:23 +0800 Subject: [PATCH] fix(sender): delete newlines before slots Why: - Chromium leaves a block wrapper when Backspace removes the leading break from a line that starts with a non-editable slot. - The remaining wrapper keeps the visual newline even though the break node is gone. What: - Detect the collapsed caret at the affected slot line boundary. - Unwrap the line, preserve the slot nodes, and restore the caret before the slot. - Add a regression test for Enter then Backspace before a slot. Testing: - Sender slot Jest suite (23 tests) - TypeScript noEmit check - Biome check and git diff --check - Real Chromium before/after interaction --- .../components/sender/__tests__/slot.test.tsx | 49 +++++++++++++++++++ .../sender/components/SlotTextArea.tsx | 36 ++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/packages/x/components/sender/__tests__/slot.test.tsx b/packages/x/components/sender/__tests__/slot.test.tsx index 1dfa46c1c..5d418b69c 100644 --- a/packages/x/components/sender/__tests__/slot.test.tsx +++ b/packages/x/components/sender/__tests__/slot.test.tsx @@ -691,6 +691,55 @@ describe('Sender Slot Component', () => { setupDOMMocks(customSelectionMock, customRangeMock); fireEvent.keyDown(dom, { key: 'Backspace' }); }); + it('should remove a newline immediately before a slot', () => { + const onChange = jest.fn(); + const ref = createRef(); + const { container } = render( + , + ); + const dom = ref.current?.inputElement as HTMLElement; + const slotDom = container.querySelector('[data-slot-key="tag1"]') as HTMLElement; + const trailingText = Array.from(dom.childNodes).find( + (node) => node.nodeType === Node.TEXT_NODE && node.textContent === ' text after slot', + ) as Text; + const nextLine = document.createElement('div'); + + nextLine.append(document.createElement('br'), slotDom, trailingText); + dom.append(nextLine); + onChange.mockClear(); + + setupDOMMocks( + { + rangeCount: 1, + focusOffset: 0, + anchorNode: nextLine, + anchorOffset: 0, + focusNode: nextLine, + isCollapsed: true, + }, + { + startContainer: nextLine, + endContainer: nextLine, + startOffset: 0, + endOffset: 0, + collapsed: true, + }, + ); + + expect(fireEvent.keyDown(dom, { key: 'Backspace' })).toBe(false); + expect(nextLine).not.toBeInTheDocument(); + expect(slotDom.parentElement).toBe(dom); + expect(onChange).toHaveBeenCalledTimes(1); + }); it('should handle backspace key in content slot', () => { const onSubmit = jest.fn(); const slotConfig = [contentSlotConfigWithValue]; diff --git a/packages/x/components/sender/components/SlotTextArea.tsx b/packages/x/components/sender/components/SlotTextArea.tsx index 47963b01d..23e152c46 100644 --- a/packages/x/components/sender/components/SlotTextArea.tsx +++ b/packages/x/components/sender/components/SlotTextArea.tsx @@ -493,6 +493,42 @@ const SlotTextArea = React.forwardRef((_, ref) => { return false; } + // Chromium keeps a block wrapper after deleting the leading
of a line that starts + // with a non-editable slot. Merge that line explicitly so Backspace removes the newline. + if ( + operationType === 'backspace' && + range?.collapsed && + focusOffset === 0 && + anchorNode.nodeType === Node.ELEMENT_NODE + ) { + const editableDom = editableRef.current; + const line = anchorNode as HTMLElement; + const lineBreak = line.firstChild; + const firstContentNode = lineBreak?.nextSibling; + const firstContentInfo = + firstContentNode?.nodeType === Node.ELEMENT_NODE + ? getNodeInfo(firstContentNode as HTMLElement) + : null; + + if ( + line.parentElement === editableDom && + line.previousSibling && + lineBreak?.nodeName === 'BR' && + firstContentInfo?.slotKey + ) { + const lineIndex = Array.from(editableDom.childNodes).indexOf(line); + e.preventDefault(); + lineBreak.remove(); + while (line.firstChild) { + editableDom.insertBefore(line.firstChild, line); + } + line.remove(); + setCursorPosition(editableDom, editableDom, lineIndex); + triggerValueChange(e as unknown as EventType); + return true; + } + } + // 处理文本节点中的slot删除 if (anchorNode.nodeType === Node.TEXT_NODE && range) { const parentElement = anchorNode.parentNode as HTMLElement;