feat(pdf-editor): read, mark up and reshape a PDF without leaving the browser
A PDF that needs a signature, a highlight or a page removed currently sends
the user out to a desktop application or, worse, to a web service that wants
the document uploaded first. Both are poor answers for a browser whose point
is that nothing has to leave the machine. This is a full-tab editor that opens
a PDF, marks it up, fills its forms and saves a new copy, entirely locally.
Two engines, vendored rather than installed, because an add-on ships as a
self-contained folder over the signed update channel and nothing runs a
package manager on the way: pdf.js reads and renders, pdf-lib writes. They
share no state. Everything in between lives in PDF user space — points,
origin bottom-left — which is the one coordinate vocabulary both speak, so a
mark survives zooming, rotating and reordering with no conversion table and
save-time needs to know nothing about how a page happened to be displayed.
The page strip is built from pdf.js's PDFPageView components rather than its
PDFViewer, which renders pages in the file's own order and cannot hide,
reorder or individually rotate one — three of the features here. Text layers
are ours and stay attached for every page, drawn or not, because Theseus's
find bar is Chromium's findInPage over the live DOM and a torn-down text layer
is a page Ctrl+F cannot see. Canvases are virtualised; a letter page at 100%
is 3.4 MB of bitmap.
Redaction is the part worth being careful about. A black box over text hides
nothing — the text stays in the content stream and comes straight out of a
copy-paste — so the editor says so in a modal before the tool can be used,
and on save rebuilds each redacted page as an image, which genuinely removes
it. Pages that were not redacted are untouched. Form widgets and links are
kept, since they were never the leak.
Saving never writes over the original: every save reloads the source bytes and
replays the session onto a fresh copy, so a botched save cannot poison the
next one.
Out of scope for this first version: editing the text that is already in the
document, and writing XFA forms back (pdf-lib cannot, so those are fill-and-
print only, and the editor says so on open).
2026-09-20 20:58:21 +02:00
|
|
|
/* PDF Editor — full-tab. Same palette and control shapes as the screenshot
|
|
|
|
|
editor so the two add-ons read as one family; loaded AFTER pdf.js's
|
|
|
|
|
pdf_viewer.css so the overrides at the bottom win. */
|
|
|
|
|
:root { color-scheme: light dark;
|
|
|
|
|
--bg:#0e131c; --panel:#141a24; --panel2:#191f2b; --line:rgba(255,255,255,.09);
|
|
|
|
|
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d;
|
|
|
|
|
--danger:#ff5b5b; --board:#0a0d13; --shadow:rgba(0,0,0,.45); }
|
|
|
|
|
@media (prefers-color-scheme: light) {
|
|
|
|
|
:root { --bg:#f8faff; --panel:#ffffff; --panel2:#eff3fb; --line:rgba(0,0,0,.10);
|
|
|
|
|
--ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5; --acid:#0AC18E;
|
|
|
|
|
--board:#dde3ee; --shadow:rgba(20,30,50,.20); }
|
|
|
|
|
}
|
|
|
|
|
* { box-sizing: border-box; }
|
|
|
|
|
html, body { margin: 0; height: 100%; }
|
|
|
|
|
body { background: var(--bg); color: var(--ink);
|
|
|
|
|
font: 13px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
|
|
|
|
display: flex; flex-direction: column; overflow: hidden; }
|
|
|
|
|
/* pdf.js's stylesheet sets display on some of the nodes we toggle, so plain
|
|
|
|
|
[hidden] loses the cascade fight. */
|
|
|
|
|
[hidden] { display: none !important; }
|
|
|
|
|
|
|
|
|
|
/* ---- bars ----------------------------------------------------------- */
|
|
|
|
|
.topbar, .toolbar { display: flex; align-items: center; gap: 4px; padding: 6px 8px;
|
|
|
|
|
border-bottom: 1px solid var(--line); background: var(--panel);
|
|
|
|
|
user-select: none; }
|
|
|
|
|
.topbar { flex-wrap: nowrap; }
|
|
|
|
|
.toolbar { align-items: flex-start; gap: 8px; row-gap: 6px; flex-wrap: wrap; }
|
|
|
|
|
.tool-cluster { display: flex; flex-wrap: wrap; align-items: center;
|
|
|
|
|
gap: 10px; row-gap: 6px; flex: 1 1 auto; justify-content: center; }
|
|
|
|
|
.tgroup { display: inline-flex; align-items: center; gap: 4px; flex: 0 0 auto; }
|
|
|
|
|
.tgroup.zoomgroup { align-self: center; }
|
|
|
|
|
.spacer { flex: 1; }
|
|
|
|
|
.sep { width: 1px; height: 20px; background: var(--line); margin: 0 4px; flex: 0 0 auto; }
|
|
|
|
|
|
|
|
|
|
.docname { color: var(--mut); font-size: 12.5px; overflow: hidden; min-width: 0;
|
|
|
|
|
text-overflow: ellipsis; white-space: nowrap; max-width: 46vw; }
|
|
|
|
|
.docname.dirty::after { content: " •"; color: var(--acid); font-weight: 700; }
|
|
|
|
|
|
|
|
|
|
.btn, .tool {
|
|
|
|
|
border: 1px solid var(--line); background: var(--panel2); color: var(--ink);
|
|
|
|
|
width: 30px; height: 30px; border-radius: 6px; cursor: pointer;
|
|
|
|
|
display: inline-flex; align-items: center; justify-content: center;
|
|
|
|
|
padding: 0; transition: background 100ms, border-color 100ms;
|
|
|
|
|
font: inherit; line-height: 0; flex: 0 0 auto;
|
|
|
|
|
}
|
|
|
|
|
.btn:hover, .tool:hover { border-color: rgb(from var(--acid) r g b / .5); }
|
|
|
|
|
.btn:disabled, .tool:disabled { opacity: .4; cursor: default; }
|
|
|
|
|
.btn:disabled:hover { border-color: var(--line); }
|
|
|
|
|
.btn svg, .tool svg { width: 16px; height: 16px; display: block; }
|
|
|
|
|
.btn.wide { width: auto; padding: 0 10px; gap: 6px; line-height: 1; }
|
|
|
|
|
.btn.wide span { font-size: 12px; }
|
|
|
|
|
.btn.primary { background: var(--acid); color: #101418; border-color: transparent; font-weight: 600; }
|
|
|
|
|
.btn.primary:hover { filter: brightness(1.06); border-color: transparent; }
|
|
|
|
|
.btn.primary:disabled:hover { filter: none; }
|
|
|
|
|
.btn.danger { color: var(--danger); }
|
|
|
|
|
.btn.danger:hover { border-color: rgba(255,91,91,.55); }
|
|
|
|
|
.tool.active { background: rgb(from var(--acid) r g b / .15);
|
|
|
|
|
border-color: rgb(from var(--acid) r g b / .7); color: var(--acid); }
|
|
|
|
|
/* Redact is destructive; it reads red when armed rather than acid. */
|
|
|
|
|
.tool[data-tool="redact"].active { background: rgba(255,91,91,.16);
|
|
|
|
|
border-color: rgba(255,91,91,.7); color: var(--danger); }
|
|
|
|
|
|
|
|
|
|
.swatch { width: 22px; height: 22px; border-radius: 50%; padding: 0;
|
|
|
|
|
border: 2px solid var(--line); cursor: pointer; flex: 0 0 auto;
|
|
|
|
|
/* The near-black ink swatch would otherwise disappear into a dark
|
|
|
|
|
toolbar; a faint inner ring keeps every colour countable. */
|
|
|
|
|
box-shadow: inset 0 0 0 1px rgb(from var(--ink) r g b / .28); }
|
|
|
|
|
.swatch.active { border-color: var(--acid);
|
|
|
|
|
outline: 1px solid rgb(from var(--acid) r g b / .35); outline-offset: 1px; }
|
|
|
|
|
.width { border: 1px solid var(--line); background: var(--panel2); color: var(--ink);
|
|
|
|
|
width: 30px; height: 30px; border-radius: 6px; cursor: pointer;
|
|
|
|
|
display: inline-flex; align-items: center; justify-content: center; padding: 0; }
|
|
|
|
|
.width.active { border-color: rgb(from var(--acid) r g b / .7);
|
|
|
|
|
background: rgb(from var(--acid) r g b / .15); }
|
|
|
|
|
.width .dot { border-radius: 50%; background: currentColor; color: var(--ink); }
|
|
|
|
|
.width.active .dot { color: var(--acid); }
|
|
|
|
|
|
|
|
|
|
select, input[type="text"], textarea {
|
|
|
|
|
border: 1px solid var(--line); background: var(--panel2); color: var(--ink);
|
|
|
|
|
border-radius: 6px; padding: 5px 7px; font: inherit; height: 30px;
|
|
|
|
|
}
|
|
|
|
|
textarea { height: auto; resize: vertical; width: 100%; line-height: 1.45; }
|
|
|
|
|
select:focus, input:focus, textarea:focus { outline: none; border-color: rgb(from var(--acid) r g b / .6); }
|
|
|
|
|
|
|
|
|
|
/* ---- body: viewer + rail -------------------------------------------- */
|
|
|
|
|
.body { flex: 1; display: flex; min-height: 0; }
|
|
|
|
|
.viewerContainer { position: absolute; inset: 0; overflow: auto; background: var(--board); }
|
|
|
|
|
.body { position: relative; }
|
|
|
|
|
.viewerContainer { position: relative; flex: 1 1 auto; min-width: 0; }
|
|
|
|
|
|
|
|
|
|
.rail { flex: 0 0 176px; border-left: 1px solid var(--line); background: var(--panel);
|
|
|
|
|
display: flex; flex-direction: column; min-height: 0; }
|
|
|
|
|
body[data-rail="off"] .rail { display: none; }
|
|
|
|
|
.rail-head { display: flex; align-items: center; gap: 6px; padding: 7px 10px;
|
|
|
|
|
border-bottom: 1px solid var(--line); color: var(--dim); font-size: 11.5px; }
|
|
|
|
|
.rail-list { flex: 1; overflow: auto; padding: 8px; display: flex;
|
|
|
|
|
flex-direction: column; gap: 8px; }
|
|
|
|
|
|
|
|
|
|
.thumb { position: relative; border: 1px solid var(--line); border-radius: 6px;
|
|
|
|
|
background: var(--panel2); padding: 6px; cursor: grab; }
|
|
|
|
|
.thumb.current { border-color: rgb(from var(--acid) r g b / .75); }
|
|
|
|
|
.thumb.dragging { opacity: .45; }
|
|
|
|
|
.thumb.dropbefore { box-shadow: 0 -3px 0 0 var(--acid); }
|
|
|
|
|
.thumb.dropafter { box-shadow: 0 3px 0 0 var(--acid); }
|
|
|
|
|
.thumb.deleted { opacity: .38; }
|
|
|
|
|
.thumb.deleted .tcanvas { filter: grayscale(1); }
|
|
|
|
|
.thumb .tcanvas { display: block; width: 100%; height: auto; background: #fff;
|
|
|
|
|
border-radius: 3px; box-shadow: 0 1px 4px var(--shadow); }
|
|
|
|
|
.thumb .tfoot { display: flex; align-items: center; gap: 3px; margin-top: 5px; }
|
|
|
|
|
.thumb .tnum { color: var(--dim); font-size: 11px; font-variant-numeric: tabular-nums;
|
|
|
|
|
flex: 1 1 auto; min-width: 0; }
|
|
|
|
|
.thumb .tbtn { width: 20px; height: 20px; border-radius: 4px; border: 1px solid var(--line);
|
|
|
|
|
background: var(--panel); color: var(--ink); cursor: pointer; padding: 0;
|
|
|
|
|
display: inline-flex; align-items: center; justify-content: center; }
|
|
|
|
|
.thumb .tbtn svg { width: 12px; height: 12px; display: block; }
|
|
|
|
|
.thumb .tbtn:hover { border-color: rgb(from var(--acid) r g b / .55); }
|
|
|
|
|
.thumb .tbtn.del:hover { border-color: rgba(255,91,91,.6); color: var(--danger); }
|
|
|
|
|
|
|
|
|
|
.fbtn { border: 1px solid var(--line); background: var(--panel2); color: var(--ink);
|
|
|
|
|
border-radius: 6px; cursor: pointer; padding: 3px 7px; font: inherit;
|
|
|
|
|
font-size: 11px; line-height: 1.4; }
|
|
|
|
|
.fbtn:hover { border-color: rgb(from var(--acid) r g b / .55); }
|
|
|
|
|
|
|
|
|
|
/* ---- footer --------------------------------------------------------- */
|
|
|
|
|
.footer { display: flex; align-items: center; gap: 8px; padding: 5px 10px;
|
|
|
|
|
border-top: 1px solid var(--line); background: var(--panel); font-size: 11.5px; }
|
|
|
|
|
.fstat { color: var(--dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
|
|
|
.fstat.err { color: var(--danger); }
|
|
|
|
|
|
|
|
|
|
/* ---- drop zone ------------------------------------------------------ */
|
|
|
|
|
.dropzone { position: absolute; inset: 0; display: none; align-items: center;
|
|
|
|
|
justify-content: center; pointer-events: none; z-index: 20; }
|
|
|
|
|
body[data-empty="1"] .dropzone { display: flex; pointer-events: auto; }
|
|
|
|
|
body[data-dragover="1"] .dropzone { display: flex; pointer-events: none;
|
|
|
|
|
background: rgb(from var(--acid) r g b / .08); }
|
|
|
|
|
.dz-inner { text-align: center; color: var(--dim); border: 2px dashed var(--line);
|
|
|
|
|
border-radius: 14px; padding: 40px 54px; background: var(--panel);
|
|
|
|
|
max-width: 82%; }
|
|
|
|
|
body[data-dragover="1"] .dz-inner { border-color: rgb(from var(--acid) r g b / .8); color: var(--ink); }
|
|
|
|
|
.dz-inner svg { width: 48px; height: 48px; margin-bottom: 10px; }
|
|
|
|
|
.dz-title { font-size: 15px; color: var(--ink); font-weight: 600; }
|
|
|
|
|
.dz-sub { margin-top: 5px; }
|
|
|
|
|
.linkish { background: none; border: none; padding: 0; font: inherit; cursor: pointer;
|
|
|
|
|
color: var(--acid); text-decoration: underline; }
|
|
|
|
|
|
|
|
|
|
/* ---- modals --------------------------------------------------------- */
|
|
|
|
|
.scrim { position: fixed; inset: 0; background: rgba(4,7,12,.62); z-index: 50;
|
|
|
|
|
display: flex; align-items: center; justify-content: center; padding: 24px; }
|
|
|
|
|
.modal { background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
|
|
|
|
|
box-shadow: 0 18px 60px rgba(0,0,0,.5); padding: 18px 20px; max-width: 560px;
|
|
|
|
|
width: 100%; max-height: 90vh; overflow: auto; }
|
|
|
|
|
.modal h2 { margin: 0 0 10px; font-size: 15px; }
|
|
|
|
|
.modal p { margin: 0 0 10px; color: var(--mut); }
|
|
|
|
|
.modal p strong { color: var(--ink); }
|
|
|
|
|
.mhint { font-size: 12px; }
|
|
|
|
|
.mhint.warn { color: var(--danger); }
|
|
|
|
|
.mrow { display: flex; align-items: center; gap: 8px; margin-top: 14px; }
|
|
|
|
|
.mfield { display: inline-flex; align-items: center; gap: 6px; color: var(--mut); font-size: 12px; }
|
|
|
|
|
.mfield.block { display: flex; flex-direction: column; align-items: stretch; gap: 5px; }
|
|
|
|
|
.check { display: flex; align-items: flex-start; gap: 8px; margin: 12px 0 0;
|
|
|
|
|
color: var(--ink); font-size: 12.5px; cursor: pointer; }
|
|
|
|
|
.check input { margin: 2px 0 0; accent-color: var(--acid); }
|
|
|
|
|
#sign-pad { width: 100%; height: auto; aspect-ratio: 640 / 220; background: #fff;
|
|
|
|
|
border: 1px solid var(--line); border-radius: 8px; cursor: crosshair;
|
|
|
|
|
touch-action: none; display: block; }
|
|
|
|
|
.summary { margin: 10px 0 0; padding-left: 18px; color: var(--mut); font-size: 12px; }
|
|
|
|
|
.summary li { margin: 2px 0; }
|
|
|
|
|
.summary li.warn { color: var(--danger); }
|
|
|
|
|
|
|
|
|
|
.toast { position: fixed; left: 50%; bottom: 44px; transform: translateX(-50%) translateY(20px);
|
|
|
|
|
padding: 8px 14px; border-radius: 6px; background: rgba(11,14,20,.95);
|
|
|
|
|
color: #e7eaf1; font-size: 12.5px; border: 1px solid var(--line);
|
|
|
|
|
opacity: 0; pointer-events: none; transition: opacity 180ms, transform 180ms;
|
|
|
|
|
z-index: 100; max-width: 80vw; text-align: center; }
|
|
|
|
|
.toast.on { opacity: 1; transform: translateX(-50%) translateY(0); }
|
|
|
|
|
.toast.err { border-color: rgba(255,91,91,.6); color: #ffb3b3; }
|
|
|
|
|
|
|
|
|
|
/* ---- pdf.js layer overrides ----------------------------------------- */
|
|
|
|
|
/* A real gap between pages plus a drop shadow, instead of pdf.js's
|
|
|
|
|
transparent-border trick. */
|
|
|
|
|
/* overflow-anchor off: pages change height as they take their real size and
|
|
|
|
|
again on every zoom, and scroll anchoring "helpfully" moves the scroll
|
|
|
|
|
position to compensate — which walks the reader away from the page they
|
|
|
|
|
were on and, worse, can park the whole document outside the viewport. */
|
|
|
|
|
.viewerContainer { overflow-anchor: none; }
|
|
|
|
|
.pdfViewer { --page-margin: 0 auto 14px; --page-border: none; padding: 14px 0 4px; }
|
|
|
|
|
.pdfViewer .page { box-shadow: 0 2px 14px var(--shadow); }
|
|
|
|
|
|
|
|
|
|
/* Our overlay sits above the canvas but below the annotation (form) layer, so
|
|
|
|
|
a form field stays clickable even with marks drawn around 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
|
|
|
/* Marks are clipped to the page by a clipPath on the marks group, not by the
|
|
|
|
|
SVG's own overflow — the selection handles sit just outside a mark's bounds
|
|
|
|
|
and a mark flush with the page edge still has to be grabbable. */
|
feat(pdf-editor): read, mark up and reshape a PDF without leaving the browser
A PDF that needs a signature, a highlight or a page removed currently sends
the user out to a desktop application or, worse, to a web service that wants
the document uploaded first. Both are poor answers for a browser whose point
is that nothing has to leave the machine. This is a full-tab editor that opens
a PDF, marks it up, fills its forms and saves a new copy, entirely locally.
Two engines, vendored rather than installed, because an add-on ships as a
self-contained folder over the signed update channel and nothing runs a
package manager on the way: pdf.js reads and renders, pdf-lib writes. They
share no state. Everything in between lives in PDF user space — points,
origin bottom-left — which is the one coordinate vocabulary both speak, so a
mark survives zooming, rotating and reordering with no conversion table and
save-time needs to know nothing about how a page happened to be displayed.
The page strip is built from pdf.js's PDFPageView components rather than its
PDFViewer, which renders pages in the file's own order and cannot hide,
reorder or individually rotate one — three of the features here. Text layers
are ours and stay attached for every page, drawn or not, because Theseus's
find bar is Chromium's findInPage over the live DOM and a torn-down text layer
is a page Ctrl+F cannot see. Canvases are virtualised; a letter page at 100%
is 3.4 MB of bitmap.
Redaction is the part worth being careful about. A black box over text hides
nothing — the text stays in the content stream and comes straight out of a
copy-paste — so the editor says so in a modal before the tool can be used,
and on save rebuilds each redacted page as an image, which genuinely removes
it. Pages that were not redacted are untouched. Form widgets and links are
kept, since they were never the leak.
Saving never writes over the original: every save reloads the source bytes and
replays the session onto a fresh copy, so a botched save cannot poison the
next one.
Out of scope for this first version: editing the text that is already in the
document, and writing XFA forms back (pdf-lib cannot, so those are fill-and-
print only, and the editor says so on open).
2026-09-20 20:58:21 +02:00
|
|
|
.pdfViewer .page .smOverlay { position: absolute; inset: 0; z-index: 3;
|
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
|
|
|
pointer-events: none; overflow: visible; }
|
feat(pdf-editor): read, mark up and reshape a PDF without leaving the browser
A PDF that needs a signature, a highlight or a page removed currently sends
the user out to a desktop application or, worse, to a web service that wants
the document uploaded first. Both are poor answers for a browser whose point
is that nothing has to leave the machine. This is a full-tab editor that opens
a PDF, marks it up, fills its forms and saves a new copy, entirely locally.
Two engines, vendored rather than installed, because an add-on ships as a
self-contained folder over the signed update channel and nothing runs a
package manager on the way: pdf.js reads and renders, pdf-lib writes. They
share no state. Everything in between lives in PDF user space — points,
origin bottom-left — which is the one coordinate vocabulary both speak, so a
mark survives zooming, rotating and reordering with no conversion table and
save-time needs to know nothing about how a page happened to be displayed.
The page strip is built from pdf.js's PDFPageView components rather than its
PDFViewer, which renders pages in the file's own order and cannot hide,
reorder or individually rotate one — three of the features here. Text layers
are ours and stay attached for every page, drawn or not, because Theseus's
find bar is Chromium's findInPage over the live DOM and a torn-down text layer
is a page Ctrl+F cannot see. Canvases are virtualised; a letter page at 100%
is 3.4 MB of bitmap.
Redaction is the part worth being careful about. A black box over text hides
nothing — the text stays in the content stream and comes straight out of a
copy-paste — so the editor says so in a modal before the tool can be used,
and on save rebuilds each redacted page as an image, which genuinely removes
it. Pages that were not redacted are untouched. Form widgets and links are
kept, since they were never the leak.
Saving never writes over the original: every save reloads the source bytes and
replays the session onto a fresh copy, so a botched save cannot poison the
next one.
Out of scope for this first version: editing the text that is already in the
document, and writing XFA forms back (pdf-lib cannot, so those are fill-and-
print only, and the editor says so on open).
2026-09-20 20:58:21 +02:00
|
|
|
.pdfViewer .page .annotationLayer { z-index: 4; }
|
|
|
|
|
.pdfViewer .page .textLayer { z-index: 2; }
|
|
|
|
|
|
|
|
|
|
/* Tool modality. When a drawing tool is armed the text and form layers stop
|
|
|
|
|
taking pointer events, so a drag paints instead of selecting text or
|
|
|
|
|
landing in a field. Highlight is the exception: it works BY selecting
|
|
|
|
|
text, so the text layer stays live for it. */
|
|
|
|
|
body[data-mode="draw"] .textLayer,
|
|
|
|
|
body[data-mode="draw"] .annotationLayer { pointer-events: none !important; }
|
|
|
|
|
body[data-mode="draw"] .pdfViewer .page { cursor: crosshair; }
|
|
|
|
|
body[data-mode="stamp"] .textLayer,
|
|
|
|
|
body[data-mode="stamp"] .annotationLayer { pointer-events: none !important; }
|
|
|
|
|
body[data-mode="stamp"] .pdfViewer .page { cursor: copy; }
|
|
|
|
|
body[data-mode="highlight"] .annotationLayer { pointer-events: none !important; }
|
|
|
|
|
body[data-mode="highlight"] .pdfViewer .page { cursor: text; }
|
|
|
|
|
|
|
|
|
|
/* Marks are only hit-testable with the select tool, so a drawing drag is
|
|
|
|
|
never stolen by something already on the page. */
|
|
|
|
|
body[data-mode="select"] .smOverlay { pointer-events: none; }
|
|
|
|
|
body[data-mode="select"] .smOverlay .mark { pointer-events: stroke; cursor: move; }
|
|
|
|
|
body[data-mode="select"] .smOverlay .mark[data-fill="1"] { pointer-events: all; }
|
|
|
|
|
.smOverlay .mark.sel { outline: none; }
|
|
|
|
|
.smOverlay .selbox { fill: none; stroke: var(--acid); stroke-width: 1;
|
|
|
|
|
stroke-dasharray: 4 3; vector-effect: non-scaling-stroke; }
|
|
|
|
|
.smOverlay .handle { fill: var(--acid); stroke: #101418; stroke-width: 1;
|
|
|
|
|
vector-effect: non-scaling-stroke; cursor: pointer; pointer-events: all; }
|
|
|
|
|
|
|
|
|
|
/* pdf.js renders form widgets with a coloured backdrop; keep it, it is the
|
|
|
|
|
only cue that a field is fillable. */
|
|
|
|
|
.annotationLayer .textWidgetAnnotation input,
|
|
|
|
|
.annotationLayer .textWidgetAnnotation textarea,
|
|
|
|
|
.annotationLayer .choiceWidgetAnnotation select { font: inherit; }
|
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 --------------------------------------------------- */
|
|
|
|
|
/* Pinned to the current selection in viewport coordinates, so a page edge
|
|
|
|
|
never clips it and moving the selection to another page needs no
|
|
|
|
|
re-parenting. It steps aside mid-drag rather than jittering under the
|
|
|
|
|
cursor. */
|
|
|
|
|
.selbar {
|
|
|
|
|
position: fixed; z-index: 40; display: flex; align-items: center; gap: 2px;
|
|
|
|
|
padding: 3px; border-radius: 8px; background: var(--panel);
|
|
|
|
|
border: 1px solid var(--line); box-shadow: 0 6px 22px var(--shadow);
|
|
|
|
|
user-select: none;
|
|
|
|
|
}
|
|
|
|
|
body[data-dragging="1"] .selbar,
|
|
|
|
|
.selbar.offscreen { opacity: 0; pointer-events: none; }
|
|
|
|
|
.selbar .sb {
|
|
|
|
|
border: 1px solid transparent; background: transparent; color: var(--ink);
|
|
|
|
|
min-width: 26px; height: 26px; border-radius: 5px; cursor: pointer; padding: 0 5px;
|
|
|
|
|
display: inline-flex; align-items: center; justify-content: center;
|
|
|
|
|
font: inherit; font-size: 13px; line-height: 1;
|
|
|
|
|
}
|
|
|
|
|
.selbar .sb svg { width: 15px; height: 15px; display: block; }
|
|
|
|
|
.selbar .sb:hover { background: var(--panel2); border-color: rgb(from var(--acid) r g b / .5); }
|
|
|
|
|
.selbar .sb.active { background: rgb(from var(--acid) r g b / .16);
|
|
|
|
|
border-color: rgb(from var(--acid) r g b / .7); color: var(--acid); }
|
|
|
|
|
.selbar .sb.danger:hover { border-color: rgba(255,91,91,.6); color: var(--danger); }
|
|
|
|
|
.selbar .sb-group { display: inline-flex; align-items: center; gap: 1px; }
|
|
|
|
|
.selbar .sb-val { min-width: 22px; text-align: center; font-size: 11.5px;
|
|
|
|
|
color: var(--mut); font-variant-numeric: tabular-nums; }
|
|
|
|
|
.selbar .sb-sep { width: 1px; height: 17px; background: var(--line); margin: 0 3px; }
|
|
|
|
|
|
|
|
|
|
/* Style toggles inside the text dialog. */
|
|
|
|
|
.btn.styl { width: 30px; font-size: 14px; line-height: 1; }
|
|
|
|
|
.btn.styl.active { background: rgb(from var(--acid) r g b / .16);
|
|
|
|
|
border-color: rgb(from var(--acid) r g b / .7); color: var(--acid); }
|
|
|
|
|
|
fix(pdf-editor): a shape can be resized the moment it is drawn
Drawing a rectangle left it selected with its handles showing, and then
refused to let you touch them. The handles were only live under the select
tool, so sizing the shape you were still looking at meant a trip to the
toolbar and back — for a gesture the editor had already drawn the grips for.
Handles are now grabbable whatever tool is armed. They cannot be confused
with drawing: a handle is a nine-pixel square that only exists while
something is selected, nobody lands on one by accident, and Escape drops the
selection if the space is wanted back for drawing. The original gate was
protecting against a collision that does not really happen, at the cost of
one that does.
Making them universal opened a trap in the release path, fixed here too: the
text-markup tools return early from onUp to commit a text selection, which
would have stranded a resize half-done — applied to the live mark, never
journalled, with the drag still set so the next press behaved oddly. A
handle drag is now finished first, whatever tool is armed.
2026-09-22 21:33:48 +02:00
|
|
|
/* Resize handles stay grabbable whatever tool is armed, so a shape can be
|
|
|
|
|
sized the moment it is drawn without a detour through the select tool.
|
|
|
|
|
Safe to leave live because a handle is a 9 px square that exists only while
|
|
|
|
|
something is selected — aiming at one is not something you do by accident,
|
|
|
|
|
and Escape clears the selection if you want the space back for drawing. */
|
|
|
|
|
.smOverlay .handle { pointer-events: all; }
|
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
|
|
|
|
|
|
|
|
/* The invisible grab area under a thin stroke. `stroke` rather than
|
|
|
|
|
`visibleStroke`: the copy is transparent on purpose. */
|
|
|
|
|
body[data-mode="select"] .smOverlay .mark .hit { pointer-events: stroke; }
|
|
|
|
|
.smOverlay .mark .hit { pointer-events: none; }
|
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
|
|
|
|
|
|
|
|
/* Picking a run of the document's own text to replace. The text layer stays
|
|
|
|
|
live so a click can land on a run; the form layer steps aside so a widget
|
|
|
|
|
does not swallow it. The hover outline is the only cue that the invisible
|
|
|
|
|
text layer is a target at all. */
|
|
|
|
|
body[data-mode="pickrun"] .annotationLayer { pointer-events: none !important; }
|
|
|
|
|
body[data-mode="pickrun"] .pdfViewer .page { cursor: text; }
|
|
|
|
|
body[data-mode="pickrun"] .textLayer span:hover {
|
|
|
|
|
outline: 2px solid rgb(from var(--acid) r g b / .9);
|
|
|
|
|
outline-offset: 1px;
|
|
|
|
|
background: rgb(from var(--acid) r g b / .16);
|
|
|
|
|
border-radius: 2px;
|
|
|
|
|
}
|