Arranging: boxes on everything, handwriting movable, the Eraser's preview, a Circle shape, pixel-exact cues

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
ackandClaude Fable 5.1 committed 2026-10-14 20:38:29 +02:00
1 parent 03907da8c9
commit 24a6a2595b
10 files changed
+690 -126

No files matched your search

+186 -23
View File
@@ -24,9 +24,10 @@ import {
import { HOLD_MS, NOMINAL_PRESSURE, attachPenInput } from './pen_input.js';
import { Renderer } from './renderer.js';
import { LEAST_IMAGE_SIDE, textBounds, rectAt } from './page_model.js';
import { inkExtent } from './stroke_geometry.js';
import {
SHAPE_KEY, SHAPE_NAMES, draggedPoints, isFilled, isTapped, lineInProgressPoints, movedPoints, shapeNamed,
straightPoints, vertexPoints,
SHAPE_KEY, SHAPE_NAMES, draggedPoints, isFilled, isTapped, lineInProgressPoints, movedPoints,
shapeNamed, straightPoints, vertexPoints,
} from './shapes.js';
import { LINE_HEIGHT, TOP_GAP, layoutText } from './text_layout.js';
import { log, logText, trace, watchLog } from './log.js';
@@ -818,16 +819,38 @@ async function enterSession(invitationText, name, notebook) {
return block ? textBounds(block) : null;
}
/**
* The grip around the held thing: for a stroke, around its ink rather
* than its points (Andy, 2026-10-14).
* @param {Held} h
*/
function showHeldGrip(h) {
if (h.kind === 'shape') {
const stroke = session.ownLayer.strokes.get(h.id);
renderer.showGrip(stroke ? inkExtent(stroke) : null, h.corner, true);
} else {
renderer.showGrip(currentHeldRect(h), h.corner, heldHasCorners(h));
}
}
/**
* @param {Held} h
* @returns {boolean} Whether the held thing has corners to resize by:
* everything but free text.
*/
const heldHasCorners = (h) => h.kind !== 'text' || session.ownLayer.texts.get(h.id)?.height !== null;
/** @returns {import('./page_model.js').Stroke[]} Our shapes, in drawing order. */
const ownShapes = () => [...session.ownLayer.strokes.values()].filter((stroke) => stroke.shape !== null && stroke.done);
/**
* With the Arrange tool chosen, mark what a press can move or resize
* and is not marked already: the images (for the host and co-hosts),
* whose edges may not show, and one's own shapes (Andy, 2026-10-14).
* Text blocks and Hides, the implied rectangles, show their corners
* under a hovering pointer with any tool, so the tool adds nothing on
* them. Called whenever the images, the shapes, or the right may have
* With the Arrange tool chosen, mark everything a press can move or
* resize with a blue box and corners (Andy, 2026-10-14): the images
* and Hides for the host and co-hosts, one's own text blocks and
* shapes; free text, which has no width to resize, gets the box without
* the corners. The one being held is left out, the green grip standing
* in its place until the pointer lifts. Called whenever the images,
* the Hides, one's own text or shapes, the right, or the hold may have
* changed.
*/
function refreshArrangeCues() {
@@ -835,18 +858,85 @@ async function enterSession(invitationText, name, notebook) {
renderer.setArrangeCues([]);
return;
}
/** @type {{rect: import('./page_model.js').Rectangle, kind: 'image' | 'text' | 'hide' | 'shape'}[]} */
/** @type {{rect: import('./page_model.js').Rectangle, kind: 'image' | 'text' | 'hide' | 'shape' | 'stroke', corners: boolean}[]} */
const cues = [];
const page = session.page;
if (session.mayMakePages()) {
for (const im of session.page.images) cues.push({ kind: 'image', rect: { left: im.x, top: im.y, width: im.width, height: im.height } });
page.images.forEach((im, index) => {
if (held?.kind === 'image' && held.index === index) return;
cues.push({ kind: 'image', rect: { left: im.x, top: im.y, width: im.width, height: im.height }, corners: true });
});
page.hides.forEach((h, index) => {
if (held?.kind === 'hide' && held.index === index) return;
cues.push({ kind: 'hide', rect: { left: h.x, top: h.y, width: h.width, height: h.height }, corners: true });
});
}
for (const block of session.ownLayer.texts.values()) {
if (held?.kind === 'text' && held.id === block.id) continue;
cues.push({ kind: 'text', rect: textBounds(block), corners: block.height !== null });
}
for (const stroke of ownShapes()) {
const rect = strokeBox(stroke);
if (rect) cues.push({ kind: 'shape', rect });
if (held?.kind === 'shape' && held.id === stroke.id) continue;
const rect = inkExtent(stroke);
if (rect) cues.push({ kind: 'shape', rect, corners: true });
}
// The handwritten stroke under the hovering pointer, if any.
const hovered = hoveredStroke && session.ownLayer.strokes.get(hoveredStroke);
if (hovered && !(held?.kind === 'shape' && held.id === hovered.id)) {
const rect = inkExtent(hovered);
if (rect) cues.push({ kind: 'stroke', rect, corners: true });
}
renderer.setArrangeCues(cues);
}
/**
* Handwriting under Arrange (Andy, 2026-10-14): a page may hold hundreds
* of strokes, so they are not all boxed at once; the one whose ink the
* pointer hovers over gets the box, kept while the pointer stays within
* it (so that its corners can be reached), and a press takes hold of a
* stroke by its ink, or by the shown box's corner, under Arrange only:
* the hold with another tool leaves handwriting alone, since resting
* the pen on one's own writing is part of writing.
* @type {string | null} The id of the stroke whose box is shown.
*/
let hoveredStroke = null;
/**
* The frontmost handwritten stroke of ours (not a shape, which has its
* cue always) within the eraser's reach of the point, by its ink.
* @param {number} x
* @param {number} y
* @returns {import('./page_model.js').Stroke | null}
*/
function handwritingAt(x, y) {
const layer = session.ownLayer;
const ids = strokesTouched(layer, x, y, x, y, renderer.pageLength(ERASER_RADIUS));
for (let i = ids.length - 1; i >= 0; i--) {
const stroke = layer.strokes.get(ids[i]);
if (stroke && stroke.done && stroke.shape === null) return stroke;
}
return null;
}
/**
* Under Arrange, the pointer hovering at this point: the stroke under
* it gets the box, or the shown box stays while the pointer is within
* it or near its corners.
* @param {number} x
* @param {number} y
*/
function hoverHandwriting(x, y) {
const shown = hoveredStroke && session.ownLayer.strokes.get(hoveredStroke);
const box = shown ? inkExtent(shown) : null;
const reach = renderer.cornerReach();
const within = box && x >= box.left - reach && x <= box.left + box.width + reach && y >= box.top - reach && y <= box.top + box.height + reach;
const under = handwritingAt(x, y);
const next = under ? under.id : within ? hoveredStroke : null;
if (next === hoveredStroke) return;
hoveredStroke = next;
refreshArrangeCues();
}
// The Shape tool (Andy, 2026-10-14; shapes.js): a rectangle, an ellipse
// or an arrow is dragged open and becomes one stroke when the pointer
// lifts, a preview showing meanwhile; a line is made of taps, one
@@ -970,29 +1060,68 @@ async function enterSession(invitationText, name, notebook) {
* The implied rectangle under a point, for the hover cue (Andy,
* 2026-10-14: text rectangles and Hides show their corners only then,
* like free text): the frontmost Hide, else the frontmost text block
* among the layers shown.
* among the layers shown, with its kind, which decides the cue's form.
* @param {number} x
* @param {number} y
* @returns {import('./page_model.js').Rectangle | null}
* @returns {{rect: import('./page_model.js').Rectangle, kind: 'hide' | 'rect' | 'free'} | null}
*/
function impliedRectAt(x, y) {
/** @param {import('./page_model.js').Rectangle} b */
const holds = (b) => x >= b.left && x <= b.left + b.width && y >= b.top && y <= b.top + b.height;
for (const h of [...session.page.hides].reverse()) {
const b = { left: h.x, top: h.y, width: h.width, height: h.height };
if (holds(b)) return b;
if (holds(b)) return { rect: b, kind: 'hide' };
}
for (const id of session.layerOrder().reverse()) {
const layer = session.page.layers.get(id);
if (!layer || renderer.hidden.has(id)) continue;
for (const block of [...layer.texts.values()].reverse()) {
const b = textBounds(block);
if (holds(b)) return b;
if (holds(b)) return { rect: b, kind: block.height === null ? 'free' : 'rect' };
}
}
return null;
}
/**
* What a press of the eraser at this point would remove, for the cue
* shown while the pointer hovers (Andy, 2026-10-14): the rectangles
* around the strokes and text blocks of ours within the eraser's reach,
* whole, as the eraser takes them; or, where there is none, the Hide or
* image a tap would remove, by the same rules as eraseEnded.
* @param {number} x
* @param {number} y
* @returns {import('./page_model.js').Rectangle[]}
*/
function eraseTargetsAt(x, y) {
const layer = session.ownLayer;
const radius = renderer.pageLength(ERASER_RADIUS);
/** @type {import('./page_model.js').Rectangle[]} */
const rects = [];
for (const id of strokesTouched(layer, x, y, x, y, radius)) {
const stroke = layer.strokes.get(id);
const rect = stroke && inkExtent(stroke);
if (rect) rects.push(rect);
}
for (const id of textsTouched(layer, x, y, x, y, radius)) {
const block = layer.texts.get(id);
if (block) rects.push(textBounds(block));
}
if (rects.length) return rects;
const page = session.page;
const hide = page.hideAt(x, y, 0);
if (hide && session.mayMakePages()) {
const h = page.hides[hide.index];
return [{ left: h.x, top: h.y, width: h.width, height: h.height }];
}
const found = page.imageAt(x, y, 0);
const image = found && page.images[found.index];
if (image && (session.mayMakePages() || image.by === session.identity.id)) {
return [{ left: image.x, top: image.y, width: image.width, height: image.height }];
}
return [];
}
/** @type {import('./pen_input.js').PenTarget} */
const penTarget = {
toPage: (clientX, clientY) => renderer.pageFromClient(clientX, clientY),
@@ -1030,14 +1159,31 @@ async function enterSession(invitationText, name, notebook) {
} else {
const blocks = [...session.ownLayer.texts.values()];
const at = rectAt(blocks.map(textBounds), x, y, reach);
const shapes = at ? [] : ownShapes();
const shapeAt = at ? null : rectAt(shapes.map((s) => strokeBox(s) ?? { left: 0, top: 0, width: 0, height: 0 }), x, y, reach);
// Handwriting, under Arrange only: the shown box's corner, or a
// stroke by its ink.
/** @type {{stroke: import('./page_model.js').Stroke, corner: [number, number] | null} | null} */
let writing = null;
if (!at && tool === 'arrange') {
const shown = hoveredStroke && session.ownLayer.strokes.get(hoveredStroke);
const shownBox = shown ? inkExtent(shown) : null;
const cornerAt = shownBox ? rectAt([shownBox], x, y, reach) : null;
if (shown && cornerAt?.corner) writing = { stroke: shown, corner: cornerAt.corner };
else {
const under = handwritingAt(x, y);
if (under) writing = { stroke: under, corner: null };
}
}
const shapes = at || writing ? [] : ownShapes();
const shapeAt = at || writing ? null : rectAt(shapes.map((s) => strokeBox(s) ?? { left: 0, top: 0, width: 0, height: 0 }), x, y, reach);
if (at) {
const block = blocks[at.index];
// Free text has no corners to resize by.
const corner = block.height === null ? null : at.corner;
found = hold('text', at.index, block.id, textBounds(block), corner, x, y);
session.beginTextEdit(block.id, 'hold');
} else if (writing) {
const box = /** @type {import('./page_model.js').Rectangle} */ (strokeBox(writing.stroke));
if (session.beginShapeEdit(writing.stroke.id)) found = hold('shape', 0, writing.stroke.id, box, writing.corner, x, y);
} else if (shapeAt) {
// A shape of ours (Andy, 2026-10-14): moved, or resized from a
// corner, by the rectangle around its points.
@@ -1062,7 +1208,8 @@ async function enterSession(invitationText, name, notebook) {
}
textEditor?.hidePreview();
held = found;
renderer.showGrip(currentHeldRect(found), found.corner);
showHeldGrip(found);
refreshArrangeCues(); // The held thing's blue cue gives way to the grip.
log(`${found.kind} held${found.corner ? ' at a corner' : ''}`);
return true;
},
@@ -1071,7 +1218,9 @@ async function enterSession(invitationText, name, notebook) {
const [x, y] = renderer.pageFromClient(clientX, clientY);
const now = currentHeldRect(held);
if (!now) return;
const rect = heldRect(held, now, x, y, held.kind === 'image');
// An image keeps its shape when resized, and so does a circle.
const keepShape = held.kind === 'image' || (held.kind === 'shape' && session.ownLayer.strokes.get(held.id)?.shape === 'circle');
const rect = heldRect(held, now, x, y, keepShape);
if (held.kind === 'image') {
held.index = session.placeImageByHand(held.index, rect);
} else if (held.kind === 'hide') {
@@ -1096,7 +1245,7 @@ async function enterSession(invitationText, name, notebook) {
// The field being typed in follows its block.
if (textEditor?.block?.id === block.id) textEditor.layout();
}
renderer.showGrip(currentHeldRect(held), held.corner);
showHeldGrip(held);
refreshArrangeCues();
},
holdEnd: () => {
@@ -1188,11 +1337,23 @@ async function enterSession(invitationText, name, notebook) {
const last = lineInProgress.vertices[lineInProgress.vertices.length - 1];
renderer.showShapePreview(straightPoints([last, [x, y]]), color, width, false);
}
renderer.showHoverCue(impliedRectAt(x, y));
// With the Eraser, the red box says what matters, so the blue cue
// of an implied rectangle stays away (the two had shown together).
const implied = tool === 'eraser' ? null : impliedRectAt(x, y);
renderer.showHoverCue(implied?.rect ?? null, implied?.kind);
renderer.setEraseCues(tool === 'eraser' ? eraseTargetsAt(x, y) : []);
if (tool === 'arrange') hoverHandwriting(x, y);
},
hoverEnded: () => {
hoverEnded: (left = false) => {
renderer.showHoverCue(null);
renderer.setEraseCues([]);
if (lineInProgress) renderer.hideShapePreview();
// The handwriting's box stays through a press (the pointer is still
// there) and goes when the pointer leaves the page.
if (left && hoveredStroke) {
hoveredStroke = null;
refreshArrangeCues();
}
},
placeRect: (x0, y0, x1, y1) => {
if (tool === 'shape') {
@@ -1248,7 +1409,9 @@ async function enterSession(invitationText, name, notebook) {
element('colors').hidden = value === 'eraser' || value === 'hide' || value === 'arrange';
element('widths').hidden = value !== 'pen' && value !== 'shape';
element('text_style').hidden = value !== 'text';
if (value !== 'arrange') hoveredStroke = null;
refreshArrangeCues();
if (value !== 'eraser') renderer.setEraseCues([]);
},
setShape: (value) => {
finishLine();