feat(hq-web): LivePreview — debounced double-buffer iframe + preview fetchers
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>feat/client-detail-redesign
parent
52e8a03c81
commit
c2eb7acba0
@ -0,0 +1,111 @@
|
||||
import { type CSSProperties, useEffect, useRef, useState } from 'react'
|
||||
import type { DocTotals } from '../api'
|
||||
|
||||
export interface PreviewFetchResult { html: string; totals?: DocTotals; warnings?: string[] }
|
||||
|
||||
interface LivePreviewProps {
|
||||
/** Serialized request body — identity for skip-if-unchanged. */
|
||||
body: string
|
||||
/** Bump to force an immediate (0ms) refire; body-only changes debounce 400ms. */
|
||||
commitNonce: number
|
||||
/** Performs the POST; must honour the AbortSignal. */
|
||||
fetchPreview: (body: string, signal: AbortSignal) => Promise<PreviewFetchResult>
|
||||
/** After each successful fetch — totals/warnings for the parent's strip. */
|
||||
onResult?: (r: PreviewFetchResult) => void
|
||||
/** In-flight/error changes — parent dims totals + shows the paused state. */
|
||||
onStatus?: (s: { busy: boolean; error: string | null }) => void
|
||||
}
|
||||
|
||||
const DEBOUNCE_MS = 400
|
||||
|
||||
/**
|
||||
* Live preview pane (composer + company + quote-content). Debounced/immediate
|
||||
* POST → double-buffered iframe of the server's real documentHtml, so the paper
|
||||
* on screen is the same HTML puppeteer rasterizes for the PDF. Never blanks: the
|
||||
* previous paper stays under a 2px shimmer, the next render is written into a
|
||||
* hidden stacked iframe, then cross-faded in with scrollTop kept. Modest by
|
||||
* design — a composer helper, not a general widget (YAGNI).
|
||||
*/
|
||||
export function LivePreview({ body, commitNonce, fetchPreview, onResult, onStatus }: LivePreviewProps) {
|
||||
const aRef = useRef<HTMLIFrameElement>(null)
|
||||
const bRef = useRef<HTMLIFrameElement>(null)
|
||||
const [aShown, setAShown] = useState(true) // which buffer is visible
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const seq = useRef(0) // sequence guard: only the latest response wins
|
||||
const lastBody = useRef<string | null>(null) // skip-if-unchanged
|
||||
const prevCommit = useRef(commitNonce)
|
||||
const shownRef = useRef(aShown)
|
||||
shownRef.current = aShown
|
||||
|
||||
useEffect(() => { onStatus?.({ busy, error }) }, [busy, error, onStatus])
|
||||
|
||||
useEffect(() => {
|
||||
const immediate = commitNonce !== prevCommit.current
|
||||
prevCommit.current = commitNonce
|
||||
if (body === lastBody.current) return // identical body — no POST (design §3)
|
||||
|
||||
const controller = new AbortController()
|
||||
const mySeq = ++seq.current
|
||||
const timer = setTimeout(() => {
|
||||
setBusy(true)
|
||||
fetchPreview(body, controller.signal)
|
||||
.then((r) => {
|
||||
if (mySeq !== seq.current) return // superseded
|
||||
lastBody.current = body
|
||||
setError(null)
|
||||
swapIn(r.html)
|
||||
onResult?.(r)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (controller.signal.aborted || mySeq !== seq.current) return
|
||||
setError(e instanceof Error ? e.message : String(e)) // keep the last good paper
|
||||
})
|
||||
.finally(() => { if (mySeq === seq.current) setBusy(false) })
|
||||
}, immediate ? 0 : DEBOUNCE_MS)
|
||||
|
||||
return () => { controller.abort(); clearTimeout(timer) }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [body, commitNonce])
|
||||
|
||||
/** Write html into the hidden buffer; on load restore scroll then cross-fade. */
|
||||
function swapIn(html: string): void {
|
||||
const a = aRef.current, b = bRef.current
|
||||
if (a === null || b === null) return
|
||||
const visible = shownRef.current ? a : b
|
||||
const hidden = shownRef.current ? b : a
|
||||
// The frames are sandboxed WITHOUT allow-scripts (scripts never run), but WITH
|
||||
// allow-same-origin so the double buffer can read/restore scrollTop. Guard the
|
||||
// access anyway — a swap must never be broken by a cross-origin read throwing.
|
||||
let keepScroll = 0
|
||||
try { keepScroll = visible.contentWindow?.scrollY ?? 0 } catch { /* opaque origin — skip */ }
|
||||
const onLoad = (): void => {
|
||||
hidden.removeEventListener('load', onLoad)
|
||||
try { hidden.contentWindow?.scrollTo(0, keepScroll) } catch { /* opaque origin — skip */ }
|
||||
setAShown((s) => !s) // opacity transition = the 120ms cross-fade
|
||||
}
|
||||
hidden.addEventListener('load', onLoad)
|
||||
hidden.srcdoc = html
|
||||
}
|
||||
|
||||
const frame = (shown: boolean): CSSProperties => ({
|
||||
position: 'absolute', inset: 0, width: '100%', height: '100%', border: 'none',
|
||||
background: '#fff', opacity: shown ? 1 : 0, transition: 'opacity 120ms ease',
|
||||
})
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', width: '100%', height: '100%', overflow: 'hidden' }}>
|
||||
{busy && <div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 2, background: 'var(--accent, #4b8bf5)', opacity: 0.85, zIndex: 3 }} />}
|
||||
{error !== null && (
|
||||
<div style={{ position: 'absolute', top: 8, left: 8, zIndex: 3, background: 'var(--bg-raised, #fff)', border: '1px solid var(--border, #ccc)', borderRadius: 6, padding: '2px 8px', fontSize: 12 }}>
|
||||
Preview paused — retrying
|
||||
</div>
|
||||
)}
|
||||
{/* No allow-scripts ⇒ the pure-HTML/CSS template can never run JS; allow-same-origin
|
||||
only lets the double buffer read/restore scrollTop (opaque origins throw on that). */}
|
||||
<iframe ref={aRef} title="preview-a" sandbox="allow-same-origin" style={frame(aShown)} />
|
||||
<iframe ref={bRef} title="preview-b" sandbox="allow-same-origin" style={frame(!aShown)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue