diff --git a/client/index.html b/client/index.html
index bacbde6..29a0912 100644
--- a/client/index.html
+++ b/client/index.html
@@ -45,14 +45,29 @@
+ data-help="Type on the page. Tap where the text should begin, or drag open a rectangle in which the text wraps and which grows downward as you type; its corners are marked in blue on the screen only. Tap your own text to change it; rest the pen on it to move it, or near a corner of a rectangle to resize it, or use Arrange.">Text
+
+
+
+
+
+
+
+
Fill the shapes with
+
+
-
+
diff --git a/client/js/main.js b/client/js/main.js
index 4d50636..1636112 100644
--- a/client/js/main.js
+++ b/client/js/main.js
@@ -18,12 +18,16 @@ import { pageFromSvg, pageToSvg } from './page_file.js';
import { hashOf } from './crypto.js';
import { imageResolution } from './image_info.js';
import {
- LEAST_TEXT_POINTS, MAX_IMAGE_BYTES, MOST_TEXT_POINTS, TEXT_FONTS, TEXT_SIZE,
- UNITS_PER_POINT, strokesTouched, textAt, textsTouched,
+ LEAST_TEXT_POINTS, MAX_IMAGE_BYTES, MOST_TEXT_POINTS, POINT_SIZE, TEXT_FONTS, TEXT_SIZE,
+ UNITS_PER_POINT, rounded, strokeBox, strokesTouched, textAt, textsTouched,
} from './page_model.js';
-import { attachPenInput } from './pen_input.js';
+import { HOLD_MS, NOMINAL_PRESSURE, attachPenInput } from './pen_input.js';
import { Renderer } from './renderer.js';
import { LEAST_IMAGE_SIDE, textBounds, rectAt } from './page_model.js';
+import {
+ SHAPE_KEY, SHAPE_NAMES, draggedPoints, isFilled, isTapped, lineInProgressPoints, movedPoints, shapeNamed,
+ straightPoints, vertexPoints,
+} from './shapes.js';
import { LINE_HEIGHT, TOP_GAP, layoutText } from './text_layout.js';
import { log, logText, trace, watchLog } from './log.js';
import { Session } from './session.js';
@@ -87,6 +91,11 @@ const PALETTE = [
'#ffffff', '#bdbdbd', '#757575', '#424242', '#8d6e63', '#5d4037', '#3e2723', '#000000',
];
const NOTEBOOK_KEY = 'kokkoja_notebook';
+/** The Shape tool's fill colour, remembered in this browser; 'none' for none. */
+const FILL_KEY = 'kokkoja_fill';
+
+/** @typedef {import('./pen_input.js').Tool} Tool */
+/** @typedef {import('./shapes.js').Shape} Shape */
/**
* Where the installed Kokkoja program serves the client, for a guest who
@@ -461,7 +470,11 @@ async function enterSession(invitationText, name, notebook) {
showInvitationCode(config.site ? input('invitation').value : '');
const isHost = identity.id === invitation.hostId;
- let tool = /** @type {'pen' | 'eraser' | 'text' | 'hide' | 'arrange'} */ ('pen');
+ let tool = /** @type {Tool} */ ('pen');
+ /** The Shape tool's current shape, remembered in this browser (Andy, 2026-10-14). */
+ let shape = shapeNamed(localStorage.getItem(SHAPE_KEY));
+ /** The colour a shape is filled with, or null (Andy, 2026-10-14); separate from the line's colour. */
+ let fill = /^#[0-9a-f]{6}$/.exec(localStorage.getItem(FILL_KEY) ?? '')?.[0] ?? null;
let color = DEFAULT_COLOR;
let width = WIDTHS[1];
let textSize = TEXT_SIZE;
@@ -501,12 +514,16 @@ async function enterSession(invitationText, name, notebook) {
onLayerReplaced: (layerId) => {
syncLayers();
renderer.redrawLayer(layerId);
+ if (layerId === session.identity.id) refreshArrangeCues();
writer.changed();
},
onStrokeProgress: (layerId, stroke) => {
syncLayers();
renderer.strokeProgress(layerId, stroke);
- if (stroke.done) writer.changed();
+ if (stroke.done) {
+ if (stroke.shape && layerId === session.identity.id) refreshArrangeCues();
+ writer.changed();
+ }
},
onTextChanged: (layerId) => {
syncLayers();
@@ -524,6 +541,10 @@ async function enterSession(invitationText, name, notebook) {
if (abandoned && abandoned.text.trim() === '') {
oldPage.layers.get(session.identity.id)?.texts.delete(abandoned.id);
}
+ // A line being made likewise stays on the page being left (the
+ // session has ended its stroke).
+ lineInProgress = null;
+ renderer.hideShapePreview();
// Nothing is lost: the page being left is kept in the notebook.
writer.pageReplaced(oldPage.number > 0 ? fileOf(oldPage) : null);
renderer.setPage(newPage);
@@ -661,9 +682,11 @@ async function enterSession(invitationText, name, notebook) {
// Undo: one's own latest gesture, then the one before (Andy,
// 2026-10-11): the button, Ctrl+Z (Command+Z on a Mac) outside a field,
// and two quick taps of the eraser on empty paper (eraseEnded below).
- // A block being typed is finished first, so that the undo is of it.
+ // A block being typed, or a line being made, is finished first, so that
+ // the undo is of it.
const undo = () => {
textEditor?.stop();
+ finishLine();
const what = session.undo();
if (what) log(`Undid ${what}`);
};
@@ -674,6 +697,13 @@ async function enterSession(invitationText, name, notebook) {
event.preventDefault();
undo();
}
+ // A line being made: Escape finishes it, Backspace takes back its
+ // last point.
+ if (lineInProgress && !inField() && (event.key === 'Escape' || event.key === 'Backspace')) {
+ event.preventDefault();
+ if (event.key === 'Escape') finishLine();
+ else removeLastVertex();
+ }
});
// Whether one's own writing may grow the page (the "Extend" box).
@@ -704,15 +734,15 @@ async function enterSession(invitationText, name, notebook) {
let reportedDevice = '';
/**
- * What a hold of the pen is handling: a Hide rectangle, a text block of
- * ours, or an image; which; whether a corner is being dragged (and then
- * which, with the opposite one fixed); and how the pen's position maps
- * to the rectangle. Images keep their shape when scaled; the others
- * resize freely (Andy, 2026-10-08).
+ * What a hold of the pen is handling: a Hide rectangle, a text block or
+ * a shape of ours, or an image; which; whether a corner is being
+ * dragged (and then which, with the opposite one fixed); and how the
+ * pen's position maps to the rectangle. Images keep their shape when
+ * scaled; the others resize freely (Andy, 2026-10-08).
* @typedef {object} Held
- * @property {'hide' | 'text' | 'image'} kind
+ * @property {'hide' | 'text' | 'shape' | 'image'} kind
* @property {number} index Of the Hide rectangle or image.
- * @property {string} id Of the text block.
+ * @property {string} id Of the text block or the shape's stroke.
* @property {[number, number] | null} corner
* @property {[number, number]} fixed
* @property {number} aspect
@@ -780,36 +810,189 @@ async function enterSession(invitationText, name, notebook) {
const image = page.images[h.index];
return image ? { left: image.x, top: image.y, width: image.width, height: image.height } : null;
}
+ if (h.kind === 'shape') {
+ const stroke = session.ownLayer.strokes.get(h.id);
+ return stroke ? strokeBox(stroke) : null;
+ }
const block = session.ownLayer.texts.get(h.id);
return block ? textBounds(block) : null;
}
+ /** @returns {import('./page_model.js').Stroke[]} Our shapes, in drawing order. */
+ 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:
- * the images and Hides (for the host and co-hosts) and one's own text
- * rectangles; free text can be moved but has no corners. Called
- * whenever any of them, or the right, may have changed.
+ * With the Arrange tool chosen, mark what a press can move or resize
+ * and is not marked already: the images (for the host and co-hosts),
+ * whose edges may not show, and one's own shapes (Andy, 2026-10-14).
+ * Text blocks and Hides, the implied rectangles, show their corners
+ * under a hovering pointer with any tool, so the tool adds nothing on
+ * them. Called whenever the images, the shapes, or the right may have
+ * changed.
*/
function refreshArrangeCues() {
if (tool !== 'arrange') {
renderer.setArrangeCues([]);
return;
}
- const page = session.page;
- /** @type {{rect: import('./page_model.js').Rectangle, kind: 'image' | 'text' | 'hide'}[]} */
+ /** @type {{rect: import('./page_model.js').Rectangle, kind: 'image' | 'text' | 'hide' | 'shape'}[]} */
const cues = [];
if (session.mayMakePages()) {
- for (const im of page.images) cues.push({ kind: 'image', rect: { left: im.x, top: im.y, width: im.width, height: im.height } });
+ for (const im of session.page.images) cues.push({ kind: 'image', rect: { left: im.x, top: im.y, width: im.width, height: im.height } });
}
- for (const block of session.ownLayer.texts.values()) {
- if (block.height !== null) cues.push({ kind: 'text', rect: textBounds(block) });
- }
- if (session.mayMakePages()) {
- for (const h of page.hides) cues.push({ kind: 'hide', rect: { left: h.x, top: h.y, width: h.width, height: h.height } });
+ for (const stroke of ownShapes()) {
+ const rect = strokeBox(stroke);
+ if (rect) cues.push({ kind: 'shape', rect });
}
renderer.setArrangeCues(cues);
}
+ // 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
+ // lifts, a preview showing meanwhile; a line is made of taps, one
+ // vertex each, its stroke growing for everyone with each tap, a rubber
+ // band from the last vertex following a hovering pointer, until a tap
+ // on the last vertex (finished open) or on the first (closed), Escape,
+ // or a change of tool or shape.
+
+ /** @type {{stroke: import('./page_model.js').Stroke, vertices: [number, number][]} | null} */
+ let lineInProgress = null;
+
+ /** The line being made is complete. */
+ function finishLine() {
+ const line = lineInProgress;
+ if (!line) return;
+ lineInProgress = null;
+ renderer.hideShapePreview();
+ if (session.currentStroke === line.stroke) session.endStroke();
+ log(`Line of ${line.vertices.length} points`);
+ }
+
+ /**
+ * Backspace while a line is made: its last vertex goes. A stroke in
+ * progress cannot lose points by any message, so the stroke is erased
+ * and begun again with the vertices left.
+ */
+ function removeLastVertex() {
+ const line = lineInProgress;
+ if (!line) return;
+ line.vertices.pop();
+ if (session.currentStroke === line.stroke) session.endStroke(false);
+ session.erase([line.stroke.id]);
+ if (line.vertices.length === 0) {
+ lineInProgress = null;
+ renderer.hideShapePreview();
+ return;
+ }
+ line.stroke = session.beginStroke(color, width, { fill, kind: 'line' });
+ session.addPoints(lineInProgressPoints(line.vertices));
+ }
+
+ /**
+ * Whether a shape's points all lie where the page need not grow, or
+ * Extend is on. With Extend off a shape is drawn whole or not at all,
+ * since dropping its points beyond the edge, as a stroke's are, would
+ * deform it.
+ * @param {number[]} points
+ */
+ function pageHolds(points) {
+ if (extend) return true;
+ for (let i = 0; i < points.length; i += POINT_SIZE) {
+ if (!session.page.holdsWriting(points[i], points[i + 1], width)) return false;
+ }
+ return true;
+ }
+
+ /**
+ * The pointer went down at (x0, y0) and up at (x1, y1) with the Shape
+ * tool.
+ * @param {number} x0
+ * @param {number} y0
+ * @param {number} x1
+ * @param {number} y1
+ */
+ function placeShape(x0, y0, x1, y1) {
+ renderer.hideShapePreview();
+ const dragged = Math.hypot(x1 - x0, y1 - y0) >= TAP_DISTANCE;
+ /** @param {number} v */
+ const r = (v) => rounded(v, 10);
+ if (!isTapped(shape)) {
+ if (!dragged) return;
+ const points = draggedPoints(shape, r(x0), r(y0), r(x1), r(y1), width);
+ if (!pageHolds(points)) {
+ trace('shape refused: it would reach past the page with Extend off');
+ return;
+ }
+ textEditor?.stop();
+ session.beginStroke(color, width, { filled: isFilled(shape), fill: isFilled(shape) ? null : fill, kind: shape });
+ session.addPoints(points);
+ session.endStroke();
+ log(`${SHAPE_NAMES[shape]} drawn${fill && !isFilled(shape) ? `, filled ${fill}` : ''}`);
+ return;
+ }
+ // A line: each press and release gives a vertex, the release point.
+ const vx = r(x1);
+ const vy = r(y1);
+ if (lineInProgress && session.currentStroke !== lineInProgress.stroke) lineInProgress = null;
+ const line = lineInProgress;
+ if (line) {
+ const reach = renderer.cornerReach();
+ const [fx, fy] = line.vertices[0];
+ const [lx, ly] = line.vertices[line.vertices.length - 1];
+ if (line.vertices.length >= 2 && Math.hypot(vx - fx, vy - fy) <= reach) {
+ session.addPoints([fx, fy, NOMINAL_PRESSURE]); // Closed: back to the first point.
+ finishLine();
+ } else if (Math.hypot(vx - lx, vy - ly) <= reach) {
+ finishLine();
+ } else if (pageHolds([vx, vy, NOMINAL_PRESSURE])) {
+ line.vertices.push([vx, vy]);
+ session.addPoints(vertexPoints(vx, vy));
+ } else {
+ trace('line point refused: beyond the page with Extend off');
+ }
+ return;
+ }
+ // The first vertex; a drag gives the first two.
+ /** @type {[number, number][]} */
+ const vertices = dragged ? [[r(x0), r(y0)], [vx, vy]] : [[vx, vy]];
+ const points = lineInProgressPoints(vertices);
+ if (!pageHolds(points)) {
+ trace('line refused: beyond the page with Extend off');
+ return;
+ }
+ textEditor?.stop();
+ const stroke = session.beginStroke(color, width, { fill, kind: 'line' });
+ lineInProgress = { stroke, vertices };
+ session.addPoints(points);
+ }
+
+ /**
+ * The implied rectangle under a point, for the hover cue (Andy,
+ * 2026-10-14: text rectangles and Hides show their corners only then,
+ * like free text): the frontmost Hide, else the frontmost text block
+ * among the layers shown.
+ * @param {number} x
+ * @param {number} y
+ * @returns {import('./page_model.js').Rectangle | null}
+ */
+ function impliedRectAt(x, y) {
+ /** @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;
+ for (const h of [...session.page.hides].reverse()) {
+ const b = { left: h.x, top: h.y, width: h.width, height: h.height };
+ if (holds(b)) return b;
+ }
+ for (const id of session.layerOrder().reverse()) {
+ const layer = session.page.layers.get(id);
+ if (!layer || renderer.hidden.has(id)) continue;
+ for (const block of [...layer.texts.values()].reverse()) {
+ const b = textBounds(block);
+ if (holds(b)) return b;
+ }
+ }
+ return null;
+ }
+
/** @type {import('./pen_input.js').PenTarget} */
const penTarget = {
toPage: (clientX, clientY) => renderer.pageFromClient(clientX, clientY),
@@ -847,12 +1030,20 @@ async function enterSession(invitationText, name, notebook) {
} else {
const blocks = [...session.ownLayer.texts.values()];
const at = rectAt(blocks.map(textBounds), x, y, reach);
+ const shapes = at ? [] : ownShapes();
+ const shapeAt = at ? null : rectAt(shapes.map((s) => strokeBox(s) ?? { left: 0, top: 0, width: 0, height: 0 }), x, y, reach);
if (at) {
const block = blocks[at.index];
// Free text has no corners to resize by.
const corner = block.height === null ? null : at.corner;
found = hold('text', at.index, block.id, textBounds(block), corner, x, y);
session.beginTextEdit(block.id, 'hold');
+ } else if (shapeAt) {
+ // A shape of ours (Andy, 2026-10-14): moved, or resized from a
+ // corner, by the rectangle around its points.
+ const stroke = shapes[shapeAt.index];
+ const box = /** @type {import('./page_model.js').Rectangle} */ (strokeBox(stroke));
+ if (session.beginShapeEdit(stroke.id)) found = hold('shape', shapeAt.index, stroke.id, box, shapeAt.corner, x, y);
} else if (session.mayMakePages()) {
const image = page.imageAt(x, y, reach);
if (image) {
@@ -885,6 +1076,12 @@ async function enterSession(invitationText, name, notebook) {
held.index = session.placeImageByHand(held.index, rect);
} else if (held.kind === 'hide') {
session.placeHideByHand(held.index, rect);
+ } else if (held.kind === 'shape') {
+ const stroke = session.ownLayer.strokes.get(held.id);
+ if (!stroke) return;
+ session.setShapePoints(held.id, movedPoints(stroke, now, {
+ left: Math.round(rect.left), top: Math.round(rect.top), width: Math.round(rect.width), height: Math.round(rect.height),
+ }));
} else {
const block = session.ownLayer.texts.get(held.id);
if (!block) return;
@@ -906,8 +1103,9 @@ async function enterSession(invitationText, name, notebook) {
if (!held) return;
const r = currentHeldRect(held);
if (r) log(`${held.kind} placed at ${r.left / 100}, ${r.top / 100} cm, ${r.width / 100} by ${r.height / 100} cm`);
- if (held.kind !== 'text') session.sendPageNow();
+ if (held.kind === 'image' || held.kind === 'hide') session.sendPageNow();
if (held.kind === 'text') session.endTextEdit(held.id, 'hold');
+ if (held.kind === 'shape') session.endShapeEdit();
if (held.kind === 'hide') session.endHideEdit();
held = null;
renderer.showGrip(null);
@@ -960,13 +1158,47 @@ async function enterSession(invitationText, name, notebook) {
}
},
previewRect: (x0, y0, x1, y1) => {
- if (Math.abs(x1 - x0) >= TAP_DISTANCE || Math.abs(y1 - y0) >= TAP_DISTANCE) {
+ const dragged = Math.hypot(x1 - x0, y1 - y0) >= TAP_DISTANCE;
+ if (tool === 'shape') {
+ // The shape as it will be, in the pen's colour and width; for a
+ // line, the side the press is adding.
+ if (isTapped(shape)) {
+ /** @type {[number, number]} */
+ const from = lineInProgress ? lineInProgress.vertices[lineInProgress.vertices.length - 1] : [x0, y0];
+ if (lineInProgress || dragged) renderer.showShapePreview(straightPoints([from, [x1, y1]]), color, width, false);
+ } else if (dragged) {
+ renderer.showShapePreview(draggedPoints(shape, x0, y0, x1, y1, width), color, width, isFilled(shape));
+ } else {
+ renderer.hideShapePreview();
+ }
+ return;
+ }
+ if (dragged) {
textEditor?.showPreview(x0, y0, x1, y1);
} else {
textEditor?.hidePreview();
}
},
+ // A hovering pointer: the rubber band of a line being made, and the
+ // corners of free text under it (Andy, 2026-10-14: free text is used
+ // constantly, so its extent shows only then).
+ hover: (clientX, clientY) => {
+ const [x, y] = renderer.pageFromClient(clientX, clientY);
+ if (lineInProgress && tool === 'shape') {
+ const last = lineInProgress.vertices[lineInProgress.vertices.length - 1];
+ renderer.showShapePreview(straightPoints([last, [x, y]]), color, width, false);
+ }
+ renderer.showHoverCue(impliedRectAt(x, y));
+ },
+ hoverEnded: () => {
+ renderer.showHoverCue(null);
+ if (lineInProgress) renderer.hideShapePreview();
+ },
placeRect: (x0, y0, x1, y1) => {
+ if (tool === 'shape') {
+ placeShape(x0, y0, x1, y1);
+ return;
+ }
if (!textEditor) return;
textEditor.hidePreview();
const dragged = Math.hypot(x1 - x0, y1 - y0) >= TAP_DISTANCE;
@@ -1004,19 +1236,32 @@ async function enterSession(invitationText, name, notebook) {
attachPenInput(pageElement, penTarget);
buildToolbar({
- color, width, textSize, textFont,
+ color, width, textSize, textFont, shape, fill,
setTool: (value) => {
if (value !== 'text') textEditor?.stop();
+ if (value !== 'shape') finishLine();
if (value !== tool) trace(`Tool: ${value}`);
tool = value;
- // Only the chosen tool's settings show: the pen's colours and line
- // widths, the text's colours, size and font, nothing for the
- // eraser, Hide or Arrange.
+ // Only the chosen tool's settings show: the pen's and the shapes'
+ // colours and line widths, the text's colours, size and font,
+ // nothing for the eraser, Hide or Arrange.
element('colors').hidden = value === 'eraser' || value === 'hide' || value === 'arrange';
- element('widths').hidden = value !== 'pen';
+ element('widths').hidden = value !== 'pen' && value !== 'shape';
element('text_style').hidden = value !== 'text';
refreshArrangeCues();
},
+ setShape: (value) => {
+ finishLine();
+ if (value !== shape) trace(`Shape: ${value}`);
+ shape = value;
+ localStorage.setItem(SHAPE_KEY, value);
+ },
+ setFill: (value) => {
+ finishLine();
+ fill = value;
+ localStorage.setItem(FILL_KEY, value ?? 'none');
+ trace(`Fill: ${value ?? 'none'}`);
+ },
setColor: (value) => {
color = value;
textEditor?.restyle({ color });
@@ -1305,25 +1550,25 @@ const popupClosers = new Set();
/**
* A popup of Kokkoja's own below a toolbar button (the colour palette,
- * the Page menu, the Help note; no system menu or dialog, 2026-10-14).
- * The button's press opens it below the button, left edges aligned, or
- * right edges when it would run past the window; a press anywhere else,
- * Escape, or a press on an item marked `.menu_item` closes it.
+ * the Page menu, the Help note, the shapes; no system menu or dialog,
+ * 2026-10-14). The button's press opens it below the button, left edges
+ * aligned, or right edges when it would run past the window; a press
+ * anywhere else, Escape, or a press on an item marked `.menu_item`
+ * closes it.
* @param {HTMLElement} button
* @param {HTMLElement} popup
* @param {() => void} [onOpen] Fill the popup before it shows.
+ * @param {boolean} [byClick] Whether the button's click opens and closes
+ * it; the Shape button opens its menu by a hold instead.
+ * @returns {{open: () => void, close: () => void}}
*/
-export function attachPopup(button, popup, onOpen) {
+export function attachPopup(button, popup, onOpen, byClick = true) {
const close = () => {
popup.hidden = true;
button.setAttribute('aria-expanded', 'false');
};
popupClosers.add(close);
- button.addEventListener('click', () => {
- if (!popup.hidden) {
- close();
- return;
- }
+ const open = () => {
// One popup at a time (a keyboard's Enter opens without the press
// that would close the other).
for (const other of popupClosers) other();
@@ -1338,7 +1583,13 @@ export function attachPopup(button, popup, onOpen) {
const width = popup.getBoundingClientRect().width;
const fits = box.left + width <= window.innerWidth - 8;
popup.style.left = `${fits ? box.left : Math.max(8, box.right - width)}px`;
- });
+ };
+ if (byClick) {
+ button.addEventListener('click', () => {
+ if (popup.hidden) open();
+ else close();
+ });
+ }
popup.addEventListener('click', (event) => {
const item = /** @type {Element} */ (event.target).closest('.menu_item');
// A label's click reaches here twice, once as the input's; close on the second.
@@ -1351,6 +1602,7 @@ export function attachPopup(button, popup, onOpen) {
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && !popup.hidden) close();
});
+ return { open, close };
}
/**
@@ -1359,24 +1611,133 @@ export function attachPopup(button, popup, onOpen) {
* @param {number} options.width Initial width.
* @param {number} options.textSize
* @param {import('./page_model.js').TextFont} options.textFont
- * @param {(tool: 'pen' | 'eraser' | 'text' | 'hide' | 'arrange') => void} options.setTool
+ * @param {Shape} options.shape The Shape tool's shape to begin with.
+ * @param {string | null} options.fill Its fill colour, or none.
+ * @param {(tool: Tool) => void} options.setTool
+ * @param {(shape: Shape) => void} options.setShape
+ * @param {(fill: string | null) => void} options.setFill
* @param {(color: string) => void} options.setColor
* @param {(width: number) => void} options.setWidth
* @param {(size: number) => void} options.setTextSize
* @param {(font: import('./page_model.js').TextFont) => void} options.setTextFont
*/
function buildToolbar(options) {
+ const shapeButton = element('shape_tool');
+ /** The Shape button's hold has just opened the menu: the click that follows chooses nothing. */
+ let shapeMenuOpened = false;
+ /** @param {Element} button */
+ const chooseTool = (button) => {
+ // The tool buttons stand in two groups since 2026-10-11 (Pen, Text
+ // and the shapes before the colours they use, Hide, Arrange and
+ // Eraser after), so the pressed one is marked among all of them, not
+ // among siblings.
+ for (const other of document.querySelectorAll('[data-tool]')) {
+ other.setAttribute('aria-pressed', String(other === button));
+ }
+ options.setTool(/** @type {Tool} */ (button.getAttribute('data-tool')));
+ };
for (const button of document.querySelectorAll('[data-tool]')) {
button.addEventListener('click', () => {
- // The tool buttons stand in two groups since 2026-10-11 (Pen and
- // Text before the colours they use, Eraser and Hide after), so the
- // pressed one is marked among all of them, not among siblings.
- for (const other of document.querySelectorAll('[data-tool]')) {
- other.setAttribute('aria-pressed', String(other === button));
+ if (button === shapeButton && shapeMenuOpened) {
+ shapeMenuOpened = false;
+ return;
}
- const chosen = button.getAttribute('data-tool');
- options.setTool(chosen === 'eraser' ? 'eraser' : chosen === 'text' ? 'text' : chosen === 'hide' ? 'hide'
- : chosen === 'arrange' ? 'arrange' : 'pen');
+ chooseTool(button);
+ });
+ }
+
+ // The Shape button names the current shape, Rectangle to begin with,
+ // and a quick press chooses the tool; a press held for HOLD_MS, a
+ // press with the right button (a pen's barrel button) or a double
+ // click (Andy, 2026-10-14) opens the menu of shapes, and the shape
+ // chosen there is the button's from then on and the tool is chosen
+ // (Andy, 2026-10-14: one button for any number of shapes). The menu
+ // marks the current shape, and ends with the fill colours. The button
+ // keeps the width of its widest name, so that nothing in the toolbar
+ // moves when the shape changes.
+ const shapeMenu = element('shape_menu');
+ const shapePopup = attachPopup(shapeButton, shapeMenu, undefined, false);
+ let widest = 0;
+ for (const name of Object.values(SHAPE_NAMES)) {
+ shapeButton.textContent = name;
+ widest = Math.max(widest, shapeButton.getBoundingClientRect().width);
+ }
+ shapeButton.style.minWidth = `${Math.ceil(widest)}px`;
+ /** @param {Shape} shape */
+ const showShape = (shape) => {
+ shapeButton.textContent = SHAPE_NAMES[shape];
+ for (const item of shapeMenu.querySelectorAll('[data-shape]')) {
+ item.setAttribute('aria-checked', String(item.getAttribute('data-shape') === shape));
+ }
+ };
+ showShape(options.shape);
+ /** @type {ReturnType | null} */
+ let shapeHold = null;
+ const cancelShapeHold = () => {
+ if (shapeHold !== null) clearTimeout(shapeHold);
+ shapeHold = null;
+ };
+ const openShapeMenu = () => {
+ shapeMenuOpened = true;
+ shapePopup.open();
+ };
+ shapeButton.addEventListener('pointerdown', (event) => {
+ if (event.button !== 0) return;
+ shapeMenuOpened = false;
+ cancelShapeHold();
+ shapeHold = setTimeout(() => {
+ shapeHold = null;
+ openShapeMenu();
+ }, HOLD_MS);
+ });
+ for (const type of ['pointerup', 'pointercancel', 'pointerleave']) shapeButton.addEventListener(type, cancelShapeHold);
+ shapeButton.addEventListener('contextmenu', (event) => {
+ event.preventDefault();
+ cancelShapeHold();
+ openShapeMenu();
+ shapeMenuOpened = false; // No click follows a secondary press.
+ });
+ // A double click, told from two clicks within DOUBLE_TAP_MS rather than
+ // from the browser's dblclick event, which a pen or a touch screen does
+ // not always send; both clicks have chosen the tool already.
+ let lastShapeClick = -Infinity;
+ shapeButton.addEventListener('click', () => {
+ const now = performance.now();
+ if (now - lastShapeClick <= DOUBLE_TAP_MS) {
+ lastShapeClick = -Infinity;
+ openShapeMenu();
+ shapeMenuOpened = false;
+ } else {
+ lastShapeClick = now;
+ }
+ });
+ // The fill colours: the toolbar's colours, those added to it, and
+ // "none", a state of their own (Andy, 2026-10-14). A press keeps the
+ // menu open.
+ const fills = element('shape_fill');
+ /** @param {string | null} value */
+ const addFill = (value) => {
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.className = `swatch${value === null ? ' none' : ''}`;
+ if (value !== null) button.style.setProperty('--swatch', value);
+ button.setAttribute('aria-label', value === null ? 'No fill' : `Fill ${value}`);
+ button.setAttribute('data-help', value === null ? 'Draw the shapes unfilled.' : 'Fill the shapes with this color.');
+ button.setAttribute('aria-pressed', String(value === options.fill));
+ button.addEventListener('click', () => {
+ press(fills, button);
+ options.setFill(value);
+ });
+ fills.append(button);
+ };
+ addFill(null);
+ for (const value of COLORS) addFill(value);
+ for (const item of shapeMenu.querySelectorAll('[data-shape]')) {
+ item.addEventListener('click', () => {
+ const chosen = shapeNamed(item.getAttribute('data-shape'));
+ options.setShape(chosen);
+ showShape(chosen);
+ chooseTool(shapeButton);
});
}
@@ -1409,7 +1770,10 @@ function buildToolbar(options) {
}
for (const color of COLORS) addSwatch(color);
for (const color of added) {
- if (/^#[0-9a-f]{6}$/.test(color)) addSwatch(color);
+ if (/^#[0-9a-f]{6}$/.test(color)) {
+ addSwatch(color);
+ addFill(color);
+ }
}
// "+" opens a palette below it; a chosen colour becomes a swatch before
@@ -1423,6 +1787,7 @@ function buildToolbar(options) {
if (!existing) {
added.push(color);
localStorage.setItem(COLORS_KEY, JSON.stringify(added));
+ addFill(color); // The shapes' fills follow the toolbar's colours.
}
press(colors, button);
options.setColor(color);
diff --git a/client/js/manual.js b/client/js/manual.js
index 86ea544..12eb797 100644
--- a/client/js/manual.js
+++ b/client/js/manual.js
@@ -13,13 +13,17 @@ export const TITLE = 'User interface actions';
export const WIDTH = 80;
/**
- * What a control is called: a button's label; a label's own text, without
+ * What a control is called: the name it gives for this text, when its
+ * label changes (`data-name`; the Shape button is named for the shape it
+ * draws at the moment); a button's label; a label's own text, without
* its checkbox; else the name given for assistive technology, the text of
* the label that points at the control, or its id.
* @param {Element} el
* @returns {string}
*/
function nameOf(el) {
+ const given = el.getAttribute('data-name');
+ if (given) return given;
if (el instanceof HTMLLabelElement) {
return [...el.childNodes].filter((n) => n.nodeType === Node.TEXT_NODE)
.map((n) => n.textContent?.trim() ?? '').join(' ').trim();
diff --git a/client/js/page_file.js b/client/js/page_file.js
index ae56181..c792c15 100644
--- a/client/js/page_file.js
+++ b/client/js/page_file.js
@@ -16,6 +16,13 @@
* stroke="#1f4fd8" ...>
* what is shown
*
+ * a filled shape (an arrow)
+ *
+ *
+ *
+ * the fill beneath the line
+ *
+ *
*
* the text as typed
* one line as shown...
@@ -28,7 +35,7 @@
import { fromBase64, toBase64 } from './crypto.js';
import {
- POINT_SIZE, Page, cleanHide, cleanImage, cleanName, cleanPageDescriptor, cleanStroke, cleanText, pointCount, textBounds, validId,
+ POINT_SIZE, Page, cleanHide, cleanImage, cleanName, cleanPageDescriptor, cleanStroke, cleanText, isClosed, pointCount, validId,
} from './page_model.js';
import { partAt } from './stroke_geometry.js';
import { LEFT_GAP, baselineOf } from './text_layout.js';
@@ -36,14 +43,11 @@ import { LEFT_GAP, baselineOf } from './text_layout.js';
/** @typedef {import('./page_model.js').Stroke} Stroke */
const FORMAT_VERSION = '1';
-/** A text rectangle's frame: the palette's red, 0.2 mm (Andy's teacher, 2026-10-08). */
-export const TEXT_FRAME_COLOR = '#e53935';
-export const TEXT_FRAME_WIDTH = 2;
-/**
- * Free text is marked by an L at its top-left corner, two segments this
- * long (5 mm; 3 mm first), in the frame's red (Andy, 2026-10-09).
- */
-export const TEXT_MARK = 50;
+// (Until 2026-10-14 free text was marked by a red L of 5 mm at its
+// top-left corner, page content, and until 2026-10-14 a text rectangle
+// by a red frame on a white ground; a text block now draws only its
+// text, its extent shown by blue corners on screen while the pointer
+// hovers over it. An old file's marks are read as nothing.)
const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
/**
@@ -97,20 +101,11 @@ function textElements(block) {
const lines = block.lines.map((line, i) =>
`${escapedContent(line)}`);
- const bounds = textBounds(block);
- // A text rectangle's red frame, or free text's corner mark, is part of
- // the page (a rectangle's white ground is written beneath all layers:
- // see pageToSvg).
- const frame = block.height === null
- ? ``
- : ``;
return `` +
- `${escapedContent(block.text)}${frame}` +
+ `${escapedContent(block.text)}` +
`${lines.join('')}`;
}
@@ -134,12 +129,22 @@ function number(value) {
function strokeShapes(stroke) {
const count = pointCount(stroke);
if (count === 0) return '';
+ /** The closed polygon of the points, filled with a colour. */
+ const polygon = (/** @type {string} */ color) => {
+ const p = stroke.points;
+ let path = '';
+ for (let i = 0; i < p.length; i += POINT_SIZE) path += `${i ? 'L' : 'M'}${number(p[i])} ${number(p[i + 1])}`;
+ return ``;
+ };
+ // A filled stroke (the Shape tool's arrow): its outline, closed and filled.
+ if (stroke.filled) return polygon(stroke.color);
+ // A shape's fill colour lies beneath its line.
+ let shapes = stroke.fill && (stroke.shape !== 'line' || isClosed(stroke)) ? polygon(stroke.fill) : '';
if (count === 1) {
const dot = partAt(stroke, 0);
- return ``;
}
- let shapes = '';
let path = '';
let pathWidth = '';
for (let k = 0; k < count; k++) {
@@ -184,20 +189,9 @@ export function pageToSvg(page, { session, hostId, hidden }) {
const shown = imageElement(image);
if (shown) lines.push(shown);
}
- // The white grounds of the text rectangles: above the images, beneath
- // every layer, so that they hide the image and never anyone's writing.
- const grounds = [];
- for (const id of page.layerOrder(hostId)) {
- const layer = page.layers.get(id);
- if (!layer || hidden?.has(id)) continue;
- for (const block of layer.texts.values()) {
- if (block.height === null) continue;
- const b = textBounds(block);
- grounds.push(``);
- }
- }
- if (grounds.length) lines.push('', ...grounds, '');
+ // (Until 2026-10-14 a of white rectangles stood here,
+ // one beneath each text rectangle; a file that has one is read as if
+ // it had none.)
for (const id of page.layerOrder(hostId)) {
const layer = page.layers.get(id);
if (!layer || layer.isEmpty() || hidden?.has(id)) continue;
@@ -206,7 +200,9 @@ export function pageToSvg(page, { session, hostId, hidden }) {
if (pointCount(stroke) === 0) continue;
lines.push(
`${strokeShapes(stroke)}`);
}
for (const block of layer.texts.values()) lines.push(textElements(block));
@@ -289,6 +285,9 @@ export function pageFromSvg(text) {
width: Number(element.getAttribute('data-width')),
points,
done: true,
+ filled: element.hasAttribute('data-filled'),
+ fill: element.getAttribute('data-fill'),
+ shape: element.getAttribute('data-shape'),
});
if (stroke && points.length >= POINT_SIZE) layer.strokes.set(stroke.id, stroke);
}
diff --git a/client/js/page_model.js b/client/js/page_model.js
index daa85c7..adfee7f 100644
--- a/client/js/page_model.js
+++ b/client/js/page_model.js
@@ -35,8 +35,24 @@ export const IMAGE_MARGIN = 50;
* @property {number[]} points x, y, pressure for each point, in sequence.
* Pressure is 0 to 1.
* @property {boolean} done False while the stroke is being drawn.
+ * @property {boolean} filled The points are the outline of a closed
+ * polygon filled with the colour, rather than a line (the Shape
+ * tool's arrow, 2026-10-14); the width then matters only to the
+ * eraser's and the page's reckoning of the stroke's reach.
+ * @property {string | null} fill A colour the closed polygon of the
+ * points is filled with beneath the line (the Shape tool's fill
+ * colour, Andy, 2026-10-14), or null.
+ * @property {ShapeKind | null} shape Which shape of the Shape tool the
+ * stroke is, so that Arrange can move and resize it, or null for a
+ * stroke of the pen.
*/
+/**
+ * The shapes of the Shape tool (shapes.js), as a stroke records its kind.
+ * @typedef {'rectangle' | 'ellipse' | 'line' | 'arrow'} ShapeKind
+ */
+export const SHAPE_KINDS = /** @type {const} */ (['rectangle', 'ellipse', 'line', 'arrow']);
+
/** Numbers per point in Stroke.points. */
export const POINT_SIZE = 3;
@@ -49,10 +65,13 @@ export const POINT_SIZE = 3;
* @property {number} y
* @property {number | null} width Page units, or null for no wrapping.
* @property {number | null} height With a width: the block is a *text
- * rectangle* (Andy's teacher, 2026-10-08), drawn with a red frame on
- * a white ground that hides the image beneath, wrapping inside and
- * growing downward by lines when the text needs more. Null for free
- * text, which is only as large as its lines.
+ * rectangle* (Andy's teacher, 2026-10-08), wrapping inside and
+ * growing downward by lines when the text needs more; an *implied*
+ * rectangle (Andy, 2026-10-14), marked on screen by blue corners and
+ * drawing nothing on the page (a red frame on a white ground until
+ * then, from a misreading of the teacher's rectangles, which are
+ * marks of their own: the Shape tool). Null for free text, which is
+ * only as large as its lines.
* @property {string} text As typed, with the writer's line breaks.
* @property {string[]} lines As shown, laid out by the writer's client.
* @property {number} size Font size in page units.
@@ -153,12 +172,44 @@ export function cleanStroke(received) {
!(received.width > 0 && received.width <= MAX_STROKE_WIDTH)) return null;
const points = received.points ?? [];
if (!validPoints(points)) return null;
+ const fill = typeof received.fill === 'string' && COLOR_PATTERN.test(received.fill) ? received.fill : null;
+ const shape = SHAPE_KINDS.find((kind) => kind === received.shape) ?? null;
return {
id: received.id, color: received.color, width: received.width,
- points: points.slice(), done: received.done !== false,
+ points: points.slice(), done: received.done !== false, filled: received.filled === true, fill, shape,
};
}
+/**
+ * The rectangle around a stroke's points (not its line's width).
+ * @param {Stroke} stroke
+ * @returns {Rectangle | null} Null for a stroke without points.
+ */
+export function strokeBox(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; i < p.length; i += POINT_SIZE) {
+ left = Math.min(left, p[i]);
+ right = Math.max(right, p[i]);
+ top = Math.min(top, p[i + 1]);
+ bottom = Math.max(bottom, p[i + 1]);
+ }
+ return { left, top, width: right - left, height: bottom - top };
+}
+
+/**
+ * @param {Stroke} stroke
+ * @returns {boolean} Whether a stroke of a line shape ends where it began.
+ */
+export function isClosed(stroke) {
+ const p = stroke.points;
+ return p.length >= 2 * POINT_SIZE && p[0] === p[p.length - POINT_SIZE] && p[1] === p[p.length - POINT_SIZE + 1];
+}
+
/**
* Check a text block that came from outside.
* @param {any} received
@@ -563,8 +614,7 @@ export class Page {
contentSheet() {
// What can be seen (Andy, 2026-10-14): a stroke or free text in the
// paper's white is left out (over an image it shows, but lies within
- // the image's box, which counts); a text rectangle counts whole, its
- // red frame being visible. The record that grows the page while
+ // the image's box, which counts). The record that grows the page while
// writing (strokeBounds) is not used here, so a white stroke still
// gets room as it is written.
/** @type {{box: Rectangle | null}} A holder, since the closure's assignments are not seen by the type checker. */
@@ -585,13 +635,15 @@ export class Page {
};
for (const layer of this.layers.values()) {
for (const stroke of layer.strokes.values()) {
- if (isPaperWhite(stroke.color)) continue;
+ if (isPaperWhite(stroke.color) && (stroke.fill === null || isPaperWhite(stroke.fill))) continue;
const reach = stroke.width * 2; // As includeStroke reckons it.
const p = stroke.points;
for (let i = 0; i < p.length; i += POINT_SIZE) add(p[i] - reach, p[i + 1] - reach, 2 * reach, 2 * reach);
}
for (const block of layer.texts.values()) {
- if (block.height === null && isPaperWhite(block.color)) continue;
+ // A text rectangle draws nothing of its own since 2026-10-14, so
+ // white text in one is as invisible as white free text.
+ if (isPaperWhite(block.color)) continue;
const b = textBounds(block);
add(b.left, b.top, b.width, b.height);
}
@@ -641,7 +693,7 @@ export class Page {
includeText(block) {
const bounds = textBounds(block);
const corners = [bounds.left, bounds.top, 0, bounds.left + bounds.width, bounds.top + bounds.height, 0];
- this.includeStroke({ id: '', color: '', width: 0, points: corners, done: true });
+ this.includeStroke({ id: '', color: '', width: 0, points: corners, done: true, filled: false, fill: null, shape: null });
}
/** Find where strokes and text lie again, after some were removed or replaced. */
diff --git a/client/js/pen_input.js b/client/js/pen_input.js
index 74b81cd..a8ea5de 100644
--- a/client/js/pen_input.js
+++ b/client/js/pen_input.js
@@ -14,7 +14,7 @@ const ERASER_BUTTON = 32;
* Pressure recorded for a mouse or a finger, which report none. It is the
* pressure at which a stroke is drawn at its nominal width.
*/
-const NOMINAL_PRESSURE = 0.4;
+export const NOMINAL_PRESSURE = 0.4;
/**
* A press this soon after the window gained focus is taken to be the
@@ -41,11 +41,16 @@ export const FOCUS_SETTLE_MS = 250;
*/
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 {() => 'pen' | 'eraser' | 'text' | 'hide' | 'arrange'} tool The tool selected in the toolbar.
+ * @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
@@ -56,10 +61,16 @@ const focusSettleOn = !new URLSearchParams(location.search).has('no_focus_settle
* The eraser went down at (x, y) and has come up again, having moved
* no farther than `reach` from there; Infinity if the press was cancelled.
* @property {(x0: number, y0: number, x1: number, y1: number) => void} placeRect
- * With the text or Hide tool, the pointer went down at (x0, y0) and up
- * at (x1, y1): a tap, or a rectangle dragged open.
+ * 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 or Hide tool, the pointer is down at (x0, y0) and now at (x1, y1).
+ * 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
@@ -240,8 +251,11 @@ export function attachPenInput(surface, target) {
return;
}
const tool = target.tool();
+ target.hoverEnded?.();
erasing = tool === 'eraser' || (event.buttons & ERASER_BUTTON) !== 0;
- placingText = (tool === 'text' || tool === 'hide') && !erasing;
+ // 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();
@@ -289,7 +303,11 @@ export function attachPenInput(surface, target) {
});
surface.addEventListener('pointermove', (event) => {
- if (event.pointerId !== activePointer) return;
+ 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;
@@ -349,6 +367,7 @@ export function attachPenInput(surface, target) {
}
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`);
});
diff --git a/client/js/renderer.js b/client/js/renderer.js
index 66ba2b8..28e675a 100644
--- a/client/js/renderer.js
+++ b/client/js/renderer.js
@@ -10,8 +10,8 @@
* stroke grows.
*/
-import { BLANK_PAGE_HEIGHT, BLANK_PAGE_WIDTH, POINT_SIZE, pointCount, textBounds } from './page_model.js';
-import { TEXT_FRAME_COLOR, TEXT_FRAME_WIDTH, TEXT_MARK } from './page_file.js';
+import { BLANK_PAGE_HEIGHT, BLANK_PAGE_WIDTH, POINT_SIZE, isClosed, pointCount } from './page_model.js';
+import { verticesOf } from './shapes.js';
import { partAt, widthAt } from './stroke_geometry.js';
import { LEFT_GAP, LINE_HEIGHT, baselineOf, fontOf } from './text_layout.js';
@@ -24,6 +24,8 @@ import { LEFT_GAP, LINE_HEIGHT, baselineOf, fontOf } from './text_layout.js';
* @typedef {{x0: number, y0: number, x1: number, y1: number}} Region
*/
+const SVG_NS = 'http://www.w3.org/2000/svg';
+
/**
* One click of the scroll wheel zooms by this factor (10%, Andy's choice
* to try, 2026-10-03).
@@ -47,18 +49,19 @@ const MOST_CSS_PIXELS_PER_UNIT = 8;
export const CORNER_REACH_PX = 24;
/**
- * How a text rectangle's frame is shown on screen: the whole rectangle,
- * as Andy's teacher is used to, or only its corners, which is quieter
- * (Andy, 2026-10-08: a constant, to try both). The page file and the
- * export always hold the whole rectangle.
- * @type {'rectangle' | 'corners'}
+ * The *implied rectangles* (Andy, 2026-10-14), text blocks and Hides,
+ * are marked on screen by blue corners while the pointer hovers over
+ * them (always, until 2026-10-14, for a text rectangle and a Hide):
+ * L-shaped marks this long, in page units, and this wide, in the blue of
+ * the Arrange cues. Nothing of them is on the page or in the file. (A
+ * text rectangle had a red frame on a white ground, page content, until
+ * 2026-10-14, and free text a red L until 2026-10-14; the teacher's
+ * rectangles turned out to be marks of their own, which the Shape tool
+ * now draws.)
*/
-export const TEXT_FRAME_STYLE = 'rectangle';
-/** The length of a corner mark, in page units, when only corners are shown. */
const CORNER_MARK = 60;
-/** Hide rectangles are marked by blue corners on screen, for everyone. */
-const HIDE_CORNER_COLOR = '#1e88e5';
-const HIDE_CORNER_WIDTH = 2;
+export const CUE_COLOR = '#1e88e5';
+const CUE_WIDTH = 2;
/**
* How the page is scaled on screen: a number of CSS pixels per page unit.
@@ -69,44 +72,6 @@ const HIDE_CORNER_WIDTH = 2;
* @typedef {'fit' | number} Zoom
*/
-/**
- * Stroke lines on the device pixel grid, so that they are as crisp as a
- * CSS border and the same width: a line of `width` page units becomes a
- * whole number of pixels, at least one, and its edges land on pixels.
- * The lines are given in page units and drawn through the context's
- * transform, which maps page units to device pixels.
- * @param {CanvasRenderingContext2D} context
- * @param {[number, number][][]} polylines
- * @param {string} color
- * @param {number} width
- */
-function strokeCrisp(context, polylines, color, width) {
- const t = context.getTransform();
- const pixels = Math.max(1, Math.round(width * t.a));
- /** @param {number} v */
- const snap = (v) => Math.round(v);
- context.save();
- context.setTransform(1, 0, 0, 1, 0, 0);
- context.strokeStyle = color;
- context.lineWidth = pixels;
- context.lineCap = 'butt';
- context.lineJoin = 'miter';
- for (const line of polylines) {
- context.beginPath();
- line.forEach(([x, y], i) => {
- // A line's centre at half a pixel in: its edges on the grid.
- const dx = snap(t.a * x + t.e);
- const dy = snap(t.d * y + t.f);
- const px = dx + (pixels % 2 ? 0.5 : 0);
- const py = dy + (pixels % 2 ? 0.5 : 0);
- if (i === 0) context.moveTo(px, py);
- else context.lineTo(px, py);
- });
- context.stroke();
- }
- context.restore();
-}
-
/**
* Four handle marks, one for each corner of an overlay (the grip and the
* Arrange cues), placed by their class.
@@ -121,45 +86,12 @@ function cornerHandles() {
}
/**
- * Draw the corners of a rectangle as short L-shaped marks.
- * @param {CanvasRenderingContext2D} context
- * @param {import('./page_model.js').Rectangle} r
- * @param {string} color
- * @param {number} width
- */
-function drawCornerMarks(context, r, color, width) {
- const mark = Math.min(CORNER_MARK, r.width / 2, r.height / 2);
- /** @type {[number, number][][]} */
- const lines = [];
- for (const [cx, cy] of [[0, 0], [1, 0], [0, 1], [1, 1]]) {
- const x = r.left + cx * r.width;
- const y = r.top + cy * r.height;
- lines.push([[x, y + (cy ? -mark : mark)], [x, y], [x + (cx ? -mark : mark), y]]);
- }
- strokeCrisp(context, lines, color, width);
-}
-
-/**
- * Draw a text block: a text rectangle's red frame, then its lines.
+ * Draw a text block's lines (nothing else: a block has no mark of its
+ * own on the page since 2026-10-14).
* @param {CanvasRenderingContext2D} context
* @param {TextBlock} block
*/
function drawText(context, block) {
- if (block.height === null) {
- // Free text: an L at its top-left corner.
- strokeCrisp(context, [[[block.x, block.y + TEXT_MARK], [block.x, block.y], [block.x + TEXT_MARK, block.y]]],
- TEXT_FRAME_COLOR, TEXT_FRAME_WIDTH);
- } else {
- const f = textBounds(block);
- if (TEXT_FRAME_STYLE === 'rectangle') {
- const r = f.left + f.width;
- const b = f.top + f.height;
- strokeCrisp(context, [[[f.left, f.top], [r, f.top], [r, b], [f.left, b], [f.left, f.top]]],
- TEXT_FRAME_COLOR, TEXT_FRAME_WIDTH);
- } else {
- drawCornerMarks(context, f, TEXT_FRAME_COLOR, TEXT_FRAME_WIDTH);
- }
- }
context.font = fontOf(block.size, block.font);
context.fillStyle = block.color;
context.textBaseline = 'alphabetic';
@@ -175,6 +107,34 @@ function drawText(context, block) {
});
}
+/**
+ * Fill the closed polygon of a stroke's points.
+ * @param {CanvasRenderingContext2D} context
+ * @param {Stroke} stroke
+ * @param {string} color
+ */
+function fillPolygon(context, stroke, color) {
+ const p = stroke.points;
+ context.beginPath();
+ for (let i = 0; i < p.length; i += POINT_SIZE) {
+ if (i === 0) context.moveTo(p[i], p[i + 1]);
+ else context.lineTo(p[i], p[i + 1]);
+ }
+ context.closePath();
+ context.fillStyle = color;
+ context.fill();
+}
+
+/**
+ * @param {Stroke} stroke
+ * @returns {boolean} Whether the stroke's polygon is filled with its
+ * fill colour beneath the line: a shape with a fill, a line only
+ * once closed.
+ */
+function hasFill(stroke) {
+ return stroke.fill !== null && stroke.done && (stroke.shape !== 'line' || isClosed(stroke));
+}
+
/**
* Draw the part of a stroke that belongs to point `k`.
* @param {CanvasRenderingContext2D} context
@@ -184,6 +144,11 @@ function drawText(context, block) {
function drawPart(context, stroke, k) {
const part = partAt(stroke, k);
context.beginPath();
+ if (stroke.filled) {
+ // The whole outline at once, filled: drawn when the stroke is complete.
+ fillPolygon(context, stroke, stroke.color);
+ return;
+ }
if (part.dot) {
context.arc(part.x, part.y, part.width / 2, 0, 2 * Math.PI);
context.fillStyle = stroke.color;
@@ -216,10 +181,6 @@ export class Renderer {
/** Region of the tail canvas in use, in page units, or null. */
this.tailRegion = /** @type {Region | null} */ (null);
- /** The white grounds of text rectangles: above the images, beneath every layer. */
- this.groundCanvas = this.makeCanvas();
- this.groundCanvas.classList.add('grounds');
- this.pageElement.append(this.groundCanvas);
this.tailCanvas = this.makeCanvas();
this.tailCanvas.classList.add('tails');
this.pageElement.append(this.tailCanvas);
@@ -252,12 +213,36 @@ export class Renderer {
* rectangle and Hide that can be moved or resized, and an outline for
* images, whose edges may be invisible on the white page (Andy,
* 2026-10-13: figures on a white ground).
- * @type {{rect: import('./page_model.js').Rectangle, kind: 'image' | 'text' | 'hide'}[]}
+ * @type {{rect: import('./page_model.js').Rectangle, kind: 'image' | 'text' | 'hide' | 'shape'}[]}
*/
this.arrangeCues = [];
this.cueElements = document.createElement('div');
this.cueElements.className = 'arrange_cues';
this.pageElement.append(this.cueElements);
+ /**
+ * The blue corners of the free text under a hovering pointer (Andy,
+ * 2026-10-14: free text is used constantly, so its extent shows only
+ * when the pointer enters it, where a text rectangle's corners always
+ * show).
+ * @type {import('./page_model.js').Rectangle | null}
+ */
+ this.hoverRect = null;
+ this.hoverCue = document.createElement('div');
+ this.hoverCue.className = 'hover_cue';
+ this.hoverCue.hidden = true;
+ this.hoverCue.append(...cornerHandles());
+ this.pageElement.append(this.hoverCue);
+ /**
+ * A shape being dragged open or a line's rubber band, in the pen's
+ * colour and width, as an SVG overlay in page units; what the Shape
+ * tool shows before the stroke is made (2026-10-14).
+ */
+ this.shapePreview = document.createElementNS(SVG_NS, 'svg');
+ this.shapePreview.setAttribute('class', 'shape_preview');
+ this.shapePreviewPath = document.createElementNS(SVG_NS, 'path');
+ this.shapePreview.append(this.shapePreviewPath);
+ this.shapePreview.setAttribute('hidden', '');
+ this.pageElement.append(this.shapePreview);
/** Canvas pixels per page unit. */
this.scale = 1;
@@ -281,8 +266,8 @@ export class Renderer {
*/
this.sheetFirst = false;
/**
- * The text block being edited, if any, which is shown by the editor's
- * own text field and so not drawn here.
+ * The text block being edited, if any, whose lines are shown by the
+ * editor's own text field and so not drawn here (its marks are).
* @type {string | null}
*/
this.editingText = null;
@@ -555,7 +540,7 @@ export class Renderer {
/**
* Show the Arrange tool's cues on these rectangles, or none.
- * @param {{rect: import('./page_model.js').Rectangle, kind: 'image' | 'text' | 'hide'}[]} cues
+ * @param {{rect: import('./page_model.js').Rectangle, kind: 'image' | 'text' | 'hide' | 'shape'}[]} cues
*/
setArrangeCues(cues) {
this.arrangeCues = cues;
@@ -575,6 +560,49 @@ export class Renderer {
const el = /** @type {HTMLElement} */ (elements[i]);
if (el) this.placeOverlay(el, cue.rect);
});
+ if (this.hoverRect) this.placeOverlay(this.hoverCue, this.hoverRect);
+ const page = this.page;
+ this.shapePreview.setAttribute('viewBox', `${page.left} ${page.top} ${page.width} ${page.height}`);
+ }
+
+ /**
+ * Mark the corners of this rectangle while the pointer hovers over it,
+ * or none.
+ * @param {import('./page_model.js').Rectangle | null} rect
+ */
+ showHoverCue(rect) {
+ this.hoverRect = rect;
+ this.hoverCue.hidden = !rect;
+ if (!rect) return;
+ 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`);
+ }
+
+ /**
+ * Show a shape that is being made, from its stroke points, as it will
+ * be drawn: a line of the width, or a filled polygon.
+ * @param {number[]} points
+ * @param {string} color
+ * @param {number} width Page units.
+ * @param {boolean} filled
+ */
+ showShapePreview(points, color, width, filled) {
+ const vertices = verticesOf(points);
+ const path = this.shapePreviewPath;
+ path.setAttribute('d', vertices.map(([x, y], i) => `${i ? 'L' : 'M'}${x} ${y}`).join('') + (filled ? 'Z' : ''));
+ path.setAttribute('fill', filled ? color : 'none');
+ path.setAttribute('stroke', filled ? 'none' : color);
+ path.setAttribute('stroke-width', String(width));
+ this.shapePreview.removeAttribute('hidden');
+ }
+
+ hideShapePreview() {
+ this.shapePreview.setAttribute('hidden', '');
}
/** @returns {number} CORNER_REACH_PX in page units at the present zoom. */
@@ -649,11 +677,9 @@ export class Renderer {
this.scale = (window.devicePixelRatio || 1) / unitsPerCssPixel;
this.sizeCanvas(this.tailCanvas);
- this.sizeCanvas(this.groundCanvas);
this.sizeCanvas(this.hideCanvas);
this.tailRegion = null;
for (const id of this.canvases.keys()) this.redrawLayer(id, true);
- this.drawGrounds();
this.drawHides();
this.drawTails();
if (first || fitted) this.onZoomChanged?.();
@@ -685,7 +711,6 @@ export class Renderer {
else this.hidden.add(id);
const canvas = this.canvases.get(id);
if (canvas) canvas.hidden = !visible;
- this.drawGrounds();
this.drawTails();
}
@@ -712,38 +737,18 @@ export class Renderer {
for (const block of layer.texts.values()) {
if (block.id !== this.editingText) drawText(context, block);
}
- this.drawGrounds();
this.drawTails();
}
/**
- * Draw the white grounds of every text rectangle of every layer shown
- * here, beneath the layers: they hide the image, never anyone's writing.
+ * Draw the Hide rectangles, white, above everything (with blue corners
+ * until 2026-10-14; the hover cue shows them now).
*/
- drawGrounds() {
- const context = this.context(this.groundCanvas);
- context.clearRect(this.page.left, this.page.top, this.page.width, this.page.height);
- context.fillStyle = '#ffffff';
- for (const [id, layer] of this.page.layers) {
- if (this.hidden.has(id)) continue;
- for (const block of layer.texts.values()) {
- if (block.height === null || block.id === this.editingText) continue;
- const b = textBounds(block);
- context.fillRect(b.left, b.top, b.width, b.height);
- }
- }
- }
-
- /** Draw the Hide rectangles, white with blue corners, above everything. */
drawHides() {
const context = this.context(this.hideCanvas);
context.clearRect(this.page.left, this.page.top, this.page.width, this.page.height);
- for (const hide of this.page.hides) {
- context.fillStyle = '#ffffff';
- context.fillRect(hide.x, hide.y, hide.width, hide.height);
- drawCornerMarks(context, { left: hide.x, top: hide.y, width: hide.width, height: hide.height },
- HIDE_CORNER_COLOR, HIDE_CORNER_WIDTH);
- }
+ context.fillStyle = '#ffffff';
+ for (const hide of this.page.hides) context.fillRect(hide.x, hide.y, hide.width, hide.height);
}
/**
@@ -754,6 +759,12 @@ export class Renderer {
strokeProgress(layerId, stroke) {
const canvas = this.canvases.get(layerId);
if (!canvas) return;
+ // A line closed by its last point gets its fill, which lies beneath
+ // the parts already drawn: the layer is drawn afresh.
+ if (hasFill(stroke) && (this.drawn.get(stroke) ?? 0) > 0) {
+ this.redrawLayer(layerId);
+ return;
+ }
this.drawNewParts(this.context(canvas), layerId, stroke);
this.drawTails();
}
@@ -765,10 +776,19 @@ export class Renderer {
*/
drawNewParts(context, layerId, stroke) {
const count = pointCount(stroke);
+ if (stroke.filled) {
+ // A filled shape is drawn whole, once its points are all here; it
+ // has no tail to show meanwhile.
+ if (stroke.done && count > 0 && (this.drawn.get(stroke) ?? 0) < count) drawPart(context, stroke, 0);
+ this.drawn.set(stroke, stroke.done ? count : 0);
+ this.active.delete(stroke);
+ return;
+ }
// The part for the newest point waits until the stroke is finished or
// another point arrives.
const ready = stroke.done ? count : count - 1;
let k = this.drawn.get(stroke) ?? 0;
+ if (k === 0 && hasFill(stroke) && stroke.fill) fillPolygon(context, stroke, stroke.fill);
for (; k < ready; k++) drawPart(context, stroke, k);
this.drawn.set(stroke, Math.max(k, 0));
if (stroke.done) this.active.delete(stroke);
diff --git a/client/js/session.js b/client/js/session.js
index 6bd0bed..e89f64d 100644
--- a/client/js/session.js
+++ b/client/js/session.js
@@ -36,6 +36,7 @@ import { trimTrailingBlankLines } from './text_layout.js';
* @typedef {{kind: 'stroke', id: string}
* | {kind: 'erase', strokes: Stroke[], texts: TextBlock[]}
* | {kind: 'text', id: string, before: TextBlock | null}
+ * | {kind: 'shape', id: string, before: Stroke}
* | {kind: 'hide', id: string, before: HideRect | null}} UndoEntry
*/
@@ -137,6 +138,10 @@ export class Session {
this.erasing = null;
/** @type {{id: string, owner: string, before: TextBlock | null} | null} A text block being edited. */
this.textEditing = null;
+ /** @type {{id: string, before: Stroke} | null} A shape of ours being moved or resized by hand. */
+ this.shapeEditing = null;
+ /** @type {ReturnType | null} The moved shape is sent when this fires. */
+ this.shapeSendTimer = null;
/** @type {{id: string, before: HideRect} | null} A Hide rectangle being moved. */
this.hideEditing = null;
this.events = events;
@@ -840,6 +845,7 @@ export class Session {
this.undoStack = [];
this.erasing = null;
this.textEditing = null;
+ this.shapeEditing = null;
this.hideEditing = null;
this.events.onUndoChanged?.();
this.ownLayer = page.layer(this.identity.id, this.name);
@@ -858,19 +864,91 @@ export class Session {
/**
* @param {string} color
* @param {number} width
+ * @param {object} [shape] What the Shape tool adds to a stroke, each
+ * sent only when set, so that a client from before 2026-10-14
+ * reads the message as before.
+ * @param {boolean} [shape.filled] A filled polygon rather than a line (the arrow).
+ * @param {string | null} [shape.fill] A fill colour beneath the line.
+ * @param {import('./page_model.js').ShapeKind | null} [shape.kind] The shape, for Arrange.
* @returns {Stroke}
*/
- beginStroke(color, width) {
+ beginStroke(color, width, shape = {}) {
if (this.currentStroke) this.endStroke();
- const stroke = { id: randomId(), color, width, points: [], done: false };
+ const filled = shape.filled ?? false;
+ const fill = shape.fill ?? null;
+ const kind = shape.kind ?? null;
+ /** @type {Stroke} */
+ const stroke = { id: randomId(), color, width, points: [], done: false, filled, fill, shape: kind };
this.ownLayer.strokes.set(stroke.id, stroke);
this.currentStroke = stroke;
this.send(0, {
- type: 'stroke_begin', page: this.page.id, stroke: { id: stroke.id, color, width },
+ type: 'stroke_begin', page: this.page.id,
+ stroke: {
+ id: stroke.id, color, width,
+ ...(filled ? { filled } : {}), ...(fill ? { fill } : {}), ...(kind ? { shape: kind } : {}),
+ },
});
return stroke;
}
+ // A shape of ours moved or resized by hand (Arrange or a hold, Andy,
+ // 2026-10-14): one gesture to undo, the shape sent whole while it is
+ // dragged (at most every PAGE_SEND_INTERVAL_MS, by the stroke_restore
+ // message, which puts a stroke in place of the one with its id) and
+ // once at the end.
+
+ /**
+ * @param {string} id
+ * @returns {boolean} Whether the stroke is a shape of ours.
+ */
+ beginShapeEdit(id) {
+ const stroke = this.ownLayer.strokes.get(id);
+ if (!stroke || !stroke.shape || this.shapeEditing) return false;
+ this.shapeEditing = { id, before: { ...stroke, points: stroke.points.slice() } };
+ return true;
+ }
+
+ /**
+ * The shape has new points: redrawn here, sent soon.
+ * @param {string} id
+ * @param {number[]} points
+ */
+ setShapePoints(id, points) {
+ const stroke = this.ownLayer.strokes.get(id);
+ if (!stroke) return;
+ stroke.points = points;
+ this.page.recomputeStrokeBounds();
+ this.events.onLayerReplaced(this.identity.id);
+ this.extentChanged();
+ if (this.shapeSendTimer === null) {
+ this.shapeSendTimer = setTimeout(() => {
+ this.shapeSendTimer = null;
+ this.sendShape(id);
+ }, PAGE_SEND_INTERVAL_MS);
+ }
+ }
+
+ /** @param {string} id */
+ sendShape(id) {
+ const stroke = this.ownLayer.strokes.get(id);
+ if (stroke) this.send(0, { type: 'stroke_restore', page: this.page.id, strokes: [stroke] });
+ }
+
+ endShapeEdit() {
+ const editing = this.shapeEditing;
+ this.shapeEditing = null;
+ if (!editing) return;
+ if (this.shapeSendTimer !== null) {
+ clearTimeout(this.shapeSendTimer);
+ this.shapeSendTimer = null;
+ }
+ this.sendShape(editing.id);
+ const now = this.ownLayer.strokes.get(editing.id);
+ if (now && now.points.join(' ') !== editing.before.points.join(' ')) {
+ this.pushUndo({ kind: 'shape', id: editing.id, before: editing.before });
+ }
+ }
+
/** @param {number[]} points x, y, pressure for each point. */
addPoints(points) {
let stroke = this.currentStroke;
@@ -878,7 +956,7 @@ export class Session {
if (pointCount(stroke) + points.length / POINT_SIZE > MAX_STROKE_POINTS) {
const last = stroke.points.slice(-POINT_SIZE);
this.endStroke();
- stroke = this.beginStroke(stroke.color, stroke.width);
+ stroke = this.beginStroke(stroke.color, stroke.width, { filled: stroke.filled, fill: stroke.fill, kind: stroke.shape });
points = last.concat(points);
}
const at = pointCount(stroke);
@@ -1032,6 +1110,9 @@ export class Session {
if (entry.before) this.setText(entry.before);
else this.removeTexts([entry.id]);
return entry.before ? 'a change of text' : 'a text block';
+ case 'shape':
+ this.restoreStrokes([entry.before]);
+ return 'a move of a shape';
case 'hide': {
const page = this.page;
if (!this.mayMakePages()) return null;
diff --git a/client/js/shapes.js b/client/js/shapes.js
new file mode 100644
index 0000000..4088166
--- /dev/null
+++ b/client/js/shapes.js
@@ -0,0 +1,269 @@
+// @ts-check
+/**
+ * The shapes of the Shape tool (Andy, 2026-10-14: his teacher draws
+ * rectangles to mark out regions of the screen, so Kokkoja needs explicit
+ * rectangles, and with them the common shapes, behind one button): a
+ * rectangle, an ellipse, a line of several points and an arrow with a
+ * barbed head, in the pen's colour and width.
+ *
+ * A shape is an ordinary stroke of the writer's layer, its points
+ * generated here, so that the eraser, Undo, the protocol, the page file
+ * and Shrink need nothing new, and other programs show it from the file.
+ * Two things about the points:
+ *
+ * - A stroke is drawn as a chain of curves through the midpoints between
+ * its points (stroke_geometry.js), which would round every corner. A
+ * vertex given TWICE keeps the chain straight: the curve from the
+ * midpoint before it to the midpoint between the two copies, which is
+ * the vertex itself, is a straight line, and so is the one after. So
+ * the straight-sided shapes repeat each vertex, and an ellipse, which
+ * wants the curve, does not.
+ *
+ * - Every point carries the pressure at which a stroke has its nominal
+ * width, so that a shape's line is the pen's width throughout.
+ *
+ * The arrow is a FILLED stroke (`Stroke.filled`): its outline, shaft and
+ * head in one closed polygon filled with the colour, like the character
+ * 🡺, which Andy chose; a stroke's own line cannot fill a head.
+ */
+
+import { NOMINAL_PRESSURE } from './pen_input.js';
+import { POINT_SIZE } from './page_model.js';
+
+/** The shapes, in the order of the menu. The first is the default. */
+export const SHAPES = /** @type {const} */ (['rectangle', 'ellipse', 'line', 'arrow']);
+/** @typedef {typeof SHAPES[number]} Shape */
+
+/** @type {Record} What the button and the menu call each shape. */
+export const SHAPE_NAMES = { rectangle: 'Rectangle', ellipse: 'Ellipse', line: 'Line', arrow: 'Arrow' };
+
+/** The key under which the browser remembers the chosen shape. */
+export const SHAPE_KEY = 'kokkoja_shape';
+
+/**
+ * @param {unknown} name
+ * @returns {Shape} The shape of that name, or the default.
+ */
+export function shapeNamed(name) {
+ return SHAPES.find((shape) => shape === name) ?? SHAPES[0];
+}
+
+/**
+ * @param {Shape} shape
+ * @returns {boolean} Whether the shape is drawn vertex by vertex, by taps,
+ * rather than dragged open.
+ */
+export function isTapped(shape) {
+ return shape === 'line';
+}
+
+/**
+ * @param {Shape} shape
+ * @returns {boolean} Whether the shape is a filled polygon.
+ */
+export function isFilled(shape) {
+ return shape === 'arrow';
+}
+
+/**
+ * Points along an ellipse are at most this far apart, in page units
+ * (3 mm): close enough for the curve through them to look round.
+ */
+const ELLIPSE_STEP = 30;
+const LEAST_ELLIPSE_POINTS = 24;
+const MOST_ELLIPSE_POINTS = 720;
+
+/**
+ * An arrow's head: its length as a multiple of the line's width (Andy,
+ * 2026-10-14: proportional to the width, the head of a 0.7 mm arrow,
+ * 2.5 mm, being the proportion that looked right), its half-width as a
+ * share of that length, and how far the barbs reach back behind the
+ * shaft's end as a share of it. The proportions of 🡺.
+ */
+const HEAD_LENGTH_WIDTHS = 25 / 7;
+const HEAD_HALF_WIDTH = 0.6;
+const BARB_DEPTH = 0.3;
+
+/**
+ * The points of a stroke through these vertices with straight sides:
+ * each vertex twice (see the module comment), the first and last once.
+ * @param {[number, number][]} vertices
+ * @returns {number[]} x, y, pressure, ...
+ */
+export function straightPoints(vertices) {
+ /** @type {number[]} */
+ const points = [];
+ vertices.forEach(([x, y], i) => {
+ const copies = i === 0 || i === vertices.length - 1 ? 1 : 2;
+ for (let c = 0; c < copies; c++) points.push(x, y, NOMINAL_PRESSURE);
+ });
+ return points;
+}
+
+/**
+ * The points one more vertex adds to a line in progress: the vertex
+ * twice, so that the side after it is straight too.
+ * @param {number} x
+ * @param {number} y
+ * @returns {number[]}
+ */
+export function vertexPoints(x, y) {
+ return [x, y, NOMINAL_PRESSURE, x, y, NOMINAL_PRESSURE];
+}
+
+/**
+ * The points of a line still being made: the first vertex once, every
+ * later one twice (vertexPoints), since more may follow.
+ * @param {[number, number][]} vertices
+ * @returns {number[]}
+ */
+export function lineInProgressPoints(vertices) {
+ return vertices.flatMap(([x, y], i) => (i === 0 ? [x, y, NOMINAL_PRESSURE] : vertexPoints(x, y)));
+}
+
+/**
+ * @param {number} x0
+ * @param {number} y0
+ * @param {number} x1 The opposite corner.
+ * @param {number} y1
+ * @returns {number[]}
+ */
+export function rectanglePoints(x0, y0, x1, y1) {
+ return straightPoints([[x0, y0], [x1, y0], [x1, y1], [x0, y1], [x0, y0]]);
+}
+
+/**
+ * An ellipse inscribed in the rectangle, as points along it, the first
+ * repeated at the end to close it.
+ * @param {number} x0
+ * @param {number} y0
+ * @param {number} x1
+ * @param {number} y1
+ * @returns {number[]}
+ */
+export function ellipsePoints(x0, y0, x1, y1) {
+ const cx = (x0 + x1) / 2;
+ const cy = (y0 + y1) / 2;
+ const rx = Math.abs(x1 - x0) / 2;
+ const ry = Math.abs(y1 - y0) / 2;
+ // Ramanujan's approximation of the circumference.
+ const h = ((rx - ry) / (rx + ry)) ** 2 || 0;
+ 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)));
+ /** @type {number[]} */
+ const points = [];
+ for (let i = 0; i <= count; i++) {
+ const angle = 2 * Math.PI * (i % count) / count;
+ points.push(cx + rx * Math.cos(angle), cy + ry * Math.sin(angle), NOMINAL_PRESSURE);
+ }
+ return points;
+}
+
+/**
+ * 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
+ * back cut in so that the barbs reach behind the shaft's end.
+ * @param {number} x0
+ * @param {number} y0
+ * @param {number} x1
+ * @param {number} y1
+ * @param {number} width The line's nominal width, in page units.
+ * @returns {number[]}
+ */
+export function arrowPoints(x0, y0, x1, y1, width) {
+ const length = Math.hypot(x1 - x0, y1 - y0);
+ if (length === 0) return [];
+ const ux = (x1 - x0) / length;
+ const uy = (y1 - y0) / length;
+ const nx = -uy;
+ const ny = ux;
+ const head = Math.min(length, HEAD_LENGTH_WIDTHS * width);
+ const half = head * HEAD_HALF_WIDTH;
+ const shaft = width / 2;
+ // Where the shaft meets the head's back, nearer the tip than the barbs.
+ const jx = x1 - ux * head * (1 - BARB_DEPTH);
+ const jy = y1 - uy * head * (1 - BARB_DEPTH);
+ const bx = x1 - ux * head;
+ const by = y1 - uy * head;
+ /** @type {[number, number][]} */
+ const outline = [
+ [x0 + nx * shaft, y0 + ny * shaft],
+ [jx + nx * shaft, jy + ny * shaft],
+ [bx + nx * half, by + ny * half],
+ [x1, y1],
+ [bx - nx * half, by - ny * half],
+ [jx - nx * shaft, jy - ny * shaft],
+ [x0 - nx * shaft, y0 - ny * shaft],
+ ];
+ /** @type {number[]} */
+ const points = [];
+ for (const [x, y] of outline) points.push(x, y, NOMINAL_PRESSURE);
+ return points;
+}
+
+/**
+ * The points of a shape dragged open from (x0, y0) to (x1, y1).
+ * @param {Shape} shape Not 'line', which is made of taps.
+ * @param {number} x0
+ * @param {number} y0
+ * @param {number} x1
+ * @param {number} y1
+ * @param {number} width
+ * @returns {number[]}
+ */
+export function draggedPoints(shape, x0, y0, x1, y1, width) {
+ switch (shape) {
+ case 'rectangle': return rectanglePoints(x0, y0, x1, y1);
+ case 'ellipse': return ellipsePoints(x0, y0, x1, y1);
+ case 'arrow': return arrowPoints(x0, y0, x1, y1, width);
+ default: return straightPoints([[x0, y0], [x1, y1]]);
+ }
+}
+
+/**
+ * The points of a shape moved or resized by Arrange from the rectangle
+ * around its points to another: every point carried along (a rectangle
+ * stays a rectangle, an ellipse an ellipse, a line keeps its angles'
+ * proportions), 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.
+ * @param {import('./page_model.js').Stroke} stroke
+ * @param {import('./page_model.js').Rectangle} from
+ * @param {import('./page_model.js').Rectangle} to
+ * @returns {number[]} The new points.
+ */
+export function movedPoints(stroke, from, to) {
+ const sx = from.width > 0 ? to.width / from.width : 1;
+ const sy = from.height > 0 ? to.height / from.height : 1;
+ /** @param {number} x @param {number} y @returns {[number, number]} */
+ const carry = (x, y) => [to.left + (x - from.left) * sx, to.top + (y - from.top) * sy];
+ const p = stroke.points;
+ if (stroke.shape === 'arrow' && p.length === 7 * POINT_SIZE) {
+ // The tail is between the two tail corners (points 0 and 6), the tip point 3.
+ const [tx, ty] = carry((p[0] + p[18]) / 2, (p[1] + p[19]) / 2);
+ const [hx, hy] = carry(p[9], p[10]);
+ return arrowPoints(tx, ty, hx, hy, stroke.width);
+ }
+ const points = [];
+ for (let i = 0; i < p.length; i += POINT_SIZE) {
+ const [x, y] = carry(p[i], p[i + 1]);
+ points.push(Math.round(x * 10) / 10, Math.round(y * 10) / 10, p[i + 2]);
+ }
+ return points;
+}
+
+/**
+ * The vertices a shape's points pass through, each once, for drawing a
+ * preview: the repeated vertices of a straight-sided shape collapse.
+ * @param {number[]} points
+ * @returns {[number, number][]}
+ */
+export function verticesOf(points) {
+ /** @type {[number, number][]} */
+ const vertices = [];
+ for (let i = 0; i < points.length; i += POINT_SIZE) {
+ const last = vertices[vertices.length - 1];
+ if (last && last[0] === points[i] && last[1] === points[i + 1]) continue;
+ vertices.push([points[i], points[i + 1]]);
+ }
+ return vertices;
+}
diff --git a/client/js/text_editor.js b/client/js/text_editor.js
index b01d424..6f81834 100644
--- a/client/js/text_editor.js
+++ b/client/js/text_editor.js
@@ -12,7 +12,6 @@
import { codeAtEnd } from './character_codes.js';
import { randomId } from './crypto.js';
import { textBounds } from './page_model.js';
-import { TEXT_FRAME_WIDTH, TEXT_MARK } from './page_file.js';
import { HOLD_MS, HOLD_SLACK_PX } from './pen_input.js';
import { LEFT_GAP, LINE_HEIGHT, TOP_GAP, baselineOf, fontMetrics, layoutText, trimTrailingBlankLines } from './text_layout.js';
@@ -67,15 +66,14 @@ export class TextEditor {
}
});
- /** Free text's corner mark while it is being edited (the renderer leaves the block out then). */
- this.mark = document.createElement('div');
- this.mark.className = 'text_mark';
- this.mark.hidden = true;
+ // The block's marks (free text's red L, a rectangle's blue corners)
+ // are drawn by the renderer while it is edited as at any other time
+ // (since 2026-10-14; the editor showed its own before).
/** Shows a rectangle while it is being dragged open. */
this.preview = document.createElement('div');
this.preview.className = 'rect_preview';
this.preview.hidden = true;
- this.pageElement.append(this.field, this.mark, this.preview);
+ this.pageElement.append(this.field, this.preview);
}
/**
@@ -216,7 +214,6 @@ export class TextEditor {
this.block = null;
this.renderer.editingText = null;
this.field.hidden = true;
- this.mark.hidden = true;
this.field.value = '';
trimTrailingBlankLines(block);
if (block.text.trim() === '') this.target.removeText(block.id);
@@ -237,7 +234,6 @@ export class TextEditor {
this.block = null;
this.renderer.editingText = null;
this.field.hidden = true;
- this.mark.hidden = true;
this.field.value = '';
return block;
}
@@ -306,12 +302,6 @@ export class TextEditor {
const { ascent, descent } = fontMetrics(block.size, block.font);
// In a line box the baseline lies half the leading and the ascent down.
const baselineInLine = (lineHeight - (ascent + descent) * scale) / 2 + ascent * scale;
- // A box's field is exactly the box, on whole pixels, and draws the
- // frame as an outline inside its edge, which takes no room, at the
- // width the canvas draws it.
- const frame = Math.max(1, Math.round(TEXT_FRAME_WIDTH * scale * dpr)) / dpr;
- style.outlineWidth = isRect ? `${frame}px` : '0';
- style.outlineOffset = `${-frame}px`;
const padTop = firstBaseline - top - baselineInLine;
style.paddingTop = `${padTop}px`;
style.paddingLeft = `${textLeft - left}px`;
@@ -324,13 +314,12 @@ export class TextEditor {
style.color = block.color;
style.whiteSpace = block.width === null ? 'pre' : 'pre-wrap';
if (isRect) {
+ // A box's field is exactly the box, on whole pixels.
const bounds = textBounds(block);
- // The canvas strokes the far lines on the pixel at the edge itself,
- // so the field reaches one pixel past it.
style.boxSizing = 'border-box';
- style.width = `${whole((bounds.left + bounds.width - page.left) * scale) - left + 1 / dpr}px`;
+ style.width = `${whole((bounds.left + bounds.width - page.left) * scale) - left}px`;
style.height = `${Math.max(block.lines.length * lineHeight + padTop + slack,
- whole((bounds.top + bounds.height - page.top) * scale) - top + 1 / dpr)}px`;
+ whole((bounds.top + bounds.height - page.top) * scale) - top)}px`;
} else {
style.boxSizing = 'content-box';
// A little wider than the block, so that the field never wraps a
@@ -339,11 +328,5 @@ export class TextEditor {
style.height = `${block.lines.length * lineHeight}px`;
}
this.field.scrollTop = 0;
- this.mark.hidden = isRect;
- this.mark.style.left = `${left}px`;
- this.mark.style.top = `${top}px`;
- this.mark.style.width = `${whole(TEXT_MARK * scale)}px`;
- this.mark.style.height = `${whole(TEXT_MARK * scale)}px`;
- this.mark.style.borderWidth = `${Math.max(1, Math.round(TEXT_FRAME_WIDTH * scale * dpr)) / dpr}px`;
}
}
diff --git a/client/kokkoja.css b/client/kokkoja.css
index b95781a..04fcd1f 100644
--- a/client/kokkoja.css
+++ b/client/kokkoja.css
@@ -5,7 +5,6 @@
--panel: #f6f6f7;
--desk: #e4e4e7;
--accent: #1f4fd8;
- --text_frame: #e53935; /* A text rectangle's frame: the palette's red. */
font-family: system-ui, sans-serif;
font-size: 15px;
color: var(--ink);
@@ -226,6 +225,18 @@ h2 {
cursor: default;
}
.menu hr { width: 100%; margin: 0.2rem 0; border: 0; border-top: 1px solid var(--line); }
+/* The shapes menu marks the current shape with a check, every item
+ leaving the room for it so that the names line up; its fill colours
+ stand in a row like the toolbar's. */
+.menu .menu_item[role="menuitem"]::before { content: ""; display: inline-block; width: 1.1em; margin-right: 0.1em; }
+.menu .menu_item[aria-checked="true"]::before { content: "✓"; }
+.menu .fills { padding: 0.25rem 0.6rem 0.3rem; }
+/* "No fill": a white swatch struck through. */
+.swatch.none {
+ --swatch: white;
+ background-image: linear-gradient(135deg, transparent 45%, #b42318 45%, #b42318 55%, transparent 55%);
+ background-clip: content-box;
+}
.menu .toggle input { margin: 0; }
.help_popup { padding: 0.8rem 1rem; }
@@ -358,15 +369,34 @@ h2 {
background: #2e7d32;
}
-/* The Arrange tool's cues: blue corners on everything that can be moved
- or resized, and an outline on images, whose edges may not show. */
+/* The Arrange tool's cues: blue corners and an outline on every image,
+ whose edges may not show (text rectangles and Hides carry blue corners
+ at all times since 2026-10-14, drawn on the canvases); and the same
+ corners on free text under a hovering pointer (.hover_cue). */
.arrange_cues { position: absolute; inset: 0; pointer-events: none; z-index: 3; }
.arrange_cue {
position: absolute;
box-sizing: border-box;
+ pointer-events: none;
--handle-color: #1e88e5;
}
.arrange_cue.image { border: 1px solid #1e88e5; }
+/* The hover cue's corners are L marks like those the canvases draw on
+ text rectangles and Hides, not the Arrange tool's squares, so that
+ "blue corners" look the same everywhere; renderer.js sets the mark's
+ length and line width for the zoom. */
+.hover_cue { position: absolute; box-sizing: border-box; pointer-events: none; z-index: 3; --handle-color: #1e88e5; }
+.hover_cue .handle {
+ width: var(--mark);
+ height: var(--mark);
+ transform: none;
+ background: none;
+ 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 {
position: relative;
@@ -414,23 +444,29 @@ h2 {
pointer-events: none;
}
-/* The field of a text rectangle shows its frame and white ground itself,
- since the renderer leaves the block out while it is edited. */
-.text_field.rect {
- background: white;
- outline-style: solid;
- outline-color: var(--text_frame);
-}
+/* (A text rectangle's field drew its red frame and white ground until
+ 2026-10-14, and free text's field its red L; the renderer draws the
+ marks of the block being edited now, the same way as at any other
+ time, and the field shows only the lines.) */
-/* Free text's corner mark while it is edited: an L at its top-left corner. */
-.text_mark {
+/* A shape being dragged open, or the rubber band of a line in progress:
+ an SVG in page units over the page, in the pen's colour and width. */
+.shape_preview {
position: absolute;
- z-index: 3;
- box-sizing: border-box;
- border-style: solid none none solid;
- border-color: var(--text_frame);
+ inset: 0;
+ width: 100%;
+ height: 100%;
pointer-events: none;
+ z-index: 3;
+ overflow: visible;
}
+.shape_preview path { stroke-linecap: round; stroke-linejoin: round; }
+
+/* The Shape button names the current shape and keeps the width of the
+ widest name (main.js measures), so that nothing moves when the shape
+ changes; a long press on it opens the menu, so a touch must not select
+ its text or call up the system's menu. */
+#shape_tool { -webkit-touch-callout: none; touch-action: none; }
/* The Session dialog: who is here, and the invitation. */
diff --git a/tests/test_end_to_end.py b/tests/test_end_to_end.py
index e5f854c..93dd346 100644
--- a/tests/test_end_to_end.py
+++ b/tests/test_end_to_end.py
@@ -36,6 +36,59 @@ import zlib
PROJECT = pathlib.Path(__file__).resolve().parent.parent
+# The hover cue (the blue corners over an implied rectangle under the
+# pointer): whether it shows, and where, in page units.
+HOVER_CUE = """
+ const c = document.querySelector('.hover_cue');
+ const r = window.kokkoja.renderer, s = window.kokkoja.session;
+ const page = document.getElementById('page').getBoundingClientRect();
+ const b = c.getBoundingClientRect();
+ const k = s.page.width / page.width;
+ 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};
+"""
+
+# Whether anything at all is drawn on the writer's own layer canvas within
+# two device pixels of the page point (x, y).
+INK_AT = """
+ const [x, y] = arguments;
+ const r = window.kokkoja.renderer, s = window.kokkoja.session;
+ const canvas = r.canvases.get(s.identity.id);
+ const px = Math.round((x - s.page.left) * r.scale), py = Math.round((y - s.page.top) * r.scale);
+ const data = canvas.getContext('2d').getImageData(px - 2, py - 2, 5, 5).data;
+ for (let i = 3; i < data.length; i += 4) if (data[i] > 0) return true;
+ return false;
+"""
+
+# A mouse moving over the page with no button down, to a fraction of the
+# page: a hover.
+HOVER = """
+ const [fx, fy] = arguments;
+ const page = document.getElementById('page');
+ const box = page.getBoundingClientRect();
+ page.dispatchEvent(new PointerEvent('pointermove', {pointerId: 9, pointerType: 'mouse', isPrimary: true,
+ clientX: box.left + fx * box.width, clientY: box.top + fy * box.height, button: -1, buttons: 0,
+ bubbles: true, cancelable: true}));
+"""
+
+# A press on the Shape button held for `wait` ms, released, and the click
+# that a browser sends after a press; calls back with whether the menu
+# is open.
+HOLD_SHAPE_BUTTON = """
+ const [wait, done] = arguments;
+ const b = document.getElementById('shape_tool');
+ const box = b.getBoundingClientRect();
+ const fire = (type) => b.dispatchEvent(new PointerEvent(type, {pointerId: 5, pointerType: 'mouse', isPrimary: true,
+ clientX: box.left + 5, clientY: box.top + 5, button: 0, buttons: type === 'pointerup' ? 0 : 1,
+ bubbles: true, cancelable: true}));
+ fire('pointerdown');
+ setTimeout(() => {
+ fire('pointerup');
+ b.click();
+ done(!document.getElementById('shape_menu').hidden);
+ }, wait);
+"""
+
# Describes the page as one participant holds it: for each layer, by the
# participant's name, the strokes with their points.
PAGE_STATE = """
@@ -45,7 +98,8 @@ PAGE_STATE = """
const texts = {};
for (const layer of session.page.layers.values()) {
layers[layer.name] = [...layer.strokes.values()].map(
- (stroke) => ({id: stroke.id, done: stroke.done, points: stroke.points}));
+ (stroke) => ({id: stroke.id, done: stroke.done, points: stroke.points, color: stroke.color,
+ width: stroke.width, filled: stroke.filled, fill: stroke.fill, shape: stroke.shape}));
texts[layer.name] = [...layer.texts.values()];
}
// The first image, and all of them in sequence.
@@ -607,6 +661,20 @@ class EndToEndTest(unittest.TestCase):
def state(self):
return self.firefox.run(PAGE_STATE)
+ def assert_hover_cue(self, x, y, rect):
+ """A pointer hovering at the page point (x, y) shows the blue
+ corners over the rectangle (page units), and none once it leaves."""
+ state = self.state()
+ fx = (x - state["origin"][0]) / state["page"]["width"]
+ fy = (y - state["origin"][1]) / state["page"]["height"]
+ self.firefox.run(HOVER, fx, fy)
+ cue = self.firefox.run(HOVER_CUE)
+ self.assertFalse(cue["hidden"], f"no cue at {x}, {y}")
+ for key, expected in zip(("x", "y", "width", "height"), rect):
+ self.assertAlmostEqual(cue[key], expected, delta=3, msg=key)
+ self.firefox.run(HOVER, 0.01, 0.01)
+ self.assertTrue(self.firefox.run(HOVER_CUE)["hidden"])
+
def wait_until_true(self, read, condition, what, seconds=15):
"""Wait until condition(read()) is true and return the value read."""
deadline = time.monotonic() + seconds
@@ -1394,9 +1462,11 @@ class EndToEndTest(unittest.TestCase):
def test_arrange_tool(self):
"""Andy, 2026-10-13: with the Arrange tool, blue corners mark every
- image, Hide and own text rectangle, a drag inside one moves it and
- a drag on a corner resizes it, at once, no hold; a press elsewhere
- does nothing. A guest without the right sees only their own text."""
+ image (and, until 2026-10-14, every Hide and own text rectangle,
+ which carry blue corners at all times since), a drag inside one
+ moves it and a drag on a corner resizes it, at once, no hold; a
+ press elsewhere does nothing. A guest without the right sees no
+ cues, their own text rectangle's corners being drawn anyway."""
firefox = self.firefox
host_tab = firefox.current_tab()
firefox.go(self.address("Late"))
@@ -1447,8 +1517,9 @@ class EndToEndTest(unittest.TestCase):
self.assertEqual(after["layers"]["Arranger"], [])
self.assertEqual(rect(after, 0), rect(scaled, 0))
- # A text rectangle of one's own and a Hide get cues too; a corner
- # drag widens the text box, a drag inside moves the Hide.
+ # A text rectangle of one's own and a Hide, marked by their own blue
+ # corners, get no cue; a corner drag widens the text box, a drag
+ # inside moves the Hide.
choose("text")
firefox.run(TEXT_GESTURE, 0.1, 0.6, 0.3, 0.7)
firefox.type_into(".text_field", "Box")
@@ -1457,8 +1528,11 @@ class EndToEndTest(unittest.TestCase):
with_all = self.wait_until(lambda s: len(s["hides"]) == 1 and s["texts"]["Arranger"] and s["texts"]["Arranger"][0]["text"] == "Box",
"the box and the Hide")
choose("arrange")
- self.assertEqual(sorted(cues()), ["hide", "image", "text"])
+ self.assertEqual(cues(), ["image"])
box = with_all["texts"]["Arranger"][0]
+ self.assert_hover_cue(box["x"] + 20, box["y"] + 20, [box["x"], box["y"], box["width"], box["height"]])
+ h = with_all["hides"][0]
+ 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,
px(box["x"] + box["width"]) + 100, py(box["y"] + box["height"]) + 40)
widened = self.wait_until(lambda s: s["texts"]["Arranger"][0]["width"] > box["width"] + 80 / scale, "the box widened")
@@ -1468,13 +1542,14 @@ class EndToEndTest(unittest.TestCase):
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")
self.assertEqual(hide_moved["hides"][0][2:], hide[2:])
- self.assertEqual(len(cues()), 3)
+ self.assertEqual(len(cues()), 1)
# Undo takes the Hide's move back, as it does a hold's.
firefox.run("document.getElementById('undo').click();")
self.wait_until(lambda s: s["hides"][0] == hide, "the Hide back by Undo")
- # The watcher, without the right, is shown only their own text.
+ # The watcher, without the right, is shown no cue; their own text
+ # rectangle has its blue corners as everyone's has.
firefox.switch_to(guest_tab)
self.wait_until(lambda s: len(s["images"]) == 1 and len(s["hides"]) == 1, "the page at the watcher")
choose("arrange")
@@ -1483,7 +1558,276 @@ class EndToEndTest(unittest.TestCase):
firefox.run(TEXT_GESTURE, 0.1, 0.8, 0.3, 0.9)
firefox.type_into(".text_field", "Mine")
choose("arrange")
- self.assertEqual(cues(), ["text"])
+ self.assertEqual(cues(), [])
+ 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]
+ self.assert_hover_cue(mine["x"] + 20, mine["y"] + 20, [mine["x"], mine["y"], mine["width"], mine["height"]])
+
+ def test_shapes(self):
+ """Andy, 2026-10-14: a Shape button right of Text draws the shape it
+ names in the pen's colour and width; a hold on the button, or a
+ secondary press, opens a menu of shapes. A rectangle, an ellipse
+ and an arrow are dragged open and become one stroke each, the
+ arrow a filled one; a line is made of taps, one point each, grows
+ for everyone as it is made, and is finished by a tap on its last
+ point or closed by one on its first. Text rectangles have no
+ frame or ground any more, only blue corners on screen; free text
+ shows its corners while the pointer hovers over it."""
+ firefox = self.firefox
+ host_tab = firefox.current_tab()
+ firefox.go(self.address("Late"))
+ self.enter_name("Drawer", "start_session")
+ invitation = firefox.run("return document.getElementById('invitation').value;")
+ guest_tab = firefox.new_tab()
+ firefox.go(self.address("Student", join=invitation))
+ self.enter_name("Viewer", "join_session")
+ self.wait_until(lambda s: s["page"]["number"] == 1 and len(s["participants"]) == 2, "the viewer")
+ firefox.switch_to(host_tab)
+ choose = lambda tool: firefox.run(f"document.querySelector('[data-tool={tool}]').click();")
+ label = lambda: firefox.run("return document.getElementById('shape_tool').textContent;")
+ pressed = lambda: firefox.run("return document.querySelector('[data-tool][aria-pressed=true]').dataset.tool;")
+ menu_open = lambda: firefox.run("return !document.getElementById('shape_menu').hidden;")
+ pick = lambda shape: firefox.run(f"document.querySelector('#shape_menu [data-shape={shape}]').click();")
+ strokes = lambda s: s["layers"].get("Drawer", [])
+ state = self.state()
+ origin, page = state["origin"], state["page"]
+ at = lambda fx, fy: (origin[0] + fx * page["width"], origin[1] + fy * page["height"])
+
+ # The button names the default shape and keeps one width whatever it names.
+ self.assertEqual(label(), "Rectangle")
+ button_width = firefox.run("return document.getElementById('shape_tool').getBoundingClientRect().width;")
+ choose("shape")
+ self.assertEqual(pressed(), "shape")
+ self.assertEqual(firefox.run("return ['colors', 'widths', 'text_style'].map((id) => document.getElementById(id).hidden);"),
+ [False, False, True], "colours and widths show, as for the pen")
+
+ # A rectangle: one stroke of four corners, each twice for straight
+ # sides (the first and last once), at the pen's width and the
+ # nominal pressure, no fill.
+ firefox.run(TEXT_GESTURE, 0.1, 0.1, 0.3, 0.25)
+ drawn = self.wait_until(lambda s: len(strokes(s)) == 1 and strokes(s)[0]["done"], "the rectangle")
+ rect = strokes(drawn)[0]
+ self.assertEqual((rect["filled"], rect["color"], rect["width"]), (False, "#000000", 7))
+ self.assertEqual(len(rect["points"]), 8 * 3)
+ self.assertEqual(set(rect["points"][2::3]), {0.4})
+ self.assertAlmostEqual(rect["points"][0], at(0.1, 0.1)[0], delta=1)
+ self.assertAlmostEqual(rect["points"][13], at(0.3, 0.25)[1], delta=1)
+ self.assertEqual(rect["points"][0:2], rect["points"][21:23], "closed at the first corner")
+ # A tap draws nothing.
+ firefox.run(TEXT_GESTURE, 0.5, 0.5, 0.5, 0.5)
+ time.sleep(0.3)
+ self.assertEqual(len(strokes(self.state())), 1)
+
+ # The menu: a quick press on the button chooses the tool and opens
+ # nothing; a press held for the hold time opens it, and the click
+ # that follows chooses nothing.
+ choose("pen")
+ self.assertFalse(menu_open())
+ self.assertTrue(firefox.run_until_done(HOLD_SHAPE_BUTTON, 600), "the menu opens on a hold")
+ self.assertEqual(pressed(), "pen", "the click after the hold chooses nothing")
+ pick("ellipse")
+ self.assertFalse(menu_open())
+ self.assertEqual((label(), pressed()), ("Ellipse", "shape"))
+ self.assertEqual(firefox.run("return document.getElementById('shape_tool').getBoundingClientRect().width;"), button_width)
+ # Two quick clicks open it too, and it marks the current shape.
+ firefox.run("const b = document.getElementById('shape_tool'); b.click(); b.click();")
+ self.assertTrue(menu_open())
+ self.assertEqual(firefox.run("return [...document.querySelectorAll('#shape_menu [data-shape]')].filter((i) => i.getAttribute('aria-checked') === 'true').map((i) => i.dataset.shape);"), ["ellipse"])
+ self.assertEqual(firefox.run("return getComputedStyle(document.querySelector('#shape_menu [data-shape=ellipse]'), '::before').content;"), '"✓"')
+ self.assertEqual(firefox.run("return getComputedStyle(document.querySelector('#shape_menu [data-shape=line]'), '::before').content;"), '""')
+ firefox.chord("\ue00c")
+ self.assertFalse(menu_open())
+ firefox.run(TEXT_GESTURE, 0.4, 0.1, 0.6, 0.3)
+ drawn = self.wait_until(lambda s: len(strokes(s)) == 2 and strokes(s)[1]["done"], "the ellipse")
+ ellipse = strokes(drawn)[1]["points"]
+ self.assertGreaterEqual(len(ellipse) // 3, 24)
+ self.assertEqual(ellipse[0:2], ellipse[-3:-1], "closed")
+ self.assertAlmostEqual(ellipse[0], at(0.6, 0.1)[0], delta=1, msg="begins at the right end of the middle line")
+ self.assertAlmostEqual(ellipse[1], at(0.5, 0.2)[1], delta=1)
+ # A secondary press (the right button, a pen's barrel button) opens it too.
+ firefox.run("document.getElementById('shape_tool').dispatchEvent(new MouseEvent('contextmenu', {bubbles: true, cancelable: true}));")
+ self.assertTrue(menu_open())
+ pick("arrow")
+ self.assertEqual(label(), "Arrow")
+
+ # An arrow: a filled polygon of seven points, its tip at the end of
+ # the drag; the file holds it as a filled path.
+ firefox.run(TEXT_GESTURE, 0.1, 0.5, 0.3, 0.5)
+ drawn = self.wait_until(lambda s: len(strokes(s)) == 3 and strokes(s)[2]["done"], "the arrow")
+ arrow = strokes(drawn)[2]
+ self.assertTrue(arrow["filled"])
+ self.assertEqual(len(arrow["points"]), 7 * 3)
+ self.assertAlmostEqual(arrow["points"][9], at(0.3, 0.5)[0], delta=1)
+ self.assertAlmostEqual(arrow["points"][10], at(0.3, 0.5)[1], delta=1)
+ self.assertEqual(arrow["points"][6], arrow["points"][12], "the barbs are abreast")
+ self.assertAlmostEqual(abs(arrow["points"][13] - arrow["points"][7]), 2 * 0.6 * 25 / 7 * 7, delta=1, msg="the head of a 0.7 mm arrow is 2.5 mm long, 3 mm wide")
+ self.assertAlmostEqual(arrow["points"][9] - arrow["points"][6], 25, delta=0.5, msg="the head's length")
+ self.assertEqual(arrow["shape"], "arrow")
+ saved = self.wait_until_true(lambda: (self.notebooks / "Drawer notebook" / "Current.svg").read_text(),
+ lambda text: "data-filled" in text, "the arrow saved")
+ self.assertRegex(saved, r']*>')
+ self.assertEqual(saved.count("]*> c.className.split(' ')[1]);")
+ choose("arrange")
+ self.assertEqual(cues(), ["shape"] * 7)
+ scale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;")
+ px = lambda x: (x - origin[0]) * scale
+ py = lambda y: (y - origin[1]) * scale
+ 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)
+ moved = self.wait_until(lambda s: strokes(s)[4]["points"] != before, "the rectangle moved")
+ after = strokes(moved)[4]["points"]
+ self.assertEqual(len(after), len(before))
+ for i in range(0, len(before), 3):
+ self.assertAlmostEqual(after[i] - before[i], 100 / scale, delta=2)
+ self.assertAlmostEqual(after[i + 1] - before[i + 1], 50 / scale, delta=2)
+ self.assertEqual((strokes(moved)[4]["id"], strokes(moved)[4]["fill"]), (strokes(filled)[4]["id"], "#43a047"))
+ firefox.switch_to(guest_tab)
+ self.wait_until(lambda s: strokes(s)[4]["points"] == after, "the moved rectangle at the viewer")
+ firefox.switch_to(host_tab)
+ firefox.run("document.getElementById('undo').click();")
+ self.wait_until(lambda s: strokes(s)[4]["points"] == before, "the move undone")
+ tri = strokes(triangle)[5]["points"]
+ right, bottom = max(tri[0::3]), max(tri[1::3])
+ firefox.drag("#page", px(right) - 3, py(bottom) - 3, px(right) + 80, py(bottom) + 60)
+ resized = self.wait_until(lambda s: strokes(s)[5]["points"] != tri, "the triangle resized")
+ p = strokes(resized)[5]["points"]
+ self.assertAlmostEqual(min(p[0::3]), min(tri[0::3]), delta=1, msg="the left edge stays")
+ self.assertAlmostEqual(max(p[0::3]), right + 80 / scale, delta=2)
+ self.assertAlmostEqual(max(p[1::3]), bottom + 60 / scale, delta=2)
+ self.assertEqual(p[0:2], p[-3:-1], "still closed")
+ head = arrow["points"][13] - arrow["points"][7]
+ firefox.drag("#page", px(at(0.2, 0.5)[0]), py(at(0.2, 0.5)[1]), px(at(0.2, 0.5)[0]), py(at(0.2, 0.5)[1]) + 40)
+ arrow_moved = self.wait_until(lambda s: strokes(s)[1]["points"] != arrow["points"], "the arrow moved") # Index 1 since the rectangle's erasure.
+ a = strokes(arrow_moved)[1]["points"]
+ self.assertAlmostEqual(a[13] - a[7], head, delta=0.5, msg="the head keeps its size")
+ self.assertAlmostEqual(a[10] - arrow["points"][10], 40 / scale, delta=2)
+ choose("pen")
+ self.assertEqual(cues(), [])
+
+ # A text rectangle: nothing along its edges, nothing of it in the
+ # file; it and free text show their corners while the pointer
+ # hovers over them, and not otherwise.
+ choose("text")
+ firefox.run(TEXT_GESTURE, 0.36, 0.58, 0.48, 0.66) # Clear of every shape (the moved arrow's tip is near 0.3, 0.53).
+ firefox.type_into(".text_field", "Box")
+ choose("pen")
+ 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["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"]])
+ saved = self.wait_until_true(lambda: (self.notebooks / "Drawer notebook" / "Current.svg").read_text(),
+ lambda text: "Box" in text, "the box saved")
+ self.assertNotIn("data-ground", saved)
+ self.assertNotIn('stroke="#e53935"', saved.split("Box")[1].split("")[0])
+ choose("text")
+ firefox.run(TEXT_GESTURE, 0.75, 0.6, 0.75, 0.6)
+ firefox.type_into(".text_field", "Frei")
+ choose("pen")
+ free = self.wait_until(lambda s: len(s["texts"]["Drawer"]) == 2 and s["texts"]["Drawer"][1]["text"] == "Frei", "the free text")["texts"]["Drawer"][1]
+ self.assertTrue(firefox.run(HOVER_CUE)["hidden"])
+ 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,
+ [free["x"], free["y"], free["boxWidth"], free["boxHeight"]])
def test_page_menu_and_help(self):
"""Andy, 2026-10-14: the page and view commands are in a Page menu,
@@ -2165,10 +2509,11 @@ class EndToEndTest(unittest.TestCase):
block = self.wait_until(lambda s: s["texts"]["Teacher"][0]["size"] == 49.4,
"the text back at 14 points")["texts"]["Teacher"][0]
- # A drag opens a text rectangle, shown dotted while dragging: a red
- # box, white inside, in which the text wraps and which grows
- # downward by lines as the text needs. A hold at its corner
- # resizes it freely; a hold inside moves it.
+ # A drag opens a text rectangle, shown dotted while dragging: a
+ # region in which the text wraps and which grows downward by lines
+ # as the text needs, marked by blue corners (until 2026-10-14 a red
+ # box, white inside, which the field drew as an outline). A hold
+ # at its corner resizes it freely; a hold inside moves it.
firefox.run("""
const page = document.getElementById('page');
const box = page.getBoundingClientRect();
@@ -2190,7 +2535,7 @@ class EndToEndTest(unittest.TestCase):
self.assertGreater(preview["height"], 10)
firefox.run(TEXT_GESTURE, 0.1, 0.75, 0.6, 0.78)
self.assertTrue(firefox.run("return document.querySelector('.rect_preview').hidden;"))
- self.assertEqual(firefox.run("return getComputedStyle(document.querySelector('.text_field')).outlineStyle;"), "solid")
+ self.assertEqual(firefox.run("return getComputedStyle(document.querySelector('.text_field')).outlineStyle;"), "none")
firefox.type_into(".text_field", "Ein langer Satz, der an der rechten Kante umbrochen wird.")
wrapped = self.wait_until(lambda s: len(s["texts"]["Teacher"]) == 2 and
len(s["texts"]["Teacher"][1]["lines"]) > 1, "the wrapped text")
@@ -2253,15 +2598,16 @@ class EndToEndTest(unittest.TestCase):
# The page file holds the text, and reads it back.
saved_text = self.wait_until_true(
lambda: (self.notebooks / "Teacher notebook" / "Current.svg").read_text(),
- lambda saved: " s.classList.contains('missing') ? '' : s.getAttribute('aria-label').replace('Color ', ''));
""")
+ row_height = firefox.run("return document.querySelector('.toolbar').getBoundingClientRect().height;")
firefox.run("document.getElementById('more_colors').click();")
self.assertFalse(firefox.run("return document.getElementById('color_palette').hidden;"))
+ # The palette floats below "+", above the page; it does not reflow
+ # the toolbar (it did from the Page menu's commit of 2026-10-14
+ # until the same day, when its positioning had moved to the shared
+ # popup rule and the palette had not been given that class).
+ self.assertEqual(firefox.run("return document.querySelector('.toolbar').getBoundingClientRect().height;"), row_height)
+ placed = firefox.run("""
+ const p = document.getElementById('color_palette').getBoundingClientRect();
+ const b = document.getElementById('more_colors').getBoundingClientRect();
+ return {position: getComputedStyle(document.getElementById('color_palette')).position,
+ below: p.top >= b.bottom, left: Math.abs(p.left - b.left) < 1};
+ """)
+ self.assertEqual(placed, {"position": "absolute", "below": True, "left": True})
shown = palette()
self.assertEqual(len(shown), 32)
self.assertEqual([i for i, c in enumerate(shown) if c == ""], [0, 3, 5, 24, 31])
@@ -2753,6 +3112,9 @@ class EndToEndTest(unittest.TestCase):
""")
self.assertTrue(firefox.run("return document.getElementById('color_palette').hidden;"))
self.assertEqual(swatches()[-1], "#8e24aa")
+ # The added colour is among the shapes' fill colours as well.
+ self.assertEqual(firefox.run("return [...document.querySelectorAll('#shape_fill .swatch')].map((s) => s.getAttribute('aria-label'));"),
+ ["No fill", "Fill #000000", "Fill #e53935", "Fill #43a047", "Fill #1e88e5", "Fill #ffffff", "Fill #8e24aa"])
self.assertEqual(firefox.run("""
const pressed = [...document.querySelectorAll('#colors .swatch[aria-pressed=true]')];
return pressed.map((s) => s.getAttribute('aria-label'));