Everything the editor put on a page was final. A text stamp could not be corrected without deleting it and typing it again, nothing could be resized, and the only way to remove a mark was a Delete key nobody had been told about — the selection drew a dashed box and offered no action at all. Placing a stamp also left its tool armed, so the next click stamped a second copy. Marks are now editable objects. Selecting one gives it grab handles and a small bar pinned above it: delete and duplicate for anything, and for text an edit button, a size stepper and bold and italic. Double-clicking text reopens it for rewriting in place rather than adding a second one. Placing a text stamp or a signature drops straight back to the select tool with the new mark live, which is both what people expect and what puts it immediately within reach of a nudge. Resizing is one function over every mark type rather than a special case per kind: a handle drag produces a new bounding box, and the mark is mapped from its old box into that one. Text scales by font size instead of stretching its glyphs, signatures keep their aspect on a corner, and lines offer their two endpoints instead of a box that would let you stretch them in ways you never aimed at. A whole gesture lands on the undo stack as one step. Selecting a thin mark used to mean clicking its outline exactly — about one screen pixel. Each stroked mark now carries an invisible fat copy of itself purely to catch the pointer. New marks to go with it: underline and strike-through, which share the highlight's text-selection geometry and differ only in where the rule sits; a plain line; and a fill toggle for rectangles and ellipses. Bold and italic mean three more Helvetica variants embedded at save time, since a PDF treats them as separate fonts rather than as a style. Double-click is detected from the pointer stream rather than from a dblclick listener, because selecting a mark calls preventDefault() on the pointerdown and that suppresses the compatibility mouse events the browser would have synthesised the dblclick from.
208 lines
8.7 KiB
JavaScript
208 lines
8.7 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 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,
|
|
}));
|
|
}
|
|
|
|
/** 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"]);
|
|
/** 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]) };
|
|
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 };
|
|
}
|
|
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));
|
|
}
|