refactor(ui): modularize PDF loader and range slider visuals
Move PDF layout scan visualization and range slider styles into dedicated CSS modules, isolating their styles from the global scope. Integrate PdfLayoutScan component into the PDF viewer loader UI for animated parse progress. Refactor progress bars to use a reusable progress-fill class with animated sheen effect. Update range input to use CSS module for precision gauge styling.
This commit is contained in:
parent
7ef18d6900
commit
52ec0ad9b4
8 changed files with 554 additions and 138 deletions
|
|
@ -19,6 +19,7 @@ import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
|
|||
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
|
||||
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
|
||||
import { LoadingSpinner } from '@/components/Spinner';
|
||||
import { PdfLayoutScan } from '@/components/reader/PdfLayoutScan';
|
||||
import { Button, ButtonLink } from '@/components/ui';
|
||||
import {
|
||||
FORCE_REPARSE_CONFIRM_MESSAGE,
|
||||
|
|
@ -279,21 +280,13 @@ export default function PDFViewerPage() {
|
|||
const isMerging = parseProgress?.phase === 'merge';
|
||||
|
||||
let statusText = 'Loading PDF...';
|
||||
let statusSubText = 'Initializing document renderer';
|
||||
if (!isLoading) {
|
||||
if (parseUiState === 'pending') {
|
||||
statusText = 'Preparing PDF layout...';
|
||||
statusSubText = parseProgress?.phase === 'merge'
|
||||
? 'Finalizing stitched block structure'
|
||||
: 'Queueing parser and preparing page extraction';
|
||||
} else if (parseUiState === 'running') {
|
||||
statusText = 'Parsing PDF layout blocks...';
|
||||
statusSubText = parseProgress?.phase === 'merge'
|
||||
? 'Merging cross-page sections'
|
||||
: 'Inferring reading order and text regions';
|
||||
} else if (parseUiState === 'failed') {
|
||||
statusText = 'PDF parsing failed. Retry to continue.';
|
||||
statusSubText = 'The parser could not build a usable layout map';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -305,34 +298,80 @@ export default function PDFViewerPage() {
|
|||
|
||||
return (
|
||||
<div className="h-full w-full bg-surface">
|
||||
<div className={`mx-auto flex h-full items-center px-4 py-6 transition duration-slow ease-standard ${showDetailedParseLoader ? 'max-w-lg' : 'max-w-md'}`}>
|
||||
<div className={`mx-auto flex h-full items-center px-4 py-6 transition duration-slow ease-standard ${showDetailedParseLoader ? 'max-w-sm' : 'max-w-md'}`}>
|
||||
{showDetailedParseLoader ? (
|
||||
<div className="w-full rounded-lg border border-line bg-surface-sunken shadow-elev-1 overflow-hidden">
|
||||
<div className="h-1 bg-[linear-gradient(90deg,var(--accent),transparent_80%)]" />
|
||||
<div className="p-3.5 sm:p-4">
|
||||
<div className="space-y-1.5">
|
||||
<div className="relative w-full overflow-hidden rounded-lg border border-line bg-surface-sunken shadow-elev-2">
|
||||
{/* prism top edge + corner glow for depth */}
|
||||
<div className="prism-divider" />
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -right-16 -top-16 h-40 w-40 rounded-full blur-3xl"
|
||||
style={{ background: 'var(--accent-wash)' }}
|
||||
/>
|
||||
{/* dotted texture spanning the whole loader */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'radial-gradient(color-mix(in srgb, var(--foreground) 14%, transparent) 1px, transparent 1px)',
|
||||
backgroundSize: '12px 12px',
|
||||
opacity: 0.35,
|
||||
WebkitMaskImage: 'radial-gradient(120% 100% at 50% 40%, #000 55%, transparent 100%)',
|
||||
maskImage: 'radial-gradient(120% 100% at 50% 40%, #000 55%, transparent 100%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative p-3.5 sm:p-4">
|
||||
{/* header: status badge + model attribution */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="inline-flex items-center gap-2 rounded-md border border-line bg-surface-solid px-2.5 py-1">
|
||||
<LoadingSpinner className="h-3.5 w-3.5 text-accent" />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.08em] text-soft">PDF Layout Parse</span>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-foreground">{statusText}</p>
|
||||
<div className="inline-flex items-center gap-1.5 rounded-md border border-accent-line bg-accent-wash px-2 py-1">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-accent" />
|
||||
<span className="text-[10px] font-semibold tracking-tight text-accent-strong">PP-DocLayout-V3</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 rounded-lg border border-line bg-surface-solid p-2.5">
|
||||
<div className="mb-1.5 flex items-end justify-between gap-2">
|
||||
<p className="text-[11px] font-semibold text-foreground">
|
||||
{hasMeasuredProgress ? `Page ${pagesParsed} / ${totalPages}` : 'Awaiting first page'}
|
||||
<div className="mt-3 flex flex-col gap-3">
|
||||
{/* animated layout scanner */}
|
||||
<div className="mx-auto w-full max-w-[15rem]">
|
||||
<PdfLayoutScan />
|
||||
</div>
|
||||
|
||||
{/* live status + progress */}
|
||||
<div className="min-w-0">
|
||||
{parseUiState === 'failed' ? (
|
||||
<p className="mb-3 text-sm font-semibold text-foreground">{statusText}</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-end justify-between gap-2">
|
||||
<p className="text-[11px] font-semibold text-foreground tabular-nums">
|
||||
{hasMeasuredProgress ? `Page ${pagesParsed} / ${totalPages}` : 'Awaiting first page'}
|
||||
</p>
|
||||
<p className="text-[10px] font-medium uppercase tracking-[0.06em] text-soft">{stageLabel}</p>
|
||||
</div>
|
||||
<div className="mt-1.5 h-2 w-full overflow-hidden rounded-full bg-surface-solid ring-1 ring-line">
|
||||
<div
|
||||
className="progress-fill h-full rounded-full bg-accent transition-[width] duration-slow ease-standard"
|
||||
style={{ width: `${hasMeasuredProgress ? progressPercent : 6}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1.5 text-[10px] tabular-nums text-soft">
|
||||
{hasMeasuredProgress ? `${Math.round(progressPercent)}% complete` : 'Calibrating layout pass'}
|
||||
</p>
|
||||
<p className="text-[10px] text-soft">{stageLabel}</p>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-full bg-surface-sunken overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent transition duration-slow ease-standard"
|
||||
style={{ width: `${hasMeasuredProgress ? progressPercent : 6}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1.5 text-[10px] text-soft">
|
||||
{hasMeasuredProgress ? `${Math.round(progressPercent)}% complete` : statusSubText}
|
||||
</div>
|
||||
|
||||
{/* attribution footer */}
|
||||
<div className="mt-3 flex items-center gap-2 border-t border-line-soft pt-3">
|
||||
<svg className="h-3.5 w-3.5 shrink-0 text-faint" fill="none" stroke="currentColor" strokeWidth="1.6" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m-6-8h6M5 4h14a1 1 0 011 1v14a1 1 0 01-1 1H5a1 1 0 01-1-1V5a1 1 0 011-1z" />
|
||||
</svg>
|
||||
<p className="text-[10px] leading-snug text-faint">
|
||||
Classifying titles, text, tables, figures, formulas, seals & more with <span className="font-semibold text-soft">PP-DocLayout-V3</span>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -425,115 +425,30 @@ h1, h2, h3, h4, h5, h6 {
|
|||
border-radius: 999px; /* subtle rounding to reinforce taper */
|
||||
}
|
||||
|
||||
/* Range slider — "precision gauge": a hairline rail with ruler notches per step
|
||||
and a slim accent needle instead of a ball. Per-instance values
|
||||
(--range-progress, --range-tick-size, --range-tick-color) come from inline styles. */
|
||||
.range-input {
|
||||
--range-track-h: 2px;
|
||||
--range-needle-h: 1rem;
|
||||
--range-needle-w: 3px;
|
||||
--range-tick-size: 25%; /* segment width; only used when ticks are enabled */
|
||||
--range-tick-color: transparent; /* set to a real color inline for discrete sliders */
|
||||
--range-fill: var(--muted);
|
||||
--range-empty: color-mix(in srgb, var(--offbase) 82%, var(--background));
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 1.25rem;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
/* Generic determinate progress fill with a moving sheen. Apply to the colored
|
||||
fill element (the one whose width tracks progress); its track should clip. */
|
||||
.progress-fill {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.range-input:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
.progress-fill::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
color-mix(in srgb, var(--background) 55%, transparent),
|
||||
transparent
|
||||
);
|
||||
transform: translateX(-100%);
|
||||
animation: progress-sheen 2.2s linear infinite;
|
||||
}
|
||||
.range-input:focus-visible {
|
||||
outline: none;
|
||||
@keyframes progress-sheen {
|
||||
0% { transform: translateX(-100%); }
|
||||
/* sweep across at constant speed, then rest off-screen so the loop reset
|
||||
(from the right edge back to the left) is never visible */
|
||||
65% { transform: translateX(220%); }
|
||||
100% { transform: translateX(220%); }
|
||||
}
|
||||
|
||||
/* Ticks ride above the muted fill / empty rail (first listed background = top). */
|
||||
.range-input::-webkit-slider-runnable-track {
|
||||
height: var(--range-track-h);
|
||||
border-radius: 999px;
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
to right,
|
||||
var(--range-tick-color) 0,
|
||||
var(--range-tick-color) 1.5px,
|
||||
transparent 1.5px,
|
||||
transparent var(--range-tick-size)
|
||||
),
|
||||
linear-gradient(
|
||||
to right,
|
||||
var(--range-fill) 0,
|
||||
var(--range-fill) var(--range-progress),
|
||||
var(--range-empty) var(--range-progress),
|
||||
var(--range-empty) 100%
|
||||
);
|
||||
}
|
||||
.range-input::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: var(--range-needle-w);
|
||||
height: var(--range-needle-h);
|
||||
margin-top: calc((var(--range-track-h) - var(--range-needle-h)) / 2);
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
box-shadow:
|
||||
0 0 0 1px color-mix(in srgb, var(--background) 70%, transparent),
|
||||
0 1px 6px color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
transition: transform 150ms ease, box-shadow 150ms ease;
|
||||
}
|
||||
.range-input:hover::-webkit-slider-thumb {
|
||||
transform: scaleY(1.3);
|
||||
}
|
||||
.range-input:active::-webkit-slider-thumb {
|
||||
transform: scaleY(1.55);
|
||||
}
|
||||
.range-input:focus-visible::-webkit-slider-thumb {
|
||||
box-shadow:
|
||||
0 0 0 4px color-mix(in srgb, var(--accent) 28%, transparent),
|
||||
0 1px 6px color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
}
|
||||
|
||||
/* Firefox: track + native progress fill + matching needle. */
|
||||
.range-input::-moz-range-track {
|
||||
height: var(--range-track-h);
|
||||
border-radius: 999px;
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
to right,
|
||||
var(--range-tick-color) 0,
|
||||
var(--range-tick-color) 1.5px,
|
||||
transparent 1.5px,
|
||||
transparent var(--range-tick-size)
|
||||
),
|
||||
var(--range-empty);
|
||||
}
|
||||
.range-input::-moz-range-progress {
|
||||
height: var(--range-track-h);
|
||||
border-radius: 999px;
|
||||
background: var(--range-fill);
|
||||
}
|
||||
.range-input::-moz-range-thumb {
|
||||
width: var(--range-needle-w);
|
||||
height: var(--range-needle-h);
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
box-shadow:
|
||||
0 0 0 1px color-mix(in srgb, var(--background) 70%, transparent),
|
||||
0 1px 6px color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
transition: transform 150ms ease, box-shadow 150ms ease;
|
||||
}
|
||||
.range-input:hover::-moz-range-thumb {
|
||||
transform: scaleY(1.3);
|
||||
}
|
||||
.range-input:active::-moz-range-thumb {
|
||||
transform: scaleY(1.55);
|
||||
}
|
||||
.range-input:focus-visible::-moz-range-thumb {
|
||||
box-shadow:
|
||||
0 0 0 4px color-mix(in srgb, var(--accent) 28%, transparent),
|
||||
0 1px 6px color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ export function ProgressCard({
|
|||
{/* Progress bar */}
|
||||
<div className="w-full bg-background rounded-full overflow-hidden h-1.5">
|
||||
<div
|
||||
className="h-full bg-accent transition duration-slow ease-standard"
|
||||
className="progress-fill h-full bg-accent transition duration-slow ease-standard"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -155,7 +155,7 @@ export function DexieMigrationModal({
|
|||
<div className="space-y-1">
|
||||
<p className="text-xs text-soft">{status}</p>
|
||||
<div className="h-2 w-full rounded bg-surface-sunken">
|
||||
<div className="h-2 rounded bg-accent" style={{ width: `${Math.max(1, Math.round(progress))}%` }} />
|
||||
<div className="progress-fill h-2 rounded bg-accent" style={{ width: `${Math.max(1, Math.round(progress))}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
202
src/components/reader/PdfLayoutScan.module.css
Normal file
202
src/components/reader/PdfLayoutScan.module.css
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
/* Scanner visualization for the PDF layout-parse loader. Scoped via CSS Module
|
||||
so it stays out of the global stylesheet and works across the helper that
|
||||
renders region content. Used by PdfLayoutScan.tsx. */
|
||||
|
||||
.stage {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.page {
|
||||
position: relative;
|
||||
width: 68%;
|
||||
aspect-ratio: 3 / 4;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--background) 92%, var(--foreground)), var(--background));
|
||||
border: 1px solid var(--line);
|
||||
box-shadow:
|
||||
var(--elev-2),
|
||||
0 0 0 1px color-mix(in srgb, var(--background) 60%, transparent),
|
||||
inset 0 1px 0 color-mix(in srgb, var(--foreground) 6%, transparent);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0,
|
||||
transparent calc(100% / 22 - 1px),
|
||||
color-mix(in srgb, var(--foreground) 6%, transparent) calc(100% / 22 - 1px),
|
||||
color-mix(in srgb, var(--foreground) 6%, transparent) calc(100% / 22)
|
||||
);
|
||||
background-size: 100% calc(100% / 22 * 4);
|
||||
}
|
||||
|
||||
/* ── readout chip (in the stage band, above the page) ─────────────────── */
|
||||
.tagRow {
|
||||
position: absolute;
|
||||
top: 4%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
z-index: 5;
|
||||
padding: 0 4%;
|
||||
}
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
padding: 3px 9px;
|
||||
border-radius: 6px;
|
||||
font-family: var(--font-display), system-ui, sans-serif;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
letter-spacing: 0.01em;
|
||||
white-space: nowrap;
|
||||
color: var(--background);
|
||||
background: var(--accent);
|
||||
box-shadow: 0 2px 10px color-mix(in srgb, var(--accent) 50%, transparent);
|
||||
animation: tagIn 0.34s var(--ease);
|
||||
}
|
||||
@keyframes tagIn {
|
||||
from { opacity: 0; transform: translateY(-4px) scale(0.92); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
/* ── region boxes (one shown at a time, centered) ─────────────────────── */
|
||||
.solos {
|
||||
position: absolute;
|
||||
inset: 8%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.solo {
|
||||
grid-area: 1 / 1; /* stack so regions cross-fade in place */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
border: 1.5px solid var(--accent);
|
||||
background: color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
box-shadow:
|
||||
0 0 0 1px var(--accent-line),
|
||||
0 0 22px color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
padding: 4px 7px;
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
transition: opacity 0.32s var(--ease), transform 0.32s var(--ease);
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
.solo.active {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
/* inner content so each region reads as real document material */
|
||||
.lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 11%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.linesHeading { gap: 22%; }
|
||||
.lines > span {
|
||||
display: block;
|
||||
height: 3px;
|
||||
width: 100%;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--foreground) 26%, transparent);
|
||||
}
|
||||
.linesHeading > span {
|
||||
height: 6px;
|
||||
background: color-mix(in srgb, var(--foreground) 46%, transparent);
|
||||
}
|
||||
|
||||
.glyph {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
stroke: color-mix(in srgb, var(--foreground) 36%, transparent);
|
||||
stroke-width: 2;
|
||||
stroke-linejoin: round;
|
||||
fill: none;
|
||||
}
|
||||
.glyph circle {
|
||||
fill: color-mix(in srgb, var(--foreground) 30%, transparent);
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
.table {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-rows: repeat(4, 1fr);
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: color-mix(in srgb, var(--foreground) 16%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 16%, transparent);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.table > span {
|
||||
background: color-mix(in srgb, var(--background) 82%, var(--foreground));
|
||||
}
|
||||
.table > span:nth-child(-n + 4) {
|
||||
background: color-mix(in srgb, var(--foreground) 22%, transparent);
|
||||
}
|
||||
|
||||
/* ── scan beam ────────────────────────────────────────────────────────── */
|
||||
.beam {
|
||||
position: absolute;
|
||||
left: -4%;
|
||||
right: -4%;
|
||||
height: 2px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent), transparent);
|
||||
box-shadow: 0 0 14px 2px color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
animation: beam 1.05s var(--ease) infinite;
|
||||
will-change: top, opacity;
|
||||
}
|
||||
.beam::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 1px;
|
||||
height: 30px;
|
||||
background: linear-gradient(to top, color-mix(in srgb, var(--accent) 18%, transparent), transparent);
|
||||
}
|
||||
@keyframes beam {
|
||||
0% { top: -4%; opacity: 0; }
|
||||
8% { opacity: 1; }
|
||||
90% { opacity: 1; }
|
||||
100% { top: 104%; opacity: 0; }
|
||||
}
|
||||
|
||||
/* camera-style corner reticles */
|
||||
.corner {
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: 1.5px solid var(--accent-line);
|
||||
pointer-events: none;
|
||||
}
|
||||
.cornerTl { top: 5px; left: 5px; border-right: 0; border-bottom: 0; border-top-left-radius: 3px; }
|
||||
.cornerTr { top: 5px; right: 5px; border-left: 0; border-bottom: 0; border-top-right-radius: 3px; }
|
||||
.cornerBl { bottom: 5px; left: 5px; border-right: 0; border-top: 0; border-bottom-left-radius: 3px; }
|
||||
.cornerBr { bottom: 5px; right: 5px; border-left: 0; border-top: 0; border-bottom-right-radius: 3px; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.beam { animation: none; opacity: 0; }
|
||||
.tag { animation: none; }
|
||||
.solo { transition: none; }
|
||||
}
|
||||
144
src/components/reader/PdfLayoutScan.tsx
Normal file
144
src/components/reader/PdfLayoutScan.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import styles from './PdfLayoutScan.module.css';
|
||||
|
||||
/**
|
||||
* PdfLayoutScan — ambient visualization of PP-DocLayout-V3 at work.
|
||||
*
|
||||
* A miniature document page sits directly on the loader's dotted background
|
||||
* while a scan beam sweeps across it. One layout region is "detected" at a time
|
||||
* — a box rendered with one
|
||||
* of a few simple shapes — while a readout chip above the page names its class.
|
||||
* It cycles through the complete set of region classes PP-DocLayout-V3 emits
|
||||
* (see PARSED_PDF_BLOCK_KINDS in types/parsed-pdf). Purely decorative; honours
|
||||
* prefers-reduced-motion by freezing on a single region. Styles live in the
|
||||
* adjacent CSS module so they stay out of the global stylesheet.
|
||||
*/
|
||||
|
||||
// Only a handful of real shapes — most regions are just text lines.
|
||||
type BlockShape = 'heading' | 'text' | 'small' | 'image' | 'table';
|
||||
|
||||
interface ScanBlock {
|
||||
label: string;
|
||||
shape: BlockShape;
|
||||
}
|
||||
|
||||
// One consistent box size per shape, so every region renders the same way and
|
||||
// nothing gets clipped — only the label changes.
|
||||
const SHAPE_SIZE: Record<BlockShape, { width: number; height: number }> = {
|
||||
heading: { width: 82, height: 22 },
|
||||
text: { width: 88, height: 66 },
|
||||
small: { width: 72, height: 24 },
|
||||
image: { width: 80, height: 68 },
|
||||
table: { width: 88, height: 64 },
|
||||
};
|
||||
|
||||
// Every PP-DocLayout-V3 class, in document-flow order, mapped to a simple shape.
|
||||
const SCAN_BLOCKS: ScanBlock[] = [
|
||||
{ label: 'Header', shape: 'small' },
|
||||
{ label: 'Doc title', shape: 'heading' },
|
||||
{ label: 'Abstract', shape: 'text' },
|
||||
{ label: 'Paragraph title', shape: 'heading' },
|
||||
{ label: 'Text', shape: 'text' },
|
||||
{ label: 'Formula', shape: 'text' },
|
||||
{ label: 'Formula number', shape: 'small' },
|
||||
{ label: 'Figure title', shape: 'small' },
|
||||
{ label: 'Image', shape: 'image' },
|
||||
{ label: 'Chart', shape: 'image' },
|
||||
{ label: 'Table', shape: 'table' },
|
||||
{ label: 'Algorithm', shape: 'text' },
|
||||
{ label: 'Content', shape: 'text' },
|
||||
{ label: 'Aside text', shape: 'text' },
|
||||
{ label: 'Number', shape: 'small' },
|
||||
{ label: 'Reference', shape: 'small' },
|
||||
{ label: 'Reference content', shape: 'text' },
|
||||
{ label: 'Footnote', shape: 'small' },
|
||||
{ label: 'Vision footnote', shape: 'small' },
|
||||
{ label: 'Seal', shape: 'image' },
|
||||
{ label: 'Footer', shape: 'small' },
|
||||
];
|
||||
|
||||
const STEP_MS = 1050;
|
||||
|
||||
const cx = (...parts: Array<string | false | undefined>) => parts.filter(Boolean).join(' ');
|
||||
|
||||
function BlockContent({ shape }: { shape: BlockShape }) {
|
||||
if (shape === 'image') {
|
||||
return (
|
||||
<svg className={styles.glyph} viewBox="0 0 48 32" fill="none" aria-hidden>
|
||||
<circle cx="13" cy="11" r="4" />
|
||||
<path d="M3 28l11-11 7 7 9-10 14 14z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (shape === 'table') {
|
||||
return (
|
||||
<div className={styles.table} aria-hidden>
|
||||
{Array.from({ length: 16 }).map((_, i) => (
|
||||
<span key={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const isHeading = shape === 'heading';
|
||||
const lines = isHeading ? 2 : shape === 'small' ? 2 : 5;
|
||||
return (
|
||||
<div className={cx(styles.lines, isHeading && styles.linesHeading)} aria-hidden>
|
||||
{Array.from({ length: lines }).map((_, i) => (
|
||||
<span key={i} style={i === lines - 1 ? { width: '58%' } : undefined} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PdfLayoutScan() {
|
||||
const [active, setActive] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
||||
if (reduced) return; // freeze on the first region for reduced-motion users
|
||||
const id = window.setInterval(() => {
|
||||
setActive((i) => (i + 1) % SCAN_BLOCKS.length);
|
||||
}, STEP_MS);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.stage} aria-hidden>
|
||||
{/* readout chip: floats in the stage band above the page so long class
|
||||
names are never clipped by the page's rounded overflow */}
|
||||
<div className={styles.tagRow}>
|
||||
<span key={active} className={styles.tag}>
|
||||
{SCAN_BLOCKS[active].label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.page}>
|
||||
<div className={styles.grid} />
|
||||
|
||||
<div className={styles.solos}>
|
||||
{SCAN_BLOCKS.map((block, i) => {
|
||||
const size = SHAPE_SIZE[block.shape];
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={cx(styles.solo, i === active && styles.active)}
|
||||
style={{ width: `${size.width}%`, height: `${size.height}%` }}
|
||||
>
|
||||
<BlockContent shape={block.shape} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className={styles.beam} />
|
||||
<span className={cx(styles.corner, styles.cornerTl)} />
|
||||
<span className={cx(styles.corner, styles.cornerTr)} />
|
||||
<span className={cx(styles.corner, styles.cornerBl)} />
|
||||
<span className={cx(styles.corner, styles.cornerBr)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
src/components/ui/range.module.css
Normal file
115
src/components/ui/range.module.css
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
/* Range slider — "precision gauge": a hairline rail with ruler notches per step
|
||||
and a slim accent needle instead of a ball. Scoped via CSS Module so it stays
|
||||
out of the global stylesheet. Per-instance values (--range-progress,
|
||||
--range-tick-size, --range-tick-color) come from inline styles set in
|
||||
range.tsx. Used by RangeInput. */
|
||||
|
||||
.range {
|
||||
--range-track-h: 2px;
|
||||
--range-needle-h: 1rem;
|
||||
--range-needle-w: 3px;
|
||||
--range-tick-size: 25%; /* segment width; only used when ticks are enabled */
|
||||
--range-tick-color: transparent; /* set to a real color inline for discrete sliders */
|
||||
--range-fill: var(--muted);
|
||||
--range-empty: color-mix(in srgb, var(--offbase) 82%, var(--background));
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 1.25rem;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
.range:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.range:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Ticks ride above the muted fill / empty rail (first listed background = top). */
|
||||
.range::-webkit-slider-runnable-track {
|
||||
height: var(--range-track-h);
|
||||
border-radius: 999px;
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
to right,
|
||||
var(--range-tick-color) 0,
|
||||
var(--range-tick-color) 1.5px,
|
||||
transparent 1.5px,
|
||||
transparent var(--range-tick-size)
|
||||
),
|
||||
linear-gradient(
|
||||
to right,
|
||||
var(--range-fill) 0,
|
||||
var(--range-fill) var(--range-progress),
|
||||
var(--range-empty) var(--range-progress),
|
||||
var(--range-empty) 100%
|
||||
);
|
||||
}
|
||||
.range::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: var(--range-needle-w);
|
||||
height: var(--range-needle-h);
|
||||
margin-top: calc((var(--range-track-h) - var(--range-needle-h)) / 2);
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
box-shadow:
|
||||
0 0 0 1px color-mix(in srgb, var(--background) 70%, transparent),
|
||||
0 1px 6px color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
transition: transform 150ms ease, box-shadow 150ms ease;
|
||||
}
|
||||
.range:hover::-webkit-slider-thumb {
|
||||
transform: scaleY(1.3);
|
||||
}
|
||||
.range:active::-webkit-slider-thumb {
|
||||
transform: scaleY(1.55);
|
||||
}
|
||||
.range:focus-visible::-webkit-slider-thumb {
|
||||
box-shadow:
|
||||
0 0 0 4px color-mix(in srgb, var(--accent) 28%, transparent),
|
||||
0 1px 6px color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
}
|
||||
|
||||
/* Firefox: track + native progress fill + matching needle. */
|
||||
.range::-moz-range-track {
|
||||
height: var(--range-track-h);
|
||||
border-radius: 999px;
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
to right,
|
||||
var(--range-tick-color) 0,
|
||||
var(--range-tick-color) 1.5px,
|
||||
transparent 1.5px,
|
||||
transparent var(--range-tick-size)
|
||||
),
|
||||
var(--range-empty);
|
||||
}
|
||||
.range::-moz-range-progress {
|
||||
height: var(--range-track-h);
|
||||
border-radius: 999px;
|
||||
background: var(--range-fill);
|
||||
}
|
||||
.range::-moz-range-thumb {
|
||||
width: var(--range-needle-w);
|
||||
height: var(--range-needle-h);
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
box-shadow:
|
||||
0 0 0 1px color-mix(in srgb, var(--background) 70%, transparent),
|
||||
0 1px 6px color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
transition: transform 150ms ease, box-shadow 150ms ease;
|
||||
}
|
||||
.range:hover::-moz-range-thumb {
|
||||
transform: scaleY(1.3);
|
||||
}
|
||||
.range:active::-moz-range-thumb {
|
||||
transform: scaleY(1.55);
|
||||
}
|
||||
.range:focus-visible::-moz-range-thumb {
|
||||
box-shadow:
|
||||
0 0 0 4px color-mix(in srgb, var(--accent) 28%, transparent),
|
||||
0 1px 6px color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import type { CSSProperties, InputHTMLAttributes } from 'react';
|
||||
import { cn } from './cn';
|
||||
import styles from './range.module.css';
|
||||
|
||||
type RangeStyle = CSSProperties & {
|
||||
'--range-progress'?: string;
|
||||
|
|
@ -55,5 +56,5 @@ export function RangeInput({
|
|||
: {}),
|
||||
};
|
||||
|
||||
return <input type="range" className={cn('range-input', className)} style={rangeStyle} {...props} />;
|
||||
return <input type="range" className={cn(styles.range, className)} style={rangeStyle} {...props} />;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue