theseus/bundled-addons/pdf-editor/lib/overlay.js

418 lines
17 KiB
JavaScript
Raw Normal View History

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
// The on-screen layer that draws marks over a rendered page.
//
// One SVG per page, sized to that page's current viewport in CSS pixels.
// Every mark is stored in PDF user space, so drawing means running each point
// through `viewport.convertToViewportPoint` — which already accounts for the
// page's scale AND its rotation. That is why rotating a page needs no special
// handling here: the viewport changes, we re-render, and the marks turn with
// the content they were drawn on.
//
// SVG rather than a canvas because marks have to be individually
// hit-testable, selectable and movable, and because it stays crisp when the
// user zooms without us re-rasterising anything.
feat(pdf-editor): select an area, and treat what it caught as one thing 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.
2026-09-27 13:45:04 +02:00
import { arrowBarbs, boundsOf, editLayout, handlesFor, signatureStrokes, textLayout, unionOf, RULE_OFFSET, SEGMENT_KINDS } from "./shape.js";
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
const NS = "http://www.w3.org/2000/svg";
const HANDLE = 9; // grab-handle size in CSS px, deliberately not scaled with zoom
let clipSeq = 0; // clipPath ids must be unique across every page in the document
const HIT_TOLERANCE = 14; // CSS px of grab room around a thin stroke
feat(pdf-editor): one select, one text tool, and a signature you can ink and turn 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.
2026-09-27 15:09:17 +02:00
const ROTATE_ARM = 22; // CSS px between the box and its rotate grip
/** Kinds that can be turned. A signature, because signing lines are not
* always square to the page; nothing else has asked for it. */
const ROTATABLE = new Set(["signature"]);
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
function el(name, attrs) {
const n = document.createElementNS(NS, name);
for (const k in attrs) if (attrs[k] != null) n.setAttribute(k, attrs[k]);
return n;
}
export class Overlay {
/** @param {HTMLElement} pageDiv the .page div owned by a PDFPageView */
constructor(pageDiv) {
this.svg = el("svg", { class: "smOverlay", xmlns: NS });
// Marks are clipped to the page, because a PDF reader shows nothing
// outside the crop box and a mark that spilled over the edge on screen
// would vanish on save. The selection chrome is NOT clipped: a mark
// flush against the edge still needs grabbable handles, and those sit
// just outside its bounds.
this.clipId = `smclip-${++clipSeq}`;
const defs = el("defs", {});
this.clipRect = el("rect", { x: 0, y: 0, width: 0, height: 0 });
const clip = el("clipPath", { id: this.clipId });
clip.append(this.clipRect);
defs.append(clip);
this.marks = el("g", { class: "marks", "clip-path": `url(#${this.clipId})` });
this.chrome = el("g", { class: "chrome" }); // selection box + handles
this.svg.append(defs, this.marks, this.chrome);
pageDiv.append(this.svg);
this.viewport = null;
}
destroy() { this.svg.remove(); }
/** Map a user-space point to CSS pixels inside the page div. */
toView(x, y) {
const [vx, vy] = this.viewport.convertToViewportPoint(x, y);
return [vx, vy];
}
/** Map a page-relative CSS pixel point back to user space. */
toPdf(vx, vy) {
const [x, y] = this.viewport.convertToPdfPoint(vx, vy);
return [x, y];
}
feat(pdf-editor): select an area, and treat what it caught as one thing 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.
2026-09-27 13:45:04 +02:00
render(viewport, annots, selected) {
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
this.viewport = viewport;
feat(pdf-editor): select an area, and treat what it caught as one thing 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.
2026-09-27 13:45:04 +02:00
// Callers pass either one id or a set of them; everything below works in
// sets, so a single id becomes a set of one here rather than at each of
// the dozen call sites.
const ids = selected instanceof Set ? selected
: new Set(Array.isArray(selected) ? selected : selected ? [selected] : []);
this._selIds = ids;
this._selId = ids.size === 1 ? [...ids][0] : null;
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
this.svg.setAttribute("width", viewport.width);
this.svg.setAttribute("height", viewport.height);
this.svg.setAttribute("viewBox", `0 0 ${viewport.width} ${viewport.height}`);
this.clipRect.setAttribute("width", viewport.width);
this.clipRect.setAttribute("height", viewport.height);
this.marks.textContent = "";
for (const a of annots) {
2026-09-27 13:16:07 +02:00
// A stamp being typed is drawn by the caret instead; two copies of the
// same words, one stale, reads as a rendering fault.
if (a._editing) continue;
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
const node = this._draw(a);
if (!node) continue;
this._addHitArea(node, a);
node.classList.add("mark");
node.dataset.id = a.id;
feat(pdf-editor): select an area, and treat what it caught as one thing 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.
2026-09-27 13:45:04 +02:00
if (ids.has(a.id)) node.classList.add("sel");
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
this.marks.append(node);
}
feat(pdf-editor): select an area, and treat what it caught as one thing 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.
2026-09-27 13:45:04 +02:00
this._drawChrome(annots.filter((a) => ids.has(a.id) && !a._editing));
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
}
// Scale a user-space length (a stroke width, a font size) into CSS pixels.
// The viewport's scale already carries zoom; rotation does not change length.
get k() { return this.viewport ? this.viewport.scale : 1; }
_draw(a) {
const g = el("g", {});
switch (a.kind) {
feat(pdf-editor): select an area, and treat what it caught as one thing 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.
2026-09-27 13:45:04 +02:00
// The rubber band. Not a mark and never saved — it is drawn through the
// same path only so it lands in the page's own coordinates and stays
// put while the page scrolls under it.
case "marquee": {
const b = {
x: Math.min(a.x0, a.x1), y: Math.min(a.y0, a.y1),
w: Math.abs(a.x1 - a.x0), h: Math.abs(a.y1 - a.y0),
};
g.append(el("rect", { ...this._box(b), class: "marquee" }));
return g;
}
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
case "highlight": {
for (const r of a.rects || []) {
const b = this._box({ x: r.x, y: r.y, w: r.w, h: r.h });
g.append(el("rect", {
...b, fill: a.color, "fill-opacity": a.opacity ?? 0.38,
style: "mix-blend-mode:multiply",
}));
}
g.dataset.fill = "1";
return g;
}
// Underline and strikeout share the highlight's geometry — one rect per
// line of selected text — and differ only in where the rule sits inside
// that rect. Keeping them on the same shape means a selection spanning a
// page break splits the same way for all three.
case "underline": case "strikeout": {
const frac = RULE_OFFSET[a.kind];
for (const r of a.rects || []) {
const yy = r.y + r.h * frac;
const [x1, y1] = this.toView(r.x, yy);
const [x2, y2] = this.toView(r.x + r.w, yy);
g.append(el("line", {
x1, y1, x2, y2, stroke: a.color,
"stroke-width": Math.max(0.6, (a.width || 1.4)) * this.k, "stroke-linecap": "butt",
}));
}
return g;
}
case "pen": {
const pts = (a.pts || []).map(([x, y]) => this.toView(x, y));
if (pts.length < 2) return null;
g.append(el("polyline", {
points: pts.map((p) => `${p[0]},${p[1]}`).join(" "),
fill: "none", stroke: a.color, "stroke-width": (a.width || 2) * this.k,
"stroke-linecap": "round", "stroke-linejoin": "round",
}));
return g;
}
case "rect": {
const b = this._box(a);
g.append(el("rect", { ...b, ...this._paint(a) }));
if (a.fill) g.dataset.fill = "1";
return g;
}
case "ellipse": {
const b = this._box(a);
g.append(el("ellipse", {
cx: b.x + b.width / 2, cy: b.y + b.height / 2,
rx: Math.max(0.5, b.width / 2), ry: Math.max(0.5, b.height / 2),
...this._paint(a),
}));
if (a.fill) g.dataset.fill = "1";
return g;
}
case "line": {
const [x1, y1] = this.toView(a.x1, a.y1);
const [x2, y2] = this.toView(a.x2, a.y2);
g.append(el("line", {
x1, y1, x2, y2, stroke: a.color,
"stroke-width": (a.width || 2) * this.k, "stroke-linecap": "round",
}));
return g;
}
case "arrow": {
const [x1, y1] = this.toView(a.x1, a.y1);
const [x2, y2] = this.toView(a.x2, a.y2);
const w = (a.width || 2) * this.k;
const barbs = arrowBarbs(a.x1, a.y1, a.x2, a.y2, a.width || 2).map((p) => this.toView(p.x, p.y));
g.append(el("line", { x1, y1, x2, y2, stroke: a.color, "stroke-width": w, "stroke-linecap": "round" }));
g.append(el("polyline", {
points: `${barbs[0][0]},${barbs[0][1]} ${x2},${y2} ${barbs[1][0]},${barbs[1][1]}`,
fill: "none", stroke: a.color, "stroke-width": w,
"stroke-linecap": "round", "stroke-linejoin": "round",
}));
return g;
}
case "text": {
const size = (a.size || 12) * this.k;
let widest = 0;
for (const line of textLayout(a)) {
const [vx, vy] = this.toView(line.x, line.y);
const t = el("text", {
x: vx, y: vy, fill: a.color,
"font-family": "Helvetica, Arial, sans-serif",
"font-size": size, "xml:space": "preserve",
"font-weight": a.bold ? "bold" : "normal",
"font-style": a.italic ? "italic" : "normal",
});
t.textContent = line.text;
g.append(t);
}
g.dataset.fill = "1";
// A string's width is only knowable once the browser has laid it out,
// so it is measured a frame later and cached on the mark in user-space
// units for boundsOf(). The selection box is drawn from that, and was
// drawn before the measurement existed — so redraw it once the real
// width is in, or the box and its handles sit at the fallback guess.
requestAnimationFrame(() => {
try {
for (const t of g.querySelectorAll("text")) widest = Math.max(widest, t.getComputedTextLength());
if (widest <= 0) return;
const w = widest / this.k;
const changed = Math.abs((a._w ?? 0) - w) > 0.5;
a._w = w;
feat(pdf-editor): select an area, and treat what it caught as one thing 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.
2026-09-27 13:45:04 +02:00
if (changed && a.id === this._selId) this._drawChrome([a]);
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
} catch {}
});
return g;
}
case "signature": {
const w = (a.width || 1.6) * this.k;
for (const stroke of signatureStrokes(a)) {
if (stroke.length < 2) continue;
g.append(el("polyline", {
points: stroke.map(([x, y]) => this.toView(x, y).join(",")).join(" "),
fill: "none", stroke: a.color, "stroke-width": w,
"stroke-linecap": "round", "stroke-linejoin": "round",
}));
}
return g;
}
feat(pdf-editor): replace the document's own text, on its own baseline Until now "editing" a PDF here meant laying things over it. You could put a word on top of a word, but the document underneath never changed, and the result read like a sticker because it was one. This adds the thing the word Edit actually promises: click a line of the document's text, type different words, and they land where the old ones were, in the old size and the old colour. The position and size come from pdf.js's text layer, which has already placed a span over every run and carries that run's size in unscaled PDF points — so the size is right whatever the zoom, which reading it off the rendered box would not be. The colours come from the rendered page, because nothing in the text API reports them: the background is the average of the most common colour bucket in the run's box, since type is a minority of the pixels even when it is dense, and the ink is whatever sits furthest from that background. On the test fixture it recovers the marker's red exactly. Two things that look like details and are not. The bucket only chooses WHICH pixels are background; the colour itself is their average, because rebuilding it from the bucket index rounds white down to #f8f8f8 and a not-quite-white patch on a white page is a visible seam. And the cover reaches below the baseline by a quarter of the font size, because pdf.js sizes its spans to the em box: cut the cover to the span and every descender in the original line survives as a little hook under the replacement. A replacement is a cover plus text, so it is a mark like any other — movable, resizable, undoable, and rendered on screen from the same numbers the writer uses, which is what makes the preview trustworthy. Said plainly in the dialog and again in the save summary: this hides the original, it does not remove it. The old glyphs are still in the content stream underneath. Redact is the tool that takes text away, and it says so too.
2026-09-22 21:49:37 +02:00
// A replaced text run: the original covered in the page's own background
// colour, the replacement drawn on the original's baseline. Rendering it
// here from the same numbers the writer uses is what makes the preview
// honest — the cover is the part people need to trust.
case "textedit": {
const b = this._box(a);
g.append(el("rect", { ...b, fill: a.cover || "#ffffff" }));
const size = (a.size || 12) * this.k;
for (const line of editLayout(a)) {
const [vx, vy] = this.toView(line.x, line.y);
const t = el("text", {
x: vx, y: vy, fill: a.color || "#000000",
"font-family": a.family || "Helvetica, Arial, sans-serif",
"font-size": size, "xml:space": "preserve",
"font-weight": a.bold ? "bold" : "normal",
"font-style": a.italic ? "italic" : "normal",
});
t.textContent = line.text;
g.append(t);
}
g.dataset.fill = "1";
return g;
}
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
case "redact": {
const b = this._box(a);
g.append(el("rect", { ...b, fill: "#000" }));
g.dataset.fill = "1";
return g;
}
default: return null;
}
}
/**
* Give a mark something to grab.
*
* A 2 pt outline is about one screen pixel: selecting it means hitting a
* hairline exactly, which is miserable with a mouse and impossible with a
* trackpad. Each stroked shape gets an invisible copy of itself with a fat
* stroke underneath, purely to catch the pointer. `pointer-events: stroke`
* hit-tests the stroke area whatever the paint is, so a transparent one
* still counts.
*/
_addHitArea(g, a) {
if (g.dataset.fill === "1") return; // already a solid target
const wide = Math.max(HIT_TOLERANCE, (a.width || 2) * this.k * 1.8);
const clones = [];
for (const child of g.children) {
if (child.tagName === "text") continue;
const c = child.cloneNode(false);
c.setAttribute("stroke", "transparent");
c.setAttribute("stroke-width", wide);
c.setAttribute("fill", "none");
c.removeAttribute("style");
c.setAttribute("class", "hit");
clones.push(c);
}
for (const c of clones) g.insertBefore(c, g.firstChild);
}
// A user-space {x,y,w,h} box as an SVG rect's attributes. Done through both
// corners so a rotated viewport lands the right way up.
_box(a) {
const [ax, ay] = this.toView(a.x, a.y);
const [bx, by] = this.toView(a.x + a.w, a.y + a.h);
return { x: Math.min(ax, bx), y: Math.min(ay, by),
width: Math.abs(bx - ax), height: Math.abs(by - ay) };
}
// The selection outline and its grab handles.
//
// Handles are drawn at a fixed pixel size rather than scaled with the page,
// so they stay grabbable at 50% zoom and do not swell into the artwork at
// 400%. Each carries data-handle, which is what tools.js hit-tests to tell a
// resize from a move.
feat(pdf-editor): select an area, and treat what it caught as one thing 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.
2026-09-27 13:45:04 +02:00
_drawChrome(picked) {
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
this.chrome.textContent = "";
feat(pdf-editor): select an area, and treat what it caught as one thing 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.
2026-09-27 13:45:04 +02:00
const list = Array.isArray(picked) ? picked : picked ? [picked] : [];
if (!list.length) return;
if (list.length > 1) return this._drawGroupChrome(list);
const sel = list[0];
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
const b = boundsOf(sel);
const r = this._box({ x: b.x, y: b.y, w: b.w, h: b.h });
const pad = 3;
this.chrome.append(el("rect", {
class: "selbox", x: r.x - pad, y: r.y - pad,
width: r.width + pad * 2, height: r.height + pad * 2,
}));
const names = handlesFor(sel);
if (SEGMENT_KINDS.has(sel.kind)) {
// A line's ends are the only meaningful grips; a bounding box would let
// you stretch it in ways that never match what you were aiming at.
const ends = { p1: this.toView(sel.x1, sel.y1), p2: this.toView(sel.x2, sel.y2) };
for (const n of names) this._handle(n, ends[n][0], ends[n][1], "move");
return;
}
const L = r.x - pad, R = r.x + r.width + pad;
const T = r.y - pad, B = r.y + r.height + pad;
const MX = (L + R) / 2, MY = (T + B) / 2;
const at = { nw: [L, T], n: [MX, T], ne: [R, T], e: [R, MY],
se: [R, B], s: [MX, B], sw: [L, B], w: [L, MY] };
const cursor = { nw: "nwse-resize", se: "nwse-resize", ne: "nesw-resize", sw: "nesw-resize",
n: "ns-resize", s: "ns-resize", e: "ew-resize", w: "ew-resize" };
for (const n of names) {
const p = at[n];
if (p) this._handle(n, p[0], p[1], cursor[n]);
}
feat(pdf-editor): one select, one text tool, and a signature you can ink and turn 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.
2026-09-27 15:09:17 +02:00
// A grip above the box, for the marks it makes sense to turn. A signature
// goes on a line that is not always square to the page — a scanned form,
// a photographed contract — and straightening one by hand beats
// straightening the whole page.
if (ROTATABLE.has(sel.kind)) {
const ry = T - ROTATE_ARM;
this.chrome.append(el("line", { class: "rotarm", x1: MX, y1: T, x2: MX, y2: ry }));
const g = this._handle("rotate", MX, ry, "grab");
if (g) g.classList.add("rot");
}
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
}
feat(pdf-editor): select an area, and treat what it caught as one thing 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.
2026-09-27 13:45:04 +02:00
/**
* Several marks at once: one box round the lot, and a fainter one round
* each member.
*
* The member boxes are the part that earns its keep — a single outline
* round six marks tells you how much is selected but not which, and a
* marquee that quietly caught a seventh is exactly the mistake worth
* seeing before you press Delete.
*
* No handles. Resizing a group 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 be a promise
* this cannot keep.
*/
_drawGroupChrome(list) {
for (const a of list) {
const b = boundsOf(a);
if (!b) continue;
const r = this._box(b);
this.chrome.append(el("rect", {
class: "selbox member", x: r.x - 2, y: r.y - 2,
width: r.width + 4, height: r.height + 4,
}));
}
const u = unionOf(list);
if (!u) return;
const r = this._box(u);
const pad = 6;
this.chrome.append(el("rect", {
class: "selbox group", x: r.x - pad, y: r.y - pad,
width: r.width + pad * 2, height: r.height + pad * 2,
}));
}
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
_handle(name, cx, cy, cursor) {
const h = el("rect", {
class: "handle", x: cx - HANDLE / 2, y: cy - HANDLE / 2,
width: HANDLE, height: HANDLE, rx: 1.5,
style: `cursor:${cursor}`,
});
h.dataset.handle = name;
this.chrome.append(h);
feat(pdf-editor): one select, one text tool, and a signature you can ink and turn 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.
2026-09-27 15:09:17 +02:00
return h;
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
}
// Stroke and fill for a shape, honouring its fill and opacity.
_paint(a) {
return {
fill: a.fill ? a.color : "none",
"fill-opacity": a.fill ? (a.opacity ?? 0.25) : 0,
stroke: a.color,
"stroke-width": (a.width || 2) * this.k,
};
}
/** The mark id under a page-relative CSS pixel point, or null. */
hitTest(vx, vy) {
const node = document.elementFromPoint(
vx + this.svg.getBoundingClientRect().left,
vy + this.svg.getBoundingClientRect().top);
const mark = node?.closest?.(".mark");
return mark ? mark.dataset.id : null;
}
}