The Shape tool, implied rectangles, and the palette floating again
A Shapes button right of Text draws a rectangle, an ellipse, a line of taps or an arrow in the pen's colour and width, with a fill colour of its own; a hold, a secondary press or two quick clicks open its menu. Text and Hide rectangles draw nothing on the page and show blue corners while the pointer hovers over them; free text's red corner mark is gone. Arrange moves and resizes shapes. The colour palette floats below "+" again instead of reflowing the toolbar. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
46a82bf3b7
commit
03907da8c9
12 files changed
+1502
-297
No files matched your search
+140
-120
@@ -10,8 +10,8 @@
|
||||
* stroke grows.
|
||||
*/
|
||||
|
||||
import { BLANK_PAGE_HEIGHT, BLANK_PAGE_WIDTH, POINT_SIZE, pointCount, textBounds } from './page_model.js';
|
||||
import { TEXT_FRAME_COLOR, TEXT_FRAME_WIDTH, TEXT_MARK } from './page_file.js';
|
||||
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';
|
||||
|
||||
@@ -24,6 +24,8 @@ import { LEFT_GAP, LINE_HEIGHT, baselineOf, fontOf } from './text_layout.js';
|
||||
* @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).
|
||||
@@ -47,18 +49,19 @@ const MOST_CSS_PIXELS_PER_UNIT = 8;
|
||||
export const CORNER_REACH_PX = 24;
|
||||
|
||||
/**
|
||||
* How a text rectangle's frame is shown on screen: the whole rectangle,
|
||||
* as Andy's teacher is used to, or only its corners, which is quieter
|
||||
* (Andy, 2026-10-08: a constant, to try both). The page file and the
|
||||
* export always hold the whole rectangle.
|
||||
* @type {'rectangle' | 'corners'}
|
||||
* 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.)
|
||||
*/
|
||||
export const TEXT_FRAME_STYLE = 'rectangle';
|
||||
/** The length of a corner mark, in page units, when only corners are shown. */
|
||||
const CORNER_MARK = 60;
|
||||
/** Hide rectangles are marked by blue corners on screen, for everyone. */
|
||||
const HIDE_CORNER_COLOR = '#1e88e5';
|
||||
const HIDE_CORNER_WIDTH = 2;
|
||||
export const CUE_COLOR = '#1e88e5';
|
||||
const CUE_WIDTH = 2;
|
||||
|
||||
/**
|
||||
* How the page is scaled on screen: a number of CSS pixels per page unit.
|
||||
@@ -69,44 +72,6 @@ const HIDE_CORNER_WIDTH = 2;
|
||||
* @typedef {'fit' | number} Zoom
|
||||
*/
|
||||
|
||||
/**
|
||||
* Stroke lines on the device pixel grid, so that they are as crisp as a
|
||||
* CSS border and the same width: a line of `width` page units becomes a
|
||||
* whole number of pixels, at least one, and its edges land on pixels.
|
||||
* The lines are given in page units and drawn through the context's
|
||||
* transform, which maps page units to device pixels.
|
||||
* @param {CanvasRenderingContext2D} context
|
||||
* @param {[number, number][][]} polylines
|
||||
* @param {string} color
|
||||
* @param {number} width
|
||||
*/
|
||||
function strokeCrisp(context, polylines, color, width) {
|
||||
const t = context.getTransform();
|
||||
const pixels = Math.max(1, Math.round(width * t.a));
|
||||
/** @param {number} v */
|
||||
const snap = (v) => Math.round(v);
|
||||
context.save();
|
||||
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||
context.strokeStyle = color;
|
||||
context.lineWidth = pixels;
|
||||
context.lineCap = 'butt';
|
||||
context.lineJoin = 'miter';
|
||||
for (const line of polylines) {
|
||||
context.beginPath();
|
||||
line.forEach(([x, y], i) => {
|
||||
// A line's centre at half a pixel in: its edges on the grid.
|
||||
const dx = snap(t.a * x + t.e);
|
||||
const dy = snap(t.d * y + t.f);
|
||||
const px = dx + (pixels % 2 ? 0.5 : 0);
|
||||
const py = dy + (pixels % 2 ? 0.5 : 0);
|
||||
if (i === 0) context.moveTo(px, py);
|
||||
else context.lineTo(px, py);
|
||||
});
|
||||
context.stroke();
|
||||
}
|
||||
context.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Four handle marks, one for each corner of an overlay (the grip and the
|
||||
* Arrange cues), placed by their class.
|
||||
@@ -121,45 +86,12 @@ function cornerHandles() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the corners of a rectangle as short L-shaped marks.
|
||||
* @param {CanvasRenderingContext2D} context
|
||||
* @param {import('./page_model.js').Rectangle} r
|
||||
* @param {string} color
|
||||
* @param {number} width
|
||||
*/
|
||||
function drawCornerMarks(context, r, color, width) {
|
||||
const mark = Math.min(CORNER_MARK, r.width / 2, r.height / 2);
|
||||
/** @type {[number, number][][]} */
|
||||
const lines = [];
|
||||
for (const [cx, cy] of [[0, 0], [1, 0], [0, 1], [1, 1]]) {
|
||||
const x = r.left + cx * r.width;
|
||||
const y = r.top + cy * r.height;
|
||||
lines.push([[x, y + (cy ? -mark : mark)], [x, y], [x + (cx ? -mark : mark), y]]);
|
||||
}
|
||||
strokeCrisp(context, lines, color, width);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a text block: a text rectangle's red frame, then its lines.
|
||||
* 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) {
|
||||
if (block.height === null) {
|
||||
// Free text: an L at its top-left corner.
|
||||
strokeCrisp(context, [[[block.x, block.y + TEXT_MARK], [block.x, block.y], [block.x + TEXT_MARK, block.y]]],
|
||||
TEXT_FRAME_COLOR, TEXT_FRAME_WIDTH);
|
||||
} else {
|
||||
const f = textBounds(block);
|
||||
if (TEXT_FRAME_STYLE === 'rectangle') {
|
||||
const r = f.left + f.width;
|
||||
const b = f.top + f.height;
|
||||
strokeCrisp(context, [[[f.left, f.top], [r, f.top], [r, b], [f.left, b], [f.left, f.top]]],
|
||||
TEXT_FRAME_COLOR, TEXT_FRAME_WIDTH);
|
||||
} else {
|
||||
drawCornerMarks(context, f, TEXT_FRAME_COLOR, TEXT_FRAME_WIDTH);
|
||||
}
|
||||
}
|
||||
context.font = fontOf(block.size, block.font);
|
||||
context.fillStyle = block.color;
|
||||
context.textBaseline = 'alphabetic';
|
||||
@@ -175,6 +107,34 @@ function drawText(context, block) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -184,6 +144,11 @@ function drawText(context, block) {
|
||||
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;
|
||||
@@ -216,10 +181,6 @@ export class Renderer {
|
||||
/** Region of the tail canvas in use, in page units, or null. */
|
||||
this.tailRegion = /** @type {Region | null} */ (null);
|
||||
|
||||
/** The white grounds of text rectangles: above the images, beneath every layer. */
|
||||
this.groundCanvas = this.makeCanvas();
|
||||
this.groundCanvas.classList.add('grounds');
|
||||
this.pageElement.append(this.groundCanvas);
|
||||
this.tailCanvas = this.makeCanvas();
|
||||
this.tailCanvas.classList.add('tails');
|
||||
this.pageElement.append(this.tailCanvas);
|
||||
@@ -252,12 +213,36 @@ export class Renderer {
|
||||
* 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'}[]}
|
||||
* @type {{rect: import('./page_model.js').Rectangle, kind: 'image' | 'text' | 'hide' | 'shape'}[]}
|
||||
*/
|
||||
this.arrangeCues = [];
|
||||
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.hoverCue.append(...cornerHandles());
|
||||
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;
|
||||
@@ -281,8 +266,8 @@ export class Renderer {
|
||||
*/
|
||||
this.sheetFirst = false;
|
||||
/**
|
||||
* The text block being edited, if any, which is shown by the editor's
|
||||
* own text field and so not drawn here.
|
||||
* 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;
|
||||
@@ -555,7 +540,7 @@ export class Renderer {
|
||||
|
||||
/**
|
||||
* Show the Arrange tool's cues on these rectangles, or none.
|
||||
* @param {{rect: import('./page_model.js').Rectangle, kind: 'image' | 'text' | 'hide'}[]} cues
|
||||
* @param {{rect: import('./page_model.js').Rectangle, kind: 'image' | 'text' | 'hide' | 'shape'}[]} cues
|
||||
*/
|
||||
setArrangeCues(cues) {
|
||||
this.arrangeCues = cues;
|
||||
@@ -575,6 +560,49 @@ export class Renderer {
|
||||
const el = /** @type {HTMLElement} */ (elements[i]);
|
||||
if (el) this.placeOverlay(el, cue.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.
|
||||
* @param {import('./page_model.js').Rectangle | null} rect
|
||||
*/
|
||||
showHoverCue(rect) {
|
||||
this.hoverRect = rect;
|
||||
this.hoverCue.hidden = !rect;
|
||||
if (!rect) return;
|
||||
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`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. */
|
||||
@@ -649,11 +677,9 @@ export class Renderer {
|
||||
|
||||
this.scale = (window.devicePixelRatio || 1) / unitsPerCssPixel;
|
||||
this.sizeCanvas(this.tailCanvas);
|
||||
this.sizeCanvas(this.groundCanvas);
|
||||
this.sizeCanvas(this.hideCanvas);
|
||||
this.tailRegion = null;
|
||||
for (const id of this.canvases.keys()) this.redrawLayer(id, true);
|
||||
this.drawGrounds();
|
||||
this.drawHides();
|
||||
this.drawTails();
|
||||
if (first || fitted) this.onZoomChanged?.();
|
||||
@@ -685,7 +711,6 @@ export class Renderer {
|
||||
else this.hidden.add(id);
|
||||
const canvas = this.canvases.get(id);
|
||||
if (canvas) canvas.hidden = !visible;
|
||||
this.drawGrounds();
|
||||
this.drawTails();
|
||||
}
|
||||
|
||||
@@ -712,38 +737,18 @@ export class Renderer {
|
||||
for (const block of layer.texts.values()) {
|
||||
if (block.id !== this.editingText) drawText(context, block);
|
||||
}
|
||||
this.drawGrounds();
|
||||
this.drawTails();
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the white grounds of every text rectangle of every layer shown
|
||||
* here, beneath the layers: they hide the image, never anyone's writing.
|
||||
* Draw the Hide rectangles, white, above everything (with blue corners
|
||||
* until 2026-10-14; the hover cue shows them now).
|
||||
*/
|
||||
drawGrounds() {
|
||||
const context = this.context(this.groundCanvas);
|
||||
context.clearRect(this.page.left, this.page.top, this.page.width, this.page.height);
|
||||
context.fillStyle = '#ffffff';
|
||||
for (const [id, layer] of this.page.layers) {
|
||||
if (this.hidden.has(id)) continue;
|
||||
for (const block of layer.texts.values()) {
|
||||
if (block.height === null || block.id === this.editingText) continue;
|
||||
const b = textBounds(block);
|
||||
context.fillRect(b.left, b.top, b.width, b.height);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Draw the Hide rectangles, white with blue corners, above everything. */
|
||||
drawHides() {
|
||||
const context = this.context(this.hideCanvas);
|
||||
context.clearRect(this.page.left, this.page.top, this.page.width, this.page.height);
|
||||
for (const hide of this.page.hides) {
|
||||
context.fillStyle = '#ffffff';
|
||||
context.fillRect(hide.x, hide.y, hide.width, hide.height);
|
||||
drawCornerMarks(context, { left: hide.x, top: hide.y, width: hide.width, height: hide.height },
|
||||
HIDE_CORNER_COLOR, HIDE_CORNER_WIDTH);
|
||||
}
|
||||
context.fillStyle = '#ffffff';
|
||||
for (const hide of this.page.hides) context.fillRect(hide.x, hide.y, hide.width, hide.height);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -754,6 +759,12 @@ export class Renderer {
|
||||
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();
|
||||
}
|
||||
@@ -765,10 +776,19 @@ export class Renderer {
|
||||
*/
|
||||
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);
|
||||
|
||||
Reference in new issue
Block a user