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..b3b474577
--- /dev/null
+++ b/src/components/ReplaceButton/index.jsx
@@ -0,0 +1,49 @@
+import { useEffect, 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)
+
+ useEffect(() => {
+ return () => {
+ if (timeoutRef.current) clearTimeout(timeoutRef.current)
+ }
+ }, [])
+
+ 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..090b2347e 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,12 +237,14 @@ const deleteToolbar = () => {
}
}
-const createSelectionTools = async (toolbarContainerElement, selection) => {
+const createSelectionTools = async (toolbarContainerElement, selection, capturedSelection) => {
console.debug(
'[content] createSelectionTools called with selection:',
selection,
- 'and container:',
+ 'container:',
toolbarContainerElement,
+ 'captured editable selection kind:',
+ capturedSelection?.kind,
)
try {
toolbarContainerElement.className = 'chatgptbox-toolbar-container'
@@ -256,6 +259,7 @@ const createSelectionTools = async (toolbarContainerElement, selection) => {
selection={selection}
container={toolbarContainerElement}
dockable={true}
+ capturedSelection={capturedSelection}
/>,
toolbarContainerElement,
)
@@ -291,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
@@ -325,7 +335,7 @@ async function prepareForSelectionTools() {
}
console.debug('[content] Toolbar position:', position)
toolbarContainer = createElementAtPosition(position.x, position.y)
- await createSelectionTools(toolbarContainer, selection)
+ await createSelectionTools(toolbarContainer, selection, capturedSelection)
} else {
console.debug('[content] No text selected on mouseup.')
}
@@ -403,16 +413,22 @@ 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 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.')
}
@@ -443,7 +459,7 @@ async function prepareForSelectionToolsTouch() {
})
}
-let menuX, menuY
+let menuX, menuY, menuCapturedSelection
let rightClickMenuInitialized = false
async function prepareForRightClickMenu() {
@@ -456,6 +472,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}`)
})
@@ -465,6 +484,10 @@ async function prepareForRightClickMenu() {
try {
const data = message.data
let prompt = ''
+ // 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) {
console.debug('[content] Generating prompt from toolsConfig for item:', data.itemId)
prompt = await toolsConfig[data.itemId].genPrompt(data.selectionText)
@@ -507,6 +530,7 @@ async function prepareForRightClickMenu() {
triggered={true}
closeable={true}
prompt={prompt}
+ capturedSelection={capturedSelection}
/>,
container,
)
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..ef62345c2
--- /dev/null
+++ b/src/utils/selection-replace.mjs
@@ -0,0 +1,172 @@
+// 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']
+
+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
+ 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
+}
+
+/**
+ * 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),
+ // the full value is kept so edits elsewhere in the field (which may
+ // shift the selection offsets) reliably invalidate the capture
+ fieldValue: activeElement.value,
+ }
+ }
+
+ 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 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
+// (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 || element.readOnly || element.disabled) return false
+ const value = element.value ?? ''
+ if (value !== captured.fieldValue) 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)
+ 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
+}
+
+/**
+ * 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..8e1218030
--- /dev/null
+++ b/tests/unit/utils/selection-replace.test.mjs
@@ -0,0 +1,399 @@
+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 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 })
+
+ 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 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')
+ 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 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 })
+ const captured = captureEditableSelection(doc)
+ element.isConnected = false
+
+ 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')
+ 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')
+ assert.deepEqual(selection.ranges, [range]) // cursor re-applied after the inserted text
+})
+
+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)
+})