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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user