A Shapes button right of Text draws a rectangle, an ellipse, a line of taps or an arrow in the pen's colour and width, with a fill colour of its own; a hold, a secondary press or two quick clicks open its menu. Text and Hide rectangles draw nothing on the page and show blue corners while the pointer hovers over them; free text's red corner mark is gone. Arrange moves and resizes shapes. The colour palette floats below "+" again instead of reflowing the toolbar. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
407 lines
17 KiB
JavaScript
407 lines
17 KiB
JavaScript
// @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.
|
|
*/
|
|
export 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');
|
|
|
|
/**
|
|
* The tools of the toolbar that act on the page.
|
|
* @typedef {'pen' | 'eraser' | 'text' | 'hide' | 'arrange' | 'shape'} Tool
|
|
*/
|
|
|
|
/**
|
|
* @typedef {object} PenTarget
|
|
* @property {(clientX: number, clientY: number) => [number, number]} toPage
|
|
* Convert a window position to page units.
|
|
* @property {() => Tool} 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, Hide or Shape tool, the pointer went down at (x0, y0)
|
|
* and up at (x1, y1): a tap, or a rectangle (a shape) dragged open.
|
|
* @property {(x0: number, y0: number, x1: number, y1: number) => void} [previewRect]
|
|
* With the Text, Hide or Shape tool, the pointer is down at (x0, y0) and now at (x1, y1).
|
|
* @property {(clientX: number, clientY: number) => void} [hover]
|
|
* The pointer moves over the page with no button down and nothing in
|
|
* progress (a mouse, or a pen in the air above a tablet that reports
|
|
* it; a finger never hovers).
|
|
* @property {() => void} [hoverEnded]
|
|
* The pointer left the page, or pressed.
|
|
* @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, text block or Hide under it (Andy, 2026-10-08; 600 ms until
|
|
* 2026-10-13, when the hold was made easier to find: shorter, and with
|
|
* more slack for a pen held in the air). Longer than a tap, shorter than
|
|
* a pause in writing (the start of a stroke is not one). Moving farther
|
|
* than HOLD_SLACK_PX before that is a stroke, or erasing, as before.
|
|
*/
|
|
export const HOLD_MS = 450;
|
|
export const HOLD_SLACK_PX = 10;
|
|
|
|
/**
|
|
* 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<typeof setTimeout> | null} */
|
|
let holdTimer = null;
|
|
let holding = false;
|
|
/** With the Arrange tool: the press took hold at once, or found nothing and does nothing. */
|
|
let arranging = 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();
|
|
target.hoverEnded?.();
|
|
erasing = tool === 'eraser' || (event.buttons & ERASER_BUTTON) !== 0;
|
|
// The Text, Hide and Shape tools place by a tap or a drag, reported
|
|
// whole when the pointer lifts, with a preview meanwhile.
|
|
placingText = (tool === 'text' || tool === 'hide' || tool === 'shape') && !erasing;
|
|
lastX = NaN;
|
|
lastY = NaN;
|
|
strokeStart = performance.now();
|
|
strokePoints = 0;
|
|
erasedStrokes = 0;
|
|
erasedTexts = 0;
|
|
holding = false;
|
|
eraseWaiting = null;
|
|
// The Arrange tool holds at once: a press on an image, a text block
|
|
// or a Hide moves it, on a corner resizes it, and a press elsewhere
|
|
// does nothing (Andy, 2026-10-13).
|
|
if (tool === 'arrange' && !erasing) {
|
|
arranging = true;
|
|
holding = target.beginHold?.(event.clientX, event.clientY) ?? false;
|
|
trace(holding ? 'arrange: took hold' : 'arrange: nothing under the pointer');
|
|
return;
|
|
}
|
|
// 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) {
|
|
// A pointer in the air: hovering, if nothing is pressed.
|
|
if (activePointer === null && event.buttons === 0) target.hover?.(event.clientX, event.clientY);
|
|
return;
|
|
}
|
|
if (holding) {
|
|
target.holdMove?.(event.clientX, event.clientY);
|
|
return;
|
|
}
|
|
if (arranging) 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;
|
|
arranging = false;
|
|
target.holdEnd?.();
|
|
} else if (arranging) {
|
|
arranging = false;
|
|
} 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('pointerleave', () => { if (activePointer === null) target.hoverEnded?.(); });
|
|
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 });
|
|
}
|