// Shared editing operations for the desktop actor and Android content bridge. export class InputEditor { constructor(win) { this.contentWindow=win; this.document=win.document; this.editing=false; } focus(element) { this.inputElement=element; } state() { const e=this.inputElement; const text=e.isContentEditable ? e.textContent : e.value; const selection=this.contentWindow.getSelection(); let start=e.selectionStart ?? text.length, end=e.selectionEnd ?? start; if(e.isContentEditable && selection.rangeCount && e.contains(selection.anchorNode)) { const range=this.document.createRange();range.selectNodeContents(e); range.setEnd(selection.anchorNode,selection.anchorOffset);start=range.toString().length; range.setEnd(selection.focusNode,selection.focusOffset);end=range.toString().length; [start,end]=[Math.min(start,end),Math.max(start,end)]; } return {type:e.isContentEditable?'contenteditable':e.localName,inputType:e.type || (e.classList.contains('recipient')?'tel':'text'), inputMode:e.inputMode || (e.classList.contains('recipient')?'tel':''), text:e.type==='password'?'':text,selectionStart:start,selectionEnd:end}; } edit(p) { const e=this.inputElement,win=this.contentWindow; if(!e?.isConnected || this.document.hidden) throw Error('INPUT_CONTEXT_EXPIRED'); this.editing=true; try {e.focus();} finally {this.editing=false;} // A touch can focus a new contenteditable while Gecko retains the previous // field's selection. execCommand follows that selection, not activeElement. if(e.isContentEditable) { const selection=win.getSelection(); if(!e.contains(selection.anchorNode) || !e.contains(selection.focusNode)) { const range=this.document.createRange(); range.selectNodeContents(e);range.collapse(false); selection.removeAllRanges();selection.addRange(range); } } if(p.operation==='key') { let key=typeof p.key==='object'?p.key.key:p.key; if(key===8 || key==='Backspace') this.document.execCommand('delete'); else if(key===13 || key==='Enter') { const options={key:'Enter',code:'Enter',keyCode:13,charCode:13,which:13,bubbles:true,cancelable:true}; const accepted=e.dispatchEvent(new win.KeyboardEvent('keydown',options)) && e.dispatchEvent(new win.KeyboardEvent('keypress',options)); if(accepted && (e.isContentEditable || e.localName==='textarea')) this.document.execCommand('insertLineBreak'); e.dispatchEvent(new win.KeyboardEvent('keyup',options)); } else { const text=typeof key==='string'?key:String.fromCodePoint(p.charCode || key); if(text.length>16) throw Error('INVALID_VALUE'); this.document.execCommand('insertText',false,text); } } else if(['selection','replace'].includes(p.operation)) { const state=this.state(); const start=p.operation==='selection'?p.start:state.selectionStart+(p.offset||0); const length=p.length||0; if(!Number.isInteger(start)||!Number.isInteger(length)||start<0||length<0||start+length>(e.value ?? e.textContent).length) throw Error('INVALID_VALUE'); if(e.isContentEditable) { const walker=this.document.createTreeWalker(e,win.NodeFilter.SHOW_TEXT); const points=[];let offset=0,node; while((node=walker.nextNode())) {points.push({node,start:offset,end:offset+node.length});offset+=node.length;} const point=index=>{const part=points.find(p=>index<=p.end);return part?[part.node,index-part.start]:[e,0];}; const range=this.document.createRange();range.setStart(...point(start));range.setEnd(...point(start+length)); win.getSelection().removeAllRanges();win.getSelection().addRange(range); } else e.setSelectionRange(start,start+length); if(p.operation==='replace') { if(typeof p.text!=='string'||p.text.length>4096) throw Error('INVALID_VALUE'); this.document.execCommand('insertText',false,p.text); } } else throw Error('METHOD_NOT_SUPPORTED'); return this.state(); } }