Init
This commit is contained in:
commit
0ad405c26e
78 changed files with 9127 additions and 0 deletions
98
src/lib/ink/palm.ts
Normal file
98
src/lib/ink/palm.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
// 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
|
||||
// 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). */
|
||||
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. */
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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): 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.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 (o.hover && this.penHovering) {
|
||||
return { accept: false, reason: 'pen hovering' };
|
||||
}
|
||||
return { accept: true, reason: 'touch accepted' };
|
||||
}
|
||||
}
|
||||
114
src/lib/ink/stroke.ts
Normal file
114
src/lib/ink/stroke.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
// Vector stroke recording, outline generation and hit testing.
|
||||
|
||||
import { getStroke } from 'perfect-freehand';
|
||||
import { STRIDE, newId, type InkTool, type Stroke } from '$lib/model/types';
|
||||
|
||||
export const HIGHLIGHTER_OPACITY = 0.35;
|
||||
|
||||
/** Incrementally records points for one stroke. */
|
||||
export class StrokeRecorder {
|
||||
stroke: Stroke;
|
||||
private t0 = performance.now();
|
||||
|
||||
constructor(tool: InkTool, color: string, width: number, pressure: boolean) {
|
||||
this.stroke = { id: newId(), tool, color, width, pressure, points: [] };
|
||||
}
|
||||
|
||||
/** Push one sample; x/y are page-local points. */
|
||||
recordPoint(x: number, y: number, e: PointerEvent) {
|
||||
const p = this.stroke.pressure ? e.pressure || 0.5 : 0.5;
|
||||
const t = Math.round(e.timeStamp ? e.timeStamp - this.t0 : performance.now() - this.t0);
|
||||
this.stroke.points.push(round(x), round(y), round(p, 3), e.tiltX || 0, e.tiltY || 0, Math.max(0, t));
|
||||
}
|
||||
|
||||
get length() {
|
||||
return this.stroke.points.length / STRIDE;
|
||||
}
|
||||
}
|
||||
|
||||
const round = (v: number, digits = 2) => {
|
||||
const f = 10 ** digits;
|
||||
return Math.round(v * f) / f;
|
||||
};
|
||||
|
||||
export function* samples(s: Stroke) {
|
||||
for (let i = 0; i < s.points.length; i += STRIDE) {
|
||||
yield { x: s.points[i], y: s.points[i + 1], p: s.points[i + 2] };
|
||||
}
|
||||
}
|
||||
|
||||
/** Closed outline polygon of a stroke (page-local points). */
|
||||
export function outline(s: Stroke, last = true): [number, number][] {
|
||||
const input: number[][] = [];
|
||||
for (const { x, y, p } of samples(s)) input.push([x, y, p]);
|
||||
const highlighter = s.tool === 'highlighter';
|
||||
return getStroke(input, {
|
||||
size: s.width,
|
||||
thinning: highlighter ? 0 : 0.6,
|
||||
smoothing: 0.5,
|
||||
streamline: 0.4,
|
||||
simulatePressure: !s.pressure && !highlighter,
|
||||
last,
|
||||
start: { cap: true },
|
||||
end: { cap: true }
|
||||
}) as [number, number][];
|
||||
}
|
||||
|
||||
const pathCache = new WeakMap<Stroke, string>();
|
||||
|
||||
export function svgPath(s: Stroke, live = false): string {
|
||||
if (!live) {
|
||||
const cached = pathCache.get(s);
|
||||
if (cached !== undefined) return cached;
|
||||
}
|
||||
const pts = outline(s, !live);
|
||||
const d = pathFromOutline(pts);
|
||||
if (!live) pathCache.set(s, d);
|
||||
return d;
|
||||
}
|
||||
|
||||
function pathFromOutline(pts: [number, number][]): string {
|
||||
if (pts.length < 2) return '';
|
||||
// Quadratic smoothing through midpoints.
|
||||
const avg = (a: number, b: number) => ((a + b) / 2).toFixed(2);
|
||||
let d = `M${pts[0][0].toFixed(2)},${pts[0][1].toFixed(2)} Q`;
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
const [x0, y0] = pts[i];
|
||||
const [x1, y1] = pts[(i + 1) % pts.length];
|
||||
d += `${x0.toFixed(2)},${y0.toFixed(2)} ${avg(x0, x1)},${avg(y0, y1)} `;
|
||||
}
|
||||
return d + 'Z';
|
||||
}
|
||||
|
||||
/** Axis-aligned bounds of the stroke's centreline, padded by its width. */
|
||||
export function strokeBounds(s: Stroke) {
|
||||
let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity;
|
||||
for (const { x, y } of samples(s)) {
|
||||
x1 = Math.min(x1, x);
|
||||
y1 = Math.min(y1, y);
|
||||
x2 = Math.max(x2, x);
|
||||
y2 = Math.max(y2, y);
|
||||
}
|
||||
const pad = s.width;
|
||||
return { x1: x1 - pad, y1: y1 - pad, x2: x2 + pad, y2: y2 + pad };
|
||||
}
|
||||
|
||||
/** Does a circle at (x, y) with radius r touch the stroke? */
|
||||
export function hitStroke(s: Stroke, x: number, y: number, r: number): boolean {
|
||||
const b = strokeBounds(s);
|
||||
if (x < b.x1 - r || x > b.x2 + r || y < b.y1 - r || y > b.y2 + r) return false;
|
||||
const reach = r + s.width / 2;
|
||||
const pts = s.points;
|
||||
if (pts.length === STRIDE) return Math.hypot(pts[0] - x, pts[1] - y) <= reach;
|
||||
for (let i = STRIDE; i < pts.length; i += STRIDE) {
|
||||
if (segDist(x, y, pts[i - STRIDE], pts[i - STRIDE + 1], pts[i], pts[i + 1]) <= reach) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function segDist(px: number, py: number, ax: number, ay: number, bx: number, by: number) {
|
||||
const dx = bx - ax, dy = by - ay;
|
||||
const len2 = dx * dx + dy * dy;
|
||||
const t = len2 === 0 ? 0 : Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / len2));
|
||||
return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue