VulpesOS/services/input-editor.mjs
OverSu 1311bed5e9 Release Preview 20 shared desktop, Android and Sargo updates
Restore Gaia settings and media workflows, extend native network adapters, and align the APK payload. Generate French/English changelogs and website entries from the simplified JSON source.

Validation: shared JS and Tundra unit tests; desktop workflows and persistence; signed APK payload parity; Sargo RAM and installed startup with boot readback. Android runtime qualification is recorded separately.
2026-10-13 00:19:45 +02:00

68 lines
4 KiB
JavaScript

// 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();
}
}