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

+140 -120
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);
}
context.fillStyle = '#ffffff';
for (const hide of this.page.hides) context.fillRect(hide.x, hide.y, hide.width, hide.height);
}
/**
@@ -754,6 +759,12 @@ export class Renderer {
strokeProgress(layerId, stroke) {
const canvas = this.canvases.get(layerId);
if (!canvas) return;
// A line closed by its last point gets its fill, which lies beneath
// the parts already drawn: the layer is drawn afresh.
if (hasFill(stroke) && (this.drawn.get(stroke) ?? 0) > 0) {
this.redrawLayer(layerId);
return;
}
this.drawNewParts(this.context(canvas), layerId, stroke);
this.drawTails();
}
@@ -765,10 +776,19 @@ export class Renderer {
*/
drawNewParts(context, layerId, stroke) {
const count = pointCount(stroke);
if (stroke.filled) {
// A filled shape is drawn whole, once its points are all here; it
// has no tail to show meanwhile.
if (stroke.done && count > 0 && (this.drawn.get(stroke) ?? 0) < count) drawPart(context, stroke, 0);
this.drawn.set(stroke, stroke.done ? count : 0);
this.active.delete(stroke);
return;
}
// The part for the newest point waits until the stroke is finished or
// another point arrives.
const ready = stroke.done ? count : count - 1;
let k = this.drawn.get(stroke) ?? 0;
if (k === 0 && hasFill(stroke) && stroke.fill) fillPolygon(context, stroke, stroke.fill);
for (; k < ready; k++) drawPart(context, stroke, k);
this.drawn.set(stroke, Math.max(k, 0));
if (stroke.done) this.active.delete(stroke);