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) +})