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:
1 parent
03907da8c9
commit
24a6a2595b
10 files changed
+690
-126
No files matched your search
+186
-23
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user