Prettier 2: Electric Boogaloo (#6113)

# Description of Changes
When I added Prettier formatting in #6052, my aim was to use just the
default settings in Prettier. Turns out, Prettier looks _really hard_
for any config files if it's not explicitly given one, which means that
if a developer has some sort of Prettier config file lying around on
their system, Prettier might find it and use it. Also, Prettier changes
its defaults based on stuff in `.editorconfig` without any good way of
disabling that behaviour explicitly in its config file.

To solve both of these issues, I've introduced a `.prettierrc` file
which sets Prettier's defaults explicitly, and then reformatted all our
code _again_ in Prettier's actual default settings. This should achieve
the aim of #6052 and remove the possibility for it breaking on different
dev computers.
This commit is contained in:
James Brunton
2026-04-17 09:50:16 +00:00
committed by GitHub
parent de8c483054
commit 8ab060a4be
1207 changed files with 52646 additions and 15352 deletions
+96 -22
View File
@@ -10,7 +10,10 @@ import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useLogoPath } from "@app/hooks/useLogoPath";
import { useLogoAssets } from "@app/hooks/useLogoAssets";
import { useFileContext } from "@app/contexts/file/fileHooks";
import { useNavigationState, useNavigationActions } from "@app/contexts/NavigationContext";
import {
useNavigationState,
useNavigationActions,
} from "@app/contexts/NavigationContext";
import { useViewer } from "@app/contexts/ViewerContext";
import AppsIcon from "@mui/icons-material/AppsRounded";
@@ -75,7 +78,12 @@ export default function HomePage() {
selectedToolKey,
});
const action = getStartupNavigationAction(prevCount, currentCount, selectedToolKey, navigationState.workbench);
const action = getStartupNavigationAction(
prevCount,
currentCount,
selectedToolKey,
navigationState.workbench,
);
console.log("[HomePage] Navigation action returned:", action);
@@ -90,9 +98,18 @@ export default function HomePage() {
}
prevFileCountRef.current = currentCount;
}, [activeFiles.length, actions, setActiveFileIndex, selectedToolKey, navigationState.workbench]);
}, [
activeFiles.length,
actions,
setActiveFileIndex,
selectedToolKey,
navigationState.workbench,
]);
const hideToolPanel = customWorkbenchViews.find((v) => v.workbenchId === navigationState.workbench)?.hideToolPanel ?? false;
const hideToolPanel =
customWorkbenchViews.find(
(v) => v.workbenchId === navigationState.workbench,
)?.hideToolPanel ?? false;
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
const brandIconSrc = useLogoPath();
@@ -146,8 +163,11 @@ export default function HomePage() {
animationFrame = window.requestAnimationFrame(() => {
const { scrollLeft, offsetWidth } = container;
const threshold = offsetWidth / 2;
const nextView: MobileView = scrollLeft >= threshold ? "workbench" : "tools";
setActiveMobileView((current) => (current === nextView ? current : nextView));
const nextView: MobileView =
scrollLeft >= threshold ? "workbench" : "tools";
setActiveMobileView((current) =>
current === nextView ? current : nextView,
);
});
};
@@ -192,10 +212,22 @@ export default function HomePage() {
const appName = config?.appNameNavbar || "Stirling PDF";
useDocumentMeta({
title: selectedTool ? `${selectedTool.name} - ${appName}` : appName,
description: selectedTool?.description || t("app.description", "The Free Adobe Acrobat alternative (10M+ Downloads)"),
description:
selectedTool?.description ||
t(
"app.description",
"The Free Adobe Acrobat alternative (10M+ Downloads)",
),
ogTitle: selectedTool ? `${selectedTool.name} - ${appName}` : appName,
ogDescription: selectedTool?.description || t("app.description", "The Free Adobe Acrobat alternative (10M+ Downloads)"),
ogImage: selectedToolKey ? `${baseUrl}/og_images/${selectedToolKey}.png` : `${baseUrl}/og_images/home.png`,
ogDescription:
selectedTool?.description ||
t(
"app.description",
"The Free Adobe Acrobat alternative (10M+ Downloads)",
),
ogImage: selectedToolKey
? `${baseUrl}/og_images/${selectedToolKey}.png`
: `${baseUrl}/og_images/home.png`,
ogUrl: selectedTool ? `${baseUrl}${window.location.pathname}` : baseUrl,
});
@@ -209,14 +241,26 @@ export default function HomePage() {
<div className="mobile-toggle">
<div className="mobile-header">
<div className="mobile-brand">
<img src={brandIconSrc} alt="" aria-hidden="true" className="mobile-brand-icon" />
<img src={brandTextSrc} alt={brandAltText} className="mobile-brand-text" />
<img
src={brandIconSrc}
alt=""
aria-hidden="true"
className="mobile-brand-icon"
/>
<img
src={brandTextSrc}
alt={brandAltText}
className="mobile-brand-text"
/>
</div>
</div>
<div
className="mobile-toggle-buttons"
role="tablist"
aria-label={t("home.mobile.viewSwitcher", "Switch workspace view")}
aria-label={t(
"home.mobile.viewSwitcher",
"Switch workspace view",
)}
>
<button
type="button"
@@ -237,15 +281,26 @@ export default function HomePage() {
{t("home.mobile.workspace", "Workspace")}
</button>
</div>
<span className="mobile-toggle-hint">{t("home.mobile.swipeHint", "Swipe left or right to switch views")}</span>
<span className="mobile-toggle-hint">
{t(
"home.mobile.swipeHint",
"Swipe left or right to switch views",
)}
</span>
</div>
<div ref={sliderRef} className="mobile-slider">
<div className="mobile-slide" aria-label={t("home.mobile.toolsSlide", "Tool selection panel")}>
<div
className="mobile-slide"
aria-label={t("home.mobile.toolsSlide", "Tool selection panel")}
>
<div className="mobile-slide-content">
<ToolPanel />
</div>
</div>
<div className="mobile-slide" aria-label={t("home.mobile.workbenchSlide", "Workspace panel")}>
<div
className="mobile-slide"
aria-label={t("home.mobile.workbenchSlide", "Workspace panel")}
>
<div className="mobile-slide-content">
<div className="flex-1 min-h-0 flex">
<Workbench />
@@ -266,7 +321,9 @@ export default function HomePage() {
}}
>
<AppsIcon sx={{ fontSize: "1.5rem" }} />
<span className="mobile-bottom-button-label">{t("quickAccess.allTools", "Tools")}</span>
<span className="mobile-bottom-button-label">
{t("quickAccess.allTools", "Tools")}
</span>
</button>
{toolAvailability["automate"]?.available !== false && (
<button
@@ -279,8 +336,14 @@ export default function HomePage() {
}
}}
>
<LocalIcon icon="automation-outline" width="1.5rem" height="1.5rem" />
<span className="mobile-bottom-button-label">{t("quickAccess.automate", "Automate")}</span>
<LocalIcon
icon="automation-outline"
width="1.5rem"
height="1.5rem"
/>
<span className="mobile-bottom-button-label">
{t("quickAccess.automate", "Automate")}
</span>
</button>
)}
<button
@@ -289,19 +352,30 @@ export default function HomePage() {
onClick={() => openFilesModal()}
>
<LocalIcon icon="folder-rounded" width="1.5rem" height="1.5rem" />
<span className="mobile-bottom-button-label">{t("quickAccess.files", "Files")}</span>
<span className="mobile-bottom-button-label">
{t("quickAccess.files", "Files")}
</span>
</button>
<button
className="mobile-bottom-button"
aria-label={t("quickAccess.config", "Config")}
onClick={() => setConfigModalOpen(true)}
>
<LocalIcon icon="settings-rounded" width="1.5rem" height="1.5rem" />
<span className="mobile-bottom-button-label">{t("quickAccess.config", "Config")}</span>
<LocalIcon
icon="settings-rounded"
width="1.5rem"
height="1.5rem"
/>
<span className="mobile-bottom-button-label">
{t("quickAccess.config", "Config")}
</span>
</button>
</div>
<FileManager selectedTool={selectedTool as any /* FIX ME */} />
<AppConfigModal opened={configModalOpen} onClose={() => setConfigModalOpen(false)} />
<AppConfigModal
opened={configModalOpen}
onClose={() => setConfigModalOpen(false)}
/>
</div>
) : (
<Group align="flex-start" gap={0} h="100%" className="flex-nowrap flex">
+419 -101
View File
@@ -1,6 +1,17 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { useSearchParams, useNavigate } from "react-router-dom";
import { Box, Button, Stack, Text, Group, Alert, Progress, Switch, Card, useMantineColorScheme } from "@mantine/core";
import {
Box,
Button,
Stack,
Text,
Group,
Alert,
Progress,
Switch,
Card,
useMantineColorScheme,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useLogoPath } from "@app/hooks/useLogoPath";
import { useLogoAssets } from "@app/hooks/useLogoAssets";
@@ -35,7 +46,9 @@ export default function MobileScannerPage() {
const { wordmark } = useLogoAssets();
const brandTextSrc = colorScheme === "dark" ? wordmark.white : wordmark.black;
const [mode, setMode] = useState<"choice" | "camera" | "file" | null>("choice");
const [mode, setMode] = useState<"choice" | "camera" | "file" | null>(
"choice",
);
const [capturedImages, setCapturedImages] = useState<string[]>([]);
const [currentPreview, setCurrentPreview] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
@@ -70,13 +83,20 @@ export default function MobileScannerPage() {
setLoadingStatus("Validating session...");
if (!sessionId) {
setSessionValid(false);
setSessionError(t("mobileScanner.noSessionMessage", "Session not found. Please try again."));
setSessionError(
t(
"mobileScanner.noSessionMessage",
"Session not found. Please try again.",
),
);
setLoadingStatus("Session validation failed");
return;
}
try {
const response = await fetch(`/api/v1/mobile-scanner/validate-session/${sessionId}`);
const response = await fetch(
`/api/v1/mobile-scanner/validate-session/${sessionId}`,
);
if (response.ok) {
const data = await response.json();
@@ -87,18 +107,33 @@ export default function MobileScannerPage() {
console.log("Session validated successfully:", data);
} else {
setSessionValid(false);
setSessionError(t("mobileScanner.sessionExpired", "This session has expired. Please refresh and try again."));
setSessionError(
t(
"mobileScanner.sessionExpired",
"This session has expired. Please refresh and try again.",
),
);
setLoadingStatus("Session expired ✗");
}
} else {
setSessionValid(false);
setSessionError(t("mobileScanner.sessionNotFound", "Session not found. Please refresh and try again."));
setSessionError(
t(
"mobileScanner.sessionNotFound",
"Session not found. Please refresh and try again.",
),
);
setLoadingStatus("Session not found ✗");
}
} catch (err) {
console.error("Failed to validate session:", err);
setSessionValid(false);
setSessionError(t("mobileScanner.sessionValidationError", "Unable to verify session. Please try again."));
setSessionError(
t(
"mobileScanner.sessionValidationError",
"Unable to verify session. Please try again.",
),
);
setLoadingStatus("Session validation error: " + (err as Error).message);
}
};
@@ -117,12 +152,17 @@ export default function MobileScannerPage() {
retryCount++;
if (retryCount < MAX_RETRIES) {
if (retryCount % 10 === 1) {
setLoadingStatus(`Loading OpenCV... (${retryCount}/${MAX_RETRIES})`);
console.log(`[${retryCount}/${MAX_RETRIES}] Waiting for OpenCV to load...`);
setLoadingStatus(
`Loading OpenCV... (${retryCount}/${MAX_RETRIES})`,
);
console.log(
`[${retryCount}/${MAX_RETRIES}] Waiting for OpenCV to load...`,
);
}
setTimeout(initScanner, 100);
} else {
const error = "OpenCV failed to load after 5 seconds. Check that /vendor/jscanify/opencv.js is accessible.";
const error =
"OpenCV failed to load after 5 seconds. Check that /vendor/jscanify/opencv.js is accessible.";
setLoadingStatus("OpenCV load failed ✗");
console.error(error);
}
@@ -133,12 +173,17 @@ export default function MobileScannerPage() {
retryCount++;
if (retryCount < MAX_RETRIES) {
if (retryCount % 10 === 1) {
setLoadingStatus(`Loading jscanify... (${retryCount}/${MAX_RETRIES})`);
console.log(`[${retryCount}/${MAX_RETRIES}] Waiting for jscanify to load...`);
setLoadingStatus(
`Loading jscanify... (${retryCount}/${MAX_RETRIES})`,
);
console.log(
`[${retryCount}/${MAX_RETRIES}] Waiting for jscanify to load...`,
);
}
setTimeout(initScanner, 100);
} else {
const error = "jscanify failed to load after 5 seconds. Check that /vendor/jscanify/jscanify.js is accessible.";
const error =
"jscanify failed to load after 5 seconds. Check that /vendor/jscanify/jscanify.js is accessible.";
setLoadingStatus("jscanify load failed ✗");
console.error(error);
}
@@ -168,11 +213,14 @@ export default function MobileScannerPage() {
);
if (mode === "camera" && !cameraError && !currentPreview) {
console.log("[Mobile Scanner] Camera effect: Starting camera initialization");
console.log(
"[Mobile Scanner] Camera effect: Starting camera initialization",
);
// Check if mediaDevices API is available (requires HTTPS or localhost)
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
const error = "MediaDevices API not available - requires HTTPS or localhost";
const error =
"MediaDevices API not available - requires HTTPS or localhost";
console.error(error);
setLoadingStatus("Camera API not available ✗");
setCameraError(
@@ -199,7 +247,9 @@ export default function MobileScannerPage() {
audio: false,
})
.then(async (stream) => {
console.log("[Mobile Scanner] Camera permission granted, stream received");
console.log(
"[Mobile Scanner] Camera permission granted, stream received",
);
streamRef.current = stream;
if (videoRef.current) {
const video = videoRef.current;
@@ -207,8 +257,15 @@ export default function MobileScannerPage() {
// Wait for video metadata to load before marking camera as ready
const handleLoadedMetadata = () => {
console.log("[Mobile Scanner] Video metadata loaded, dimensions:", video.videoWidth, "x", video.videoHeight);
setLoadingStatus(`Camera ready: ${video.videoWidth}x${video.videoHeight}`);
console.log(
"[Mobile Scanner] Video metadata loaded, dimensions:",
video.videoWidth,
"x",
video.videoHeight,
);
setLoadingStatus(
`Camera ready: ${video.videoWidth}x${video.videoHeight}`,
);
// Signal that camera is ready - this will trigger detection effect
console.log("[Mobile Scanner] Setting cameraReady = true");
@@ -221,13 +278,20 @@ export default function MobileScannerPage() {
handleLoadedMetadata();
} else {
// Wait for loadedmetadata event
video.addEventListener("loadedmetadata", handleLoadedMetadata, { once: true });
video.addEventListener("loadedmetadata", handleLoadedMetadata, {
once: true,
});
}
// Log actual resolution we got from stream settings
const videoTrack = stream.getVideoTracks()[0];
const settings = videoTrack.getSettings();
console.log("[Mobile Scanner] Camera stream settings:", settings.width, "x", settings.height);
console.log(
"[Mobile Scanner] Camera stream settings:",
settings.width,
"x",
settings.height,
);
// Configure camera capabilities for document scanning
try {
@@ -235,13 +299,19 @@ export default function MobileScannerPage() {
const constraints: any = { advanced: [] };
// 1. Enable continuous autofocus
if (capabilities.focusMode && capabilities.focusMode.includes("continuous")) {
if (
capabilities.focusMode &&
capabilities.focusMode.includes("continuous")
) {
constraints.advanced.push({ focusMode: "continuous" });
console.log("✓ Continuous autofocus enabled");
}
// 2. Enable continuous auto-exposure for varying lighting
if (capabilities.exposureMode && capabilities.exposureMode.includes("continuous")) {
if (
capabilities.exposureMode &&
capabilities.exposureMode.includes("continuous")
) {
constraints.advanced.push({ exposureMode: "continuous" });
console.log("✓ Auto-exposure enabled");
}
@@ -264,7 +334,12 @@ export default function MobileScannerPage() {
.catch((err) => {
console.error("Camera error:", err);
setLoadingStatus("Camera access denied ✗");
setCameraError(t("mobileScanner.cameraAccessDenied", "Camera access denied. Please enable camera access."));
setCameraError(
t(
"mobileScanner.cameraAccessDenied",
"Camera access denied. Please enable camera access.",
),
);
// Auto-switch to file upload if camera fails
setMode("file");
});
@@ -301,21 +376,34 @@ export default function MobileScannerPage() {
}
}
if (mode === "camera" && autoEnhance && openCvReady && cameraReady && scannerRef.current && !currentPreview) {
if (
mode === "camera" &&
autoEnhance &&
openCvReady &&
cameraReady &&
scannerRef.current &&
!currentPreview
) {
const startHighlighting = () => {
console.log("[Mobile Scanner] startHighlighting() called");
if (!videoRef.current || !highlightCanvasRef.current) {
setLoadingStatus("Missing video/canvas refs ✗");
console.error(
"[Mobile Scanner] Missing refs: video=" + !!videoRef.current + ", canvas=" + !!highlightCanvasRef.current,
"[Mobile Scanner] Missing refs: video=" +
!!videoRef.current +
", canvas=" +
!!highlightCanvasRef.current,
);
return;
}
if (!videoRef.current.videoWidth || !videoRef.current.videoHeight) {
setLoadingStatus("Video has no dimensions ✗");
console.error(
"[Mobile Scanner] Missing video dimensions: " + videoRef.current.videoWidth + "x" + videoRef.current.videoHeight,
"[Mobile Scanner] Missing video dimensions: " +
videoRef.current.videoWidth +
"x" +
videoRef.current.videoHeight,
);
return;
}
@@ -324,12 +412,18 @@ export default function MobileScannerPage() {
const highlightCanvas = highlightCanvasRef.current;
setLoadingStatus("Detection active ✓");
console.log(
"[Mobile Scanner] Starting highlighting loop for " + video.videoWidth + "x" + video.videoHeight + " video",
"[Mobile Scanner] Starting highlighting loop for " +
video.videoWidth +
"x" +
video.videoHeight +
" video",
);
// Create low-res detection canvas with optimized context for frequent pixel reading
const detectionCanvas = document.createElement("canvas");
const detectionCtx = detectionCanvas.getContext("2d", { willReadFrequently: true });
const detectionCtx = detectionCanvas.getContext("2d", {
willReadFrequently: true,
});
if (!detectionCtx) return;
// Calculate scaled dimensions for detection (160px wide max)
@@ -342,17 +436,23 @@ export default function MobileScannerPage() {
highlightCanvas.width = DETECTION_WIDTH;
highlightCanvas.height = Math.round(video.videoHeight * scale);
console.log(`[Mobile Scanner] Video: ${video.videoWidth}x${video.videoHeight}`);
console.log(
`[Mobile Scanner] Video: ${video.videoWidth}x${video.videoHeight}`,
);
console.log(
`[Mobile Scanner] Detection: ${detectionCanvas.width}x${detectionCanvas.height} (${Math.round(scale * 100)}%)`,
);
console.log(`[Mobile Scanner] Highlight canvas: ${highlightCanvas.width}x${highlightCanvas.height}`);
console.log(
`[Mobile Scanner] Highlight canvas: ${highlightCanvas.width}x${highlightCanvas.height}`,
);
console.log(`[Mobile Scanner] Starting interval at 1 FPS`);
// Set highlight canvas to match video for vector drawing
highlightCanvas.width = video.videoWidth;
highlightCanvas.height = video.videoHeight;
const highlightCtx = highlightCanvas.getContext("2d", { willReadFrequently: true });
const highlightCtx = highlightCanvas.getContext("2d", {
willReadFrequently: true,
});
if (!highlightCtx) return;
// Use requestAnimationFrame with adaptive throttle based on device performance
@@ -374,7 +474,13 @@ export default function MobileScannerPage() {
try {
// Step 1: Copy video to low-res detection canvas
const copyStart = performance.now();
detectionCtx.drawImage(video, 0, 0, detectionCanvas.width, detectionCanvas.height);
detectionCtx.drawImage(
video,
0,
0,
detectionCanvas.width,
detectionCanvas.height,
);
const copyTime = performance.now() - copyStart;
// Step 2: Simple jscanify detection
@@ -394,7 +500,12 @@ export default function MobileScannerPage() {
// Step 3: Draw corner lines on full-res canvas
const drawStart = performance.now();
highlightCtx.clearRect(0, 0, highlightCanvas.width, highlightCanvas.height);
highlightCtx.clearRect(
0,
0,
highlightCanvas.width,
highlightCanvas.height,
);
// Draw lines if corners detected
if (
@@ -406,10 +517,22 @@ export default function MobileScannerPage() {
) {
// Scale corner points from low-res to full-res
const scaleFactor = video.videoWidth / detectionCanvas.width;
const tl = { x: corners.topLeftCorner.x * scaleFactor, y: corners.topLeftCorner.y * scaleFactor };
const tr = { x: corners.topRightCorner.x * scaleFactor, y: corners.topRightCorner.y * scaleFactor };
const br = { x: corners.bottomRightCorner.x * scaleFactor, y: corners.bottomRightCorner.y * scaleFactor };
const bl = { x: corners.bottomLeftCorner.x * scaleFactor, y: corners.bottomLeftCorner.y * scaleFactor };
const tl = {
x: corners.topLeftCorner.x * scaleFactor,
y: corners.topLeftCorner.y * scaleFactor,
};
const tr = {
x: corners.topRightCorner.x * scaleFactor,
y: corners.topRightCorner.y * scaleFactor,
};
const br = {
x: corners.bottomRightCorner.x * scaleFactor,
y: corners.bottomRightCorner.y * scaleFactor,
};
const bl = {
x: corners.bottomLeftCorner.x * scaleFactor,
y: corners.bottomLeftCorner.y * scaleFactor,
};
// Draw green lines connecting corners
highlightCtx.strokeStyle = "#00FF00";
@@ -437,7 +560,9 @@ export default function MobileScannerPage() {
// Adaptive performance adjustment (after warmup period)
if (frameCount > 5 && detectionTimings.length >= 5) {
const avgTime = detectionTimings.reduce((a, b) => a + b, 0) / detectionTimings.length;
const avgTime =
detectionTimings.reduce((a, b) => a + b, 0) /
detectionTimings.length;
// Adjust detection interval based on average performance
if (avgTime < 20) {
@@ -462,7 +587,8 @@ export default function MobileScannerPage() {
}
if (frameCount === 10) {
const avg = frameTimes.reduce((a, b) => a + b, 0) / frameTimes.length;
const avg =
frameTimes.reduce((a, b) => a + b, 0) / frameTimes.length;
console.log(
`[Mobile Scanner] Average of first 10 frames: ${Math.round(avg)}ms - Adaptive rate: ${Math.round(1000 / detectionInterval)} FPS`,
);
@@ -497,7 +623,11 @@ export default function MobileScannerPage() {
`[Mobile Scanner] Video check: readyState=${video.readyState}, width=${video.videoWidth}, height=${video.videoHeight}`,
);
if (video.readyState >= 2 && video.videoWidth > 0 && video.videoHeight > 0) {
if (
video.readyState >= 2 &&
video.videoWidth > 0 &&
video.videoHeight > 0
) {
setLoadingStatus("Detection starting... ✓");
console.log("[Mobile Scanner] ✓ Video ready, starting detection now");
startHighlighting();
@@ -505,11 +635,15 @@ export default function MobileScannerPage() {
// Retry up to 50 times (5 seconds)
retryCount++;
setLoadingStatus(`Waiting for video... (${retryCount}/50)`);
console.log(`[Mobile Scanner] Video not ready yet, retry ${retryCount}/50...`);
console.log(
`[Mobile Scanner] Video not ready yet, retry ${retryCount}/50...`,
);
retryTimeout = window.setTimeout(startWhenReady, 100);
} else {
setLoadingStatus("Video failed to load ✗");
console.error("[Mobile Scanner] ✗ Video failed to become ready after 5 seconds");
console.error(
"[Mobile Scanner] ✗ Video failed to become ready after 5 seconds",
);
}
};
@@ -571,7 +705,9 @@ export default function MobileScannerPage() {
try {
// Create low-res canvas for detection (faster processing)
const detectionCanvas = document.createElement("canvas");
const detectionCtx = detectionCanvas.getContext("2d", { willReadFrequently: true });
const detectionCtx = detectionCanvas.getContext("2d", {
willReadFrequently: true,
});
if (!detectionCtx) throw new Error("Cannot create detection context");
const scale = DETECTION_WIDTH / video.videoWidth;
@@ -579,7 +715,13 @@ export default function MobileScannerPage() {
detectionCanvas.height = Math.round(video.videoHeight * scale);
// Draw downscaled image for detection
detectionCtx.drawImage(video, 0, 0, detectionCanvas.width, detectionCanvas.height);
detectionCtx.drawImage(
video,
0,
0,
detectionCanvas.width,
detectionCanvas.height,
);
// Run detection on low-res image
const mat = (window as any).cv.imread(detectionCanvas);
@@ -611,27 +753,46 @@ export default function MobileScannerPage() {
};
// Use scaled corners for extraction
const { topLeftCorner, topRightCorner, bottomLeftCorner, bottomRightCorner } = scaledCorners;
const {
topLeftCorner,
topRightCorner,
bottomLeftCorner,
bottomRightCorner,
} = scaledCorners;
console.log("Document detected at full resolution:", {
corners: scaledCorners,
});
// Calculate width and height of the document
const topWidth = Math.hypot(topRightCorner.x - topLeftCorner.x, topRightCorner.y - topLeftCorner.y);
const topWidth = Math.hypot(
topRightCorner.x - topLeftCorner.x,
topRightCorner.y - topLeftCorner.y,
);
const bottomWidth = Math.hypot(
bottomRightCorner.x - bottomLeftCorner.x,
bottomRightCorner.y - bottomLeftCorner.y,
);
const leftHeight = Math.hypot(bottomLeftCorner.x - topLeftCorner.x, bottomLeftCorner.y - topLeftCorner.y);
const rightHeight = Math.hypot(bottomRightCorner.x - topRightCorner.x, bottomRightCorner.y - topRightCorner.y);
const leftHeight = Math.hypot(
bottomLeftCorner.x - topLeftCorner.x,
bottomLeftCorner.y - topLeftCorner.y,
);
const rightHeight = Math.hypot(
bottomRightCorner.x - topRightCorner.x,
bottomRightCorner.y - topRightCorner.y,
);
// Use average dimensions to maintain proper aspect ratio
const docWidth = Math.round((topWidth + bottomWidth) / 2);
const docHeight = Math.round((leftHeight + rightHeight) / 2);
// Extract paper from full-resolution canvas with scaled corner points
const resultCanvas = scannerRef.current.extractPaper(canvas, docWidth, docHeight, scaledCorners);
const resultCanvas = scannerRef.current.extractPaper(
canvas,
docWidth,
docHeight,
scaledCorners,
);
// Clean up Mat
mat.delete();
@@ -649,7 +810,10 @@ export default function MobileScannerPage() {
finalDataUrl = canvas.toDataURL("image/jpeg", 0.95);
}
} catch (err) {
console.warn("jscanify processing failed, using original image:", err);
console.warn(
"jscanify processing failed, using original image:",
err,
);
finalDataUrl = canvas.toDataURL("image/jpeg", 0.95);
}
} else {
@@ -663,21 +827,24 @@ export default function MobileScannerPage() {
}
}, [autoEnhance, openCvReady]);
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
const file = files[0];
const reader = new FileReader();
const file = files[0];
const reader = new FileReader();
reader.onload = (event) => {
if (event.target?.result) {
setCurrentPreview(event.target.result as string);
}
};
reader.onload = (event) => {
if (event.target?.result) {
setCurrentPreview(event.target.result as string);
}
};
reader.readAsDataURL(file);
}, []);
reader.readAsDataURL(file);
},
[],
);
const addToBatch = useCallback(() => {
if (currentPreview) {
@@ -687,7 +854,9 @@ export default function MobileScannerPage() {
}, [currentPreview]);
const uploadImages = useCallback(async () => {
const imagesToUpload = currentPreview ? [currentPreview, ...capturedImages] : capturedImages;
const imagesToUpload = currentPreview
? [currentPreview, ...capturedImages]
: capturedImages;
if (imagesToUpload.length === 0) return;
if (!sessionId) return;
@@ -703,7 +872,9 @@ export default function MobileScannerPage() {
const dataUrl = imagesToUpload[i];
const response = await fetch(dataUrl);
const blob = await response.blob();
const file = new File([blob], `scan-${Date.now()}-${i}.jpg`, { type: "image/jpeg" });
const file = new File([blob], `scan-${Date.now()}-${i}.jpg`, {
type: "image/jpeg",
});
files.push(file);
setUploadProgress(((i + 1) / (imagesToUpload.length + 1)) * 50); // 0-50% for conversion
}
@@ -714,10 +885,13 @@ export default function MobileScannerPage() {
formData.append("files", file);
});
const uploadResponse = await fetch(`/api/v1/mobile-scanner/upload/${sessionId}`, {
method: "POST",
body: formData,
});
const uploadResponse = await fetch(
`/api/v1/mobile-scanner/upload/${sessionId}`,
{
method: "POST",
body: formData,
},
);
if (!uploadResponse.ok) {
throw new Error("Upload failed");
@@ -736,7 +910,9 @@ export default function MobileScannerPage() {
}, 1500);
} catch (err) {
console.error("Upload failed:", err);
setUploadError(t("mobileScanner.uploadFailed", "Upload failed. Please try again."));
setUploadError(
t("mobileScanner.uploadFailed", "Upload failed. Please try again."),
);
} finally {
setIsUploading(false);
}
@@ -768,8 +944,18 @@ export default function MobileScannerPage() {
// Show loading while validating
if (sessionValid === null) {
return (
<Box p="xl" style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "1rem" }}>
<Text size="lg">{t("mobileScanner.validating", "Validating session...")}</Text>
<Box
p="xl"
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "1rem",
}}
>
<Text size="lg">
{t("mobileScanner.validating", "Validating session...")}
</Text>
</Box>
);
}
@@ -778,8 +964,15 @@ export default function MobileScannerPage() {
if (!sessionValid || !sessionId) {
return (
<Box p="xl">
<Alert color="red" title={t("mobileScanner.sessionInvalid", "Session Error")}>
{sessionError || t("mobileScanner.noSessionMessage", "Session not found. Please try again.")}
<Alert
color="red"
title={t("mobileScanner.sessionInvalid", "Session Error")}
>
{sessionError ||
t(
"mobileScanner.noSessionMessage",
"Session not found. Please try again.",
)}
</Alert>
</Box>
);
@@ -797,12 +990,17 @@ export default function MobileScannerPage() {
padding: "2rem",
}}
>
<CheckCircleRoundedIcon style={{ fontSize: "4rem", color: "var(--mantine-color-green-6)" }} />
<CheckCircleRoundedIcon
style={{ fontSize: "4rem", color: "var(--mantine-color-green-6)" }}
/>
<Text size="xl" fw="bold" mt="md">
{t("mobileScanner.uploadSuccess", "Upload Successful!")}
</Text>
<Text size="sm" c="dimmed">
{t("mobileScanner.uploadSuccessMessage", "Your images have been transferred.")}
{t(
"mobileScanner.uploadSuccessMessage",
"Your images have been transferred.",
)}
</Text>
</Box>
);
@@ -831,7 +1029,11 @@ export default function MobileScannerPage() {
alt={t("home.mobile.brandAlt", "Stirling PDF logo")}
style={{ height: "32px", width: "32px" }}
/>
<img src={brandTextSrc} alt="Stirling PDF" style={{ height: "24px" }} />
<img
src={brandTextSrc}
alt="Stirling PDF"
style={{ height: "24px" }}
/>
</Group>
</Box>
@@ -840,7 +1042,9 @@ export default function MobileScannerPage() {
<Box
p="xs"
style={{
background: loadingStatus.includes("✗") ? "var(--mantine-color-red-1)" : "var(--mantine-color-blue-1)",
background: loadingStatus.includes("✗")
? "var(--mantine-color-red-1)"
: "var(--mantine-color-blue-1)",
borderBottom: "1px solid var(--border-subtle)",
fontSize: "0.85rem",
fontFamily: "monospace",
@@ -853,7 +1057,12 @@ export default function MobileScannerPage() {
{uploadError && (
<Box p="md">
<Alert color="red" icon={<ErrorRoundedIcon />} onClose={() => setUploadError(null)} withCloseButton>
<Alert
color="red"
icon={<ErrorRoundedIcon />}
onClose={() => setUploadError(null)}
withCloseButton
>
{uploadError}
</Alert>
</Box>
@@ -878,13 +1087,21 @@ export default function MobileScannerPage() {
{/* Choice screen */}
{mode === "choice" && !currentPreview && (
<Stack gap="lg" p="xl" align="center" style={{ maxWidth: "500px", margin: "0 auto" }}>
<Stack
gap="lg"
p="xl"
align="center"
style={{ maxWidth: "500px", margin: "0 auto" }}
>
<Stack gap="xs" align="center">
<Text size="xl" fw={700} ta="center">
{t("mobileScanner.chooseMethod", "Choose Upload Method")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t("mobileScanner.chooseMethodDescription", "Select how you want to scan and upload documents")}
{t(
"mobileScanner.chooseMethodDescription",
"Select how you want to scan and upload documents",
)}
</Text>
</Stack>
@@ -907,7 +1124,12 @@ export default function MobileScannerPage() {
}}
>
<Stack align="center" gap="md">
<PhotoCameraRoundedIcon style={{ fontSize: "3rem", color: "var(--mantine-color-blue-6)" }} />
<PhotoCameraRoundedIcon
style={{
fontSize: "3rem",
color: "var(--mantine-color-blue-6)",
}}
/>
<Text size="lg" fw={600}>
{t("mobileScanner.camera", "Camera")}
</Text>
@@ -938,12 +1160,20 @@ export default function MobileScannerPage() {
}}
>
<Stack align="center" gap="md">
<UploadRoundedIcon style={{ fontSize: "3rem", color: "var(--mantine-color-green-6)" }} />
<UploadRoundedIcon
style={{
fontSize: "3rem",
color: "var(--mantine-color-green-6)",
}}
/>
<Text size="lg" fw={600}>
{t("mobileScanner.fileUpload", "File Upload")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t("mobileScanner.fileDescription", "Upload existing photos or documents from your device")}
{t(
"mobileScanner.fileDescription",
"Upload existing photos or documents from your device",
)}
</Text>
</Stack>
</Card>
@@ -953,7 +1183,14 @@ export default function MobileScannerPage() {
{/* Camera interface */}
{mode === "camera" && !currentPreview && (
<Box style={{ position: "relative", height: "calc(100dvh - 60px)", display: "flex", flexDirection: "column" }}>
<Box
style={{
position: "relative",
height: "calc(100dvh - 60px)",
display: "flex",
flexDirection: "column",
}}
>
{/* Back button - floating top left */}
<Button
onClick={() => setMode("choice")}
@@ -973,7 +1210,14 @@ export default function MobileScannerPage() {
</Button>
{/* Video feed - fills available space */}
<Box style={{ position: "relative", flex: 1, background: "#000", overflow: "hidden" }}>
<Box
style={{
position: "relative",
flex: 1,
background: "#000",
overflow: "hidden",
}}
>
<video
ref={videoRef}
autoPlay
@@ -1023,19 +1267,36 @@ export default function MobileScannerPage() {
onChange={(e) => setAutoEnhance(e.currentTarget.checked)}
disabled={!openCvReady}
/>
<Text size="xs">{t("mobileScanner.edgeDetection", "Edge Detection")}</Text>
<Text size="xs">
{t("mobileScanner.edgeDetection", "Edge Detection")}
</Text>
</Group>
{torchSupported && (
<Group gap="xs">
<Switch size="sm" checked={torchEnabled} onChange={toggleTorch} />
<Text size="xs">{t("mobileScanner.flashlight", "Flash")}</Text>
<Switch
size="sm"
checked={torchEnabled}
onChange={toggleTorch}
/>
<Text size="xs">
{t("mobileScanner.flashlight", "Flash")}
</Text>
</Group>
)}
</Group>
{/* Capture button */}
<Button fullWidth size="lg" onClick={captureImage} loading={isProcessing} variant="filled" radius="xl">
{isProcessing ? t("mobileScanner.processing", "Processing...") : t("mobileScanner.capture", "Capture")}
<Button
fullWidth
size="lg"
onClick={captureImage}
loading={isProcessing}
variant="filled"
radius="xl"
>
{isProcessing
? t("mobileScanner.processing", "Processing...")
: t("mobileScanner.capture", "Capture")}
</Button>
</Stack>
</Box>
@@ -1044,14 +1305,35 @@ export default function MobileScannerPage() {
{/* File upload interface */}
{mode === "file" && !currentPreview && (
<Stack gap="lg" p="xl" align="center" style={{ maxWidth: "500px", margin: "0 auto" }}>
<Button onClick={() => setMode("choice")} variant="subtle" size="sm" style={{ alignSelf: "flex-start" }}>
<Stack
gap="lg"
p="xl"
align="center"
style={{ maxWidth: "500px", margin: "0 auto" }}
>
<Button
onClick={() => setMode("choice")}
variant="subtle"
size="sm"
style={{ alignSelf: "flex-start" }}
>
{t("mobileScanner.back", "Back")}
</Button>
<Card shadow="sm" padding="xl" radius="md" withBorder style={{ width: "100%" }}>
<Card
shadow="sm"
padding="xl"
radius="md"
withBorder
style={{ width: "100%" }}
>
<Stack align="center" gap="lg">
<UploadRoundedIcon style={{ fontSize: "4rem", color: "var(--mantine-color-gray-5)" }} />
<UploadRoundedIcon
style={{
fontSize: "4rem",
color: "var(--mantine-color-gray-5)",
}}
/>
<Text size="lg" fw={600} ta="center">
{t("mobileScanner.selectFilesPrompt", "Select files to upload")}
</Text>
@@ -1079,7 +1361,14 @@ export default function MobileScannerPage() {
{/* Preview interface */}
{currentPreview && (
<Box style={{ position: "relative", height: "calc(100dvh - 60px)", display: "flex", flexDirection: "column" }}>
<Box
style={{
position: "relative",
height: "calc(100dvh - 60px)",
display: "flex",
flexDirection: "column",
}}
>
{/* Preview image - fills available space */}
<Box
style={{
@@ -1095,7 +1384,12 @@ export default function MobileScannerPage() {
<img
src={currentPreview}
alt="Preview"
style={{ maxWidth: "100%", maxHeight: "100%", display: "block", objectFit: "contain" }}
style={{
maxWidth: "100%",
maxHeight: "100%",
display: "block",
objectFit: "contain",
}}
/>
</Box>
@@ -1116,7 +1410,14 @@ export default function MobileScannerPage() {
{t("mobileScanner.addToBatch", "Add to Batch")}
</Button>
</Group>
<Button fullWidth variant="filled" size="lg" onClick={uploadImages} loading={isUploading} radius="xl">
<Button
fullWidth
variant="filled"
size="lg"
onClick={uploadImages}
loading={isUploading}
radius="xl"
>
{t("mobileScanner.upload", "Upload")}
</Button>
</Stack>
@@ -1128,10 +1429,16 @@ export default function MobileScannerPage() {
<Box p="sm" style={{ borderTop: "1px solid var(--border-subtle)" }}>
<Group justify="space-between" mb="sm">
<Text size="sm" fw={600}>
{t("mobileScanner.batchImages", "Batch")} ({capturedImages.length})
{t("mobileScanner.batchImages", "Batch")} ({capturedImages.length}
)
</Text>
<Group gap="xs">
<Button size="xs" variant="outline" onClick={clearBatch} color="red">
<Button
size="xs"
variant="outline"
onClick={clearBatch}
color="red"
>
{t("mobileScanner.clearBatch", "Clear")}
</Button>
<Button size="xs" onClick={uploadImages} loading={isUploading}>
@@ -1139,7 +1446,14 @@ export default function MobileScannerPage() {
</Button>
</Group>
</Group>
<Box style={{ display: "flex", gap: "var(--space-sm)", overflowX: "auto", paddingBottom: "var(--space-sm)" }}>
<Box
style={{
display: "flex",
gap: "var(--space-sm)",
overflowX: "auto",
paddingBottom: "var(--space-sm)",
}}
>
{capturedImages.map((img, idx) => (
<Box
key={idx}
@@ -1151,7 +1465,11 @@ export default function MobileScannerPage() {
border: "2px solid var(--border-subtle)",
}}
>
<img src={img} alt={`Capture ${idx + 1}`} style={{ width: "100%", height: "100%", objectFit: "cover" }} />
<img
src={img}
alt={`Capture ${idx + 1}`}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
</Box>
))}
</Box>