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

+35 -36
View File
@@ -16,6 +16,13 @@
* stroke="#1f4fd8" ...>
* <path d="M... Q..." stroke-width="6.9"/> what is shown
* </g>
* <g data-stroke=".." data-filled="" data-shape="arrow" ...> a filled shape (an arrow)
* <path d="M... L... Z" fill="#1f4fd8"/>
* </g>
* <g data-stroke=".." data-shape="rectangle" data-fill="#fdd835" ...>
* <path d="M... L... Z" fill="#fdd835" stroke="none"/> the fill beneath the line
* <path d="M... Q..." stroke-width="7"/>
* </g>
* <g data-text="<text id>" data-x=".." data-y=".." data-width=".." ...>
* <desc>the text as typed</desc>
* <text ...><tspan>one line as shown</tspan>...</text>
@@ -28,7 +35,7 @@
import { fromBase64, toBase64 } from './crypto.js';
import {
POINT_SIZE, Page, cleanHide, cleanImage, cleanName, cleanPageDescriptor, cleanStroke, cleanText, pointCount, textBounds, validId,
POINT_SIZE, Page, cleanHide, cleanImage, cleanName, cleanPageDescriptor, cleanStroke, cleanText, isClosed, pointCount, validId,
} from './page_model.js';
import { partAt } from './stroke_geometry.js';
import { LEFT_GAP, baselineOf } from './text_layout.js';
@@ -36,14 +43,11 @@ import { LEFT_GAP, baselineOf } from './text_layout.js';
/** @typedef {import('./page_model.js').Stroke} Stroke */
const FORMAT_VERSION = '1';
/** A text rectangle's frame: the palette's red, 0.2 mm (Andy's teacher, 2026-10-08). */
export const TEXT_FRAME_COLOR = '#e53935';
export const TEXT_FRAME_WIDTH = 2;
/**
* Free text is marked by an L at its top-left corner, two segments this
* long (5 mm; 3 mm first), in the frame's red (Andy, 2026-10-09).
*/
export const TEXT_MARK = 50;
// (Until 2026-10-14 free text was marked by a red L of 5 mm at its
// top-left corner, page content, and until 2026-10-14 a text rectangle
// by a red frame on a white ground; a text block now draws only its
// text, its extent shown by blue corners on screen while the pointer
// hovers over it. An old file's marks are read as nothing.)
const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
/**
@@ -97,20 +101,11 @@ function textElements(block) {
const lines = block.lines.map((line, i) =>
`<tspan x="${number(block.x + block.size * LEFT_GAP)}"` +
` y="${number(block.y + baselineOf(block.size, block.font, i))}">${escapedContent(line)}</tspan>`);
const bounds = textBounds(block);
// A text rectangle's red frame, or free text's corner mark, is part of
// the page (a rectangle's white ground is written beneath all layers:
// see pageToSvg).
const frame = block.height === null
? `<path d="M${block.x} ${block.y + TEXT_MARK}V${block.y}H${block.x + TEXT_MARK}"` +
` fill="none" stroke="${TEXT_FRAME_COLOR}" stroke-width="${TEXT_FRAME_WIDTH}"/>`
: `<rect x="${bounds.left}" y="${bounds.top}" width="${bounds.width}" height="${bounds.height}"` +
` fill="none" stroke="${TEXT_FRAME_COLOR}" stroke-width="${TEXT_FRAME_WIDTH}"/>`;
return `<g data-text="${escaped(block.id)}" data-x="${block.x}" data-y="${block.y}"` +
` data-width="${block.width ?? ''}" data-height="${block.height ?? ''}"` +
` data-size="${block.size}" data-font="${block.font}"` +
` data-box="${block.boxWidth} ${block.boxHeight}">` +
`<desc>${escapedContent(block.text)}</desc>${frame}` +
`<desc>${escapedContent(block.text)}</desc>` +
`<text font-family="${block.font}" font-size="${block.size}" fill="${block.color}"` +
` xml:space="preserve">${lines.join('')}</text></g>`;
}
@@ -134,12 +129,22 @@ function number(value) {
function strokeShapes(stroke) {
const count = pointCount(stroke);
if (count === 0) return '';
/** The closed polygon of the points, filled with a colour. */
const polygon = (/** @type {string} */ color) => {
const p = stroke.points;
let path = '';
for (let i = 0; i < p.length; i += POINT_SIZE) path += `${i ? 'L' : 'M'}${number(p[i])} ${number(p[i + 1])}`;
return `<path d="${path}Z" fill="${color}" stroke="none"/>`;
};
// A filled stroke (the Shape tool's arrow): its outline, closed and filled.
if (stroke.filled) return polygon(stroke.color);
// A shape's fill colour lies beneath its line.
let shapes = stroke.fill && (stroke.shape !== 'line' || isClosed(stroke)) ? polygon(stroke.fill) : '';
if (count === 1) {
const dot = partAt(stroke, 0);
return `<circle cx="${number(dot.x)}" cy="${number(dot.y)}" r="${number(dot.width / 2)}"` +
return shapes + `<circle cx="${number(dot.x)}" cy="${number(dot.y)}" r="${number(dot.width / 2)}"` +
` fill="${stroke.color}" stroke="none"/>`;
}
let shapes = '';
let path = '';
let pathWidth = '';
for (let k = 0; k < count; k++) {
@@ -184,20 +189,9 @@ export function pageToSvg(page, { session, hostId, hidden }) {
const shown = imageElement(image);
if (shown) lines.push(shown);
}
// The white grounds of the text rectangles: above the images, beneath
// every layer, so that they hide the image and never anyone's writing.
const grounds = [];
for (const id of page.layerOrder(hostId)) {
const layer = page.layers.get(id);
if (!layer || hidden?.has(id)) continue;
for (const block of layer.texts.values()) {
if (block.height === null) continue;
const b = textBounds(block);
grounds.push(`<rect data-ground="${escaped(block.id)}" x="${b.left}" y="${b.top}"` +
` width="${b.width}" height="${b.height}" fill="#ffffff"/>`);
}
}
if (grounds.length) lines.push('<g data-grounds="">', ...grounds, '</g>');
// (Until 2026-10-14 a <g data-grounds> of white rectangles stood here,
// one beneath each text rectangle; a file that has one is read as if
// it had none.)
for (const id of page.layerOrder(hostId)) {
const layer = page.layers.get(id);
if (!layer || layer.isEmpty() || hidden?.has(id)) continue;
@@ -206,7 +200,9 @@ export function pageToSvg(page, { session, hostId, hidden }) {
if (pointCount(stroke) === 0) continue;
lines.push(
`<g data-stroke="${escaped(stroke.id)}" data-width="${stroke.width}"` +
` data-points="${stroke.points.join(' ')}" stroke="${stroke.color}" fill="none"` +
` data-points="${stroke.points.join(' ')}"${stroke.filled ? ' data-filled=""' : ''}` +
`${stroke.fill ? ` data-fill="${stroke.fill}"` : ''}${stroke.shape ? ` data-shape="${stroke.shape}"` : ''}` +
` stroke="${stroke.color}" fill="none"` +
` stroke-linecap="round" stroke-linejoin="round">${strokeShapes(stroke)}</g>`);
}
for (const block of layer.texts.values()) lines.push(textElements(block));
@@ -289,6 +285,9 @@ export function pageFromSvg(text) {
width: Number(element.getAttribute('data-width')),
points,
done: true,
filled: element.hasAttribute('data-filled'),
fill: element.getAttribute('data-fill'),
shape: element.getAttribute('data-shape'),
});
if (stroke && points.length >= POINT_SIZE) layer.strokes.set(stroke.id, stroke);
}