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.
This commit is contained in:
Local Dev 2026-09-27 19:24:28 +02:00
parent 5a17b0e866
commit 8ed051c2da
7 changed files with 235 additions and 108 deletions

View file

@ -1,7 +1,7 @@
{ {
"id": "pdf-editor", "id": "pdf-editor",
"name": "PDF Editor", "name": "PDF Editor",
"version": "0.8.1", "version": "0.9.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.", "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", "author": "Silent Mode",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYgMmg4bDYgNnYxMmEyIDIgMCAwIDEtMiAySDZhMiAyIDAgMCAxLTItMlY0YTIgMiAwIDAgMSAyLTJ6IiBmaWxsPSIjZGMyNjI2Ii8+PHBhdGggZD0iTTE0IDJ2Nmg2eiIgZmlsbD0iIzk5MWIxYiIvPjx0ZXh0IHg9IjEyIiB5PSIxNyIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1mYW1pbHk9InN5c3RlbS11aSwtYXBwbGUtc3lzdGVtLHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iNS41IiBmb250LXdlaWdodD0iODAwIiBmaWxsPSIjZmZmIj5QREY8L3RleHQ+PC9zdmc+", "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYgMmg4bDYgNnYxMmEyIDIgMCAwIDEtMiAySDZhMiAyIDAgMCAxLTItMlY0YTIgMiAwIDAgMSAyLTJ6IiBmaWxsPSIjZGMyNjI2Ii8+PHBhdGggZD0iTTE0IDJ2Nmg2eiIgZmlsbD0iIzk5MWIxYiIvPjx0ZXh0IHg9IjEyIiB5PSIxNyIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1mYW1pbHk9InN5c3RlbS11aSwtYXBwbGUtc3lzdGVtLHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iNS41IiBmb250LXdlaWdodD0iODAwIiBmaWxsPSIjZmZmIj5QREY8L3RleHQ+PC9zdmc+",

View file

@ -296,6 +296,24 @@ body[data-mode="select"] .smOverlay .mark[data-fill="1"] { pointer-events: all;
vector-effect: non-scaling-stroke; } vector-effect: non-scaling-stroke; }
.smOverlay .handle { fill: var(--acid); stroke: #101418; stroke-width: 1; .smOverlay .handle { fill: var(--acid); stroke: #101418; stroke-width: 1;
vector-effect: non-scaling-stroke; cursor: pointer; pointer-events: all; } vector-effect: non-scaling-stroke; cursor: pointer; pointer-events: all; }
/* The rotate grip. A turning arrow on a disc, and a cursor that says turn
rather than the open hand the browser gives `grab`, which reads as "drag
the page". No standard cursor means turn, so it is drawn here. */
.smOverlay .handle.rot { fill: none; stroke: none; }
.smOverlay .rotdisc { fill: var(--acid); stroke: #101418; stroke-width: 1;
vector-effect: non-scaling-stroke; }
.smOverlay .rotmark { fill: none; stroke: #101418; stroke-width: 1.4;
stroke-linecap: round; stroke-linejoin: round;
vector-effect: non-scaling-stroke; }
.smOverlay .rotarm { stroke: var(--acid); stroke-width: 1; stroke-dasharray: 2 2;
vector-effect: non-scaling-stroke; }
.smOverlay .handle.rot,
body[data-rotating="1"] { cursor: url("data:image/svg+xml;utf8,\
%3Csvg xmlns='http://www.w3.org/2000/svg' width='26' height='26' viewBox='0 0 26 26'%3E\
%3Cg fill='none' stroke='%23101418' stroke-width='3.6' stroke-linecap='round' stroke-linejoin='round'%3E\
%3Cpath d='M19 13a6 6 0 1 1-1.8-4.2'/%3E%3Cpath d='M17.4 4.2v4.6h-4.6'/%3E%3C/g%3E\
%3Cg fill='none' stroke='%23ffffff' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E\
%3Cpath d='M19 13a6 6 0 1 1-1.8-4.2'/%3E%3Cpath d='M17.4 4.2v4.6h-4.6'/%3E%3C/g%3E%3C/svg%3E") 13 13, grab; }
/* pdf.js renders form widgets with a coloured backdrop; keep it, it is the /* pdf.js renders form widgets with a coloured backdrop; keep it, it is the
only cue that a field is fillable. */ only cue that a field is fillable. */

View file

@ -259,28 +259,6 @@
</div> </div>
</div> </div>
<div class="modal" id="modal-text" hidden>
<h2 id="modal-text-title">Text stamp</h2>
<textarea id="text-body" rows="4" placeholder="Type the text to place…"></textarea>
<div class="mrow">
<label class="mfield" id="text-size-field">Size
<select id="text-size">
<option value="8">8</option><option value="10">10</option>
<option value="12" selected>12</option><option value="14">14</option>
<option value="18">18</option><option value="24">24</option>
<option value="36">36</option><option value="48">48</option><option value="72">72</option>
</select>
</label>
<button class="btn styl" id="text-bold" type="button" title="Bold"><b>B</b></button>
<button class="btn styl" id="text-italic" type="button" title="Italic"><i>I</i></button>
<span class="spacer"></span>
<button class="btn wide" id="text-cancel">Cancel</button>
<button class="btn wide primary" id="text-ok">Place</button>
</div>
<p class="mhint" id="text-note" hidden></p>
<p class="mhint" id="text-warn" hidden></p>
</div>
<div class="modal" id="modal-convert" hidden> <div class="modal" id="modal-convert" hidden>
<h2>Convert to Word</h2> <h2>Convert to Word</h2>
<p>A PDF stores glyphs with coordinates, not paragraphs. Converting means working out <p>A PDF stores glyphs with coordinates, not paragraphs. Converting means working out

View file

@ -21,7 +21,7 @@ import { Tools } from "./lib/tools.js";
import { SignaturePad } from "./lib/signature.js"; import { SignaturePad } from "./lib/signature.js";
import { InlineText } from "./lib/inline.js"; import { InlineText } from "./lib/inline.js";
import { buildPdf, unencodableStamps, replacedRuns } from "./lib/save.js"; import { buildPdf, unencodableStamps, replacedRuns } from "./lib/save.js";
import { boundsOf, clampFont, translatePatch, unionOf } from "./lib/shape.js"; import { ASCENT, boundsOf, clampFont, translatePatch, unionOf } from "./lib/shape.js";
import { reflow } from "./lib/reflow.js"; import { reflow } from "./lib/reflow.js";
import { buildDocx } from "./lib/docx.js"; import { buildDocx } from "./lib/docx.js";
import { imagesToPdf, mergePdfs } from "./lib/create.js"; import { imagesToPdf, mergePdfs } from "./lib/create.js";
@ -202,7 +202,6 @@ async function loadBytes(bytes, name, scratchId = null) {
selectTool, selectTool,
onSelectionChange: paintSelectionBar, onSelectionChange: paintSelectionBar,
beginText, beginText,
editText: onTextModal,
editRun: onEditRun, editRun: onEditRun,
chooseSignature, chooseSignature,
signature: () => savedSignature, signature: () => savedSignature,
@ -562,6 +561,38 @@ $("zoom").addEventListener("change", (e) => {
}); });
$("zoom-in").addEventListener("click", () => { session.strip?.stepScale(1); syncZoomSelect(); }); $("zoom-in").addEventListener("click", () => { session.strip?.stepScale(1); syncZoomSelect(); });
$("zoom-out").addEventListener("click", () => { session.strip?.stepScale(-1); syncZoomSelect(); }); $("zoom-out").addEventListener("click", () => { session.strip?.stepScale(-1); syncZoomSelect(); });
/**
* Ctrl/⌘ + wheel zooms, and keeps the page under the pointer under it.
*
* Plain wheel still scrolls, because that is what a wheel does in every
* reader. Without `passive: false` the preventDefault is ignored and the
* browser zooms the whole tab instead of the document — which is why this
* listener is on the container and not on the window.
*
* Zooming about the pointer rather than the top-left is the part that makes
* it usable: the words you were reading stay where they were instead of
* sliding off the screen as the page grows.
*/
$("viewerContainer").addEventListener("wheel", (e) => {
if (!(e.ctrlKey || e.metaKey) || !session.strip) return;
e.preventDefault();
const strip = session.strip;
const before = strip.scale;
strip.stepScale(e.deltaY < 0 ? 1 : -1);
const after = strip.scale;
if (after === before) return;
const c = $("viewerContainer");
const r = c.getBoundingClientRect();
const k = after / before;
// The point under the cursor, in content coordinates, has to land back
// under the cursor once everything has grown by k.
const px = c.scrollLeft + (e.clientX - r.left);
const py = c.scrollTop + (e.clientY - r.top);
c.scrollLeft = px * k - (e.clientX - r.left);
c.scrollTop = py * k - (e.clientY - r.top);
syncZoomSelect();
}, { passive: false });
function syncZoomSelect() { function syncZoomSelect() {
positionSelectionBar(); positionSelectionBar();
if (!session.strip) return; if (!session.strip) return;
@ -654,31 +685,34 @@ function sampleRun(run) {
} catch { return fallback; } } catch { return fallback; }
} }
async function onEditRun(run) { /**
* Edit a line of the document's own text, in place.
*
* This used to open a dialog holding a copy of the line. That is the wrong
* shape for the job twice over: you edit words while looking at a box
* instead of at the page, and what you are really doing — covering the
* original and drawing over it — is invisible until you commit, at which
* point a replacement that does not quite line up reads as a copy pasted on
* top of the original.
*
* So the cover goes down FIRST, carrying the line's own words in the page's
* own size and colour, and the caret opens on it. Nothing appears to happen
* except that the line becomes editable, which is what the user asked for.
* Escape with nothing changed takes the cover away again.
*/
function onEditRun(run) {
const look = sampleRun(run); const look = sampleRun(run);
$("modal-text-title").textContent = "Replace text";
$("text-body").value = run.text;
$("text-size-field").hidden = true; // the size comes from the document
$("text-bold").classList.remove("active");
$("text-italic").classList.remove("active");
$("text-warn").hidden = true;
const note = $("text-note");
note.hidden = false;
note.textContent = `${run.size.toFixed(1)} pt from the document. The replacement is drawn over the original, `
+ `which stays in the file underneath — use Redact if it has to be gone.`;
const ok = await openModal("modal-text");
$("text-size-field").hidden = false;
note.hidden = true;
if (!ok) return null;
const a = session.tools.replaceRun(run, { const a = session.tools.replaceRun(run, {
text: $("text-body").value, text: run.text,
size: run.size, size: run.size,
color: look.ink, color: look.ink,
cover: look.bg, cover: look.bg,
bold: $("text-bold").classList.contains("active"),
italic: $("text-italic").classList.contains("active"),
}); });
if (a && !look.sampled) toast("Could not read the page's colours; used black on white.", true); if (!a) return null;
if (!look.sampled) toast("Could not read the page's colours; used black on white.", true);
beginText({ id: a.id, fresh: true });
status(`${run.size.toFixed(1)} pt from the document — the original stays in the file underneath. `
+ `Escape leaves it as it was.`);
return a; return a;
} }
@ -804,7 +838,7 @@ $("sb-edit").addEventListener("click", () => {
const id = session.tools?.selected; const id = session.tools?.selected;
if (!id) return; if (!id) return;
const a = session.model?.annot(id); const a = session.model?.annot(id);
if (a?.kind === "text") beginText({ id }); else onTextModal(id, null); if (a?.kind === "text" || a?.kind === "textedit") beginText({ id });
}); });
// Pressing a style button must not take the caret away: focus leaving the // 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 // editor is how a click elsewhere finishes the edit, and these are not
@ -872,7 +906,17 @@ function inlineEditor() {
// A mark being re-edited was hidden, not removed; put it back as it // 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. // was. A brand-new one was never added, so there is nothing to undo.
const a = s.id ? session.model?.annot(s.id) : null; const a = s.id ? session.model?.annot(s.id) : null;
if (a) { delete a._editing; session.strip?.refreshOverlay(s.uid, s.id); } if (a) delete a._editing;
// ...except a cover laid down just to open this caret. Escaping out
// of it should leave the page exactly as it was found, not leave a
// replacement of a line by itself.
if (a && s.fresh) {
session.tools.select(a.id);
session.tools.deleteSelected();
paintChrome();
return;
}
if (a) session.strip?.refreshOverlay(s.uid, s.id);
paintSelectionBar(session.tools?.selected || null); paintSelectionBar(session.tools?.selected || null);
status(""); status("");
}, },
@ -892,19 +936,29 @@ function beginText(where) {
const ed = inlineEditor(); const ed = inlineEditor();
const existing = where.id ? session.model.annot(where.id) : null; const existing = where.id ? session.model.annot(where.id) : null;
if (where.id && !existing) return; if (where.id && !existing) return;
// A stamp carries the top of its block; a replaced line carries its
// baseline, because that is what it has to keep sharing with the line it
// sits on. The caret wants the top either way.
const isRun = existing?.kind === "textedit";
const seed = existing const seed = existing
? { uid: existing.page, x: existing.x, y: existing.y, id: existing.id, ? { uid: existing.page, id: existing.id,
x: existing.x,
y: isRun ? existing.baseline + (existing.size || 12) * ASCENT : existing.y,
text: existing.text, size: existing.size || 12, text: existing.text, size: existing.size || 12,
bold: !!existing.bold, italic: !!existing.italic, color: existing.color } bold: !!existing.bold, italic: !!existing.italic, color: existing.color,
fresh: !!where.fresh, was: existing.text }
: { uid: where.uid, x: where.x, y: where.y, text: "", : { uid: where.uid, x: where.x, y: where.y, text: "",
size: lastTextSize, bold: lastTextBold, italic: lastTextItalic, size: lastTextSize, bold: lastTextBold, italic: lastTextItalic,
color: session.tools.ink }; color: session.tools.ink };
// While it is being typed, the stamp itself is hidden: seeing the old words // While it is being typed, the stamp itself is hidden: seeing the old words
// underneath the new ones reads as a rendering fault. // underneath the new ones reads as a rendering fault. A replaced line keeps
// its cover — that is what is holding the original out of sight.
if (existing) { existing._editing = true; session.strip.refreshOverlay(existing.page, existing.id); } if (existing) { existing._editing = true; session.strip.refreshOverlay(existing.page, existing.id); }
if (!ed.start(seed)) { if (existing) delete existing._editing; return; } if (!ed.start(seed)) { if (existing) delete existing._editing; return; }
paintInlineBar(); paintInlineBar();
status(existing ? "Editing text — Escape to leave it as it was." : "Type. Click elsewhere when you are done."); if (!isRun) {
status(existing ? "Editing text — Escape to leave it as it was." : "Type. Click elsewhere when you are done.");
}
} }
function finishInlineText(s) { function finishInlineText(s) {
@ -914,13 +968,24 @@ function finishInlineText(s) {
const existing = s.id ? model.annot(s.id) : null; const existing = s.id ? model.annot(s.id) : null;
if (existing) delete existing._editing; if (existing) delete existing._editing;
const text = String(s.text || ""); const text = String(s.text || "");
// A cover laid down only to open this caret, closed without a word being
// changed, is not an edit and should not be one on the undo stack.
if (existing && s.fresh && text === s.was) {
session.tools.select(existing.id);
session.tools.deleteSelected();
paintChrome();
status("");
return;
}
if (existing) { if (existing) {
// updateText deletes the mark when the text is emptied, which is the // updateText deletes the mark when the text is emptied, which is the
// right reading of "select it all and press delete". // right reading of "select it all and press delete".
session.tools.updateText(s.id, { text, size: s.size, bold: s.bold, italic: s.italic }); session.tools.updateText(s.id, { text, size: s.size, bold: s.bold, italic: s.italic });
warnUnencodable(text);
} else if (text.trim()) { } else if (text.trim()) {
session.tools.placeText({ uid: s.uid, x: s.x, y: s.y }, session.tools.placeText({ uid: s.uid, x: s.x, y: s.y },
{ text, size: s.size, bold: s.bold, italic: s.italic }); { text, size: s.size, bold: s.bold, italic: s.italic });
warnUnencodable(text);
} else { } else {
// Typed nothing. Nothing was ever added, so nothing has to be taken away. // Typed nothing. Nothing was ever added, so nothing has to be taken away.
session.strip?.refreshOverlay(s.uid, session.tools?.selected || null); session.strip?.refreshOverlay(s.uid, session.tools?.selected || null);
@ -1019,51 +1084,17 @@ function positionInlineBar() {
let lastTextSize = 12, lastTextBold = false, lastTextItalic = false; let lastTextSize = 12, lastTextBold = false, lastTextItalic = false;
async function onTextModal(id, at) { // Typing happens on the page now, so the dialog that used to hold a copy of
const existing = id ? session.model?.annot(id) : null; // the words is gone. What it did that the caret does not is warn about
$("modal-text-title").textContent = existing ? "Edit text" : "Text stamp"; // characters the built-in font cannot write — so the caret says it on the way
$("text-body").value = existing ? existing.text : ""; // out instead, once, rather than as you type.
// A run lifted out of a document is whatever size the document set — 11 pt, function warnUnencodable(text) {
// 9.5 pt — and the picker only lists round numbers. Without this the select const n = unencodableStampsIn(text);
// falls back to empty and the size silently becomes 12 on the way out. if (n) {
const wanted = existing ? existing.size || 12 : 12; toast(`${n} character${n === 1 ? "" : "s"} cannot be written with the built-in font `
const sizeSel = $("text-size"); + `and will be saved as "?".`, true);
if (![...sizeSel.options].some((o) => Number(o.value) === Number(wanted))) {
const opt = document.createElement("option");
opt.value = String(wanted);
opt.textContent = String(Math.round(wanted * 10) / 10);
opt.dataset.adhoc = "1";
sizeSel.append(opt);
} }
sizeSel.value = String(wanted);
$("text-bold").classList.toggle("active", !!existing?.bold);
$("text-italic").classList.toggle("active", !!existing?.italic);
$("text-warn").hidden = true;
const p = openModal("modal-text");
setTimeout(() => { $("text-body").focus(); $("text-body").select(); }, 30);
const ok = await p;
const props = {
text: $("text-body").value,
size: parseFloat($("text-size").value) || wanted,
bold: $("text-bold").classList.contains("active"),
italic: $("text-italic").classList.contains("active"),
};
for (const o of [...$("text-size").options]) if (o.dataset.adhoc) o.remove();
if (!ok) return null;
return existing ? session.tools.updateText(id, props) : session.tools.placeText(at, props);
} }
for (const b of [$("text-bold"), $("text-italic")]) {
b?.addEventListener("click", () => b.classList.toggle("active"));
}
$("text-ok").addEventListener("click", () => closeModal(true));
$("text-cancel").addEventListener("click", () => closeModal(null));
$("text-body").addEventListener("input", () => {
const n = unencodableStampsIn($("text-body").value);
const w = $("text-warn");
w.hidden = n === 0;
w.classList.add("warn");
if (n) w.textContent = `${n} character${n === 1 ? "" : "s"} cannot be written with the built-in font and will be saved as "?".`;
});
function unencodableStampsIn(text) { function unencodableStampsIn(text) {
// Same WinAnsi test the writer uses, applied live so the user finds out // Same WinAnsi test the writer uses, applied live so the user finds out
// before placing the stamp rather than after saving. // before placing the stamp rather than after saving.
@ -1566,7 +1597,7 @@ window.addEventListener("keydown", (e) => {
if (session.tools?.selection?.size > 1) return; // one caret, one mark if (session.tools?.selection?.size > 1) return; // one caret, one mark
const a = sel && session.model?.annot(sel); const a = sel && session.model?.annot(sel);
if (a?.kind === "text") { beginText({ id: sel }); e.preventDefault(); return; } if (a?.kind === "text") { beginText({ id: sel }); e.preventDefault(); return; }
if (a?.kind === "textedit") { onTextModal(sel, null); e.preventDefault(); } if (a?.kind === "textedit") { beginText({ id: sel }); e.preventDefault(); }
return; return;
} }
if (e.key === "+" || e.key === "=") { session.strip?.stepScale(1); syncZoomSelect(); e.preventDefault(); return; } if (e.key === "+" || e.key === "=") { session.strip?.stepScale(1); syncZoomSelect(); e.preventDefault(); return; }

View file

@ -87,13 +87,22 @@ export class InlineText {
* @param {number} o.y user-space TOP 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 * @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" }) { /**
* `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); this.close(true);
const view = this.strip.views.get(uid); const view = this.strip.views.get(uid);
const ov = this.strip.overlayFor(uid); const ov = this.strip.overlayFor(uid);
if (!view || !ov?.viewport) return false; if (!view || !ov?.viewport) return false;
this.state = { uid, x, y, id, size, bold, italic, color }; this.state = { uid, x, y, id, size, bold, italic, color, fresh, was };
const el = document.createElement("div"); const el = document.createElement("div");
el.className = "inline-text"; el.className = "inline-text";
el.contentEditable = "plaintext-only"; el.contentEditable = "plaintext-only";

View file

@ -17,10 +17,10 @@ const NS = "http://www.w3.org/2000/svg";
const HANDLE = 9; // grab-handle size in CSS px, deliberately not scaled with zoom const HANDLE = 9; // grab-handle size in CSS px, deliberately not scaled with zoom
let clipSeq = 0; // clipPath ids must be unique across every page in the document let clipSeq = 0; // clipPath ids must be unique across every page in the document
const HIT_TOLERANCE = 14; // CSS px of grab room around a thin stroke const HIT_TOLERANCE = 14; // CSS px of grab room around a thin stroke
const ROTATE_ARM = 22; // CSS px between the box and its rotate grip const ROTATE_ARM = 22; // CSS px between the box and its rotate grip
/** Kinds that can be turned. A signature, because signing lines are not /** Kinds that can be turned. A signature, because signing lines are not
* always square to the page; nothing else has asked for it. */ * always square to the page; nothing else has asked for it. */
const ROTATABLE = new Set(["signature"]); const ROTATABLE = new Set(["signature"]);
function el(name, attrs) { function el(name, attrs) {
const n = document.createElementNS(NS, name); const n = document.createElementNS(NS, name);
for (const k in attrs) if (attrs[k] != null) n.setAttribute(k, attrs[k]); for (const k in attrs) if (attrs[k] != null) n.setAttribute(k, attrs[k]);
@ -80,8 +80,14 @@ export class Overlay {
for (const a of annots) { for (const a of annots) {
// A stamp being typed is drawn by the caret instead; two copies of the // A stamp being typed is drawn by the caret instead; two copies of the
// same words, one stale, reads as a rendering fault. // same words, one stale, reads as a rendering fault.
if (a._editing) continue; //
const node = this._draw(a); // A replaced line keeps its cover while being typed, and loses only its
// words: the cover is the thing holding the document's own line out of
// sight, and dropping it would put the original back under the caret —
// which is precisely the "a copy on top of the original" that editing
// in place exists to avoid.
if (a._editing && a.kind !== "textedit") continue;
const node = this._draw(a._editing ? { ...a, text: "" } : a);
if (!node) continue; if (!node) continue;
this._addHitArea(node, a); this._addHitArea(node, a);
node.classList.add("mark"); node.classList.add("mark");
@ -346,8 +352,7 @@ export class Overlay {
if (ROTATABLE.has(sel.kind)) { if (ROTATABLE.has(sel.kind)) {
const ry = T - ROTATE_ARM; const ry = T - ROTATE_ARM;
this.chrome.append(el("line", { class: "rotarm", x1: MX, y1: T, x2: MX, y2: ry })); this.chrome.append(el("line", { class: "rotarm", x1: MX, y1: T, x2: MX, y2: ry }));
const g = this._handle("rotate", MX, ry, "grab"); this._rotateHandle(MX, ry);
if (g) g.classList.add("rot");
} }
} }
@ -396,6 +401,35 @@ export class Overlay {
return h; return h;
} }
/**
* The rotate grip: a round button with a turning arrow on it.
*
* It was a plain square like the resize handles, which said "drag me to
* make this bigger" — the one thing it does not do — and the cursor over
* it was an open hand, which says "drag the page". A circle with an arrow
* curving round it says turn, in every drawing program there has ever
* been.
*/
_rotateHandle(cx, cy) {
// No inline cursor: an inline style beats the stylesheet, and the
// stylesheet is where the turning-arrow cursor lives.
const g = el("g", { class: "handle rot" });
g.dataset.handle = "rotate";
g.append(el("circle", { cx, cy, r: 7.5, class: "rotdisc" }));
// Three-quarters of a circle with an arrowhead at the open end.
const r = 4.2;
g.append(el("path", {
class: "rotmark",
d: `M ${cx + r} ${cy} A ${r} ${r} 0 1 1 ${(cx - r * 0.7).toFixed(2)} ${(cy - r * 0.7).toFixed(2)}`,
}));
g.append(el("path", {
class: "rotmark",
d: `M ${(cx - r * 1.5).toFixed(2)} ${(cy - r * 0.55).toFixed(2)} L ${(cx - r * 0.55).toFixed(2)} ${(cy - r * 1.0).toFixed(2)} L ${(cx - r * 0.2).toFixed(2)} ${(cy - r * 0.05).toFixed(2)}`,
}));
this.chrome.append(g);
return g;
}
// Stroke and fill for a shape, honouring its fill and opacity. // Stroke and fill for a shape, honouring its fill and opacity.
_paint(a) { _paint(a) {
return { return {

View file

@ -50,7 +50,7 @@ export class Tools {
this.strip = strip; this.strip = strip;
this.model = model; this.model = model;
// host (editor.js): { toast, onChange, selectTool, onSelectionChange, // host (editor.js): { toast, onChange, selectTool, onSelectionChange,
// beginText, editText, editRun, chooseSignature, // beginText, editRun, chooseSignature,
// signature } // signature }
this.host = host; this.host = host;
this.tool = "select"; this.tool = "select";
@ -340,7 +340,7 @@ export class Tools {
// A stamp is edited where it sits; a replaced run keeps its dialog, // A stamp is edited where it sits; a replaced run keeps its dialog,
// which is where it explains what it is replacing. // which is where it explains what it is replacing.
if (a?.kind === "text") { this.host.beginText?.({ id }); return; } if (a?.kind === "text") { this.host.beginText?.({ id }); return; }
if (a?.kind === "textedit") { this.host.editText?.(id, null); return; } if (a?.kind === "textedit") { this.host.beginText?.({ id }); return; }
} }
this.drag = this._beginMove(hit.uid, id, x, y, e); this.drag = this._beginMove(hit.uid, id, x, y, e);
return; return;
@ -612,12 +612,68 @@ export class Tools {
* — so the size survives zooming, which reading it off the rendered box * — so the size survives zooming, which reading it off the rendered box
* would not. * would not.
*/ */
/**
* The whole visual line the clicked span belongs to.
*
* A line of a PDF is rarely one span. pdf.js splits it wherever the file
* does — a font change, a kerning adjustment, a ligature — so "Quarterly
* Report" can be three spans and a real invoice line can be ten. Replacing
* one of them covered a fragment and left the rest of the line standing,
* which is what a replacement that looks like a copy laid on top of the
* original actually is.
*
* Same line means: the vertical middles agree, and the horizontal gaps are
* small enough to be spacing rather than a second column.
*/
_lineSpans(span) {
const layer = span.closest(".textLayer");
if (!layer) return [span];
const r0 = span.getBoundingClientRect();
if (!(r0.height > 0)) return [span];
const mid = r0.top + r0.height / 2;
const tol = Math.max(3, r0.height * 0.4);
const row = [...layer.querySelectorAll("span")]
.map((el) => ({ el, r: el.getBoundingClientRect() }))
.filter((s) => s.r.width > 0 && s.r.height > 0 &&
Math.abs(s.r.top + s.r.height / 2 - mid) <= tol)
.sort((a, b) => a.r.left - b.r.left);
const i = row.findIndex((s) => s.el === span);
if (i < 0) return [span];
// A gap wider than a couple of spaces is another block of text, not the
// rest of this line.
const maxGap = Math.max(8, r0.height * 1.2);
let lo = i, hi = i;
while (lo > 0 && row[lo].r.left - row[lo - 1].r.right <= maxGap) lo--;
while (hi < row.length - 1 && row[hi + 1].r.left - row[hi].r.right <= maxGap) hi++;
return row.slice(lo, hi + 1).map((s) => s.el);
}
describeRun(span, uid) { describeRun(span, uid) {
const ov = this.strip.overlayFor(uid); const ov = this.strip.overlayFor(uid);
const view = this.strip.views.get(uid); const view = this.strip.views.get(uid);
if (!ov?.viewport || !view) return null; if (!ov?.viewport || !view) return null;
const pr = view.pv.div.getBoundingClientRect(); const pr = view.pv.div.getBoundingClientRect();
const r = span.getBoundingClientRect(); const spans = this._lineSpans(span);
const rects = spans.map((el) => el.getBoundingClientRect());
// The line's own box, and its text with the spaces the geometry implies:
// pdf.js puts no space between spans, the gap between them IS the space.
const r = {
left: Math.min(...rects.map((b) => b.left)),
right: Math.max(...rects.map((b) => b.right)),
top: Math.min(...rects.map((b) => b.top)),
bottom: Math.max(...rects.map((b) => b.bottom)),
};
r.width = r.right - r.left;
r.height = r.bottom - r.top;
let lineText = "";
for (let k = 0; k < spans.length; k++) {
const t = spans[k].textContent;
if (k > 0) {
const gap = rects[k].left - rects[k - 1].right;
if (gap > rects[k].height * 0.18 && !/\s$/.test(lineText) && !/^\s/.test(t)) lineText += " ";
}
lineText += t;
}
if (r.width < 1 || r.height < 1) return null; if (r.width < 1 || r.height < 1) return null;
const [ax, ay] = ov.toPdf(r.left - pr.left, r.bottom - pr.top); const [ax, ay] = ov.toPdf(r.left - pr.left, r.bottom - pr.top);
const [bx, by] = ov.toPdf(r.right - pr.left, r.top - pr.top); const [bx, by] = ov.toPdf(r.right - pr.left, r.top - pr.top);
@ -636,7 +692,8 @@ export class Tools {
const top = box.y + box.h + size * 0.06; const top = box.y + box.h + size * 0.06;
return { return {
uid, uid,
text: span.textContent, text: lineText,
spans: spans.length,
box: { x: box.x - 0.6, y: bottom, w: box.w + 1.2, h: top - bottom }, box: { x: box.x - 0.6, y: bottom, w: box.w + 1.2, h: top - bottom },
size, size,
baseline, baseline,