feat(pdf-editor): a mark you placed is something you can still work on
Everything the editor put on a page was final. A text stamp could not be
corrected without deleting it and typing it again, nothing could be resized,
and the only way to remove a mark was a Delete key nobody had been told
about — the selection drew a dashed box and offered no action at all. Placing
a stamp also left its tool armed, so the next click stamped a second copy.
Marks are now editable objects. Selecting one gives it grab handles and a
small bar pinned above it: delete and duplicate for anything, and for text an
edit button, a size stepper and bold and italic. Double-clicking text reopens
it for rewriting in place rather than adding a second one. Placing a text
stamp or a signature drops straight back to the select tool with the new mark
live, which is both what people expect and what puts it immediately within
reach of a nudge.
Resizing is one function over every mark type rather than a special case per
kind: a handle drag produces a new bounding box, and the mark is mapped from
its old box into that one. Text scales by font size instead of stretching its
glyphs, signatures keep their aspect on a corner, and lines offer their two
endpoints instead of a box that would let you stretch them in ways you never
aimed at. A whole gesture lands on the undo stack as one step.
Selecting a thin mark used to mean clicking its outline exactly — about one
screen pixel. Each stroked mark now carries an invisible fat copy of itself
purely to catch the pointer.
New marks to go with it: underline and strike-through, which share the
highlight's text-selection geometry and differ only in where the rule sits; a
plain line; and a fill toggle for rectangles and ellipses. Bold and italic
mean three more Helvetica variants embedded at save time, since a PDF treats
them as separate fonts rather than as a style.
Double-click is detected from the pointer stream rather than from a dblclick
listener, because selecting a mark calls preventDefault() on the pointerdown
and that suppresses the compatibility mouse events the browser would have
synthesised the dblclick from.
2026-09-21 03:13:06 +02:00
|
|
|
// PDF Editor — the page. A full Theseus tab, opened through the "open-tab"
|
|
|
|
|
// capability, talking to the add-on's Node half over window.silentmode.
|
|
|
|
|
//
|
|
|
|
|
// Boot order matters: pdf.js's viewer components are published as a bundle
|
|
|
|
|
// that reads its API off globalThis.pdfjsLib rather than importing it, so the
|
|
|
|
|
// core has to be imported and parked on the global BEFORE the components
|
|
|
|
|
// bundle is pulled in.
|
|
|
|
|
//
|
|
|
|
|
// Find is deliberately absent from this file. Theseus captures Ctrl+F at the
|
|
|
|
|
// app level (main.js's before-input-event handler) and drives Chromium's own
|
|
|
|
|
// findInPage over the tab — so the browser's find bar already searches this
|
|
|
|
|
// document, with the count and next/previous the user knows from every other
|
|
|
|
|
// page. A second find UI inside the tab would be the worse one. What this file
|
|
|
|
|
// owes that arrangement is a text layer for every page, drawn or not, which is
|
|
|
|
|
// why lib/view.js keeps its own (see the comment there).
|
|
|
|
|
|
|
|
|
|
import { DocModel } from "./lib/model.js";
|
|
|
|
|
import { PageStrip } from "./lib/view.js";
|
|
|
|
|
import { Rail } from "./lib/rail.js";
|
|
|
|
|
import { Tools } from "./lib/tools.js";
|
|
|
|
|
import { SignaturePad } from "./lib/signature.js";
|
feat(pdf-editor): replace the document's own text, on its own baseline
Until now "editing" a PDF here meant laying things over it. You could put a
word on top of a word, but the document underneath never changed, and the
result read like a sticker because it was one. This adds the thing the word
Edit actually promises: click a line of the document's text, type different
words, and they land where the old ones were, in the old size and the old
colour.
The position and size come from pdf.js's text layer, which has already placed
a span over every run and carries that run's size in unscaled PDF points — so
the size is right whatever the zoom, which reading it off the rendered box
would not be. The colours come from the rendered page, because nothing in the
text API reports them: the background is the average of the most common colour
bucket in the run's box, since type is a minority of the pixels even when it
is dense, and the ink is whatever sits furthest from that background. On the
test fixture it recovers the marker's red exactly.
Two things that look like details and are not. The bucket only chooses WHICH
pixels are background; the colour itself is their average, because rebuilding
it from the bucket index rounds white down to #f8f8f8 and a not-quite-white
patch on a white page is a visible seam. And the cover reaches below the
baseline by a quarter of the font size, because pdf.js sizes its spans to the
em box: cut the cover to the span and every descender in the original line
survives as a little hook under the replacement.
A replacement is a cover plus text, so it is a mark like any other — movable,
resizable, undoable, and rendered on screen from the same numbers the writer
uses, which is what makes the preview trustworthy.
Said plainly in the dialog and again in the save summary: this hides the
original, it does not remove it. The old glyphs are still in the content
stream underneath. Redact is the tool that takes text away, and it says so
too.
2026-09-22 21:49:37 +02:00
|
|
|
import { buildPdf, unencodableStamps, replacedRuns } from "./lib/save.js";
|
feat(pdf-editor): a mark you placed is something you can still work on
Everything the editor put on a page was final. A text stamp could not be
corrected without deleting it and typing it again, nothing could be resized,
and the only way to remove a mark was a Delete key nobody had been told
about — the selection drew a dashed box and offered no action at all. Placing
a stamp also left its tool armed, so the next click stamped a second copy.
Marks are now editable objects. Selecting one gives it grab handles and a
small bar pinned above it: delete and duplicate for anything, and for text an
edit button, a size stepper and bold and italic. Double-clicking text reopens
it for rewriting in place rather than adding a second one. Placing a text
stamp or a signature drops straight back to the select tool with the new mark
live, which is both what people expect and what puts it immediately within
reach of a nudge.
Resizing is one function over every mark type rather than a special case per
kind: a handle drag produces a new bounding box, and the mark is mapped from
its old box into that one. Text scales by font size instead of stretching its
glyphs, signatures keep their aspect on a corner, and lines offer their two
endpoints instead of a box that would let you stretch them in ways you never
aimed at. A whole gesture lands on the undo stack as one step.
Selecting a thin mark used to mean clicking its outline exactly — about one
screen pixel. Each stroked mark now carries an invisible fat copy of itself
purely to catch the pointer.
New marks to go with it: underline and strike-through, which share the
highlight's text-selection geometry and differ only in where the rule sits; a
plain line; and a fill toggle for rectangles and ellipses. Bold and italic
mean three more Helvetica variants embedded at save time, since a PDF treats
them as separate fonts rather than as a style.
Double-click is detected from the pointer stream rather than from a dblclick
listener, because selecting a mark calls preventDefault() on the pointerdown
and that suppresses the compatibility mouse events the browser would have
synthesised the dblclick from.
2026-09-21 03:13:06 +02:00
|
|
|
import { boundsOf, clampFont } from "./lib/shape.js";
|
|
|
|
|
|
|
|
|
|
const $ = (id) => document.getElementById(id);
|
|
|
|
|
const sm = window.silentmode || null;
|
|
|
|
|
|
|
|
|
|
const FLATTEN_DPI = 150; // raster resolution when rebuilding a redacted page
|
|
|
|
|
|
|
|
|
|
// ---- boot pdf.js ------------------------------------------------------
|
|
|
|
|
let pdfjsLib = null;
|
|
|
|
|
async function bootPdfjs() {
|
|
|
|
|
if (pdfjsLib) return pdfjsLib;
|
|
|
|
|
const core = await import("./vendor/pdfjs/pdf.min.mjs");
|
|
|
|
|
core.GlobalWorkerOptions.workerSrc = new URL("./vendor/pdfjs/pdf.worker.min.mjs", import.meta.url).href;
|
|
|
|
|
// pdf_viewer.mjs is built with the core as an external and reads it off the
|
|
|
|
|
// global at module-evaluation time, so this assignment has to happen first.
|
|
|
|
|
globalThis.pdfjsLib = core;
|
|
|
|
|
const viewer = await import("./vendor/pdfjs/web/pdf_viewer.mjs");
|
|
|
|
|
// A module namespace object is frozen, so the components go on a wrapper
|
|
|
|
|
// rather than onto `core` itself.
|
|
|
|
|
pdfjsLib = {
|
|
|
|
|
getDocument: core.getDocument,
|
|
|
|
|
TextLayer: core.TextLayer,
|
|
|
|
|
setLayerDimensions: core.setLayerDimensions,
|
|
|
|
|
AnnotationMode: core.AnnotationMode,
|
|
|
|
|
viewer,
|
|
|
|
|
};
|
|
|
|
|
return pdfjsLib;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- session ----------------------------------------------------------
|
|
|
|
|
const session = {
|
|
|
|
|
bytes: null, // Uint8Array, the untouched original
|
|
|
|
|
pdf: null, // pdf.js PDFDocumentProxy
|
|
|
|
|
model: null,
|
|
|
|
|
strip: null,
|
|
|
|
|
rail: null,
|
|
|
|
|
tools: null,
|
|
|
|
|
scratchId: null, // set when the document came from the add-on's scratch dir
|
|
|
|
|
name: "document.pdf",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ---- chrome -----------------------------------------------------------
|
|
|
|
|
function toast(msg, err) {
|
|
|
|
|
const t = $("toast");
|
|
|
|
|
t.textContent = msg;
|
|
|
|
|
t.classList.toggle("err", !!err);
|
|
|
|
|
t.classList.add("on");
|
|
|
|
|
clearTimeout(toast._t);
|
|
|
|
|
toast._t = setTimeout(() => t.classList.remove("on"), err ? 4200 : 2000);
|
|
|
|
|
}
|
|
|
|
|
function status(msg, err) {
|
|
|
|
|
const s = $("status");
|
|
|
|
|
s.textContent = msg;
|
|
|
|
|
s.classList.toggle("err", !!err);
|
|
|
|
|
}
|
|
|
|
|
function setBusy(on, why) {
|
|
|
|
|
document.body.dataset.busy = on ? "1" : "";
|
|
|
|
|
if (on && why) status(why);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function paintChrome() {
|
|
|
|
|
const m = session.model;
|
|
|
|
|
$("save").disabled = !m;
|
|
|
|
|
$("undo").disabled = !m || !m.canUndo();
|
|
|
|
|
$("redo").disabled = !m || !m.canRedo();
|
|
|
|
|
const nameEl = $("docname");
|
|
|
|
|
nameEl.textContent = session.name || "No document";
|
|
|
|
|
nameEl.classList.toggle("dirty", !!m && m.isDirty());
|
|
|
|
|
document.body.dataset.empty = m ? "" : "1";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function paintPageInfo() {
|
|
|
|
|
const m = session.model, strip = session.strip;
|
|
|
|
|
if (!m || !strip) { $("pageinfo").textContent = ""; return; }
|
|
|
|
|
const vis = m.visible();
|
|
|
|
|
const at = strip.current ? vis.indexOf(strip.current) + 1 : 1;
|
|
|
|
|
const pct = Math.round(strip.scale * 100);
|
|
|
|
|
$("pageinfo").textContent = `Page ${at} of ${vis.length} · ${pct}%`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- modals -----------------------------------------------------------
|
|
|
|
|
let modalResolve = null;
|
|
|
|
|
function openModal(id) {
|
|
|
|
|
for (const m of document.querySelectorAll(".modal")) m.hidden = true;
|
|
|
|
|
$(id).hidden = false;
|
|
|
|
|
$("scrim").hidden = false;
|
|
|
|
|
return new Promise((res) => { modalResolve = res; });
|
|
|
|
|
}
|
|
|
|
|
function closeModal(value) {
|
|
|
|
|
$("scrim").hidden = true;
|
|
|
|
|
for (const m of document.querySelectorAll(".modal")) m.hidden = true;
|
|
|
|
|
const r = modalResolve; modalResolve = null;
|
|
|
|
|
r?.(value);
|
|
|
|
|
}
|
|
|
|
|
$("scrim").addEventListener("pointerdown", (e) => { if (e.target === $("scrim")) closeModal(null); });
|
|
|
|
|
|
|
|
|
|
// ---- loading ----------------------------------------------------------
|
|
|
|
|
async function loadBytes(bytes, name, scratchId = null) {
|
|
|
|
|
if (session.pdf) await teardown();
|
|
|
|
|
// pdf.js TRANSFERS the buffer it is given to its worker, which detaches our
|
|
|
|
|
// view of it — and pdf-lib needs those same bytes at save time. Keep the
|
|
|
|
|
// original and hand the worker a copy.
|
|
|
|
|
session.bytes = bytes;
|
|
|
|
|
session.name = name;
|
|
|
|
|
session.scratchId = scratchId;
|
|
|
|
|
setBusy(true, "Opening…");
|
|
|
|
|
const lib = await bootPdfjs();
|
|
|
|
|
const task = lib.getDocument({
|
|
|
|
|
data: bytes.slice(),
|
|
|
|
|
standardFontDataUrl: "vendor/pdfjs/standard_fonts/",
|
|
|
|
|
wasmUrl: "vendor/pdfjs/wasm/",
|
|
|
|
|
iccUrl: "vendor/pdfjs/iccs/",
|
|
|
|
|
// XFA forms render best-effort. pdf-lib cannot write XFA back, so a
|
|
|
|
|
// dynamic XFA form is view-and-print only here; see the warning below.
|
|
|
|
|
enableXfa: true,
|
|
|
|
|
enableScripting: false,
|
|
|
|
|
isEvalSupported: false,
|
|
|
|
|
});
|
|
|
|
|
let pdf;
|
|
|
|
|
try { pdf = await task.promise; }
|
|
|
|
|
catch (e) {
|
|
|
|
|
setBusy(false);
|
|
|
|
|
status(`Could not open ${name}: ${e?.message || e}`, true);
|
|
|
|
|
toast(`Could not open that PDF: ${e?.message || e}`, true);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
session.pdf = pdf;
|
|
|
|
|
|
|
|
|
|
const pageInfo = [];
|
|
|
|
|
for (let i = 1; i <= pdf.numPages; i++) {
|
|
|
|
|
const p = await pdf.getPage(i);
|
|
|
|
|
const vp = p.getViewport({ scale: 1, rotation: 0 });
|
|
|
|
|
pageInfo.push({ width: vp.width, height: vp.height, rotate: p.rotate });
|
|
|
|
|
}
|
|
|
|
|
session.model = new DocModel({ name, pageCount: pdf.numPages, pageInfo });
|
|
|
|
|
|
|
|
|
|
const eventBus = new lib.viewer.EventBus();
|
|
|
|
|
const linkService = new lib.viewer.PDFLinkService({
|
|
|
|
|
eventBus, externalLinkTarget: lib.viewer.LinkTarget.BLANK,
|
|
|
|
|
});
|
|
|
|
|
const layerProperties = {
|
|
|
|
|
annotationEditorUIManager: null,
|
|
|
|
|
annotationStorage: pdf.annotationStorage,
|
|
|
|
|
downloadManager: null,
|
|
|
|
|
enableScripting: false,
|
|
|
|
|
fieldObjectsPromise: pdf.getFieldObjects().catch(() => null),
|
|
|
|
|
findController: null,
|
|
|
|
|
hasJSActionsPromise: pdf.hasJSActions().catch(() => false),
|
|
|
|
|
linkService,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
session.strip = new PageStrip({
|
|
|
|
|
container: $("viewerContainer"), viewer: $("viewer"),
|
|
|
|
|
pdfjsLib: lib, pdf, model: session.model, eventBus, layerProperties,
|
|
|
|
|
});
|
|
|
|
|
await session.strip.build();
|
|
|
|
|
|
|
|
|
|
// A shim is all PDFLinkService needs to make a PDF's own table-of-contents
|
|
|
|
|
// links jump to the right page.
|
|
|
|
|
linkService.setViewer(linkShim(session.strip, session.model));
|
|
|
|
|
linkService.setDocument(pdf, null);
|
|
|
|
|
|
|
|
|
|
session.rail = new Rail({
|
|
|
|
|
list: $("rail-list"), countEl: $("rail-count"),
|
|
|
|
|
model: session.model, strip: session.strip,
|
|
|
|
|
host: { rotate: onRotate, toggleDelete: onToggleDelete, reorder: onReorder },
|
|
|
|
|
});
|
|
|
|
|
session.rail.build();
|
|
|
|
|
|
|
|
|
|
session.tools = new Tools({
|
|
|
|
|
strip: session.strip, model: session.model,
|
|
|
|
|
host: {
|
|
|
|
|
toast,
|
|
|
|
|
onChange: () => { paintChrome(); },
|
|
|
|
|
selectTool,
|
|
|
|
|
onSelectionChange: paintSelectionBar,
|
|
|
|
|
editText: onTextModal,
|
feat(pdf-editor): replace the document's own text, on its own baseline
Until now "editing" a PDF here meant laying things over it. You could put a
word on top of a word, but the document underneath never changed, and the
result read like a sticker because it was one. This adds the thing the word
Edit actually promises: click a line of the document's text, type different
words, and they land where the old ones were, in the old size and the old
colour.
The position and size come from pdf.js's text layer, which has already placed
a span over every run and carries that run's size in unscaled PDF points — so
the size is right whatever the zoom, which reading it off the rendered box
would not be. The colours come from the rendered page, because nothing in the
text API reports them: the background is the average of the most common colour
bucket in the run's box, since type is a minority of the pixels even when it
is dense, and the ink is whatever sits furthest from that background. On the
test fixture it recovers the marker's red exactly.
Two things that look like details and are not. The bucket only chooses WHICH
pixels are background; the colour itself is their average, because rebuilding
it from the bucket index rounds white down to #f8f8f8 and a not-quite-white
patch on a white page is a visible seam. And the cover reaches below the
baseline by a quarter of the font size, because pdf.js sizes its spans to the
em box: cut the cover to the span and every descender in the original line
survives as a little hook under the replacement.
A replacement is a cover plus text, so it is a mark like any other — movable,
resizable, undoable, and rendered on screen from the same numbers the writer
uses, which is what makes the preview trustworthy.
Said plainly in the dialog and again in the save summary: this hides the
original, it does not remove it. The old glyphs are still in the content
stream underneath. Redact is the tool that takes text away, and it says so
too.
2026-09-22 21:49:37 +02:00
|
|
|
editRun: onEditRun,
|
feat(pdf-editor): a mark you placed is something you can still work on
Everything the editor put on a page was final. A text stamp could not be
corrected without deleting it and typing it again, nothing could be resized,
and the only way to remove a mark was a Delete key nobody had been told
about — the selection drew a dashed box and offered no action at all. Placing
a stamp also left its tool armed, so the next click stamped a second copy.
Marks are now editable objects. Selecting one gives it grab handles and a
small bar pinned above it: delete and duplicate for anything, and for text an
edit button, a size stepper and bold and italic. Double-clicking text reopens
it for rewriting in place rather than adding a second one. Placing a text
stamp or a signature drops straight back to the select tool with the new mark
live, which is both what people expect and what puts it immediately within
reach of a nudge.
Resizing is one function over every mark type rather than a special case per
kind: a handle drag produces a new bounding box, and the mark is mapped from
its old box into that one. Text scales by font size instead of stretching its
glyphs, signatures keep their aspect on a corner, and lines offer their two
endpoints instead of a box that would let you stretch them in ways you never
aimed at. A whole gesture lands on the undo stack as one step.
Selecting a thin mark used to mean clicking its outline exactly — about one
screen pixel. Each stroked mark now carries an invisible fat copy of itself
purely to catch the pointer.
New marks to go with it: underline and strike-through, which share the
highlight's text-selection geometry and differ only in where the rule sits; a
plain line; and a fill toggle for rectangles and ellipses. Bold and italic
mean three more Helvetica variants embedded at save time, since a PDF treats
them as separate fonts rather than as a style.
Double-click is detected from the pointer stream rather than from a dblclick
listener, because selecting a mark calls preventDefault() on the pointerdown
and that suppresses the compatibility mouse events the browser would have
synthesised the dblclick from.
2026-09-21 03:13:06 +02:00
|
|
|
openSignaturePad: onSignaturePad,
|
|
|
|
|
signature: () => savedSignature,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
session.tools.setTool("select");
|
|
|
|
|
|
|
|
|
|
session.strip.onCurrentChange((uid) => { session.rail.markCurrent(uid); paintPageInfo(); });
|
|
|
|
|
session.model.onChange(() => { paintChrome(); });
|
|
|
|
|
|
|
|
|
|
setBusy(false);
|
|
|
|
|
const forms = await countFields(pdf);
|
|
|
|
|
const notes = [`${pdf.numPages} page${pdf.numPages === 1 ? "" : "s"}`];
|
|
|
|
|
if (forms.fields) notes.push(`${forms.fields} form field${forms.fields === 1 ? "" : "s"}`);
|
|
|
|
|
if (forms.xfa) notes.push("XFA form — fields can be filled here but cannot be saved back");
|
|
|
|
|
status(notes.join(" · "), !!forms.xfa);
|
|
|
|
|
paintChrome();
|
|
|
|
|
paintPageInfo();
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function countFields(pdf) {
|
|
|
|
|
let fields = 0, xfa = false;
|
|
|
|
|
try {
|
|
|
|
|
const fo = await pdf.getFieldObjects();
|
|
|
|
|
if (fo) for (const v of fo.values()) fields += v.length;
|
|
|
|
|
} catch {}
|
|
|
|
|
try { xfa = !!(await pdf.getMetadata())?.info?.IsXFAPresent; } catch {}
|
|
|
|
|
return { fields, xfa };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function linkShim(strip, model) {
|
|
|
|
|
return {
|
|
|
|
|
get pagesCount() { return model.visible().length; },
|
|
|
|
|
get currentPageNumber() { return Math.max(1, model.visible().indexOf(strip.current) + 1); },
|
|
|
|
|
set currentPageNumber(n) { const uid = model.visible()[n - 1]; if (uid) strip.scrollTo(uid); },
|
|
|
|
|
get currentScaleValue() { return strip.scaleMode; },
|
|
|
|
|
set currentScaleValue(v) { strip.setScaleMode(Number.isFinite(+v) ? +v : v); },
|
|
|
|
|
get currentScale() { return strip.scale; },
|
|
|
|
|
get isInPresentationMode() { return false; },
|
|
|
|
|
scrollPageIntoView({ pageNumber }) {
|
|
|
|
|
// pageNumber is a SOURCE page number; map it through our order so a link
|
|
|
|
|
// still lands on the right page after a reorder.
|
|
|
|
|
const uid = model.order.find((u) => model.page(u).src === pageNumber - 1);
|
|
|
|
|
if (uid && !model.page(uid).deleted) strip.scrollTo(uid);
|
|
|
|
|
},
|
|
|
|
|
get pageViewsReady() { return true; },
|
|
|
|
|
getPageView(i) { const uid = model.visible()[i]; return uid ? strip.views.get(uid)?.pv : null; },
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function teardown() {
|
|
|
|
|
// Tools first: it is the one holding listeners on the container, and a stale
|
|
|
|
|
// one would go on handling clicks against a strip that no longer exists.
|
|
|
|
|
try { session.tools?.destroy(); } catch {}
|
|
|
|
|
try { session.strip?.destroy(); } catch {}
|
|
|
|
|
try { session.rail?.destroy(); } catch {}
|
|
|
|
|
try { await session.pdf?.destroy(); } catch {}
|
|
|
|
|
session.pdf = null; session.model = null; session.strip = null;
|
|
|
|
|
session.rail = null; session.tools = null;
|
|
|
|
|
$("viewer").textContent = "";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- open paths -------------------------------------------------------
|
|
|
|
|
async function openFile(file) {
|
|
|
|
|
if (!file) return;
|
|
|
|
|
const looksPdf = /\.pdf$/i.test(file.name) || file.type === "application/pdf";
|
|
|
|
|
if (!looksPdf) { toast(`${file.name} is not a PDF`, true); return; }
|
|
|
|
|
if (!(await confirmDiscard())) return;
|
|
|
|
|
const buf = new Uint8Array(await file.arrayBuffer());
|
|
|
|
|
if (buf.subarray(0, 5).every((b, i) => b === "%PDF-".charCodeAt(i)) === false) {
|
|
|
|
|
toast("That file does not start with a PDF header", true);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
await loadBytes(buf, file.name);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$("file-input").addEventListener("change", (e) => {
|
|
|
|
|
const f = e.target.files?.[0];
|
|
|
|
|
e.target.value = "";
|
|
|
|
|
openFile(f);
|
|
|
|
|
});
|
|
|
|
|
const pick = () => $("file-input").click();
|
|
|
|
|
$("open").addEventListener("click", pick);
|
|
|
|
|
$("open2").addEventListener("click", pick);
|
|
|
|
|
|
|
|
|
|
// Drag and drop anywhere in the tab.
|
|
|
|
|
for (const type of ["dragenter", "dragover"]) {
|
|
|
|
|
window.addEventListener(type, (e) => {
|
|
|
|
|
if (![...(e.dataTransfer?.types || [])].includes("Files")) return;
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.dataTransfer.dropEffect = "copy";
|
|
|
|
|
document.body.dataset.dragover = "1";
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
window.addEventListener("dragleave", (e) => {
|
|
|
|
|
if (e.relatedTarget) return; // still inside the window
|
|
|
|
|
document.body.dataset.dragover = "";
|
|
|
|
|
});
|
|
|
|
|
window.addEventListener("drop", (e) => {
|
|
|
|
|
if (![...(e.dataTransfer?.types || [])].includes("Files")) return;
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
document.body.dataset.dragover = "";
|
|
|
|
|
openFile(e.dataTransfer.files?.[0]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ---- page operations --------------------------------------------------
|
|
|
|
|
async function onRotate(uid) {
|
|
|
|
|
session.model.rotatePage(uid, 90);
|
|
|
|
|
session.strip.layout();
|
|
|
|
|
await session.strip.redraw(uid);
|
|
|
|
|
session.rail.refresh(uid);
|
|
|
|
|
paintChrome();
|
|
|
|
|
}
|
|
|
|
|
function onToggleDelete(uid) {
|
|
|
|
|
const was = session.model.page(uid).deleted;
|
|
|
|
|
const ok = session.model.setDeleted(uid, !was);
|
|
|
|
|
if (!ok && !was) { toast("A PDF needs at least one page", true); return; }
|
|
|
|
|
session.strip.layout();
|
|
|
|
|
session.rail.build();
|
|
|
|
|
paintChrome();
|
|
|
|
|
paintPageInfo();
|
|
|
|
|
}
|
|
|
|
|
function onReorder(uid, beforeUid) {
|
|
|
|
|
session.model.reorder(uid, beforeUid);
|
|
|
|
|
session.strip.layout();
|
|
|
|
|
session.rail.build();
|
|
|
|
|
paintChrome();
|
|
|
|
|
paintPageInfo();
|
|
|
|
|
}
|
|
|
|
|
$("restore-pages").addEventListener("click", () => {
|
|
|
|
|
session.model.restoreAllPages();
|
|
|
|
|
session.strip.layout();
|
|
|
|
|
session.rail.build();
|
|
|
|
|
paintChrome();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ---- toolbar ----------------------------------------------------------
|
|
|
|
|
let redactAcknowledged = false;
|
|
|
|
|
let flattenRedactions = true;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Arm a tool and sync the toolbar. Tools calls this itself after placing a
|
|
|
|
|
* stamp, so the button highlight and the armed tool cannot drift apart.
|
|
|
|
|
*/
|
|
|
|
|
function selectTool(t) {
|
|
|
|
|
for (const o of document.querySelectorAll(".tool")) o.classList.toggle("active", o.dataset.tool === t);
|
|
|
|
|
session.tools?.setTool(t);
|
|
|
|
|
status(TOOL_HINTS[t] || "");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const b of document.querySelectorAll(".tool[data-tool]")) {
|
|
|
|
|
b.addEventListener("click", async () => {
|
|
|
|
|
const t = b.dataset.tool;
|
|
|
|
|
if (t === "redact" && !redactAcknowledged) {
|
|
|
|
|
const ok = await openModal("modal-redact");
|
|
|
|
|
if (!ok) return;
|
|
|
|
|
redactAcknowledged = true;
|
|
|
|
|
flattenRedactions = $("redact-flatten").checked;
|
|
|
|
|
try { await sm?.storage?.set("redactAcknowledged", true); } catch {}
|
|
|
|
|
}
|
|
|
|
|
selectTool(t);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
$("redact-ok").addEventListener("click", () => closeModal(true));
|
|
|
|
|
$("redact-cancel").addEventListener("click", () => closeModal(null));
|
|
|
|
|
|
|
|
|
|
const TOOL_HINTS = {
|
|
|
|
|
select: "Click a mark to select it. Drag to move, drag a handle to resize, double-click text to edit.",
|
|
|
|
|
highlight: "Drag across text to highlight it.",
|
|
|
|
|
underline: "Drag across text to underline it.",
|
|
|
|
|
strikeout: "Drag across text to strike it through.",
|
|
|
|
|
pen: "Drag to draw.",
|
|
|
|
|
rect: "Drag to draw a rectangle.",
|
|
|
|
|
ellipse: "Drag to draw an ellipse.",
|
|
|
|
|
line: "Drag to draw a line.",
|
|
|
|
|
arrow: "Drag from tail to head.",
|
|
|
|
|
text: "Click where the text should start.",
|
feat(pdf-editor): replace the document's own text, on its own baseline
Until now "editing" a PDF here meant laying things over it. You could put a
word on top of a word, but the document underneath never changed, and the
result read like a sticker because it was one. This adds the thing the word
Edit actually promises: click a line of the document's text, type different
words, and they land where the old ones were, in the old size and the old
colour.
The position and size come from pdf.js's text layer, which has already placed
a span over every run and carries that run's size in unscaled PDF points — so
the size is right whatever the zoom, which reading it off the rendered box
would not be. The colours come from the rendered page, because nothing in the
text API reports them: the background is the average of the most common colour
bucket in the run's box, since type is a minority of the pixels even when it
is dense, and the ink is whatever sits furthest from that background. On the
test fixture it recovers the marker's red exactly.
Two things that look like details and are not. The bucket only chooses WHICH
pixels are background; the colour itself is their average, because rebuilding
it from the bucket index rounds white down to #f8f8f8 and a not-quite-white
patch on a white page is a visible seam. And the cover reaches below the
baseline by a quarter of the font size, because pdf.js sizes its spans to the
em box: cut the cover to the span and every descender in the original line
survives as a little hook under the replacement.
A replacement is a cover plus text, so it is a mark like any other — movable,
resizable, undoable, and rendered on screen from the same numbers the writer
uses, which is what makes the preview trustworthy.
Said plainly in the dialog and again in the save summary: this hides the
original, it does not remove it. The old glyphs are still in the content
stream underneath. Redact is the tool that takes text away, and it says so
too.
2026-09-22 21:49:37 +02:00
|
|
|
edittext: "Click a line of the document's own text to replace it.",
|
feat(pdf-editor): a mark you placed is something you can still work on
Everything the editor put on a page was final. A text stamp could not be
corrected without deleting it and typing it again, nothing could be resized,
and the only way to remove a mark was a Delete key nobody had been told
about — the selection drew a dashed box and offered no action at all. Placing
a stamp also left its tool armed, so the next click stamped a second copy.
Marks are now editable objects. Selecting one gives it grab handles and a
small bar pinned above it: delete and duplicate for anything, and for text an
edit button, a size stepper and bold and italic. Double-clicking text reopens
it for rewriting in place rather than adding a second one. Placing a text
stamp or a signature drops straight back to the select tool with the new mark
live, which is both what people expect and what puts it immediately within
reach of a nudge.
Resizing is one function over every mark type rather than a special case per
kind: a handle drag produces a new bounding box, and the mark is mapped from
its old box into that one. Text scales by font size instead of stretching its
glyphs, signatures keep their aspect on a corner, and lines offer their two
endpoints instead of a box that would let you stretch them in ways you never
aimed at. A whole gesture lands on the undo stack as one step.
Selecting a thin mark used to mean clicking its outline exactly — about one
screen pixel. Each stroked mark now carries an invisible fat copy of itself
purely to catch the pointer.
New marks to go with it: underline and strike-through, which share the
highlight's text-selection geometry and differ only in where the rule sits; a
plain line; and a fill toggle for rectangles and ellipses. Bold and italic
mean three more Helvetica variants embedded at save time, since a PDF treats
them as separate fonts rather than as a style.
Double-click is detected from the pointer stream rather than from a dblclick
listener, because selecting a mark calls preventDefault() on the pointerdown
and that suppresses the compatibility mouse events the browser would have
synthesised the dblclick from.
2026-09-21 03:13:06 +02:00
|
|
|
signature: "Click to place your signature.",
|
|
|
|
|
redact: "Drag a box over what has to go. Redacted pages are rebuilt as images on save.",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
for (const s of document.querySelectorAll(".swatch")) {
|
|
|
|
|
s.addEventListener("click", () => {
|
|
|
|
|
for (const o of document.querySelectorAll(".swatch")) o.classList.toggle("active", o === s);
|
|
|
|
|
session.tools?.setColor(s.dataset.color);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
const fillBtn = $("toggle-fill");
|
|
|
|
|
if (fillBtn) fillBtn.addEventListener("click", () => {
|
|
|
|
|
const on = !fillBtn.classList.contains("active");
|
|
|
|
|
fillBtn.classList.toggle("active", on);
|
|
|
|
|
session.tools?.setFill(on);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
for (const w of document.querySelectorAll(".width")) {
|
|
|
|
|
w.addEventListener("click", () => {
|
|
|
|
|
for (const o of document.querySelectorAll(".width")) o.classList.toggle("active", o === w);
|
|
|
|
|
session.tools?.setWidth(parseFloat(w.dataset.width));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$("undo").addEventListener("click", () => { session.model?.undo(); afterHistory(); });
|
|
|
|
|
$("redo").addEventListener("click", () => { session.model?.redo(); afterHistory(); });
|
|
|
|
|
function afterHistory() {
|
|
|
|
|
if (!session.model) return;
|
|
|
|
|
session.strip.layout();
|
|
|
|
|
session.rail.build();
|
|
|
|
|
// Through select() rather than by assignment, so the selection bar hears
|
|
|
|
|
// about it and does not hang over a mark that undo just removed.
|
|
|
|
|
session.tools.select(null);
|
|
|
|
|
paintChrome();
|
|
|
|
|
paintPageInfo();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$("zoom").addEventListener("change", (e) => {
|
|
|
|
|
const v = e.target.value;
|
|
|
|
|
session.strip?.setScaleMode(/^[\d.]+$/.test(v) ? parseFloat(v) : v);
|
|
|
|
|
paintPageInfo();
|
|
|
|
|
positionSelectionBar();
|
|
|
|
|
});
|
|
|
|
|
$("zoom-in").addEventListener("click", () => { session.strip?.stepScale(1); syncZoomSelect(); });
|
|
|
|
|
$("zoom-out").addEventListener("click", () => { session.strip?.stepScale(-1); syncZoomSelect(); });
|
|
|
|
|
function syncZoomSelect() {
|
|
|
|
|
positionSelectionBar();
|
|
|
|
|
if (!session.strip) return;
|
|
|
|
|
const exact = String(session.strip.scale);
|
|
|
|
|
const opt = [...$("zoom").options].find((o) => o.value === exact);
|
|
|
|
|
$("zoom").value = opt ? exact : $("zoom").value;
|
|
|
|
|
paintPageInfo();
|
|
|
|
|
}
|
|
|
|
|
$("toggle-rail").addEventListener("click", () => {
|
|
|
|
|
const off = document.body.dataset.rail === "off";
|
|
|
|
|
document.body.dataset.rail = off ? "" : "off";
|
|
|
|
|
if (off) session.rail?.build();
|
|
|
|
|
session.strip?.computeScale();
|
|
|
|
|
session.strip?.layout();
|
|
|
|
|
paintPageInfo();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let resizeT = null;
|
|
|
|
|
window.addEventListener("resize", () => {
|
|
|
|
|
clearTimeout(resizeT);
|
|
|
|
|
resizeT = setTimeout(() => {
|
|
|
|
|
if (!session.strip) return;
|
|
|
|
|
if (typeof session.strip.scaleMode === "string") { session.strip.computeScale(); session.strip.layout(); }
|
|
|
|
|
paintPageInfo();
|
|
|
|
|
positionSelectionBar();
|
|
|
|
|
}, 140);
|
|
|
|
|
});
|
|
|
|
|
|
feat(pdf-editor): replace the document's own text, on its own baseline
Until now "editing" a PDF here meant laying things over it. You could put a
word on top of a word, but the document underneath never changed, and the
result read like a sticker because it was one. This adds the thing the word
Edit actually promises: click a line of the document's text, type different
words, and they land where the old ones were, in the old size and the old
colour.
The position and size come from pdf.js's text layer, which has already placed
a span over every run and carries that run's size in unscaled PDF points — so
the size is right whatever the zoom, which reading it off the rendered box
would not be. The colours come from the rendered page, because nothing in the
text API reports them: the background is the average of the most common colour
bucket in the run's box, since type is a minority of the pixels even when it
is dense, and the ink is whatever sits furthest from that background. On the
test fixture it recovers the marker's red exactly.
Two things that look like details and are not. The bucket only chooses WHICH
pixels are background; the colour itself is their average, because rebuilding
it from the bucket index rounds white down to #f8f8f8 and a not-quite-white
patch on a white page is a visible seam. And the cover reaches below the
baseline by a quarter of the font size, because pdf.js sizes its spans to the
em box: cut the cover to the span and every descender in the original line
survives as a little hook under the replacement.
A replacement is a cover plus text, so it is a mark like any other — movable,
resizable, undoable, and rendered on screen from the same numbers the writer
uses, which is what makes the preview trustworthy.
Said plainly in the dialog and again in the save summary: this hides the
original, it does not remove it. The old glyphs are still in the content
stream underneath. Redact is the tool that takes text away, and it says so
too.
2026-09-22 21:49:37 +02:00
|
|
|
// ---- replacing the document's own text --------------------------------
|
|
|
|
|
//
|
|
|
|
|
// The run's size and position come from pdf.js's text layer; its colours come
|
|
|
|
|
// from the rendered page, because nothing in the text API reports them. Both
|
|
|
|
|
// are guesses made from what is actually on screen, which is the same thing
|
|
|
|
|
// the reader is looking at, so a wrong guess is visible immediately rather
|
|
|
|
|
// than only after saving.
|
|
|
|
|
|
|
|
|
|
function hex([r, g, b]) {
|
|
|
|
|
return "#" + [r, g, b].map((n) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, "0")).join("");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The background and ink colours of one text run, read off the rendered page.
|
|
|
|
|
* Background is the most common colour in the run's box — type is a minority
|
|
|
|
|
* of the pixels even when it is dense. Ink is whatever sits furthest from it.
|
|
|
|
|
*/
|
|
|
|
|
function sampleRun(run) {
|
|
|
|
|
const fallback = { bg: "#ffffff", ink: "#000000", sampled: false };
|
|
|
|
|
try {
|
|
|
|
|
const view = session.strip?.views.get(run.uid);
|
|
|
|
|
const canvas = view?.pv?.div?.querySelector(".canvasWrapper canvas");
|
|
|
|
|
if (!canvas || !canvas.width) return fallback;
|
|
|
|
|
const cr = canvas.getBoundingClientRect();
|
|
|
|
|
if (!cr.width || !cr.height) return fallback;
|
|
|
|
|
const sx = canvas.width / cr.width, sy = canvas.height / cr.height;
|
|
|
|
|
const r = run.clientRect;
|
|
|
|
|
const x0 = Math.max(0, Math.floor((r.left - cr.left) * sx));
|
|
|
|
|
const y0 = Math.max(0, Math.floor((r.top - cr.top) * sy));
|
|
|
|
|
const w = Math.min(canvas.width - x0, Math.ceil(r.width * sx));
|
|
|
|
|
const h = Math.min(canvas.height - y0, Math.ceil(r.height * sy));
|
|
|
|
|
if (w < 2 || h < 2) return fallback;
|
|
|
|
|
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
|
|
|
|
const data = ctx.getImageData(x0, y0, w, h).data;
|
|
|
|
|
|
|
|
|
|
// Buckets of 8 per channel: exact equality would split an anti-aliased
|
|
|
|
|
// background into hundreds of near-identical shades and no single one
|
|
|
|
|
// would win. The bucket only decides WHICH pixels are the background —
|
|
|
|
|
// the colour itself is their average, because reconstructing it from the
|
|
|
|
|
// bucket index rounds white down to #f8f8f8, and a not-quite-white patch
|
|
|
|
|
// over a white page is a visible seam.
|
|
|
|
|
const buckets = new Map();
|
|
|
|
|
for (let i = 0; i < data.length; i += 4) {
|
|
|
|
|
const key = ((data[i] >> 3) << 10) | ((data[i + 1] >> 3) << 5) | (data[i + 2] >> 3);
|
|
|
|
|
let acc = buckets.get(key);
|
|
|
|
|
if (!acc) buckets.set(key, (acc = { n: 0, r: 0, g: 0, b: 0 }));
|
|
|
|
|
acc.n++; acc.r += data[i]; acc.g += data[i + 1]; acc.b += data[i + 2];
|
|
|
|
|
}
|
|
|
|
|
let win = null;
|
|
|
|
|
for (const acc of buckets.values()) if (!win || acc.n > win.n) win = acc;
|
|
|
|
|
if (!win) return fallback;
|
|
|
|
|
const bg = [win.r / win.n, win.g / win.n, win.b / win.n];
|
|
|
|
|
|
|
|
|
|
let ink = [0, 0, 0], far = -1;
|
|
|
|
|
for (let i = 0; i < data.length; i += 4) {
|
|
|
|
|
const d = Math.abs(data[i] - bg[0]) + Math.abs(data[i + 1] - bg[1]) + Math.abs(data[i + 2] - bg[2]);
|
|
|
|
|
if (d > far) { far = d; ink = [data[i], data[i + 1], data[i + 2]]; }
|
|
|
|
|
}
|
|
|
|
|
// Nothing that reads as ink — a blank strip, or type too faint to trust.
|
|
|
|
|
if (far < 40) return { ...fallback, bg: hex(bg), sampled: true };
|
|
|
|
|
return { bg: hex(bg), ink: hex(ink), sampled: true };
|
|
|
|
|
} catch { return fallback; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function onEditRun(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, {
|
|
|
|
|
text: $("text-body").value,
|
|
|
|
|
size: run.size,
|
|
|
|
|
color: look.ink,
|
|
|
|
|
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);
|
|
|
|
|
return a;
|
|
|
|
|
}
|
|
|
|
|
|
feat(pdf-editor): a mark you placed is something you can still work on
Everything the editor put on a page was final. A text stamp could not be
corrected without deleting it and typing it again, nothing could be resized,
and the only way to remove a mark was a Delete key nobody had been told
about — the selection drew a dashed box and offered no action at all. Placing
a stamp also left its tool armed, so the next click stamped a second copy.
Marks are now editable objects. Selecting one gives it grab handles and a
small bar pinned above it: delete and duplicate for anything, and for text an
edit button, a size stepper and bold and italic. Double-clicking text reopens
it for rewriting in place rather than adding a second one. Placing a text
stamp or a signature drops straight back to the select tool with the new mark
live, which is both what people expect and what puts it immediately within
reach of a nudge.
Resizing is one function over every mark type rather than a special case per
kind: a handle drag produces a new bounding box, and the mark is mapped from
its old box into that one. Text scales by font size instead of stretching its
glyphs, signatures keep their aspect on a corner, and lines offer their two
endpoints instead of a box that would let you stretch them in ways you never
aimed at. A whole gesture lands on the undo stack as one step.
Selecting a thin mark used to mean clicking its outline exactly — about one
screen pixel. Each stroked mark now carries an invisible fat copy of itself
purely to catch the pointer.
New marks to go with it: underline and strike-through, which share the
highlight's text-selection geometry and differ only in where the rule sits; a
plain line; and a fill toggle for rectangles and ellipses. Bold and italic
mean three more Helvetica variants embedded at save time, since a PDF treats
them as separate fonts rather than as a style.
Double-click is detected from the pointer stream rather than from a dblclick
listener, because selecting a mark calls preventDefault() on the pointerdown
and that suppresses the compatibility mouse events the browser would have
synthesised the dblclick from.
2026-09-21 03:13:06 +02:00
|
|
|
// ---- selection bar ----------------------------------------------------
|
|
|
|
|
//
|
|
|
|
|
// A floating strip pinned to whatever is selected, carrying the actions that
|
|
|
|
|
// only make sense for that mark: edit and type controls for text, fill for a
|
|
|
|
|
// shape, and duplicate/delete for anything. Position is fixed rather than
|
|
|
|
|
// parented into the page, so it is never clipped by a page edge and never has
|
|
|
|
|
// to be re-homed when the selection moves to another page.
|
|
|
|
|
//
|
|
|
|
|
// It exists because Delete-on-the-keyboard was the only way to remove a mark,
|
|
|
|
|
// which is undiscoverable: nothing on screen said a selected mark could go.
|
|
|
|
|
|
|
|
|
|
const selbar = $("selbar");
|
|
|
|
|
|
|
|
|
|
function paintSelectionBar(id) {
|
|
|
|
|
const a = id && session.model ? session.model.annot(id) : null;
|
|
|
|
|
if (!a) { selbar.hidden = true; return; }
|
|
|
|
|
const isText = a.kind === "text";
|
|
|
|
|
const isShape = a.kind === "rect" || a.kind === "ellipse";
|
|
|
|
|
$("sb-edit").hidden = !isText;
|
|
|
|
|
$("sb-textsize").hidden = !isText;
|
|
|
|
|
$("sb-bold").hidden = !isText;
|
|
|
|
|
$("sb-italic").hidden = !isText;
|
|
|
|
|
$("sb-fill").hidden = !isShape;
|
|
|
|
|
if (isText) {
|
|
|
|
|
$("sb-size").textContent = String(Math.round(a.size || 12));
|
|
|
|
|
$("sb-bold").classList.toggle("active", !!a.bold);
|
|
|
|
|
$("sb-italic").classList.toggle("active", !!a.italic);
|
|
|
|
|
}
|
|
|
|
|
if (isShape) $("sb-fill").classList.toggle("active", !!a.fill);
|
|
|
|
|
selbar.hidden = false;
|
|
|
|
|
positionSelectionBar();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function positionSelectionBar() {
|
|
|
|
|
const t = session.tools, m = session.model, strip = session.strip;
|
|
|
|
|
const a = t && m ? m.annot(t.selected) : null;
|
|
|
|
|
if (!a || selbar.hidden) return;
|
|
|
|
|
const ov = strip?.overlayFor(a.page);
|
|
|
|
|
const view = strip?.views.get(a.page);
|
|
|
|
|
if (!ov?.viewport || !view) { selbar.hidden = true; return; }
|
|
|
|
|
const b = boundsOf(a);
|
|
|
|
|
const [ax, ay] = ov.toView(b.x, b.y + b.h);
|
|
|
|
|
const [bx, by] = ov.toView(b.x + b.w, b.y);
|
|
|
|
|
const pr = view.pv.div.getBoundingClientRect();
|
|
|
|
|
const left = pr.left + Math.min(ax, bx);
|
|
|
|
|
const right = pr.left + Math.max(ax, bx);
|
|
|
|
|
const top = pr.top + Math.min(ay, by);
|
|
|
|
|
const bottom = pr.top + Math.max(ay, by);
|
|
|
|
|
|
|
|
|
|
const box = selbar.getBoundingClientRect();
|
|
|
|
|
const cr = $("viewerContainer").getBoundingClientRect();
|
|
|
|
|
const GAP = 10;
|
|
|
|
|
// Above the mark by preference; below when there is no room up there.
|
|
|
|
|
let y = top - box.height - GAP;
|
|
|
|
|
if (y < cr.top + 4) y = Math.min(bottom + GAP, cr.bottom - box.height - 4);
|
|
|
|
|
let x = (left + right) / 2 - box.width / 2;
|
|
|
|
|
x = Math.min(Math.max(x, cr.left + 4), cr.right - box.width - 4);
|
|
|
|
|
selbar.style.left = `${Math.round(x)}px`;
|
|
|
|
|
selbar.style.top = `${Math.round(y)}px`;
|
|
|
|
|
// A selection scrolled out of the viewport takes its bar with it.
|
|
|
|
|
selbar.classList.toggle("offscreen", bottom < cr.top || top > cr.bottom);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The bar would jitter under the cursor mid-gesture, so it steps aside while
|
|
|
|
|
// a drag is in flight and comes back where the mark ended up.
|
|
|
|
|
$("viewerContainer").addEventListener("pointerdown", () => { document.body.dataset.dragging = "1"; });
|
|
|
|
|
window.addEventListener("pointerup", () => {
|
|
|
|
|
document.body.dataset.dragging = "";
|
|
|
|
|
positionSelectionBar();
|
|
|
|
|
});
|
|
|
|
|
$("viewerContainer").addEventListener("scroll", () => positionSelectionBar(), { passive: true });
|
|
|
|
|
|
|
|
|
|
$("sb-edit").addEventListener("click", () => {
|
|
|
|
|
const id = session.tools?.selected;
|
|
|
|
|
if (id) onTextModal(id, null);
|
|
|
|
|
});
|
|
|
|
|
$("sb-del").addEventListener("click", () => { session.tools?.deleteSelected(); paintChrome(); });
|
|
|
|
|
$("sb-dup").addEventListener("click", () => { session.tools?.duplicateSelected(); paintChrome(); });
|
|
|
|
|
$("sb-bold").addEventListener("click", () => toggleTextStyle("bold"));
|
|
|
|
|
$("sb-italic").addEventListener("click", () => toggleTextStyle("italic"));
|
|
|
|
|
$("sb-fill").addEventListener("click", () => {
|
|
|
|
|
const a = session.model?.annot(session.tools?.selected);
|
|
|
|
|
if (a) session.tools.setFill(!a.fill);
|
|
|
|
|
});
|
|
|
|
|
$("sb-smaller").addEventListener("click", () => stepTextSize(-1));
|
|
|
|
|
$("sb-bigger").addEventListener("click", () => stepTextSize(1));
|
|
|
|
|
|
|
|
|
|
function toggleTextStyle(which) {
|
|
|
|
|
const a = session.model?.annot(session.tools?.selected);
|
|
|
|
|
if (!a || a.kind !== "text") return;
|
|
|
|
|
delete a._w; // the glyphs change width; re-measure
|
|
|
|
|
session.tools.patchSelected({ [which]: !a[which] });
|
|
|
|
|
}
|
|
|
|
|
function stepTextSize(dir) {
|
|
|
|
|
const a = session.model?.annot(session.tools?.selected);
|
|
|
|
|
if (!a || a.kind !== "text") 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);
|
|
|
|
|
if (next == null) return;
|
|
|
|
|
delete a._w;
|
|
|
|
|
session.tools.patchSelected({ size: clampFont(next) });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- text stamp -------------------------------------------------------
|
|
|
|
|
/**
|
|
|
|
|
* 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.
|
|
|
|
|
*/
|
|
|
|
|
async function onTextModal(id, at) {
|
|
|
|
|
const existing = id ? session.model?.annot(id) : null;
|
|
|
|
|
$("modal-text-title").textContent = existing ? "Edit text" : "Text stamp";
|
|
|
|
|
$("text-body").value = existing ? existing.text : "";
|
|
|
|
|
$("text-size").value = String(existing ? existing.size || 12 : 12);
|
|
|
|
|
$("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;
|
|
|
|
|
if (!ok) return null;
|
|
|
|
|
const props = {
|
|
|
|
|
text: $("text-body").value,
|
|
|
|
|
size: parseFloat($("text-size").value) || 12,
|
|
|
|
|
bold: $("text-bold").classList.contains("active"),
|
|
|
|
|
italic: $("text-italic").classList.contains("active"),
|
|
|
|
|
};
|
|
|
|
|
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) {
|
|
|
|
|
// Same WinAnsi test the writer uses, applied live so the user finds out
|
|
|
|
|
// before placing the stamp rather than after saving.
|
|
|
|
|
let n = 0;
|
|
|
|
|
for (const ch of String(text)) {
|
|
|
|
|
const code = ch.codePointAt(0);
|
|
|
|
|
const ok = (code >= 0x20 && code <= 0x7e) || (code >= 0xa0 && code <= 0xff) ||
|
|
|
|
|
[0x2018, 0x2019, 0x201c, 0x201d, 0x2013, 0x2014, 0x2022, 0x20ac, 0x2026, 0x2122].includes(code) ||
|
|
|
|
|
ch === "\n";
|
|
|
|
|
if (!ok) n++;
|
|
|
|
|
}
|
|
|
|
|
return n;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- signature --------------------------------------------------------
|
|
|
|
|
let savedSignature = null;
|
|
|
|
|
let pad = null;
|
|
|
|
|
async function onSignaturePad() {
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
$("sign-ok").addEventListener("click", () => closeModal(true));
|
|
|
|
|
$("sign-cancel").addEventListener("click", () => closeModal(null));
|
|
|
|
|
$("sign-clear").addEventListener("click", () => pad?.clear());
|
|
|
|
|
|
|
|
|
|
// ---- save -------------------------------------------------------------
|
|
|
|
|
function defaultSaveName() {
|
|
|
|
|
const base = String(session.name || "document.pdf").replace(/\.pdf$/i, "");
|
|
|
|
|
return `${base}-edited.pdf`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Everything the user typed into a form field, keyed by field NAME.
|
|
|
|
|
*
|
|
|
|
|
* Driven from the document's field list rather than from the storage's
|
|
|
|
|
* contents. pdf.js keys its annotation storage by annotation id ("11R"), which
|
|
|
|
|
* means nothing to pdf-lib — pdf-lib addresses fields by name — so the field
|
|
|
|
|
* list is needed for the mapping either way, and iterating it also keeps
|
|
|
|
|
* pdf.js's own editor bookkeeping out of the results for free.
|
|
|
|
|
*/
|
|
|
|
|
async function harvestFormValues(pdf) {
|
|
|
|
|
const out = [];
|
|
|
|
|
let fields = null;
|
|
|
|
|
try { fields = await pdf.getFieldObjects(); } catch { return out; }
|
|
|
|
|
if (!fields) return out;
|
|
|
|
|
const storage = pdf.annotationStorage;
|
|
|
|
|
for (const [name, objs] of fields) {
|
|
|
|
|
for (const o of objs || []) {
|
|
|
|
|
// `has` distinguishes a field the user touched from one they left alone;
|
|
|
|
|
// writing back every field would rewrite appearances across the whole
|
|
|
|
|
// document for no reason.
|
|
|
|
|
if (!o?.id || !storage.has(o.id)) continue;
|
|
|
|
|
const entry = storage.getRawValue(o.id);
|
|
|
|
|
if (!entry || typeof entry !== "object" || !("value" in entry)) continue;
|
|
|
|
|
out.push({ name, value: entry.value });
|
|
|
|
|
break; // a field's kids (radio buttons) share one value
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Render one page to a PNG at FLATTEN_DPI, unrotated, for the flatten pass. */
|
|
|
|
|
async function rasterizePage(uid) {
|
|
|
|
|
const entry = session.strip.views.get(uid);
|
|
|
|
|
const scale = FLATTEN_DPI / 72;
|
|
|
|
|
// rotation 0, so the image lands in the page's own unrotated coordinate
|
|
|
|
|
// space — the page's /Rotate then presents it exactly as before.
|
|
|
|
|
const vp = entry.pdfPage.getViewport({ scale, rotation: 0 });
|
|
|
|
|
const canvas = document.createElement("canvas");
|
|
|
|
|
canvas.width = Math.max(1, Math.ceil(vp.width));
|
|
|
|
|
canvas.height = Math.max(1, Math.ceil(vp.height));
|
|
|
|
|
const ctx = canvas.getContext("2d", { alpha: false });
|
|
|
|
|
ctx.fillStyle = "#ffffff";
|
|
|
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
|
|
|
await entry.pdfPage.render({ canvasContext: ctx, viewport: vp, intent: "print" }).promise;
|
|
|
|
|
const blob = await new Promise((res, rej) =>
|
|
|
|
|
canvas.toBlob((b) => b ? res(b) : rej(new Error("toBlob returned null")), "image/png"));
|
|
|
|
|
return new Uint8Array(await blob.arrayBuffer());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function doSave() {
|
|
|
|
|
const m = session.model;
|
|
|
|
|
if (!m) return;
|
|
|
|
|
$("save-name").value = defaultSaveName();
|
|
|
|
|
const summary = $("save-summary");
|
|
|
|
|
summary.textContent = "";
|
|
|
|
|
const lines = [];
|
|
|
|
|
if (m.annots.length) lines.push([`${m.annots.length} mark${m.annots.length === 1 ? "" : "s"} written into the file`, false]);
|
|
|
|
|
const formValues = await harvestFormValues(session.pdf);
|
|
|
|
|
if (formValues.length) lines.push([`${formValues.length} form field${formValues.length === 1 ? "" : "s"} filled in`, false]);
|
|
|
|
|
if (m.deletedCount()) lines.push([`${m.deletedCount()} page${m.deletedCount() === 1 ? "" : "s"} removed`, false]);
|
|
|
|
|
if (m.pagesChanged() && !m.deletedCount()) lines.push(["pages reordered", false]);
|
|
|
|
|
if (m.rotationsChanged()) lines.push(["page rotation changed", false]);
|
|
|
|
|
const red = m.redactedPages();
|
|
|
|
|
if (red.size) {
|
|
|
|
|
lines.push([flattenRedactions
|
|
|
|
|
? `${red.size} redacted page${red.size === 1 ? "" : "s"} rebuilt as an image, so the text under the boxes is gone`
|
|
|
|
|
: `${red.size} redacted page${red.size === 1 ? "" : "s"} — the text under the boxes stays extractable`,
|
|
|
|
|
!flattenRedactions]);
|
|
|
|
|
}
|
feat(pdf-editor): replace the document's own text, on its own baseline
Until now "editing" a PDF here meant laying things over it. You could put a
word on top of a word, but the document underneath never changed, and the
result read like a sticker because it was one. This adds the thing the word
Edit actually promises: click a line of the document's text, type different
words, and they land where the old ones were, in the old size and the old
colour.
The position and size come from pdf.js's text layer, which has already placed
a span over every run and carries that run's size in unscaled PDF points — so
the size is right whatever the zoom, which reading it off the rendered box
would not be. The colours come from the rendered page, because nothing in the
text API reports them: the background is the average of the most common colour
bucket in the run's box, since type is a minority of the pixels even when it
is dense, and the ink is whatever sits furthest from that background. On the
test fixture it recovers the marker's red exactly.
Two things that look like details and are not. The bucket only chooses WHICH
pixels are background; the colour itself is their average, because rebuilding
it from the bucket index rounds white down to #f8f8f8 and a not-quite-white
patch on a white page is a visible seam. And the cover reaches below the
baseline by a quarter of the font size, because pdf.js sizes its spans to the
em box: cut the cover to the span and every descender in the original line
survives as a little hook under the replacement.
A replacement is a cover plus text, so it is a mark like any other — movable,
resizable, undoable, and rendered on screen from the same numbers the writer
uses, which is what makes the preview trustworthy.
Said plainly in the dialog and again in the save summary: this hides the
original, it does not remove it. The old glyphs are still in the content
stream underneath. Redact is the tool that takes text away, and it says so
too.
2026-09-22 21:49:37 +02:00
|
|
|
const replaced = replacedRuns(m);
|
|
|
|
|
if (replaced) lines.push([`${replaced} text run${replaced === 1 ? "" : "s"} replaced in place — the original text stays in the file underneath`, false]);
|
feat(pdf-editor): a mark you placed is something you can still work on
Everything the editor put on a page was final. A text stamp could not be
corrected without deleting it and typing it again, nothing could be resized,
and the only way to remove a mark was a Delete key nobody had been told
about — the selection drew a dashed box and offered no action at all. Placing
a stamp also left its tool armed, so the next click stamped a second copy.
Marks are now editable objects. Selecting one gives it grab handles and a
small bar pinned above it: delete and duplicate for anything, and for text an
edit button, a size stepper and bold and italic. Double-clicking text reopens
it for rewriting in place rather than adding a second one. Placing a text
stamp or a signature drops straight back to the select tool with the new mark
live, which is both what people expect and what puts it immediately within
reach of a nudge.
Resizing is one function over every mark type rather than a special case per
kind: a handle drag produces a new bounding box, and the mark is mapped from
its old box into that one. Text scales by font size instead of stretching its
glyphs, signatures keep their aspect on a corner, and lines offer their two
endpoints instead of a box that would let you stretch them in ways you never
aimed at. A whole gesture lands on the undo stack as one step.
Selecting a thin mark used to mean clicking its outline exactly — about one
screen pixel. Each stroked mark now carries an invisible fat copy of itself
purely to catch the pointer.
New marks to go with it: underline and strike-through, which share the
highlight's text-selection geometry and differ only in where the rule sits; a
plain line; and a fill toggle for rectangles and ellipses. Bold and italic
mean three more Helvetica variants embedded at save time, since a PDF treats
them as separate fonts rather than as a style.
Double-click is detected from the pointer stream rather than from a dblclick
listener, because selecting a mark calls preventDefault() on the pointerdown
and that suppresses the compatibility mouse events the browser would have
synthesised the dblclick from.
2026-09-21 03:13:06 +02:00
|
|
|
const bad = unencodableStamps(m);
|
|
|
|
|
if (bad) lines.push([`${bad} text stamp${bad === 1 ? "" : "s"} contain characters the built-in font cannot write`, true]);
|
|
|
|
|
if (!lines.length) lines.push(["no changes — this saves a copy of the original", false]);
|
|
|
|
|
for (const [text, warn] of lines) {
|
|
|
|
|
const li = document.createElement("li");
|
|
|
|
|
li.textContent = text;
|
|
|
|
|
if (warn) li.className = "warn";
|
|
|
|
|
summary.append(li);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const go = await openModal("modal-save");
|
|
|
|
|
if (!go) return;
|
|
|
|
|
let filename = String($("save-name").value || "").trim() || defaultSaveName();
|
|
|
|
|
if (!/\.pdf$/i.test(filename)) filename += ".pdf";
|
|
|
|
|
|
|
|
|
|
setBusy(true, "Writing…");
|
|
|
|
|
try {
|
|
|
|
|
const { bytes, report } = await buildPdf({
|
|
|
|
|
originalBytes: session.bytes,
|
|
|
|
|
model: m,
|
|
|
|
|
formValues,
|
|
|
|
|
flatten: flattenRedactions,
|
|
|
|
|
rasterize: rasterizePage,
|
|
|
|
|
});
|
|
|
|
|
const blob = new Blob([bytes], { type: "application/pdf" });
|
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
|
const a = $("download-link");
|
|
|
|
|
a.href = url;
|
|
|
|
|
a.download = filename;
|
|
|
|
|
a.click();
|
|
|
|
|
setTimeout(() => URL.revokeObjectURL(url), 8000);
|
|
|
|
|
m.markSaved();
|
|
|
|
|
paintChrome();
|
|
|
|
|
const kb = (bytes.length / 1024).toFixed(0);
|
|
|
|
|
status(`Saved ${filename} · ${kb} KB${report.flattened ? ` · ${report.flattened} page(s) rebuilt as images` : ""}`);
|
|
|
|
|
toast(`Saved ${filename} (${kb} KB)`);
|
|
|
|
|
for (const w of report.warn) toast(w, true);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error("save failed:", e);
|
|
|
|
|
status(`Save failed: ${e?.message || e}`, true);
|
|
|
|
|
toast(`Save failed: ${e?.message || e}`, true);
|
|
|
|
|
} finally {
|
|
|
|
|
setBusy(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
$("save").addEventListener("click", doSave);
|
|
|
|
|
$("save-go").addEventListener("click", () => closeModal(true));
|
|
|
|
|
$("save-cancel").addEventListener("click", () => closeModal(null));
|
|
|
|
|
|
|
|
|
|
// ---- discard / close --------------------------------------------------
|
|
|
|
|
async function confirmDiscard() {
|
|
|
|
|
if (!session.model || !session.model.isDirty()) return true;
|
|
|
|
|
// Deliberately the browser's own confirm(): it is modal against the tab, it
|
|
|
|
|
// cannot be dismissed by a stray click the way the in-page scrim can, and
|
|
|
|
|
// losing an hour of markup to a misplaced click is not a risk worth a
|
|
|
|
|
// prettier dialog.
|
|
|
|
|
return window.confirm("This document has unsaved changes. Discard them?");
|
|
|
|
|
}
|
|
|
|
|
$("discard").addEventListener("click", async () => {
|
|
|
|
|
if (!(await confirmDiscard())) return;
|
|
|
|
|
if (session.scratchId && sm?.invoke) {
|
|
|
|
|
try { await sm.invoke("release", { id: session.scratchId }); } catch {}
|
|
|
|
|
}
|
|
|
|
|
try { sm?.closeTab?.(); } catch {}
|
|
|
|
|
});
|
|
|
|
|
// Belt and braces for a tab closed from the tab strip rather than our button.
|
|
|
|
|
window.addEventListener("beforeunload", (e) => {
|
|
|
|
|
if (session.model?.isDirty()) { e.preventDefault(); e.returnValue = ""; }
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// A link inside a PDF must not navigate this tab — that would drop the whole
|
|
|
|
|
// editing session on the floor. Hand it to the add-on, which opens a new tab.
|
|
|
|
|
$("viewerContainer").addEventListener("click", (e) => {
|
|
|
|
|
const a = e.target?.closest?.("a[href]");
|
|
|
|
|
if (!a) return;
|
|
|
|
|
const href = a.getAttribute("href") || "";
|
|
|
|
|
if (!/^https?:/i.test(href)) return;
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
sm?.invoke?.("openExternal", { url: href })
|
|
|
|
|
.catch((err) => toast(`Could not open that link: ${err?.message || err}`, true));
|
|
|
|
|
}, true);
|
|
|
|
|
|
|
|
|
|
// ---- keyboard ---------------------------------------------------------
|
|
|
|
|
// Ctrl+F, Ctrl+B and Ctrl +/-/0 never reach this page — Theseus claims them
|
|
|
|
|
// app-wide for find, the add-on sidebar and tab zoom. PDF zoom therefore lives
|
|
|
|
|
// on bare +/-/0, which is what desktop readers use anyway.
|
|
|
|
|
const TOOL_KEYS = {
|
|
|
|
|
v: "select", h: "highlight", u: "underline", k: "strikeout", p: "pen",
|
|
|
|
|
r: "rect", o: "ellipse", l: "line", a: "arrow", t: "text", g: "signature",
|
feat(pdf-editor): replace the document's own text, on its own baseline
Until now "editing" a PDF here meant laying things over it. You could put a
word on top of a word, but the document underneath never changed, and the
result read like a sticker because it was one. This adds the thing the word
Edit actually promises: click a line of the document's text, type different
words, and they land where the old ones were, in the old size and the old
colour.
The position and size come from pdf.js's text layer, which has already placed
a span over every run and carries that run's size in unscaled PDF points — so
the size is right whatever the zoom, which reading it off the rendered box
would not be. The colours come from the rendered page, because nothing in the
text API reports them: the background is the average of the most common colour
bucket in the run's box, since type is a minority of the pixels even when it
is dense, and the ink is whatever sits furthest from that background. On the
test fixture it recovers the marker's red exactly.
Two things that look like details and are not. The bucket only chooses WHICH
pixels are background; the colour itself is their average, because rebuilding
it from the bucket index rounds white down to #f8f8f8 and a not-quite-white
patch on a white page is a visible seam. And the cover reaches below the
baseline by a quarter of the font size, because pdf.js sizes its spans to the
em box: cut the cover to the span and every descender in the original line
survives as a little hook under the replacement.
A replacement is a cover plus text, so it is a mark like any other — movable,
resizable, undoable, and rendered on screen from the same numbers the writer
uses, which is what makes the preview trustworthy.
Said plainly in the dialog and again in the save summary: this hides the
original, it does not remove it. The old glyphs are still in the content
stream underneath. Redact is the tool that takes text away, and it says so
too.
2026-09-22 21:49:37 +02:00
|
|
|
e: "edittext",
|
feat(pdf-editor): a mark you placed is something you can still work on
Everything the editor put on a page was final. A text stamp could not be
corrected without deleting it and typing it again, nothing could be resized,
and the only way to remove a mark was a Delete key nobody had been told
about — the selection drew a dashed box and offered no action at all. Placing
a stamp also left its tool armed, so the next click stamped a second copy.
Marks are now editable objects. Selecting one gives it grab handles and a
small bar pinned above it: delete and duplicate for anything, and for text an
edit button, a size stepper and bold and italic. Double-clicking text reopens
it for rewriting in place rather than adding a second one. Placing a text
stamp or a signature drops straight back to the select tool with the new mark
live, which is both what people expect and what puts it immediately within
reach of a nudge.
Resizing is one function over every mark type rather than a special case per
kind: a handle drag produces a new bounding box, and the mark is mapped from
its old box into that one. Text scales by font size instead of stretching its
glyphs, signatures keep their aspect on a corner, and lines offer their two
endpoints instead of a box that would let you stretch them in ways you never
aimed at. A whole gesture lands on the undo stack as one step.
Selecting a thin mark used to mean clicking its outline exactly — about one
screen pixel. Each stroked mark now carries an invisible fat copy of itself
purely to catch the pointer.
New marks to go with it: underline and strike-through, which share the
highlight's text-selection geometry and differ only in where the rule sits; a
plain line; and a fill toggle for rectangles and ellipses. Bold and italic
mean three more Helvetica variants embedded at save time, since a PDF treats
them as separate fonts rather than as a style.
Double-click is detected from the pointer stream rather than from a dblclick
listener, because selecting a mark calls preventDefault() on the pointerdown
and that suppresses the compatibility mouse events the browser would have
synthesised the dblclick from.
2026-09-21 03:13:06 +02:00
|
|
|
};
|
|
|
|
|
window.addEventListener("keydown", (e) => {
|
|
|
|
|
// A modal owns the keyboard while it is up: Escape cancels, and Enter
|
|
|
|
|
// commits unless the focus is in the textarea, where it means a newline.
|
|
|
|
|
if (!$("scrim").hidden) {
|
|
|
|
|
if (e.key === "Escape") { closeModal(null); e.preventDefault(); return; }
|
|
|
|
|
if (e.key === "Enter" && e.target?.tagName !== "TEXTAREA" && !e.shiftKey) {
|
|
|
|
|
const go = document.querySelector(".modal:not([hidden]) .btn.primary:not(:disabled)");
|
|
|
|
|
if (go) { go.click(); e.preventDefault(); }
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(e.target?.tagName || "") || e.target?.isContentEditable;
|
|
|
|
|
const meta = e.ctrlKey || e.metaKey;
|
|
|
|
|
|
|
|
|
|
if (meta && e.key.toLowerCase() === "s") { e.preventDefault(); doSave(); return; }
|
|
|
|
|
if (meta && e.key.toLowerCase() === "o") { e.preventDefault(); pick(); return; }
|
|
|
|
|
if (meta && e.key.toLowerCase() === "d") {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
if (session.tools?.duplicateSelected()) paintChrome();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (meta && e.key.toLowerCase() === "z") {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
if (e.shiftKey) session.model?.redo(); else session.model?.undo();
|
|
|
|
|
afterHistory();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (typing) return;
|
|
|
|
|
|
|
|
|
|
if (e.key === "Escape") {
|
|
|
|
|
// Step back one level at a time: drop the selection first, and only then
|
|
|
|
|
// disarm the tool. Doing both at once loses whichever the user meant.
|
|
|
|
|
if (session.tools?.selected) { session.tools.select(null); e.preventDefault(); return; }
|
|
|
|
|
if (session.tools?.tool !== "select") { selectTool("select"); e.preventDefault(); }
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (e.key === "Delete" || e.key === "Backspace") {
|
|
|
|
|
if (session.tools?.deleteSelected()) { paintChrome(); e.preventDefault(); }
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (e.key === "Enter") {
|
|
|
|
|
const sel = session.tools?.selected;
|
|
|
|
|
const a = sel && session.model?.annot(sel);
|
|
|
|
|
if (a?.kind === "text") { onTextModal(sel, null); e.preventDefault(); }
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (e.key === "+" || e.key === "=") { session.strip?.stepScale(1); syncZoomSelect(); e.preventDefault(); return; }
|
|
|
|
|
if (e.key === "-") { session.strip?.stepScale(-1); syncZoomSelect(); e.preventDefault(); return; }
|
|
|
|
|
if (e.key === "0") { $("zoom").value = "page-width"; session.strip?.setScaleMode("page-width"); paintPageInfo(); e.preventDefault(); return; }
|
|
|
|
|
|
|
|
|
|
const t = TOOL_KEYS[e.key.toLowerCase()];
|
|
|
|
|
if (t) { document.querySelector(`.tool[data-tool="${t}"]`)?.click(); e.preventDefault(); }
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ---- start ------------------------------------------------------------
|
|
|
|
|
(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;
|
|
|
|
|
} catch {}
|
|
|
|
|
|
|
|
|
|
const id = new URLSearchParams(location.search).get("doc");
|
|
|
|
|
if (!id) { status("Drop a PDF here, or use Open."); paintChrome(); return; }
|
|
|
|
|
if (!sm?.invoke) { status("This page has to be opened from the PDF Editor add-on.", true); return; }
|
|
|
|
|
setBusy(true, "Fetching…");
|
|
|
|
|
try {
|
|
|
|
|
const res = await sm.invoke("getBytes", { id });
|
|
|
|
|
const bytes = res.data instanceof Uint8Array ? res.data : new Uint8Array(res.data);
|
|
|
|
|
await loadBytes(bytes, res.name || "document.pdf", id);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
setBusy(false);
|
|
|
|
|
status(`Could not load that document: ${e?.message || e}`, true);
|
|
|
|
|
paintChrome();
|
|
|
|
|
}
|
|
|
|
|
})();
|