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

+85 -4
View File
@@ -36,6 +36,7 @@ import { trimTrailingBlankLines } from './text_layout.js';
* @typedef {{kind: 'stroke', id: string}
* | {kind: 'erase', strokes: Stroke[], texts: TextBlock[]}
* | {kind: 'text', id: string, before: TextBlock | null}
* | {kind: 'shape', id: string, before: Stroke}
* | {kind: 'hide', id: string, before: HideRect | null}} UndoEntry
*/
@@ -137,6 +138,10 @@ export class Session {
this.erasing = null;
/** @type {{id: string, owner: string, before: TextBlock | null} | null} A text block being edited. */
this.textEditing = null;
/** @type {{id: string, before: Stroke} | null} A shape of ours being moved or resized by hand. */
this.shapeEditing = null;
/** @type {ReturnType<typeof setTimeout> | null} The moved shape is sent when this fires. */
this.shapeSendTimer = null;
/** @type {{id: string, before: HideRect} | null} A Hide rectangle being moved. */
this.hideEditing = null;
this.events = events;
@@ -840,6 +845,7 @@ export class Session {
this.undoStack = [];
this.erasing = null;
this.textEditing = null;
this.shapeEditing = null;
this.hideEditing = null;
this.events.onUndoChanged?.();
this.ownLayer = page.layer(this.identity.id, this.name);
@@ -858,19 +864,91 @@ export class Session {
/**
* @param {string} color
* @param {number} width
* @param {object} [shape] What the Shape tool adds to a stroke, each
* sent only when set, so that a client from before 2026-10-14
* reads the message as before.
* @param {boolean} [shape.filled] A filled polygon rather than a line (the arrow).
* @param {string | null} [shape.fill] A fill colour beneath the line.
* @param {import('./page_model.js').ShapeKind | null} [shape.kind] The shape, for Arrange.
* @returns {Stroke}
*/
beginStroke(color, width) {
beginStroke(color, width, shape = {}) {
if (this.currentStroke) this.endStroke();
const stroke = { id: randomId(), color, width, points: [], done: false };
const filled = shape.filled ?? false;
const fill = shape.fill ?? null;
const kind = shape.kind ?? null;
/** @type {Stroke} */
const stroke = { id: randomId(), color, width, points: [], done: false, filled, fill, shape: kind };
this.ownLayer.strokes.set(stroke.id, stroke);
this.currentStroke = stroke;
this.send(0, {
type: 'stroke_begin', page: this.page.id, stroke: { id: stroke.id, color, width },
type: 'stroke_begin', page: this.page.id,
stroke: {
id: stroke.id, color, width,
...(filled ? { filled } : {}), ...(fill ? { fill } : {}), ...(kind ? { shape: kind } : {}),
},
});
return stroke;
}
// A shape of ours moved or resized by hand (Arrange or a hold, Andy,
// 2026-10-14): one gesture to undo, the shape sent whole while it is
// dragged (at most every PAGE_SEND_INTERVAL_MS, by the stroke_restore
// message, which puts a stroke in place of the one with its id) and
// once at the end.
/**
* @param {string} id
* @returns {boolean} Whether the stroke is a shape of ours.
*/
beginShapeEdit(id) {
const stroke = this.ownLayer.strokes.get(id);
if (!stroke || !stroke.shape || this.shapeEditing) return false;
this.shapeEditing = { id, before: { ...stroke, points: stroke.points.slice() } };
return true;
}
/**
* The shape has new points: redrawn here, sent soon.
* @param {string} id
* @param {number[]} points
*/
setShapePoints(id, points) {
const stroke = this.ownLayer.strokes.get(id);
if (!stroke) return;
stroke.points = points;
this.page.recomputeStrokeBounds();
this.events.onLayerReplaced(this.identity.id);
this.extentChanged();
if (this.shapeSendTimer === null) {
this.shapeSendTimer = setTimeout(() => {
this.shapeSendTimer = null;
this.sendShape(id);
}, PAGE_SEND_INTERVAL_MS);
}
}
/** @param {string} id */
sendShape(id) {
const stroke = this.ownLayer.strokes.get(id);
if (stroke) this.send(0, { type: 'stroke_restore', page: this.page.id, strokes: [stroke] });
}
endShapeEdit() {
const editing = this.shapeEditing;
this.shapeEditing = null;
if (!editing) return;
if (this.shapeSendTimer !== null) {
clearTimeout(this.shapeSendTimer);
this.shapeSendTimer = null;
}
this.sendShape(editing.id);
const now = this.ownLayer.strokes.get(editing.id);
if (now && now.points.join(' ') !== editing.before.points.join(' ')) {
this.pushUndo({ kind: 'shape', id: editing.id, before: editing.before });
}
}
/** @param {number[]} points x, y, pressure for each point. */
addPoints(points) {
let stroke = this.currentStroke;
@@ -878,7 +956,7 @@ export class Session {
if (pointCount(stroke) + points.length / POINT_SIZE > MAX_STROKE_POINTS) {
const last = stroke.points.slice(-POINT_SIZE);
this.endStroke();
stroke = this.beginStroke(stroke.color, stroke.width);
stroke = this.beginStroke(stroke.color, stroke.width, { filled: stroke.filled, fill: stroke.fill, kind: stroke.shape });
points = last.concat(points);
}
const at = pointCount(stroke);
@@ -1032,6 +1110,9 @@ export class Session {
if (entry.before) this.setText(entry.before);
else this.removeTexts([entry.id]);
return entry.before ? 'a change of text' : 'a text block';
case 'shape':
this.restoreStrokes([entry.before]);
return 'a move of a shape';
case 'hide': {
const page = this.page;
if (!this.mayMakePages()) return null;