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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user