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();
+2 -2
View File
@@ -49,9 +49,9 @@ export const IMAGE_MARGIN = 50;
/**
* The shapes of the Shape tool (shapes.js), as a stroke records its kind.
* @typedef {'rectangle' | 'ellipse' | 'line' | 'arrow'} ShapeKind
* @typedef {'rectangle' | 'circle' | 'ellipse' | 'line' | 'arrow'} ShapeKind
*/
export const SHAPE_KINDS = /** @type {const} */ (['rectangle', 'ellipse', 'line', 'arrow']);
export const SHAPE_KINDS = /** @type {const} */ (['rectangle', 'circle', 'ellipse', 'line', 'arrow']);
/** Numbers per point in Stroke.points. */
export const POINT_SIZE = 3;
+3 -3
View File
@@ -69,8 +69,8 @@ const focusSettleOn = !new URLSearchParams(location.search).has('no_focus_settle
* The pointer moves over the page with no button down and nothing in
* progress (a mouse, or a pen in the air above a tablet that reports
* it; a finger never hovers).
* @property {() => void} [hoverEnded]
* The pointer left the page, or pressed.
* @property {(left?: boolean) => void} [hoverEnded]
* The pointer pressed, or left the page (`left` true).
* @property {(clientX: number, clientY: number) => boolean} [beginHold]
* The pointer has rested on the page for HOLD_MS, with any tool: a
* hold. True if something under it took the hold (an image to move
@@ -367,7 +367,7 @@ export function attachPenInput(surface, target) {
}
surface.addEventListener('pointerup', finish);
surface.addEventListener('pointercancel', finish);
surface.addEventListener('pointerleave', () => { if (activePointer === null) target.hoverEnded?.(); });
surface.addEventListener('pointerleave', () => { if (activePointer === null) target.hoverEnded?.(true); });
surface.addEventListener('lostpointercapture', (event) => {
if (event.pointerId === activePointer) trace(`capture of pointer ${event.pointerId} lost while active`);
});
+99 -40
View File
@@ -50,18 +50,25 @@ export const CORNER_REACH_PX = 24;
/**
* The *implied rectangles* (Andy, 2026-10-14), text blocks and Hides,
* are marked on screen by blue corners while the pointer hovers over
* them (always, until 2026-10-14, for a text rectangle and a Hide):
* L-shaped marks this long, in page units, and this wide, in the blue of
* the Arrange cues. Nothing of them is on the page or in the file. (A
* text rectangle had a red frame on a white ground, page content, until
* 2026-10-14, and free text a red L until 2026-10-14; the teacher's
* rectangles turned out to be marks of their own, which the Shape tool
* now draws.)
* are marked on screen while the pointer hovers over them, by a line
* this wide in page units (the hover cue; blue L corners until the
* evening of 2026-10-14, always shown on a text rectangle and a Hide
* until that day): a text rectangle or a Hide outlined, free text by
* its top and left edges. Nothing of them is on the page or in the
* file. (A text rectangle had a red frame on a white ground, page
* content, until 2026-10-14, and free text a red L until 2026-10-14;
* the teacher's rectangles turned out to be marks of their own, which
* the Shape tool now draws.)
*/
const CORNER_MARK = 60;
export const CUE_COLOR = '#1e88e5';
const CUE_WIDTH = 2;
/**
* Every overlay that marks a thing's extent (Arrange's cues, the grip,
* the hover cue) draws its line this many pixels beyond the extent: 1
* puts the line on the first pixel outside the extent, touching it; 2
* leaves one pixel clear between (Andy, 2026-10-14, to see how it
* looks and whether a rounding error shows).
*/
export const CUE_OFFSET_PX = 2;
/**
* How the page is scaled on screen: a number of CSS pixels per page unit.
@@ -202,20 +209,29 @@ export class Renderer {
* corners are).
*/
this.grip = document.createElement('div');
this.grip.className = 'image_grip';
this.grip.className = 'grip';
this.grip.hidden = true;
this.grip.append(...cornerHandles());
this.pageElement.append(this.grip);
/** @type {{rect: import('./page_model.js').Rectangle, corner: [number, number] | null} | null} */
/** @type {{rect: import('./page_model.js').Rectangle, corner: [number, number] | null, corners: boolean} | null} */
this.gripShown = null;
/**
* The blue cues of the Arrange tool: the corners of every image, text
* rectangle and Hide that can be moved or resized, and an outline for
* images, whose edges may be invisible on the white page (Andy,
* 2026-10-13: figures on a white ground).
* @type {{rect: import('./page_model.js').Rectangle, kind: 'image' | 'text' | 'hide' | 'shape'}[]}
* The blue cues of the Arrange tool: a box and its corners on every
* image, Hide, text block and shape that can be moved or resized
* (the box, since an image's edges may be invisible on the white
* page; Andy, 2026-10-13: figures on a white ground).
* @type {{rect: import('./page_model.js').Rectangle, kind: 'image' | 'text' | 'hide' | 'shape' | 'stroke', corners: boolean}[]}
*/
this.arrangeCues = [];
/**
* The eraser's cues (Andy, 2026-10-14): a red box around everything a
* press where the pointer hovers would erase.
* @type {import('./page_model.js').Rectangle[]}
*/
this.eraseCues = [];
this.eraseCueElements = document.createElement('div');
this.eraseCueElements.className = 'erase_cues';
this.pageElement.append(this.eraseCueElements);
this.cueElements = document.createElement('div');
this.cueElements.className = 'arrange_cues';
this.pageElement.append(this.cueElements);
@@ -230,7 +246,6 @@ export class Renderer {
this.hoverCue = document.createElement('div');
this.hoverCue.className = 'hover_cue';
this.hoverCue.hidden = true;
this.hoverCue.append(...cornerHandles());
this.pageElement.append(this.hoverCue);
/**
* A shape being dragged open or a line's rubber band, in the pen's
@@ -502,13 +517,16 @@ export class Renderer {
}
/**
* Show, or hide, the cue around an image being handled: its outline,
* and the corner being dragged if it is being scaled.
* Show, or hide, the green grip around what is being moved or resized
* (an image, a Hide, a text block or a shape): its outline, its four
* corners, and the corner being dragged larger.
* @param {import('./page_model.js').Rectangle | null} rect Page units.
* @param {[number, number] | null} [corner]
* @param {boolean} [corners] False for free text, which has no corners
* to resize by (Andy, 2026-10-14), so the grip shows none.
*/
showGrip(rect, corner = null) {
this.gripShown = rect ? { rect, corner } : null;
showGrip(rect, corner = null, corners = true) {
this.gripShown = rect ? { rect, corner, corners } : null;
this.layoutGrip();
}
@@ -517,7 +535,9 @@ export class Renderer {
this.grip.hidden = !shown;
if (!shown) return;
this.placeOverlay(this.grip, shown.rect);
this.grip.classList.toggle('no_corners', !shown.corners);
this.grip.classList.toggle('corner', shown.corner !== null);
this.grip.classList.toggle('moving', shown.corner === null);
if (shown.corner) {
this.grip.style.setProperty('--corner-x', shown.corner[0] ? '100%' : '0%');
this.grip.style.setProperty('--corner-y', shown.corner[1] ? '100%' : '0%');
@@ -525,62 +545,101 @@ export class Renderer {
}
/**
* Place an overlay element over a rectangle of the page, in percentages
* of the page, so that it keeps its place at any zoom.
* Place an overlay element that marks a rectangle of the page (a cue,
* the grip, the hover cue, the eraser's cue) just outside it, in pixels, relaid at every
* zoom by resize(): the rectangle's edges are taken out to whole device
* pixels (ink's edge is anti-aliased across the pixel it falls in, and
* the line must not cover it), then the line is set CUE_OFFSET_PX
* beyond them (Andy, 2026-10-14: first one pixel, the line touching
* the extent, then two to see how it looks), the element's own border
* width telling where its line lies. Measured at the four line widths
* of a rectangle: with the position left to the browser's rounding, an
* edge past the middle of a pixel had put the line on that pixel.
* @param {HTMLElement} el
* @param {import('./page_model.js').Rectangle} rect Page units.
*/
placeOverlay(el, rect) {
const page = this.page;
el.style.left = `${(rect.left - page.left) / page.width * 100}%`;
el.style.top = `${(rect.top - page.top) / page.height * 100}%`;
el.style.width = `${rect.width / page.width * 100}%`;
el.style.height = `${rect.height / page.height * 100}%`;
const line = parseFloat(getComputedStyle(el).borderTopWidth) || 0;
const scale = this.cssPixelsPerUnit;
const d = window.devicePixelRatio || 1;
const beyond = CUE_OFFSET_PX - 1 + line;
const left = Math.floor((rect.left - page.left) * scale * d) / d - beyond;
const top = Math.floor((rect.top - page.top) * scale * d) / d - beyond;
const right = Math.ceil((rect.left + rect.width - page.left) * scale * d) / d + beyond;
const bottom = Math.ceil((rect.top + rect.height - page.top) * scale * d) / d + beyond;
el.style.left = `${left}px`;
el.style.top = `${top}px`;
el.style.width = `${right - left}px`;
el.style.height = `${bottom - top}px`;
}
/**
* Show the Arrange tool's cues on these rectangles, or none.
* @param {{rect: import('./page_model.js').Rectangle, kind: 'image' | 'text' | 'hide' | 'shape'}[]} cues
* Show the Arrange tool's cues on these rectangles, or none: a box and
* its four corners, or the box alone where `corners` is false, laid
* just outside the rectangle (placeOverlay).
* @param {{rect: import('./page_model.js').Rectangle, kind: 'image' | 'text' | 'hide' | 'shape' | 'stroke', corners: boolean}[]} cues
*/
setArrangeCues(cues) {
this.arrangeCues = cues;
this.cueElements.replaceChildren();
for (const cue of cues) {
const el = document.createElement('div');
el.className = `arrange_cue ${cue.kind}`;
el.className = `arrange_cue ${cue.kind}${cue.corners ? '' : ' no_corners'}`;
el.append(...cornerHandles());
this.cueElements.append(el);
}
this.layoutCues();
}
/**
* Show the eraser's cues around these rectangles, or none.
* @param {import('./page_model.js').Rectangle[]} rects
*/
setEraseCues(rects) {
this.eraseCues = rects;
this.eraseCueElements.replaceChildren();
for (const _ of rects) {
const el = document.createElement('div');
el.className = 'erase_cue';
this.eraseCueElements.append(el);
}
this.layoutCues();
}
layoutCues() {
const elements = this.cueElements.children;
this.arrangeCues.forEach((cue, i) => {
const el = /** @type {HTMLElement} */ (elements[i]);
if (el) this.placeOverlay(el, cue.rect);
});
const eraseElements = this.eraseCueElements.children;
this.eraseCues.forEach((rect, i) => {
const el = /** @type {HTMLElement} */ (eraseElements[i]);
if (el) this.placeOverlay(el, rect);
});
if (this.hoverRect) this.placeOverlay(this.hoverCue, this.hoverRect);
const page = this.page;
this.shapePreview.setAttribute('viewBox', `${page.left} ${page.top} ${page.width} ${page.height}`);
}
/**
* Mark the corners of this rectangle while the pointer hovers over it,
* or none.
* Mark this implied rectangle while the pointer hovers over it, or
* none (Andy, 2026-10-14): a text rectangle or a Hide by its outline,
* free text by its top and left edges only, so the two kinds of text
* are told apart.
* @param {import('./page_model.js').Rectangle | null} rect
* @param {'rect' | 'free' | 'hide'} [kind]
*/
showHoverCue(rect) {
showHoverCue(rect, kind = 'hide') {
this.hoverRect = rect;
this.hoverCue.hidden = !rect;
if (!rect) return;
this.hoverCue.className = `hover_cue ${kind}`;
// The line CUE_WIDTH wide at this zoom, at least a pixel; set before
// the placement, which reads the border's width.
this.hoverCue.style.setProperty('--mark_line', `${Math.max(1, Math.round(CUE_WIDTH * this.cssPixelsPerUnit))}px`);
this.placeOverlay(this.hoverCue, rect);
// The corner marks as drawCornerMarks draws them: CORNER_MARK long,
// shorter on a small block, CUE_WIDTH wide and at least a pixel.
const scale = this.cssPixelsPerUnit;
const mark = Math.min(CORNER_MARK, rect.width / 2, rect.height / 2) * scale;
this.hoverCue.style.setProperty('--mark', `${mark}px`);
this.hoverCue.style.setProperty('--mark_line', `${Math.max(1, Math.round(CUE_WIDTH * scale))}px`);
}
/**
+2 -1
View File
@@ -903,7 +903,8 @@ export class Session {
*/
beginShapeEdit(id) {
const stroke = this.ownLayer.strokes.get(id);
if (!stroke || !stroke.shape || this.shapeEditing) return false;
// Any finished stroke of ours: a shape, or handwriting under Arrange (Andy, 2026-10-14).
if (!stroke || !stroke.done || this.shapeEditing) return false;
this.shapeEditing = { id, before: { ...stroke, points: stroke.points.slice() } };
return true;
}
+27 -5
View File
@@ -31,11 +31,11 @@ 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']);
export const SHAPES = /** @type {const} */ (['rectangle', 'circle', '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' };
export const SHAPE_NAMES = { rectangle: 'Rectangle', circle: 'Circle', ellipse: 'Ellipse', line: 'Line', arrow: 'Arrow' };
/** The key under which the browser remembers the chosen shape. */
export const SHAPE_KEY = 'kokkoja_shape';
@@ -149,7 +149,10 @@ export function ellipsePoints(x0, y0, x1, y1) {
// 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)));
// A multiple of four, so that a point lies at each end of both axes and
// the rectangle around the points is exactly the ellipse's (Arrange's
// box and a circle's proportions depend on it).
const count = Math.max(LEAST_ELLIPSE_POINTS, Math.min(MOST_ELLIPSE_POINTS, 4 * Math.ceil(circumference / ELLIPSE_STEP / 4)));
/** @type {number[]} */
const points = [];
for (let i = 0; i <= count; i++) {
@@ -159,6 +162,21 @@ export function ellipsePoints(x0, y0, x1, y1) {
return points;
}
/**
* A circle about its centre (cx, cy), the radius reaching the dragged
* point (Andy, 2026-10-14: the centre first, then the radius in any
* direction).
* @param {number} cx
* @param {number} cy
* @param {number} x
* @param {number} y
* @returns {number[]}
*/
export function circlePoints(cx, cy, x, y) {
const radius = Math.hypot(x - cx, y - cy);
return ellipsePoints(cx - radius, cy - radius, cx + radius, cy + radius);
}
/**
* 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
@@ -202,7 +220,9 @@ export function arrowPoints(x0, y0, x1, y1, width) {
}
/**
* The points of a shape dragged open from (x0, y0) to (x1, y1).
* The points of a shape dragged open from (x0, y0) to (x1, y1): a
* circle from its centre to a point of its edge, the others from one
* corner of their rectangle to the opposite one.
* @param {Shape} shape Not 'line', which is made of taps.
* @param {number} x0
* @param {number} y0
@@ -214,6 +234,7 @@ export function arrowPoints(x0, y0, x1, y1, width) {
export function draggedPoints(shape, x0, y0, x1, y1, width) {
switch (shape) {
case 'rectangle': return rectanglePoints(x0, y0, x1, y1);
case 'circle': return circlePoints(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]]);
@@ -224,7 +245,8 @@ export function draggedPoints(shape, x0, y0, x1, y1, width) {
* 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
* proportions; a circle is resized with its shape kept, so stays one),
* 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
+23
View File
@@ -39,6 +39,29 @@ export function widthAt(stroke, index) {
return stroke.width * (0.36 + 4 * pressure * pressure);
}
/**
* The rectangle a stroke's ink covers: around its points, each taken
* out by half the line's width at that point (round caps and joins).
* @param {Stroke} stroke
* @returns {{left: number, top: number, width: number, height: number} | null}
*/
export function inkExtent(stroke) {
const p = stroke.points;
if (p.length < POINT_SIZE) return null;
let left = Infinity;
let top = Infinity;
let right = -Infinity;
let bottom = -Infinity;
for (let i = 0, k = 0; i < p.length; i += POINT_SIZE, k++) {
const half = widthAt(stroke, k) / 2;
left = Math.min(left, p[i] - half);
right = Math.max(right, p[i] + half);
top = Math.min(top, p[i + 1] - half);
bottom = Math.max(bottom, p[i + 1] + half);
}
return { left, top, width: right - left, height: bottom - top };
}
/**
* The part of a stroke that belongs to point `k`.
* @param {Stroke} stroke