Select does what selecting does in every editor people already know. Double-click bare page and a caret opens there; double-click the document's own words and they open for replacement; what is selected copies with Ctrl+C, pastes with Ctrl+V and goes with Del. Nothing was taken away — a drag on empty page still gathers an area, and a drag that starts on words still selects words to copy. Type text and Edit text were two buttons for one question the click already answers. They are one Text tool: land on the document's own words and it offers to replace them, land anywhere else and it starts new text. The words light up under the cursor so which is which is visible before clicking, not after. The toolbar says what it is for. Select, Text and Sign are labelled and set apart; the drawing kit and the markup kit are their own groups. Sign gets a pen icon over a signature rather than a squiggle that could have been anything. Signatures take ink — black, blue, red, green — chosen while drawing and kept with the signature, because people sign in a particular colour and it belongs to the signature, not to whichever swatch was armed. And they turn: a grip above the box, free rotation, Shift to snap to 15°, for the signing line that is not square to the page. Two faults the tests found, both invisible by eye: The rotate grip was drawn in the right place and could not be grabbed — the selection bar floats directly above a mark, which is exactly where the grip sits, and it swallowed every click. The bar now stands clear of it. Undo would not undo a first rotation. Restoring a mark with Object.assign copies the keys the original HAD, so a property the drag introduced survived the restore; the journal then recorded the rotated state as the state to go back to. Restoring now forgets keys the original never had, which fixes every future property with the same shape. Also: building a document from pictures or joins refuses to start a second one on top of the first, and says so rather than failing quietly.
283 lines
12 KiB
JavaScript
283 lines
12 KiB
JavaScript
// Geometry shared by the on-screen overlay and the pdf-lib writer.
|
|
//
|
|
// Both need to agree exactly on where an arrowhead's barbs land, where each
|
|
// line of a text stamp sits, and how a normalised signature stroke maps into
|
|
// its placed box. Keeping that arithmetic in one place is what makes "what I
|
|
// drew" and "what got saved" the same picture; two copies of it drift the
|
|
// first time either side is touched.
|
|
//
|
|
// Everything here works in PDF user space: points, origin bottom-left, y up.
|
|
|
|
/** Approximate ascent as a fraction of font size, for Helvetica. */
|
|
export const ASCENT = 0.76;
|
|
/** Line advance as a fraction of font size. */
|
|
export const LINE_GAP = 1.2;
|
|
|
|
/**
|
|
* Where each line of a replaced text run is drawn.
|
|
*
|
|
* A "textedit" is a cover rectangle over the original run plus replacement
|
|
* text sitting on that run's own baseline — so it is a text stamp whose origin
|
|
* came from the document rather than from a click. Sharing the line layout
|
|
* with textLayout keeps the two kinds rendering identically.
|
|
*/
|
|
export function editLayout(a) {
|
|
const lines = String(a.text ?? "").split("\n");
|
|
const size = a.size || 12;
|
|
return lines.map((text, i) => ({ text, x: a.x, y: a.baseline - i * size * LINE_GAP }));
|
|
}
|
|
|
|
/**
|
|
* Where each line of a text stamp is drawn. `a.y` is the TOP of the block —
|
|
* that is what the user clicked — so every baseline hangs below it.
|
|
* @returns {{text:string, x:number, y:number}[]} y is the baseline.
|
|
*/
|
|
export function textLayout(a) {
|
|
const lines = String(a.text ?? "").split("\n");
|
|
const size = a.size || 12;
|
|
return lines.map((text, i) => ({
|
|
text,
|
|
x: a.x,
|
|
y: a.y - size * ASCENT - i * size * LINE_GAP,
|
|
}));
|
|
}
|
|
|
|
/** The one box that holds all of them, in user space. */
|
|
export function unionOf(annots) {
|
|
const boxes = (annots || []).map(boundsOf).filter(Boolean);
|
|
if (!boxes.length) return null;
|
|
const x = Math.min(...boxes.map((b) => b.x));
|
|
const y = Math.min(...boxes.map((b) => b.y));
|
|
const x1 = Math.max(...boxes.map((b) => b.x + b.w));
|
|
const y1 = Math.max(...boxes.map((b) => b.y + b.h));
|
|
return { x, y, w: x1 - x, h: y1 - y };
|
|
}
|
|
|
|
/** Do two user-space boxes touch at all? */
|
|
export function boxesOverlap(a, b) {
|
|
return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
|
|
}
|
|
|
|
/** Height of a text stamp's block, for hit-testing and the selection box. */
|
|
export function textBlockHeight(a) {
|
|
const lines = String(a.text ?? "").split("\n").length;
|
|
const size = a.size || 12;
|
|
return size * ASCENT + (lines - 1) * size * LINE_GAP + size * 0.24;
|
|
}
|
|
|
|
/**
|
|
* The two barb points of an arrowhead at (x2,y2) coming from (x1,y1).
|
|
* Head length scales with stroke width but is floored so a thin arrow still
|
|
* reads as an arrow.
|
|
*/
|
|
export function arrowBarbs(x1, y1, x2, y2, width) {
|
|
const dx = x2 - x1, dy = y2 - y1;
|
|
const len = Math.hypot(dx, dy) || 1;
|
|
const head = Math.max(6, Math.min(len * 0.34, (width || 2) * 4.5));
|
|
const ux = dx / len, uy = dy / len;
|
|
// 24° either side of the shaft.
|
|
const cos = Math.cos(0.42), sin = Math.sin(0.42);
|
|
return [
|
|
{ x: x2 - head * (ux * cos - uy * sin), y: y2 - head * (uy * cos + ux * sin) },
|
|
{ x: x2 - head * (ux * cos + uy * sin), y: y2 - head * (uy * cos - ux * sin) },
|
|
];
|
|
}
|
|
|
|
/**
|
|
* A placed signature's strokes in user space. Strokes are stored normalised
|
|
* (0..1, y measured DOWN from the top of the pad) so the same drawing can be
|
|
* dropped at any size, on any page, any number of times.
|
|
*/
|
|
export function signatureStrokes(a) {
|
|
const { x, y, w, h } = a;
|
|
const pts = (a.strokes || []).map((s) => s.map(([nx, ny]) => [x + nx * w, y + (1 - ny) * h]));
|
|
const deg = Number(a.rotate) || 0;
|
|
if (!deg) return pts;
|
|
// Turned about the middle of its own box, in user space — which means the
|
|
// renderer and the writer get the same rotated points from one piece of
|
|
// arithmetic, instead of each rotating in a different coordinate system.
|
|
const cx = x + w / 2, cy = y + h / 2;
|
|
const r = (deg * Math.PI) / 180;
|
|
const cos = Math.cos(r), sin = Math.sin(r);
|
|
return pts.map((s) => s.map(([px, py]) => {
|
|
const dx = px - cx, dy = py - cy;
|
|
return [cx + dx * cos - dy * sin, cy + dx * sin + dy * cos];
|
|
}));
|
|
}
|
|
|
|
/** The box a rotated signature actually occupies, for selection and hit-testing. */
|
|
export function rotatedBounds(a) {
|
|
const pts = signatureStrokes(a).flat();
|
|
if (!pts.length) return { x: a.x, y: a.y, w: a.w, h: a.h };
|
|
const xs = pts.map((p) => p[0]), ys = pts.map((p) => p[1]);
|
|
const x0 = Math.min(...xs), x1 = Math.max(...xs);
|
|
const y0 = Math.min(...ys), y1 = Math.max(...ys);
|
|
return { x: x0, y: y0, w: Math.max(1, x1 - x0), h: Math.max(1, y1 - y0) };
|
|
}
|
|
|
|
/** Normalise a box that may have been dragged right-to-left or bottom-to-top. */
|
|
export function normBox(x0, y0, x1, y1) {
|
|
return { x: Math.min(x0, x1), y: Math.min(y0, y1), w: Math.abs(x1 - x0), h: Math.abs(y1 - y0) };
|
|
}
|
|
|
|
/**
|
|
* Where a rule sits inside a line's rectangle, as a fraction of its height
|
|
* from the baseline side. Shared by the renderer and the writer so that what
|
|
* is on screen and what lands in the file are the same picture — two copies of
|
|
* this number is two pictures the first time either is touched.
|
|
*/
|
|
export const RULE_OFFSET = { underline: 0.06, strikeout: 0.42 };
|
|
|
|
/** Marks whose geometry is a list of text-line rectangles. */
|
|
export const RECT_LIST_KINDS = new Set(["highlight", "underline", "strikeout"]);
|
|
/** Marks whose geometry is a single box. */
|
|
export const BOX_KINDS = new Set(["rect", "ellipse", "redact", "signature", "textedit"]);
|
|
/** Marks defined by two endpoints. */
|
|
export const SEGMENT_KINDS = new Set(["arrow", "line"]);
|
|
|
|
export function boundsOf(a) {
|
|
if (RECT_LIST_KINDS.has(a.kind)) {
|
|
const rs = a.rects || [];
|
|
if (!rs.length) return { x: 0, y: 0, w: 0, h: 0 };
|
|
const x0 = Math.min(...rs.map((r) => r.x)), y0 = Math.min(...rs.map((r) => r.y));
|
|
const x1 = Math.max(...rs.map((r) => r.x + r.w)), y1 = Math.max(...rs.map((r) => r.y + r.h));
|
|
return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
|
|
}
|
|
if (SEGMENT_KINDS.has(a.kind)) return normBox(a.x1, a.y1, a.x2, a.y2);
|
|
switch (a.kind) {
|
|
case "pen": {
|
|
const pts = a.pts || [];
|
|
if (!pts.length) return { x: 0, y: 0, w: 0, h: 0 };
|
|
const xs = pts.map((p) => p[0]), ys = pts.map((p) => p[1]);
|
|
const pad = (a.width || 2) / 2;
|
|
return { x: Math.min(...xs) - pad, y: Math.min(...ys) - pad,
|
|
w: Math.max(...xs) - Math.min(...xs) + pad * 2, h: Math.max(...ys) - Math.min(...ys) + pad * 2 };
|
|
}
|
|
case "text": {
|
|
// Width is unknown without measuring the font; the renderer measures it
|
|
// and caches it on the mark as `_w` so selection boxes are tight.
|
|
return { x: a.x, y: a.y - textBlockHeight(a), w: a._w || (a.size || 12) * 6, h: textBlockHeight(a) };
|
|
}
|
|
// A turned signature occupies more than the box it was placed in, and
|
|
// the selection outline has to agree with what is on screen.
|
|
case "signature":
|
|
return a.rotate ? rotatedBounds(a) : { x: a.x, y: a.y, w: a.w, h: a.h };
|
|
default: return { x: a.x, y: a.y, w: a.w, h: a.h };
|
|
}
|
|
}
|
|
|
|
/** Shift a mark by (dx,dy) in user space. Returns the patch, does not mutate. */
|
|
export function translatePatch(a, dx, dy) {
|
|
if (RECT_LIST_KINDS.has(a.kind)) {
|
|
return { rects: (a.rects || []).map((r) => ({ ...r, x: r.x + dx, y: r.y + dy })) };
|
|
}
|
|
if (SEGMENT_KINDS.has(a.kind)) {
|
|
return { x1: a.x1 + dx, y1: a.y1 + dy, x2: a.x2 + dx, y2: a.y2 + dy };
|
|
}
|
|
if (a.kind === "pen") return { pts: (a.pts || []).map(([x, y]) => [x + dx, y + dy]) };
|
|
if (a.kind === "textedit") return { x: a.x + dx, y: a.y + dy, baseline: a.baseline + dy };
|
|
return { x: a.x + dx, y: a.y + dy };
|
|
}
|
|
|
|
// ---- resizing ---------------------------------------------------------
|
|
//
|
|
// Handles are named by the corner or edge they sit on, in screen terms: "nw"
|
|
// is top-left as the reader sees it. User space has y running the other way,
|
|
// so "n" moves the box's y + h and "s" moves its y. Keeping the names in
|
|
// reader terms means the cursor CSS and the drag maths agree with what the
|
|
// user is doing with the mouse.
|
|
|
|
export const BOX_HANDLES = ["nw", "n", "ne", "e", "se", "s", "sw", "w"];
|
|
export const CORNER_HANDLES = ["nw", "ne", "se", "sw"];
|
|
|
|
/** Smallest box a resize may produce, in points. */
|
|
export const MIN_EXTENT = 4;
|
|
export const MIN_FONT = 4;
|
|
export const MAX_FONT = 288;
|
|
|
|
/** Which handles a mark offers. Segments get their two endpoints instead. */
|
|
export function handlesFor(a) {
|
|
if (SEGMENT_KINDS.has(a.kind)) return ["p1", "p2"];
|
|
if (a.kind === "text") return CORNER_HANDLES; // uniform scale only
|
|
return BOX_HANDLES;
|
|
}
|
|
|
|
/**
|
|
* The box a drag of `handle` produces, given the mark's current bounds and a
|
|
* user-space delta. Anchors the opposite corner or edge, and never inverts:
|
|
* dragging the top edge past the bottom stops at the minimum rather than
|
|
* flipping the mark over, which is disorienting mid-gesture.
|
|
*/
|
|
export function resizeBox(b, handle, dx, dy, { uniform = false } = {}) {
|
|
let { x, y, w, h } = b;
|
|
const east = handle.includes("e"), west = handle.includes("w");
|
|
const north = handle.includes("n"), south = handle.includes("s");
|
|
if (uniform && (east || west) && (north || south)) {
|
|
// Corner drag on a uniform mark: one scale factor, taken from whichever
|
|
// axis moved further, so the mark never distorts.
|
|
const sx = (w + (east ? dx : -dx)) / (w || 1);
|
|
const sy = (h + (north ? dy : -dy)) / (h || 1);
|
|
const k = Math.max(MIN_EXTENT / Math.max(w, h, 1), Math.abs(sx) > Math.abs(sy) ? sx : sy);
|
|
const nw = Math.max(MIN_EXTENT, w * k), nh = Math.max(MIN_EXTENT, h * k);
|
|
return {
|
|
x: west ? x + w - nw : x,
|
|
y: south ? y + h - nh : y,
|
|
w: nw, h: nh,
|
|
};
|
|
}
|
|
if (east) w = Math.max(MIN_EXTENT, w + dx);
|
|
if (west) { const nw2 = Math.max(MIN_EXTENT, w - dx); x = x + w - nw2; w = nw2; }
|
|
if (north) h = Math.max(MIN_EXTENT, h + dy);
|
|
if (south) { const nh2 = Math.max(MIN_EXTENT, h - dy); y = y + h - nh2; h = nh2; }
|
|
return { x, y, w, h };
|
|
}
|
|
|
|
/**
|
|
* Map a mark from one bounding box to another. One function for every kind,
|
|
* so a new mark type gets resizing for free as long as boundsOf knows it.
|
|
*/
|
|
export function scaleToBox(a, from, to) {
|
|
const sx = from.w > 0.001 ? to.w / from.w : 1;
|
|
const sy = from.h > 0.001 ? to.h / from.h : 1;
|
|
const fx = (x) => to.x + (x - from.x) * sx;
|
|
const fy = (y) => to.y + (y - from.y) * sy;
|
|
|
|
if (RECT_LIST_KINDS.has(a.kind)) {
|
|
return { rects: (a.rects || []).map((r) => ({ x: fx(r.x), y: fy(r.y), w: r.w * sx, h: r.h * sy })) };
|
|
}
|
|
if (SEGMENT_KINDS.has(a.kind)) {
|
|
return { x1: fx(a.x1), y1: fy(a.y1), x2: fx(a.x2), y2: fy(a.y2) };
|
|
}
|
|
if (a.kind === "pen") {
|
|
return { pts: (a.pts || []).map(([x, y]) => [fx(x), fy(y)]) };
|
|
}
|
|
if (a.kind === "text") {
|
|
// Text scales by font size, not by stretching glyphs. `y` is the top of
|
|
// the block, which is where the new box's top is.
|
|
const size = clampFont((a.size || 12) * (sy || 1));
|
|
return { size, x: to.x, y: to.y + to.h };
|
|
}
|
|
if (a.kind === "textedit") {
|
|
// The cover follows the box; the baseline rides with it so the
|
|
// replacement keeps sitting where the original line sat.
|
|
return {
|
|
x: to.x, y: to.y, w: Math.max(MIN_EXTENT, to.w), h: Math.max(MIN_EXTENT, to.h),
|
|
size: clampFont((a.size || 12) * (sy || 1)),
|
|
baseline: fy(a.baseline),
|
|
};
|
|
}
|
|
if (a.kind === "signature" && a.rotate) {
|
|
// `from`/`to` describe the TURNED extent; the mark's own box is the
|
|
// unturned one. Scale that box about its centre by the same factors and
|
|
// the turned extent follows, because the rotation is about that centre.
|
|
const cx = to.x + to.w / 2, cy = to.y + to.h / 2;
|
|
const w = Math.max(MIN_EXTENT, a.w * sx);
|
|
const h = Math.max(MIN_EXTENT, a.h * sy);
|
|
return { x: cx - w / 2, y: cy - h / 2, w, h };
|
|
}
|
|
return { x: to.x, y: to.y, w: Math.max(MIN_EXTENT, to.w), h: Math.max(MIN_EXTENT, to.h) };
|
|
}
|
|
|
|
export function clampFont(n) {
|
|
return Math.min(MAX_FONT, Math.max(MIN_FONT, Math.round(n * 10) / 10));
|
|
}
|