/* eslint-disable @typescript-eslint/no-explicit-any */ /** * "Draw with finger", for visitors on a phone or tablet without a pen. * * FineNotes itself never draws with a finger: fingers scroll and pinch, the * pen and the mouse write. With this switch on, one finger on the page is * handed to the notebook as a pen; two fingers still scroll and pinch. The * first finger's events are held for a moment (HOLD_MS) so a second finger * landing just after it makes the pair a gesture, never a dot of ink. * * Done outside FineNotes: trusted touch events are stopped in the capture * phase and the same events, retyped, are dispatched to the same target. * * Original code for this site. GPL-3.0-or-later. */ const HOLD_MS = 90; const HOLD_PX = 10; const STORE = "finenotes-playground:finger-draw"; type Mode = "pending" | "pen" | "touch" | "drop"; interface Track { mode: Mode; queue: PointerEvent[]; x: number; y: number; timer: number; } let enabled = false; const tracks = new Map(); function retype(e: PointerEvent, pointerType: string): PointerEvent { const pen = pointerType === "pen"; const init: PointerEventInit = { bubbles: true, cancelable: true, composed: true, clientX: e.clientX, clientY: e.clientY, screenX: e.screenX, screenY: e.screenY, pointerId: e.pointerId, pointerType, isPrimary: e.isPrimary, button: e.button, buttons: e.buttons, width: e.width, height: e.height, pressure: pen ? (e.type === "pointerup" || e.type === "pointercancel" ? 0 : 0.5) : e.pressure, tiltX: 0, tiltY: 0, }; const out = new PointerEvent(e.type, init); const coalesced = typeof e.getCoalescedEvents === "function" ? e.getCoalescedEvents() : []; if (coalesced.length > 1) { const list = coalesced.map( (c) => ({ clientX: c.clientX, clientY: c.clientY, pressure: pen ? 0.5 : c.pressure, timeStamp: c.timeStamp, pointerType, pointerId: c.pointerId, tiltX: 0, tiltY: 0, width: c.width, height: c.height, buttons: c.buttons, }) as unknown as PointerEvent, ); Object.defineProperty(out, "getCoalescedEvents", { value: () => list }); } return out; } /** The events this module dispatched itself: never intercepted again. */ const ours = new WeakSet(); function send(e: PointerEvent, pointerType: string): void { const target = (e.target as Element | null) ?? document.body; const copy = retype(e, pointerType); ours.add(copy); target.dispatchEvent(copy); } function flush(id: number, mode: "pen" | "touch"): void { const t = tracks.get(id); if (!t) return; window.clearTimeout(t.timer); t.mode = mode; const queued = t.queue.splice(0); for (const e of queued) send(e, mode); } function onEvent(e: PointerEvent): void { if (!enabled || ours.has(e) || e.pointerType !== "touch") return; // Only the browser's own touches (a test may pass `__playUntrustedTouch`). if (!e.isTrusted && !(window as any).__playUntrustedTouch) return; const target = e.target as Element | null; let t = tracks.get(e.pointerId); if (e.type === "pointerdown") { // Only the page itself: buttons, menus and dialogs keep their own touches. if (!target?.closest?.(".goodobsidian-scroll")) return; if (target.closest("textarea, input, [contenteditable='true']")) return; const others = [...tracks.values()].filter((o) => o.mode !== "drop"); t = { mode: "pending", queue: [], x: e.clientX, y: e.clientY, timer: 0 }; tracks.set(e.pointerId, t); if (others.some((o) => o.mode === "pen")) { // A stroke is under way: a second finger is a resting hand, not a gesture. t.mode = "drop"; } else if (others.length > 0) { // A second finger: both scroll and pinch, as fingers do in FineNotes. for (const [id, o] of tracks) if (o.mode === "pending") flush(id, "touch"); t.mode = "touch"; } else { t.timer = window.setTimeout(() => flush(e.pointerId, "pen"), HOLD_MS); } } if (!t) return; e.stopImmediatePropagation(); e.preventDefault(); if (t.mode === "drop") { if (e.type === "pointerup" || e.type === "pointercancel") tracks.delete(e.pointerId); return; } if (t.mode === "pending") { t.queue.push(e); if (e.type === "pointermove" && Math.hypot(e.clientX - t.x, e.clientY - t.y) > HOLD_PX) flush(e.pointerId, "pen"); else if (e.type === "pointerup" || e.type === "pointercancel") flush(e.pointerId, "pen"); } else { send(e, t.mode); } if (e.type === "pointerup" || e.type === "pointercancel") tracks.delete(e.pointerId); } /** The switch in the playground's bar, and the listeners it needs. */ export function installFingerDraw(): void { for (const type of ["pointerdown", "pointermove", "pointerup", "pointercancel"]) window.addEventListener(type, onEvent as EventListener, { capture: true, passive: false }); const button = document.getElementById("play-finger") as HTMLButtonElement | null; if (!button) return; let saved: string | null = null; try { saved = localStorage.getItem(STORE); } catch { saved = null; } // On by default where a finger is the only pointer (a phone, a tablet). const touchOnly = matchMedia("(pointer: coarse)").matches && !matchMedia("(hover: hover)").matches; const set = (on: boolean): void => { enabled = on; button.setAttribute("aria-pressed", String(on)); button.classList.toggle("is-on", on); }; set(saved === null ? touchOnly : saved === "1"); if (!touchOnly && navigator.maxTouchPoints === 0) button.hidden = true; // A real pen showed up (an Apple Pencil, a stylus): the visitor writes with // it, and fingers go back to scrolling, as in FineNotes, unless they chose. window.addEventListener( "pointerdown", (e) => { if (e.isTrusted && e.pointerType === "pen" && enabled && saved === null) set(false); }, true, ); button.addEventListener("click", () => { saved = enabled ? "0" : "1"; set(!enabled); try { localStorage.setItem(STORE, enabled ? "1" : "0"); } catch { // Not kept. } }); }