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

184 lines
6.6 KiB
JavaScript
Raw Normal View History

2026-09-27 13:16:07 +02:00
// 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;
}
}