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