538 lines
21 KiB
TypeScript
538 lines
21 KiB
TypeScript
/* 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>
|
||
)
|
||
}
|