This commit is contained in:
Reudy 2026-09-26 19:13:02 +02:00
commit 0ad405c26e
78 changed files with 9127 additions and 0 deletions

98
src/lib/ink/palm.ts Normal file
View file

@ -0,0 +1,98 @@
// Palm rejection — ported from palm-rejection-test.html.
//
// Pens are always accepted. A touch is rejected when a pen session is active
// (stroke in progress or pen activity within the last 5 s), when its contact
// ellipse is palm-sized, when it lands within 150 ms of pen activity, or while
// a pen hovers over the screen. A touch already drawing is cut off as soon as
// pen activity appears (callers re-run `evaluate` on every move).
export interface PalmOptions {
/** Block all touches while a pen session is active (5 s window). */
penSession: boolean;
/** Reject touches with a wide contact ellipse. */
geometry: boolean;
/** Reject touches within 150 ms of pen activity. */
timing: boolean;
/** Arm the lockout as soon as the pen hovers. */
hover: boolean;
}
export const DEFAULT_PALM: PalmOptions = { penSession: true, geometry: true, timing: true, hover: true };
const PEN_SESSION_MS = 5000;
const PEN_TIMING_MS = 150;
const PALM_CONTACT_PX = 35;
export interface Decision {
accept: boolean;
reason: string;
}
export class PalmRejector {
options: PalmOptions;
lastPenActivityTime = 0;
private activePens = new Set<number>();
private penHovering = false;
constructor(options: PalmOptions = DEFAULT_PALM) {
this.options = options;
}
get penIsDown() {
return this.activePens.size > 0;
}
/** Feed every pointerdown/move/up/leave so pen state stays current. */
track(e: PointerEvent) {
if (e.pointerType !== 'pen') return;
const now = Date.now();
switch (e.type) {
case 'pointerdown':
this.activePens.add(e.pointerId);
this.lastPenActivityTime = now;
break;
case 'pointermove':
case 'pointerover':
case 'pointerenter':
// Hover (buttons == 0) counts as "pen nearby" too.
this.lastPenActivityTime = now;
this.penHovering = !this.activePens.has(e.pointerId);
break;
case 'pointerup':
case 'pointercancel':
this.activePens.delete(e.pointerId);
this.lastPenActivityTime = now;
break;
case 'pointerleave':
case 'pointerout':
this.penHovering = false;
this.lastPenActivityTime = now;
break;
}
}
evaluate(e: PointerEvent): Decision {
if (e.pointerType === 'pen') return { accept: true, reason: 'pen input' };
if (e.pointerType !== 'touch') return { accept: true, reason: 'non-touch pointer' };
const o = this.options;
const since = Date.now() - this.lastPenActivityTime;
if (o.penSession && (since < PEN_SESSION_MS || this.penIsDown)) {
return { accept: false, reason: 'pen session active' };
}
if (o.geometry) {
const contact = Math.max(e.width || 0, e.height || 0);
if (contact > PALM_CONTACT_PX) {
return { accept: false, reason: `wide contact (${contact.toFixed(0)}px)` };
}
}
if (o.timing && since < PEN_TIMING_MS) {
return { accept: false, reason: 'within 150ms of pen activity' };
}
if (o.hover && this.penHovering) {
return { accept: false, reason: 'pen hovering' };
}
return { accept: true, reason: 'touch accepted' };
}
}