fix(web): restores element text scrolling

This commit is contained in:
Joshua A. Horton 2023-06-06 10:15:16 +07:00
parent 21c780f66b
commit 39f30e4385
6 changed files with 101 additions and 114 deletions

View file

@ -41,6 +41,18 @@ interface EventMap {
export class FocusAssistant extends EventEmitter<EventMap> {
private _maintainingFocus: boolean = false; // ActivatingKeymanWebUI - Does the OSK have active focus / an active interaction?
/**
* Returns `true` only when the active target has an active `forceScroll` method/state, which deliberately
* blurs and then refocuses the same element in order to force a browser-default page scroll to keep the
* element and text-caret visible.
*/
readonly isTargetForcingScroll: () => boolean;
constructor(isTargetForcingScroll: () => boolean) {
super();
this.isTargetForcingScroll = isTargetForcingScroll;
}
/*
* Long-term idea here: about all of the relevant OSK events that would interact with this have "enter" and
* "leave" variants - we could take a stack of `Promise`s. On a `Promise` fulfillment, remove it from the
@ -109,16 +121,6 @@ export class FocusAssistant extends EventEmitter<EventMap> {
*/
_IgnoreNextSelChange = 0;
/**
* JH (2023-04-24): Set only by the OutputTarget `forceScroll` method, which deliberately blurs and
* then refocuses the same element in order to force a browser-default page scroll to keep the element
* visible.
*
* While it feels like this should be possible to merge with the other class fields in some form... it
* doesn't seem as safe to do on first glance.
*/
_IgnoreBlurFocus: boolean = false;
/**
* Is used as a time-delayed async `restoringFocus` or `maintainingFocus` - could be modeled decently as a Promise.
* Probably more the latter, as it's a touch-OSK interaction like the other `maintainingFocus` cases.

View file

@ -67,8 +67,10 @@ export class PageIntegrationHandlers {
}
private suppressFocusCheck: (e: FocusEvent) => boolean = (e) => {
if(this.focusAssistant._IgnoreBlurFocus) {
// Prevent triggering other blur-handling events (as possible)
if(this.focusAssistant.isTargetForcingScroll()) {
// Prevent triggering other blur-handling events (as possible) - this blur
// is programmatic in order to force a browser scroll-position update.
// All focus changes should be prevented at this time.
e.stopPropagation();
e.cancelBubble = true;
}

View file

@ -43,7 +43,7 @@ function _SetTargDir(Ptarg: HTMLElement, activeKeyboard: Keyboard) {
export default class ContextManager extends ContextManagerBase<BrowserConfiguration> {
private _activeKeyboard: {keyboard: Keyboard, metadata: KeyboardStub};
private cookieManager = new CookieSerializer<KeyboardCookie>('KeymanWeb_Keyboard');
readonly focusAssistant = new FocusAssistant();
readonly focusAssistant = new FocusAssistant(() => this.activeTarget?.isForcingScroll());
readonly page: PageContextAttachment;
private mostRecentTarget: OutputTarget<any>;
private currentTarget: OutputTarget<any>;
@ -231,6 +231,11 @@ export default class ContextManager extends ContextManagerBase<BrowserConfigurat
* still trigger a focus event upon it... which can cascade here if uncaught
* and trigger a contextReset DURING keyboard rule processing without this
* guard.
*
* The #2 reason: the `forceScroll` method used within the Input and Textarea
* types whenever the selection must be programatically updated. The blur
* is 'swallowed', preventing it from being dropped as 'active'. However, the
* corresponding focus is not swallowed... until this if-condition's check.
*/
return;
}
@ -598,9 +603,6 @@ export default class ContextManager extends ContextManagerBase<BrowserConfigurat
// Ltarg=Ltarg.contentWindow.document.body; // And we only care about Ltarg b/c of finding the OutputTarget.
// }
// Save it for the event in step 3... but now, before we mutate the field's value!
const previousTarget = this.lastActiveTarget;
// Step 2: Make the newly-focused control the active control, and thus the active context.
this.setActiveTarget(target, true);
@ -622,7 +624,7 @@ export default class ContextManager extends ContextManagerBase<BrowserConfigurat
return true;
}
if(this.focusAssistant._IgnoreBlurFocus) {
if(this.focusAssistant.isTargetForcingScroll()) {
// Prevent triggering other blur-handling events (as possible)
e.cancelBubble = true;
e.stopPropagation();

View file

@ -1,39 +1,6 @@
import OutputTarget from './outputTarget.js';
interface EventMap {
/**
* Used to facilitate a pre-modularization utility method we wish to maintain:
```
export function forceScroll(element: HTMLInputElement | HTMLTextAreaElement) {
// Only executes when com.keyman.DOMEventHandlers is defined.
//
// We bypass this whenever operating in the embedded format.
if(com && com.keyman && com.keyman['DOMEventHandlers'] && !com.keyman['singleton']['isEmbedded']) {
let DOMEventHandlers = com.keyman['DOMEventHandlers'];
let selectionStart = element.selectionStart;
let selectionEnd = element.selectionEnd;
DOMEventHandlers.states._IgnoreBlurFocus = true;
//Forces scrolling; the re-focus triggers the scroll, at least.
element.blur();
element.focus();
DOMEventHandlers.states._IgnoreBlurFocus = false;
// On Edge, it appears that the blur/focus combination will reset the caret position
// under certain scenarios during unit tests. So, we re-set it afterward.
element.selectionStart = selectionStart;
element.selectionEnd = selectionEnd;
}
}
```
* References to the event-handlers & related states objects are not available within this submodule.
*
* It is the parts between and including the _IgnoreBlurFocus references that must be
* implemented externally.
*/
'scrollfocusrequest': (element: HTMLInputElement) => void,
/**
* This event will be raised when a newline is received by wrapped elements not of
* the 'search' or 'submit' types.
@ -74,23 +41,19 @@ export default class Input extends OutputTarget<EventMap> {
*/
private processedSelectionEnd: number;
/**
* Set, then unset within the `forceScroll` method in order to facilitate the
* `isForcingScroll` flag.
*/
private _activeForcedScroll: boolean;
constructor(ele: HTMLInputElement) {
super();
this.root = ele;
this._cachedSelectionStart = -1;
// Intended to facilitate reimplmentation of the old `forceScroll` as an event handler
// defined externally, but automatically set on class construction.
Input.constructorExtensions(this);
}
/**
* This may be set to define additional construction behaviors to perform, such as
* automatically setting handlers for defined events.
*/
public static constructorExtensions: (constructingInstance: Input) => void = () => {};
get isSynthetic(): boolean {
return false;
}
@ -146,11 +109,39 @@ export default class Input extends OutputTarget<EventMap> {
this.processedSelectionStart = start;
this.processedSelectionEnd = end;
this.events.emit('scrollfocusrequest', this.root);
this.forceScroll();
this.root.setSelectionRange(domStart, domEnd, direction);
}
forceScroll() {
// Only executes when com.keyman.DOMEventHandlers is defined.
//
// We bypass this whenever operating in the embedded format.
const element = this.getElement();
let selectionStart = element.selectionStart;
let selectionEnd = element.selectionEnd;
this._activeForcedScroll = true;
try {
//Forces scrolling; the re-focus triggers the scroll, at least.
element.blur();
element.focus();
} finally {
// On Edge, it appears that the blur/focus combination will reset the caret position
// under certain scenarios during unit tests. So, we re-set it afterward.
element.selectionStart = selectionStart;
element.selectionEnd = selectionEnd;
this._activeForcedScroll = false;
}
}
isForcingScroll(): boolean {
return this._activeForcedScroll;
}
getSelectionDirection(): "forward" | "backward" | "none" {
return this.root.selectionDirection;
}

View file

@ -23,6 +23,16 @@ export default abstract class OutputTarget<EventMap extends EventEmitter.ValidEv
}
}
/**
* Denotes when the represented element is forcing a text scroll via focus manipulation.
* As the intent is not to change the focused element, but just to have the browser update
* the scroll location, standard focus handlers (for updating the active context) should
* not deactivate the element while this state is active.
*/
isForcingScroll(): boolean {
return false;
}
/**
* A helper method for doInputEvent; creates a simple common event and default dispatching.
* @param elem

View file

@ -1,41 +1,6 @@
import OutputTarget from './outputTarget.js';
interface EventMap {
/**
* Used to facilitate a pre-modularization utility method we wish to maintain:
```
export function forceScroll(element: HTMLInputElement | HTMLTextAreaElement) {
// Only executes when com.keyman.DOMEventHandlers is defined.
//
// We bypass this whenever operating in the embedded format.
if(com && com.keyman && com.keyman['DOMEventHandlers'] && !com.keyman['singleton']['isEmbedded']) {
let DOMEventHandlers = com.keyman['DOMEventHandlers'];
let selectionStart = element.selectionStart;
let selectionEnd = element.selectionEnd;
DOMEventHandlers.states._IgnoreBlurFocus = true;
//Forces scrolling; the re-focus triggers the scroll, at least.
element.blur();
element.focus();
DOMEventHandlers.states._IgnoreBlurFocus = false;
// On Edge, it appears that the blur/focus combination will reset the caret position
// under certain scenarios during unit tests. So, we re-set it afterward.
element.selectionStart = selectionStart;
element.selectionEnd = selectionEnd;
}
}
```
* References to the event-handlers & related states objects are not available within this submodule.
*
* It is the parts between and including the _IgnoreBlurFocus references that must be
* implemented externally.
*/
'scrollfocusrequest': (element: HTMLTextAreaElement) => void,
}
export default class TextArea extends OutputTarget<EventMap> {
export default class TextArea extends OutputTarget<{}> {
root: HTMLTextAreaElement;
/**
@ -56,31 +21,18 @@ export default class TextArea extends OutputTarget<EventMap> {
private processedSelectionEnd: number;
/**
* Used to temporarily store the y-axis scroll coordinate.
* Set, then unset within the `forceScroll` method in order to facilitate the
* `isForcingScroll` flag.
*/
private scrollTop?: number;
/**
* Used to temporarily store the x-axis scroll coordinate.
*/
private scrollLeft?: number;
private _activeForcedScroll: boolean;
constructor(ele: HTMLTextAreaElement) {
super();
this.root = ele;
this._cachedSelectionStart = -1;
// Intended to facilitate reimplmentation of the old `forceScroll` as an event handler
// defined externally, but automatically set on class construction.
TextArea.constructorExtensions(this);
}
/**
* This may be set to define additional construction behaviors to perform, such as
* automatically setting handlers for defined events.
*/
public static constructorExtensions: (constructingInstance: TextArea) => void = () => {};
get isSynthetic(): boolean {
return false;
}
@ -136,11 +88,39 @@ export default class TextArea extends OutputTarget<EventMap> {
this.processedSelectionStart = start;
this.processedSelectionEnd = end;
this.events.emit('scrollfocusrequest', this.root);
this.forceScroll();
this.root.setSelectionRange(domStart, domEnd, direction);
}
forceScroll() {
// Only executes when com.keyman.DOMEventHandlers is defined.
//
// We bypass this whenever operating in the embedded format.
const element = this.getElement();
let selectionStart = element.selectionStart;
let selectionEnd = element.selectionEnd;
this._activeForcedScroll = true;
try {
//Forces scrolling; the re-focus triggers the scroll, at least.
element.blur();
element.focus();
} finally {
// On Edge, it appears that the blur/focus combination will reset the caret position
// under certain scenarios during unit tests. So, we re-set it afterward.
element.selectionStart = selectionStart;
element.selectionEnd = selectionEnd;
this._activeForcedScroll = false;
}
}
isForcingScroll(): boolean {
return this._activeForcedScroll;
}
getSelectionDirection(): "forward" | "backward" | "none" {
return this.root.selectionDirection;
}