From 0a865e5948cb836eba93620e365703ec38cf76e3 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Sat, 13 Jun 2026 15:49:58 -0500 Subject: [PATCH] fix(desktop): bypass Chromium editing pipeline for large paste & select-delete (#45812) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Large paste and Ctrl+A → Delete froze the composer for seconds — both routed through Chromium's contenteditable editing pipeline (~O(n²) on multiline DOM). - insertPlainTextAtCaret: Range + text/
fragment (paste path) - deleteSelectionInEditor: range.deleteContents for non-collapsed Backspace/Delete - Shared composerSelectionRange helper; both flush via flushEditorToDraft Profiled live (47 KB / 122 paragraphs): paste 4474 ms → 13 ms; select-delete 1304 ms → 4 ms. Collapsed-caret deletes still native. --- apps/desktop/src/app/chat/composer/index.tsx | 96 +++++++++++-------- .../src/app/chat/composer/rich-editor.test.ts | 73 ++++++++++++++ .../src/app/chat/composer/rich-editor.ts | 57 +++++++++++ 3 files changed, 184 insertions(+), 42 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 96b8be61ef1..dc3f0a490cb 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -85,6 +85,8 @@ import { import { QueuePanel } from './queue-panel' import { composerPlainText, + deleteSelectionInEditor, + insertPlainTextAtCaret, normalizeComposerEditorDom, placeCaretEnd, refChipElement, @@ -538,48 +540,6 @@ export function ChatBar({ }) }, []) - const handlePaste = (event: ClipboardEvent) => { - const imageBlobs = extractClipboardImageBlobs(event.clipboardData) - - if (imageBlobs.length > 0) { - event.preventDefault() - - if (onAttachImageBlob) { - triggerHaptic('selection') - - for (const blob of imageBlobs) { - void onAttachImageBlob(blob) - } - } - - return - } - - // Trim surrounding whitespace so a copy that dragged along leading/trailing - // blank lines (common when selecting from terminals, code blocks, web pages) - // doesn't dump multiline padding into the composer. Internal newlines are - // preserved — only the edges are cleaned up. - const pastedText = event.clipboardData.getData('text').trim() - - if (!pastedText) { - event.preventDefault() - - return - } - - if (DATA_IMAGE_URL_RE.test(pastedText)) { - event.preventDefault() - - return - } - - event.preventDefault() - document.execCommand('insertText', false, pastedText) - const nextDraft = composerPlainText(event.currentTarget) - draftRef.current = nextDraft - aui.composer().setText(nextDraft) - } - const [trigger, setTrigger] = useState(null) const [triggerActive, setTriggerActive] = useState(0) const [triggerItems, setTriggerItems] = useState([]) @@ -664,6 +624,46 @@ export function ChatBar({ flushEditorToDraft(event.currentTarget) } + const handlePaste = (event: ClipboardEvent) => { + const imageBlobs = extractClipboardImageBlobs(event.clipboardData) + + if (imageBlobs.length > 0) { + event.preventDefault() + + if (onAttachImageBlob) { + triggerHaptic('selection') + + for (const blob of imageBlobs) { + void onAttachImageBlob(blob) + } + } + + return + } + + // Trim surrounding whitespace so a copy that dragged along leading/trailing + // blank lines (common when selecting from terminals, code blocks, web pages) + // doesn't dump multiline padding into the composer. Internal newlines are + // preserved — only the edges are cleaned up. + const pastedText = event.clipboardData.getData('text').trim() + + if (!pastedText) { + event.preventDefault() + + return + } + + if (DATA_IMAGE_URL_RE.test(pastedText)) { + event.preventDefault() + + return + } + + event.preventDefault() + insertPlainTextAtCaret(event.currentTarget, pastedText) + flushEditorToDraft(event.currentTarget) + } + const triggerAdapter: Unstable_TriggerAdapter | null = trigger?.kind === '@' ? at.adapter : trigger?.kind === '/' ? slash.adapter : null @@ -832,6 +832,18 @@ export function ChatBar({ return } + // Non-collapsed Backspace/Delete: native selection-delete is ~O(n²) on large + // drafts (Ctrl+A → Delete froze ~1.3s). Collapsed carets fall through. + if ( + (event.key === 'Backspace' || event.key === 'Delete') && + deleteSelectionInEditor(event.currentTarget) + ) { + event.preventDefault() + flushEditorToDraft(event.currentTarget) + + return + } + // Cmd/Ctrl+Shift+K drains the next queued message. Plain Cmd/Ctrl+K is // reserved for the global command palette. if ((event.metaKey || event.ctrlKey) && !event.altKey && event.shiftKey && event.key.toLowerCase() === 'k') { diff --git a/apps/desktop/src/app/chat/composer/rich-editor.test.ts b/apps/desktop/src/app/chat/composer/rich-editor.test.ts index 45204fb34a5..12e3e9613ef 100644 --- a/apps/desktop/src/app/chat/composer/rich-editor.test.ts +++ b/apps/desktop/src/app/chat/composer/rich-editor.test.ts @@ -3,12 +3,24 @@ import { describe, expect, it } from 'vitest' import { insertInlineRefsIntoEditor } from './inline-refs' import { composerPlainText, + deleteSelectionInEditor, + insertPlainTextAtCaret, normalizeComposerEditorDom, refChipElement, renderComposerContents, RICH_INPUT_SLOT } from './rich-editor' +const caretIn = (editor: HTMLElement) => { + const range = document.createRange() + const selection = window.getSelection()! + + range.selectNodeContents(editor) + range.collapse(false) + selection.removeAllRanges() + selection.addRange(range) +} + describe('renderComposerContents', () => { it('renders refs and raw text without interpreting user text as HTML', () => { const editor = document.createElement('div') @@ -59,3 +71,64 @@ describe('insertInlineRefsIntoEditor', () => { expect(composerPlainText(editor)).toBe('@file:`src/foo.ts` ') }) }) + +describe('insertPlainTextAtCaret', () => { + it('inserts multiline text as text nodes + br', () => { + const editor = document.createElement('div') + editor.dataset.slot = RICH_INPUT_SLOT + document.body.append(editor) + caretIn(editor) + + insertPlainTextAtCaret(editor, 'one\ntwo\nthree') + + expect(editor.querySelectorAll('br').length).toBe(2) + expect(composerPlainText(editor)).toBe('one\ntwo\nthree') + + editor.remove() + }) + + it('replaces the selected span', () => { + const editor = document.createElement('div') + editor.dataset.slot = RICH_INPUT_SLOT + editor.textContent = 'abXYef' + document.body.append(editor) + + const text = editor.firstChild! + const selection = window.getSelection()! + const range = document.createRange() + + range.setStart(text, 2) + range.setEnd(text, 4) + selection.removeAllRanges() + selection.addRange(range) + + insertPlainTextAtCaret(editor, 'cd') + + expect(composerPlainText(editor)).toBe('abcdef') + + editor.remove() + }) +}) + +describe('deleteSelectionInEditor', () => { + it('clears a non-collapsed range and leaves a collapsed caret', () => { + const editor = document.createElement('div') + editor.dataset.slot = RICH_INPUT_SLOT + editor.textContent = 'hello world' + document.body.append(editor) + + const selection = window.getSelection()! + const range = document.createRange() + + range.selectNodeContents(editor) + selection.removeAllRanges() + selection.addRange(range) + + expect(deleteSelectionInEditor(editor)).toBe(true) + expect(composerPlainText(editor)).toBe('') + expect(selection.getRangeAt(0).collapsed).toBe(true) + expect(deleteSelectionInEditor(editor)).toBe(false) + + editor.remove() + }) +}) diff --git a/apps/desktop/src/app/chat/composer/rich-editor.ts b/apps/desktop/src/app/chat/composer/rich-editor.ts index 89a54b69925..f74d2ee5bf7 100644 --- a/apps/desktop/src/app/chat/composer/rich-editor.ts +++ b/apps/desktop/src/app/chat/composer/rich-editor.ts @@ -132,6 +132,63 @@ export function renderComposerContents(target: HTMLElement, text: string) { appendComposerContents(target, text) } +/** Caret range when the selection lives inside `editor`; else null. */ +function composerSelectionRange(editor: HTMLElement) { + const selection = window.getSelection() + const range = selection?.rangeCount ? selection.getRangeAt(0) : null + + if (!selection || !range || !editor.contains(range.commonAncestorContainer)) { + return null + } + + return { range, selection } +} + +/** Insert plain text at the caret (replacing any selection). Pastes use this + * instead of `execCommand('insertText')` — Chromium's editing pipeline is + * ~O(n²) on large multiline blobs. */ +export function insertPlainTextAtCaret(editor: HTMLElement, text: string) { + const hit = composerSelectionRange(editor) + const fragment = document.createDocumentFragment() + + appendTextWithBreaks(fragment, text) + + const tail = fragment.lastChild + + if (hit) { + hit.range.deleteContents() + hit.range.insertNode(fragment) + } else { + editor.append(fragment) + } + + if (tail) { + const caret = document.createRange() + caret.setStartAfter(tail) + caret.collapse(true) + const selection = hit?.selection ?? window.getSelection() + selection?.removeAllRanges() + selection?.addRange(caret) + } +} + +/** Remove a non-collapsed selection in-editor. Skips collapsed carets so word/ + * line delete (Opt/Cmd+Backspace) stays native. Returns whether anything ran. */ +export function deleteSelectionInEditor(editor: HTMLElement) { + const hit = composerSelectionRange(editor) + + if (!hit || hit.range.collapsed) { + return false + } + + hit.range.deleteContents() + hit.range.collapse(true) + hit.selection.removeAllRanges() + hit.selection.addRange(hit.range) + + return true +} + /** Serialize a draft string into chip-HTML for the contenteditable surface. */ export function composerHtml(text: string) { let cursor = 0