Kokkoja as of 2026-10-10: the teacher's requests, the Session window, notices, the QR code, the license
This commit is contained in:
1 parent
95c80d04c9
commit
1d087af633
32 files changed
+4917
-890
No files matched your search
+174
-21
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
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;
|
||||
@@ -19,23 +20,53 @@ 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 {() => '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) => void} eraseAlong
|
||||
* @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.
|
||||
* @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 {(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
|
||||
@@ -44,6 +75,13 @@ const NOMINAL_PRESSURE = 0.4;
|
||||
* @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
|
||||
@@ -53,6 +91,28 @@ export function attachPenInput(surface, target) {
|
||||
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;
|
||||
let pressX = 0;
|
||||
let pressY = 0;
|
||||
/** @type {PointerEvent | null} The eraser's press, not yet erased with. */
|
||||
let eraseWaiting = null;
|
||||
|
||||
/** 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;
|
||||
@@ -63,6 +123,9 @@ export function attachPenInput(surface, target) {
|
||||
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
|
||||
@@ -103,35 +166,77 @@ export function attachPenInput(surface, target) {
|
||||
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);
|
||||
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)}`;
|
||||
return `${event.pointerType} ${event.pointerId} buttons ${event.buttons} pressure ${event.pressure.toFixed(2)}${where}`;
|
||||
}
|
||||
|
||||
surface.addEventListener('pointerdown', (event) => {
|
||||
if (activePointer !== null || !target.canWrite() || event.target !== surface) return;
|
||||
/** @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;
|
||||
// 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;
|
||||
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' && !erasing;
|
||||
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;
|
||||
erase(event);
|
||||
if (target.beginHold) eraseWaiting = event;
|
||||
else erase(event);
|
||||
} else {
|
||||
target.beginStroke();
|
||||
draw(event);
|
||||
@@ -140,23 +245,53 @@ export function attachPenInput(surface, target) {
|
||||
|
||||
surface.addEventListener('pointermove', (event) => {
|
||||
if (event.pointerId !== activePointer) return;
|
||||
if (placingText) {
|
||||
const [x] = target.toPage(event.clientX, event.clientY);
|
||||
target.previewText?.(textStartX, textStartY, x);
|
||||
if (holding) {
|
||||
target.holdMove?.(event.clientX, event.clientY);
|
||||
return;
|
||||
}
|
||||
if (erasing) erase(event);
|
||||
else draw(event);
|
||||
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) return;
|
||||
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;
|
||||
if (placingText) {
|
||||
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.placeText(textStartX, textStartY, x, y);
|
||||
if (event.type === 'pointerup') target.placeRect(textStartX, textStartY, x, y);
|
||||
} else if (erasing) {
|
||||
eraseFromPress();
|
||||
if (event.type === 'pointerup') target.eraseEnded?.(eraseStartX, eraseStartY, eraseReach);
|
||||
} else {
|
||||
target.endStroke();
|
||||
@@ -164,7 +299,25 @@ export function attachPenInput(surface, target) {
|
||||
}
|
||||
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());
|
||||
|
||||
// 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 });
|
||||
}
|
||||
Reference in new issue
Block a user