// 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 */ /** * `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 }) { 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, fresh, was }; 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; } }