Arranging: boxes on everything, handwriting movable, the Eraser's preview, a Circle shape, pixel-exact cues

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
ackandClaude Fable 5.1 committed 2026-10-14 20:38:29 +02:00
1 parent 03907da8c9
commit 24a6a2595b
10 files changed
+690 -126

No files matched your search

+2
View File
@@ -51,6 +51,8 @@
<div id="shape_menu" class="popup menu" role="menu" hidden> <div id="shape_menu" class="popup menu" role="menu" hidden>
<button type="button" class="menu_item" role="menuitem" data-shape="rectangle" <button type="button" class="menu_item" role="menuitem" data-shape="rectangle"
data-help="Rectangle: drag open the rectangle from one corner to the opposite one.">Rectangle</button> data-help="Rectangle: drag open the rectangle from one corner to the opposite one.">Rectangle</button>
<button type="button" class="menu_item" role="menuitem" data-shape="circle"
data-help="Circle: press at the centre and drag out to the edge, in any direction.">Circle</button>
<button type="button" class="menu_item" role="menuitem" data-shape="ellipse" <button type="button" class="menu_item" role="menuitem" data-shape="ellipse"
data-help="Ellipse: drag open the rectangle the ellipse fills.">Ellipse</button> data-help="Ellipse: drag open the rectangle the ellipse fills.">Ellipse</button>
<button type="button" class="menu_item" role="menuitem" data-shape="line" <button type="button" class="menu_item" role="menuitem" data-shape="line"
+186 -23
View File
@@ -24,9 +24,10 @@ import {
import { HOLD_MS, NOMINAL_PRESSURE, attachPenInput } from './pen_input.js'; import { HOLD_MS, NOMINAL_PRESSURE, attachPenInput } from './pen_input.js';
import { Renderer } from './renderer.js'; import { Renderer } from './renderer.js';
import { LEAST_IMAGE_SIDE, textBounds, rectAt } from './page_model.js'; import { LEAST_IMAGE_SIDE, textBounds, rectAt } from './page_model.js';
import { inkExtent } from './stroke_geometry.js';
import { import {
SHAPE_KEY, SHAPE_NAMES, draggedPoints, isFilled, isTapped, lineInProgressPoints, movedPoints, shapeNamed, SHAPE_KEY, SHAPE_NAMES, draggedPoints, isFilled, isTapped, lineInProgressPoints, movedPoints,
straightPoints, vertexPoints, shapeNamed, straightPoints, vertexPoints,
} from './shapes.js'; } from './shapes.js';
import { LINE_HEIGHT, TOP_GAP, layoutText } from './text_layout.js'; import { LINE_HEIGHT, TOP_GAP, layoutText } from './text_layout.js';
import { log, logText, trace, watchLog } from './log.js'; import { log, logText, trace, watchLog } from './log.js';
@@ -818,16 +819,38 @@ async function enterSession(invitationText, name, notebook) {
return block ? textBounds(block) : null; return block ? textBounds(block) : null;
} }
/**
* The grip around the held thing: for a stroke, around its ink rather
* than its points (Andy, 2026-10-14).
* @param {Held} h
*/
function showHeldGrip(h) {
if (h.kind === 'shape') {
const stroke = session.ownLayer.strokes.get(h.id);
renderer.showGrip(stroke ? inkExtent(stroke) : null, h.corner, true);
} else {
renderer.showGrip(currentHeldRect(h), h.corner, heldHasCorners(h));
}
}
/**
* @param {Held} h
* @returns {boolean} Whether the held thing has corners to resize by:
* everything but free text.
*/
const heldHasCorners = (h) => h.kind !== 'text' || session.ownLayer.texts.get(h.id)?.height !== null;
/** @returns {import('./page_model.js').Stroke[]} Our shapes, in drawing order. */ /** @returns {import('./page_model.js').Stroke[]} Our shapes, in drawing order. */
const ownShapes = () => [...session.ownLayer.strokes.values()].filter((stroke) => stroke.shape !== null && stroke.done); const ownShapes = () => [...session.ownLayer.strokes.values()].filter((stroke) => stroke.shape !== null && stroke.done);
/** /**
* With the Arrange tool chosen, mark what a press can move or resize * With the Arrange tool chosen, mark everything a press can move or
* and is not marked already: the images (for the host and co-hosts), * resize with a blue box and corners (Andy, 2026-10-14): the images
* whose edges may not show, and one's own shapes (Andy, 2026-10-14). * and Hides for the host and co-hosts, one's own text blocks and
* Text blocks and Hides, the implied rectangles, show their corners * shapes; free text, which has no width to resize, gets the box without
* under a hovering pointer with any tool, so the tool adds nothing on * the corners. The one being held is left out, the green grip standing
* them. Called whenever the images, the shapes, or the right may have * in its place until the pointer lifts. Called whenever the images,
* the Hides, one's own text or shapes, the right, or the hold may have
* changed. * changed.
*/ */
function refreshArrangeCues() { function refreshArrangeCues() {
@@ -835,18 +858,85 @@ async function enterSession(invitationText, name, notebook) {
renderer.setArrangeCues([]); renderer.setArrangeCues([]);
return; return;
} }
/** @type {{rect: import('./page_model.js').Rectangle, kind: 'image' | 'text' | 'hide' | 'shape'}[]} */ /** @type {{rect: import('./page_model.js').Rectangle, kind: 'image' | 'text' | 'hide' | 'shape' | 'stroke', corners: boolean}[]} */
const cues = []; const cues = [];
const page = session.page;
if (session.mayMakePages()) { if (session.mayMakePages()) {
for (const im of session.page.images) cues.push({ kind: 'image', rect: { left: im.x, top: im.y, width: im.width, height: im.height } }); page.images.forEach((im, index) => {
if (held?.kind === 'image' && held.index === index) return;
cues.push({ kind: 'image', rect: { left: im.x, top: im.y, width: im.width, height: im.height }, corners: true });
});
page.hides.forEach((h, index) => {
if (held?.kind === 'hide' && held.index === index) return;
cues.push({ kind: 'hide', rect: { left: h.x, top: h.y, width: h.width, height: h.height }, corners: true });
});
}
for (const block of session.ownLayer.texts.values()) {
if (held?.kind === 'text' && held.id === block.id) continue;
cues.push({ kind: 'text', rect: textBounds(block), corners: block.height !== null });
} }
for (const stroke of ownShapes()) { for (const stroke of ownShapes()) {
const rect = strokeBox(stroke); if (held?.kind === 'shape' && held.id === stroke.id) continue;
if (rect) cues.push({ kind: 'shape', rect }); const rect = inkExtent(stroke);
if (rect) cues.push({ kind: 'shape', rect, corners: true });
}
// The handwritten stroke under the hovering pointer, if any.
const hovered = hoveredStroke && session.ownLayer.strokes.get(hoveredStroke);
if (hovered && !(held?.kind === 'shape' && held.id === hovered.id)) {
const rect = inkExtent(hovered);
if (rect) cues.push({ kind: 'stroke', rect, corners: true });
} }
renderer.setArrangeCues(cues); renderer.setArrangeCues(cues);
} }
/**
* Handwriting under Arrange (Andy, 2026-10-14): a page may hold hundreds
* of strokes, so they are not all boxed at once; the one whose ink the
* pointer hovers over gets the box, kept while the pointer stays within
* it (so that its corners can be reached), and a press takes hold of a
* stroke by its ink, or by the shown box's corner, under Arrange only:
* the hold with another tool leaves handwriting alone, since resting
* the pen on one's own writing is part of writing.
* @type {string | null} The id of the stroke whose box is shown.
*/
let hoveredStroke = null;
/**
* The frontmost handwritten stroke of ours (not a shape, which has its
* cue always) within the eraser's reach of the point, by its ink.
* @param {number} x
* @param {number} y
* @returns {import('./page_model.js').Stroke | null}
*/
function handwritingAt(x, y) {
const layer = session.ownLayer;
const ids = strokesTouched(layer, x, y, x, y, renderer.pageLength(ERASER_RADIUS));
for (let i = ids.length - 1; i >= 0; i--) {
const stroke = layer.strokes.get(ids[i]);
if (stroke && stroke.done && stroke.shape === null) return stroke;
}
return null;
}
/**
* Under Arrange, the pointer hovering at this point: the stroke under
* it gets the box, or the shown box stays while the pointer is within
* it or near its corners.
* @param {number} x
* @param {number} y
*/
function hoverHandwriting(x, y) {
const shown = hoveredStroke && session.ownLayer.strokes.get(hoveredStroke);
const box = shown ? inkExtent(shown) : null;
const reach = renderer.cornerReach();
const within = box && x >= box.left - reach && x <= box.left + box.width + reach && y >= box.top - reach && y <= box.top + box.height + reach;
const under = handwritingAt(x, y);
const next = under ? under.id : within ? hoveredStroke : null;
if (next === hoveredStroke) return;
hoveredStroke = next;
refreshArrangeCues();
}
// The Shape tool (Andy, 2026-10-14; shapes.js): a rectangle, an ellipse // The Shape tool (Andy, 2026-10-14; shapes.js): a rectangle, an ellipse
// or an arrow is dragged open and becomes one stroke when the pointer // or an arrow is dragged open and becomes one stroke when the pointer
// lifts, a preview showing meanwhile; a line is made of taps, one // lifts, a preview showing meanwhile; a line is made of taps, one
@@ -970,29 +1060,68 @@ async function enterSession(invitationText, name, notebook) {
* The implied rectangle under a point, for the hover cue (Andy, * The implied rectangle under a point, for the hover cue (Andy,
* 2026-10-14: text rectangles and Hides show their corners only then, * 2026-10-14: text rectangles and Hides show their corners only then,
* like free text): the frontmost Hide, else the frontmost text block * like free text): the frontmost Hide, else the frontmost text block
* among the layers shown. * among the layers shown, with its kind, which decides the cue's form.
* @param {number} x * @param {number} x
* @param {number} y * @param {number} y
* @returns {import('./page_model.js').Rectangle | null} * @returns {{rect: import('./page_model.js').Rectangle, kind: 'hide' | 'rect' | 'free'} | null}
*/ */
function impliedRectAt(x, y) { function impliedRectAt(x, y) {
/** @param {import('./page_model.js').Rectangle} b */ /** @param {import('./page_model.js').Rectangle} b */
const holds = (b) => x >= b.left && x <= b.left + b.width && y >= b.top && y <= b.top + b.height; const holds = (b) => x >= b.left && x <= b.left + b.width && y >= b.top && y <= b.top + b.height;
for (const h of [...session.page.hides].reverse()) { for (const h of [...session.page.hides].reverse()) {
const b = { left: h.x, top: h.y, width: h.width, height: h.height }; const b = { left: h.x, top: h.y, width: h.width, height: h.height };
if (holds(b)) return b; if (holds(b)) return { rect: b, kind: 'hide' };
} }
for (const id of session.layerOrder().reverse()) { for (const id of session.layerOrder().reverse()) {
const layer = session.page.layers.get(id); const layer = session.page.layers.get(id);
if (!layer || renderer.hidden.has(id)) continue; if (!layer || renderer.hidden.has(id)) continue;
for (const block of [...layer.texts.values()].reverse()) { for (const block of [...layer.texts.values()].reverse()) {
const b = textBounds(block); const b = textBounds(block);
if (holds(b)) return b; if (holds(b)) return { rect: b, kind: block.height === null ? 'free' : 'rect' };
} }
} }
return null; return null;
} }
/**
* What a press of the eraser at this point would remove, for the cue
* shown while the pointer hovers (Andy, 2026-10-14): the rectangles
* around the strokes and text blocks of ours within the eraser's reach,
* whole, as the eraser takes them; or, where there is none, the Hide or
* image a tap would remove, by the same rules as eraseEnded.
* @param {number} x
* @param {number} y
* @returns {import('./page_model.js').Rectangle[]}
*/
function eraseTargetsAt(x, y) {
const layer = session.ownLayer;
const radius = renderer.pageLength(ERASER_RADIUS);
/** @type {import('./page_model.js').Rectangle[]} */
const rects = [];
for (const id of strokesTouched(layer, x, y, x, y, radius)) {
const stroke = layer.strokes.get(id);
const rect = stroke && inkExtent(stroke);
if (rect) rects.push(rect);
}
for (const id of textsTouched(layer, x, y, x, y, radius)) {
const block = layer.texts.get(id);
if (block) rects.push(textBounds(block));
}
if (rects.length) return rects;
const page = session.page;
const hide = page.hideAt(x, y, 0);
if (hide && session.mayMakePages()) {
const h = page.hides[hide.index];
return [{ left: h.x, top: h.y, width: h.width, height: h.height }];
}
const found = page.imageAt(x, y, 0);
const image = found && page.images[found.index];
if (image && (session.mayMakePages() || image.by === session.identity.id)) {
return [{ left: image.x, top: image.y, width: image.width, height: image.height }];
}
return [];
}
/** @type {import('./pen_input.js').PenTarget} */ /** @type {import('./pen_input.js').PenTarget} */
const penTarget = { const penTarget = {
toPage: (clientX, clientY) => renderer.pageFromClient(clientX, clientY), toPage: (clientX, clientY) => renderer.pageFromClient(clientX, clientY),
@@ -1030,14 +1159,31 @@ async function enterSession(invitationText, name, notebook) {
} else { } else {
const blocks = [...session.ownLayer.texts.values()]; const blocks = [...session.ownLayer.texts.values()];
const at = rectAt(blocks.map(textBounds), x, y, reach); const at = rectAt(blocks.map(textBounds), x, y, reach);
const shapes = at ? [] : ownShapes(); // Handwriting, under Arrange only: the shown box's corner, or a
const shapeAt = at ? null : rectAt(shapes.map((s) => strokeBox(s) ?? { left: 0, top: 0, width: 0, height: 0 }), x, y, reach); // stroke by its ink.
/** @type {{stroke: import('./page_model.js').Stroke, corner: [number, number] | null} | null} */
let writing = null;
if (!at && tool === 'arrange') {
const shown = hoveredStroke && session.ownLayer.strokes.get(hoveredStroke);
const shownBox = shown ? inkExtent(shown) : null;
const cornerAt = shownBox ? rectAt([shownBox], x, y, reach) : null;
if (shown && cornerAt?.corner) writing = { stroke: shown, corner: cornerAt.corner };
else {
const under = handwritingAt(x, y);
if (under) writing = { stroke: under, corner: null };
}
}
const shapes = at || writing ? [] : ownShapes();
const shapeAt = at || writing ? null : rectAt(shapes.map((s) => strokeBox(s) ?? { left: 0, top: 0, width: 0, height: 0 }), x, y, reach);
if (at) { if (at) {
const block = blocks[at.index]; const block = blocks[at.index];
// Free text has no corners to resize by. // Free text has no corners to resize by.
const corner = block.height === null ? null : at.corner; const corner = block.height === null ? null : at.corner;
found = hold('text', at.index, block.id, textBounds(block), corner, x, y); found = hold('text', at.index, block.id, textBounds(block), corner, x, y);
session.beginTextEdit(block.id, 'hold'); session.beginTextEdit(block.id, 'hold');
} else if (writing) {
const box = /** @type {import('./page_model.js').Rectangle} */ (strokeBox(writing.stroke));
if (session.beginShapeEdit(writing.stroke.id)) found = hold('shape', 0, writing.stroke.id, box, writing.corner, x, y);
} else if (shapeAt) { } else if (shapeAt) {
// A shape of ours (Andy, 2026-10-14): moved, or resized from a // A shape of ours (Andy, 2026-10-14): moved, or resized from a
// corner, by the rectangle around its points. // corner, by the rectangle around its points.
@@ -1062,7 +1208,8 @@ async function enterSession(invitationText, name, notebook) {
} }
textEditor?.hidePreview(); textEditor?.hidePreview();
held = found; held = found;
renderer.showGrip(currentHeldRect(found), found.corner); showHeldGrip(found);
refreshArrangeCues(); // The held thing's blue cue gives way to the grip.
log(`${found.kind} held${found.corner ? ' at a corner' : ''}`); log(`${found.kind} held${found.corner ? ' at a corner' : ''}`);
return true; return true;
}, },
@@ -1071,7 +1218,9 @@ async function enterSession(invitationText, name, notebook) {
const [x, y] = renderer.pageFromClient(clientX, clientY); const [x, y] = renderer.pageFromClient(clientX, clientY);
const now = currentHeldRect(held); const now = currentHeldRect(held);
if (!now) return; if (!now) return;
const rect = heldRect(held, now, x, y, held.kind === 'image'); // An image keeps its shape when resized, and so does a circle.
const keepShape = held.kind === 'image' || (held.kind === 'shape' && session.ownLayer.strokes.get(held.id)?.shape === 'circle');
const rect = heldRect(held, now, x, y, keepShape);
if (held.kind === 'image') { if (held.kind === 'image') {
held.index = session.placeImageByHand(held.index, rect); held.index = session.placeImageByHand(held.index, rect);
} else if (held.kind === 'hide') { } else if (held.kind === 'hide') {
@@ -1096,7 +1245,7 @@ async function enterSession(invitationText, name, notebook) {
// The field being typed in follows its block. // The field being typed in follows its block.
if (textEditor?.block?.id === block.id) textEditor.layout(); if (textEditor?.block?.id === block.id) textEditor.layout();
} }
renderer.showGrip(currentHeldRect(held), held.corner); showHeldGrip(held);
refreshArrangeCues(); refreshArrangeCues();
}, },
holdEnd: () => { holdEnd: () => {
@@ -1188,11 +1337,23 @@ async function enterSession(invitationText, name, notebook) {
const last = lineInProgress.vertices[lineInProgress.vertices.length - 1]; const last = lineInProgress.vertices[lineInProgress.vertices.length - 1];
renderer.showShapePreview(straightPoints([last, [x, y]]), color, width, false); renderer.showShapePreview(straightPoints([last, [x, y]]), color, width, false);
} }
renderer.showHoverCue(impliedRectAt(x, y)); // With the Eraser, the red box says what matters, so the blue cue
// of an implied rectangle stays away (the two had shown together).
const implied = tool === 'eraser' ? null : impliedRectAt(x, y);
renderer.showHoverCue(implied?.rect ?? null, implied?.kind);
renderer.setEraseCues(tool === 'eraser' ? eraseTargetsAt(x, y) : []);
if (tool === 'arrange') hoverHandwriting(x, y);
}, },
hoverEnded: () => { hoverEnded: (left = false) => {
renderer.showHoverCue(null); renderer.showHoverCue(null);
renderer.setEraseCues([]);
if (lineInProgress) renderer.hideShapePreview(); if (lineInProgress) renderer.hideShapePreview();
// The handwriting's box stays through a press (the pointer is still
// there) and goes when the pointer leaves the page.
if (left && hoveredStroke) {
hoveredStroke = null;
refreshArrangeCues();
}
}, },
placeRect: (x0, y0, x1, y1) => { placeRect: (x0, y0, x1, y1) => {
if (tool === 'shape') { if (tool === 'shape') {
@@ -1248,7 +1409,9 @@ async function enterSession(invitationText, name, notebook) {
element('colors').hidden = value === 'eraser' || value === 'hide' || value === 'arrange'; element('colors').hidden = value === 'eraser' || value === 'hide' || value === 'arrange';
element('widths').hidden = value !== 'pen' && value !== 'shape'; element('widths').hidden = value !== 'pen' && value !== 'shape';
element('text_style').hidden = value !== 'text'; element('text_style').hidden = value !== 'text';
if (value !== 'arrange') hoveredStroke = null;
refreshArrangeCues(); refreshArrangeCues();
if (value !== 'eraser') renderer.setEraseCues([]);
}, },
setShape: (value) => { setShape: (value) => {
finishLine(); finishLine();
+2 -2
View File
@@ -49,9 +49,9 @@ export const IMAGE_MARGIN = 50;
/** /**
* The shapes of the Shape tool (shapes.js), as a stroke records its kind. * The shapes of the Shape tool (shapes.js), as a stroke records its kind.
* @typedef {'rectangle' | 'ellipse' | 'line' | 'arrow'} ShapeKind * @typedef {'rectangle' | 'circle' | 'ellipse' | 'line' | 'arrow'} ShapeKind
*/ */
export const SHAPE_KINDS = /** @type {const} */ (['rectangle', 'ellipse', 'line', 'arrow']); export const SHAPE_KINDS = /** @type {const} */ (['rectangle', 'circle', 'ellipse', 'line', 'arrow']);
/** Numbers per point in Stroke.points. */ /** Numbers per point in Stroke.points. */
export const POINT_SIZE = 3; export const POINT_SIZE = 3;
+3 -3
View File
@@ -69,8 +69,8 @@ const focusSettleOn = !new URLSearchParams(location.search).has('no_focus_settle
* The pointer moves over the page with no button down and nothing in * 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 * progress (a mouse, or a pen in the air above a tablet that reports
* it; a finger never hovers). * it; a finger never hovers).
* @property {() => void} [hoverEnded] * @property {(left?: boolean) => void} [hoverEnded]
* The pointer left the page, or pressed. * The pointer pressed, or left the page (`left` true).
* @property {(clientX: number, clientY: number) => boolean} [beginHold] * @property {(clientX: number, clientY: number) => boolean} [beginHold]
* The pointer has rested on the page for HOLD_MS, with any tool: a * 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 * hold. True if something under it took the hold (an image to move
@@ -367,7 +367,7 @@ export function attachPenInput(surface, target) {
} }
surface.addEventListener('pointerup', finish); surface.addEventListener('pointerup', finish);
surface.addEventListener('pointercancel', finish); surface.addEventListener('pointercancel', finish);
surface.addEventListener('pointerleave', () => { if (activePointer === null) target.hoverEnded?.(); }); surface.addEventListener('pointerleave', () => { if (activePointer === null) target.hoverEnded?.(true); });
surface.addEventListener('lostpointercapture', (event) => { surface.addEventListener('lostpointercapture', (event) => {
if (event.pointerId === activePointer) trace(`capture of pointer ${event.pointerId} lost while active`); if (event.pointerId === activePointer) trace(`capture of pointer ${event.pointerId} lost while active`);
}); });
+99 -40
View File
@@ -50,18 +50,25 @@ export const CORNER_REACH_PX = 24;
/** /**
* The *implied rectangles* (Andy, 2026-10-14), text blocks and Hides, * The *implied rectangles* (Andy, 2026-10-14), text blocks and Hides,
* are marked on screen by blue corners while the pointer hovers over * are marked on screen while the pointer hovers over them, by a line
* them (always, until 2026-10-14, for a text rectangle and a Hide): * this wide in page units (the hover cue; blue L corners until the
* L-shaped marks this long, in page units, and this wide, in the blue of * evening of 2026-10-14, always shown on a text rectangle and a Hide
* the Arrange cues. Nothing of them is on the page or in the file. (A * until that day): a text rectangle or a Hide outlined, free text by
* text rectangle had a red frame on a white ground, page content, until * its top and left edges. Nothing of them is on the page or in the
* 2026-10-14, and free text a red L until 2026-10-14; the teacher's * file. (A text rectangle had a red frame on a white ground, page
* rectangles turned out to be marks of their own, which the Shape tool * content, until 2026-10-14, and free text a red L until 2026-10-14;
* now draws.) * the teacher's rectangles turned out to be marks of their own, which
* the Shape tool now draws.)
*/ */
const CORNER_MARK = 60;
export const CUE_COLOR = '#1e88e5';
const CUE_WIDTH = 2; const CUE_WIDTH = 2;
/**
* Every overlay that marks a thing's extent (Arrange's cues, the grip,
* the hover cue) draws its line this many pixels beyond the extent: 1
* puts the line on the first pixel outside the extent, touching it; 2
* leaves one pixel clear between (Andy, 2026-10-14, to see how it
* looks and whether a rounding error shows).
*/
export const CUE_OFFSET_PX = 2;
/** /**
* How the page is scaled on screen: a number of CSS pixels per page unit. * How the page is scaled on screen: a number of CSS pixels per page unit.
@@ -202,20 +209,29 @@ export class Renderer {
* corners are). * corners are).
*/ */
this.grip = document.createElement('div'); this.grip = document.createElement('div');
this.grip.className = 'image_grip'; this.grip.className = 'grip';
this.grip.hidden = true; this.grip.hidden = true;
this.grip.append(...cornerHandles()); this.grip.append(...cornerHandles());
this.pageElement.append(this.grip); this.pageElement.append(this.grip);
/** @type {{rect: import('./page_model.js').Rectangle, corner: [number, number] | null} | null} */ /** @type {{rect: import('./page_model.js').Rectangle, corner: [number, number] | null, corners: boolean} | null} */
this.gripShown = null; this.gripShown = null;
/** /**
* The blue cues of the Arrange tool: the corners of every image, text * The blue cues of the Arrange tool: a box and its corners on every
* rectangle and Hide that can be moved or resized, and an outline for * image, Hide, text block and shape that can be moved or resized
* images, whose edges may be invisible on the white page (Andy, * (the box, since an image's edges may be invisible on the white
* 2026-10-13: figures on a white ground). * page; Andy, 2026-10-13: figures on a white ground).
* @type {{rect: import('./page_model.js').Rectangle, kind: 'image' | 'text' | 'hide' | 'shape'}[]} * @type {{rect: import('./page_model.js').Rectangle, kind: 'image' | 'text' | 'hide' | 'shape' | 'stroke', corners: boolean}[]}
*/ */
this.arrangeCues = []; this.arrangeCues = [];
/**
* The eraser's cues (Andy, 2026-10-14): a red box around everything a
* press where the pointer hovers would erase.
* @type {import('./page_model.js').Rectangle[]}
*/
this.eraseCues = [];
this.eraseCueElements = document.createElement('div');
this.eraseCueElements.className = 'erase_cues';
this.pageElement.append(this.eraseCueElements);
this.cueElements = document.createElement('div'); this.cueElements = document.createElement('div');
this.cueElements.className = 'arrange_cues'; this.cueElements.className = 'arrange_cues';
this.pageElement.append(this.cueElements); this.pageElement.append(this.cueElements);
@@ -230,7 +246,6 @@ export class Renderer {
this.hoverCue = document.createElement('div'); this.hoverCue = document.createElement('div');
this.hoverCue.className = 'hover_cue'; this.hoverCue.className = 'hover_cue';
this.hoverCue.hidden = true; this.hoverCue.hidden = true;
this.hoverCue.append(...cornerHandles());
this.pageElement.append(this.hoverCue); this.pageElement.append(this.hoverCue);
/** /**
* A shape being dragged open or a line's rubber band, in the pen's * A shape being dragged open or a line's rubber band, in the pen's
@@ -502,13 +517,16 @@ export class Renderer {
} }
/** /**
* Show, or hide, the cue around an image being handled: its outline, * Show, or hide, the green grip around what is being moved or resized
* and the corner being dragged if it is being scaled. * (an image, a Hide, a text block or a shape): its outline, its four
* corners, and the corner being dragged larger.
* @param {import('./page_model.js').Rectangle | null} rect Page units. * @param {import('./page_model.js').Rectangle | null} rect Page units.
* @param {[number, number] | null} [corner] * @param {[number, number] | null} [corner]
* @param {boolean} [corners] False for free text, which has no corners
* to resize by (Andy, 2026-10-14), so the grip shows none.
*/ */
showGrip(rect, corner = null) { showGrip(rect, corner = null, corners = true) {
this.gripShown = rect ? { rect, corner } : null; this.gripShown = rect ? { rect, corner, corners } : null;
this.layoutGrip(); this.layoutGrip();
} }
@@ -517,7 +535,9 @@ export class Renderer {
this.grip.hidden = !shown; this.grip.hidden = !shown;
if (!shown) return; if (!shown) return;
this.placeOverlay(this.grip, shown.rect); this.placeOverlay(this.grip, shown.rect);
this.grip.classList.toggle('no_corners', !shown.corners);
this.grip.classList.toggle('corner', shown.corner !== null); this.grip.classList.toggle('corner', shown.corner !== null);
this.grip.classList.toggle('moving', shown.corner === null);
if (shown.corner) { if (shown.corner) {
this.grip.style.setProperty('--corner-x', shown.corner[0] ? '100%' : '0%'); this.grip.style.setProperty('--corner-x', shown.corner[0] ? '100%' : '0%');
this.grip.style.setProperty('--corner-y', shown.corner[1] ? '100%' : '0%'); this.grip.style.setProperty('--corner-y', shown.corner[1] ? '100%' : '0%');
@@ -525,62 +545,101 @@ export class Renderer {
} }
/** /**
* Place an overlay element over a rectangle of the page, in percentages * Place an overlay element that marks a rectangle of the page (a cue,
* of the page, so that it keeps its place at any zoom. * the grip, the hover cue, the eraser's cue) just outside it, in pixels, relaid at every
* zoom by resize(): the rectangle's edges are taken out to whole device
* pixels (ink's edge is anti-aliased across the pixel it falls in, and
* the line must not cover it), then the line is set CUE_OFFSET_PX
* beyond them (Andy, 2026-10-14: first one pixel, the line touching
* the extent, then two to see how it looks), the element's own border
* width telling where its line lies. Measured at the four line widths
* of a rectangle: with the position left to the browser's rounding, an
* edge past the middle of a pixel had put the line on that pixel.
* @param {HTMLElement} el * @param {HTMLElement} el
* @param {import('./page_model.js').Rectangle} rect Page units. * @param {import('./page_model.js').Rectangle} rect Page units.
*/ */
placeOverlay(el, rect) { placeOverlay(el, rect) {
const page = this.page; const page = this.page;
el.style.left = `${(rect.left - page.left) / page.width * 100}%`; const line = parseFloat(getComputedStyle(el).borderTopWidth) || 0;
el.style.top = `${(rect.top - page.top) / page.height * 100}%`; const scale = this.cssPixelsPerUnit;
el.style.width = `${rect.width / page.width * 100}%`; const d = window.devicePixelRatio || 1;
el.style.height = `${rect.height / page.height * 100}%`; const beyond = CUE_OFFSET_PX - 1 + line;
const left = Math.floor((rect.left - page.left) * scale * d) / d - beyond;
const top = Math.floor((rect.top - page.top) * scale * d) / d - beyond;
const right = Math.ceil((rect.left + rect.width - page.left) * scale * d) / d + beyond;
const bottom = Math.ceil((rect.top + rect.height - page.top) * scale * d) / d + beyond;
el.style.left = `${left}px`;
el.style.top = `${top}px`;
el.style.width = `${right - left}px`;
el.style.height = `${bottom - top}px`;
} }
/** /**
* Show the Arrange tool's cues on these rectangles, or none. * Show the Arrange tool's cues on these rectangles, or none: a box and
* @param {{rect: import('./page_model.js').Rectangle, kind: 'image' | 'text' | 'hide' | 'shape'}[]} cues * its four corners, or the box alone where `corners` is false, laid
* just outside the rectangle (placeOverlay).
* @param {{rect: import('./page_model.js').Rectangle, kind: 'image' | 'text' | 'hide' | 'shape' | 'stroke', corners: boolean}[]} cues
*/ */
setArrangeCues(cues) { setArrangeCues(cues) {
this.arrangeCues = cues; this.arrangeCues = cues;
this.cueElements.replaceChildren(); this.cueElements.replaceChildren();
for (const cue of cues) { for (const cue of cues) {
const el = document.createElement('div'); const el = document.createElement('div');
el.className = `arrange_cue ${cue.kind}`; el.className = `arrange_cue ${cue.kind}${cue.corners ? '' : ' no_corners'}`;
el.append(...cornerHandles()); el.append(...cornerHandles());
this.cueElements.append(el); this.cueElements.append(el);
} }
this.layoutCues(); this.layoutCues();
} }
/**
* Show the eraser's cues around these rectangles, or none.
* @param {import('./page_model.js').Rectangle[]} rects
*/
setEraseCues(rects) {
this.eraseCues = rects;
this.eraseCueElements.replaceChildren();
for (const _ of rects) {
const el = document.createElement('div');
el.className = 'erase_cue';
this.eraseCueElements.append(el);
}
this.layoutCues();
}
layoutCues() { layoutCues() {
const elements = this.cueElements.children; const elements = this.cueElements.children;
this.arrangeCues.forEach((cue, i) => { this.arrangeCues.forEach((cue, i) => {
const el = /** @type {HTMLElement} */ (elements[i]); const el = /** @type {HTMLElement} */ (elements[i]);
if (el) this.placeOverlay(el, cue.rect); if (el) this.placeOverlay(el, cue.rect);
}); });
const eraseElements = this.eraseCueElements.children;
this.eraseCues.forEach((rect, i) => {
const el = /** @type {HTMLElement} */ (eraseElements[i]);
if (el) this.placeOverlay(el, rect);
});
if (this.hoverRect) this.placeOverlay(this.hoverCue, this.hoverRect); if (this.hoverRect) this.placeOverlay(this.hoverCue, this.hoverRect);
const page = this.page; const page = this.page;
this.shapePreview.setAttribute('viewBox', `${page.left} ${page.top} ${page.width} ${page.height}`); this.shapePreview.setAttribute('viewBox', `${page.left} ${page.top} ${page.width} ${page.height}`);
} }
/** /**
* Mark the corners of this rectangle while the pointer hovers over it, * Mark this implied rectangle while the pointer hovers over it, or
* or none. * none (Andy, 2026-10-14): a text rectangle or a Hide by its outline,
* free text by its top and left edges only, so the two kinds of text
* are told apart.
* @param {import('./page_model.js').Rectangle | null} rect * @param {import('./page_model.js').Rectangle | null} rect
* @param {'rect' | 'free' | 'hide'} [kind]
*/ */
showHoverCue(rect) { showHoverCue(rect, kind = 'hide') {
this.hoverRect = rect; this.hoverRect = rect;
this.hoverCue.hidden = !rect; this.hoverCue.hidden = !rect;
if (!rect) return; if (!rect) return;
this.hoverCue.className = `hover_cue ${kind}`;
// The line CUE_WIDTH wide at this zoom, at least a pixel; set before
// the placement, which reads the border's width.
this.hoverCue.style.setProperty('--mark_line', `${Math.max(1, Math.round(CUE_WIDTH * this.cssPixelsPerUnit))}px`);
this.placeOverlay(this.hoverCue, rect); this.placeOverlay(this.hoverCue, rect);
// The corner marks as drawCornerMarks draws them: CORNER_MARK long,
// shorter on a small block, CUE_WIDTH wide and at least a pixel.
const scale = this.cssPixelsPerUnit;
const mark = Math.min(CORNER_MARK, rect.width / 2, rect.height / 2) * scale;
this.hoverCue.style.setProperty('--mark', `${mark}px`);
this.hoverCue.style.setProperty('--mark_line', `${Math.max(1, Math.round(CUE_WIDTH * scale))}px`);
} }
/** /**
+2 -1
View File
@@ -903,7 +903,8 @@ export class Session {
*/ */
beginShapeEdit(id) { beginShapeEdit(id) {
const stroke = this.ownLayer.strokes.get(id); const stroke = this.ownLayer.strokes.get(id);
if (!stroke || !stroke.shape || this.shapeEditing) return false; // Any finished stroke of ours: a shape, or handwriting under Arrange (Andy, 2026-10-14).
if (!stroke || !stroke.done || this.shapeEditing) return false;
this.shapeEditing = { id, before: { ...stroke, points: stroke.points.slice() } }; this.shapeEditing = { id, before: { ...stroke, points: stroke.points.slice() } };
return true; return true;
} }
+27 -5
View File
@@ -31,11 +31,11 @@ import { NOMINAL_PRESSURE } from './pen_input.js';
import { POINT_SIZE } from './page_model.js'; import { POINT_SIZE } from './page_model.js';
/** The shapes, in the order of the menu. The first is the default. */ /** The shapes, in the order of the menu. The first is the default. */
export const SHAPES = /** @type {const} */ (['rectangle', 'ellipse', 'line', 'arrow']); export const SHAPES = /** @type {const} */ (['rectangle', 'circle', 'ellipse', 'line', 'arrow']);
/** @typedef {typeof SHAPES[number]} Shape */ /** @typedef {typeof SHAPES[number]} Shape */
/** @type {Record<Shape, string>} What the button and the menu call each shape. */ /** @type {Record<Shape, string>} What the button and the menu call each shape. */
export const SHAPE_NAMES = { rectangle: 'Rectangle', ellipse: 'Ellipse', line: 'Line', arrow: 'Arrow' }; export const SHAPE_NAMES = { rectangle: 'Rectangle', circle: 'Circle', ellipse: 'Ellipse', line: 'Line', arrow: 'Arrow' };
/** The key under which the browser remembers the chosen shape. */ /** The key under which the browser remembers the chosen shape. */
export const SHAPE_KEY = 'kokkoja_shape'; export const SHAPE_KEY = 'kokkoja_shape';
@@ -149,7 +149,10 @@ export function ellipsePoints(x0, y0, x1, y1) {
// Ramanujan's approximation of the circumference. // Ramanujan's approximation of the circumference.
const h = ((rx - ry) / (rx + ry)) ** 2 || 0; const h = ((rx - ry) / (rx + ry)) ** 2 || 0;
const circumference = Math.PI * (rx + ry) * (1 + 3 * h / (10 + Math.sqrt(4 - 3 * h))); const circumference = Math.PI * (rx + ry) * (1 + 3 * h / (10 + Math.sqrt(4 - 3 * h)));
const count = Math.max(LEAST_ELLIPSE_POINTS, Math.min(MOST_ELLIPSE_POINTS, Math.ceil(circumference / ELLIPSE_STEP))); // A multiple of four, so that a point lies at each end of both axes and
// the rectangle around the points is exactly the ellipse's (Arrange's
// box and a circle's proportions depend on it).
const count = Math.max(LEAST_ELLIPSE_POINTS, Math.min(MOST_ELLIPSE_POINTS, 4 * Math.ceil(circumference / ELLIPSE_STEP / 4)));
/** @type {number[]} */ /** @type {number[]} */
const points = []; const points = [];
for (let i = 0; i <= count; i++) { for (let i = 0; i <= count; i++) {
@@ -159,6 +162,21 @@ export function ellipsePoints(x0, y0, x1, y1) {
return points; return points;
} }
/**
* A circle about its centre (cx, cy), the radius reaching the dragged
* point (Andy, 2026-10-14: the centre first, then the radius in any
* direction).
* @param {number} cx
* @param {number} cy
* @param {number} x
* @param {number} y
* @returns {number[]}
*/
export function circlePoints(cx, cy, x, y) {
const radius = Math.hypot(x - cx, y - cy);
return ellipsePoints(cx - radius, cy - radius, cx + radius, cy + radius);
}
/** /**
* An arrow from (x0, y0) to its tip at (x1, y1), as the outline of a * An arrow from (x0, y0) to its tip at (x1, y1), as the outline of a
* filled polygon: the shaft as wide as the line, the head wider, its * filled polygon: the shaft as wide as the line, the head wider, its
@@ -202,7 +220,9 @@ export function arrowPoints(x0, y0, x1, y1, width) {
} }
/** /**
* The points of a shape dragged open from (x0, y0) to (x1, y1). * The points of a shape dragged open from (x0, y0) to (x1, y1): a
* circle from its centre to a point of its edge, the others from one
* corner of their rectangle to the opposite one.
* @param {Shape} shape Not 'line', which is made of taps. * @param {Shape} shape Not 'line', which is made of taps.
* @param {number} x0 * @param {number} x0
* @param {number} y0 * @param {number} y0
@@ -214,6 +234,7 @@ export function arrowPoints(x0, y0, x1, y1, width) {
export function draggedPoints(shape, x0, y0, x1, y1, width) { export function draggedPoints(shape, x0, y0, x1, y1, width) {
switch (shape) { switch (shape) {
case 'rectangle': return rectanglePoints(x0, y0, x1, y1); case 'rectangle': return rectanglePoints(x0, y0, x1, y1);
case 'circle': return circlePoints(x0, y0, x1, y1);
case 'ellipse': return ellipsePoints(x0, y0, x1, y1); case 'ellipse': return ellipsePoints(x0, y0, x1, y1);
case 'arrow': return arrowPoints(x0, y0, x1, y1, width); case 'arrow': return arrowPoints(x0, y0, x1, y1, width);
default: return straightPoints([[x0, y0], [x1, y1]]); default: return straightPoints([[x0, y0], [x1, y1]]);
@@ -224,7 +245,8 @@ export function draggedPoints(shape, x0, y0, x1, y1, width) {
* The points of a shape moved or resized by Arrange from the rectangle * The points of a shape moved or resized by Arrange from the rectangle
* around its points to another: every point carried along (a rectangle * around its points to another: every point carried along (a rectangle
* stays a rectangle, an ellipse an ellipse, a line keeps its angles' * stays a rectangle, an ellipse an ellipse, a line keeps its angles'
* proportions), except an arrow, which is made afresh from its tail and * proportions; a circle is resized with its shape kept, so stays one),
* except an arrow, which is made afresh from its tail and
* tip carried along, so that its head and shaft keep the line's width. * tip carried along, so that its head and shaft keep the line's width.
* @param {import('./page_model.js').Stroke} stroke * @param {import('./page_model.js').Stroke} stroke
* @param {import('./page_model.js').Rectangle} from * @param {import('./page_model.js').Rectangle} from
+23
View File
@@ -39,6 +39,29 @@ export function widthAt(stroke, index) {
return stroke.width * (0.36 + 4 * pressure * pressure); return stroke.width * (0.36 + 4 * pressure * pressure);
} }
/**
* The rectangle a stroke's ink covers: around its points, each taken
* out by half the line's width at that point (round caps and joins).
* @param {Stroke} stroke
* @returns {{left: number, top: number, width: number, height: number} | null}
*/
export function inkExtent(stroke) {
const p = stroke.points;
if (p.length < POINT_SIZE) return null;
let left = Infinity;
let top = Infinity;
let right = -Infinity;
let bottom = -Infinity;
for (let i = 0, k = 0; i < p.length; i += POINT_SIZE, k++) {
const half = widthAt(stroke, k) / 2;
left = Math.min(left, p[i] - half);
right = Math.max(right, p[i] + half);
top = Math.min(top, p[i + 1] - half);
bottom = Math.max(bottom, p[i + 1] + half);
}
return { left, top, width: right - left, height: bottom - top };
}
/** /**
* The part of a stroke that belongs to point `k`. * The part of a stroke that belongs to point `k`.
* @param {Stroke} stroke * @param {Stroke} stroke
+51 -32
View File
@@ -5,6 +5,22 @@
--panel: #f6f6f7; --panel: #f6f6f7;
--desk: #e4e4e7; --desk: #e4e4e7;
--accent: #1f4fd8; --accent: #1f4fd8;
/* The two colours of arranging (Andy, 2026-10-14): the blue of the
boxes and corners the Arrange tool lays on everything it can move,
and the green of the one being moved or resized, by Arrange or by a
hold with any tool, until the pointer lifts. The hover cue on an
implied rectangle (free text, a text rectangle, a Hide) is the same
blue today but has its own name, so that trying another arrange
blue leaves it alone: it is the only sign of the rectangle's extent. */
--arrange_blue: #1e88e5;
--held_green: #2e7d32;
--hover_blue: #1e88e5;
/* The line of the Arrange cue and of the grip, the same width for both
(Andy, 2026-10-14: the thinner blue line had read as standing closer
to the ink than the green, the gap being the same). */
--cue_line: 2px;
/* The eraser's cue: what a press would erase (Andy, 2026-10-14). */
--erase_red: #e53935;
font-family: system-ui, sans-serif; font-family: system-ui, sans-serif;
font-size: 15px; font-size: 15px;
color: var(--ink); color: var(--ink);
@@ -334,18 +350,23 @@ h2 {
background: var(--desk); background: var(--desk);
} }
/* The cue around an image being moved or scaled by hand: green, with a /* The grip around whatever is being moved or resized (an image, a Hide,
square at the corner being dragged. */ a text block, a shape), by a hold or by Arrange: green, its four
.image_grip { corners (none on free text, which has no corners to resize by), and
the corner being dragged larger. Under Arrange it takes
the place of the thing's blue cue, which is left out while held. */
.grip {
position: absolute; position: absolute;
z-index: 3; z-index: 3;
box-sizing: border-box; box-sizing: border-box;
border: 2px solid #2e7d32; border: var(--cue_line) solid var(--held_green);
pointer-events: none; pointer-events: none;
--handle-color: #2e7d32; --handle-color: var(--held_green);
--moving_handle: 14px;
} }
/* The four corners of a held object, and the corner being dragged, larger. */ /* The four corners of a held object or an Arrange cue; a moving corner
is larger (the one dragged, or all four when the thing is moved). */
.handle { .handle {
position: absolute; position: absolute;
width: 10px; width: 10px;
@@ -358,45 +379,43 @@ h2 {
.handle.sw { left: 0; top: 100%; } .handle.sw { left: 0; top: 100%; }
.handle.se { left: 100%; top: 100%; } .handle.se { left: 100%; top: 100%; }
.image_grip.corner::after { /* The corner being dragged is larger; and while the whole thing is moved
all four corners are, since all four are moving (Andy, 2026-10-14). */
.grip.corner::after {
content: ""; content: "";
position: absolute; position: absolute;
left: var(--corner-x); left: var(--corner-x);
top: var(--corner-y); top: var(--corner-y);
width: 16px; width: var(--moving_handle);
height: 16px; height: var(--moving_handle);
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
background: #2e7d32; background: var(--held_green);
} }
.grip.moving .handle { width: var(--moving_handle); height: var(--moving_handle); }
/* The Arrange tool's cues: blue corners and an outline on every image, /* The Arrange tool's cues (Andy, 2026-10-14): a blue box with blue
whose edges may not show (text rectangles and Hides carry blue corners corner squares on everything a press can move or resize, images,
at all times since 2026-10-14, drawn on the canvases); and the same Hides, one's own text and shapes alike; free text, which has no width
corners on free text under a hovering pointer (.hover_cue). */ to resize, gets the box without the corners. */
.arrange_cues { position: absolute; inset: 0; pointer-events: none; z-index: 3; } .arrange_cues { position: absolute; inset: 0; pointer-events: none; z-index: 3; }
.arrange_cue { .arrange_cue {
position: absolute; position: absolute;
box-sizing: border-box; box-sizing: border-box;
pointer-events: none; pointer-events: none;
--handle-color: #1e88e5; border: var(--cue_line) solid var(--arrange_blue);
--handle-color: var(--arrange_blue);
} }
.arrange_cue.image { border: 1px solid #1e88e5; } .arrange_cue.no_corners .handle, .grip.no_corners .handle { display: none; }
/* The hover cue's corners are L marks like those the canvases draw on /* The eraser's cues: a red box around each element a press where the
text rectangles and Hides, not the Arrange tool's squares, so that pointer hovers would erase, whole, as the eraser works. */
"blue corners" look the same everywhere; renderer.js sets the mark's .erase_cues { position: absolute; inset: 0; pointer-events: none; z-index: 3; }
length and line width for the zoom. */ .erase_cue { position: absolute; box-sizing: border-box; pointer-events: none; border: var(--cue_line) solid var(--erase_red); }
.hover_cue { position: absolute; box-sizing: border-box; pointer-events: none; z-index: 3; --handle-color: #1e88e5; } /* The hover cue on an implied rectangle (Andy, 2026-10-14, evening): a
.hover_cue .handle { text rectangle and a Hide are outlined, free text shows its top and
width: var(--mark); left edges only, so the two kinds of text are told apart. renderer.js
height: var(--mark); sets the line width for the zoom. */
transform: none; .hover_cue { position: absolute; box-sizing: border-box; pointer-events: none; z-index: 3; border: var(--mark_line) solid var(--hover_blue); }
background: none; .hover_cue.free { border-width: var(--mark_line) 0 0 var(--mark_line); }
border: 0 solid var(--handle-color);
}
.hover_cue .handle.nw { left: 0; top: 0; border-width: var(--mark_line) 0 0 var(--mark_line); }
.hover_cue .handle.ne { left: auto; right: 0; top: 0; border-width: var(--mark_line) var(--mark_line) 0 0; }
.hover_cue .handle.sw { left: 0; top: auto; bottom: 0; border-width: 0 0 var(--mark_line) var(--mark_line); }
.hover_cue .handle.se { left: auto; right: 0; top: auto; bottom: 0; border-width: 0 var(--mark_line) var(--mark_line) 0; }
.page { .page {
position: relative; position: relative;
+295 -20
View File
@@ -44,10 +44,27 @@ HOVER_CUE = """
const page = document.getElementById('page').getBoundingClientRect(); const page = document.getElementById('page').getBoundingClientRect();
const b = c.getBoundingClientRect(); const b = c.getBoundingClientRect();
const k = s.page.width / page.width; const k = s.page.width / page.width;
const st = getComputedStyle(c);
return {hidden: c.hidden, x: s.page.left + (b.left - page.left) * k, y: s.page.top + (b.top - page.top) * k, return {hidden: c.hidden, x: s.page.left + (b.left - page.left) * k, y: s.page.top + (b.top - page.top) * k,
width: b.width * k, height: b.height * k}; width: b.width * k, height: b.height * k, kind: c.className.split(' ')[1],
box: [b.left - page.left, b.top - page.top, b.right - page.left, b.bottom - page.top],
scale: r.cssPixelsPerUnit, origin: [s.page.left, s.page.top],
edges: [st.borderTopWidth, st.borderRightWidth, st.borderBottomWidth, st.borderLeftWidth]};
""" """
# Where an overlay's box must lie around a rectangle's edges in pixels
# (CUE_OFFSET_PX in renderer.js): the edges taken out to whole pixels and
# the line this far beyond them.
CUE_OFFSET_PX = 2
def overlay_box(left, top, right, bottom, line):
"""The pixel box an overlay with this line width takes around a
rectangle whose edges are at these pixel positions."""
beyond = CUE_OFFSET_PX - 1 + line
return [math.floor(left) - beyond, math.floor(top) - beyond, math.ceil(right) + beyond, math.ceil(bottom) + beyond]
# Whether anything at all is drawn on the writer's own layer canvas within # Whether anything at all is drawn on the writer's own layer canvas within
# two device pixels of the page point (x, y). # two device pixels of the page point (x, y).
INK_AT = """ INK_AT = """
@@ -538,6 +555,12 @@ class Firefox:
"type": "pointer", "id": "mouse", "parameters": {"pointerType": "mouse"}, "type": "pointer", "id": "mouse", "parameters": {"pointerType": "mouse"},
"actions": moves}]}) "actions": moves}]})
def pointer_actions(self, actions):
"""Perform mouse actions (WebDriver's pointer actions) in sequence."""
self.in_session("POST", "/actions", {"actions": [{
"type": "pointer", "id": "mouse", "parameters": {"pointerType": "mouse"},
"actions": actions}]})
def type_into(self, selector, text): def type_into(self, selector, text):
"""Type text into an element, as the keyboard would.""" """Type text into an element, as the keyboard would."""
found = self.in_session("POST", "/element", {"using": "css selector", "value": selector}) found = self.in_session("POST", "/element", {"using": "css selector", "value": selector})
@@ -661,17 +684,30 @@ class EndToEndTest(unittest.TestCase):
def state(self): def state(self):
return self.firefox.run(PAGE_STATE) return self.firefox.run(PAGE_STATE)
def assert_hover_cue(self, x, y, rect): def assert_hover_cue(self, x, y, rect, kind="hide"):
"""A pointer hovering at the page point (x, y) shows the blue """A pointer hovering at the page point (x, y) shows the blue cue
corners over the rectangle (page units), and none once it leaves.""" over the rectangle (page units), and none once it leaves: a text
rectangle or a Hide outlined, free text's top and left edges
(Andy, 2026-10-14)."""
state = self.state() state = self.state()
fx = (x - state["origin"][0]) / state["page"]["width"] fx = (x - state["origin"][0]) / state["page"]["width"]
fy = (y - state["origin"][1]) / state["page"]["height"] fy = (y - state["origin"][1]) / state["page"]["height"]
self.firefox.run(HOVER, fx, fy) self.firefox.run(HOVER, fx, fy)
cue = self.firefox.run(HOVER_CUE) cue = self.firefox.run(HOVER_CUE)
self.assertFalse(cue["hidden"], f"no cue at {x}, {y}") self.assertFalse(cue["hidden"], f"no cue at {x}, {y}")
for key, expected in zip(("x", "y", "width", "height"), rect): self.assertEqual(cue["kind"], kind)
self.assertAlmostEqual(cue[key], expected, delta=3, msg=key) line = cue["edges"][0]
self.assertNotEqual(line, "0px")
# The cue's box lies CUE_OFFSET_PX outside the rectangle.
px = lambda v: (v - cue["origin"][0]) * cue["scale"]
py = lambda v: (v - cue["origin"][1]) * cue["scale"]
expected = overlay_box(px(rect[0]), py(rect[1]), px(rect[0] + rect[2]), py(rect[1] + rect[3]), float(line[:-2]))
for side, got, want in zip(("left", "top", "right", "bottom"), cue["box"], expected):
self.assertAlmostEqual(got, want, delta=0.01, msg=side)
if kind == "free":
self.assertEqual(cue["edges"], [line, "0px", "0px", line], "top and left edges only")
else:
self.assertEqual(cue["edges"], [line] * 4, "outlined")
self.firefox.run(HOVER, 0.01, 0.01) self.firefox.run(HOVER, 0.01, 0.01)
self.assertTrue(self.firefox.run(HOVER_CUE)["hidden"]) self.assertTrue(self.firefox.run(HOVER_CUE)["hidden"])
@@ -1517,20 +1553,39 @@ class EndToEndTest(unittest.TestCase):
self.assertEqual(after["layers"]["Arranger"], []) self.assertEqual(after["layers"]["Arranger"], [])
self.assertEqual(rect(after, 0), rect(scaled, 0)) self.assertEqual(rect(after, 0), rect(scaled, 0))
# A text rectangle of one's own and a Hide, marked by their own blue # A text rectangle of one's own, free text and a Hide get cues too
# corners, get no cue; a corner drag widens the text box, a drag # (Andy, 2026-10-14), the free text's without corners; the hover
# inside moves the Hide. # cue still shows on the box and the Hide; a corner drag widens
# the text box, a drag inside moves the Hide.
choose("text") choose("text")
firefox.run(TEXT_GESTURE, 0.1, 0.6, 0.3, 0.7) firefox.run(TEXT_GESTURE, 0.1, 0.6, 0.3, 0.7)
firefox.type_into(".text_field", "Box") firefox.type_into(".text_field", "Box")
firefox.run(TEXT_GESTURE, 0.4, 0.8, 0.4, 0.8) # Finishes the box.
self.wait_until(lambda s: firefox.run("return document.querySelector('.text_field').hidden;"), "the box finished")
firefox.run(TEXT_GESTURE, 0.4, 0.8, 0.4, 0.8) # Starts free text.
firefox.type_into(".text_field", "Free")
choose("hide") choose("hide")
firefox.run(TEXT_GESTURE, 0.5, 0.6, 0.7, 0.7) firefox.run(TEXT_GESTURE, 0.5, 0.6, 0.7, 0.7)
with_all = self.wait_until(lambda s: len(s["hides"]) == 1 and s["texts"]["Arranger"] and s["texts"]["Arranger"][0]["text"] == "Box", with_all = self.wait_until(lambda s: len(s["hides"]) == 1 and len(s["texts"]["Arranger"]) == 2
"the box and the Hide") and s["texts"]["Arranger"][0]["text"] == "Box" and s["texts"]["Arranger"][1]["text"] == "Free",
"the box, the free text and the Hide")
choose("arrange") choose("arrange")
self.assertEqual(cues(), ["image"]) self.assertEqual(cues(), ["image", "hide", "text", "text"])
self.assertEqual(firefox.run("return [...document.querySelectorAll('.arrange_cue.text')].map((c) => c.classList.contains('no_corners'));"),
[False, True], "free text's cue has no corners")
self.assertEqual(firefox.run("return [...document.querySelectorAll('.arrange_cue')].map((c) => getComputedStyle(c).borderTopWidth);"),
["2px"] * 4, "every cue is a box, its line the grip's width")
# The image's cue lies CUE_OFFSET_PX outside the image.
im = scaled["images"][0]
page_box = firefox.run("const b = document.getElementById('page').getBoundingClientRect(); return [b.left, b.top];")
got = firefox.run("const b = document.querySelector('.arrange_cue.image').getBoundingClientRect(); return [b.left, b.top, b.right, b.bottom];")
want = overlay_box(px(im["x"]), py(im["y"]), px(im["x"] + im["width"]), py(im["y"] + im["height"]), 2)
for side, g, w in zip(("left", "top", "right", "bottom"), (got[0] - page_box[0], got[1] - page_box[1], got[2] - page_box[0], got[3] - page_box[1]), want):
self.assertAlmostEqual(g, w, delta=0.01, msg=side)
self.assertEqual(firefox.run("return getComputedStyle(document.documentElement).getPropertyValue('--arrange_blue');").strip(), "#1e88e5")
self.assertEqual(firefox.run("return getComputedStyle(document.querySelector('.arrange_cue')).borderTopColor;"), "rgb(30, 136, 229)")
box = with_all["texts"]["Arranger"][0] box = with_all["texts"]["Arranger"][0]
self.assert_hover_cue(box["x"] + 20, box["y"] + 20, [box["x"], box["y"], box["width"], box["height"]]) self.assert_hover_cue(box["x"] + 20, box["y"] + 20, [box["x"], box["y"], box["width"], box["height"]], "rect")
h = with_all["hides"][0] h = with_all["hides"][0]
self.assert_hover_cue(h[0] + h[2] / 2, h[1] + h[3] / 2, h) self.assert_hover_cue(h[0] + h[2] / 2, h[1] + h[3] / 2, h)
firefox.drag("#page", px(box["x"] + box["width"]) - 3, py(box["y"] + box["height"]) - 3, firefox.drag("#page", px(box["x"] + box["width"]) - 3, py(box["y"] + box["height"]) - 3,
@@ -1542,14 +1597,51 @@ class EndToEndTest(unittest.TestCase):
px(hide[0] + hide[2] / 2) - 100, py(hide[1] + hide[3] / 2) + 50) px(hide[0] + hide[2] / 2) - 100, py(hide[1] + hide[3] / 2) + 50)
hide_moved = self.wait_until(lambda s: s["hides"][0][0] != hide[0], "the Hide moved") hide_moved = self.wait_until(lambda s: s["hides"][0][0] != hide[0], "the Hide moved")
self.assertEqual(hide_moved["hides"][0][2:], hide[2:]) self.assertEqual(hide_moved["hides"][0][2:], hide[2:])
self.assertEqual(len(cues()), 1) self.assertEqual(len(cues()), 4)
# Undo takes the Hide's move back, as it does a hold's. # Undo takes the Hide's move back, as it does a hold's.
firefox.run("document.getElementById('undo').click();") firefox.run("document.getElementById('undo').click();")
self.wait_until(lambda s: s["hides"][0] == hide, "the Hide back by Undo") self.wait_until(lambda s: s["hides"][0] == hide, "the Hide back by Undo")
# The watcher, without the right, is shown no cue; their own text # While something is held its blue cue gives way to the green grip:
# rectangle has its blue corners as everyone's has. # a press on the Hide, held down, then released.
page_box = firefox.run("const b = document.getElementById('page').getBoundingClientRect(); return [b.left, b.top];")
firefox.pointer_actions([{"type": "pointerMove", "duration": 0, "origin": "viewport",
"x": round(page_box[0] + px(hide[0] + hide[2] / 2)), "y": round(page_box[1] + py(hide[1] + hide[3] / 2))},
{"type": "pointerDown", "button": 0}, {"type": "pause", "duration": 100}])
self.assertEqual(cues(), ["image", "text", "text"], "the held Hide's cue is gone")
self.assertFalse(firefox.run("return document.querySelector('.grip').hidden;"))
self.assertEqual(firefox.run("return getComputedStyle(document.querySelector('.grip')).borderTopColor;"), "rgb(46, 125, 50)")
# Held for moving, all four corners are the moving size (Andy, 2026-10-14).
self.assertEqual(firefox.run("return [...document.querySelectorAll('.grip .handle')].map((h) => getComputedStyle(h).width);"), ["14px"] * 4)
firefox.pointer_actions([{"type": "pointerUp", "button": 0}])
self.assertEqual(cues(), ["image", "hide", "text", "text"], "the cue is back")
self.assertTrue(firefox.run("return document.querySelector('.grip').hidden;"))
# Held at a corner, only that corner is the moving size.
firefox.pointer_actions([{"type": "pointerMove", "duration": 0, "origin": "viewport",
"x": round(page_box[0] + px(hide[0] + hide[2])) - 3, "y": round(page_box[1] + py(hide[1] + hide[3])) - 3},
{"type": "pointerDown", "button": 0}, {"type": "pause", "duration": 100}])
self.assertEqual(firefox.run("return [...document.querySelectorAll('.grip .handle')].map((h) => getComputedStyle(h).width);"), ["10px"] * 4)
# The square's position is a percentage of the grip's padding box.
self.assertEqual(firefox.run("const g = document.querySelector('.grip'); const s = getComputedStyle(g, '::after');"
" return [s.width, parseFloat(s.left) === g.clientWidth, parseFloat(s.top) === g.clientHeight];"),
["14px", True, True], "the dragged corner, bottom right, larger")
firefox.pointer_actions([{"type": "pointerUp", "button": 0}])
self.wait_until(lambda s: s["hides"][0] == hide, "the Hide unchanged by a press")
# Held free text shows the grip without corners, as its cue has none.
free = self.state()["texts"]["Arranger"][1]
firefox.pointer_actions([{"type": "pointerMove", "duration": 0, "origin": "viewport",
"x": round(page_box[0] + px(free["x"] + 20)), "y": round(page_box[1] + py(free["y"] + 20))},
{"type": "pointerDown", "button": 0}, {"type": "pause", "duration": 100}])
self.assertEqual(cues(), ["image", "hide", "text"], "the held free text's cue is gone")
self.assertEqual(firefox.run("return [...document.querySelectorAll('.grip .handle')].map((h) => getComputedStyle(h).display);"),
["none"] * 4, "no corners on held free text")
firefox.pointer_actions([{"type": "pointerUp", "button": 0}])
self.assertEqual(cues(), ["image", "hide", "text", "text"])
# The watcher, without the right, is shown no cue on the image or
# the Hide, only on their own text.
firefox.switch_to(guest_tab) firefox.switch_to(guest_tab)
self.wait_until(lambda s: len(s["images"]) == 1 and len(s["hides"]) == 1, "the page at the watcher") self.wait_until(lambda s: len(s["images"]) == 1 and len(s["hides"]) == 1, "the page at the watcher")
choose("arrange") choose("arrange")
@@ -1558,10 +1650,10 @@ class EndToEndTest(unittest.TestCase):
firefox.run(TEXT_GESTURE, 0.1, 0.8, 0.3, 0.9) firefox.run(TEXT_GESTURE, 0.1, 0.8, 0.3, 0.9)
firefox.type_into(".text_field", "Mine") firefox.type_into(".text_field", "Mine")
choose("arrange") choose("arrange")
self.assertEqual(cues(), []) self.assertEqual(cues(), ["text"])
mine = self.wait_until(lambda s: s["texts"].get("Watcher") and s["texts"]["Watcher"][0]["text"] == "Mine", "the watcher's box") mine = self.wait_until(lambda s: s["texts"].get("Watcher") and s["texts"]["Watcher"][0]["text"] == "Mine", "the watcher's box")
mine = mine["texts"]["Watcher"][0] mine = mine["texts"]["Watcher"][0]
self.assert_hover_cue(mine["x"] + 20, mine["y"] + 20, [mine["x"], mine["y"], mine["width"], mine["height"]]) self.assert_hover_cue(mine["x"] + 20, mine["y"] + 20, [mine["x"], mine["y"], mine["width"], mine["height"]], "rect")
def test_shapes(self): def test_shapes(self):
"""Andy, 2026-10-14: a Shape button right of Text draws the shape it """Andy, 2026-10-14: a Shape button right of Text draws the shape it
@@ -1772,6 +1864,19 @@ class EndToEndTest(unittest.TestCase):
scale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;") scale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;")
px = lambda x: (x - origin[0]) * scale px = lambda x: (x - origin[0]) * scale
py = lambda y: (y - origin[1]) * scale py = lambda y: (y - origin[1]) * scale
# Each cue's line lies just outside the shape's ink (the points plus
# half the line's width, taken out to whole pixels), one pixel
# beyond it on every side (Andy, 2026-10-14).
page_box = firefox.run("const b = document.getElementById('page').getBoundingClientRect(); return [b.left, b.top];")
boxes = firefox.run("return [...document.querySelectorAll('.arrange_cue')].map((c) => { const b = c.getBoundingClientRect();"
" return [b.left, b.top, b.right, b.bottom, parseFloat(getComputedStyle(c).borderTopWidth)]; });")
for stroke, (left, top, right, bottom, line) in zip(strokes(unfilled), boxes):
xs, ys = stroke["points"][0::3], stroke["points"][1::3]
half = stroke["width"] / 2
self.assertEqual(line, 2)
expected = overlay_box(px(min(xs) - half), py(min(ys) - half), px(max(xs) + half), py(max(ys) + half), line)
for side, got, want in zip(("left", "top", "right", "bottom"), (left - page_box[0], top - page_box[1], right - page_box[0], bottom - page_box[1]), expected):
self.assertAlmostEqual(got, want, delta=0.01, msg=side)
before = strokes(filled)[4]["points"] before = strokes(filled)[4]["points"]
firefox.drag("#page", px(at(0.2, 0.37)[0]), py(at(0.2, 0.37)[1]), px(at(0.2, 0.37)[0]) + 100, py(at(0.2, 0.37)[1]) + 50) firefox.drag("#page", px(at(0.2, 0.37)[0]), py(at(0.2, 0.37)[1]), px(at(0.2, 0.37)[0]) + 100, py(at(0.2, 0.37)[1]) + 50)
moved = self.wait_until(lambda s: strokes(s)[4]["points"] != before, "the rectangle moved") moved = self.wait_until(lambda s: strokes(s)[4]["points"] != before, "the rectangle moved")
@@ -1814,7 +1919,7 @@ class EndToEndTest(unittest.TestCase):
box = self.wait_until(lambda s: s["texts"].get("Drawer") and s["texts"]["Drawer"][0]["text"] == "Box", "the box")["texts"]["Drawer"][0] box = self.wait_until(lambda s: s["texts"].get("Drawer") and s["texts"]["Drawer"][0]["text"] == "Box", "the box")["texts"]["Drawer"][0]
self.assertFalse(firefox.run(INK_AT, box["x"] + box["width"] / 2, box["y"]), "no frame along the top edge") self.assertFalse(firefox.run(INK_AT, box["x"] + box["width"] / 2, box["y"]), "no frame along the top edge")
self.assertFalse(firefox.run(INK_AT, box["x"], box["y"]), "no corner mark on the canvas") self.assertFalse(firefox.run(INK_AT, box["x"], box["y"]), "no corner mark on the canvas")
self.assert_hover_cue(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2, [box["x"], box["y"], box["width"], box["height"]]) self.assert_hover_cue(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2, [box["x"], box["y"], box["width"], box["height"]], "rect")
saved = self.wait_until_true(lambda: (self.notebooks / "Drawer notebook" / "Current.svg").read_text(), saved = self.wait_until_true(lambda: (self.notebooks / "Drawer notebook" / "Current.svg").read_text(),
lambda text: "<desc>Box</desc>" in text, "the box saved") lambda text: "<desc>Box</desc>" in text, "the box saved")
self.assertNotIn("data-ground", saved) self.assertNotIn("data-ground", saved)
@@ -1827,7 +1932,177 @@ class EndToEndTest(unittest.TestCase):
self.assertTrue(firefox.run(HOVER_CUE)["hidden"]) self.assertTrue(firefox.run(HOVER_CUE)["hidden"])
self.assertFalse(firefox.run(INK_AT, free["x"], free["y"]), "no red L on free text") self.assertFalse(firefox.run(INK_AT, free["x"], free["y"]), "no red L on free text")
self.assert_hover_cue(free["x"] + free["boxWidth"] / 2, free["y"] + free["boxHeight"] / 2, self.assert_hover_cue(free["x"] + free["boxWidth"] / 2, free["y"] + free["boxHeight"] / 2,
[free["x"], free["y"], free["boxWidth"], free["boxHeight"]]) [free["x"], free["y"], free["boxWidth"], free["boxHeight"]], "free")
# A circle (Andy, 2026-10-14): the centre first, then a drag out to
# the edge in any direction; every point at that distance from the
# centre. Resized by Arrange from a corner, it stays a circle.
choose("shape")
pick("circle")
self.assertEqual(label(), "Circle")
self.assertEqual(firefox.run("return [...document.querySelectorAll('#shape_menu [data-shape]')].filter((i) => i.getAttribute('aria-checked') === 'true').map((i) => i.dataset.shape);"), ["circle"])
self.assertEqual(firefox.run("return getComputedStyle(document.querySelector('#shape_menu [data-shape=circle]'), '::before').content;"), '"✓"')
self.assertEqual(firefox.run("return [...document.querySelectorAll('#shape_menu [data-shape]')].map((i) => i.dataset.shape);"),
["rectangle", "circle", "ellipse", "line", "arrow"])
now = self.state()
count = len(strokes(now))
now_at = lambda fx, fy: (now["origin"][0] + fx * now["page"]["width"], now["origin"][1] + fy * now["page"]["height"])
firefox.run(TEXT_GESTURE, 0.3, 0.3, 0.33, 0.34)
with_circle = self.wait_until(lambda s: len(strokes(s)) == count + 1 and strokes(s)[-1]["done"], "the circle")
circle = strokes(with_circle)[-1]
self.assertEqual(circle["shape"], "circle")
cx, cy = now_at(0.3, 0.3)
radius = math.hypot(*(a - b for a, b in zip(now_at(0.33, 0.34), (cx, cy))))
xs, ys = circle["points"][0::3], circle["points"][1::3]
for x, y in zip(xs, ys):
self.assertAlmostEqual(math.hypot(x - cx, y - cy), radius, delta=1)
self.assertEqual((xs[0], ys[0]), (xs[-1], ys[-1]), "closed")
choose("arrange")
right, bottom = max(xs), max(ys)
firefox.drag("#page", px(right) - 3, py(bottom) - 3, px(right) + 90, py(bottom) + 30)
resized = self.wait_until(lambda s: strokes(s)[-1]["points"] != circle["points"], "the circle resized")
rx, ry = strokes(resized)[-1]["points"][0::3], strokes(resized)[-1]["points"][1::3]
ncx, ncy = (min(rx) + max(rx)) / 2, (min(ry) + max(ry)) / 2
nr = (max(rx) - min(rx)) / 2
self.assertGreater(nr, radius + 40 / scale, "the radius grew by half the drag")
self.assertAlmostEqual(max(ry) - min(ry), max(rx) - min(rx), delta=1, msg="still round")
for x, y in zip(rx, ry):
self.assertAlmostEqual(math.hypot(x - ncx, y - ncy), nr, delta=1)
def test_eraser_preview(self):
"""Andy, 2026-10-14: with the Eraser chosen, a red box around each
element a press under the hovering pointer would erase: a stroke by
the ink's extent, a text block, an image a tap would remove; none
on empty paper, none with another tool."""
firefox = self.firefox
firefox.go(self.address("Host"))
self.enter_name("Eraser", "start_session")
self.wait_until(lambda s: s["page"]["number"] == 1, "the page")
choose = lambda tool: firefox.run(f"document.querySelector('[data-tool={tool}]').click();")
cues = lambda: firefox.run("const p = document.getElementById('page').getBoundingClientRect(); return [...document.querySelectorAll('.erase_cue')]"
".map((c) => { const b = c.getBoundingClientRect(); return [b.left - p.left, b.top - p.top, b.right - p.left, b.bottom - p.top, getComputedStyle(c).borderTopColor]; });")
firefox.run(PEN_WAVE, 0.3, 0.5)
choose("text")
firefox.run(TEXT_GESTURE, 0.1, 0.7, 0.3, 0.8)
firefox.type_into(".text_field", "Box")
choose("pen")
firefox.run_until_done(LOAD_IMAGE, 300, 200, "png", 0, False)
state = self.wait_until(lambda s: len(s["layers"]["Eraser"]) == 1 and s["layers"]["Eraser"][0]["done"] and s["texts"]["Eraser"]
and s["texts"]["Eraser"][0]["text"] == "Box" and len(s["images"]) == 1 and s["image"]["ready"], "the stroke, the box and the image")
origin = state["origin"]
scale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;")
px = lambda x: (x - origin[0]) * scale
py = lambda y: (y - origin[1]) * scale
hover_at = lambda x, y: firefox.run(HOVER, (x - origin[0]) / state["page"]["width"], (y - origin[1]) / state["page"]["height"])
stroke = state["layers"]["Eraser"][0]
p = stroke["points"]
halves = [stroke["width"] * (0.36 + 4 * q * q) / 2 for q in p[2::3]]
left = min(x - h for x, h in zip(p[0::3], halves))
right = max(x + h for x, h in zip(p[0::3], halves))
top = min(y - h for y, h in zip(p[1::3], halves))
bottom = max(y + h for y, h in zip(p[1::3], halves))
# With the pen, nothing; with the eraser, the stroke's ink boxed in red.
hover_at(p[0], p[1])
self.assertEqual(cues(), [])
choose("eraser")
hover_at(p[0], p[1])
boxes = cues()
self.assertEqual(len(boxes), 1)
self.assertEqual(boxes[0][4], "rgb(229, 57, 53)")
for got, want in zip(boxes[0], overlay_box(px(left), py(top), px(right), py(bottom), 2)):
self.assertAlmostEqual(got, want, delta=0.01)
# The text block, by its box; the image, by a tap's rule; empty
# paper, nothing; the cue goes when the pointer leaves the page.
box = state["texts"]["Eraser"][0]
hover_at(box["x"] + 20, box["y"] + 20)
boxes = cues()
self.assertEqual(len(boxes), 1)
self.assertTrue(firefox.run(HOVER_CUE)["hidden"], "no blue cue beside the red one")
for got, want in zip(boxes[0], overlay_box(px(box["x"]), py(box["y"]), px(box["x"] + box["width"]), py(box["y"] + box["height"]), 2)):
self.assertAlmostEqual(got, want, delta=0.01)
im = state["images"][0]
hover_at(im["x"] + im["width"] / 2, im["y"] + im["height"] / 2)
boxes = cues()
self.assertEqual(len(boxes), 1)
for got, want in zip(boxes[0], overlay_box(px(im["x"]), py(im["y"]), px(im["x"] + im["width"]), py(im["y"] + im["height"]), 2)):
self.assertAlmostEqual(got, want, delta=0.01)
hover_at(origin[0] + 0.9 * state["page"]["width"], origin[1] + 0.9 * state["page"]["height"])
self.assertEqual(cues(), [])
hover_at(p[0], p[1])
self.assertEqual(len(cues()), 1)
firefox.run("document.getElementById('page').dispatchEvent(new PointerEvent('pointerleave', {pointerId: 9, pointerType: 'mouse', bubbles: true}));")
self.assertEqual(cues(), [])
# Choosing another tool clears it.
hover_at(p[0], p[1])
self.assertEqual(len(cues()), 1)
choose("pen")
self.assertEqual(cues(), [])
def test_arrange_handwriting(self):
"""Andy, 2026-10-14: under Arrange a handwritten stroke gets its
box while the pointer hovers over its ink, kept while the pointer
stays within the box; a press on the ink moves it, on the box's
corner scales it; the hold with the pen leaves it alone."""
firefox = self.firefox
firefox.go(self.address("Host"))
self.enter_name("Scribe", "start_session") # Not "Writer": another test counts the pages kept under that name.
self.wait_until(lambda s: s["page"]["number"] == 1, "the page")
choose = lambda tool: firefox.run(f"document.querySelector('[data-tool={tool}]').click();")
cues = lambda: firefox.run("return [...document.querySelectorAll('.arrange_cue')].map((c) => c.className.split(' ')[1]);")
firefox.run(PEN_WAVE, 0.3, 0.5)
state = self.wait_until(lambda s: len(s["layers"]["Scribe"]) == 1 and s["layers"]["Scribe"][0]["done"], "the stroke")
origin = state["origin"]
scale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;")
px = lambda x: (x - origin[0]) * scale
py = lambda y: (y - origin[1]) * scale
hover_at = lambda x, y: firefox.run(HOVER, (x - origin[0]) / state["page"]["width"], (y - origin[1]) / state["page"]["height"])
stroke = state["layers"]["Scribe"][0]
p = stroke["points"]
halves = [stroke["width"] * (0.36 + 4 * q * q) / 2 for q in p[2::3]]
left = min(x - h for x, h in zip(p[0::3], halves))
right = max(x + h for x, h in zip(p[0::3], halves))
top = min(y - h for y, h in zip(p[1::3], halves))
bottom = max(y + h for y, h in zip(p[1::3], halves))
choose("arrange")
self.assertEqual(cues(), [], "no box until the pointer is over the ink")
hover_at(p[0], p[1])
self.assertEqual(cues(), ["stroke"])
page_box = firefox.run("const b = document.getElementById('page').getBoundingClientRect(); return [b.left, b.top];")
got = firefox.run("const b = document.querySelector('.arrange_cue.stroke').getBoundingClientRect(); return [b.left, b.top, b.right, b.bottom];")
want = overlay_box(px(left), py(top), px(right), py(bottom), 2)
for side, g, w in zip(("left", "top", "right", "bottom"), (got[0] - page_box[0], got[1] - page_box[1], got[2] - page_box[0], got[3] - page_box[1]), want):
self.assertAlmostEqual(g, w, delta=0.01, msg=side)
# Off the ink but within the box, the box stays; outside it, it goes.
hover_at(left + 2, top + 2)
self.assertEqual(cues(), ["stroke"], "kept within the box")
hover_at(right + 200, bottom + 200)
self.assertEqual(cues(), [], "gone outside the box")
# A drag on the ink moves the whole stroke; Undo takes it back.
firefox.drag("#page", px(p[0]), py(p[1]), px(p[0]) + 120, py(p[1]) + 60)
moved = self.wait_until(lambda s: s["layers"]["Scribe"][0]["points"] != p, "the stroke moved")
q = moved["layers"]["Scribe"][0]["points"]
self.assertEqual(len(q), len(p))
self.assertAlmostEqual(q[0] - p[0], 120 / scale, delta=2)
self.assertAlmostEqual(q[1] - p[1], 60 / scale, delta=2)
self.assertEqual(q[2::3], p[2::3], "the pressures stay")
self.assertAlmostEqual(max(q[0::3]) - min(q[0::3]), max(p[0::3]) - min(p[0::3]), delta=0.3, msg="the same size")
firefox.run("document.getElementById('undo').click();")
self.wait_until(lambda s: s["layers"]["Scribe"][0]["points"] == p, "the move undone")
# A drag on the shown box's bottom-right corner scales the stroke.
hover_at(p[0], p[1])
self.assertEqual(cues(), ["stroke"])
firefox.drag("#page", px(right) + 3, py(bottom) + 3, px(right) + 150, py(bottom) + 40)
scaled = self.wait_until(lambda s: s["layers"]["Scribe"][0]["points"] != p, "the stroke scaled")
q = scaled["layers"]["Scribe"][0]["points"]
self.assertAlmostEqual(min(q[0::3]), min(p[0::3]), delta=1, msg="the left edge stays")
self.assertGreater(max(q[0::3]) - min(q[0::3]), max(p[0::3]) - min(p[0::3]) + 100 / scale)
self.assertEqual(scaled["layers"]["Scribe"][0]["width"], stroke["width"], "the line width unchanged")
# The hold with the pen draws a dot and leaves the stroke alone.
choose("pen")
self.assertEqual(cues(), [])
firefox.run_until_done(PEN_HOLD, px(q[0]), py(q[1]), 40, 20, 700)
held = self.wait_until(lambda s: len(s["layers"]["Scribe"]) == 2 and s["layers"]["Scribe"][1]["done"], "the pen's own stroke")
self.assertEqual(held["layers"]["Scribe"][0]["points"], q, "the handwriting not moved by a hold")
def test_page_menu_and_help(self): def test_page_menu_and_help(self):
"""Andy, 2026-10-14: the page and view commands are in a Page menu, """Andy, 2026-10-14: the page and view commands are in a Page menu,