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

193 lines
7.1 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
*/
feat(pdf-editor): edit the document's own text on the page, and a line means the whole line Three complaints, one cause between the first two. A line of a PDF is rarely one run. pdf.js splits it wherever the file does — a font change, a kerning adjustment, a colour change — so a heading can be three spans and an invoice line ten. Replacing the span under the cursor covered a fragment and left the rest of the line standing, which is exactly what a replacement that looks like a copy laid over the original is. A run is now the whole visual line: the spans that share its baseline and sit close enough to be spacing rather than a second column, with the spaces the geometry implies put back between them. And that line is edited on the page. The dialog that used to hold a copy of the words is gone: the cover goes down first, carrying the line's own words at the page's own size and colour, and the caret opens on it. The cover keeps its words hidden while you type, so the original never shows through the thing covering it. Escape with nothing changed lifts the cover again and leaves the page as it was found — no mark, no undo step. The rotate grip was a square like the resize handles, wearing the open hand that means drag-the-page. It is a disc with a turning arrow now, and a cursor drawn to match, since no standard cursor means turn. The inline style that was defeating the stylesheet is gone with it. Ctrl and the wheel zoom, about the pointer rather than the top-left, so the words you were reading stay where they were. A plain wheel still scrolls.
2026-09-27 19:24:28 +02:00
/**
* `fresh` marks a mark that exists only to hold this caret — the cover laid
* over a line of the document's own text — and `was` is what it said when
* the caret opened. Together they let the caller undo the whole thing when
* nothing ends up changed, so that opening a line and closing it again
* leaves no trace. They are carried here rather than looked up later
* because by the time the caret closes the mark may be gone.
*/
start({ uid, x, y, id = null, text = "", size = 12, bold = false, italic = false,
color = "#1a1f2b", fresh = false, was = null }) {
2026-09-27 13:16:07 +02:00
this.close(true);
const view = this.strip.views.get(uid);
const ov = this.strip.overlayFor(uid);
if (!view || !ov?.viewport) return false;
feat(pdf-editor): edit the document's own text on the page, and a line means the whole line Three complaints, one cause between the first two. A line of a PDF is rarely one run. pdf.js splits it wherever the file does — a font change, a kerning adjustment, a colour change — so a heading can be three spans and an invoice line ten. Replacing the span under the cursor covered a fragment and left the rest of the line standing, which is exactly what a replacement that looks like a copy laid over the original is. A run is now the whole visual line: the spans that share its baseline and sit close enough to be spacing rather than a second column, with the spaces the geometry implies put back between them. And that line is edited on the page. The dialog that used to hold a copy of the words is gone: the cover goes down first, carrying the line's own words at the page's own size and colour, and the caret opens on it. The cover keeps its words hidden while you type, so the original never shows through the thing covering it. Escape with nothing changed lifts the cover again and leaves the page as it was found — no mark, no undo step. The rotate grip was a square like the resize handles, wearing the open hand that means drag-the-page. It is a disc with a turning arrow now, and a cursor drawn to match, since no standard cursor means turn. The inline style that was defeating the stylesheet is gone with it. Ctrl and the wheel zoom, about the pointer rather than the top-left, so the words you were reading stay where they were. A plain wheel still scrolls.
2026-09-27 19:24:28 +02:00
this.state = { uid, x, y, id, size, bold, italic, color, fresh, was };
2026-09-27 13:16:07 +02:00
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;
}
}