Arranging: boxes on everything, handwriting movable, the Eraser's preview, a Circle shape, pixel-exact cues

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
ackandClaude Fable 5.1 committed 2026-10-14 20:38:29 +02:00
1 parent 03907da8c9
commit 24a6a2595b
10 files changed
+690 -126

No files matched your search

+99 -40
View File
@@ -50,18 +50,25 @@ export const CORNER_REACH_PX = 24;
/**
* The *implied rectangles* (Andy, 2026-10-14), text blocks and Hides,
* are marked on screen by blue corners while the pointer hovers over
* them (always, until 2026-10-14, for a text rectangle and a Hide):
* L-shaped marks this long, in page units, and this wide, in the blue of
* the Arrange cues. Nothing of them is on the page or in the file. (A
* text rectangle had a red frame on a white ground, page content, until
* 2026-10-14, and free text a red L until 2026-10-14; the teacher's
* rectangles turned out to be marks of their own, which the Shape tool
* now draws.)
* are marked on screen while the pointer hovers over them, by a line
* this wide in page units (the hover cue; blue L corners until the
* evening of 2026-10-14, always shown on a text rectangle and a Hide
* until that day): a text rectangle or a Hide outlined, free text by
* its top and left edges. Nothing of them is on the page or in the
* file. (A text rectangle had a red frame on a white ground, page
* content, until 2026-10-14, and free text a red L until 2026-10-14;
* the teacher's rectangles turned out to be marks of their own, which
* the Shape tool now draws.)
*/
const CORNER_MARK = 60;
export const CUE_COLOR = '#1e88e5';
const CUE_WIDTH = 2;
/**
* Every overlay that marks a thing's extent (Arrange's cues, the grip,
* the hover cue) draws its line this many pixels beyond the extent: 1
* puts the line on the first pixel outside the extent, touching it; 2
* leaves one pixel clear between (Andy, 2026-10-14, to see how it
* looks and whether a rounding error shows).
*/
export const CUE_OFFSET_PX = 2;
/**
* How the page is scaled on screen: a number of CSS pixels per page unit.
@@ -202,20 +209,29 @@ export class Renderer {
* corners are).
*/
this.grip = document.createElement('div');
this.grip.className = 'image_grip';
this.grip.className = 'grip';
this.grip.hidden = true;
this.grip.append(...cornerHandles());
this.pageElement.append(this.grip);
/** @type {{rect: import('./page_model.js').Rectangle, corner: [number, number] | null} | null} */
/** @type {{rect: import('./page_model.js').Rectangle, corner: [number, number] | null, corners: boolean} | null} */
this.gripShown = null;
/**
* The blue cues of the Arrange tool: the corners of every image, text
* rectangle and Hide that can be moved or resized, and an outline for
* images, whose edges may be invisible on the white page (Andy,
* 2026-10-13: figures on a white ground).
* @type {{rect: import('./page_model.js').Rectangle, kind: 'image' | 'text' | 'hide' | 'shape'}[]}
* The blue cues of the Arrange tool: a box and its corners on every
* image, Hide, text block and shape that can be moved or resized
* (the box, since an image's edges may be invisible on the white
* page; Andy, 2026-10-13: figures on a white ground).
* @type {{rect: import('./page_model.js').Rectangle, kind: 'image' | 'text' | 'hide' | 'shape' | 'stroke', corners: boolean}[]}
*/
this.arrangeCues = [];
/**
* The eraser's cues (Andy, 2026-10-14): a red box around everything a
* press where the pointer hovers would erase.
* @type {import('./page_model.js').Rectangle[]}
*/
this.eraseCues = [];
this.eraseCueElements = document.createElement('div');
this.eraseCueElements.className = 'erase_cues';
this.pageElement.append(this.eraseCueElements);
this.cueElements = document.createElement('div');
this.cueElements.className = 'arrange_cues';
this.pageElement.append(this.cueElements);
@@ -230,7 +246,6 @@ export class Renderer {
this.hoverCue = document.createElement('div');
this.hoverCue.className = 'hover_cue';
this.hoverCue.hidden = true;
this.hoverCue.append(...cornerHandles());
this.pageElement.append(this.hoverCue);
/**
* A shape being dragged open or a line's rubber band, in the pen's
@@ -502,13 +517,16 @@ export class Renderer {
}
/**
* Show, or hide, the cue around an image being handled: its outline,
* and the corner being dragged if it is being scaled.
* Show, or hide, the green grip around what is being moved or resized
* (an image, a Hide, a text block or a shape): its outline, its four
* corners, and the corner being dragged larger.
* @param {import('./page_model.js').Rectangle | null} rect Page units.
* @param {[number, number] | null} [corner]
* @param {boolean} [corners] False for free text, which has no corners
* to resize by (Andy, 2026-10-14), so the grip shows none.
*/
showGrip(rect, corner = null) {
this.gripShown = rect ? { rect, corner } : null;
showGrip(rect, corner = null, corners = true) {
this.gripShown = rect ? { rect, corner, corners } : null;
this.layoutGrip();
}
@@ -517,7 +535,9 @@ export class Renderer {
this.grip.hidden = !shown;
if (!shown) return;
this.placeOverlay(this.grip, shown.rect);
this.grip.classList.toggle('no_corners', !shown.corners);
this.grip.classList.toggle('corner', shown.corner !== null);
this.grip.classList.toggle('moving', shown.corner === null);
if (shown.corner) {
this.grip.style.setProperty('--corner-x', shown.corner[0] ? '100%' : '0%');
this.grip.style.setProperty('--corner-y', shown.corner[1] ? '100%' : '0%');
@@ -525,62 +545,101 @@ export class Renderer {
}
/**
* Place an overlay element over a rectangle of the page, in percentages
* of the page, so that it keeps its place at any zoom.
* Place an overlay element that marks a rectangle of the page (a cue,
* the grip, the hover cue, the eraser's cue) just outside it, in pixels, relaid at every
* zoom by resize(): the rectangle's edges are taken out to whole device
* pixels (ink's edge is anti-aliased across the pixel it falls in, and
* the line must not cover it), then the line is set CUE_OFFSET_PX
* beyond them (Andy, 2026-10-14: first one pixel, the line touching
* the extent, then two to see how it looks), the element's own border
* width telling where its line lies. Measured at the four line widths
* of a rectangle: with the position left to the browser's rounding, an
* edge past the middle of a pixel had put the line on that pixel.
* @param {HTMLElement} el
* @param {import('./page_model.js').Rectangle} rect Page units.
*/
placeOverlay(el, rect) {
const page = this.page;
el.style.left = `${(rect.left - page.left) / page.width * 100}%`;
el.style.top = `${(rect.top - page.top) / page.height * 100}%`;
el.style.width = `${rect.width / page.width * 100}%`;
el.style.height = `${rect.height / page.height * 100}%`;
const line = parseFloat(getComputedStyle(el).borderTopWidth) || 0;
const scale = this.cssPixelsPerUnit;
const d = window.devicePixelRatio || 1;
const beyond = CUE_OFFSET_PX - 1 + line;
const left = Math.floor((rect.left - page.left) * scale * d) / d - beyond;
const top = Math.floor((rect.top - page.top) * scale * d) / d - beyond;
const right = Math.ceil((rect.left + rect.width - page.left) * scale * d) / d + beyond;
const bottom = Math.ceil((rect.top + rect.height - page.top) * scale * d) / d + beyond;
el.style.left = `${left}px`;
el.style.top = `${top}px`;
el.style.width = `${right - left}px`;
el.style.height = `${bottom - top}px`;
}
/**
* Show the Arrange tool's cues on these rectangles, or none.
* @param {{rect: import('./page_model.js').Rectangle, kind: 'image' | 'text' | 'hide' | 'shape'}[]} cues
* Show the Arrange tool's cues on these rectangles, or none: a box and
* its four corners, or the box alone where `corners` is false, laid
* just outside the rectangle (placeOverlay).
* @param {{rect: import('./page_model.js').Rectangle, kind: 'image' | 'text' | 'hide' | 'shape' | 'stroke', corners: boolean}[]} cues
*/
setArrangeCues(cues) {
this.arrangeCues = cues;
this.cueElements.replaceChildren();
for (const cue of cues) {
const el = document.createElement('div');
el.className = `arrange_cue ${cue.kind}`;
el.className = `arrange_cue ${cue.kind}${cue.corners ? '' : ' no_corners'}`;
el.append(...cornerHandles());
this.cueElements.append(el);
}
this.layoutCues();
}
/**
* Show the eraser's cues around these rectangles, or none.
* @param {import('./page_model.js').Rectangle[]} rects
*/
setEraseCues(rects) {
this.eraseCues = rects;
this.eraseCueElements.replaceChildren();
for (const _ of rects) {
const el = document.createElement('div');
el.className = 'erase_cue';
this.eraseCueElements.append(el);
}
this.layoutCues();
}
layoutCues() {
const elements = this.cueElements.children;
this.arrangeCues.forEach((cue, i) => {
const el = /** @type {HTMLElement} */ (elements[i]);
if (el) this.placeOverlay(el, cue.rect);
});
const eraseElements = this.eraseCueElements.children;
this.eraseCues.forEach((rect, i) => {
const el = /** @type {HTMLElement} */ (eraseElements[i]);
if (el) this.placeOverlay(el, rect);
});
if (this.hoverRect) this.placeOverlay(this.hoverCue, this.hoverRect);
const page = this.page;
this.shapePreview.setAttribute('viewBox', `${page.left} ${page.top} ${page.width} ${page.height}`);
}
/**
* Mark the corners of this rectangle while the pointer hovers over it,
* or none.
* Mark this implied rectangle while the pointer hovers over it, or
* none (Andy, 2026-10-14): a text rectangle or a Hide by its outline,
* free text by its top and left edges only, so the two kinds of text
* are told apart.
* @param {import('./page_model.js').Rectangle | null} rect
* @param {'rect' | 'free' | 'hide'} [kind]
*/
showHoverCue(rect) {
showHoverCue(rect, kind = 'hide') {
this.hoverRect = rect;
this.hoverCue.hidden = !rect;
if (!rect) return;
this.hoverCue.className = `hover_cue ${kind}`;
// The line CUE_WIDTH wide at this zoom, at least a pixel; set before
// the placement, which reads the border's width.
this.hoverCue.style.setProperty('--mark_line', `${Math.max(1, Math.round(CUE_WIDTH * this.cssPixelsPerUnit))}px`);
this.placeOverlay(this.hoverCue, rect);
// The corner marks as drawCornerMarks draws them: CORNER_MARK long,
// shorter on a small block, CUE_WIDTH wide and at least a pixel.
const scale = this.cssPixelsPerUnit;
const mark = Math.min(CORNER_MARK, rect.width / 2, rect.height / 2) * scale;
this.hoverCue.style.setProperty('--mark', `${mark}px`);
this.hoverCue.style.setProperty('--mark_line', `${Math.max(1, Math.round(CUE_WIDTH * scale))}px`);
}
/**