Move the draw-or-navigate choice into touchIntent() so the rule the canvas uses can be tested, and cover finger taps on the plus button: accepted after writing and while the pen hovers, rejected while the pen is down, for palm-sized contacts and right after pen activity. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GaoZtJQZwLXkEgWs8twCia
121 lines
3.8 KiB
TypeScript
121 lines
3.8 KiB
TypeScript
// 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<number>();
|
|
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' };
|
|
}
|
|
}
|