theseus/bundled-addons/pdf-editor/lib/inline.js
Local Dev 10c83a1431 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

183 lines
6.6 KiB
JavaScript

// Typing on the page, where the words will actually sit.
//
// Text used to be typed into a dialog and then placed, which meant choosing a
// size and a weight for text you could not see against the page it was going
// on. This puts a caret at the click instead. What you type is what lands:
// same font, same size, same colour, same position.
//
// The one hard part is the baseline. A PDF puts text on a baseline and
// measures everything from there; CSS puts a line box around it and centres
// the glyphs inside. Lining the two up by eye would be off by a few pixels at
// 12 pt and by a lot at 48, so the offset is measured from the real font
// metrics instead of guessed.
import { ASCENT, LINE_GAP } from "./shape.js";
const FONT = "Helvetica, Arial, sans-serif";
/** The CSS font shorthand for a mark's style, at a given pixel size. */
function fontCss(px, bold, italic) {
return `${italic ? "italic " : ""}${bold ? "700 " : "400 "}${px}px ${FONT}`;
}
/**
* Distance from the top of an inline box to the FIRST baseline inside it.
*
* CSS splits the leading — the difference between the line height and the
* font's own height — evenly above and below, then sits the glyphs on their
* baseline within what is left.
*/
function baselineOffset(px, lineHeightPx, bold, italic) {
const ctx = baselineOffset._ctx ||
(baselineOffset._ctx = document.createElement("canvas").getContext("2d"));
ctx.font = fontCss(px, bold, italic);
const m = ctx.measureText("Hxg");
// Chromium reports the font box; the fallbacks are Helvetica's own ratios,
// for the day a build ships without those metrics.
const ascent = m.fontBoundingBoxAscent || px * 0.905;
const descent = m.fontBoundingBoxDescent || px * 0.212;
return (lineHeightPx - (ascent + descent)) / 2 + ascent;
}
export class InlineText {
/**
* @param {object} o
* @param {object} o.strip the PageStrip, for page views and overlays
* @param {(state:object)=>void} o.onCommit called with the finished text
* @param {()=>void} o.onCancel
* @param {()=>void} o.onLayout called whenever the box moves or resizes,
* so the caller can follow it with a toolbar
*/
constructor({ strip, onCommit, onCancel, onLayout }) {
this.strip = strip;
this.onCommit = onCommit;
this.onCancel = onCancel;
this.onLayout = onLayout;
this.el = null;
this.state = null;
this._closing = false;
// Capture phase, on the window: a press anywhere else finishes the edit
// BEFORE the tools layer sees it, so finishing one stamp cannot also
// start the next one.
this._outside = (e) => {
if (!this.el || this._closing) return;
if (this.el.contains(e.target)) return;
if (e.target?.closest?.("#selbar")) return; // the style buttons are ours
e.preventDefault();
e.stopPropagation();
this.commit();
};
this._keys = (e) => {
if (!this.el) return;
if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); this.cancel(); return; }
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); this.commit(); return; }
// Del and Backspace edit the text here; the page must not also read
// them as "delete the selected mark".
e.stopPropagation();
};
}
get isOpen() { return !!this.el; }
get id() { return this.state?.id || null; }
/**
* @param {object} o
* @param {string} o.uid page the text belongs to
* @param {number} o.x user-space left of the text block
* @param {number} o.y user-space TOP of the text block
* @param {string} [o.id] the mark being re-edited, if any
*/
start({ uid, x, y, id = null, text = "", size = 12, bold = false, italic = false, color = "#1a1f2b" }) {
this.close(true);
const view = this.strip.views.get(uid);
const ov = this.strip.overlayFor(uid);
if (!view || !ov?.viewport) return false;
this.state = { uid, x, y, id, size, bold, italic, color };
const el = document.createElement("div");
el.className = "inline-text";
el.contentEditable = "plaintext-only";
el.spellcheck = false;
el.textContent = text;
view.pv.div.append(el);
this.el = el;
el.addEventListener("keydown", this._keys);
el.addEventListener("input", () => this.onLayout?.());
window.addEventListener("pointerdown", this._outside, true);
this.layout();
// Caret at the end of what is already there, which is where someone
// re-opening a stamp expects to carry on typing.
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
el.focus({ preventScroll: true });
return true;
}
/** Re-place and re-style the box against the page's current zoom. */
layout() {
if (!this.el) return;
const { uid, x, y, size, bold, italic, color } = this.state;
const ov = this.strip.overlayFor(uid);
if (!ov?.viewport) return;
const k = ov.k;
const px = size * k;
const lineHeight = px * LINE_GAP;
// Put the box's first baseline exactly where the PDF's would be.
const [vx, vBaseline] = ov.toView(x, y - size * ASCENT);
const el = this.el;
el.style.left = `${vx}px`;
el.style.top = `${vBaseline - baselineOffset(px, lineHeight, bold, italic)}px`;
el.style.font = fontCss(px, bold, italic);
el.style.lineHeight = `${lineHeight}px`;
el.style.color = color;
el.style.caretColor = color;
this.onLayout?.();
}
/** The box on screen, for anything that wants to sit beside it. */
rect() { return this.el ? this.el.getBoundingClientRect() : null; }
setStyle(patch) {
if (!this.state) return;
Object.assign(this.state, patch);
this.layout();
this.el?.focus({ preventScroll: true });
}
get text() {
// innerText gives the newlines; contenteditable likes to leave a trailing
// one behind, and an invisible empty last line is not text anyone typed.
return String(this.el?.innerText ?? "").replace(/\n$/, "");
}
commit() {
if (!this.el || this._closing) return;
const { text } = this;
const state = { ...this.state, text };
this.close();
this.onCommit?.(state);
}
cancel() {
if (!this.el || this._closing) return;
const state = { ...this.state };
this.close();
this.onCancel?.(state);
}
close(silent = false) {
if (!this.el) return;
this._closing = true;
window.removeEventListener("pointerdown", this._outside, true);
this.el.remove();
this.el = null;
if (silent) this.state = null;
this._closing = false;
}
}