mirror of
https://github.com/keymanapp/keyman.git
synced 2026-08-12 03:45:34 +00:00
Merge pull request #9526 from keymanapp/feat/web/config-shifting-and-source-rigor
feat(web): allows shifting the recognition zone of an active GestureSource 🐵
This commit is contained in:
commit
ca6b4f90fc
12 changed files with 427 additions and 109 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
start = source._baseStartIndex;
|
||||
const expectedLength = start + length;
|
||||
// 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,18 @@ 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++) {
|
||||
subpath.extend(translateSample(baseSource.path.coords[start + i]));
|
||||
}
|
||||
|
||||
this._path = subpath;
|
||||
|
|
@ -196,25 +267,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 +328,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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -1,51 +1,244 @@
|
|||
import { assert } from 'chai'
|
||||
import { GestureSource, InputSample } from '@keymanapp/gesture-recognizer';
|
||||
import { GestureRecognizerConfiguration, GestureSource, InputSample } from '@keymanapp/gesture-recognizer';
|
||||
|
||||
const helloSample: InputSample<string> = {
|
||||
targetX: 1,
|
||||
targetY: 2,
|
||||
item: 'hello',
|
||||
t: 101
|
||||
};
|
||||
|
||||
const worldSample: InputSample<string> = {
|
||||
targetX: 2,
|
||||
targetY: 3,
|
||||
item: 'world',
|
||||
t: 121
|
||||
};
|
||||
|
||||
// 1 -> 4, 'a' -> 'd'
|
||||
const simpleSampleSequence: InputSample<string>[] = [1, 2, 3, 4].map((i) => {
|
||||
return {
|
||||
targetX: i,
|
||||
targetY: i,
|
||||
item: String.fromCharCode('a'.charCodeAt(0) + i - 1),
|
||||
t: 20 * i
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* A minimalist mock for a recognizer-configuration based at <0, 0>.
|
||||
*/
|
||||
const mockedOriginConfig = {
|
||||
targetRoot: {
|
||||
getBoundingClientRect: () => {
|
||||
return {
|
||||
x: 0,
|
||||
y: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* A minimalist mock for a recognizer-configuration based at <-2, -2>.
|
||||
*/
|
||||
const mockedInitialConfig = {
|
||||
targetRoot: {
|
||||
getBoundingClientRect: () => {
|
||||
return {
|
||||
x: -2,
|
||||
y: -2
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* A minimalist mock for a recognizer-configuration based at <2, 2>.
|
||||
*/
|
||||
const mockedShiftedConfig = {
|
||||
targetRoot: {
|
||||
getBoundingClientRect: () => {
|
||||
return {
|
||||
x: 2,
|
||||
y: 2
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Should probably be a bit more thorough, but it's a start.
|
||||
describe("GestureSource", function() {
|
||||
describe(".constructSubview", function() {
|
||||
it("properly propagates updates", () => {
|
||||
let source = new GestureSource<string>(0, true);
|
||||
describe("Subviews", function() {
|
||||
it("construction: preserve current path & base item", () => {
|
||||
let source = new GestureSource<string>(0, null, true);
|
||||
for(let i=0; i < simpleSampleSequence.length; i++) {
|
||||
source.update(simpleSampleSequence[i]);
|
||||
}
|
||||
|
||||
let subview = source.constructSubview(false, true);
|
||||
assert.equal(subview.path.coords.length, simpleSampleSequence.length);
|
||||
assert.deepEqual(subview.path.coords, simpleSampleSequence);
|
||||
assert.equal(subview.baseItem, simpleSampleSequence[0].item);
|
||||
});
|
||||
|
||||
it("construction: preserve only most recent sample & base item", () => {
|
||||
let source = new GestureSource<string>(0, null, true);
|
||||
for(let i=0; i < simpleSampleSequence.length; i++) {
|
||||
source.update(simpleSampleSequence[i]);
|
||||
}
|
||||
|
||||
let subview = source.constructSubview(true, true);
|
||||
assert.equal(subview.path.coords.length, 1);
|
||||
|
||||
const lastSample = simpleSampleSequence[simpleSampleSequence.length - 1];
|
||||
assert.deepEqual(subview.path.coords, [lastSample]);
|
||||
assert.equal(subview.baseItem, simpleSampleSequence[0].item);
|
||||
});
|
||||
|
||||
it("construction: preserve only most recent sample", () => {
|
||||
let source = new GestureSource<string>(0, null, true);
|
||||
for(let i=0; i < simpleSampleSequence.length; i++) {
|
||||
source.update(simpleSampleSequence[i]);
|
||||
}
|
||||
|
||||
let subview = source.constructSubview(true, false);
|
||||
assert.equal(subview.path.coords.length, 1);
|
||||
|
||||
const lastSample = simpleSampleSequence[simpleSampleSequence.length - 1];
|
||||
assert.deepEqual(subview.path.coords, [lastSample]);
|
||||
assert.equal(subview.baseItem, lastSample.item);
|
||||
});
|
||||
|
||||
it("construction: preserve current path & base item, with translation", () => {
|
||||
let source = new GestureSource<string>(0, mockedInitialConfig as any, true);
|
||||
for(let i=0; i < simpleSampleSequence.length; i++) {
|
||||
source.update(simpleSampleSequence[i]);
|
||||
}
|
||||
|
||||
source.pushRecognizerConfig(mockedShiftedConfig as any);
|
||||
|
||||
let subview = source.constructSubview(false, true);
|
||||
// When an alternate recognizer-config is pushed, the original source's version of
|
||||
// the path should remain unchanged.
|
||||
assert.deepEqual(source.path.coords, simpleSampleSequence);
|
||||
|
||||
// Subviews, on the other hand, should use the most appropriate current recognizer-config.
|
||||
// It'll be "locked in" for the subview.
|
||||
assert.equal(subview.path.coords.length, simpleSampleSequence.length);
|
||||
assert.deepEqual(subview.path.coords, simpleSampleSequence.map((value) => {
|
||||
return {
|
||||
...value,
|
||||
targetX: value.targetX - 4,
|
||||
targetY: value.targetY - 4
|
||||
}
|
||||
}));
|
||||
assert.equal(subview.baseItem, simpleSampleSequence[0].item);
|
||||
});
|
||||
|
||||
it("properly propagate updates", () => {
|
||||
let source = new GestureSource<string>(0, null, true);
|
||||
let subview = source.constructSubview(false, true);
|
||||
|
||||
assert.equal(subview.path.coords.length, 0);
|
||||
source.update(helloSample);
|
||||
|
||||
assert.equal(subview.path.coords.length, 1);
|
||||
assert.deepEqual(subview.currentSample, helloSample);
|
||||
});
|
||||
|
||||
it("may be disconnected from further updates", () => {
|
||||
let source = new GestureSource<string>(0, null, true);
|
||||
let subview = source.constructSubview(false, true);
|
||||
|
||||
source.update(helloSample);
|
||||
|
||||
subview.disconnect();
|
||||
|
||||
source.update(worldSample);
|
||||
|
||||
assert.equal(source.path.coords.length, 2);
|
||||
|
||||
// Should not receive sample2.
|
||||
assert.equal(subview.path.coords.length, 1);
|
||||
assert.deepEqual(subview.currentSample, helloSample);
|
||||
});
|
||||
|
||||
it('is updated if constructed from "current" intermediate subview', () => {
|
||||
let source = new GestureSource<string>(0, null, true);
|
||||
let baseSubview = source.constructSubview(false, true);
|
||||
|
||||
source.update(helloSample);
|
||||
|
||||
// Is constructed from a 'current' subview - one including the base source's
|
||||
// most recent sample.
|
||||
let subview = baseSubview.constructSubview(false, true);
|
||||
|
||||
assert.equal(subview.path.coords.length, 1);
|
||||
|
||||
source.update(worldSample);
|
||||
|
||||
// BOTH should update in this scenario.
|
||||
assert.equal(baseSubview.path.coords.length, 2);
|
||||
assert.equal(subview.path.coords.length, 2);
|
||||
assert.deepEqual(baseSubview.currentSample, worldSample);
|
||||
assert.deepEqual(subview.currentSample, worldSample);
|
||||
});
|
||||
|
||||
it('is not updated if constructed from old, not-up-to-date subview', () => {
|
||||
let source = new GestureSource<string>(0, null, true);
|
||||
let updatingSubview = source.constructSubview(false, true);
|
||||
let oldSubview = source.constructSubview(false, true);
|
||||
oldSubview.disconnect();
|
||||
|
||||
source.update(helloSample);
|
||||
|
||||
let subview = oldSubview.constructSubview(false, true);
|
||||
|
||||
assert.equal(subview.path.coords.length, 0);
|
||||
|
||||
let sample: InputSample<string> = {
|
||||
targetX: 1,
|
||||
targetY: 2,
|
||||
item: 'hello',
|
||||
t: 101
|
||||
};
|
||||
source.update(worldSample);
|
||||
|
||||
source.update(sample);
|
||||
|
||||
assert.equal(subview.path.coords.length, 1);
|
||||
assert.equal(subview.currentSample, sample);
|
||||
// Only the 'updating' one should update in this scenario.
|
||||
//
|
||||
// It's an error if the subview based on the non-updated subview updates,
|
||||
// as there would be a gap in the path!
|
||||
assert.equal(updatingSubview.path.coords.length, 2);
|
||||
assert.equal(subview.path.coords.length, 0);
|
||||
assert.deepEqual(updatingSubview.currentSample, worldSample);
|
||||
assert.isNotOk(subview.currentSample);
|
||||
});
|
||||
|
||||
it("propagates path termination (complete)", () => {
|
||||
let source = new GestureSource<string>(0, true);
|
||||
it("propagate path termination (complete)", () => {
|
||||
let source = new GestureSource<string>(0, null, true);
|
||||
let subview = source.constructSubview(true, true);
|
||||
let subview2 = source.constructSubview(true, true);
|
||||
|
||||
assert.equal(subview.path.coords.length, 0);
|
||||
|
||||
let sample: InputSample<string> = {
|
||||
targetX: 1,
|
||||
targetY: 2,
|
||||
item: 'hello',
|
||||
t: 101
|
||||
};
|
||||
|
||||
source.update(sample);
|
||||
source.update(helloSample);
|
||||
subview.terminate(false);
|
||||
|
||||
assert.equal(subview.path.coords.length, 1);
|
||||
assert.equal(subview.currentSample, sample);
|
||||
assert.deepEqual(subview.currentSample, helloSample);
|
||||
assert.equal(subview.isPathComplete, true);
|
||||
assert.equal(subview.path.wasCancelled, false);
|
||||
assert.equal(subview2.isPathComplete, true);
|
||||
assert.equal(subview2.path.wasCancelled, false);
|
||||
});
|
||||
|
||||
it("acts read-only - does not allow direct mutations", () => {
|
||||
let source = new GestureSource<string>(0, mockedInitialConfig as any, true);
|
||||
let subview = source.constructSubview(false, true);
|
||||
source.update(helloSample);
|
||||
source.pushRecognizerConfig(mockedShiftedConfig as any);
|
||||
|
||||
assert.throws(() => subview.update(worldSample));
|
||||
assert.throws(() => subview.pushRecognizerConfig(mockedOriginConfig as any));
|
||||
assert.throws(() => subview.popRecognizerConfig());
|
||||
|
||||
// Should NOT throw; this is on the base source.
|
||||
source.popRecognizerConfig();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -870,7 +870,7 @@ describe("GestureMatcher", function() {
|
|||
sources[0].path.on('invalidated', () => secondMatcher.update());
|
||||
|
||||
timedPromise(50).then(() => {
|
||||
const secondContact = new GestureSource<string>(5, true);
|
||||
const secondContact = new GestureSource<string>(5, null, true);
|
||||
sources.push(secondContact);
|
||||
secondMatcher.addContact(secondContact);
|
||||
secondContact.update({
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ describe("PathMatcher", function() {
|
|||
|
||||
describe("Instant fulfillment modeling", function() {
|
||||
it("resolve", async function() {
|
||||
const emulatedContactPoint = new GestureSource<string>(1, true);
|
||||
const emulatedContactPoint = new GestureSource<string>(1, null, true);
|
||||
const modelMatcher = new gestures.matchers.PathMatcher(InstantResolutionModel, emulatedContactPoint);
|
||||
|
||||
const startSample = {
|
||||
|
|
@ -86,7 +86,7 @@ describe("PathMatcher", function() {
|
|||
});
|
||||
|
||||
it("reject", async function() {
|
||||
const emulatedContactPoint = new GestureSource<string>(1, true);
|
||||
const emulatedContactPoint = new GestureSource<string>(1, null, true);
|
||||
const modelMatcher = new gestures.matchers.PathMatcher(InstantRejectionModel, emulatedContactPoint);
|
||||
|
||||
const startSample = {
|
||||
|
|
@ -107,7 +107,7 @@ describe("PathMatcher", function() {
|
|||
|
||||
describe("Longpress: primary path modeling", function() {
|
||||
it("resolve: path completed (long wait)", async function() {
|
||||
const emulatedContactPoint = new GestureSource<string>(1, true);
|
||||
const emulatedContactPoint = new GestureSource<string>(1, null, true);
|
||||
const modelMatcher = new gestures.matchers.PathMatcher(MainLongpressSourceModel, emulatedContactPoint);
|
||||
|
||||
const startSample = {
|
||||
|
|
@ -133,7 +133,7 @@ describe("PathMatcher", function() {
|
|||
});
|
||||
|
||||
it("resolve: path not completed (long wait)", async function() {
|
||||
const emulatedContactPoint = new GestureSource<string>(1, true);
|
||||
const emulatedContactPoint = new GestureSource<string>(1, null, true);
|
||||
const modelMatcher = new gestures.matchers.PathMatcher(MainLongpressSourceModel, emulatedContactPoint);
|
||||
|
||||
const startSample = {
|
||||
|
|
@ -159,7 +159,7 @@ describe("PathMatcher", function() {
|
|||
});
|
||||
|
||||
it("reject: path completed (short wait)", async function() {
|
||||
const emulatedContactPoint = new GestureSource<string>(1, true);
|
||||
const emulatedContactPoint = new GestureSource<string>(1, null, true);
|
||||
const modelMatcher = new gestures.matchers.PathMatcher(MainLongpressSourceModel, emulatedContactPoint);
|
||||
|
||||
const startSample = {
|
||||
|
|
@ -185,7 +185,7 @@ describe("PathMatcher", function() {
|
|||
});
|
||||
|
||||
it("reject: path cancelled", async function() {
|
||||
const emulatedContactPoint = new GestureSource<string>(1, true);
|
||||
const emulatedContactPoint = new GestureSource<string>(1, null, true);
|
||||
const modelMatcher = new gestures.matchers.PathMatcher(MainLongpressSourceModel, emulatedContactPoint);
|
||||
|
||||
const startSample = {
|
||||
|
|
@ -218,7 +218,7 @@ describe("PathMatcher", function() {
|
|||
});
|
||||
|
||||
it("reject: distance moved", async function() {
|
||||
const emulatedContactPoint = new GestureSource<string>(1, true);
|
||||
const emulatedContactPoint = new GestureSource<string>(1, null, true);
|
||||
const modelMatcher = new gestures.matchers.PathMatcher(MainLongpressSourceModel, emulatedContactPoint);
|
||||
|
||||
const startSample = {
|
||||
|
|
@ -253,7 +253,7 @@ describe("PathMatcher", function() {
|
|||
});
|
||||
|
||||
it("reject: item changed", async function() {
|
||||
const emulatedContactPoint = new GestureSource<string>(1, true);
|
||||
const emulatedContactPoint = new GestureSource<string>(1, null, true);
|
||||
const modelMatcher = new gestures.matchers.PathMatcher(MainLongpressSourceModel, emulatedContactPoint);
|
||||
|
||||
const startSample = {
|
||||
|
|
@ -287,7 +287,7 @@ describe("PathMatcher", function() {
|
|||
});
|
||||
|
||||
it("reject: disabled up-flick shortcut", async function() {
|
||||
const emulatedContactPoint = new GestureSource<string>(1, true);
|
||||
const emulatedContactPoint = new GestureSource<string>(1, null, true);
|
||||
const modelMatcher = new gestures.matchers.PathMatcher(MainLongpressSourceModel, emulatedContactPoint);
|
||||
|
||||
const startSample = {
|
||||
|
|
@ -313,7 +313,7 @@ describe("PathMatcher", function() {
|
|||
});
|
||||
|
||||
it("resolve: enabled up-flick shortcut", async function() {
|
||||
const emulatedContactPoint = new GestureSource<string>(1, true);
|
||||
const emulatedContactPoint = new GestureSource<string>(1, null, true);
|
||||
const modelMatcher = new gestures.matchers.PathMatcher(MainLongpressSourceModelWithShortcut, emulatedContactPoint);
|
||||
|
||||
const startSample = {
|
||||
|
|
@ -341,7 +341,7 @@ describe("PathMatcher", function() {
|
|||
|
||||
describe("Modipress: primary path modeling", function () {
|
||||
it("push: on path start", async function() {
|
||||
const emulatedContactPoint = new GestureSource<string>(1, true);
|
||||
const emulatedContactPoint = new GestureSource<string>(1, null, true);
|
||||
const modelMatcher = new gestures.matchers.PathMatcher(ModipressStartModel, emulatedContactPoint);
|
||||
|
||||
const startSample = {
|
||||
|
|
@ -360,7 +360,7 @@ describe("PathMatcher", function() {
|
|||
});
|
||||
|
||||
it("pop: released", async function() {
|
||||
const emulatedContactPoint = new GestureSource<string>(1, true);
|
||||
const emulatedContactPoint = new GestureSource<string>(1, null, true);
|
||||
const modelMatcher = new gestures.matchers.PathMatcher(ModipressEndModel, emulatedContactPoint);
|
||||
|
||||
const startSample = {
|
||||
|
|
@ -379,7 +379,7 @@ describe("PathMatcher", function() {
|
|||
});
|
||||
|
||||
it("pop: item changed", async function() {
|
||||
const emulatedContactPoint = new GestureSource<string>(1, true);
|
||||
const emulatedContactPoint = new GestureSource<string>(1, null, true);
|
||||
const modelMatcher = new gestures.matchers.PathMatcher(ModipressEndModel, emulatedContactPoint);
|
||||
|
||||
const startSample = {
|
||||
|
|
@ -407,7 +407,7 @@ describe("PathMatcher", function() {
|
|||
|
||||
describe("Simple Tap: primary path modeling", function() {
|
||||
it("resolve: path completed (long wait)", async function() {
|
||||
const emulatedContactPoint = new GestureSource<string>(1, true);
|
||||
const emulatedContactPoint = new GestureSource<string>(1, null, true);
|
||||
const modelMatcher = new gestures.matchers.PathMatcher(SimpleTapModel, emulatedContactPoint);
|
||||
|
||||
const startSample = {
|
||||
|
|
@ -433,7 +433,7 @@ describe("PathMatcher", function() {
|
|||
});
|
||||
|
||||
it("resolve: path completed (short wait)", async function() {
|
||||
const emulatedContactPoint = new GestureSource<string>(1, true);
|
||||
const emulatedContactPoint = new GestureSource<string>(1, null, true);
|
||||
const modelMatcher = new gestures.matchers.PathMatcher(SimpleTapModel, emulatedContactPoint);
|
||||
|
||||
const startSample = {
|
||||
|
|
@ -459,7 +459,7 @@ describe("PathMatcher", function() {
|
|||
});
|
||||
|
||||
it("reject: path cancelled", async function() {
|
||||
const emulatedContactPoint = new GestureSource<string>(1, true);
|
||||
const emulatedContactPoint = new GestureSource<string>(1, null, true);
|
||||
const modelMatcher = new gestures.matchers.PathMatcher(SimpleTapModel, emulatedContactPoint);
|
||||
|
||||
const startSample = {
|
||||
|
|
@ -492,7 +492,7 @@ describe("PathMatcher", function() {
|
|||
});
|
||||
|
||||
it("resolve: significant movement, but no item change", async function() {
|
||||
const emulatedContactPoint = new GestureSource<string>(1, true);
|
||||
const emulatedContactPoint = new GestureSource<string>(1, null, true);
|
||||
const modelMatcher = new gestures.matchers.PathMatcher(SimpleTapModel, emulatedContactPoint);
|
||||
|
||||
const startSample = {
|
||||
|
|
@ -527,7 +527,7 @@ describe("PathMatcher", function() {
|
|||
});
|
||||
|
||||
it("reject: little movement, but item changed", async function() {
|
||||
const emulatedContactPoint = new GestureSource<string>(1, true);
|
||||
const emulatedContactPoint = new GestureSource<string>(1, null, true);
|
||||
const modelMatcher = new gestures.matchers.PathMatcher(SimpleTapModel, emulatedContactPoint);
|
||||
|
||||
const startSample = {
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ function mockedPredecessor<Type>(
|
|||
lastSample: InputSample<Type>,
|
||||
baseItem?: Type
|
||||
): MockedPredecessor<Type> {
|
||||
const mockedSrc = new GestureSource<Type>(simSourceIdSeed++, true);
|
||||
const mockedSrc = new GestureSource<Type>(simSourceIdSeed++, null, true);
|
||||
mockedSrc.path.extend(lastSample);
|
||||
mockedSrc.terminate(false);
|
||||
|
||||
|
|
@ -97,7 +97,7 @@ function prepareSourceForTimer<Type> (
|
|||
timerSpec: SimSpecTimer<Type>,
|
||||
startTime: number
|
||||
): ReturnType<typeof prepareSimContact<Type>> {
|
||||
const simpleSource = new GestureSource<Type>(simSourceIdSeed++, true);
|
||||
const simpleSource = new GestureSource<Type>(simSourceIdSeed++, null, true);
|
||||
|
||||
const promise = timedPromise(startTime).then(() => {
|
||||
// We're simulating a previously-completed contact point's path..
|
||||
|
|
@ -124,7 +124,7 @@ function prepareSourceForRawSequence<Type>(
|
|||
contactSpec: SimSpecSequence<Type>,
|
||||
startTime: number
|
||||
): ReturnType<typeof prepareSimContact<Type>> {
|
||||
const simpleSource = new GestureSource<Type>(simSourceIdSeed++, true);
|
||||
const simpleSource = new GestureSource<Type>(simSourceIdSeed++, null, true);
|
||||
|
||||
const promise = timedPromise(startTime).then(() => {
|
||||
if(startTime != 0) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue