From 8725ba66bbf59346ad9b8874bbb32f3667772be8 Mon Sep 17 00:00:00 2001
From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Date: Thu, 19 Feb 2026 15:58:03 +0000
Subject: [PATCH] ruler support (#5758)
# Description of Changes
on hover
---
## Checklist
### General
- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### Translations (if applicable)
- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
### Testing (if applicable)
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
---
.../public/locales/en-GB/translation.toml | 1 +
.../core/components/viewer/EmbedPdfViewer.tsx | 96 ++-
.../core/components/viewer/RulerOverlay.tsx | 747 ++++++++++++++++++
.../viewer/useViewerRightRailButtons.tsx | 29 +-
4 files changed, 871 insertions(+), 2 deletions(-)
create mode 100644 frontend/src/core/components/viewer/RulerOverlay.tsx
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 (
+
+ );
+ }
+);
+
+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);