// @ts-check /** * Turns pointer events (pen, mouse or finger) on the page into strokes and * erasures. */ import { rounded } from './page_model.js'; import { trace } from './log.js'; /** Bit in PointerEvent.buttons for the eraser end of a pen. */ const ERASER_BUTTON = 32; /** * Pressure recorded for a mouse or a finger, which report none. It is the * pressure at which a stroke is drawn at its nominal width. */ const NOMINAL_PRESSURE = 0.4; /** * A press this soon after the window gained focus is taken to be the * click that brought the window to the front, and draws nothing (Andy, * 2026-10-11): clicking the page to return from the terminal, with the * Pen chosen, left a stray dot. Measured on Hollis (Firefox, Linux): * the window's focus event fires about 4 ms before such a press, the * document already reporting focus, so neither document.hasFocus() nor * the pointer's movement (a pen hovers either way) can tell it from an * ordinary press; only the time since the focus can. A quarter second * is far longer than that press and far shorter than any deliberate * stroke made as a separate act after focusing the window. */ export const FOCUS_SETTLE_MS = 250; /** * The end-to-end tests turn the focusing-click rule off with * `?no_focus_settle` in the address: a browser driven by WebDriver gains * window focus on the first pointer action after every load or tab * switch, so it cannot tell such a switch from a click that raised the * window, and the rule would refuse legitimate test strokes. The rule * itself is tested with a dispatched focus event in one tab. A real * user never sets this, and it disables nothing but this nicety. */ const focusSettleOn = !new URLSearchParams(location.search).has('no_focus_settle'); /** * @typedef {object} PenTarget * @property {(clientX: number, clientY: number) => [number, number]} toPage * Convert a window position to page units. * @property {() => 'pen' | 'eraser' | 'text' | 'hide'} tool The tool selected in the toolbar. * @property {() => boolean} canWrite False while there is no page to write on. * @property {() => void} beginStroke * @property {(points: number[]) => void} addPoints * @property {() => void} endStroke * @property {(x0: number, y0: number, x1: number, y1: number) => {strokes: number, texts: number}} eraseAlong * Erases one's own strokes and text blocks along the movement and says how many. * @property {(x: number, y: number, reach: number) => void} [eraseEnded] * The eraser went down at (x, y) and has come up again, having moved * no farther than `reach` from there; Infinity if the press was cancelled. * @property {(x0: number, y0: number, x1: number, y1: number) => void} placeRect * With the text or Hide tool, the pointer went down at (x0, y0) and up * at (x1, y1): a tap, or a rectangle dragged open. * @property {(x0: number, y0: number, x1: number, y1: number) => void} [previewRect] * With the text or Hide tool, the pointer is down at (x0, y0) and now at (x1, y1). * @property {(clientX: number, clientY: number) => boolean} [beginHold] * The pointer has rested on the page for HOLD_MS, with any tool: a * hold. True if something under it took the hold (an image to move * or scale); the stroke or text placement begun is then abandoned * here, and the pointer's movement goes to holdMove. With the * eraser, erasing waits until the pointer moves or is lifted, so that * a hold erases nothing (Andy, 2026-10-08: every tool can move). * @property {(clientX: number, clientY: number) => void} [holdMove] * @property {() => void} [holdEnd] * @property {(clientX: number, clientY: number) => void} [beginZoom] * The pointer went down with Ctrl (or Command) held: a zoom drag. * @property {(pixelsUp: number) => void} [zoomTo] * The zoom drag has moved this far up the screen (negative: down). * @property {() => void} [endZoom] * @property {(report: PenReport) => void} [onReport] */ /** * A zoom drag shorter than this, in screen pixels, zooms nothing, so that * a tap with Ctrl held is not a zoom. */ const ZOOM_DEAD_ZONE = 4; /** * A press that rests this long becomes a hold, which moves or scales the * image under it (Andy's teacher, 2026-10-08; 600 ms so that a careful * start of a stroke is not one). Moving farther than HOLD_SLACK_PX * before then makes it a stroke after all. */ export const HOLD_MS = 600; export const HOLD_SLACK_PX = 6; /** * What the input device is delivering, for judging pen support. * @typedef {object} PenReport * @property {string} pointerType 'pen', 'mouse' or 'touch'. * @property {number} pressure Latest pressure. * @property {number} pointsPerSecond */ /** @param {EventTarget | null} target @returns {string} The element, for the trace. */ function describeTarget(target) { if (!(target instanceof Element)) return 'nothing'; const name = target.tagName.toLowerCase(); return target.id ? `${name}#${target.id}` : (target.className ? `${name}.${target.className}` : name); } /** * @param {HTMLElement} surface The element that receives the pointer. * @param {PenTarget} target */ export function attachPenInput(surface, target) { /** @type {number | null} */ let activePointer = null; let erasing = false; let placingText = false; let zooming = false; let zoomStartY = 0; /** @type {ReturnType | null} */ let holdTimer = null; let holding = false; let pressX = 0; let pressY = 0; /** @type {PointerEvent | null} The eraser's press, not yet erased with. */ let eraseWaiting = null; /** When the window last gained focus, for the focusing-click rule. */ let lastFocusAt = -Infinity; /** The eraser's press erases now, if it was waiting. */ function eraseFromPress() { if (!eraseWaiting) return; const press = eraseWaiting; eraseWaiting = null; erase(press); } function cancelHoldTimer() { if (holdTimer !== null) clearTimeout(holdTimer); holdTimer = null; } let textStartX = 0; let textStartY = 0; let eraseStartX = 0; let eraseStartY = 0; let eraseReach = 0; let penSeen = false; let lastX = NaN; let lastY = NaN; let strokeStart = 0; let strokePoints = 0; /** What the eraser removed since its press, for the trace. */ let erasedStrokes = 0; let erasedTexts = 0; /** * @param {PointerEvent} event * @returns {PointerEvent[]} Every position the pointer reported since * the previous event, not only the latest. */ function allEvents(event) { const coalesced = event.getCoalescedEvents ? event.getCoalescedEvents() : []; return coalesced.length ? coalesced : [event]; } /** @param {PointerEvent} event */ function draw(event) { /** @type {number[]} */ const points = []; let pressure = 0; for (const e of allEvents(event)) { const [x, y] = target.toPage(e.clientX, e.clientY); const px = rounded(x, 10); const py = rounded(y, 10); if (px === lastX && py === lastY) continue; pressure = e.pointerType === 'pen' ? rounded(e.pressure, 1000) : NOMINAL_PRESSURE; points.push(px, py, pressure); lastX = px; lastY = py; } if (points.length === 0) return; target.addPoints(points); strokePoints += points.length / 3; const seconds = (performance.now() - strokeStart) / 1000; target.onReport?.({ pointerType: event.pointerType, pressure, pointsPerSecond: seconds > 0.2 ? Math.round(strokePoints / seconds) : 0, }); } /** @param {PointerEvent} event */ function erase(event) { for (const e of allEvents(event)) { const [x, y] = target.toPage(e.clientX, e.clientY); const removed = target.eraseAlong(Number.isNaN(lastX) ? x : lastX, Number.isNaN(lastY) ? y : lastY, x, y); erasedStrokes += removed.strokes; erasedTexts += removed.texts; eraseReach = Math.max(eraseReach, Math.hypot(x - eraseStartX, y - eraseStartY)); lastX = x; lastY = y; } } /** @param {PointerEvent} event @returns {string} The press, for the trace. */ function describe(event) { const where = event.target === surface ? '' : ` on ${describeTarget(event.target)}`; const focus = document.hasFocus() ? 'in front' : 'behind'; return `${event.pointerType} ${event.pointerId} buttons ${event.buttons} pressure ${event.pressure.toFixed(2)} (${focus})${where}`; } surface.addEventListener('pointerdown', (event) => { /** @param {string} why */ const refuse = (why) => trace(`press ${describe(event)} refused: ${why}`); if (activePointer !== null) return refuse(`pointer ${activePointer} still active`); if (event.target !== surface) return refuse('not on the page'); if (event.pointerType === 'pen') penSeen = true; // A press that only brought the window to the front draws nothing. if (focusSettleOn && performance.now() - lastFocusAt < FOCUS_SETTLE_MS) { return refuse('it brought the window to the front'); } // Once a pen has been used, a touch is taken to be the resting hand. if (event.pointerType === 'touch' && penSeen) return refuse('a touch after a pen: the resting hand'); if (event.pointerType === 'mouse' && event.button !== 0) return refuse('not the main button'); // With Ctrl (Command on a Mac) held, a drag up or down zooms and // draws nothing (Andy, 2026-10-08). zooming = (event.ctrlKey || event.metaKey) && target.beginZoom !== undefined; if (!zooming && !target.canWrite()) return refuse('cannot write now'); event.preventDefault(); surface.setPointerCapture(event.pointerId); activePointer = event.pointerId; trace(`press ${describe(event)}, ${target.tool()}${zooming ? ', zooming' : ''}`); if (zooming) { zoomStartY = event.clientY; target.beginZoom?.(event.clientX, event.clientY); return; } const tool = target.tool(); erasing = tool === 'eraser' || (event.buttons & ERASER_BUTTON) !== 0; placingText = (tool === 'text' || tool === 'hide') && !erasing; lastX = NaN; lastY = NaN; strokeStart = performance.now(); strokePoints = 0; erasedStrokes = 0; erasedTexts = 0; holding = false; eraseWaiting = null; // Resting the pointer, with any tool, may become a hold on an image. if (target.beginHold) { pressX = event.clientX; pressY = event.clientY; holdTimer = setTimeout(() => { holdTimer = null; if (activePointer === null) return; if (target.beginHold?.(pressX, pressY)) { holding = true; placingText = false; eraseWaiting = null; } else { eraseFromPress(); } }, HOLD_MS); } if (placingText) { [textStartX, textStartY] = target.toPage(event.clientX, event.clientY); } else if (erasing) { [eraseStartX, eraseStartY] = target.toPage(event.clientX, event.clientY); eraseReach = 0; if (target.beginHold) eraseWaiting = event; else erase(event); } else { target.beginStroke(); draw(event); } }); surface.addEventListener('pointermove', (event) => { if (event.pointerId !== activePointer) return; if (holding) { target.holdMove?.(event.clientX, event.clientY); return; } if (holdTimer !== null && Math.hypot(event.clientX - pressX, event.clientY - pressY) > HOLD_SLACK_PX) { cancelHoldTimer(); eraseFromPress(); } if (zooming) { const up = zoomStartY - event.clientY; const beyond = Math.max(0, Math.abs(up) - ZOOM_DEAD_ZONE); target.zoomTo?.(Math.sign(up) * beyond); return; } if (placingText) { const [x, y] = target.toPage(event.clientX, event.clientY); target.previewRect?.(textStartX, textStartY, x, y); return; } if (erasing) { if (!eraseWaiting) erase(event); } else { draw(event); } }); /** @param {PointerEvent} event */ function finish(event) { if (event.pointerId !== activePointer) { trace(`${event.type} ${event.pointerType} ${event.pointerId}, not the active pointer`); return; } trace(`${event.type} ${event.pointerType} ${event.pointerId}, ` + (erasing ? `erased ${erasedStrokes} strokes and ${erasedTexts} texts` : `${strokePoints} points`)); activePointer = null; cancelHoldTimer(); if (holding) { holding = false; target.holdEnd?.(); } else if (zooming) { zooming = false; target.endZoom?.(); } else if (placingText) { const [x, y] = target.toPage(event.clientX, event.clientY); if (event.type === 'pointerup') target.placeRect(textStartX, textStartY, x, y); } else if (erasing) { eraseFromPress(); // A cancelled press ends the gesture too, with no tap in it. target.eraseEnded?.(eraseStartX, eraseStartY, event.type === 'pointerup' ? eraseReach : Infinity); } else { target.endStroke(); } } surface.addEventListener('pointerup', finish); surface.addEventListener('pointercancel', finish); surface.addEventListener('lostpointercapture', (event) => { if (event.pointerId === activePointer) trace(`capture of pointer ${event.pointerId} lost while active`); }); for (const type of ['touchstart', 'touchend', 'touchcancel']) { surface.addEventListener(type, (event) => { const touches = [...(/** @type {TouchEvent} */ (event).changedTouches)] .map((touch) => /** @type {{touchType?: string}} */ (touch).touchType ?? 'touch'); trace(`${type} ${touches.join(' ')}${event.target === surface ? '' : ` on ${describeTarget(event.target)}`}`); }); } // The side button of a pen asks for a context menu. surface.addEventListener('contextmenu', (event) => event.preventDefault()); // The window's focus, blur and the tab's visibility, traced into the // Session log but acting on nothing yet (2026-10-11): Andy's pages // sometimes gained a lone dot when he clicked the page to bring the // browser to the front from the terminal, with the Pen chosen. A // press that only raised the window should draw nothing, but whether // the browser reports the focus before that press or after it, and // whether the document is focused when the press arrives, must be // learned on his machine before the rule is written (a window focus // is faked by the test harness on every tab switch, so it cannot be // learned here). The press trace already carries "in front"/"behind". window.addEventListener('focus', () => { lastFocusAt = performance.now(); trace('window: focus'); }); window.addEventListener('blur', () => trace('window: blur')); document.addEventListener('visibilitychange', () => trace(`visibility: ${document.visibilityState}`)); // Safari on iOS: cancelling pointerdown leaves the touch's own // behaviour (selection, the magnifier, the callout) to WebKit; only a // cancelled touchstart stops it. The text field is a child of the // surface and keeps its touches, or it could not be tapped into. surface.addEventListener('touchstart', (event) => { if (event.target === surface) event.preventDefault(); }, { passive: false }); }