Files
Rpa_Skills/skills/rpa-dashboard-ai/templates/reference/web/components/PdfPane.tsx
T

538 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* EXAMPLE ONLY: an extract from the Superbill Review prototype, kept as reference for porting. ECW/eClinicalWorks, Tebra, 'Example Client', SPB_* tables and patient/ticket wording are that project's examples. Don't copy them into a new project. */
import { useCallback, useEffect, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { api } from '../api'
import { BAND_VAR } from '../bands'
import type { ReviewActions } from './ReviewPane'
/**
* The source page, as pixels the page owns.
*
* This used to be the browser's own PDF plugin in an <iframe>. That viewer
* cannot be driven from the page: zoom, scroll and pan all live inside it, so
* pinch-to-zoom and drag-to-pan were impossible and the only control was a
* `#zoom=` fragment that forced a full reload on every step. Here the page is
* a PNG rendered by PyMuPDF on the server (already a dependency of the
* extractor, so no new package and no bundled renderer), and zoom/pan are a
* single CSS transform — continuous, immediate, and reversible.
*
* Verifying a handwritten code means getting close to it, so this is not a
* nicety: at 100% these scans are barely readable.
*/
const MIN_ZOOM = 0.25
const MAX_ZOOM = 8
export function PdfPane({
jobId,
page,
onPickPage,
document: docName,
fullscreen,
onToggleFullscreen,
onExit,
reviewActions,
}: {
jobId: string
page: number | null
onPickPage: (page: number) => void
document: string
fullscreen: boolean
onToggleFullscreen: () => void
onExit: () => void
/** The open patient's unsaved-edit state, so Save/Discard here act on the
* exact same drafts as the review list — one save action, not two. */
reviewActions?: ReviewActions | null
}) {
const [zoom, setZoom] = useState(1)
const [box, setBox] = useState({ w: 0, h: 0 })
// Aspect ratio of the rendered page, learned from the image itself. Pages in
// this set are not all the same shape, so it cannot be a constant.
const [aspect, setAspect] = useState(0)
// A callback ref, not a plain one. Observing `ref.current` inside a
// mount-only effect attached the ResizeObserver to whichever node existed
// first; when React later swapped that node the observer kept watching the
// detached original, which never resizes. The measured width then froze at
// its first value — the page stayed sized for a 344px pane inside a 462px
// one, leaving a dead strip down both sides. Keying the effect to the
// element itself re-attaches the observer whenever the node changes.
const viewportRef = useRef<HTMLDivElement | null>(null)
const [viewportEl, setViewportEl] = useState<HTMLDivElement | null>(null)
const setViewport = useCallback((el: HTMLDivElement | null) => {
viewportRef.current = el
setViewportEl(el)
}, [])
useEffect(() => {
if (!viewportEl) return
const measure = () =>
setBox({ w: viewportEl.clientWidth, h: viewportEl.clientHeight })
measure()
const ro = new ResizeObserver(measure)
ro.observe(viewportEl)
// The observer covers pane resizes; this covers the window itself, which
// some browsers report only here when the element's box is unchanged in
// the same frame.
window.addEventListener('resize', measure)
return () => {
ro.disconnect()
window.removeEventListener('resize', measure)
}
}, [viewportEl])
// The page at 100% fills the pane's WIDTH, and scrolls vertically for the
// rest. Fitting the whole page into the pane's height instead made a
// portrait scan only ~380px wide in a ~680px pane — the page came out small
// with a wide dead strip beside it, and the handwriting this product exists
// to read was too small to read. A document viewer's useful default is
// fit-width; the height is what scrolling is for.
const PAD = 32
const fit = (() => {
if (!aspect || !box.w) return { w: 0, h: 0 }
const w = Math.max(0, box.w - PAD)
return { w, h: w / aspect }
})()
const scaledW = fit.w * zoom
const scaledH = fit.h * zoom
const info = useQuery({
queryKey: ['pdf-info', jobId],
queryFn: () => api.pdfInfo(jobId),
retry: false,
})
const pages = info.data?.pages ?? 0
const current = Math.min(Math.max(page ?? 1, 1), Math.max(pages, 1))
// Render at a higher DPI as the reviewer zooms in, so magnification reaches
// real detail instead of enlarging the same soft pixels. Stepped, not
// continuous — every distinct value is a fresh request, and a smooth ramp
// would refetch the page on every pinch frame.
const dpi = zoom > 3 ? 300 : zoom > 1.5 ? 220 : 150
const src = api.pageImage(jobId, current, dpi)
const reset = useCallback(() => {
setZoom(1)
const el = viewportRef.current
if (el) el.scrollTo({ top: 0, left: 0 })
}, [])
// A new page starts at the top; carrying the previous page's scroll position
// over lands the reviewer somewhere arbitrary on a differently-sized page.
useEffect(() => {
reset()
}, [current, reset])
/**
* Zoom about a fixed point, so the pixel under the cursor stays under it.
*
* The page is laid out at its real size inside a scrolling box rather than
* being CSS-transformed inside a clipped one. That is what makes the mouse
* wheel, the scrollbar, Page Up/Down and keyboard scrolling all work on it
* for free — with a transform there is no scrollable overflow for any of
* them to act on, so the only way to move the page was to drag it, and a
* reviewer looking for the bottom of a page had nothing to grab.
*/
const zoomAt = useCallback((factor: number, clientX?: number, clientY?: number) => {
const el = viewportRef.current
setZoom((z) => {
const next = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z * factor))
if (el) {
const box = el.getBoundingClientRect()
// Where in the CONTENT the anchor currently sits, then put that same
// content point back under the cursor at the new scale.
const ax = (clientX ?? box.left + box.width / 2) - box.left
const ay = (clientY ?? box.top + box.height / 2) - box.top
const cx = (el.scrollLeft + ax) / z
const cy = (el.scrollTop + ay) / z
requestAnimationFrame(() => {
el.scrollLeft = cx * next - ax
el.scrollTop = cy * next - ay
})
}
return next
})
}, [])
// Ctrl/⌘+wheel and trackpad pinch zoom; a plain wheel is left alone so it
// scrolls the page normally. A browser reports a trackpad pinch as a wheel
// event with ctrlKey set — the same gesture that would otherwise zoom the
// whole browser — so this must be a non-passive listener that can
// preventDefault(), which React's passive onWheel cannot do.
useEffect(() => {
const el = viewportRef.current
if (!el) return
const onWheel = (e: WheelEvent) => {
if (!e.ctrlKey && !e.metaKey) return
e.preventDefault()
zoomAt(Math.exp(-e.deltaY * 0.002), e.clientX, e.clientY)
}
el.addEventListener('wheel', onWheel, { passive: false })
return () => el.removeEventListener('wheel', onWheel)
}, [zoomAt])
// Drag to pan, and two-finger pinch on a touchscreen. Panning now moves the
// scroll position rather than a transform, so it stays in sync with the
// scrollbar and with wheel scrolling instead of fighting them.
const pointers = useRef(new Map<number, { x: number; y: number }>())
const pinchStart = useRef<{ dist: number; zoom: number } | null>(null)
const dragFrom = useRef<{ x: number; y: number; left: number; top: number } | null>(null)
const onPointerDown = (e: React.PointerEvent) => {
// A drag starting on the scrollbar itself must stay the browser's job.
if (e.button !== 0 && e.pointerType === 'mouse') return
try {
;(e.currentTarget as Element).setPointerCapture?.(e.pointerId)
} catch {
/* drag still works, it just stops at the pane edge */
}
pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY })
const el = viewportRef.current
if (pointers.current.size === 2) {
const [a, b] = [...pointers.current.values()]
pinchStart.current = { dist: Math.hypot(a.x - b.x, a.y - b.y), zoom }
dragFrom.current = null
} else if (el) {
dragFrom.current = {
x: e.clientX,
y: e.clientY,
left: el.scrollLeft,
top: el.scrollTop,
}
}
}
const onPointerMove = (e: React.PointerEvent) => {
if (!pointers.current.has(e.pointerId)) return
pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY })
const el = viewportRef.current
if (!el) return
if (pointers.current.size === 2 && pinchStart.current) {
const [a, b] = [...pointers.current.values()]
const dist = Math.hypot(a.x - b.x, a.y - b.y)
const target = pinchStart.current.zoom * (dist / pinchStart.current.dist)
setZoom(Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, target)))
return
}
if (dragFrom.current) {
el.scrollLeft = dragFrom.current.left - (e.clientX - dragFrom.current.x)
el.scrollTop = dragFrom.current.top - (e.clientY - dragFrom.current.y)
}
}
const endPointer = (e: React.PointerEvent) => {
pointers.current.delete(e.pointerId)
if (pointers.current.size < 2) pinchStart.current = null
if (pointers.current.size === 0) dragFrom.current = null
}
// Prints the actual source PDF at full quality via the browser's own PDF
// renderer, not the on-screen PNG — a reviewer printing for a paper trail
// wants what came off the scanner, not a screenshot of whatever zoom level
// they happened to be looking at. A hidden iframe is the standard way to
// trigger a browser print for a document the page did not itself render.
const printPdf = useCallback(() => {
const frame = window.document.createElement('iframe')
frame.style.position = 'fixed'
frame.style.width = '0'
frame.style.height = '0'
frame.style.border = '0'
frame.src = `/api/runs/${jobId}/pdf`
frame.onload = () => {
try {
frame.contentWindow?.focus()
frame.contentWindow?.print()
} finally {
// The print dialog is modal in most browsers, but not guaranteed to
// be, so the frame is cleaned up on a delay rather than immediately.
setTimeout(() => frame.remove(), 60_000)
}
}
window.document.body.appendChild(frame)
}, [jobId])
if (info.isError) {
return (
<div className="flex h-full items-center justify-center bg-surface-2 p-6 text-center">
<p className="max-w-xs text-xs text-muted">
No source PDF on disk for this run, so there is no page to check the
values against.
</p>
</div>
)
}
return (
// min-w-0 / overflow-hidden all the way down: a flex item's default
// min-width is auto, i.e. "never smaller than my content". With a
// nowrap page at 800% that content is thousands of pixels wide, so
// without these the pane grew to fit the zoomed page and pushed the whole
// app into horizontal overflow instead of scrolling inside itself.
//
// w-full alongside them: this element is itself a flex item, and min-w-0
// freed it to shrink to its content — so it settled at 510px inside an
// 891px pane and the page sat in the middle of a dead strip. min-w-0 says
// "you may shrink"; w-full says "but fill what you are given".
<div className="flex h-full min-h-0 w-full min-w-0 flex-col overflow-hidden bg-surface-2">
<div className="sb-rv-toolbar shrink-0">
{fullscreen && (
<button onClick={onExit} className="sb-btn sb-btn-quiet sb-btn-sm">
← Back
</button>
)}
<span className="mr-auto min-w-0 flex-1 truncate text-[12px] font-medium text-ink-2" title={docName}>
{docName}
</span>
{pages > 1 && (
<span className="flex shrink-0 items-center gap-1.5">
<Btn label="Previous page" onClick={() => onPickPage(Math.max(1, current - 1))} disabled={current <= 1}>
‹
</Btn>
<span className="tnum w-16 text-center text-[12px] text-muted">
{current} / {pages}
</span>
<Btn label="Next page" onClick={() => onPickPage(Math.min(pages, current + 1))} disabled={current >= pages}>
›
</Btn>
</span>
)}
<span className="flex shrink-0 items-center gap-1.5">
<Btn label="Zoom out" onClick={() => zoomAt(1 / 1.25)}>−</Btn>
<span className="tnum w-12 text-center text-[12px] text-muted">
{Math.round(zoom * 100)}%
</span>
<Btn label="Zoom in" onClick={() => zoomAt(1.25)}>+</Btn>
<Btn label="Reset zoom and position" onClick={reset} wide>Fit</Btn>
<Btn
label={fullscreen ? 'Leave fullscreen' : 'Fill the screen with this page'}
onClick={onToggleFullscreen}
>
{fullscreen ? '⤡' : '⤢'}
</Btn>
<a
href={`/api/runs/${jobId}/pdf`}
target="_blank"
rel="noreferrer"
aria-label="Open the PDF in a separate tab"
title="Open in a separate tab"
className="sb-icon-btn inline-flex items-center justify-center no-underline"
>
↗
</a>
</span>
</div>
<div className="flex min-h-0 min-w-0 flex-1">
<div
ref={setViewport}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endPointer}
onPointerCancel={endPointer}
onDoubleClick={(e) => zoomAt(zoom >= MAX_ZOOM ? MIN_ZOOM / zoom : 1.8, e.clientX, e.clientY)}
// pan-x pan-y (not none) keeps one-finger scrolling native while the
// two-pointer pinch is still delivered here rather than zooming the
// whole browser.
// whitespace-nowrap stops the inline-block page being wrapped onto
// its own line box at high zoom.
className="relative min-h-0 min-w-0 flex-1 overflow-auto overscroll-contain whitespace-nowrap bg-surface-2"
style={{
touchAction: 'pan-x pan-y',
cursor: zoom > 1 ? 'grab' : 'default',
// Centre the page only while it fits. Centring an overflowing
// inline box pushes its left edge to a negative offset, which no
// scrollbar can reach — at 800% the left third of the page became
// permanently invisible.
textAlign: scaledW + PAD > box.w ? 'left' : 'center',
}}
>
{/* The image IS the scrollable content — no spacer, no transform.
Earlier attempts sized a wrapper and scaled the image inside it;
in this subtree the wrapper's inline width was simply not honoured
(it reported 672px and laid out at 360px, and the transform came
back as an identity matrix), which left the page drawn at fit size
inside a much larger reserved area — the large empty gap beside a
zoomed page. Setting the <img>'s own width/height as an
inline-block inside a nowrap scroller is the plain version: the
element that shows the page is also the element that defines the
overflow, so the two cannot disagree. */}
<img
// Remounted on every zoom step, deliberately. Updating this node in
// place did not resize it: its inline style reported the new width
// while it kept laying out at the old one, and writing
// element.style directly did not move it either — only a freshly
// created node ever took the size. Re-keying sidesteps that
// entirely, and costs nothing visible because the src is unchanged
// for a given (page, dpi) so the bitmap is served from cache.
key={`p${current}-d${dpi}-z${Math.round(zoom * 1000)}`}
src={src}
alt={`Page ${current} of ${docName}`}
draggable={false}
onLoad={(e) => {
const el = e.currentTarget
if (el.naturalWidth && el.naturalHeight) {
setAspect(el.naturalWidth / el.naturalHeight)
}
}}
className="m-4 inline-block max-w-none select-none rounded-md bg-surface align-top shadow-2xl ring-1 ring-line"
style={{ width: scaledW || undefined, height: scaledH || undefined }}
/>
</div>
<SideRail reviewActions={reviewActions} onPrint={printPdf} />
</div>
<p className="shrink-0 border-t border-line-soft bg-rail px-3 py-1.5 text-[11px] text-muted">
Scroll or drag to move · pinch or Ctrl+scroll to zoom · double-click to zoom in
</p>
</div>
)
}
/**
* The common controls, always in view regardless of scroll or zoom: Save the
* open patient's edits, Print the source PDF, and the score legend. Save here
* drives the exact same state as the bar in ReviewPane — there is one save
* action, reachable from whichever pane the reviewer's eyes are already on.
*/
function SideRail({
reviewActions,
onPrint,
}: {
reviewActions?: ReviewActions | null
onPrint: () => void
}) {
const dirty = (reviewActions?.dirtyCount ?? 0) > 0
return (
<div className="flex w-12 shrink-0 flex-col items-center gap-2 border-l border-line bg-rail py-3">
<RailButton
label={
dirty
? `Save ${reviewActions!.dirtyCount} unsaved change(s) for ${reviewActions?.patientName ?? 'this patient'}`
: 'No unsaved changes'
}
onClick={() => reviewActions?.onSave()}
disabled={!dirty || reviewActions?.saving}
tone={dirty ? 'warn' : 'neutral'}
>
<span aria-hidden="true">💾</span>
{dirty && (
<span
className="tnum absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full text-[9px] font-bold"
style={{ background: 'var(--brand-grad)', color: 'var(--accent-fg)' }}
>
{reviewActions!.dirtyCount}
</span>
)}
</RailButton>
{dirty && (
<RailButton
label={`Discard unsaved changes for ${reviewActions?.patientName ?? 'this patient'}`}
onClick={() => reviewActions?.onDiscard()}
tone="neutral"
>
<span aria-hidden="true">↺</span>
</RailButton>
)}
<div className="my-1 h-px w-6 bg-line" aria-hidden="true" />
<RailButton label="Print the source PDF" onClick={onPrint} tone="neutral">
<span aria-hidden="true">🖨</span>
</RailButton>
<div className="my-1 h-px w-6 bg-line" aria-hidden="true" />
{/* The score legend, vertical to fit this rail — same meaning as the
horizontal one in the document list (StatsBar's BandLegend), just
laid out for a narrow column instead of a wide header. */}
<div
className="flex flex-col items-center gap-1.5"
title="Colour reflects print clarity, not correctness. A cleanly printed value can still be misread."
>
<ScoreDot band="green" label="≥0.95 clean" />
<ScoreDot band="amber" label="0.90–0.95 sample" />
<ScoreDot band="red" label="<0.90 review" />
</div>
</div>
)
}
function RailButton({
children,
label,
onClick,
disabled,
tone = 'neutral',
}: {
children: React.ReactNode
label: string
onClick: () => void
disabled?: boolean
tone?: 'neutral' | 'warn'
}) {
return (
<button
onClick={onClick}
disabled={disabled}
aria-label={label}
title={label}
className="sb-icon-btn relative h-8 w-8 shrink-0 text-sm"
style={
tone === 'warn'
? {
borderColor: 'color-mix(in srgb, var(--verify) 45%, transparent)',
background: 'color-mix(in srgb, var(--verify) 14%, var(--surface))',
}
: undefined
}
>
{children}
</button>
)
}
function ScoreDot({ band, label }: { band: 'green' | 'amber' | 'red'; label: string }) {
return (
<span
className="sb-swatch shrink-0"
title={label}
style={{ backgroundColor: BAND_VAR[band], marginRight: 0 }}
/>
)
}
function Btn({
children,
label,
onClick,
disabled,
wide,
}: {
children: React.ReactNode
label: string
onClick: () => void
disabled?: boolean
/** Word labels ("Fit") need more than the 26px icon square. */
wide?: boolean
}) {
return (
<button
onClick={onClick}
disabled={disabled}
// The visible glyph is a symbol, not a word — "−" reads as "minus sign"
// to a screen reader, so the accessible name has to be written out.
aria-label={label}
title={label}
className={`sb-icon-btn ${wide ? 'w-auto px-2' : ''}`}
>
{children}
</button>
)
}