fix(web): optimizes, enhances caret placement within text on mobile devices#6551
fix(web): optimizes, enhances caret placement within text on mobile devices#6551
Conversation
User Test ResultsTest specification and instructions
Test Artifacts
|
| setFocusWithTouch(tEvent: {clientX: number, clientY: number, target?: EventTarget}) { | ||
| var touchX=tEvent.clientX,touchY=tEvent.clientY; | ||
| setFocusWithTouch(tEvent: {pageX: number, pageY: number, target?: EventTarget}) { | ||
| var touchX=tEvent.pageX,touchY=tEvent.pageY; |
There was a problem hiding this comment.
The getAbsoluteY function (used heavily by caret-placement logic) operates from a pageY perspective.
This change is needed to properly handle caret positioning when the page is scrolled; my iPhone SE tests (see test spec) fail otherwise, as the page will auto-scroll, causing divergence between clientY and pageY.
There was a problem hiding this comment.
This looks like it's just renaming variables to better match reality, but it doesn't change any behavior, or do I miss something here?
There was a problem hiding this comment.
Note the second line - there's an actual functional change here.
This link may help explain the distinction: https://stackoverflow.com/a/21452887
There was a problem hiding this comment.
Ahh, I fell into the TypeScript trap again 😄 The event actually contains both client* and page* fields but we were using the wrong set. Thanks!
| y=dom.Utils.getAbsoluteY(caret)-dy; //top of caret | ||
| if(y > touchY && cp > cpMin && cp != cpMax) {cpMax=cp; cp=Math.round((cp+cpMin)/2);} | ||
| else if(y < touchY-yRow && cp < cpMax && cp != cpMin) {cpMin=cp; cp=Math.round((cp+cpMax)/2);} | ||
| else break; | ||
| target.setTextCaret(cp); | ||
| } | ||
|
|
||
| while(dom.Utils.getAbsoluteY(caret)-dy > touchY && cp > cpMin) { | ||
| target.setTextCaret(--cp); | ||
| } | ||
|
|
||
| while(dom.Utils.getAbsoluteY(caret)-dy < touchY-yRow && cp < cpMax) { | ||
| target.setTextCaret(++cp); | ||
| } |
There was a problem hiding this comment.
For comparison with the other block, re: getAbsoluteY motivating the clientY -> pageY change.
This block still exists; it's just been refactored into the new performCaretSearch function. Along with some light refactoring to reformat and clarify the code, as it was somewhat dense in its original form.
GROUP_PHONE: Run tests on the page while emulating an iPhone SE via Chrome mobile-device emulation.
GROUP_TABLET: Run tests on the page while emulating an iPad Air (or similar tablet form-factor) using Chrome mobile-device emulation.
@jahorton "little bit" sluggishness means sometime, the cursor stick around the text even though I clicked some other text in the same paragraph. But it happens randomly. Not able to reproduce it. |
Define "a little bit sluggishness". Are we talking about a quarter second, a half second, more than one second...? It'd help to know the order of magnitude. I did note a "little bit", but on my computer (at least) it was under 0.25 seconds, hence that detail from the test instructions:
A little bit sluggish, but only about as long as it takes to blink... which I think is supposed to be about 0.1 seconds, actually. If it was the same for you, then all the tests passed, not failed. Either way, "a little bit" is, at least, far, far better than the amount of time it originally took. |
| setFocusWithTouch(tEvent: {clientX: number, clientY: number, target?: EventTarget}) { | ||
| var touchX=tEvent.clientX,touchY=tEvent.clientY; | ||
| setFocusWithTouch(tEvent: {pageX: number, pageY: number, target?: EventTarget}) { | ||
| var touchX=tEvent.pageX,touchY=tEvent.pageY; |
There was a problem hiding this comment.
This looks like it's just renaming variables to better match reality, but it doesn't change any behavior, or do I miss something here?
@jahorton Okay, Thanks for the clarification. I will retest it (with stop clock) and will post my comment. |
GROUP_PHONE: Run tests on the page while emulating an iPhone SE via Chrome mobile-device emulation.
GROUP_TABLET: Run tests on the page while emulating an iPad Air (or similar tablet form-factor) using Chrome mobile-device emulation.
|
|
Changes in this pull request will be available for download in Keyman version 15.0.239-beta |
mcdurdin
left a comment
There was a problem hiding this comment.
Looking good. A few comments on the comments, mostly.
| // Except... we... haven't modified cpMin & cpMax since their roles in the | ||
| // y-position search, which matters if the backing element is a TextArea. | ||
| // Why not? That sounds dangerous! |
There was a problem hiding this comment.
I have no idea what this comment means?!
There was a problem hiding this comment.
That comment was written during my analysis of the old code; there was faulty logic when repurposing the <input>-oriented horizontal positioning code, which assumes cpMin and cpMax to be boundary positions on the target line. The <textarea> branch never tightened the bounds.
I can probably clean that up now that the bug's removed, but it was a critical note when diagnosing the caret-misplacement aspect noted in the description:
While I was at it, I also fixed up the caret placement logic; it should land where expected far, far more consistently and reliably than before.
There was a problem hiding this comment.
Yes, please clean up -- comments are intended for future readers who don't know the history or context. So write them accordingly 😁
| } | ||
| } | ||
|
|
||
| private performCaretSearch(target: TouchAliasElement, scroller: HTMLElement, touchX: number, touchY: number) { |
There was a problem hiding this comment.
This seems to have changed somewhat from the previous code. I have done a comparison and think I can see the differences okay.
Also, please add a code comment describing the function.
| var snapOrder; | ||
| if(target.dir == 'rtl') { // I would use arrow functions, but IE doesn't like 'em. | ||
| snapOrder = function(a, b) { | ||
| return a < b; | ||
| }; | ||
| } else { | ||
| snapOrder = function(a, b) { | ||
| return a > b; | ||
| }; | ||
| } |
There was a problem hiding this comment.
| var snapOrder; | |
| if(target.dir == 'rtl') { // I would use arrow functions, but IE doesn't like 'em. | |
| snapOrder = function(a, b) { | |
| return a < b; | |
| }; | |
| } else { | |
| snapOrder = function(a, b) { | |
| return a > b; | |
| }; | |
| } | |
| const snapOrder = target.dir == 'rtl' ? (a,b) => a < b : (a,b) => a > b; |
There was a problem hiding this comment.
Would it be fine if I placed this change within #6557 instead?
| if(y > maxY) { | ||
| maxCpMax = searchPt-1; // We already know it's not on this row, so the maximum possible should be decreased. | ||
| } else { | ||
| minCpMax = searchPt; // Still in the same row, so the boundary can only be at or after this point. | ||
| } |
There was a problem hiding this comment.
| if(y > maxY) { | |
| maxCpMax = searchPt-1; // We already know it's not on this row, so the maximum possible should be decreased. | |
| } else { | |
| minCpMax = searchPt; // Still in the same row, so the boundary can only be at or after this point. | |
| } | |
| if(y > maxY) { | |
| // We already know it's not on this row, so the maximum possible should be decreased. | |
| maxCpMax = searchPt-1; | |
| } else { | |
| // Still in the same row, so the boundary can only be at or after this point. | |
| minCpMax = searchPt; | |
| } |
| if(y < minY) { | ||
| minCpMin = searchPt+1; // We already know it's not on this row, so the minimum possible should be increased. | ||
| } else { | ||
| maxCpMin = searchPt; // Still in the same row, so the boundary can only be at or before this point. | ||
| } |
There was a problem hiding this comment.
| if(y < minY) { | |
| minCpMin = searchPt+1; // We already know it's not on this row, so the minimum possible should be increased. | |
| } else { | |
| maxCpMin = searchPt; // Still in the same row, so the boundary can only be at or before this point. | |
| } | |
| if(y < minY) { | |
| // We already know it's not on this row, so the minimum possible should be increased. | |
| minCpMin = searchPt+1; | |
| } else { | |
| // Still in the same row, so the boundary can only be at or before this point. | |
| maxCpMin = searchPt; | |
| } |
| if(y > maxY && cp > cpMin /*&& cp != cpMax*/) { | ||
| // If caret's prior placement is below (after) the touch's y-pos... | ||
| cpMax=cp; // new max position | ||
| cp=Math.round((cp+cpMin)/2); // guess the halfway mark | ||
| } else if(y < minY && cp < cpMax /*&& cp != cpMin*/) { |
There was a problem hiding this comment.
| if(y > maxY && cp > cpMin /*&& cp != cpMax*/) { | |
| // If caret's prior placement is below (after) the touch's y-pos... | |
| cpMax=cp; // new max position | |
| cp=Math.round((cp+cpMin)/2); // guess the halfway mark | |
| } else if(y < minY && cp < cpMax /*&& cp != cpMin*/) { | |
| if(y > maxY && cp > cpMin) { | |
| // If caret's prior placement is below (after) the touch's y-pos... | |
| cpMax=cp; // new max position | |
| cp=Math.round((cp+cpMin)/2); // guess the halfway mark | |
| } else if(y < minY && cp < cpMax) { |
| // Break the binary search if our final search window is extremely small. | ||
| if(cpMax - cpMin <= 1) { | ||
| break; | ||
| } |
There was a problem hiding this comment.
| // Break the binary search if our final search window is extremely small. | |
| if(cpMax - cpMin <= 1) { | |
| break; | |
| } | |
| if(cpMax - cpMin <= 1) { | |
| // Break the binary search if our final search window is extremely small. | |
| break; | |
| } |
| __caretTimerId: number; | ||
| __activeCaret: boolean = false; | ||
|
|
||
| __midCaretSearching: boolean = false; |
There was a problem hiding this comment.
Perhaps __executingCaretSearch is better? ('mid' is a bit confusing as it has mathematical meaning as well).
| let _this = this; | ||
| target.executeCaretSearchFunctor(function() { | ||
| _this.performCaretSearch(target, scroller, touchX, touchY); | ||
| }); |
There was a problem hiding this comment.
| let _this = this; | |
| target.executeCaretSearchFunctor(function() { | |
| _this.performCaretSearch(target, scroller, touchX, touchY); | |
| }); | |
| target.executeCaretSearch(() => this.performCaretSearch(target, scroller, touchX, touchY)); |
Using arrow function means we have lexical this.
| * Exists to encapsulate this behavior to ensure it's always, unfailingly re-enabled. | ||
| * @param functor A function that will search for the best caret position. | ||
| */ | ||
| executeCaretSearchFunctor(functor: () => void) { |
There was a problem hiding this comment.
Just executeCaretSearch is probably a good name? Functor is not really required.
I understand you need this indirection because the state variable is in touchAliasElement but the actual search is done in domEventHandlers, yes?
There was a problem hiding this comment.
I understand you need this indirection because the state variable is in touchAliasElement but the actual search is done in domEventHandlers, yes?
Correct.




Fixes #5312.
Dramatically reduces the time taken to place the caret within text for
TouchAliasElements, the emulated text inputs KMW produces on mobile devices to facilitate use of the Web-based keyboard. This is most notable for the first touch into such an area, which had been particularly bad due to some initialization issues for the caret-placement algorithm.While I was at it, I also fixed up the caret placement logic; it should land where expected far, far more consistently and reliably than before.
User Testing
This PR adds a new testing page:
web/testing/issue5312-touch-alias-optimization.Said testing page (seen as "Tests performance of touch-alias elements with large amounts of text (#5312)" on the testing page index) features a text area preloaded with 6957 characters. (Thanks, Lorem Ipsum Generator.) Without the optimizations, my testing tab in Chrome would effectively hang immediately. (I had to force-quit Chrome a number of times for testing!) Now... interactions feel nigh-instant!
Tests
Use remote device emulation on Google Chrome on the testing page noted above.
GROUP_PHONE: Run tests on the page while emulating an iPhone SE via Chrome mobile-device emulation.
GROUP_TABLET: Run tests on the page while emulating an iPad Air (or similar tablet form-factor) using Chrome mobile-device emulation.
TEST_INITIAL_TOUCH:
TEST_SCROLLED_TOUCH:
As noted, there may be very slight lag on caret-placement attempts, but only very slight. It shouldn't ever come close to feeling like it's 'locked up' your tab in the browser.
Lone caveat: unless I'm mistaken, it will not allow you to place the caret at the start of any line but the first. If it does, it won't allow you to place it at the end of the previous line. Only one of the two will be possible; that's a
TouchAliasElementlimitation.