feat(pdf-editor): type on the page, keep more than one signature, move what you drew
Three things the editor made you work around. Text was typed into a dialog and then placed, so you chose a size and a weight for words you could not see against the page they were going on. The click now opens a caret where you clicked, in the font, size and colour the words will have, with the style bar over it; double-clicking a stamp reopens it in place. Lining a CSS line box up with a PDF baseline is measured from the font's own metrics, not guessed. A signature lived in a single slot. There was nowhere to keep initials as well as a name, nowhere to change the one you had, and reaching for the tool again simply stamped the first one — which is the same fault three times: one slot. It is a library now, with redraw, rename and delete, and the choice is made when the tool is picked up, so placing stays one click. An existing single signature is carried into it rather than dropped. The mark you had just drawn could be resized by its handles and not moved by its middle, because only the select tool let marks be hit-tested at all. The SELECTED mark now takes a press whatever tool is armed. A press anywhere else still draws, and an unfilled shape is still grabbed by its outline — the same rule select has always followed. Also: words default to dark ink rather than highlighter yellow, which was unreadable on white and is now impossible to miss, since you watch yourself type it.
This commit is contained in:
parent
130b7ecc5b
commit
10c83a1431
8 changed files with 587 additions and 39 deletions
|
|
@ -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+",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -206,15 +206,35 @@
|
|||
</div>
|
||||
|
||||
<div class="modal" id="modal-sign" hidden>
|
||||
<h2>Draw your signature</h2>
|
||||
<p class="mhint">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.</p>
|
||||
<canvas id="sign-pad" width="640" height="220"></canvas>
|
||||
<div class="mrow">
|
||||
<button class="btn wide" id="sign-clear">Clear</button>
|
||||
<span class="spacer"></span>
|
||||
<button class="btn wide" id="sign-cancel">Cancel</button>
|
||||
<button class="btn wide primary" id="sign-ok" disabled>Use this signature</button>
|
||||
<h2 id="sign-title">Your signatures</h2>
|
||||
|
||||
<!-- The library. Each one is stored as lines, not a picture, so a
|
||||
thumbnail is the same drawing at a smaller size. -->
|
||||
<div id="sign-library">
|
||||
<p class="mhint">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.</p>
|
||||
<ul class="siglist" id="sign-list"></ul>
|
||||
<p class="mhint" id="sign-empty" hidden>Nothing saved yet.</p>
|
||||
<div class="mrow">
|
||||
<button class="btn wide" id="sign-new">Draw a new one</button>
|
||||
<span class="spacer"></span>
|
||||
<button class="btn wide" id="sign-close">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The pad, shown for a new signature or to redraw an existing one. -->
|
||||
<div id="sign-draw" hidden>
|
||||
<p class="mhint">Draw in the box. The line under it is a guide, not part of what is saved.</p>
|
||||
<canvas id="sign-pad" width="640" height="220"></canvas>
|
||||
<label class="mfield block">Name
|
||||
<input type="text" id="sign-name" spellcheck="false" placeholder="Signature">
|
||||
</label>
|
||||
<div class="mrow">
|
||||
<button class="btn wide" id="sign-clear">Clear</button>
|
||||
<span class="spacer"></span>
|
||||
<button class="btn wide" id="sign-cancel">Back</button>
|
||||
<button class="btn wide primary" id="sign-ok" disabled>Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
`<path d="${s.map(([x, y], i) => `${i ? "L" : "M"}${(x * 100).toFixed(1)} ${(y * 100 * (sig.aspect || 0.34)).toFixed(1)}`).join(" ")}"/>`
|
||||
).join("");
|
||||
return `<svg viewBox="0 0 100 ${(100 * (sig.aspect || 0.34)).toFixed(1)}" fill="none"
|
||||
stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"
|
||||
preserveAspectRatio="xMidYMid meet">${paths}</svg>`;
|
||||
}
|
||||
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 = `<span class="sigpreview">${sigThumb(sig)}</span>
|
||||
<span class="signame"></span>
|
||||
<span class="sigacts">
|
||||
<button class="btn primary" data-act="use">Use</button>
|
||||
<button class="btn" data-act="edit">Redraw</button>
|
||||
<button class="btn" data-act="rename">Rename</button>
|
||||
<button class="btn" data-act="delete">Delete</button>
|
||||
</span>`;
|
||||
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");
|
||||
|
|
|
|||
183
bundled-addons/pdf-editor/lib/inline.js
Normal file
183
bundled-addons/pdf-editor/lib/inline.js
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue