mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
ruler support (#5758)
# Description of Changes <img width="266" height="228" alt="image" src="https://github.com/user-attachments/assets/bcc6ec11-bd9e-4b83-a081-62149dd92f2a" /> <img width="882" height="335" alt="image" src="https://github.com/user-attachments/assets/b86dbf13-6bcf-4b28-81a6-8c405358a58e" /> <img width="1050" height="399" alt="image" src="https://github.com/user-attachments/assets/6a4468ed-d0f9-44ab-978a-c640d490da8b" /> on hover <img width="380" height="196" alt="image" src="https://github.com/user-attachments/assets/ba3755b3-4823-48dc-b6aa-3a0f9b0517a3" /> --- ## 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.
This commit is contained in:
@@ -4273,6 +4273,7 @@ rotateRight = "Rotate Right"
|
|||||||
toggleSidebar = "Toggle Sidebar"
|
toggleSidebar = "Toggle Sidebar"
|
||||||
toggleBookmarks = "Toggle Bookmarks"
|
toggleBookmarks = "Toggle Bookmarks"
|
||||||
print = "Print PDF"
|
print = "Print PDF"
|
||||||
|
ruler = "Ruler / Measure"
|
||||||
draw = "Draw"
|
draw = "Draw"
|
||||||
redact = "Redact"
|
redact = "Redact"
|
||||||
exitRedaction = "Exit Redaction Mode"
|
exitRedaction = "Exit Redaction Mode"
|
||||||
|
|||||||
@@ -19,10 +19,87 @@ import NavigationWarningModal from '@app/components/shared/NavigationWarningModa
|
|||||||
import { isStirlingFile } from '@app/types/fileContext';
|
import { isStirlingFile } from '@app/types/fileContext';
|
||||||
import { useViewerRightRailButtons } from '@app/components/viewer/useViewerRightRailButtons';
|
import { useViewerRightRailButtons } from '@app/components/viewer/useViewerRightRailButtons';
|
||||||
import { StampPlacementOverlay } from '@app/components/viewer/StampPlacementOverlay';
|
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 { useWheelZoom } from '@app/hooks/useWheelZoom';
|
||||||
import { useFormFill } from '@app/tools/formFill/FormFillContext';
|
import { useFormFill } from '@app/tools/formFill/FormFillContext';
|
||||||
import { FormSaveBar } from '@app/tools/formFill/FormSaveBar';
|
import { FormSaveBar } from '@app/tools/formFill/FormSaveBar';
|
||||||
|
|
||||||
|
import type { PDFDict, PDFNumber } from '@cantoo/pdf-lib';
|
||||||
|
|
||||||
|
// ─── Measure dictionary extraction ────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function extractPageMeasureScales(file: Blob): Promise<PageMeasureScales | null> {
|
||||||
|
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 {
|
export interface EmbedPdfViewerProps {
|
||||||
sidebarsVisible: boolean;
|
sidebarsVisible: boolean;
|
||||||
setSidebarsVisible: (v: boolean) => void;
|
setSidebarsVisible: (v: boolean) => void;
|
||||||
@@ -688,8 +765,20 @@ const EmbedPdfViewerContent = ({
|
|||||||
};
|
};
|
||||||
}, [applyChanges, setApplyChanges]);
|
}, [applyChanges, setApplyChanges]);
|
||||||
|
|
||||||
|
// Ruler / measurement tool state
|
||||||
|
const [isRulerActive, setIsRulerActive] = useState(false);
|
||||||
|
const [pageMeasureScales, setPageMeasureScales] = useState<PageMeasureScales | null>(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
|
// Register viewer right-rail buttons
|
||||||
useViewerRightRailButtons();
|
useViewerRightRailButtons(isRulerActive, setIsRulerActive);
|
||||||
|
|
||||||
// Auto-fetch form fields when a PDF is loaded in the viewer.
|
// Auto-fetch form fields when a PDF is loaded in the viewer.
|
||||||
// In normal viewer mode, this uses pdf-lib (frontend-only).
|
// In normal viewer mode, this uses pdf-lib (frontend-only).
|
||||||
@@ -819,6 +908,11 @@ const EmbedPdfViewerContent = ({
|
|||||||
isActive={isPlacementOverlayActive}
|
isActive={isPlacementOverlayActive}
|
||||||
signatureConfig={signatureConfig}
|
signatureConfig={signatureConfig}
|
||||||
/>
|
/>
|
||||||
|
<RulerOverlay
|
||||||
|
containerRef={pdfContainerRef}
|
||||||
|
isActive={isRulerActive}
|
||||||
|
pageMeasureScales={pageMeasureScales}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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<HTMLElement | null>;
|
||||||
|
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<number, PageScaleInfo>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string, number> = {
|
||||||
|
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 (
|
||||||
|
<g
|
||||||
|
onMouseEnter={() => onHover(id)}
|
||||||
|
onMouseLeave={() => onHover(null)}
|
||||||
|
style={{ pointerEvents: 'all' }}
|
||||||
|
>
|
||||||
|
<rect x={hitLeft} y={hitTop} width={hitWidth} height={hitHeight}
|
||||||
|
fill="transparent" stroke="none" style={{ pointerEvents: 'all' }} />
|
||||||
|
|
||||||
|
<line x1={startS.x} y1={startS.y} x2={endS.x} y2={endS.y}
|
||||||
|
stroke="#1e88e5" strokeWidth={sw} strokeLinecap="round" />
|
||||||
|
<line x1={startS.x + nx * TICK / 2} y1={startS.y + ny * TICK / 2}
|
||||||
|
x2={startS.x - nx * TICK / 2} y2={startS.y - ny * TICK / 2}
|
||||||
|
stroke="#1e88e5" strokeWidth={sw} strokeLinecap="round" />
|
||||||
|
<line x1={endS.x + nx * TICK / 2} y1={endS.y + ny * TICK / 2}
|
||||||
|
x2={endS.x - nx * TICK / 2} y2={endS.y - ny * TICK / 2}
|
||||||
|
stroke="#1e88e5" strokeWidth={sw} strokeLinecap="round" />
|
||||||
|
<circle cx={startS.x} cy={startS.y} r={DOT_R} fill="#1e88e5" stroke="white" strokeWidth={2} />
|
||||||
|
<circle cx={endS.x} cy={endS.y} r={DOT_R} fill="#1e88e5" stroke="white" strokeWidth={2} />
|
||||||
|
|
||||||
|
<g style={{ pointerEvents: 'all', cursor: 'default' }}>
|
||||||
|
<rect x={mid.x - lw / 2} y={mid.y - lh / 2} width={lw} height={lh}
|
||||||
|
rx={5} fill="white" stroke="#1e88e5" strokeWidth={1.5} filter="url(#ruler-shadow)" />
|
||||||
|
|
||||||
|
{hovered && measureScale ? (
|
||||||
|
// 3-line scaled hover
|
||||||
|
<>
|
||||||
|
<text x={mid.x} y={mid.y - 17} textAnchor="middle" dominantBaseline="middle"
|
||||||
|
fill="#1e88e5" fontSize={12} fontFamily={mono} fontWeight={600}
|
||||||
|
style={{ userSelect: 'none' }}>{hoverLine1}</text>
|
||||||
|
<text x={mid.x} y={mid.y} textAnchor="middle" dominantBaseline="middle"
|
||||||
|
fill="#546e7a" fontSize={11} fontFamily={mono} fontWeight={500}
|
||||||
|
style={{ userSelect: 'none' }}>{hoverLine2}</text>
|
||||||
|
<text x={mid.x} y={mid.y + 17} textAnchor="middle" dominantBaseline="middle"
|
||||||
|
fill="#5c6bc0" fontSize={10} fontFamily={mono} fontWeight={500}
|
||||||
|
style={{ userSelect: 'none' }}>{contextLabel}</text>
|
||||||
|
</>
|
||||||
|
) : hovered ? (
|
||||||
|
// 2-line no-scale hover
|
||||||
|
<>
|
||||||
|
<text x={mid.x} y={mid.y - 6} textAnchor="middle" dominantBaseline="middle"
|
||||||
|
fill="#1e88e5" fontSize={12} fontFamily={mono} fontWeight={600}
|
||||||
|
style={{ userSelect: 'none' }}>{hoverLine1}</text>
|
||||||
|
<text x={mid.x} y={mid.y + 13} textAnchor="middle" dominantBaseline="middle"
|
||||||
|
fill="#5c6bc0" fontSize={11} fontFamily={mono} fontWeight={500}
|
||||||
|
style={{ userSelect: 'none' }}>{contextLabel}</text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// Idle — single line
|
||||||
|
<text x={mid.x} y={mid.y + 1} textAnchor="middle" dominantBaseline="middle"
|
||||||
|
fill="#1e88e5" fontSize={12} fontFamily={mono} fontWeight={600}
|
||||||
|
style={{ userSelect: 'none' }}>{distLabel}</text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<g style={{ cursor: 'pointer' }} onClick={(e) => { e.stopPropagation(); onDelete(id); }}>
|
||||||
|
<circle cx={delX} cy={delY} r={DEL_R} fill="#ef5350" stroke="white" strokeWidth={1.5} />
|
||||||
|
<text x={delX} y={delY} textAnchor="middle" dominantBaseline="middle"
|
||||||
|
fill="white" fontSize={12} fontWeight={700} style={{ userSelect: 'none' }}>×</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<g>
|
||||||
|
<line x1={startS.x} y1={startS.y} x2={endS.x} y2={endS.y}
|
||||||
|
stroke="#1e88e5" strokeWidth={2} strokeDasharray="7 4"
|
||||||
|
strokeLinecap="round" opacity={0.85} />
|
||||||
|
<line x1={startS.x + nx * TICK / 2} y1={startS.y + ny * TICK / 2}
|
||||||
|
x2={startS.x - nx * TICK / 2} y2={startS.y - ny * TICK / 2}
|
||||||
|
stroke="#1e88e5" strokeWidth={2} strokeLinecap="round" />
|
||||||
|
{d > 4 && (
|
||||||
|
<g>
|
||||||
|
<rect x={mid.x - lw / 2} y={mid.y - LH2 / 2} width={lw} height={LH2}
|
||||||
|
rx={5} fill="#1e88e5" stroke="white" strokeWidth={1} />
|
||||||
|
<text x={mid.x} y={mid.y - 6}
|
||||||
|
textAnchor="middle" dominantBaseline="middle"
|
||||||
|
fill="white" fontSize={12}
|
||||||
|
fontFamily="'Roboto Mono','Consolas',monospace" fontWeight={600}
|
||||||
|
style={{ userSelect: 'none' }}>
|
||||||
|
{distLabel}
|
||||||
|
</text>
|
||||||
|
<text x={mid.x} y={mid.y + 13}
|
||||||
|
textAnchor="middle" dominantBaseline="middle"
|
||||||
|
fill="rgba(255,255,255,0.85)" fontSize={11}
|
||||||
|
fontFamily="'Roboto Mono','Consolas',monospace" fontWeight={500}
|
||||||
|
style={{ userSelect: 'none' }}>
|
||||||
|
{`∠ ${ang.toFixed(1)}°`}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const RulerOverlay = React.forwardRef<RulerOverlayHandle, RulerOverlayProps>(
|
||||||
|
({ containerRef, isActive, pageMeasureScales }, ref) => {
|
||||||
|
const [measurements, setMeasurements] = useState<Measurement[]>([]);
|
||||||
|
const [firstPt, setFirstPt] = useState<PagePoint | null>(null);
|
||||||
|
/** Current cursor in SVG screen-space — for live crosshair and live line rendering. */
|
||||||
|
const [cursorS, setCursorS] = useState<Point | null>(null);
|
||||||
|
/** Current cursor in page-relative PDF units — for finalising off-page clicks. */
|
||||||
|
const [cursorDoc, setCursorDoc] = useState<PagePoint | null>(null);
|
||||||
|
const [hoveredId, setHoveredId] = useState<string | null>(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<HTMLElement | null>(null);
|
||||||
|
const scrollCleanupRef = useRef<(() => void) | null>(null);
|
||||||
|
const idCounter = useRef(0);
|
||||||
|
|
||||||
|
const firstPtRef = useRef<PagePoint | null>(null);
|
||||||
|
useEffect(() => { firstPtRef.current = firstPt; }, [firstPt]);
|
||||||
|
|
||||||
|
const cursorDocRef = useRef<PagePoint | null>(null);
|
||||||
|
|
||||||
|
// ── Zoom ──────────────────────────────────────────────────────────────────
|
||||||
|
const viewer = useViewer();
|
||||||
|
const { registerImmediateZoomUpdate } = viewer;
|
||||||
|
|
||||||
|
const [zoom, setZoom] = useState<number>(() => {
|
||||||
|
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 (
|
||||||
|
<svg
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
overflow: 'visible',
|
||||||
|
zIndex: 100,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<filter id="ruler-shadow" x="-20%" y="-50%" width="140%" height="200%">
|
||||||
|
<feDropShadow dx="0" dy="1" stdDeviation="2" floodColor="rgba(0,0,0,0.22)" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* 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 (
|
||||||
|
<MeasurementLine
|
||||||
|
key={m.id}
|
||||||
|
id={m.id}
|
||||||
|
startS={startS}
|
||||||
|
endS={endS}
|
||||||
|
distPts={dist(startS, endS) / zoom}
|
||||||
|
hovered={hoveredId === m.id}
|
||||||
|
onDelete={deleteMeasurement}
|
||||||
|
onHover={setHoveredId}
|
||||||
|
measureScale={mScale}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Live line while drawing */}
|
||||||
|
{isActive && firstPtS && cursorS && (
|
||||||
|
<LiveLine
|
||||||
|
startS={firstPtS} endS={cursorS} zoom={zoom}
|
||||||
|
measureScale={pageMeasureScales && firstPt && cursorDoc
|
||||||
|
? pickScale(firstPt, cursorDoc, pageMeasureScales)
|
||||||
|
: null}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* First-point anchor dot */}
|
||||||
|
{isActive && firstPtS && (
|
||||||
|
<circle cx={firstPtS.x} cy={firstPtS.y} r={DOT_R} fill="#1e88e5" stroke="white" strokeWidth={2} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Crosshair */}
|
||||||
|
{isActive && cursorS && (
|
||||||
|
<g opacity={0.75}>
|
||||||
|
<line x1={cursorS.x - 12} y1={cursorS.y} x2={cursorS.x + 12} y2={cursorS.y} stroke="#1e88e5" strokeWidth={1.5} />
|
||||||
|
<line x1={cursorS.x} y1={cursorS.y - 12} x2={cursorS.x} y2={cursorS.y + 12} stroke="#1e88e5" strokeWidth={1.5} />
|
||||||
|
<circle cx={cursorS.x} cy={cursorS.y} r={2} fill="#1e88e5" />
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Clear all */}
|
||||||
|
{measurements.length > 0 && (
|
||||||
|
<g data-ruler-interactive="true" style={{ pointerEvents: 'all', cursor: 'pointer' }}
|
||||||
|
onClick={(e) => { e.stopPropagation(); setMeasurements([]); }}>
|
||||||
|
<rect x={8} y={8} width={88} height={26} rx={5}
|
||||||
|
fill="rgba(239,83,80,0.9)" stroke="white" strokeWidth={1} />
|
||||||
|
<text x={52} y={25} textAnchor="middle" fill="white" fontSize={12}
|
||||||
|
fontFamily="sans-serif" fontWeight={600} style={{ userSelect: 'none' }}>
|
||||||
|
Clear all
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
RulerOverlay.displayName = 'RulerOverlay';
|
||||||
@@ -14,8 +14,12 @@ import { useNavigationState, useNavigationGuard } from '@app/contexts/Navigation
|
|||||||
import { BASE_PATH, withBasePath } from '@app/constants/app';
|
import { BASE_PATH, withBasePath } from '@app/constants/app';
|
||||||
import { useRedaction, useRedactionMode } from '@app/contexts/RedactionContext';
|
import { useRedaction, useRedactionMode } from '@app/contexts/RedactionContext';
|
||||||
import TextFieldsIcon from '@mui/icons-material/TextFields';
|
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 { t, i18n } = useTranslation();
|
||||||
const viewer = useViewer();
|
const viewer = useViewer();
|
||||||
const { isThumbnailSidebarVisible, isBookmarkSidebarVisible, isAttachmentSidebarVisible, isSearchInterfaceVisible, registerImmediatePanUpdate } = viewer;
|
const { isThumbnailSidebarVisible, isBookmarkSidebarVisible, isAttachmentSidebarVisible, isSearchInterfaceVisible, registerImmediatePanUpdate } = viewer;
|
||||||
@@ -82,6 +86,8 @@ export function useViewerRightRailButtons() {
|
|||||||
|
|
||||||
const isFormFillActive = (selectedTool as string) === 'formFill';
|
const isFormFillActive = (selectedTool as string) === 'formFill';
|
||||||
|
|
||||||
|
const rulerLabel = t('rightRail.ruler', 'Ruler / Measure');
|
||||||
|
|
||||||
const viewerButtons = useMemo<RightRailButtonWithAction[]>(() => {
|
const viewerButtons = useMemo<RightRailButtonWithAction[]>(() => {
|
||||||
const buttons: RightRailButtonWithAction[] = [
|
const buttons: RightRailButtonWithAction[] = [
|
||||||
{
|
{
|
||||||
@@ -137,6 +143,24 @@ export function useViewerRightRailButtons() {
|
|||||||
setIsPanning(prev => !prev);
|
setIsPanning(prev => !prev);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'viewer-ruler',
|
||||||
|
icon: <StraightenIcon sx={{ fontSize: '1.5rem' }} />,
|
||||||
|
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',
|
id: 'viewer-rotate-left',
|
||||||
icon: <LocalIcon icon="rotate-left" width="1.5rem" height="1.5rem" />,
|
icon: <LocalIcon icon="rotate-left" width="1.5rem" height="1.5rem" />,
|
||||||
@@ -317,6 +341,9 @@ export function useViewerRightRailButtons() {
|
|||||||
redactionActiveType,
|
redactionActiveType,
|
||||||
formFillLabel,
|
formFillLabel,
|
||||||
isFormFillActive,
|
isFormFillActive,
|
||||||
|
rulerLabel,
|
||||||
|
isRulerActive,
|
||||||
|
setIsRulerActive,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useRightRailButtons(viewerButtons);
|
useRightRailButtons(viewerButtons);
|
||||||
|
|||||||
Reference in New Issue
Block a user