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): 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
|
|
|
import { ASCENT, boundsOf, 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. */
|
|
|
|
|
const STAMP_TOOLS = new Set(["text", "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
|
|
|
/** Tools that act on a run of the document's OWN text. */
|
|
|
|
|
const RUN_TOOLS = new Set(["edittext"]);
|
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,
|
feat(pdf-editor): type on the page, keep more than one signature, move what you drew
Three things the editor made you work around.
Text was typed into a dialog and then placed, so you chose a size and a
weight for words you could not see against the page they were going on.
The click now opens a caret where you clicked, in the font, size and
colour the words will have, with the style bar over it; double-clicking a
stamp reopens it in place. Lining a CSS line box up with a PDF baseline is
measured from the font's own metrics, not guessed.
A signature lived in a single slot. There was nowhere to keep initials as
well as a name, nowhere to change the one you had, and reaching for the
tool again simply stamped the first one — which is the same fault three
times: one slot. It is a library now, with redraw, rename and delete, and
the choice is made when the tool is picked up, so placing stays one click.
An existing single signature is carried into it rather than dropped.
The mark you had just drawn could be resized by its handles and not moved
by its middle, because only the select tool let marks be hit-tested at
all. The SELECTED mark now takes a press whatever tool is armed. A press
anywhere else still draws, and an unfilled shape is still grabbed by its
outline — the same rule select has always followed.
Also: words default to dark ink rather than highlighter yellow, which was
unreadable on white and is now impossible to miss, since you watch
yourself type it.
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;
|
|
|
|
|
this.selected = null;
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
get mode() {
|
|
|
|
|
if (this.tool === "select") return "select";
|
|
|
|
|
if (TEXT_MARKUP.has(this.tool)) return "highlight";
|
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
|
|
|
if (RUN_TOOLS.has(this.tool)) return "pickrun";
|
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. */
|
|
|
|
|
_setSelected(id) {
|
|
|
|
|
this.selected = id;
|
|
|
|
|
this.strip.refreshAllOverlays(id);
|
|
|
|
|
this.host.onSelectionChange?.(id);
|
|
|
|
|
}
|
|
|
|
|
select(id) { this._setSelected(id); }
|
|
|
|
|
|
|
|
|
|
setColor(c) {
|
|
|
|
|
this.color = c;
|
|
|
|
|
const a = this.model.annot(this.selected);
|
|
|
|
|
if (a && a.kind !== "redact") this.patchSelected({ color: c });
|
|
|
|
|
}
|
|
|
|
|
setFill(on) {
|
|
|
|
|
this.fill = !!on;
|
|
|
|
|
const a = this.model.annot(this.selected);
|
|
|
|
|
if (a && (a.kind === "rect" || a.kind === "ellipse")) this.patchSelected({ fill: this.fill });
|
|
|
|
|
}
|
|
|
|
|
setWidth(w) {
|
|
|
|
|
this.width = w;
|
|
|
|
|
const a = this.model.annot(this.selected);
|
|
|
|
|
if (a && a.kind !== "text" && a.kind !== "redact") this.patchSelected({ width: w });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Change the selected mark, as one undo step. */
|
|
|
|
|
patchSelected(patch) {
|
|
|
|
|
if (!this.selected) return false;
|
|
|
|
|
const ok = this.model.moveAnnot(this.selected, patch);
|
|
|
|
|
this.strip.refreshAllOverlays(this.selected);
|
|
|
|
|
this.host.onSelectionChange?.(this.selected);
|
|
|
|
|
this.host.onChange?.();
|
|
|
|
|
return ok;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
deleteSelected() {
|
|
|
|
|
if (!this.selected) return false;
|
|
|
|
|
const ok = this.model.removeAnnot(this.selected);
|
|
|
|
|
this._setSelected(null);
|
|
|
|
|
this.host.onChange?.();
|
|
|
|
|
return ok;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Drop a copy of the selected mark, offset so it is visibly a second one. */
|
|
|
|
|
duplicateSelected() {
|
|
|
|
|
const a = this.model.annot(this.selected);
|
|
|
|
|
if (!a) return null;
|
|
|
|
|
const off = 12;
|
|
|
|
|
const copy = { ...structuredClone(a), ...translatePatch(a, off, -off) };
|
|
|
|
|
delete copy.id;
|
|
|
|
|
delete copy._w;
|
|
|
|
|
const made = this.model.addAnnot(copy);
|
|
|
|
|
this._setSelected(made.id);
|
|
|
|
|
this.host.onChange?.();
|
|
|
|
|
return made;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- pointer ------------------------------------------------------
|
|
|
|
|
onDown(e) {
|
|
|
|
|
if (e.button !== 0) return;
|
|
|
|
|
|
fix(pdf-editor): a shape can be resized the moment it is drawn
Drawing a rectangle left it selected with its handles showing, and then
refused to let you touch them. The handles were only live under the select
tool, so sizing the shape you were still looking at meant a trip to the
toolbar and back — for a gesture the editor had already drawn the grips for.
Handles are now grabbable whatever tool is armed. They cannot be confused
with drawing: a handle is a nine-pixel square that only exists while
something is selected, nobody lands on one by accident, and Escape drops the
selection if the space is wanted back for drawing. The original gate was
protecting against a collision that does not really happen, at the cost of
one that does.
Making them universal opened a trap in the release path, fixed here too: the
text-markup tools return early from onUp to commit a text selection, which
would have stranded a resize half-done — applied to the live mark, never
journalled, with the drag still set so the next press behaved oddly. A
handle drag is now finished first, whatever tool is armed.
2026-09-22 21:33:48 +02:00
|
|
|
// 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);
|
|
|
|
|
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
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
feat(pdf-editor): type on the page, keep more than one signature, move what you drew
Three things the editor made you work around.
Text was typed into a dialog and then placed, so you chose a size and a
weight for words you could not see against the page they were going on.
The click now opens a caret where you clicked, in the font, size and
colour the words will have, with the style bar over it; double-clicking a
stamp reopens it in place. Lining a CSS line box up with a PDF baseline is
measured from the font's own metrics, not guessed.
A signature lived in a single slot. There was nowhere to keep initials as
well as a name, nowhere to change the one you had, and reaching for the
tool again simply stamped the first one — which is the same fault three
times: one slot. It is a library now, with redraw, rename and delete, and
the choice is made when the tool is picked up, so placing stays one click.
An existing single signature is carried into it rather than dropped.
The mark you had just drawn could be resized by its handles and not moved
by its middle, because only the select tool let marks be hit-tested at
all. The SELECTED mark now takes a press whatever tool is armed. A press
anywhere else still draws, and an unfilled shape is still grabbed by its
outline — the same rule select has always followed.
Also: words default to dark ink rather than highlighter yellow, which was
unreadable on white and is now impossible to miss, since you watch
yourself type it.
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);
|
|
|
|
|
this.drag = { kind: "move", uid: a.page, id: a.id, x0: mx, y0: my,
|
|
|
|
|
orig: structuredClone(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
|
|
|
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
|
|
|
|
|
|
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
|
|
|
// Editing the document's own text: the click has to land on a run of it,
|
|
|
|
|
// which is exactly what a text-layer span is. Anywhere else is a miss, and
|
|
|
|
|
// saying so beats silently doing nothing.
|
|
|
|
|
if (this.tool === "edittext") {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
const span = e.target?.closest?.(".textLayer span");
|
|
|
|
|
if (!span || !span.textContent.trim()) {
|
|
|
|
|
this.host.toast?.("Click on a line of the document's text to replace it.", true);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const run = this.describeRun(span, hit.uid);
|
|
|
|
|
if (run) this.host.editRun?.(run);
|
|
|
|
|
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 [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;
|
|
|
|
|
|
|
|
|
|
this._setSelected(id);
|
|
|
|
|
if (!id) return;
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
if (double) {
|
|
|
|
|
const a = this.model.annot(id);
|
feat(pdf-editor): type on the page, keep more than one signature, move what you drew
Three things the editor made you work around.
Text was typed into a dialog and then placed, so you chose a size and a
weight for words you could not see against the page they were going on.
The click now opens a caret where you clicked, in the font, size and
colour the words will have, with the style bar over it; double-clicking a
stamp reopens it in place. Lining a CSS line box up with a PDF baseline is
measured from the font's own metrics, not guessed.
A signature lived in a single slot. There was nowhere to keep initials as
well as a name, nowhere to change the one you had, and reaching for the
tool again simply stamped the first one — which is the same fault three
times: one slot. It is a library now, with redraw, rename and delete, and
the choice is made when the tool is picked up, so placing stays one click.
An existing single signature is carried into it rather than dropped.
The mark you had just drawn could be resized by its handles and not moved
by its middle, because only the select tool let marks be hit-tested at
all. The SELECTED mark now takes a press whatever tool is armed. A press
anywhere else still draws, and an unfilled shape is still grabbed by its
outline — the same rule select has always followed.
Also: words default to dark ink rather than highlighter yellow, which was
unreadable on white and is now impossible to miss, since you watch
yourself type it.
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
|
|
|
}
|
|
|
|
|
this.drag = { kind: "move", uid: hit.uid, id, x0: x, y0: y,
|
|
|
|
|
orig: structuredClone(this.model.annot(id)), cx: e.clientX, cy: e.clientY, moved: false };
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.tool === "text") {
|
feat(pdf-editor): type on the page, keep more than one signature, move what you drew
Three things the editor made you work around.
Text was typed into a dialog and then placed, so you chose a size and a
weight for words you could not see against the page they were going on.
The click now opens a caret where you clicked, in the font, size and
colour the words will have, with the style bar over it; double-clicking a
stamp reopens it in place. Lining a CSS line box up with a PDF baseline is
measured from the font's own metrics, not guessed.
A signature lived in a single slot. There was nowhere to keep initials as
well as a name, nowhere to change the one you had, and reaching for the
tool again simply stamped the first one — which is the same fault three
times: one slot. It is a library now, with redraw, rename and delete, and
the choice is made when the tool is picked up, so placing stays one click.
An existing single signature is carried into it rather than dropped.
The mark you had just drawn could be resized by its handles and not moved
by its middle, because only the select tool let marks be hit-tested at
all. The SELECTED mark now takes a press whatever tool is armed. A press
anywhere else still draws, and an unfilled shape is still grabbed by its
outline — the same rule select has always followed.
Also: words default to dark ink rather than highlighter yellow, which was
unreadable on white and is now impossible to miss, since you watch
yourself type it.
2026-09-27 13:16:07 +02:00
|
|
|
// A caret on the page, not a dialog about the page.
|
|
|
|
|
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
|
|
|
e.preventDefault();
|
|
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
if (d.kind === "move") {
|
|
|
|
|
const a = this.model.annot(d.id);
|
|
|
|
|
if (!a) return;
|
|
|
|
|
// Live and un-journalled; the whole gesture is committed once on release
|
|
|
|
|
// so undo steps back over the drag, not over each mouse move.
|
|
|
|
|
Object.assign(a, translatePatch(d.orig, x - d.x0, y - d.y0));
|
|
|
|
|
d.moved = true;
|
|
|
|
|
this.strip.refreshOverlay(d.uid, d.id);
|
|
|
|
|
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) {
|
feat(pdf-editor): type on the page, keep more than one signature, move what you drew
Three things the editor made you work around.
Text was typed into a dialog and then placed, so you chose a size and a
weight for words you could not see against the page they were going on.
The click now opens a caret where you clicked, in the font, size and
colour the words will have, with the style bar over it; double-clicking a
stamp reopens it in place. Lining a CSS line box up with a PDF baseline is
measured from the font's own metrics, not guessed.
A signature lived in a single slot. There was nowhere to keep initials as
well as a name, nowhere to change the one you had, and reaching for the
tool again simply stamped the first one — which is the same fault three
times: one slot. It is a library now, with redraw, rename and delete, and
the choice is made when the tool is picked up, so placing stays one click.
An existing single signature is carried into it rather than dropped.
The mark you had just drawn could be resized by its handles and not moved
by its middle, because only the select tool let marks be hit-tested at
all. The SELECTED mark now takes a press whatever tool is armed. A press
anywhere else still draws, and an unfilled shape is still grabbed by its
outline — the same rule select has always followed.
Also: words default to dark ink rather than highlighter yellow, which was
unreadable on white and is now impossible to miss, since you watch
yourself type it.
2026-09-27 13:16:07 +02:00
|
|
|
// A handle or move drag is finished here whatever tool is armed. Without
|
|
|
|
|
// this the
|
fix(pdf-editor): a shape can be resized the moment it is drawn
Drawing a rectangle left it selected with its handles showing, and then
refused to let you touch them. The handles were only live under the select
tool, so sizing the shape you were still looking at meant a trip to the
toolbar and back — for a gesture the editor had already drawn the grips for.
Handles are now grabbable whatever tool is armed. They cannot be confused
with drawing: a handle is a nine-pixel square that only exists while
something is selected, nobody lands on one by accident, and Escape drops the
selection if the space is wanted back for drawing. The original gate was
protecting against a collision that does not really happen, at the cost of
one that does.
Making them universal opened a trap in the release path, fixed here too: the
text-markup tools return early from onUp to commit a text selection, which
would have stranded a resize half-done — applied to the live mark, never
journalled, with the drag still set so the next press behaved oddly. A
handle drag is now finished first, whatever tool is armed.
2026-09-22 21:33:48 +02:00
|
|
|
// 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): type on the page, keep more than one signature, move what you drew
Three things the editor made you work around.
Text was typed into a dialog and then placed, so you chose a size and a
weight for words you could not see against the page they were going on.
The click now opens a caret where you clicked, in the font, size and
colour the words will have, with the style bar over it; double-clicking a
stamp reopens it in place. Lining a CSS line box up with a PDF baseline is
measured from the font's own metrics, not guessed.
A signature lived in a single slot. There was nowhere to keep initials as
well as a name, nowhere to change the one you had, and reaching for the
tool again simply stamped the first one — which is the same fault three
times: one slot. It is a library now, with redraw, rename and delete, and
the choice is made when the tool is picked up, so placing stays one click.
An existing single signature is carried into it rather than dropped.
The mark you had just drawn could be resized by its handles and not moved
by its middle, because only the select tool let marks be hit-tested at
all. The SELECTED mark now takes a press whatever tool is armed. A press
anywhere else still draws, and an unfilled shape is still grabbed by its
outline — the same rule select has always followed.
Also: words default to dark ink rather than highlighter yellow, which was
unreadable on white and is now impossible to miss, since you watch
yourself type it.
2026-09-27 13:16:07 +02:00
|
|
|
const live = inFlight && (inFlight.kind === "resize" || inFlight.kind === "move");
|
|
|
|
|
if (!live && TEXT_MARKUP.has(this.tool)) {
|
fix(pdf-editor): a shape can be resized the moment it is drawn
Drawing a rectangle left it selected with its handles showing, and then
refused to let you touch them. The handles were only live under the select
tool, so sizing the shape you were still looking at meant a trip to the
toolbar and back — for a gesture the editor had already drawn the grips for.
Handles are now grabbable whatever tool is armed. They cannot be confused
with drawing: a handle is a nine-pixel square that only exists while
something is selected, nobody lands on one by accident, and Escape drops the
selection if the space is wanted back for drawing. The original gate was
protecting against a collision that does not really happen, at the cost of
one that does.
Making them universal opened a trap in the release path, fixed here too: the
text-markup tools return early from onUp to commit a text selection, which
would have stranded a resize half-done — applied to the live mark, never
journalled, with the drag still set so the next press behaved oddly. A
handle drag is now finished first, whatever tool is armed.
2026-09-22 21:33:48 +02:00
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
if (d.kind === "move" || d.kind === "resize") {
|
|
|
|
|
const a = this.model.annot(d.id);
|
|
|
|
|
if (!a || !d.moved) { this.strip.refreshOverlay(d.uid, this.selected); return; }
|
|
|
|
|
this._commitLiveEdit(a, d.orig);
|
|
|
|
|
this._setSelected(d.id);
|
|
|
|
|
this.host.onChange?.();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const a = this._previewAnnot(d, true);
|
|
|
|
|
if (a) {
|
|
|
|
|
const made = this.model.addAnnot(a);
|
|
|
|
|
this._setSelected(made.id);
|
|
|
|
|
} else {
|
|
|
|
|
this.strip.refreshAllOverlays(this.selected);
|
|
|
|
|
}
|
|
|
|
|
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.
|
|
|
|
|
_commitLiveEdit(a, orig) {
|
|
|
|
|
const after = structuredClone(a);
|
|
|
|
|
Object.assign(a, orig);
|
|
|
|
|
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. */
|
feat(pdf-editor): type on the page, keep more than one signature, move what you drew
Three things the editor made you work around.
Text was typed into a dialog and then placed, so you chose a size and a
weight for words you could not see against the page they were going on.
The click now opens a caret where you clicked, in the font, size and
colour the words will have, with the style bar over it; double-clicking a
stamp reopens it in place. Lining a CSS line box up with a PDF baseline is
measured from the font's own metrics, not guessed.
A signature lived in a single slot. There was nowhere to keep initials as
well as a name, nowhere to change the one you had, and reaching for the
tool again simply stamped the first one — which is the same fault three
times: one slot. It is a library now, with redraw, rename and delete, and
the choice is made when the tool is picked up, so placing stays one click.
An existing single signature is carried into it rather than dropped.
The mark you had just drawn could be resized by its handles and not moved
by its middle, because only the select tool let marks be hit-tested at
all. The SELECTED mark now takes a press whatever tool is armed. A press
anywhere else still draws, and an unfilled shape is still grabbed by its
outline — the same rule select has always followed.
Also: words default to dark ink rather than highlighter yellow, which was
unreadable on white and is now impossible to miss, since you watch
yourself type it.
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({
|
feat(pdf-editor): type on the page, keep more than one signature, move what you drew
Three things the editor made you work around.
Text was typed into a dialog and then placed, so you chose a size and a
weight for words you could not see against the page they were going on.
The click now opens a caret where you clicked, in the font, size and
colour the words will have, with the style bar over it; double-clicking a
stamp reopens it in place. Lining a CSS line box up with a PDF baseline is
measured from the font's own metrics, not guessed.
A signature lived in a single slot. There was nowhere to keep initials as
well as a name, nowhere to change the one you had, and reaching for the
tool again simply stamped the first one — which is the same fault three
times: one slot. It is a library now, with redraw, rename and delete, and
the choice is made when the tool is picked up, so placing stays one click.
An existing single signature is carried into it rather than dropped.
The mark you had just drawn could be resized by its handles and not moved
by its middle, because only the select tool let marks be hit-tested at
all. The SELECTED mark now takes a press whatever tool is armed. A press
anywhere else still draws, and an unfilled shape is still grabbed by its
outline — the same rule select has always followed.
Also: words default to dark ink rather than highlighter yellow, which was
unreadable on white and is now impossible to miss, since you watch
yourself type it.
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) {
|
feat(pdf-editor): type on the page, keep more than one signature, move what you drew
Three things the editor made you work around.
Text was typed into a dialog and then placed, so you chose a size and a
weight for words you could not see against the page they were going on.
The click now opens a caret where you clicked, in the font, size and
colour the words will have, with the style bar over it; double-clicking a
stamp reopens it in place. Lining a CSS line box up with a PDF baseline is
measured from the font's own metrics, not guessed.
A signature lived in a single slot. There was nowhere to keep initials as
well as a name, nowhere to change the one you had, and reaching for the
tool again simply stamped the first one — which is the same fault three
times: one slot. It is a library now, with redraw, rename and delete, and
the choice is made when the tool is picked up, so placing stays one click.
An existing single signature is carried into it rather than dropped.
The mark you had just drawn could be resized by its handles and not moved
by its middle, because only the select tool let marks be hit-tested at
all. The SELECTED mark now takes a press whatever tool is armed. A press
anywhere else still draws, and an unfilled shape is still grabbed by its
outline — the same rule select has always followed.
Also: words default to dark ink rather than highlighter yellow, which was
unreadable on white and is now impossible to miss, since you watch
yourself type it.
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) {
|
feat(pdf-editor): type on the page, keep more than one signature, move what you drew
Three things the editor made you work around.
Text was typed into a dialog and then placed, so you chose a size and a
weight for words you could not see against the page they were going on.
The click now opens a caret where you clicked, in the font, size and
colour the words will have, with the style bar over it; double-clicking a
stamp reopens it in place. Lining a CSS line box up with a PDF baseline is
measured from the font's own metrics, not guessed.
A signature lived in a single slot. There was nowhere to keep initials as
well as a name, nowhere to change the one you had, and reaching for the
tool again simply stamped the first one — which is the same fault three
times: one slot. It is a library now, with redraw, rename and delete, and
the choice is made when the tool is picked up, so placing stays one click.
An existing single signature is carried into it rather than dropped.
The mark you had just drawn could be resized by its handles and not moved
by its middle, because only the select tool let marks be hit-tested at
all. The SELECTED mark now takes a press whatever tool is armed. A press
anywhere else still draws, and an unfilled shape is still grabbed by its
outline — the same rule select has always followed.
Also: words default to dark ink rather than highlighter yellow, which was
unreadable on white and is now impossible to miss, since you watch
yourself type it.
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): type on the page, keep more than one signature, move what you drew
Three things the editor made you work around.
Text was typed into a dialog and then placed, so you chose a size and a
weight for words you could not see against the page they were going on.
The click now opens a caret where you clicked, in the font, size and
colour the words will have, with the style bar over it; double-clicking a
stamp reopens it in place. Lining a CSS line box up with a PDF baseline is
measured from the font's own metrics, not guessed.
A signature lived in a single slot. There was nowhere to keep initials as
well as a name, nowhere to change the one you had, and reaching for the
tool again simply stamped the first one — which is the same fault three
times: one slot. It is a library now, with redraw, rename and delete, and
the choice is made when the tool is picked up, so placing stays one click.
An existing single signature is carried into it rather than dropped.
The mark you had just drawn could be resized by its handles and not moved
by its middle, because only the select tool let marks be hit-tested at
all. The SELECTED mark now takes a press whatever tool is armed. A press
anywhere else still draws, and an unfilled shape is still grabbed by its
outline — the same rule select has always followed.
Also: words default to dark ink rather than highlighter yellow, which was
unreadable on white and is now impossible to miss, since you watch
yourself type it.
2026-09-27 13:16:07 +02:00
|
|
|
kind: "signature", page: uid, 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;
|
|
|
|
|
}
|