184 lines
6.6 KiB
JavaScript
184 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;
|
||
|
|
}
|
||
|
|
}
|