diff --git a/common/web/gesture-recognizer/src/engine/configuration/gestureRecognizerConfiguration.ts b/common/web/gesture-recognizer/src/engine/configuration/gestureRecognizerConfiguration.ts index 20ac75cab0..6849a2c29f 100644 --- a/common/web/gesture-recognizer/src/engine/configuration/gestureRecognizerConfiguration.ts +++ b/common/web/gesture-recognizer/src/engine/configuration/gestureRecognizerConfiguration.ts @@ -3,6 +3,9 @@ // after configuration. import { InputSample } from "../headless/inputSample.js"; +import { Mutable } from "../mutable.js"; +import { Nonoptional } from "../nonoptional.js"; +import { PaddedZoneSource } from "./paddedZoneSource.js"; import { RecognitionZoneSource } from "./recognitionZoneSource.js"; // For example, customization of a longpress timer's length need not be readonly. @@ -81,4 +84,34 @@ export interface GestureRecognizerConfiguration { * @returns */ readonly itemIdentifier?: (coord: Omit, 'item'>, target: EventTarget) => HoveredItemType; +} + +export function preprocessRecognizerConfig( + config: GestureRecognizerConfiguration +): Nonoptional> { + // Allows configuration pre-processing during this method. + let processingConfig: Mutable>> = {...config} as Nonoptional>; + processingConfig.mouseEventRoot = processingConfig.mouseEventRoot ?? processingConfig.targetRoot; + processingConfig.touchEventRoot = processingConfig.touchEventRoot ?? processingConfig.targetRoot; + + processingConfig.inputStartBounds = processingConfig.inputStartBounds ?? processingConfig.targetRoot; + processingConfig.maxRoamingBounds = processingConfig.maxRoamingBounds ?? processingConfig.targetRoot; + processingConfig.safeBounds = processingConfig.safeBounds ?? new PaddedZoneSource([2]); + + processingConfig.itemIdentifier = processingConfig.itemIdentifier ?? (() => null); + + if(!config.paddedSafeBounds) { + let paddingArray = config.safeBoundPadding; + if(typeof paddingArray == 'number') { + paddingArray = [ paddingArray ]; + } + paddingArray = paddingArray ?? [3]; + + processingConfig.paddedSafeBounds = new PaddedZoneSource(processingConfig.safeBounds, paddingArray); + } else { + // processingConfig.paddedSafeBounds is already set via the spread operator above. + delete processingConfig.safeBoundPadding; + } + + return processingConfig; } \ No newline at end of file diff --git a/common/web/gesture-recognizer/src/engine/gestureRecognizer.ts b/common/web/gesture-recognizer/src/engine/gestureRecognizer.ts index 7e254ad019..d847dc9b03 100644 --- a/common/web/gesture-recognizer/src/engine/gestureRecognizer.ts +++ b/common/web/gesture-recognizer/src/engine/gestureRecognizer.ts @@ -1,8 +1,6 @@ -import { GestureRecognizerConfiguration } from "./configuration/gestureRecognizerConfiguration.js"; +import { GestureRecognizerConfiguration, preprocessRecognizerConfig } from "./configuration/gestureRecognizerConfiguration.js"; import { MouseEventEngine } from "./mouseEventEngine.js"; -import { Mutable } from "./mutable.js"; import { Nonoptional } from "./nonoptional.js"; -import { PaddedZoneSource } from "./configuration/paddedZoneSource.js"; import { TouchEventEngine } from "./touchEventEngine.js"; import { TouchpointCoordinator } from "./headless/touchpointCoordinator.js"; @@ -12,39 +10,9 @@ export class GestureRecognizer extends TouchpointCoordinator; private readonly touchEngine: TouchEventEngine; - protected static preprocessConfig( - config: GestureRecognizerConfiguration - ): Nonoptional> { - // Allows configuration pre-processing during this method. - let processingConfig: Mutable>> = {...config} as Nonoptional>; - processingConfig.mouseEventRoot = processingConfig.mouseEventRoot ?? processingConfig.targetRoot; - processingConfig.touchEventRoot = processingConfig.touchEventRoot ?? processingConfig.targetRoot; - - processingConfig.inputStartBounds = processingConfig.inputStartBounds ?? processingConfig.targetRoot; - processingConfig.maxRoamingBounds = processingConfig.maxRoamingBounds ?? processingConfig.targetRoot; - processingConfig.safeBounds = processingConfig.safeBounds ?? new PaddedZoneSource([2]); - - processingConfig.itemIdentifier = processingConfig.itemIdentifier ?? (() => null); - - if(!config.paddedSafeBounds) { - let paddingArray = config.safeBoundPadding; - if(typeof paddingArray == 'number') { - paddingArray = [ paddingArray ]; - } - paddingArray = paddingArray ?? [3]; - - processingConfig.paddedSafeBounds = new PaddedZoneSource(processingConfig.safeBounds, paddingArray); - } else { - // processingConfig.paddedSafeBounds is already set via the spread operator above. - delete processingConfig.safeBoundPadding; - } - - return processingConfig; - } - public constructor(config: GestureRecognizerConfiguration) { super(); - this.config = GestureRecognizer.preprocessConfig(config); + this.config = preprocessRecognizerConfig(config); this.mouseEngine = new MouseEventEngine(this.config); this.touchEngine = new TouchEventEngine(this.config); diff --git a/common/web/gesture-recognizer/src/engine/headless/gestureSource.ts b/common/web/gesture-recognizer/src/engine/headless/gestureSource.ts index 17476e949e..0051369993 100644 --- a/common/web/gesture-recognizer/src/engine/headless/gestureSource.ts +++ b/common/web/gesture-recognizer/src/engine/headless/gestureSource.ts @@ -1,5 +1,7 @@ import { InputSample } from "./inputSample.js"; import { SerializedGesturePath, GesturePath } from "./gesturePath.js"; +import { GestureRecognizerConfiguration, preprocessRecognizerConfig } from "../configuration/gestureRecognizerConfiguration.js"; +import { Nonoptional } from "../nonoptional.js"; /** * Documents the expected typing of serialized versions of the `GestureSource` class. @@ -47,6 +49,9 @@ export class GestureSource { private static _jsonIdSeed: -1; + // Assertion: must always contain an index 0 - the base recognizer config. + protected recognizerConfigStack: Nonoptional>[]; + /** * Tracks the coordinates and timestamps of each update for the lifetime of this `GestureSource`. */ @@ -60,10 +65,16 @@ export class GestureSource { * @param initialHoveredItem The initiating event's original target element * @param isFromTouch `true` if sourced from a `TouchEvent`; `false` otherwise. */ - constructor(identifier: number, isFromTouch: boolean) { + constructor( + identifier: number, + recognizerConfig: Nonoptional> | Nonoptional>[], + isFromTouch: boolean + ) { this.rawIdentifier = identifier; this.isFromTouch = isFromTouch; this._path = new GesturePath(); + + this.recognizerConfigStack = Array.isArray(recognizerConfig) ? recognizerConfig : [recognizerConfig]; } /** @@ -76,7 +87,7 @@ export class GestureSource { const isFromTouch = jsonObj.isFromTouch; const path = GesturePath.deserialize(jsonObj.path); - const instance = new GestureSource(id, isFromTouch); + const instance = new GestureSource(id, null, isFromTouch); instance._path = path; return instance; } @@ -112,7 +123,7 @@ export class GestureSource { * @returns */ public constructSubview(startAtEnd: boolean, preserveBaseItem: boolean): GestureSourceSubview { - return new GestureSourceSubview(this, startAtEnd, preserveBaseItem); + return new GestureSourceSubview(this, this.recognizerConfigStack, startAtEnd, preserveBaseItem); } /** @@ -142,10 +153,31 @@ export class GestureSource { return `${prefix}:${this.rawIdentifier}`; } + public pushRecognizerConfig(config: Omit, 'touchEventRoot'| 'mouseEventRoot'>) { + const configToProcess = {...config, + mouseEventRoot: this.recognizerConfigStack[0].mouseEventRoot, + touchEventRoot: this.recognizerConfigStack[0].touchEventRoot + } + this.recognizerConfigStack.push(preprocessRecognizerConfig(configToProcess)); + } + + public popRecognizerConfig() { + if(this.recognizerConfigStack.length == 1) { + throw new Error("Cannot 'pop' the original recognizer-configuration for this GestureSource.") + } + + return this.recognizerConfigStack.pop(); + } + + public get currentRecognizerConfig() { + return this.recognizerConfigStack[this.recognizerConfigStack.length-1]; + } + /** * Creates a serialization-friendly version of this instance for use by * `JSON.stringify`. */ + /* c8 ignore start */ toJSON(): SerializedGestureSource { let jsonClone: SerializedGestureSource = { isFromTouch: this.isFromTouch, @@ -153,11 +185,15 @@ export class GestureSource { } return jsonClone; + /* c8 ignore stop */ + /* c8 ignore next 2 */ + // esbuild or tsc seems to mangle the 'ignore stop' if put outside the ending brace. } } export class GestureSourceSubview extends GestureSource { private _baseSource: GestureSource + private _baseStartIndex: number; private subviewDisconnector: () => void; /** @@ -167,11 +203,41 @@ export class GestureSourceSubview extends GestureSource, startAtEnd: boolean, preserveBaseItem: boolean) { - super(source.rawIdentifier, source.isFromTouch); + constructor( + source: GestureSource, + configStack: typeof GestureSource.prototype['recognizerConfigStack'], + startAtEnd: boolean, + preserveBaseItem: boolean + ) { + let mayUpdate = true; + let start = 0; + let length = source.path.coords.length; + if(source instanceof GestureSourceSubview) { + const expectedLength = source._baseStartIndex + source.path.coords.length; + start = source._baseStartIndex; + // Check against the full remaining length of the original source; does + // the subview provided to us include its source's most recent point? + const sampleCountSinceStart = source.baseSource.path.coords.length; + if(expectedLength != start + sampleCountSinceStart) { + mayUpdate = false; + } + } + + super(source.rawIdentifier, configStack, source.isFromTouch); const baseSource = this._baseSource = source instanceof GestureSourceSubview ? source._baseSource : source; + /** + * Provides a coordinate-system translation for source subviews. + * The base version still needs to use the original coord system, though. + */ + const translateSample = (sample: InputSample) => { + const translation = this.recognizerTranslation; + // Provide a coordinate-system translation for source subviews. + // The base version still needs to use the original coord system, though. + return {...sample, targetX: sample.targetX - translation.x, targetY: sample.targetY - translation.y}; + } + // Note: we don't particularly need subviews to track the actual coords aside from // tracking related stats data. But... we don't have an "off-switch" for that yet. let subpath: GesturePath; @@ -179,13 +245,19 @@ export class GestureSourceSubview extends GestureSource(); - if(lastSample) { - subpath.extend(lastSample); - } + this._baseStartIndex = start = Math.max(start + length - 1, 0); + length = length > 0 ? 1 : 0; } else { - subpath = source.path.clone(); + this._baseStartIndex = start; + } + + subpath = new GesturePath(); + for(let i=0; i < length; i++) { + const index = start + i; + subpath.extend(translateSample(baseSource.path.coords[index])); } this._path = subpath; @@ -196,25 +268,51 @@ export class GestureSourceSubview extends GestureSource this.path.terminate(false); - const invalidatedHook = () => this.path.terminate(true); - const stepHook = (sample) => this.update(sample); - baseSource.path.on('complete', completeHook); - baseSource.path.on('invalidated', invalidatedHook); - baseSource.path.on('step', stepHook); + if(mayUpdate) { + // Ensure that this 'subview' is updated whenever the "source of truth" is. + const completeHook = () => this.path.terminate(false); + const invalidatedHook = () => this.path.terminate(true); + const stepHook = (sample: InputSample) => { + super.update(translateSample(sample)); + }; + baseSource.path.on('complete', completeHook); + baseSource.path.on('invalidated', invalidatedHook); + baseSource.path.on('step', stepHook); - // But make sure we can "disconnect" it later once the gesture being matched - // with the subview has fully matched; it's good to have a snapshot left over. - this.subviewDisconnector = () => { - baseSource.path.off('complete', completeHook); - baseSource.path.off('invalidated', invalidatedHook); - baseSource.path.off('step', stepHook); + // But make sure we can "disconnect" it later once the gesture being matched + // with the subview has fully matched; it's good to have a snapshot left over. + this.subviewDisconnector = () => { + baseSource.path.off('complete', completeHook); + baseSource.path.off('invalidated', invalidatedHook); + baseSource.path.off('step', stepHook); + } + } + } + + private get recognizerTranslation() { + // Allowing a 'null' config greatly simplifies many of our unit-test specs. + if(this.recognizerConfigStack.length == 1 || !this.currentRecognizerConfig) { + return { + x: 0, + y: 0 + }; + } + + // Could compute all of this a single time & cache the value whenever a recognizer-config is pushed or popped. + const currentRecognizer = this.currentRecognizerConfig; + const currentClientRect = currentRecognizer.targetRoot.getBoundingClientRect(); + const baseClientRect = this.recognizerConfigStack[0].targetRoot.getBoundingClientRect(); + + return { + x: currentClientRect.x - baseClientRect.x, + y: currentClientRect.y - baseClientRect.y } } /** - * The original GestureSource this subview is based upon. + * The original GestureSource this subview is based upon. Note that the coordinate system may + * differ if a gesture stage/component has occurred that triggered a change to the active + * recognizer configuration. (e.g. a subkey menu is being displayed for a longpress interaction) */ public get baseSource() { return this._baseSource; @@ -231,6 +329,18 @@ export class GestureSourceSubview extends GestureSource, "touchEventRoot" | "mouseEventRoot">): void { + throw new Error("Pushing and popping of recognizer configurations should only be called on the base GestureSource"); + } + + public popRecognizerConfig(): Nonoptional> { + throw new Error("Pushing and popping of recognizer configurations should only be called on the base GestureSource"); + } + + public update(sample: InputSample): void { + throw new Error("Updates should be provided through the base GestureSource.") + } + /** * Like `disconnect`, but this will also terminate the baseSource and prevent further * updates for the true, original `GestureSource` instance. If the gesture-model diff --git a/common/web/gesture-recognizer/src/engine/headless/inputEngineBase.ts b/common/web/gesture-recognizer/src/engine/headless/inputEngineBase.ts index 32442ac39b..272d20a942 100644 --- a/common/web/gesture-recognizer/src/engine/headless/inputEngineBase.ts +++ b/common/web/gesture-recognizer/src/engine/headless/inputEngineBase.ts @@ -31,6 +31,18 @@ export abstract class InputEngineBase extends EventEmitter point.rawIdentifier == identifier); } + /** + * During the lifetime of a GestureSource (a continuous path for a single touchpoint), + * it is possible that the legal area for the path may change. This function allows + * us to find the appropriate set of constraints for the path if any changes have been + * requested - say, for a subkey menu after a longpress. + * @param identifier + * @returns + */ + protected getConfigForId(identifier: number) { + return this.getTouchpointWithId(identifier).currentRecognizerConfig; + } + public dropTouchpointWithId(identifier: number) { this._activeTouchpoints = this._activeTouchpoints.filter((point) => point.rawIdentifier != identifier); } diff --git a/common/web/gesture-recognizer/src/engine/inputEventEngine.ts b/common/web/gesture-recognizer/src/engine/inputEventEngine.ts index fa37e272c9..533c377883 100644 --- a/common/web/gesture-recognizer/src/engine/inputEventEngine.ts +++ b/common/web/gesture-recognizer/src/engine/inputEventEngine.ts @@ -32,7 +32,7 @@ export abstract class InputEventEngine extends InputEngineBase< } protected onInputStart(identifier: number, sample: InputSample, target: EventTarget, isFromTouch: boolean) { - const touchpoint = new GestureSource(identifier, isFromTouch); + const touchpoint = new GestureSource(identifier, this.config, isFromTouch); touchpoint.update(sample); this.addTouchpoint(touchpoint); diff --git a/common/web/gesture-recognizer/src/engine/mouseEventEngine.ts b/common/web/gesture-recognizer/src/engine/mouseEventEngine.ts index f0475c9ccc..5952f9cdbb 100644 --- a/common/web/gesture-recognizer/src/engine/mouseEventEngine.ts +++ b/common/web/gesture-recognizer/src/engine/mouseEventEngine.ts @@ -128,8 +128,9 @@ export class MouseEventEngine extends InputEventEngine extends InputEventEngine extends InputEngineBase { const tailSamples = originalSamples.slice(1); const pathID = this.PATH_ID_SEED++; - let replayPoint = new GestureSource(pathID, recordedPoint.isFromTouch); + let replayPoint = new GestureSource(pathID, null, recordedPoint.isFromTouch); replayPoint.update(headSample); // is included before the point is made available. // Build promises designed to reproduce the events at the correct times.