mirror of
https://github.com/keymanapp/keyman.git
synced 2026-09-04 14:47:42 +00:00
feat(web): adds support for pushed alt-configurations during gesture-source lifetime
This commit is contained in:
parent
6bf0cebcd6
commit
504eb53565
8 changed files with 188 additions and 62 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) {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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