diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index fba28f6872..2bf1d347b6 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -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" diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index ea4f0399b9..4e4abe1ab7 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -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" diff --git a/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx b/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx index be4a4a9971..39d751790f 100644 --- a/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx @@ -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 { - 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(null); + // Ruler / measurement tool state is handled by the dedicated hook. + const rulerOverlayRef = useRef(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} /> + )} diff --git a/frontend/editor/src/core/components/viewer/LocalEmbedPDF.tsx b/frontend/editor/src/core/components/viewer/LocalEmbedPDF.tsx index 547fba12e7..91c0dcf6a8 100644 --- a/frontend/editor/src/core/components/viewer/LocalEmbedPDF.tsx +++ b/frontend/editor/src/core/components/viewer/LocalEmbedPDF.tsx @@ -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; } +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 ( +
e.preventDefault()} + onDrop={(e) => e.preventDefault()} + onDragOver={(e) => e.preventDefault()} + > + {children} +
+ ); +} + export function LocalEmbedPDF({ file, url, @@ -1023,25 +1076,11 @@ export function LocalEmbedPDF({ documentId={documentId} pageIndex={pageIndex} > -
e.preventDefault()} - onDrop={(e) => e.preventDefault()} - onDragOver={(e) => e.preventDefault()} +
)} -
+
); diff --git a/frontend/editor/src/core/components/viewer/RotateAPIBridge.tsx b/frontend/editor/src/core/components/viewer/RotateAPIBridge.tsx index b33b8a6819..a713aac519 100644 --- a/frontend/editor/src/core/components/viewer/RotateAPIBridge.tsx +++ b/frontend/editor/src/core/components/viewer/RotateAPIBridge.tsx @@ -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; } diff --git a/frontend/editor/src/core/components/viewer/RulerMeasurementLayer.tsx b/frontend/editor/src/core/components/viewer/RulerMeasurementLayer.tsx new file mode 100644 index 0000000000..01f147ba52 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/RulerMeasurementLayer.tsx @@ -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; + 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 { + const visibleIds = new Set(); + 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 ( + { + 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", + }} + > + + + + + + + + + {showLabel && ( + + + + + + {isSelected && measureScale ? ( + <> + + {hoverLine1} + + + {hoverLine2} + + + {contextLabel} + + + ) : isSelected ? ( + <> + + {hoverLine1} + + + {contextLabel} + + + ) : ( + + {distLabel} + + )} + + )} + + {showDelete && ( + <> + + { + e.stopPropagation(); + onDelete(id); + }} + > + + + × + + + + )} + + ); +} + +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 ( + + + + {d > 4 && ( + + + + {distLabel} + + + {`∠ ${ang.toFixed(1)}°`} + + + )} + + ); +} + +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 ( + <> + + {orderedMeasurements.map((item) => { + const { measurement, startS, endS, distPts, measureScale } = item; + return ( + + ); + })} + + {firstPoint && ( + + )} + + + {liveLine && ( + + )} + + {cursor && ( + + + + + + )} + + {measurements.length > 0 && ( + <> + { + e.stopPropagation(); + onClearAll(); + }} + > + + + {t("ruler.clearAll", "Clear all")} + + + { + e.stopPropagation(); + onCycleLabelVisibilityMode(); + }} + > + + + {labelModeButtonLabel} + + + + )} + + ); +} diff --git a/frontend/editor/src/core/components/viewer/RulerOverlay.tsx b/frontend/editor/src/core/components/viewer/RulerOverlay.tsx index 7fa7debeb7..9f431fb628 100644 --- a/frontend/editor/src/core/components/viewer/RulerOverlay.tsx +++ b/frontend/editor/src/core/components/viewer/RulerOverlay.tsx @@ -1,5 +1,31 @@ -import React, { useCallback, useEffect, useRef, useState } from "react"; +import React, { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { + restorePosition, + transformPosition, + transformSize, + type Rotation, + type Size, +} from "@embedpdf/models"; import { useViewer } from "@app/contexts/ViewerContext"; +import type { + MeasureScale, + Measurement, + PageMeasureScales, + PagePoint, +} from "@app/utils/measurementTypes"; +import { validateMeasurement } from "@app/utils/measurementUtils"; +import type { ScaleCalibrationMeasurement } from "@app/components/viewer/ScaleCalibrationDialog"; +import { + RulerMeasurementLayer, + type RulerLabelVisibilityMode, + type RulerRenderedMeasurement, +} from "@app/components/viewer/RulerMeasurementLayer"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -8,35 +34,32 @@ interface Point { y: number; } -/** - * A point anchored to a specific PDF page in PDF-unit space. - * x and y are in PDF points (1/72 inch) relative to the page's top-left corner. - * - * This is the only truly zoom-invariant representation. Screen positions are - * recovered at render time via getBoundingClientRect on the page element, so - * scroll, zoom, and fixed page margins are all handled by the browser — we never - * have to track them ourselves. - */ -interface PagePoint { - pageIndex: number; - x: number; - y: number; -} +let rulerMeasurementIdCounter = 0; -interface Measurement { - id: string; - start: PagePoint; - end: PagePoint; +function createRulerMeasurementId(): string { + rulerMeasurementIdCounter += 1; + return `ruler-${Date.now().toString(36)}-${rulerMeasurementIdCounter.toString(36)}`; } export interface RulerOverlayHandle { - clearAll: () => void; + clearAll: (silent?: boolean) => void; + getMeasurements: () => Measurement[]; + setMeasurements: (measurements: Measurement[]) => void; + /** Restore measurements without triggering notification */ + restoreMeasurements: (measurements: Measurement[]) => void; + /** Register a callback to be notified when measurements change from user actions */ + onMeasurementsChange: ( + callback: (measurements: Measurement[]) => void, + ) => () => void; } interface RulerOverlayProps { containerRef: React.RefObject; isActive: boolean; pageMeasureScales?: PageMeasureScales | null; + customScale?: MeasureScale | null; + isCalibrationActive?: boolean; + onCalibrationMeasure?: (measurement: ScaleCalibrationMeasurement) => void; } // ─── Math ───────────────────────────────────────────────────────────────────── @@ -45,69 +68,111 @@ function dist(a: Point, b: Point): number { return Math.sqrt((b.x - a.x) ** 2 + (b.y - a.y) ** 2); } -function midpoint(a: Point, b: Point): Point { - return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; +function normalizeRotation(rotation: number | null | undefined): Rotation { + const value = + typeof rotation === "number" && Number.isFinite(rotation) ? rotation : 0; + return (((Math.round(value) % 4) + 4) % 4) as Rotation; } -function perpUnit(a: Point, b: Point): { nx: number; ny: number } { - const dx = b.x - a.x; - const dy = b.y - a.y; - const len = Math.sqrt(dx * dx + dy * dy) || 1; - return { nx: -dy / len, ny: dx / len }; +function getPageRotation(pageEl: HTMLElement): Rotation { + return normalizeRotation(Number(pageEl.dataset.pageRotation)); } -/** Angle from horizontal 0°–90°. Computed from screen-space points (same angle as PDF space). */ -function angleDeg(a: Point, b: Point): number { - return Math.atan2(Math.abs(b.y - a.y), Math.abs(b.x - a.x)) * (180 / Math.PI); +function getEffectivePageRotation( + pageEl: HTMLElement, + documentRotation: Rotation, +): Rotation { + return normalizeRotation(getPageRotation(pageEl) + documentRotation); } -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 getPageNaturalSize( + pageEl: HTMLElement, + pageRect: DOMRect, + zoom: number, + rotation: Rotation, +): Size { + const safeZoom = Number.isFinite(zoom) && zoom > 0 ? zoom : 1; + const dataWidth = Number(pageEl.dataset.pageWidth); + const dataHeight = Number(pageEl.dataset.pageHeight); + + if ( + Number.isFinite(dataWidth) && + dataWidth > 0 && + Number.isFinite(dataHeight) && + dataHeight > 0 + ) { + return { + width: dataWidth / safeZoom, + height: dataHeight / safeZoom, + }; + } + + const visualWidth = pageRect.width / safeZoom; + const visualHeight = pageRect.height / safeZoom; + return rotation % 2 === 0 + ? { width: visualWidth, height: visualHeight } + : { width: visualHeight, height: visualWidth }; } -function formatInches(pts: number): string { - const inches = pts / 72; - if (inches < 12) return `${inches.toFixed(2)} in`; - return `${(inches / 12).toFixed(2)} ft`; +function clampToPage(point: Point, pageSize: Size): Point { + return { + x: Math.max(0, Math.min(pageSize.width, point.x)), + y: Math.max(0, Math.min(pageSize.height, point.y)), + }; } -export interface MeasureScale { - /** real_world_value = pdf_points * factor */ - factor: number; - /** e.g. "ft", "m" */ - unit: string; - /** Human-readable ratio from PDF, e.g. "1 in = 10 ft" */ - ratioLabel: string; +function clientPointToPagePoint( + pageEl: HTMLElement, + clientX: number, + clientY: number, + zoom: number, + rotation: Rotation, +): Point { + const safeZoom = Number.isFinite(zoom) && zoom > 0 ? zoom : 1; + const pageRect = pageEl.getBoundingClientRect(); + const pageSize = getPageNaturalSize(pageEl, pageRect, safeZoom, rotation); + const rotatedDisplaySize = transformSize(pageSize, rotation, safeZoom); + const displayPoint = { + x: clientX - pageRect.left, + y: clientY - pageRect.top, + }; + + return clampToPage( + restorePosition(rotatedDisplaySize, displayPoint, rotation, safeZoom), + pageSize, + ); } -export interface ViewportScale { - /** BBox in PDF user space (bottom-left origin). null = entire page. */ - bbox: [number, number, number, number] | null; - scale: MeasureScale; +function pagePointToDisplayPoint( + pageEl: HTMLElement, + pageRect: DOMRect, + point: Point, + zoom: number, + rotation: Rotation, +): Point { + const safeZoom = Number.isFinite(zoom) && zoom > 0 ? zoom : 1; + const pageSize = getPageNaturalSize(pageEl, pageRect, safeZoom, rotation); + return transformPosition(pageSize, point, rotation, safeZoom); } -export interface PageScaleInfo { - viewports: ViewportScale[]; - /** Page height in PDF points — used to flip screen-y (top=0) to PDF-y (bottom=0). */ - pageHeight: number; -} - -export type PageMeasureScales = Map; - /** * Given the start/end PagePoints of a measurement, find the scale from the - * viewport whose BBox contains the midpoint. Falls back to the first viewport - * if none contains it (handles whole-page viewports with bbox=null). + * custom scale, then the viewport whose BBox contains the midpoint, then the + * first whole-page viewport with bbox=null. */ function pickScale( start: PagePoint, end: PagePoint, - pageMeasureScales: PageMeasureScales, + pageMeasureScales: PageMeasureScales | null | undefined, + customScale?: MeasureScale | null, ): MeasureScale | null { + // Cross-page measurements are meaningless — reject regardless of scale source if (start.pageIndex !== end.pageIndex) return null; + + // Priority 1: Use custom scale if provided + if (customScale) return customScale; + + if (!pageMeasureScales) return null; const info = pageMeasureScales.get(start.pageIndex); if (!info?.viewports.length) return null; @@ -116,8 +181,14 @@ function pickScale( // Flip y: screen y=0 is page top; PDF user space y=0 is page bottom const my = info.pageHeight - (start.y + end.y) / 2; + let fallbackScale: MeasureScale | null = null; + for (const { bbox, scale } of info.viewports) { - if (!bbox) return scale; // whole-page viewport + if (!bbox) { + fallbackScale ??= scale; + continue; + } + const [x0, y0, x1, y1] = bbox; if ( mx >= Math.min(x0, x1) && @@ -128,58 +199,7 @@ function pickScale( return scale; } } - return null; -} - -function formatScaled(pts: number, scale: MeasureScale): string { - const val = pts * scale.factor; - if (val >= 1000) return `${val.toFixed(0)} ${scale.unit}`; - if (val >= 100) return `${val.toFixed(1)} ${scale.unit}`; - if (val >= 10) return `${val.toFixed(2)} ${scale.unit}`; - return `${val.toFixed(3)} ${scale.unit}`; -} - -// Conversion factors to metres for known units -const TO_METRES: Record = { - m: 1, - cm: 0.01, - mm: 0.001, - km: 1000, - ft: 0.3048, - in: 0.0254, - yd: 0.9144, - mi: 1609.344, -}; - -function isImperialUnit(unit: string): boolean { - return ["ft", "in", "yd", "mi"].includes(unit.toLowerCase().trim()); -} - -function formatMetricFromMetres(m: number): string { - if (m >= 1000) return `${(m / 1000).toFixed(2)} km`; - if (m >= 1) return `${m.toFixed(1)} m`; - if (m >= 0.1) return `${(m * 100).toFixed(1)} cm`; - return `${(m * 1000).toFixed(1)} mm`; -} - -function formatImperialFromFeet(ft: number): string { - if (ft >= 1) return `${ft.toFixed(2)} ft`; - return `${(ft * 12).toFixed(2)} in`; -} - -/** - * Returns the scaled real-world value in the *other* unit system, or null if - * the unit is not a recognised metric/imperial unit. - * e.g. 72 pts, scale {factor:0.138889, unit:"ft"} → "3.048 m" - * 72 pts, scale {factor:0.352778, unit:"m"} → "1.157 ft" (approx) - */ -function scaledCross(pts: number, scale: MeasureScale): string | null { - const toM = TO_METRES[scale.unit.toLowerCase().trim()]; - if (!toM) return null; - const metres = pts * scale.factor * toM; - return isImperialUnit(scale.unit) - ? formatMetricFromMetres(metres) - : formatImperialFromFeet(metres / 0.3048); + return fallbackScale; } // ─── DOM helpers ────────────────────────────────────────────────────────────── @@ -202,12 +222,36 @@ function findScrollEl(root: HTMLElement): HTMLElement | null { return null; } -function isOverPage(e: MouseEvent): boolean { - return !!(e.target as Element).closest?.("[data-page-index]"); +function isEditableKeyboardTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) { + return false; + } + + return ( + target.isContentEditable || + target.closest("input, textarea, select") !== null + ); +} + +function findPageAtClientPoint( + container: HTMLElement, + clientX: number, + clientY: number, +): HTMLElement | null { + const elementsAtPoint = document.elementsFromPoint(clientX, clientY); + + for (const element of elementsAtPoint) { + const pageEl = element.closest?.("[data-page-index]"); + if (pageEl instanceof HTMLElement && container.contains(pageEl)) { + return pageEl; + } + } + + return null; } /** - * Find the nearest point on any page boundary and return it as both + * Find the nearest point on the starting page boundary and return it as both * an SVG screen coordinate and a PagePoint (page-relative PDF units). * Used to clamp the live line when the cursor drifts off the page. */ @@ -215,421 +259,43 @@ function nearestPageDocPt( cursor: Point, container: HTMLElement, zoom: number, + documentRotation: Rotation, + pageIndex: number, ): { screenPt: Point; docPt: PagePoint } | null { - const pages = container.querySelectorAll("[data-page-index]"); - if (!pages.length) return null; + const pageEl = container.querySelector( + `[data-page-index="${pageIndex}"]`, + ) as HTMLElement | null; + if (!pageEl) return null; const cr = container.getBoundingClientRect(); - let bestDist = Infinity; - let best: { screenPt: Point; docPt: PagePoint } | null = null; + const r = pageEl.getBoundingClientRect(); + const effectiveRotation = getEffectivePageRotation(pageEl, documentRotation); - pages.forEach((pageNode) => { - const pageEl = pageNode as HTMLElement; - const r = pageEl.getBoundingClientRect(); - const pageIndex = parseInt(pageEl.dataset.pageIndex ?? "0", 10); + // Page bounds in SVG (container-relative) space + const left = r.left - cr.left; + const top = r.top - cr.top; + const right = r.right - cr.left; + const bottom = r.bottom - cr.top; - // Page bounds in SVG (container-relative) space - const left = r.left - cr.left; - const top = r.top - cr.top; - const right = r.right - cr.left; - const bottom = r.bottom - cr.top; - - // Nearest point on this rect to the cursor (SVG space) - const cx = Math.max(left, Math.min(right, cursor.x)); - const cy = Math.max(top, Math.min(bottom, cursor.y)); - const d = Math.sqrt((cursor.x - cx) ** 2 + (cursor.y - cy) ** 2); - - if (d < bestDist) { - bestDist = d; - // Convert SVG-space point (cx, cy) → page-relative viewport → PDF points: - // viewport position of cx = cr.left + cx - // page-relative position = (cr.left + cx) - r.left - // PDF units = page-relative / zoom - best = { - screenPt: { x: cx, y: cy }, - docPt: { - pageIndex, - x: (cr.left + cx - r.left) / zoom, - y: (cr.top + cy - r.top) / zoom, - }, - }; - } - }); - - return best; -} - -// ─── Sub-components ─────────────────────────────────────────────────────────── - -const TICK = 10; -const DOT_R = 5; -const LH = 26; // label height (normal — 1 line) -const LH2 = 44; // label height (hovered, no scale — 2 lines) -const LH3 = 62; // label height (hovered, with scale — 3 lines) -const LP = 10; // label horizontal padding -const DEL_R = 8; - -interface MeasurementLineProps { - id: string; - startS: Point; - endS: Point; - /** Physical distance in PDF points (= screen pixel distance / zoom). */ - distPts: number; - hovered: boolean; - onDelete: (id: string) => void; - onHover: (id: string | null) => void; - measureScale?: MeasureScale | null; -} - -function MeasurementLine({ - id, - startS, - endS, - distPts, - hovered, - onDelete, - onHover, - measureScale, -}: MeasurementLineProps) { - const mid = midpoint(startS, endS); - const { nx, ny } = perpUnit(startS, endS); - const ang = angleDeg(startS, endS); - const angLabel = `∠ ${ang.toFixed(1)}°`; - - // Whether the PDF's unit is imperial — determines display order (imperial-first vs metric-first) - const imperialFirst = !!measureScale && isImperialUnit(measureScale.unit); - - // Idle: scaled primary if scale present, else physical metric - const distLabel = measureScale - ? formatScaled(distPts, measureScale) - : formatDist(distPts); - - // Hover line 1 — both real-world values ordered by PDF unit system: - // imperial PDF: "10.000 ft / 3.048 m" - // metric PDF: "142.5 m / 467.5 ft" - // no scale: "25.4 mm / 1.00 in" (metric first, default) - const hoverLine1 = measureScale - ? (() => { - const primary = formatScaled(distPts, measureScale); - const cross = scaledCross(distPts, measureScale); - return cross ? `${primary} / ${cross}` : primary; - })() - : `${formatDist(distPts)} / ${formatInches(distPts)}`; - - // Hover line 2 — both physical paper values, same order as line 1: - // imperial PDF: "1.00 in / 25.4 mm" - // metric PDF or no scale: "25.4 mm / 1.00 in" - const hoverLine2 = measureScale - ? imperialFirst - ? `${formatInches(distPts)} / ${formatDist(distPts)}` - : `${formatDist(distPts)} / ${formatInches(distPts)}` - : null; - - // Hover line 3 (scaled) / line 2 (no scale) — ratio label + angle - const contextLabel = measureScale?.ratioLabel - ? `${measureScale.ratioLabel} ${angLabel}` - : angLabel; - - const maxHoverLh = measureScale ? LH3 : LH2; - const lh = hovered ? maxHoverLh : LH; - - const lwNormal = Math.max(distLabel.length * 8 + LP * 2, 80); - const lwHover = Math.max( - hoverLine1.length * 8 + LP * 2, - (hoverLine2?.length ?? 0) * 8 + LP * 2, - contextLabel.length * 8 + LP * 2, - 80, + // Nearest point on this rect to the cursor (SVG space) + const cx = Math.max(left, Math.min(right, cursor.x)); + const cy = Math.max(top, Math.min(bottom, cursor.y)); + const docPoint = clientPointToPagePoint( + pageEl, + cr.left + cx, + cr.top + cy, + zoom, + effectiveRotation, ); - const lw = hovered ? lwHover : lwNormal; - const sw = hovered ? 3 : 2; - const delX = mid.x + lwHover / 2 + DEL_R + 4; - const delY = mid.y; - - const hitLeft = mid.x - lwHover / 2 - 4; - const hitTop = mid.y - maxHoverLh / 2 - 4; - const hitWidth = delX + DEL_R + 4 - hitLeft; - const hitHeight = maxHoverLh + 8; - - const mono = "'Roboto Mono','Consolas',monospace"; - - return ( - onHover(id)} - onMouseLeave={() => onHover(null)} - style={{ pointerEvents: "all" }} - > - - - - - - - - - - - - {hovered && measureScale ? ( - // 3-line scaled hover - <> - - {hoverLine1} - - - {hoverLine2} - - - {contextLabel} - - - ) : hovered ? ( - // 2-line no-scale hover - <> - - {hoverLine1} - - - {contextLabel} - - - ) : ( - // Idle — single line - - {distLabel} - - )} - - { - e.stopPropagation(); - onDelete(id); - }} - > - - - × - - - - - ); -} - -interface LiveLineProps { - startS: Point; - endS: Point; - zoom: number; - measureScale?: MeasureScale | null; -} - -function LiveLine({ startS, endS, zoom, measureScale }: LiveLineProps) { - const d = dist(startS, endS) / zoom; // PDF points from screen distance - const mid = midpoint(startS, endS); - const { nx, ny } = perpUnit(startS, endS); - const ang = angleDeg(startS, endS); - const distLabel = measureScale - ? formatScaled(d, measureScale) - : formatDist(d); - const lw = Math.max(distLabel.length * 8 + LP * 2, 80); - - return ( - - - - {d > 4 && ( - - - - {distLabel} - - - {`∠ ${ang.toFixed(1)}°`} - - - )} - - ); + return { + screenPt: { x: cx, y: cy }, + docPt: { + pageIndex, + x: docPoint.x, + y: docPoint.y, + }, + }; } // ─── Main ───────────────────────────────────────────────────────────────────── @@ -637,14 +303,34 @@ function LiveLine({ startS, endS, zoom, measureScale }: LiveLineProps) { export const RulerOverlay = React.forwardRef< RulerOverlayHandle, RulerOverlayProps ->(({ containerRef, isActive, pageMeasureScales }, ref) => { +>(function RulerOverlayImpl( + { + containerRef, + isActive, + pageMeasureScales, + customScale, + isCalibrationActive = false, + onCalibrationMeasure, + }: RulerOverlayProps, + ref, +) { const [measurements, setMeasurements] = useState([]); const [firstPt, setFirstPt] = useState(null); /** Current cursor in SVG screen-space — for live crosshair and live line rendering. */ const [cursorS, setCursorS] = useState(null); /** Current cursor in page-relative PDF units — for finalising off-page clicks. */ const [cursorDoc, setCursorDoc] = useState(null); + const [selectedId, setSelectedId] = useState(null); const [hoveredId, setHoveredId] = useState(null); + const [labelVisibilityMode, setLabelVisibilityMode] = + useState("hideSmall"); + const [isDrawThroughActive, setIsDrawThroughActive] = useState(false); + + // Callbacks for explicit measurement changes; restores stay silent. + const measurementsListenersRef = useRef< + Set<(measurements: Measurement[]) => void> + >(new Set()); + const measurementsRef = useRef(measurements); /** * Incremented on scroll to trigger re-renders. @@ -655,7 +341,13 @@ export const RulerOverlay = React.forwardRef< const scrollElRef = useRef(null); const scrollCleanupRef = useRef<(() => void) | null>(null); - const idCounter = useRef(0); + const scrollRafRef = useRef(null); + const rulerPageContentRef = useRef(null); + const renderedScrollRef = useRef({ left: 0, top: 0 }); + const isActiveRef = useRef(isActive); + useEffect(() => { + isActiveRef.current = isActive; + }, [isActive]); const firstPtRef = useRef(null); useEffect(() => { @@ -663,10 +355,66 @@ export const RulerOverlay = React.forwardRef< }, [firstPt]); const cursorDocRef = useRef(null); + const wasCalibrationActiveRef = useRef(isCalibrationActive); + const drawThroughActiveRef = useRef(false); + + const setDrawThroughMode = useCallback((isEnabled: boolean) => { + drawThroughActiveRef.current = isEnabled; + setIsDrawThroughActive(isEnabled); + + if (isEnabled) { + setHoveredId(null); + } + }, []); + + const cycleLabelVisibilityMode = useCallback(() => { + setLabelVisibilityMode((currentMode) => { + if (currentMode === "hideSmall") { + return "showAll"; + } + + if (currentMode === "showAll") { + return "hideAll"; + } + + return "hideSmall"; + }); + }, []); + + const notifyMeasurementsChange = useCallback( + (nextMeasurements: Measurement[]) => { + measurementsListenersRef.current.forEach((listener) => + listener(nextMeasurements), + ); + }, + [], + ); + + const replaceMeasurements = useCallback( + (nextMeasurements: Measurement[], notify = false) => { + measurementsRef.current = nextMeasurements; + setMeasurements(nextMeasurements); + if (notify) { + notifyMeasurementsChange(nextMeasurements); + } + }, + [notifyMeasurementsChange], + ); + + const updateMeasurements = useCallback( + ( + updater: (currentMeasurements: Measurement[]) => Measurement[], + notify = false, + ) => { + replaceMeasurements(updater(measurementsRef.current), notify); + }, + [replaceMeasurements], + ); // ── Zoom ────────────────────────────────────────────────────────────────── const viewer = useViewer(); - const { registerImmediateZoomUpdate } = viewer; + const { registerImmediateRotationUpdate, registerImmediateZoomUpdate } = + viewer; const [zoom, setZoom] = useState(() => { try { @@ -681,6 +429,19 @@ export const RulerOverlay = React.forwardRef< zoomRef.current = zoom; }, [zoom]); + const [rotation, setRotation] = useState(() => { + try { + return normalizeRotation(viewer.getRotationState().rotation); + } catch { + return normalizeRotation(0); + } + }); + + const rotationRef = useRef(rotation); + useEffect(() => { + rotationRef.current = rotation; + }, [rotation]); + useEffect(() => { return registerImmediateZoomUpdate((pct) => { const newZoom = pct / 100; @@ -692,16 +453,88 @@ export const RulerOverlay = React.forwardRef< }); }, [registerImmediateZoomUpdate]); + useEffect(() => { + return registerImmediateRotationUpdate((nextRotation) => { + const normalizedRotation = normalizeRotation(nextRotation); + rotationRef.current = normalizedRotation; + setRotation(normalizedRotation); + requestAnimationFrame(() => setScrollVersion((n) => n + 1)); + }); + }, [registerImmediateRotationUpdate]); + + // ── Layout change tracking (menu close, sidebar toggle, etc.) ────────────── + // Monitor PDF container for layout changes and force re-render so measurements + // use updated getBoundingClientRect positions after layout reflow + useEffect(() => { + if (!containerRef.current) return; + + const handleLayoutChange = () => { + // Layout changed - force re-render to recalculate coordinates from getBoundingClientRect + setScrollVersion((n) => n + 1); + }; + + // Use ResizeObserver if available, otherwise fall back to window resize event + if (typeof ResizeObserver !== "undefined") { + const resizeObserver = new ResizeObserver(handleLayoutChange); + resizeObserver.observe(containerRef.current); + return () => resizeObserver.disconnect(); + } else { + // Fallback for environments without ResizeObserver (legacy browsers, embedded webviews) + window.addEventListener("resize", handleLayoutChange); + return () => window.removeEventListener("resize", handleLayoutChange); + } + }, [containerRef]); + // ── Scroll tracking ──────────────────────────────────────────────────────── - // We only need re-renders on scroll; getBoundingClientRect gives us accurate - // positions without needing to know the scroll offset ourselves. + // Native scrolling moves the PDF pages before React re-renders this fixed + // overlay. Translate page-anchored SVG content immediately, then let the next + // frame render exact coordinates from getBoundingClientRect. + + useLayoutEffect(() => { + const scrollEl = scrollElRef.current; + if (scrollEl) { + renderedScrollRef.current = { + left: scrollEl.scrollLeft, + top: scrollEl.scrollTop, + }; + } + rulerPageContentRef.current?.removeAttribute("transform"); + }); const attachScrollEl = useCallback((el: HTMLElement) => { scrollCleanupRef.current?.(); scrollElRef.current = el; - const handler = () => setScrollVersion((n) => n + 1); + const handler = () => { + if (!isActiveRef.current && measurementsRef.current.length === 0) { + return; + } + + const dx = renderedScrollRef.current.left - el.scrollLeft; + const dy = renderedScrollRef.current.top - el.scrollTop; + if (dx !== 0 || dy !== 0) { + rulerPageContentRef.current?.setAttribute( + "transform", + `translate(${dx} ${dy})`, + ); + } + + if (scrollRafRef.current !== null) { + return; + } + + scrollRafRef.current = requestAnimationFrame(() => { + scrollRafRef.current = null; + setScrollVersion((n) => n + 1); + }); + }; el.addEventListener("scroll", handler, { passive: true }); - scrollCleanupRef.current = () => el.removeEventListener("scroll", handler); + scrollCleanupRef.current = () => { + el.removeEventListener("scroll", handler); + if (scrollRafRef.current !== null) { + cancelAnimationFrame(scrollRafRef.current); + scrollRafRef.current = null; + } + }; }, []); useEffect(() => { @@ -737,22 +570,100 @@ export const RulerOverlay = React.forwardRef< // ── Imperative handle ────────────────────────────────────────────────────── React.useImperativeHandle(ref, () => ({ - clearAll: () => { - setMeasurements([]); + clearAll: (silent = false) => { + firstPtRef.current = null; + cursorDocRef.current = null; + replaceMeasurements([], !silent); setFirstPt(null); setCursorS(null); setCursorDoc(null); + setSelectedId(null); + setHoveredId(null); + }, + getMeasurements: () => measurementsRef.current, + setMeasurements: (newMeasurements: Measurement[]) => { + // Validate all measurements before setting state + const validated = newMeasurements.filter((m) => validateMeasurement(m)); + replaceMeasurements(validated, true); + }, + restoreMeasurements: (newMeasurements: Measurement[]) => { + replaceMeasurements( + newMeasurements.filter((measurement) => + validateMeasurement(measurement), + ), + false, + ); + }, + onMeasurementsChange: (callback: (measurements: Measurement[]) => void) => { + measurementsListenersRef.current.add(callback); + // Return unsubscribe function + return () => { + measurementsListenersRef.current.delete(callback); + }; }, })); // ── Reset when deactivated ───────────────────────────────────────────────── useEffect(() => { if (!isActive) { + firstPtRef.current = null; + cursorDocRef.current = null; + setFirstPt(null); + setCursorS(null); + setCursorDoc(null); + setSelectedId(null); + setHoveredId(null); + setDrawThroughMode(false); + } + }, [isActive, setDrawThroughMode]); + + useEffect(() => { + const wasCalibrationActive = wasCalibrationActiveRef.current; + wasCalibrationActiveRef.current = isCalibrationActive; + + if (wasCalibrationActive !== isCalibrationActive) { + firstPtRef.current = null; + cursorDocRef.current = null; setFirstPt(null); setCursorS(null); setCursorDoc(null); } - }, [isActive]); + }, [isCalibrationActive]); + + useEffect(() => { + if (!isActive) { + return; + } + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== "Alt" || isEditableKeyboardTarget(e.target)) { + return; + } + + e.preventDefault(); + setDrawThroughMode(true); + }; + + const onKeyUp = (e: KeyboardEvent) => { + if (e.key === "Alt") { + setDrawThroughMode(false); + } + }; + + const onBlur = () => { + setDrawThroughMode(false); + }; + + document.addEventListener("keydown", onKeyDown); + document.addEventListener("keyup", onKeyUp); + window.addEventListener("blur", onBlur); + return () => { + document.removeEventListener("keydown", onKeyDown); + document.removeEventListener("keyup", onKeyUp); + window.removeEventListener("blur", onBlur); + setDrawThroughMode(false); + }; + }, [isActive, setDrawThroughMode]); // ── Mouse events ─────────────────────────────────────────────────────────── useEffect(() => { @@ -766,20 +677,28 @@ export const RulerOverlay = React.forwardRef< /** * Convert a mouse event to a page-relative PagePoint. - * Returns null if the cursor is not directly over a page element. + * Returns null if the cursor is not over a page. */ const toDocPagePt = (e: MouseEvent): PagePoint | null => { - const pageEl = (e.target as Element).closest?.( - "[data-page-index]", - ) as HTMLElement | null; + const pageEl = findPageAtClientPoint(el, e.clientX, e.clientY); if (!pageEl) return null; const pageIndex = parseInt(pageEl.dataset.pageIndex ?? "0", 10); - const r = pageEl.getBoundingClientRect(); const z = zoomRef.current; + const effectiveRotation = getEffectivePageRotation( + pageEl, + rotationRef.current, + ); + const docPoint = clientPointToPagePoint( + pageEl, + e.clientX, + e.clientY, + z, + effectiveRotation, + ); return { pageIndex, - x: (e.clientX - r.left) / z, - y: (e.clientY - r.top) / z, + x: docPoint.x, + y: docPoint.y, }; }; @@ -791,17 +710,23 @@ export const RulerOverlay = React.forwardRef< const onMove = (e: MouseEvent) => { const screenPt = toScreenPt(e); + const docPt = toDocPagePt(e); - if (isOverPage(e)) { + if (docPt) { el.style.cursor = "crosshair"; - const docPt = toDocPagePt(e); setCursorS(screenPt); setCursorDoc(docPt); cursorDocRef.current = docPt; } else if (firstPtRef.current !== null) { // First point placed, cursor wandered off page — clamp to nearest edge el.style.cursor = "crosshair"; - const result = nearestPageDocPt(screenPt, el, zoomRef.current); + const result = nearestPageDocPt( + screenPt, + el, + zoomRef.current, + rotationRef.current, + firstPtRef.current.pageIndex, + ); if (result) { setCursorS(result.screenPt); setCursorDoc(result.docPt); @@ -815,25 +740,67 @@ export const RulerOverlay = React.forwardRef< const onClick = (e: MouseEvent) => { if (e.button !== 0) return; - if ((e.target as Element).closest?.("[data-ruler-interactive]")) return; + const target = e.target as Element; + if (target.closest?.("[data-ruler-control]")) return; - const overPage = isOverPage(e); + const hasMeasurementInProgress = + firstPtRef.current !== null || drawThroughActiveRef.current || e.altKey; + if ( + !hasMeasurementInProgress && + target.closest?.("[data-ruler-interactive]") + ) { + return; + } + + const dp = toDocPagePt(e); + const overPage = dp !== null; if (!overPage && firstPtRef.current === null) return; e.preventDefault(); - const dp = overPage ? toDocPagePt(e) : cursorDocRef.current; - if (!dp) return; + const nextPoint = dp ?? cursorDocRef.current; + if (!nextPoint) return; - setFirstPt((prev) => { - if (!prev) { - firstPtRef.current = dp; - return dp; - } + const prev = firstPtRef.current; + if (!prev) { + firstPtRef.current = nextPoint; + setFirstPt(nextPoint); + setSelectedId(null); + setHoveredId(null); + return; + } + + // CRITICAL: Reject cross-page measurements + // Measurements must have both points on the same page + if (prev.pageIndex !== nextPoint.pageIndex) { + // Reset first point so user can start fresh on same page firstPtRef.current = null; - const id = `ruler-${++idCounter.current}`; - setMeasurements((m) => [...m, { id, start: prev, end: dp }]); - return null; - }); + cursorDocRef.current = null; + setFirstPt(null); + setCursorS(null); + setCursorDoc(null); + return; + } + + firstPtRef.current = null; + setFirstPt(null); + + if (isCalibrationActive) { + const distancePts = dist(prev, nextPoint); + if (distancePts > 0) { + onCalibrationMeasure?.({ + start: prev, + end: nextPoint, + pdfDistancePts: distancePts, + }); + } + return; + } + + const id = createRulerMeasurementId(); + updateMeasurements( + (m) => [...m, { id, start: prev, end: nextPoint }], + true, + ); }; const onLeave = () => { @@ -842,6 +809,8 @@ export const RulerOverlay = React.forwardRef< }; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { + firstPtRef.current = null; + cursorDocRef.current = null; setFirstPt(null); setCursorS(null); setCursorDoc(null); @@ -859,11 +828,27 @@ export const RulerOverlay = React.forwardRef< document.removeEventListener("keydown", onKey); el.style.cursor = ""; }; - }, [containerRef, isActive]); + }, [ + containerRef, + isActive, + isCalibrationActive, + onCalibrationMeasure, + updateMeasurements, + ]); - const deleteMeasurement = useCallback((id: string) => { - setMeasurements((prev) => prev.filter((m) => m.id !== id)); - }, []); + const deleteMeasurement = useCallback( + (id: string) => { + updateMeasurements((prev) => prev.filter((m) => m.id !== id), true); + // Close expanded label if the deleted measurement was selected + if (selectedId === id) { + setSelectedId(null); + } + if (hoveredId === id) { + setHoveredId(null); + } + }, + [hoveredId, selectedId, updateMeasurements], + ); if (!isActive && measurements.length === 0) return null; @@ -887,13 +872,60 @@ export const RulerOverlay = React.forwardRef< if (!pageEl) return null; const pageRect = pageEl.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); + const effectiveRotation = getEffectivePageRotation(pageEl, rotation); + const displayPoint = pagePointToDisplayPoint( + pageEl, + pageRect, + pt, + zoom, + effectiveRotation, + ); return { - x: pageRect.left - containerRect.left + pt.x * zoom, - y: pageRect.top - containerRect.top + pt.y * zoom, + x: pageRect.left - containerRect.left + displayPoint.x, + y: pageRect.top - containerRect.top + displayPoint.y, }; }; const firstPtS = firstPt ? pagePointToScreen(firstPt) : null; + const renderedMeasurements = measurements.reduce( + (items, measurement) => { + const startS = pagePointToScreen(measurement.start); + const endS = pagePointToScreen(measurement.end); + if (!startS || !endS) { + return items; + } + + const measureScale = pickScale( + measurement.start, + measurement.end, + pageMeasureScales, + customScale, + ); + + items.push({ + measurement, + startS, + endS, + distPts: dist(measurement.start, measurement.end), + measureScale, + }); + return items; + }, + [], + ); + const isMeasurementInteractionPassthroughActive = + firstPt !== null || isDrawThroughActive; + const liveLine = + isActive && firstPtS && cursorS + ? { + startS: firstPtS, + endS: cursorS, + measureScale: + !isCalibrationActive && firstPt && cursorDoc + ? pickScale(firstPt, cursorDoc, pageMeasureScales, customScale) + : null, + } + : null; return ( { + // Close expanded label if clicking on empty SVG area + if (e.target === e.currentTarget) { + setSelectedId(null); + } + }} > @@ -919,112 +957,29 @@ export const RulerOverlay = React.forwardRef< - {/* Completed measurements */} - {measurements.map((m) => { - const startS = pagePointToScreen(m.start); - const endS = pagePointToScreen(m.end); - if (!startS || !endS) return null; - const mScale = pageMeasureScales - ? pickScale(m.start, m.end, pageMeasureScales) - : null; - return ( - - ); - })} - - {/* Live line while drawing */} - {isActive && firstPtS && cursorS && ( - - )} - - {/* First-point anchor dot */} - {isActive && firstPtS && ( - - )} - - {/* Crosshair */} - {isActive && cursorS && ( - - - - - - )} - - {/* Clear all */} - {measurements.length > 0 && ( - { - e.stopPropagation(); - setMeasurements([]); - }} - > - - - Clear all - - - )} + { + replaceMeasurements([], true); + setSelectedId(null); + setHoveredId(null); + }} + onCycleLabelVisibilityMode={cycleLabelVisibilityMode} + /> ); }); diff --git a/frontend/editor/src/core/components/viewer/RulerScaleSettingsButton.tsx b/frontend/editor/src/core/components/viewer/RulerScaleSettingsButton.tsx new file mode 100644 index 0000000000..88b62d7520 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/RulerScaleSettingsButton.tsx @@ -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; + 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(null); + + return ( + + +
+ + + + + +
+
+ + { + onApplyScale?.(scale); + }} + onResetScale={() => { + onResetScale?.(); + }} + onStartCalibration={onStartCalibration} + onCancelCalibration={onCancelCalibration} + isCalibrationActive={isCalibrationActive} + onClose={() => { + scalePopoverRef.current?.click(); + }} + /> + +
+ ); +} diff --git a/frontend/editor/src/core/components/viewer/ScaleCalibrationDialog.tsx b/frontend/editor/src/core/components/viewer/ScaleCalibrationDialog.tsx new file mode 100644 index 0000000000..3b7b54311f --- /dev/null +++ b/frontend/editor/src/core/components/viewer/ScaleCalibrationDialog.tsx @@ -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(null); + const [unit, setUnit] = useState(() => getLastCalibrationUnit(defaultUnit)); + const [error, setError] = useState(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 ( + + + {/* Show paper distance automatically */} + {measurement && ( + + {t( + "scaleSettings.calibrationPaperDistance", + "Measured page distance: {{distance}}", + { + distance: formatPaperDistance(measurement.pdfDistancePts), + }, + )} + + )} + + {/* Input: real distance + unit selector */} + + + +
+ + + {t( + "scaleSettings.ratioHelp", + "Ratio: 1 page unit = X real-world units", + )} + + + + + + {/* Active Scale Display */} +
+ + {t("scaleSettings.activeScale", "Active Scale")}:{" "} + {currentScale && currentScale.ratio + ? generateScaleLabel(currentScale.ratio, currentScale.unit) + : currentScale && !currentScale.ratio + ? `${currentScale.unit} (custom)` + : t("scaleSettings.noneSet", "No custom scale set")} + +
+ + {/* Calibration Mode */} + + + {/* Reset Button */} + {currentScale && ( + + )} + + ); +} diff --git a/frontend/editor/src/core/components/viewer/useViewerWorkbenchBarButtons.tsx b/frontend/editor/src/core/components/viewer/useViewerWorkbenchBarButtons.tsx index 14a308e0f3..d5d39c4c0c 100644 --- a/frontend/editor/src/core/components/viewer/useViewerWorkbenchBarButtons.tsx +++ b/frontend/editor/src/core/components/viewer/useViewerWorkbenchBarButtons.tsx @@ -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: , + tooltip: rulerSettingsLabel, + ariaLabel: rulerSettingsLabel, + section: "top" as const, + order: 25.5, + render: ({ disabled }: { disabled?: boolean }) => ( + + ), + }, + ] + : []), { id: "viewer-rotate-left", icon: , @@ -553,8 +612,15 @@ export function useViewerWorkbenchBarButtons( formFillLabel, isFormFillActive, rulerLabel, + rulerSettingsLabel, isRulerActive, setIsRulerActive, + handleStartScaleCalibration, + handleCancelScaleCalibration, + handleApplyRulerScale, + handleResetRulerScale, + customScale, + isScaleCalibrationActive, readAloudLabel, readAloudSpeedLabel, isReadingAloud, diff --git a/frontend/editor/src/core/contexts/ViewerContext.tsx b/frontend/editor/src/core/contexts/ViewerContext.tsx index 5e5d08dbb4..78535b41ac 100644 --- a/frontend/editor/src/core/contexts/ViewerContext.tsx +++ b/frontend/editor/src/core/contexts/ViewerContext.tsx @@ -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 = ({ 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 = ({ children }) => { [triggerImmediatePanInternal], ); + const triggerImmediateRotationUpdate = useCallback( + (rotation: number) => { + triggerImmediateRotationInternal(rotation); + }, + [triggerImmediateRotationInternal], + ); + const registerBridge = useCallback( ( type: K, @@ -638,10 +653,12 @@ export const ViewerProvider: React.FC = ({ children }) => { registerImmediateScrollUpdate, registerImmediateSpreadUpdate, registerImmediatePanUpdate, + registerImmediateRotationUpdate, triggerImmediateScrollUpdate, triggerImmediateZoomUpdate, triggerImmediateSpreadUpdate, triggerImmediatePanUpdate, + triggerImmediateRotationUpdate, // Actions scrollActions, diff --git a/frontend/editor/src/core/hooks/useMeasurementManager.ts b/frontend/editor/src/core/hooks/useMeasurementManager.ts new file mode 100644 index 0000000000..f6f6b9d6b1 --- /dev/null +++ b/frontend/editor/src/core/hooks/useMeasurementManager.ts @@ -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; +} + +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(null); + const [customScale, setCustomScale] = useState(null); + const [isScaleCalibrationActive, setIsScaleCalibrationActive] = + useState(false); + const [scaleCalibrationMeasurement, setScaleCalibrationMeasurement] = + useState(null); + const [scalesByFileId, setScalesByFileId] = useState< + Map + >(new Map()); + const [measurementsByFileId, setMeasurementsByFileId] = useState< + Map + >(new Map()); + + const restoredFileKeyRef = useRef(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, + }; +} diff --git a/frontend/editor/src/core/utils/measurementPreferences.ts b/frontend/editor/src/core/utils/measurementPreferences.ts new file mode 100644 index 0000000000..c339ed64f8 --- /dev/null +++ b/frontend/editor/src/core/utils/measurementPreferences.ts @@ -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, + ); + } +} diff --git a/frontend/editor/src/core/utils/measurementTypes.ts b/frontend/editor/src/core/utils/measurementTypes.ts new file mode 100644 index 0000000000..caa47ff876 --- /dev/null +++ b/frontend/editor/src/core/utils/measurementTypes.ts @@ -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; diff --git a/frontend/editor/src/core/utils/measurementUtils.test.ts b/frontend/editor/src/core/utils/measurementUtils.test.ts new file mode 100644 index 0000000000..33cf3f5c2d --- /dev/null +++ b/frontend/editor/src/core/utils/measurementUtils.test.ts @@ -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", + ); + }); + }); +}); diff --git a/frontend/editor/src/core/utils/measurementUtils.ts b/frontend/editor/src/core/utils/measurementUtils.ts new file mode 100644 index 0000000000..e35645fb2b --- /dev/null +++ b/frontend/editor/src/core/utils/measurementUtils.ts @@ -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; + + // 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 { + 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; + } 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 = { + ...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 = { [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; + 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; + + // 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; + + // 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, + }; +} diff --git a/frontend/editor/src/core/utils/pdfMeasurementExtraction.ts b/frontend/editor/src/core/utils/pdfMeasurementExtraction.ts new file mode 100644 index 0000000000..3c40e3209f --- /dev/null +++ b/frontend/editor/src/core/utils/pdfMeasurementExtraction.ts @@ -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 { + 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; + } +}