From bca1a6a40797b89cf48a45e5e7a5bfaf053bc52a Mon Sep 17 00:00:00 2001 From: besbara Date: Sat, 26 Sep 2026 20:09:53 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01VsorPV3JeJRoZ1mD1pdvtL --- src/lib/components/CanvasView.svelte | 6 ++- src/lib/components/SettingsModal.svelte | 6 +-- src/lib/ink/palm.test.ts | 56 +++++++++++++++++++++++++ src/lib/ink/palm.ts | 36 +++++++++++----- 4 files changed, 88 insertions(+), 16 deletions(-) create mode 100644 src/lib/ink/palm.test.ts diff --git a/src/lib/components/CanvasView.svelte b/src/lib/components/CanvasView.svelte index ad72c68..4f05d39 100644 --- a/src/lib/components/CanvasView.svelte +++ b/src/lib/components/CanvasView.svelte @@ -271,8 +271,10 @@ if (target.closest('.no-stage')) return; const p = local(e); + // Fingers ink only when enabled and no pen has been used; otherwise they pan/zoom. + const fingerInks = tools.inking && settings.data.fingerDraw && !palm.penSeen; if (e.pointerType === 'touch') { - const decision = palm.evaluate(e); + const decision = palm.evaluate(e, fingerInks ? 'draw' : 'navigate'); if (!decision.accept) return; // palm: ignore entirely touches.set(e.pointerId, p); if (touches.size >= 2) { @@ -293,7 +295,7 @@ const wantsPan = e.button === 1 || spaceDown || - (e.pointerType === 'touch' && (!tools.inking || !settings.data.fingerDraw)) || + (e.pointerType === 'touch' && (!fingerInks || !onPage)) || (e.pointerType === 'mouse' && e.button === 0 && !onPage && !target.closest('.ghost, .slot')); if (wantsPan) { if (onPage && !inEditor) doc.activeId = onPage; diff --git a/src/lib/components/SettingsModal.svelte b/src/lib/components/SettingsModal.svelte index 42a4e25..c296b04 100644 --- a/src/lib/components/SettingsModal.svelte +++ b/src/lib/components/SettingsModal.svelte @@ -82,12 +82,12 @@ {:else if tab === 'input'}

Pen & touch

-
Draw with finger
When off, a single finger always pans; only pen and mouse draw.
+
Draw with finger
When off, a single finger always pans; only pen and mouse draw. Once you use a pen, fingers only pan and zoom.

Palm rejection

-
Pen session lockout
Ignore touches while the pen is writing or was used in the last 5 seconds.
+
Pen session lockout
Ignore touches while the pen is writing, and finger ink for 5 seconds after it was used.
@@ -99,7 +99,7 @@
-
Lock out on pen hover
Ignore touches while the pen hovers above the screen.
+
Lock out on pen hover
Ignore finger ink while the pen hovers above the screen.
{:else} diff --git a/src/lib/ink/palm.test.ts b/src/lib/ink/palm.test.ts new file mode 100644 index 0000000..473358c --- /dev/null +++ b/src/lib/ink/palm.test.ts @@ -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 = {}) => + ({ type, pointerType, pointerId: pointerType === 'pen' ? 1 : 2, width: 10, height: 10, ...extra }) as PointerEvent; +const touch = (extra: Partial = {}) => 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); + }); +}); diff --git a/src/lib/ink/palm.ts b/src/lib/ink/palm.ts index 341247d..7030b2b 100644 --- a/src/lib/ink/palm.ts +++ b/src/lib/ink/palm.ts @@ -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' }; }