From d153ecb1c04731a7c3071303808ad7298662707e Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Fri, 14 Aug 2026 15:26:57 -0500 Subject: [PATCH 1/5] fix(web): cancel gestures on relayouts and context resets Related-to: #15226 This fixes the bug underlying the repro described here: https://github.com/keymanapp/keyman/issues/15226#issuecomment-3842211229 I cannot yet confirm that it fixes the issue as a whole, however. Build-bot: skip build:web release:android release:ios --- web/src/engine/src/osk/input/gestures/heldRepeater.ts | 2 +- web/src/engine/src/osk/visualKeyboard.ts | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/web/src/engine/src/osk/input/gestures/heldRepeater.ts b/web/src/engine/src/osk/input/gestures/heldRepeater.ts index 253d57930c..60ac0c63bc 100644 --- a/web/src/engine/src/osk/input/gestures/heldRepeater.ts +++ b/web/src/engine/src/osk/input/gestures/heldRepeater.ts @@ -39,7 +39,7 @@ export class HeldRepeater implements GestureHandler { } cancel() { - this.deleteRepeater(); + window.clearTimeout(this.timerHandle); this.source.cancel(); } diff --git a/web/src/engine/src/osk/visualKeyboard.ts b/web/src/engine/src/osk/visualKeyboard.ts index 07bb794110..bbec8144f2 100644 --- a/web/src/engine/src/osk/visualKeyboard.ts +++ b/web/src/engine/src/osk/visualKeyboard.ts @@ -1162,6 +1162,10 @@ export class VisualKeyboard extends EventEmitter implements KeyboardVi return; } + // A re-layout operation can break an ongoing gesture operation. + // See #15226 - this is surprisingly relevant for backspaces! + this.activeGestures.forEach((g) => g.cancel()); + /* Phase 1: calculations possible at the start without triggering _any_ additional layout reflow. (A single, initial reflow may happen depending on DOM manipulations before this method..., From 3c13dfb175dacd5f1a3d049844878db65c0cb5c0 Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Mon, 17 Aug 2026 08:12:05 -0500 Subject: [PATCH 2/5] refactor(web): refactor HeldRepeater class --- .../src/osk/input/gestures/heldRepeater.ts | 57 +++++++++++-------- 1 file changed, 33 insertions(+), 24 deletions(-) diff --git a/web/src/engine/src/osk/input/gestures/heldRepeater.ts b/web/src/engine/src/osk/input/gestures/heldRepeater.ts index 60ac0c63bc..8b8ba563f1 100644 --- a/web/src/engine/src/osk/input/gestures/heldRepeater.ts +++ b/web/src/engine/src/osk/input/gestures/heldRepeater.ts @@ -1,3 +1,13 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by jahorton on 2023-10-04. + * + * The HeldRepeater class models key input that is repeated when its + * corresponding key is held. At this time, the class is mostly used to model a + * repeatable Backspace input. + */ + import { GestureSequence } from "keyman/engine/gesture-processor"; import { KeyDistribution } from "keyman/engine/keyboard"; @@ -5,48 +15,47 @@ import { KeyElement } from "../../keyElement.js"; import { GestureHandler } from './gestureHandler.js'; export class HeldRepeater implements GestureHandler { - readonly directlyEmitsKeys = true; + public readonly directlyEmitsKeys = true; + public readonly hasModalVisualization = false; - static readonly INITIAL_DELAY = 500; - static readonly REPEAT_DELAY = 100; + public static readonly INITIAL_DELAY = 500; // msec + public static readonly REPEAT_DELAY = 100; // msec readonly source: GestureSequence; - readonly hasModalVisualization = false; - readonly repeatClosure: () => void; + readonly baseKey: KeyElement + private readonly actionToRepeat: () => void; + private timerHandle: number; - timerHandle: number; - - constructor(source: GestureSequence, closureToRepeat: () => void) { + constructor(source: GestureSequence, actionToRepeat: () => void) { this.source = source; - const baseKey = source.stageReports[0].item; - baseKey.key.highlight(true); + this.baseKey = source.stageReports[0].item; + this.baseKey.key.highlight(true); + this.actionToRepeat = actionToRepeat; - this.repeatClosure = () => { - closureToRepeat(); - // The repeat-closure may cancel key highlighting. This restores it afterward. - baseKey.key.highlight(true); - } - - - this.timerHandle = window.setTimeout(this.deleteRepeater, HeldRepeater.INITIAL_DELAY); + this.timerHandle = window.setTimeout(() => this.repeatAction(), HeldRepeater.INITIAL_DELAY); this.source.on('complete', () => { window.clearTimeout(this.timerHandle); this.timerHandle = undefined; - baseKey.key.highlight(false); + this.baseKey.key.highlight(false); }); } cancel() { - window.clearTimeout(this.timerHandle); + if(this.timerHandle !== undefined) { + window.clearTimeout(this.timerHandle); + delete this.timerHandle; + } + this.source.cancel(); } - readonly deleteRepeater = () => { - this.repeatClosure(); - - this.timerHandle = window.setTimeout(this.deleteRepeater, HeldRepeater.REPEAT_DELAY); + private repeatAction() { + this.actionToRepeat(); + // The repeat-closure may cancel key highlighting. This restores it afterward. + this.baseKey.key.highlight(true); + this.timerHandle = window.setTimeout(() => this.repeatAction(), HeldRepeater.REPEAT_DELAY); } currentStageKeyDistribution(): KeyDistribution { From 0f666be3d5da59d12c9cbfe89032ec6625d36dd2 Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Mon, 17 Aug 2026 08:21:52 -0500 Subject: [PATCH 3/5] docs(web): document GestureHandler.cancel idempotency --- .../src/osk/input/gestures/gestureHandler.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/web/src/engine/src/osk/input/gestures/gestureHandler.ts b/web/src/engine/src/osk/input/gestures/gestureHandler.ts index 3c585de646..8bb099a07a 100644 --- a/web/src/engine/src/osk/input/gestures/gestureHandler.ts +++ b/web/src/engine/src/osk/input/gestures/gestureHandler.ts @@ -1,8 +1,22 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by jahorton on 2023-10-16. + * + * The GestureHandler interface defines methods common to all OSK + * gesture-handling classes, providing a common abstraction for common-path + * gesture support. + */ + import { ActiveKeyBase, KeyDistribution } from "keyman/engine/keyboard"; export interface GestureHandler { /** - * Triggers cancellation of any further processing for the gesture being handled. + * Triggers cancellation of any further processing for the gesture being + * handled. + * + * The method should be able to safely handle the `cancel` method being called + * multiple times, much like `clearTimeout` in JS. */ cancel(): void; From 7a7ca442423198f2bfbec0989ef7457e873522f4 Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Tue, 18 Aug 2026 08:12:51 -0500 Subject: [PATCH 4/5] change(web): merge gesture-completion handler and cancler for HeldRepeater --- web/src/engine/src/osk/input/gestures/heldRepeater.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/web/src/engine/src/osk/input/gestures/heldRepeater.ts b/web/src/engine/src/osk/input/gestures/heldRepeater.ts index 8b8ba563f1..51c5b3cf02 100644 --- a/web/src/engine/src/osk/input/gestures/heldRepeater.ts +++ b/web/src/engine/src/osk/input/gestures/heldRepeater.ts @@ -36,9 +36,7 @@ export class HeldRepeater implements GestureHandler { this.timerHandle = window.setTimeout(() => this.repeatAction(), HeldRepeater.INITIAL_DELAY); this.source.on('complete', () => { - window.clearTimeout(this.timerHandle); - this.timerHandle = undefined; - this.baseKey.key.highlight(false); + this.cancel(); }); } @@ -48,12 +46,14 @@ export class HeldRepeater implements GestureHandler { delete this.timerHandle; } + this.baseKey.key.highlight(false); this.source.cancel(); } private repeatAction() { this.actionToRepeat(); - // The repeat-closure may cancel key highlighting. This restores it afterward. + // In case the action to repeat cancels key highlighting, we restore it + // afterward. this.baseKey.key.highlight(true); this.timerHandle = window.setTimeout(() => this.repeatAction(), HeldRepeater.REPEAT_DELAY); } From 9372137bee4fb00ec328a614491d13eb5c4b5edf Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Tue, 18 Aug 2026 09:12:42 -0500 Subject: [PATCH 5/5] change(web): mark a couple fields private --- web/src/engine/src/osk/input/gestures/heldRepeater.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/engine/src/osk/input/gestures/heldRepeater.ts b/web/src/engine/src/osk/input/gestures/heldRepeater.ts index 51c5b3cf02..5feb87c198 100644 --- a/web/src/engine/src/osk/input/gestures/heldRepeater.ts +++ b/web/src/engine/src/osk/input/gestures/heldRepeater.ts @@ -21,8 +21,8 @@ export class HeldRepeater implements GestureHandler { public static readonly INITIAL_DELAY = 500; // msec public static readonly REPEAT_DELAY = 100; // msec - readonly source: GestureSequence; - readonly baseKey: KeyElement + private readonly source: GestureSequence; + private readonly baseKey: KeyElement private readonly actionToRepeat: () => void; private timerHandle: number;