Merge pull request #3378 from vitorpamplona/fix/embed-ime-selection

Embedded text selection: native-parity IME + selection UI + magnifier, and platform-bug fixes
This commit is contained in:
Vitor Pamplona
2026-06-26 11:08:42 -04:00
committed by GitHub
21 changed files with 2307 additions and 76 deletions
@@ -191,6 +191,7 @@
if (IME_PROXY) (function(){
var el = null; // the focused editable element, or null
var inComposition = false;
function perfNow(){ try { return performance.now(); } catch (_) { return 0; } }
function isEditable(n){
if (!n) return false;
@@ -205,7 +206,22 @@
}
function isCE(n){ return !!(n && n.isContentEditable); }
function valOf(n){ return isCE(n) ? n.textContent : (n.value || ''); }
function setVal(n, v){ if (isCE(n)) n.textContent = v; else n.value = v; }
// Controlled-input frameworks (React, Preact, …) install an INSTANCE-level `value` setter on the
// <input>/<textarea> that records the last value they wrote, and then suppress their onChange whenever
// the element's value already equals that recorded value. A plain `n.value = v` assignment goes through
// that tracker, so our programmatic edit looks like a no-op to the framework: onChange never fires, its
// state stays stale, and on the next render it reconciles the field straight back to the stale value —
// wiping what we just typed. Writing through the NATIVE prototype setter sets the real value without
// touching the tracker, so the framework's input handler sees value != tracked, detects the change, and
// commits it. Identical to `n.value = v` for plain (non-framework) pages.
var nativeInputValueSet = (function(){ try { return Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; } catch (_) { return null; } })();
var nativeAreaValueSet = (function(){ try { return Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set; } catch (_) { return null; } })();
function setVal(n, v){
if (isCE(n)) { n.textContent = v; return; }
var set = (typeof HTMLTextAreaElement !== 'undefined' && n instanceof HTMLTextAreaElement) ? nativeAreaValueSet : nativeInputValueSet;
if (set) { try { set.call(n, v); return; } catch (_) {} }
n.value = v;
}
// --- contenteditable selection/replacement, mapped through char offsets into textContent ---
// We can't use setSelectionRange/value on a contenteditable root; instead we map a char offset
@@ -261,31 +277,144 @@
return [n.selectionStart || 0, n.selectionEnd || 0];
}
function setSel(n, s, e){
// No-op if already there: re-applying the same selection still fires `select`/`selectionchange`, and
// every such redundant event ripples into a host report → geometry update → toolbar/handle recompose
// (visible churn). Our re-asserts/re-applies frequently target the current range, so guard them here.
var cur = selOf(n);
if (cur[0] === s && cur[1] === e) return;
lastSelActivityAt = perfNow(); // a real selection write → the field may auto-scroll to reveal it
if (isCE(n)) ceSetSel(n, s, e);
else { try { n.setSelectionRange(s, e); } catch (_) {} }
}
// The DOM exposes no caret/selection rect for a position inside an <input>/<textarea>, so we mirror the
// field into a hidden div (same font/padding/wrapping) and measure where a marker span lands — the
// well-known "textarea-caret-position" technique. Used to place the insertion handle (and drag it).
var CARET_PROPS = ['direction','boxSizing','width','height','overflowX','overflowY','borderTopWidth','borderRightWidth','borderBottomWidth','borderLeftWidth','paddingTop','paddingRight','paddingBottom','paddingLeft','fontStyle','fontVariant','fontWeight','fontStretch','fontSize','fontSizeAdjust','lineHeight','fontFamily','textAlign','textTransform','textIndent','textDecoration','letterSpacing','wordSpacing','tabSize','MozTabSize'];
function caretCoords(n, position){
try {
var isInput = (n.nodeName || '').toUpperCase() === 'INPUT';
var computed = window.getComputedStyle(n);
var div = document.createElement('div');
var s = div.style;
s.position = 'absolute'; s.visibility = 'hidden'; s.whiteSpace = isInput ? 'nowrap' : 'pre-wrap'; s.wordWrap = 'break-word'; s.overflow = 'hidden';
for (var i = 0; i < CARET_PROPS.length; i++) { s[CARET_PROPS[i]] = computed[CARET_PROPS[i]]; }
var val = valOf(n);
div.textContent = val.substring(0, position);
if (isInput) div.textContent = div.textContent.replace(/\s/g, ' ');
var span = document.createElement('span');
span.textContent = val.substring(position) || '.';
div.appendChild(span);
document.body.appendChild(div);
var caretL = span.offsetLeft, caretT = span.offsetTop;
var lineHeight = parseInt(computed.lineHeight) || parseInt(computed.fontSize) || 16;
document.body.removeChild(div);
// offsetLeft/Top are measured from the mirror's *inner* (padding) edge, but getBoundingClientRect is the
// *outer* border box — so add the field's border widths to land on the real caret.
var bl = parseFloat(computed.borderLeftWidth) || 0, bt = parseFloat(computed.borderTopWidth) || 0;
var rect = n.getBoundingClientRect();
var x = rect.left + bl + caretL - n.scrollLeft;
var top = rect.top + bt + caretT - n.scrollTop;
return { x: x, top: top, bottom: top + lineHeight };
} catch (_) { return null; }
}
// Inverse: the char offset whose caret is nearest the CSS-px point (x,y). Binary search in reading order
// (offset increases left-to-right, top-to-bottom), so a handle drag maps back to a cursor position. Y is
// first clamped into the field's text rows, so dragging the handle (which sits below the line) or off the
// field keeps the cursor on the nearest line and lets X drive the column — like Android.
function offsetFromPoint(n, x, y){
var len = valOf(n).length;
var first = caretCoords(n, 0), last = caretCoords(n, len);
if (first && y < first.top) y = (first.top + first.bottom) / 2;
else if (last && y > last.bottom) y = (last.top + last.bottom) / 2;
var lo = 0, hi = len;
while (lo < hi) {
var mid = (lo + hi) >> 1, c = caretCoords(n, mid);
if (!c) break;
if (y < c.top) hi = mid;
else if (y > c.bottom) lo = mid + 1;
else if (x < c.x) hi = mid;
else lo = mid + 1;
}
// The search lands on the boundary just RIGHT of x; round to the NEAREST boundary instead (native
// getOffsetForPosition) so tapping the left half of a glyph doesn't advance the caret past it. Only
// compare within the same line (skip when lo sits at a wrap, where lo-1 is on the previous row).
if (lo > 0) {
var cl = caretCoords(n, lo - 1), cr = caretCoords(n, lo);
if (cl && cr && cl.top === cr.top && (x - cl.x) < (cr.x - x)) lo = lo - 1;
}
return lo;
}
// The focused field's bounding box in CSS px (toolbar anchor). When the selection is a bare caret it also
// carries the caret rect (cx/ct/cb) so the host can show a draggable insertion handle.
function fieldGeom(n){
try {
var b = n.getBoundingClientRect();
var g = { l: b.left, t: b.top, r: b.right, b: b.bottom, sx: b.left, sb: b.bottom, ex: b.right, eb: b.bottom, vw: window.innerWidth };
if (!isCE(n)) {
var sel = selOf(n);
if (sel[0] === sel[1]) {
// Bare caret → carry the caret rect so the host shows the insertion handle.
var c = caretCoords(n, sel[0]);
if (c) { g.cx = c.x; g.ct = c.top; g.cb = c.bottom; }
} else {
// Range → carry the start/end caret feet so the host shows draggable selection handles, and
// tighten the box to the selected line span so the toolbar anchors above the selection.
var cs = caretCoords(n, sel[0]), ce = caretCoords(n, sel[1]);
if (cs && ce) {
g.rng = true; // marks a real range so the host keeps these feet through Chrome's collapse fight
g.sx = cs.x; g.sb = cs.bottom; g.ex = ce.x; g.eb = ce.bottom;
g.t = Math.min(cs.top, ce.top); g.b = Math.max(cs.bottom, ce.bottom);
}
}
}
return g;
} catch (_) { return null; }
}
function focusInfo(n){
var t = (n.tagName || '').toUpperCase();
var multiline = isCE(n) || t === 'TEXTAREA';
var inputType = isCE(n) ? 'text' : (t === 'TEXTAREA' ? 'textarea' : (n.type || 'text').toLowerCase());
var sel = selOf(n);
return { type:'ime.focus', inputType: inputType, enterKeyHint: (n.enterKeyHint || ''),
multiline: multiline, text: valOf(n), selStart: sel[0], selEnd: sel[1] };
multiline: multiline, text: valOf(n), selStart: sel[0], selEnd: sel[1], geom: fieldGeom(n) };
}
// Last selection we either applied (applyState) or already reported, so the asynchronous
// selectionchange our own setSel triggers doesn't echo back to the host as a fresh edit.
var lastSel = null;
// The live non-collapsed field range we protect from Chrome's off-window abandonment (see the
// selectionchange handler). Tracked centrally so handle-extends and select-all keep it current.
var lastFieldRange = null, lastFieldAt = -1;
// When the selection last changed (Chrome-driven OR our own setSel). Forming/re-asserting a selection makes
// the browser auto-scroll the field to reveal it, firing `scroll` events that are NOT a user content scroll.
// The hide-on-scroll path uses this to ignore those: hiding the host overlays on a selection-reveal scroll
// makes the handles/toolbar blink off-and-on every time a selection settles. See onAnyScroll.
var lastSelActivityAt = -1;
function sameSel(a, b){ return !!(a && b && a[0] === b[0] && a[1] === b[1]); }
function noteSel(sel){
lastSel = sel;
if (el && !isCE(el)) {
if (sel[0] !== sel[1]) { lastFieldRange = [sel[0], sel[1]]; lastFieldAt = perfNow(); }
else { lastFieldRange = null; }
}
}
function reportState(){
if (!el) return;
var sel = selOf(el);
lastSel = sel;
send({ type:'ime.state', text: valOf(el), selStart: sel[0], selEnd: sel[1] });
noteSel(sel);
send({ type:'ime.state', text: valOf(el), selStart: sel[0], selEnd: sel[1], geom: fieldGeom(el) });
}
document.addEventListener('focusin', function(e){
if (isEditable(e.target)) {
el = e.target; inComposition = false; lastSel = selOf(el); send(focusInfo(el));
// Focusing a field clears any page-text selection in the browser. The page selectionchange handler is
// muted while a field is focused (el is set), so it never emits the `active:false` — emit it here, or
// the host's page handles + Copy bar linger ABOVE the field overlays (and, being z-above, steal the
// caret/selection-handle drag so the caret can't be moved).
if (pageSelText) { lastPageRange = null; sendPageSel(false, null); }
// Cancel any in-flight scroll-hide from the page phase so the new field overlays aren't suppressed by a
// stale `scrolling` state (its settle would otherwise keep getting re-armed by the field reveal-scrolls).
scrolling = false; if (scrollTimer) { clearTimeout(scrollTimer); scrollTimer = null; }
// The host shrinks the surface for the keyboard, but also nudge the field into view in case IME
// insets aren't delivered (some hosts) so it never sits behind the keyboard.
try { el.scrollIntoView({ block: 'center', inline: 'nearest' }); } catch (_) {}
@@ -296,11 +425,71 @@
}, true);
// The page (its own JS, autofill) changed the field: resync the host keyboard's view of it.
document.addEventListener('input', function(e){ if (e.target === el && !el.__nappletIme) reportState(); }, true);
// Mirror selection changes inside the focused editable to the host. Off-window Chrome abandons a field
// selection by collapsing the caret to one of its endpoints; we re-assert it RIGHT HERE, synchronously,
// the same way the page-text path does — reverting before the collapse paints, so it doesn't blink (the
// old path round-tripped through the host EditText, leaving a visible collapsed frame each cycle). The
// host's own re-assert in RemoteImeView.onPageState stays as a fallback for collapses we don't catch.
document.addEventListener('selectionchange', function(){
if (!el || el.__nappletIme) return;
if (sameSel(selOf(el), lastSel)) return; // our own applyState/setSel echoing back
if (!el || el.__nappletIme) return; // our own applyState/setSel
lastSelActivityAt = perfNow(); // Chrome moved the selection → an imminent reveal-scroll isn't a user scroll
var sel = selOf(el);
if (sameSel(sel, lastSel)) return; // echo of what we just applied
if (!isCE(el) && lastFieldRange && sel[0] === sel[1] &&
(sel[0] === lastFieldRange[0] || sel[0] === lastFieldRange[1]) &&
(perfNow() - lastFieldAt) < 1500) {
// Chrome's off-window abandonment collapsed our live range to an endpoint → snap it back
// synchronously (reverts before paint, so no blink) and keep the window open. The host already
// holds this range, so we don't re-report (which would feed the slow round-trip loop).
el.__nappletIme = true;
setSel(el, lastFieldRange[0], lastFieldRange[1]);
el.__nappletIme = false;
lastSel = selOf(el);
lastFieldAt = perfNow();
return;
}
reportState();
}, true);
// Tap handling on the focused editable. A single tap collapses any selection to a caret at the tap point
// and shows the insertion handle (native; off-window Chrome won't collapse-on-tap itself). A DOUBLE tap
// selects the word — but `click` fires before `dblclick`, so instead of guessing with timing we DEFER the
// collapse and let the real `dblclick` cancel it. This is robust to Chrome's own double-click timing
// (a timing guess raced it and sometimes ate the word selection → "cursor jumps to end of word").
var collapseTimer = null;
function clearCollapse() { if (collapseTimer) { clearTimeout(collapseTimer); collapseTimer = null; } }
document.addEventListener('dblclick', function(e){
if (!el || isCE(el)) return;
clearCollapse(); // a real double-tap → don't collapse; keep Chrome's word selection
var s = selOf(el);
if (s[0] !== s[1] && !sameSel(s, lastSel)) reportState(); // report the word only if not already sent
}, true);
document.addEventListener('click', function(e){
if (!el || isCE(el) || e.target !== el) return;
var sel = selOf(el);
if (sel[0] !== sel[1]) {
// Tap landed on a selection. Defer the collapse: if a dblclick follows (within the tap window) it
// cancels this and the word stays selected; otherwise this fires and collapses to the tapped offset.
var x = e.clientX, y = e.clientY;
clearCollapse();
collapseTimer = setTimeout(function(){
collapseTimer = null;
if (!el) return;
var s = selOf(el);
if (s[0] === s[1]) return; // already collapsed
var off = offsetFromPoint(el, x, y);
el.__nappletIme = true;
setSel(el, off, off);
el.__nappletIme = false;
lastSel = selOf(el);
reportState();
send({ type:'ime.carettap', geom: fieldGeom(el) });
}, 300);
} else {
// Tap on a bare caret → (re-)show the insertion handle. If a double-tap follows, dblclick selects the
// word and supersedes this.
send({ type:'ime.carettap', geom: fieldGeom(el) });
}
}, true);
function enter(n){
if (!n) return;
@@ -363,12 +552,190 @@
}
setSel(n, msg.selStart, msg.selEnd);
if (!composingActive && inComposition) { inComposition = false; fireComp(n, 'compositionend', d.inserted || ''); }
} finally { n.__nappletIme = false; lastSel = selOf(n); }
} finally { n.__nappletIme = false; noteSel(selOf(n)); }
}
// --- Page (non-editable) text selection re-hosting ---
// Chrome can't present its selection handles/toolbar in the cross-process embedded surface, so a
// long-press on ordinary page text selects a word and then ~60ms later abandons (collapses) it, the
// same way it does inside inputs. Mirror the document selection: re-assert it when it collapses right
// after forming, and report the selected text so the host can show its own Copy bar over the page.
var pageSelText = '', lastPageRange = null, lastPageAt = -1, pageReasserting = false;
// Selection geometry in CSS px (viewport coords). The host maps these to screen px (scale = surface
// width / vw) to draw the toolbar above the selection and a handle at each end. l/t/r/b is the bounding
// box; (sx,sb) the start-caret foot, (ex,eb) the end-caret foot; vw lets the host derive the scale.
function pageGeom(r){
try {
var b = r.getBoundingClientRect();
var sr = r.cloneRange(); sr.collapse(true); var s = sr.getBoundingClientRect();
var er = r.cloneRange(); er.collapse(false); var e = er.getBoundingClientRect();
return { l: b.left, t: b.top, r: b.right, b: b.bottom, sx: s.left, sb: s.bottom, ex: e.left, eb: e.bottom, vw: window.innerWidth };
} catch (_) { return null; }
}
function sendPageSel(active, r){
var text = active ? String(window.getSelection()) : '';
pageSelText = text;
send({ type: 'ime.pagesel', active: active, text: text, geom: active && r ? pageGeom(r) : null });
}
document.addEventListener('selectionchange', function(){
if (el || pageReasserting) return; // selections inside an editable are handled above
lastSelActivityAt = perfNow(); // page selection moved → an imminent reveal-scroll isn't a user scroll
var s = window.getSelection();
if (s && s.rangeCount && !s.isCollapsed) {
var r = s.getRangeAt(0);
lastPageRange = r.cloneRange(); lastPageAt = perfNow();
sendPageSel(true, r);
} else if (lastPageRange && (perfNow() - lastPageAt) < 400) {
pageReasserting = true;
try { s.removeAllRanges(); s.addRange(lastPageRange); } catch (_) {}
pageReasserting = false;
lastPageAt = perfNow();
} else if (pageSelText) {
lastPageRange = null;
sendPageSel(false, null);
}
}, true);
// Host drag of a selection handle: move the dragged edge to the text position under (x,y) CSS px,
// keeping the opposite edge anchored. setBaseAndExtent tolerates either drag direction.
function pageExtend(edge, x, y){
try {
var pt = document.caretRangeFromPoint && document.caretRangeFromPoint(x, y);
var s = window.getSelection();
if (!pt || !s.rangeCount) return;
var cur = s.getRangeAt(0);
var aN, aO;
if (edge === 'start') { aN = cur.endContainer; aO = cur.endOffset; } else { aN = cur.startContainer; aO = cur.startOffset; }
pageReasserting = true;
s.setBaseAndExtent(aN, aO, pt.startContainer, pt.startOffset);
pageReasserting = false;
if (!s.isCollapsed) {
var nr = s.getRangeAt(0);
lastPageRange = nr.cloneRange(); lastPageAt = perfNow();
sendPageSel(true, nr);
}
} catch (_) {}
}
// Word-granularity snapping (native: dragging a word selection's handle extends a word at a time). The
// end handle snaps to the end of the word at/after the offset; the start handle to the start of the word
// at/before it. Whitespace between words extends to the adjacent word so you never stop mid-gap.
function isWordChar(c){ return c != null && /\S/.test(c); }
function wordEndAt(text, off){
var i = off;
while (i < text.length && !isWordChar(text[i])) i++;
while (i < text.length && isWordChar(text[i])) i++;
return i;
}
function wordStartAt(text, off){
var i = off;
while (i > 0 && !isWordChar(text[i - 1])) i--;
while (i > 0 && isWordChar(text[i - 1])) i--;
return i;
}
// Per-drag state for the hybrid word/char handle extend below. `fieldDragWordEnd`/`fieldDragWordStart`
// remember how far the dragged edge has been word-snapped so far this gesture; a >250ms gap between
// `ime.fieldextend` ops (or a switch of edge) means a NEW drag, so we re-baseline to the live selection.
var fieldDragAt = -1, fieldDragEdge = null, fieldDragWordEnd = -1, fieldDragWordStart = -1;
// Host drag of an in-field selection handle: move the dragged edge to the offset under (x,y) CSS px,
// keeping the other edge anchored, clamped so it can't cross the anchor. HYBRID granularity, matching
// native `Editor` word-selection drags (#5): the gesture starts anchored to the current selection edge,
// and as the finger sweeps PAST that word's far boundary it snaps the dragged edge to the next WHOLE word
// (so sweeping across words grabs them whole and never stops mid-gap); moving WITHIN or back from the
// furthest-reached word gives CHARACTER precision (so you can fine-tune to a single character).
function fieldExtend(edge, x, y){
if (!el || isCE(el)) return;
try {
var off = offsetFromPoint(el, x, y);
var text = valOf(el);
var sel = selOf(el);
var now = perfNow();
var fresh = (now - fieldDragAt > 250) || edge !== fieldDragEdge;
fieldDragAt = now; fieldDragEdge = edge;
var s, e;
if (edge === 'start') {
e = sel[1];
if (fresh) fieldDragWordStart = sel[0]; // baseline at the current selection start
if (off < fieldDragWordStart) { s = wordStartAt(text, off); fieldDragWordStart = s; } // swept into a new word → snap whole
else s = off; // within / back from the furthest word → character precision
s = Math.max(0, Math.min(s, e));
} else {
s = sel[0];
if (fresh) fieldDragWordEnd = sel[1]; // baseline at the current selection end
if (off > fieldDragWordEnd) { e = wordEndAt(text, off); fieldDragWordEnd = e; } // swept into a new word → snap whole
else e = off; // within / back from the furthest word → character precision
e = Math.min(text.length, Math.max(e, s));
}
el.__nappletIme = true;
setSel(el, s, e);
el.__nappletIme = false;
lastSel = selOf(el);
reportState();
} catch (_) {}
}
// While the page scrolls, host-drawn selection UI (toolbar + handles) would float at stale positions, so
// the host hides it on scroll-start and we re-report fresh geometry on scroll-idle so it reappears in the
// right place — like Android. Only signal when there's a selection to hide (a field range or page text).
var scrolling = false, scrollTimer = null, autoScrolling = false;
// How long after a selection change a scroll is treated as the browser's auto-reveal of that selection
// (not a user content scroll). Generous enough to catch the reveal-scroll that fires a frame or two later.
var SCROLL_SEL_GUARD_MS = 350;
function hasSelectionUi(){ return !!pageSelText || !!(el && (function(s){ return s[0] !== s[1]; })(selOf(el))); }
function onAnyScroll(){
if (autoScrolling) return; // our own drag-to-edge auto-scroll: keep the overlays up, don't hide them
if (!hasSelectionUi()) return;
if ((perfNow() - lastSelActivityAt) < SCROLL_SEL_GUARD_MS) {
// The browser auto-scrolled to reveal a just-changed selection (forming/re-asserting a range scrolls a
// textarea). That's not a user content scroll: hiding here would blink the host overlays off-and-on every
// time a selection settles. Reposition them in place instead (geometry shifted by the reveal-scroll).
// Crucially we do NOT touch the hide-on-scroll timer: if a real scroll-hide is somehow active, these
// reveal-scrolls must not keep re-arming it (that would leave the overlays hidden indefinitely).
if (el) reportState();
else { var sr = window.getSelection(); if (sr && sr.rangeCount && !sr.isCollapsed) sendPageSel(true, sr.getRangeAt(0)); }
return;
}
if (!scrolling) { scrolling = true; send({ type:'ime.scroll', active: true }); }
if (scrollTimer) clearTimeout(scrollTimer);
scrollTimer = setTimeout(function(){
scrolling = false; scrollTimer = null;
// Refresh geometry FIRST (so overlays reposition), then tell the host to show them again.
if (el) reportState();
else { var s = window.getSelection(); if (s && s.rangeCount && !s.isCollapsed) sendPageSel(true, s.getRangeAt(0)); }
send({ type:'ime.scroll', active: false });
}, 150);
}
document.addEventListener('scroll', onAnyScroll, true); // capture: any scroller, not just the document
window.__nappletImeHandle = function(msg){
if (msg.type === 'ime.set') applyState(msg);
else if (msg.type === 'ime.action') enter(el);
else if (msg.type === 'ime.pageextend') pageExtend(msg.edge, msg.x, msg.y);
else if (msg.type === 'ime.fieldextend') fieldExtend(msg.edge, msg.x, msg.y);
else if (msg.type === 'ime.autoscroll') {
// Host drag of a handle near the surface's top/bottom edge → scroll the content (the textarea if it
// scrolls, else the page) so the selection can keep extending, then re-report geometry so the
// overlays follow. Flagged so our own scroll doesn't trip the hide-on-scroll path above.
var dy = msg.dy || 0;
autoScrolling = true;
try {
if (el && (el.tagName || '').toUpperCase() === 'TEXTAREA') el.scrollTop += dy;
window.scrollBy(0, dy);
} catch (_) {}
if (el) reportState();
else { var s = window.getSelection(); if (s && s.rangeCount && !s.isCollapsed) sendPageSel(true, s.getRangeAt(0)); }
setTimeout(function(){ autoScrolling = false; }, 0);
}
else if (msg.type === 'ime.caretmove') {
if (el && !isCE(el)) {
var off = offsetFromPoint(el, msg.x, msg.y);
el.__nappletIme = true;
setSel(el, off, off);
el.__nappletIme = false;
lastSel = selOf(el);
reportState();
}
}
};
})();