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
@@ -51,6 +51,8 @@
|
||||
<div id="shape_menu" class="popup menu" role="menu" hidden>
|
||||
<button type="button" class="menu_item" role="menuitem" data-shape="rectangle"
|
||||
data-help="Rectangle: drag open the rectangle from one corner to the opposite one.">Rectangle</button>
|
||||
<button type="button" class="menu_item" role="menuitem" data-shape="circle"
|
||||
data-help="Circle: press at the centre and drag out to the edge, in any direction.">Circle</button>
|
||||
<button type="button" class="menu_item" role="menuitem" data-shape="ellipse"
|
||||
data-help="Ellipse: drag open the rectangle the ellipse fills.">Ellipse</button>
|
||||
<button type="button" class="menu_item" role="menuitem" data-shape="line"
|
||||
|
||||
+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
|
||||
|
||||
+51
-32
@@ -5,6 +5,22 @@
|
||||
--panel: #f6f6f7;
|
||||
--desk: #e4e4e7;
|
||||
--accent: #1f4fd8;
|
||||
/* The two colours of arranging (Andy, 2026-10-14): the blue of the
|
||||
boxes and corners the Arrange tool lays on everything it can move,
|
||||
and the green of the one being moved or resized, by Arrange or by a
|
||||
hold with any tool, until the pointer lifts. The hover cue on an
|
||||
implied rectangle (free text, a text rectangle, a Hide) is the same
|
||||
blue today but has its own name, so that trying another arrange
|
||||
blue leaves it alone: it is the only sign of the rectangle's extent. */
|
||||
--arrange_blue: #1e88e5;
|
||||
--held_green: #2e7d32;
|
||||
--hover_blue: #1e88e5;
|
||||
/* The line of the Arrange cue and of the grip, the same width for both
|
||||
(Andy, 2026-10-14: the thinner blue line had read as standing closer
|
||||
to the ink than the green, the gap being the same). */
|
||||
--cue_line: 2px;
|
||||
/* The eraser's cue: what a press would erase (Andy, 2026-10-14). */
|
||||
--erase_red: #e53935;
|
||||
font-family: system-ui, sans-serif;
|
||||
font-size: 15px;
|
||||
color: var(--ink);
|
||||
@@ -334,18 +350,23 @@ h2 {
|
||||
background: var(--desk);
|
||||
}
|
||||
|
||||
/* The cue around an image being moved or scaled by hand: green, with a
|
||||
square at the corner being dragged. */
|
||||
.image_grip {
|
||||
/* The grip around whatever is being moved or resized (an image, a Hide,
|
||||
a text block, a shape), by a hold or by Arrange: green, its four
|
||||
corners (none on free text, which has no corners to resize by), and
|
||||
the corner being dragged larger. Under Arrange it takes
|
||||
the place of the thing's blue cue, which is left out while held. */
|
||||
.grip {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
box-sizing: border-box;
|
||||
border: 2px solid #2e7d32;
|
||||
border: var(--cue_line) solid var(--held_green);
|
||||
pointer-events: none;
|
||||
--handle-color: #2e7d32;
|
||||
--handle-color: var(--held_green);
|
||||
--moving_handle: 14px;
|
||||
}
|
||||
|
||||
/* The four corners of a held object, and the corner being dragged, larger. */
|
||||
/* The four corners of a held object or an Arrange cue; a moving corner
|
||||
is larger (the one dragged, or all four when the thing is moved). */
|
||||
.handle {
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
@@ -358,45 +379,43 @@ h2 {
|
||||
.handle.sw { left: 0; top: 100%; }
|
||||
.handle.se { left: 100%; top: 100%; }
|
||||
|
||||
.image_grip.corner::after {
|
||||
/* The corner being dragged is larger; and while the whole thing is moved
|
||||
all four corners are, since all four are moving (Andy, 2026-10-14). */
|
||||
.grip.corner::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: var(--corner-x);
|
||||
top: var(--corner-y);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: var(--moving_handle);
|
||||
height: var(--moving_handle);
|
||||
transform: translate(-50%, -50%);
|
||||
background: #2e7d32;
|
||||
background: var(--held_green);
|
||||
}
|
||||
.grip.moving .handle { width: var(--moving_handle); height: var(--moving_handle); }
|
||||
|
||||
/* The Arrange tool's cues: blue corners and an outline on every image,
|
||||
whose edges may not show (text rectangles and Hides carry blue corners
|
||||
at all times since 2026-10-14, drawn on the canvases); and the same
|
||||
corners on free text under a hovering pointer (.hover_cue). */
|
||||
/* The Arrange tool's cues (Andy, 2026-10-14): a blue box with blue
|
||||
corner squares on everything a press can move or resize, images,
|
||||
Hides, one's own text and shapes alike; free text, which has no width
|
||||
to resize, gets the box without the corners. */
|
||||
.arrange_cues { position: absolute; inset: 0; pointer-events: none; z-index: 3; }
|
||||
.arrange_cue {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
pointer-events: none;
|
||||
--handle-color: #1e88e5;
|
||||
border: var(--cue_line) solid var(--arrange_blue);
|
||||
--handle-color: var(--arrange_blue);
|
||||
}
|
||||
.arrange_cue.image { border: 1px solid #1e88e5; }
|
||||
/* The hover cue's corners are L marks like those the canvases draw on
|
||||
text rectangles and Hides, not the Arrange tool's squares, so that
|
||||
"blue corners" look the same everywhere; renderer.js sets the mark's
|
||||
length and line width for the zoom. */
|
||||
.hover_cue { position: absolute; box-sizing: border-box; pointer-events: none; z-index: 3; --handle-color: #1e88e5; }
|
||||
.hover_cue .handle {
|
||||
width: var(--mark);
|
||||
height: var(--mark);
|
||||
transform: none;
|
||||
background: none;
|
||||
border: 0 solid var(--handle-color);
|
||||
}
|
||||
.hover_cue .handle.nw { left: 0; top: 0; border-width: var(--mark_line) 0 0 var(--mark_line); }
|
||||
.hover_cue .handle.ne { left: auto; right: 0; top: 0; border-width: var(--mark_line) var(--mark_line) 0 0; }
|
||||
.hover_cue .handle.sw { left: 0; top: auto; bottom: 0; border-width: 0 0 var(--mark_line) var(--mark_line); }
|
||||
.hover_cue .handle.se { left: auto; right: 0; top: auto; bottom: 0; border-width: 0 var(--mark_line) var(--mark_line) 0; }
|
||||
.arrange_cue.no_corners .handle, .grip.no_corners .handle { display: none; }
|
||||
/* The eraser's cues: a red box around each element a press where the
|
||||
pointer hovers would erase, whole, as the eraser works. */
|
||||
.erase_cues { position: absolute; inset: 0; pointer-events: none; z-index: 3; }
|
||||
.erase_cue { position: absolute; box-sizing: border-box; pointer-events: none; border: var(--cue_line) solid var(--erase_red); }
|
||||
/* The hover cue on an implied rectangle (Andy, 2026-10-14, evening): a
|
||||
text rectangle and a Hide are outlined, free text shows its top and
|
||||
left edges only, so the two kinds of text are told apart. renderer.js
|
||||
sets the line width for the zoom. */
|
||||
.hover_cue { position: absolute; box-sizing: border-box; pointer-events: none; z-index: 3; border: var(--mark_line) solid var(--hover_blue); }
|
||||
.hover_cue.free { border-width: var(--mark_line) 0 0 var(--mark_line); }
|
||||
|
||||
.page {
|
||||
position: relative;
|
||||
|
||||
+295
-20
@@ -44,10 +44,27 @@ HOVER_CUE = """
|
||||
const page = document.getElementById('page').getBoundingClientRect();
|
||||
const b = c.getBoundingClientRect();
|
||||
const k = s.page.width / page.width;
|
||||
const st = getComputedStyle(c);
|
||||
return {hidden: c.hidden, x: s.page.left + (b.left - page.left) * k, y: s.page.top + (b.top - page.top) * k,
|
||||
width: b.width * k, height: b.height * k};
|
||||
width: b.width * k, height: b.height * k, kind: c.className.split(' ')[1],
|
||||
box: [b.left - page.left, b.top - page.top, b.right - page.left, b.bottom - page.top],
|
||||
scale: r.cssPixelsPerUnit, origin: [s.page.left, s.page.top],
|
||||
edges: [st.borderTopWidth, st.borderRightWidth, st.borderBottomWidth, st.borderLeftWidth]};
|
||||
"""
|
||||
|
||||
# Where an overlay's box must lie around a rectangle's edges in pixels
|
||||
# (CUE_OFFSET_PX in renderer.js): the edges taken out to whole pixels and
|
||||
# the line this far beyond them.
|
||||
CUE_OFFSET_PX = 2
|
||||
|
||||
|
||||
def overlay_box(left, top, right, bottom, line):
|
||||
"""The pixel box an overlay with this line width takes around a
|
||||
rectangle whose edges are at these pixel positions."""
|
||||
beyond = CUE_OFFSET_PX - 1 + line
|
||||
return [math.floor(left) - beyond, math.floor(top) - beyond, math.ceil(right) + beyond, math.ceil(bottom) + beyond]
|
||||
|
||||
|
||||
# Whether anything at all is drawn on the writer's own layer canvas within
|
||||
# two device pixels of the page point (x, y).
|
||||
INK_AT = """
|
||||
@@ -538,6 +555,12 @@ class Firefox:
|
||||
"type": "pointer", "id": "mouse", "parameters": {"pointerType": "mouse"},
|
||||
"actions": moves}]})
|
||||
|
||||
def pointer_actions(self, actions):
|
||||
"""Perform mouse actions (WebDriver's pointer actions) in sequence."""
|
||||
self.in_session("POST", "/actions", {"actions": [{
|
||||
"type": "pointer", "id": "mouse", "parameters": {"pointerType": "mouse"},
|
||||
"actions": actions}]})
|
||||
|
||||
def type_into(self, selector, text):
|
||||
"""Type text into an element, as the keyboard would."""
|
||||
found = self.in_session("POST", "/element", {"using": "css selector", "value": selector})
|
||||
@@ -661,17 +684,30 @@ class EndToEndTest(unittest.TestCase):
|
||||
def state(self):
|
||||
return self.firefox.run(PAGE_STATE)
|
||||
|
||||
def assert_hover_cue(self, x, y, rect):
|
||||
"""A pointer hovering at the page point (x, y) shows the blue
|
||||
corners over the rectangle (page units), and none once it leaves."""
|
||||
def assert_hover_cue(self, x, y, rect, kind="hide"):
|
||||
"""A pointer hovering at the page point (x, y) shows the blue cue
|
||||
over the rectangle (page units), and none once it leaves: a text
|
||||
rectangle or a Hide outlined, free text's top and left edges
|
||||
(Andy, 2026-10-14)."""
|
||||
state = self.state()
|
||||
fx = (x - state["origin"][0]) / state["page"]["width"]
|
||||
fy = (y - state["origin"][1]) / state["page"]["height"]
|
||||
self.firefox.run(HOVER, fx, fy)
|
||||
cue = self.firefox.run(HOVER_CUE)
|
||||
self.assertFalse(cue["hidden"], f"no cue at {x}, {y}")
|
||||
for key, expected in zip(("x", "y", "width", "height"), rect):
|
||||
self.assertAlmostEqual(cue[key], expected, delta=3, msg=key)
|
||||
self.assertEqual(cue["kind"], kind)
|
||||
line = cue["edges"][0]
|
||||
self.assertNotEqual(line, "0px")
|
||||
# The cue's box lies CUE_OFFSET_PX outside the rectangle.
|
||||
px = lambda v: (v - cue["origin"][0]) * cue["scale"]
|
||||
py = lambda v: (v - cue["origin"][1]) * cue["scale"]
|
||||
expected = overlay_box(px(rect[0]), py(rect[1]), px(rect[0] + rect[2]), py(rect[1] + rect[3]), float(line[:-2]))
|
||||
for side, got, want in zip(("left", "top", "right", "bottom"), cue["box"], expected):
|
||||
self.assertAlmostEqual(got, want, delta=0.01, msg=side)
|
||||
if kind == "free":
|
||||
self.assertEqual(cue["edges"], [line, "0px", "0px", line], "top and left edges only")
|
||||
else:
|
||||
self.assertEqual(cue["edges"], [line] * 4, "outlined")
|
||||
self.firefox.run(HOVER, 0.01, 0.01)
|
||||
self.assertTrue(self.firefox.run(HOVER_CUE)["hidden"])
|
||||
|
||||
@@ -1517,20 +1553,39 @@ class EndToEndTest(unittest.TestCase):
|
||||
self.assertEqual(after["layers"]["Arranger"], [])
|
||||
self.assertEqual(rect(after, 0), rect(scaled, 0))
|
||||
|
||||
# A text rectangle of one's own and a Hide, marked by their own blue
|
||||
# corners, get no cue; a corner drag widens the text box, a drag
|
||||
# inside moves the Hide.
|
||||
# A text rectangle of one's own, free text and a Hide get cues too
|
||||
# (Andy, 2026-10-14), the free text's without corners; the hover
|
||||
# cue still shows on the box and the Hide; a corner drag widens
|
||||
# the text box, a drag inside moves the Hide.
|
||||
choose("text")
|
||||
firefox.run(TEXT_GESTURE, 0.1, 0.6, 0.3, 0.7)
|
||||
firefox.type_into(".text_field", "Box")
|
||||
firefox.run(TEXT_GESTURE, 0.4, 0.8, 0.4, 0.8) # Finishes the box.
|
||||
self.wait_until(lambda s: firefox.run("return document.querySelector('.text_field').hidden;"), "the box finished")
|
||||
firefox.run(TEXT_GESTURE, 0.4, 0.8, 0.4, 0.8) # Starts free text.
|
||||
firefox.type_into(".text_field", "Free")
|
||||
choose("hide")
|
||||
firefox.run(TEXT_GESTURE, 0.5, 0.6, 0.7, 0.7)
|
||||
with_all = self.wait_until(lambda s: len(s["hides"]) == 1 and s["texts"]["Arranger"] and s["texts"]["Arranger"][0]["text"] == "Box",
|
||||
"the box and the Hide")
|
||||
with_all = self.wait_until(lambda s: len(s["hides"]) == 1 and len(s["texts"]["Arranger"]) == 2
|
||||
and s["texts"]["Arranger"][0]["text"] == "Box" and s["texts"]["Arranger"][1]["text"] == "Free",
|
||||
"the box, the free text and the Hide")
|
||||
choose("arrange")
|
||||
self.assertEqual(cues(), ["image"])
|
||||
self.assertEqual(cues(), ["image", "hide", "text", "text"])
|
||||
self.assertEqual(firefox.run("return [...document.querySelectorAll('.arrange_cue.text')].map((c) => c.classList.contains('no_corners'));"),
|
||||
[False, True], "free text's cue has no corners")
|
||||
self.assertEqual(firefox.run("return [...document.querySelectorAll('.arrange_cue')].map((c) => getComputedStyle(c).borderTopWidth);"),
|
||||
["2px"] * 4, "every cue is a box, its line the grip's width")
|
||||
# The image's cue lies CUE_OFFSET_PX outside the image.
|
||||
im = scaled["images"][0]
|
||||
page_box = firefox.run("const b = document.getElementById('page').getBoundingClientRect(); return [b.left, b.top];")
|
||||
got = firefox.run("const b = document.querySelector('.arrange_cue.image').getBoundingClientRect(); return [b.left, b.top, b.right, b.bottom];")
|
||||
want = overlay_box(px(im["x"]), py(im["y"]), px(im["x"] + im["width"]), py(im["y"] + im["height"]), 2)
|
||||
for side, g, w in zip(("left", "top", "right", "bottom"), (got[0] - page_box[0], got[1] - page_box[1], got[2] - page_box[0], got[3] - page_box[1]), want):
|
||||
self.assertAlmostEqual(g, w, delta=0.01, msg=side)
|
||||
self.assertEqual(firefox.run("return getComputedStyle(document.documentElement).getPropertyValue('--arrange_blue');").strip(), "#1e88e5")
|
||||
self.assertEqual(firefox.run("return getComputedStyle(document.querySelector('.arrange_cue')).borderTopColor;"), "rgb(30, 136, 229)")
|
||||
box = with_all["texts"]["Arranger"][0]
|
||||
self.assert_hover_cue(box["x"] + 20, box["y"] + 20, [box["x"], box["y"], box["width"], box["height"]])
|
||||
self.assert_hover_cue(box["x"] + 20, box["y"] + 20, [box["x"], box["y"], box["width"], box["height"]], "rect")
|
||||
h = with_all["hides"][0]
|
||||
self.assert_hover_cue(h[0] + h[2] / 2, h[1] + h[3] / 2, h)
|
||||
firefox.drag("#page", px(box["x"] + box["width"]) - 3, py(box["y"] + box["height"]) - 3,
|
||||
@@ -1542,14 +1597,51 @@ class EndToEndTest(unittest.TestCase):
|
||||
px(hide[0] + hide[2] / 2) - 100, py(hide[1] + hide[3] / 2) + 50)
|
||||
hide_moved = self.wait_until(lambda s: s["hides"][0][0] != hide[0], "the Hide moved")
|
||||
self.assertEqual(hide_moved["hides"][0][2:], hide[2:])
|
||||
self.assertEqual(len(cues()), 1)
|
||||
self.assertEqual(len(cues()), 4)
|
||||
|
||||
|
||||
# Undo takes the Hide's move back, as it does a hold's.
|
||||
firefox.run("document.getElementById('undo').click();")
|
||||
self.wait_until(lambda s: s["hides"][0] == hide, "the Hide back by Undo")
|
||||
|
||||
# The watcher, without the right, is shown no cue; their own text
|
||||
# rectangle has its blue corners as everyone's has.
|
||||
# While something is held its blue cue gives way to the green grip:
|
||||
# a press on the Hide, held down, then released.
|
||||
page_box = firefox.run("const b = document.getElementById('page').getBoundingClientRect(); return [b.left, b.top];")
|
||||
firefox.pointer_actions([{"type": "pointerMove", "duration": 0, "origin": "viewport",
|
||||
"x": round(page_box[0] + px(hide[0] + hide[2] / 2)), "y": round(page_box[1] + py(hide[1] + hide[3] / 2))},
|
||||
{"type": "pointerDown", "button": 0}, {"type": "pause", "duration": 100}])
|
||||
self.assertEqual(cues(), ["image", "text", "text"], "the held Hide's cue is gone")
|
||||
self.assertFalse(firefox.run("return document.querySelector('.grip').hidden;"))
|
||||
self.assertEqual(firefox.run("return getComputedStyle(document.querySelector('.grip')).borderTopColor;"), "rgb(46, 125, 50)")
|
||||
# Held for moving, all four corners are the moving size (Andy, 2026-10-14).
|
||||
self.assertEqual(firefox.run("return [...document.querySelectorAll('.grip .handle')].map((h) => getComputedStyle(h).width);"), ["14px"] * 4)
|
||||
firefox.pointer_actions([{"type": "pointerUp", "button": 0}])
|
||||
self.assertEqual(cues(), ["image", "hide", "text", "text"], "the cue is back")
|
||||
self.assertTrue(firefox.run("return document.querySelector('.grip').hidden;"))
|
||||
# Held at a corner, only that corner is the moving size.
|
||||
firefox.pointer_actions([{"type": "pointerMove", "duration": 0, "origin": "viewport",
|
||||
"x": round(page_box[0] + px(hide[0] + hide[2])) - 3, "y": round(page_box[1] + py(hide[1] + hide[3])) - 3},
|
||||
{"type": "pointerDown", "button": 0}, {"type": "pause", "duration": 100}])
|
||||
self.assertEqual(firefox.run("return [...document.querySelectorAll('.grip .handle')].map((h) => getComputedStyle(h).width);"), ["10px"] * 4)
|
||||
# The square's position is a percentage of the grip's padding box.
|
||||
self.assertEqual(firefox.run("const g = document.querySelector('.grip'); const s = getComputedStyle(g, '::after');"
|
||||
" return [s.width, parseFloat(s.left) === g.clientWidth, parseFloat(s.top) === g.clientHeight];"),
|
||||
["14px", True, True], "the dragged corner, bottom right, larger")
|
||||
firefox.pointer_actions([{"type": "pointerUp", "button": 0}])
|
||||
self.wait_until(lambda s: s["hides"][0] == hide, "the Hide unchanged by a press")
|
||||
# Held free text shows the grip without corners, as its cue has none.
|
||||
free = self.state()["texts"]["Arranger"][1]
|
||||
firefox.pointer_actions([{"type": "pointerMove", "duration": 0, "origin": "viewport",
|
||||
"x": round(page_box[0] + px(free["x"] + 20)), "y": round(page_box[1] + py(free["y"] + 20))},
|
||||
{"type": "pointerDown", "button": 0}, {"type": "pause", "duration": 100}])
|
||||
self.assertEqual(cues(), ["image", "hide", "text"], "the held free text's cue is gone")
|
||||
self.assertEqual(firefox.run("return [...document.querySelectorAll('.grip .handle')].map((h) => getComputedStyle(h).display);"),
|
||||
["none"] * 4, "no corners on held free text")
|
||||
firefox.pointer_actions([{"type": "pointerUp", "button": 0}])
|
||||
self.assertEqual(cues(), ["image", "hide", "text", "text"])
|
||||
|
||||
# The watcher, without the right, is shown no cue on the image or
|
||||
# the Hide, only on their own text.
|
||||
firefox.switch_to(guest_tab)
|
||||
self.wait_until(lambda s: len(s["images"]) == 1 and len(s["hides"]) == 1, "the page at the watcher")
|
||||
choose("arrange")
|
||||
@@ -1558,10 +1650,10 @@ class EndToEndTest(unittest.TestCase):
|
||||
firefox.run(TEXT_GESTURE, 0.1, 0.8, 0.3, 0.9)
|
||||
firefox.type_into(".text_field", "Mine")
|
||||
choose("arrange")
|
||||
self.assertEqual(cues(), [])
|
||||
self.assertEqual(cues(), ["text"])
|
||||
mine = self.wait_until(lambda s: s["texts"].get("Watcher") and s["texts"]["Watcher"][0]["text"] == "Mine", "the watcher's box")
|
||||
mine = mine["texts"]["Watcher"][0]
|
||||
self.assert_hover_cue(mine["x"] + 20, mine["y"] + 20, [mine["x"], mine["y"], mine["width"], mine["height"]])
|
||||
self.assert_hover_cue(mine["x"] + 20, mine["y"] + 20, [mine["x"], mine["y"], mine["width"], mine["height"]], "rect")
|
||||
|
||||
def test_shapes(self):
|
||||
"""Andy, 2026-10-14: a Shape button right of Text draws the shape it
|
||||
@@ -1772,6 +1864,19 @@ class EndToEndTest(unittest.TestCase):
|
||||
scale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;")
|
||||
px = lambda x: (x - origin[0]) * scale
|
||||
py = lambda y: (y - origin[1]) * scale
|
||||
# Each cue's line lies just outside the shape's ink (the points plus
|
||||
# half the line's width, taken out to whole pixels), one pixel
|
||||
# beyond it on every side (Andy, 2026-10-14).
|
||||
page_box = firefox.run("const b = document.getElementById('page').getBoundingClientRect(); return [b.left, b.top];")
|
||||
boxes = firefox.run("return [...document.querySelectorAll('.arrange_cue')].map((c) => { const b = c.getBoundingClientRect();"
|
||||
" return [b.left, b.top, b.right, b.bottom, parseFloat(getComputedStyle(c).borderTopWidth)]; });")
|
||||
for stroke, (left, top, right, bottom, line) in zip(strokes(unfilled), boxes):
|
||||
xs, ys = stroke["points"][0::3], stroke["points"][1::3]
|
||||
half = stroke["width"] / 2
|
||||
self.assertEqual(line, 2)
|
||||
expected = overlay_box(px(min(xs) - half), py(min(ys) - half), px(max(xs) + half), py(max(ys) + half), line)
|
||||
for side, got, want in zip(("left", "top", "right", "bottom"), (left - page_box[0], top - page_box[1], right - page_box[0], bottom - page_box[1]), expected):
|
||||
self.assertAlmostEqual(got, want, delta=0.01, msg=side)
|
||||
before = strokes(filled)[4]["points"]
|
||||
firefox.drag("#page", px(at(0.2, 0.37)[0]), py(at(0.2, 0.37)[1]), px(at(0.2, 0.37)[0]) + 100, py(at(0.2, 0.37)[1]) + 50)
|
||||
moved = self.wait_until(lambda s: strokes(s)[4]["points"] != before, "the rectangle moved")
|
||||
@@ -1814,7 +1919,7 @@ class EndToEndTest(unittest.TestCase):
|
||||
box = self.wait_until(lambda s: s["texts"].get("Drawer") and s["texts"]["Drawer"][0]["text"] == "Box", "the box")["texts"]["Drawer"][0]
|
||||
self.assertFalse(firefox.run(INK_AT, box["x"] + box["width"] / 2, box["y"]), "no frame along the top edge")
|
||||
self.assertFalse(firefox.run(INK_AT, box["x"], box["y"]), "no corner mark on the canvas")
|
||||
self.assert_hover_cue(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2, [box["x"], box["y"], box["width"], box["height"]])
|
||||
self.assert_hover_cue(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2, [box["x"], box["y"], box["width"], box["height"]], "rect")
|
||||
saved = self.wait_until_true(lambda: (self.notebooks / "Drawer notebook" / "Current.svg").read_text(),
|
||||
lambda text: "<desc>Box</desc>" in text, "the box saved")
|
||||
self.assertNotIn("data-ground", saved)
|
||||
@@ -1827,7 +1932,177 @@ class EndToEndTest(unittest.TestCase):
|
||||
self.assertTrue(firefox.run(HOVER_CUE)["hidden"])
|
||||
self.assertFalse(firefox.run(INK_AT, free["x"], free["y"]), "no red L on free text")
|
||||
self.assert_hover_cue(free["x"] + free["boxWidth"] / 2, free["y"] + free["boxHeight"] / 2,
|
||||
[free["x"], free["y"], free["boxWidth"], free["boxHeight"]])
|
||||
[free["x"], free["y"], free["boxWidth"], free["boxHeight"]], "free")
|
||||
|
||||
# A circle (Andy, 2026-10-14): the centre first, then a drag out to
|
||||
# the edge in any direction; every point at that distance from the
|
||||
# centre. Resized by Arrange from a corner, it stays a circle.
|
||||
choose("shape")
|
||||
pick("circle")
|
||||
self.assertEqual(label(), "Circle")
|
||||
self.assertEqual(firefox.run("return [...document.querySelectorAll('#shape_menu [data-shape]')].filter((i) => i.getAttribute('aria-checked') === 'true').map((i) => i.dataset.shape);"), ["circle"])
|
||||
self.assertEqual(firefox.run("return getComputedStyle(document.querySelector('#shape_menu [data-shape=circle]'), '::before').content;"), '"✓"')
|
||||
self.assertEqual(firefox.run("return [...document.querySelectorAll('#shape_menu [data-shape]')].map((i) => i.dataset.shape);"),
|
||||
["rectangle", "circle", "ellipse", "line", "arrow"])
|
||||
now = self.state()
|
||||
count = len(strokes(now))
|
||||
now_at = lambda fx, fy: (now["origin"][0] + fx * now["page"]["width"], now["origin"][1] + fy * now["page"]["height"])
|
||||
firefox.run(TEXT_GESTURE, 0.3, 0.3, 0.33, 0.34)
|
||||
with_circle = self.wait_until(lambda s: len(strokes(s)) == count + 1 and strokes(s)[-1]["done"], "the circle")
|
||||
circle = strokes(with_circle)[-1]
|
||||
self.assertEqual(circle["shape"], "circle")
|
||||
cx, cy = now_at(0.3, 0.3)
|
||||
radius = math.hypot(*(a - b for a, b in zip(now_at(0.33, 0.34), (cx, cy))))
|
||||
xs, ys = circle["points"][0::3], circle["points"][1::3]
|
||||
for x, y in zip(xs, ys):
|
||||
self.assertAlmostEqual(math.hypot(x - cx, y - cy), radius, delta=1)
|
||||
self.assertEqual((xs[0], ys[0]), (xs[-1], ys[-1]), "closed")
|
||||
choose("arrange")
|
||||
right, bottom = max(xs), max(ys)
|
||||
firefox.drag("#page", px(right) - 3, py(bottom) - 3, px(right) + 90, py(bottom) + 30)
|
||||
resized = self.wait_until(lambda s: strokes(s)[-1]["points"] != circle["points"], "the circle resized")
|
||||
rx, ry = strokes(resized)[-1]["points"][0::3], strokes(resized)[-1]["points"][1::3]
|
||||
ncx, ncy = (min(rx) + max(rx)) / 2, (min(ry) + max(ry)) / 2
|
||||
nr = (max(rx) - min(rx)) / 2
|
||||
self.assertGreater(nr, radius + 40 / scale, "the radius grew by half the drag")
|
||||
self.assertAlmostEqual(max(ry) - min(ry), max(rx) - min(rx), delta=1, msg="still round")
|
||||
for x, y in zip(rx, ry):
|
||||
self.assertAlmostEqual(math.hypot(x - ncx, y - ncy), nr, delta=1)
|
||||
|
||||
def test_eraser_preview(self):
|
||||
"""Andy, 2026-10-14: with the Eraser chosen, a red box around each
|
||||
element a press under the hovering pointer would erase: a stroke by
|
||||
the ink's extent, a text block, an image a tap would remove; none
|
||||
on empty paper, none with another tool."""
|
||||
firefox = self.firefox
|
||||
firefox.go(self.address("Host"))
|
||||
self.enter_name("Eraser", "start_session")
|
||||
self.wait_until(lambda s: s["page"]["number"] == 1, "the page")
|
||||
choose = lambda tool: firefox.run(f"document.querySelector('[data-tool={tool}]').click();")
|
||||
cues = lambda: firefox.run("const p = document.getElementById('page').getBoundingClientRect(); return [...document.querySelectorAll('.erase_cue')]"
|
||||
".map((c) => { const b = c.getBoundingClientRect(); return [b.left - p.left, b.top - p.top, b.right - p.left, b.bottom - p.top, getComputedStyle(c).borderTopColor]; });")
|
||||
firefox.run(PEN_WAVE, 0.3, 0.5)
|
||||
choose("text")
|
||||
firefox.run(TEXT_GESTURE, 0.1, 0.7, 0.3, 0.8)
|
||||
firefox.type_into(".text_field", "Box")
|
||||
choose("pen")
|
||||
firefox.run_until_done(LOAD_IMAGE, 300, 200, "png", 0, False)
|
||||
state = self.wait_until(lambda s: len(s["layers"]["Eraser"]) == 1 and s["layers"]["Eraser"][0]["done"] and s["texts"]["Eraser"]
|
||||
and s["texts"]["Eraser"][0]["text"] == "Box" and len(s["images"]) == 1 and s["image"]["ready"], "the stroke, the box and the image")
|
||||
origin = state["origin"]
|
||||
scale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;")
|
||||
px = lambda x: (x - origin[0]) * scale
|
||||
py = lambda y: (y - origin[1]) * scale
|
||||
hover_at = lambda x, y: firefox.run(HOVER, (x - origin[0]) / state["page"]["width"], (y - origin[1]) / state["page"]["height"])
|
||||
stroke = state["layers"]["Eraser"][0]
|
||||
p = stroke["points"]
|
||||
halves = [stroke["width"] * (0.36 + 4 * q * q) / 2 for q in p[2::3]]
|
||||
left = min(x - h for x, h in zip(p[0::3], halves))
|
||||
right = max(x + h for x, h in zip(p[0::3], halves))
|
||||
top = min(y - h for y, h in zip(p[1::3], halves))
|
||||
bottom = max(y + h for y, h in zip(p[1::3], halves))
|
||||
# With the pen, nothing; with the eraser, the stroke's ink boxed in red.
|
||||
hover_at(p[0], p[1])
|
||||
self.assertEqual(cues(), [])
|
||||
choose("eraser")
|
||||
hover_at(p[0], p[1])
|
||||
boxes = cues()
|
||||
self.assertEqual(len(boxes), 1)
|
||||
self.assertEqual(boxes[0][4], "rgb(229, 57, 53)")
|
||||
for got, want in zip(boxes[0], overlay_box(px(left), py(top), px(right), py(bottom), 2)):
|
||||
self.assertAlmostEqual(got, want, delta=0.01)
|
||||
# The text block, by its box; the image, by a tap's rule; empty
|
||||
# paper, nothing; the cue goes when the pointer leaves the page.
|
||||
box = state["texts"]["Eraser"][0]
|
||||
hover_at(box["x"] + 20, box["y"] + 20)
|
||||
boxes = cues()
|
||||
self.assertEqual(len(boxes), 1)
|
||||
self.assertTrue(firefox.run(HOVER_CUE)["hidden"], "no blue cue beside the red one")
|
||||
for got, want in zip(boxes[0], overlay_box(px(box["x"]), py(box["y"]), px(box["x"] + box["width"]), py(box["y"] + box["height"]), 2)):
|
||||
self.assertAlmostEqual(got, want, delta=0.01)
|
||||
im = state["images"][0]
|
||||
hover_at(im["x"] + im["width"] / 2, im["y"] + im["height"] / 2)
|
||||
boxes = cues()
|
||||
self.assertEqual(len(boxes), 1)
|
||||
for got, want in zip(boxes[0], overlay_box(px(im["x"]), py(im["y"]), px(im["x"] + im["width"]), py(im["y"] + im["height"]), 2)):
|
||||
self.assertAlmostEqual(got, want, delta=0.01)
|
||||
hover_at(origin[0] + 0.9 * state["page"]["width"], origin[1] + 0.9 * state["page"]["height"])
|
||||
self.assertEqual(cues(), [])
|
||||
hover_at(p[0], p[1])
|
||||
self.assertEqual(len(cues()), 1)
|
||||
firefox.run("document.getElementById('page').dispatchEvent(new PointerEvent('pointerleave', {pointerId: 9, pointerType: 'mouse', bubbles: true}));")
|
||||
self.assertEqual(cues(), [])
|
||||
# Choosing another tool clears it.
|
||||
hover_at(p[0], p[1])
|
||||
self.assertEqual(len(cues()), 1)
|
||||
choose("pen")
|
||||
self.assertEqual(cues(), [])
|
||||
|
||||
def test_arrange_handwriting(self):
|
||||
"""Andy, 2026-10-14: under Arrange a handwritten stroke gets its
|
||||
box while the pointer hovers over its ink, kept while the pointer
|
||||
stays within the box; a press on the ink moves it, on the box's
|
||||
corner scales it; the hold with the pen leaves it alone."""
|
||||
firefox = self.firefox
|
||||
firefox.go(self.address("Host"))
|
||||
self.enter_name("Scribe", "start_session") # Not "Writer": another test counts the pages kept under that name.
|
||||
self.wait_until(lambda s: s["page"]["number"] == 1, "the page")
|
||||
choose = lambda tool: firefox.run(f"document.querySelector('[data-tool={tool}]').click();")
|
||||
cues = lambda: firefox.run("return [...document.querySelectorAll('.arrange_cue')].map((c) => c.className.split(' ')[1]);")
|
||||
firefox.run(PEN_WAVE, 0.3, 0.5)
|
||||
state = self.wait_until(lambda s: len(s["layers"]["Scribe"]) == 1 and s["layers"]["Scribe"][0]["done"], "the stroke")
|
||||
origin = state["origin"]
|
||||
scale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;")
|
||||
px = lambda x: (x - origin[0]) * scale
|
||||
py = lambda y: (y - origin[1]) * scale
|
||||
hover_at = lambda x, y: firefox.run(HOVER, (x - origin[0]) / state["page"]["width"], (y - origin[1]) / state["page"]["height"])
|
||||
stroke = state["layers"]["Scribe"][0]
|
||||
p = stroke["points"]
|
||||
halves = [stroke["width"] * (0.36 + 4 * q * q) / 2 for q in p[2::3]]
|
||||
left = min(x - h for x, h in zip(p[0::3], halves))
|
||||
right = max(x + h for x, h in zip(p[0::3], halves))
|
||||
top = min(y - h for y, h in zip(p[1::3], halves))
|
||||
bottom = max(y + h for y, h in zip(p[1::3], halves))
|
||||
choose("arrange")
|
||||
self.assertEqual(cues(), [], "no box until the pointer is over the ink")
|
||||
hover_at(p[0], p[1])
|
||||
self.assertEqual(cues(), ["stroke"])
|
||||
page_box = firefox.run("const b = document.getElementById('page').getBoundingClientRect(); return [b.left, b.top];")
|
||||
got = firefox.run("const b = document.querySelector('.arrange_cue.stroke').getBoundingClientRect(); return [b.left, b.top, b.right, b.bottom];")
|
||||
want = overlay_box(px(left), py(top), px(right), py(bottom), 2)
|
||||
for side, g, w in zip(("left", "top", "right", "bottom"), (got[0] - page_box[0], got[1] - page_box[1], got[2] - page_box[0], got[3] - page_box[1]), want):
|
||||
self.assertAlmostEqual(g, w, delta=0.01, msg=side)
|
||||
# Off the ink but within the box, the box stays; outside it, it goes.
|
||||
hover_at(left + 2, top + 2)
|
||||
self.assertEqual(cues(), ["stroke"], "kept within the box")
|
||||
hover_at(right + 200, bottom + 200)
|
||||
self.assertEqual(cues(), [], "gone outside the box")
|
||||
# A drag on the ink moves the whole stroke; Undo takes it back.
|
||||
firefox.drag("#page", px(p[0]), py(p[1]), px(p[0]) + 120, py(p[1]) + 60)
|
||||
moved = self.wait_until(lambda s: s["layers"]["Scribe"][0]["points"] != p, "the stroke moved")
|
||||
q = moved["layers"]["Scribe"][0]["points"]
|
||||
self.assertEqual(len(q), len(p))
|
||||
self.assertAlmostEqual(q[0] - p[0], 120 / scale, delta=2)
|
||||
self.assertAlmostEqual(q[1] - p[1], 60 / scale, delta=2)
|
||||
self.assertEqual(q[2::3], p[2::3], "the pressures stay")
|
||||
self.assertAlmostEqual(max(q[0::3]) - min(q[0::3]), max(p[0::3]) - min(p[0::3]), delta=0.3, msg="the same size")
|
||||
firefox.run("document.getElementById('undo').click();")
|
||||
self.wait_until(lambda s: s["layers"]["Scribe"][0]["points"] == p, "the move undone")
|
||||
# A drag on the shown box's bottom-right corner scales the stroke.
|
||||
hover_at(p[0], p[1])
|
||||
self.assertEqual(cues(), ["stroke"])
|
||||
firefox.drag("#page", px(right) + 3, py(bottom) + 3, px(right) + 150, py(bottom) + 40)
|
||||
scaled = self.wait_until(lambda s: s["layers"]["Scribe"][0]["points"] != p, "the stroke scaled")
|
||||
q = scaled["layers"]["Scribe"][0]["points"]
|
||||
self.assertAlmostEqual(min(q[0::3]), min(p[0::3]), delta=1, msg="the left edge stays")
|
||||
self.assertGreater(max(q[0::3]) - min(q[0::3]), max(p[0::3]) - min(p[0::3]) + 100 / scale)
|
||||
self.assertEqual(scaled["layers"]["Scribe"][0]["width"], stroke["width"], "the line width unchanged")
|
||||
# The hold with the pen draws a dot and leaves the stroke alone.
|
||||
choose("pen")
|
||||
self.assertEqual(cues(), [])
|
||||
firefox.run_until_done(PEN_HOLD, px(q[0]), py(q[1]), 40, 20, 700)
|
||||
held = self.wait_until(lambda s: len(s["layers"]["Scribe"]) == 2 and s["layers"]["Scribe"][1]["done"], "the pen's own stroke")
|
||||
self.assertEqual(held["layers"]["Scribe"][0]["points"], q, "the handwriting not moved by a hold")
|
||||
|
||||
def test_page_menu_and_help(self):
|
||||
"""Andy, 2026-10-14: the page and view commands are in a Page menu,
|
||||
|
||||
Reference in new issue
Block a user