From 50b65a1c8363f186b99f1055e4b4e7e8066179da Mon Sep 17 00:00:00 2001 From: Sebastian Elsner Date: Thu, 9 Jul 2026 00:48:59 +0200 Subject: [PATCH 1/5] Add replace-with-answer button to selection tools When the selection toolbar is triggered on text inside an editable target (input, textarea, or contenteditable element), the editable selection is captured before it gets lost by interacting with the toolbar. Completed answers in the floating toolbar then show a Replace button next to Copy that writes the answer back over the originally selected text, e.g. to apply a polished or translated version in place. The replacement verifies the original text is still present before writing (stale captures never overwrite changed content), inserts plain text only (never parsed as HTML), prefers execCommand('insertText') so native undo keeps working, and falls back to setting the value through the prototype setter and dispatching an input event so pages with controlled inputs (React and similar) observe the change. Static page content is intentionally not replaceable, and other users of ConversationCard and FloatingToolbar (popup, independent panel, site adapters) are unaffected since the capture is only wired up in the selection tools path. Co-Authored-By: Claude Fable 5 --- src/_locales/de/main.json | 2 + src/_locales/en/main.json | 2 + src/_locales/es/main.json | 2 + src/_locales/fr/main.json | 2 + src/_locales/in/main.json | 2 + src/_locales/it/main.json | 2 + src/_locales/ja/main.json | 2 + src/_locales/ko/main.json | 2 + src/_locales/pt/main.json | 2 + src/_locales/ru/main.json | 2 + src/_locales/tr/main.json | 2 + src/_locales/zh-hans/main.json | 2 + src/_locales/zh-hant/main.json | 2 + src/components/ConversationCard/index.jsx | 6 + src/components/ConversationItem/index.jsx | 5 +- src/components/FloatingToolbar/index.jsx | 13 +- src/components/ReplaceButton/index.jsx | 43 +++ src/content-script/index.jsx | 10 +- src/utils/index.mjs | 1 + src/utils/selection-replace.mjs | 156 ++++++++++ tests/unit/utils/selection-replace.test.mjs | 329 ++++++++++++++++++++ 21 files changed, 584 insertions(+), 5 deletions(-) create mode 100644 src/components/ReplaceButton/index.jsx create mode 100644 src/utils/selection-replace.mjs create mode 100644 tests/unit/utils/selection-replace.test.mjs diff --git a/src/_locales/de/main.json b/src/_locales/de/main.json index e09c196aa..abac86f74 100644 --- a/src/_locales/de/main.json +++ b/src/_locales/de/main.json @@ -85,6 +85,8 @@ "Confirm": "Bestätigen", "Clear Conversation": "Konversation löschen", "Retry": "Erneut versuchen", + "Replace Selection": "Auswahl ersetzen", + "Replace Failed": "Ersetzen fehlgeschlagen", "Exceeded maximum context length": "Maximale Kontextlänge überschritten, bitte Konversation löschen und erneut versuchen", "Regenerate the answer after switching model": "Antwort nach dem Wechseln des Modells neu generieren", "Pin": "Anheften", diff --git a/src/_locales/en/main.json b/src/_locales/en/main.json index 800478fc7..1da6e22c0 100644 --- a/src/_locales/en/main.json +++ b/src/_locales/en/main.json @@ -92,6 +92,8 @@ "Confirm": "Confirm", "Clear Conversation": "Clear Conversation", "Retry": "Retry", + "Replace Selection": "Replace Selection", + "Replace Failed": "Replace Failed", "Exceeded maximum context length": "Exceeded maximum context length, please clear the conversation and try again", "Regenerate the answer after switching model": "Regenerate the answer after switching model", "Pin": "Pin", diff --git a/src/_locales/es/main.json b/src/_locales/es/main.json index 5e0f6e553..0dee67104 100644 --- a/src/_locales/es/main.json +++ b/src/_locales/es/main.json @@ -83,6 +83,8 @@ "Confirm": "Confirmar", "Clear Conversation": "Borrar conversación", "Retry": "Reintentar", + "Replace Selection": "Reemplazar selección", + "Replace Failed": "Error al reemplazar", "Exceeded maximum context length": "Se superó la longitud máxima del contexto, borre la conversación y vuelva a intentarlo", "Regenerate the answer after switching model": "Regenerar la respuesta después de cambiar el modelo", "Pin": "Fijar", diff --git a/src/_locales/fr/main.json b/src/_locales/fr/main.json index b125b3975..5d3bf186e 100644 --- a/src/_locales/fr/main.json +++ b/src/_locales/fr/main.json @@ -83,6 +83,8 @@ "Confirm": "Confirmer", "Clear Conversation": "Effacer la conversation", "Retry": "Réessayer", + "Replace Selection": "Remplacer la sélection", + "Replace Failed": "Échec du remplacement", "Exceeded maximum context length": "Dépassement de la longueur maximale de contexte, veuillez effacer la conversation et réessayer", "Regenerate the answer after switching model": "Régénérer la réponse après avoir changé de modèle", "Pin": "Épingler", diff --git a/src/_locales/in/main.json b/src/_locales/in/main.json index 99619e5bf..92bbf51f1 100644 --- a/src/_locales/in/main.json +++ b/src/_locales/in/main.json @@ -83,6 +83,8 @@ "Confirm": "Konfirmasi", "Clear Conversation": "Bersihkan Percakapan", "Retry": "Coba Lagi", + "Replace Selection": "Ganti Teks Terpilih", + "Replace Failed": "Gagal Mengganti", "Exceeded maximum context length": "Melampaui batas maksimum panjang konteks, harap bersihkan percakapan dan coba lagi", "Regenerate the answer after switching model": "Hasilkan kembali jawaban setelah beralih ke model lain", "Pin": "Sematkan", diff --git a/src/_locales/it/main.json b/src/_locales/it/main.json index a7146afad..ff68d5f7b 100644 --- a/src/_locales/it/main.json +++ b/src/_locales/it/main.json @@ -83,6 +83,8 @@ "Confirm": "Conferma", "Clear Conversation": "Pulisci la conversazione", "Retry": "Riprova", + "Replace Selection": "Sostituisci selezione", + "Replace Failed": "Sostituzione non riuscita", "Exceeded maximum context length": "Lunghezza massima del contesto superata, si prega di pulire la conversazione e riprovare", "Regenerate the answer after switching model": "Rigenerare la risposta dopo aver cambiato il modello", "Pin": "Fissa", diff --git a/src/_locales/ja/main.json b/src/_locales/ja/main.json index 38d91a4ec..2bf492aad 100644 --- a/src/_locales/ja/main.json +++ b/src/_locales/ja/main.json @@ -83,6 +83,8 @@ "Confirm": "確認", "Clear Conversation": "会話をクリア", "Retry": "再試行", + "Replace Selection": "選択範囲を置換", + "Replace Failed": "置換に失敗しました", "Exceeded maximum context length": "最大コンテキスト長を超えました。会話をクリアして再試行してください", "Regenerate the answer after switching model": "モデルを切り替えた後に回答を再生成", "Pin": "ピン留め", diff --git a/src/_locales/ko/main.json b/src/_locales/ko/main.json index 595559dcb..5a4698e93 100644 --- a/src/_locales/ko/main.json +++ b/src/_locales/ko/main.json @@ -83,6 +83,8 @@ "Confirm": "확인", "Clear Conversation": "대화 내용 지우기", "Retry": "재시도", + "Replace Selection": "선택 영역 바꾸기", + "Replace Failed": "바꾸기 실패", "Exceeded maximum context length": "최대 컨텍스트 길이를 초과하였습니다. 대화 내용을 지우고 다시 시도해주세요.", "Regenerate the answer after switching model": "모델 전환 후 대답 다시 생성", "Pin": "고정", diff --git a/src/_locales/pt/main.json b/src/_locales/pt/main.json index 88db48125..d8d935c2d 100644 --- a/src/_locales/pt/main.json +++ b/src/_locales/pt/main.json @@ -83,6 +83,8 @@ "Confirm": "Confirmar", "Clear Conversation": "Limpar Conversa", "Retry": "Tentar novamente", + "Replace Selection": "Substituir seleção", + "Replace Failed": "Falha ao substituir", "Exceeded maximum context length": "Ultrapassou o comprimento máximo do contexto. Limpe a conversa e tente novamente", "Regenerate the answer after switching model": "Regenerar a resposta após trocar o modelo", "Pin": "Fixar", diff --git a/src/_locales/ru/main.json b/src/_locales/ru/main.json index f0cdbcbad..982e7f82f 100644 --- a/src/_locales/ru/main.json +++ b/src/_locales/ru/main.json @@ -91,6 +91,8 @@ "Confirm": "Подтверждение", "Clear Conversation": "Очистить беседу", "Retry": "Повторить", + "Replace Selection": "Заменить выделенное", + "Replace Failed": "Не удалось заменить", "Exceeded maximum context length": "Превышена максимальная длина контекста, очистите беседу и повторите попытку", "Regenerate the answer after switching model": "Генерировать ответ после смены модели", "Pin": "Закрепить", diff --git a/src/_locales/tr/main.json b/src/_locales/tr/main.json index 438f017c1..c0f350cea 100644 --- a/src/_locales/tr/main.json +++ b/src/_locales/tr/main.json @@ -83,6 +83,8 @@ "Confirm": "Onayla", "Clear Conversation": "Konuşmayı Temizle", "Retry": "Tekrar Dene", + "Replace Selection": "Seçimi Değiştir", + "Replace Failed": "Değiştirme Başarısız", "Exceeded maximum context length": "Maksimum bağlam uzunluğu aşıldı", "Regenerate the answer after switching model": "Modeli değiştirdikten sonra cevabı yeniden oluştur", "Pin": "Sabitle", diff --git a/src/_locales/zh-hans/main.json b/src/_locales/zh-hans/main.json index 8a02d2d3f..f8ff8a82b 100644 --- a/src/_locales/zh-hans/main.json +++ b/src/_locales/zh-hans/main.json @@ -86,6 +86,8 @@ "Confirm": "确认", "Clear Conversation": "清理对话", "Retry": "重试", + "Replace Selection": "替换选中文本", + "Replace Failed": "替换失败", "Exceeded maximum context length": "超出最大上下文长度, 请清理对话并重试", "Regenerate the answer after switching model": "快捷切换模型时自动重新生成回答", "Pin": "固定侧边", diff --git a/src/_locales/zh-hant/main.json b/src/_locales/zh-hant/main.json index 8b1c632d8..d3dde49eb 100644 --- a/src/_locales/zh-hant/main.json +++ b/src/_locales/zh-hant/main.json @@ -86,6 +86,8 @@ "Confirm": "確認", "Clear Conversation": "清除對話", "Retry": "重試", + "Replace Selection": "取代選取文字", + "Replace Failed": "取代失敗", "Exceeded maximum context length": "超出最大上下文長度,請清除對話並重試", "Regenerate the answer after switching model": "切換模型後自動重新產生回答", "Pin": "固定側邊", diff --git a/src/components/ConversationCard/index.jsx b/src/components/ConversationCard/index.jsx index f7137506e..a2f91ccce 100644 --- a/src/components/ConversationCard/index.jsx +++ b/src/components/ConversationCard/index.jsx @@ -583,6 +583,11 @@ function ConversationCard(props) { type={data.type} descName={data.type === 'answer' && currentAiName} onRetry={idx === conversationItemData.length - 1 ? retryFn : null} + onReplace={ + data.type === 'answer' && data.done && props.onReplaceContent + ? () => props.onReplaceContent(data.content.replace(/\n$/, '')) + : null + } /> ))} @@ -641,6 +646,7 @@ ConversationCard.propTypes = { session: PropTypes.object.isRequired, question: PropTypes.string, onUpdate: PropTypes.func, + onReplaceContent: PropTypes.func, draggable: PropTypes.bool, closeable: PropTypes.bool, onClose: PropTypes.func, diff --git a/src/components/ConversationItem/index.jsx b/src/components/ConversationItem/index.jsx index d9e9e9f35..e099a07fd 100644 --- a/src/components/ConversationItem/index.jsx +++ b/src/components/ConversationItem/index.jsx @@ -2,6 +2,7 @@ import { memo, useState } from 'react' import { ChevronDownIcon, XCircleIcon, SyncIcon } from '@primer/octicons-react' import CopyButton from '../CopyButton' import ReadButton from '../ReadButton' +import ReplaceButton from '../ReplaceButton' import PropTypes from 'prop-types' import MarkdownRender from '../MarkdownRender/markdown.jsx' import { useTranslation } from 'react-i18next' @@ -16,7 +17,7 @@ AnswerTitle.propTypes = { descName: PropTypes.string, } -export function ConversationItem({ type, content, descName, onRetry }) { +export function ConversationItem({ type, content, descName, onRetry, onReplace }) { const { t } = useTranslation() const [collapsed, setCollapsed] = useState(false) @@ -62,6 +63,7 @@ export function ConversationItem({ type, content, descName, onRetry }) { )} + {descName && onReplace && } {descName && ( content.replace(/\n$/, '')} size={14} /> )} @@ -130,6 +132,7 @@ ConversationItem.propTypes = { content: PropTypes.string.isRequired, descName: PropTypes.string, onRetry: PropTypes.func, + onReplace: PropTypes.func, } export default memo(ConversationItem) diff --git a/src/components/FloatingToolbar/index.jsx b/src/components/FloatingToolbar/index.jsx index e44665ec1..4366896c3 100644 --- a/src/components/FloatingToolbar/index.jsx +++ b/src/components/FloatingToolbar/index.jsx @@ -2,7 +2,12 @@ import { cloneElement, useCallback, useEffect, useState } from 'react' import ConversationCard from '../ConversationCard' import PropTypes from 'prop-types' import { config as toolsConfig } from '../../content-script/selection-tools' -import { getClientPosition, isMobile, setElementPositionInViewport } from '../../utils' +import { + getClientPosition, + isMobile, + replaceCapturedSelection, + setElementPositionInViewport, +} from '../../utils' import Draggable from 'react-draggable' import { useClampWindowSize } from '../../hooks/use-clamp-window-size' import { useTranslation } from 'react-i18next' @@ -106,6 +111,11 @@ function FloatingToolbar(props) { dockable={props.dockable} onDock={onDock} onUpdate={onUpdate} + onReplaceContent={ + props.capturedSelection + ? (content) => replaceCapturedSelection(props.capturedSelection, content) + : null + } waitForTrigger={prompt && !triggered && !selection} /> @@ -168,6 +178,7 @@ FloatingToolbar.propTypes = { closeable: PropTypes.bool, dockable: PropTypes.bool, prompt: PropTypes.string, + capturedSelection: PropTypes.object, } export default FloatingToolbar diff --git a/src/components/ReplaceButton/index.jsx b/src/components/ReplaceButton/index.jsx new file mode 100644 index 000000000..4d4f99150 --- /dev/null +++ b/src/components/ReplaceButton/index.jsx @@ -0,0 +1,43 @@ +import { useRef, useState } from 'react' +import { CheckIcon, ReplyIcon, XIcon } from '@primer/octicons-react' +import PropTypes from 'prop-types' +import { useTranslation } from 'react-i18next' + +ReplaceButton.propTypes = { + onReplace: PropTypes.func.isRequired, + size: PropTypes.number.isRequired, + className: PropTypes.string, +} + +function ReplaceButton({ className, onReplace, size }) { + const { t } = useTranslation() + const [status, setStatus] = useState('idle') + const timeoutRef = useRef(null) + + const onClick = () => { + setStatus(onReplace() ? 'replaced' : 'failed') + if (timeoutRef.current) clearTimeout(timeoutRef.current) + timeoutRef.current = setTimeout(() => { + setStatus('idle') + timeoutRef.current = null + }, 800) + } + + return ( + + {status === 'replaced' ? ( + + ) : status === 'failed' ? ( + + ) : ( + + )} + + ) +} + +export default ReplaceButton diff --git a/src/content-script/index.jsx b/src/content-script/index.jsx index ea70dfbd2..43722d6c5 100644 --- a/src/content-script/index.jsx +++ b/src/content-script/index.jsx @@ -14,6 +14,7 @@ import { setUserConfig, } from '../config/index.mjs' import { + captureEditableSelection, createElementAtPosition, cropText, endsWithQuestionMark, @@ -236,7 +237,7 @@ const deleteToolbar = () => { } } -const createSelectionTools = async (toolbarContainerElement, selection) => { +const createSelectionTools = async (toolbarContainerElement, selection, capturedSelection) => { console.debug( '[content] createSelectionTools called with selection:', selection, @@ -256,6 +257,7 @@ const createSelectionTools = async (toolbarContainerElement, selection) => { selection={selection} container={toolbarContainerElement} dockable={true} + capturedSelection={capturedSelection} />, toolbarContainerElement, ) @@ -324,8 +326,9 @@ async function prepareForSelectionTools() { } } console.debug('[content] Toolbar position:', position) + const capturedSelection = captureEditableSelection() toolbarContainer = createElementAtPosition(position.x, position.y) - await createSelectionTools(toolbarContainer, selection) + await createSelectionTools(toolbarContainer, selection, capturedSelection) } else { console.debug('[content] No text selected on mouseup.') } @@ -410,9 +413,10 @@ async function prepareForSelectionToolsTouch() { .replace(/^-+|-+$/g, '') if (selection) { console.debug('[content] Text selected via touch:', selection) + const capturedSelection = captureEditableSelection() const touch = e.changedTouches[0] toolbarContainer = createElementAtPosition(touch.pageX + 20, touch.pageY + 20) - await createSelectionTools(toolbarContainer, selection) + await createSelectionTools(toolbarContainer, selection, capturedSelection) } else { console.debug('[content] No text selected on touchend.') } diff --git a/src/utils/index.mjs b/src/utils/index.mjs index ef82e9846..3479d1c85 100644 --- a/src/utils/index.mjs +++ b/src/utils/index.mjs @@ -15,6 +15,7 @@ export * from './limited-fetch' export * from './open-url' export * from './parse-float-with-clamp' export * from './parse-int-with-clamp' +export * from './selection-replace' export * from './set-element-position-in-viewport' export * from './eventsource-parser.mjs' export * from './update-ref-height' diff --git a/src/utils/selection-replace.mjs b/src/utils/selection-replace.mjs new file mode 100644 index 000000000..d08be508a --- /dev/null +++ b/src/utils/selection-replace.mjs @@ -0,0 +1,156 @@ +// Capture the editable target of the current selection so its content can be +// replaced later (e.g. with a polished or translated answer), even after the +// selection itself has been lost by interacting with the floating toolbar. +// Only editable targets (text fields and contenteditable regions) are +// supported; static page content is intentionally left untouched. + +const TEXT_FIELD_INPUT_TYPES = ['text', 'search', 'url', 'tel', 'password'] + +const isTextField = (element) => { + if (!element || typeof element.tagName !== 'string') return false + const tagName = element.tagName.toUpperCase() + if (tagName === 'TEXTAREA') return true + if (tagName !== 'INPUT') return false + return TEXT_FIELD_INPUT_TYPES.includes((element.type || 'text').toLowerCase()) +} + +const findContentEditableElement = (node) => { + if (!node) return null + const element = node.nodeType === 3 ? node.parentElement : node + return element && element.isContentEditable ? element : null +} + +/** + * Capture the editable target of the current selection. + * @param {Document} doc + * @returns {object|null} an opaque descriptor for replaceCapturedSelection, or null + * if the selection is not inside an editable element + */ +export const captureEditableSelection = (doc = globalThis.document) => { + if (!doc) return null + try { + const activeElement = doc.activeElement + if (isTextField(activeElement)) { + const { selectionStart: start, selectionEnd: end } = activeElement + if (typeof start !== 'number' || typeof end !== 'number' || start >= end) return null + return { + kind: 'text-field', + element: activeElement, + start, + end, + text: activeElement.value.slice(start, end), + } + } + + const selection = typeof doc.getSelection === 'function' ? doc.getSelection() : null + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return null + const range = selection.getRangeAt(0) + const element = findContentEditableElement(range.commonAncestorContainer) + if (!element) return null + return { + kind: 'contenteditable', + element, + range: range.cloneRange(), + text: range.toString(), + } + } catch (error) { + console.error('[selection-replace] Failed to capture selection:', error) + return null + } +} + +const dispatchInputEvent = (element) => { + if (typeof element.dispatchEvent !== 'function') return + const InputEventConstructor = globalThis.InputEvent + const event = InputEventConstructor + ? new InputEventConstructor('input', { bubbles: true, inputType: 'insertText' }) + : { type: 'input', bubbles: true } + element.dispatchEvent(event) +} + +// Set value through the prototype setter so pages using controlled inputs +// (e.g. React) observe the change when the input event is dispatched. +const setNativeValue = (element, value) => { + let descriptor + let proto = Object.getPrototypeOf(element) + while (proto && !descriptor) { + descriptor = Object.getOwnPropertyDescriptor(proto, 'value') + proto = Object.getPrototypeOf(proto) + } + if (descriptor && descriptor.set) descriptor.set.call(element, value) + else element.value = value +} + +const replaceInTextField = (captured, text, doc) => { + const { element, start, end, text: originalText } = captured + if (element.isConnected === false) return false + const value = element.value ?? '' + if (value.slice(start, end) !== originalText) return false + + if (typeof element.focus === 'function') element.focus() + let replaced = false + if (typeof element.setSelectionRange === 'function' && typeof doc.execCommand === 'function') { + element.setSelectionRange(start, end) + try { + // execCommand keeps the native undo history working where supported + replaced = doc.execCommand('insertText', false, text) && element.value !== value + } catch (error) { + replaced = false + } + if (text === originalText) replaced = true + } + if (!replaced) { + setNativeValue(element, value.slice(0, start) + text + value.slice(end)) + dispatchInputEvent(element) + } + if (typeof element.setSelectionRange === 'function') { + const caretPosition = start + text.length + element.setSelectionRange(caretPosition, caretPosition) + } + return true +} + +const replaceInContentEditable = (captured, text, doc) => { + const { element, range, text: originalText } = captured + if (element.isConnected === false || !element.isContentEditable) return false + if (range.toString() !== originalText) return false + + if (typeof element.focus === 'function') element.focus() + const selection = typeof doc.getSelection === 'function' ? doc.getSelection() : null + if (selection) { + selection.removeAllRanges() + selection.addRange(range) + if (typeof doc.execCommand === 'function') { + try { + if (doc.execCommand('insertText', false, text)) return true + } catch (error) { + // fall through to manual replacement + } + } + } + range.deleteContents() + range.insertNode(doc.createTextNode(text)) + range.collapse(false) + dispatchInputEvent(element) + return true +} + +/** + * Replace the previously captured selection with the given plain text. + * The original content is verified first so stale captures never overwrite + * content that has changed in the meantime. + * @param {object|null} captured descriptor from captureEditableSelection + * @param {string} text plain text to insert (never parsed as HTML) + * @param {Document} doc + * @returns {boolean} whether the replacement was performed + */ +export const replaceCapturedSelection = (captured, text, doc = globalThis.document) => { + if (!captured || typeof text !== 'string' || !doc) return false + try { + if (captured.kind === 'text-field') return replaceInTextField(captured, text, doc) + if (captured.kind === 'contenteditable') return replaceInContentEditable(captured, text, doc) + } catch (error) { + console.error('[selection-replace] Failed to replace selection:', error) + } + return false +} diff --git a/tests/unit/utils/selection-replace.test.mjs b/tests/unit/utils/selection-replace.test.mjs new file mode 100644 index 000000000..a5c2fa292 --- /dev/null +++ b/tests/unit/utils/selection-replace.test.mjs @@ -0,0 +1,329 @@ +import assert from 'node:assert/strict' +import { beforeEach, test } from 'node:test' +import { + captureEditableSelection, + replaceCapturedSelection, +} from '../../../src/utils/selection-replace.mjs' + +const createTextField = ({ tagName = 'TEXTAREA', type, value = '', start = 0, end = 0 } = {}) => { + const element = { + tagName, + value, + selectionStart: start, + selectionEnd: end, + isConnected: true, + focused: false, + dispatchedEvents: [], + focus() { + this.focused = true + }, + setSelectionRange(newStart, newEnd) { + this.selectionStart = newStart + this.selectionEnd = newEnd + }, + dispatchEvent(event) { + this.dispatchedEvents.push(event) + return true + }, + } + if (type) element.type = type + return element +} + +const createContentEditableElement = () => ({ + nodeType: 1, + isContentEditable: true, + isConnected: true, + parentElement: null, + dispatchedEvents: [], + focus() { + this.focused = true + }, + dispatchEvent(event) { + this.dispatchedEvents.push(event) + return true + }, +}) + +const createRange = (element, text) => ({ + commonAncestorContainer: element, + collapsed: text.length === 0, + deletedContents: false, + insertedNodes: [], + toString() { + return text + }, + cloneRange() { + return this + }, + deleteContents() { + this.deletedContents = true + }, + insertNode(node) { + this.insertedNodes.push(node) + }, + collapse() {}, +}) + +const createDocument = ({ activeElement = null, selection = null, execCommand } = {}) => { + const doc = { + activeElement, + createTextNode(text) { + return { nodeType: 3, textContent: text } + }, + getSelection() { + return selection + }, + } + if (execCommand) doc.execCommand = execCommand + return doc +} + +const createSelection = (range) => ({ + rangeCount: range ? 1 : 0, + isCollapsed: range ? range.collapsed : true, + ranges: [], + getRangeAt() { + return range + }, + removeAllRanges() { + this.ranges = [] + }, + addRange(newRange) { + this.ranges.push(newRange) + }, +}) + +beforeEach(() => { + delete globalThis.InputEvent +}) + +test('captureEditableSelection returns null without a document', () => { + assert.equal(captureEditableSelection(null), null) +}) + +test('captureEditableSelection captures a textarea selection', () => { + const element = createTextField({ value: 'hello world', start: 6, end: 11 }) + const doc = createDocument({ activeElement: element }) + + const captured = captureEditableSelection(doc) + + assert.equal(captured.kind, 'text-field') + assert.equal(captured.element, element) + assert.equal(captured.start, 6) + assert.equal(captured.end, 11) + assert.equal(captured.text, 'world') +}) + +test('captureEditableSelection captures a text input selection', () => { + const element = createTextField({ + tagName: 'INPUT', + type: 'text', + value: 'abcdef', + start: 0, + end: 3, + }) + const doc = createDocument({ activeElement: element }) + + const captured = captureEditableSelection(doc) + + assert.equal(captured.kind, 'text-field') + assert.equal(captured.text, 'abc') +}) + +test('captureEditableSelection ignores inputs without selection support', () => { + const element = createTextField({ + tagName: 'INPUT', + type: 'checkbox', + value: 'on', + start: 0, + end: 2, + }) + const doc = createDocument({ activeElement: element }) + + assert.equal(captureEditableSelection(doc), null) +}) + +test('captureEditableSelection ignores collapsed text field selections', () => { + const element = createTextField({ value: 'hello', start: 2, end: 2 }) + const doc = createDocument({ activeElement: element }) + + assert.equal(captureEditableSelection(doc), null) +}) + +test('captureEditableSelection captures a contenteditable selection', () => { + const element = createContentEditableElement() + const range = createRange(element, 'selected text') + const doc = createDocument({ selection: createSelection(range) }) + + const captured = captureEditableSelection(doc) + + assert.equal(captured.kind, 'contenteditable') + assert.equal(captured.element, element) + assert.equal(captured.text, 'selected text') +}) + +test('captureEditableSelection resolves text nodes to their parent element', () => { + const element = createContentEditableElement() + const textNode = { nodeType: 3, parentElement: element } + const range = createRange(textNode, 'selected text') + const doc = createDocument({ selection: createSelection(range) }) + + const captured = captureEditableSelection(doc) + + assert.equal(captured.kind, 'contenteditable') + assert.equal(captured.element, element) +}) + +test('captureEditableSelection returns null for non-editable selections', () => { + const element = { nodeType: 1, isContentEditable: false } + const range = createRange(element, 'selected text') + const doc = createDocument({ selection: createSelection(range) }) + + assert.equal(captureEditableSelection(doc), null) +}) + +test('captureEditableSelection returns null for collapsed selections', () => { + const element = createContentEditableElement() + const range = createRange(element, '') + const doc = createDocument({ selection: createSelection(range) }) + + assert.equal(captureEditableSelection(doc), null) +}) + +test('replaceCapturedSelection rejects invalid arguments', () => { + assert.equal(replaceCapturedSelection(null, 'text', createDocument()), false) + const element = createTextField({ value: 'hello', start: 0, end: 5 }) + const doc = createDocument({ activeElement: element }) + const captured = captureEditableSelection(doc) + assert.equal(replaceCapturedSelection(captured, undefined, doc), false) +}) + +test('replaceCapturedSelection replaces text field content and dispatches input', () => { + const element = createTextField({ value: 'say helo world', start: 4, end: 8 }) + const doc = createDocument({ activeElement: element }) + const captured = captureEditableSelection(doc) + + const replaced = replaceCapturedSelection(captured, 'hello', doc) + + assert.equal(replaced, true) + assert.equal(element.value, 'say hello world') + assert.equal(element.focused, true) + assert.equal(element.dispatchedEvents.length, 1) + assert.equal(element.dispatchedEvents[0].type, 'input') + assert.equal(element.selectionStart, 'say hello'.length) + assert.equal(element.selectionEnd, 'say hello'.length) +}) + +test('replaceCapturedSelection uses execCommand when available', () => { + const element = createTextField({ value: 'helo', start: 0, end: 4 }) + const doc = createDocument({ + activeElement: element, + execCommand: (command, showUI, text) => { + assert.equal(command, 'insertText') + element.value = text + return true + }, + }) + const captured = captureEditableSelection(doc) + + const replaced = replaceCapturedSelection(captured, 'hello', doc) + + assert.equal(replaced, true) + assert.equal(element.value, 'hello') + assert.equal(element.dispatchedEvents.length, 0) +}) + +test('replaceCapturedSelection falls back when execCommand fails', () => { + const element = createTextField({ value: 'helo', start: 0, end: 4 }) + const doc = createDocument({ + activeElement: element, + execCommand: () => false, + }) + const captured = captureEditableSelection(doc) + + const replaced = replaceCapturedSelection(captured, 'hello', doc) + + assert.equal(replaced, true) + assert.equal(element.value, 'hello') + assert.equal(element.dispatchedEvents.length, 1) +}) + +test('replaceCapturedSelection refuses stale text field content', () => { + const element = createTextField({ value: 'hello world', start: 0, end: 5 }) + const doc = createDocument({ activeElement: element }) + const captured = captureEditableSelection(doc) + element.value = 'changed content' + + assert.equal(replaceCapturedSelection(captured, 'hi', doc), false) + assert.equal(element.value, 'changed content') +}) + +test('replaceCapturedSelection refuses disconnected text fields', () => { + const element = createTextField({ value: 'hello', start: 0, end: 5 }) + const doc = createDocument({ activeElement: element }) + const captured = captureEditableSelection(doc) + element.isConnected = false + + assert.equal(replaceCapturedSelection(captured, 'hi', doc), false) +}) + +test('replaceCapturedSelection replaces contenteditable content via range fallback', () => { + const element = createContentEditableElement() + const range = createRange(element, 'old text') + const selection = createSelection(range) + const doc = createDocument({ selection }) + const captured = captureEditableSelection(doc) + + const replaced = replaceCapturedSelection(captured, 'new text', doc) + + assert.equal(replaced, true) + assert.equal(range.deletedContents, true) + assert.equal(range.insertedNodes.length, 1) + assert.equal(range.insertedNodes[0].textContent, 'new text') + assert.equal(element.dispatchedEvents.length, 1) + assert.equal(element.dispatchedEvents[0].type, 'input') +}) + +test('replaceCapturedSelection uses execCommand for contenteditable when available', () => { + const element = createContentEditableElement() + const range = createRange(element, 'old text') + const selection = createSelection(range) + let inserted = null + const doc = createDocument({ + selection, + execCommand: (command, showUI, text) => { + inserted = text + return true + }, + }) + const captured = captureEditableSelection(doc) + + const replaced = replaceCapturedSelection(captured, 'new text', doc) + + assert.equal(replaced, true) + assert.equal(inserted, 'new text') + assert.equal(range.deletedContents, false) + assert.deepEqual(selection.ranges, [range]) +}) + +test('replaceCapturedSelection refuses stale contenteditable ranges', () => { + const element = createContentEditableElement() + const range = createRange(element, 'old text') + const doc = createDocument({ selection: createSelection(range) }) + const captured = captureEditableSelection(doc) + range.toString = () => 'mutated text' + + assert.equal(replaceCapturedSelection(captured, 'new text', doc), false) + assert.equal(range.deletedContents, false) +}) + +test('replaceCapturedSelection refuses disconnected contenteditable elements', () => { + const element = createContentEditableElement() + const range = createRange(element, 'old text') + const doc = createDocument({ selection: createSelection(range) }) + const captured = captureEditableSelection(doc) + element.isConnected = false + + assert.equal(replaceCapturedSelection(captured, 'new text', doc), false) +}) From f39ab7ed585c9dc48ed222c42d50ffa06ef8d2d5 Mon Sep 17 00:00:00 2001 From: Sebastian Elsner Date: Thu, 9 Jul 2026 01:09:11 +0200 Subject: [PATCH 2/5] Log captured editable selection in selection tools debug output Co-Authored-By: Claude Fable 5 --- src/content-script/index.jsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/content-script/index.jsx b/src/content-script/index.jsx index 43722d6c5..3825c24c3 100644 --- a/src/content-script/index.jsx +++ b/src/content-script/index.jsx @@ -241,8 +241,10 @@ const createSelectionTools = async (toolbarContainerElement, selection, captured console.debug( '[content] createSelectionTools called with selection:', selection, - 'and container:', + 'container:', toolbarContainerElement, + 'captured editable selection:', + capturedSelection, ) try { toolbarContainerElement.className = 'chatgptbox-toolbar-container' From 70bf9c1aa9a91be7bdd85106b3639177d374642d Mon Sep 17 00:00:00 2001 From: Sebastian Elsner Date: Thu, 9 Jul 2026 01:14:36 +0200 Subject: [PATCH 3/5] Offer replace button for selection tools from the context menu too The context menu click arrives asynchronously via the background script, so the editable selection is captured on the contextmenu event alongside the menu position. Menu tools operating on the whole page intentionally do not offer replacement. Co-Authored-By: Claude Fable 5 --- src/content-script/index.jsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/content-script/index.jsx b/src/content-script/index.jsx index 3825c24c3..71a6ad74e 100644 --- a/src/content-script/index.jsx +++ b/src/content-script/index.jsx @@ -449,7 +449,7 @@ async function prepareForSelectionToolsTouch() { }) } -let menuX, menuY +let menuX, menuY, menuCapturedSelection let rightClickMenuInitialized = false async function prepareForRightClickMenu() { @@ -462,6 +462,9 @@ async function prepareForRightClickMenu() { document.addEventListener('contextmenu', (e) => { menuX = e.clientX menuY = e.clientY + // the menu click arrives asynchronously via the background script, so the + // editable selection has to be captured while it still exists + menuCapturedSelection = captureEditableSelection() console.debug(`[content] Context menu opened at X: ${menuX}, Y: ${menuY}`) }) @@ -471,7 +474,11 @@ async function prepareForRightClickMenu() { try { const data = message.data let prompt = '' + let capturedSelection = null if (data.itemId in toolsConfig) { + // only selection tools operate on the selected text; menu tools work + // on the whole page and must not offer replacing the selection + capturedSelection = menuCapturedSelection console.debug('[content] Generating prompt from toolsConfig for item:', data.itemId) prompt = await toolsConfig[data.itemId].genPrompt(data.selectionText) } else if (data.itemId in menuConfig) { @@ -513,6 +520,7 @@ async function prepareForRightClickMenu() { triggered={true} closeable={true} prompt={prompt} + capturedSelection={capturedSelection} />, container, ) From 0fb4fd71b3995476a4cd65761ac3178542f5f317 Mon Sep 17 00:00:00 2001 From: Sebastian Elsner Date: Thu, 9 Jul 2026 01:19:49 +0200 Subject: [PATCH 4/5] Address code review feedback on selection replace - Exclude password inputs from capture so credential text is never stored in the descriptor, and exclude readonly/disabled fields both at capture time and again before replacing - Resolve nested contenteditable selections to the editing host so focus() lands on a focusable element before execCommand - Re-apply the collapsed range to the selection in the contenteditable fallback so the cursor ends up after the inserted text - Dispatch a real Event instance when InputEvent is unavailable instead of a plain object - Clear the ReplaceButton status timeout on unmount - Fall back to the captured text field selection when window.getSelection() is empty, so selection tools also trigger for input/textarea selections on Firefox Co-Authored-By: Claude Fable 5 --- src/components/ReplaceButton/index.jsx | 8 ++- src/content-script/index.jsx | 34 +++++++----- src/utils/selection-replace.mjs | 31 +++++++---- tests/unit/utils/selection-replace.test.mjs | 60 +++++++++++++++++++++ 4 files changed, 111 insertions(+), 22 deletions(-) diff --git a/src/components/ReplaceButton/index.jsx b/src/components/ReplaceButton/index.jsx index 4d4f99150..b3b474577 100644 --- a/src/components/ReplaceButton/index.jsx +++ b/src/components/ReplaceButton/index.jsx @@ -1,4 +1,4 @@ -import { useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { CheckIcon, ReplyIcon, XIcon } from '@primer/octicons-react' import PropTypes from 'prop-types' import { useTranslation } from 'react-i18next' @@ -14,6 +14,12 @@ function ReplaceButton({ className, onReplace, size }) { const [status, setStatus] = useState('idle') const timeoutRef = useRef(null) + useEffect(() => { + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current) + } + }, []) + const onClick = () => { setStatus(onReplace() ? 'replaced' : 'failed') if (timeoutRef.current) clearTimeout(timeoutRef.current) diff --git a/src/content-script/index.jsx b/src/content-script/index.jsx index 71a6ad74e..ae968aeab 100644 --- a/src/content-script/index.jsx +++ b/src/content-script/index.jsx @@ -295,11 +295,17 @@ async function prepareForSelectionTools() { deleteToolbar() setTimeout(async () => { try { - const selection = window - .getSelection() - ?.toString() - .trim() - .replace(/^-+|-+$/g, '') + const capturedSelection = captureEditableSelection() + const selection = + window + .getSelection() + ?.toString() + .trim() + .replace(/^-+|-+$/g, '') || + // Firefox does not expose text field selections via window.getSelection() + (capturedSelection?.kind === 'text-field' + ? capturedSelection.text.trim().replace(/^-+|-+$/g, '') + : '') if (selection) { console.debug('[content] Text selected. Length:', selection.length) let position @@ -328,7 +334,6 @@ async function prepareForSelectionTools() { } } console.debug('[content] Toolbar position:', position) - const capturedSelection = captureEditableSelection() toolbarContainer = createElementAtPosition(position.x, position.y) await createSelectionTools(toolbarContainer, selection, capturedSelection) } else { @@ -408,14 +413,19 @@ async function prepareForSelectionToolsTouch() { deleteToolbar() setTimeout(async () => { try { - const selection = window - .getSelection() - ?.toString() - .trim() - .replace(/^-+|-+$/g, '') + const capturedSelection = captureEditableSelection() + const selection = + window + .getSelection() + ?.toString() + .trim() + .replace(/^-+|-+$/g, '') || + // Firefox does not expose text field selections via window.getSelection() + (capturedSelection?.kind === 'text-field' + ? capturedSelection.text.trim().replace(/^-+|-+$/g, '') + : '') if (selection) { console.debug('[content] Text selected via touch:', selection) - const capturedSelection = captureEditableSelection() const touch = e.changedTouches[0] toolbarContainer = createElementAtPosition(touch.pageX + 20, touch.pageY + 20) await createSelectionTools(toolbarContainer, selection, capturedSelection) diff --git a/src/utils/selection-replace.mjs b/src/utils/selection-replace.mjs index d08be508a..4c283139a 100644 --- a/src/utils/selection-replace.mjs +++ b/src/utils/selection-replace.mjs @@ -4,20 +4,27 @@ // Only editable targets (text fields and contenteditable regions) are // supported; static page content is intentionally left untouched. -const TEXT_FIELD_INPUT_TYPES = ['text', 'search', 'url', 'tel', 'password'] +const TEXT_FIELD_INPUT_TYPES = ['text', 'search', 'url', 'tel'] const isTextField = (element) => { if (!element || typeof element.tagName !== 'string') return false + if (element.readOnly || element.disabled) return false const tagName = element.tagName.toUpperCase() if (tagName === 'TEXTAREA') return true if (tagName !== 'INPUT') return false return TEXT_FIELD_INPUT_TYPES.includes((element.type || 'text').toLowerCase()) } +// walk up to the editing host: focus() only works on the top-most +// contenteditable element, not on child elements of the region const findContentEditableElement = (node) => { if (!node) return null - const element = node.nodeType === 3 ? node.parentElement : node - return element && element.isContentEditable ? element : null + let element = node.nodeType === 3 ? node.parentElement : node + if (!element || !element.isContentEditable) return null + while (element.parentElement && element.parentElement.isContentEditable) { + element = element.parentElement + } + return element } /** @@ -61,11 +68,12 @@ export const captureEditableSelection = (doc = globalThis.document) => { const dispatchInputEvent = (element) => { if (typeof element.dispatchEvent !== 'function') return - const InputEventConstructor = globalThis.InputEvent - const event = InputEventConstructor - ? new InputEventConstructor('input', { bubbles: true, inputType: 'insertText' }) - : { type: 'input', bubbles: true } - element.dispatchEvent(event) + const event = globalThis.InputEvent + ? new globalThis.InputEvent('input', { bubbles: true, inputType: 'insertText' }) + : globalThis.Event + ? new globalThis.Event('input', { bubbles: true }) + : null + if (event) element.dispatchEvent(event) } // Set value through the prototype setter so pages using controlled inputs @@ -83,7 +91,7 @@ const setNativeValue = (element, value) => { const replaceInTextField = (captured, text, doc) => { const { element, start, end, text: originalText } = captured - if (element.isConnected === false) return false + if (element.isConnected === false || element.readOnly || element.disabled) return false const value = element.value ?? '' if (value.slice(start, end) !== originalText) return false @@ -131,6 +139,11 @@ const replaceInContentEditable = (captured, text, doc) => { range.deleteContents() range.insertNode(doc.createTextNode(text)) range.collapse(false) + if (selection) { + // re-apply the collapsed range so the cursor ends up after the inserted text + selection.removeAllRanges() + selection.addRange(range) + } dispatchInputEvent(element) return true } diff --git a/tests/unit/utils/selection-replace.test.mjs b/tests/unit/utils/selection-replace.test.mjs index a5c2fa292..3f9890b32 100644 --- a/tests/unit/utils/selection-replace.test.mjs +++ b/tests/unit/utils/selection-replace.test.mjs @@ -144,6 +144,29 @@ test('captureEditableSelection ignores inputs without selection support', () => assert.equal(captureEditableSelection(doc), null) }) +test('captureEditableSelection ignores password inputs', () => { + const element = createTextField({ + tagName: 'INPUT', + type: 'password', + value: 'secret', + start: 0, + end: 6, + }) + const doc = createDocument({ activeElement: element }) + + assert.equal(captureEditableSelection(doc), null) +}) + +test('captureEditableSelection ignores readonly and disabled fields', () => { + const readOnlyElement = createTextField({ value: 'hello', start: 0, end: 5 }) + readOnlyElement.readOnly = true + assert.equal(captureEditableSelection(createDocument({ activeElement: readOnlyElement })), null) + + const disabledElement = createTextField({ value: 'hello', start: 0, end: 5 }) + disabledElement.disabled = true + assert.equal(captureEditableSelection(createDocument({ activeElement: disabledElement })), null) +}) + test('captureEditableSelection ignores collapsed text field selections', () => { const element = createTextField({ value: 'hello', start: 2, end: 2 }) const doc = createDocument({ activeElement: element }) @@ -175,6 +198,19 @@ test('captureEditableSelection resolves text nodes to their parent element', () assert.equal(captured.element, element) }) +test('captureEditableSelection resolves nested elements to the editing host', () => { + const host = createContentEditableElement() + const child = createContentEditableElement() + child.parentElement = host + const range = createRange(child, 'selected text') + const doc = createDocument({ selection: createSelection(range) }) + + const captured = captureEditableSelection(doc) + + assert.equal(captured.kind, 'contenteditable') + assert.equal(captured.element, host) +}) + test('captureEditableSelection returns null for non-editable selections', () => { const element = { nodeType: 1, isContentEditable: false } const range = createRange(element, 'selected text') @@ -268,6 +304,29 @@ test('replaceCapturedSelection refuses disconnected text fields', () => { assert.equal(replaceCapturedSelection(captured, 'hi', doc), false) }) +test('replaceCapturedSelection refuses fields that became readonly after capture', () => { + const element = createTextField({ value: 'hello', start: 0, end: 5 }) + const doc = createDocument({ activeElement: element }) + const captured = captureEditableSelection(doc) + element.readOnly = true + + assert.equal(replaceCapturedSelection(captured, 'hi', doc), false) + assert.equal(element.value, 'hello') +}) + +test('replaceCapturedSelection dispatches a real Event instance as fallback', () => { + const element = createTextField({ value: 'helo', start: 0, end: 4 }) + const doc = createDocument({ activeElement: element }) + const captured = captureEditableSelection(doc) + + const replaced = replaceCapturedSelection(captured, 'hello', doc) + + assert.equal(replaced, true) + assert.equal(element.dispatchedEvents.length, 1) + assert.ok(element.dispatchedEvents[0] instanceof globalThis.Event) + assert.equal(element.dispatchedEvents[0].bubbles, true) +}) + test('replaceCapturedSelection replaces contenteditable content via range fallback', () => { const element = createContentEditableElement() const range = createRange(element, 'old text') @@ -283,6 +342,7 @@ test('replaceCapturedSelection replaces contenteditable content via range fallba assert.equal(range.insertedNodes[0].textContent, 'new text') assert.equal(element.dispatchedEvents.length, 1) assert.equal(element.dispatchedEvents[0].type, 'input') + assert.deepEqual(selection.ranges, [range]) // cursor re-applied after the inserted text }) test('replaceCapturedSelection uses execCommand for contenteditable when available', () => { From 25fbd1e87c5ecb7e1128c44d38f577e6e3148214 Mon Sep 17 00:00:00 2001 From: Sebastian Elsner Date: Thu, 9 Jul 2026 02:09:43 +0200 Subject: [PATCH 5/5] Address second review round on selection replace - Log only the captured selection kind instead of the full descriptor so selected text and DOM references never end up in page console logs - Clear the context menu selection capture once the menu action has been handled instead of retaining element and range references - Store the full text field value at capture time and require an exact match before replacing, so edits that shift the selection offsets while leaving the same substring in place fail safely Co-Authored-By: Claude Fable 5 --- src/content-script/index.jsx | 12 ++++++------ src/utils/selection-replace.mjs | 5 ++++- tests/unit/utils/selection-replace.test.mjs | 10 ++++++++++ 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/content-script/index.jsx b/src/content-script/index.jsx index ae968aeab..090b2347e 100644 --- a/src/content-script/index.jsx +++ b/src/content-script/index.jsx @@ -243,8 +243,8 @@ const createSelectionTools = async (toolbarContainerElement, selection, captured selection, 'container:', toolbarContainerElement, - 'captured editable selection:', - capturedSelection, + 'captured editable selection kind:', + capturedSelection?.kind, ) try { toolbarContainerElement.className = 'chatgptbox-toolbar-container' @@ -484,11 +484,11 @@ async function prepareForRightClickMenu() { try { const data = message.data let prompt = '' - let capturedSelection = null + // only selection tools operate on the selected text; menu tools work + // on the whole page and must not offer replacing the selection + const capturedSelection = data.itemId in toolsConfig ? menuCapturedSelection : null + menuCapturedSelection = null if (data.itemId in toolsConfig) { - // only selection tools operate on the selected text; menu tools work - // on the whole page and must not offer replacing the selection - capturedSelection = menuCapturedSelection console.debug('[content] Generating prompt from toolsConfig for item:', data.itemId) prompt = await toolsConfig[data.itemId].genPrompt(data.selectionText) } else if (data.itemId in menuConfig) { diff --git a/src/utils/selection-replace.mjs b/src/utils/selection-replace.mjs index 4c283139a..ef62345c2 100644 --- a/src/utils/selection-replace.mjs +++ b/src/utils/selection-replace.mjs @@ -46,6 +46,9 @@ export const captureEditableSelection = (doc = globalThis.document) => { start, end, text: activeElement.value.slice(start, end), + // the full value is kept so edits elsewhere in the field (which may + // shift the selection offsets) reliably invalidate the capture + fieldValue: activeElement.value, } } @@ -93,7 +96,7 @@ const replaceInTextField = (captured, text, doc) => { const { element, start, end, text: originalText } = captured if (element.isConnected === false || element.readOnly || element.disabled) return false const value = element.value ?? '' - if (value.slice(start, end) !== originalText) return false + if (value !== captured.fieldValue) return false if (typeof element.focus === 'function') element.focus() let replaced = false diff --git a/tests/unit/utils/selection-replace.test.mjs b/tests/unit/utils/selection-replace.test.mjs index 3f9890b32..8e1218030 100644 --- a/tests/unit/utils/selection-replace.test.mjs +++ b/tests/unit/utils/selection-replace.test.mjs @@ -295,6 +295,16 @@ test('replaceCapturedSelection refuses stale text field content', () => { assert.equal(element.value, 'changed content') }) +test('replaceCapturedSelection refuses shifted offsets even when the substring still matches', () => { + const element = createTextField({ value: 'aaaa', start: 0, end: 2 }) + const doc = createDocument({ activeElement: element }) + const captured = captureEditableSelection(doc) + element.value = 'aaaaaa' // text inserted before the selection keeps slice(0, 2) === 'aa' + + assert.equal(replaceCapturedSelection(captured, 'bb', doc), false) + assert.equal(element.value, 'aaaaaa') +}) + test('replaceCapturedSelection refuses disconnected text fields', () => { const element = createTextField({ value: 'hello', start: 0, end: 5 }) const doc = createDocument({ activeElement: element })