147 lines
5.2 KiB
TypeScript
147 lines
5.2 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
|
|
const KEY = 'superbill.split'
|
|
|
|
/**
|
|
* Draggable two-pane split, PDF on the left and values on the right.
|
|
*
|
|
* The ratio is remembered: how much page a reviewer needs beside the values
|
|
* depends on their screen and their document, and re-dragging it every session
|
|
* would be a small tax paid hundreds of times.
|
|
*
|
|
* The drag listeners live on window, not the handle, so the pointer can outrun
|
|
* the 6px handle mid-drag without dropping it.
|
|
*
|
|
* Below `md`, side-by-side stops being usable at all — squeezing both panes
|
|
* into a 375px screen leaves the PDF at ~165px wide, unreadable. Below that
|
|
* breakpoint this becomes two full-width tabs instead of a shrinking split;
|
|
* both panes stay mounted (toggling `hidden`/`flex`, not unmounting) so
|
|
* switching tabs never resets the PDF viewer's scroll or zoom.
|
|
*/
|
|
export function SplitPane({
|
|
left,
|
|
right,
|
|
min = 25,
|
|
max = 75,
|
|
ratio,
|
|
}: {
|
|
left: React.ReactNode
|
|
right: React.ReactNode
|
|
min?: number
|
|
max?: number
|
|
/**
|
|
* Force a split for as long as it is set, without overwriting the reviewer's
|
|
* remembered one — fullscreen widens the page and docks the values to the
|
|
* right, and leaving it must restore the ratio they chose, not this one.
|
|
*/
|
|
ratio?: number
|
|
}) {
|
|
const [saved, setSaved] = useState(() => {
|
|
const stored = Number(localStorage.getItem(KEY))
|
|
// The scan is the thing being read; the values are short. The page gets
|
|
// the larger share by default.
|
|
return stored >= min && stored <= max ? stored : 60
|
|
})
|
|
const pct = ratio ?? saved
|
|
const setPct = setSaved
|
|
const [dragging, setDragging] = useState(false)
|
|
const [mobileTab, setMobileTab] = useState<'left' | 'right'>('left')
|
|
const box = useRef<HTMLDivElement>(null)
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem(KEY, String(saved))
|
|
}, [saved])
|
|
|
|
const onMove = useCallback(
|
|
(e: MouseEvent) => {
|
|
const rect = box.current?.getBoundingClientRect()
|
|
if (!rect) return
|
|
const next = ((e.clientX - rect.left) / rect.width) * 100
|
|
setPct(Math.min(max, Math.max(min, next)))
|
|
},
|
|
[min, max],
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (!dragging) return
|
|
const stop = () => setDragging(false)
|
|
window.addEventListener('mousemove', onMove)
|
|
window.addEventListener('mouseup', stop)
|
|
// The PDF pane has its own drag-to-pan handler; without this it would
|
|
// interpret the divider drag as a pan and text would select across both
|
|
// panes on the way.
|
|
document.body.style.userSelect = 'none'
|
|
document.body.style.cursor = 'col-resize'
|
|
return () => {
|
|
window.removeEventListener('mousemove', onMove)
|
|
window.removeEventListener('mouseup', stop)
|
|
document.body.style.userSelect = ''
|
|
document.body.style.cursor = ''
|
|
}
|
|
}, [dragging, onMove])
|
|
|
|
return (
|
|
<div className="flex min-h-0 flex-1 flex-col">
|
|
{/* Tab bar: mobile only. The drag handle is the equivalent affordance on
|
|
desktop, so this disappears at md instead of coexisting with it. */}
|
|
<div className="flex shrink-0 border-b border-line-soft bg-rail md:hidden">
|
|
<button
|
|
onClick={() => setMobileTab('left')}
|
|
aria-current={mobileTab === 'left'}
|
|
className="flex-1 border-b-2 py-2.5 font-sora text-[13px] font-semibold transition-colors"
|
|
style={{
|
|
borderColor: mobileTab === 'left' ? 'var(--accent-1)' : 'transparent',
|
|
color: mobileTab === 'left' ? 'var(--ink)' : 'var(--muted)',
|
|
}}
|
|
>
|
|
Source page
|
|
</button>
|
|
<button
|
|
onClick={() => setMobileTab('right')}
|
|
aria-current={mobileTab === 'right'}
|
|
className="flex-1 border-b-2 py-2.5 font-sora text-[13px] font-semibold transition-colors"
|
|
style={{
|
|
borderColor: mobileTab === 'right' ? 'var(--accent-1)' : 'transparent',
|
|
color: mobileTab === 'right' ? 'var(--ink)' : 'var(--muted)',
|
|
}}
|
|
>
|
|
Fields to review
|
|
</button>
|
|
</div>
|
|
|
|
<div ref={box} className="flex min-h-0 flex-1 flex-col md:flex-row">
|
|
<div
|
|
style={{ '--split-w': `${pct}%` } as React.CSSProperties}
|
|
className={`min-h-0 min-w-0 flex-1 border-b border-line md:w-[var(--split-w)] md:flex-none md:border-b-0 md:border-r md:border-line ${
|
|
mobileTab === 'left' ? 'flex' : 'hidden'
|
|
} md:flex`}
|
|
>
|
|
{left}
|
|
</div>
|
|
|
|
<div
|
|
onMouseDown={() => setDragging(true)}
|
|
onDoubleClick={() => setPct(52)}
|
|
title="Drag to resize — double-click to reset"
|
|
className={`hidden w-1.5 shrink-0 cursor-col-resize transition-colors md:block ${
|
|
dragging ? 'bg-accent-1' : 'bg-line hover:bg-muted'
|
|
}`}
|
|
/>
|
|
|
|
{/* Pointer events off during the drag, so neither pane steals the move. */}
|
|
<div
|
|
style={{
|
|
'--split-w': `${100 - pct}%`,
|
|
pointerEvents: dragging ? 'none' : undefined,
|
|
} as React.CSSProperties}
|
|
className={`min-h-0 min-w-0 flex-1 md:w-[var(--split-w)] md:flex-none ${
|
|
mobileTab === 'right' ? 'flex' : 'hidden'
|
|
} md:flex`}
|
|
>
|
|
{right}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|