theseus/bundled-addons/pdf-editor/editor.css
Local Dev e1b5e60aaf 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

227 lines
14 KiB
CSS

/* 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. */
/* Clipped to the page, because the saved file is too: a PDF viewer shows
nothing outside the crop box, so a mark that spilled past the edge on
screen would simply vanish on save. */
.pdfViewer .page .smOverlay { position: absolute; inset: 0; z-index: 3;
pointer-events: none; overflow: hidden; }
.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; }