Merge pull request 'Keep finger pan/zoom working after using the pen' (#1) from agent/fix-finger-navigation into main
Reviewed-on: https://git.srazka.com/reudy-net/Papure/pulls/1
This commit is contained in:
commit
08a530c78f
4 changed files with 88 additions and 16 deletions
|
|
@ -271,8 +271,10 @@
|
||||||
if (target.closest('.no-stage')) return;
|
if (target.closest('.no-stage')) return;
|
||||||
const p = local(e);
|
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') {
|
if (e.pointerType === 'touch') {
|
||||||
const decision = palm.evaluate(e);
|
const decision = palm.evaluate(e, fingerInks ? 'draw' : 'navigate');
|
||||||
if (!decision.accept) return; // palm: ignore entirely
|
if (!decision.accept) return; // palm: ignore entirely
|
||||||
touches.set(e.pointerId, p);
|
touches.set(e.pointerId, p);
|
||||||
if (touches.size >= 2) {
|
if (touches.size >= 2) {
|
||||||
|
|
@ -293,7 +295,7 @@
|
||||||
const wantsPan =
|
const wantsPan =
|
||||||
e.button === 1 ||
|
e.button === 1 ||
|
||||||
spaceDown ||
|
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'));
|
(e.pointerType === 'mouse' && e.button === 0 && !onPage && !target.closest('.ghost, .slot'));
|
||||||
if (wantsPan) {
|
if (wantsPan) {
|
||||||
if (onPage && !inEditor) doc.activeId = onPage;
|
if (onPage && !inEditor) doc.activeId = onPage;
|
||||||
|
|
|
||||||
|
|
@ -82,12 +82,12 @@
|
||||||
{:else if tab === 'input'}
|
{:else if tab === 'input'}
|
||||||
<h2>Pen & touch</h2>
|
<h2>Pen & touch</h2>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div><div class="name">Draw with finger</div><div class="desc">When off, a single finger always pans; only pen and mouse draw.</div></div>
|
<div><div class="name">Draw with finger</div><div class="desc">When off, a single finger always pans; only pen and mouse draw. Once you use a pen, fingers only pan and zoom.</div></div>
|
||||||
<input type="checkbox" class="toggle" bind:checked={s.fingerDraw} onchange={save} />
|
<input type="checkbox" class="toggle" bind:checked={s.fingerDraw} onchange={save} />
|
||||||
</div>
|
</div>
|
||||||
<h3>Palm rejection</h3>
|
<h3>Palm rejection</h3>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div><div class="name">Pen session lockout</div><div class="desc">Ignore touches while the pen is writing or was used in the last 5 seconds.</div></div>
|
<div><div class="name">Pen session lockout</div><div class="desc">Ignore touches while the pen is writing, and finger ink for 5 seconds after it was used.</div></div>
|
||||||
<input type="checkbox" class="toggle" bind:checked={s.palm.penSession} onchange={save} />
|
<input type="checkbox" class="toggle" bind:checked={s.palm.penSession} onchange={save} />
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
|
@ -99,7 +99,7 @@
|
||||||
<input type="checkbox" class="toggle" bind:checked={s.palm.timing} onchange={save} />
|
<input type="checkbox" class="toggle" bind:checked={s.palm.timing} onchange={save} />
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div><div class="name">Lock out on pen hover</div><div class="desc">Ignore touches while the pen hovers above the screen.</div></div>
|
<div><div class="name">Lock out on pen hover</div><div class="desc">Ignore finger ink while the pen hovers above the screen.</div></div>
|
||||||
<input type="checkbox" class="toggle" bind:checked={s.palm.hover} onchange={save} />
|
<input type="checkbox" class="toggle" bind:checked={s.palm.hover} onchange={save} />
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|
|
||||||
56
src/lib/ink/palm.test.ts
Normal file
56
src/lib/ink/palm.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,19 +1,20 @@
|
||||||
// Palm rejection — ported from palm-rejection-test.html.
|
// Palm rejection — ported from palm-rejection-test.html.
|
||||||
//
|
//
|
||||||
// Pens are always accepted. A touch is rejected when a pen session is active
|
// Pens are always accepted. A touch is rejected while the pen is down, when its
|
||||||
// (stroke in progress or pen activity within the last 5 s), when its contact
|
// contact ellipse is palm-sized, or when it lands within 150 ms of pen activity.
|
||||||
// ellipse is palm-sized, when it lands within 150 ms of pen activity, or while
|
// Touches that would draw are also rejected for 5 s after pen activity and while
|
||||||
// a pen hovers over the screen. A touch already drawing is cut off as soon as
|
// 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).
|
// pen activity appears (callers re-run `evaluate` on every move).
|
||||||
|
|
||||||
export interface PalmOptions {
|
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;
|
penSession: boolean;
|
||||||
/** Reject touches with a wide contact ellipse. */
|
/** Reject touches with a wide contact ellipse. */
|
||||||
geometry: boolean;
|
geometry: boolean;
|
||||||
/** Reject touches within 150 ms of pen activity. */
|
/** Reject touches within 150 ms of pen activity. */
|
||||||
timing: boolean;
|
timing: boolean;
|
||||||
/** Arm the lockout as soon as the pen hovers. */
|
/** Block finger ink while the pen hovers. */
|
||||||
hover: boolean;
|
hover: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -23,6 +24,9 @@ const PEN_SESSION_MS = 5000;
|
||||||
const PEN_TIMING_MS = 150;
|
const PEN_TIMING_MS = 150;
|
||||||
const PALM_CONTACT_PX = 35;
|
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 {
|
export interface Decision {
|
||||||
accept: boolean;
|
accept: boolean;
|
||||||
reason: string;
|
reason: string;
|
||||||
|
|
@ -42,6 +46,11 @@ export class PalmRejector {
|
||||||
return this.activePens.size > 0;
|
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. */
|
/** Feed every pointerdown/move/up/leave so pen state stays current. */
|
||||||
track(e: PointerEvent) {
|
track(e: PointerEvent) {
|
||||||
if (e.pointerType !== 'pen') return;
|
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 === 'pen') return { accept: true, reason: 'pen input' };
|
||||||
if (e.pointerType !== 'touch') return { accept: true, reason: 'non-touch pointer' };
|
if (e.pointerType !== 'touch') return { accept: true, reason: 'non-touch pointer' };
|
||||||
|
|
||||||
const o = this.options;
|
const o = this.options;
|
||||||
const since = Date.now() - this.lastPenActivityTime;
|
const since = Date.now() - this.lastPenActivityTime;
|
||||||
|
|
||||||
if (o.penSession && (since < PEN_SESSION_MS || this.penIsDown)) {
|
if (o.penSession && this.penIsDown) {
|
||||||
return { accept: false, reason: 'pen session active' };
|
return { accept: false, reason: 'pen is down' };
|
||||||
}
|
}
|
||||||
if (o.geometry) {
|
if (o.geometry) {
|
||||||
const contact = Math.max(e.width || 0, e.height || 0);
|
const contact = Math.max(e.width || 0, e.height || 0);
|
||||||
|
|
@ -90,8 +99,13 @@ export class PalmRejector {
|
||||||
if (o.timing && since < PEN_TIMING_MS) {
|
if (o.timing && since < PEN_TIMING_MS) {
|
||||||
return { accept: false, reason: 'within 150ms of pen activity' };
|
return { accept: false, reason: 'within 150ms of pen activity' };
|
||||||
}
|
}
|
||||||
if (o.hover && this.penHovering) {
|
if (intent === 'draw') {
|
||||||
return { accept: false, reason: 'pen hovering' };
|
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' };
|
return { accept: true, reason: 'touch accepted' };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue