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

787 lines
33 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
// Pointer behaviour for every tool.
//
// The editor is modal: one tool is armed, and what a drag does depends only on
// which. The alternative — inferring intent from what is under the cursor — is
// what makes PDF annotators infuriating, because a drag meant to draw a box
// selects a paragraph instead. Modality is enforced in CSS (see the
// body[data-mode] rules): with a drawing tool armed, the text and form layers
// stop taking pointer events entirely, so a drag cannot be stolen.
//
// The text-markup tools are the deliberate exception. Highlight, underline and
// strikeout all work BY selecting text — the text layer stays live, the browser
// does the selection, and on release we convert the selection's client rects
// into user space. Rebuilding text selection over pdf.js's span soup would be
// worse at it.
//
// Placing something one-shot — a text stamp, a signature — drops straight back
// to the select tool with the new mark selected. Staying armed means the next
// click stamps a second copy, which is almost never what was wanted, and it
// puts the thing just placed immediately within reach of a nudge or a resize.
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 { ASCENT, boundsOf, boxesOverlap, normBox, translatePatch, resizeBox, scaleToBox } 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 MIN_DRAG = 3; // CSS px before a press counts as a drag
const DOUBLE_CLICK_MS = 450; // window for a second click on the same mark
const SIG_WIDTH_PT = 170; // default placed width of a signature
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
const DESCENDER = 0.26; // how far below the baseline a glyph can reach, as a fraction of size
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
/** Tools that mark up existing text rather than drawing free-hand. */
export const TEXT_MARKUP = new Set(["highlight", "underline", "strikeout"]);
/** Tools that place something with a single click. */
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 STAMP_TOOLS = new Set(["signature"]);
/**
* Put `a` back exactly as `orig` was — including forgetting keys `orig`
* never had.
*
* Object.assign alone copies the keys the original HAD, so a property the
* drag introduced survives the restore. That is not a tidiness point: the
* journal snapshots the restored mark as the "before", so a first rotation
* of a never-rotated signature was recorded as already rotated, and undo
* had nothing to undo.
*/
function restoreExactly(a, orig) {
for (const k of Object.keys(a)) if (!(k in orig)) delete a[k];
Object.assign(a, orig);
}
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
export class Tools {
constructor({ strip, model, host }) {
this.strip = strip;
this.model = model;
// host (editor.js): { toast, onChange, selectTool, onSelectionChange,
2026-09-27 13:16:07 +02:00
// beginText, editText, editRun, chooseSignature,
// 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
this.host = host;
this.tool = "select";
this.color = "#ffd400";
this.width = 2.5;
this.fill = false; // shapes: outline only, or filled
this.fillOpacity = 0.25;
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.selected = null; // the anchor of the selection, or null
this.selection = new Set(); // every selected mark
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.drag = null;
// Opening a second document builds a second Tools over the same container.
// Without a way to unbind, the first one stays subscribed, keeps pointing
// at a torn-down strip, and throws on the next click — so every listener
// goes on one signal and destroy() drops the lot.
this._abort = new AbortController();
const opts = { signal: this._abort.signal };
const c = strip.container;
c.addEventListener("pointerdown", (e) => this.onDown(e), opts);
c.addEventListener("pointermove", (e) => this.onMove(e), opts);
window.addEventListener("pointerup", (e) => this.onUp(e), opts);
}
destroy() {
this._abort.abort();
this.drag = null;
this.selected = null;
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.selection = new Set();
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
}
get mode() {
if (this.tool === "select") return "select";
if (TEXT_MARKUP.has(this.tool)) return "highlight";
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
// The text tool needs the document's own words to be hit-testable, so a
// click on them can replace them. A stamp tool must NOT, so its click
// lands on the page rather than inside a text selection.
if (this.tool === "text") return "text";
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
if (STAMP_TOOLS.has(this.tool)) return "stamp";
return "draw";
}
setTool(t) {
this.tool = t;
this._setSelected(null);
document.body.dataset.mode = this.mode;
this.host.onChange?.();
}
/** The one place selection changes, so the host never misses one. */
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
_setSelected(id) { this._setSelection(id ? [id] : []); }
/**
* Select a set of marks.
*
* `selection` is the truth; `selected` is the one of them the single-mark
* paths work on — the anchor — and is null when nothing is selected. With
* exactly one mark the two say the same thing, which is why every existing
* caller kept working when this grew from an id into a set.
*/
_setSelection(ids) {
const list = [...new Set(ids)].filter((id) => !!this.model.annot(id));
this.selection = new Set(list);
this.selected = list.length ? list[0] : null;
this.strip.refreshAllOverlays(this.selection);
this.host.onSelectionChange?.(this.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
}
select(id) { this._setSelected(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
selectMany(ids) { this._setSelection(ids); }
/** Every selected mark, as objects. */
selectedAnnots() {
return [...this.selection].map((id) => this.model.annot(id)).filter(Boolean);
}
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
setColor(c) {
this.color = c;
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
// Redaction is black because it is a redaction; it does not take a colour.
const ids = this.selectedAnnots().filter((a) => a.kind !== "redact").map((a) => a.id);
if (ids.length) this.patchSelected({ color: c }, ids);
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
}
setFill(on) {
this.fill = !!on;
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 ids = this.selectedAnnots()
.filter((a) => a.kind === "rect" || a.kind === "ellipse").map((a) => a.id);
if (ids.length) this.patchSelected({ fill: this.fill }, ids);
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
}
setWidth(w) {
this.width = 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
const ids = this.selectedAnnots()
.filter((a) => a.kind !== "text" && a.kind !== "redact").map((a) => a.id);
if (ids.length) this.patchSelected({ width: w }, ids);
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
/** Change the selected marks, as one undo step. */
patchSelected(patch, ids = null) {
const list = ids || [...this.selection];
if (!list.length) return false;
const ok = list.length === 1
? this.model.moveAnnot(list[0], patch)
: this.model.moveAnnots(list, patch);
this.strip.refreshAllOverlays(this.selection);
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.host.onSelectionChange?.(this.selected);
this.host.onChange?.();
return ok;
}
deleteSelected() {
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 = [...this.selection];
if (!list.length) return false;
const ok = list.length === 1
? this.model.removeAnnot(list[0])
: this.model.removeAnnots(list);
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._setSelected(null);
this.host.onChange?.();
return ok;
}
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
/** Drop a copy of each selected mark, offset so they are visibly seconds. */
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
duplicateSelected() {
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 originals = this.selectedAnnots();
if (!originals.length) return 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
const off = 12;
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 clone = (a) => {
const copy = { ...structuredClone(a), ...translatePatch(a, off, -off) };
delete copy.id;
delete copy._w;
delete copy._editing;
return copy;
};
if (originals.length === 1) {
const made = this.model.addAnnot(clone(originals[0]));
this._setSelected(made.id);
this.host.onChange?.();
return made;
}
// One step for the group, and the copies end up selected so the next
// drag moves the copies rather than the originals.
const made = [];
this.model.edit(`duplicate ${originals.length} marks`, () => {
for (const a of originals) made.push(this.model.pushAnnot(clone(a)));
return true;
});
this._setSelection(made.map((m) => m.id));
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.host.onChange?.();
return made;
}
// ---- pointer ------------------------------------------------------
onDown(e) {
if (e.button !== 0) return;
// A resize handle is checked before anything else, and whatever tool is
// armed. Two reasons it comes first: it sits outside the mark's own
// bounds, so the page under it may not even be the page the mark is on;
// and a shape is selected the instant it is drawn, so grabbing a corner
// has to work right then, without a detour through the select tool to
// resize the thing you are still looking at.
//
// It cannot be confused with drawing. A handle is a 9 px square that
// exists only while something is selected; nobody aims at one by
// accident, and Escape drops the selection if the space is wanted back.
const handle = e.target?.closest?.(".handle")?.dataset?.handle;
if (handle && this.selected) {
const a = this.model.annot(this.selected);
const ov = a && this.strip.overlayFor(a.page);
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
if (a && ov?.viewport && handle === "rotate") {
// Turning is measured from the mark's centre, so the grip follows the
// pointer round rather than jumping to it.
const b = boundsOf(a);
this.drag = {
kind: "rotate", uid: a.page, id: a.id, orig: structuredClone(a),
cx0: a.x + a.w / 2, cy0: a.y + a.h / 2,
from: null, cx: e.clientX, cy: e.clientY, moved: false, box: b,
};
e.preventDefault();
return;
}
if (a && ov?.viewport) {
const view = this.strip.views.get(a.page);
const r = view.pv.div.getBoundingClientRect();
const [hx, hy] = ov.toPdf(e.clientX - r.left, e.clientY - r.top);
this.drag = {
kind: "resize", uid: a.page, id: a.id, handle,
x0: hx, y0: hy, orig: structuredClone(a), from: boundsOf(a),
cx: e.clientX, cy: e.clientY, moved: false,
};
e.preventDefault();
return;
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
}
}
2026-09-27 13:16:07 +02:00
// Dragging what is already selected moves it, whatever tool is armed.
// A tool drops back to select after it stamps, but a shape tool stays
// armed for the next shape — so the mark you just drew could be resized
// by its handles and not moved by its middle, which is a strange half of
// an editor. Only the SELECTED mark does this: a press anywhere else
// still draws.
if (this.selected && this.tool !== "select" &&
e.target?.closest?.(".mark")?.dataset?.id === this.selected) {
const a = this.model.annot(this.selected);
const spot = this.strip.pageAt(e.clientX, e.clientY);
const ovm = a && this.strip.overlayFor(a.page);
if (a && spot && spot.uid === a.page && ovm?.viewport) {
const [mx, my] = ovm.toPdf(spot.vx, spot.vy);
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.drag = this._beginMove(a.page, a.id, mx, my, e);
2026-09-27 13:16:07 +02:00
e.preventDefault();
return;
}
}
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 hit = this.strip.pageAt(e.clientX, e.clientY);
if (!hit) return;
const ov = this.strip.overlayFor(hit.uid);
if (!ov || !ov.viewport) return;
if (TEXT_MARKUP.has(this.tool)) return; // let the browser select text
const [x, y] = ov.toPdf(hit.vx, hit.vy);
if (this.tool === "select") {
const id = e.target?.closest?.(".mark")?.dataset?.id || null;
// Double-click is detected here rather than from a `dblclick` listener.
// Selecting a mark calls preventDefault() on the pointerdown, and that
// suppresses the compatibility mouse events the browser would otherwise
// synthesise a dblclick from — so the native event never arrives.
const now = Date.now();
const double = !!id && this._lastClick?.id === id && now - this._lastClick.t < DOUBLE_CLICK_MS;
this._lastClick = id ? { id, t: now } : null;
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 second kind of double-click, by POSITION rather than by mark: the
// page itself and the document's own words are not marks, and both
// answer to being double-clicked.
const near = this._lastPress &&
Math.hypot(e.clientX - this._lastPress.x, e.clientY - this._lastPress.y) < 6;
const doubleAnywhere = !!near && now - this._lastPress.t < DOUBLE_CLICK_MS;
this._lastPress = { x: e.clientX, y: e.clientY, t: now };
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
if (!id) {
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 span = e.target?.closest?.(".textLayer span");
// Double-click on the document's own words: replace that run, the
// same thing the text tool does, because "edit what is there" and
// "select something" are one gesture in every editor people already
// know.
if (doubleAnywhere && span && span.textContent.trim()) {
this._setSelected(null);
e.preventDefault();
this._replaceRunAt(e, hit.uid);
return;
}
// Double-click on bare page: start typing there.
if (doubleAnywhere && !span) {
this._setSelected(null);
e.preventDefault();
this.host.beginText?.({ uid: hit.uid, x, y });
return;
}
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
// Empty page: start a rubber band. A press that never moves is just
// a click on nothing, and onUp treats it as one — it clears the
// selection, exactly as it always did.
this._setSelected(null);
// ...unless the press is on one of the document's own form fields.
// Starting a band there means calling preventDefault, and that is
// what gives a text field its caret, so the field would go dead.
if (e.target?.closest?.(".annotationLayer")) return;
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
// ...or on the document's own words, where a drag means selecting
// them to copy. A band is started from the space between things,
// which is where a hand reaches for one anyway — except on a page
// that is wall-to-wall text and has no such space, which is what
// Alt is for.
if (span && !e.altKey) return;
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.drag = { kind: "marquee", uid: hit.uid, x0: x, y0: y, x, y,
cx: e.clientX, cy: e.clientY, moved: false };
e.preventDefault();
return;
}
// Shift adds to, or takes away from, what is already selected.
if (e.shiftKey) {
const next = new Set(this.selection);
next.has(id) ? next.delete(id) : next.add(id);
this._setSelection([...next]);
e.preventDefault();
return;
}
// Pressing on a mark that is already part of a group keeps the group,
// so the drag moves all of it. Pressing on any other mark selects just
// that one.
if (!this.selection.has(id)) this._setSelected(id);
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
e.preventDefault();
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 (double && this.selection.size === 1) {
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 a = this.model.annot(id);
2026-09-27 13:16:07 +02:00
// A stamp is edited where it sits; a replaced run keeps its dialog,
// which is where it explains what it is replacing.
if (a?.kind === "text") { this.host.beginText?.({ id }); return; }
if (a?.kind === "textedit") { this.host.editText?.(id, null); return; }
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
this.drag = this._beginMove(hit.uid, id, x, y, e);
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
return;
}
if (this.tool === "text") {
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
// One text tool, two jobs, told apart by what is under the click:
// words that are already in the document get replaced, and anywhere
// else gets a caret. They were two buttons, which asked the user to
// decide in advance something the click already says.
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
e.preventDefault();
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
if (this._replaceRunAt(e, hit.uid)) return;
this.host.beginText?.({ uid: hit.uid, x, y });
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
return;
}
if (this.tool === "signature") {
this._placeSignature(hit.uid, x, y);
e.preventDefault();
return;
}
// Drawing tools: start a drag with a live preview.
this.drag = { kind: this.tool, uid: hit.uid, x0: x, y0: y, x, y,
pts: this.tool === "pen" ? [[x, y]] : null,
cx: e.clientX, cy: e.clientY, moved: false };
e.preventDefault();
}
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
/**
* If the press landed on a run of the document's own text, open it for
* replacement and say so.
*
* A text-layer span IS a run, which is why this is a one-line test: pdf.js
* groups glyphs into spans by the same runs the PDF itself declares.
* @returns true when it took the click.
*/
_replaceRunAt(e, uid) {
const span = e.target?.closest?.(".textLayer span");
if (!span || !span.textContent.trim()) return false;
const run = this.describeRun(span, uid);
if (!run) return false;
// A press on text starts a browser selection; the dialog is about to
// cover it, and a half-highlighted line underneath reads as a fault.
try { window.getSelection()?.removeAllRanges(); } catch {}
this.host.editRun?.(run);
return true;
}
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
/**
* A move drag, of one mark or of the whole group.
*
* `origs` holds every mark's starting geometry: each one shifts from where
* IT was, not from where the one under the cursor was.
*/
_beginMove(uid, id, x, y, e) {
const ids = this.selection.has(id) ? [...this.selection] : [id];
const origs = new Map();
for (const i of ids) {
const a = this.model.annot(i);
// A group can only be dragged within one page: a mark carries its page,
// and PDF user space means nothing across two of them.
if (a && a.page === uid) origs.set(i, structuredClone(a));
}
return { kind: "move", uid, id, x0: x, y0: y, origs,
orig: origs.get(id) || structuredClone(this.model.annot(id)),
cx: e.clientX, cy: e.clientY, moved: false };
}
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
onMove(e) {
const d = this.drag;
if (!d) return;
const ov = this.strip.overlayFor(d.uid);
if (!ov || !ov.viewport) return;
const view = this.strip.views.get(d.uid);
if (!view) { this.drag = null; return; }
const rect = view.pv.div.getBoundingClientRect();
// A drag that wanders off the page stays on it. Marks outside the page
// box are invisible in any PDF reader, so letting one be drawn there
// would only produce something that disappears on save.
const cx = Math.min(Math.max(e.clientX, rect.left), rect.right);
const cy = Math.min(Math.max(e.clientY, rect.top), rect.bottom);
const [x, y] = ov.toPdf(cx - rect.left, cy - rect.top);
d.x = x; d.y = y;
if (!d.moved && Math.hypot(e.clientX - (d.cx ?? e.clientX), e.clientY - (d.cy ?? e.clientY)) > MIN_DRAG) d.moved = true;
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 (d.kind === "marquee") {
this.strip.setPreview(d.uid, { kind: "marquee", x0: d.x0, y0: d.y0, x1: x, y1: y });
return;
}
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
if (d.kind === "rotate") {
const a = this.model.annot(d.id);
if (!a) return;
// User space has y upwards, so a turn that looks clockwise on screen is
// counter-clockwise here; negating keeps "drag right" meaning "turn
// right" to the person doing it.
const angle = -Math.atan2(y - d.cy0, x - d.cx0) * 180 / Math.PI - 90;
if (d.from === null) d.from = angle - (d.orig.rotate || 0);
let deg = angle - d.from;
// Shift snaps, for the common case of straightening something by a few
// degrees or turning it exactly a quarter.
if (e.shiftKey) deg = Math.round(deg / 15) * 15;
a.rotate = ((deg % 360) + 360) % 360;
d.moved = true;
this.strip.refreshOverlay(d.uid, this.selection);
this.host.rotating?.(a.rotate);
return;
}
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
if (d.kind === "move") {
// Live and un-journalled; the whole gesture is committed once on release
// so undo steps back over the drag, not over each mouse 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
for (const [id, orig] of d.origs) {
const a = this.model.annot(id);
if (a) Object.assign(a, translatePatch(orig, x - d.x0, y - d.y0));
}
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
d.moved = true;
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.strip.refreshOverlay(d.uid, this.selection);
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
return;
}
if (d.kind === "resize") {
const a = this.model.annot(d.id);
if (!a) return;
// Always mapped from the ORIGINAL geometry, never from the last frame,
// so a drag back to where it started restores the mark exactly.
if (d.handle === "p1" || d.handle === "p2") {
Object.assign(a, d.handle === "p1" ? { x1: x, y1: y } : { x2: x, y2: y });
} else {
const uniform = d.orig.kind === "text" || d.orig.kind === "signature";
const to = resizeBox(d.from, d.handle, x - d.x0, y - d.y0, { uniform });
Object.assign(a, scaleToBox(d.orig, d.from, to));
}
d.moved = true;
this.strip.refreshOverlay(d.uid, d.id);
return;
}
if (d.kind === "pen") { d.pts.push([x, y]); }
this.strip.setPreview(d.uid, this._previewAnnot(d));
}
onUp(e) {
2026-09-27 13:16:07 +02:00
// A handle or move drag is finished here whatever tool is armed. Without
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 the text-markup tools would return on the line below and strand a
// resize half-done: applied to the live mark, never journalled, and with
// this.drag still set so the next press behaves oddly.
const inFlight = this.drag;
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 live = inFlight && ["resize", "move", "rotate", "marquee"].includes(inFlight.kind);
2026-09-27 13:16:07 +02:00
if (!live && TEXT_MARKUP.has(this.tool)) {
this._commitTextMarkup();
return;
}
const d = inFlight;
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.drag = null;
if (!d) return;
this.strip.setPreview(null, null);
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 (d.kind === "marquee") {
// A press that never moved is a click on empty space: the selection is
// already cleared, and there is nothing to gather.
if (d.moved) this._selectWithin(d.uid, d.x0, d.y0, d.x, d.y);
else this.strip.refreshAllOverlays(this.selection);
this.host.onChange?.();
return;
}
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
if (d.kind === "move" || d.kind === "resize" || d.kind === "rotate") {
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 a = this.model.annot(d.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 (!a || !d.moved) { this.strip.refreshOverlay(d.uid, this.selection); return; }
if (d.origs && d.origs.size > 1) this._commitGroupMove(d);
else this._commitLiveEdit(a, d.orig);
this._setSelection([...this.selection]);
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.host.onChange?.();
return;
}
const a = this._previewAnnot(d, true);
if (a) {
const made = this.model.addAnnot(a);
this._setSelected(made.id);
} else {
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.strip.refreshAllOverlays(this.selection);
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.host.onChange?.();
}
// A live drag mutates the mark in place so the screen keeps up. To land it
// on the undo stack as ONE step, put the original back and re-apply the
// result through the model.
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
/**
* Everything the rubber band touched, on the page it was drawn on.
*
* Touching counts, not enclosing. A band you have to draw right around a
* long arrow to catch it is a band you draw twice; catching what you swept
* over is what people expect from sweeping.
*/
_selectWithin(uid, x0, y0, x1, y1) {
const box = {
x: Math.min(x0, x1), y: Math.min(y0, y1),
w: Math.abs(x1 - x0), h: Math.abs(y1 - y0),
};
const hits = this.model.annotsFor(uid)
.filter((a) => { const b = boundsOf(a); return b && boxesOverlap(b, box); })
.map((a) => a.id);
this._setSelection(hits);
// Only the empty case is worth a toast; when it caught something, the
// boxes on screen and the status line have already said so.
if (!hits.length) this.host.toast?.("Nothing in that area");
}
/** A group drag, landed on the undo stack as one step. */
_commitGroupMove(d) {
const after = new Map();
for (const id of d.origs.keys()) {
const a = this.model.annot(id);
if (a) after.set(id, structuredClone(a));
}
// Put every mark back before re-applying through the model, so the
// journal's "before" is the state the drag actually started from.
for (const [id, orig] of d.origs) {
const a = this.model.annot(id);
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
if (a) restoreExactly(a, orig);
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.model.moveAnnots([...after.keys()], (a) => {
const to = after.get(a.id);
const patch = {};
for (const k of Object.keys(to)) {
if (k !== "id" && k !== "page" && k !== "kind") patch[k] = to[k];
}
return patch;
});
}
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
_commitLiveEdit(a, orig) {
const after = structuredClone(a);
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
restoreExactly(a, orig);
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 patch = {};
for (const k of Object.keys(after)) {
if (k !== "id" && k !== "page" && k !== "kind") patch[k] = after[k];
}
this.model.moveAnnot(a.id, patch);
}
// The mark a drag currently describes. `final` rejects degenerate ones so a
// stray click does not leave an invisible zero-size rectangle behind.
_previewAnnot(d, final = false) {
const common = { page: d.uid, color: this.color, width: this.width };
switch (d.kind) {
case "pen": {
if (final && d.pts.length < 2) return null;
return { kind: "pen", ...common, pts: d.pts.slice() };
}
case "arrow": case "line": {
if (final && !d.moved) return null;
return { kind: d.kind, ...common, x1: d.x0, y1: d.y0, x2: d.x, y2: d.y };
}
case "rect": case "ellipse": case "redact": {
const b = normBox(d.x0, d.y0, d.x, d.y);
if (final && (b.w < 2 || b.h < 2)) return null;
if (d.kind === "redact") return { kind: "redact", page: d.uid, ...b };
return { kind: d.kind, ...common, ...b, fill: !!this.fill, opacity: this.fillOpacity ?? 0.25 };
}
default: return null;
}
}
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
/**
* Everything needed to replace one run of the document's text, in the
* coordinates the rest of the editor speaks.
*
* The text layer is the source: pdf.js has already placed a span over every
* run, and its `--font-height` carries the run's size in unscaled PDF points
* — so the size survives zooming, which reading it off the rendered box
* would not.
*/
describeRun(span, uid) {
const ov = this.strip.overlayFor(uid);
const view = this.strip.views.get(uid);
if (!ov?.viewport || !view) return null;
const pr = view.pv.div.getBoundingClientRect();
const r = span.getBoundingClientRect();
if (r.width < 1 || r.height < 1) return null;
const [ax, ay] = ov.toPdf(r.left - pr.left, r.bottom - pr.top);
const [bx, by] = ov.toPdf(r.right - pr.left, r.top - pr.top);
const box = normBox(ax, ay, bx, by);
const declared = parseFloat(getComputedStyle(span).getPropertyValue("--font-height"));
const size = Number.isFinite(declared) && declared > 0 ? declared : box.h * 0.8;
// PDF text sits on a baseline; the span's top is the top of the em box,
// so the baseline hangs one ascent below it.
const baseline = box.y + box.h - size * ASCENT;
// The span does NOT enclose the glyphs. pdf.js sizes it to the em box, so
// every descender — the tail of a y, a comma — falls below it, and a cover
// cut to the span leaves a row of little hooks showing under the
// replacement. Reach below the baseline far enough to take them, and a
// hair above and to the sides for accents and side bearings.
const bottom = Math.min(box.y, baseline - size * DESCENDER);
const top = box.y + box.h + size * 0.06;
return {
uid,
text: span.textContent,
box: { x: box.x - 0.6, y: bottom, w: box.w + 1.2, h: top - bottom },
size,
baseline,
clientRect: { left: r.left, top: r.top, width: r.width, height: r.height },
};
}
/** Commit a replaced run as a mark, selected and ready to nudge. */
replaceRun(run, props) {
const a = this.model.addAnnot({
kind: "textedit", page: run.uid,
x: run.box.x, y: run.box.y, w: run.box.w, h: run.box.h,
baseline: run.baseline,
text: props.text, size: props.size ?? run.size,
color: props.color || "#000000",
cover: props.cover || "#ffffff",
bold: !!props.bold, italic: !!props.italic,
});
this._afterStamp(a);
return 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
// ---- text markup --------------------------------------------------
// Take the live selection's client rects, drop them onto whichever page each
// one sits on, and convert to user space. A selection spanning a page break
// therefore produces one mark per page, which is also what the PDF format
// wants. Highlight, underline and strikeout differ only in `kind`.
_commitTextMarkup() {
const kind = this.tool;
const sel = window.getSelection();
if (!sel || sel.isCollapsed || sel.rangeCount === 0) return;
const byPage = new Map();
for (let i = 0; i < sel.rangeCount; i++) {
for (const cr of sel.getRangeAt(i).getClientRects()) {
if (cr.width < 0.5 || cr.height < 0.5) continue;
const hit = this.strip.pageAt(cr.left + cr.width / 2, cr.top + cr.height / 2);
if (!hit) continue;
const ov = this.strip.overlayFor(hit.uid);
if (!ov || !ov.viewport) continue;
const r = this.strip.views.get(hit.uid).pv.div.getBoundingClientRect();
const [ax, ay] = ov.toPdf(cr.left - r.left, cr.top - r.top);
const [bx, by] = ov.toPdf(cr.right - r.left, cr.bottom - r.top);
const box = normBox(ax, ay, bx, by);
if (box.w < 0.5 || box.h < 0.5) continue;
if (!byPage.has(hit.uid)) byPage.set(hit.uid, []);
byPage.get(hit.uid).push(box);
}
}
if (!byPage.size) return;
let last = null;
for (const [uid, rects] of byPage) {
last = this.model.addAnnot({
kind, page: uid, color: this.color,
opacity: kind === "highlight" ? 0.38 : 1,
width: kind === "highlight" ? undefined : Math.max(0.8, this.width * 0.55),
rects: mergeRows(rects),
});
}
sel.removeAllRanges();
this._setSelected(last ? last.id : null);
this.host.onChange?.();
}
// ---- stamps -------------------------------------------------------
/** Place a new text mark, or rewrite an existing one, as one undo step. */
2026-09-27 13:16:07 +02:00
/**
* The colour to write WORDS in.
*
* The palette's default is highlighter yellow, which is right for a
* highlight and unreadable as text on a white page. A signature already
* made this swap; text was left behind, and you now watch yourself type
* in it, so it is impossible to miss.
*/
get ink() { return this.color === "#ffd400" ? "#1a1f2b" : this.color; }
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
placeText({ uid, x, y }, props) {
if (!String(props.text || "").trim()) return null;
const a = this.model.addAnnot({
2026-09-27 13:16:07 +02:00
kind: "text", page: uid, color: this.ink, x, y,
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
size: props.size, text: props.text, bold: !!props.bold, italic: !!props.italic,
});
this._afterStamp(a);
return a;
}
updateText(id, props) {
const a = this.model.annot(id);
if (!a) return null;
if (!String(props.text || "").trim()) { this.deleteSelected(); return null; }
this.model.moveAnnot(id, {
text: props.text, size: props.size, bold: !!props.bold, italic: !!props.italic,
});
delete a._w; // width changed; let the renderer re-measure
this._setSelected(id);
this.host.onChange?.();
return a;
}
async _placeSignature(uid, x, y) {
2026-09-27 13:16:07 +02:00
// Which signature is decided when the tool is armed, not on every click,
// so placing three initials in three boxes is three clicks. This is only
// the fallback for a tool armed with nothing chosen yet.
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
let sig = this.host.signature();
if (!sig) {
2026-09-27 13:16:07 +02:00
sig = await this.host.chooseSignature?.();
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
if (!sig) return;
}
const w = SIG_WIDTH_PT;
const h = w * (sig.aspect || 0.34);
const a = this.model.addAnnot({
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
// The ink the signature was drawn in, not whatever swatch is armed.
kind: "signature", page: uid, color: sig.color || this.ink,
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
width: 1.6, x, y: y - h, w, h, strokes: sig.strokes,
});
this._afterStamp(a);
}
// Drop back to select with the new mark live, so it can be nudged, resized
// or deleted straight away and a second click does not stamp a duplicate.
_afterStamp(a) {
this.host.selectTool?.("select");
this._setSelected(a.id);
this.host.onChange?.();
}
/** Bounds of the current selection, for the status line. */
selectedBounds() {
const a = this.model.annot(this.selected);
return a ? boundsOf(a) : null;
}
}
// Client rects for a text selection come one per text run, so a single
// highlighted line arrives as a dozen slivers. Merging the ones that share a
// baseline turns them back into one band per line — fewer objects in the saved
// file, and no seams where two slivers overlap at 38% opacity.
function mergeRows(rects) {
const rows = [];
for (const r of rects.slice().sort((a, b) => b.y - a.y || a.x - b.x)) {
const row = rows.find((q) => Math.abs((q.y + q.h / 2) - (r.y + r.h / 2)) < Math.min(q.h, r.h) * 0.5);
if (!row) { rows.push({ ...r }); continue; }
const x0 = Math.min(row.x, r.x), x1 = Math.max(row.x + row.w, r.x + r.w);
const y0 = Math.min(row.y, r.y), y1 = Math.max(row.y + row.h, r.y + r.h);
row.x = x0; row.w = x1 - x0; row.y = y0; row.h = y1 - y0;
}
return rows;
}