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

+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. */