theseus/bundled-addons/pdf-editor/editor.css
Local Dev ce066bb4ca feat(pdf-editor): one select, one text tool, and a signature you can ink and turn
Select does what selecting does in every editor people already know.
Double-click bare page and a caret opens there; double-click the document's
own words and they open for replacement; what is selected copies with
Ctrl+C, pastes with Ctrl+V and goes with Del. Nothing was taken away — a
drag on empty page still gathers an area, and a drag that starts on words
still selects words to copy.

Type text and Edit text were two buttons for one question the click already
answers. They are one Text tool: land on the document's own words and it
offers to replace them, land anywhere else and it starts new text. The
words light up under the cursor so which is which is visible before
clicking, not after.

The toolbar says what it is for. Select, Text and Sign are labelled and set
apart; the drawing kit and the markup kit are their own groups. Sign gets a
pen icon over a signature rather than a squiggle that could have been
anything.

Signatures take ink — black, blue, red, green — chosen while drawing and
kept with the signature, because people sign in a particular colour and it
belongs to the signature, not to whichever swatch was armed. And they turn:
a grip above the box, free rotation, Shift to snap to 15°, for the signing
line that is not square to the page.

Two faults the tests found, both invisible by eye:

The rotate grip was drawn in the right place and could not be grabbed —
the selection bar floats directly above a mark, which is exactly where the
grip sits, and it swallowed every click. The bar now stands clear of it.

Undo would not undo a first rotation. Restoring a mark with Object.assign
copies the keys the original HAD, so a property the drag introduced
survived the restore; the journal then recorded the rotated state as the
state to go back to. Restoring now forgets keys the original never had,
which fixes every future property with the same shape.

Also: building a document from pictures or joins refuses to start a second
one on top of the first, and says so rather than failing quietly.
2026-09-27 15:09:17 +02:00

363 lines
22 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; max-width: 42ch; margin-left: auto; margin-right: auto; }
/* Start-screen tiles. Hidden while a drag is in flight: during a drop the
drop zone is the whole target, and a row of buttons under the cursor
reads as somewhere to aim, which it is not. */
.dz-actions { display: flex; gap: 10px; margin-top: 22px; justify-content: center;
flex-wrap: wrap; }
body[data-dragover="1"] .dz-actions { visibility: hidden; }
.tile { display: flex; flex-direction: column; align-items: flex-start; gap: 3px;
width: 176px; padding: 13px 14px; text-align: left; cursor: pointer;
background: var(--bg); color: var(--ink); border: 1px solid var(--line);
border-radius: 10px; font: inherit; }
.tile:hover { border-color: rgb(from var(--acid) r g b / .55);
background: rgb(from var(--acid) r g b / .06); }
.tile:focus-visible { outline: 2px solid var(--acid); outline-offset: 2px; }
.tile svg { width: 22px; height: 22px; margin: 0 0 5px; color: var(--acid); }
.tile-t { font-weight: 600; font-size: 13px; }
.tile-s { font-size: 11.5px; color: var(--dim); line-height: 1.35; }
.linkish { background: none; border: none; padding: 0; font: inherit; cursor: pointer;
color: var(--acid); text-decoration: underline; }
/* ---- the three main tools ------------------------------------------- */
/* Point at it, write on it, sign it. Wider, labelled, and set apart from the
drawing kit, because they are the things most people open a PDF to do and
they used to be four identical squares in a row of thirteen. */
.tgroup.primary { gap: 4px; }
.tool.big { width: auto; padding: 0 10px; gap: 6px; }
.tool.big .tlabel { font-size: 12px; line-height: 1; }
.tool.big svg { width: 15px; height: 15px; }
@media (max-width: 900px) { .tool.big .tlabel { display: none; } .tool.big { width: 30px; padding: 0; } }
/* ---- typing on the page --------------------------------------------- */
/* Positioned by lib/inline.js against the page's own coordinates, so it sits
where the text will sit. No background: the page is the background, which
is the whole point — you see the words against what they are going on. */
.inline-text { position: absolute; z-index: 24; margin: 0; padding: 0;
min-width: 1px; white-space: pre; outline: none;
caret-color: currentColor;
box-shadow: 0 0 0 1px rgb(from var(--acid) r g b / .55),
0 0 0 4px rgb(from var(--acid) r g b / .14); }
.inline-text:empty::before { content: ""; display: inline-block; width: 1px; }
/* ---- signature library ---------------------------------------------- */
.siglist { list-style: none; margin: 0 0 12px; padding: 0; display: grid;
gap: 8px; max-height: 40vh; overflow: auto; }
.sigrow { display: flex; align-items: center; gap: 10px; padding: 8px 10px;
border: 1px solid var(--line); border-radius: 9px; background: var(--bg); }
.sigrow .sigpreview { flex: 0 0 132px; height: 44px; }
.sigrow .sigpreview svg { width: 100%; height: 100%; }
.sigrow .signame { flex: 1 1 auto; min-width: 0; font-size: 13px; color: var(--ink);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* flex: none on the buttons — without it they shrink below their own labels
and the words run into each other. The name gives way instead. */
.sigrow .sigacts { display: flex; gap: 6px; flex: 0 0 auto; }
/* width: auto matters most — .btn is a 30px square by default, made for an
icon, and a word does not fit in one. */
.sigrow .sigacts .btn { flex: none; width: auto; height: 26px; white-space: nowrap;
padding: 0 10px; font-size: 11.5px; line-height: 1; }
/* Ink for the pad. Dark by default: a signature is not a highlight. */
.sigink { display: flex; gap: 6px; margin: 0 0 8px; }
.sigink .swatch { width: 22px; height: 22px; border-radius: 50%;
border: 2px solid var(--line); cursor: pointer; padding: 0; }
.sigink .swatch.active { border-color: var(--acid); }
/* ---- 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. */
/* 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. */
.pdfViewer .page .smOverlay { position: absolute; inset: 0; z-index: 3;
pointer-events: none; overflow: visible; }
.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; }
/* ...with one exception: the SELECTED mark stays grabbable whatever tool is
armed. Its handles already were, so a shape you had just drawn could be
stretched but not shifted. Only the selected one opts in, so a press
anywhere else still draws. */
.smOverlay .mark.sel { pointer-events: stroke; cursor: move; }
.smOverlay .mark.sel[data-fill="1"] { pointer-events: all; }
.smOverlay .selbox { fill: none; stroke: var(--acid); stroke-width: 1;
stroke-dasharray: 4 3; vector-effect: non-scaling-stroke; }
/* A group: one solid box round the lot, and a fainter one round each member
so it is obvious WHICH marks are in it — a marquee that caught one more
than you meant is worth seeing before you press Delete. */
.smOverlay .selbox.group { stroke-dasharray: 2 4; stroke-width: 1.5; }
.smOverlay .selbox.member { stroke-dasharray: none; stroke-opacity: .45; }
/* The rubber band itself. */
.smOverlay .marquee { fill: rgb(from var(--acid) r g b / .10); stroke: var(--acid);
stroke-width: 1; stroke-dasharray: 5 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; }
/* ---- 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); }
/* 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; }
/* 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; }
/* 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="text"] .annotationLayer { pointer-events: none !important; }
body[data-mode="text"] .pdfViewer .page { cursor: text; }
/* The document's own words light up under the cursor, so it is obvious that
clicking them replaces them and clicking anywhere else starts new text. */
body[data-mode="text"] .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;
}