920 lines
34 KiB
JavaScript
920 lines
34 KiB
JavaScript
// @ts-check
|
|
/**
|
|
* Draws the page. Each layer has its own canvas, so hiding a layer or
|
|
* redrawing it after an erasure does not touch the others.
|
|
*
|
|
* A stroke is drawn as a chain of parts, one per point (see
|
|
* stroke_geometry.js). The part for a point can be drawn only when the
|
|
* following point is known, so the newest end of a stroke in progress (its
|
|
* *tail*) is drawn on a separate canvas that is cleared and redrawn as the
|
|
* stroke grows.
|
|
*/
|
|
|
|
import { BLANK_PAGE_HEIGHT, BLANK_PAGE_WIDTH, POINT_SIZE, isClosed, pointCount } from './page_model.js';
|
|
import { verticesOf } from './shapes.js';
|
|
import { partAt, widthAt } from './stroke_geometry.js';
|
|
import { LEFT_GAP, LINE_HEIGHT, baselineOf, fontOf } from './text_layout.js';
|
|
|
|
/** @typedef {import('./page_model.js').Page} Page */
|
|
/** @typedef {import('./page_model.js').Stroke} Stroke */
|
|
/** @typedef {import('./page_model.js').TextBlock} TextBlock */
|
|
|
|
/**
|
|
* A rectangle in page units.
|
|
* @typedef {{x0: number, y0: number, x1: number, y1: number}} Region
|
|
*/
|
|
|
|
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
|
|
/**
|
|
* One click of the scroll wheel zooms by this factor (10%, Andy's choice
|
|
* to try, 2026-10-03).
|
|
*/
|
|
export const ZOOM_STEP = 1.1;
|
|
/**
|
|
* A pen drag with Ctrl held zooms by this factor for every 100 screen
|
|
* pixels of movement, continuously (Andy, 2026-10-08; a constant so that
|
|
* it can be tuned).
|
|
*/
|
|
export const ZOOM_PER_100_PIXELS = 2;
|
|
const LEAST_CSS_PIXELS_PER_UNIT = 0.05;
|
|
const MOST_CSS_PIXELS_PER_UNIT = 8;
|
|
|
|
/**
|
|
* How near a corner of an image a press must be, in screen pixels, to
|
|
* scale the image rather than move it (Andy, 2026-10-08; a constant to
|
|
* tune). In screen pixels so that the handle is the same size under
|
|
* the pen at any zoom.
|
|
*/
|
|
export const CORNER_REACH_PX = 24;
|
|
|
|
/**
|
|
* The *implied rectangles* (Andy, 2026-10-14), text blocks and Hides,
|
|
* 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 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.
|
|
* 'fit' asks for the scale at which the whole page fits the window, and
|
|
* is replaced by that number as soon as the page is shown: "Fit" is a
|
|
* command, not a mode (Andy, 2026-10-08), so a page that grows afterwards
|
|
* extends beyond the window instead of being refitted.
|
|
* @typedef {'fit' | number} Zoom
|
|
*/
|
|
|
|
/**
|
|
* Four handle marks, one for each corner of an overlay (the grip and the
|
|
* Arrange cues), placed by their class.
|
|
* @returns {HTMLElement[]}
|
|
*/
|
|
function cornerHandles() {
|
|
return ['nw', 'ne', 'sw', 'se'].map((corner) => {
|
|
const handle = document.createElement('span');
|
|
handle.className = `handle ${corner}`;
|
|
return handle;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Draw a text block's lines (nothing else: a block has no mark of its
|
|
* own on the page since 2026-10-14).
|
|
* @param {CanvasRenderingContext2D} context
|
|
* @param {TextBlock} block
|
|
*/
|
|
function drawText(context, block) {
|
|
context.font = fontOf(block.size, block.font);
|
|
context.fillStyle = block.color;
|
|
context.textBaseline = 'alphabetic';
|
|
// On whole device pixels, the lines a whole number of pixels apart, as
|
|
// the text field lays them out (TextEditor.layout): the same pixels in
|
|
// both, whatever the zoom (Andy, 2026-10-09).
|
|
const t = context.getTransform();
|
|
const x = Math.round(t.a * (block.x + block.size * LEFT_GAP) + t.e);
|
|
const first = Math.round(t.d * (block.y + baselineOf(block.size, block.font, 0)) + t.f);
|
|
const spacing = Math.round(t.d * block.size * LINE_HEIGHT);
|
|
block.lines.forEach((line, i) => {
|
|
context.fillText(line, (x - t.e) / t.a, (first + i * spacing - t.f) / t.d);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Fill the closed polygon of a stroke's points.
|
|
* @param {CanvasRenderingContext2D} context
|
|
* @param {Stroke} stroke
|
|
* @param {string} color
|
|
*/
|
|
function fillPolygon(context, stroke, color) {
|
|
const p = stroke.points;
|
|
context.beginPath();
|
|
for (let i = 0; i < p.length; i += POINT_SIZE) {
|
|
if (i === 0) context.moveTo(p[i], p[i + 1]);
|
|
else context.lineTo(p[i], p[i + 1]);
|
|
}
|
|
context.closePath();
|
|
context.fillStyle = color;
|
|
context.fill();
|
|
}
|
|
|
|
/**
|
|
* @param {Stroke} stroke
|
|
* @returns {boolean} Whether the stroke's polygon is filled with its
|
|
* fill colour beneath the line: a shape with a fill, a line only
|
|
* once closed.
|
|
*/
|
|
function hasFill(stroke) {
|
|
return stroke.fill !== null && stroke.done && (stroke.shape !== 'line' || isClosed(stroke));
|
|
}
|
|
|
|
/**
|
|
* Draw the part of a stroke that belongs to point `k`.
|
|
* @param {CanvasRenderingContext2D} context
|
|
* @param {Stroke} stroke
|
|
* @param {number} k
|
|
*/
|
|
function drawPart(context, stroke, k) {
|
|
const part = partAt(stroke, k);
|
|
context.beginPath();
|
|
if (stroke.filled) {
|
|
// The whole outline at once, filled: drawn when the stroke is complete.
|
|
fillPolygon(context, stroke, stroke.color);
|
|
return;
|
|
}
|
|
if (part.dot) {
|
|
context.arc(part.x, part.y, part.width / 2, 0, 2 * Math.PI);
|
|
context.fillStyle = stroke.color;
|
|
context.fill();
|
|
return;
|
|
}
|
|
context.moveTo(part.fromX, part.fromY);
|
|
context.quadraticCurveTo(part.x, part.y, part.toX, part.toY);
|
|
context.strokeStyle = stroke.color;
|
|
context.lineWidth = part.width;
|
|
context.stroke();
|
|
}
|
|
|
|
export class Renderer {
|
|
/**
|
|
* @param {HTMLElement} pageElement Element that holds the canvases.
|
|
* @param {Page} page
|
|
*/
|
|
constructor(pageElement, page) {
|
|
this.pageElement = pageElement;
|
|
this.page = page;
|
|
/** @type {Map<string, HTMLCanvasElement>} */
|
|
this.canvases = new Map();
|
|
/** @type {Set<string>} */
|
|
this.hidden = new Set();
|
|
/** @type {WeakMap<Stroke, number>} Parts of each stroke already drawn. */
|
|
this.drawn = new WeakMap();
|
|
/** @type {Map<Stroke, string>} Strokes in progress and their layers. */
|
|
this.active = new Map();
|
|
/** Region of the tail canvas in use, in page units, or null. */
|
|
this.tailRegion = /** @type {Region | null} */ (null);
|
|
|
|
this.tailCanvas = this.makeCanvas();
|
|
this.tailCanvas.classList.add('tails');
|
|
this.pageElement.append(this.tailCanvas);
|
|
/** The Hide rectangles, above all writing, with their blue corners. */
|
|
this.hideCanvas = this.makeCanvas();
|
|
this.hideCanvas.classList.add('hides');
|
|
this.pageElement.append(this.hideCanvas);
|
|
|
|
/**
|
|
* The elements that show the page's images beneath the layers, one for
|
|
* each image, in the images' sequence, and the bytes each one shows.
|
|
* @type {{element: HTMLImageElement, data: Uint8Array | null, hash: string}[]}
|
|
*/
|
|
this.imageElements = [];
|
|
/**
|
|
* The green cue around an image, text block or Hide being handled by
|
|
* hand: its outline and its four corners, the corner being dragged
|
|
* marked (all four since 2026-10-13, so that a hold shows where the
|
|
* corners are).
|
|
*/
|
|
this.grip = document.createElement('div');
|
|
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, corners: boolean} | null} */
|
|
this.gripShown = null;
|
|
/**
|
|
* 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);
|
|
/**
|
|
* The blue corners of the free text under a hovering pointer (Andy,
|
|
* 2026-10-14: free text is used constantly, so its extent shows only
|
|
* when the pointer enters it, where a text rectangle's corners always
|
|
* show).
|
|
* @type {import('./page_model.js').Rectangle | null}
|
|
*/
|
|
this.hoverRect = null;
|
|
this.hoverCue = document.createElement('div');
|
|
this.hoverCue.className = 'hover_cue';
|
|
this.hoverCue.hidden = true;
|
|
this.pageElement.append(this.hoverCue);
|
|
/**
|
|
* A shape being dragged open or a line's rubber band, in the pen's
|
|
* colour and width, as an SVG overlay in page units; what the Shape
|
|
* tool shows before the stroke is made (2026-10-14).
|
|
*/
|
|
this.shapePreview = document.createElementNS(SVG_NS, 'svg');
|
|
this.shapePreview.setAttribute('class', 'shape_preview');
|
|
this.shapePreviewPath = document.createElementNS(SVG_NS, 'path');
|
|
this.shapePreview.append(this.shapePreviewPath);
|
|
this.shapePreview.setAttribute('hidden', '');
|
|
this.pageElement.append(this.shapePreview);
|
|
|
|
/** Canvas pixels per page unit. */
|
|
this.scale = 1;
|
|
/** The page's left and top as last shown, for keeping the view still. */
|
|
this.shownLeft = /** @type {number | null} */ (null);
|
|
this.shownTop = /** @type {number | null} */ (null);
|
|
/** @type {Zoom} */
|
|
this.zoom = 'fit';
|
|
/** CSS pixels per page unit as last shown. */
|
|
this.cssPixelsPerUnit = 1;
|
|
/** @type {(() => void) | null} Told when the zoom changes. */
|
|
this.onZoomChanged = null;
|
|
/** A zoom by pen drag in progress: the scale it started from and its anchor. */
|
|
this.zoomDrag = /** @type {{zoom: number, clientX: number, clientY: number} | null} */ (null);
|
|
/**
|
|
* True until a page that its images have made larger than a sheet has
|
|
* been shown for the first time. Such a page is a series of exercises
|
|
* to scroll through (Andy, 2026-10-05), so it is first shown at the
|
|
* scale at which one sheet fits the window, not all of it made small.
|
|
* (A reloaded tab shows it as it was last seen there: see showView.)
|
|
*/
|
|
this.sheetFirst = false;
|
|
/**
|
|
* The text block being edited, if any, whose lines are shown by the
|
|
* editor's own text field and so not drawn here (its marks are).
|
|
* @type {string | null}
|
|
*/
|
|
this.editingText = null;
|
|
this.setPage(page);
|
|
|
|
// Ctrl (Command on a Mac) and the scroll wheel zoom about the
|
|
// pointer, as elsewhere.
|
|
this.desk().addEventListener('wheel', (event) => {
|
|
if (!(event.ctrlKey || event.metaKey)) return;
|
|
event.preventDefault();
|
|
this.zoomBy(event.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP, event.clientX, event.clientY);
|
|
}, { passive: false });
|
|
}
|
|
|
|
/** @returns {HTMLElement} The scrolling container of the page. */
|
|
desk() {
|
|
return /** @type {HTMLElement} */ (this.pageElement.parentElement);
|
|
}
|
|
|
|
/**
|
|
* The room the page can be shown in, in CSS pixels.
|
|
* @returns {{width: number, height: number}}
|
|
*/
|
|
available() {
|
|
const desk = this.desk();
|
|
const style = getComputedStyle(desk);
|
|
// offsetWidth, not clientWidth: a fitted page needs no scrollbars, so
|
|
// the room they take while zoomed in must not count.
|
|
return {
|
|
width: desk.offsetWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight),
|
|
height: desk.offsetHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Show the page at the scale a new page has: that at which one A4
|
|
* sheet fits the window ("A4", Andy, 2026-10-08, so that a shrunk page
|
|
* is seen at its size on the sheet rather than blown up by "Fit").
|
|
*/
|
|
sheetScale() {
|
|
const { width, height } = this.available();
|
|
if (!(width > 0) || !(height > 0)) return;
|
|
this.setZoom(1 / Math.max(BLANK_PAGE_WIDTH / width, BLANK_PAGE_HEIGHT / height));
|
|
}
|
|
|
|
/** Show the whole page in the window. */
|
|
fit() {
|
|
this.setZoom('fit');
|
|
this.desk().scrollTop = 0;
|
|
this.desk().scrollLeft = 0;
|
|
}
|
|
|
|
/**
|
|
* A zoom by pen drag begins at this point, which stays under the pen.
|
|
* @param {number} clientX
|
|
* @param {number} clientY
|
|
*/
|
|
beginZoomDrag(clientX, clientY) {
|
|
this.zoomDrag = { zoom: this.cssPixelsPerUnit, clientX, clientY };
|
|
}
|
|
|
|
/**
|
|
* The pen of a zoom drag has moved this far up from where it began
|
|
* (negative: down). ZOOM_PER_100_PIXELS per 100 pixels, continuously.
|
|
* @param {number} pixelsUp
|
|
*/
|
|
zoomDragTo(pixelsUp) {
|
|
const drag = this.zoomDrag;
|
|
if (!drag) return;
|
|
this.setZoom(drag.zoom * ZOOM_PER_100_PIXELS ** (pixelsUp / 100), drag.clientX, drag.clientY);
|
|
}
|
|
|
|
endZoomDrag() {
|
|
this.zoomDrag = null;
|
|
}
|
|
|
|
/**
|
|
* Zoom in or out by a factor, from whatever the zoom is now.
|
|
* @param {number} factor
|
|
* @param {number} [clientX] The point to zoom about; else the middle.
|
|
* @param {number} [clientY]
|
|
*/
|
|
zoomBy(factor, clientX, clientY) {
|
|
this.setZoom(this.cssPixelsPerUnit * factor, clientX, clientY);
|
|
}
|
|
|
|
/**
|
|
* Change the zoom. With a position given, the page point under it stays
|
|
* under it; otherwise the point at the middle of the view stays.
|
|
* @param {Zoom} zoom
|
|
* @param {number} [clientX]
|
|
* @param {number} [clientY]
|
|
*/
|
|
setZoom(zoom, clientX, clientY) {
|
|
if (typeof zoom === 'number') {
|
|
zoom = Math.max(LEAST_CSS_PIXELS_PER_UNIT, Math.min(MOST_CSS_PIXELS_PER_UNIT, zoom));
|
|
}
|
|
const desk = this.desk();
|
|
const deskBox = desk.getBoundingClientRect();
|
|
const anchorX = clientX ?? deskBox.left + desk.clientWidth / 2;
|
|
const anchorY = clientY ?? deskBox.top + desk.clientHeight / 2;
|
|
const [pageX, pageY] = this.pageFromClient(anchorX, anchorY);
|
|
|
|
this.zoom = zoom;
|
|
this.sheetFirst = false;
|
|
this.resize();
|
|
|
|
// Scroll so that the anchored page point is back under the anchor.
|
|
const box = this.pageElement.getBoundingClientRect();
|
|
const nowX = box.left + (pageX - this.page.left) * this.cssPixelsPerUnit;
|
|
const nowY = box.top + (pageY - this.page.top) * this.cssPixelsPerUnit;
|
|
desk.scrollLeft += nowX - anchorX;
|
|
desk.scrollTop += nowY - anchorY;
|
|
this.onZoomChanged?.();
|
|
}
|
|
|
|
/**
|
|
* Show another page in place of the present one.
|
|
* @param {Page} page
|
|
*/
|
|
setPage(page) {
|
|
this.page = page;
|
|
this.shownLeft = null;
|
|
this.shownTop = null;
|
|
this.zoom = 'fit';
|
|
this.sheetFirst = page.isLargerThanSheet();
|
|
this.onZoomChanged?.();
|
|
// A new page is seen from its top.
|
|
this.desk().scrollTop = 0;
|
|
this.desk().scrollLeft = 0;
|
|
for (const canvas of this.canvases.values()) canvas.remove();
|
|
this.canvases.clear();
|
|
this.hidden.clear();
|
|
this.active.clear();
|
|
this.gripShown = null;
|
|
this.showImages();
|
|
this.resize();
|
|
// Once more after the browser has laid the page out, in case anything
|
|
// above (a dialog closing, say) left the measurements stale. Andy saw
|
|
// a loaded page's image appear only after pressing Fit (2026-10-03),
|
|
// which could not be reproduced.
|
|
requestAnimationFrame(() => {
|
|
if (this.page === page) this.resize();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* How the page is being shown, so that it can be shown the same way
|
|
* after the browser page is reloaded.
|
|
* @returns {{zoom: Zoom, x: number, y: number}} The zoom, and the page
|
|
* point at the top left corner of the view.
|
|
*/
|
|
view() {
|
|
const desk = this.desk();
|
|
return {
|
|
zoom: this.zoom,
|
|
x: this.page.left + desk.scrollLeft / this.cssPixelsPerUnit,
|
|
y: this.page.top + desk.scrollTop / this.cssPixelsPerUnit,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Show the page as view() described it.
|
|
* @param {{zoom: Zoom, x: number, y: number}} view
|
|
*/
|
|
showView(view) {
|
|
this.zoom = view.zoom === 'fit' ? 'fit'
|
|
: Math.max(LEAST_CSS_PIXELS_PER_UNIT, Math.min(MOST_CSS_PIXELS_PER_UNIT, view.zoom));
|
|
this.sheetFirst = false;
|
|
this.resize();
|
|
const desk = this.desk();
|
|
desk.scrollLeft = (view.x - this.page.left) * this.cssPixelsPerUnit;
|
|
desk.scrollTop = (view.y - this.page.top) * this.cssPixelsPerUnit;
|
|
this.onZoomChanged?.();
|
|
}
|
|
|
|
/** Place the image elements where the images lie on the page. */
|
|
layoutImages() {
|
|
const page = this.page;
|
|
for (const [i, image] of page.images.entries()) {
|
|
const style = this.imageElements[i]?.element.style;
|
|
if (!style) continue;
|
|
style.left = `${(image.x - page.left) / page.width * 100}%`;
|
|
style.top = `${(image.y - page.top) / page.height * 100}%`;
|
|
style.width = `${image.width / page.width * 100}%`;
|
|
style.height = `${image.height / page.height * 100}%`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show the page's images whose bytes are here, in their sequence (the
|
|
* last in front). An element that already shows an image keeps it when
|
|
* the images are reordered, so that nothing flickers.
|
|
*/
|
|
showImages() {
|
|
const images = this.page.images;
|
|
const spare = this.imageElements.slice();
|
|
/** @type {typeof this.imageElements} */
|
|
const shownNow = [];
|
|
for (const image of images) {
|
|
const at = spare.findIndex((one) => one.hash === image.hash);
|
|
let shown = at >= 0 ? spare.splice(at, 1)[0] : null;
|
|
if (!shown) {
|
|
const element = document.createElement('img');
|
|
element.className = 'page_image';
|
|
element.alt = '';
|
|
element.draggable = false;
|
|
element.hidden = true;
|
|
shown = { element, data: null, hash: image.hash };
|
|
}
|
|
if (shown.data !== image.data) {
|
|
if (shown.element.src) URL.revokeObjectURL(shown.element.src);
|
|
shown.data = image.data;
|
|
shown.element.hidden = !image.data;
|
|
if (image.data) {
|
|
shown.element.src = URL.createObjectURL(new Blob([image.data], { type: image.type }));
|
|
} else {
|
|
shown.element.removeAttribute('src');
|
|
}
|
|
}
|
|
shownNow.push(shown);
|
|
}
|
|
for (const gone of spare) {
|
|
if (gone.element.src) URL.revokeObjectURL(gone.element.src);
|
|
gone.element.remove();
|
|
}
|
|
// Beneath the layers, in the sequence of the images.
|
|
const firstLayer = this.pageElement.querySelector('canvas');
|
|
for (const shown of shownNow) this.pageElement.insertBefore(shown.element, firstLayer);
|
|
this.imageElements = shownNow;
|
|
this.layoutImages();
|
|
}
|
|
|
|
/**
|
|
* 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, corners = true) {
|
|
this.gripShown = rect ? { rect, corner, corners } : null;
|
|
this.layoutGrip();
|
|
}
|
|
|
|
layoutGrip() {
|
|
const shown = this.gripShown;
|
|
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%');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
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: 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}${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 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, 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);
|
|
}
|
|
|
|
/**
|
|
* Show a shape that is being made, from its stroke points, as it will
|
|
* be drawn: a line of the width, or a filled polygon.
|
|
* @param {number[]} points
|
|
* @param {string} color
|
|
* @param {number} width Page units.
|
|
* @param {boolean} filled
|
|
*/
|
|
showShapePreview(points, color, width, filled) {
|
|
const vertices = verticesOf(points);
|
|
const path = this.shapePreviewPath;
|
|
path.setAttribute('d', vertices.map(([x, y], i) => `${i ? 'L' : 'M'}${x} ${y}`).join('') + (filled ? 'Z' : ''));
|
|
path.setAttribute('fill', filled ? color : 'none');
|
|
path.setAttribute('stroke', filled ? 'none' : color);
|
|
path.setAttribute('stroke-width', String(width));
|
|
this.shapePreview.removeAttribute('hidden');
|
|
}
|
|
|
|
hideShapePreview() {
|
|
this.shapePreview.setAttribute('hidden', '');
|
|
}
|
|
|
|
/** @returns {number} CORNER_REACH_PX in page units at the present zoom. */
|
|
cornerReach() {
|
|
return CORNER_REACH_PX / this.cssPixelsPerUnit;
|
|
}
|
|
|
|
makeCanvas() {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.className = 'layer';
|
|
return canvas;
|
|
}
|
|
|
|
/**
|
|
* @param {HTMLCanvasElement} canvas
|
|
* @returns {CanvasRenderingContext2D}
|
|
*/
|
|
context(canvas) {
|
|
const context = /** @type {CanvasRenderingContext2D} */ (canvas.getContext('2d'));
|
|
context.setTransform(this.scale, 0, 0, this.scale,
|
|
-this.page.left * this.scale, -this.page.top * this.scale);
|
|
context.lineCap = 'round';
|
|
context.lineJoin = 'round';
|
|
return context;
|
|
}
|
|
|
|
/** @param {HTMLCanvasElement} canvas */
|
|
sizeCanvas(canvas) {
|
|
canvas.width = Math.round(this.page.width * this.scale);
|
|
canvas.height = Math.round(this.page.height * this.scale);
|
|
}
|
|
|
|
/**
|
|
* Match the canvases to the page's rectangle and its displayed size, and
|
|
* redraw. Called when the window changes, when the page grows and when
|
|
* the zoom changes.
|
|
*
|
|
* A zoom of 'fit' (a page shown for the first time, or "Fit" pressed)
|
|
* becomes the scale at which the whole page fits the window, and stays
|
|
* a number from then on: growth extends the page beyond the window.
|
|
*/
|
|
resize() {
|
|
const desk = this.desk();
|
|
const { width: availableWidth, height: availableHeight } = this.available();
|
|
if (!(availableWidth > 0) || !(availableHeight > 0)) return;
|
|
const first = this.zoom === 'fit' && this.sheetFirst;
|
|
this.sheetFirst = false;
|
|
if (first) {
|
|
this.zoom = Math.max(LEAST_CSS_PIXELS_PER_UNIT, Math.min(MOST_CSS_PIXELS_PER_UNIT,
|
|
1 / Math.max(BLANK_PAGE_WIDTH / availableWidth, BLANK_PAGE_HEIGHT / availableHeight)));
|
|
}
|
|
const fitted = this.zoom === 'fit';
|
|
const unitsPerCssPixel = this.zoom === 'fit'
|
|
? Math.max(this.page.width / availableWidth, this.page.height / availableHeight)
|
|
: 1 / this.zoom;
|
|
this.cssPixelsPerUnit = 1 / unitsPerCssPixel;
|
|
if (fitted) this.zoom = this.cssPixelsPerUnit;
|
|
this.pageElement.style.width = `${this.page.width / unitsPerCssPixel}px`;
|
|
this.pageElement.style.height = `${this.page.height / unitsPerCssPixel}px`;
|
|
this.layoutImages();
|
|
this.layoutGrip();
|
|
this.layoutCues();
|
|
// When the page grew to the left or upward, keep what is in view in view.
|
|
if (this.shownLeft !== null && this.page.left < this.shownLeft) {
|
|
desk.scrollLeft += (this.shownLeft - this.page.left) / unitsPerCssPixel;
|
|
}
|
|
if (this.shownTop !== null && this.page.top < this.shownTop) {
|
|
desk.scrollTop += (this.shownTop - this.page.top) / unitsPerCssPixel;
|
|
}
|
|
this.shownLeft = this.page.left;
|
|
this.shownTop = this.page.top;
|
|
|
|
this.scale = (window.devicePixelRatio || 1) / unitsPerCssPixel;
|
|
this.sizeCanvas(this.tailCanvas);
|
|
this.sizeCanvas(this.hideCanvas);
|
|
this.tailRegion = null;
|
|
for (const id of this.canvases.keys()) this.redrawLayer(id, true);
|
|
this.drawHides();
|
|
this.drawTails();
|
|
if (first || fitted) this.onZoomChanged?.();
|
|
}
|
|
|
|
/**
|
|
* Put the layer canvases in the given sequence, bottom to top, creating
|
|
* canvases for new layers.
|
|
* @param {string[]} ids
|
|
*/
|
|
setLayerOrder(ids) {
|
|
for (const id of ids) {
|
|
let canvas = this.canvases.get(id);
|
|
if (!canvas) {
|
|
canvas = this.makeCanvas();
|
|
this.sizeCanvas(canvas);
|
|
this.canvases.set(id, canvas);
|
|
}
|
|
this.pageElement.insertBefore(canvas, this.tailCanvas);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string} id
|
|
* @param {boolean} visible
|
|
*/
|
|
setLayerVisible(id, visible) {
|
|
if (visible) this.hidden.delete(id);
|
|
else this.hidden.add(id);
|
|
const canvas = this.canvases.get(id);
|
|
if (canvas) canvas.hidden = !visible;
|
|
this.drawTails();
|
|
}
|
|
|
|
/**
|
|
* Draw a layer again from the page.
|
|
* @param {string} id
|
|
* @param {boolean} [resized] The canvas size must change as well.
|
|
*/
|
|
redrawLayer(id, resized = false) {
|
|
const canvas = this.canvases.get(id);
|
|
const layer = this.page.layers.get(id);
|
|
if (!canvas) return;
|
|
if (resized) this.sizeCanvas(canvas);
|
|
const context = this.context(canvas);
|
|
context.clearRect(this.page.left, this.page.top, this.page.width, this.page.height);
|
|
for (const [stroke, layerId] of this.active) {
|
|
if (layerId === id) this.active.delete(stroke);
|
|
}
|
|
if (!layer) return;
|
|
for (const stroke of layer.strokes.values()) {
|
|
this.drawn.set(stroke, 0);
|
|
this.drawNewParts(context, id, stroke);
|
|
}
|
|
for (const block of layer.texts.values()) {
|
|
if (block.id !== this.editingText) drawText(context, block);
|
|
}
|
|
this.drawTails();
|
|
}
|
|
|
|
/**
|
|
* Draw the Hide rectangles, white, above everything (with blue corners
|
|
* until 2026-10-14; the hover cue shows them now).
|
|
*/
|
|
drawHides() {
|
|
const context = this.context(this.hideCanvas);
|
|
context.clearRect(this.page.left, this.page.top, this.page.width, this.page.height);
|
|
context.fillStyle = '#ffffff';
|
|
for (const hide of this.page.hides) context.fillRect(hide.x, hide.y, hide.width, hide.height);
|
|
}
|
|
|
|
/**
|
|
* A stroke gained points or was finished: draw what is new.
|
|
* @param {string} layerId
|
|
* @param {Stroke} stroke
|
|
*/
|
|
strokeProgress(layerId, stroke) {
|
|
const canvas = this.canvases.get(layerId);
|
|
if (!canvas) return;
|
|
// A line closed by its last point gets its fill, which lies beneath
|
|
// the parts already drawn: the layer is drawn afresh.
|
|
if (hasFill(stroke) && (this.drawn.get(stroke) ?? 0) > 0) {
|
|
this.redrawLayer(layerId);
|
|
return;
|
|
}
|
|
this.drawNewParts(this.context(canvas), layerId, stroke);
|
|
this.drawTails();
|
|
}
|
|
|
|
/**
|
|
* @param {CanvasRenderingContext2D} context
|
|
* @param {string} layerId
|
|
* @param {Stroke} stroke
|
|
*/
|
|
drawNewParts(context, layerId, stroke) {
|
|
const count = pointCount(stroke);
|
|
if (stroke.filled) {
|
|
// A filled shape is drawn whole, once its points are all here; it
|
|
// has no tail to show meanwhile.
|
|
if (stroke.done && count > 0 && (this.drawn.get(stroke) ?? 0) < count) drawPart(context, stroke, 0);
|
|
this.drawn.set(stroke, stroke.done ? count : 0);
|
|
this.active.delete(stroke);
|
|
return;
|
|
}
|
|
// The part for the newest point waits until the stroke is finished or
|
|
// another point arrives.
|
|
const ready = stroke.done ? count : count - 1;
|
|
let k = this.drawn.get(stroke) ?? 0;
|
|
if (k === 0 && hasFill(stroke) && stroke.fill) fillPolygon(context, stroke, stroke.fill);
|
|
for (; k < ready; k++) drawPart(context, stroke, k);
|
|
this.drawn.set(stroke, Math.max(k, 0));
|
|
if (stroke.done) this.active.delete(stroke);
|
|
else this.active.set(stroke, layerId);
|
|
}
|
|
|
|
/** Redraw the tails of the strokes in progress. */
|
|
drawTails() {
|
|
const context = this.context(this.tailCanvas);
|
|
if (this.tailRegion) {
|
|
const r = this.tailRegion;
|
|
context.clearRect(r.x0, r.y0, r.x1 - r.x0, r.y1 - r.y0);
|
|
this.tailRegion = null;
|
|
}
|
|
for (const [stroke, layerId] of this.active) {
|
|
const count = pointCount(stroke);
|
|
if (count === 0 || this.hidden.has(layerId)) continue;
|
|
const p = stroke.points;
|
|
const end = (count - 1) * POINT_SIZE;
|
|
const width = widthAt(stroke, count - 1);
|
|
context.beginPath();
|
|
let startX = p[end];
|
|
let startY = p[end + 1];
|
|
if (count === 1) {
|
|
context.arc(p[end], p[end + 1], width / 2, 0, 2 * Math.PI);
|
|
context.fillStyle = stroke.color;
|
|
context.fill();
|
|
} else {
|
|
startX = (p[end - POINT_SIZE] + p[end]) / 2;
|
|
startY = (p[end - POINT_SIZE + 1] + p[end + 1]) / 2;
|
|
context.moveTo(startX, startY);
|
|
context.lineTo(p[end], p[end + 1]);
|
|
context.strokeStyle = stroke.color;
|
|
context.lineWidth = width;
|
|
context.stroke();
|
|
}
|
|
const margin = width + 2 / this.scale;
|
|
/** @type {Region} */
|
|
const r = this.tailRegion ?? { x0: Infinity, y0: Infinity, x1: -Infinity, y1: -Infinity };
|
|
r.x0 = Math.min(r.x0, startX - margin, p[end] - margin);
|
|
r.y0 = Math.min(r.y0, startY - margin, p[end + 1] - margin);
|
|
r.x1 = Math.max(r.x1, startX + margin, p[end] + margin);
|
|
r.y1 = Math.max(r.y1, startY + margin, p[end + 1] + margin);
|
|
this.tailRegion = r;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert a position in the window to page units.
|
|
* @param {number} clientX
|
|
* @param {number} clientY
|
|
* @returns {[number, number]}
|
|
*/
|
|
pageFromClient(clientX, clientY) {
|
|
const box = this.pageElement.getBoundingClientRect();
|
|
return [
|
|
this.page.left + (clientX - box.left) * this.page.width / box.width,
|
|
this.page.top + (clientY - box.top) * this.page.height / box.height,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param {number} cssPixels
|
|
* @returns {number} The same length in page units.
|
|
*/
|
|
pageLength(cssPixels) {
|
|
return cssPixels * this.page.width / this.pageElement.getBoundingClientRect().width;
|
|
}
|
|
}
|