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
+1502 -297

No files matched your search

+418 -53
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) {
const shapeButton = element('shape_tool');
/** The Shape button's hold has just opened the menu: the click that follows chooses nothing. */
let shapeMenuOpened = false;
/** @param {Element} button */
const chooseTool = (button) => {
// The tool buttons stand in two groups since 2026-10-11 (Pen, Text
// and the shapes before the colours they use, Hide, Arrange and
// Eraser after), so the pressed one is marked among all of them, not
// among siblings.
for (const other of document.querySelectorAll('[data-tool]')) {
other.setAttribute('aria-pressed', String(other === button));
}
options.setTool(/** @type {Tool} */ (button.getAttribute('data-tool')));
};
for (const button of document.querySelectorAll('[data-tool]')) {
button.addEventListener('click', () => {
// The tool buttons stand in two groups since 2026-10-11 (Pen and
// Text before the colours they use, Eraser and Hide after), so the
// pressed one is marked among all of them, not among siblings.
for (const other of document.querySelectorAll('[data-tool]')) {
other.setAttribute('aria-pressed', String(other === button));
if (button === shapeButton && shapeMenuOpened) {
shapeMenuOpened = false;
return;
}
const chosen = button.getAttribute('data-tool');
options.setTool(chosen === 'eraser' ? 'eraser' : chosen === 'text' ? 'text' : chosen === 'hide' ? 'hide'
: chosen === 'arrange' ? 'arrange' : 'pen');
chooseTool(button);
});
}
// The Shape button names the current shape, Rectangle to begin with,
// and a quick press chooses the tool; a press held for HOLD_MS, a
// press with the right button (a pen's barrel button) or a double
// click (Andy, 2026-10-14) opens the menu of shapes, and the shape
// chosen there is the button's from then on and the tool is chosen
// (Andy, 2026-10-14: one button for any number of shapes). The menu
// marks the current shape, and ends with the fill colours. The button
// keeps the width of its widest name, so that nothing in the toolbar
// moves when the shape changes.
const shapeMenu = element('shape_menu');
const shapePopup = attachPopup(shapeButton, shapeMenu, undefined, false);
let widest = 0;
for (const name of Object.values(SHAPE_NAMES)) {
shapeButton.textContent = name;
widest = Math.max(widest, shapeButton.getBoundingClientRect().width);
}
shapeButton.style.minWidth = `${Math.ceil(widest)}px`;
/** @param {Shape} shape */
const showShape = (shape) => {
shapeButton.textContent = SHAPE_NAMES[shape];
for (const item of shapeMenu.querySelectorAll('[data-shape]')) {
item.setAttribute('aria-checked', String(item.getAttribute('data-shape') === shape));
}
};
showShape(options.shape);
/** @type {ReturnType<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);