diff --git a/common/web/es-bundling/src/classTreeshaker.mts b/common/web/es-bundling/src/classTreeshaker.mts index 4f0e1d8dad..0cdac0712a 100644 --- a/common/web/es-bundling/src/classTreeshaker.mts +++ b/common/web/es-bundling/src/classTreeshaker.mts @@ -15,7 +15,7 @@ export const pluginForDowncompiledClassTreeshaking: esbuild.Plugin = { return { // Marks any classes compiled by TS (as per the /** @class */ annotation) // as __PURE__ in order to facilitate tree-shaking. - contents: source.replace('/** @class */', '/* @__PURE__ */ /** @class */'), + contents: source.replaceAll('/** @class */', '/** @__PURE__ */'), loader: 'js' } }); diff --git a/web/src/engine/osk/src/banner/banner.ts b/web/src/engine/osk/src/banner/banner.ts index 999f7d6348..e90187ce02 100644 --- a/web/src/engine/osk/src/banner/banner.ts +++ b/web/src/engine/osk/src/banner/banner.ts @@ -3,10 +3,17 @@ import EventEmitter from 'eventemitter3'; import { DeviceSpec } from '@keymanapp/web-utils'; import { Keyboard, KeyboardProperties } from '@keymanapp/keyboard-processor'; import { type PredictionContext } from '@keymanapp/input-processor'; -import InputEventEngine, { InputEventEngineConfig } from '../input/event-interpreter/inputEventEngine.js'; -import MouseEventEngine from '../input/event-interpreter/mouseEventEngine.js'; -import TouchEventEngine from '../input/event-interpreter/touchEventEngine.js'; -import UITouchHandlerBase from '../input/event-interpreter/uiTouchHandlerBase.js'; + + +import { + GestureRecognizer, + GestureRecognizerConfiguration, + GestureSource, + InputSample, + PaddedZoneSource +} from '@keymanapp/gesture-recognizer'; + +import { BANNER_GESTURE_SET } from './bannerGestureSet.js'; import { createUnselectableElement } from 'keyman/engine/dom-utils'; @@ -49,7 +56,7 @@ export abstract class Banner { * @return {boolean} true if the banner styling changed * Description Update the height and display styling of the banner */ - private update() : boolean { + protected update() : boolean { let ds = this.div.style; let currentHeightStyle = ds.height; let currentDisplayStyle = ds.display; @@ -233,6 +240,18 @@ export class BannerSuggestion { this.display = display; } + public highlight(on: boolean) { + const elem = this.div; + let classes = elem.className; + let cs = ' ' + SuggestionBanner.TOUCHED_CLASS; + + if(on && classes.indexOf(cs) < 0) { + elem.className=classes+cs; + } else { + elem.className=classes.replace(cs,''); + } + } + public isEmpty(): boolean { return !this._suggestion; } @@ -287,7 +306,7 @@ export class SuggestionBanner extends Banner { private options : BannerSuggestion[] = []; private hostDevice: DeviceSpec; - private manager: SuggestionInputManager; + private gestureEngine: GestureRecognizer; private _predictionContext: PredictionContext; @@ -299,13 +318,11 @@ export class SuggestionBanner extends Banner { this.hostDevice = hostDevice; this.getDiv().className = this.getDiv().className + ' ' + SuggestionBanner.BANNER_CLASS; - this.buildInternals(false); - this.manager = new SuggestionInputManager(this.getDiv()); - this.events = this.manager.events; + this.events = new EventEmitter(); //this.manager.events; - this.setupInputHandling(); + this.gestureEngine = this.setupInputHandling(); } buildInternals(rtl: boolean) { @@ -342,34 +359,118 @@ export class SuggestionBanner extends Banner { } } - private setupInputHandling() { - let inputEngine: InputEventEngine; - if(this.hostDevice.touchable) { // /*&& ('ontouchstart' in window)*/ // Except Chrome emulation doesn't set this. - // Not to mention, it's rather redundant. - inputEngine = this.touchEventConfig; - } else { - inputEngine = this.mouseEventConfig; + private setupInputHandling(): GestureRecognizer { + + const findTargetFrom = (e: HTMLElement): HTMLDivElement => { + try { + if(e) { + if(e.classList.contains('kmw-suggest-option')) { + return e as HTMLDivElement; + } + if(e.parentElement && e.parentElement.classList.contains('kmw-suggest-option')) { + return e.parentElement as HTMLDivElement; + } + } + } catch(ex) {} + return null; } - inputEngine.registerEventHandlers(); + const config: GestureRecognizerConfiguration = { + targetRoot: this.getDiv(), + maxRoamingBounds: new PaddedZoneSource(this.getDiv(), [-0.333 * this.height]), + // touchEventRoot: this.element, // is the default + itemIdentifier: (sample, target: HTMLElement) => { + let bestMatch: BannerSuggestion = null; + let bestDist = Number.MAX_VALUE; - this.manager.events.on('highlight', (suggestion, on) => { - const elem = suggestion.div; - let classes = elem.className; - let cs = ' ' + SuggestionBanner.TOUCHED_CLASS; + for(const option of this.options) { + const optionBounding = option.div.getBoundingClientRect(); - if(on && classes.indexOf(cs) < 0) { - elem.className=classes+cs; - } else { - elem.className=classes.replace(cs,''); + if(optionBounding.left <= sample.clientX && sample.clientX < optionBounding.right) { + return option; + } else { + const dist = (sample.clientX < optionBounding.left ? -1 : 1) * (sample.clientX - optionBounding.left); + + if(dist < bestDist) { + bestDist = dist; + bestMatch = option; + } + } + } + + return bestMatch; } + }; + + const engine = new GestureRecognizer(BANNER_GESTURE_SET, config); + + const sourceTracker: { + source: GestureSource, + roamingHighlightHandler: (sample: InputSample) => void, + suggestion: BannerSuggestion + } = { + source: null, + roamingHighlightHandler: null, + suggestion: null + }; + + engine.on('inputstart', (source) => { + // The banner does not support multi-touch - if one is still current, block all others. + if(sourceTracker.source) { + source.terminate(true); + return; + } + + sourceTracker.source = source; + sourceTracker.roamingHighlightHandler = (sample) => { + // Maintain highlighting + const suggestion = sample.item; + + if(suggestion != sourceTracker.suggestion) { + sourceTracker.suggestion.highlight(false); + suggestion.highlight(true); + sourceTracker.suggestion = suggestion; + } + }; + sourceTracker.suggestion = source.currentSample.item; + + + source.currentSample.item.highlight(true); + + const terminationHandler = () => { + sourceTracker.suggestion.highlight(false); + sourceTracker.source = null; + sourceTracker.roamingHighlightHandler = null; + sourceTracker.suggestion = null; + } + + source.path.on('complete', terminationHandler); + source.path.on('invalidated', terminationHandler); + source.path.on('step', sourceTracker.roamingHighlightHandler); }); - this.manager.events.on('apply', (option) => { - if(this.predictionContext) { - this.predictionContext.accept(option.suggestion); - } + engine.on('recognizedgesture', (sequence) => { + // The actual result comes in via the sequence's `stage` event. + sequence.once('stage', (result) => { + const suggestion = result.item; // Should also == sourceTracker.suggestion. + if(suggestion) { + this.predictionContext.accept(suggestion.suggestion); + } + }); }); + + return engine; + } + + protected update() { + const result = super.update(); + + // Ensure the banner's extended recognition zone is based on proper, up-to-date layout info. + // Note: during banner init, `this.gestureEngine` may only be defined after + // the first call to this setter! + (this.gestureEngine?.config.maxRoamingBounds as PaddedZoneSource)?.updatePadding([-0.333 * this.height]); + + return result; } public configureForKeyboard(keyboard: Keyboard, keyboardProperties: KeyboardProperties) { @@ -389,36 +490,6 @@ export class SuggestionBanner extends Banner { this.onSuggestionUpdate(this.currentSuggestions); // restore suggestions } - private get mouseEventConfig() { - const config: InputEventEngineConfig = { - targetRoot: this.getDiv(), - // document.body is the event root b/c we need to track the mouse if it leaves - // the VisualKeyboard's hierarchy. - eventRoot: document.body, - inputStartHandler: this.manager.touchStart.bind(this.manager), - inputMoveHandler: this.manager.touchMove.bind(this.manager), - inputEndHandler: this.manager.touchEnd.bind(this.manager), - coordConstrainedWithinInteractiveBounds: function() { return true; } - }; - - return new MouseEventEngine(config); - } - - private get touchEventConfig() { - const config: InputEventEngineConfig = { - targetRoot: this.getDiv(), - // document.body is the event root b/c we need to track the mouse if it leaves - // the VisualKeyboard's hierarchy. - eventRoot: this.getDiv(), - inputStartHandler: this.manager.touchStart.bind(this.manager), - inputMoveHandler: this.manager.touchMove.bind(this.manager), - inputEndHandler: this.manager.touchEnd.bind(this.manager), - coordConstrainedWithinInteractiveBounds: function() { return true; } - }; - - return new TouchEventEngine(config); - } - public get predictionContext(): PredictionContext { return this._predictionContext; } @@ -454,117 +525,4 @@ interface SuggestionInputEventMap { highlight: (bannerSuggestion: BannerSuggestion, state: boolean) => void, apply: (bannerSuggestion: BannerSuggestion) => void; hold: (bannerSuggestion: BannerSuggestion) => void; -} - -class SuggestionInputManager extends UITouchHandlerBase { - public readonly events = new EventEmitter(); - - private eventDisablePromise: Promise; - - platformHold: (suggestion: BannerSuggestion, isCustom: boolean) => void; - - //#region Touch handling implementation - findTargetFrom(e: HTMLElement): HTMLDivElement { - try { - if(e) { - if(e.classList.contains('kmw-suggest-option')) { - return e as HTMLDivElement; - } - if(e.parentElement && e.parentElement.classList.contains('kmw-suggest-option')) { - return e.parentElement as HTMLDivElement; - } - // if(e.firstChild && util.hasClass( e.firstChild,'kmw-suggest-option')) { - // return e.firstChild as HTMLDivElement; - // } - } - } catch(ex) {} - return null; - } - - protected highlight(t: HTMLDivElement, on: boolean): void { - let suggestion = t['suggestion'] as BannerSuggestion; - - // Never highlight an empty suggestion button. - if(suggestion.isEmpty()) { - on = false; - } - - this.events.emit('highlight', suggestion, on); - } - - protected select(t: HTMLDivElement): void { - this.events.emit('apply', t['suggestion'] as BannerSuggestion); - } - - //#region Long-press support - protected hold(t: HTMLDivElement): void { - // let suggestionObj = t['suggestion'] as BannerSuggestion; - // - // // Is this the suggestion? It's never in this.currentSuggestions, so check against that. - // let isCustom = this.currentSuggestions.indexOf(suggestionObj.suggestion) == -1; - - this.events.emit('hold', t['suggestion'] as BannerSuggestion); - } - protected clearHolds(): void { - // Temp, pending implementation of suggestion longpress submenus - // - nothing to clear without them - - - // only really used in native-KMW - } - - protected hasModalPopup(): boolean { - return this.eventsBlocked; - } - - protected dealiasSubTarget(target: HTMLDivElement): HTMLDivElement { - return target; - } - - protected hasSubmenu(t: HTMLDivElement): boolean { - // Temp, pending implementation of suggestion longpress submenus - - // Only really used by native-KMW - see kmwnative's highlightSubKeys func. - return false; - } - - protected isSubmenuActive(): boolean { - // Temp, pending implementation of suggestion longpress submenus - - // Utilized only by native-KMW - it parallels hasModalPopup() in purpose. - return false; - } - - protected displaySubmenuFor(target: HTMLDivElement) { - // Utilized only by native-KMW to show submenus. - throw new Error("Method not implemented."); - } - //#endregion - //#endregion - - public get eventsBlocked(): boolean { - return !!this.eventDisablePromise; - } - - /** - * Intended for use by the mobile apps, which sometimes 'takes over' touch handling. - * For such cases, input should be blocked within KMW when the apps are managing an - * ongoing touch-hold for any other interaction. - * - * Formerly: - ``` - let keyman = com.keyman.singleton; - return keyman['osk'].vkbd.subkeyGesture && keyman.isEmbedded; - ``` - */ - public temporarilyBlockEvents(promise: Promise) { // TODO: ensure connection for embedded mode! - this.eventDisablePromise = promise; // Will require routing; this class is not exported! - promise.finally(() => { - this.eventDisablePromise = null; - }) - } - - constructor(div: HTMLElement) { - // TODO: Determine appropriate CSS styling names, etc. - super(div, Banner.BANNER_CLASS, SuggestionBanner.TOUCHED_CLASS); - } -} +} \ No newline at end of file diff --git a/web/src/engine/osk/src/banner/bannerGestureSet.ts b/web/src/engine/osk/src/banner/bannerGestureSet.ts new file mode 100644 index 0000000000..7e71020ee3 --- /dev/null +++ b/web/src/engine/osk/src/banner/bannerGestureSet.ts @@ -0,0 +1,27 @@ +import { deepCopy } from '@keymanapp/web-utils'; + +import { + gestures, + GestureModelDefs, + InputSample +} from '@keymanapp/gesture-recognizer'; + +import { BannerSuggestion } from './banner.js'; +import { SimpleTapModelWithReset } from "../input/gestures/specsForLayout.js"; + +export const BannerSimpleTap: gestures.specs.GestureModel = { + ...deepCopy(SimpleTapModelWithReset), + resolutionAction: { + type: 'complete', + item: 'current' + } +}; + +export const BANNER_GESTURE_SET: GestureModelDefs = { + gestures: [ + BannerSimpleTap + ], + sets: { + default: [SimpleTapModelWithReset.id] + } +} \ No newline at end of file diff --git a/web/src/engine/osk/src/config/embeddedGestureConfig.ts b/web/src/engine/osk/src/config/embeddedGestureConfig.ts index 57f756d771..774e66b7c5 100644 --- a/web/src/engine/osk/src/config/embeddedGestureConfig.ts +++ b/web/src/engine/osk/src/config/embeddedGestureConfig.ts @@ -1,7 +1,4 @@ import GlobeHint from "../globehint.interface.js"; -import PendingGesture from "../input/gestures/pendingGesture.interface.js"; -import { KeyElement } from "../keyElement.js"; -import KeyTip from "../keytip.interface.js"; import VisualKeyboard from "../visualKeyboard.js"; export default interface EmbeddedGestureConfig { diff --git a/web/src/engine/osk/src/index.ts b/web/src/engine/osk/src/index.ts index 135c57f5e2..15a74c5306 100644 --- a/web/src/engine/osk/src/index.ts +++ b/web/src/engine/osk/src/index.ts @@ -14,9 +14,6 @@ export { type KeyElement } from './keyElement.js'; export { type default as OSKBaseKey } from './keyboard-layout/oskBaseKey.js'; export { type default as GlobeHint } from './globehint.interface.js'; export { type default as KeyTip } from './keytip.interface.js'; -export { type default as PendingGesture } from './input/gestures/pendingGesture.interface.js'; -export { type default as RealizedGesture } from './input/gestures/realizedGesture.interface.js'; -export { type default as InputEventCoordinate } from './input/inputEventCoordinate.js'; export { type default as EmbeddedGestureConfig } from './config/embeddedGestureConfig.js'; export { default as Activator, StaticActivator } from './views/activator.js'; diff --git a/web/src/engine/osk/src/input/event-interpreter/inputEventEngine.ts b/web/src/engine/osk/src/input/event-interpreter/inputEventEngine.ts deleted file mode 100644 index 3da8a2acea..0000000000 --- a/web/src/engine/osk/src/input/event-interpreter/inputEventEngine.ts +++ /dev/null @@ -1,57 +0,0 @@ -import InputEventCoordinate from '../inputEventCoordinate.js'; - -export type InputHandler = (coord: InputEventCoordinate) => void; - -export interface InputEventEngineConfig { - /** - * Specifies the element that input listeners should be attached to. - */ - readonly eventRoot: HTMLElement; - /** - * Specifies the most specific common ancestor element of any event target - * that the InputEventEngine should consider. - */ - readonly targetRoot: HTMLElement; - - readonly coordConstrainedWithinInteractiveBounds: (coord: InputEventCoordinate) => boolean; - - readonly inputStartHandler?: InputHandler; - readonly inputMoveHandler?: InputHandler; - readonly inputMoveCancelHandler?: InputHandler; - readonly inputEndHandler?: InputHandler; -} - -export default abstract class InputEventEngine { - protected readonly config: InputEventEngineConfig; - - public constructor(config: InputEventEngineConfig) { - this.config = config; - } - - abstract registerEventHandlers(); - abstract unregisterEventHandlers(); - - onInputStart(coord: InputEventCoordinate) { - if(this.config.inputStartHandler) { - this.config.inputStartHandler(coord); - } - } - - onInputMove(coord: InputEventCoordinate) { - if(this.config.inputMoveHandler) { - this.config.inputMoveHandler(coord); - } - } - - onInputMoveCancel(coord: InputEventCoordinate) { - if(this.config.inputMoveCancelHandler) { - this.config.inputMoveCancelHandler(coord); - } - } - - onInputEnd(coord: InputEventCoordinate) { - if(this.config.inputEndHandler) { - this.config.inputEndHandler(coord); - } - } -} \ No newline at end of file diff --git a/web/src/engine/osk/src/input/event-interpreter/mouseEventEngine.ts b/web/src/engine/osk/src/input/event-interpreter/mouseEventEngine.ts deleted file mode 100644 index 2978edd3de..0000000000 --- a/web/src/engine/osk/src/input/event-interpreter/mouseEventEngine.ts +++ /dev/null @@ -1,95 +0,0 @@ -import InputEventEngine, { InputEventEngineConfig } from './inputEventEngine.js'; -import InputEventCoordinate from '../inputEventCoordinate.js'; - -export default class MouseEventEngine extends InputEventEngine { - private readonly _mouseStart: typeof MouseEventEngine.prototype.onMouseStart; - private readonly _mouseMove: typeof MouseEventEngine.prototype.onMouseMove; - private readonly _mouseEnd: typeof MouseEventEngine.prototype.onMouseEnd; - - private hasActiveClick: boolean = false; - private ignoreSequence: boolean = false; - - public constructor(config: InputEventEngineConfig) { - super(config); - - this._mouseStart = this.onMouseStart.bind(this); - this._mouseMove = this.onMouseMove.bind(this); - this._mouseEnd = this.onMouseEnd.bind(this); - } - - registerEventHandlers() { - this.config.eventRoot.addEventListener('mousedown', this._mouseStart, true); - this.config.eventRoot.addEventListener('mousemove', this._mouseMove, false); - // The listener below fails to capture when performing automated testing checks in Chrome emulation unless 'true'. - this.config.eventRoot.addEventListener('mouseup', this._mouseEnd, true); - } - - unregisterEventHandlers() { - this.config.eventRoot.removeEventListener('mousedown', this._mouseStart, true); - this.config.eventRoot.removeEventListener('mousemove', this._mouseMove, false); - this.config.eventRoot.removeEventListener('mouseup', this._mouseEnd, true); - } - - private preventPropagation(e: MouseEvent) { - // Standard event maintenance - e.preventDefault(); - e.cancelBubble=true; - e.returnValue=false; // I2409 - Avoid focus loss for visual keyboard events - - if(typeof e.stopImmediatePropagation == 'function') { - e.stopImmediatePropagation(); - } else if(typeof e.stopPropagation == 'function') { - e.stopPropagation(); - } - } - - onMouseStart(event: MouseEvent) { - if(!this.config.targetRoot.contains(event.target as Node)) { - this.ignoreSequence = true; - return; - } - - this.preventPropagation(event); - this.onInputStart(InputEventCoordinate.fromEvent(event)); - this.hasActiveClick = true; - } - - onMouseMove(event: MouseEvent) { - if(this.ignoreSequence) { - return; - } - - const coord = InputEventCoordinate.fromEvent(event); - - if(!event.buttons) { - if(this.hasActiveClick) { - this.hasActiveClick = false; - this.onInputMoveCancel(coord); - } - return; - } else if(!this.hasActiveClick) { - // Can interfere with OSK drag-handlers (title bar, resize bar) otherwise. - return; - } - - this.preventPropagation(event); - - if(this.config.coordConstrainedWithinInteractiveBounds(coord)) { - this.onInputMove(coord); - } else { - this.onInputMoveCancel(coord); - } - } - - onMouseEnd(event: MouseEvent) { - if(this.ignoreSequence) { - this.ignoreSequence = false; - return; - } - - if(!event.buttons) { - this.hasActiveClick = false; - } - this.onInputEnd(InputEventCoordinate.fromEvent(event)); - } -} \ No newline at end of file diff --git a/web/src/engine/osk/src/input/event-interpreter/touchEventEngine.ts b/web/src/engine/osk/src/input/event-interpreter/touchEventEngine.ts deleted file mode 100644 index 39e8492173..0000000000 --- a/web/src/engine/osk/src/input/event-interpreter/touchEventEngine.ts +++ /dev/null @@ -1,60 +0,0 @@ -import InputEventEngine, { InputEventEngineConfig } from './inputEventEngine.js'; -import InputEventCoordinate from '../inputEventCoordinate.js'; - -export default class TouchEventEngine extends InputEventEngine { - private readonly _touchStart: typeof TouchEventEngine.prototype.onTouchStart; - private readonly _touchMove: typeof TouchEventEngine.prototype.onTouchMove; - private readonly _touchEnd: typeof TouchEventEngine.prototype.onTouchEnd; - - public constructor(config: InputEventEngineConfig) { - super(config); - - this._touchStart = this.onTouchStart.bind(this); - this._touchMove = this.onTouchMove.bind(this); - this._touchEnd = this.onTouchEnd.bind(this); - } - - registerEventHandlers() { - this.config.eventRoot.addEventListener('touchstart', this._touchStart, true); - this.config.eventRoot.addEventListener('touchmove', this._touchMove, false); - // The listener below fails to capture when performing automated testing checks in Chrome emulation unless 'true'. - this.config.eventRoot.addEventListener('touchend', this._touchEnd, true); - } - - unregisterEventHandlers() { - this.config.eventRoot.removeEventListener('touchstart', this._touchStart, true); - this.config.eventRoot.removeEventListener('touchmove', this._touchMove, false); - this.config.eventRoot.removeEventListener('touchend', this._touchEnd, true); - } - - private preventPropagation(e: TouchEvent) { - // Standard event maintenance - e.preventDefault(); - e.cancelBubble=true; - - if(typeof e.stopImmediatePropagation == 'function') { - e.stopImmediatePropagation(); - } else if(typeof e.stopPropagation == 'function') { - e.stopPropagation(); - } - } - - onTouchStart(event: TouchEvent) { - this.onInputStart(InputEventCoordinate.fromEvent(event)); - } - - onTouchMove(event: TouchEvent) { - this.preventPropagation(event); - const coord = InputEventCoordinate.fromEvent(event); - - if(this.config.coordConstrainedWithinInteractiveBounds(coord)) { - this.onInputMove(coord); - } else { - this.onInputMoveCancel(coord); - } - } - - onTouchEnd(event: TouchEvent) { - this.onInputEnd(InputEventCoordinate.fromEvent(event)); - } -} \ No newline at end of file diff --git a/web/src/engine/osk/src/input/event-interpreter/uiTouchHandlerBase.ts b/web/src/engine/osk/src/input/event-interpreter/uiTouchHandlerBase.ts deleted file mode 100644 index 1fea7fab11..0000000000 --- a/web/src/engine/osk/src/input/event-interpreter/uiTouchHandlerBase.ts +++ /dev/null @@ -1,429 +0,0 @@ -import InputEventCoordinate from "../inputEventCoordinate.js"; -import { getAbsoluteY } from 'keyman/engine/dom-utils'; - -/** - * This class was added to facilitate scroll handling for overflow-x elements, though it could - * be extended in the future to accept overflow-y if needed. - * - * This is necessary because of the OSK's need to use `.preventDefault()` for stability; that - * same method blocks native handling of overflow scrolling for touch browsers. - */ -class ScrollState { - // While we don't currently track y-coordinates here, the class is designed - // to permit tracking them with minimal extra effort if we ever decide to do so. - x: number; - totalLength = 0; - - // The amount of coordinate 'noise' allowed during a scroll-enabled touch allowed - // before interpreting the currently-ongoing touch command as having scrolled. - static readonly HAS_SCROLLED_FUDGE_FACTOR = 10; - - constructor(coord: InputEventCoordinate) { - this.x = coord.x; - - this.totalLength = 0; - } - - updateTo(coord: InputEventCoordinate): {deltaX: number} { - let x = this.x; - this.x = coord.x; - - let deltas = {deltaX: this.x - x}; - this.totalLength += Math.abs(deltas.deltaX); - - return deltas; - } - - public get hasScrolled(): boolean { - // Allow an accidental fudge-factor for overflow element noise during a touch, but not much. - return this.totalLength > ScrollState.HAS_SCROLLED_FUDGE_FACTOR; - } -} - -export default abstract class UITouchHandlerBase { - private rowClassMatch: string; - private selectedTargetMatch: string; - private baseElement: HTMLElement; - - private touchX: number; - private touchY: number; - private touchCount: number; - - private currentTarget: Target; - - private scrollTouchState: ScrollState; - private pendingTarget: Target; - - constructor(baseElement: HTMLElement, rowClassMatch: string, selectedTargetMatch: string) { - this.baseElement = baseElement; - this.rowClassMatch = rowClassMatch; - this.selectedTargetMatch = selectedTargetMatch; - } - - /** - * Finds the internally-preferred target element or submenu target element. - * @param e The DOM element that actually received the touch event. - * May be parent, child, or the actually-desired element itself. - */ - abstract findTargetFrom(e: HTMLElement): Target; - - /** - * Highlights the target element as visual feedback representing - * a pending touch. - * @param t The `Target` to highlight - * @param state `true` to apply highlighting, `false` to remove it. - */ - protected abstract highlight(t: Target, state: boolean): void; - - /** - * Called whenever the touch-handling analysis determines that the Target has been selected - * @param t The `Target` to activate/execute. - */ - protected abstract select(t: Target): void; - - /** - * Requests info on whether or not the indicated `Target` has subkeys or a submenu. - * @param t A `Target`. - */ - protected abstract hasSubmenu(t: Target): boolean; - - /** - * Indicates that the user is maintaining a `Touch` on the specified `Target`. - * Popups and-or longpress menus may be appropriate. - * @param t The `Target` being held. - */ - protected abstract hold(t: Target): void; - - /** - * Signals that any popup elements (previews, subkey views, etc) should be cancelled. - */ - protected abstract clearHolds(): void; - - /** - * Requests a boolean indicating whether or not the UI is currently displaying any input-blocking popup elements. - * Embedded mode should return `true` when the app is displaying popup menus. - */ - protected abstract hasModalPopup(): boolean; - - /** - * Designed to support highlighting of prepended base keys on phone form-factor subkey menus. - * @param target The base element with a potential subkey menu alias. - * @returns The aliased submenu version of the `Target`, or the original `Target` if no alias exists. - */ - protected abstract dealiasSubTarget(target: Target): Target; - - /** - * Should return true whenever a 'native'-mode submenu (or subkey) display is active. - */ - protected abstract isSubmenuActive(): boolean; - - /** - * For 'native' mode - requests that the submenu for the indicated `Target` be instantly displayed. - * @param target The base element with a potential submenu - */ - protected abstract displaySubmenuFor(target: Target); - - /** - * Identify the key nearest to (but NOT under) the touch point if at the end of a key row, - * but return null more than about 0.6 key width from the nearest key. - * - * @param {Object} coord A pre-analyzed input coordinate - * @param {Object} t HTML object at touch point - * @param {boolean} omitCurrent Omits any target directly under the touch point. - * @return {Object} nearest key to touch point - * - **/ - private findTargetFromTouch(coord: InputEventCoordinate, t: HTMLElement, forMove: boolean): Target { - var x = coord.x; - - // Get the UI row beneath touch point (SuggestionBanner div, 'kmw-key-row' if OSK, ...) - while(t && t.className !== undefined && t.className.indexOf(this.rowClassMatch) < 0) { - t = t.parentNode; - } - if(!t) { - return null; - } - - // Find minimum distance from any key - var k: number, bestMatch=0, dxMax=24, dxMin=100000, x1: number, x2: number; - for(k = 0; k < t.childNodes.length; k++) { - let childNode = t.childNodes[k] as HTMLElement; - - if(this.isInvalidTarget(this.findTargetFrom(childNode))) { - continue; - } - - x1 = childNode.offsetLeft; - x2 = x1 + childNode.offsetWidth; - - // If it lies completely to the right and is the closest so far - let dxRight = x1 - x; - if(dxRight >= 0 && dxRight < dxMin) { - bestMatch = k; - dxMin = dxRight; - } - - // If it lies completely to the left and is the closest so far - let dxLeft = x - x2; - if(dxLeft >= 0 && dxLeft < dxMin) { - bestMatch = k; - dxMin = dxLeft; - } - - // If it is neither completely to the left nor completely to the right, - // it's under the cursor. Stop the search! - if(dxLeft < 0 && dxRight < 0) { - return this.findTargetFrom(childNode); - } - } - - if(dxMin < 100000) { - t = t.childNodes[bestMatch]; - x1 = t.offsetLeft; - x2 = x1 + t.offsetWidth; - - // Limit extended touch area to the larger of 0.6 of the potential target's width and 24 px - if(t.offsetWidth > 40) { - dxMax = 0.6 * t.offsetWidth; - } - - if(((x1 - x) >= 0 && (x1 - x) < dxMax) || ((x - x2) >= 0 && (x - x2) < dxMax)) { - return this.findTargetFrom(t); - } - } - return null; - } - - findBestTarget(coord: InputEventCoordinate, forMove?: boolean) { - var eventTarget: HTMLElement; - - if(forMove) { - const clientX = coord.x + document.body.scrollLeft; - const clientY = coord.y + document.body.scrollTop; - eventTarget = document.elementFromPoint(clientX, clientY) as HTMLElement; - } else { - eventTarget = coord.target as HTMLElement; - } - - let target = this.findTargetFrom(eventTarget); - - // Should refactor this multi-check a bit for more overall reliability. - if(!target) { - // We didn't find a direct target, so we should look for the closest possible one. - // Filters out invalid targets. - target = this.findTargetFromTouch(coord, eventTarget, forMove); - } - - return target; - } - - /** - * Reports whether or not a `Target` should be considered invalid. Needed by the OSK for - * hidden keys. - * @param target A `Target` element to be validated. - */ - protected isInvalidTarget(target: Target): boolean { - return false; - } - - touchStart(coord: InputEventCoordinate) { - // Determine the selected Target, manage state. - this.currentTarget = this.findBestTarget(coord); - this.touchX = coord.x; - this.touchY = coord.y; - - // If popup stuff, immediately return. - - this.touchCount = coord.activeInputCount; - - if(!this.currentTarget) { - return; - } - - // Establish scroll tracking. - let shouldScroll = (this.currentTarget.clientWidth < this.currentTarget.scrollWidth); - this.scrollTouchState = shouldScroll ? new ScrollState(coord) : null; - - // Alright, Target acquired! Now to use it: - - // Highlight the touched key - this.highlight(this.currentTarget,true); - - // If used by the OSK, the special function keys need immediate action - // Add a `checkForImmediates()` to facilitate this. - if(this.pendingTarget) { - this.highlight(this.pendingTarget, false); - this.select(this.pendingTarget); - this.clearHolds(); - // Decrement the number of unreleased touch points to prevent - // sending the keystroke again when the key is actually released - this.touchCount--; - } else { - // If this key has subkey, start timer to display subkeys after delay, set up release - this.hold(this.currentTarget); - } - this.pendingTarget = this.currentTarget; - } - - touchEnd(coord: InputEventCoordinate): void { - // Prevent incorrect multi-touch behaviour if native or device popup visible - let t = this.currentTarget; - - if(this.isSubmenuActive() || this.hasModalPopup()) { - // Ignore release if a multiple touch - if(coord.activeInputCount > 0) { - return; - } - - // Cancel (but do not execute) pending key if neither a popup key or the base key - if(t == null || t.id.indexOf('popup') < 0) { - if (this.pendingTarget) { - this.highlight(this.pendingTarget,false); - } - this.clearHolds(); - this.pendingTarget = null; - } - } - - // Test if moved off screen (effective release point must be corrected for touch point horizontal speed) - // This is not completely effective and needs some tweaking, especially on Android - var x = coord.x; - var beyondEdge = ((x < 2 && this.touchX > 5) || (x > window.innerWidth - 2 && this.touchX < window.innerWidth - 5)); - - if(this.scrollTouchState) { - beyondEdge = beyondEdge || this.scrollTouchState.hasScrolled; - } - - // Save then decrement current touch count - var tc=this.touchCount; - if(this.touchCount > 0) { - this.touchCount--; - } - - // Process and clear highlighting of pending target - if(this.pendingTarget) { - this.highlight(this.pendingTarget,false); - - // Output character unless moved off key - if(this.pendingTarget.className.indexOf('hidden') < 0 && tc > 0 && !beyondEdge) { - this.select(this.pendingTarget); - } - this.clearHolds(); - this.pendingTarget = null; - // Always clear highlighting of current target on release (multi-touch) - } else { - t = this.findBestTarget(coord); - - if(t) { - this.highlight(t,false); - } - } - } - - /** - * OSK touch move event handler - * - * @param {Object} coord A pre-analyzed input coordinate - * - **/ - touchMove(coord: InputEventCoordinate) : void { - //let keyman = com.keyman.singleton; - - // Do not attempt to support reselection of target key for overlapped keystrokes - if(coord.activeInputCount > 1 || this.touchCount == 0) { - return; - } - - if(this.currentTarget && this.scrollTouchState != null) { - let deltaX = this.scrollTouchState.updateTo(coord).deltaX; - this.currentTarget.scrollLeft -= window.devicePixelRatio * deltaX; - - return; - } - - // Get touch position - var y = coord.y; - - // Move target key and highlighting - var key0 = this.pendingTarget, - key1 = this.findBestTarget(coord, true); // For the OSK, this ALSO gets subkeys. - - // If option should not be selectable, how do we re-target? - - - // Do not move over keys if device popup visible - if(this.hasModalPopup()) { - if(key0) { - this.highlight(key0,false); - } - this.pendingTarget=null; - return; - } - - // Use the popup duplicate of the base key if a phone with a visible popup array - key1 = this.dealiasSubTarget(key1); - - // Identify current touch position (to manage off-key release) - this.currentTarget = key1; - - // Clear previous key highlighting - if(key0 && key1 && key1 !== key0) { - this.highlight(key0,false); - } - - // Code below directly related to subkeys should only be triggered within 'native' mode. - // The embedded version instead passes info to the apps to produce their own subkeys in-app. - - // If popup is visible, need to move over popup, not over main keyboard - if(key1 && this.hasSubmenu(key1)) { - //this.highlightSubKeys(key1,x,y); - - // Currently only used by the banner... which currently does not do submenus. - // // Native-mode: show popup keys immediately if touch moved up towards key array (KMEW-100, Build 353) - // if(!keyman.isEmbedded && (this.touchY-y > 5) && !this.isSubmenuActive()) { - // // Instantly show the submenu. - // this.displaySubmenuFor(key1); - // } - - // Once a subkey array is displayed, do not allow changing the base key. - // Keep that array visible and accept no other options until the touch ends. - if(key1 && key1.id.indexOf('popup') < 0) { // TODO: reliant on 'popup' in .id - return; - } - - // Highlight the base key on devices that do not append it to the subkey array. - if(key1 && key1.className.indexOf(this.selectedTargetMatch) < 0) { - this.highlight(key1,true); - } - // Cancel touch if moved up and off keyboard, unless popup keys visible - } else { - let base = this.baseElement; - let top = getAbsoluteY(base); - let height = base.offsetHeight; - let yMin = Math.max(5, top - 0.25 * height); - let yMax = (top + height) + 0.25 * height; - if(key0 && (coord.y < yMin || coord.y > yMax)) { - this.highlight(key0,false); - this.clearHolds(); - this.pendingTarget = null; - } - } - - // Replace the target key, if any, by the new target key - // Do not replace a null target, as that indicates the key has already been released - if(key1 && this.pendingTarget) { - this.pendingTarget = key1; - } - - if(this.pendingTarget) { - if(key1 && (key0 != key1 || key1.className.indexOf(this.selectedTargetMatch) < 0)) { - this.highlight(key1,true); - } - } - - if(key0 && key1 && (key1 != key0) && (key1.id != '')) { - // Display the touch-hold keys (after a pause) - this.hold(key1); - } - } -} diff --git a/web/src/engine/osk/src/input/gestures/browser/pendingMultiTap.ts b/web/src/engine/osk/src/input/gestures/browser/pendingMultiTap.ts index a49ea3ac8f..4f26862e8d 100644 --- a/web/src/engine/osk/src/input/gestures/browser/pendingMultiTap.ts +++ b/web/src/engine/osk/src/input/gestures/browser/pendingMultiTap.ts @@ -1,7 +1,7 @@ import { Codes, KeyEvent } from '@keymanapp/keyboard-processor'; import { type KeyElement } from '../../../keyElement.js'; import VisualKeyboard from '../../../visualKeyboard.js'; -import PendingGesture from '../pendingGesture.interface.js'; +// import PendingGesture from '../pendingGesture.interface.js'; export enum PendingMultiTapState { Waiting, Realized, Cancelled }; /** @@ -9,7 +9,7 @@ export enum PendingMultiTapState { Waiting, Realized, Cancelled }; * (based on key id substring in the case of the shift key), within a * specified timeout period. */ -export default class PendingMultiTap implements PendingGesture { +export default class PendingMultiTap { public readonly vkbd: VisualKeyboard; public readonly baseKey: KeyElement; public readonly count: number; diff --git a/web/src/engine/osk/src/input/gestures/pendingGesture.interface.ts b/web/src/engine/osk/src/input/gestures/pendingGesture.interface.ts deleted file mode 100644 index 390d66b969..0000000000 --- a/web/src/engine/osk/src/input/gestures/pendingGesture.interface.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type RealizedGesture from './realizedGesture.interface.js'; -import { type KeyElement } from '../../keyElement.js'; - -/** - * Used for evaluating potential gestures. Classes adhering to this interface - * should be instantiated whenever the (implied) state-machine allows a new - * touch event to mark the start of a potential new gesture. - * - * For example, whenever a user touches a base key and there are no "realized" - * (fully-completed, but as-of-yet unresolved) gestures, that state allows the - * start of a potential new longpress event. - * - * The role of the `PendingGesture` is complete whenever all touch-events and - * conditions necessary for a modeled gesture have been met. As this point, - * it should be `resolve`d, fulfilling its `promise`. This results in a - * `RealizedGesture` appropriate for the gesture type that is used to obtain - * the final `KeyEvent` for the overall gesture sequence. - * - * For example, a "longpress" is considered resolved once the user has maintained - * an active, stationary touch point on the same key for a sufficiently long - * period without releasing it. - * * Were it released earlier, that would result in selection of a base key. - * - * Alternatively, a "flick" might be considered resolved if: - * * a user has rapidly moved a touch point in a consistent direction - * * for a long enough distance - * * and _then_ releases that touch point within a short timeframe. - * - * The pending gesture should only `resolve` to a realized gesture once - * _all_ such conditions are met, confirming that this specific gesture, - * and _only_ this specific gesture, could have resulted from the active - * touch sequence. - * - * The `RealizedGesture` that results and is 'returned' via the Promise will - * be handled by the `VisualKeyboard` class, which will retrieve and forward - * any `KeyEvent` that results from the overall gesture input sequence. - * - * @see `RealizedGesture` - */ -export default interface PendingGesture { - readonly baseKey: KeyElement; - readonly promise?: Promise; - - cancel(): void; - resolve?(): void; -} \ No newline at end of file diff --git a/web/src/engine/osk/src/input/gestures/realizedGesture.interface.ts b/web/src/engine/osk/src/input/gestures/realizedGesture.interface.ts deleted file mode 100644 index 4c5eea5790..0000000000 --- a/web/src/engine/osk/src/input/gestures/realizedGesture.interface.ts +++ /dev/null @@ -1,36 +0,0 @@ -import InputEventCoordinate from '../inputEventCoordinate.js'; -import { type KeyElement } from '../../keyElement.js'; -import { type KeyEvent } from '@keymanapp/keyboard-processor'; - -/** - * Implementations of this interface allow individual types of gestures to - * specify any additional user interaction and functionality (which may - * include UI elements) appropriate for obtaining a key event that may be - * produced by the modeled gesture type. These should only be instantiated - * once the associated `PendingLongpress` is no longer 'pending' - once it - * has become clear that the input touch-event sequence could only correspond - * to the modeled gesture. - * - * For example, when a longpress gesture completes - and hence, the user has - * kept their finger stationary on the same key for a long enough period - - * we display a popup view presenting subkeys corresponding to the gesture's - * underlying element. This popup view accepts touch input and completes only - * upon release of the ongoing touch sequence. - * - * Gestures are events that occur over intervals of time, and since some of them - * will require time and user interaction after becoming 'realized', these cases - * will be inherently async. The simplest way to model this is with `Promise`s. - * - * If appropriate for the modeled gesture type, an implementation may supply an - * instantly-resolving `Promise`. This may be appropriate for modeling "flick" - * or "swipe" gestures in the future, which may require no additional input once - * such a gesture is fully realized. - */ -export default interface RealizedGesture { - readonly baseKey: KeyElement; - readonly promise: Promise; - - clear(): void; - isVisible(): boolean; - updateTouch(input: InputEventCoordinate): void; -} \ No newline at end of file diff --git a/web/src/engine/osk/src/input/gestures/specsForLayout.ts b/web/src/engine/osk/src/input/gestures/specsForLayout.ts index ff9ae75718..bb8acade40 100644 --- a/web/src/engine/osk/src/input/gestures/specsForLayout.ts +++ b/web/src/engine/osk/src/input/gestures/specsForLayout.ts @@ -95,11 +95,11 @@ export function gestureSetForLayout(layerGroup: OSKLayerGroup, params: GesturePa } }; - const simpleTapModel: GestureModel = deepCopy(layout.hasFlicks ? SimpleTapModel : SimpleTapModelWithReset); - const longpressModel: GestureModel = deepCopy(layout.hasFlicks ? basicLongpressModel(params) : longpressModelWithShortcut(params)); + const simpleTapModel: GestureModel = deepCopy(layout.hasFlicks ? SimpleTapModel : SimpleTapModelWithReset); + const longpressModel: GestureModel = deepCopy(layout.hasFlicks ? basicLongpressModel(params) : longpressModelWithShortcut(params)); // #region Functions for implementing and/or extending path initial-state checks - function withKeySpecFiltering(model: GestureModel, contactIndices: number | number[]) { + function withKeySpecFiltering(model: GestureModel, contactIndices: number | number[]) { // Creates deep copies of the model specifications that are safe to customize to the // keyboard layout. model = deepCopy(model); @@ -125,7 +125,7 @@ export function gestureSetForLayout(layerGroup: OSKLayerGroup, params: GesturePa return model; } - function withLayerChangeItemFix(model: GestureModel, contactIndices: number | number[]) { + function withLayerChangeItemFix(model: GestureModel, contactIndices: number | number[]) { // Creates deep copies of the model specifications that are safe to customize to the // keyboard layout. model = deepCopy(model); @@ -206,7 +206,9 @@ export function gestureSetForLayout(layerGroup: OSKLayerGroup, params: GesturePa // #region Definition of models for paths comprising gesture-stage models -type ContactModel = specs.ContactModel; +// Note: as specified below, none of the raw specs actually need access to KeyElement typing. + +type ContactModel = specs.ContactModel; export const InstantContactRejectionModel: ContactModel = { itemPriority: 0, @@ -337,14 +339,18 @@ export const SubkeySelectContactModel: ContactModel = { // #endregion // #region Gesture-stage model definitions -type GestureModel = specs.GestureModel; + +// Note: as specified below, most of the raw specs actually need access to KeyElement typing. +// That only becomes relevant with some of the modifier functions in the `gestureSetForLayout` +// func at the top. +type GestureModel = specs.GestureModel; // TODO: customization of the gesture models depending upon properties of the keyboard. // - has flicks? no longpress shortcut, also no longpress reset(?) // - modipress: keyboard-specific modifier keys - which may require inspection of a // key's properties. -export const SpecialKeyStartModel: GestureModel = { +export const SpecialKeyStartModel: GestureModel = { id: 'special-key-start', resolutionPriority: 0, contacts : [ @@ -376,7 +382,7 @@ export const SpecialKeyStartModel: GestureModel = { } } -export const SpecialKeyEndModel: GestureModel = { +export const SpecialKeyEndModel: GestureModel = { id: 'special-key-end', resolutionPriority: 0, contacts : [ @@ -397,7 +403,7 @@ export const SpecialKeyEndModel: GestureModel = { /** * The flickless, roaming-touch-less version. */ -export function basicLongpressModel(params: GestureParams): GestureModel { +export function basicLongpressModel(params: GestureParams): GestureModel { return { id: 'longpress', resolutionPriority: 0, @@ -427,7 +433,7 @@ export function basicLongpressModel(params: GestureParams): GestureModel { * For use when a layout doesn't have flicks; has the up-flick shortcut * and facilitates roaming-touch. */ -export function longpressModelWithShortcut(params: GestureParams): GestureModel { +export function longpressModelWithShortcut(params: GestureParams): GestureModel { return { ...basicLongpressModel(params), @@ -473,7 +479,7 @@ export function longpressModelWithShortcut(params: GestureParams): GestureModel * For use when a layout doesn't have flicks; has the up-flick shortcut * and facilitates roaming-touch. */ -export function longpressModelWithRoaming(params: GestureParams): GestureModel { +export function longpressModelWithRoaming(params: GestureParams): GestureModel { return { ...basicLongpressModel(params), @@ -517,7 +523,7 @@ export function longpressModelWithRoaming(params: GestureParams): GestureModel { } -export const MultitapModel: GestureModel = { +export const MultitapModel: GestureModel = { id: 'multitap', resolutionPriority: 2, contacts: [ @@ -547,7 +553,7 @@ export const MultitapModel: GestureModel = { } } -export const SimpleTapModel: GestureModel = { +export const SimpleTapModel: GestureModel = { id: 'simple-tap', resolutionPriority: 1, contacts: [ @@ -570,7 +576,7 @@ export const SimpleTapModel: GestureModel = { } } -export const SimpleTapModelWithReset: GestureModel = { +export const SimpleTapModelWithReset: GestureModel = { ...SimpleTapModel, rejectionActions: { item: { @@ -580,7 +586,7 @@ export const SimpleTapModelWithReset: GestureModel = { } } -export const SubkeySelectModel: GestureModel = { +export const SubkeySelectModel: GestureModel = { id: 'subkey-select', resolutionPriority: 0, contacts: [ @@ -608,7 +614,7 @@ export const SubkeySelectModel: GestureModel = { sustainWhenNested: true } -export const ModipressStartModel: GestureModel = { +export const ModipressStartModel: GestureModel = { id: 'modipress-start', resolutionPriority: 5, contacts: [ @@ -641,7 +647,7 @@ export const ModipressStartModel: GestureModel = { } } -export const ModipressEndModel: GestureModel = { +export const ModipressEndModel: GestureModel = { id: 'modipress-end', resolutionPriority: 5, contacts: [ diff --git a/web/src/engine/osk/src/input/inputEventCoordinate.ts b/web/src/engine/osk/src/input/inputEventCoordinate.ts deleted file mode 100644 index 5b81417b48..0000000000 --- a/web/src/engine/osk/src/input/inputEventCoordinate.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * Represents the current location of the current cursor / touchpoint during - * an ongoing OSK input event. This class standardizes to .pageX (document) - * coordinates, rather than .clientX (viewport) coordinates. - */ -export default class InputEventCoordinate { - public readonly x: number; - public readonly y: number; - - private readonly source: MouseEvent | TouchEvent; - - public constructor(x: number, y: number, source?: MouseEvent | TouchEvent) { - this.x = x; - this.y = y; - - if(source) { - this.source = source; - } - } - - // Converts a MouseEvent or TouchEvent into the base coordinates needed - // by the mouse-dragging operations. - public static fromEvent(e: MouseEvent | TouchEvent) { - let coordSource: MouseEvent | Touch; - - // Desktop Safari versions as recent as 14.1 do not support TouchEvents. - // So, just in case, a two-fold conditional check to avoid issues with a direct - // 'instanceof' against the type. - if(window['TouchEvent'] && e instanceof TouchEvent) { - coordSource = e.changedTouches[0]; - } else if(e['changedTouches']) { - coordSource = e['changedTouches'][0] as Touch; - } else { - coordSource = e as MouseEvent; - } - - // For MouseEvents, .pageX is slightly less supported in older browsers when - // compared to .clientX. They're about equally supported for TouchEvents. - if (coordSource.pageX) { - return new InputEventCoordinate(coordSource.pageX, coordSource.pageY, e); - } else if (coordSource.clientX) { - const x = coordSource.clientX + document.body.scrollLeft; - const y = coordSource.clientY + document.body.scrollTop; - - return new InputEventCoordinate(x, y, e); - } else { - return new InputEventCoordinate(null, null, e); - } - } - - public get activeInputCount(): number { - // May not be an ACTUAL touch event during unit tests. - if(window['TouchEvent'] && this.source['touches'] !== undefined && this.source['touches'] !== null) { - return this.source['touches'].length; - } else { - const event = this.source as MouseEvent; - return event.buttons > 0 ? 1 : 0; - } - } - - public get target() { - return this.source?.target; - } - - public get isFromTouch(): boolean { - return !this.isFromMouse; - } - - public get isFromMouse(): boolean { - return this.source instanceof MouseEvent; - } -} \ No newline at end of file diff --git a/web/src/engine/osk/src/input/mouseDragOperation.ts b/web/src/engine/osk/src/input/mouseDragOperation.ts index 4dc76145ba..35ff7077c7 100644 --- a/web/src/engine/osk/src/input/mouseDragOperation.ts +++ b/web/src/engine/osk/src/input/mouseDragOperation.ts @@ -1,7 +1,56 @@ -import InputEventCoordinate from './inputEventCoordinate.js'; - type MouseHandler = (this: GlobalEventHandlers, ev: MouseEvent) => any; +/** + * Represents the current location of the current cursor / touchpoint during + * an ongoing contact-point event series. This class standardizes to .pageX + * (document) coordinates, rather than .clientX (viewport) coordinates. + */ +class InputEventCoordinate { + public readonly x: number; + public readonly y: number; + + private readonly source: MouseEvent | TouchEvent; + + public constructor(x: number, y: number, source?: MouseEvent | TouchEvent) { + this.x = x; + this.y = y; + + if(source) { + this.source = source; + } + } + + // Converts a MouseEvent or TouchEvent into the base coordinates needed + // by the mouse-dragging operations. + public static fromEvent(e: MouseEvent | TouchEvent) { + let coordSource: MouseEvent | Touch; + + // Desktop Safari versions as recent as 14.1 do not support TouchEvents. + // So, just in case, a two-fold conditional check to avoid issues with a direct + // 'instanceof' against the type. + if(window['TouchEvent'] && e instanceof TouchEvent) { + coordSource = e.changedTouches[0]; + } else if(e['changedTouches']) { + coordSource = e['changedTouches'][0] as Touch; + } else { + coordSource = e as MouseEvent; + } + + // For MouseEvents, .pageX is slightly less supported in older browsers when + // compared to .clientX. They're about equally supported for TouchEvents. + if (coordSource.pageX) { + return new InputEventCoordinate(coordSource.pageX, coordSource.pageY, e); + } else if (coordSource.clientX) { + const x = coordSource.clientX + document.body.scrollLeft; + const y = coordSource.clientY + document.body.scrollTop; + + return new InputEventCoordinate(x, y, e); + } else { + return new InputEventCoordinate(null, null, e); + } + } +} + /** * Used to store the page's original mouse handlers and properties * when temporarily overridden by OSK moving or resizing handlers due diff --git a/web/src/engine/osk/src/keyboard-layout/oskKey.ts b/web/src/engine/osk/src/keyboard-layout/oskKey.ts index df94dfd53c..d86511f351 100644 --- a/web/src/engine/osk/src/keyboard-layout/oskKey.ts +++ b/web/src/engine/osk/src/keyboard-layout/oskKey.ts @@ -6,7 +6,6 @@ import TouchLayoutFlick = TouchLayout.TouchLayoutFlick; import { getAbsoluteX, getAbsoluteY } from 'keyman/engine/dom-utils'; import { getFontSizeStyle } from '../fontSizeUtils.js'; -import InputEventCoordinate from '../input/inputEventCoordinate.js'; import specialChars from '../specialCharacters.js'; import buttonClassNames from '../buttonClassNames.js'; @@ -379,19 +378,6 @@ export default abstract class OSKKey { return t; } - public isUnderTouch(input: InputEventCoordinate): boolean { - let x = input.x; - let y = input.y; - - let btn = this.btn; - let x0 = getAbsoluteX(btn); - let y0 = getAbsoluteY(btn); - let x1 = x0 + btn.offsetWidth; - let y1 = y0 + btn.offsetHeight; - - return (x > x0 && x < x1 && y > y0 && y < y1); - } - public refreshLayout(vkbd: VisualKeyboard) { // space bar may not define the text span! if(this.label) { diff --git a/web/src/engine/osk/src/visualKeyboard.ts b/web/src/engine/osk/src/visualKeyboard.ts index 50a47003b3..f0c86b1aad 100644 --- a/web/src/engine/osk/src/visualKeyboard.ts +++ b/web/src/engine/osk/src/visualKeyboard.ts @@ -29,7 +29,6 @@ import { import { createStyleSheet, getAbsoluteX, getAbsoluteY, StylesheetManager } from 'keyman/engine/dom-utils'; import GlobeHint from './globehint.interface.js'; -import InputEventCoordinate from './input/inputEventCoordinate.js'; import KeyboardView from './components/keyboardView.interface.js'; import { type KeyElement, getKeyFrom } from './keyElement.js'; import KeyTip from './keytip.interface.js'; @@ -37,8 +36,6 @@ import OSKKey from './keyboard-layout/oskKey.js'; import OSKLayer from './keyboard-layout/oskLayer.js'; import OSKLayerGroup from './keyboard-layout/oskLayerGroup.js'; import { LengthStyle, ParsedLengthStyle } from './lengthStyle.js'; -import PendingGesture from './input/gestures/pendingGesture.interface.js'; -import RealizedGesture from './input/gestures/realizedGesture.interface.js'; import { defaultFontSize, getFontSizeStyle } from './fontSizeUtils.js'; import PendingMultiTap, { PendingMultiTapState } from './input/gestures/browser/pendingMultiTap.js'; import InternalKeyTip from './input/gestures/browser/keytip.js'; @@ -180,7 +177,6 @@ export default class VisualKeyboard extends EventEmitter implements Ke // State-related properties keyPending: KeyElement; - touchPending: InputEventCoordinate; deleteKey: KeyElement; deleting: number; // Tracks a timer id for repeated deletions. nextLayer: string; @@ -192,7 +188,6 @@ export default class VisualKeyboard extends EventEmitter implements Ke }; // Touch-tracking properties - initTouchCoord: InputEventCoordinate; touchCount: number; currentTarget: KeyElement; @@ -205,9 +200,6 @@ export default class VisualKeyboard extends EventEmitter implements Ke activeGestures: GestureHandler[] = []; - pendingSubkey: PendingGesture; - subkeyGesture: RealizedGesture; - // Multi-tap gesture management pendingMultiTap: PendingMultiTap; @@ -773,108 +765,6 @@ export default class VisualKeyboard extends EventEmitter implements Ke //#region Input handling start - /** - * Determines a "fuzzy boundary" area around the OSK within which active mouse and - * touch events will be maintained, even if their coordinates lie outside of the OSK's - * true visual bounds. - * @returns A `BoundingRect`, in `.pageX` / `.pageY` coordinates. - */ - private getInteractiveBoundingRect(): BoundingRect { - // Determine the important geometric values involved - let oskX = getAbsoluteX(this.element); - let oskY = getAbsoluteY(this.element); - - // Determine the out-of-bounds threshold at which touch-cancellation should automatically occur. - // Assuming square key-squares, we'll use 1/3 the height of a row for bounds detection - // for both dimensions. - const rowCount = this.currentLayer.rows.length; - const buffer = (0.333 * this.height / rowCount); - - // Determine the OSK's boundaries and the boundaries of the page / view. - // These values are needed in .pageX / .pageY coordinates for the final calcs. - let boundingRect: BoundingRect = { - left: oskX - buffer, - right: oskX + this.width + buffer, - top: oskY - buffer, - bottom: oskY + this.height + buffer - }; - - return boundingRect; - } - - /** - * Adjusts a potential "interactive boundary" definition by enforcing an - * "event cancellation zone" near screen boundaries that are not directly adjacent - * to the ongoing input event's initial coordinate. - * - * This facilitates modeling of conventional cancellation gestures where a user would - * drag the mouse or touch point off the OSK, as mouse and touch event handlers receive - * no input beyond screen boundaries. - * - * @param baseBounds The baseline interactive bounding area to be adjusted - * @param startCoord The initial coordinate of a currently-ongoing input event - * @returns - */ - private applyScreenMarginBoundsThresholding(baseBounds: BoundingRect, - startCoord: InputEventCoordinate): BoundingRect { - // Determine the needed linear translation to screen coordinates. - const xDelta = window.screenLeft - window.pageXOffset; - const yDelta = window.screenTop - window.pageYOffset; - - let adjustedBounds: BoundingRect = { ...baseBounds }; - - // Also translate the initial touch's screen coord, as it affects our bounding box logic. - const initScreenCoord = new InputEventCoordinate(startCoord.x + xDelta, startCoord.y + yDelta); - - // Detection: is the OSK aligned with any screen boundaries? - // If so, create a 'fuzzy' zone around the edges not near the initial touch point that allow - // move-based cancellation. - - // If the initial input screen-coord is at least 5 pixels from the screen's left AND - // the OSK's left boundary is within 2 pixels from the screen's left... - if (initScreenCoord.x >= 5 && baseBounds.left + xDelta <= 2) { - adjustedBounds.left = 2 - xDelta; // new `leftBound` is set to 2 pixels from the screen's left. - } - - if (initScreenCoord.x <= screen.width - 5 && baseBounds.right + xDelta >= screen.width - 2) { - adjustedBounds.right = (screen.width - 2) - xDelta; // new `rightBound` 2px from screen's right. - } - - if (initScreenCoord.y >= 5 && baseBounds.top + yDelta <= 2) { - adjustedBounds.top = 2 - yDelta; - } - - if (initScreenCoord.y <= screen.height - 5 && baseBounds.bottom + yDelta >= screen.height - 2) { - adjustedBounds.bottom = (screen.height - 2) - yDelta; - } - - return adjustedBounds; - } - - detectWithinInteractiveBounds(coord: InputEventCoordinate): boolean { - // Shortcuts the method during unit testing, as we don't currently - // provide coordinate values in its synthetic events. - if (coord.x === null && coord.y === null) { - return true; - } - - const baseBoundingRect = this.getInteractiveBoundingRect(); - let adjustedBoundingRect = baseBoundingRect; - if(this.initTouchCoord) { - this.applyScreenMarginBoundsThresholding(baseBoundingRect, this.initTouchCoord); - } - - // Now to check where the input coordinate lies in relation to the final bounding box! - - if (coord.x < adjustedBoundingRect.left || coord.x > adjustedBoundingRect.right) { - return false; - } else if (coord.y < adjustedBoundingRect.top || coord.y > adjustedBoundingRect.bottom) { - return false; - } else { - return true; - } - } - // /** // * The main OSK touch start event handler // * @@ -1571,88 +1461,78 @@ export default class VisualKeyboard extends EventEmitter implements Ke } } - /** - * Initializes all supported gestures given a base key and the triggering touch coordinates. - * @param key The gesture's base key - * @param touch The starting touch coordinates for the gesture - * @returns - */ - initGestures(key: KeyElement, input: InputEventCoordinate) { + // /** + // * Initializes all supported gestures given a base key and the triggering touch coordinates. + // * @param key The gesture's base key + // * @param touch The starting touch coordinates for the gesture + // * @returns + // */ + // initGestures(key: KeyElement, input: InputEventCoordinate) { - if (this.pendingMultiTap) { - switch (this.pendingMultiTap.incrementTouch(key)) { - case PendingMultiTapState.Cancelled: - this.pendingMultiTap = null; - break; - case PendingMultiTapState.Realized: - // Don't initialize any other gestures if the - // multi tap is realized; we cleanup on touch - // release because we need to cancel the base - // key action - return; - } - } + // if (this.pendingMultiTap) { + // switch (this.pendingMultiTap.incrementTouch(key)) { + // case PendingMultiTapState.Cancelled: + // this.pendingMultiTap = null; + // break; + // case PendingMultiTapState.Realized: + // // Don't initialize any other gestures if the + // // multi tap is realized; we cleanup on touch + // // release because we need to cancel the base + // // key action + // return; + // } + // } - if (!this.pendingMultiTap && PendingMultiTap.isValidTarget(this, key)) { - // We are only going to support double-tap on Shift - // in Keyman 15, so we pass in the constant count = 2 - this.pendingMultiTap = new PendingMultiTap(this, key, 2); - this.pendingMultiTap.timeout.then(() => { - this.pendingMultiTap = null; - }); - } - } + // if (!this.pendingMultiTap && PendingMultiTap.isValidTarget(this, key)) { + // // We are only going to support double-tap on Shift + // // in Keyman 15, so we pass in the constant count = 2 + // this.pendingMultiTap = new PendingMultiTap(this, key, 2); + // this.pendingMultiTap.timeout.then(() => { + // this.pendingMultiTap = null; + // }); + // } + // } - /** - * Updates all currently-pending and activated gestures. - * - * @param currentKey The key currently underneath the most recent touch coordinate - * @param previousKey The previously-selected key - * @param input The current mouse or touch coordinate for the gesture - * @returns true if should fully capture input, false if input should 'fall through'. - */ - updateGestures(currentKey: KeyElement, previousKey: KeyElement, input: InputEventCoordinate): boolean { - let key0 = previousKey; - let key1 = currentKey; + // /** + // * Updates all currently-pending and activated gestures. + // * + // * @param currentKey The key currently underneath the most recent touch coordinate + // * @param previousKey The previously-selected key + // * @param input The current mouse or touch coordinate for the gesture + // * @returns true if should fully capture input, false if input should 'fall through'. + // */ + // updateGestures(currentKey: KeyElement, previousKey: KeyElement, input: InputEventCoordinate): boolean { + // let key0 = previousKey; + // let key1 = currentKey; - if(!currentKey && this.pendingMultiTap) { - this.pendingMultiTap.cancel(); - this.pendingMultiTap = null; - } + // if(!currentKey && this.pendingMultiTap) { + // this.pendingMultiTap.cancel(); + // this.pendingMultiTap = null; + // } - // Clear previous key highlighting, allow subkey controller to highlight as appropriate. - if (this.subkeyGesture) { - if (key0) { - key0.key.highlight(false); - } - this.subkeyGesture.updateTouch(input); + // // Clear previous key highlighting, allow subkey controller to highlight as appropriate. + // if (this.subkeyGesture) { + // if (key0) { + // key0.key.highlight(false); + // } + // this.subkeyGesture.updateTouch(input); - this.keyPending = null; - this.touchPending = null; + // this.keyPending = null; + // this.touchPending = null; - return true; - } + // return true; + // } - this.currentTarget = null; + // this.currentTarget = null; - // If there is an active popup menu (which can occur from the previous block), - // a subkey popup exists; do not allow base key output. - if (this.subkeyGesture || this.pendingSubkey) { - return true; - } + // // If there is an active popup menu (which can occur from the previous block), + // // a subkey popup exists; do not allow base key output. + // if (this.subkeyGesture || this.pendingSubkey) { + // return true; + // } - return false; - } - - private getLongpressFlickThreshold(): number { - const rowHeight = this.currentLayer.rowHeight; - - // If larger than 5 (and it likely is), new threshold = 1/4 the std. key height. - const proportionalThreshold = rowHeight / 4; - - // 5 - the longpress-flick triggering threshold before 15.0. - return Math.max(proportionalThreshold, 5); - } + // return false; + // } optionKey(e: KeyElement, keyName: string, keyDown: boolean) { if (keyName.indexOf('K_LOPT') >= 0) { @@ -1729,7 +1609,6 @@ export default class VisualKeyboard extends EventEmitter implements Ke } this.keyPending = null; - this.touchPending = null; this.keytip?.show(null, false, this); }