Fix mobile scanner upload flow and fit it to one screen (#7684)

file mobile phone scanner UI issues when on http and scaling UI issues
Ensuring that smaller screens dont cut off UI elements 
better handling of batch photos

<img width="2104" height="8800" alt="montage_mobile-scanner"
src="https://github.com/user-attachments/assets/b4dd114b-c54d-4101-8700-7307dbb0eee9"
/>


---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
This commit is contained in:
Anthony Stirling
2026-09-02 09:13:31 +00:00
committed by GitHub
parent 2cf355c5cd
commit 42bdce155c
5 changed files with 772 additions and 557 deletions
@@ -5416,39 +5416,45 @@ sort = "Sort"
title = "Merge Settings Overview"
[mobileScanner]
addToBatch = "Add to Batch"
addMore = "Add More"
back = "Back"
batchImages = "Batch"
camera = "Camera"
cameraAccessDenied = "Camera access denied. Please enable camera access."
cameraDescription = "Scan documents using your device camera with automatic edge detection"
capture = "Capture Photo"
chooseMethod = "Choose Upload Method"
chooseMethodDescription = "Select how you want to scan and upload documents"
clearBatch = "Clear"
clearAll = "Clear All"
closeTabHint = "You can close this tab now."
dismiss = "Dismiss"
edgeDetection = "Edge Detection"
fileDescription = "Upload existing photos or documents from your device"
fileReadFailed = "Could not read that file."
fileUpload = "File Upload"
flash = "Flash"
flashlight = "Flashlight"
httpsRequired = "Camera access requires HTTPS or localhost. Please use HTTPS or access via localhost."
noSession = "Invalid Session"
imageCount_one = "{{count}} image"
imageCount_other = "{{count}} images"
imagePosition = "Image {{index}} of {{total}}"
invalidFileType = "Please choose an image file."
noSessionMessage = "Please scan a valid QR code to access this page."
processing = "Processing..."
remove = "Remove"
retake = "Retake"
scanAnother = "Scan another"
selectFilesPrompt = "Select files to upload"
selectImage = "Select Image"
selectImages = "Select Images"
sessionExpired = "This session has expired. Please refresh and try again."
sessionInvalid = "Session Error"
sessionNotFound = "Session not found. Please refresh and try again."
sessionValidationError = "Unable to verify session. Please try again."
startingCamera = "Starting camera…"
title = "Mobile Scanner"
upload = "Upload"
uploadAll = "Upload All"
uploadFailed = "Upload failed. Please try again."
uploading = "Uploading..."
uploadSuccess = "Upload Successful!"
uploadSuccessMessage = "Your images have been transferred."
uploadWithCount = "Upload ({{total}})"
validating = "Validating session..."
[mobileSign]
File diff suppressed because it is too large Load Diff
@@ -100,8 +100,11 @@ export async function handleHttpError(error: unknown): Promise<boolean> {
pathname.includes("/auth/") ||
pathname.includes("/invite/");
const isPublicMobilePage =
pathname.includes("/mobile-scanner") || pathname.includes("/mobile-sign");
// If not on auth page, redirect to login with expired session message
if (!isAuthPage && !skipAuthRedirect) {
if (!isAuthPage && !isPublicMobilePage && !skipAuthRedirect) {
if (loginRedirectRecentlyFired()) {
console.warn(
"[httpErrorHandler] 401 redirect already fired moments ago — suppressing repeat to avoid a login loop:",
@@ -0,0 +1,62 @@
/**
* Unit tests for fitThumbs, which sizes the mobile scanner's thumbnail strip.
*
* The page deliberately never scrolls, so the strip shrinks to fit instead.
* These pin the invariants that keep it from swallowing the camera preview.
*/
import { describe, test, expect } from "vitest";
import { fitThumbs, THUMB_SIZES } from "@app/utils/mobileScannerThumbs";
const TOTALS = [1, 3, 9, 21, 40];
const VIEWPORTS = [
{ width: 240, height: 320 },
{ width: 1024, height: 1366 },
];
describe("fitThumbs", () => {
test("a zero viewport falls back to the largest thumb and no height cap", () => {
expect(fitThumbs(3, 0, 0)).toEqual({
thumbSize: THUMB_SIZES[0],
stripMaxHeight: undefined,
});
expect(fitThumbs(3, 375, 0)).toEqual({
thumbSize: THUMB_SIZES[0],
stripMaxHeight: undefined,
});
expect(fitThumbs(3, 0, 812)).toEqual({
thumbSize: THUMB_SIZES[0],
stripMaxHeight: undefined,
});
});
test("stripMaxHeight never exceeds 45% of the viewport height", () => {
for (const { width, height } of VIEWPORTS) {
for (const total of TOTALS) {
const { stripMaxHeight } = fitThumbs(total, width, height);
expect(stripMaxHeight).toBeLessThanOrEqual(height * 0.45);
}
}
});
test("thumbSize never grows as more images are added", () => {
for (const { width, height } of VIEWPORTS) {
let previous = Number.POSITIVE_INFINITY;
for (let total = 1; total <= 60; total++) {
const { thumbSize } = fitThumbs(total, width, height);
expect(thumbSize).toBeLessThanOrEqual(previous);
previous = thumbSize;
}
}
});
test("thumbSize is always one of the allowed sizes", () => {
for (const { width, height } of VIEWPORTS) {
for (const total of TOTALS) {
expect(THUMB_SIZES).toContain(
fitThumbs(total, width, height).thumbSize,
);
}
}
});
});
@@ -0,0 +1,36 @@
export const THUMB_SIZES = [52, 44, 38, 32, 26, 20];
export const THUMB_GAP = 4;
/**
* Pick the largest thumbnail size whose wrapped strip still fits the share of
* the viewport the mobile scanner reserves for it, and the height cap to render
* that strip at. Keeps the page to one screen with no scrolling at any size.
*/
export function fitThumbs(
total: number,
viewportWidth: number,
viewportHeight: number,
) {
if (!viewportWidth || !viewportHeight) {
return { thumbSize: THUMB_SIZES[0], stripMaxHeight: undefined };
}
const rowWidth = Math.max(viewportWidth - 24, 80);
const heightFor = (size: number) => {
const perRow = Math.max(
1,
Math.floor((rowWidth + THUMB_GAP) / (size + THUMB_GAP)),
);
return Math.ceil(total / perRow) * (size + THUMB_GAP);
};
const preferred = viewportHeight * 0.24;
const thumbSize =
THUMB_SIZES.find((size) => heightFor(size) <= preferred) ??
THUMB_SIZES[THUMB_SIZES.length - 1];
// Let the strip grow past its usual share rather than hide images, but never
// far enough to swallow the preview.
const stripMaxHeight = Math.min(
Math.max(preferred, heightFor(thumbSize)),
viewportHeight * 0.45,
);
return { thumbSize, stripMaxHeight };
}