Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(web): integration for detecting, managing key-specific gestures 🐵 #9690

Merged
merged 15 commits into from
Oct 20, 2023
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,17 @@ import { ViewportZoneSource } from "./viewportZoneSource.js";
export class PaddedZoneSource implements RecognitionZoneSource {
private readonly root: RecognitionZoneSource;

private edgePadding: {
private _edgePadding: {
x: number,
y: number,
w: number,
h: number
};

public get edgePadding() {
return this._edgePadding;
}

/**
* Provides a dynamic 'padded' recognition zone based upon offsetting from the borders
* of the active page's viewport.
Expand Down Expand Up @@ -54,12 +58,32 @@ export class PaddedZoneSource implements RecognitionZoneSource {
// In case it isn't yet defined.
edgePadding = edgePadding || [0, 0, 0, 0];

this.updatePadding(edgePadding);
}

/**
* Provides a dynamic 'padded' recognition zone based upon offsetting from the borders
* of another defined zone.
*
* Padding is defined using the standard CSS border & padding spec style:
* - [a]: equal and even padding on all sides
* - [a, b]: top & bottom use `a`, left & right use `b`
* - [a, b, c]: top uses `a`, left & right use `b`, bottom uses `c`
* - [a, b, c, d]: top, right, bottom, then left.
*
* Positive padding reduces the size of the resulting zone; negative padding expands it.
*
* @param rootZoneSource The root zone source object/element to be 'padded'
* @param edgePadding A set of 1 to 4 numbers defining padding per the standard CSS border & padding spec style.
*/
updatePadding(edgePadding: number[]) {

// Modeled after CSS styling definitions... just with preprocessed numbers, not strings.
switch(edgePadding.length) {
case 1:
// all sides equal
const val = edgePadding[0];
this.edgePadding = {
this._edgePadding = {
x: val,
y: val,
w: 2 * val,
Expand All @@ -68,7 +92,7 @@ export class PaddedZoneSource implements RecognitionZoneSource {
break;
case 2:
// top & bottom, left & right
this.edgePadding = {
this._edgePadding = {
x: edgePadding[1],
y: edgePadding[0],
w: 2 * edgePadding[1],
Expand All @@ -77,15 +101,15 @@ export class PaddedZoneSource implements RecognitionZoneSource {
break;
case 3:
// top, left & right, bottom
this.edgePadding = {
this._edgePadding = {
x: edgePadding[1],
y: edgePadding[0],
w: 2 * edgePadding[1],
h: edgePadding[0] + edgePadding[2]
};
case 4:
// top, right, bottom, left
this.edgePadding = {
this._edgePadding = {
x: edgePadding[3],
y: edgePadding[0],
w: edgePadding[1] + edgePadding[3],
Expand Down
18 changes: 16 additions & 2 deletions common/web/gesture-recognizer/src/engine/headless/gestureSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,12 +133,20 @@ export class GestureSource<HoveredItemType, StateToken=any> {
}

/**
* The first path sample (coordinate) under consideration for this `GestureSource`.
* The 'base item' for the path of this `GestureSource`.
*
* May be set independently after construction for cases where one GestureSource conceptually
* "succeeds" another one, as with multitap gestures. (Though, those generally constrain
* new paths to have the same base item.)
*/
public get baseItem(): HoveredItemType {
return this._baseItem;
}

public set baseItem(value: HoveredItemType) {
this._baseItem = value;
}

/**
* The most recent path sample (coordinate) under consideration for this `GestureSource`.
*/
Expand Down Expand Up @@ -261,7 +269,7 @@ export class GestureSourceSubview<HoveredItemType, StateToken = any> extends Ges
// 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) {
if(expectedLength != sampleCountSinceStart) {
mayUpdate = false;
}
}
Expand Down Expand Up @@ -299,12 +307,18 @@ export class GestureSourceSubview<HoveredItemType, StateToken = any> extends Ges

subpath = new GesturePath<HoveredItemType, StateToken>();
for(let i=0; i < length; i++) {
// IMPORTANT: also acts as a deep-copy of the sample; edits to it do not propagate to other
// subviews or the original `baseSource`. Needed for multitaps that trigger system
// `stateToken` changes.
subpath.extend(translateSample(baseSource.path.coords[start + i]));
}

this._path = subpath;

if(preserveBaseItem) {
// IMPORTANT: inherits the _subview's_ base item, not the baseSource's version thereof.
// This allows gesture models based upon 'sustain timers' to have a different base item
// than concurrent models that aren't sustain-followups.
this._baseItem = source.baseItem;
} else {
this._baseItem = lastSample?.item;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export class GestureMatcher<Type> implements PredecessorMatch<Type> {
return entry.isPathComplete ? null : entry;
}).reduce((cleansed, entry) => {
return entry ? cleansed.concat(entry) : cleansed;
}, []);
}, [] as GestureSource<Type>[]);

if(model.sustainTimer && sourceTouchpoints.length > 0) {
// If a sustain timer is set, it's because we expect to have NO gesture-source _initially_.
Expand All @@ -115,9 +115,15 @@ export class GestureMatcher<Type> implements PredecessorMatch<Type> {

for(let touchpointIndex = 0; touchpointIndex < sourceTouchpoints.length; touchpointIndex++) {
const srcContact = sourceTouchpoints[touchpointIndex];
let baseContact = srcContact;

if(srcContact instanceof GestureSourceSubview) {
srcContact.disconnect(); // prevent further updates from mangling tracked path info.
baseContact = srcContact.baseSource;
}

if(baseContact.isPathComplete) {
throw new Error("GestureMatcher may not be built against already-completed contact points");
}

const contactSpec = model.contacts[touchpointIndex];
Expand Down Expand Up @@ -266,7 +272,7 @@ export class GestureMatcher<Type> implements PredecessorMatch<Type> {
*/
public get primaryPath(): GestureSource<Type> {
let bestMatcher: PathMatcher<Type>;
let highestPriority = Number.MIN_VALUE;
let highestPriority = Number.NEGATIVE_INFINITY;
for(let matcher of this.pathMatchers) {
if(matcher.model.itemPriority > highestPriority) {
highestPriority = matcher.model.itemPriority;
Expand Down Expand Up @@ -359,6 +365,11 @@ export class GestureMatcher<Type> implements PredecessorMatch<Type> {
baseItem = this.predecessor.result.action.item;
break;
}

// Under 'sustain timer' mode, the concept is that the first new source is the
// continuation and successor to `predecessor.primaryPath`. Its base `item`
// should reflect this.
simpleSource.baseItem = baseItem ?? simpleSource.baseItem;
} else {
// just use the highest-priority item source's base item and call it a day.
// There's no need to refer to some previously-existing source for comparison.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,14 @@ export class GestureSequence<Type> extends EventEmitter<EventMap<Type>> {
this.stageReports = [];
this.selector = selector;
this.selector.on('rejectionwithaction', this.modelResetHandler);
this.once('complete', () => {
this.selector.off('rejectionwithaction', this.modelResetHandler)

// Dropping the reference here gives us two benefits:
// 1. Allows garbage collection to do its thing; this might be the last reference left to the selector instance.
// 2. Acts as an obvious flag / indicator of sequence completion.
this.selector = null;
});
this.gestureConfig = gestureModelDefinitions;

// So that we can...
Expand Down Expand Up @@ -134,6 +142,12 @@ export class GestureSequence<Type> extends EventEmitter<EventMap<Type>> {
* current state. They will be specified in descending `resolutionPriority` order.
*/
public get potentialModelMatchIds(): string[] {
// If `this.selector` is null, it's because no further matches are possible.
// We've already emitted the 'complete' event as well.
if(!this.selector) {
return [];
}

// The new round of model-matching is based on the sources used by the previous round.
// This is important; 'sustainTimer' gesture models may rely on a now-terminated source
// from that previous round (like with multitaps).
Expand Down Expand Up @@ -260,17 +274,23 @@ export class GestureSequence<Type> extends EventEmitter<EventMap<Type>> {
this.pushedSelector = null;
}

// Dropping the reference here gives us two benefits:
// 1. Allows garbage collection to do its thing; this might be the last reference left to the selector instance.
// 2. Acts as an obvious flag / indicator of sequence completion.
this.selector = null;

// Any extra finalization stuff should go here, before the event, if needed.
this.emit('complete');
}
}

private readonly modelResetHandler = (selection: MatcherSelection<Type>, replaceModelWith: (model: GestureModel<Type>) => void) => {
const sourceIds = selection.matcher.allSourceIds;

// If none of the sources involved match a source already included in the sequence, bypass
// this handler; it belongs to a different sequence or one that's beginning.
//
// This works even for multitaps because we include the most recent ancestor sources in
// `allSourceIds` - that one will match here.
if(this.allSourceIds.find((a) => sourceIds.indexOf(a) == -1)) {
return;
}

if(selection.result.action.type == 'replace') {
replaceModelWith(getGestureModel(this.gestureConfig, selection.result.action.replace));
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,16 @@ export interface ContactModel<Type> {
/**
* Is needed to define whether or not the contact-point should be ignored by this gesture type.
* If undefined, defaults to () => true.
* Param 2 only matters for slots of multitouch gesture spec added after initialization of
* the gesture's corresponding matcher; it will receive the latest sample from the highest-priority
* active path (or predecessor gesture, if no path is active due to a 'sustain' state)
*
* @param incomingSample The first input sample of the path to be modeled.
* @param comparisonSample The most recent sample related to the same gesture component, if one exists.
* May be `null`.
* @param baseItem The 'base item' for the path corresponding to `comparisonSample`
* @returns
*/
readonly allowsInitialState?: (incomingSample: InputSample<Type>, comparisonSample?: InputSample<Type>, baseItem?: Type) => boolean;
readonly allowsInitialState?: (
incomingSample: InputSample<Type>,
comparisonSample?: InputSample<Type>,
baseItem?: Type
) => boolean;
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import EventEmitter from "eventemitter3";
import { InputEngineBase } from "./inputEngineBase.js";
import { buildGestureMatchInspector, GestureSource } from "./gestureSource.js";
import { MatcherSelector } from "./gestures/matchers/matcherSelector.js";
import { MatcherSelection, MatcherSelector } from "./gestures/matchers/matcherSelector.js";
import { GestureSequence } from "./gestures/matchers/gestureSequence.js";
import { GestureModelDefs, getGestureModelSet } from "./gestures/specs/gestureModelDefs.js";
import { GestureModelDefs, getGestureModel, getGestureModelSet } from "./gestures/specs/gestureModelDefs.js";
import { GestureModel } from "./gestures/specs/gestureModel.js";

interface EventMap<HoveredItemType, StateToken> {
/**
Expand Down Expand Up @@ -45,10 +46,31 @@ export class TouchpointCoordinator<HoveredItemType, StateToken=any> extends Even
this.addEngine(engine);
}
}

this.selectorStack[0].on('rejectionwithaction', this.modelResetHandler)
}

private readonly modelResetHandler = (selection: MatcherSelection<HoveredItemType>, replaceModelWith: (model: GestureModel<HoveredItemType>) => void) => {
const sourceIds = selection.matcher.allSourceIds;

// If there's an active gesture that uses a source noted in the selection, it's the responsibility
// of an existing GestureSequence to handle this one. The handler should bypass it for this round.
if(this.activeGestures.find((sequence) => {
return sequence.allSourceIds.find((a) => sourceIds.indexOf(a) != -1);
})) {
return;
}

if(selection.result.action.type == 'replace') {
replaceModelWith(getGestureModel(this.gestureModelDefinitions, selection.result.action.replace));
} else {
throw new Error("Missed a case in implementation!");
}
};

public pushSelector(selector: MatcherSelector<HoveredItemType>) {
this.selectorStack.push(selector);
selector.on('rejectionwithaction', this.modelResetHandler);
}

public popSelector(selector: MatcherSelector<HoveredItemType>) {
Expand All @@ -63,6 +85,7 @@ export class TouchpointCoordinator<HoveredItemType, StateToken=any> extends Even
}
/* c8 ignore end */

selector.off('rejectionwithaction', this.modelResetHandler);
selector.cascadeTermination();

this.selectorStack.splice(index, 1);
Expand Down
1 change: 1 addition & 0 deletions common/web/gesture-recognizer/src/engine/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export { GestureRecognizerConfiguration } from "./configuration/gestureRecognize
export { InputEngineBase } from "./headless/inputEngineBase.js";
export { InputSample } from "./headless/inputSample.js";
export { SerializedGesturePath, GesturePath } from "./headless/gesturePath.js";
export { GestureStageReport, GestureSequence } from "./headless/gestures/matchers/gestureSequence.js";
export { SerializedGestureSource, GestureSource, buildGestureMatchInspector } from "./headless/gestureSource.js";
export { MouseEventEngine } from "./mouseEventEngine.js";
export { PathSegmenter, Subsegmentation } from "./headless/subsegmentation/pathSegmenter.js";
Expand Down
Loading