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:
1 parent
46a82bf3b7
commit
03907da8c9
12 files changed
+1502
-297
No files matched your search
+418
-53
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user