mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +00:00
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:
@@ -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();
|
||||
}
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user