The Shape tool, implied rectangles, and the palette floating again

A Shapes button right of Text draws a rectangle, an ellipse, a line of
taps or an arrow in the pen's colour and width, with a fill colour of
its own; a hold, a secondary press or two quick clicks open its menu.
Text and Hide rectangles draw nothing on the page and show blue corners
while the pointer hovers over them; free text's red corner mark is
gone.  Arrange moves and resizes shapes.  The colour palette floats
below "+" again instead of reflowing the toolbar.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
ackandClaude Fable 5.1 committed 2026-10-14 14:00:47 +02:00
1 parent 46a82bf3b7
commit 03907da8c9
12 files changed
+1500 -295

No files matched your search

+18 -3
View File
@@ -45,14 +45,29 @@
<button type="button" data-tool="pen" aria-pressed="true"
data-help="Write on the page. Only your own writing can be erased.">Pen</button>
<button type="button" data-tool="text" aria-pressed="false"
data-help="Type on the page. Tap where the text should begin, or drag open a rectangle: a red box, white inside, in which the text wraps and which grows downward as you type. Tap your own text to change it; rest the pen on it to move it, or near a corner of a box to resize it, or use Arrange.">Text</button>
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</button>
<button type="button" id="shape_tool" data-tool="shape" aria-pressed="false" aria-haspopup="menu" aria-expanded="false" data-name="Shapes"
data-help="Draw the shape named on the button, in the pen’s colour and line width. Hold the button down, or press it with the right button, for the other shapes.">Rectangle</button>
<div id="shape_menu" class="popup menu" role="menu" hidden>
<button type="button" class="menu_item" role="menuitem" data-shape="rectangle"
data-help="Rectangle: drag open the rectangle from one corner to the opposite one.">Rectangle</button>
<button type="button" class="menu_item" role="menuitem" data-shape="ellipse"
data-help="Ellipse: drag open the rectangle the ellipse fills.">Ellipse</button>
<button type="button" class="menu_item" role="menuitem" data-shape="line"
data-help="Line: tap each point of the line in turn; a drag gives its first two. Tap the last point again to finish, or the first point to close the shape. Backspace takes back the last point, Escape finishes.">Line</button>
<button type="button" class="menu_item" role="menuitem" data-shape="arrow"
data-help="Arrow: drag from the tail to the tip. The head is filled, its width from the line width.">Arrow</button>
<hr>
<div class="menu_title">Fill the shapes with</div>
<div class="group fills" id="shape_fill" role="radiogroup" aria-label="Fill"></div>
</div>
</div>
<div id="settings" class="settings">
<div class="group" id="colors" role="radiogroup" aria-label="Color">
<button id="more_colors" type="button" class="swatch more"
data-help="Choose another color. It is added here.">+</button>
</div>
<div id="color_palette" class="palette" hidden></div>
<div id="color_palette" class="popup palette" hidden></div>
<div class="group" id="widths" role="radiogroup" aria-label="Line width"></div>
<div class="group" id="text_style" hidden>
<select id="text_font" aria-label="Font" data-help="The font: a sans-serif, a serif or a monospaced one, as this computer has them.">
@@ -68,7 +83,7 @@
<button type="button" data-tool="hide" aria-pressed="false" hidden
data-help="Hide part of the page for everyone: drag open a rectangle, and what lies under it is covered in white, marked by blue corners. Rest the pen on it to move it, or near a corner to resize it, or use Arrange; a tap of the eraser removes it.">Hide</button>
<button type="button" data-tool="arrange" aria-pressed="false"
data-help="Move and resize: the corners of every image, Hide and text box of yours are marked in blue. Drag inside one to move it, drag a corner to resize it (an image keeps its shape). A press elsewhere does nothing. With any other tool, resting the pen on one of them for half a second does the same.">Arrange</button>
data-help="Move and resize: the corners of every image are marked in blue, as those of Hides and text rectangles always are. Drag inside an image, a Hide or a text rectangle of yours to move it, drag a corner to resize it (an image keeps its shape). A press elsewhere does nothing. With any other tool, resting the pen on one of them for half a second does the same.">Arrange</button>
<button type="button" data-tool="eraser" aria-pressed="false"
data-help="Erase your own strokes and text by moving over them. A tap on an image, or on a Hide rectangle, removes it, if you are the host or a co-host (or added the image yourself). Two quick taps on empty paper, away from anything the eraser would remove, undo your latest mark; again, and the one before.">Eraser</button>
</div>
+417 -52
View File
@@ -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) {
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.
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));
}
const chosen = button.getAttribute('data-tool');
options.setTool(chosen === 'eraser' ? 'eraser' : chosen === 'text' ? 'text' : chosen === 'hide' ? 'hide'
: chosen === 'arrange' ? 'arrange' : 'pen');
options.setTool(/** @type {Tool} */ (button.getAttribute('data-tool')));
};
for (const button of document.querySelectorAll('[data-tool]')) {
button.addEventListener('click', () => {
if (button === shapeButton && shapeMenuOpened) {
shapeMenuOpened = false;
return;
}
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<typeof setTimeout> | 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);
+5 -1
View File
@@ -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();
+35 -36
View File
@@ -16,6 +16,13 @@
* stroke="#1f4fd8" ...>
* <path d="M... Q..." stroke-width="6.9"/> what is shown
* </g>
* <g data-stroke=".." data-filled="" data-shape="arrow" ...> a filled shape (an arrow)
* <path d="M... L... Z" fill="#1f4fd8"/>
* </g>
* <g data-stroke=".." data-shape="rectangle" data-fill="#fdd835" ...>
* <path d="M... L... Z" fill="#fdd835" stroke="none"/> the fill beneath the line
* <path d="M... Q..." stroke-width="7"/>
* </g>
* <g data-text="<text id>" data-x=".." data-y=".." data-width=".." ...>
* <desc>the text as typed</desc>
* <text ...><tspan>one line as shown</tspan>...</text>
@@ -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) =>
`<tspan x="${number(block.x + block.size * LEFT_GAP)}"` +
` y="${number(block.y + baselineOf(block.size, block.font, i))}">${escapedContent(line)}</tspan>`);
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
? `<path d="M${block.x} ${block.y + TEXT_MARK}V${block.y}H${block.x + TEXT_MARK}"` +
` fill="none" stroke="${TEXT_FRAME_COLOR}" stroke-width="${TEXT_FRAME_WIDTH}"/>`
: `<rect x="${bounds.left}" y="${bounds.top}" width="${bounds.width}" height="${bounds.height}"` +
` fill="none" stroke="${TEXT_FRAME_COLOR}" stroke-width="${TEXT_FRAME_WIDTH}"/>`;
return `<g data-text="${escaped(block.id)}" data-x="${block.x}" data-y="${block.y}"` +
` data-width="${block.width ?? ''}" data-height="${block.height ?? ''}"` +
` data-size="${block.size}" data-font="${block.font}"` +
` data-box="${block.boxWidth} ${block.boxHeight}">` +
`<desc>${escapedContent(block.text)}</desc>${frame}` +
`<desc>${escapedContent(block.text)}</desc>` +
`<text font-family="${block.font}" font-size="${block.size}" fill="${block.color}"` +
` xml:space="preserve">${lines.join('')}</text></g>`;
}
@@ -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 `<path d="${path}Z" fill="${color}" stroke="none"/>`;
};
// 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 `<circle cx="${number(dot.x)}" cy="${number(dot.y)}" r="${number(dot.width / 2)}"` +
return shapes + `<circle cx="${number(dot.x)}" cy="${number(dot.y)}" r="${number(dot.width / 2)}"` +
` fill="${stroke.color}" stroke="none"/>`;
}
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(`<rect data-ground="${escaped(block.id)}" x="${b.left}" y="${b.top}"` +
` width="${b.width}" height="${b.height}" fill="#ffffff"/>`);
}
}
if (grounds.length) lines.push('<g data-grounds="">', ...grounds, '</g>');
// (Until 2026-10-14 a <g data-grounds> 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(
`<g data-stroke="${escaped(stroke.id)}" data-width="${stroke.width}"` +
` data-points="${stroke.points.join(' ')}" stroke="${stroke.color}" fill="none"` +
` data-points="${stroke.points.join(' ')}"${stroke.filled ? ' data-filled=""' : ''}` +
`${stroke.fill ? ` data-fill="${stroke.fill}"` : ''}${stroke.shape ? ` data-shape="${stroke.shape}"` : ''}` +
` stroke="${stroke.color}" fill="none"` +
` stroke-linecap="round" stroke-linejoin="round">${strokeShapes(stroke)}</g>`);
}
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);
}
+62 -10
View File
@@ -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. */
+26 -7
View File
@@ -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`);
});
+139 -119
View File
@@ -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);
}
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);
+85 -4
View File
@@ -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<typeof setTimeout> | 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;
+269
View File
@@ -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<Shape, string>} 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;
}
+7 -24
View File
@@ -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`;
}
}
+52 -16
View File
@@ -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. */
+385 -23
View File
@@ -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'<g data-stroke="[^"]+" data-width="7" data-points="[^"]+" data-filled="" data-shape="arrow" stroke="#000000"[^>]*><path d="M[^"]+Z" fill="#000000" stroke="none"/></g>')
self.assertEqual(saved.count("<g data-stroke="), 3)
# A line: a tap for each point, the stroke growing with each (its
# inner points twice, for straight sides); a tap on the last point
# finishes it.
firefox.run("document.getElementById('shape_tool').dispatchEvent(new MouseEvent('contextmenu', {bubbles: true, cancelable: true}));")
pick("line")
firefox.run(TEXT_GESTURE, 0.1, 0.7, 0.1, 0.7)
begun = self.wait_until(lambda s: len(strokes(s)) == 4, "the line's first point")
self.assertEqual((strokes(begun)[3]["done"], len(strokes(begun)[3]["points"])), (False, 3))
firefox.run(TEXT_GESTURE, 0.3, 0.7, 0.3, 0.7)
firefox.run(TEXT_GESTURE, 0.3, 0.9, 0.3, 0.9)
grown = self.wait_until(lambda s: len(strokes(s)[3]["points"]) == 5 * 3, "two more points")
self.assertFalse(strokes(grown)[3]["done"])
p = strokes(grown)[3]["points"]
self.assertEqual(p[3:5], p[6:8])
self.assertEqual(p[9:11], p[12:14])
# The rubber band follows a hovering pointer.
firefox.run(HOVER, 0.5, 0.9)
self.assertFalse(firefox.run("return document.querySelector('.shape_preview').hasAttribute('hidden');"))
firefox.run(TEXT_GESTURE, 0.3, 0.9, 0.3, 0.9)
done = self.wait_until(lambda s: strokes(s)[3]["done"], "the line finished by a tap on its last point")
self.assertEqual(len(strokes(done)[3]["points"]), 5 * 3)
self.assertTrue(firefox.run("return document.querySelector('.shape_preview').hasAttribute('hidden');"))
# A tap on the first point closes the shape: a triangle.
for fx, fy in ((0.5, 0.7), (0.7, 0.7), (0.6, 0.9), (0.5, 0.7)):
firefox.run(TEXT_GESTURE, fx, fy, fx, fy)
closed = self.wait_until(lambda s: len(strokes(s)) == 5 and strokes(s)[4]["done"], "the triangle")
p = strokes(closed)[4]["points"]
self.assertEqual(len(p), 6 * 3)
self.assertEqual(p[0:2], p[15:17], "back at the first point")
# Backspace takes the last point back, Escape finishes.
for fx, fy in ((0.8, 0.7), (0.9, 0.7), (0.9, 0.9)):
firefox.run(TEXT_GESTURE, fx, fy, fx, fy)
self.wait_until(lambda s: len(strokes(s)) == 6 and len(strokes(s)[5]["points"]) == 5 * 3, "three points")
firefox.chord("\ue003")
shortened = self.wait_until(lambda s: len(strokes(s)) == 6 and len(strokes(s)[5]["points"]) == 3 * 3, "two points left")
self.assertFalse(strokes(shortened)[5]["done"])
firefox.chord("\ue00c")
self.wait_until(lambda s: strokes(s)[5]["done"], "the line finished by Escape")
# The viewer holds the same six strokes, the arrow filled.
firefox.switch_to(guest_tab)
theirs = self.wait_until(lambda s: len(strokes(s)) == 6 and all(one["done"] for one in strokes(s)), "the shapes at the viewer")
self.assertEqual(strokes(theirs), strokes(self.state()))
firefox.switch_to(host_tab)
self.assertEqual(strokes(theirs), strokes(self.state()))
# Undo takes the last line back; the eraser removes the rectangle
# like any stroke.
firefox.run("document.getElementById('undo').click();")
self.wait_until(lambda s: len(strokes(s)) == 5, "the line undone")
choose("eraser")
firefox.run(TEXT_GESTURE, 0.2, 0.1, 0.2, 0.1)
self.wait_until(lambda s: len(strokes(s)) == 4 and strokes(s)[0]["id"] != rect["id"], "the rectangle erased")
# With Extend off a shape that would reach past the page is not drawn.
firefox.run("const box = document.getElementById('extend'); box.checked = false; box.dispatchEvent(new Event('change'));")
choose("shape")
firefox.run("document.getElementById('shape_tool').dispatchEvent(new MouseEvent('contextmenu', {bubbles: true, cancelable: true}));")
pick("rectangle")
firefox.run(TEXT_GESTURE, 0.9, 0.5, 0.995, 0.6)
time.sleep(0.4)
self.assertEqual(len(strokes(self.state())), 4)
firefox.run("const box = document.getElementById('extend'); box.checked = true; box.dispatchEvent(new Event('change'));")
# A fill colour from the menu's row, a state of its own: the
# rectangle's polygon is filled beneath its line, in the file too;
# a closed line is filled, an open one not; "No fill" ends it.
firefox.run("document.getElementById('shape_tool').dispatchEvent(new MouseEvent('contextmenu', {bubbles: true, cancelable: true}));")
firefox.run("document.querySelector('#shape_fill [aria-label=\"Fill #43a047\"]').click();")
self.assertTrue(menu_open(), "a fill keeps the menu open")
pick("rectangle")
firefox.run(TEXT_GESTURE, 0.1, 0.3, 0.3, 0.45)
filled = self.wait_until(lambda s: len(strokes(s)) == 5 and strokes(s)[4]["done"], "the filled rectangle")
self.assertEqual((strokes(filled)[4]["fill"], strokes(filled)[4]["color"], strokes(filled)[4]["shape"]), ("#43a047", "#000000", "rectangle"))
saved = self.wait_until_true(lambda: (self.notebooks / "Drawer notebook" / "Current.svg").read_text(),
lambda text: 'data-fill="' in text, "the fill saved")
self.assertRegex(saved, r'data-fill="#43a047" data-shape="rectangle"[^>]*><path d="M[^"]+Z" fill="#43a047" stroke="none"/><path d="M')
firefox.run("document.getElementById('shape_tool').dispatchEvent(new MouseEvent('contextmenu', {bubbles: true, cancelable: true}));")
pick("line")
for fx, fy in ((0.5, 0.3), (0.7, 0.3), (0.6, 0.45), (0.5, 0.3)):
firefox.run(TEXT_GESTURE, fx, fy, fx, fy)
triangle = self.wait_until(lambda s: len(strokes(s)) == 6 and strokes(s)[5]["done"], "a filled triangle")
self.assertEqual(strokes(triangle)[5]["fill"], "#43a047")
saved = self.wait_until_true(lambda: (self.notebooks / "Drawer notebook" / "Current.svg").read_text(),
lambda text: text.count('data-fill="') == 2, "the triangle saved")
self.assertEqual(saved.count('fill="#43a047" stroke="none"'), 2)
firefox.run("document.getElementById('shape_tool').dispatchEvent(new MouseEvent('contextmenu', {bubbles: true, cancelable: true}));")
firefox.run("document.querySelector('#shape_fill [aria-label=\"No fill\"]').click();")
pick("line")
for fx, fy in ((0.8, 0.3), (0.9, 0.45), (0.9, 0.45)):
firefox.run(TEXT_GESTURE, fx, fy, fx, fy)
unfilled = self.wait_until(lambda s: len(strokes(s)) == 7 and strokes(s)[6]["done"], "an unfilled line")
self.assertIsNone(strokes(unfilled)[6]["fill"])
# Arrange marks one's own shapes and moves or resizes them: a drag
# inside the filled rectangle moves it whole, a corner drag of the
# triangle resizes it, the arrow keeps its head when moved; the
# viewer follows; Undo takes a move back.
cues = lambda: firefox.run("return [...document.querySelectorAll('.arrange_cue')].map((c) => 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: "<desc>Box</desc>" in text, "the box saved")
self.assertNotIn("data-ground", saved)
self.assertNotIn('stroke="#e53935"', saved.split("<desc>Box</desc>")[1].split("</g>")[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: "<text " in saved and "zwei Zeilen" in saved and 'data-ground=' in saved,
lambda saved: "<text " in saved and "zwei Zeilen" in saved and "Weiter." in saved,
"the text to be saved")
# The rectangle's white ground lies beneath every layer, its red
# frame within its own.
self.assertLess(saved_text.index('<g data-grounds='), saved_text.index('<g data-layer='))
self.assertIn('fill="none" stroke="#e53935"', saved_text)
# Free text carries its corner mark, a text rectangle its frame.
self.assertEqual(saved_text.count('<path d="M'), 1)
self.assertEqual(saved_text.count('<rect x='), 2) # The sheet, and the frame.
# A text block draws nothing of its own on the page (Andy,
# 2026-10-14 and 2026-10-14: an implied rectangle, blue corners on
# screen while the pointer hovers; a red frame on a white ground
# and a red L on free text until then).
self.assertNotIn('#e53935', saved_text)
self.assertEqual(saved_text.count('<path d="M'), 0)
self.assertNotIn('data-ground', saved_text)
self.assertEqual(saved_text.count('<rect x='), 1) # The sheet alone.
round_trip = firefox.run("""
return (async () => {
const {pageToSvg, pageFromSvg} = await import('./js/page_file.js');
@@ -2742,8 +3088,21 @@ class EndToEndTest(unittest.TestCase):
return [...document.querySelectorAll('#color_palette .swatch')]
.map((s) => 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'));