Keep finger pan/zoom working after using the pen

After writing with the pen, fingers could no longer navigate:
- every touch was ignored for 5 s after any pen activity (and for as long
  as a hover-capable pen stayed near the screen),
- after that, a one-finger drag on a page drew ink instead of panning,
- a one-finger drag on the background did nothing while an ink tool was on.

Once a pen has been used, fingers now only pan and zoom. The 5 s session
and hover lockouts only block finger ink; touches are still rejected while
the pen is down, for palm-sized contacts and within 150 ms of pen activity.
A finger drag off the page always pans.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsorPV3JeJRoZ1mD1pdvtL
This commit is contained in:
besbara 2026-09-26 20:09:53 +00:00
parent fa2f491161
commit bca1a6a407
4 changed files with 88 additions and 16 deletions

56
src/lib/ink/palm.test.ts Normal file
View file

@ -0,0 +1,56 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { PalmRejector } from './palm';
const ev = (type: string, pointerType: string, extra: Partial<PointerEvent> = {}) =>
({ type, pointerType, pointerId: pointerType === 'pen' ? 1 : 2, width: 10, height: 10, ...extra }) as PointerEvent;
const touch = (extra: Partial<PointerEvent> = {}) => ev('pointerdown', 'touch', extra);
describe('palm rejection', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
/** Pen writes a stroke, hovers, then leaves range; `ms` later a finger lands. */
function afterPen(ms: number, { leave = true } = {}) {
const palm = new PalmRejector();
for (const t of ['pointermove', 'pointerdown', 'pointermove', 'pointerup', 'pointermove']) palm.track(ev(t, 'pen'));
if (leave) palm.track(ev('pointerleave', 'pen'));
vi.advanceTimersByTime(ms);
return palm;
}
it('accepts fingers before any pen is used', () => {
const palm = new PalmRejector();
expect(palm.penSeen).toBe(false);
expect(palm.evaluate(touch(), 'draw').accept).toBe(true);
});
it('rejects every touch while the pen is down', () => {
const palm = new PalmRejector();
palm.track(ev('pointerdown', 'pen'));
vi.advanceTimersByTime(1000);
expect(palm.evaluate(touch(), 'navigate').accept).toBe(false);
expect(palm.evaluate(touch(), 'draw').accept).toBe(false);
});
it('lets fingers navigate between pen strokes but not draw', () => {
const palm = afterPen(300);
expect(palm.penSeen).toBe(true);
expect(palm.evaluate(touch(), 'navigate').accept).toBe(true);
expect(palm.evaluate(touch(), 'draw')).toEqual({ accept: false, reason: 'pen session active' });
});
it('lets fingers navigate while the pen hovers, but not draw', () => {
const palm = afterPen(6000, { leave: false });
expect(palm.evaluate(touch(), 'navigate').accept).toBe(true);
expect(palm.evaluate(touch(), 'draw')).toEqual({ accept: false, reason: 'pen hovering' });
});
it('still rejects palms and touches right after the pen', () => {
expect(afterPen(50).evaluate(touch(), 'navigate').accept).toBe(false);
expect(afterPen(300).evaluate(touch({ width: 60, height: 60 }), 'navigate').accept).toBe(false);
});
it('allows finger ink again once the pen session ends', () => {
expect(afterPen(5100).evaluate(touch(), 'draw').accept).toBe(true);
});
});

View file

@ -1,19 +1,20 @@
// 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
// 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 all touches while a pen session is active (5 s window). */
/** 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;
/** Arm the lockout as soon as the pen hovers. */
/** Block finger ink while the pen hovers. */
hover: boolean;
}
@ -23,6 +24,9 @@ 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';
export interface Decision {
accept: boolean;
reason: string;
@ -42,6 +46,11 @@ export class PalmRejector {
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;
@ -71,15 +80,15 @@ export class PalmRejector {
}
}
evaluate(e: PointerEvent): Decision {
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 && (since < PEN_SESSION_MS || this.penIsDown)) {
return { accept: false, reason: 'pen session active' };
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);
@ -90,8 +99,13 @@ export class PalmRejector {
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' };
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' };
}