mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-03-04 02:20:19 +01: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"
|
||||
toggleBookmarks = "Toggle Bookmarks"
|
||||
print = "Print PDF"
|
||||
ruler = "Ruler / Measure"
|
||||
draw = "Draw"
|
||||
redact = "Redact"
|
||||
exitRedaction = "Exit Redaction Mode"
|
||||
|
||||
@@ -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<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 {
|
||||
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<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
|
||||
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}
|
||||
/>
|
||||
<RulerOverlay
|
||||
containerRef={pdfContainerRef}
|
||||
isActive={isRulerActive}
|
||||
pageMeasureScales={pageMeasureScales}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
747
frontend/src/core/components/viewer/RulerOverlay.tsx
Normal file
747
frontend/src/core/components/viewer/RulerOverlay.tsx
Normal file
@@ -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 { 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<RightRailButtonWithAction[]>(() => {
|
||||
const buttons: RightRailButtonWithAction[] = [
|
||||
{
|
||||
@@ -137,6 +143,24 @@ export function useViewerRightRailButtons() {
|
||||
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',
|
||||
icon: <LocalIcon icon="rotate-left" width="1.5rem" height="1.5rem" />,
|
||||
@@ -317,6 +341,9 @@ export function useViewerRightRailButtons() {
|
||||
redactionActiveType,
|
||||
formFillLabel,
|
||||
isFormFillActive,
|
||||
rulerLabel,
|
||||
isRulerActive,
|
||||
setIsRulerActive,
|
||||
]);
|
||||
|
||||
useRightRailButtons(viewerButtons);
|
||||
|
||||
Reference in New Issue
Block a user