Drag on empty page and a rubber band gathers every mark it sweeps over. Touching counts rather than enclosing: a band you have to draw right around a long arrow is a band you draw twice. Shift-click adds or removes one, Ctrl+A takes the page. What the group can then do is move, restyle, duplicate and delete, each as a single undo step — six marks deleted is one thing the user did, so it has to be one thing to undo. The selection bar offers only what is true of every member: a group of shapes gets Fill, a group of stamps gets size and weight, a mixed group gets neither, and none of them gets Edit, which needs one mark to put a caret in. No resize handles on a group. Stretching a mixed selection means deciding what a stretch does to text, which scales by font size, and to a line, which has no box at all; until there is an answer worth defending, offering the grip would promise something this cannot keep. Each member is outlined as well as the group, because a band that caught one more mark than you meant is worth seeing before you press Delete. A group lives on one page. A mark carries its page, and PDF user space means nothing across two of them, so a band selects within the page it was drawn on. One trap found on the way: starting the band on a press means calling preventDefault, which is also what gives a form field its caret — so the band now keeps its hands off the annotation layer, and typing into a PDF form still works.
248 lines
10 KiB
JavaScript
248 lines
10 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;
|
|
return (a.strokes || []).map((s) => s.map(([nx, ny]) => [x + nx * w, y + (1 - ny) * h]));
|
|
}
|
|
|
|
/** 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) };
|
|
}
|
|
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),
|
|
};
|
|
}
|
|
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));
|
|
}
|