feat(pdf-editor): a mark you placed is something you can still work on
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.
2026-09-21 03:13:06 +02:00
|
|
|
// 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));
|
|
|
|
|
}
|