mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
Fix #6121 # Description of Changes <!-- Please provide a summary of the changes, including: - What was changed - Why the change was made - Any challenges encountered Closes (#6121 ) --> This PR expands the viewer ruler/measurement tool with real-world scale support. Users can now apply preset scales, define custom scales, calibrate a scale by drawing a reference measurement and entering its known real-world distance, and view measurements with scaled real-world values. It also refactors PDF `/Measure` and `/VP` scale extraction out of `EmbedPdfViewer` into a dedicated utility, centralizes ruler state management in a dedicated hook, persists ruler measurements and selected scales per file during the browser session, remembers the last calibration unit locally, and updates the ruler overlay so measurements remain aligned with the PDF page during rotation and scrolling. **New Files** - `RulerMeasurementLayer.tsx` - Renders ruler measurements in the SVG overlay, including lines, points, labels, page/scaled values, delete controls, live previews, clear controls, and label visibility modes. - `RulerScaleSettingsButton.tsx` - Adds the scale settings button/popover to the viewer toolbar. - `ScaleCalibrationDialog.tsx` - Provides the calibration modal where users enter a known real-world distance to calculate the scale automatically. - `ScaleSettingsPanel.tsx` - Provides preset scales, custom scale input, calibration entry point, active scale display, and reset controls. - `useMeasurementManager.ts` - Centralizes ruler state, custom scale state, calibration flow, per-file measurements, session persistence, and loading of PDF-derived scale data. - `measurementPreferences.ts` - Persists the last calibration unit in `localStorage`. - `measurementTypes.ts` - Defines shared measurement, point, scale, page scale, and viewport scale types. - `measurementUtils.ts` - Provides unit conversion, scale calculation, validation, formatting, calibration helpers, and session storage helpers. - `measurementUtils.test.ts` - Adds unit tests for scale calculations, unit conversion, preset parsing, ratio derivation, and calibration. - `pdfMeasurementExtraction.ts` - Moves PDF `/Measure` and `/VP` scale extraction into a dedicated utility. **Changed Files** - `EmbedPdfViewer.tsx` - Removes inline PDF scale extraction and delegates ruler/measurement state to `useMeasurementManager`; integrates the ruler overlay, custom scale support, restored measurements, and calibration dialog. - `LocalEmbedPDF.tsx` - Adds page-level metadata used by the ruler overlay, including page width, height, and native page rotation. - `RotateAPIBridge.tsx` - Adds immediate rotation update propagation so ruler measurements can update their page-anchored positions during rotation changes. - `RulerOverlay.tsx` - Refactors the ruler overlay to use shared measurement types/utilities, support custom scales, calibration measurements, restored measurements, measurement change listeners, rotation-aware positioning, and scroll compensation, also holding Alt key will activate pass-through behavior so labels do not block ruler interactions. - `useViewerWorkbenchBarButtons.tsx` - Adds the ruler scale settings action and coordinates ruler, pan mode, and calibration behavior. - `ViewerContext.tsx` - Adds immediate rotation notification support used by ruler measurements while viewer rotation changes are applied. - `en-GB/translation.toml` and `en-US/translation.toml` - Add UI text for scale settings, calibration actions, ruler measurement values, and ruler label controls. --- ## Checklist ### General - [X] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [X] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [x] 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) - [X] I have performed a self-review of my own code - [X] 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) - [x] 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) - [x] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) Current scale panel : <img width="1316" height="515" alt="Captura de tela de 2026-05-31 21-57-53" src="https://github.com/user-attachments/assets/df0ffb7d-4546-450c-8380-409658852a87" /> Current calibration input : <img width="1021" height="522" alt="Captura de tela de 2026-05-31 21-59-52" src="https://github.com/user-attachments/assets/e17c14e7-fec7-42e9-af48-33ef825acb6c" /> Example of usage : <img width="1316" height="760" alt="Captura de tela de 2026-05-31 22-31-01" src="https://github.com/user-attachments/assets/fcd3578e-1eaa-42d3-b30b-3fc87c796f90" /> ### Testing (if applicable) - [X] I have run `task check` to verify linters, typechecks, and tests pass - [X] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --------- Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
This commit is contained in:
co-authored by
Anthony Stirling
parent
84b0b69fe7
commit
fa11034a99
@@ -8631,6 +8631,14 @@ text = "Rotate your PDF pages clockwise or anticlockwise in 90-degree increments
|
||||
[rotate.tooltip.header]
|
||||
title = "Rotate Settings Overview"
|
||||
|
||||
[ruler]
|
||||
clearAll = "Clear all"
|
||||
hideAllLabels = "Hide all labels"
|
||||
hideSmallLabels = "Hide small labels"
|
||||
physicalValues = "Physical"
|
||||
scaled = "Scaled"
|
||||
showAllLabels = "Show all labels"
|
||||
|
||||
[sanitize]
|
||||
desc = "Remove potentially harmful elements from PDF files."
|
||||
sanitizationResults = "Sanitisation Results"
|
||||
@@ -8682,6 +8690,32 @@ title = "Sanitise PDF"
|
||||
tags = "resize,adjust,scale,page size,resize page,scale page,change size,adjust size,enlarge,shrink page,fit to page,A4,letter size"
|
||||
title = "Adjust page-scale"
|
||||
|
||||
[scaleSettings]
|
||||
activeScale = "Active Scale"
|
||||
apply = "Apply Scale"
|
||||
applyCalibration = "Apply Calibration"
|
||||
calculatedScale = "Calculated scale"
|
||||
calibrate = "Calibrate"
|
||||
calibrating = "Calibrating"
|
||||
calibrationDistanceRequired = "Enter a real-world distance greater than zero"
|
||||
calibrationInvalid = "Unable to calculate scale from this measurement"
|
||||
calibrationPaperDistance = "Measured page distance: {{distance}}"
|
||||
calibrationTitle = "Calibrate Scale"
|
||||
calibrationTooltip = "Measure a known distance to calculate the scale automatically"
|
||||
customScale = "Custom Scale"
|
||||
noneSet = "No custom scale set"
|
||||
presets = "Preset Scales"
|
||||
ratio = "Scale Ratio"
|
||||
ratioHelp = "Ratio: 1 page unit = X real-world units"
|
||||
ratioPlaceholder = "e.g., 100"
|
||||
ratioPositive = "Ratio must be greater than zero"
|
||||
ratioRequired = "Ratio is required"
|
||||
realDistance = "Real-world distance"
|
||||
realDistancePlaceholder = "e.g., 5"
|
||||
reset = "Reset to Defaults"
|
||||
unit = "Unit"
|
||||
unitInvalid = "Invalid unit: {{scaleUnit}}"
|
||||
|
||||
[scannerEffect]
|
||||
tags = "scan,simulate,create,fake scan,look scanned,scanner effect,make look scanned,photocopy effect,simulate scanner,realistic scan"
|
||||
|
||||
@@ -10670,6 +10704,7 @@ redact = "Redact"
|
||||
rotateLeft = "Rotate Left"
|
||||
rotateRight = "Rotate Right"
|
||||
ruler = "Ruler / Measure"
|
||||
rulerSettings = "Scale Settings"
|
||||
save = "Save"
|
||||
saveAll = "Save All"
|
||||
saveAs = "Save As"
|
||||
|
||||
@@ -9555,6 +9555,14 @@ text = "Rotate your PDF pages clockwise or counterclockwise in 90-degree increme
|
||||
[rotate.tooltip.header]
|
||||
title = "Rotate Settings Overview"
|
||||
|
||||
[ruler]
|
||||
clearAll = "Clear all"
|
||||
hideAllLabels = "Hide all labels"
|
||||
hideSmallLabels = "Hide small labels"
|
||||
physicalValues = "Physical"
|
||||
scaled = "Scaled"
|
||||
showAllLabels = "Show all labels"
|
||||
|
||||
[sanitize]
|
||||
desc = "Remove potentially harmful elements from PDF files."
|
||||
sanitizationResults = "Sanitization Results"
|
||||
@@ -9606,6 +9614,32 @@ tags = "clean,secure,safe,remove-threats"
|
||||
tags = "resize,adjust,scale,page size,resize page,scale page,change size,adjust size,enlarge,shrink page,fit to page,A4,letter size"
|
||||
title = "Adjust page-scale"
|
||||
|
||||
[scaleSettings]
|
||||
activeScale = "Active Scale"
|
||||
apply = "Apply Scale"
|
||||
applyCalibration = "Apply Calibration"
|
||||
calculatedScale = "Calculated scale"
|
||||
calibrate = "Calibrate"
|
||||
calibrating = "Calibrating"
|
||||
calibrationDistanceRequired = "Enter a real-world distance greater than zero"
|
||||
calibrationInvalid = "Unable to calculate scale from this measurement"
|
||||
calibrationPaperDistance = "Measured page distance: {{distance}}"
|
||||
calibrationTitle = "Calibrate Scale"
|
||||
calibrationTooltip = "Measure a known distance to calculate the scale automatically"
|
||||
customScale = "Custom Scale"
|
||||
noneSet = "No custom scale set"
|
||||
presets = "Preset Scales"
|
||||
ratio = "Scale Ratio"
|
||||
ratioHelp = "Ratio: 1 page unit = X real-world units"
|
||||
ratioPlaceholder = "e.g., 100"
|
||||
ratioPositive = "Ratio must be greater than zero"
|
||||
ratioRequired = "Ratio is required"
|
||||
realDistance = "Real-world distance"
|
||||
realDistancePlaceholder = "e.g., 5"
|
||||
reset = "Reset to Defaults"
|
||||
unit = "Unit"
|
||||
unitInvalid = "Invalid unit: {{scaleUnit}}"
|
||||
|
||||
[scannerEffect]
|
||||
tags = "scan,simulate,create,fake scan,look scanned,scanner effect,make look scanned,photocopy effect,simulate scanner,realistic scan"
|
||||
|
||||
@@ -11624,6 +11658,7 @@ redact = "Redact"
|
||||
rotateLeft = "Rotate Left"
|
||||
rotateRight = "Rotate Right"
|
||||
ruler = "Ruler / Measure"
|
||||
rulerSettings = "Scale Settings"
|
||||
save = "Save"
|
||||
saveAll = "Save All"
|
||||
saveAs = "Save As"
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Box, Center, Text, Stack } from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
@@ -43,109 +37,17 @@ import { useViewerWorkbenchBarButtons } from "@app/components/viewer/useViewerWo
|
||||
import { StampPlacementOverlay } from "@app/components/viewer/StampPlacementOverlay";
|
||||
import {
|
||||
RulerOverlay,
|
||||
type PageMeasureScales,
|
||||
type PageScaleInfo,
|
||||
type ViewportScale,
|
||||
type RulerOverlayHandle,
|
||||
} from "@app/components/viewer/RulerOverlay";
|
||||
import type { PDFDict, PDFNumber } from "@cantoo/pdf-lib";
|
||||
import { useWheelZoom } from "@app/hooks/useWheelZoom";
|
||||
import { useFormFill } from "@app/tools/formFill/FormFillContext";
|
||||
import { FormSaveBar } from "@app/tools/formFill/FormSaveBar";
|
||||
import { useViewerKeyCommand } from "@app/hooks/useViewerKeyCommand";
|
||||
import { useMeasurementManager } from "@app/hooks/useMeasurementManager";
|
||||
import { ScaleCalibrationDialog } from "@app/components/viewer/ScaleCalibrationDialog";
|
||||
import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges";
|
||||
import { alert } from "@app/components/toast";
|
||||
|
||||
// ─── 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;
|
||||
// @cantoo/pdf-lib ships without individual .d.ts files so instanceof can't narrow `unknown`
|
||||
const m = measureObj as PDFDict;
|
||||
const rObj = m.lookup(PDFName.of("R"));
|
||||
const ratioLabel =
|
||||
rObj instanceof PDFString || rObj instanceof PDFHexString
|
||||
? rObj.decodeText()
|
||||
: "";
|
||||
// D = distance array, X = x-axis fallback
|
||||
let fmtArray = m.lookup(PDFName.of("D"));
|
||||
if (!(fmtArray instanceof PDFArray)) fmtArray = m.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 {
|
||||
@@ -378,6 +280,12 @@ const EmbedPdfViewerContent = ({
|
||||
return null;
|
||||
}, [previewFile, activeFiles, activeFileId]);
|
||||
|
||||
// Namespaced identifier for form-fill state; keep this aligned with FormFill.
|
||||
const currentFileId = React.useMemo(
|
||||
() => getFormFillFileId(currentFile),
|
||||
[currentFile],
|
||||
);
|
||||
|
||||
// Stable id — avoids blob URL churn when FileContext recreates file objects each render.
|
||||
const currentFileStableId =
|
||||
currentFile && isStirlingFile(currentFile) ? currentFile.fileId : null;
|
||||
@@ -1148,28 +1056,37 @@ const EmbedPdfViewerContent = ({
|
||||
};
|
||||
}, [applyChanges, setApplyChanges]);
|
||||
|
||||
// Ruler / measurement tool state
|
||||
const [isRulerActive, setIsRulerActive] = useState(false);
|
||||
const [pageMeasureScales, setPageMeasureScales] =
|
||||
useState<PageMeasureScales | null>(null);
|
||||
// Ruler / measurement tool state is handled by the dedicated hook.
|
||||
const rulerOverlayRef = useRef<RulerOverlayHandle | 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]);
|
||||
const {
|
||||
isRulerActive,
|
||||
setIsRulerActive,
|
||||
pageMeasureScales,
|
||||
customScale,
|
||||
handleSetCustomScale,
|
||||
isScaleCalibrationActive,
|
||||
scaleCalibrationMeasurement,
|
||||
startScaleCalibration,
|
||||
cancelScaleCalibration,
|
||||
handleScaleCalibrationMeasurement,
|
||||
applyScaleCalibration,
|
||||
} = useMeasurementManager({
|
||||
currentFile,
|
||||
effectiveFile,
|
||||
rulerOverlayRef,
|
||||
});
|
||||
|
||||
// Register workbench bar buttons for the viewer
|
||||
useViewerWorkbenchBarButtons(isRulerActive, setIsRulerActive);
|
||||
useViewerWorkbenchBarButtons(
|
||||
isRulerActive,
|
||||
setIsRulerActive,
|
||||
customScale,
|
||||
handleSetCustomScale,
|
||||
isScaleCalibrationActive,
|
||||
startScaleCalibration,
|
||||
cancelScaleCalibration,
|
||||
);
|
||||
|
||||
// Auto-fetch form fields when a PDF is loaded in the viewer.
|
||||
// In normal viewer mode, this uses PDFium WASM (frontend-only).
|
||||
@@ -1178,10 +1095,6 @@ const EmbedPdfViewerContent = ({
|
||||
const formFillProviderRef = useRef(isFormFillToolActive);
|
||||
|
||||
// Generate a unique identifier for the current file to detect file changes
|
||||
const currentFileId = React.useMemo(() => {
|
||||
return getFormFillFileId(currentFile);
|
||||
}, [currentFile]);
|
||||
|
||||
useEffect(() => {
|
||||
const fileChanged = currentFileId !== formFillFileIdRef.current;
|
||||
const providerChanged =
|
||||
@@ -1358,11 +1271,22 @@ const EmbedPdfViewerContent = ({
|
||||
signatureConfig={signatureConfig}
|
||||
/>
|
||||
<RulerOverlay
|
||||
ref={rulerOverlayRef}
|
||||
containerRef={pdfContainerRef}
|
||||
isActive={isRulerActive}
|
||||
pageMeasureScales={pageMeasureScales}
|
||||
customScale={customScale}
|
||||
isCalibrationActive={isScaleCalibrationActive}
|
||||
onCalibrationMeasure={handleScaleCalibrationMeasurement}
|
||||
/>
|
||||
</Box>
|
||||
<ScaleCalibrationDialog
|
||||
opened={!!scaleCalibrationMeasurement}
|
||||
measurement={scaleCalibrationMeasurement}
|
||||
defaultUnit={customScale?.unit ?? "m"}
|
||||
onApplyScale={applyScaleCalibration}
|
||||
onClose={cancelScaleCalibration}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import React, {
|
||||
} from "react";
|
||||
import { createPluginRegistration } from "@embedpdf/core";
|
||||
import type { PluginRegistry } from "@embedpdf/core";
|
||||
import { EmbedPDF } from "@embedpdf/core/react";
|
||||
import { EmbedPDF, useDocumentState } from "@embedpdf/core/react";
|
||||
import { usePdfiumEngine } from "@embedpdf/engines/react";
|
||||
import { PrivateContent } from "@app/components/shared/PrivateContent";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
@@ -147,6 +147,59 @@ interface LocalEmbedPDFProps {
|
||||
signatureOverlayApiRef?: React.RefObject<SignatureOverlayAPI | null>;
|
||||
}
|
||||
|
||||
interface ViewerPageContainerProps {
|
||||
documentId: string;
|
||||
pageIndex: number;
|
||||
width: number;
|
||||
height: number;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function normalizePageRotation(rotation: number | null | undefined): number {
|
||||
const value =
|
||||
typeof rotation === "number" && Number.isFinite(rotation) ? rotation : 0;
|
||||
return ((Math.round(value) % 4) + 4) % 4;
|
||||
}
|
||||
|
||||
function ViewerPageContainer({
|
||||
documentId,
|
||||
pageIndex,
|
||||
width,
|
||||
height,
|
||||
children,
|
||||
}: ViewerPageContainerProps) {
|
||||
const documentState = useDocumentState(documentId);
|
||||
const pageRotation = normalizePageRotation(
|
||||
documentState?.document?.pages?.[pageIndex]?.rotation,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-page-index={pageIndex}
|
||||
data-page-width={width}
|
||||
data-page-height={height}
|
||||
data-page-rotation={pageRotation}
|
||||
style={{
|
||||
width,
|
||||
height,
|
||||
position: "relative",
|
||||
overflow: "hidden", // clip overlays (buttons, fields) that extend beyond the page rect
|
||||
userSelect: "none",
|
||||
WebkitUserSelect: "none",
|
||||
MozUserSelect: "none",
|
||||
msUserSelect: "none",
|
||||
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.15)",
|
||||
}}
|
||||
draggable={false}
|
||||
onDragStart={(e) => e.preventDefault()}
|
||||
onDrop={(e) => e.preventDefault()}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LocalEmbedPDF({
|
||||
file,
|
||||
url,
|
||||
@@ -1023,25 +1076,11 @@ export function LocalEmbedPDF({
|
||||
documentId={documentId}
|
||||
pageIndex={pageIndex}
|
||||
>
|
||||
<div
|
||||
data-page-index={pageIndex}
|
||||
data-page-width={width}
|
||||
data-page-height={height}
|
||||
style={{
|
||||
width,
|
||||
height,
|
||||
position: "relative",
|
||||
overflow: "hidden", // clip overlays (buttons, fields) that extend beyond the page rect
|
||||
userSelect: "none",
|
||||
WebkitUserSelect: "none",
|
||||
MozUserSelect: "none",
|
||||
msUserSelect: "none",
|
||||
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.15)",
|
||||
}}
|
||||
draggable={false}
|
||||
onDragStart={(e) => e.preventDefault()}
|
||||
onDrop={(e) => e.preventDefault()}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
<ViewerPageContainer
|
||||
documentId={documentId}
|
||||
pageIndex={pageIndex}
|
||||
width={width}
|
||||
height={height}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
@@ -1173,7 +1212,7 @@ export function LocalEmbedPDF({
|
||||
onSelect={setSelectedSignatureId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ViewerPageContainer>
|
||||
</PagePointerProvider>
|
||||
</Rotate>
|
||||
);
|
||||
|
||||
@@ -21,7 +21,8 @@ export function RotateAPIBridge() {
|
||||
|
||||
function RotateAPIBridgeInner({ documentId }: { documentId: string }) {
|
||||
const { provides: rotate, rotation } = useRotate(documentId);
|
||||
const { registerBridge } = useViewer();
|
||||
const { registerBridge, triggerImmediateRotationUpdate } = useViewer();
|
||||
const isRotateAvailable = rotate !== null;
|
||||
|
||||
// Keep rotate ref updated to avoid re-running effect when object reference changes
|
||||
const rotateRef = useRef(rotate);
|
||||
@@ -29,6 +30,18 @@ function RotateAPIBridgeInner({ documentId }: { documentId: string }) {
|
||||
rotateRef.current = rotate;
|
||||
}, [rotate]);
|
||||
|
||||
// Use the plugin event directly so overlays can update in the same turn as
|
||||
// the page rotation, instead of waiting for this bridge to re-render.
|
||||
useEffect(() => {
|
||||
const currentRotate = rotateRef.current;
|
||||
if (!currentRotate) return;
|
||||
|
||||
triggerImmediateRotationUpdate(currentRotate.getRotation());
|
||||
return currentRotate.onRotateChange((nextRotation) => {
|
||||
triggerImmediateRotationUpdate(nextRotation);
|
||||
});
|
||||
}, [documentId, isRotateAvailable, triggerImmediateRotationUpdate]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentRotate = rotateRef.current;
|
||||
if (currentRotate) {
|
||||
@@ -52,7 +65,7 @@ function RotateAPIBridgeInner({ documentId }: { documentId: string }) {
|
||||
return () => {
|
||||
registerBridge("rotation", null);
|
||||
};
|
||||
}, [rotation, registerBridge]);
|
||||
}, [rotation, isRotateAvailable, registerBridge]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,950 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { MeasureScale, Measurement } from "@app/utils/measurementTypes";
|
||||
import {
|
||||
POINT_TO_UNIT,
|
||||
convertUnit,
|
||||
generateScaleLabel,
|
||||
isImperialUnit,
|
||||
} from "@app/utils/measurementUtils";
|
||||
|
||||
export interface RulerPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface RulerRenderedMeasurement {
|
||||
measurement: Measurement;
|
||||
startS: RulerPoint;
|
||||
endS: RulerPoint;
|
||||
distPts: number;
|
||||
measureScale: MeasureScale | null;
|
||||
}
|
||||
|
||||
export type RulerLabelVisibilityMode = "hideSmall" | "showAll" | "hideAll";
|
||||
|
||||
interface RulerMeasurementLayerProps {
|
||||
measurements: RulerRenderedMeasurement[];
|
||||
zoom: number;
|
||||
selectedId: string | null;
|
||||
hoveredId: string | null;
|
||||
labelVisibilityMode: RulerLabelVisibilityMode;
|
||||
isInteractionPassthroughActive: boolean;
|
||||
liveLine?: {
|
||||
startS: RulerPoint;
|
||||
endS: RulerPoint;
|
||||
measureScale?: MeasureScale | null;
|
||||
} | null;
|
||||
firstPoint?: RulerPoint | null;
|
||||
cursor?: RulerPoint | null;
|
||||
pageContentRef?: React.Ref<SVGGElement>;
|
||||
onSelect: (id: string | null) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onHoverChange: (id: string | null) => void;
|
||||
onClearAll: () => void;
|
||||
onCycleLabelVisibilityMode: () => void;
|
||||
}
|
||||
|
||||
interface LabelBox {
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
interface MeasurementLineLabels {
|
||||
scaled: string;
|
||||
physical: string;
|
||||
}
|
||||
|
||||
export const RULER_DOT_RADIUS = 5;
|
||||
|
||||
const TICK = 10;
|
||||
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;
|
||||
const IDLE_LH = 20;
|
||||
const IDLE_LP = 6;
|
||||
const IDLE_LABEL_MIN_WIDTH = 42;
|
||||
const IDLE_LABEL_MIN_LINE_LENGTH = 88;
|
||||
const IDLE_LABEL_COLLISION_GAP = 6;
|
||||
const LABEL_LINE_GAP = 8;
|
||||
const DELETE_LINE_GAP = 10;
|
||||
const DELETE_LABEL_GAP = 8;
|
||||
const LINE_HIT_WIDTH = 18;
|
||||
const LABEL_MODE_BUTTON_WIDTH = 124;
|
||||
|
||||
function dist(a: RulerPoint, b: RulerPoint): number {
|
||||
return Math.sqrt((b.x - a.x) ** 2 + (b.y - a.y) ** 2);
|
||||
}
|
||||
|
||||
function midpoint(a: RulerPoint, b: RulerPoint): RulerPoint {
|
||||
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
||||
}
|
||||
|
||||
function perpUnit(a: RulerPoint, b: RulerPoint): { 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 };
|
||||
}
|
||||
|
||||
function angleDeg(a: RulerPoint, b: RulerPoint): 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`;
|
||||
}
|
||||
|
||||
function getDecimalPlaces(value: number): number {
|
||||
const absVal = Math.abs(value);
|
||||
|
||||
const decimalRanges = [
|
||||
{ threshold: 1000000, decimals: 0 },
|
||||
{ threshold: 1000, decimals: 2 },
|
||||
{ threshold: 1, decimals: 3 },
|
||||
{ threshold: 0.1, decimals: 3 },
|
||||
{ threshold: 0.01, decimals: 4 },
|
||||
{ threshold: 0.001, decimals: 5 },
|
||||
];
|
||||
|
||||
return decimalRanges.find((r) => absVal >= r.threshold)?.decimals ?? 6;
|
||||
}
|
||||
|
||||
function formatScaled(pts: number, scale: MeasureScale): string {
|
||||
const val = pts * scale.factor;
|
||||
if (val === 0) return `0 ${scale.unit}`;
|
||||
|
||||
const decimals = getDecimalPlaces(val);
|
||||
|
||||
return `${val.toFixed(decimals)} ${scale.unit}`;
|
||||
}
|
||||
|
||||
function scaledCross(pts: number, scale: MeasureScale): string | null {
|
||||
const unit = scale.unit.toLowerCase().trim();
|
||||
|
||||
if (!Object.hasOwn(POINT_TO_UNIT, unit)) return null;
|
||||
|
||||
const valueInUnit = pts * scale.factor;
|
||||
|
||||
if (isImperialUnit(scale.unit)) {
|
||||
const meters = convertUnit(valueInUnit, scale.unit, "m");
|
||||
if (meters === null) return null;
|
||||
const decimals = getDecimalPlaces(meters);
|
||||
return `${meters.toFixed(decimals)} m`;
|
||||
} else {
|
||||
const feet = convertUnit(valueInUnit, scale.unit, "ft");
|
||||
if (feet === null) return null;
|
||||
const decimals = getDecimalPlaces(feet);
|
||||
return `${feet.toFixed(decimals)} ft`;
|
||||
}
|
||||
}
|
||||
|
||||
function getZoomScale(zoom: number): number {
|
||||
return Math.max(0.6, Math.min(1.0, zoom / 1.5));
|
||||
}
|
||||
|
||||
function getMeasurementLabel(
|
||||
distPts: number,
|
||||
measureScale?: MeasureScale | null,
|
||||
): string {
|
||||
return measureScale
|
||||
? formatScaled(distPts, measureScale)
|
||||
: formatDist(distPts);
|
||||
}
|
||||
|
||||
function preferredPerpUnit(
|
||||
a: RulerPoint,
|
||||
b: RulerPoint,
|
||||
): { nx: number; ny: number } {
|
||||
const normal = perpUnit(a, b);
|
||||
if (normal.ny > 0 || (Math.abs(normal.ny) < 0.001 && normal.nx < 0)) {
|
||||
return { nx: -normal.nx, ny: -normal.ny };
|
||||
}
|
||||
return normal;
|
||||
}
|
||||
|
||||
function getLabelCenter(
|
||||
a: RulerPoint,
|
||||
b: RulerPoint,
|
||||
width: number,
|
||||
height: number,
|
||||
): RulerPoint {
|
||||
const mid = midpoint(a, b);
|
||||
const { nx, ny } = preferredPerpUnit(a, b);
|
||||
const projectedHalfSize =
|
||||
(Math.abs(nx) * width) / 2 + (Math.abs(ny) * height) / 2;
|
||||
const offset = projectedHalfSize + RULER_DOT_RADIUS + LABEL_LINE_GAP;
|
||||
return {
|
||||
x: mid.x + nx * offset,
|
||||
y: mid.y + ny * offset,
|
||||
};
|
||||
}
|
||||
|
||||
function getIdleLabelDimensions(label: string, zoom: number) {
|
||||
return {
|
||||
width: Math.max(label.length * 7 + IDLE_LP * 2, IDLE_LABEL_MIN_WIDTH),
|
||||
height: Math.max(18, Math.round(IDLE_LH * getZoomScale(zoom))),
|
||||
};
|
||||
}
|
||||
|
||||
function getIdleLabelBox(
|
||||
startS: RulerPoint,
|
||||
endS: RulerPoint,
|
||||
label: string,
|
||||
zoom: number,
|
||||
): LabelBox {
|
||||
const { width, height } = getIdleLabelDimensions(label, zoom);
|
||||
const center = getLabelCenter(startS, endS, width, height);
|
||||
return {
|
||||
left: center.x - width / 2,
|
||||
top: center.y - height / 2,
|
||||
right: center.x + width / 2,
|
||||
bottom: center.y + height / 2,
|
||||
};
|
||||
}
|
||||
|
||||
function getBoxFromCenter(
|
||||
center: RulerPoint,
|
||||
width: number,
|
||||
height: number,
|
||||
): LabelBox {
|
||||
return {
|
||||
left: center.x - width / 2,
|
||||
top: center.y - height / 2,
|
||||
right: center.x + width / 2,
|
||||
bottom: center.y + height / 2,
|
||||
};
|
||||
}
|
||||
|
||||
function boxesOverlap(a: LabelBox, b: LabelBox, gap: number): boolean {
|
||||
return !(
|
||||
a.right + gap < b.left ||
|
||||
a.left - gap > b.right ||
|
||||
a.bottom + gap < b.top ||
|
||||
a.top - gap > b.bottom
|
||||
);
|
||||
}
|
||||
|
||||
function circleOverlapsBox(
|
||||
center: RulerPoint,
|
||||
radius: number,
|
||||
box: LabelBox,
|
||||
gap: number,
|
||||
): boolean {
|
||||
return (
|
||||
center.x + radius + gap >= box.left &&
|
||||
center.x - radius - gap <= box.right &&
|
||||
center.y + radius + gap >= box.top &&
|
||||
center.y - radius - gap <= box.bottom
|
||||
);
|
||||
}
|
||||
|
||||
function lineUnit(a: RulerPoint, b: RulerPoint): { ux: number; uy: number } {
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const len = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
return { ux: dx / len, uy: dy / len };
|
||||
}
|
||||
|
||||
function getDeleteCenter(
|
||||
startS: RulerPoint,
|
||||
endS: RulerPoint,
|
||||
labelCenter: RulerPoint,
|
||||
labelWidth: number,
|
||||
labelHeight: number,
|
||||
): RulerPoint {
|
||||
const { nx, ny } = preferredPerpUnit(startS, endS);
|
||||
const endpointCenter = {
|
||||
x: endS.x + nx * (RULER_DOT_RADIUS + DEL_R + DELETE_LINE_GAP),
|
||||
y: endS.y + ny * (RULER_DOT_RADIUS + DEL_R + DELETE_LINE_GAP),
|
||||
};
|
||||
const labelBox = getBoxFromCenter(labelCenter, labelWidth, labelHeight);
|
||||
|
||||
if (!circleOverlapsBox(endpointCenter, DEL_R, labelBox, DELETE_LABEL_GAP)) {
|
||||
return endpointCenter;
|
||||
}
|
||||
|
||||
const { ux, uy } = lineUnit(startS, endS);
|
||||
const projectedLabelRadius =
|
||||
(Math.abs(ux) * labelWidth) / 2 + (Math.abs(uy) * labelHeight) / 2;
|
||||
|
||||
return {
|
||||
x: labelCenter.x + ux * (projectedLabelRadius + DEL_R + DELETE_LABEL_GAP),
|
||||
y: labelCenter.y + uy * (projectedLabelRadius + DEL_R + DELETE_LABEL_GAP),
|
||||
};
|
||||
}
|
||||
|
||||
function getVisibleIdleLabelIds(
|
||||
renderedMeasurements: RulerRenderedMeasurement[],
|
||||
zoom: number,
|
||||
): Set<string> {
|
||||
const visibleIds = new Set<string>();
|
||||
const occupiedBoxes: LabelBox[] = [];
|
||||
|
||||
renderedMeasurements
|
||||
.map((item, index) => ({
|
||||
item,
|
||||
index,
|
||||
lineLength: dist(item.startS, item.endS),
|
||||
label: getMeasurementLabel(item.distPts, item.measureScale),
|
||||
}))
|
||||
.sort((a, b) => b.lineLength - a.lineLength || a.index - b.index)
|
||||
.forEach(({ item, lineLength, label }) => {
|
||||
const box = getIdleLabelBox(item.startS, item.endS, label, zoom);
|
||||
const labelWidth = box.right - box.left;
|
||||
const hasEnoughRoom =
|
||||
lineLength >= Math.max(IDLE_LABEL_MIN_LINE_LENGTH, labelWidth + 24);
|
||||
const collides = occupiedBoxes.some((occupiedBox) =>
|
||||
boxesOverlap(box, occupiedBox, IDLE_LABEL_COLLISION_GAP),
|
||||
);
|
||||
|
||||
if (hasEnoughRoom && !collides) {
|
||||
visibleIds.add(item.measurement.id);
|
||||
occupiedBoxes.push(box);
|
||||
}
|
||||
});
|
||||
|
||||
return visibleIds;
|
||||
}
|
||||
|
||||
interface MeasurementLineProps {
|
||||
id: string;
|
||||
startS: RulerPoint;
|
||||
endS: RulerPoint;
|
||||
distPts: number;
|
||||
isSelected: boolean;
|
||||
isHovered: boolean;
|
||||
onSelect: (id: string | null) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onHoverChange: (id: string | null) => void;
|
||||
measureScale?: MeasureScale | null;
|
||||
zoom: number;
|
||||
showIdleLabel: boolean;
|
||||
expandLabelOnHover: boolean;
|
||||
isInteractionPassthroughActive: boolean;
|
||||
labels: MeasurementLineLabels;
|
||||
}
|
||||
|
||||
function MeasurementLine({
|
||||
id,
|
||||
startS,
|
||||
endS,
|
||||
distPts,
|
||||
isSelected,
|
||||
isHovered,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onHoverChange,
|
||||
measureScale,
|
||||
zoom,
|
||||
showIdleLabel,
|
||||
expandLabelOnHover,
|
||||
isInteractionPassthroughActive,
|
||||
labels,
|
||||
}: MeasurementLineProps) {
|
||||
const { nx, ny } = preferredPerpUnit(startS, endS);
|
||||
const ang = angleDeg(startS, endS);
|
||||
const angLabel = `∠ ${ang.toFixed(1)}°`;
|
||||
|
||||
const imperialFirst = !!measureScale && isImperialUnit(measureScale.unit);
|
||||
const distLabel = getMeasurementLabel(distPts, measureScale);
|
||||
|
||||
const hoverLine1 = measureScale
|
||||
? (() => {
|
||||
const primary = formatScaled(distPts, measureScale);
|
||||
const cross = scaledCross(distPts, measureScale);
|
||||
return cross
|
||||
? `${labels.scaled}: ${primary} / ${cross}`
|
||||
: `${labels.scaled}: ${primary}`;
|
||||
})()
|
||||
: `${formatDist(distPts)} / ${formatInches(distPts)}`;
|
||||
|
||||
const hoverLine2 = measureScale
|
||||
? imperialFirst
|
||||
? `${labels.physical}: ${formatInches(distPts)} / ${formatDist(distPts)}`
|
||||
: `${labels.physical}: ${formatDist(distPts)} / ${formatInches(distPts)}`
|
||||
: null;
|
||||
|
||||
const scaleLabel = measureScale
|
||||
? generateScaleLabel(measureScale.ratio, measureScale.unit)
|
||||
: null;
|
||||
const contextLabel = scaleLabel ? `${scaleLabel} ${angLabel}` : angLabel;
|
||||
|
||||
const zoomScale = getZoomScale(zoom);
|
||||
const scaledLH = Math.round(LH * zoomScale);
|
||||
const scaledLH2 = Math.round(LH2 * zoomScale);
|
||||
const scaledLH3 = Math.round(LH3 * zoomScale);
|
||||
const idleDimensions = getIdleLabelDimensions(distLabel, zoom);
|
||||
|
||||
const maxHoverLh = measureScale ? scaledLH3 : scaledLH2;
|
||||
const isHoveredLabelExpanded = isHovered && expandLabelOnHover;
|
||||
const isInspecting = isSelected || isHoveredLabelExpanded;
|
||||
const isCompactIdle = !isInspecting;
|
||||
const showLabel = isSelected || isHovered || showIdleLabel;
|
||||
const showDelete = isSelected || isHovered;
|
||||
const lh = isSelected
|
||||
? maxHoverLh
|
||||
: isHoveredLabelExpanded
|
||||
? scaledLH
|
||||
: idleDimensions.height;
|
||||
|
||||
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 = isSelected
|
||||
? lwHover
|
||||
: isHoveredLabelExpanded
|
||||
? lwNormal
|
||||
: idleDimensions.width;
|
||||
const sw = isSelected ? 3 : 2;
|
||||
|
||||
const labelCenter = getLabelCenter(startS, endS, lw, lh);
|
||||
const deleteCenter = getDeleteCenter(startS, endS, labelCenter, lw, lh);
|
||||
const mono = "'Roboto Mono','Consolas',monospace";
|
||||
|
||||
return (
|
||||
<g
|
||||
data-ruler-interactive="true"
|
||||
onPointerEnter={() => {
|
||||
if (!isInteractionPassthroughActive) {
|
||||
onHoverChange(id);
|
||||
}
|
||||
}}
|
||||
onPointerLeave={() => onHoverChange(null)}
|
||||
onClick={(e) => {
|
||||
if (isInteractionPassthroughActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.stopPropagation();
|
||||
onSelect(isSelected ? null : id);
|
||||
}}
|
||||
style={{
|
||||
pointerEvents: isInteractionPassthroughActive ? "none" : "all",
|
||||
cursor: isInteractionPassthroughActive ? "crosshair" : "pointer",
|
||||
}}
|
||||
>
|
||||
<line
|
||||
x1={startS.x}
|
||||
y1={startS.y}
|
||||
x2={endS.x}
|
||||
y2={endS.y}
|
||||
stroke="transparent"
|
||||
strokeWidth={LINE_HIT_WIDTH}
|
||||
strokeLinecap="round"
|
||||
pointerEvents="stroke"
|
||||
/>
|
||||
|
||||
<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={RULER_DOT_RADIUS}
|
||||
fill="#1e88e5"
|
||||
stroke="white"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<circle
|
||||
cx={endS.x}
|
||||
cy={endS.y}
|
||||
r={RULER_DOT_RADIUS}
|
||||
fill="#1e88e5"
|
||||
stroke="white"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
|
||||
{showLabel && (
|
||||
<g style={{ pointerEvents: "all", cursor: "pointer" }}>
|
||||
<rect
|
||||
x={labelCenter.x - lw / 2 - 4}
|
||||
y={labelCenter.y - lh / 2 - 4}
|
||||
width={lw + 8}
|
||||
height={lh + 8}
|
||||
fill="transparent"
|
||||
stroke="none"
|
||||
/>
|
||||
|
||||
<rect
|
||||
x={labelCenter.x - lw / 2}
|
||||
y={labelCenter.y - lh / 2}
|
||||
width={lw}
|
||||
height={lh}
|
||||
rx={isCompactIdle ? 4 : 5}
|
||||
fill={isCompactIdle ? "rgba(255,255,255,0.94)" : "white"}
|
||||
stroke="#1e88e5"
|
||||
strokeWidth={isCompactIdle ? 1 : 1.5}
|
||||
filter="url(#ruler-shadow)"
|
||||
/>
|
||||
|
||||
{isSelected && measureScale ? (
|
||||
<>
|
||||
<text
|
||||
x={labelCenter.x}
|
||||
y={labelCenter.y - Math.round(17 * zoomScale)}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill="#1e88e5"
|
||||
fontSize={12}
|
||||
fontFamily={mono}
|
||||
fontWeight={600}
|
||||
style={{ userSelect: "none" }}
|
||||
>
|
||||
{hoverLine1}
|
||||
</text>
|
||||
<text
|
||||
x={labelCenter.x}
|
||||
y={labelCenter.y}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill="#546e7a"
|
||||
fontSize={11}
|
||||
fontFamily={mono}
|
||||
fontWeight={500}
|
||||
style={{ userSelect: "none" }}
|
||||
>
|
||||
{hoverLine2}
|
||||
</text>
|
||||
<text
|
||||
x={labelCenter.x}
|
||||
y={labelCenter.y + Math.round(17 * zoomScale)}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill="#5c6bc0"
|
||||
fontSize={10}
|
||||
fontFamily={mono}
|
||||
fontWeight={500}
|
||||
style={{ userSelect: "none" }}
|
||||
>
|
||||
{contextLabel}
|
||||
</text>
|
||||
</>
|
||||
) : isSelected ? (
|
||||
<>
|
||||
<text
|
||||
x={labelCenter.x}
|
||||
y={labelCenter.y - Math.round(6 * zoomScale)}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill="#1e88e5"
|
||||
fontSize={12}
|
||||
fontFamily={mono}
|
||||
fontWeight={600}
|
||||
style={{ userSelect: "none" }}
|
||||
>
|
||||
{hoverLine1}
|
||||
</text>
|
||||
<text
|
||||
x={labelCenter.x}
|
||||
y={labelCenter.y + Math.round(13 * zoomScale)}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill="#5c6bc0"
|
||||
fontSize={11}
|
||||
fontFamily={mono}
|
||||
fontWeight={500}
|
||||
style={{ userSelect: "none" }}
|
||||
>
|
||||
{contextLabel}
|
||||
</text>
|
||||
</>
|
||||
) : (
|
||||
<text
|
||||
x={labelCenter.x}
|
||||
y={labelCenter.y + 1}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill="#1e88e5"
|
||||
fontSize={isCompactIdle ? 10 : 12}
|
||||
fontFamily={mono}
|
||||
fontWeight={600}
|
||||
style={{ userSelect: "none" }}
|
||||
>
|
||||
{distLabel}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
)}
|
||||
|
||||
{showDelete && (
|
||||
<>
|
||||
<line
|
||||
x1={endS.x}
|
||||
y1={endS.y}
|
||||
x2={deleteCenter.x}
|
||||
y2={deleteCenter.y}
|
||||
stroke="transparent"
|
||||
strokeWidth={DEL_R * 2}
|
||||
strokeLinecap="round"
|
||||
pointerEvents="stroke"
|
||||
/>
|
||||
<g
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(id);
|
||||
}}
|
||||
>
|
||||
<circle
|
||||
cx={deleteCenter.x}
|
||||
cy={deleteCenter.y}
|
||||
r={DEL_R}
|
||||
fill="#ef5350"
|
||||
stroke="white"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<text
|
||||
x={deleteCenter.x}
|
||||
y={deleteCenter.y}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill="white"
|
||||
fontSize={12}
|
||||
fontWeight={700}
|
||||
style={{ userSelect: "none" }}
|
||||
>
|
||||
×
|
||||
</text>
|
||||
</g>
|
||||
</>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
interface LiveLineProps {
|
||||
startS: RulerPoint;
|
||||
endS: RulerPoint;
|
||||
zoom: number;
|
||||
measureScale?: MeasureScale | null;
|
||||
}
|
||||
|
||||
function LiveLine({ startS, endS, zoom, measureScale }: LiveLineProps) {
|
||||
const d = dist(startS, endS) / zoom;
|
||||
const { nx, ny } = preferredPerpUnit(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);
|
||||
const lh = Math.round(LH2 * getZoomScale(zoom));
|
||||
const labelCenter = getLabelCenter(startS, endS, lw, lh);
|
||||
|
||||
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={labelCenter.x - lw / 2}
|
||||
y={labelCenter.y - lh / 2}
|
||||
width={lw}
|
||||
height={lh}
|
||||
rx={5}
|
||||
fill="#1e88e5"
|
||||
stroke="white"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<text
|
||||
x={labelCenter.x}
|
||||
y={labelCenter.y - 6}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill="white"
|
||||
fontSize={12}
|
||||
fontFamily="'Roboto Mono','Consolas',monospace"
|
||||
fontWeight={600}
|
||||
style={{ userSelect: "none" }}
|
||||
>
|
||||
{distLabel}
|
||||
</text>
|
||||
<text
|
||||
x={labelCenter.x}
|
||||
y={labelCenter.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>
|
||||
);
|
||||
}
|
||||
|
||||
export function RulerMeasurementLayer({
|
||||
measurements,
|
||||
zoom,
|
||||
selectedId,
|
||||
hoveredId,
|
||||
labelVisibilityMode,
|
||||
isInteractionPassthroughActive,
|
||||
liveLine,
|
||||
firstPoint,
|
||||
cursor,
|
||||
pageContentRef,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onHoverChange,
|
||||
onClearAll,
|
||||
onCycleLabelVisibilityMode,
|
||||
}: RulerMeasurementLayerProps) {
|
||||
const { t } = useTranslation();
|
||||
const measurementLineLabels = React.useMemo(
|
||||
() => ({
|
||||
scaled: t("ruler.scaled", "Scaled"),
|
||||
physical: t("ruler.physicalValues", "Physical"),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
const visibleIdleLabelIds = React.useMemo(
|
||||
() => getVisibleIdleLabelIds(measurements, zoom),
|
||||
[measurements, zoom],
|
||||
);
|
||||
const orderedMeasurements = React.useMemo(() => {
|
||||
const getRank = (item: RulerRenderedMeasurement) => {
|
||||
if (item.measurement.id === hoveredId) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (item.measurement.id === selectedId) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
return [...measurements].sort((a, b) => getRank(a) - getRank(b));
|
||||
}, [hoveredId, measurements, selectedId]);
|
||||
|
||||
const getShouldShowIdleLabel = (measurementId: string) => {
|
||||
if (labelVisibilityMode === "showAll") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (labelVisibilityMode === "hideAll") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return visibleIdleLabelIds.has(measurementId);
|
||||
};
|
||||
|
||||
const labelModeButtonLabel =
|
||||
labelVisibilityMode === "hideSmall"
|
||||
? t("ruler.showAllLabels", "Show all labels")
|
||||
: labelVisibilityMode === "showAll"
|
||||
? t("ruler.hideAllLabels", "Hide all labels")
|
||||
: t("ruler.hideSmallLabels", "Hide small labels");
|
||||
|
||||
return (
|
||||
<>
|
||||
<g ref={pageContentRef}>
|
||||
{orderedMeasurements.map((item) => {
|
||||
const { measurement, startS, endS, distPts, measureScale } = item;
|
||||
return (
|
||||
<MeasurementLine
|
||||
key={measurement.id}
|
||||
id={measurement.id}
|
||||
startS={startS}
|
||||
endS={endS}
|
||||
distPts={distPts}
|
||||
isSelected={
|
||||
!isInteractionPassthroughActive && selectedId === measurement.id
|
||||
}
|
||||
isHovered={
|
||||
!isInteractionPassthroughActive && hoveredId === measurement.id
|
||||
}
|
||||
onSelect={onSelect}
|
||||
onDelete={onDelete}
|
||||
onHoverChange={onHoverChange}
|
||||
measureScale={measureScale}
|
||||
zoom={zoom}
|
||||
showIdleLabel={getShouldShowIdleLabel(measurement.id)}
|
||||
expandLabelOnHover={labelVisibilityMode !== "showAll"}
|
||||
isInteractionPassthroughActive={isInteractionPassthroughActive}
|
||||
labels={measurementLineLabels}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{firstPoint && (
|
||||
<circle
|
||||
cx={firstPoint.x}
|
||||
cy={firstPoint.y}
|
||||
r={RULER_DOT_RADIUS}
|
||||
fill="#1e88e5"
|
||||
stroke="white"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
|
||||
{liveLine && (
|
||||
<LiveLine
|
||||
startS={liveLine.startS}
|
||||
endS={liveLine.endS}
|
||||
zoom={zoom}
|
||||
measureScale={liveLine.measureScale}
|
||||
/>
|
||||
)}
|
||||
|
||||
{cursor && (
|
||||
<g opacity={0.75}>
|
||||
<line
|
||||
x1={cursor.x - 12}
|
||||
y1={cursor.y}
|
||||
x2={cursor.x + 12}
|
||||
y2={cursor.y}
|
||||
stroke="#1e88e5"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<line
|
||||
x1={cursor.x}
|
||||
y1={cursor.y - 12}
|
||||
x2={cursor.x}
|
||||
y2={cursor.y + 12}
|
||||
stroke="#1e88e5"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<circle cx={cursor.x} cy={cursor.y} r={2} fill="#1e88e5" />
|
||||
</g>
|
||||
)}
|
||||
|
||||
{measurements.length > 0 && (
|
||||
<>
|
||||
<g
|
||||
data-ruler-interactive="true"
|
||||
data-ruler-control="true"
|
||||
style={{ pointerEvents: "all", cursor: "pointer" }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClearAll();
|
||||
}}
|
||||
>
|
||||
<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" }}
|
||||
>
|
||||
{t("ruler.clearAll", "Clear all")}
|
||||
</text>
|
||||
</g>
|
||||
<g
|
||||
data-ruler-interactive="true"
|
||||
data-ruler-control="true"
|
||||
style={{ pointerEvents: "all", cursor: "pointer" }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCycleLabelVisibilityMode();
|
||||
}}
|
||||
>
|
||||
<rect
|
||||
x={8}
|
||||
y={40}
|
||||
width={LABEL_MODE_BUTTON_WIDTH}
|
||||
height={26}
|
||||
rx={5}
|
||||
fill="rgba(30,136,229,0.9)"
|
||||
stroke="white"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<text
|
||||
x={8 + LABEL_MODE_BUTTON_WIDTH / 2}
|
||||
y={57}
|
||||
textAnchor="middle"
|
||||
fill="white"
|
||||
fontSize={12}
|
||||
fontFamily="sans-serif"
|
||||
fontWeight={600}
|
||||
style={{ userSelect: "none" }}
|
||||
>
|
||||
{labelModeButtonLabel}
|
||||
</text>
|
||||
</g>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
||||
import { useRef } from "react";
|
||||
import { Popover } from "@mantine/core";
|
||||
import SettingsIcon from "@mui/icons-material/Settings";
|
||||
import { Tooltip, type TooltipProps } from "@app/components/shared/Tooltip";
|
||||
import { ScaleSettingsPanel } from "@app/components/viewer/ScaleSettingsPanel";
|
||||
import type { MeasureScale } from "@app/utils/measurementTypes";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
|
||||
interface RulerScaleSettingsButtonProps {
|
||||
disabled?: boolean;
|
||||
label: string;
|
||||
tooltipPosition: NonNullable<TooltipProps["position"]>;
|
||||
currentScale?: MeasureScale | null;
|
||||
onApplyScale?: (scale: MeasureScale) => void;
|
||||
onResetScale?: () => void;
|
||||
onStartCalibration?: () => void;
|
||||
onCancelCalibration?: () => void;
|
||||
isCalibrationActive?: boolean;
|
||||
}
|
||||
|
||||
export function RulerScaleSettingsButton({
|
||||
disabled,
|
||||
label,
|
||||
tooltipPosition,
|
||||
currentScale,
|
||||
onApplyScale,
|
||||
onResetScale,
|
||||
onStartCalibration,
|
||||
onCancelCalibration,
|
||||
isCalibrationActive,
|
||||
}: RulerScaleSettingsButtonProps) {
|
||||
const scalePopoverRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
position={tooltipPosition}
|
||||
withArrow
|
||||
shadow="md"
|
||||
offset={8}
|
||||
withinPortal
|
||||
>
|
||||
<Popover.Target>
|
||||
<div style={{ display: "inline-flex" }}>
|
||||
<Tooltip
|
||||
content={label}
|
||||
position={tooltipPosition}
|
||||
offset={12}
|
||||
arrow
|
||||
portalTarget={document.body}
|
||||
>
|
||||
<ActionIcon
|
||||
ref={scalePopoverRef}
|
||||
variant="primary"
|
||||
className="workbench-bar-action-icon"
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
>
|
||||
<SettingsIcon sx={{ fontSize: "1rem" }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<ScaleSettingsPanel
|
||||
currentScale={currentScale}
|
||||
onApplyScale={(scale) => {
|
||||
onApplyScale?.(scale);
|
||||
}}
|
||||
onResetScale={() => {
|
||||
onResetScale?.();
|
||||
}}
|
||||
onStartCalibration={onStartCalibration}
|
||||
onCancelCalibration={onCancelCalibration}
|
||||
isCalibrationActive={isCalibrationActive}
|
||||
onClose={() => {
|
||||
scalePopoverRef.current?.click();
|
||||
}}
|
||||
/>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Group, Modal, NumberInput, Select, Stack, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import type { MeasureScale, PagePoint } from "@app/utils/measurementTypes";
|
||||
import {
|
||||
UNIT_OPTIONS,
|
||||
formatPaperDistance,
|
||||
validateRealDistance,
|
||||
calculateCalibratedScale,
|
||||
generateScaleLabel,
|
||||
} from "@app/utils/measurementUtils";
|
||||
import {
|
||||
getLastCalibrationUnit,
|
||||
setLastCalibrationUnit,
|
||||
} from "@app/utils/measurementPreferences";
|
||||
|
||||
// Result of user drawing measurement on PDF (from RulerOverlay)
|
||||
export interface ScaleCalibrationMeasurement {
|
||||
start: PagePoint;
|
||||
end: PagePoint;
|
||||
pdfDistancePts: number;
|
||||
}
|
||||
|
||||
interface ScaleCalibrationDialogProps {
|
||||
opened: boolean;
|
||||
measurement: ScaleCalibrationMeasurement | null;
|
||||
defaultUnit: string;
|
||||
onApplyScale: (scale: MeasureScale) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ScaleCalibrationDialog({
|
||||
opened,
|
||||
measurement,
|
||||
defaultUnit,
|
||||
onApplyScale,
|
||||
onClose,
|
||||
}: ScaleCalibrationDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [realDistance, setRealDistance] = useState<number | null>(null);
|
||||
const [unit, setUnit] = useState(() => getLastCalibrationUnit(defaultUnit));
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const wasOpenedRef = useRef(opened);
|
||||
|
||||
const previewScale = useMemo(() => {
|
||||
if (measurement == null || realDistance == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return calculateCalibratedScale(
|
||||
measurement.pdfDistancePts,
|
||||
realDistance,
|
||||
unit,
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [measurement, realDistance, unit]);
|
||||
|
||||
useEffect(() => {
|
||||
const isOpening = opened && !wasOpenedRef.current;
|
||||
wasOpenedRef.current = opened;
|
||||
|
||||
if (isOpening) {
|
||||
setRealDistance(null);
|
||||
setUnit(getLastCalibrationUnit(defaultUnit));
|
||||
setError(null);
|
||||
}
|
||||
}, [opened, defaultUnit]);
|
||||
|
||||
const handleRealDistanceChange = (value: number | string | null) => {
|
||||
setError(null);
|
||||
|
||||
const validated = validateRealDistance(value);
|
||||
setRealDistance(validated);
|
||||
};
|
||||
|
||||
const handleUnitChange = (newUnit: string | null) => {
|
||||
if (newUnit == null) return;
|
||||
|
||||
setUnit(newUnit);
|
||||
setLastCalibrationUnit(newUnit);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
if (measurement == null || realDistance == null) {
|
||||
setError(
|
||||
t(
|
||||
"scaleSettings.calibrationDistanceRequired",
|
||||
"Enter a real-world distance greater than zero",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const scale = calculateCalibratedScale(
|
||||
measurement.pdfDistancePts,
|
||||
realDistance,
|
||||
unit,
|
||||
);
|
||||
|
||||
setLastCalibrationUnit(unit);
|
||||
onApplyScale(scale);
|
||||
} catch (err) {
|
||||
console.error("[Calibration] Failed to apply:", err);
|
||||
setError(
|
||||
t(
|
||||
"scaleSettings.calibrationInvalid",
|
||||
"Unable to calculate scale from this measurement",
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={t("scaleSettings.calibrationTitle", "Calibrate Scale")}
|
||||
centered
|
||||
size="sm"
|
||||
>
|
||||
<Stack gap="md">
|
||||
{/* Show paper distance automatically */}
|
||||
{measurement && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"scaleSettings.calibrationPaperDistance",
|
||||
"Measured page distance: {{distance}}",
|
||||
{
|
||||
distance: formatPaperDistance(measurement.pdfDistancePts),
|
||||
},
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Input: real distance + unit selector */}
|
||||
<Group grow align="flex-start">
|
||||
<NumberInput
|
||||
label={t("scaleSettings.realDistance", "Real-world distance")}
|
||||
placeholder={t(
|
||||
"scaleSettings.realDistancePlaceholder",
|
||||
"e.g., 5 (actual distance in {{unit}})",
|
||||
{ unit },
|
||||
)}
|
||||
value={realDistance ?? undefined}
|
||||
onChange={handleRealDistanceChange}
|
||||
min={0}
|
||||
step={0.1}
|
||||
decimalScale={4}
|
||||
error={error}
|
||||
/>
|
||||
<Select
|
||||
label={t("scaleSettings.unit", "Unit")}
|
||||
data={UNIT_OPTIONS}
|
||||
value={unit}
|
||||
onChange={handleUnitChange}
|
||||
searchable
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Preview: show calculated scale */}
|
||||
{previewScale && (
|
||||
<Text size="sm" c="blue">
|
||||
{t(
|
||||
"scaleSettings.calculatedScale",
|
||||
"Calculated scale (1 page unit = X real units)",
|
||||
)}
|
||||
: {generateScaleLabel(previewScale.ratio, previewScale.unit)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Actions: Cancel or Apply */}
|
||||
<Group justify="flex-end">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleApply}>
|
||||
{t("scaleSettings.applyCalibration", "Apply Calibration")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { Group, NumberInput, Select, Stack, Text, Grid } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import type { MeasureScale } from "@app/utils/measurementTypes";
|
||||
import {
|
||||
generateScaleLabel,
|
||||
parsePresetRatio,
|
||||
calculateScaleFactor,
|
||||
UNIT_OPTIONS,
|
||||
} from "@app/utils/measurementUtils";
|
||||
|
||||
// ─── Constants ─────────────────────────────────────────────────────────────
|
||||
// Preset scales as architectural ratios (e.g., "1:100" = 1 page unit = 100 real-world units)
|
||||
const PRESET_SCALES = ["1:5", "1:10", "1:20", "1:50", "1:100", "1:150"];
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────
|
||||
interface ScaleSettingsPanelProps {
|
||||
onApplyScale: (scale: MeasureScale) => void;
|
||||
onResetScale?: () => void;
|
||||
onStartCalibration?: () => void;
|
||||
onCancelCalibration?: () => void;
|
||||
isCalibrationActive?: boolean;
|
||||
currentScale?: MeasureScale | null;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function ScaleSettingsPanel({
|
||||
onApplyScale,
|
||||
onResetScale,
|
||||
onStartCalibration,
|
||||
onCancelCalibration,
|
||||
isCalibrationActive = false,
|
||||
currentScale,
|
||||
onClose,
|
||||
}: ScaleSettingsPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [ratio, setRatio] = useState<string | number>(
|
||||
currentScale?.ratio ?? "",
|
||||
);
|
||||
const [unit, setUnit] = useState<string>(currentScale?.unit || "m");
|
||||
const [presetSelected, setPresetSelected] = useState<string | null>(null);
|
||||
const [ratioError, setRatioError] = useState<string | null>(null);
|
||||
const [unitError, setUnitError] = useState<string | null>(null);
|
||||
|
||||
// Helper functions to clear errors
|
||||
const clearErrors = useCallback(() => {
|
||||
setRatioError(null);
|
||||
setUnitError(null);
|
||||
}, []);
|
||||
|
||||
const clearRatioError = useCallback(() => {
|
||||
setRatioError(null);
|
||||
}, []);
|
||||
|
||||
const clearUnitError = useCallback(() => {
|
||||
setUnitError(null);
|
||||
}, []);
|
||||
|
||||
// Sync form fields when currentScale changes
|
||||
useEffect(() => {
|
||||
if (currentScale) {
|
||||
// ratio can be null, so only set it if it exists
|
||||
setRatio(currentScale.ratio ?? "");
|
||||
setUnit(currentScale.unit);
|
||||
clearErrors();
|
||||
|
||||
// Check if current scale matches any preset
|
||||
const matchedPreset =
|
||||
currentScale.ratio !== null
|
||||
? PRESET_SCALES.find(
|
||||
(preset) => parsePresetRatio(preset) === currentScale.ratio,
|
||||
)
|
||||
: null;
|
||||
setPresetSelected(matchedPreset ?? null);
|
||||
} else {
|
||||
// Reset to defaults when no active scale
|
||||
setRatio("");
|
||||
setUnit("m");
|
||||
setPresetSelected(null);
|
||||
clearErrors();
|
||||
}
|
||||
}, [currentScale, clearErrors]);
|
||||
|
||||
const handlePresetClick = (preset: string) => {
|
||||
const presetRatio = parsePresetRatio(preset);
|
||||
if (presetRatio === null) return;
|
||||
|
||||
setPresetSelected(preset);
|
||||
setRatio(presetRatio);
|
||||
clearRatioError();
|
||||
// Live update: apply immediately for presets
|
||||
applyScale(presetRatio, unit);
|
||||
// Close panel after applying preset
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
const handleRatioChange = (value: string | number) => {
|
||||
clearRatioError();
|
||||
|
||||
if (value === "") {
|
||||
setRatio("");
|
||||
setPresetSelected(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setRatio(value);
|
||||
// Clear preset selection when user enters custom value
|
||||
setPresetSelected(null);
|
||||
};
|
||||
|
||||
const parseRatioValue = (value: string | number): number | null => {
|
||||
if (typeof value === "number") return Number.isNaN(value) ? null : value;
|
||||
|
||||
const trimmedValue = value.trim();
|
||||
if (!trimmedValue) return null;
|
||||
|
||||
const numValue = Number(trimmedValue);
|
||||
return Number.isNaN(numValue) ? null : numValue;
|
||||
};
|
||||
|
||||
const handleUnitChange = (val: string | null) => {
|
||||
if (!val) return;
|
||||
setUnit(val);
|
||||
clearUnitError();
|
||||
// Auto-apply immediately if ratio is set (better UX consistency)
|
||||
const numValue = parseRatioValue(ratio);
|
||||
if (numValue && numValue > 0) {
|
||||
// Close panel after auto-applying unit change
|
||||
applyScale(numValue, val, true);
|
||||
}
|
||||
};
|
||||
|
||||
const validateRatio = (): number | null => {
|
||||
const numValue = parseRatioValue(ratio);
|
||||
if (numValue === null || numValue === undefined || isNaN(numValue)) {
|
||||
setRatioError(t("scaleSettings.ratioRequired", "Ratio is required"));
|
||||
return null;
|
||||
}
|
||||
if (numValue <= 0) {
|
||||
setRatioError(
|
||||
t("scaleSettings.ratioPositive", "Ratio must be greater than zero"),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return numValue;
|
||||
};
|
||||
|
||||
const applyScale = (
|
||||
scaleRatio: number,
|
||||
scaleUnit: string,
|
||||
shouldClose = false,
|
||||
) => {
|
||||
if (!scaleRatio || scaleRatio <= 0) return;
|
||||
|
||||
try {
|
||||
// Calculate the raw factor from the ratio
|
||||
const factor = calculateScaleFactor(scaleRatio, scaleUnit);
|
||||
|
||||
const scale: MeasureScale = {
|
||||
factor,
|
||||
ratio: scaleRatio,
|
||||
unit: scaleUnit,
|
||||
};
|
||||
|
||||
onApplyScale(scale);
|
||||
clearUnitError();
|
||||
// Close panel only if explicitly requested
|
||||
if (shouldClose) {
|
||||
onClose?.();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Invalid unit:", scaleUnit, err);
|
||||
setUnitError(
|
||||
t("scaleSettings.unitInvalid", "Invalid unit: {{scaleUnit}}", {
|
||||
scaleUnit,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
const numValue = validateRatio();
|
||||
if (numValue === null) return;
|
||||
// Close panel after manual apply (if validation passed)
|
||||
applyScale(numValue, unit, true);
|
||||
};
|
||||
|
||||
const handleCalibrationToggle = () => {
|
||||
clearErrors();
|
||||
if (isCalibrationActive) {
|
||||
onCancelCalibration?.();
|
||||
} else {
|
||||
onStartCalibration?.();
|
||||
}
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md" style={{ padding: "0.75rem", minWidth: "300px" }}>
|
||||
{/* Preset Scales Section */}
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb="xs">
|
||||
{t("scaleSettings.presets", "Preset Scales")}
|
||||
</Text>
|
||||
<Grid gutter="xs">
|
||||
{PRESET_SCALES.map((preset) => (
|
||||
<Grid.Col span={4} key={preset}>
|
||||
<Button
|
||||
variant={presetSelected === preset ? "primary" : "tertiary"}
|
||||
size="sm"
|
||||
fontSize="xs"
|
||||
fullWidth
|
||||
onClick={() => handlePresetClick(preset)}
|
||||
>
|
||||
{preset}
|
||||
</Button>
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
</div>
|
||||
|
||||
{/* Custom Scale Section */}
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb="xs">
|
||||
{t("scaleSettings.customScale", "Custom Scale")}
|
||||
</Text>
|
||||
<Group grow>
|
||||
<div>
|
||||
<NumberInput
|
||||
key={`ratio-${currentScale?.ratio ?? "empty"}`}
|
||||
label={t("scaleSettings.ratio", "Scale Ratio")}
|
||||
placeholder={t(
|
||||
"scaleSettings.ratioPlaceholder",
|
||||
"e.g., 100 (1 page unit = 100 real units)",
|
||||
)}
|
||||
value={ratio}
|
||||
onChange={handleRatioChange}
|
||||
min={0.1}
|
||||
step={1}
|
||||
size="xs"
|
||||
error={ratioError}
|
||||
/>
|
||||
</div>
|
||||
<div onMouseDown={(e) => e.stopPropagation()}>
|
||||
<Select
|
||||
label={t("scaleSettings.unit", "Unit")}
|
||||
data={UNIT_OPTIONS}
|
||||
value={unit}
|
||||
onChange={handleUnitChange}
|
||||
size="xs"
|
||||
searchable
|
||||
error={unitError}
|
||||
/>
|
||||
</div>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" mt="xs">
|
||||
{t(
|
||||
"scaleSettings.ratioHelp",
|
||||
"Ratio: 1 page unit = X real-world units",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleApply} fullWidth size="sm">
|
||||
{t("scaleSettings.apply", "Apply Scale")}
|
||||
</Button>
|
||||
|
||||
{/* Active Scale Display */}
|
||||
<div
|
||||
style={{
|
||||
padding: "0.5rem",
|
||||
backgroundColor: currentScale
|
||||
? "rgba(30, 136, 229, 0.1)"
|
||||
: "rgba(158, 158, 158, 0.1)",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
>
|
||||
<Text size="xs" c={currentScale ? "blue" : "dimmed"}>
|
||||
<strong>{t("scaleSettings.activeScale", "Active Scale")}:</strong>{" "}
|
||||
{currentScale && currentScale.ratio
|
||||
? generateScaleLabel(currentScale.ratio, currentScale.unit)
|
||||
: currentScale && !currentScale.ratio
|
||||
? `${currentScale.unit} (custom)`
|
||||
: t("scaleSettings.noneSet", "No custom scale set")}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Calibration Mode */}
|
||||
<Button
|
||||
onClick={handleCalibrationToggle}
|
||||
fullWidth
|
||||
variant={isCalibrationActive ? "primary" : "secondary"}
|
||||
size="sm"
|
||||
disabled={
|
||||
isCalibrationActive ? !onCancelCalibration : !onStartCalibration
|
||||
}
|
||||
title={t(
|
||||
"scaleSettings.calibrationTooltip",
|
||||
"Measure a known distance to calculate the scale automatically",
|
||||
)}
|
||||
>
|
||||
{isCalibrationActive
|
||||
? t("scaleSettings.calibrating", "Calibrating")
|
||||
: t("scaleSettings.calibrate", "Calibrate")}
|
||||
</Button>
|
||||
|
||||
{/* Reset Button */}
|
||||
{currentScale && (
|
||||
<Button
|
||||
onClick={() => onResetScale?.()}
|
||||
fullWidth
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
{t("scaleSettings.reset", "Reset to Defaults")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -26,11 +26,19 @@ import StraightenIcon from "@mui/icons-material/Straighten";
|
||||
import LayersIcon from "@mui/icons-material/Layers";
|
||||
import VolumeUpIcon from "@mui/icons-material/VolumeUp";
|
||||
import StopIcon from "@mui/icons-material/Stop";
|
||||
import SettingsIcon from "@mui/icons-material/Settings";
|
||||
import { useViewerReadAloud } from "@app/components/viewer/useViewerReadAloud";
|
||||
import { RulerScaleSettingsButton } from "@app/components/viewer/RulerScaleSettingsButton";
|
||||
import type { MeasureScale } from "@app/utils/measurementTypes";
|
||||
|
||||
export function useViewerWorkbenchBarButtons(
|
||||
isRulerActive?: boolean,
|
||||
setIsRulerActive?: (v: boolean) => void,
|
||||
customScale?: MeasureScale | null,
|
||||
setCustomScale?: (scale: MeasureScale | null) => void,
|
||||
isScaleCalibrationActive?: boolean,
|
||||
startScaleCalibration?: () => void,
|
||||
cancelScaleCalibration?: () => void,
|
||||
) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const viewer = useViewer();
|
||||
@@ -118,11 +126,36 @@ export function useViewerWorkbenchBarButtons(
|
||||
const annotationsLabel = t("workbenchBar.annotations", "Annotations");
|
||||
const formFillLabel = t("workbenchBar.formFill", "Fill Form");
|
||||
const rulerLabel = t("workbenchBar.ruler", "Ruler / Measure");
|
||||
const rulerSettingsLabel = t("workbenchBar.rulerSettings", "Scale Settings");
|
||||
const readAloudLabel = t("workbenchBar.readAloud", "Read Aloud");
|
||||
const readAloudSpeedLabel = t("workbenchBar.readAloudSpeed", "Speed");
|
||||
|
||||
const isFormFillActive = (selectedTool as string) === "formFill";
|
||||
|
||||
const handleStartScaleCalibration = useCallback(() => {
|
||||
startScaleCalibration?.();
|
||||
setIsRulerActive?.(true);
|
||||
if (isPanning) {
|
||||
viewer.panActions.disablePan();
|
||||
setIsPanning(false);
|
||||
}
|
||||
}, [isPanning, setIsRulerActive, startScaleCalibration, viewer.panActions]);
|
||||
|
||||
const handleCancelScaleCalibration = useCallback(() => {
|
||||
cancelScaleCalibration?.();
|
||||
}, [cancelScaleCalibration]);
|
||||
|
||||
const handleApplyRulerScale = useCallback(
|
||||
(scale: MeasureScale) => {
|
||||
setCustomScale?.(scale);
|
||||
},
|
||||
[setCustomScale],
|
||||
);
|
||||
|
||||
const handleResetRulerScale = useCallback(() => {
|
||||
setCustomScale?.(null);
|
||||
}, [setCustomScale]);
|
||||
|
||||
// Filter languages based on available voices
|
||||
const filteredLanguages = useMemo(
|
||||
() =>
|
||||
@@ -234,6 +267,32 @@ export function useViewerWorkbenchBarButtons(
|
||||
}
|
||||
},
|
||||
},
|
||||
// Ruler scale settings button - only visible when ruler is active
|
||||
...(isRulerActive
|
||||
? [
|
||||
{
|
||||
id: "viewer-ruler-settings",
|
||||
icon: <SettingsIcon sx={{ fontSize: "1.5rem" }} />,
|
||||
tooltip: rulerSettingsLabel,
|
||||
ariaLabel: rulerSettingsLabel,
|
||||
section: "top" as const,
|
||||
order: 25.5,
|
||||
render: ({ disabled }: { disabled?: boolean }) => (
|
||||
<RulerScaleSettingsButton
|
||||
disabled={disabled}
|
||||
label={rulerSettingsLabel}
|
||||
tooltipPosition={tooltipPosition}
|
||||
currentScale={customScale}
|
||||
onApplyScale={handleApplyRulerScale}
|
||||
onResetScale={handleResetRulerScale}
|
||||
onStartCalibration={handleStartScaleCalibration}
|
||||
onCancelCalibration={handleCancelScaleCalibration}
|
||||
isCalibrationActive={isScaleCalibrationActive}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "viewer-rotate-left",
|
||||
icon: <LocalIcon icon="rotate-left" width="1rem" height="1rem" />,
|
||||
@@ -553,8 +612,15 @@ export function useViewerWorkbenchBarButtons(
|
||||
formFillLabel,
|
||||
isFormFillActive,
|
||||
rulerLabel,
|
||||
rulerSettingsLabel,
|
||||
isRulerActive,
|
||||
setIsRulerActive,
|
||||
handleStartScaleCalibration,
|
||||
handleCancelScaleCalibration,
|
||||
handleApplyRulerScale,
|
||||
handleResetRulerScale,
|
||||
customScale,
|
||||
isScaleCalibrationActive,
|
||||
readAloudLabel,
|
||||
readAloudSpeedLabel,
|
||||
isReadingAloud,
|
||||
|
||||
@@ -176,6 +176,9 @@ export interface ViewerContextType {
|
||||
registerImmediatePanUpdate: (
|
||||
callback: (isPanning: boolean) => void,
|
||||
) => () => void;
|
||||
registerImmediateRotationUpdate: (
|
||||
callback: (rotation: number) => void,
|
||||
) => () => void;
|
||||
|
||||
// Internal - for bridges to trigger immediate updates
|
||||
triggerImmediateScrollUpdate: (
|
||||
@@ -188,6 +191,7 @@ export interface ViewerContextType {
|
||||
isDualPage?: boolean,
|
||||
) => void;
|
||||
triggerImmediatePanUpdate: (isPanning: boolean) => void;
|
||||
triggerImmediateRotationUpdate: (rotation: number) => void;
|
||||
|
||||
// Action handlers - call EmbedPDF APIs directly
|
||||
scrollActions: ScrollActions;
|
||||
@@ -310,6 +314,10 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
register: registerImmediatePanUpdate,
|
||||
trigger: triggerImmediatePanInternal,
|
||||
} = useImmediateNotifier<[boolean]>();
|
||||
const {
|
||||
register: registerImmediateRotationUpdate,
|
||||
trigger: triggerImmediateRotationInternal,
|
||||
} = useImmediateNotifier<[number]>();
|
||||
|
||||
const triggerImmediateZoomUpdate = useCallback(
|
||||
(percent: number) => {
|
||||
@@ -339,6 +347,13 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
[triggerImmediatePanInternal],
|
||||
);
|
||||
|
||||
const triggerImmediateRotationUpdate = useCallback(
|
||||
(rotation: number) => {
|
||||
triggerImmediateRotationInternal(rotation);
|
||||
},
|
||||
[triggerImmediateRotationInternal],
|
||||
);
|
||||
|
||||
const registerBridge = useCallback(
|
||||
<K extends BridgeKey>(
|
||||
type: K,
|
||||
@@ -638,10 +653,12 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
registerImmediateScrollUpdate,
|
||||
registerImmediateSpreadUpdate,
|
||||
registerImmediatePanUpdate,
|
||||
registerImmediateRotationUpdate,
|
||||
triggerImmediateScrollUpdate,
|
||||
triggerImmediateZoomUpdate,
|
||||
triggerImmediateSpreadUpdate,
|
||||
triggerImmediatePanUpdate,
|
||||
triggerImmediateRotationUpdate,
|
||||
|
||||
// Actions
|
||||
scrollActions,
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import type {
|
||||
Measurement,
|
||||
MeasureScale,
|
||||
PageMeasureScales,
|
||||
} from "@app/utils/measurementTypes";
|
||||
import type { RulerOverlayHandle } from "@app/components/viewer/RulerOverlay";
|
||||
import {
|
||||
loadSessionMap,
|
||||
saveSessionMap,
|
||||
validateMeasureScale,
|
||||
validateMeasurement,
|
||||
} from "@app/utils/measurementUtils";
|
||||
import type { StirlingFile } from "@app/types/fileContext";
|
||||
import { isStirlingFile, getFormFillFileId } from "@app/types/fileContext";
|
||||
import { extractPageMeasureScales } from "@app/utils/pdfMeasurementExtraction";
|
||||
import type { ScaleCalibrationMeasurement } from "@app/components/viewer/ScaleCalibrationDialog";
|
||||
|
||||
// ─── Hook: useMeasurementManager ──────────────────────────────────────────────
|
||||
|
||||
interface EffectiveFileLike {
|
||||
file: Blob | File;
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
type ViewerFile = StirlingFile | File | null | undefined;
|
||||
|
||||
interface UseMeasurementManagerProps {
|
||||
currentFile: ViewerFile;
|
||||
effectiveFile: EffectiveFileLike | null | undefined;
|
||||
rulerOverlayRef: RefObject<RulerOverlayHandle | null>;
|
||||
}
|
||||
|
||||
interface UseMeasurementManagerReturn {
|
||||
isRulerActive: boolean;
|
||||
setIsRulerActive: (v: boolean) => void;
|
||||
pageMeasureScales: PageMeasureScales | null;
|
||||
customScale: MeasureScale | null;
|
||||
handleSetCustomScale: (scale: MeasureScale | null) => void;
|
||||
isScaleCalibrationActive: boolean;
|
||||
scaleCalibrationMeasurement: ScaleCalibrationMeasurement | null;
|
||||
startScaleCalibration: () => void;
|
||||
cancelScaleCalibration: () => void;
|
||||
handleScaleCalibrationMeasurement: (
|
||||
measurement: ScaleCalibrationMeasurement,
|
||||
) => void;
|
||||
applyScaleCalibration: (scale: MeasureScale) => void;
|
||||
}
|
||||
|
||||
export function useMeasurementManager({
|
||||
currentFile,
|
||||
effectiveFile,
|
||||
rulerOverlayRef,
|
||||
}: UseMeasurementManagerProps): UseMeasurementManagerReturn {
|
||||
const [isRulerActive, setIsRulerActive] = useState(false);
|
||||
const [pageMeasureScales, setPageMeasureScales] =
|
||||
useState<PageMeasureScales | null>(null);
|
||||
const [customScale, setCustomScale] = useState<MeasureScale | null>(null);
|
||||
const [isScaleCalibrationActive, setIsScaleCalibrationActive] =
|
||||
useState(false);
|
||||
const [scaleCalibrationMeasurement, setScaleCalibrationMeasurement] =
|
||||
useState<ScaleCalibrationMeasurement | null>(null);
|
||||
const [scalesByFileId, setScalesByFileId] = useState<
|
||||
Map<string, MeasureScale | null>
|
||||
>(new Map());
|
||||
const [measurementsByFileId, setMeasurementsByFileId] = useState<
|
||||
Map<string, Measurement[]>
|
||||
>(new Map());
|
||||
|
||||
const restoredFileKeyRef = useRef<string | null>(null);
|
||||
|
||||
const getStableFileKey = useCallback((file: ViewerFile): string | null => {
|
||||
if (!file) return null;
|
||||
if (isStirlingFile(file)) {
|
||||
return file.fileId;
|
||||
}
|
||||
return getFormFillFileId(file);
|
||||
}, []);
|
||||
|
||||
const currentFileKey = getStableFileKey(currentFile);
|
||||
|
||||
function persistSessionValue(
|
||||
storageKey: string,
|
||||
fileKey: string,
|
||||
value: MeasureScale | Measurement[] | null,
|
||||
label: string,
|
||||
) {
|
||||
try {
|
||||
saveSessionMap(storageKey, fileKey, value);
|
||||
} catch (error) {
|
||||
console.error(`[Measurement] Failed to persist ${label}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
function readStoredScale(fileKey: string): MeasureScale | null | undefined {
|
||||
const storedMap = loadSessionMap("stirling_scales");
|
||||
if (!(fileKey in storedMap)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const storedValue = storedMap[fileKey];
|
||||
return validateMeasureScale(storedValue) ? storedValue : null;
|
||||
}
|
||||
|
||||
function readStoredMeasurements(fileKey: string): Measurement[] | undefined {
|
||||
const storedMap = loadSessionMap("stirling_measurements");
|
||||
if (!(fileKey in storedMap)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const storedValue = storedMap[fileKey];
|
||||
if (!Array.isArray(storedValue)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return storedValue.filter((measurement) =>
|
||||
validateMeasurement(measurement),
|
||||
);
|
||||
}
|
||||
|
||||
function persistScale(fileKey: string, scale: MeasureScale | null) {
|
||||
persistSessionValue("stirling_scales", fileKey, scale, "scale");
|
||||
}
|
||||
|
||||
function persistMeasurements(fileKey: string, value: Measurement[]) {
|
||||
persistSessionValue(
|
||||
"stirling_measurements",
|
||||
fileKey,
|
||||
value,
|
||||
"measurements",
|
||||
);
|
||||
}
|
||||
|
||||
const handleSetCustomScale = useCallback(
|
||||
(scale: MeasureScale | null) => {
|
||||
const fileKey = currentFileKey;
|
||||
|
||||
if (fileKey) {
|
||||
setScalesByFileId((prev) => new Map(prev).set(fileKey, scale));
|
||||
persistScale(fileKey, scale);
|
||||
}
|
||||
|
||||
setCustomScale(scale);
|
||||
setScaleCalibrationMeasurement(null);
|
||||
setIsScaleCalibrationActive(false);
|
||||
},
|
||||
[currentFileKey],
|
||||
);
|
||||
|
||||
const handleSetRulerActive = useCallback((active: boolean) => {
|
||||
setIsRulerActive(active);
|
||||
if (!active) {
|
||||
setScaleCalibrationMeasurement(null);
|
||||
setIsScaleCalibrationActive(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startScaleCalibration = useCallback(() => {
|
||||
setScaleCalibrationMeasurement(null);
|
||||
setIsScaleCalibrationActive(true);
|
||||
setIsRulerActive(true);
|
||||
}, []);
|
||||
|
||||
const cancelScaleCalibration = useCallback(() => {
|
||||
setScaleCalibrationMeasurement(null);
|
||||
setIsScaleCalibrationActive(false);
|
||||
}, []);
|
||||
|
||||
const handleScaleCalibrationMeasurement = useCallback(
|
||||
(measurement: ScaleCalibrationMeasurement) => {
|
||||
setScaleCalibrationMeasurement(measurement);
|
||||
setIsScaleCalibrationActive(false);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const applyScaleCalibration = useCallback(
|
||||
(scale: MeasureScale) => {
|
||||
handleSetCustomScale(scale);
|
||||
setScaleCalibrationMeasurement(null);
|
||||
setIsScaleCalibrationActive(false);
|
||||
},
|
||||
[handleSetCustomScale],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentFileKey) {
|
||||
setPageMeasureScales(null);
|
||||
setCustomScale(null);
|
||||
setScaleCalibrationMeasurement(null);
|
||||
setIsScaleCalibrationActive(false);
|
||||
setIsRulerActive(false);
|
||||
rulerOverlayRef.current?.clearAll(true);
|
||||
restoredFileKeyRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (restoredFileKeyRef.current === currentFileKey) {
|
||||
return;
|
||||
}
|
||||
restoredFileKeyRef.current = currentFileKey;
|
||||
setScaleCalibrationMeasurement(null);
|
||||
setIsScaleCalibrationActive(false);
|
||||
|
||||
const storedScale = readStoredScale(currentFileKey);
|
||||
const savedScale =
|
||||
storedScale === undefined
|
||||
? (scalesByFileId.get(currentFileKey) ?? null)
|
||||
: storedScale;
|
||||
|
||||
setCustomScale(savedScale);
|
||||
|
||||
const storedMeasurements = readStoredMeasurements(currentFileKey);
|
||||
const savedMeasurements =
|
||||
storedMeasurements === undefined
|
||||
? (measurementsByFileId.get(currentFileKey) ?? [])
|
||||
: storedMeasurements;
|
||||
|
||||
rulerOverlayRef.current?.clearAll(true);
|
||||
rulerOverlayRef.current?.restoreMeasurements(savedMeasurements);
|
||||
}, [currentFileKey, measurementsByFileId, rulerOverlayRef, scalesByFileId]);
|
||||
|
||||
useEffect(() => {
|
||||
const fileBlob = effectiveFile?.file;
|
||||
if (!fileBlob || !currentFileKey) {
|
||||
setPageMeasureScales(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setPageMeasureScales(null);
|
||||
|
||||
let cancelled = false;
|
||||
extractPageMeasureScales(fileBlob)
|
||||
.then((scales) => {
|
||||
if (!cancelled) {
|
||||
setPageMeasureScales(scales);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!cancelled) {
|
||||
console.warn("[Measurement] Failed to load PDF scales", error);
|
||||
setPageMeasureScales(null);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentFileKey, effectiveFile?.file]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!rulerOverlayRef.current || !currentFileKey) return;
|
||||
|
||||
const unsubscribe = rulerOverlayRef.current.onMeasurementsChange(
|
||||
(newMeasurements: Measurement[]) => {
|
||||
const validMeasurements = newMeasurements.filter((measurement) =>
|
||||
validateMeasurement(measurement),
|
||||
);
|
||||
|
||||
setMeasurementsByFileId((prev) =>
|
||||
new Map(prev).set(currentFileKey, validMeasurements),
|
||||
);
|
||||
persistMeasurements(currentFileKey, validMeasurements);
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
if (typeof unsubscribe === "function") {
|
||||
unsubscribe();
|
||||
}
|
||||
};
|
||||
}, [currentFileKey, rulerOverlayRef]);
|
||||
|
||||
return {
|
||||
isRulerActive,
|
||||
setIsRulerActive: handleSetRulerActive,
|
||||
pageMeasureScales,
|
||||
customScale,
|
||||
handleSetCustomScale,
|
||||
isScaleCalibrationActive,
|
||||
scaleCalibrationMeasurement,
|
||||
startScaleCalibration,
|
||||
cancelScaleCalibration,
|
||||
handleScaleCalibrationMeasurement,
|
||||
applyScaleCalibration,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Persist calibration unit preference across sessions
|
||||
const STORAGE_KEY_LAST_CALIBRATION_UNIT = "stirling_calibration_last_unit";
|
||||
|
||||
export function getLastCalibrationUnit(defaultUnit: string): string {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY_LAST_CALIBRATION_UNIT);
|
||||
return stored && stored.trim() ? stored : defaultUnit;
|
||||
} catch {
|
||||
// Storage unavailable - private browsing or quota exceeded
|
||||
return defaultUnit;
|
||||
}
|
||||
}
|
||||
|
||||
export function setLastCalibrationUnit(unit: string): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY_LAST_CALIBRATION_UNIT, unit);
|
||||
} catch (error) {
|
||||
// Storage unavailable - preference won't be retained
|
||||
console.debug(
|
||||
"[MeasurementPreferences] Unable to persist unit preference:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Page coordinates with absolute page index
|
||||
export interface PagePoint {
|
||||
pageIndex: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
// Real-world units per PDF point (factor) vs. architectural ratio for display
|
||||
export interface MeasureScale {
|
||||
factor: number; // Real-world units per PDF point
|
||||
ratio: number | null; // Architectural ratio (e.g., 100 for "1:100") - display only
|
||||
unit: string; // m, cm, mm, km, ft, in, yd, mi
|
||||
}
|
||||
|
||||
export type MeasureScaleLike = MeasureScale;
|
||||
|
||||
// Calibration result with full context for audit trail
|
||||
export interface CalibrationMetadata {
|
||||
pdfDistancePts: number; // PDF space distance in points
|
||||
realDistance: number; // User-specified real-world distance
|
||||
scale: MeasureScale; // Resulting calculated scale
|
||||
timestamp: string; // ISO 8601 format
|
||||
unitUsed: string; // Unit active during calibration
|
||||
}
|
||||
|
||||
// Single measurement between two page points on same page
|
||||
export interface Measurement {
|
||||
id: string;
|
||||
start: PagePoint;
|
||||
end: PagePoint;
|
||||
}
|
||||
|
||||
// Viewport area with its own scale (for multi-region PDFs)
|
||||
export interface ViewportScale {
|
||||
bbox: [number, number, number, number] | null; // PDF user space or null for entire page
|
||||
scale: MeasureScale;
|
||||
}
|
||||
|
||||
// Scale information for a single page with all viewports
|
||||
export interface PageScaleInfo {
|
||||
viewports: ViewportScale[];
|
||||
pageHeight: number; // PDF points - used to flip screen-y to PDF-y
|
||||
}
|
||||
|
||||
export type PageMeasureScales = Map<number, PageScaleInfo>;
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
POINT_TO_UNIT,
|
||||
calculateCalibratedScale,
|
||||
calculateScaleFactor,
|
||||
convertUnit,
|
||||
deriveRatioFromFactor,
|
||||
parsePresetRatio,
|
||||
} from "@app/utils/measurementUtils";
|
||||
|
||||
describe("measurementUtils", () => {
|
||||
describe("calculateScaleFactor", () => {
|
||||
test("calculates real-world units per PDF point from a scale ratio", () => {
|
||||
expect(calculateScaleFactor(100, "m")).toBeCloseTo(POINT_TO_UNIT.m * 100);
|
||||
expect(calculateScaleFactor(50, " cm ")).toBeCloseTo(
|
||||
POINT_TO_UNIT.cm * 50,
|
||||
);
|
||||
expect(calculateScaleFactor(12, "FT")).toBeCloseTo(POINT_TO_UNIT.ft * 12);
|
||||
});
|
||||
|
||||
test("rejects invalid scale ratios", () => {
|
||||
expect(() => calculateScaleFactor(0, "m")).toThrow("Invalid scale ratio");
|
||||
expect(() => calculateScaleFactor(-1, "m")).toThrow(
|
||||
"Invalid scale ratio",
|
||||
);
|
||||
expect(() => calculateScaleFactor(Number.NaN, "m")).toThrow(
|
||||
"Invalid scale ratio",
|
||||
);
|
||||
expect(() => calculateScaleFactor(Number.POSITIVE_INFINITY, "m")).toThrow(
|
||||
"Invalid scale ratio",
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects unsupported units", () => {
|
||||
expect(() => calculateScaleFactor(100, "px")).toThrow("Unsupported unit");
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertUnit", () => {
|
||||
test("converts representative metric and imperial values", () => {
|
||||
expect(convertUnit(1, "m", "cm")).toBeCloseTo(100);
|
||||
expect(convertUnit(12, "in", "ft")).toBeCloseTo(1);
|
||||
expect(convertUnit(3, "ft", "yd")).toBeCloseTo(1);
|
||||
expect(convertUnit(1, "ft", "m")).toBeCloseTo(0.3048);
|
||||
});
|
||||
|
||||
test("returns null for invalid values or unsupported units", () => {
|
||||
expect(convertUnit(Number.NaN, "m", "cm")).toBeNull();
|
||||
expect(convertUnit(Number.POSITIVE_INFINITY, "m", "cm")).toBeNull();
|
||||
expect(convertUnit(1, "px", "cm")).toBeNull();
|
||||
expect(convertUnit(1, "m", "px")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePresetRatio", () => {
|
||||
test("parses supported preset ratios", () => {
|
||||
expect(parsePresetRatio("1:5")).toBe(5);
|
||||
expect(parsePresetRatio("1:100")).toBe(100);
|
||||
expect(parsePresetRatio(" 1 : 150 ")).toBe(150);
|
||||
});
|
||||
|
||||
test("returns null for malformed or non-positive presets", () => {
|
||||
expect(parsePresetRatio("2:100")).toBeNull();
|
||||
expect(parsePresetRatio("1:0")).toBeNull();
|
||||
expect(parsePresetRatio("1:-10")).toBeNull();
|
||||
expect(parsePresetRatio("1:not-a-number")).toBeNull();
|
||||
expect(parsePresetRatio("bad")).toBeNull();
|
||||
expect(parsePresetRatio("1:10:20")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveRatioFromFactor", () => {
|
||||
test("recovers the scale ratio from a factor and unit", () => {
|
||||
const factor = calculateScaleFactor(100, "m");
|
||||
|
||||
expect(deriveRatioFromFactor(factor, "m")).toBeCloseTo(100);
|
||||
});
|
||||
|
||||
test("returns null for invalid factors or unsupported units", () => {
|
||||
expect(deriveRatioFromFactor(0, "m")).toBeNull();
|
||||
expect(deriveRatioFromFactor(-1, "m")).toBeNull();
|
||||
expect(deriveRatioFromFactor(Number.NaN, "m")).toBeNull();
|
||||
expect(deriveRatioFromFactor(1, "px")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateCalibratedScale", () => {
|
||||
test("calculates a calibrated scale from a known physical distance", () => {
|
||||
const scale = calculateCalibratedScale(72, 1, "in");
|
||||
|
||||
expect(scale.factor).toBeCloseTo(POINT_TO_UNIT.in);
|
||||
expect(scale.ratio).toBeCloseTo(1);
|
||||
expect(scale.unit).toBe("in");
|
||||
});
|
||||
|
||||
test("calculates architectural ratios for metric calibration", () => {
|
||||
const scale = calculateCalibratedScale(72, 0.0254, "m");
|
||||
|
||||
expect(scale.factor).toBeCloseTo(POINT_TO_UNIT.m);
|
||||
expect(scale.ratio).toBeCloseTo(1);
|
||||
expect(scale.unit).toBe("m");
|
||||
});
|
||||
|
||||
test("rejects invalid calibration inputs", () => {
|
||||
expect(() => calculateCalibratedScale(0, 1, "m")).toThrow(
|
||||
"Invalid PDF distance",
|
||||
);
|
||||
expect(() => calculateCalibratedScale(72, 0, "m")).toThrow(
|
||||
"Invalid real-world distance",
|
||||
);
|
||||
expect(() => calculateCalibratedScale(72, 1, "px")).toThrow(
|
||||
"Unsupported unit",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,398 @@
|
||||
// PDF point to real-world unit conversions
|
||||
|
||||
import type {
|
||||
Measurement,
|
||||
MeasureScale,
|
||||
PagePoint,
|
||||
CalibrationMetadata,
|
||||
} from "@app/utils/measurementTypes";
|
||||
|
||||
// 1 PDF point in meters (1/72 inch)
|
||||
const POINT_TO_METERS = 0.0254 / 72;
|
||||
|
||||
// Conversion factors: units per PDF point
|
||||
export const POINT_TO_UNIT = {
|
||||
m: POINT_TO_METERS,
|
||||
cm: POINT_TO_METERS * 100,
|
||||
mm: POINT_TO_METERS * 1000,
|
||||
km: POINT_TO_METERS / 1000,
|
||||
ft: POINT_TO_METERS / 0.3048,
|
||||
in: POINT_TO_METERS / 0.0254,
|
||||
yd: POINT_TO_METERS / 0.9144,
|
||||
mi: POINT_TO_METERS / 1609.344,
|
||||
} as const;
|
||||
|
||||
// Valid measurement units from POINT_TO_UNIT
|
||||
export type MeasurementUnit = keyof typeof POINT_TO_UNIT;
|
||||
|
||||
function normalizeUnit(unit: string): string {
|
||||
return unit.toLowerCase().trim();
|
||||
}
|
||||
|
||||
function isMeasurementUnit(unit: string): unit is MeasurementUnit {
|
||||
return Object.hasOwn(POINT_TO_UNIT, unit);
|
||||
}
|
||||
|
||||
export function getUnitFactor(unit: string): number | undefined {
|
||||
const normalized = normalizeUnit(unit);
|
||||
if (!isMeasurementUnit(normalized)) {
|
||||
return undefined;
|
||||
}
|
||||
return POINT_TO_UNIT[normalized];
|
||||
}
|
||||
|
||||
export function calculateScaleFactor(ratio: number, unit: string): number {
|
||||
if (!Number.isFinite(ratio) || ratio <= 0) {
|
||||
throw new Error(`Invalid scale ratio: ${ratio}`);
|
||||
}
|
||||
|
||||
const normalized = normalizeUnit(unit);
|
||||
if (!isMeasurementUnit(normalized)) {
|
||||
throw new Error(`Unsupported unit: ${unit}`);
|
||||
}
|
||||
|
||||
return POINT_TO_UNIT[normalized] * ratio;
|
||||
}
|
||||
|
||||
export function generateScaleLabel(ratio: number | null, unit: string): string {
|
||||
if (ratio === null || ratio === undefined) {
|
||||
return unit;
|
||||
}
|
||||
const display = Number.isInteger(ratio)
|
||||
? ratio.toString()
|
||||
: ratio.toFixed(2).replace(/\.?0+$/, "");
|
||||
return `1:${display} (${unit})`;
|
||||
}
|
||||
|
||||
// Imperial units
|
||||
const IMPERIAL_UNITS = ["ft", "in", "yd", "mi"] as const;
|
||||
export function isImperialUnit(unit: string): boolean {
|
||||
const normalized = normalizeUnit(unit);
|
||||
return isMeasurementUnit(normalized)
|
||||
? (IMPERIAL_UNITS as readonly MeasurementUnit[]).includes(normalized)
|
||||
: false;
|
||||
}
|
||||
|
||||
export function convertUnit(
|
||||
value: number,
|
||||
sourceUnit: string,
|
||||
targetUnit: string,
|
||||
): number | null {
|
||||
if (!Number.isFinite(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const src = normalizeUnit(sourceUnit);
|
||||
const tgt = normalizeUnit(targetUnit);
|
||||
|
||||
if (!isMeasurementUnit(src) || !isMeasurementUnit(tgt)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sourceFactor = POINT_TO_UNIT[src];
|
||||
const targetFactor = POINT_TO_UNIT[tgt];
|
||||
|
||||
return value * (targetFactor / sourceFactor);
|
||||
}
|
||||
|
||||
export function parsePresetRatio(preset: string): number | null {
|
||||
const parts = preset.split(":");
|
||||
|
||||
// Must have exactly 2 parts and first part must be "1"
|
||||
if (parts.length !== 2 || parts[0].trim() !== "1") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = Number(parts[1]);
|
||||
return Number.isFinite(value) && value > 0 ? value : null;
|
||||
}
|
||||
|
||||
// UI dropdown options - shared across components
|
||||
export const UNIT_OPTIONS = [
|
||||
{ value: "m", label: "Meters (m)" },
|
||||
{ value: "cm", label: "Centimeters (cm)" },
|
||||
{ value: "mm", label: "Millimeters (mm)" },
|
||||
{ value: "km", label: "Kilometers (km)" },
|
||||
{ value: "ft", label: "Feet (ft)" },
|
||||
{ value: "in", label: "Inches (in)" },
|
||||
{ value: "yd", label: "Yards (yd)" },
|
||||
{ value: "mi", label: "Miles (mi)" },
|
||||
] as const;
|
||||
|
||||
const MAX_SESSION_ENTRIES = 50;
|
||||
const TRIMMED_SESSION_ENTRIES = 40;
|
||||
|
||||
/**
|
||||
* Detect quota exceeded errors across browser implementations.
|
||||
* Handles: name "QuotaExceededError", code 22 (legacy), "NS_ERROR_DOM_QUOTA_REACHED"
|
||||
*
|
||||
* Note: DOMException may not be instanceof Error in all browsers,
|
||||
* so we check by shape and properties rather than type.
|
||||
* Note: DOMException.code is deprecated but kept for legacy browser support.
|
||||
*/
|
||||
function isQuotaExceededError(error: unknown): boolean {
|
||||
if (error === null || error === undefined) return false;
|
||||
|
||||
// Check if it's a DOMException when available (standard)
|
||||
if (typeof DOMException !== "undefined" && error instanceof DOMException) {
|
||||
if (error.name === "QuotaExceededError") return true;
|
||||
}
|
||||
|
||||
// Fallback: check by shape for any object with name/code properties
|
||||
if (typeof error === "object") {
|
||||
const err = error as Record<string, unknown>;
|
||||
|
||||
// Modern standard: check name property (works in all modern browsers)
|
||||
if (err.name === "QuotaExceededError") return true;
|
||||
if (err.name === "NS_ERROR_DOM_QUOTA_REACHED") return true;
|
||||
|
||||
// Legacy support: check deprecated code property for very old browsers
|
||||
// Use Object.hasOwn for safe own-property check
|
||||
if (Object.hasOwn(err, "code") && err.code === 22) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Load entries from sessionStorage
|
||||
export function loadSessionMap(key: string): Record<string, unknown> {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(key);
|
||||
if (!raw) return {};
|
||||
|
||||
const data = JSON.parse(raw);
|
||||
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return data as Record<string, unknown>;
|
||||
} catch {
|
||||
// Silently return empty object on parse error
|
||||
try {
|
||||
sessionStorage.removeItem(key);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// Save entry to sessionStorage with quota management.
|
||||
export function saveSessionMap(
|
||||
key: string,
|
||||
fileKey: string,
|
||||
value: MeasureScale | Measurement[] | null,
|
||||
): void {
|
||||
if (!fileKey) return;
|
||||
|
||||
try {
|
||||
const existing: Record<string, unknown> = {
|
||||
...loadSessionMap(key),
|
||||
};
|
||||
|
||||
// Delete first to move fileKey to end (maintains insertion order recency)
|
||||
delete existing[fileKey];
|
||||
existing[fileKey] = value;
|
||||
|
||||
// Trim back below the max to avoid pruning again on every subsequent save.
|
||||
const keys = Object.keys(existing);
|
||||
if (keys.length > MAX_SESSION_ENTRIES) {
|
||||
const entriesToDelete = keys.slice(
|
||||
0,
|
||||
keys.length - TRIMMED_SESSION_ENTRIES,
|
||||
);
|
||||
entriesToDelete.forEach((k) => delete existing[k]);
|
||||
}
|
||||
|
||||
sessionStorage.setItem(key, JSON.stringify(existing));
|
||||
} catch (e) {
|
||||
// Quota exceeded - try clearing and retrying (handles cross-browser error variants)
|
||||
if (isQuotaExceededError(e)) {
|
||||
try {
|
||||
sessionStorage.removeItem(key);
|
||||
// Retry with fresh storage
|
||||
const fresh: Record<string, unknown> = { [fileKey]: value };
|
||||
sessionStorage.setItem(key, JSON.stringify(fresh));
|
||||
} catch {
|
||||
// Silently ignore if retry fails - data loss is acceptable
|
||||
}
|
||||
}
|
||||
// Silently ignore other storage errors
|
||||
}
|
||||
}
|
||||
|
||||
// Validation helpers
|
||||
|
||||
export function validatePagePoint(obj: unknown): obj is PagePoint {
|
||||
if (typeof obj !== "object" || obj === null) return false;
|
||||
|
||||
const pt = obj as Record<string, unknown>;
|
||||
return (
|
||||
typeof pt.pageIndex === "number" &&
|
||||
Number.isFinite(pt.pageIndex) &&
|
||||
pt.pageIndex >= 0 &&
|
||||
typeof pt.x === "number" &&
|
||||
Number.isFinite(pt.x) &&
|
||||
typeof pt.y === "number" &&
|
||||
Number.isFinite(pt.y)
|
||||
);
|
||||
}
|
||||
|
||||
// MeasureScale can be null (reset) or valid object
|
||||
export function validateMeasureScale(obj: unknown): obj is MeasureScale | null {
|
||||
// null is allowed (reset to default)
|
||||
if (obj === null) return true;
|
||||
|
||||
if (typeof obj !== "object") return false;
|
||||
|
||||
const s = obj as Record<string, unknown>;
|
||||
|
||||
// Validate factor: must be positive finite number
|
||||
if (
|
||||
typeof s.factor !== "number" ||
|
||||
!Number.isFinite(s.factor) ||
|
||||
s.factor <= 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate ratio: optional, but if present must be positive finite number
|
||||
if (
|
||||
s.ratio !== null &&
|
||||
(typeof s.ratio !== "number" || !Number.isFinite(s.ratio) || s.ratio <= 0)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate unit: must be non-empty string and exist in POINT_TO_UNIT
|
||||
if (typeof s.unit !== "string" || s.unit.trim().length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalized = normalizeUnit(s.unit);
|
||||
if (!isMeasurementUnit(normalized)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Reject cross-page measurements
|
||||
export function validateMeasurement(obj: unknown): obj is Measurement {
|
||||
if (typeof obj !== "object" || obj === null) return false;
|
||||
|
||||
const m = obj as Record<string, unknown>;
|
||||
|
||||
// Validate structure
|
||||
if (
|
||||
!(
|
||||
typeof m.id === "string" &&
|
||||
m.id.trim().length > 0 &&
|
||||
validatePagePoint(m.start) &&
|
||||
validatePagePoint(m.end)
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Reject cross-page measurements
|
||||
const start = m.start as PagePoint;
|
||||
const end = m.end as PagePoint;
|
||||
if (start.pageIndex !== end.pageIndex) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function formatPaperDistance(distancePts: number): string {
|
||||
if (!Number.isFinite(distancePts) || distancePts < 0) {
|
||||
return "0 mm";
|
||||
}
|
||||
|
||||
const inches = distancePts / 72;
|
||||
const mm = inches * 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`;
|
||||
}
|
||||
|
||||
export function validateRealDistance(value: unknown): number | null {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const num = typeof value === "number" ? value : Number(value);
|
||||
|
||||
if (!Number.isFinite(num) || num <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return num;
|
||||
}
|
||||
|
||||
export function deriveRatioFromFactor(
|
||||
factor: number,
|
||||
unit: string,
|
||||
): number | null {
|
||||
if (!Number.isFinite(factor) || factor <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseFactor = getUnitFactor(unit);
|
||||
if (!baseFactor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ratio = factor / baseFactor
|
||||
const ratio = factor / baseFactor;
|
||||
return Number.isFinite(ratio) && ratio > 0 ? ratio : null;
|
||||
}
|
||||
|
||||
export function calculateCalibratedScale(
|
||||
pdfDistancePts: number,
|
||||
realDistance: number,
|
||||
unit: string,
|
||||
): MeasureScale {
|
||||
if (!Number.isFinite(pdfDistancePts) || pdfDistancePts <= 0) {
|
||||
throw new Error("Invalid PDF distance (must be positive)");
|
||||
}
|
||||
|
||||
if (!Number.isFinite(realDistance) || realDistance <= 0) {
|
||||
throw new Error("Invalid real-world distance (must be positive)");
|
||||
}
|
||||
|
||||
const baseFactor = getUnitFactor(unit);
|
||||
if (!baseFactor) {
|
||||
throw new Error(`Unsupported unit: ${unit}`);
|
||||
}
|
||||
|
||||
const factor = realDistance / pdfDistancePts;
|
||||
const ratio = deriveRatioFromFactor(factor, unit);
|
||||
|
||||
return {
|
||||
factor,
|
||||
ratio,
|
||||
unit,
|
||||
};
|
||||
}
|
||||
|
||||
export function createCalibrationMetadata(
|
||||
pdfDistancePts: number,
|
||||
realDistance: number,
|
||||
scale: MeasureScale,
|
||||
unitUsed: string,
|
||||
): CalibrationMetadata {
|
||||
return {
|
||||
pdfDistancePts,
|
||||
realDistance,
|
||||
scale,
|
||||
timestamp: new Date().toISOString(),
|
||||
unitUsed,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import type {
|
||||
PDFArray,
|
||||
PDFDict,
|
||||
PDFHexString,
|
||||
PDFName,
|
||||
PDFNumber,
|
||||
PDFString,
|
||||
} from "@cantoo/pdf-lib";
|
||||
import type {
|
||||
MeasureScale,
|
||||
PageMeasureScales,
|
||||
PageScaleInfo,
|
||||
ViewportScale,
|
||||
} from "@app/utils/measurementTypes";
|
||||
import { getUnitFactor } from "@app/utils/measurementUtils";
|
||||
|
||||
type PdfMeasurementObjects = Pick<
|
||||
typeof import("@cantoo/pdf-lib"),
|
||||
| "PDFArray"
|
||||
| "PDFDict"
|
||||
| "PDFHexString"
|
||||
| "PDFName"
|
||||
| "PDFNumber"
|
||||
| "PDFString"
|
||||
>;
|
||||
|
||||
function asPdfArray(
|
||||
value: unknown,
|
||||
{ PDFArray }: PdfMeasurementObjects,
|
||||
): PDFArray | null {
|
||||
return value instanceof PDFArray ? value : null;
|
||||
}
|
||||
|
||||
function asPdfDict(
|
||||
value: unknown,
|
||||
{ PDFDict }: PdfMeasurementObjects,
|
||||
): PDFDict | null {
|
||||
return value instanceof PDFDict ? value : null;
|
||||
}
|
||||
|
||||
function asPdfNumber(
|
||||
value: unknown,
|
||||
{ PDFNumber }: PdfMeasurementObjects,
|
||||
): PDFNumber | null {
|
||||
return value instanceof PDFNumber ? value : null;
|
||||
}
|
||||
|
||||
function asPdfText(
|
||||
value: unknown,
|
||||
{ PDFHexString, PDFName, PDFString }: PdfMeasurementObjects,
|
||||
): PDFHexString | PDFName | PDFString | null {
|
||||
if (
|
||||
value instanceof PDFString ||
|
||||
value instanceof PDFHexString ||
|
||||
value instanceof PDFName
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function lookupArray(
|
||||
dict: PDFDict,
|
||||
key: string,
|
||||
pdfObjects: PdfMeasurementObjects,
|
||||
): PDFArray | null {
|
||||
return asPdfArray(dict.lookup(pdfObjects.PDFName.of(key)), pdfObjects);
|
||||
}
|
||||
|
||||
function lookupDict(
|
||||
dict: PDFDict,
|
||||
key: string,
|
||||
pdfObjects: PdfMeasurementObjects,
|
||||
): PDFDict | null {
|
||||
return asPdfDict(dict.lookup(pdfObjects.PDFName.of(key)), pdfObjects);
|
||||
}
|
||||
|
||||
function lookupNumber(
|
||||
dict: PDFDict,
|
||||
key: string,
|
||||
pdfObjects: PdfMeasurementObjects,
|
||||
): number | null {
|
||||
return (
|
||||
asPdfNumber(
|
||||
dict.lookup(pdfObjects.PDFName.of(key)),
|
||||
pdfObjects,
|
||||
)?.asNumber() ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function lookupText(
|
||||
dict: PDFDict,
|
||||
key: string,
|
||||
pdfObjects: PdfMeasurementObjects,
|
||||
): string | null {
|
||||
return (
|
||||
asPdfText(
|
||||
dict.lookup(pdfObjects.PDFName.of(key)),
|
||||
pdfObjects,
|
||||
)?.decodeText() ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function readArrayNumber(
|
||||
array: PDFArray,
|
||||
index: number,
|
||||
pdfObjects: PdfMeasurementObjects,
|
||||
): number | null {
|
||||
return asPdfNumber(array.lookup(index), pdfObjects)?.asNumber() ?? null;
|
||||
}
|
||||
|
||||
function readBBox(
|
||||
bboxArray: PDFArray | null,
|
||||
pdfObjects: PdfMeasurementObjects,
|
||||
): ViewportScale["bbox"] {
|
||||
if (!bboxArray || bboxArray.size() < 4) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const x0 = readArrayNumber(bboxArray, 0, pdfObjects);
|
||||
const y0 = readArrayNumber(bboxArray, 1, pdfObjects);
|
||||
const x1 = readArrayNumber(bboxArray, 2, pdfObjects);
|
||||
const y1 = readArrayNumber(bboxArray, 3, pdfObjects);
|
||||
|
||||
if (x0 === null || y0 === null || x1 === null || y1 === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [x0, y0, x1, y1];
|
||||
}
|
||||
|
||||
function parseScale(
|
||||
measureDict: PDFDict | null,
|
||||
pdfObjects: PdfMeasurementObjects,
|
||||
): MeasureScale | null {
|
||||
if (!measureDict) return null;
|
||||
|
||||
const fmtArray =
|
||||
lookupArray(measureDict, "D", pdfObjects) ??
|
||||
lookupArray(measureDict, "X", pdfObjects);
|
||||
if (!fmtArray || fmtArray.size() === 0) return null;
|
||||
|
||||
const firstFmt = asPdfDict(fmtArray.lookup(0), pdfObjects);
|
||||
if (!firstFmt) return null;
|
||||
|
||||
const factor = lookupNumber(firstFmt, "C", pdfObjects);
|
||||
if (factor === null || factor <= 0) return null;
|
||||
|
||||
const unit = lookupText(firstFmt, "U", pdfObjects)?.trim().toLowerCase();
|
||||
if (!unit) return null;
|
||||
|
||||
const baseFactor = getUnitFactor(unit);
|
||||
if (!baseFactor) return null;
|
||||
|
||||
const ratio = factor / baseFactor;
|
||||
return { factor, ratio, unit };
|
||||
}
|
||||
|
||||
export async function extractPageMeasureScales(
|
||||
file: Blob,
|
||||
): Promise<PageMeasureScales | null> {
|
||||
try {
|
||||
const pdfLib = await import("@cantoo/pdf-lib");
|
||||
const { PDFDocument, PDFArray, PDFDict, PDFName } = pdfLib;
|
||||
const pdfDoc = await PDFDocument.load(await file.arrayBuffer(), {
|
||||
ignoreEncryption: true,
|
||||
});
|
||||
|
||||
const result: PageMeasureScales = new Map();
|
||||
|
||||
for (let i = 0; i < pdfDoc.getPageCount(); i++) {
|
||||
const page = pdfDoc.getPage(i);
|
||||
const pageHeight = page.getHeight();
|
||||
const viewports: ViewportScale[] = [];
|
||||
|
||||
const vpObj = page.node.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(
|
||||
lookupDict(vpEntry, "Measure", pdfLib),
|
||||
pdfLib,
|
||||
);
|
||||
if (!scale) continue;
|
||||
|
||||
viewports.push({
|
||||
bbox: readBBox(lookupArray(vpEntry, "BBox", pdfLib), pdfLib),
|
||||
scale,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (viewports.length === 0) {
|
||||
const scale = parseScale(
|
||||
lookupDict(page.node, "Measure", pdfLib),
|
||||
pdfLib,
|
||||
);
|
||||
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 (error) {
|
||||
console.warn("[Measurement] Failed to extract PDF scales", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user