* feat(pdf-server): rasterize imported annotations via annotationCanvasMap
Adds an 'imported' annotation type for anything in a loaded PDF that we
either don't model (Ink, Polygon, Caret, FileAttachment, ...) or can't
faithfully re-render (Stamp with an appearance stream, e.g. an image
signature). These now:
- Appear in the annotation panel as '<Subtype> (from PDF)'
- Render in our layer as a positioned div whose body is the per-
annotation canvas pdf.js produced via page.render({annotationCanvasMap})
- if pdf.js didn't divert it (no hasOwnCanvas), the box is transparent
over the main-canvas pixel and just captures clicks
- Are selectable and draggable (resize/rotate disabled - bitmap would
just stretch)
- Are skipped by addAnnotationDicts; getAnnotatedPdfBytes already
filters baseline ids, so save leaves the original in the PDF.
Move/delete are UI-only for now (documented).
Link (2) and Popup (16) are excluded - navigational/auxiliary, not
markup.
importPdfjsAnnotation tests added for unsupported-type and
appearance-stamp paths; computeDiff round-trip for 'imported'.
* fix(pdf-server): isEditing on page.render so stamps always divert to annotationCanvasMap
StampAnnotation.hasOwnCanvas defaults to noRotate; stamps without that
flag composited onto the main canvas, so deleting the 'imported' overlay
left an unclickable pixel. isEditing forces hasOwnCanvas=true for stamps
(via mustBeViewedWhenEditing) so the appearance lands in the per-id
canvas and our DOM element is the only render.
* fix(pdf-server): refresh annotation panel after save rebases baseline
setDirty(false) updated the title and save button but the panel kept
showing pending-change badges because it diffs against
pdfBaselineAnnotations/FormValues and was never re-rendered.
* fix(pdf-server): imported stamp canvases cropped/2x on retina
renderPage applied devicePixelRatio via ctx.scale(dpr,dpr) instead of
page.render's transform parameter. pdf.js sizes annotationCanvasMap
backing buffers as rectW * outputScaleX * viewport.scale, and
outputScaleX is read from transform[0] (defaults 1). So on retina the
per-annotation canvas got a 1x backing while its internal setTransform
(from the SVD of the already-dpr-scaled ctx) was 2x - the appearance
rendered at 2x into a half-sized buffer, showing only the top-left
quarter.
Pass dpr via transform: [dpr,0,0,dpr,0,0] so outputScaleX matches.
Also filter AnnotationLayer.render() to Widget annotations only so it
stops creating empty pointer-events:auto sections for stamps in
#form-layer that could steal clicks from our overlays.
* feat(pdf-server): strip deleted baseline annotations from /Annots on save
buildAnnotatedPdfBytes takes a removedRefs list and walks each page's
/Annots array (backwards) removing matching PDFRef entries.
getAnnotatedPdfBytes computes that list from baseline annotations no
longer in annotationMap, parsing the ref back from our id via
parseAnnotationRef (handles both pdf-<num>-<gen> and pdf-<num>R).
Panel: removed baseline annotations now stay listed as crossed-out cards
with a revert button (mirrors cleared form fields), so the user can see
and undo the pending deletion before save commits it.
* fix(pdf-server): re-render after seeding storage from AcroForm tree
buildFieldNameMap runs AFTER the first renderPage (perf: don't block the
canvas on an O(numPages) scan). When it detects a widget/field-tree
mismatch and pushes the field-tree value into annotationStorage, the
form layer has already rendered the stale widget value. Re-render once
when that happens so the input shows the AcroForm truth.
* fix(pdf-server): reload PDF after successful save instead of rebasing in place
Rebasing baselines while keeping the old pdfDocument drifts: subsequent
renders rasterize annotations that were just stripped from disk, and the
field/widget split pdf-lib's save can create isn't visible until reload.
Reload makes 'viewer == disk' an invariant. localStorage cleared first;
file_changed echo suppressed by lastSavedMtime as before.
1249 lines
No EOL
27 KiB
CSS
1249 lines
No EOL
27 KiB
CSS
:root {
|
|
color-scheme: light dark;
|
|
|
|
/* Background colors */
|
|
--bg000: light-dark(#ffffff, #1a1a1a);
|
|
--bg100: light-dark(#f5f5f5, #252525);
|
|
--bg200: light-dark(#e0e0e0, #333333);
|
|
--bg300: light-dark(#cccccc, #444444);
|
|
|
|
/* Text colors */
|
|
--text000: light-dark(#1a1a1a, #f0f0f0);
|
|
--text100: light-dark(#666666, #aaaaaa);
|
|
--text200: light-dark(#999999, #888888);
|
|
|
|
/* Shadows */
|
|
--shadow-page-color: light-dark(rgba(0 0 0 / 0.15), rgba(0 0 0 / 0.4));
|
|
--selection-bg: light-dark(rgba(0, 0, 255, 0.3), rgba(100, 150, 255, 0.4));
|
|
}
|
|
|
|
body {
|
|
overscroll-behavior-x: none;
|
|
display: flex;
|
|
}
|
|
|
|
.main {
|
|
display: flex;
|
|
flex-direction: column;
|
|
flex: 1;
|
|
width: 100%;
|
|
background: var(--bg100);
|
|
color: var(--text000);
|
|
overflow: hidden; /* Prevent scrollbars in inline mode - we request exact size */
|
|
border-radius: 0.75rem;
|
|
border: 1px solid var(--bg200);
|
|
}
|
|
|
|
/* Loading State */
|
|
.loading {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex: 1;
|
|
gap: 0.75rem;
|
|
padding: 2rem;
|
|
}
|
|
|
|
.spinner {
|
|
width: 24px;
|
|
height: 24px;
|
|
border: 2px solid var(--bg200);
|
|
border-top-color: var(--text100);
|
|
border-radius: 50%;
|
|
animation: spin 0.8s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
|
|
#loading-text {
|
|
font-size: 0.8rem;
|
|
color: var(--text100);
|
|
}
|
|
|
|
/* Error State */
|
|
.error {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 100%;
|
|
gap: 1rem;
|
|
padding: 2rem;
|
|
text-align: center;
|
|
}
|
|
|
|
.error-icon {
|
|
font-size: 3rem;
|
|
}
|
|
|
|
#error-message {
|
|
color: var(--text200);
|
|
max-width: 400px;
|
|
}
|
|
|
|
/* Viewer Container */
|
|
.viewer {
|
|
display: flex;
|
|
flex-direction: column;
|
|
flex: 1;
|
|
overflow: visible;
|
|
position: relative;
|
|
}
|
|
|
|
/* Toolbar */
|
|
.toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 0.5rem 0.5rem;
|
|
background: var(--bg000);
|
|
border-bottom: 1px solid var(--bg200);
|
|
flex-shrink: 0;
|
|
gap: 0.25rem 0.5rem;
|
|
min-height: 48px;
|
|
box-sizing: border-box;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.toolbar-left {
|
|
flex: 1;
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.pdf-title {
|
|
font-weight: 500;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
font-size: 0.9rem;
|
|
display: block;
|
|
max-width: 100%;
|
|
}
|
|
|
|
.toolbar-center {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.toolbar-right {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.25rem;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
/* Page Navigation */
|
|
.page-nav {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.25rem;
|
|
}
|
|
|
|
.page-input {
|
|
width: 50px;
|
|
padding: 0.25rem 0.5rem;
|
|
border: 1px solid var(--bg200);
|
|
border-radius: 4px;
|
|
font-size: 0.85rem;
|
|
text-align: center;
|
|
background: var(--bg000);
|
|
color: var(--text000);
|
|
}
|
|
|
|
.page-input:focus {
|
|
outline: none;
|
|
border-color: var(--text100);
|
|
}
|
|
|
|
.page-input::-webkit-outer-spin-button,
|
|
.page-input::-webkit-inner-spin-button {
|
|
-webkit-appearance: none;
|
|
margin: 0;
|
|
}
|
|
.page-input[type="number"] {
|
|
-moz-appearance: textfield;
|
|
}
|
|
|
|
.total-pages {
|
|
font-size: 0.85rem;
|
|
color: var(--text100);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.nav-btn,
|
|
.zoom-btn,
|
|
.fullscreen-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 32px;
|
|
height: 32px;
|
|
border: 1px solid var(--bg200);
|
|
border-radius: 4px;
|
|
background: var(--bg000);
|
|
color: var(--text000);
|
|
cursor: pointer;
|
|
font-size: 1rem;
|
|
transition: all 0.15s ease;
|
|
}
|
|
|
|
.nav-btn:hover:not(:disabled),
|
|
.zoom-btn:hover:not(:disabled),
|
|
.fullscreen-btn:hover {
|
|
background: var(--bg100);
|
|
border-color: var(--bg300);
|
|
}
|
|
|
|
.nav-btn:disabled,
|
|
.zoom-btn:disabled {
|
|
opacity: 0.4;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.zoom-level {
|
|
font-size: 0.8rem;
|
|
color: var(--text100);
|
|
min-width: 50px;
|
|
text-align: center;
|
|
}
|
|
|
|
/* Single Page Canvas Container */
|
|
.canvas-container {
|
|
flex: 1;
|
|
overflow-x: auto; /* Allow horizontal scrolling when zoomed in inline mode */
|
|
overflow-y: visible;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: flex-start;
|
|
padding: 1rem;
|
|
background: var(--bg200);
|
|
/* JS owns pinch (inline pinch-in → fullscreen). pan-x/pan-y keeps native
|
|
* scrolling; the fullscreen rule below repeats this with a comment on the
|
|
* iOS-Safari preventDefault backstop. */
|
|
touch-action: pan-x pan-y;
|
|
}
|
|
|
|
.page-wrapper {
|
|
position: relative;
|
|
box-shadow:
|
|
0 1px 4px var(--shadow-page-color),
|
|
0 4px 16px var(--shadow-page-color);
|
|
background: white;
|
|
}
|
|
|
|
#pdf-canvas {
|
|
display: block;
|
|
}
|
|
|
|
/* Text Layer for Selection
|
|
* Critical: must include font-size and transform rules that PDF.js TextLayer
|
|
* relies on via CSS custom properties (--font-height, --scale-x, --rotate).
|
|
* Without these, text layer spans won't align with the canvas rendering. */
|
|
.text-layer {
|
|
position: absolute;
|
|
left: 0;
|
|
top: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
overflow: hidden;
|
|
opacity: 0.2;
|
|
line-height: 1;
|
|
text-size-adjust: none;
|
|
forced-color-adjust: none;
|
|
transform-origin: 0 0;
|
|
z-index: 2;
|
|
/* PDF.js TextLayer sets --min-font-size on container */
|
|
--min-font-size-inv: calc(1 / var(--min-font-size, 1));
|
|
}
|
|
|
|
.text-layer :is(span, br) {
|
|
color: transparent;
|
|
position: absolute;
|
|
white-space: pre;
|
|
cursor: text;
|
|
transform-origin: 0% 0%;
|
|
}
|
|
|
|
/* PDF.js sets --font-height, --scale-x, --rotate as inline styles on each span.
|
|
* These rules apply proper font size and transforms to match the canvas. */
|
|
.text-layer > :not(.markedContent),
|
|
.text-layer .markedContent span:not(.markedContent) {
|
|
z-index: 1;
|
|
--font-height: 0;
|
|
font-size: calc(var(--scale-factor, 1) * var(--font-height));
|
|
--scale-x: 1;
|
|
--rotate: 0deg;
|
|
transform: rotate(var(--rotate)) scaleX(var(--scale-x))
|
|
scale(var(--min-font-size-inv, 1));
|
|
}
|
|
|
|
.text-layer .markedContent {
|
|
display: contents;
|
|
}
|
|
|
|
.text-layer ::selection {
|
|
background: var(--selection-bg);
|
|
}
|
|
|
|
.text-layer > span {
|
|
pointer-events: all;
|
|
}
|
|
|
|
/* Fullscreen mode */
|
|
.main.fullscreen {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
z-index: 1000;
|
|
overflow: hidden; /* No scrolling on main - only canvas-container scrolls */
|
|
border-radius: 0;
|
|
border: none;
|
|
padding: 0 !important; /* Background extends edge-to-edge */
|
|
}
|
|
|
|
.main.fullscreen .toolbar {
|
|
/* Hosts wrap fullscreen in their own header (title + close), which already
|
|
* clears the top safe-area — so no --safe-top here, only sides. */
|
|
padding: 0.25rem calc(0.5rem + var(--safe-right, 0px)) 0.25rem
|
|
calc(0.5rem + var(--safe-left, 0px));
|
|
min-height: 40px;
|
|
/* Inline can wrap (narrow chat bubble); fullscreen has the width, and
|
|
* wrapping would double the bar height + desync the search-bar offset. */
|
|
flex-wrap: nowrap;
|
|
}
|
|
|
|
.main.fullscreen .search-bar {
|
|
/* -1px overlaps the toolbar border so the dropdown looks attached. */
|
|
top: 39px;
|
|
right: calc(var(--safe-right, 0px) - 1px);
|
|
}
|
|
|
|
.main.fullscreen .viewer {
|
|
min-height: 0; /* Allow flex item to shrink below content size */
|
|
}
|
|
|
|
.main.fullscreen .viewer-body {
|
|
min-height: 0;
|
|
}
|
|
|
|
.main.fullscreen .canvas-container {
|
|
min-height: 0; /* Allow flex item to shrink below content size */
|
|
overflow: auto; /* Scroll within the document area only */
|
|
/* Let JS own pinch-zoom (we re-render the PDF, not the viewport).
|
|
* pan-x pan-y keeps native scrolling/panning working. iOS Safari only
|
|
* partially honours this — preventDefault on touchmove backs it up. */
|
|
touch-action: pan-x pan-y;
|
|
/* Keep the page clear of host overlays (chat input, home indicator).
|
|
* computeFitScale subtracts padding, so fit-to-page lands above these.
|
|
* The container background still extends edge-to-edge (.main.fullscreen
|
|
* has padding:0). Top inset is on the toolbar; the rest land here. */
|
|
padding-bottom: calc(1rem + var(--safe-bottom, 0px));
|
|
padding-left: calc(1rem + var(--safe-left, 0px));
|
|
padding-right: calc(1rem + var(--safe-right, 0px));
|
|
}
|
|
|
|
/* Search Button */
|
|
.search-btn,
|
|
.nav-btn,
|
|
.zoom-btn,
|
|
.fullscreen-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 32px;
|
|
height: 32px;
|
|
border: 1px solid var(--bg200);
|
|
border-radius: 4px;
|
|
background: var(--bg000);
|
|
color: var(--text000);
|
|
cursor: pointer;
|
|
font-size: 1rem;
|
|
transition: all 0.15s ease;
|
|
}
|
|
|
|
.search-btn:hover,
|
|
.nav-btn:hover:not(:disabled),
|
|
.zoom-btn:hover:not(:disabled),
|
|
.fullscreen-btn:hover {
|
|
background: var(--bg100);
|
|
border-color: var(--bg300);
|
|
}
|
|
|
|
/* Search Bar */
|
|
.search-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
position: absolute;
|
|
top: 47px; /* below toolbar (48px - 1px border overlap) */
|
|
right: -1px; /* align with outer border */
|
|
padding: 0.375rem 0.5rem;
|
|
background: var(--bg000);
|
|
border: 1px solid var(--bg200);
|
|
border-top: none;
|
|
border-radius: 0 0 6px 6px;
|
|
gap: 0.5rem;
|
|
z-index: 10;
|
|
}
|
|
|
|
.search-input {
|
|
width: 200px;
|
|
padding: 0.25rem 0.5rem;
|
|
border: 1px solid var(--bg200);
|
|
border-radius: 4px;
|
|
font-size: 0.85rem;
|
|
background: var(--bg000);
|
|
color: var(--text000);
|
|
}
|
|
|
|
.search-input:focus {
|
|
outline: none;
|
|
border-color: var(--text100);
|
|
}
|
|
|
|
.search-match-count {
|
|
font-size: 0.8rem;
|
|
color: var(--text100);
|
|
white-space: nowrap;
|
|
min-width: 60px;
|
|
}
|
|
|
|
.search-nav-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 28px;
|
|
height: 28px;
|
|
border: 1px solid var(--bg200);
|
|
border-radius: 4px;
|
|
background: var(--bg000);
|
|
color: var(--text000);
|
|
cursor: pointer;
|
|
font-size: 0.8rem;
|
|
transition: all 0.15s ease;
|
|
}
|
|
|
|
.search-nav-btn:hover:not(:disabled) {
|
|
background: var(--bg100);
|
|
border-color: var(--bg300);
|
|
}
|
|
|
|
.search-nav-btn:disabled {
|
|
opacity: 0.4;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
/* Annotation Layer */
|
|
.annotation-layer {
|
|
position: absolute;
|
|
left: 0;
|
|
top: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
pointer-events: none;
|
|
z-index: 3; /* above text-layer (z-index: 2) so notes are clickable */
|
|
}
|
|
|
|
.annotation-highlight {
|
|
position: absolute;
|
|
background: rgba(255, 255, 0, 0.35);
|
|
mix-blend-mode: multiply;
|
|
border-radius: 1px;
|
|
pointer-events: auto;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.annotation-underline {
|
|
position: absolute;
|
|
border-bottom: 2px solid #ff0000;
|
|
box-sizing: border-box;
|
|
pointer-events: auto;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.annotation-strikethrough {
|
|
position: absolute;
|
|
box-sizing: border-box;
|
|
pointer-events: auto;
|
|
cursor: pointer;
|
|
}
|
|
.annotation-strikethrough::after {
|
|
content: "";
|
|
position: absolute;
|
|
left: 0;
|
|
right: 0;
|
|
top: 50%;
|
|
border-top: 2px solid #ff0000;
|
|
}
|
|
|
|
.annotation-note {
|
|
position: absolute;
|
|
width: 20px;
|
|
height: 20px;
|
|
cursor: pointer;
|
|
pointer-events: auto;
|
|
}
|
|
.annotation-note::after {
|
|
content: "";
|
|
display: block;
|
|
width: 16px;
|
|
height: 16px;
|
|
margin: 2px;
|
|
background: currentColor;
|
|
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='black'%3E%3Cpath d='M3 1a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V5l-4-4H3zm7 1.5L13.5 6H11a1 1 0 0 1-1-1V2.5zM5 8.5h6v1H5v-1zm0 2.5h4v1H5v-1z'/%3E%3C/svg%3E") no-repeat center / contain;
|
|
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='black'%3E%3Cpath d='M3 1a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V5l-4-4H3zm7 1.5L13.5 6H11a1 1 0 0 1-1-1V2.5zM5 8.5h6v1H5v-1zm0 2.5h4v1H5v-1z'/%3E%3C/svg%3E") no-repeat center / contain;
|
|
}
|
|
.annotation-note .annotation-tooltip {
|
|
display: none;
|
|
position: absolute;
|
|
bottom: 100%;
|
|
left: 0;
|
|
background: var(--bg000, #fff);
|
|
color: var(--text000, #000);
|
|
border: 1px solid var(--bg200, #ccc);
|
|
border-radius: 4px;
|
|
padding: 4px 8px;
|
|
font-size: 0.8rem;
|
|
white-space: pre-wrap;
|
|
max-width: 200px;
|
|
z-index: 100; /* Must float above all sibling annotation elements */
|
|
pointer-events: none;
|
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
|
}
|
|
.annotation-note:hover .annotation-tooltip {
|
|
display: block;
|
|
}
|
|
|
|
/* When hovering a note, raise the entire annotation layer above the form
|
|
layer so the tooltip isn't clipped by the z-index:4 form overlay. */
|
|
.annotation-layer:has(.annotation-note:hover) {
|
|
z-index: 5;
|
|
}
|
|
|
|
.annotation-rectangle {
|
|
position: absolute;
|
|
border: 2px solid #0066cc;
|
|
box-sizing: border-box;
|
|
pointer-events: auto;
|
|
cursor: grab;
|
|
user-select: none;
|
|
}
|
|
|
|
.annotation-circle {
|
|
position: absolute;
|
|
border: 2px solid #0066cc;
|
|
border-radius: 50%;
|
|
box-sizing: border-box;
|
|
pointer-events: auto;
|
|
cursor: grab;
|
|
user-select: none;
|
|
}
|
|
|
|
.annotation-line {
|
|
position: absolute;
|
|
height: 0;
|
|
border-top: 2px solid #333;
|
|
pointer-events: auto;
|
|
cursor: grab;
|
|
user-select: none;
|
|
}
|
|
|
|
.annotation-freetext {
|
|
position: absolute;
|
|
font-family: Helvetica, Arial, sans-serif;
|
|
white-space: pre-wrap;
|
|
pointer-events: auto;
|
|
cursor: grab;
|
|
user-select: none;
|
|
}
|
|
|
|
.annotation-stamp {
|
|
position: absolute;
|
|
font-family: Helvetica, Arial, sans-serif;
|
|
font-weight: bold;
|
|
font-size: 24px;
|
|
border: 3px solid currentColor;
|
|
padding: 4px 12px;
|
|
opacity: 0.6;
|
|
text-transform: uppercase;
|
|
white-space: nowrap;
|
|
pointer-events: auto;
|
|
cursor: grab;
|
|
user-select: none;
|
|
}
|
|
|
|
.annotation-image {
|
|
position: absolute;
|
|
pointer-events: auto;
|
|
cursor: grab;
|
|
user-select: none;
|
|
}
|
|
|
|
/* Annotation imported verbatim from the PDF (Stamp/Ink/etc. via
|
|
* annotationCanvasMap). The body is the rasterized appearance canvas;
|
|
* if pdf.js didn't divert it (no hasOwnCanvas), the box stays transparent
|
|
* over the main-canvas pixel and just captures clicks. */
|
|
.annotation-imported {
|
|
position: absolute;
|
|
pointer-events: auto;
|
|
cursor: grab;
|
|
user-select: none;
|
|
}
|
|
.annotation-imported:hover {
|
|
outline: 1px dashed var(--accent, #2563eb);
|
|
outline-offset: 1px;
|
|
}
|
|
|
|
/* Selection visuals */
|
|
.annotation-selected {
|
|
outline: 2px solid var(--accent, #2563eb);
|
|
outline-offset: 2px;
|
|
z-index: 10;
|
|
}
|
|
|
|
.annotation-handle {
|
|
position: absolute;
|
|
width: 8px;
|
|
height: 8px;
|
|
background: white;
|
|
border: 2px solid var(--accent, #2563eb);
|
|
border-radius: 50%;
|
|
z-index: 11;
|
|
pointer-events: auto;
|
|
}
|
|
.annotation-handle.nw { top: -5px; left: -5px; cursor: nwse-resize; }
|
|
.annotation-handle.ne { top: -5px; right: -5px; cursor: nesw-resize; }
|
|
.annotation-handle.sw { bottom: -5px; left: -5px; cursor: nesw-resize; }
|
|
.annotation-handle.se { bottom: -5px; right: -5px; cursor: nwse-resize; }
|
|
|
|
.annotation-handle-rotate {
|
|
position: absolute;
|
|
top: -20px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
width: 10px;
|
|
height: 10px;
|
|
background: white;
|
|
border: 2px solid var(--accent, #2563eb);
|
|
border-radius: 50%;
|
|
z-index: 11;
|
|
pointer-events: auto;
|
|
cursor: grab;
|
|
}
|
|
|
|
.annotation-card.selected {
|
|
background: var(--accent-bg, rgba(37, 99, 235, 0.08));
|
|
border-left-color: var(--accent, #2563eb);
|
|
}
|
|
|
|
.annotation-dragging {
|
|
cursor: grabbing !important;
|
|
}
|
|
|
|
@media (prefers-color-scheme: dark) {
|
|
.annotation-highlight {
|
|
background: rgba(255, 255, 0, 0.3);
|
|
mix-blend-mode: screen;
|
|
}
|
|
}
|
|
|
|
/* Save / Download Buttons */
|
|
.save-btn,
|
|
.download-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 32px;
|
|
height: 32px;
|
|
border: 1px solid var(--bg200);
|
|
border-radius: 4px;
|
|
background: var(--bg000);
|
|
color: var(--text000);
|
|
cursor: pointer;
|
|
font-size: 1rem;
|
|
transition: all 0.15s ease;
|
|
}
|
|
|
|
.save-btn:hover:not(:disabled),
|
|
.download-btn:hover:not(:disabled) {
|
|
background: var(--bg100);
|
|
border-color: var(--bg300);
|
|
}
|
|
|
|
.save-btn:disabled,
|
|
.download-btn:disabled {
|
|
opacity: 0.4;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
/*
|
|
* Confirmation Dialog
|
|
*
|
|
* Styled to match the host's native dialogs (e.g. the downloadFile prompt).
|
|
* Uses host-provided CSS variables (set via applyHostStyleVariables) with
|
|
* local fallbacks so it still looks reasonable in standalone dev.
|
|
*/
|
|
.confirm-dialog {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 1000;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: light-dark(rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.6));
|
|
font-family: var(--font-sans, inherit);
|
|
}
|
|
|
|
.confirm-box {
|
|
background: var(--color-background-primary, var(--bg000));
|
|
border-radius: var(--border-radius-xl, 20px);
|
|
padding: 1.75rem;
|
|
min-width: 360px;
|
|
max-width: 520px;
|
|
box-shadow: var(--shadow-lg, 0 20px 50px rgba(0, 0, 0, 0.3));
|
|
}
|
|
|
|
.confirm-title {
|
|
font-weight: var(--font-weight-bold, 700);
|
|
font-size: var(--font-heading-md-size, 1.5rem);
|
|
line-height: var(--font-heading-md-line-height, 1.25);
|
|
margin-bottom: 0.5rem;
|
|
color: var(--color-text-primary, var(--text000));
|
|
}
|
|
|
|
.confirm-body {
|
|
font-size: var(--font-text-md-size, 1rem);
|
|
line-height: var(--font-text-md-line-height, 1.5);
|
|
color: var(--color-text-secondary, var(--text100));
|
|
margin-bottom: 1.5rem;
|
|
word-break: break-word;
|
|
}
|
|
|
|
/* Optional monospace detail box (filename, path, etc.) */
|
|
.confirm-detail {
|
|
font-family: var(--font-mono, ui-monospace, monospace);
|
|
font-size: var(--font-text-sm-size, 0.9rem);
|
|
padding: 0.75rem 1rem;
|
|
margin-bottom: 1.5rem;
|
|
border: var(--border-width-regular, 1px) solid
|
|
var(--color-border-secondary, var(--bg200));
|
|
border-radius: var(--border-radius-md, 8px);
|
|
background: var(--color-background-secondary, var(--bg100));
|
|
color: var(--color-text-primary, var(--text000));
|
|
word-break: break-all;
|
|
}
|
|
|
|
.confirm-detail:empty {
|
|
display: none;
|
|
}
|
|
|
|
.confirm-buttons {
|
|
display: flex;
|
|
gap: 0.75rem;
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
.confirm-btn {
|
|
padding: 0.625rem 1.25rem;
|
|
border: var(--border-width-regular, 1px) solid
|
|
var(--color-border-primary, var(--bg200));
|
|
border-radius: var(--border-radius-md, 8px);
|
|
background: var(--color-background-primary, var(--bg000));
|
|
color: var(--color-text-primary, var(--text000));
|
|
cursor: pointer;
|
|
font: inherit;
|
|
font-size: var(--font-text-md-size, 0.95rem);
|
|
font-weight: var(--font-weight-medium, 500);
|
|
}
|
|
|
|
.confirm-btn:hover {
|
|
background: var(--color-background-secondary, var(--bg100));
|
|
}
|
|
|
|
.confirm-btn-primary {
|
|
/* Host's native primary buttons use inverse colors (dark bg, light text) */
|
|
background: var(--color-background-inverse, var(--text000));
|
|
border-color: var(--color-background-inverse, var(--text000));
|
|
color: var(--color-text-inverse, var(--bg000));
|
|
}
|
|
|
|
.confirm-btn-primary:hover {
|
|
/* .confirm-btn:hover above would otherwise reset background */
|
|
background: var(--color-background-inverse, var(--text000));
|
|
filter: brightness(1.15);
|
|
}
|
|
|
|
/* Form Layer (PDF.js AnnotationLayer for interactive form widgets) */
|
|
#form-layer {
|
|
position: absolute;
|
|
left: 0;
|
|
top: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
z-index: 4; /* above annotation-layer (z-index: 3) so form inputs are clickable */
|
|
}
|
|
|
|
/*
|
|
* PDF.js AnnotationLayer renders interactive form inputs on top of the canvas.
|
|
* When the widget is transparent the canvas's static appearance bleeds through,
|
|
* causing double-text (the widget font never matches the PDF's font metrics).
|
|
* Fix: force widgets opaque. PDF.js also reads the annotation's /BC entry and
|
|
* writes it as an inline `background-color` on the element (typically
|
|
* `transparent` for unstyled forms) — inline style beats any selector, hence
|
|
* !important.
|
|
*/
|
|
#form-layer .textWidgetAnnotation :is(input, textarea),
|
|
#form-layer .choiceWidgetAnnotation select {
|
|
background-image: none !important;
|
|
background-color: light-dark(#fff, #2a2a2a) !important;
|
|
}
|
|
|
|
#form-layer select option:checked {
|
|
background: light-dark(#d0e0ff, #335);
|
|
}
|
|
|
|
|
|
/* Highlight Layer */
|
|
.highlight-layer {
|
|
position: absolute;
|
|
left: 0;
|
|
top: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
pointer-events: none;
|
|
z-index: 1;
|
|
}
|
|
|
|
.search-highlight {
|
|
background: rgba(255, 255, 0, 0.4);
|
|
mix-blend-mode: multiply;
|
|
border-radius: 2px;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.search-highlight.current {
|
|
background: rgba(255, 165, 0, 0.6);
|
|
}
|
|
|
|
@media (prefers-color-scheme: dark) {
|
|
.search-highlight {
|
|
background: rgba(255, 255, 0, 0.3);
|
|
mix-blend-mode: screen;
|
|
}
|
|
|
|
.search-highlight.current {
|
|
background: rgba(255, 165, 0, 0.5);
|
|
mix-blend-mode: screen;
|
|
}
|
|
}
|
|
|
|
/* Viewer Body (flex row for canvas + panel) */
|
|
.viewer-body {
|
|
display: flex;
|
|
flex: 1;
|
|
min-height: 0;
|
|
overflow: visible;
|
|
position: relative; /* Anchor for floating annotation panel */
|
|
}
|
|
|
|
/* Annotations Toolbar Button */
|
|
.annotations-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 32px;
|
|
height: 32px;
|
|
border: 1px solid var(--bg200);
|
|
border-radius: 4px;
|
|
background: var(--bg000);
|
|
color: var(--text000);
|
|
cursor: pointer;
|
|
font-size: 1rem;
|
|
transition: all 0.15s ease;
|
|
position: relative;
|
|
}
|
|
.annotations-btn:hover {
|
|
background: var(--bg100);
|
|
border-color: var(--bg300);
|
|
}
|
|
.annotations-btn.active {
|
|
background: var(--bg200);
|
|
border-color: var(--bg300);
|
|
}
|
|
|
|
.annotations-badge {
|
|
position: absolute;
|
|
top: -4px;
|
|
right: -4px;
|
|
min-width: 16px;
|
|
height: 16px;
|
|
background: var(--accent, #2563eb);
|
|
color: #fff;
|
|
font-size: 0.65rem;
|
|
font-weight: 600;
|
|
border-radius: 8px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 0 3px;
|
|
line-height: 1;
|
|
}
|
|
|
|
/* Annotation Side Panel */
|
|
.annotation-panel {
|
|
width: 500px;
|
|
min-width: 180px;
|
|
max-width: 50vw;
|
|
background: var(--bg000);
|
|
border-left: 1px solid var(--bg200);
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
position: relative;
|
|
}
|
|
|
|
/* Resize handle on left edge */
|
|
.annotation-panel-resize {
|
|
position: absolute;
|
|
left: -3px;
|
|
top: 0;
|
|
bottom: 0;
|
|
width: 6px;
|
|
cursor: col-resize;
|
|
z-index: 10;
|
|
}
|
|
.annotation-panel-resize:hover,
|
|
.annotation-panel-resize.dragging {
|
|
background: var(--text100);
|
|
opacity: 0.3;
|
|
}
|
|
|
|
.annotation-panel-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 0.5rem 0.75rem;
|
|
border-bottom: 1px solid var(--bg200);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.annotation-panel-title {
|
|
font-size: 0.85rem;
|
|
font-weight: 600;
|
|
color: var(--text000);
|
|
}
|
|
|
|
.annotation-panel-header-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
|
|
.annotation-panel-reset,
|
|
.annotation-panel-clear-all {
|
|
border: none;
|
|
background: transparent;
|
|
color: var(--text100);
|
|
cursor: pointer;
|
|
border-radius: 4px;
|
|
padding: 4px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.annotation-panel-reset:hover {
|
|
color: var(--text000);
|
|
background: var(--bg100);
|
|
}
|
|
.annotation-panel-clear-all:hover {
|
|
color: #e74c3c;
|
|
background: var(--bg100);
|
|
}
|
|
.annotation-panel-reset:disabled,
|
|
.annotation-panel-clear-all:disabled {
|
|
opacity: 0.4;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.annotation-panel-close {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 24px;
|
|
height: 24px;
|
|
border: none;
|
|
border-radius: 4px;
|
|
background: transparent;
|
|
color: var(--text100);
|
|
cursor: pointer;
|
|
font-size: 0.8rem;
|
|
transition: all 0.15s ease;
|
|
}
|
|
.annotation-panel-close:hover {
|
|
background: var(--bg100);
|
|
color: var(--text000);
|
|
}
|
|
|
|
.annotation-panel-list {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 0.25rem 0;
|
|
}
|
|
|
|
/* Floating annotation panel (used in both inline and fullscreen) */
|
|
.annotation-panel.floating {
|
|
position: absolute;
|
|
z-index: 20;
|
|
border: 1px solid var(--bg200);
|
|
border-radius: 6px;
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
|
max-height: 60%;
|
|
width: 135px;
|
|
transition: top 0.15s ease, left 0.15s ease, right 0.15s ease, bottom 0.15s ease;
|
|
}
|
|
.annotation-panel.floating .annotation-panel-header {
|
|
cursor: grab;
|
|
}
|
|
.annotation-panel.floating.dragging .annotation-panel-header {
|
|
cursor: grabbing;
|
|
}
|
|
|
|
/* Accordion section headers */
|
|
.annotation-section-header {
|
|
font-size: 0.7rem;
|
|
font-weight: 600;
|
|
color: var(--text100);
|
|
padding: 0.3rem 0.6rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.03em;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
border-bottom: 1px solid var(--bg200);
|
|
user-select: none;
|
|
transition: background 0.1s ease;
|
|
}
|
|
.annotation-section-header:hover {
|
|
background: var(--bg100);
|
|
}
|
|
.annotation-section-header.current-page {
|
|
color: var(--text000);
|
|
}
|
|
.annotation-section-header.open {
|
|
color: var(--text000);
|
|
}
|
|
.annotation-section-chevron {
|
|
font-size: 0.55rem;
|
|
transition: transform 0.15s ease;
|
|
}
|
|
.annotation-section-body {
|
|
display: none;
|
|
}
|
|
.annotation-section-body.open {
|
|
display: block;
|
|
}
|
|
|
|
/* Legacy page group headers (fullscreen mode) */
|
|
.annotation-page-group {
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
color: var(--text100);
|
|
padding: 0.5rem 0.75rem 0.25rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.03em;
|
|
}
|
|
.annotation-page-group.current-page {
|
|
color: var(--text000);
|
|
}
|
|
|
|
/* Annotation cards */
|
|
.annotation-card {
|
|
display: flex;
|
|
flex-direction: column;
|
|
padding: 0.4rem 0.75rem;
|
|
cursor: pointer;
|
|
transition: background 0.1s ease;
|
|
border-left: 3px solid transparent;
|
|
}
|
|
.annotation-card:hover {
|
|
background: var(--bg100);
|
|
}
|
|
.annotation-card.highlighted {
|
|
background: var(--bg100);
|
|
border-left-color: var(--text100);
|
|
}
|
|
|
|
.annotation-card-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
min-height: 24px;
|
|
}
|
|
|
|
.annotation-card-swatch {
|
|
width: 10px;
|
|
height: 10px;
|
|
border-radius: 2px;
|
|
flex-shrink: 0;
|
|
border: 1px solid rgba(0, 0, 0, 0.15);
|
|
}
|
|
|
|
/* Swatch for baseline items the user cleared — outlined with a cross
|
|
instead of solid fill, so the panel still shows "this existed in the
|
|
file and you removed it" rather than vanishing. */
|
|
.annotation-card-swatch-cleared {
|
|
background: transparent;
|
|
border-color: #4a90d9;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.annotation-card-cleared .annotation-card-type,
|
|
.annotation-card-cleared .annotation-card-preview {
|
|
opacity: 0.55;
|
|
text-decoration: line-through;
|
|
}
|
|
|
|
.annotation-card-type {
|
|
font-size: 0.7rem;
|
|
color: var(--text200);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.03em;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.annotation-card-preview {
|
|
flex: 1;
|
|
min-width: 0;
|
|
font-size: 0.8rem;
|
|
color: var(--text000);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.annotation-card-delete {
|
|
flex-shrink: 0;
|
|
margin-left: auto;
|
|
background: none;
|
|
border: none;
|
|
padding: 2px;
|
|
cursor: pointer;
|
|
color: var(--text200);
|
|
opacity: 0;
|
|
transition: opacity 0.1s ease, color 0.1s ease;
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
.annotation-card:hover .annotation-card-delete {
|
|
opacity: 1;
|
|
}
|
|
.annotation-card-delete:hover {
|
|
color: #d32f2f;
|
|
}
|
|
|
|
.annotation-card-expand {
|
|
flex-shrink: 0;
|
|
font-size: 0.65rem;
|
|
color: var(--text200);
|
|
transition: transform 0.15s ease;
|
|
}
|
|
.annotation-card.expanded .annotation-card-expand {
|
|
transform: rotate(180deg);
|
|
}
|
|
|
|
.annotation-card-content {
|
|
display: none;
|
|
font-size: 0.8rem;
|
|
color: var(--text100);
|
|
padding: 0.25rem 0 0.25rem 14px;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
line-height: 1.4;
|
|
}
|
|
.annotation-card.expanded .annotation-card-content {
|
|
display: block;
|
|
}
|
|
|
|
/* Pulse animation for annotation elements on PDF */
|
|
@keyframes annotation-pulse {
|
|
0% { box-shadow: 0 0 0 0 rgba(255, 165, 0, 0.6); }
|
|
70% { box-shadow: 0 0 0 8px rgba(255, 165, 0, 0); }
|
|
100% { box-shadow: 0 0 0 0 rgba(255, 165, 0, 0); }
|
|
}
|
|
.annotation-pulse {
|
|
animation: annotation-pulse 0.6s ease-out 2;
|
|
}
|
|
|
|
/* Loading Indicator (pie) */
|
|
.loading-indicator {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
margin-left: 0.5rem;
|
|
flex-shrink: 0;
|
|
transition: opacity 0.3s;
|
|
}
|
|
.loading-indicator-bg {
|
|
fill: none;
|
|
stroke: var(--bg200);
|
|
stroke-width: 3;
|
|
}
|
|
.loading-indicator-arc {
|
|
fill: none;
|
|
stroke: var(--text100);
|
|
stroke-width: 3;
|
|
stroke-dasharray: 50.27; /* 2*pi*8 */
|
|
stroke-dashoffset: 50.27;
|
|
transform: rotate(-90deg);
|
|
transform-origin: center;
|
|
transition: stroke-dashoffset 0.3s;
|
|
}
|
|
.loading-indicator.error .loading-indicator-arc {
|
|
stroke: #e74c3c;
|
|
}
|
|
|
|
|
|
/* Compact toolbar on narrow screens */
|
|
@media (max-width: 480px) {
|
|
.toolbar-left {
|
|
display: none;
|
|
}
|
|
.toolbar {
|
|
justify-content: center;
|
|
}
|
|
.nav-btn,
|
|
.zoom-btn,
|
|
.search-btn,
|
|
.fullscreen-btn {
|
|
width: 28px;
|
|
height: 28px;
|
|
font-size: 0.85rem;
|
|
}
|
|
.page-input {
|
|
width: 40px;
|
|
padding: 0.2rem 0.3rem;
|
|
font-size: 0.8rem;
|
|
}
|
|
.total-pages,
|
|
.zoom-level {
|
|
font-size: 0.75rem;
|
|
min-width: 36px;
|
|
}
|
|
.toolbar-center,
|
|
.toolbar-right {
|
|
gap: 0.2rem;
|
|
}
|
|
} |