feat(web): adds support for pushed alt-configurations during gesture-source lifetime

This commit is contained in:
Joshua A. Horton 2023-08-30 10:06:12 +07:00
parent 6bf0cebcd6
commit 504eb53565
8 changed files with 188 additions and 62 deletions

View file

@ -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<HoveredItemType> {
* @returns
*/
readonly itemIdentifier?: (coord: Omit<InputSample<any>, 'item'>, target: EventTarget) => HoveredItemType;
}
export function preprocessRecognizerConfig<HoveredItemType>(
config: GestureRecognizerConfiguration<HoveredItemType>
): Nonoptional<GestureRecognizerConfiguration<HoveredItemType>> {
// Allows configuration pre-processing during this method.
let processingConfig: Mutable<Nonoptional<GestureRecognizerConfiguration<HoveredItemType>>> = {...config} as Nonoptional<GestureRecognizerConfiguration<HoveredItemType>>;
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;
}

View file

@ -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<HoveredItemType> extends TouchpointCoordinator<Ho
private readonly mouseEngine: MouseEventEngine<HoveredItemType>;
private readonly touchEngine: TouchEventEngine<HoveredItemType>;
protected static preprocessConfig<HoveredItemType>(
config: GestureRecognizerConfiguration<HoveredItemType>
): Nonoptional<GestureRecognizerConfiguration<HoveredItemType>> {
// Allows configuration pre-processing during this method.
let processingConfig: Mutable<Nonoptional<GestureRecognizerConfiguration<HoveredItemType>>> = {...config} as Nonoptional<GestureRecognizerConfiguration<HoveredItemType>>;
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<HoveredItemType>) {
super();
this.config = GestureRecognizer.preprocessConfig(config);
this.config = preprocessRecognizerConfig(config);
this.mouseEngine = new MouseEventEngine<HoveredItemType>(this.config);
this.touchEngine = new TouchEventEngine<HoveredItemType>(this.config);

View file

@ -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<HoveredItemType> {
private static _jsonIdSeed: -1;
// Assertion: must always contain an index 0 - the base recognizer config.
protected recognizerConfigStack: Nonoptional<GestureRecognizerConfiguration<HoveredItemType>>[];
/**
* Tracks the coordinates and timestamps of each update for the lifetime of this `GestureSource`.
*/
@ -60,10 +65,16 @@ export class GestureSource<HoveredItemType> {
* @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<GestureRecognizerConfiguration<HoveredItemType>> | Nonoptional<GestureRecognizerConfiguration<HoveredItemType>>[],
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<HoveredItemType> {
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<HoveredItemType> {
* @returns
*/
public constructSubview(startAtEnd: boolean, preserveBaseItem: boolean): GestureSourceSubview<HoveredItemType> {
return new GestureSourceSubview(this, startAtEnd, preserveBaseItem);
return new GestureSourceSubview(this, this.recognizerConfigStack, startAtEnd, preserveBaseItem);
}
/**
@ -142,10 +153,31 @@ export class GestureSource<HoveredItemType> {
return `${prefix}:${this.rawIdentifier}`;
}
public pushRecognizerConfig(config: Omit<GestureRecognizerConfiguration<HoveredItemType>, '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<HoveredItemType> {
}
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<HoveredItemType> extends GestureSource<HoveredItemType> {
private _baseSource: GestureSource<HoveredItemType>
private _baseStartIndex: number;
private subviewDisconnector: () => void;
/**
@ -167,11 +203,41 @@ export class GestureSourceSubview<HoveredItemType> extends GestureSource<Hovered
* @param initialHoveredItem The initiating event's original target element
* @param isFromTouch `true` if sourced from a `TouchEvent`; `false` otherwise.
*/
constructor(source: GestureSource<HoveredItemType>, startAtEnd: boolean, preserveBaseItem: boolean) {
super(source.rawIdentifier, source.isFromTouch);
constructor(
source: GestureSource<HoveredItemType>,
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<HoveredItemType>) => {
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<HoveredItemType>;
@ -179,13 +245,19 @@ export class GestureSourceSubview<HoveredItemType> extends GestureSource<Hovered
// Will hold the last sample _even if_ we don't save every coord that comes through.
const lastSample = source.path.stats.lastSample;
// Are we 'chop'ping off the existing path or preserving it? This sets the sample-copying
// configuration accordingly.
if(startAtEnd) {
subpath = new GesturePath<HoveredItemType>();
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<HoveredItemType>();
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<HoveredItemType> extends GestureSource<Hovered
this._baseItem = lastSample?.item;
}
// 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) => 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<HoveredItemType>) => {
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<HoveredItemType> extends GestureSource<Hovered
}
}
public pushRecognizerConfig(config: Omit<GestureRecognizerConfiguration<HoveredItemType>, "touchEventRoot" | "mouseEventRoot">): void {
throw new Error("Pushing and popping of recognizer configurations should only be called on the base GestureSource");
}
public popRecognizerConfig(): Nonoptional<GestureRecognizerConfiguration<HoveredItemType>> {
throw new Error("Pushing and popping of recognizer configurations should only be called on the base GestureSource");
}
public update(sample: InputSample<HoveredItemType>): 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

View file

@ -31,6 +31,18 @@ export abstract class InputEngineBase<HoveredItemType> extends EventEmitter<Even
return this._activeTouchpoints.find((point) => 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);
}

View file

@ -32,7 +32,7 @@ export abstract class InputEventEngine<HoveredItemType> extends InputEngineBase<
}
protected onInputStart(identifier: number, sample: InputSample<HoveredItemType>, target: EventTarget, isFromTouch: boolean) {
const touchpoint = new GestureSource<HoveredItemType>(identifier, isFromTouch);
const touchpoint = new GestureSource<HoveredItemType>(identifier, this.config, isFromTouch);
touchpoint.update(sample);
this.addTouchpoint(touchpoint);

View file

@ -128,8 +128,9 @@ export class MouseEventEngine<HoveredItemType> extends InputEventEngine<HoveredI
}
this.preventPropagation(event);
const config = this.getConfigForId(this.activeIdentifier);
if(!ZoneBoundaryChecker.inputMoveCancellationCheck(sample, this.config, this.disabledSafeBounds)) {
if(!ZoneBoundaryChecker.inputMoveCancellationCheck(sample, config, this.disabledSafeBounds)) {
this.onInputMove(this.activeIdentifier, sample, event.target);
} else {
this.onInputMoveCancel(this.activeIdentifier, sample, event.target);

View file

@ -135,9 +135,11 @@ export class TouchEventEngine<HoveredItemType> extends InputEventEngine<HoveredI
propagationActive = false;
}
const config = this.getConfigForId(touch.identifier);
const sample = this.buildSampleFromTouch(touch, timestamp);
if(!ZoneBoundaryChecker.inputMoveCancellationCheck(sample, this.config, this.safeBoundMaskMap[touch.identifier])) {
if(!ZoneBoundaryChecker.inputMoveCancellationCheck(sample, config, this.safeBoundMaskMap[touch.identifier])) {
this.onInputMove(touch.identifier, sample, touch.target);
} else {
this.onInputMoveCancel(touch.identifier, sample, touch.target);

View file

@ -23,7 +23,7 @@ export class HeadlessInputEngine<Type = any> extends InputEngineBase<Type> {
const tailSamples = originalSamples.slice(1);
const pathID = this.PATH_ID_SEED++;
let replayPoint = new GestureSource<Type>(pathID, recordedPoint.isFromTouch);
let replayPoint = new GestureSource<Type>(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.