diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index 62e629f19..02f168117 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -4273,6 +4273,7 @@ rotateRight = "Rotate Right" toggleSidebar = "Toggle Sidebar" toggleBookmarks = "Toggle Bookmarks" print = "Print PDF" +ruler = "Ruler / Measure" draw = "Draw" redact = "Redact" exitRedaction = "Exit Redaction Mode" diff --git a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx index dc3e5a1bf..5b18eda47 100644 --- a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx @@ -19,10 +19,87 @@ import NavigationWarningModal from '@app/components/shared/NavigationWarningModa import { isStirlingFile } from '@app/types/fileContext'; import { useViewerRightRailButtons } from '@app/components/viewer/useViewerRightRailButtons'; import { StampPlacementOverlay } from '@app/components/viewer/StampPlacementOverlay'; +import { RulerOverlay, type PageMeasureScales, type PageScaleInfo, type ViewportScale } from '@app/components/viewer/RulerOverlay'; import { useWheelZoom } from '@app/hooks/useWheelZoom'; import { useFormFill } from '@app/tools/formFill/FormFillContext'; import { FormSaveBar } from '@app/tools/formFill/FormSaveBar'; +import type { PDFDict, PDFNumber } from '@cantoo/pdf-lib'; + +// ─── Measure dictionary extraction ──────────────────────────────────────────── + +async function extractPageMeasureScales(file: Blob): Promise { + try { + const { PDFDocument, PDFDict, PDFName, PDFArray, PDFNumber, PDFString, PDFHexString } = await import('@cantoo/pdf-lib'); + const pdfDoc = await PDFDocument.load(await file.arrayBuffer(), { ignoreEncryption: true }); + + // Parse a Measure dict into a MeasureScale, or return null if malformed. + const parseScale = (measureObj: unknown) => { + if (!(measureObj instanceof PDFDict)) return null; + const rObj = measureObj.lookup(PDFName.of('R')); + const ratioLabel = (rObj instanceof PDFString || rObj instanceof PDFHexString) + ? rObj.decodeText() : ''; + // D = distance array, X = x-axis fallback + let fmtArray = measureObj.lookup(PDFName.of('D')); + if (!(fmtArray instanceof PDFArray)) fmtArray = measureObj.lookup(PDFName.of('X')); + if (!(fmtArray instanceof PDFArray)) return null; + const firstFmt = fmtArray.lookup(0); + if (!(firstFmt instanceof PDFDict)) return null; + const cObj = firstFmt.lookup(PDFName.of('C')); + const uObj = firstFmt.lookup(PDFName.of('U')); + if (!(cObj instanceof PDFNumber) || cObj.asNumber() <= 0) return null; + const unit = (uObj instanceof PDFString || uObj instanceof PDFHexString) + ? uObj.decodeText() : 'units'; + return { factor: cObj.asNumber(), unit, ratioLabel }; + }; + + const result: PageMeasureScales = new Map(); + + for (let i = 0; i < pdfDoc.getPageCount(); i++) { + const page = pdfDoc.getPage(i); + const pageHeight = page.getHeight(); + const pageNode = page.node as unknown as PDFDict; + const viewports: ViewportScale[] = []; + + // Spec-conformant: /VP array — each viewport can have its own scale and BBox + const vpObj = pageNode.lookup(PDFName.of('VP')); + if (vpObj instanceof PDFArray) { + for (let j = 0; j < vpObj.size(); j++) { + const vpEntry = vpObj.lookup(j); + if (!(vpEntry instanceof PDFDict)) continue; + const scale = parseScale(vpEntry.lookup(PDFName.of('Measure'))); + if (!scale) continue; + let bbox: ViewportScale['bbox'] = null; + const bboxObj = vpEntry.lookup(PDFName.of('BBox')); + if (bboxObj instanceof PDFArray && bboxObj.size() >= 4) { + bbox = [ + (bboxObj.lookup(0) as PDFNumber).asNumber(), + (bboxObj.lookup(1) as PDFNumber).asNumber(), + (bboxObj.lookup(2) as PDFNumber).asNumber(), + (bboxObj.lookup(3) as PDFNumber).asNumber(), + ]; + } + viewports.push({ bbox, scale }); + } + } + + // Fallback: /Measure directly on page (non-conforming but seen in the wild) + if (viewports.length === 0) { + const scale = parseScale(pageNode.lookup(PDFName.of('Measure'))); + if (scale) viewports.push({ bbox: null, scale }); + } + + if (viewports.length > 0) result.set(i, { viewports, pageHeight } satisfies PageScaleInfo); + } + + return result.size > 0 ? result : null; + } catch { + return null; + } +} + +// ────────────────────────────────────────────────────────────────────────────── + export interface EmbedPdfViewerProps { sidebarsVisible: boolean; setSidebarsVisible: (v: boolean) => void; @@ -688,8 +765,20 @@ const EmbedPdfViewerContent = ({ }; }, [applyChanges, setApplyChanges]); + // Ruler / measurement tool state + const [isRulerActive, setIsRulerActive] = useState(false); + const [pageMeasureScales, setPageMeasureScales] = useState(null); + + useEffect(() => { + const file = effectiveFile?.file; + if (!file) { setPageMeasureScales(null); return; } + let cancelled = false; + extractPageMeasureScales(file).then(scales => { if (!cancelled) setPageMeasureScales(scales); }); + return () => { cancelled = true; }; + }, [effectiveFile]); + // Register viewer right-rail buttons - useViewerRightRailButtons(); + useViewerRightRailButtons(isRulerActive, setIsRulerActive); // Auto-fetch form fields when a PDF is loaded in the viewer. // In normal viewer mode, this uses pdf-lib (frontend-only). @@ -819,6 +908,11 @@ const EmbedPdfViewerContent = ({ isActive={isPlacementOverlayActive} signatureConfig={signatureConfig} /> + )} diff --git a/frontend/src/core/components/viewer/RulerOverlay.tsx b/frontend/src/core/components/viewer/RulerOverlay.tsx new file mode 100644 index 000000000..3d8f91a74 --- /dev/null +++ b/frontend/src/core/components/viewer/RulerOverlay.tsx @@ -0,0 +1,747 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useViewer } from '@app/contexts/ViewerContext'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface Point { + x: number; + y: number; +} + +/** + * A point anchored to a specific PDF page in PDF-unit space. + * x and y are in PDF points (1/72 inch) relative to the page's top-left corner. + * + * This is the only truly zoom-invariant representation. Screen positions are + * recovered at render time via getBoundingClientRect on the page element, so + * scroll, zoom, and fixed page margins are all handled by the browser — we never + * have to track them ourselves. + */ +interface PagePoint { + pageIndex: number; + x: number; + y: number; +} + +interface Measurement { + id: string; + start: PagePoint; + end: PagePoint; +} + +export interface RulerOverlayHandle { + clearAll: () => void; +} + +interface RulerOverlayProps { + containerRef: React.RefObject; + isActive: boolean; + pageMeasureScales?: PageMeasureScales | null; +} + +// ─── Math ───────────────────────────────────────────────────────────────────── + +function dist(a: Point, b: Point): number { + return Math.sqrt((b.x - a.x) ** 2 + (b.y - a.y) ** 2); +} + +function midpoint(a: Point, b: Point): Point { + return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; +} + +function perpUnit(a: Point, b: Point): { nx: number; ny: number } { + const dx = b.x - a.x; + const dy = b.y - a.y; + const len = Math.sqrt(dx * dx + dy * dy) || 1; + return { nx: -dy / len, ny: dx / len }; +} + +/** Angle from horizontal 0°–90°. Computed from screen-space points (same angle as PDF space). */ +function angleDeg(a: Point, b: Point): number { + return Math.atan2(Math.abs(b.y - a.y), Math.abs(b.x - a.x)) * (180 / Math.PI); +} + +function formatDist(pts: number): string { + const mm = (pts / 72) * 25.4; + if (mm < 100) return `${mm.toFixed(1)} mm`; + if (mm < 1000) return `${(mm / 10).toFixed(1)} cm`; + return `${(mm / 1000).toFixed(2)} m`; +} + +function formatInches(pts: number): string { + const inches = pts / 72; + if (inches < 12) return `${inches.toFixed(2)} in`; + return `${(inches / 12).toFixed(2)} ft`; +} + +export interface MeasureScale { + /** real_world_value = pdf_points * factor */ + factor: number; + /** e.g. "ft", "m" */ + unit: string; + /** Human-readable ratio from PDF, e.g. "1 in = 10 ft" */ + ratioLabel: string; +} + +export interface ViewportScale { + /** BBox in PDF user space (bottom-left origin). null = entire page. */ + bbox: [number, number, number, number] | null; + scale: MeasureScale; +} + +export interface PageScaleInfo { + viewports: ViewportScale[]; + /** Page height in PDF points — used to flip screen-y (top=0) to PDF-y (bottom=0). */ + pageHeight: number; +} + +export type PageMeasureScales = Map; + +/** + * Given the start/end PagePoints of a measurement, find the scale from the + * viewport whose BBox contains the midpoint. Falls back to the first viewport + * if none contains it (handles whole-page viewports with bbox=null). + */ +function pickScale( + start: PagePoint, + end: PagePoint, + pageMeasureScales: PageMeasureScales, +): MeasureScale | null { + if (start.pageIndex !== end.pageIndex) return null; + const info = pageMeasureScales.get(start.pageIndex); + if (!info?.viewports.length) return null; + + // Midpoint in screen-space page coords (x left→right, y top→bottom, PDF points) + const mx = (start.x + end.x) / 2; + // Flip y: screen y=0 is page top; PDF user space y=0 is page bottom + const my = info.pageHeight - (start.y + end.y) / 2; + + for (const { bbox, scale } of info.viewports) { + if (!bbox) return scale; // whole-page viewport + const [x0, y0, x1, y1] = bbox; + if (mx >= Math.min(x0, x1) && mx <= Math.max(x0, x1) && + my >= Math.min(y0, y1) && my <= Math.max(y0, y1)) { + return scale; + } + } + return null; +} + +function formatScaled(pts: number, scale: MeasureScale): string { + const val = pts * scale.factor; + if (val >= 1000) return `${val.toFixed(0)} ${scale.unit}`; + if (val >= 100) return `${val.toFixed(1)} ${scale.unit}`; + if (val >= 10) return `${val.toFixed(2)} ${scale.unit}`; + return `${val.toFixed(3)} ${scale.unit}`; +} + +// Conversion factors to metres for known units +const TO_METRES: Record = { + m: 1, cm: 0.01, mm: 0.001, km: 1000, + ft: 0.3048, in: 0.0254, yd: 0.9144, mi: 1609.344, +}; + +function isImperialUnit(unit: string): boolean { + return ['ft', 'in', 'yd', 'mi'].includes(unit.toLowerCase().trim()); +} + +function formatMetricFromMetres(m: number): string { + if (m >= 1000) return `${(m / 1000).toFixed(2)} km`; + if (m >= 1) return `${m.toFixed(1)} m`; + if (m >= 0.1) return `${(m * 100).toFixed(1)} cm`; + return `${(m * 1000).toFixed(1)} mm`; +} + +function formatImperialFromFeet(ft: number): string { + if (ft >= 1) return `${ft.toFixed(2)} ft`; + return `${(ft * 12).toFixed(2)} in`; +} + +/** + * Returns the scaled real-world value in the *other* unit system, or null if + * the unit is not a recognised metric/imperial unit. + * e.g. 72 pts, scale {factor:0.138889, unit:"ft"} → "3.048 m" + * 72 pts, scale {factor:0.352778, unit:"m"} → "1.157 ft" (approx) + */ +function scaledCross(pts: number, scale: MeasureScale): string | null { + const toM = TO_METRES[scale.unit.toLowerCase().trim()]; + if (!toM) return null; + const metres = pts * scale.factor * toM; + return isImperialUnit(scale.unit) + ? formatMetricFromMetres(metres) + : formatImperialFromFeet(metres / 0.3048); +} + +// ─── DOM helpers ────────────────────────────────────────────────────────────── + +function findScrollEl(root: HTMLElement): HTMLElement | null { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); + let node: Node | null; + while ((node = walker.nextNode())) { + const el = node as HTMLElement; + if (el === root) continue; + const { overflow, overflowY, overflowX } = window.getComputedStyle(el); + if ([overflow, overflowY, overflowX].some(v => v === 'auto' || v === 'scroll')) { + return el; + } + } + return null; +} + +function isOverPage(e: MouseEvent): boolean { + return !!(e.target as Element).closest?.('[data-page-index]'); +} + +/** + * Find the nearest point on any page boundary and return it as both + * an SVG screen coordinate and a PagePoint (page-relative PDF units). + * Used to clamp the live line when the cursor drifts off the page. + */ +function nearestPageDocPt( + cursor: Point, + container: HTMLElement, + zoom: number, +): { screenPt: Point; docPt: PagePoint } | null { + const pages = container.querySelectorAll('[data-page-index]'); + if (!pages.length) return null; + + const cr = container.getBoundingClientRect(); + let bestDist = Infinity; + let best: { screenPt: Point; docPt: PagePoint } | null = null; + + pages.forEach(pageNode => { + const pageEl = pageNode as HTMLElement; + const r = pageEl.getBoundingClientRect(); + const pageIndex = parseInt(pageEl.dataset.pageIndex ?? '0', 10); + + // Page bounds in SVG (container-relative) space + const left = r.left - cr.left; + const top = r.top - cr.top; + const right = r.right - cr.left; + const bottom = r.bottom - cr.top; + + // Nearest point on this rect to the cursor (SVG space) + const cx = Math.max(left, Math.min(right, cursor.x)); + const cy = Math.max(top, Math.min(bottom, cursor.y)); + const d = Math.sqrt((cursor.x - cx) ** 2 + (cursor.y - cy) ** 2); + + if (d < bestDist) { + bestDist = d; + // Convert SVG-space point (cx, cy) → page-relative viewport → PDF points: + // viewport position of cx = cr.left + cx + // page-relative position = (cr.left + cx) - r.left + // PDF units = page-relative / zoom + best = { + screenPt: { x: cx, y: cy }, + docPt: { + pageIndex, + x: (cr.left + cx - r.left) / zoom, + y: (cr.top + cy - r.top ) / zoom, + }, + }; + } + }); + + return best; +} + +// ─── Sub-components ─────────────────────────────────────────────────────────── + +const TICK = 10; +const DOT_R = 5; +const LH = 26; // label height (normal — 1 line) +const LH2 = 44; // label height (hovered, no scale — 2 lines) +const LH3 = 62; // label height (hovered, with scale — 3 lines) +const LP = 10; // label horizontal padding +const DEL_R = 8; + +interface MeasurementLineProps { + id: string; + startS: Point; + endS: Point; + /** Physical distance in PDF points (= screen pixel distance / zoom). */ + distPts: number; + hovered: boolean; + onDelete: (id: string) => void; + onHover: (id: string | null) => void; + measureScale?: MeasureScale | null; +} + +function MeasurementLine({ id, startS, endS, distPts, hovered, onDelete, onHover, measureScale }: MeasurementLineProps) { + const mid = midpoint(startS, endS); + const { nx, ny } = perpUnit(startS, endS); + const ang = angleDeg(startS, endS); + const angLabel = `∠ ${ang.toFixed(1)}°`; + + // Whether the PDF's unit is imperial — determines display order (imperial-first vs metric-first) + const imperialFirst = !!measureScale && isImperialUnit(measureScale.unit); + + // Idle: scaled primary if scale present, else physical metric + const distLabel = measureScale ? formatScaled(distPts, measureScale) : formatDist(distPts); + + // Hover line 1 — both real-world values ordered by PDF unit system: + // imperial PDF: "10.000 ft / 3.048 m" + // metric PDF: "142.5 m / 467.5 ft" + // no scale: "25.4 mm / 1.00 in" (metric first, default) + const hoverLine1 = measureScale + ? (() => { + const primary = formatScaled(distPts, measureScale); + const cross = scaledCross(distPts, measureScale); + return cross ? `${primary} / ${cross}` : primary; + })() + : `${formatDist(distPts)} / ${formatInches(distPts)}`; + + // Hover line 2 — both physical paper values, same order as line 1: + // imperial PDF: "1.00 in / 25.4 mm" + // metric PDF or no scale: "25.4 mm / 1.00 in" + const hoverLine2 = measureScale + ? (imperialFirst + ? `${formatInches(distPts)} / ${formatDist(distPts)}` + : `${formatDist(distPts)} / ${formatInches(distPts)}`) + : null; + + // Hover line 3 (scaled) / line 2 (no scale) — ratio label + angle + const contextLabel = measureScale?.ratioLabel + ? `${measureScale.ratioLabel} ${angLabel}` + : angLabel; + + const maxHoverLh = measureScale ? LH3 : LH2; + const lh = hovered ? maxHoverLh : LH; + + const lwNormal = Math.max(distLabel.length * 8 + LP * 2, 80); + const lwHover = Math.max( + hoverLine1.length * 8 + LP * 2, + (hoverLine2?.length ?? 0) * 8 + LP * 2, + contextLabel.length * 8 + LP * 2, + 80, + ); + const lw = hovered ? lwHover : lwNormal; + const sw = hovered ? 3 : 2; + + const delX = mid.x + lwHover / 2 + DEL_R + 4; + const delY = mid.y; + + const hitLeft = mid.x - lwHover / 2 - 4; + const hitTop = mid.y - maxHoverLh / 2 - 4; + const hitWidth = (delX + DEL_R + 4) - hitLeft; + const hitHeight = maxHoverLh + 8; + + const mono = "'Roboto Mono','Consolas',monospace"; + + return ( + onHover(id)} + onMouseLeave={() => onHover(null)} + style={{ pointerEvents: 'all' }} + > + + + + + + + + + + + + {hovered && measureScale ? ( + // 3-line scaled hover + <> + {hoverLine1} + {hoverLine2} + {contextLabel} + + ) : hovered ? ( + // 2-line no-scale hover + <> + {hoverLine1} + {contextLabel} + + ) : ( + // Idle — single line + {distLabel} + )} + + { e.stopPropagation(); onDelete(id); }}> + + × + + + + ); +} + +interface LiveLineProps { + startS: Point; + endS: Point; + zoom: number; + measureScale?: MeasureScale | null; +} + +function LiveLine({ startS, endS, zoom, measureScale }: LiveLineProps) { + const d = dist(startS, endS) / zoom; // PDF points from screen distance + const mid = midpoint(startS, endS); + const { nx, ny } = perpUnit(startS, endS); + const ang = angleDeg(startS, endS); + const distLabel = measureScale ? formatScaled(d, measureScale) : formatDist(d); + const lw = Math.max(distLabel.length * 8 + LP * 2, 80); + + return ( + + + + {d > 4 && ( + + + + {distLabel} + + + {`∠ ${ang.toFixed(1)}°`} + + + )} + + ); +} + +// ─── Main ───────────────────────────────────────────────────────────────────── + +export const RulerOverlay = React.forwardRef( + ({ containerRef, isActive, pageMeasureScales }, ref) => { + const [measurements, setMeasurements] = useState([]); + const [firstPt, setFirstPt] = useState(null); + /** Current cursor in SVG screen-space — for live crosshair and live line rendering. */ + const [cursorS, setCursorS] = useState(null); + /** Current cursor in page-relative PDF units — for finalising off-page clicks. */ + const [cursorDoc, setCursorDoc] = useState(null); + const [hoveredId, setHoveredId] = useState(null); + + /** + * Incremented on scroll to trigger re-renders. + * We no longer store the scroll value — getBoundingClientRect handles that + * automatically and is always accurate regardless of scroll position. + */ + const [, setScrollVersion] = useState(0); + + const scrollElRef = useRef(null); + const scrollCleanupRef = useRef<(() => void) | null>(null); + const idCounter = useRef(0); + + const firstPtRef = useRef(null); + useEffect(() => { firstPtRef.current = firstPt; }, [firstPt]); + + const cursorDocRef = useRef(null); + + // ── Zoom ────────────────────────────────────────────────────────────────── + const viewer = useViewer(); + const { registerImmediateZoomUpdate } = viewer; + + const [zoom, setZoom] = useState(() => { + try { return ((viewer.getZoomState() as any)?.zoomPercent ?? 140) / 100; } + catch { return 1.4; } + }); + + const zoomRef = useRef(zoom); + useEffect(() => { zoomRef.current = zoom; }, [zoom]); + + useEffect(() => { + return registerImmediateZoomUpdate((pct) => { + const newZoom = pct / 100; + zoomRef.current = newZoom; // immediate for event-listener closures + setZoom(newZoom); // re-render #1: zoom updated, but PDF.js DOM may not be yet + // re-render #2: after PDF.js has updated page element dimensions in the DOM, + // so getBoundingClientRect returns the correct positions for the new zoom level. + requestAnimationFrame(() => setScrollVersion(n => n + 1)); + }); + }, [registerImmediateZoomUpdate]); + + // ── Scroll tracking ──────────────────────────────────────────────────────── + // We only need re-renders on scroll; getBoundingClientRect gives us accurate + // positions without needing to know the scroll offset ourselves. + + const attachScrollEl = useCallback((el: HTMLElement) => { + scrollCleanupRef.current?.(); + scrollElRef.current = el; + const handler = () => setScrollVersion(n => n + 1); + el.addEventListener('scroll', handler, { passive: true }); + scrollCleanupRef.current = () => el.removeEventListener('scroll', handler); + }, []); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const tryAttach = () => { + const el = findScrollEl(container); + if (el) { attachScrollEl(el); return true; } + return false; + }; + + if (!tryAttach()) { + const timer = setTimeout(() => tryAttach(), 600); + return () => { clearTimeout(timer); scrollCleanupRef.current?.(); }; + } + return () => scrollCleanupRef.current?.(); + }, [containerRef, attachScrollEl]); + + // Re-find scroll element when zoom changes (PDF.js may recreate the scroll DOM). + useEffect(() => { + const container = containerRef.current; + if (!container) return; + const el = findScrollEl(container); + if (el && el !== scrollElRef.current) attachScrollEl(el); + }, [zoom, containerRef, attachScrollEl]); + + // ── Imperative handle ────────────────────────────────────────────────────── + React.useImperativeHandle(ref, () => ({ + clearAll: () => { setMeasurements([]); setFirstPt(null); setCursorS(null); setCursorDoc(null); }, + })); + + // ── Reset when deactivated ───────────────────────────────────────────────── + useEffect(() => { + if (!isActive) { setFirstPt(null); setCursorS(null); setCursorDoc(null); } + }, [isActive]); + + // ── Mouse events ─────────────────────────────────────────────────────────── + useEffect(() => { + const el = containerRef.current; + if (!isActive || !el) return; + + const toScreenPt = (e: MouseEvent): Point => { + const r = el.getBoundingClientRect(); + return { x: e.clientX - r.left, y: e.clientY - r.top }; + }; + + /** + * Convert a mouse event to a page-relative PagePoint. + * Returns null if the cursor is not directly over a page element. + */ + const toDocPagePt = (e: MouseEvent): PagePoint | null => { + const pageEl = (e.target as Element).closest?.('[data-page-index]') as HTMLElement | null; + if (!pageEl) return null; + const pageIndex = parseInt(pageEl.dataset.pageIndex ?? '0', 10); + const r = pageEl.getBoundingClientRect(); + const z = zoomRef.current; + return { pageIndex, x: (e.clientX - r.left) / z, y: (e.clientY - r.top) / z }; + }; + + const clearCursor = () => { + setCursorS(null); + setCursorDoc(null); + cursorDocRef.current = null; + }; + + const onMove = (e: MouseEvent) => { + const screenPt = toScreenPt(e); + + if (isOverPage(e)) { + el.style.cursor = 'crosshair'; + const docPt = toDocPagePt(e); + setCursorS(screenPt); + setCursorDoc(docPt); + cursorDocRef.current = docPt; + } else if (firstPtRef.current !== null) { + // First point placed, cursor wandered off page — clamp to nearest edge + el.style.cursor = 'crosshair'; + const result = nearestPageDocPt(screenPt, el, zoomRef.current); + if (result) { + setCursorS(result.screenPt); + setCursorDoc(result.docPt); + cursorDocRef.current = result.docPt; + } + } else { + el.style.cursor = 'default'; + clearCursor(); + } + }; + + const onClick = (e: MouseEvent) => { + if (e.button !== 0) return; + if ((e.target as Element).closest?.('[data-ruler-interactive]')) return; + + const overPage = isOverPage(e); + if (!overPage && firstPtRef.current === null) return; + e.preventDefault(); + + const dp = overPage ? toDocPagePt(e) : cursorDocRef.current; + if (!dp) return; + + setFirstPt(prev => { + if (!prev) { firstPtRef.current = dp; return dp; } + firstPtRef.current = null; + const id = `ruler-${++idCounter.current}`; + setMeasurements(m => [...m, { id, start: prev, end: dp }]); + return null; + }); + }; + + const onLeave = () => { + el.style.cursor = ''; + if (firstPtRef.current === null) clearCursor(); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { setFirstPt(null); setCursorS(null); setCursorDoc(null); } + }; + + el.addEventListener('mousemove', onMove); + el.addEventListener('click', onClick); + el.addEventListener('mouseleave', onLeave); + document.addEventListener('keydown', onKey); + return () => { + el.removeEventListener('mousemove', onMove); + el.removeEventListener('click', onClick); + el.removeEventListener('mouseleave', onLeave); + document.removeEventListener('keydown', onKey); + el.style.cursor = ''; + }; + }, [containerRef, isActive]); + + const deleteMeasurement = useCallback((id: string) => { + setMeasurements(prev => prev.filter(m => m.id !== id)); + }, []); + + if (!isActive && measurements.length === 0) return null; + + // ── PagePoint → SVG screen coordinates ──────────────────────────────────── + /** + * Convert a page-anchored point to SVG screen coordinates. + * + * Uses getBoundingClientRect so the browser computes the exact screen position + * accounting for scroll, zoom, page margins, centering — everything. This is + * why we no longer need to track scroll offsets. + * + * Returns null if the page element isn't in the DOM (shouldn't happen with + * PDF.js placeholder divs, but guard anyway). + */ + const pagePointToScreen = (pt: PagePoint): Point | null => { + const container = containerRef.current; + if (!container) return null; + const pageEl = container.querySelector(`[data-page-index="${pt.pageIndex}"]`) as HTMLElement | null; + if (!pageEl) return null; + const pageRect = pageEl.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + return { + x: pageRect.left - containerRect.left + pt.x * zoom, + y: pageRect.top - containerRect.top + pt.y * zoom, + }; + }; + + const firstPtS = firstPt ? pagePointToScreen(firstPt) : null; + + return ( + + + + + + + + {/* Completed measurements */} + {measurements.map(m => { + const startS = pagePointToScreen(m.start); + const endS = pagePointToScreen(m.end); + if (!startS || !endS) return null; + const mScale = pageMeasureScales ? pickScale(m.start, m.end, pageMeasureScales) : null; + return ( + + ); + })} + + {/* Live line while drawing */} + {isActive && firstPtS && cursorS && ( + + )} + + {/* First-point anchor dot */} + {isActive && firstPtS && ( + + )} + + {/* Crosshair */} + {isActive && cursorS && ( + + + + + + )} + + {/* Clear all */} + {measurements.length > 0 && ( + { e.stopPropagation(); setMeasurements([]); }}> + + + Clear all + + + )} + + ); + } +); + +RulerOverlay.displayName = 'RulerOverlay'; diff --git a/frontend/src/core/components/viewer/useViewerRightRailButtons.tsx b/frontend/src/core/components/viewer/useViewerRightRailButtons.tsx index 3e742a54c..224d3a022 100644 --- a/frontend/src/core/components/viewer/useViewerRightRailButtons.tsx +++ b/frontend/src/core/components/viewer/useViewerRightRailButtons.tsx @@ -14,8 +14,12 @@ import { useNavigationState, useNavigationGuard } from '@app/contexts/Navigation import { BASE_PATH, withBasePath } from '@app/constants/app'; import { useRedaction, useRedactionMode } from '@app/contexts/RedactionContext'; import TextFieldsIcon from '@mui/icons-material/TextFields'; +import StraightenIcon from '@mui/icons-material/Straighten'; -export function useViewerRightRailButtons() { +export function useViewerRightRailButtons( + isRulerActive?: boolean, + setIsRulerActive?: (v: boolean) => void, +) { const { t, i18n } = useTranslation(); const viewer = useViewer(); const { isThumbnailSidebarVisible, isBookmarkSidebarVisible, isAttachmentSidebarVisible, isSearchInterfaceVisible, registerImmediatePanUpdate } = viewer; @@ -82,6 +86,8 @@ export function useViewerRightRailButtons() { const isFormFillActive = (selectedTool as string) === 'formFill'; + const rulerLabel = t('rightRail.ruler', 'Ruler / Measure'); + const viewerButtons = useMemo(() => { const buttons: RightRailButtonWithAction[] = [ { @@ -137,6 +143,24 @@ export function useViewerRightRailButtons() { setIsPanning(prev => !prev); }, }, + { + id: 'viewer-ruler', + icon: , + tooltip: rulerLabel, + ariaLabel: rulerLabel, + section: 'top' as const, + order: 25, + active: Boolean(isRulerActive), + onClick: () => { + const next = !isRulerActive; + setIsRulerActive?.(next); + // Disable pan when activating ruler — they conflict + if (next && viewer.getPanState()?.isPanning) { + viewer.panActions.togglePan(); + setIsPanning(false); + } + }, + }, { id: 'viewer-rotate-left', icon: , @@ -317,6 +341,9 @@ export function useViewerRightRailButtons() { redactionActiveType, formFillLabel, isFormFillActive, + rulerLabel, + isRulerActive, + setIsRulerActive, ]); useRightRailButtons(viewerButtons);