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>
|
<div id="shape_menu" class="popup menu" role="menu" hidden>
|
||||||
<button type="button" class="menu_item" role="menuitem" data-shape="rectangle"
|
<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>
|
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"
|
<button type="button" class="menu_item" role="menuitem" data-shape="ellipse"
|
||||||
data-help="Ellipse: drag open the rectangle the ellipse fills.">Ellipse</button>
|
data-help="Ellipse: drag open the rectangle the ellipse fills.">Ellipse</button>
|
||||||
<button type="button" class="menu_item" role="menuitem" data-shape="line"
|
<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 { HOLD_MS, NOMINAL_PRESSURE, attachPenInput } from './pen_input.js';
|
||||||
import { Renderer } from './renderer.js';
|
import { Renderer } from './renderer.js';
|
||||||
import { LEAST_IMAGE_SIDE, textBounds, rectAt } from './page_model.js';
|
import { LEAST_IMAGE_SIDE, textBounds, rectAt } from './page_model.js';
|
||||||
|
import { inkExtent } from './stroke_geometry.js';
|
||||||
import {
|
import {
|
||||||
SHAPE_KEY, SHAPE_NAMES, draggedPoints, isFilled, isTapped, lineInProgressPoints, movedPoints, shapeNamed,
|
SHAPE_KEY, SHAPE_NAMES, draggedPoints, isFilled, isTapped, lineInProgressPoints, movedPoints,
|
||||||
straightPoints, vertexPoints,
|
shapeNamed, straightPoints, vertexPoints,
|
||||||
} from './shapes.js';
|
} from './shapes.js';
|
||||||
import { LINE_HEIGHT, TOP_GAP, layoutText } from './text_layout.js';
|
import { LINE_HEIGHT, TOP_GAP, layoutText } from './text_layout.js';
|
||||||
import { log, logText, trace, watchLog } from './log.js';
|
import { log, logText, trace, watchLog } from './log.js';
|
||||||
@@ -818,16 +819,38 @@ async function enterSession(invitationText, name, notebook) {
|
|||||||
return block ? textBounds(block) : null;
|
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. */
|
/** @returns {import('./page_model.js').Stroke[]} Our shapes, in drawing order. */
|
||||||
const ownShapes = () => [...session.ownLayer.strokes.values()].filter((stroke) => stroke.shape !== null && stroke.done);
|
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
|
* With the Arrange tool chosen, mark everything a press can move or
|
||||||
* and is not marked already: the images (for the host and co-hosts),
|
* resize with a blue box and corners (Andy, 2026-10-14): the images
|
||||||
* whose edges may not show, and one's own shapes (Andy, 2026-10-14).
|
* and Hides for the host and co-hosts, one's own text blocks and
|
||||||
* Text blocks and Hides, the implied rectangles, show their corners
|
* shapes; free text, which has no width to resize, gets the box without
|
||||||
* under a hovering pointer with any tool, so the tool adds nothing on
|
* the corners. The one being held is left out, the green grip standing
|
||||||
* them. Called whenever the images, the shapes, or the right may have
|
* 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.
|
* changed.
|
||||||
*/
|
*/
|
||||||
function refreshArrangeCues() {
|
function refreshArrangeCues() {
|
||||||
@@ -835,18 +858,85 @@ async function enterSession(invitationText, name, notebook) {
|
|||||||
renderer.setArrangeCues([]);
|
renderer.setArrangeCues([]);
|
||||||
return;
|
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 cues = [];
|
||||||
|
const page = session.page;
|
||||||
if (session.mayMakePages()) {
|
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()) {
|
for (const stroke of ownShapes()) {
|
||||||
const rect = strokeBox(stroke);
|
if (held?.kind === 'shape' && held.id === stroke.id) continue;
|
||||||
if (rect) cues.push({ kind: 'shape', rect });
|
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);
|
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
|
// 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
|
// 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
|
// 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,
|
* The implied rectangle under a point, for the hover cue (Andy,
|
||||||
* 2026-10-14: text rectangles and Hides show their corners only then,
|
* 2026-10-14: text rectangles and Hides show their corners only then,
|
||||||
* like free text): the frontmost Hide, else the frontmost text block
|
* 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} x
|
||||||
* @param {number} y
|
* @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) {
|
function impliedRectAt(x, y) {
|
||||||
/** @param {import('./page_model.js').Rectangle} b */
|
/** @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;
|
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()) {
|
for (const h of [...session.page.hides].reverse()) {
|
||||||
const b = { left: h.x, top: h.y, width: h.width, height: h.height };
|
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()) {
|
for (const id of session.layerOrder().reverse()) {
|
||||||
const layer = session.page.layers.get(id);
|
const layer = session.page.layers.get(id);
|
||||||
if (!layer || renderer.hidden.has(id)) continue;
|
if (!layer || renderer.hidden.has(id)) continue;
|
||||||
for (const block of [...layer.texts.values()].reverse()) {
|
for (const block of [...layer.texts.values()].reverse()) {
|
||||||
const b = textBounds(block);
|
const b = textBounds(block);
|
||||||
if (holds(b)) return b;
|
if (holds(b)) return { rect: b, kind: block.height === null ? 'free' : 'rect' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
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} */
|
/** @type {import('./pen_input.js').PenTarget} */
|
||||||
const penTarget = {
|
const penTarget = {
|
||||||
toPage: (clientX, clientY) => renderer.pageFromClient(clientX, clientY),
|
toPage: (clientX, clientY) => renderer.pageFromClient(clientX, clientY),
|
||||||
@@ -1030,14 +1159,31 @@ async function enterSession(invitationText, name, notebook) {
|
|||||||
} else {
|
} else {
|
||||||
const blocks = [...session.ownLayer.texts.values()];
|
const blocks = [...session.ownLayer.texts.values()];
|
||||||
const at = rectAt(blocks.map(textBounds), x, y, reach);
|
const at = rectAt(blocks.map(textBounds), x, y, reach);
|
||||||
const shapes = at ? [] : ownShapes();
|
// Handwriting, under Arrange only: the shown box's corner, or a
|
||||||
const shapeAt = at ? null : rectAt(shapes.map((s) => strokeBox(s) ?? { left: 0, top: 0, width: 0, height: 0 }), x, y, reach);
|
// 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) {
|
if (at) {
|
||||||
const block = blocks[at.index];
|
const block = blocks[at.index];
|
||||||
// Free text has no corners to resize by.
|
// Free text has no corners to resize by.
|
||||||
const corner = block.height === null ? null : at.corner;
|
const corner = block.height === null ? null : at.corner;
|
||||||
found = hold('text', at.index, block.id, textBounds(block), corner, x, y);
|
found = hold('text', at.index, block.id, textBounds(block), corner, x, y);
|
||||||
session.beginTextEdit(block.id, 'hold');
|
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) {
|
} else if (shapeAt) {
|
||||||
// A shape of ours (Andy, 2026-10-14): moved, or resized from a
|
// A shape of ours (Andy, 2026-10-14): moved, or resized from a
|
||||||
// corner, by the rectangle around its points.
|
// corner, by the rectangle around its points.
|
||||||
@@ -1062,7 +1208,8 @@ async function enterSession(invitationText, name, notebook) {
|
|||||||
}
|
}
|
||||||
textEditor?.hidePreview();
|
textEditor?.hidePreview();
|
||||||
held = found;
|
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' : ''}`);
|
log(`${found.kind} held${found.corner ? ' at a corner' : ''}`);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
@@ -1071,7 +1218,9 @@ async function enterSession(invitationText, name, notebook) {
|
|||||||
const [x, y] = renderer.pageFromClient(clientX, clientY);
|
const [x, y] = renderer.pageFromClient(clientX, clientY);
|
||||||
const now = currentHeldRect(held);
|
const now = currentHeldRect(held);
|
||||||
if (!now) return;
|
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') {
|
if (held.kind === 'image') {
|
||||||
held.index = session.placeImageByHand(held.index, rect);
|
held.index = session.placeImageByHand(held.index, rect);
|
||||||
} else if (held.kind === 'hide') {
|
} else if (held.kind === 'hide') {
|
||||||
@@ -1096,7 +1245,7 @@ async function enterSession(invitationText, name, notebook) {
|
|||||||
// The field being typed in follows its block.
|
// The field being typed in follows its block.
|
||||||
if (textEditor?.block?.id === block.id) textEditor.layout();
|
if (textEditor?.block?.id === block.id) textEditor.layout();
|
||||||
}
|
}
|
||||||
renderer.showGrip(currentHeldRect(held), held.corner);
|
showHeldGrip(held);
|
||||||
refreshArrangeCues();
|
refreshArrangeCues();
|
||||||
},
|
},
|
||||||
holdEnd: () => {
|
holdEnd: () => {
|
||||||
@@ -1188,11 +1337,23 @@ async function enterSession(invitationText, name, notebook) {
|
|||||||
const last = lineInProgress.vertices[lineInProgress.vertices.length - 1];
|
const last = lineInProgress.vertices[lineInProgress.vertices.length - 1];
|
||||||
renderer.showShapePreview(straightPoints([last, [x, y]]), color, width, false);
|
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.showHoverCue(null);
|
||||||
|
renderer.setEraseCues([]);
|
||||||
if (lineInProgress) renderer.hideShapePreview();
|
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) => {
|
placeRect: (x0, y0, x1, y1) => {
|
||||||
if (tool === 'shape') {
|
if (tool === 'shape') {
|
||||||
@@ -1248,7 +1409,9 @@ async function enterSession(invitationText, name, notebook) {
|
|||||||
element('colors').hidden = value === 'eraser' || value === 'hide' || value === 'arrange';
|
element('colors').hidden = value === 'eraser' || value === 'hide' || value === 'arrange';
|
||||||
element('widths').hidden = value !== 'pen' && value !== 'shape';
|
element('widths').hidden = value !== 'pen' && value !== 'shape';
|
||||||
element('text_style').hidden = value !== 'text';
|
element('text_style').hidden = value !== 'text';
|
||||||
|
if (value !== 'arrange') hoveredStroke = null;
|
||||||
refreshArrangeCues();
|
refreshArrangeCues();
|
||||||
|
if (value !== 'eraser') renderer.setEraseCues([]);
|
||||||
},
|
},
|
||||||
setShape: (value) => {
|
setShape: (value) => {
|
||||||
finishLine();
|
finishLine();
|
||||||
|
|||||||
@@ -49,9 +49,9 @@ export const IMAGE_MARGIN = 50;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The shapes of the Shape tool (shapes.js), as a stroke records its kind.
|
* 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. */
|
/** Numbers per point in Stroke.points. */
|
||||||
export const POINT_SIZE = 3;
|
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
|
* 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
|
* progress (a mouse, or a pen in the air above a tablet that reports
|
||||||
* it; a finger never hovers).
|
* it; a finger never hovers).
|
||||||
* @property {() => void} [hoverEnded]
|
* @property {(left?: boolean) => void} [hoverEnded]
|
||||||
* The pointer left the page, or pressed.
|
* The pointer pressed, or left the page (`left` true).
|
||||||
* @property {(clientX: number, clientY: number) => boolean} [beginHold]
|
* @property {(clientX: number, clientY: number) => boolean} [beginHold]
|
||||||
* The pointer has rested on the page for HOLD_MS, with any tool: a
|
* 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
|
* 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('pointerup', finish);
|
||||||
surface.addEventListener('pointercancel', 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) => {
|
surface.addEventListener('lostpointercapture', (event) => {
|
||||||
if (event.pointerId === activePointer) trace(`capture of pointer ${event.pointerId} lost while active`);
|
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,
|
* The *implied rectangles* (Andy, 2026-10-14), text blocks and Hides,
|
||||||
* are marked on screen by blue corners while the pointer hovers over
|
* are marked on screen while the pointer hovers over them, by a line
|
||||||
* them (always, until 2026-10-14, for a text rectangle and a Hide):
|
* this wide in page units (the hover cue; blue L corners until the
|
||||||
* L-shaped marks this long, in page units, and this wide, in the blue of
|
* evening of 2026-10-14, always shown on a text rectangle and a Hide
|
||||||
* the Arrange cues. Nothing of them is on the page or in the file. (A
|
* until that day): a text rectangle or a Hide outlined, free text by
|
||||||
* text rectangle had a red frame on a white ground, page content, until
|
* its top and left edges. Nothing of them is on the page or in the
|
||||||
* 2026-10-14, and free text a red L until 2026-10-14; the teacher's
|
* file. (A text rectangle had a red frame on a white ground, page
|
||||||
* rectangles turned out to be marks of their own, which the Shape tool
|
* content, until 2026-10-14, and free text a red L until 2026-10-14;
|
||||||
* now draws.)
|
* 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;
|
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.
|
* How the page is scaled on screen: a number of CSS pixels per page unit.
|
||||||
@@ -202,20 +209,29 @@ export class Renderer {
|
|||||||
* corners are).
|
* corners are).
|
||||||
*/
|
*/
|
||||||
this.grip = document.createElement('div');
|
this.grip = document.createElement('div');
|
||||||
this.grip.className = 'image_grip';
|
this.grip.className = 'grip';
|
||||||
this.grip.hidden = true;
|
this.grip.hidden = true;
|
||||||
this.grip.append(...cornerHandles());
|
this.grip.append(...cornerHandles());
|
||||||
this.pageElement.append(this.grip);
|
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;
|
this.gripShown = null;
|
||||||
/**
|
/**
|
||||||
* The blue cues of the Arrange tool: the corners of every image, text
|
* The blue cues of the Arrange tool: a box and its corners on every
|
||||||
* rectangle and Hide that can be moved or resized, and an outline for
|
* image, Hide, text block and shape that can be moved or resized
|
||||||
* images, whose edges may be invisible on the white page (Andy,
|
* (the box, since an image's edges may be invisible on the white
|
||||||
* 2026-10-13: figures on a white ground).
|
* page; Andy, 2026-10-13: figures on a white ground).
|
||||||
* @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}[]}
|
||||||
*/
|
*/
|
||||||
this.arrangeCues = [];
|
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 = document.createElement('div');
|
||||||
this.cueElements.className = 'arrange_cues';
|
this.cueElements.className = 'arrange_cues';
|
||||||
this.pageElement.append(this.cueElements);
|
this.pageElement.append(this.cueElements);
|
||||||
@@ -230,7 +246,6 @@ export class Renderer {
|
|||||||
this.hoverCue = document.createElement('div');
|
this.hoverCue = document.createElement('div');
|
||||||
this.hoverCue.className = 'hover_cue';
|
this.hoverCue.className = 'hover_cue';
|
||||||
this.hoverCue.hidden = true;
|
this.hoverCue.hidden = true;
|
||||||
this.hoverCue.append(...cornerHandles());
|
|
||||||
this.pageElement.append(this.hoverCue);
|
this.pageElement.append(this.hoverCue);
|
||||||
/**
|
/**
|
||||||
* A shape being dragged open or a line's rubber band, in the pen's
|
* 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,
|
* Show, or hide, the green grip around what is being moved or resized
|
||||||
* and the corner being dragged if it is being scaled.
|
* (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 {import('./page_model.js').Rectangle | null} rect Page units.
|
||||||
* @param {[number, number] | null} [corner]
|
* @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) {
|
showGrip(rect, corner = null, corners = true) {
|
||||||
this.gripShown = rect ? { rect, corner } : null;
|
this.gripShown = rect ? { rect, corner, corners } : null;
|
||||||
this.layoutGrip();
|
this.layoutGrip();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -517,7 +535,9 @@ export class Renderer {
|
|||||||
this.grip.hidden = !shown;
|
this.grip.hidden = !shown;
|
||||||
if (!shown) return;
|
if (!shown) return;
|
||||||
this.placeOverlay(this.grip, shown.rect);
|
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('corner', shown.corner !== null);
|
||||||
|
this.grip.classList.toggle('moving', shown.corner === null);
|
||||||
if (shown.corner) {
|
if (shown.corner) {
|
||||||
this.grip.style.setProperty('--corner-x', shown.corner[0] ? '100%' : '0%');
|
this.grip.style.setProperty('--corner-x', shown.corner[0] ? '100%' : '0%');
|
||||||
this.grip.style.setProperty('--corner-y', shown.corner[1] ? '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
|
* Place an overlay element that marks a rectangle of the page (a cue,
|
||||||
* of the page, so that it keeps its place at any zoom.
|
* 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 {HTMLElement} el
|
||||||
* @param {import('./page_model.js').Rectangle} rect Page units.
|
* @param {import('./page_model.js').Rectangle} rect Page units.
|
||||||
*/
|
*/
|
||||||
placeOverlay(el, rect) {
|
placeOverlay(el, rect) {
|
||||||
const page = this.page;
|
const page = this.page;
|
||||||
el.style.left = `${(rect.left - page.left) / page.width * 100}%`;
|
const line = parseFloat(getComputedStyle(el).borderTopWidth) || 0;
|
||||||
el.style.top = `${(rect.top - page.top) / page.height * 100}%`;
|
const scale = this.cssPixelsPerUnit;
|
||||||
el.style.width = `${rect.width / page.width * 100}%`;
|
const d = window.devicePixelRatio || 1;
|
||||||
el.style.height = `${rect.height / page.height * 100}%`;
|
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.
|
* Show the Arrange tool's cues on these rectangles, or none: a box and
|
||||||
* @param {{rect: import('./page_model.js').Rectangle, kind: 'image' | 'text' | 'hide' | 'shape'}[]} cues
|
* 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) {
|
setArrangeCues(cues) {
|
||||||
this.arrangeCues = cues;
|
this.arrangeCues = cues;
|
||||||
this.cueElements.replaceChildren();
|
this.cueElements.replaceChildren();
|
||||||
for (const cue of cues) {
|
for (const cue of cues) {
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.className = `arrange_cue ${cue.kind}`;
|
el.className = `arrange_cue ${cue.kind}${cue.corners ? '' : ' no_corners'}`;
|
||||||
el.append(...cornerHandles());
|
el.append(...cornerHandles());
|
||||||
this.cueElements.append(el);
|
this.cueElements.append(el);
|
||||||
}
|
}
|
||||||
this.layoutCues();
|
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() {
|
layoutCues() {
|
||||||
const elements = this.cueElements.children;
|
const elements = this.cueElements.children;
|
||||||
this.arrangeCues.forEach((cue, i) => {
|
this.arrangeCues.forEach((cue, i) => {
|
||||||
const el = /** @type {HTMLElement} */ (elements[i]);
|
const el = /** @type {HTMLElement} */ (elements[i]);
|
||||||
if (el) this.placeOverlay(el, cue.rect);
|
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);
|
if (this.hoverRect) this.placeOverlay(this.hoverCue, this.hoverRect);
|
||||||
const page = this.page;
|
const page = this.page;
|
||||||
this.shapePreview.setAttribute('viewBox', `${page.left} ${page.top} ${page.width} ${page.height}`);
|
this.shapePreview.setAttribute('viewBox', `${page.left} ${page.top} ${page.width} ${page.height}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark the corners of this rectangle while the pointer hovers over it,
|
* Mark this implied rectangle while the pointer hovers over it, or
|
||||||
* or none.
|
* 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 {import('./page_model.js').Rectangle | null} rect
|
||||||
|
* @param {'rect' | 'free' | 'hide'} [kind]
|
||||||
*/
|
*/
|
||||||
showHoverCue(rect) {
|
showHoverCue(rect, kind = 'hide') {
|
||||||
this.hoverRect = rect;
|
this.hoverRect = rect;
|
||||||
this.hoverCue.hidden = !rect;
|
this.hoverCue.hidden = !rect;
|
||||||
if (!rect) return;
|
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);
|
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) {
|
beginShapeEdit(id) {
|
||||||
const stroke = this.ownLayer.strokes.get(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() } };
|
this.shapeEditing = { id, before: { ...stroke, points: stroke.points.slice() } };
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-5
@@ -31,11 +31,11 @@ import { NOMINAL_PRESSURE } from './pen_input.js';
|
|||||||
import { POINT_SIZE } from './page_model.js';
|
import { POINT_SIZE } from './page_model.js';
|
||||||
|
|
||||||
/** The shapes, in the order of the menu. The first is the default. */
|
/** 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 */
|
/** @typedef {typeof SHAPES[number]} Shape */
|
||||||
|
|
||||||
/** @type {Record<Shape, string>} What the button and the menu call each 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. */
|
/** The key under which the browser remembers the chosen shape. */
|
||||||
export const SHAPE_KEY = 'kokkoja_shape';
|
export const SHAPE_KEY = 'kokkoja_shape';
|
||||||
@@ -149,7 +149,10 @@ export function ellipsePoints(x0, y0, x1, y1) {
|
|||||||
// Ramanujan's approximation of the circumference.
|
// Ramanujan's approximation of the circumference.
|
||||||
const h = ((rx - ry) / (rx + ry)) ** 2 || 0;
|
const h = ((rx - ry) / (rx + ry)) ** 2 || 0;
|
||||||
const circumference = Math.PI * (rx + ry) * (1 + 3 * h / (10 + Math.sqrt(4 - 3 * h)));
|
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[]} */
|
/** @type {number[]} */
|
||||||
const points = [];
|
const points = [];
|
||||||
for (let i = 0; i <= count; i++) {
|
for (let i = 0; i <= count; i++) {
|
||||||
@@ -159,6 +162,21 @@ export function ellipsePoints(x0, y0, x1, y1) {
|
|||||||
return points;
|
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
|
* 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
|
* 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 {Shape} shape Not 'line', which is made of taps.
|
||||||
* @param {number} x0
|
* @param {number} x0
|
||||||
* @param {number} y0
|
* @param {number} y0
|
||||||
@@ -214,6 +234,7 @@ export function arrowPoints(x0, y0, x1, y1, width) {
|
|||||||
export function draggedPoints(shape, x0, y0, x1, y1, width) {
|
export function draggedPoints(shape, x0, y0, x1, y1, width) {
|
||||||
switch (shape) {
|
switch (shape) {
|
||||||
case 'rectangle': return rectanglePoints(x0, y0, x1, y1);
|
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 'ellipse': return ellipsePoints(x0, y0, x1, y1);
|
||||||
case 'arrow': return arrowPoints(x0, y0, x1, y1, width);
|
case 'arrow': return arrowPoints(x0, y0, x1, y1, width);
|
||||||
default: return straightPoints([[x0, y0], [x1, y1]]);
|
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
|
* The points of a shape moved or resized by Arrange from the rectangle
|
||||||
* around its points to another: every point carried along (a rectangle
|
* around its points to another: every point carried along (a rectangle
|
||||||
* stays a rectangle, an ellipse an ellipse, a line keeps its angles'
|
* 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.
|
* 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').Stroke} stroke
|
||||||
* @param {import('./page_model.js').Rectangle} from
|
* @param {import('./page_model.js').Rectangle} from
|
||||||
|
|||||||
@@ -39,6 +39,29 @@ export function widthAt(stroke, index) {
|
|||||||
return stroke.width * (0.36 + 4 * pressure * pressure);
|
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`.
|
* The part of a stroke that belongs to point `k`.
|
||||||
* @param {Stroke} stroke
|
* @param {Stroke} stroke
|
||||||
|
|||||||
+51
-32
@@ -5,6 +5,22 @@
|
|||||||
--panel: #f6f6f7;
|
--panel: #f6f6f7;
|
||||||
--desk: #e4e4e7;
|
--desk: #e4e4e7;
|
||||||
--accent: #1f4fd8;
|
--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-family: system-ui, sans-serif;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
@@ -334,18 +350,23 @@ h2 {
|
|||||||
background: var(--desk);
|
background: var(--desk);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The cue around an image being moved or scaled by hand: green, with a
|
/* The grip around whatever is being moved or resized (an image, a Hide,
|
||||||
square at the corner being dragged. */
|
a text block, a shape), by a hold or by Arrange: green, its four
|
||||||
.image_grip {
|
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;
|
position: absolute;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border: 2px solid #2e7d32;
|
border: var(--cue_line) solid var(--held_green);
|
||||||
pointer-events: none;
|
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 {
|
.handle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 10px;
|
width: 10px;
|
||||||
@@ -358,45 +379,43 @@ h2 {
|
|||||||
.handle.sw { left: 0; top: 100%; }
|
.handle.sw { left: 0; top: 100%; }
|
||||||
.handle.se { left: 100%; 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: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: var(--corner-x);
|
left: var(--corner-x);
|
||||||
top: var(--corner-y);
|
top: var(--corner-y);
|
||||||
width: 16px;
|
width: var(--moving_handle);
|
||||||
height: 16px;
|
height: var(--moving_handle);
|
||||||
transform: translate(-50%, -50%);
|
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,
|
/* The Arrange tool's cues (Andy, 2026-10-14): a blue box with blue
|
||||||
whose edges may not show (text rectangles and Hides carry blue corners
|
corner squares on everything a press can move or resize, images,
|
||||||
at all times since 2026-10-14, drawn on the canvases); and the same
|
Hides, one's own text and shapes alike; free text, which has no width
|
||||||
corners on free text under a hovering pointer (.hover_cue). */
|
to resize, gets the box without the corners. */
|
||||||
.arrange_cues { position: absolute; inset: 0; pointer-events: none; z-index: 3; }
|
.arrange_cues { position: absolute; inset: 0; pointer-events: none; z-index: 3; }
|
||||||
.arrange_cue {
|
.arrange_cue {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
pointer-events: none;
|
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; }
|
.arrange_cue.no_corners .handle, .grip.no_corners .handle { display: none; }
|
||||||
/* The hover cue's corners are L marks like those the canvases draw on
|
/* The eraser's cues: a red box around each element a press where the
|
||||||
text rectangles and Hides, not the Arrange tool's squares, so that
|
pointer hovers would erase, whole, as the eraser works. */
|
||||||
"blue corners" look the same everywhere; renderer.js sets the mark's
|
.erase_cues { position: absolute; inset: 0; pointer-events: none; z-index: 3; }
|
||||||
length and line width for the zoom. */
|
.erase_cue { position: absolute; box-sizing: border-box; pointer-events: none; border: var(--cue_line) solid var(--erase_red); }
|
||||||
.hover_cue { position: absolute; box-sizing: border-box; pointer-events: none; z-index: 3; --handle-color: #1e88e5; }
|
/* The hover cue on an implied rectangle (Andy, 2026-10-14, evening): a
|
||||||
.hover_cue .handle {
|
text rectangle and a Hide are outlined, free text shows its top and
|
||||||
width: var(--mark);
|
left edges only, so the two kinds of text are told apart. renderer.js
|
||||||
height: var(--mark);
|
sets the line width for the zoom. */
|
||||||
transform: none;
|
.hover_cue { position: absolute; box-sizing: border-box; pointer-events: none; z-index: 3; border: var(--mark_line) solid var(--hover_blue); }
|
||||||
background: none;
|
.hover_cue.free { border-width: var(--mark_line) 0 0 var(--mark_line); }
|
||||||
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; }
|
|
||||||
|
|
||||||
.page {
|
.page {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
+295
-20
@@ -44,10 +44,27 @@ HOVER_CUE = """
|
|||||||
const page = document.getElementById('page').getBoundingClientRect();
|
const page = document.getElementById('page').getBoundingClientRect();
|
||||||
const b = c.getBoundingClientRect();
|
const b = c.getBoundingClientRect();
|
||||||
const k = s.page.width / page.width;
|
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,
|
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
|
# Whether anything at all is drawn on the writer's own layer canvas within
|
||||||
# two device pixels of the page point (x, y).
|
# two device pixels of the page point (x, y).
|
||||||
INK_AT = """
|
INK_AT = """
|
||||||
@@ -538,6 +555,12 @@ class Firefox:
|
|||||||
"type": "pointer", "id": "mouse", "parameters": {"pointerType": "mouse"},
|
"type": "pointer", "id": "mouse", "parameters": {"pointerType": "mouse"},
|
||||||
"actions": moves}]})
|
"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):
|
def type_into(self, selector, text):
|
||||||
"""Type text into an element, as the keyboard would."""
|
"""Type text into an element, as the keyboard would."""
|
||||||
found = self.in_session("POST", "/element", {"using": "css selector", "value": selector})
|
found = self.in_session("POST", "/element", {"using": "css selector", "value": selector})
|
||||||
@@ -661,17 +684,30 @@ class EndToEndTest(unittest.TestCase):
|
|||||||
def state(self):
|
def state(self):
|
||||||
return self.firefox.run(PAGE_STATE)
|
return self.firefox.run(PAGE_STATE)
|
||||||
|
|
||||||
def assert_hover_cue(self, x, y, rect):
|
def assert_hover_cue(self, x, y, rect, kind="hide"):
|
||||||
"""A pointer hovering at the page point (x, y) shows the blue
|
"""A pointer hovering at the page point (x, y) shows the blue cue
|
||||||
corners over the rectangle (page units), and none once it leaves."""
|
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()
|
state = self.state()
|
||||||
fx = (x - state["origin"][0]) / state["page"]["width"]
|
fx = (x - state["origin"][0]) / state["page"]["width"]
|
||||||
fy = (y - state["origin"][1]) / state["page"]["height"]
|
fy = (y - state["origin"][1]) / state["page"]["height"]
|
||||||
self.firefox.run(HOVER, fx, fy)
|
self.firefox.run(HOVER, fx, fy)
|
||||||
cue = self.firefox.run(HOVER_CUE)
|
cue = self.firefox.run(HOVER_CUE)
|
||||||
self.assertFalse(cue["hidden"], f"no cue at {x}, {y}")
|
self.assertFalse(cue["hidden"], f"no cue at {x}, {y}")
|
||||||
for key, expected in zip(("x", "y", "width", "height"), rect):
|
self.assertEqual(cue["kind"], kind)
|
||||||
self.assertAlmostEqual(cue[key], expected, delta=3, msg=key)
|
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.firefox.run(HOVER, 0.01, 0.01)
|
||||||
self.assertTrue(self.firefox.run(HOVER_CUE)["hidden"])
|
self.assertTrue(self.firefox.run(HOVER_CUE)["hidden"])
|
||||||
|
|
||||||
@@ -1517,20 +1553,39 @@ class EndToEndTest(unittest.TestCase):
|
|||||||
self.assertEqual(after["layers"]["Arranger"], [])
|
self.assertEqual(after["layers"]["Arranger"], [])
|
||||||
self.assertEqual(rect(after, 0), rect(scaled, 0))
|
self.assertEqual(rect(after, 0), rect(scaled, 0))
|
||||||
|
|
||||||
# A text rectangle of one's own and a Hide, marked by their own blue
|
# A text rectangle of one's own, free text and a Hide get cues too
|
||||||
# corners, get no cue; a corner drag widens the text box, a drag
|
# (Andy, 2026-10-14), the free text's without corners; the hover
|
||||||
# inside moves the Hide.
|
# cue still shows on the box and the Hide; a corner drag widens
|
||||||
|
# the text box, a drag inside moves the Hide.
|
||||||
choose("text")
|
choose("text")
|
||||||
firefox.run(TEXT_GESTURE, 0.1, 0.6, 0.3, 0.7)
|
firefox.run(TEXT_GESTURE, 0.1, 0.6, 0.3, 0.7)
|
||||||
firefox.type_into(".text_field", "Box")
|
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")
|
choose("hide")
|
||||||
firefox.run(TEXT_GESTURE, 0.5, 0.6, 0.7, 0.7)
|
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",
|
with_all = self.wait_until(lambda s: len(s["hides"]) == 1 and len(s["texts"]["Arranger"]) == 2
|
||||||
"the box and the Hide")
|
and s["texts"]["Arranger"][0]["text"] == "Box" and s["texts"]["Arranger"][1]["text"] == "Free",
|
||||||
|
"the box, the free text and the Hide")
|
||||||
choose("arrange")
|
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]
|
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]
|
h = with_all["hides"][0]
|
||||||
self.assert_hover_cue(h[0] + h[2] / 2, h[1] + h[3] / 2, h)
|
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,
|
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)
|
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")
|
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(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.
|
# Undo takes the Hide's move back, as it does a hold's.
|
||||||
firefox.run("document.getElementById('undo').click();")
|
firefox.run("document.getElementById('undo').click();")
|
||||||
self.wait_until(lambda s: s["hides"][0] == hide, "the Hide back by Undo")
|
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
|
# While something is held its blue cue gives way to the green grip:
|
||||||
# rectangle has its blue corners as everyone's has.
|
# 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)
|
firefox.switch_to(guest_tab)
|
||||||
self.wait_until(lambda s: len(s["images"]) == 1 and len(s["hides"]) == 1, "the page at the watcher")
|
self.wait_until(lambda s: len(s["images"]) == 1 and len(s["hides"]) == 1, "the page at the watcher")
|
||||||
choose("arrange")
|
choose("arrange")
|
||||||
@@ -1558,10 +1650,10 @@ class EndToEndTest(unittest.TestCase):
|
|||||||
firefox.run(TEXT_GESTURE, 0.1, 0.8, 0.3, 0.9)
|
firefox.run(TEXT_GESTURE, 0.1, 0.8, 0.3, 0.9)
|
||||||
firefox.type_into(".text_field", "Mine")
|
firefox.type_into(".text_field", "Mine")
|
||||||
choose("arrange")
|
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 = 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]
|
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):
|
def test_shapes(self):
|
||||||
"""Andy, 2026-10-14: a Shape button right of Text draws the shape it
|
"""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;")
|
scale = firefox.run("return window.kokkoja.renderer.cssPixelsPerUnit;")
|
||||||
px = lambda x: (x - origin[0]) * scale
|
px = lambda x: (x - origin[0]) * scale
|
||||||
py = lambda y: (y - origin[1]) * 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"]
|
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)
|
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")
|
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]
|
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["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.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(),
|
saved = self.wait_until_true(lambda: (self.notebooks / "Drawer notebook" / "Current.svg").read_text(),
|
||||||
lambda text: "<desc>Box</desc>" in text, "the box saved")
|
lambda text: "<desc>Box</desc>" in text, "the box saved")
|
||||||
self.assertNotIn("data-ground", saved)
|
self.assertNotIn("data-ground", saved)
|
||||||
@@ -1827,7 +1932,177 @@ class EndToEndTest(unittest.TestCase):
|
|||||||
self.assertTrue(firefox.run(HOVER_CUE)["hidden"])
|
self.assertTrue(firefox.run(HOVER_CUE)["hidden"])
|
||||||
self.assertFalse(firefox.run(INK_AT, free["x"], free["y"]), "no red L on free text")
|
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,
|
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):
|
def test_page_menu_and_help(self):
|
||||||
"""Andy, 2026-10-14: the page and view commands are in a Page menu,
|
"""Andy, 2026-10-14: the page and view commands are in a Page menu,
|
||||||
|
|||||||
Reference in new issue
Block a user