Skip to content

Commit 51412d8

Browse files
chapterjasonclaude
andcommitted
Expose gesture timing as HTML attributes
Adds double-tap-ms, long-press-ms, repeat-initial-ms, and repeat-interval-ms observed attributes so consumers can tune the keyboard for their device (stylus vs finger, desktop vs tablet) without forking. Defaults match the previous constants; invalid values fall back to them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0b4928f commit 51412d8

1 file changed

Lines changed: 44 additions & 15 deletions

File tree

src/keyboard/virtual-keyboard.ts

Lines changed: 44 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -33,19 +33,21 @@ type Press = {
3333
};
3434

3535
const DEFAULT_LOCALE: Locale = "en";
36-
const DOUBLE_TAP_MS = 300;
37-
const LONG_PRESS_MS = 350;
38-
const REPEAT_INITIAL_MS = 450;
39-
const REPEAT_INTERVAL_MS = 35;
36+
const DEFAULT_DOUBLE_TAP_MS = 300;
37+
const DEFAULT_LONG_PRESS_MS = 350;
38+
const DEFAULT_REPEAT_INITIAL_MS = 450;
39+
const DEFAULT_REPEAT_INTERVAL_MS = 35;
4040

4141
/** Hold-to-repeat binder.
42-
* `fire` runs once on pointerdown, then every REPEAT_INTERVAL_MS after
43-
* REPEAT_INITIAL_MS until pointerup/cancel, the signal aborts, or `fire`
44-
* returns `false`. `true` or `void` means "keep repeating". */
42+
* `fire` runs once on pointerdown, then every `intervalMs` after `initialMs`
43+
* until pointerup/cancel, the signal aborts, or `fire` returns `false`.
44+
* `true` or `void` means "keep repeating". */
4545
const attachRepeat = (
4646
btn: HTMLElement,
4747
fire: () => boolean | void,
4848
signal: AbortSignal,
49+
initialMs: number,
50+
intervalMs: number,
4951
): void => {
5052
let initial: number | null = null;
5153
let interval: number | null = null;
@@ -63,8 +65,8 @@ const attachRepeat = (
6365
if (fire() === false) return;
6466
initial = window.setTimeout(() => {
6567
initial = null;
66-
interval = window.setInterval(run, REPEAT_INTERVAL_MS);
67-
}, REPEAT_INITIAL_MS);
68+
interval = window.setInterval(run, intervalMs);
69+
}, initialMs);
6870
}, { signal });
6971
btn.addEventListener("pointerup", stop, { signal });
7072
btn.addEventListener("pointercancel", stop, { signal });
@@ -82,7 +84,13 @@ const isRepeatableTopbar = (action: TopbarKey["action"]): boolean => {
8284
};
8385

8486
export class VirtualKeyboard extends HTMLElement {
85-
static observedAttributes = ["locale"];
87+
static observedAttributes = [
88+
"locale",
89+
"double-tap-ms",
90+
"long-press-ms",
91+
"repeat-initial-ms",
92+
"repeat-interval-ms",
93+
];
8694

8795
#state: State = {
8896
locale: DEFAULT_LOCALE,
@@ -96,6 +104,10 @@ export class VirtualKeyboard extends HTMLElement {
96104
#adapter: OutputAdapter = nativeAdapter();
97105
#press: Press | null = null;
98106
#controller: AbortController | null = null;
107+
#doubleTapMs = DEFAULT_DOUBLE_TAP_MS;
108+
#longPressMs = DEFAULT_LONG_PRESS_MS;
109+
#repeatInitialMs = DEFAULT_REPEAT_INITIAL_MS;
110+
#repeatIntervalMs = DEFAULT_REPEAT_INTERVAL_MS;
99111

100112
constructor() {
101113
super();
@@ -122,6 +134,23 @@ export class VirtualKeyboard extends HTMLElement {
122134
this.#state.layer = "letters";
123135
this.#state.shift = "off";
124136
if (this.isConnected) this.#render();
137+
return;
138+
}
139+
const parsed = value === null ? NaN : Number(value);
140+
const ms = Number.isFinite(parsed) && parsed > 0 ? parsed : null;
141+
switch (name) {
142+
case "double-tap-ms":
143+
this.#doubleTapMs = ms ?? DEFAULT_DOUBLE_TAP_MS;
144+
return;
145+
case "long-press-ms":
146+
this.#longPressMs = ms ?? DEFAULT_LONG_PRESS_MS;
147+
return;
148+
case "repeat-initial-ms":
149+
this.#repeatInitialMs = ms ?? DEFAULT_REPEAT_INITIAL_MS;
150+
return;
151+
case "repeat-interval-ms":
152+
this.#repeatIntervalMs = ms ?? DEFAULT_REPEAT_INTERVAL_MS;
153+
return;
125154
}
126155
}
127156

@@ -210,7 +239,7 @@ export class VirtualKeyboard extends HTMLElement {
210239
}
211240
const signal = this.#controller!.signal;
212241
if (isRepeatableTopbar(key.action)) {
213-
attachRepeat(btn, (): boolean | void => this.#handleTopbar(key), signal);
242+
attachRepeat(btn, (): boolean | void => this.#handleTopbar(key), signal, this.#repeatInitialMs, this.#repeatIntervalMs);
214243
} else {
215244
let startX = 0;
216245
let startY = 0;
@@ -277,7 +306,7 @@ export class VirtualKeyboard extends HTMLElement {
277306
#toggleModifier(modifier: Modifier): void {
278307
const now = performance.now();
279308
const prev = this.#lastModifierTap.get(modifier) ?? 0;
280-
const doubleTap = now - prev < DOUBLE_TAP_MS;
309+
const doubleTap = now - prev < this.#doubleTapMs;
281310
this.#lastModifierTap.set(modifier, now);
282311
const current = this.#state.modifiers.get(modifier);
283312
if (doubleTap && current === "armed") {
@@ -441,7 +470,7 @@ export class VirtualKeyboard extends HTMLElement {
441470
btn.addEventListener("pointerup", (e) => this.#onPointerUp(e), { signal });
442471
btn.addEventListener("pointercancel", () => this.#cancelPress(), { signal });
443472
} else if (isRepeatableKey(key)) {
444-
attachRepeat(btn, () => this.#handle(key), signal);
473+
attachRepeat(btn, () => this.#handle(key), signal, this.#repeatInitialMs, this.#repeatIntervalMs);
445474
} else {
446475
btn.addEventListener("pointerdown", () => this.#handle(key), { signal });
447476
}
@@ -486,7 +515,7 @@ export class VirtualKeyboard extends HTMLElement {
486515
selectedIndex: 0,
487516
committed: false,
488517
};
489-
press.timer = window.setTimeout(() => this.#openPopover(), LONG_PRESS_MS);
518+
press.timer = window.setTimeout(() => this.#openPopover(), this.#longPressMs);
490519
this.#press = press;
491520
}
492521

@@ -648,7 +677,7 @@ export class VirtualKeyboard extends HTMLElement {
648677

649678
#toggleShift(): void {
650679
const now = performance.now();
651-
const isDoubleTap = now - this.#lastShiftTap < DOUBLE_TAP_MS;
680+
const isDoubleTap = now - this.#lastShiftTap < this.#doubleTapMs;
652681
this.#lastShiftTap = now;
653682

654683
if (isDoubleTap && this.#state.shift === "on") {

0 commit comments

Comments
 (0)