// @ts-check /** * Turns pointer events (pen, mouse or finger) on the page into strokes and * erasures. */ import { rounded } from './page_model.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; /** * @typedef {object} PenTarget * @property {(clientX: number, clientY: number) => [number, number]} toPage * Convert a window position to page units. * @property {() => 'pen' | 'eraser' | 'text'} 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) => void} eraseAlong * @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. * @property {(x0: number, y0: number, x1: number, y1: number) => void} placeText * With the text tool, the pointer went down at (x0, y0) and up at (x1, y1). * @property {(x0: number, y0: number, x1: number) => void} [previewText] * With the text tool, the pointer is down at (x0, y0) and now at x1. * @property {(report: PenReport) => void} [onReport] */ /** * 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 {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 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; /** * @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); target.eraseAlong(Number.isNaN(lastX) ? x : lastX, Number.isNaN(lastY) ? y : lastY, x, y); eraseReach = Math.max(eraseReach, Math.hypot(x - eraseStartX, y - eraseStartY)); lastX = x; lastY = y; } } surface.addEventListener('pointerdown', (event) => { if (activePointer !== null || !target.canWrite() || event.target !== surface) return; if (event.pointerType === 'pen') penSeen = true; // Once a pen has been used, a touch is taken to be the resting hand. if (event.pointerType === 'touch' && penSeen) return; if (event.pointerType === 'mouse' && event.button !== 0) return; event.preventDefault(); surface.setPointerCapture(event.pointerId); activePointer = event.pointerId; const tool = target.tool(); erasing = tool === 'eraser' || (event.buttons & ERASER_BUTTON) !== 0; placingText = tool === 'text' && !erasing; lastX = NaN; lastY = NaN; strokeStart = performance.now(); strokePoints = 0; if (placingText) { [textStartX, textStartY] = target.toPage(event.clientX, event.clientY); } else if (erasing) { [eraseStartX, eraseStartY] = target.toPage(event.clientX, event.clientY); eraseReach = 0; erase(event); } else { target.beginStroke(); draw(event); } }); surface.addEventListener('pointermove', (event) => { if (event.pointerId !== activePointer) return; if (placingText) { const [x] = target.toPage(event.clientX, event.clientY); target.previewText?.(textStartX, textStartY, x); return; } if (erasing) erase(event); else draw(event); }); /** @param {PointerEvent} event */ function finish(event) { if (event.pointerId !== activePointer) return; activePointer = null; if (placingText) { const [x, y] = target.toPage(event.clientX, event.clientY); if (event.type === 'pointerup') target.placeText(textStartX, textStartY, x, y); } else if (erasing) { if (event.type === 'pointerup') target.eraseEnded?.(eraseStartX, eraseStartY, eraseReach); } else { target.endStroke(); } } surface.addEventListener('pointerup', finish); surface.addEventListener('pointercancel', finish); // The side button of a pen asks for a context menu. surface.addEventListener('contextmenu', (event) => event.preventDefault()); }