// Palm rejection — ported from palm-rejection-test.html. // // Pens are always accepted. A touch is rejected while the pen is down, when its // contact ellipse is palm-sized, or when it lands within 150 ms of pen activity. // Touches that would draw are also rejected for 5 s after pen activity and while // a pen hovers; touches that only pan/zoom are not, so navigating by hand keeps // working between pen strokes. A touch already drawing is cut off as soon as // pen activity appears (callers re-run `evaluate` on every move). export interface PalmOptions { /** Block touches while the pen is down, and finger ink for 5 s after pen activity. */ penSession: boolean; /** Reject touches with a wide contact ellipse. */ geometry: boolean; /** Reject touches within 150 ms of pen activity. */ timing: boolean; /** Block finger ink while 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; /** What an accepted touch would do: ink (draw/erase) or pan/zoom. */ export type TouchIntent = 'draw' | 'navigate'; /** * Which policy a touch falls under. It only draws when finger ink is on and it * didn't land on a button; taps on buttons (e.g. add page) navigate, so they keep * working between pen strokes. */ export function touchIntent(fingerInks: boolean, onButton: boolean): TouchIntent { return fingerInks && !onButton ? 'draw' : 'navigate'; } export interface Decision { accept: boolean; reason: string; } export class PalmRejector { options: PalmOptions; lastPenActivityTime = 0; private activePens = new Set(); private penHovering = false; constructor(options: PalmOptions = DEFAULT_PALM) { this.options = options; } get penIsDown() { return this.activePens.size > 0; } /** A pen has been used since the app loaded; fingers then only navigate. */ get penSeen() { return this.lastPenActivityTime > 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, intent: TouchIntent = 'draw'): 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 && this.penIsDown) { return { accept: false, reason: 'pen is down' }; } 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 (intent === 'draw') { if (o.penSession && since < PEN_SESSION_MS) { return { accept: false, reason: 'pen session active' }; } if (o.hover && this.penHovering) { return { accept: false, reason: 'pen hovering' }; } } return { accept: true, reason: 'touch accepted' }; } }