Enhance camera capture/edit permissions and fix OPD AdviceBlock rendering

This commit is contained in:
DevSoftTech Server 2026-07-08 15:24:39 +05:30
parent fd8e96747d
commit 540de8c76e
8 changed files with 1247 additions and 92 deletions

View File

@ -113,6 +113,7 @@ class AuthGroups extends ShieldAuthGroups
'beta.access' => 'Can access beta-level features',
'billing.access' => 'Can access billing module',
'opd.doctor-panel.access' => 'Can access OPD doctor work panel (doctor-only)',
'media.image.preupload-edit' => 'Can rotate/crop images before upload (requires hospital setting ON)',
'billing.patient.edit-name-anytime' => 'Can edit patient name even after 24-hour restriction',
'billing.opd.edit' => 'Can edit OPD registrations',
@ -199,6 +200,7 @@ class AuthGroups extends ShieldAuthGroups
'users.*',
'beta.*',
'billing.*',
'media.image.preupload-edit',
'opd.doctor-panel.access',
'settings.bed_status.view',
'settings.charges.access',
@ -221,6 +223,7 @@ class AuthGroups extends ShieldAuthGroups
'users.delete',
'beta.access',
'billing.*',
'media.image.preupload-edit',
'billing.patient.edit-name-anytime',
'opd.doctor-panel.access',
'settings.bed_status.view',
@ -244,6 +247,7 @@ class AuthGroups extends ShieldAuthGroups
'users.edit',
'beta.access',
'billing.*',
'media.image.preupload-edit',
'opd.doctor-panel.access',
'settings.bed_status.view',
'settings.charges.access',

View File

@ -2934,6 +2934,7 @@ class Opd extends BaseController
if (!empty($data['rx_advices']) && is_array($data['rx_advices'])) {
$parts = [];
$partsLocal = [];
$partsPreferred = [];
foreach ($data['rx_advices'] as $adv) {
$txt = trim((string) ($adv['advice_txt'] ?? $adv['advice'] ?? ''));
if ($txt !== '') {
@ -2945,10 +2946,51 @@ class Opd extends BaseController
if ($localTxt !== '') {
$partsLocal[] = $localTxt;
}
$preferredTxt = $localTxt !== '' ? $localTxt : $txt;
if ($preferredTxt !== '') {
$partsPreferred[] = $preferredTxt;
}
}
if ($rxAdvice === '' && !empty($parts)) {
$rxAdvice = implode(', ', array_values(array_unique($parts)));
// Build final printable advice as: typed advice + selected advice
// (selected uses Hindi when available, else English), with de-duplication.
$typedAdviceLines = [];
$typedRaw = trim((string) $rxAdvice);
if ($typedRaw !== '') {
$typedRaw = str_replace(["\r\n", "\r"], "\n", $typedRaw);
$typedAdviceLines = array_values(array_filter(array_map(
static fn(string $v): string => trim($v),
explode("\n", $typedRaw)
), static fn(string $v): bool => $v !== ''));
}
$selectedPreferred = !empty($partsPreferred)
? array_values(array_unique($partsPreferred))
: array_values(array_unique($parts));
$seen = [];
$combined = [];
foreach ($typedAdviceLines as $line) {
$key = mb_strtolower(trim($line));
if ($key === '' || isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$combined[] = $line;
}
foreach ($selectedPreferred as $line) {
$line = trim((string) $line);
$key = mb_strtolower($line);
if ($key === '' || isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$combined[] = $line;
}
if (!empty($combined)) {
$rxAdvice = implode("\n", $combined);
}
if (!empty($partsLocal)) {
@ -2986,9 +3028,6 @@ class Opd extends BaseController
$complaintLocal = $this->translateToLocalPatientText($complaintText);
$diagnosisLocal = $this->translateToLocalPatientText($diagnosisText);
$investigationLocal = $this->translateToLocalPatientText($rxInvestigation);
if ($rxAdviceLocal === '') {
$rxAdviceLocal = $this->translateToLocalPatientText($rxAdvice);
}
if (strtolower(trim($complaintLocal)) === strtolower(trim($complaintText))) {
$complaintLocal = '';
@ -4200,18 +4239,10 @@ class Opd extends BaseController
$tokens['RemarksBlock'] = (string) ($tokens['Prescriber_Remarks'] ?? '');
$adviceBlockBody = trim((string) ($tokens['advice'] ?? ''));
$adviceBlockLocal = trim((string) ($tokens['advice_local'] ?? ''));
if ($adviceBlockBody !== '' || $adviceBlockLocal !== '') {
$adviceBlockLines = [];
if ($adviceBlockBody !== '') {
$adviceBlockLines[] = '<strong>Advice</strong> : ' . $adviceBlockBody;
}
if ($adviceBlockLocal !== '') {
$adviceBlockLines[] = '<strong>सलाह</strong> : ' . $adviceBlockLocal;
}
$tokens['AdviceBlock'] = implode('<br>', $adviceBlockLines);
} else {
$tokens['AdviceBlock'] = '';
}
$adviceBlockPreferred = $adviceBlockBody !== '' ? $adviceBlockBody : $adviceBlockLocal;
$tokens['AdviceBlock'] = $adviceBlockPreferred !== ''
? ('<strong>Advice</strong> : ' . $adviceBlockPreferred)
: '';
$tokens['NextVisitBlock'] = (string) ($tokens['next_visit'] ?? '');
$tokens['RxTable'] = $medicalHtml;
@ -4296,7 +4327,8 @@ class Opd extends BaseController
$tokens['content'] = (string) ($tokens['content'] ?? '');
$adviceLocal = trim((string) ($tokens['advice_local'] ?? ''));
$tokens['advice_local_line'] = $adviceLocal !== ''
$adviceMain = trim((string) ($tokens['advice'] ?? ''));
$tokens['advice_local_line'] = ($adviceMain === '' && $adviceLocal !== '')
? ('<br><strong>सलाह:</strong> ' . $adviceLocal)
: '';
@ -6062,10 +6094,15 @@ class Opd extends BaseController
$personInfo[0]->age = get_age_1($personInfo[0]->dob ?? null, $personInfo[0]->age ?? '', $personInfo[0]->age_in_month ?? '', $personInfo[0]->estimate_dob ?? '', $opdRow->apointment_date ?? null);
}
$hospitalEnabled = hospital_setting_value('ALLOW_IMAGE_PREUPLOAD_EDIT', '0') === '1';
$user = function_exists('auth') ? auth()->user() : null;
$userAllowed = $user && method_exists($user, 'can') && $user->can('media.image.preupload-edit');
return view('billing/opd_scan_modal_body', [
'opdid' => $opdid,
'opd_master' => $opdMaster,
'person_info' => $personInfo,
'allow_image_preupload_edit' => ($hospitalEnabled && $userAllowed) ? 1 : 0,
]);
}

View File

@ -953,10 +953,14 @@ class Patient extends BaseController
}
$profileFilePath = $this->getProfileFilePath((int) ($patient->profile_file_id ?? 0));
$hospitalEnabled = hospital_setting_value('ALLOW_IMAGE_PREUPLOAD_EDIT', '0') === '1';
$user = function_exists('auth') ? auth()->user() : null;
$userAllowed = $user && method_exists($user, 'can') && $user->can('media.image.preupload-edit');
return view('billing/Patient_Profile_Image_V', [
'patient' => $patient,
'profileFilePath' => $profileFilePath,
'allow_image_preupload_edit' => ($hospitalEnabled && $userAllowed) ? 1 : 0,
]);
}

View File

@ -38,6 +38,7 @@ class HospitalProfile extends BaseController
'sidebar_auto_hide_seconds' => $this->readSettingValue('SIDEBAR_AUTO_HIDE_SECONDS') !== ''
? $this->readSettingValue('SIDEBAR_AUTO_HIDE_SECONDS')
: '7',
'allow_image_preupload_edit' => $this->readSettingValue('ALLOW_IMAGE_PREUPLOAD_EDIT') === '1' ? '1' : '0',
]);
}
@ -69,6 +70,7 @@ class HospitalProfile extends BaseController
$pharmacyGst = trim((string) $this->request->getPost('pharmacy_gst'));
$sidebarAutoHideSecondsRaw = trim((string) $this->request->getPost('sidebar_auto_hide_seconds'));
$sidebarAutoHideSeconds = $sidebarAutoHideSecondsRaw === '' ? 7 : (int) $sidebarAutoHideSecondsRaw;
$allowImagePreuploadEdit = $this->request->getPost('allow_image_preupload_edit') === '1' ? '1' : '0';
if ($name === '' || $address === '') {
return $this->response->setJSON([
@ -134,6 +136,9 @@ class HospitalProfile extends BaseController
if ($this->upsertSettingValue('SIDEBAR_AUTO_HIDE_SECONDS', (string) $sidebarAutoHideSeconds)) {
$savedCount++;
}
if ($this->upsertSettingValue('ALLOW_IMAGE_PREUPLOAD_EDIT', $allowImagePreuploadEdit)) {
$savedCount++;
}
$logoResult = $this->processLogoUpload(
$this->request->getFile('hospital_logo'),
@ -244,6 +249,7 @@ class HospitalProfile extends BaseController
'H_Med_GST',
'H_Med_logo',
'SIDEBAR_AUTO_HIDE_SECONDS',
'ALLOW_IMAGE_PREUPLOAD_EDIT',
])->delete();
if ($logo !== '') {

View File

@ -41,6 +41,14 @@
<input type="number" class="form-control" id="sidebar_auto_hide_seconds" min="0" max="60" step="1" value="<?= esc($sidebar_auto_hide_seconds ?? '7') ?>" placeholder="7">
<div class="form-text">Desktop only. Set 0 to disable auto-hide.</div>
</div>
<div class="col-md-6">
<label class="form-label d-block">Image Edit Permission</label>
<div class="form-check form-switch mt-2">
<input class="form-check-input" type="checkbox" id="allow_image_preupload_edit" <?= ($allow_image_preupload_edit ?? '0') === '1' ? 'checked' : '' ?>>
<label class="form-check-label" for="allow_image_preupload_edit">Allow rotate/crop before image upload</label>
</div>
<div class="form-text">Global master switch. User must also be granted permission <strong>media.image.preupload-edit</strong> from User Management → Give Permissions.</div>
</div>
<div class="col-md-7">
<label class="form-label">Hospital Logo</label>
@ -159,6 +167,7 @@
fd.append('hospital_hfr_id', ($('#hospital_hfr_id').val() || '').trim());
fd.append('footer_version', ($('#footer_version').val() || '').trim());
fd.append('sidebar_auto_hide_seconds', ($('#sidebar_auto_hide_seconds').val() || '').trim());
fd.append('allow_image_preupload_edit', $('#allow_image_preupload_edit').is(':checked') ? '1' : '0');
fd.append('pharmacy_name', ($('#pharmacy_name').val() || '').trim());
fd.append('pharmacy_address', ($('#pharmacy_address').val() || '').trim());
fd.append('pharmacy_phone', ($('#pharmacy_phone').val() || '').trim());
@ -275,6 +284,7 @@
$('#hospital_hfr_id').val('');
$('#footer_version').val('');
$('#sidebar_auto_hide_seconds').val('7');
$('#allow_image_preupload_edit').prop('checked', false);
$('#pharmacy_name').val('');
$('#pharmacy_address').val('');
$('#pharmacy_phone').val('');

View File

@ -29,7 +29,16 @@
<div class="row g-3">
<div class="col-md-6">
<div class="border rounded p-2" style="min-height: 200px;">
<video id="camera" autoplay playsinline class="w-100 rounded" style="max-height: 240px;"></video>
<video id="camera" autoplay muted playsinline class="w-100 rounded" style="max-height: 240px;"></video>
</div>
<div class="mt-2">
<label for="profile_camera_select" class="form-label mb-1 small">Camera</label>
<div class="d-flex gap-2 align-items-start">
<select id="profile_camera_select" class="form-select form-select-sm">
<option value="">Default camera</option>
</select>
<button type="button" id="profile_camera_refresh" class="btn btn-outline-secondary btn-sm">Refresh</button>
</div>
</div>
<div class="d-flex gap-2 mt-2">
<button type="button" id="start_camera" class="btn btn-outline-primary btn-sm">Start Camera</button>
@ -37,6 +46,7 @@
<button type="button" id="capture_btn" class="btn btn-primary btn-sm" disabled>Capture</button>
<button type="button" id="retake_btn" class="btn btn-outline-info btn-sm" disabled>Retake</button>
</div>
<div id="camera_resolution" class="small text-muted mt-2">Camera: detecting resolution...</div>
<div id="camera_status" class="small text-muted mt-2">Camera is idle.</div>
</div>
<div class="col-md-6">
@ -57,14 +67,14 @@
<hr class="my-3">
<form method="post" enctype="multipart/form-data"
<form method="post" enctype="multipart/form-data" id="profile_upload_form"
action="<?= base_url('billing/patient/patient_file_upload') ?>/<?= esc($patient->id) ?>">
<?= csrf_field() ?>
<input type="hidden" name="doc_type" value="profile">
<input type="hidden" name="update_profile" value="1">
<div class="input-group input-group-sm">
<input type="file" name="upload_file" class="form-control" accept="image/*">
<input type="file" name="upload_file" id="profile_upload_file" class="form-control" accept="image/*">
<button type="submit" class="btn btn-success">Upload</button>
</div>
</form>
@ -92,31 +102,272 @@
</div>
</div>
<div class="modal fade" id="profileEditModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Edit Capture</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="d-flex gap-2 flex-wrap mb-2">
<button type="button" id="profile_rotate_left" class="btn btn-outline-secondary btn-sm">Rotate Left</button>
<button type="button" id="profile_rotate_right" class="btn btn-outline-secondary btn-sm">Rotate Right</button>
<button type="button" id="profile_crop_btn" class="btn btn-outline-primary btn-sm">Crop Selection</button>
<button type="button" id="profile_reset_edit" class="btn btn-outline-warning btn-sm">Reset</button>
</div>
<div class="small text-muted mb-2">Drag on the image to select crop area. Rotate if needed, then save.</div>
<div class="border rounded p-2 text-center bg-light">
<canvas id="profile_edit_canvas" style="max-width:100%;cursor:crosshair;"></canvas>
</div>
<div id="profile_edit_status" class="small text-muted mt-2">No crop selected.</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
<button type="button" id="profile_save_edit" class="btn btn-primary btn-sm">Save and Upload</button>
</div>
</div>
</div>
</div>
<script>
$(function() {
var saveUrl = '<?= base_url('billing/patient/save_profile_image') ?>/<?= esc($patient->id) ?>';
var csrfName = '<?= csrf_token() ?>';
var csrfValue = $('input[name="<?= csrf_token() ?>"]').first().val() || '<?= csrf_hash() ?>';
var allowImagePreuploadEdit = <?= !empty($allow_image_preupload_edit) ? 'true' : 'false' ?>;
var stream = null;
var video = document.getElementById('camera');
var canvas = document.getElementById('snapshot');
var $cameraSelect = $('#profile_camera_select');
var $cameraRefresh = $('#profile_camera_refresh');
var $startBtn = $('#start_camera');
var $stopBtn = $('#stop_camera');
var $captureBtn = $('#capture_btn');
var $cameraStatus = $('#camera_status');
var $cameraResolution = $('#camera_resolution');
var $retakeBtn = $('#retake_btn');
var $clearHistory = $('#clear_history');
var historyList = [];
var currentIndex = -1;
var modalEl = document.getElementById('capturePreviewModal');
var modalInstance = modalEl ? new bootstrap.Modal(modalEl) : null;
var editModalEl = document.getElementById('profileEditModal');
var editModalInstance = editModalEl ? new bootstrap.Modal(editModalEl) : null;
var editCanvas = document.getElementById('profile_edit_canvas');
var editCtx = editCanvas ? editCanvas.getContext('2d') : null;
var $editStatus = $('#profile_edit_status');
var cameraStorageKey = 'profile_image_preferred_camera';
var selectedDeviceId = '';
var videoConstraints = {
width: { ideal: 1920, max: 3840 },
height: { ideal: 1080, max: 2160 },
frameRate: { ideal: 30, max: 30 }
};
var editState = {
originalDataUrl: '',
currentDataUrl: '',
sourceType: 'capture',
sourceFilename: 'capture.jpg',
image: null,
imageRect: null,
selection: null,
dragging: false,
dragStart: null
};
try {
selectedDeviceId = window.localStorage.getItem(cameraStorageKey) || '';
} catch (e) {
selectedDeviceId = '';
}
function setStatus(type, message) {
var klass = type === 'success' ? 'alert-success' : 'alert-danger';
$('#upload_status').html('<div class="alert ' + klass + ' py-1 mb-0">' + message + '</div>');
}
function dataUrlToBlob(dataUrl) {
var parts = (dataUrl || '').split(',');
if (parts.length < 2) {
return null;
}
var mimeMatch = parts[0].match(/data:(.*?);base64/);
var mime = mimeMatch ? mimeMatch[1] : 'image/jpeg';
var binary = window.atob(parts[1]);
var len = binary.length;
var bytes = new window.Uint8Array(len);
for (var i = 0; i < len; i++) {
bytes[i] = binary.charCodeAt(i);
}
return new window.Blob([bytes], { type: mime });
}
function isEditableImageFile(file) {
if (!file) {
return false;
}
var type = (file.type || '').toLowerCase();
if (type.indexOf('image/') === 0) {
return true;
}
var name = (file.name || '').toLowerCase();
return /\.(jpg|jpeg|png|webp|gif)$/.test(name);
}
function readFileAsDataUrl(file, onDone) {
if (!file) {
return;
}
var reader = new window.FileReader();
reader.onload = function(evt) {
if (typeof onDone === 'function') {
onDone((evt && evt.target) ? (evt.target.result || '') : '');
}
};
reader.onerror = function() {
setStatus('error', 'Unable to read selected image.');
};
reader.readAsDataURL(file);
}
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function getCanvasPoint(evt) {
var rect = editCanvas.getBoundingClientRect();
return {
x: (evt.clientX || 0) - rect.left,
y: (evt.clientY || 0) - rect.top
};
}
function normalizeSelection(start, end, bounds) {
var x1 = clamp(Math.min(start.x, end.x), bounds.x, bounds.x + bounds.width);
var y1 = clamp(Math.min(start.y, end.y), bounds.y, bounds.y + bounds.height);
var x2 = clamp(Math.max(start.x, end.x), bounds.x, bounds.x + bounds.width);
var y2 = clamp(Math.max(start.y, end.y), bounds.y, bounds.y + bounds.height);
return {
x: x1,
y: y1,
width: Math.max(0, x2 - x1),
height: Math.max(0, y2 - y1)
};
}
function drawEditorCanvas() {
if (!editCanvas || !editCtx || !editState.image) {
return;
}
var maxWidth = Math.min(900, Math.max(320, window.innerWidth - 140));
var maxHeight = Math.min(560, Math.max(240, window.innerHeight - 260));
var imageWidth = editState.image.width || 1;
var imageHeight = editState.image.height || 1;
var scale = Math.min(maxWidth / imageWidth, maxHeight / imageHeight, 1);
var drawWidth = Math.max(1, Math.round(imageWidth * scale));
var drawHeight = Math.max(1, Math.round(imageHeight * scale));
editCanvas.width = drawWidth;
editCanvas.height = drawHeight;
editCtx.clearRect(0, 0, drawWidth, drawHeight);
editCtx.drawImage(editState.image, 0, 0, drawWidth, drawHeight);
editState.imageRect = { x: 0, y: 0, width: drawWidth, height: drawHeight };
if (editState.selection && editState.selection.width > 2 && editState.selection.height > 2) {
editCtx.save();
editCtx.strokeStyle = '#0d6efd';
editCtx.lineWidth = 2;
editCtx.setLineDash([6, 4]);
editCtx.strokeRect(editState.selection.x, editState.selection.y, editState.selection.width, editState.selection.height);
editCtx.fillStyle = 'rgba(13, 110, 253, 0.12)';
editCtx.fillRect(editState.selection.x, editState.selection.y, editState.selection.width, editState.selection.height);
editCtx.restore();
$editStatus.text('Crop area: ' + Math.round(editState.selection.width) + ' x ' + Math.round(editState.selection.height));
} else {
$editStatus.text('No crop selected.');
}
}
function loadEditorImage(dataUrl) {
if (!dataUrl) {
return;
}
var img = new window.Image();
img.onload = function() {
editState.currentDataUrl = dataUrl;
editState.image = img;
editState.selection = null;
drawEditorCanvas();
};
img.src = dataUrl;
}
function rotateEditorImage(direction) {
if (!editState.image) {
return;
}
var source = document.createElement('canvas');
var sourceCtx = source.getContext('2d');
var srcWidth = editState.image.width;
var srcHeight = editState.image.height;
var clockwise = direction === 'right';
source.width = srcHeight;
source.height = srcWidth;
sourceCtx.save();
if (clockwise) {
sourceCtx.translate(source.width, 0);
sourceCtx.rotate(Math.PI / 2);
} else {
sourceCtx.translate(0, source.height);
sourceCtx.rotate(-Math.PI / 2);
}
sourceCtx.drawImage(editState.image, 0, 0);
sourceCtx.restore();
loadEditorImage(source.toDataURL('image/jpeg', 0.95));
}
function cropEditorImage() {
if (!editState.image || !editState.selection || !editState.imageRect) {
$editStatus.text('Select a crop area first.');
return;
}
var selection = editState.selection;
if (selection.width < 8 || selection.height < 8) {
$editStatus.text('Crop area is too small.');
return;
}
var scaleX = editState.image.width / editState.imageRect.width;
var scaleY = editState.image.height / editState.imageRect.height;
var srcX = Math.round(selection.x * scaleX);
var srcY = Math.round(selection.y * scaleY);
var srcW = Math.round(selection.width * scaleX);
var srcH = Math.round(selection.height * scaleY);
var cropCanvas = document.createElement('canvas');
var cropCtx = cropCanvas.getContext('2d');
cropCanvas.width = Math.max(1, srcW);
cropCanvas.height = Math.max(1, srcH);
cropCtx.drawImage(editState.image, srcX, srcY, srcW, srcH, 0, 0, cropCanvas.width, cropCanvas.height);
loadEditorImage(cropCanvas.toDataURL('image/jpeg', 0.95));
}
function openCaptureEditor(dataUrl, sourceType, sourceFilename) {
editState.originalDataUrl = dataUrl;
editState.currentDataUrl = dataUrl;
editState.sourceType = sourceType || 'capture';
editState.sourceFilename = sourceFilename || (editState.sourceType === 'upload' ? 'upload.jpg' : 'capture.jpg');
editState.selection = null;
loadEditorImage(dataUrl);
if (editModalInstance) {
editModalInstance.show();
}
}
function setCameraState(active) {
$startBtn.prop('disabled', active);
$stopBtn.prop('disabled', !active);
@ -125,9 +376,91 @@ $(function() {
$cameraStatus.text(active ? 'Camera is active.' : 'Camera is idle.');
}
function startCamera() {
function saveSelectedDevice(deviceId) {
selectedDeviceId = deviceId || '';
try {
if (selectedDeviceId) {
window.localStorage.setItem(cameraStorageKey, selectedDeviceId);
} else {
window.localStorage.removeItem(cameraStorageKey);
}
} catch (e) {
}
}
function getPreferredConstraints(deviceId) {
var constraints = {
width: videoConstraints.width,
height: videoConstraints.height,
frameRate: videoConstraints.frameRate
};
if (deviceId) {
constraints.deviceId = { exact: deviceId };
}
return constraints;
}
function refreshCameraList(preferredDeviceId) {
if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) {
return;
}
navigator.mediaDevices.enumerateDevices().then(function(devices) {
var videoDevices = [];
devices.forEach(function(device) {
if (device.kind === 'videoinput') {
videoDevices.push(device);
}
});
var currentValue = preferredDeviceId || selectedDeviceId || $cameraSelect.val() || '';
var optionsHtml = '<option value="">Default camera</option>';
videoDevices.forEach(function(device, index) {
var label = device.label || ('Camera ' + (index + 1));
var value = device.deviceId || '';
var selectedAttr = value && value === currentValue ? ' selected' : '';
optionsHtml += '<option value="' + $('<div>').text(value).html() + '"' + selectedAttr + '>' + $('<div>').text(label).html() + '</option>';
});
$cameraSelect.html(optionsHtml);
if (currentValue) {
$cameraSelect.val(currentValue);
if ($cameraSelect.val() !== currentValue) {
$cameraSelect.val('');
saveSelectedDevice('');
}
} else {
$cameraSelect.val('');
}
}).catch(function() {
});
}
function updateResolutionLabel() {
var width = 0;
var height = 0;
if (stream && stream.getVideoTracks) {
var track = stream.getVideoTracks()[0];
if (track && track.getSettings) {
var settings = track.getSettings();
width = parseInt(settings.width || 0, 10) || 0;
height = parseInt(settings.height || 0, 10) || 0;
}
}
if ((!width || !height) && video) {
width = parseInt(video.videoWidth || 0, 10) || width;
height = parseInt(video.videoHeight || 0, 10) || height;
}
if (width > 0 && height > 0) {
$cameraResolution.text('Camera: ' + width + ' x ' + height);
} else {
$cameraResolution.text('Camera: detecting resolution...');
}
}
function startCamera(deviceId) {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
$cameraStatus.text('Camera not supported in this browser.');
$cameraResolution.text('Camera: not supported in this browser');
return;
}
@ -135,17 +468,38 @@ $(function() {
stopCamera();
}
navigator.mediaDevices.getUserMedia({ video: true })
var preferredDeviceId = typeof deviceId === 'string' ? deviceId : ($cameraSelect.val() || selectedDeviceId || '');
function attachStream(s, persistedDeviceId) {
stream = s;
video.srcObject = s;
video.onloadedmetadata = function() {
video.play().catch(function() {});
updateResolutionLabel();
};
setCameraState(true);
saveSelectedDevice(persistedDeviceId || '');
refreshCameraList(persistedDeviceId || '');
setTimeout(updateResolutionLabel, 300);
setTimeout(updateResolutionLabel, 1000);
}
navigator.mediaDevices.getUserMedia({ video: getPreferredConstraints(preferredDeviceId), audio: false })
.then(function(s) {
stream = s;
video.srcObject = s;
setCameraState(true);
attachStream(s, preferredDeviceId);
})
.catch(function(err) {
var msg = (err && err.name === 'NotAllowedError')
? 'Camera access denied.'
: 'Camera not available.';
$cameraStatus.text(msg);
.catch(function() {
navigator.mediaDevices.getUserMedia({ video: true, audio: false })
.then(function(s) {
attachStream(s, '');
})
.catch(function(err) {
var msg = (err && err.name === 'NotAllowedError')
? 'Camera access denied.'
: 'Camera not available.';
$cameraStatus.text(msg);
$cameraResolution.text('Camera: unavailable');
});
});
}
@ -165,6 +519,7 @@ $(function() {
video.srcObject = null;
}
setCameraState(false);
$cameraResolution.text('Camera: stopped');
}
window.pageCleanup = function() {
@ -173,6 +528,15 @@ $(function() {
$startBtn.on('click', startCamera);
$stopBtn.on('click', stopCamera);
$cameraSelect.on('change', function() {
var deviceId = $(this).val() || '';
saveSelectedDevice(deviceId);
startCamera(deviceId);
});
$cameraRefresh.on('click', function() {
refreshCameraList($cameraSelect.val() || selectedDeviceId || '');
startCamera($cameraSelect.val() || selectedDeviceId || '');
});
$retakeBtn.on('click', function() {
$('#snapshot').hide();
$('#results').html('Captured image will appear here.');
@ -187,7 +551,8 @@ $(function() {
});
setCameraState(false);
startCamera();
refreshCameraList(selectedDeviceId);
startCamera(selectedDeviceId);
$('#capture_btn').on('click', function() {
if (!stream || !video || !canvas) {
@ -199,12 +564,35 @@ $(function() {
var width = video.videoWidth || 640;
var height = video.videoHeight || 480;
if (stream && stream.getVideoTracks) {
var track = stream.getVideoTracks()[0];
if (track && track.getSettings) {
var settings = track.getSettings();
width = parseInt(settings.width || width || 0, 10) || width;
height = parseInt(settings.height || height || 0, 10) || height;
}
}
canvas.width = width;
canvas.height = height;
var ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(video, 0, 0, width, height);
var dataUri = canvas.toDataURL('image/jpeg', 0.9);
var dataUri = canvas.toDataURL('image/jpeg', 0.95);
if (!allowImagePreuploadEdit) {
persistProfileImageDataUri(dataUri);
return;
}
openCaptureEditor(dataUri, 'capture', 'capture.jpg');
});
function persistProfileImageDataUri(dataUri) {
if (!dataUri) {
setStatus('error', 'Capture not ready');
return;
}
$('#results').html('');
$('#snapshot').show();
$('#upload_status').html('');
@ -251,8 +639,90 @@ $(function() {
$history.children('img').each(function(i) {
$(this).attr('data-index', i);
});
if (editModalInstance) {
editModalInstance.hide();
}
}
function saveEditedProfileCapture() {
persistProfileImageDataUri(editState.currentDataUrl);
}
$('#profile_upload_form').on('submit', function(e) {
if (!allowImagePreuploadEdit) {
return;
}
var fileInput = document.getElementById('profile_upload_file');
var file = fileInput && fileInput.files ? fileInput.files[0] : null;
if (!file || !isEditableImageFile(file)) {
return;
}
e.preventDefault();
setStatus('success', 'Opening image editor...');
readFileAsDataUrl(file, function(dataUrl) {
if (!dataUrl) {
setStatus('error', 'Unable to read selected image.');
return;
}
openCaptureEditor(dataUrl, 'upload', file.name || 'upload.jpg');
});
});
$('#profile_rotate_left').on('click', function() {
rotateEditorImage('left');
});
$('#profile_rotate_right').on('click', function() {
rotateEditorImage('right');
});
$('#profile_crop_btn').on('click', function() {
cropEditorImage();
});
$('#profile_reset_edit').on('click', function() {
loadEditorImage(editState.originalDataUrl);
});
$('#profile_save_edit').on('click', function() {
saveEditedProfileCapture();
});
if (editCanvas) {
editCanvas.addEventListener('mousedown', function(evt) {
if (!editState.imageRect) {
return;
}
editState.dragging = true;
editState.dragStart = getCanvasPoint(evt);
editState.selection = normalizeSelection(editState.dragStart, editState.dragStart, editState.imageRect);
drawEditorCanvas();
});
editCanvas.addEventListener('mousemove', function(evt) {
if (!editState.dragging || !editState.dragStart || !editState.imageRect) {
return;
}
editState.selection = normalizeSelection(editState.dragStart, getCanvasPoint(evt), editState.imageRect);
drawEditorCanvas();
});
editCanvas.addEventListener('mouseup', function(evt) {
if (!editState.dragging || !editState.dragStart || !editState.imageRect) {
return;
}
editState.selection = normalizeSelection(editState.dragStart, getCanvasPoint(evt), editState.imageRect);
editState.dragging = false;
editState.dragStart = null;
drawEditorCanvas();
});
editCanvas.addEventListener('mouseleave', function() {
if (editState.dragging) {
editState.dragging = false;
editState.dragStart = null;
}
});
}
$('#capture_history').on('click', 'img', function() {
var idx = parseInt($(this).attr('data-index'), 10);
if (isNaN(idx)) {
@ -305,6 +775,15 @@ $(function() {
stopCamera();
});
if (editModalEl) {
editModalEl.addEventListener('hidden.bs.modal', function() {
editState.selection = null;
editState.dragging = false;
editState.dragStart = null;
$editStatus.text('No crop selected.');
});
}
document.addEventListener('visibilitychange', function() {
if (document.hidden) {
stopCamera();

View File

@ -605,6 +605,15 @@
<!-- Panel: Method 2 - Scan QR -->
<div id="abhareg_panel_qr" class="abhareg-panel d-none">
<p class="text-muted small mb-2">Point camera at patient's ABHA QR code (from PHR app or printed card).</p>
<div class="mb-2" style="max-width:300px;">
<label for="abhareg_qr_camera_select" class="form-label small mb-1">Camera</label>
<div class="d-flex gap-2 align-items-start">
<select id="abhareg_qr_camera_select" class="form-select form-select-sm">
<option value="">Default camera</option>
</select>
<button type="button" id="abhareg_qr_camera_refresh" class="btn btn-outline-secondary btn-sm">Refresh</button>
</div>
</div>
<div id="abhareg_qr_reader" style="width:300px;max-width:100%;"></div>
<div id="abhareg_qr_result" class="mt-2"></div>
<div class="mt-2">
@ -1032,8 +1041,18 @@
(function() {
var regTxnId = null;
var html5QrCode = null;
var qrScriptLoaded = false;
var qrScriptLoading = false;
var qrCameraStorageKey = 'abha_qr_preferred_camera';
var qrSelectedCamera = '';
var csrf = function() { return $('input[name="<?= csrf_token() ?>"]').first().val(); };
try {
qrSelectedCamera = window.localStorage.getItem(qrCameraStorageKey) || '';
} catch (e) {
qrSelectedCamera = '';
}
var methodMeta = {
number: { icon: '🆔', title: 'By ABHA Number/Address', sub: 'Validate then OTP verify' },
qr: { icon: '📷', title: 'Scan Patient ABHA QR', sub: 'Camera → ABHA extracted → OTP verify' },
@ -1079,6 +1098,128 @@
$('#' + id).html(msg ? '<div class="alert alert-' + type + ' py-2">' + msg + '</div>' : '');
}
function saveQrSelectedCamera(cameraId) {
qrSelectedCamera = cameraId || '';
try {
if (qrSelectedCamera) {
window.localStorage.setItem(qrCameraStorageKey, qrSelectedCamera);
} else {
window.localStorage.removeItem(qrCameraStorageKey);
}
} catch (e) {
}
}
function populateQrCameraSelect(cameras, preferredCameraId) {
var $select = $('#abhareg_qr_camera_select');
if (!$select.length) {
return;
}
var currentValue = preferredCameraId || qrSelectedCamera || $select.val() || '';
var optionsHtml = '<option value="">Default camera</option>';
(cameras || []).forEach(function(camera, index) {
var label = camera.label || ('Camera ' + (index + 1));
var value = camera.id || camera.deviceId || '';
var selectedAttr = value && value === currentValue ? ' selected' : '';
optionsHtml += '<option value="' + $('<div>').text(value).html() + '"' + selectedAttr + '>' + $('<div>').text(label).html() + '</option>';
});
$select.html(optionsHtml);
if (currentValue) {
$select.val(currentValue);
if ($select.val() !== currentValue) {
$select.val('');
saveQrSelectedCamera('');
}
} else {
$select.val('');
}
}
function loadQrScannerScript(onReady) {
if (qrScriptLoaded && typeof window.Html5Qrcode !== 'undefined') {
onReady();
return;
}
if (qrScriptLoading) {
var waitReady = function() {
if (qrScriptLoaded && typeof window.Html5Qrcode !== 'undefined') {
onReady();
} else {
window.setTimeout(waitReady, 100);
}
};
waitReady();
return;
}
qrScriptLoading = true;
var script = document.createElement('script');
script.src = 'https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js';
script.onload = function() {
qrScriptLoading = false;
qrScriptLoaded = true;
onReady();
};
script.onerror = function() {
qrScriptLoading = false;
$('#abhareg_qr_result').html('<div class="alert alert-warning py-2">Unable to load QR scanner library.</div>');
};
document.head.appendChild(script);
}
function startQrScanner(cameraId) {
if (typeof window.Html5Qrcode === 'undefined') {
return;
}
var selectedCameraId = typeof cameraId === 'string' ? cameraId : ($('#abhareg_qr_camera_select').val() || qrSelectedCamera || '');
if (!html5QrCode) {
html5QrCode = new window.Html5Qrcode('abhareg_qr_reader');
}
$('#abhareg_qr_stop_btn').removeClass('d-none');
$('#abhareg_qr_result').html('');
window.Html5Qrcode.getCameras().then(function(cameras) {
populateQrCameraSelect(cameras, selectedCameraId);
var cameraConfig = selectedCameraId
? { deviceId: { exact: selectedCameraId } }
: { facingMode: 'environment' };
html5QrCode.start(
cameraConfig,
{ fps: 10, qrbox: { width: 250, height: 250 } },
function(decodedText) {
stopQrScanner();
var abha = decodedText.trim();
var numMatch = abha.match(/\d{14}/);
if (numMatch) { abha = numMatch[0]; }
$('#abhareg_qr_result').html('<div class="alert alert-success py-2"><i class="bi bi-qr-code me-2"></i>QR scanned: <strong>' + abha + '</strong></div>');
abhaRegSelectMethod('number');
$('#abhareg_abha_input').val(abha);
$('#abhareg_validate_btn').trigger('click');
},
function() {}
).then(function() {
saveQrSelectedCamera(selectedCameraId);
}).catch(function(err) {
if (selectedCameraId) {
saveQrSelectedCamera('');
$('#abhareg_qr_camera_select').val('');
stopQrScanner().then(function() {
initQrScanner('');
});
return;
}
$('#abhareg_qr_result').html('<div class="alert alert-warning py-2">Camera error: ' + err + '. Please allow camera access.</div>');
});
}).catch(function(err) {
$('#abhareg_qr_result').html('<div class="alert alert-warning py-2">Unable to list cameras: ' + err + '</div>');
});
}
function showRegResult(resp) {
var abhaNum = resp.abha_number || '';
var abhaDisp = abhaNum.replace(/(\d{2})(\d{4})(\d{4})(\d{4})/, '$1-$2-$3-$4');
@ -1245,42 +1386,36 @@
});
// --- Method 2: QR Scanner ---
function initQrScanner() {
if (html5QrCode) { return; }
var script = document.createElement('script');
script.src = 'https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js';
script.onload = function() {
html5QrCode = new Html5Qrcode('abhareg_qr_reader');
$('#abhareg_qr_stop_btn').removeClass('d-none');
html5QrCode.start(
{ facingMode: 'environment' },
{ fps: 10, qrbox: { width: 250, height: 250 } },
function(decodedText) {
stopQrScanner();
var abha = decodedText.trim();
var numMatch = abha.match(/\d{14}/);
if (numMatch) { abha = numMatch[0]; }
$('#abhareg_qr_result').html('<div class="alert alert-success py-2"><i class="bi bi-qr-code me-2"></i>QR scanned: <strong>' + abha + '</strong></div>');
abhaRegSelectMethod('number');
$('#abhareg_abha_input').val(abha);
$('#abhareg_validate_btn').trigger('click');
},
function() {}
).catch(function(err) {
$('#abhareg_qr_result').html('<div class="alert alert-warning py-2">Camera error: ' + err + '. Please allow camera access.</div>');
});
};
document.head.appendChild(script);
function initQrScanner(cameraId) {
loadQrScannerScript(function() {
startQrScanner(cameraId);
});
}
function stopQrScanner() {
if (html5QrCode) {
html5QrCode.stop().catch(function(){}).then(function() { html5QrCode = null; });
$('#abhareg_qr_stop_btn').addClass('d-none');
$('#abhareg_qr_stop_btn').addClass('d-none');
if (!html5QrCode) {
return Promise.resolve();
}
return html5QrCode.stop().catch(function(){}).then(function() {
html5QrCode = null;
});
}
$('#abhareg_qr_stop_btn').on('click', function() { stopQrScanner(); });
$('#abhareg_qr_camera_select').on('change', function() {
var cameraId = ($(this).val() || '').toString();
saveQrSelectedCamera(cameraId);
stopQrScanner().then(function() {
initQrScanner(cameraId);
});
});
$('#abhareg_qr_camera_refresh').on('click', function() {
saveQrSelectedCamera(($('#abhareg_qr_camera_select').val() || '').toString());
stopQrScanner().then(function() {
initQrScanner(qrSelectedCamera || '');
});
});
// Enter key on OTP fields
$('#abhareg_num_otp_input').on('keypress', function(e) {

View File

@ -9,7 +9,17 @@
</div>
<div class="row">
<div class="col-md-3">
<video id="opd_scan_video" autoplay playsinline style="width:100%;max-width:220px;border:1px solid #ced4da;border-radius:4px;"></video>
<video id="opd_scan_video" autoplay muted playsinline style="width:100%;max-width:220px;border:1px solid #ced4da;border-radius:4px;"></video>
<div class="mt-2">
<label for="opd_scan_camera_select" class="form-label mb-1 small">Camera</label>
<div class="d-flex gap-2 align-items-start">
<select id="opd_scan_camera_select" class="form-select form-select-sm">
<option value="">Default camera</option>
</select>
<button type="button" id="opd_scan_camera_refresh" class="btn btn-outline-secondary btn-sm">Refresh</button>
</div>
</div>
<div id="opd_scan_resolution" class="small text-muted mt-1">Camera: detecting resolution...</div>
<canvas id="opd_scan_canvas" style="display:none;"></canvas>
<input type="hidden" id="opd_scan_opdid" value="<?= esc((int) ($opdid ?? 0)) ?>">
<div class="mt-2 d-flex gap-2">
@ -43,6 +53,34 @@
</div>
</div>
<div class="modal fade" id="opd_scan_edit_modal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Edit Capture</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="d-flex gap-2 flex-wrap mb-2">
<button type="button" id="opd_scan_rotate_left" class="btn btn-outline-secondary btn-sm">Rotate Left</button>
<button type="button" id="opd_scan_rotate_right" class="btn btn-outline-secondary btn-sm">Rotate Right</button>
<button type="button" id="opd_scan_crop_btn" class="btn btn-outline-primary btn-sm">Crop Selection</button>
<button type="button" id="opd_scan_reset_edit" class="btn btn-outline-warning btn-sm">Reset</button>
</div>
<div class="small text-muted mb-2">Drag on the image to select crop area. Rotate if needed, then save.</div>
<div class="border rounded p-2 text-center bg-light">
<canvas id="opd_scan_edit_canvas" style="max-width:100%;cursor:crosshair;"></canvas>
</div>
<div id="opd_scan_edit_status" class="small text-muted mt-2">No crop selected.</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
<button type="button" id="opd_scan_save_edit" class="btn btn-primary btn-sm">Save and Upload</button>
</div>
</div>
</div>
</div>
<script>
(function() {
// Keep references to camera stream and frequently used DOM elements.
@ -56,8 +94,41 @@
var $results = $('#opd_scan_results');
var $textBox = $('#opd_scan_text_box');
var $aiResult = $('#opd_scan_ai_result');
var $cameraSelect = $('#opd_scan_camera_select');
var $cameraRefresh = $('#opd_scan_camera_refresh');
var $resolution = $('#opd_scan_resolution');
var allowImagePreuploadEdit = <?= !empty($allow_image_preupload_edit) ? 'true' : 'false' ?>;
var editModalEl = document.getElementById('opd_scan_edit_modal');
var editModal = editModalEl && window.bootstrap ? new bootstrap.Modal(editModalEl) : null;
var editCanvas = document.getElementById('opd_scan_edit_canvas');
var editCtx = editCanvas ? editCanvas.getContext('2d') : null;
var $editStatus = $('#opd_scan_edit_status');
var opdid = parseInt($('#opd_scan_opdid').val() || '0', 10);
var latestFileId = 0;
var cameraStorageKey = 'opd_scan_preferred_camera';
var selectedDeviceId = '';
var editState = {
originalDataUrl: '',
currentDataUrl: '',
sourceType: 'capture',
sourceFilename: 'capture.jpg',
image: null,
imageRect: null,
selection: null,
dragging: false,
dragStart: null
};
var videoConstraints = {
width: { ideal: 1920, max: 3840 },
height: { ideal: 1080, max: 2160 },
frameRate: { ideal: 30, max: 30 }
};
try {
selectedDeviceId = window.localStorage.getItem(cameraStorageKey) || '';
} catch (e) {
selectedDeviceId = '';
}
function getCsrfPair() {
var input = document.querySelector('input[name="<?= csrf_token() ?>"]');
@ -77,31 +148,345 @@
}
}
function startCamera() {
// Start webcam stream for quick bedside capture.
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
$results.html('<div class="text-danger">Camera not supported in this browser.</div>');
function dataUrlToBlob(dataUrl) {
var parts = (dataUrl || '').split(',');
if (parts.length < 2) {
return null;
}
var mimeMatch = parts[0].match(/data:(.*?);base64/);
var mime = mimeMatch ? mimeMatch[1] : 'image/jpeg';
var binary = window.atob(parts[1]);
var len = binary.length;
var bytes = new window.Uint8Array(len);
for (var i = 0; i < len; i++) {
bytes[i] = binary.charCodeAt(i);
}
return new window.Blob([bytes], { type: mime });
}
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function getCanvasPoint(evt) {
var rect = editCanvas.getBoundingClientRect();
return {
x: (evt.clientX || 0) - rect.left,
y: (evt.clientY || 0) - rect.top
};
}
function normalizeSelection(start, end, bounds) {
var x1 = clamp(Math.min(start.x, end.x), bounds.x, bounds.x + bounds.width);
var y1 = clamp(Math.min(start.y, end.y), bounds.y, bounds.y + bounds.height);
var x2 = clamp(Math.max(start.x, end.x), bounds.x, bounds.x + bounds.width);
var y2 = clamp(Math.max(start.y, end.y), bounds.y, bounds.y + bounds.height);
return {
x: x1,
y: y1,
width: Math.max(0, x2 - x1),
height: Math.max(0, y2 - y1)
};
}
function drawEditorCanvas() {
if (!editCanvas || !editCtx || !editState.image) {
return;
}
navigator.mediaDevices.getUserMedia({ video: true, audio: false })
var maxWidth = Math.min(900, Math.max(320, window.innerWidth - 140));
var maxHeight = Math.min(560, Math.max(240, window.innerHeight - 260));
var imageWidth = editState.image.width || 1;
var imageHeight = editState.image.height || 1;
var scale = Math.min(maxWidth / imageWidth, maxHeight / imageHeight, 1);
var drawWidth = Math.max(1, Math.round(imageWidth * scale));
var drawHeight = Math.max(1, Math.round(imageHeight * scale));
editCanvas.width = drawWidth;
editCanvas.height = drawHeight;
editCtx.clearRect(0, 0, drawWidth, drawHeight);
editCtx.drawImage(editState.image, 0, 0, drawWidth, drawHeight);
editState.imageRect = { x: 0, y: 0, width: drawWidth, height: drawHeight };
if (editState.selection && editState.selection.width > 2 && editState.selection.height > 2) {
editCtx.save();
editCtx.strokeStyle = '#0d6efd';
editCtx.lineWidth = 2;
editCtx.setLineDash([6, 4]);
editCtx.strokeRect(editState.selection.x, editState.selection.y, editState.selection.width, editState.selection.height);
editCtx.fillStyle = 'rgba(13, 110, 253, 0.12)';
editCtx.fillRect(editState.selection.x, editState.selection.y, editState.selection.width, editState.selection.height);
editCtx.restore();
$editStatus.text('Crop area: ' + Math.round(editState.selection.width) + ' x ' + Math.round(editState.selection.height));
} else {
$editStatus.text('No crop selected.');
}
}
function loadEditorImage(dataUrl, preserveSelection) {
if (!dataUrl) {
return;
}
var img = new window.Image();
img.onload = function() {
editState.currentDataUrl = dataUrl;
editState.image = img;
if (!preserveSelection) {
editState.selection = null;
}
drawEditorCanvas();
};
img.src = dataUrl;
}
function rotateEditorImage(direction) {
if (!editState.image) {
return;
}
var source = document.createElement('canvas');
var sourceCtx = source.getContext('2d');
var srcWidth = editState.image.width;
var srcHeight = editState.image.height;
var clockwise = direction === 'right';
source.width = srcHeight;
source.height = srcWidth;
sourceCtx.save();
if (clockwise) {
sourceCtx.translate(source.width, 0);
sourceCtx.rotate(Math.PI / 2);
} else {
sourceCtx.translate(0, source.height);
sourceCtx.rotate(-Math.PI / 2);
}
sourceCtx.drawImage(editState.image, 0, 0);
sourceCtx.restore();
loadEditorImage(source.toDataURL('image/jpeg', 0.95));
}
function cropEditorImage() {
if (!editState.image || !editState.selection || !editState.imageRect) {
$editStatus.text('Select a crop area first.');
return;
}
var selection = editState.selection;
if (selection.width < 8 || selection.height < 8) {
$editStatus.text('Crop area is too small.');
return;
}
var scaleX = editState.image.width / editState.imageRect.width;
var scaleY = editState.image.height / editState.imageRect.height;
var srcX = Math.round(selection.x * scaleX);
var srcY = Math.round(selection.y * scaleY);
var srcW = Math.round(selection.width * scaleX);
var srcH = Math.round(selection.height * scaleY);
var cropCanvas = document.createElement('canvas');
var cropCtx = cropCanvas.getContext('2d');
cropCanvas.width = Math.max(1, srcW);
cropCanvas.height = Math.max(1, srcH);
cropCtx.drawImage(editState.image, srcX, srcY, srcW, srcH, 0, 0, cropCanvas.width, cropCanvas.height);
loadEditorImage(cropCanvas.toDataURL('image/jpeg', 0.95));
}
function openCaptureEditor(dataUrl, sourceType, sourceFilename) {
editState.originalDataUrl = dataUrl;
editState.currentDataUrl = dataUrl;
editState.sourceType = sourceType || 'capture';
editState.sourceFilename = sourceFilename || (editState.sourceType === 'upload' ? 'upload.jpg' : 'capture.jpg');
editState.selection = null;
loadEditorImage(dataUrl);
if (editModal) {
editModal.show();
}
}
function saveEditedCapture() {
var blob = dataUrlToBlob(editState.currentDataUrl);
if (!blob) {
$editStatus.text('Unable to prepare edited image.');
return;
}
if (editModal) {
editModal.hide();
}
var formData = new window.FormData();
var csrf = getCsrfPair();
if (editState.sourceType === 'upload') {
formData.append('userfile', blob, editState.sourceFilename || 'upload.jpg');
} else {
formData.append('webcam', blob, editState.sourceFilename || 'capture.jpg');
}
formData.append(csrf.name, csrf.value);
submitScanFormData(formData);
}
function isEditableImageFile(file) {
if (!file) {
return false;
}
var type = (file.type || '').toLowerCase();
if (type.indexOf('image/') === 0) {
return true;
}
var name = (file.name || '').toLowerCase();
return /\.(jpg|jpeg|png|webp|gif)$/.test(name);
}
function readFileAsDataUrl(file, onDone) {
if (!file) {
return;
}
var reader = new window.FileReader();
reader.onload = function(evt) {
if (typeof onDone === 'function') {
onDone((evt && evt.target) ? (evt.target.result || '') : '');
}
};
reader.onerror = function() {
$results.html('<div class="text-danger">Unable to read selected image.</div>');
};
reader.readAsDataURL(file);
}
function saveSelectedDevice(deviceId) {
selectedDeviceId = deviceId || '';
try {
if (selectedDeviceId) {
window.localStorage.setItem(cameraStorageKey, selectedDeviceId);
} else {
window.localStorage.removeItem(cameraStorageKey);
}
} catch (e) {
}
}
function getPreferredConstraints(deviceId) {
var constraints = {
width: videoConstraints.width,
height: videoConstraints.height,
frameRate: videoConstraints.frameRate
};
if (deviceId) {
constraints.deviceId = { exact: deviceId };
}
return constraints;
}
function refreshCameraList(preferredDeviceId) {
if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) {
return $.Deferred ? $.Deferred().resolve().promise() : null;
}
return navigator.mediaDevices.enumerateDevices().then(function(devices) {
var videoDevices = [];
devices.forEach(function(device) {
if (device.kind === 'videoinput') {
videoDevices.push(device);
}
});
var currentValue = preferredDeviceId || selectedDeviceId || $cameraSelect.val() || '';
var optionsHtml = '<option value="">Default camera</option>';
videoDevices.forEach(function(device, index) {
var label = device.label || ('Camera ' + (index + 1));
var value = device.deviceId || '';
var selectedAttr = value && value === currentValue ? ' selected' : '';
optionsHtml += '<option value="' + $('<div>').text(value).html() + '"' + selectedAttr + '>' + $('<div>').text(label).html() + '</option>';
});
$cameraSelect.html(optionsHtml);
if (currentValue && $cameraSelect.find('option[value="' + currentValue.replace(/"/g, '\\"') + '"]').length) {
$cameraSelect.val(currentValue);
} else {
$cameraSelect.val('');
if (preferredDeviceId || selectedDeviceId) {
saveSelectedDevice('');
}
}
}).catch(function() {
});
}
function startCamera(deviceId) {
// Start webcam stream for quick bedside capture.
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
$results.html('<div class="text-danger">Camera not supported in this browser.</div>');
$resolution.text('Camera: not supported in this browser');
return;
}
stopCamera();
function attachStream(stream) {
streamRef = stream;
var video = $video.get(0);
video.srcObject = stream;
video.onloadedmetadata = function() {
video.play().catch(function() {});
updateResolutionLabel();
};
setTimeout(updateResolutionLabel, 300);
setTimeout(updateResolutionLabel, 1000);
}
var preferredDeviceId = typeof deviceId === 'string' ? deviceId : ($cameraSelect.val() || selectedDeviceId || '');
navigator.mediaDevices.getUserMedia({ video: getPreferredConstraints(preferredDeviceId), audio: false })
.then(function(stream) {
streamRef = stream;
var video = $video.get(0);
video.srcObject = stream;
video.play();
saveSelectedDevice(preferredDeviceId);
attachStream(stream);
refreshCameraList(preferredDeviceId);
})
.catch(function() {
$results.html('<div class="text-danger">Unable to access webcam. Please allow camera permission.</div>');
// Fallback to default camera settings if HD constraints fail.
navigator.mediaDevices.getUserMedia({ video: true, audio: false })
.then(function(stream) {
saveSelectedDevice('');
attachStream(stream);
refreshCameraList('');
})
.catch(function() {
$results.html('<div class="text-danger">Unable to access webcam. Please allow camera permission.</div>');
$resolution.text('Camera: permission denied or unavailable');
});
});
}
function updateResolutionLabel() {
var video = $video.get(0);
var width = 0;
var height = 0;
if (streamRef && streamRef.getVideoTracks) {
var track = streamRef.getVideoTracks()[0];
if (track && track.getSettings) {
var settings = track.getSettings();
width = parseInt(settings.width || 0, 10) || 0;
height = parseInt(settings.height || 0, 10) || 0;
}
}
if ((!width || !height) && video) {
width = parseInt(video.videoWidth || 0, 10) || width;
height = parseInt(video.videoHeight || 0, 10) || height;
}
if (width > 0 && height > 0) {
$resolution.text('Camera: ' + width + ' x ' + height);
} else {
$resolution.text('Camera: detecting resolution...');
}
}
function stopCamera() {
// Stop all active tracks to release camera hardware.
if (streamRef) {
streamRef.getTracks().forEach(function(track) { track.stop(); });
streamRef = null;
}
$resolution.text('Camera: stopped');
}
function loadLastList() {
@ -213,7 +598,7 @@
}
function captureAndUpload() {
// Capture current webcam frame and send it as multipart form data.
// Capture current webcam frame, allow edit, then upload it.
if (opdid <= 0) {
return;
}
@ -228,24 +613,39 @@
$textBox.html('<div class="text-muted small">Capturing and uploading file...</div>');
var canvas = $canvas.get(0);
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
var ctx = canvas.getContext('2d');
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
canvas.toBlob(function(blob) {
if (!blob) {
$captureBtn.prop('disabled', false);
$results.html('<div class="text-danger">Capture failed.</div>');
return;
var width = video.videoWidth;
var height = video.videoHeight;
if (streamRef && streamRef.getVideoTracks) {
var track = streamRef.getVideoTracks()[0];
if (track && track.getSettings) {
var settings = track.getSettings();
width = parseInt(settings.width || width || 0, 10) || width;
height = parseInt(settings.height || height || 0, 10) || height;
}
var formData = new window.FormData();
var csrf = getCsrfPair();
formData.append('webcam', blob, 'capture.jpg');
formData.append(csrf.name, csrf.value);
submitScanFormData(formData);
}, 'image/jpeg', 0.9);
}
canvas.width = width;
canvas.height = height;
var ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
updateResolutionLabel();
$captureBtn.prop('disabled', false);
if (!allowImagePreuploadEdit) {
canvas.toBlob(function(blob) {
if (!blob) {
$results.html('<div class="text-danger">Capture failed.</div>');
return;
}
var formData = new window.FormData();
var csrf = getCsrfPair();
formData.append('webcam', blob, 'capture.jpg');
formData.append(csrf.name, csrf.value);
submitScanFormData(formData);
}, 'image/jpeg', 0.95);
return;
}
openCaptureEditor(canvas.toDataURL('image/jpeg', 0.95), 'capture', 'capture.jpg');
}
function uploadSelectedFile() {
@ -260,6 +660,18 @@
return;
}
if (allowImagePreuploadEdit && isEditableImageFile(file)) {
$textBox.html('<div class="text-muted small">Preparing image editor...</div>');
readFileAsDataUrl(file, function(dataUrl) {
if (!dataUrl) {
$results.html('<div class="text-danger">Unable to read selected image.</div>');
return;
}
openCaptureEditor(dataUrl, 'upload', file.name || 'upload.jpg');
});
return;
}
$uploadBtn.prop('disabled', true).text('Uploading...');
$textBox.html('<div class="text-muted small">Uploading file...</div>');
@ -274,6 +686,64 @@
$stopBtn.off('click.opdscan').on('click.opdscan', stopCamera);
$uploadBtn.off('click.opdscan').on('click.opdscan', uploadSelectedFile);
$processAiBtn.off('click.opdscan').on('click.opdscan', processLatestAi);
$cameraSelect.off('change.opdscan').on('change.opdscan', function() {
var deviceId = $(this).val() || '';
saveSelectedDevice(deviceId);
startCamera(deviceId);
});
$cameraRefresh.off('click.opdscan').on('click.opdscan', function() {
refreshCameraList($cameraSelect.val() || selectedDeviceId || '');
startCamera($cameraSelect.val() || selectedDeviceId || '');
});
$('#opd_scan_rotate_left').off('click.opdscan').on('click.opdscan', function() {
rotateEditorImage('left');
});
$('#opd_scan_rotate_right').off('click.opdscan').on('click.opdscan', function() {
rotateEditorImage('right');
});
$('#opd_scan_crop_btn').off('click.opdscan').on('click.opdscan', function() {
cropEditorImage();
});
$('#opd_scan_reset_edit').off('click.opdscan').on('click.opdscan', function() {
loadEditorImage(editState.originalDataUrl);
});
$('#opd_scan_save_edit').off('click.opdscan').on('click.opdscan', function() {
saveEditedCapture();
});
if (editCanvas) {
editCanvas.addEventListener('mousedown', function(evt) {
if (!editState.imageRect) {
return;
}
editState.dragging = true;
editState.dragStart = getCanvasPoint(evt);
editState.selection = normalizeSelection(editState.dragStart, editState.dragStart, editState.imageRect);
drawEditorCanvas();
});
editCanvas.addEventListener('mousemove', function(evt) {
if (!editState.dragging || !editState.dragStart || !editState.imageRect) {
return;
}
editState.selection = normalizeSelection(editState.dragStart, getCanvasPoint(evt), editState.imageRect);
drawEditorCanvas();
});
editCanvas.addEventListener('mouseup', function(evt) {
if (!editState.dragging || !editState.dragStart || !editState.imageRect) {
return;
}
editState.selection = normalizeSelection(editState.dragStart, getCanvasPoint(evt), editState.imageRect);
editState.dragging = false;
editState.dragStart = null;
drawEditorCanvas();
});
editCanvas.addEventListener('mouseleave', function() {
if (editState.dragging) {
editState.dragging = false;
editState.dragStart = null;
}
});
}
window.removeOpdScanImage = function(fileId) {
if (!fileId) {
@ -300,12 +770,22 @@
});
};
startCamera();
refreshCameraList(selectedDeviceId);
startCamera(selectedDeviceId);
loadLastList();
$('#tallModal').off('hidden.bs.modal.opdscan').on('hidden.bs.modal.opdscan', function() {
stopCamera();
$('#opd_scan_results').html('Captured image will appear here.');
$resolution.text('Camera: closed');
});
if (editModalEl) {
editModalEl.addEventListener('hidden.bs.modal', function() {
editState.selection = null;
editState.dragging = false;
editState.dragStart = null;
$editStatus.text('No crop selected.');
});
}
})();
</script>