Files
PacketStation/frontend/src/packet/DrawingViewer.tsx
T
spencermandClaude Fable 5.1 37d9a0cb50 Phase 2: core domain, policy engine, packet API, Clerical screen
Migration 0003: projects, aliases, renumbers, units, packets and every child table,
change orders, assets, documents, ITPs, station limits, department weld flag.
Policy package: Principal, PacketState, Action rules, custody, preconditions,
CO hold, order of checks, can/blocked/holder on reads. docs/POLICY.md with the
tightenings table for Seth. Write pipeline: FOR UPDATE, CAS on status and dept,
If-Match row versions, server-assigned numbers, field CAS, version bump, packet
log, audit, NOTIFY, signatures, IT reasons, filed lock and HMAC seal.
API: /api/packets (header, transitions, work orders, pages, docs, move, remove,
BOM, shorts, welds, heat tags, heat table, stage, checklist, clock, warehouse
stage, Q.C., change orders, log, seal), /api/projects, /api/units, /api/assets,
/_blob. Frontend: Clerical home, projects and units, packet page with pdf.js
and QR codes, hand draft, heat map, send to warehouse, department or Q.C.
Tests: policy matrix, API, concurrency, schema, projects; Playwright Clerical
draft scenario. Verified on docker-1 with the CI stack and the Samba DC.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-16 09:31:45 -06:00

112 lines
4.4 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.
import { useEffect, useRef, useState } from "react";
import type { PacketPage } from "../api/types";
import { openPdf } from "../lib/pdf";
const ZOOMS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3];
/** The drawing pages, one at a time, fitted to the width. Zoom buttons and a page list. No marking yet. */
export function DrawingViewer({ pages }: { pages: PacketPage[] }) {
const [sel, setSel] = useState(0);
const [zi, setZi] = useState(2);
const [width, setWidth] = useState(0);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const page = pages[Math.min(sel, pages.length - 1)];
const zoom = ZOOMS[zi] ?? 1;
const url = page?.url ?? "";
const pn = page?.pn ?? 1;
// Fit to width: follow the frame's width.
useEffect(() => {
const el = wrapRef.current;
if (!el) return;
const measure = () => setWidth(el.clientWidth);
measure();
if (typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, []);
useEffect(() => {
if (!url || !width) return;
let cancelled = false;
let task: { cancel: () => void; promise: Promise<unknown> } | null = null;
setBusy(true);
setError(null);
openPdf(url)
.then((doc) => doc.getPage(Math.max(1, Math.min(pn || 1, doc.numPages))))
.then(async (pg) => {
if (cancelled) return;
const canvas = canvasRef.current;
if (!canvas) return;
const base = pg.getViewport({ scale: 1 });
const scale = (Math.max(200, width - 2) / base.width) * zoom;
const ratio = window.devicePixelRatio || 1;
const vp = pg.getViewport({ scale: scale * ratio });
canvas.width = Math.floor(vp.width);
canvas.height = Math.floor(vp.height);
canvas.style.width = `${Math.floor(vp.width / ratio)}px`;
canvas.style.height = `${Math.floor(vp.height / ratio)}px`;
task = pg.render({ canvas, viewport: vp });
await task.promise;
if (!cancelled) setBusy(false);
})
.catch((err: unknown) => {
if (cancelled) return;
if (err && typeof err === "object" && "name" in err && (err as { name: string }).name === "RenderingCancelledException") return;
setBusy(false);
setError("The drawing could not be shown. Open it in a new tab instead.");
});
return () => {
cancelled = true;
task?.cancel();
};
}, [url, pn, width, zoom]);
if (!page) return <div className="empty">No drawing attached yet.</div>;
return (
<div className="pdfv">
<div className="pdfv-bar">
<div className="seg" role="group" aria-label="Zoom">
<button type="button" aria-label="Zoom out" disabled={zi === 0} onClick={() => setZi((z) => Math.max(0, z - 1))}>
−
</button>
<button type="button" aria-label="Fit to width" aria-pressed={zoom === 1} onClick={() => setZi(2)}>
Fit
</button>
<button type="button" aria-label="Zoom in" disabled={zi === ZOOMS.length - 1} onClick={() => setZi((z) => Math.min(ZOOMS.length - 1, z + 1))}>
+
</button>
</div>
<span className="small muted mono">{Math.round(zoom * 100)}%</span>
<span className="grow" />
<a className="btn sm ghost" href={page.url} target="_blank" rel="noopener noreferrer">
Open in a new tab
</a>
</div>
<div className="pdfv-body">
<ol className="pdfv-pages" aria-label="Pages">
{pages.map((p, i) => (
<li key={p.n}>
<button type="button" aria-current={i === sel ? "page" : undefined} onClick={() => setSel(i)}>
<span className="mono">p.{p.n}</span>
{p.dwg ? <span className="small muted"> {p.dwg}{p.rev ? ` R${p.rev}` : ""}</span> : null}
{p.void ? <span className="pill st-bad">Void</span> : null}
</button>
</li>
))}
</ol>
<div className="pdfv-frame" ref={wrapRef}>
{error ? <div className="callout warn flush">{error}</div> : null}
{busy && !error ? <div className="small muted">Drawing the page</div> : null}
<canvas ref={canvasRef} className="pdfv-canvas" role="img" aria-label={`Drawing page ${page.n}`} />
</div>
</div>
</div>
);
}