- 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
833 lines
31 KiB
PHP
833 lines
31 KiB
PHP
|
|
<!-- Modal Scanner -->
|
|
<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">
|
|
<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">×</span>
|
|
</button>
|
|
</div>
|
|
<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>
|
|
<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>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
/* Disable all transitions and hover effects for scanner modal */
|
|
#modalScanner .card,
|
|
#modalScanner .modal-content,
|
|
#modalScanner .modal-body,
|
|
#modalScanner .modal-header,
|
|
#modalScanner .modal-footer,
|
|
#modalScanner .modal-dialog,
|
|
#modalScanner #scanner-container,
|
|
#modalScanner #scanner-container * {
|
|
transition: none !important;
|
|
}
|
|
#modalScanner .card:hover,
|
|
#modalScanner .modal-content:hover,
|
|
#modalScanner .modal-body:hover,
|
|
#modalScanner .modal-header:hover,
|
|
#modalScanner .modal-footer:hover,
|
|
#modalScanner .modal-dialog:hover,
|
|
#modalScanner #scanner-container:hover,
|
|
#modalScanner #scanner-container *:hover {
|
|
transform: none !important;
|
|
}
|
|
/* Ensure canvas is fully visible */
|
|
#canvas-image {
|
|
opacity: 1 !important;
|
|
filter: none !important;
|
|
background-color: white !important;
|
|
}
|
|
#canvas-overlay {
|
|
opacity: 1 !important;
|
|
filter: none !important;
|
|
}
|
|
/* Prevent canvas from moving on hover */
|
|
#canvas-image:hover,
|
|
#canvas-overlay:hover {
|
|
transform: none !important;
|
|
}
|
|
|
|
/* Prevent modal dragging and improve touch handling on mobile */
|
|
#modalScanner .modal-dialog,
|
|
#modalScanner .modal-content,
|
|
#modalScanner .modal-header,
|
|
#modalScanner .modal-footer {
|
|
touch-action: none !important; /* Prevent browser touch gestures (pan, zoom, swipe) */
|
|
user-select: none !important; /* Prevent text selection during drag */
|
|
-webkit-user-select: none !important;
|
|
-webkit-touch-callout: none !important;
|
|
}
|
|
|
|
/* Allow touch interaction only on canvas and buttons */
|
|
#modalScanner .modal-body,
|
|
#canvas-image,
|
|
#canvas-overlay {
|
|
touch-action: manipulation !important; /* Allow pinch-zoom and pan on canvas only */
|
|
}
|
|
|
|
/* Prevent modal backdrop from responding to touch */
|
|
.modal-backdrop {
|
|
touch-action: none !important;
|
|
}
|
|
|
|
/* Lock modal position on mobile */
|
|
@media (max-width: 768px) {
|
|
#modalScanner {
|
|
padding-right: 0 !important; /* Prevent shift from scrollbar */
|
|
}
|
|
#modalScanner .modal-dialog {
|
|
margin: 0 !important;
|
|
max-height: 100vh !important;
|
|
height: 100vh !important;
|
|
width: 100vw !important;
|
|
max-width: 100vw !important;
|
|
position: fixed !important;
|
|
top: 0 !important;
|
|
left: 0 !important;
|
|
right: 0 !important;
|
|
bottom: 0 !important;
|
|
transform: none !important;
|
|
transition: none !important;
|
|
}
|
|
#modalScanner .modal-content {
|
|
border-radius: 0 !important;
|
|
height: 100vh !important;
|
|
max-height: 100vh !important;
|
|
width: 100vw !important;
|
|
max-width: 100vw !important;
|
|
overflow: hidden !important;
|
|
position: fixed !important;
|
|
top: 0 !important;
|
|
left: 0 !important;
|
|
transform: none !important;
|
|
transition: none !important;
|
|
}
|
|
#modalScanner .modal-body {
|
|
height: calc(100vh - 120px) !important; /* Account for header and footer */
|
|
overflow: hidden !important;
|
|
}
|
|
/* Prevent any modal movement */
|
|
.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 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>
|
|
window.addEventListener('load', function() {
|
|
// Scanner Variables
|
|
var scannerModal = $('#modalScanner');
|
|
var canvasImage = document.getElementById('canvas-image');
|
|
var canvasOverlay = document.getElementById('canvas-overlay');
|
|
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();
|
|
|
|
// 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 = [];
|
|
var activePoint = null;
|
|
var isDragging = false;
|
|
var touchStartPos = null;
|
|
var isTouchInteraction = false;
|
|
var touchOffset = null;
|
|
|
|
// 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;
|
|
|
|
// --- 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
|
|
corners = [];
|
|
activePoint = null;
|
|
isDragging = false;
|
|
|
|
// Show Loading
|
|
$('#scanner-loading').show();
|
|
|
|
// Logic: Wait for BOTH Image Load AND Modal Shown
|
|
var imgLoaded = false;
|
|
var modalShown = false;
|
|
|
|
function checkReady() {
|
|
if (imgLoaded && modalShown) {
|
|
initScanner();
|
|
}
|
|
}
|
|
|
|
// 1. Load Image
|
|
var reader = new FileReader();
|
|
reader.onload = function(e) {
|
|
originalImg.onload = function() {
|
|
imgLoaded = true;
|
|
checkReady();
|
|
};
|
|
originalImg.src = e.target.result;
|
|
};
|
|
reader.readAsDataURL(file);
|
|
|
|
// 2. Show Modal & Listen
|
|
scannerModal.off('shown.bs.modal'); // Remove old listeners
|
|
scannerModal.on('shown.bs.modal', function() {
|
|
modalShown = true;
|
|
checkReady();
|
|
|
|
// Prevent body scrolling on mobile
|
|
document.body.style.overflow = 'hidden';
|
|
document.body.style.position = 'fixed';
|
|
document.body.style.width = '100%';
|
|
|
|
// Prevent modal dragging on mobile
|
|
var modalDialog = document.querySelector('#modalScanner .modal-dialog');
|
|
var modalContent = document.querySelector('#modalScanner .modal-content');
|
|
var modalHeader = document.querySelector('#modalScanner .modal-header');
|
|
var modalFooter = document.querySelector('#modalScanner .modal-footer');
|
|
|
|
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 });
|
|
modalDialog.addEventListener('touchmove', preventTouch, { passive: false });
|
|
}
|
|
if (modalContent) {
|
|
modalContent.addEventListener('touchstart', preventTouch, { passive: false });
|
|
modalContent.addEventListener('touchmove', preventTouch, { passive: false });
|
|
}
|
|
if (modalHeader) {
|
|
modalHeader.addEventListener('touchstart', preventTouch, { passive: false });
|
|
modalHeader.addEventListener('touchmove', preventTouch, { passive: false });
|
|
}
|
|
if (modalFooter) {
|
|
modalFooter.addEventListener('touchstart', preventTouch, { passive: false });
|
|
modalFooter.addEventListener('touchmove', preventTouch, { passive: false });
|
|
}
|
|
});
|
|
|
|
// 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');
|
|
};
|
|
|
|
function detectDocument(scale, w, h, showAlert = false) {
|
|
// Detect Contour using jscanify
|
|
try {
|
|
// jscanify expects an image element, we can pass originalImg but we need to map coordinates
|
|
// Wait, jscanify uses OpenCV which might not be ready.
|
|
if (typeof cv !== 'undefined' && cv.Mat) {
|
|
// We need to work on the original image for detection, then scale points
|
|
var contour = scanner ? scanner.findPaper(originalImg) : null;
|
|
// contour returns { topLeftCorner, topRightCorner, bottomLeftCorner, bottomRightCorner } each {x, y}
|
|
|
|
if (contour) {
|
|
// Order: TL, TR, BR, BL (clockwise)
|
|
corners = [
|
|
{ x: contour.topLeftCorner.x * scale, y: contour.topLeftCorner.y * scale },
|
|
{ x: contour.topRightCorner.x * scale, y: contour.topRightCorner.y * scale },
|
|
{ x: contour.bottomRightCorner.x * scale, y: contour.bottomRightCorner.y * scale },
|
|
{ x: contour.bottomLeftCorner.x * scale, y: contour.bottomLeftCorner.y * scale }
|
|
];
|
|
|
|
} else {
|
|
defaultCorners(w, h);
|
|
if (showAlert) {
|
|
Swal.fire({
|
|
icon: 'warning',
|
|
title: 'Deteksi Gagal',
|
|
text: 'Dokumen tidak terdeteksi. Silakan atur sudut secara manual.',
|
|
confirmButtonText: 'OK'
|
|
});
|
|
}
|
|
}
|
|
} else {
|
|
console.warn("OpenCV not ready yet");
|
|
defaultCorners(w, h);
|
|
if (showAlert) {
|
|
Swal.fire({
|
|
icon: 'warning',
|
|
title: 'Scanner Tidak Siap',
|
|
text: 'OpenCV belum siap. Silakan atur sudut secara manual.',
|
|
confirmButtonText: 'OK'
|
|
});
|
|
}
|
|
}
|
|
} catch(e) {
|
|
console.error("Scanner Error:", e);
|
|
defaultCorners(w, h); // Fallback
|
|
if (showAlert) {
|
|
Swal.fire({
|
|
icon: 'error',
|
|
title: 'Error Scanner',
|
|
text: 'Terjadi kesalahan saat mendeteksi dokumen: ' + e.message,
|
|
confirmButtonText: 'OK'
|
|
});
|
|
}
|
|
}
|
|
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);
|
|
|
|
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 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;
|
|
corners = [
|
|
{ x: mX, y: mY }, // TL
|
|
{ x: w - mX, y: mY }, // TR
|
|
{ x: w - mX, y: h - mY }, // BR
|
|
{ x: mX, y: h - mY } // BL
|
|
];
|
|
}
|
|
|
|
function drawOverlay() {
|
|
ctxOver.clearRect(0, 0, canvasOverlay.width, canvasOverlay.height);
|
|
|
|
if (corners.length < 4) return;
|
|
|
|
// Draw Lines
|
|
ctxOver.beginPath();
|
|
ctxOver.lineWidth = LINE_WIDTH;
|
|
ctxOver.strokeStyle = LINE_COLOR;
|
|
ctxOver.moveTo(corners[0].x, corners[0].y);
|
|
ctxOver.lineTo(corners[1].x, corners[1].y);
|
|
ctxOver.lineTo(corners[2].x, corners[2].y);
|
|
ctxOver.lineTo(corners[3].x, corners[3].y);
|
|
ctxOver.closePath();
|
|
ctxOver.stroke();
|
|
|
|
// Draw Points
|
|
ctxOver.fillStyle = POINT_COLOR;
|
|
corners.forEach(p => {
|
|
ctxOver.beginPath();
|
|
ctxOver.arc(p.x, p.y, POINT_RADIUS, 0, Math.PI * 2);
|
|
ctxOver.fill();
|
|
ctxOver.strokeStyle = 'white';
|
|
ctxOver.lineWidth = 2;
|
|
ctxOver.stroke();
|
|
});
|
|
}
|
|
|
|
// --- Mouse / Touch Interactivity ---
|
|
|
|
function getMousePos(evt) {
|
|
var rect = canvasOverlay.getBoundingClientRect();
|
|
return {
|
|
x: (evt.clientX || evt.touches[0].clientX) - rect.left,
|
|
y: (evt.clientY || evt.touches[0].clientY) - rect.top
|
|
};
|
|
}
|
|
|
|
function isInside(pos, point) {
|
|
var dx = pos.x - point.x;
|
|
var dy = pos.y - point.y;
|
|
return dx * dx + dy * dy <= POINT_RADIUS * POINT_RADIUS * 2; // Bigger hit area
|
|
}
|
|
|
|
function getClosestCorner(pos, isTouch) {
|
|
var closestIdx = -1;
|
|
var closestDist = Infinity;
|
|
var radius = POINT_RADIUS * (isTouch ? TOUCH_RADIUS_MULTIPLIER : 1.5); // Larger radius for touch
|
|
var radiusSq = radius * radius;
|
|
|
|
corners.forEach((p, i) => {
|
|
var dx = pos.x - p.x;
|
|
var dy = pos.y - p.y;
|
|
var distSq = dx * dx + dy * dy;
|
|
if (distSq < radiusSq && distSq < closestDist) {
|
|
closestDist = distSq;
|
|
closestIdx = i;
|
|
}
|
|
});
|
|
return closestIdx;
|
|
}
|
|
|
|
canvasOverlay.addEventListener('mousedown', function(e) {
|
|
handleStart(getMousePos(e), false);
|
|
e.stopPropagation();
|
|
});
|
|
canvasOverlay.addEventListener('touchstart', function(e) {
|
|
var pos = getMousePos(e);
|
|
handleStart(pos, true);
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}, {passive: false});
|
|
|
|
window.addEventListener('mousemove', function(e) { if(isDragging) handleMove(getMousePos(e)); }); // Window to catch drag out
|
|
canvasOverlay.addEventListener('touchmove', function(e) {
|
|
if(isDragging) {
|
|
handleMove(getMousePos(e));
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}
|
|
}, {passive: false});
|
|
|
|
window.addEventListener('mouseup', function() { handleEnd(); });
|
|
window.addEventListener('touchend', function() { handleEnd(); });
|
|
|
|
function handleStart(pos, isTouch = false) {
|
|
activePoint = getClosestCorner(pos, isTouch);
|
|
if (activePoint !== -1) {
|
|
isTouchInteraction = isTouch;
|
|
touchOffset = { x: pos.x - corners[activePoint].x, y: pos.y - corners[activePoint].y };
|
|
if (isTouch) {
|
|
touchStartPos = pos;
|
|
// Start dragging immediately but handle slop in handleMove
|
|
isDragging = true;
|
|
} else {
|
|
isDragging = true;
|
|
touchStartPos = null;
|
|
}
|
|
} else {
|
|
activePoint = null;
|
|
isDragging = false;
|
|
isTouchInteraction = false;
|
|
touchStartPos = null;
|
|
touchOffset = null;
|
|
}
|
|
}
|
|
|
|
function handleMove(pos) {
|
|
if (activePoint !== null) {
|
|
// Touch slop detection
|
|
if (isTouchInteraction && touchStartPos) {
|
|
var dx = pos.x - touchStartPos.x;
|
|
var dy = pos.y - touchStartPos.y;
|
|
var distSq = dx * dx + dy * dy;
|
|
if (distSq < TOUCH_SLOP * TOUCH_SLOP) {
|
|
return; // Ignore small movements until slop exceeded
|
|
}
|
|
// Slop exceeded, clear touchStartPos so we don't check again
|
|
touchStartPos = null;
|
|
}
|
|
|
|
// Apply offset to maintain relative position
|
|
if (touchOffset) {
|
|
corners[activePoint].x = pos.x - touchOffset.x;
|
|
corners[activePoint].y = pos.y - touchOffset.y;
|
|
} else {
|
|
corners[activePoint].x = pos.x;
|
|
corners[activePoint].y = pos.y;
|
|
}
|
|
|
|
// Optional: constrain to canvas bounds
|
|
var w = canvasOverlay.width;
|
|
var h = canvasOverlay.height;
|
|
corners[activePoint].x = Math.max(0, Math.min(w, corners[activePoint].x));
|
|
corners[activePoint].y = Math.max(0, Math.min(h, corners[activePoint].y));
|
|
|
|
drawOverlay();
|
|
}
|
|
}
|
|
|
|
function handleEnd() {
|
|
isDragging = false;
|
|
activePoint = null;
|
|
isTouchInteraction = false;
|
|
touchStartPos = null;
|
|
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() {
|
|
// 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();
|
|
});
|
|
|
|
// --- Crop Reset Button ---
|
|
$('#btnCropReset').click(function() {
|
|
if (cropper) {
|
|
cropper.reset();
|
|
}
|
|
});
|
|
|
|
// --- Save / Extract Button ---
|
|
$('#btnScanSave').click(function() {
|
|
try {
|
|
var base64;
|
|
|
|
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);
|
|
} else {
|
|
alert("Scanner library not loaded. Please refresh the page.");
|
|
return;
|
|
}
|
|
base64 = resultCanvas.toDataURL('image/jpeg');
|
|
|
|
} else {
|
|
// 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');
|
|
}
|
|
|
|
if (window.handleScannerResult) {
|
|
window.handleScannerResult(base64);
|
|
}
|
|
|
|
scannerModal.modal('hide');
|
|
|
|
} 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>
|