Enhance mobile cropping experience with dual-mode scanner

- Add toggle between Smart Scan (jscanify) and Manual Crop (cropper.js) modes
- Default to Manual Crop mode on touch devices for easier mobile use
- Integrate cropper.js with pinch-to-zoom, pan, and touch-friendly handles
- Increase corner point size and button sizes for better touch targets
- Add mobile help text and visual guidance
- Maintain backward compatibility with existing scanner functionality
- Improve touch event handling to prevent unwanted scrolling
This commit is contained in:
2026-01-22 19:31:39 +08:00
parent 564869b110
commit 7bf7787c6f

View File

@@ -3,28 +3,51 @@
<div class="modal fade" id="modalScanner" tabindex="-1" role="dialog" aria-labelledby="modalScannerLabel" aria-hidden="true" data-backdrop="static">
<div class="modal-dialog modal-xl" role="document">
<div class="modal-content">
<div class="modal-header">
<div class="modal-header">
<h5 class="modal-title" id="modalScannerLabel"><i class="fas fa-expand"></i> Smart Scanner</h5>
<div class="ml-auto mr-3">
<div class="btn-group btn-group-sm" role="group" id="scannerModeToggle">
<button type="button" class="btn btn-outline-primary active" data-mode="smart">
<i class="fas fa-magic"></i> Smart Scan
</button>
<button type="button" class="btn btn-outline-secondary" data-mode="manual">
<i class="fas fa-crop-alt"></i> Manual Crop
</button>
</div>
</div>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body text-center bg-dark p-0" style="position: relative; overflow: hidden; height: 80vh;">
<!-- Container for Canvases -->
<div id="scanner-container" style="position: relative; margin: auto; display: inline-block;">
<div class="modal-body text-center bg-dark p-0" style="position: relative; overflow: hidden; height: 80vh;">
<!-- Smart Scan Mode (Default) -->
<div id="scanner-container" class="scanner-mode" data-mode="smart" style="display: block; position: relative; margin: auto; display: inline-block;">
<canvas id="canvas-image" style="position: absolute; left: 0; top: 0; z-index: 1;"></canvas>
<canvas id="canvas-overlay" style="position: absolute; left: 0; top: 0; z-index: 2; cursor: crosshair;"></canvas>
</div>
<!-- Manual Crop Mode (Cropper.js) -->
<div id="crop-container" class="scanner-mode" data-mode="manual" style="display: none; width: 100%; height: 100%;">
<img id="crop-image" style="max-width: 100%; max-height: 100%;">
</div>
<div id="scanner-loading" style="position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); color: white; display: none;">
<i class="fas fa-spinner fa-spin fa-3x"></i><br>Detecting Document...
</div>
<!-- Mobile Help Tips -->
<div id="mobile-help" class="d-none d-md-none d-lg-none" style="position: absolute; bottom: 10px; left: 0; right: 0; text-align: center; color: white; background: rgba(0,0,0,0.7); padding: 5px; font-size: 12px;">
<span id="help-text-smart">📍 Sentuh & geser titik biru untuk atur sudut (untuk mobile, gunakan Manual Crop)</span>
<span id="help-text-manual" style="display: none;">📍 Pinch untuk zoom, geser untuk pindah area crop</span>
</div>
</div>
<div class="modal-footer justify-content-between">
<div>
<button type="button" class="btn btn-secondary" id="btnScanRotateLeft" title="Putar Kiri (-90°)"><i class="fas fa-undo"></i></button>
<button type="button" class="btn btn-secondary" id="btnScanRotateRight" title="Putar Kanan (+90°)"><i class="fas fa-redo"></i></button>
<button type="button" class="btn btn-warning" id="btnScanReset"><i class="fas fa-sync"></i> Reset Sudut</button>
</div>
<div>
<button type="button" class="btn btn-secondary" id="btnScanRotateLeft" title="Putar Kiri (-90°)"><i class="fas fa-undo"></i></button>
<button type="button" class="btn btn-secondary" id="btnScanRotateRight" title="Putar Kanan (+90°)"><i class="fas fa-redo"></i></button>
<button type="button" class="btn btn-warning" id="btnScanReset"><i class="fas fa-sync"></i> Reset Sudut</button>
<button type="button" class="btn btn-info d-none" id="btnCropReset"><i class="fas fa-crop"></i> Reset Crop</button>
</div>
<div>
<button type="button" class="btn btn-secondary" data-dismiss="modal">Batal</button>
<button type="button" class="btn btn-primary" id="btnScanSave"><i class="fas fa-check"></i> Simpan Hasil Scan</button>
@@ -135,14 +158,55 @@
.modal-open #modalScanner {
overflow: hidden !important;
}
/* Larger touch target for close button on mobile */
#modalScanner .modal-header .close {
padding: 20px !important;
font-size: 2rem !important;
line-height: 1 !important;
margin: -10px -10px -10px auto !important;
}
}
/* Larger touch target for close button on mobile */
#modalScanner .modal-header .close {
padding: 20px !important;
font-size: 2rem !important;
line-height: 1 !important;
margin: -10px -10px -10px auto !important;
}
/* Larger touch targets for all buttons */
#modalScanner .modal-footer .btn {
min-height: 44px !important;
min-width: 44px !important;
padding: 10px 15px !important;
font-size: 16px !important; /* Prevent zoom on iOS */
}
/* Larger corner points for touch */
#canvas-overlay {
touch-action: manipulation;
}
/* Mode toggle buttons */
#scannerModeToggle .btn {
min-height: 36px !important;
min-width: 90px !important;
font-size: 14px !important;
}
/* Help text */
#mobile-help {
font-size: 14px !important;
padding: 10px !important;
}
}
/* Cropper.js customizations for mobile */
.cropper-point {
width: 30px !important;
height: 30px !important;
}
.cropper-line {
background-color: rgba(0, 123, 255, 0.5) !important;
}
/* Larger hit area for corner points in smart scan */
.scanner-mode[data-mode="smart"] canvas {
touch-action: pinch-zoom;
}
</style>
<script>
@@ -154,14 +218,18 @@ window.addEventListener('load', function() {
var ctxImg = canvasImage.getContext('2d');
var ctxOver = canvasOverlay.getContext('2d');
var scanner = null;
try {
scanner = new jscanify();
} catch (e) {
console.error('Failed to initialize jscanify:', e);
// scanner remains null
}
var originalImg = new Image();
var scanner = null;
try {
scanner = new jscanify();
} catch (e) {
console.error('Failed to initialize jscanify:', e);
// scanner remains null
}
var originalImg = new Image();
// Cropper.js instance
var cropper = null;
var currentMode = IS_TOUCH_DEVICE ? 'manual' : 'smart'; // Default to manual for touch devices
// Corner Points (tl, tr, bl, br)
var corners = [];
@@ -171,18 +239,51 @@ window.addEventListener('load', function() {
var isTouchInteraction = false;
var touchOffset = null;
// Config
const POINT_RADIUS = 15;
const POINT_COLOR = '#007bff';
const LINE_COLOR = '#00ff00';
const LINE_WIDTH = 3;
const TOUCH_RADIUS_MULTIPLIER = 2.5; // Larger hit area for touch devices
const TOUCH_SLOP = 5; // pixels threshold before dragging starts
const IS_TOUCH_DEVICE = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
// Config
const POINT_RADIUS = IS_TOUCH_DEVICE ? 25 : 15; // Larger for touch devices
const POINT_COLOR = '#007bff';
const LINE_COLOR = '#00ff00';
const LINE_WIDTH = IS_TOUCH_DEVICE ? 5 : 3;
const TOUCH_RADIUS_MULTIPLIER = 2.5; // Larger hit area for touch devices
const TOUCH_SLOP = 5; // pixels threshold before dragging starts
const IS_TOUCH_DEVICE = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
// --- Public Function to Open Scanner ---
// --- Public Function to Open Scanner ---
window.openScanner = function(file) {
// --- Mode Toggle Handler ---
$('#scannerModeToggle button').on('click', function() {
var mode = $(this).data('mode');
if (mode === currentMode) return;
// Update UI
$('#scannerModeToggle button').removeClass('active btn-primary').addClass('btn-outline-secondary');
$(this).removeClass('btn-outline-secondary').addClass('active btn-primary');
// Switch modes
$('.scanner-mode').hide();
$('.scanner-mode[data-mode="' + mode + '"]').show();
// Update help text
$('#help-text-smart, #help-text-manual').hide();
$('#help-text-' + mode).show();
// Show/hide appropriate buttons
if (mode === 'smart') {
$('#btnScanReset').removeClass('d-none');
$('#btnCropReset').addClass('d-none');
} else {
$('#btnScanReset').addClass('d-none');
$('#btnCropReset').removeClass('d-none');
// Initialize cropper if not already
if (typeof Cropper !== 'undefined' && !cropper && originalImg.src) {
initCropper();
}
}
currentMode = mode;
});
// --- Public Function to Open Scanner ---
window.openScanner = function(file) {
if (!file) return;
// Reset State
@@ -231,13 +332,19 @@ window.addEventListener('load', function() {
var modalHeader = document.querySelector('#modalScanner .modal-header');
var modalFooter = document.querySelector('#modalScanner .modal-footer');
var preventTouch = function(e) {
// Only prevent if not clicking a button
if (e.target.tagName !== 'BUTTON' && !e.target.closest('button')) {
e.preventDefault();
e.stopPropagation();
}
};
var preventTouch = function(e) {
// Allow touch on canvas elements and buttons
var target = e.target;
var isCanvas = target.id === 'canvas-overlay' || target.id === 'canvas-image' ||
target.id === 'crop-image' || target.closest('#crop-container') ||
target.closest('#scanner-container');
var isButton = target.tagName === 'BUTTON' || target.closest('button');
if (!isCanvas && !isButton) {
e.preventDefault();
e.stopPropagation();
}
};
if (modalDialog) {
modalDialog.addEventListener('touchstart', preventTouch, { passive: false });
@@ -257,12 +364,14 @@ window.addEventListener('load', function() {
}
});
// Restore scrolling when modal is hidden
scannerModal.on('hidden.bs.modal', function() {
document.body.style.overflow = '';
document.body.style.position = '';
document.body.style.width = '';
});
// Restore scrolling when modal is hidden
scannerModal.on('hidden.bs.modal', function() {
document.body.style.overflow = '';
document.body.style.position = '';
document.body.style.width = '';
// Cleanup cropper
destroyCropper();
});
scannerModal.modal('show');
};
@@ -324,31 +433,106 @@ window.addEventListener('load', function() {
drawOverlay();
}
function initScanner() {
// Resize Canvas to fit screen but keep aspect ratio
var maxWidth = $('#modalScanner .modal-body').width() - 20;
var maxHeight = $('#modalScanner .modal-body').height() - 20;
var scale = Math.min(maxWidth / originalImg.width, maxHeight / originalImg.height);
var w = originalImg.width * scale;
var h = originalImg.height * scale;
canvasImage.width = w;
canvasImage.height = h;
canvasOverlay.width = w;
canvasOverlay.height = h;
// Resize container
$('#scanner-container').css({ width: w, height: h, marginTop: '10px' });
// Draw Image
ctxImg.drawImage(originalImg, 0, 0, w, h);
function initScanner() {
// Resize Canvas to fit screen but keep aspect ratio
var maxWidth = $('#modalScanner .modal-body').width() - 20;
var maxHeight = $('#modalScanner .modal-body').height() - 20;
detectDocument(scale, w, h, false);
$('#scanner-loading').hide();
}
var scale = Math.min(maxWidth / originalImg.width, maxHeight / originalImg.height);
var w = originalImg.width * scale;
var h = originalImg.height * scale;
canvasImage.width = w;
canvasImage.height = h;
canvasOverlay.width = w;
canvasOverlay.height = h;
// Resize container
$('#scanner-container').css({ width: w, height: h, marginTop: '10px' });
// Draw Image
ctxImg.drawImage(originalImg, 0, 0, w, h);
detectDocument(scale, w, h, false);
$('#scanner-loading').hide();
// Prepare image for cropper
var cropImage = document.getElementById('crop-image');
cropImage.src = originalImg.src;
// Initialize cropper if in manual mode
if (currentMode === 'manual' && typeof Cropper !== 'undefined') {
initCropper();
}
}
function defaultCorners(w, h) {
function initCropper() {
if (typeof Cropper === 'undefined') {
console.error('Cropper.js not loaded');
alert('Cropper library not loaded. Please refresh the page.');
return;
}
var cropImage = document.getElementById('crop-image');
if (!cropImage.src) {
console.warn('Crop image not loaded yet');
return;
}
// Destroy existing cropper
if (cropper) {
cropper.destroy();
cropper = null;
}
// Initialize new cropper with mobile-friendly options
cropper = new Cropper(cropImage, {
viewMode: 1,
dragMode: 'crop',
initialAspectRatio: 16 / 9,
aspectRatio: NaN, // Free aspect ratio
autoCrop: true,
autoCropArea: 0.8,
responsive: true,
restore: true,
checkCrossOrigin: false,
highlight: false,
cropBoxMovable: true,
cropBoxResizable: true,
toggleDragModeOnDblclick: false,
minCanvasWidth: 100,
minCanvasHeight: 100,
minContainerWidth: 100,
minContainerHeight: 100,
minCropBoxWidth: 50,
minCropBoxHeight: 50,
// Mobile touch settings
touchDragZoom: true,
wheelZoomRatio: 0.1,
ready: function() {
// Adjust for mobile
if (IS_TOUCH_DEVICE) {
// Make handles larger for touch
var points = this.cropper.cropBox.querySelectorAll('.cropper-point');
points.forEach(function(point) {
point.style.width = '30px';
point.style.height = '30px';
});
}
}
});
}
function destroyCropper() {
if (cropper) {
cropper.destroy();
cropper = null;
}
}
function defaultCorners(w, h) {
// Default 20% margin
var mX = w * 0.1;
var mY = h * 0.1;
@@ -508,73 +692,115 @@ window.addEventListener('load', function() {
touchOffset = null;
}
// --- Rotate Functions ---
function rotateImage(degree) {
var offCanvas = document.createElement('canvas');
var offCtx = offCanvas.getContext('2d');
// Swap Width/Height for 90 degree rotation
offCanvas.width = originalImg.height;
offCanvas.height = originalImg.width;
offCtx.translate(offCanvas.width / 2, offCanvas.height / 2);
offCtx.rotate(degree * Math.PI / 180);
offCtx.drawImage(originalImg, -originalImg.width / 2, -originalImg.height / 2);
// Update originalImg
var rotatedUrl = offCanvas.toDataURL();
originalImg.onload = function() {
initScanner(); // Re-init with new image
}
originalImg.src = rotatedUrl;
}
// --- Rotate Functions ---
function rotateImage(degree) {
var offCanvas = document.createElement('canvas');
var offCtx = offCanvas.getContext('2d');
// Swap Width/Height for 90 degree rotation
offCanvas.width = originalImg.height;
offCanvas.height = originalImg.width;
offCtx.translate(offCanvas.width / 2, offCanvas.height / 2);
offCtx.rotate(degree * Math.PI / 180);
offCtx.drawImage(originalImg, -originalImg.width / 2, -originalImg.height / 2);
// Update originalImg
var rotatedUrl = offCanvas.toDataURL();
originalImg.onload = function() {
// Re-init scanner for both modes
if (currentMode === 'smart') {
initScanner();
} else {
// Update cropper image
var cropImage = document.getElementById('crop-image');
cropImage.src = rotatedUrl;
// Reinitialize cropper
if (cropper) {
cropper.destroy();
}
initCropper();
}
}
originalImg.src = rotatedUrl;
}
$('#btnScanRotateLeft').click(function() { rotateImage(-90); });
$('#btnScanRotateRight').click(function() { rotateImage(90); });
// --- Reset Button ---
$('#btnScanReset').click(function() {
initScanner();
});
// --- Reset Button ---
$('#btnScanReset').click(function() {
initScanner();
});
// --- Crop Reset Button ---
$('#btnCropReset').click(function() {
if (cropper) {
cropper.reset();
}
});
// --- Save / Extract Button ---
$('#btnScanSave').click(function() {
// Warp Image
try {
// 1. Get raw points relative to Original Image
var scaleX = originalImg.width / canvasImage.width;
var scaleY = originalImg.height / canvasImage.height;
// --- Save / Extract Button ---
$('#btnScanSave').click(function() {
try {
var base64;
var tl = { x: corners[0].x * scaleX, y: corners[0].y * scaleY };
var tr = { x: corners[1].x * scaleX, y: corners[1].y * scaleY };
var br = { x: corners[2].x * scaleX, y: corners[2].y * scaleY };
var bl = { x: corners[3].x * scaleX, y: corners[3].y * scaleY };
// 2. Calculate dimensions of the crop area
var widthTop = Math.hypot(tr.x - tl.x, tr.y - tl.y);
var widthBottom = Math.hypot(br.x - bl.x, br.y - bl.y);
var outputWidth = Math.max(widthTop, widthBottom);
var heightLeft = Math.hypot(bl.x - tl.x, bl.y - tl.y);
var heightRight = Math.hypot(br.x - tr.x, br.y - tr.y);
var outputHeight = Math.max(heightLeft, heightRight);
if (currentMode === 'smart') {
// Smart Scan Mode - Use jscanify
// 1. Get raw points relative to Original Image
var scaleX = originalImg.width / canvasImage.width;
var scaleY = originalImg.height / canvasImage.height;
var tl = { x: corners[0].x * scaleX, y: corners[0].y * scaleY };
var tr = { x: corners[1].x * scaleX, y: corners[1].y * scaleY };
var br = { x: corners[2].x * scaleX, y: corners[2].y * scaleY };
var bl = { x: corners[3].x * scaleX, y: corners[3].y * scaleY };
// 2. Calculate dimensions of the crop area
var widthTop = Math.hypot(tr.x - tl.x, tr.y - tl.y);
var widthBottom = Math.hypot(br.x - bl.x, br.y - bl.y);
var outputWidth = Math.max(widthTop, widthBottom);
var heightLeft = Math.hypot(bl.x - tl.x, bl.y - tl.y);
var heightRight = Math.hypot(br.x - tr.x, br.y - tr.y);
var outputHeight = Math.max(heightLeft, heightRight);
var extractPoints = {
topLeftCorner: tl,
topRightCorner: tr,
bottomRightCorner: br,
bottomLeftCorner: bl
};
// 3. Extract with dynamic dimensions
var resultCanvas = null;
if (scanner && scanner.extractPaper) {
resultCanvas = scanner.extractPaper(originalImg, outputWidth, outputHeight, extractPoints);
var extractPoints = {
topLeftCorner: tl,
topRightCorner: tr,
bottomRightCorner: br,
bottomLeftCorner: bl
};
// 3. Extract with dynamic dimensions
var resultCanvas = null;
if (scanner && scanner.extractPaper) {
resultCanvas = scanner.extractPaper(originalImg, outputWidth, outputHeight, extractPoints);
} else {
alert("Scanner library not loaded. Please refresh the page.");
return;
}
base64 = resultCanvas.toDataURL('image/jpeg');
} else {
alert("Scanner library not loaded. Please refresh the page.");
return;
// Manual Crop Mode - Use cropper.js
if (!cropper) {
alert("Cropper not initialized. Please try again.");
return;
}
// Get cropped canvas
var canvas = cropper.getCroppedCanvas({
width: originalImg.width,
height: originalImg.height,
fillColor: '#fff',
imageSmoothingEnabled: true,
imageSmoothingQuality: 'high'
});
base64 = canvas.toDataURL('image/jpeg');
}
var base64 = resultCanvas.toDataURL('image/jpeg');
if (window.handleScannerResult) {
window.handleScannerResult(base64);
@@ -582,10 +808,25 @@ window.addEventListener('load', function() {
scannerModal.modal('hide');
} catch (e) {
alert("Gagal memproses gambar: " + e.message);
}
});
} catch (e) {
alert("Gagal memproses gambar: " + e.message);
console.error(e);
}
});
// Show mobile help if touch device
if (IS_TOUCH_DEVICE) {
$('#mobile-help').removeClass('d-none');
}
// Set initial mode based on device
setTimeout(function() {
var initialMode = IS_TOUCH_DEVICE ? 'manual' : 'smart';
var button = $('#scannerModeToggle button[data-mode="' + initialMode + '"]');
if (button.length) {
button.click();
}
}, 100);
});
});
</script>
</script>