diff --git a/bundled-addons/pdf-editor/addon.json b/bundled-addons/pdf-editor/addon.json
index 8b88ad5b..61790177 100644
--- a/bundled-addons/pdf-editor/addon.json
+++ b/bundled-addons/pdf-editor/addon.json
@@ -1,7 +1,7 @@
{
"id": "pdf-editor",
"name": "PDF Editor",
- "version": "0.4.0",
+ "version": "0.5.0",
"description": "Open a PDF in a full tab: read it, mark it up with highlight, underline, strike-through, pen, shapes, arrows, text and a drawn signature, then move, resize, restyle or delete anything you placed. Reorder, rotate or drop pages, fill in form fields, redact for real, and save a new copy. Build a PDF from pictures, one page each, or join several PDFs into one. Marks are written into the saved file as real PDF content. The original is never touched.",
"author": "Silent Mode",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYgMmg4bDYgNnYxMmEyIDIgMCAwIDEtMiAySDZhMiAyIDAgMCAxLTItMlY0YTIgMiAwIDAgMSAyLTJ6IiBmaWxsPSIjZGMyNjI2Ii8+PHBhdGggZD0iTTE0IDJ2Nmg2eiIgZmlsbD0iIzk5MWIxYiIvPjx0ZXh0IHg9IjEyIiB5PSIxNyIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1mYW1pbHk9InN5c3RlbS11aSwtYXBwbGUtc3lzdGVtLHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iNS41IiBmb250LXdlaWdodD0iODAwIiBmaWxsPSIjZmZmIj5QREY8L3RleHQ+PC9zdmc+",
diff --git a/bundled-addons/pdf-editor/editor.css b/bundled-addons/pdf-editor/editor.css
index aee789a5..633e91f4 100644
--- a/bundled-addons/pdf-editor/editor.css
+++ b/bundled-addons/pdf-editor/editor.css
@@ -160,6 +160,34 @@ body[data-dragover="1"] .dz-actions { visibility: hidden; }
.linkish { background: none; border: none; padding: 0; font: inherit; cursor: pointer;
color: var(--acid); text-decoration: underline; }
+/* ---- typing on the page --------------------------------------------- */
+/* Positioned by lib/inline.js against the page's own coordinates, so it sits
+ where the text will sit. No background: the page is the background, which
+ is the whole point — you see the words against what they are going on. */
+.inline-text { position: absolute; z-index: 24; margin: 0; padding: 0;
+ min-width: 1px; white-space: pre; outline: none;
+ caret-color: currentColor;
+ box-shadow: 0 0 0 1px rgb(from var(--acid) r g b / .55),
+ 0 0 0 4px rgb(from var(--acid) r g b / .14); }
+.inline-text:empty::before { content: ""; display: inline-block; width: 1px; }
+
+/* ---- signature library ---------------------------------------------- */
+.siglist { list-style: none; margin: 0 0 12px; padding: 0; display: grid;
+ gap: 8px; max-height: 40vh; overflow: auto; }
+.sigrow { display: flex; align-items: center; gap: 10px; padding: 8px 10px;
+ border: 1px solid var(--line); border-radius: 9px; background: var(--bg); }
+.sigrow .sigpreview { flex: 0 0 132px; height: 44px; }
+.sigrow .sigpreview svg { width: 100%; height: 100%; }
+.sigrow .signame { flex: 1 1 auto; min-width: 0; font-size: 13px; color: var(--ink);
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+/* flex: none on the buttons — without it they shrink below their own labels
+ and the words run into each other. The name gives way instead. */
+.sigrow .sigacts { display: flex; gap: 6px; flex: 0 0 auto; }
+/* width: auto matters most — .btn is a 30px square by default, made for an
+ icon, and a word does not fit in one. */
+.sigrow .sigacts .btn { flex: none; width: auto; height: 26px; white-space: nowrap;
+ padding: 0 10px; font-size: 11.5px; line-height: 1; }
+
/* ---- modals --------------------------------------------------------- */
.scrim { position: fixed; inset: 0; background: rgba(4,7,12,.62); z-index: 50;
display: flex; align-items: center; justify-content: center; padding: 24px; }
@@ -232,6 +260,12 @@ body[data-mode="select"] .smOverlay { pointer-events: none; }
body[data-mode="select"] .smOverlay .mark { pointer-events: stroke; cursor: move; }
body[data-mode="select"] .smOverlay .mark[data-fill="1"] { pointer-events: all; }
.smOverlay .mark.sel { outline: none; }
+/* ...with one exception: the SELECTED mark stays grabbable whatever tool is
+ armed. Its handles already were, so a shape you had just drawn could be
+ stretched but not shifted. Only the selected one opts in, so a press
+ anywhere else still draws. */
+.smOverlay .mark.sel { pointer-events: stroke; cursor: move; }
+.smOverlay .mark.sel[data-fill="1"] { pointer-events: all; }
.smOverlay .selbox { fill: none; stroke: var(--acid); stroke-width: 1;
stroke-dasharray: 4 3; vector-effect: non-scaling-stroke; }
.smOverlay .handle { fill: var(--acid); stroke: #101418; stroke-width: 1;
diff --git a/bundled-addons/pdf-editor/editor.html b/bundled-addons/pdf-editor/editor.html
index dafbde70..21907b02 100644
--- a/bundled-addons/pdf-editor/editor.html
+++ b/bundled-addons/pdf-editor/editor.html
@@ -206,15 +206,35 @@
-
Draw your signature
-
Draw in the box, then place it on the page. It is stored as lines, not a
- picture, so it stays sharp at any zoom.
-
-
-
-
-
-
+
Your signatures
+
+
+
+
Pick one to place, or draw another. They are kept in this browser,
+ as lines rather than pictures, so they stay sharp at any size.
+
+
Nothing saved yet.
+
+
+
+
+
+
+
+
+
+
Draw in the box. The line under it is a guide, not part of what is saved.
+
+
+
+
+
+
+
+
diff --git a/bundled-addons/pdf-editor/editor.js b/bundled-addons/pdf-editor/editor.js
index a7d5b038..cd4a1c88 100644
--- a/bundled-addons/pdf-editor/editor.js
+++ b/bundled-addons/pdf-editor/editor.js
@@ -19,6 +19,7 @@ import { PageStrip } from "./lib/view.js";
import { Rail } from "./lib/rail.js";
import { Tools } from "./lib/tools.js";
import { SignaturePad } from "./lib/signature.js";
+import { InlineText } from "./lib/inline.js";
import { buildPdf, unencodableStamps, replacedRuns } from "./lib/save.js";
import { boundsOf, clampFont } from "./lib/shape.js";
import { reflow } from "./lib/reflow.js";
@@ -200,9 +201,10 @@ async function loadBytes(bytes, name, scratchId = null) {
onChange: () => { paintChrome(); },
selectTool,
onSelectionChange: paintSelectionBar,
+ beginText,
editText: onTextModal,
editRun: onEditRun,
- openSignaturePad: onSignaturePad,
+ chooseSignature,
signature: () => savedSignature,
},
});
@@ -460,6 +462,16 @@ function selectTool(t) {
for (const b of document.querySelectorAll(".tool[data-tool]")) {
b.addEventListener("click", async () => {
const t = b.dataset.tool;
+ // Which signature is settled when the tool is picked up, so placing it
+ // is one click per place — and so there is somewhere to add, rename or
+ // redraw one, which a tool that silently reused the last had nowhere for.
+ if (t === "signature") {
+ selectTool(t);
+ const sig = await chooseSignature();
+ if (!sig) { selectTool("select"); return; }
+ status(`Click to place "${sig.name || "your signature"}".`);
+ return;
+ }
if (t === "redact" && !redactAcknowledged) {
const ok = await openModal("modal-redact");
if (!ok) return;
@@ -664,8 +676,12 @@ async function onEditRun(run) {
const selbar = $("selbar");
function paintSelectionBar(id) {
+ if (inline?.isOpen) return; // the caret owns the bar while it is up
const a = id && session.model ? session.model.annot(id) : null;
if (!a) { selbar.hidden = true; return; }
+ delete selbar.dataset.mode;
+ $("sb-dup").hidden = false;
+ $("sb-del").hidden = false;
// A replaced run is text too. Leaving it out meant the one mark made of
// words offered no way to change them — the exact gap this bar exists for.
const isText = a.kind === "text" || a.kind === "textedit";
@@ -722,12 +738,24 @@ window.addEventListener("pointerup", () => {
document.body.dataset.dragging = "";
positionSelectionBar();
});
-$("viewerContainer").addEventListener("scroll", () => positionSelectionBar(), { passive: true });
+$("viewerContainer").addEventListener("scroll", () => {
+ // The caret is positioned inside the page, so scrolling carries it along;
+ // the bar floats over the viewport and has to be told.
+ if (inline?.isOpen) positionInlineBar(); else positionSelectionBar();
+}, { passive: true });
$("sb-edit").addEventListener("click", () => {
const id = session.tools?.selected;
- if (id) onTextModal(id, null);
+ if (!id) return;
+ const a = session.model?.annot(id);
+ if (a?.kind === "text") beginText({ id }); else onTextModal(id, null);
});
+// Pressing a style button must not take the caret away: focus leaving the
+// editor is how a click elsewhere finishes the edit, and these are not
+// elsewhere.
+for (const b of selbar.querySelectorAll("button")) {
+ b.addEventListener("pointerdown", (e) => { if (inline?.isOpen) e.preventDefault(); });
+}
$("sb-del").addEventListener("click", () => { session.tools?.deleteSelected(); paintChrome(); });
$("sb-dup").addEventListener("click", () => { session.tools?.duplicateSelected(); paintChrome(); });
$("sb-bold").addEventListener("click", () => toggleTextStyle("bold"));
@@ -739,18 +767,34 @@ $("sb-fill").addEventListener("click", () => {
$("sb-smaller").addEventListener("click", () => stepTextSize(-1));
$("sb-bigger").addEventListener("click", () => stepTextSize(1));
+const SIZE_STEPS = [6, 8, 9, 10, 11, 12, 14, 16, 18, 24, 30, 36, 48, 60, 72, 96];
+
function toggleTextStyle(which) {
+ // While a caret is open the bar is driving the caret, not a mark.
+ if (inline?.isOpen) {
+ inline.setStyle({ [which]: !inline.state[which] });
+ paintInlineBar();
+ return;
+ }
const a = session.model?.annot(session.tools?.selected);
if (!a || (a.kind !== "text" && a.kind !== "textedit")) return;
delete a._w; // the glyphs change width; re-measure
session.tools.patchSelected({ [which]: !a[which] });
}
function stepTextSize(dir) {
+ const nextSize = (at) => dir > 0
+ ? SIZE_STEPS.find((v) => v > at + 0.01)
+ : [...SIZE_STEPS].reverse().find((v) => v < at - 0.01);
+ if (inline?.isOpen) {
+ const next = nextSize(inline.state.size);
+ if (next == null) return;
+ inline.setStyle({ size: clampFont(next) });
+ paintInlineBar();
+ return;
+ }
const a = session.model?.annot(session.tools?.selected);
if (!a || (a.kind !== "text" && a.kind !== "textedit")) return;
- const steps = [6, 8, 9, 10, 11, 12, 14, 16, 18, 24, 30, 36, 48, 60, 72, 96];
- const at = a.size || 12;
- const next = dir > 0 ? steps.find((v) => v > at + 0.01) : [...steps].reverse().find((v) => v < at - 0.01);
+ const next = nextSize(a.size || 12);
if (next == null) return;
delete a._w;
session.tools.patchSelected({ size: clampFont(next) });
@@ -761,6 +805,107 @@ function stepTextSize(dir) {
* One dialog for both jobs. `id` set means rewrite that mark in place, which
* is what a double-click on placed text opens; `at` set means place a new one.
*/
+// ---- typing on the page -----------------------------------------------
+let inline = null;
+function inlineEditor() {
+ if (!inline) {
+ inline = new InlineText({
+ strip: session.strip,
+ onCommit: (s) => finishInlineText(s),
+ onCancel: (s) => {
+ // A mark being re-edited was hidden, not removed; put it back as it
+ // was. A brand-new one was never added, so there is nothing to undo.
+ const a = s.id ? session.model?.annot(s.id) : null;
+ if (a) { delete a._editing; session.strip?.refreshOverlay(s.uid, s.id); }
+ paintSelectionBar(session.tools?.selected || null);
+ status("");
+ },
+ onLayout: () => positionInlineBar(),
+ });
+ }
+ inline.strip = session.strip; // a new document brings a new strip
+ return inline;
+}
+
+/**
+ * Start typing at a point on the page, or inside an existing stamp.
+ * @param {{uid?:string, x?:number, y?:number, id?:string}} where
+ */
+function beginText(where) {
+ if (!session.model) return;
+ const ed = inlineEditor();
+ const existing = where.id ? session.model.annot(where.id) : null;
+ if (where.id && !existing) return;
+ const seed = existing
+ ? { uid: existing.page, x: existing.x, y: existing.y, id: existing.id,
+ text: existing.text, size: existing.size || 12,
+ bold: !!existing.bold, italic: !!existing.italic, color: existing.color }
+ : { uid: where.uid, x: where.x, y: where.y, text: "",
+ size: lastTextSize, bold: lastTextBold, italic: lastTextItalic,
+ color: session.tools.ink };
+ // While it is being typed, the stamp itself is hidden: seeing the old words
+ // underneath the new ones reads as a rendering fault.
+ if (existing) { existing._editing = true; session.strip.refreshOverlay(existing.page, existing.id); }
+ if (!ed.start(seed)) { if (existing) delete existing._editing; return; }
+ paintInlineBar();
+ status(existing ? "Editing text — Escape to leave it as it was." : "Type. Click elsewhere when you are done.");
+}
+
+function finishInlineText(s) {
+ const model = session.model;
+ if (!model) return;
+ lastTextSize = s.size; lastTextBold = s.bold; lastTextItalic = s.italic;
+ const existing = s.id ? model.annot(s.id) : null;
+ if (existing) delete existing._editing;
+ const text = String(s.text || "");
+ if (existing) {
+ // updateText deletes the mark when the text is emptied, which is the
+ // right reading of "select it all and press delete".
+ session.tools.updateText(s.id, { text, size: s.size, bold: s.bold, italic: s.italic });
+ } else if (text.trim()) {
+ session.tools.placeText({ uid: s.uid, x: s.x, y: s.y },
+ { text, size: s.size, bold: s.bold, italic: s.italic });
+ } else {
+ // Typed nothing. Nothing was ever added, so nothing has to be taken away.
+ session.strip?.refreshOverlay(s.uid, session.tools?.selected || null);
+ }
+ paintSelectionBar(session.tools?.selected || null);
+ status("");
+}
+
+/** The style buttons, while a caret is open rather than a mark selected. */
+function paintInlineBar() {
+ const st = inline?.state;
+ if (!st) { selbar.hidden = true; return; }
+ selbar.dataset.mode = "inline";
+ $("sb-edit").hidden = true;
+ $("sb-textsize").hidden = false;
+ $("sb-bold").hidden = false;
+ $("sb-italic").hidden = false;
+ $("sb-fill").hidden = true;
+ $("sb-dup").hidden = true;
+ $("sb-del").hidden = true;
+ $("sb-size").textContent = String(Math.round(st.size));
+ $("sb-bold").classList.toggle("active", !!st.bold);
+ $("sb-italic").classList.toggle("active", !!st.italic);
+ selbar.hidden = false;
+ positionInlineBar();
+}
+function positionInlineBar() {
+ const r = inline?.rect();
+ if (!r || selbar.hidden) return;
+ const box = selbar.getBoundingClientRect();
+ const cr = $("viewerContainer").getBoundingClientRect();
+ const GAP = 10;
+ let top = r.top - box.height - GAP;
+ if (top < cr.top + 4) top = r.bottom + GAP;
+ const left = Math.min(Math.max(r.left, cr.left + 4), cr.right - box.width - 4);
+ selbar.style.left = `${Math.round(left)}px`;
+ selbar.style.top = `${Math.round(top)}px`;
+}
+
+let lastTextSize = 12, lastTextBold = false, lastTextItalic = false;
+
async function onTextModal(id, at) {
const existing = id ? session.model?.annot(id) : null;
$("modal-text-title").textContent = existing ? "Edit text" : "Text stamp";
@@ -820,26 +965,121 @@ function unencodableStampsIn(text) {
return n;
}
-// ---- signature --------------------------------------------------------
-let savedSignature = null;
+// ---- signatures -------------------------------------------------------
+// A library, not a single slot. One signature was enough until you needed
+// initials as well as a full name, or your own next to someone else's — at
+// which point the only way to change the one you had was to have no way at
+// all, because every click placed it again.
+let signatures = []; // [{ id, name, strokes, aspect }]
+let savedSignature = null; // the one the armed tool will place
let pad = null;
-async function onSignaturePad() {
+
+async function persistSignatures() {
+ try { await sm?.storage?.set("signatures", signatures); } catch {}
+}
+function sigThumb(sig) {
+ // The strokes are normalised 0..1 with y downwards, which is already an SVG
+ // viewBox; nothing has to be scaled to draw a preview.
+ const paths = (sig.strokes || []).map((s) =>
+ ``
+ ).join("");
+ return ``;
+}
+function paintSignatureList() {
+ const list = $("sign-list");
+ list.textContent = "";
+ $("sign-empty").hidden = signatures.length > 0;
+ for (const sig of signatures) {
+ const li = document.createElement("li");
+ li.className = "sigrow";
+ li.innerHTML = `${sigThumb(sig)}
+
+
+
+
+
+
+ `;
+ li.querySelector(".signame").textContent = sig.name || "Signature";
+ li.querySelector('[data-act="use"]').addEventListener("click", () => closeModal(sig));
+ li.querySelector('[data-act="edit"]').addEventListener("click", () => showSignaturePad(sig));
+ li.querySelector('[data-act="rename"]').addEventListener("click", async () => {
+ const name = window.prompt("Name this signature", sig.name || "Signature");
+ if (name === null) return;
+ sig.name = name.trim().slice(0, 60) || "Signature";
+ await persistSignatures();
+ paintSignatureList();
+ });
+ li.querySelector('[data-act="delete"]').addEventListener("click", async () => {
+ if (!window.confirm(`Delete "${sig.name || "Signature"}"?`)) return;
+ signatures = signatures.filter((s) => s.id !== sig.id);
+ if (savedSignature?.id === sig.id) savedSignature = null;
+ await persistSignatures();
+ paintSignatureList();
+ });
+ list.append(li);
+ }
+}
+/** Swap the modal over to the pad, either blank or loaded with one to redraw. */
+function showSignaturePad(existing = null) {
if (!pad) {
pad = new SignaturePad($("sign-pad"));
pad.onChange((has) => { $("sign-ok").disabled = !has; });
}
pad.clear();
- const ok = await openModal("modal-sign");
- if (!ok) return null;
- const res = pad.result();
- if (!res) return null;
- savedSignature = res;
- try { await sm?.storage?.set("signature", res); } catch {}
- return res;
+ padEditing = existing;
+ if (existing) pad.load(existing.strokes, existing.aspect);
+ $("sign-title").textContent = existing ? "Redraw this signature" : "Draw a signature";
+ $("sign-name").value = existing?.name || "";
+ $("sign-library").hidden = true;
+ $("sign-draw").hidden = false;
+ $("sign-ok").disabled = pad.isEmpty;
}
-$("sign-ok").addEventListener("click", () => closeModal(true));
-$("sign-cancel").addEventListener("click", () => closeModal(null));
+function showSignatureLibrary() {
+ padEditing = null;
+ $("sign-title").textContent = "Your signatures";
+ $("sign-draw").hidden = true;
+ $("sign-library").hidden = false;
+ paintSignatureList();
+}
+let padEditing = null;
+
+/**
+ * Open the library and hand back whichever signature was chosen, or null.
+ * With nothing saved there is nothing to choose from, so it opens on the pad.
+ */
+async function chooseSignature() {
+ if (signatures.length) showSignatureLibrary(); else showSignaturePad(null);
+ const chosen = await openModal("modal-sign");
+ if (!chosen) return null;
+ savedSignature = chosen;
+ return chosen;
+}
+$("sign-new").addEventListener("click", () => showSignaturePad(null));
+$("sign-close").addEventListener("click", () => closeModal(null));
+$("sign-cancel").addEventListener("click", () => {
+ // "Back" when there is a library to go back to; otherwise it is the only
+ // screen, so it closes.
+ if (signatures.length) showSignatureLibrary(); else closeModal(null);
+});
$("sign-clear").addEventListener("click", () => pad?.clear());
+$("sign-ok").addEventListener("click", async () => {
+ const res = pad?.result();
+ if (!res) return;
+ const name = $("sign-name").value.trim().slice(0, 60) ||
+ padEditing?.name || `Signature ${signatures.length + 1}`;
+ let sig;
+ if (padEditing) {
+ sig = Object.assign(padEditing, { ...res, name });
+ } else {
+ sig = { id: `sig-${Date.now().toString(36)}`, name, ...res };
+ signatures.push(sig);
+ }
+ await persistSignatures();
+ closeModal(sig);
+});
// ---- save -------------------------------------------------------------
function defaultSaveName() {
@@ -1202,8 +1442,16 @@ window.addEventListener("keydown", (e) => {
(async function start() {
try {
redactAcknowledged = (await sm?.storage?.get("redactAcknowledged", false)) === true;
- const sig = await sm?.storage?.get("signature", null);
- if (sig && Array.isArray(sig.strokes)) savedSignature = sig;
+ const saved = await sm?.storage?.get("signatures", null);
+ if (Array.isArray(saved)) signatures = saved.filter((s) => s && Array.isArray(s.strokes));
+ // One signature used to live under its own key. Carry it into the
+ // library rather than leaving someone's only signature behind.
+ const legacy = await sm?.storage?.get("signature", null);
+ if (!signatures.length && legacy && Array.isArray(legacy.strokes)) {
+ signatures = [{ id: "sig-legacy", name: "Signature", ...legacy }];
+ await persistSignatures();
+ }
+ savedSignature = signatures[0] || null;
} catch {}
const id = new URLSearchParams(location.search).get("doc");
diff --git a/bundled-addons/pdf-editor/lib/inline.js b/bundled-addons/pdf-editor/lib/inline.js
new file mode 100644
index 00000000..0000dd52
--- /dev/null
+++ b/bundled-addons/pdf-editor/lib/inline.js
@@ -0,0 +1,183 @@
+// 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;
+ }
+}
diff --git a/bundled-addons/pdf-editor/lib/overlay.js b/bundled-addons/pdf-editor/lib/overlay.js
index 5bcbfe4b..2b141e1c 100644
--- a/bundled-addons/pdf-editor/lib/overlay.js
+++ b/bundled-addons/pdf-editor/lib/overlay.js
@@ -68,6 +68,9 @@ export class Overlay {
this.clipRect.setAttribute("height", viewport.height);
this.marks.textContent = "";
for (const a of annots) {
+ // A stamp being typed is drawn by the caret instead; two copies of the
+ // same words, one stale, reads as a rendering fault.
+ if (a._editing) continue;
const node = this._draw(a);
if (!node) continue;
this._addHitArea(node, a);
@@ -76,7 +79,7 @@ export class Overlay {
if (a.id === selectedId) node.classList.add("sel");
this.marks.append(node);
}
- this._drawChrome(annots.find((a) => a.id === selectedId) || null);
+ this._drawChrome(annots.find((a) => a.id === selectedId && !a._editing) || null);
}
// Scale a user-space length (a stroke width, a font size) into CSS pixels.
diff --git a/bundled-addons/pdf-editor/lib/signature.js b/bundled-addons/pdf-editor/lib/signature.js
index d317f271..e1a5d7e1 100644
--- a/bundled-addons/pdf-editor/lib/signature.js
+++ b/bundled-addons/pdf-editor/lib/signature.js
@@ -35,6 +35,26 @@ export class SignaturePad {
get isEmpty() { return !this.strokes.some((s) => s.length > 1); }
+ /**
+ * Put a saved signature back on the pad so it can be redrawn or added to.
+ *
+ * `result()` throws away where in the pad the signature was drawn — it
+ * keeps only the shape and its aspect — so this lays it out afresh:
+ * across the pad's width, centred, at the aspect it was saved with.
+ */
+ load(strokes, aspect = 0.34) {
+ const margin = 24;
+ const w = this.canvas.width - margin * 2;
+ const h = Math.min(this.canvas.height - margin * 2, w * aspect);
+ const top = (this.canvas.height - h) / 2;
+ this.strokes = (strokes || [])
+ .filter((s) => Array.isArray(s) && s.length > 1)
+ .map((s) => s.map(([x, y]) => [margin + x * w, top + y * h]));
+ this.cur = null;
+ this._paint();
+ this._onChange?.(!this.isEmpty);
+ }
+
_pt(e) {
const r = this.canvas.getBoundingClientRect();
// The canvas is CSS-scaled to the modal's width, so a client point has to
diff --git a/bundled-addons/pdf-editor/lib/tools.js b/bundled-addons/pdf-editor/lib/tools.js
index c77edf0e..450b0ce4 100644
--- a/bundled-addons/pdf-editor/lib/tools.js
+++ b/bundled-addons/pdf-editor/lib/tools.js
@@ -37,7 +37,8 @@ export class Tools {
this.strip = strip;
this.model = model;
// host (editor.js): { toast, onChange, selectTool, onSelectionChange,
- // editText, openSignaturePad, signature }
+ // beginText, editText, editRun, chooseSignature,
+ // signature }
this.host = host;
this.tool = "select";
this.color = "#ffd400";
@@ -167,6 +168,26 @@ export class Tools {
}
}
+ // Dragging what is already selected moves it, whatever tool is armed.
+ // A tool drops back to select after it stamps, but a shape tool stays
+ // armed for the next shape — so the mark you just drew could be resized
+ // by its handles and not moved by its middle, which is a strange half of
+ // an editor. Only the SELECTED mark does this: a press anywhere else
+ // still draws.
+ if (this.selected && this.tool !== "select" &&
+ e.target?.closest?.(".mark")?.dataset?.id === this.selected) {
+ const a = this.model.annot(this.selected);
+ const spot = this.strip.pageAt(e.clientX, e.clientY);
+ const ovm = a && this.strip.overlayFor(a.page);
+ if (a && spot && spot.uid === a.page && ovm?.viewport) {
+ const [mx, my] = ovm.toPdf(spot.vx, spot.vy);
+ this.drag = { kind: "move", uid: a.page, id: a.id, x0: mx, y0: my,
+ orig: structuredClone(a), cx: e.clientX, cy: e.clientY, moved: false };
+ e.preventDefault();
+ return;
+ }
+ }
+
const hit = this.strip.pageAt(e.clientX, e.clientY);
if (!hit) return;
const ov = this.strip.overlayFor(hit.uid);
@@ -206,7 +227,10 @@ export class Tools {
e.preventDefault();
if (double) {
const a = this.model.annot(id);
- if (a?.kind === "text" || a?.kind === "textedit") { this.host.editText?.(id, null); return; }
+ // A stamp is edited where it sits; a replaced run keeps its dialog,
+ // which is where it explains what it is replacing.
+ if (a?.kind === "text") { this.host.beginText?.({ id }); return; }
+ if (a?.kind === "textedit") { this.host.editText?.(id, null); return; }
}
this.drag = { kind: "move", uid: hit.uid, id, x0: x, y0: y,
orig: structuredClone(this.model.annot(id)), cx: e.clientX, cy: e.clientY, moved: false };
@@ -214,7 +238,8 @@ export class Tools {
}
if (this.tool === "text") {
- this.host.editText?.(null, { uid: hit.uid, x, y });
+ // A caret on the page, not a dialog about the page.
+ this.host.beginText?.({ uid: hit.uid, x, y });
e.preventDefault();
return;
}
@@ -279,12 +304,14 @@ export class Tools {
}
onUp(e) {
- // A handle drag is finished here whatever tool is armed. Without this the
+ // A handle or move drag is finished here whatever tool is armed. Without
+ // this the
// text-markup tools would return on the line below and strand a resize
// half-done: applied to the live mark, never journalled, and with
// this.drag still set so the next press behaves oddly.
const inFlight = this.drag;
- if (!(inFlight && inFlight.kind === "resize") && TEXT_MARKUP.has(this.tool)) {
+ const live = inFlight && (inFlight.kind === "resize" || inFlight.kind === "move");
+ if (!live && TEXT_MARKUP.has(this.tool)) {
this._commitTextMarkup();
return;
}
@@ -447,10 +474,20 @@ export class Tools {
// ---- stamps -------------------------------------------------------
/** Place a new text mark, or rewrite an existing one, as one undo step. */
+ /**
+ * The colour to write WORDS in.
+ *
+ * The palette's default is highlighter yellow, which is right for a
+ * highlight and unreadable as text on a white page. A signature already
+ * made this swap; text was left behind, and you now watch yourself type
+ * in it, so it is impossible to miss.
+ */
+ get ink() { return this.color === "#ffd400" ? "#1a1f2b" : this.color; }
+
placeText({ uid, x, y }, props) {
if (!String(props.text || "").trim()) return null;
const a = this.model.addAnnot({
- kind: "text", page: uid, color: this.color, x, y,
+ kind: "text", page: uid, color: this.ink, x, y,
size: props.size, text: props.text, bold: !!props.bold, italic: !!props.italic,
});
this._afterStamp(a);
@@ -471,15 +508,18 @@ export class Tools {
}
async _placeSignature(uid, x, y) {
+ // Which signature is decided when the tool is armed, not on every click,
+ // so placing three initials in three boxes is three clicks. This is only
+ // the fallback for a tool armed with nothing chosen yet.
let sig = this.host.signature();
if (!sig) {
- sig = await this.host.openSignaturePad();
+ sig = await this.host.chooseSignature?.();
if (!sig) return;
}
const w = SIG_WIDTH_PT;
const h = w * (sig.aspect || 0.34);
const a = this.model.addAnnot({
- kind: "signature", page: uid, color: this.color === "#ffd400" ? "#1a1f2b" : this.color,
+ kind: "signature", page: uid, color: this.ink,
width: 1.6, x, y: y - h, w, h, strokes: sig.strokes,
});
this._afterStamp(a);