Compare commits

...
53 Commits
Author SHA1 Message Date
Ross 972d2a9766 feat: enhance Sentry integration with dynamic loading and status reporting 2026-07-20 10:41:55 +01:00
Ross 837c8cea2b feat: add default DSN fallback for Sentry initialization 2026-07-20 10:36:59 +01:00
Ross c5663c4a56 add setry 2026-07-20 10:29:04 +01:00
Ross 5aae169a25 add sentry 2026-07-20 08:51:29 +01:00
Ross be04531a12 feat: add manual reset instructions for local data clearing 2026-07-05 22:47:45 +01:00
Ross 9bccd3a4f1 feat: implement force reset functionality to clear local data and storage 2026-07-05 22:37:58 +01:00
Ross 58deaee5ce feat: enhance error handling with submission error overlay and logging level controls 2026-07-05 21:59:12 +01:00
Ross 348cdbf9d8 feat: update candidate number display to use a div and hide secondary element 2026-07-05 21:24:49 +01:00
Ross 2ca3ae4761 feat: add exit button for exam mode with URL parameter handling 2026-07-05 21:10:58 +01:00
Ross 32655c9fa8 feat: add refresh buttons for exams and packets with loading state management 2026-07-05 20:49:01 +01:00
Ross 8ab8a7e8db some big changes... 2026-07-04 22:40:56 +01:00
Ross f74aba0e9c feat: implement finish dialog with unanswered question detection and submit functionality 2026-06-29 12:37:20 +01:00
Ross 873d05e7cd feat: refactor performSync to use $.ajax for improved error handling and prevent global AJAX events 2026-06-29 12:23:22 +01:00
Ross 253b0bc183 feat: enhance sync functionality with unsynced answer detection and UI updates 2026-06-29 11:49:45 +01:00
Ross 499707f946 feat: update base URL handling to dynamically use current origin for improved compatibility 2026-06-29 11:08:53 +01:00
Ross 9afc75763e prepare for better integration and testing 2026-06-29 10:44:50 +01:00
Ross 4380d03d89 feat: add friendly exam name mapping for improved display in answers overlay 2026-01-19 11:56:05 +00:00
Ross cc2e17e331 feat: enhance view answers overlay with filtering, formatting, and save functionality 2026-01-19 11:53:53 +00:00
Ross e90667a37e feat: enhance error submission overlay with improved messaging and close button functionality 2026-01-19 11:52:45 +00:00
Ross e68483ba41 feat: add candidate details display to user interface 2026-01-19 11:42:53 +00:00
Ross a50af67315 feat: enhance candidate details display with logout functionality and conditional action visibility 2026-01-19 11:40:47 +00:00
Ross db04550e1a fix: disable action buttons for user and candidate login when users are not allowed 2026-01-19 11:37:25 +00:00
Ross 8993a1e8a1 fix: replace logger with console.log in showCandidateDetails function 2026-01-19 11:33:50 +00:00
Ross fe1f8dfc1d feat: add logging for candidate details display function 2026-01-19 11:31:35 +00:00
Ross 803775f462 fix: update candidate details display logic to avoid page reload on CID/passcode extraction 2026-01-19 11:28:51 +00:00
Ross c6b0e14dda feat: implement candidate details display with logout functionality and exam results link 2026-01-19 11:27:27 +00:00
Ross eed5b1344a fix: improve URL parameter handling for CID and passcode extraction 2026-01-19 11:22:26 +00:00
Ross d380c745c9 fix: clear saved cid items from local storage on login failure 2026-01-19 11:17:30 +00:00
Ross 9e53a54ac1 fix: correct typo in comment for loadExamList function 2026-01-19 11:08:26 +00:00
Ross f7221fd930 feat: enable exam mode via URL parameters and update exam details accordingly 2026-01-05 11:11:44 +00:00
Ross 971a82c6a1 fix: update handling of hidden options in openMainImage function 2025-09-02 21:21:34 +01:00
Ross b6bccf8ce3 feat: add hidden and disabled options for window settings in openMainImage function 2025-09-02 21:03:21 +01:00
Ross 4cc033ad90 feat: add scroll tool option to openMainImage function 2025-09-02 20:58:29 +01:00
Ross 50cd3f10cf refactor: improve code readability and performance in viewer.js 2025-09-01 15:21:39 +01:00
Ross 02162d6aea get shorts working 2025-07-28 12:09:11 +01:00
Ross 01514c4ea4 further tidyup 2025-04-14 13:46:52 +01:00
Ross 2f88e101e9 start working towards new viewer 2025-04-14 12:48:04 +01:00
Ross a8aac638da . 2024-12-30 11:55:19 +00:00
Ross 87bc50d40b . 2024-12-30 11:53:35 +00:00
Ross 9a9aa5d56c . 2024-12-30 11:51:04 +00:00
Ross 7abaee97c4 . 2024-12-30 11:48:01 +00:00
Ross 10c1aacd65 fix results url 2024-12-30 11:44:44 +00:00
Ross 7ebba5cdb2 small fixes 2024-12-30 11:31:05 +00:00
Ross 2f6e3a8701 improve packet display 2023-10-16 10:16:13 +01:00
Ross 64b496988f update exam question url 2023-06-05 17:00:37 +01:00
Ross ff94c4db93 Merge branch 'master' of https://git.xkjq.uk/ross/rts 2023-05-15 09:34:56 +01:00
Ross 57e76c10fa View submitted answers locally 2023-05-15 09:16:46 +01:00
Ross d284c91173 begin clean up and log / comments 2023-04-24 16:30:20 +01:00
Ross 8c3fb74a25 switch user saved data to localstorage 2023-04-24 13:17:17 +01:00
Ross fe6f398424 improve logging, dynamic url creation 2023-04-24 12:00:37 +01:00
Ross a8b6100018 add loglevel 2023-04-24 11:05:30 +01:00
Ross 2df520f83a standardise button naming 2023-04-24 10:24:21 +01:00
Ross 70647d162b switch to local cornerstone 2023-04-24 10:09:12 +01:00
17 changed files with 49423 additions and 2675 deletions
+6 -3
View File
@@ -1,8 +1,11 @@
{
"editor.autoIndent": "keep",
"javascript.format.insertSpaceAfterFunctionKeywordForAnonymousFunctions": false,
"javascript.format.insertSpaceAfterKeywordsInControlFlowStatements": false,
"javascript.format.insertSpaceAfterKeywordsInControlFlowStatements": true,
"javascript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces": false,
"javascript.format.enable": false,
"liveServer.settings.port": 5502
"javascript.format.enable": true,
"liveServer.settings.port": 5502,
"javascript.format.insertSpaceAfterConstructor": true,
"javascript.format.insertSpaceBeforeFunctionParenthesis": false,
"javascript.format.insertSpaceAfterOpeningAndBeforeClosingEmptyBraces": false
}
+1
View File
@@ -1,3 +1,4 @@
/* Set some default CSS */
html,
body,
div,
+89
View File
@@ -0,0 +1,89 @@
/* Some fancy loading CSS */
.sk-cube-grid {
width: 60px;
height: 60px;
}
.sk-cube-grid .sk-cube {
width: 33%;
height: 33%;
background-color: #4527A0;
float: left;
-webkit-animation: sk-cubeGridScaleDelay 1.3s infinite ease-in-out;
animation: sk-cubeGridScaleDelay 1.3s infinite ease-in-out;
}
.sk-cube-grid .sk-cube1 {
-webkit-animation-delay: 0.2s;
animation-delay: 0.2s;
}
.sk-cube-grid .sk-cube2 {
-webkit-animation-delay: 0.3s;
animation-delay: 0.3s;
}
.sk-cube-grid .sk-cube3 {
-webkit-animation-delay: 0.4s;
animation-delay: 0.4s;
}
.sk-cube-grid .sk-cube4 {
-webkit-animation-delay: 0.1s;
animation-delay: 0.1s;
}
.sk-cube-grid .sk-cube5 {
-webkit-animation-delay: 0.2s;
animation-delay: 0.2s;
}
.sk-cube-grid .sk-cube6 {
-webkit-animation-delay: 0.3s;
animation-delay: 0.3s;
}
.sk-cube-grid .sk-cube7 {
-webkit-animation-delay: 0s;
animation-delay: 0s;
}
.sk-cube-grid .sk-cube8 {
-webkit-animation-delay: 0.1s;
animation-delay: 0.1s;
}
.sk-cube-grid .sk-cube9 {
-webkit-animation-delay: 0.2s;
animation-delay: 0.2s;
}
@-webkit-keyframes sk-cubeGridScaleDelay {
0%,
70%,
100% {
-webkit-transform: scale3D(1, 1, 1);
transform: scale3D(1, 1, 1);
}
35% {
-webkit-transform: scale3D(0, 0, 1);
transform: scale3D(0, 0, 1);
}
}
@keyframes sk-cubeGridScaleDelay {
0%,
70%,
100% {
-webkit-transform: scale3D(1, 1, 1);
transform: scale3D(1, 1, 1);
}
35% {
-webkit-transform: scale3D(0, 0, 1);
transform: scale3D(0, 0, 1);
}
}
+460 -98
View File
@@ -1,7 +1,8 @@
@charset "UTF-8";
@import "./base.css";
@import "./loading.css";
@import url("https://fonts.googleapis.com/css?family=Noto+Sans:400,700");
/* Colourscheme from https://www.materialui.co/colors */
/* Colourscheme from https://www.materialui.co/colors (as per original) */
* {
-webkit-font-smoothing: antialiased;
@@ -50,7 +51,7 @@ html {
html,
body,
input:not([type=text]),
input:not([type=text]):not([type=number]), /* TODO: check it works in safari */
button,
select,
option {
@@ -72,8 +73,6 @@ textarea {
margin: 0;
padding: 0;
color: black;
/*#1d3850;*/
/*font-family: 'Noto Sans', sans-serif;*/
overflow: hidden;
}
@@ -239,6 +238,7 @@ td {
box-shadow: none;
text-shadow: none;
flex-shrink: 0;
font-size: 12px;
}
.nav-left {
@@ -272,7 +272,7 @@ td {
border-style: dashed;
}
/*@media screen and (orientation:landscape) {*/
/* LANDSCAPE */
@media screen and (min-aspect-ratio:12/9) {
.exam-content-panel {
display: flex;
@@ -339,7 +339,7 @@ td {
}
}
/*@media screen and (orientation:portrait) {*/
/* PORTRAIT*/
@media screen and (max-aspect-ratio:12/9) {
.exam-content-panel {
display: block;
@@ -473,6 +473,7 @@ td {
overflow: hidden;
resize: none;
margin-bottom: 1.6rem;
border-radius: 0.3em;
}
.long-answer-marked {
@@ -498,6 +499,20 @@ td {
box-shadow: 0 0 0 3px #4527A0;
}
/* .short-option-answer {
width: 100%;
display: block;
border: 1px solid #4527A0;
margin: 0.8rem 1.6rem 1.6rem 1.6rem;
overflow: hidden;
resize: none;
padding: 0.4rem;
}
.short-option-answer:focus {
box-shadow: 0 0 0 3px #4527A0;
} */
img {
display: block;
padding: 0;
@@ -548,6 +563,71 @@ img {
text-align: center;
}
/* Finish dialog styling */
.finish-header {
display: flex;
justify-content: center;
padding-top: 6vh;
pointer-events: none;
}
.login-risr-box {
display: inline-block;
}
.login-risr-logo {
height: 64px;
width: auto;
user-drag: none;
}
.finish-dialog {
display: inline-block;
background-color: #1b1b1b;
color: #fff;
margin: 3vh auto;
padding: 1.6rem;
border-radius: 8px;
max-width: 520px;
box-shadow: 0 8px 24px rgba(0,0,0,0.6);
text-align: left;
}
.logout-title {
font-size: 1.4rem;
font-weight: 700;
margin-bottom: 0.8rem;
}
.logout-error.info {
background-color: #e3f2fd;
color: #0d47a1;
border: 1px solid rgba(13,71,161,0.15);
padding: 0.8rem;
border-radius: 4px;
margin-bottom: 0.8rem;
}
.logout-text {
margin-bottom: 0.8rem;
color: #ddd;
}
.logout-buttons {
display: flex;
gap: 0.6rem;
justify-content: flex-end;
}
.logout-button {
padding: 0.6rem 1rem;
border-radius: 4px;
border: none;
cursor: pointer;
font-weight: 600;
}
.logout-button.danger {
background: #b71c1c;
color: white;
}
.logout-button.primary {
background: #1976d2;
color: white;
}
.dialog {
/*display: inline-block;*/
display: none;
@@ -790,6 +870,7 @@ select option:disabled {
border-radius: 5px;
color: #311B92;
margin: 0 .8rem .8rem 0;
padding: 1rem 1.6rem;
box-shadow: none;
text-shadow: none;
flex: 0 0 auto;
@@ -921,94 +1002,6 @@ select option:disabled {
content: "⏸"
}
.sk-cube-grid {
width: 60px;
height: 60px;
}
.sk-cube-grid .sk-cube {
width: 33%;
height: 33%;
background-color: #4527A0;
float: left;
-webkit-animation: sk-cubeGridScaleDelay 1.3s infinite ease-in-out;
animation: sk-cubeGridScaleDelay 1.3s infinite ease-in-out;
}
.sk-cube-grid .sk-cube1 {
-webkit-animation-delay: 0.2s;
animation-delay: 0.2s;
}
.sk-cube-grid .sk-cube2 {
-webkit-animation-delay: 0.3s;
animation-delay: 0.3s;
}
.sk-cube-grid .sk-cube3 {
-webkit-animation-delay: 0.4s;
animation-delay: 0.4s;
}
.sk-cube-grid .sk-cube4 {
-webkit-animation-delay: 0.1s;
animation-delay: 0.1s;
}
.sk-cube-grid .sk-cube5 {
-webkit-animation-delay: 0.2s;
animation-delay: 0.2s;
}
.sk-cube-grid .sk-cube6 {
-webkit-animation-delay: 0.3s;
animation-delay: 0.3s;
}
.sk-cube-grid .sk-cube7 {
-webkit-animation-delay: 0s;
animation-delay: 0s;
}
.sk-cube-grid .sk-cube8 {
-webkit-animation-delay: 0.1s;
animation-delay: 0.1s;
}
.sk-cube-grid .sk-cube9 {
-webkit-animation-delay: 0.2s;
animation-delay: 0.2s;
}
@-webkit-keyframes sk-cubeGridScaleDelay {
0%,
70%,
100% {
-webkit-transform: scale3D(1, 1, 1);
transform: scale3D(1, 1, 1);
}
35% {
-webkit-transform: scale3D(0, 0, 1);
transform: scale3D(0, 0, 1);
}
}
@keyframes sk-cubeGridScaleDelay {
0%,
70%,
100% {
-webkit-transform: scale3D(1, 1, 1);
transform: scale3D(1, 1, 1);
}
35% {
-webkit-transform: scale3D(0, 0, 1);
transform: scale3D(0, 0, 1);
}
}
.displayblock {
@@ -1145,7 +1138,7 @@ select option:disabled {
}
}
#submit-error-overlay {
#view-answers-overlay {
opacity: 0.9;
position: absolute;
top: 0;
@@ -1158,6 +1151,78 @@ select option:disabled {
z-index: 5000;
}
#submit-error-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.85);
display: flex;
justify-content: center;
align-items: center;
z-index: 10000;
user-select: text;
-moz-user-select: text;
-webkit-user-select: text;
overflow-y: auto;
padding: 20px;
box-sizing: border-box;
}
#submit-error-overlay .error-card {
background-color: #1a1a1a;
border: 1px solid #c62828;
border-radius: 8px;
max-width: 600px;
width: 100%;
padding: 25px;
box-shadow: 0 10px 25px rgba(0,0,0,0.5);
color: #eee;
font-family: sans-serif;
}
#submit-error-overlay .error-card h3 {
margin-top: 0;
color: #ef5350;
font-size: 20px;
border-bottom: 1px solid #333;
padding-bottom: 10px;
margin-bottom: 15px;
}
#submit-error-overlay .error-card button {
cursor: pointer;
background-color: #311B92;
color: white;
border: none;
padding: 10px 18px;
font-size: 14px;
border-radius: 4px;
transition: background-color 0.2s;
font-weight: bold;
}
#submit-error-overlay .error-card button:hover {
background-color: #4527A0;
}
#submit-error-overlay .error-card button.btn-secondary {
background-color: #444;
}
#submit-error-overlay .error-card button.btn-secondary:hover {
background-color: #555;
}
#submit-error-overlay .error-card button.btn-danger {
background-color: #c62828;
}
#submit-error-overlay .error-card button.btn-danger:hover {
background-color: #d32f2f;
}
.cache-out-of-date {
color: red;
}
@@ -1187,12 +1252,26 @@ select option:disabled {
.invalid-login {
color: red;
}
input[type="number"] {
.invalid-login::before {
content: "Invalid login";
color: red;
font-size: 1rem;
font-weight: 900;
border: 0.25rem solid red;
display: inline-block;
text-transform: uppercase;
border-radius: 1rem;
font-family: 'Courier';
opacity: 80%;
margin-top: 3em;
padding: 0.25rem 1rem;
position: absolute;
}
/* input[type="number"] {
user-select: text;
-moz-user-select: text;
-webkit-user-select: text;
}
} */
.feedback-image {
max-width: 100%;
@@ -1205,3 +1284,286 @@ input[type="button"], button {
.login-button {
cursor: pointer;
}
h2:has(+ #exam-list:empty), h2:has(+ #packet-list:empty){
opacity: 30%;
}
#exam-list:empty::after, #packet-list:empty::after{
content: "None available.";
opacity: 30%;
}
#viewer-title-bar {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 0.8rem 1.6rem;
background-color: gray;
color: white;
}
.title-bar-button {
display: inline-flex;
flex-direction: column;
align-items: center;
border: none;
margin: 0 0 0 2px;
background-color: gray;
color: white;
border-left: 1px solid white;
}
.viewer-icon {
margin: auto;
}
.title-bar-button:hover {
background-color: darkgray;
}
/* Sync Status Styles */
#sync-status {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.85);
margin: auto 0.8rem;
padding: 0.3rem 0.6rem;
border-radius: 4px;
background-color: rgba(255, 255, 255, 0.05);
transition: background-color 0.3s ease;
}
.sync-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.sync-dot.saved {
background-color: #4CAF50;
box-shadow: 0 0 8px #4CAF50;
}
.sync-dot.syncing {
background-color: #FFC107;
box-shadow: 0 0 8px #FFC107;
animation: pulse-syncing 1.2s infinite ease-in-out;
}
.sync-dot.failed {
background-color: #F44336;
box-shadow: 0 0 8px #F44336;
animation: pulse-failed 1s infinite alternate;
}
.sync-dot.practice {
background-color: #2196F3;
box-shadow: 0 0 8px #2196F3;
}
.sync-text {
letter-spacing: 0.2px;
}
@keyframes pulse-syncing {
0% {
transform: scale(0.85);
opacity: 0.6;
}
50% {
transform: scale(1.15);
opacity: 1;
}
100% {
transform: scale(0.85);
opacity: 0.6;
}
}
@keyframes pulse-failed {
from {
box-shadow: 0 0 4px #F44336;
}
to {
box-shadow: 0 0 12px #F44336;
}
}
.refresh-button {
background-color: transparent;
border: 1px solid #555;
border-radius: 3px;
color: #bbb;
padding: 1px 6px;
font-size: 10px;
cursor: pointer;
margin-left: 10px;
vertical-align: middle;
transition: all 0.2s;
}
.refresh-button:hover {
background-color: #333;
border-color: #888;
color: #fff;
}
.global-refresh-container {
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 15px;
}
.exit-exam-mode {
position: fixed;
bottom: 20px;
right: 20px;
background-color: #b71c1c;
color: white;
border: none;
padding: 10px 15px;
border-radius: 5px;
cursor: pointer;
z-index: 10000;
}
/* Options and Cache Tidy Up */
.options-details, .cache-details-box {
background: #181818;
border: 1px solid #333;
border-radius: 6px;
margin-bottom: 15px;
padding: 12px 18px;
font-family: sans-serif;
}
.options-details summary, .cache-details-box summary {
font-weight: bold;
cursor: pointer;
font-size: 15px;
outline: none;
color: #e0e0e0;
}
.options-details summary:hover, .cache-details-box summary:hover {
color: #fff;
}
.options-details[open], .cache-details-box[open] {
border-color: #444;
}
.options-details[open] summary, .cache-details-box[open] summary {
border-bottom: 1px solid #333;
padding-bottom: 8px;
margin-bottom: 12px;
}
.database-btn-group {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 15px;
}
.database-btn-group input[type="button"] {
background: #2a2a2a;
color: #eee;
border: 1px solid #444;
padding: 8px 14px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
font-weight: bold;
transition: background-color 0.2s, border-color 0.2s;
}
.database-btn-group input[type="button"]:hover {
background: #3a3a3a;
border-color: #666;
}
.database-btn-group input#btn-delete-answer-databases {
background: #5f0909;
border-color: #8c0d0d;
color: #ffcdd2;
}
.database-btn-group input#btn-delete-answer-databases:hover {
background: #8c0d0d;
border-color: #b71c1c;
}
.database-btn-group input#btn-reset-local {
background: #5d2e00;
border-color: #8c4600;
color: #ffe0b2;
}
.database-btn-group input#btn-reset-local:hover {
background: #8c4600;
border-color: #e65100;
}
.local-loader-field {
border: 1px solid #333;
padding: 15px;
border-radius: 6px;
background: #111;
margin-bottom: 15px;
}
.local-loader-field h2 {
margin-top: 0;
font-size: 14px;
color: #ccc;
margin-bottom: 10px;
}
.local-loader-field input[type="file"] {
background: #222;
border: 1px solid #333;
color: #eee;
padding: 4px;
border-radius: 4px;
font-size: 12px;
}
.local-loader-field input[type="button"] {
background: #311B92;
color: #fff;
border: none;
padding: 6px 12px;
border-radius: 4px;
font-weight: bold;
cursor: pointer;
font-size: 12px;
margin-left: 5px;
}
.local-loader-field input[type="button"]:hover {
background: #4527A0;
}
.view-answers-btn {
background: #2e7d32;
color: white;
border: none;
padding: 10px 16px;
border-radius: 4px;
font-size: 13px;
font-weight: bold;
cursor: pointer;
transition: background-color 0.2s;
}
.view-answers-btn:hover {
background: #388e3c;
}
+312 -90
View File
@@ -1,15 +1,24 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" class="no-touch" lang="en">
<!doctype html>
<html
xmlns="http://www.w3.org/1999/xhtml"
xml:lang="en"
class="no-touch"
lang="en"
>
<head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no">
<meta name="viewport" content="initial-scale=1.0, maximum-scale=1.0, width=device-width, height=device-height">
<link type="text/css" rel="stylesheet" href="lib/jquery.modal.min.css">
<link type="text/css" rel="stylesheet" href="lib/cornerstone.min.css">
<link type="text/css" rel="stylesheet" href="css/main.css">
<meta http-equiv="content-type" content="text/html; charset=UTF-8" />
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no"
/>
<meta
name="viewport"
content="initial-scale=1.0, maximum-scale=1.0, width=device-width, height=device-height"
/>
<link type="text/css" rel="stylesheet" href="lib/jquery.modal.min.css" />
<link type="text/css" rel="stylesheet" href="lib/cornerstone.min.css" />
<link type="text/css" rel="stylesheet" href="css/main.css" />
<title>RTS</title>
</head>
@@ -17,98 +26,257 @@
<div id="content">
<div class="content-panel">
<div class="nav-bar">
<button id="review-button" class="navigation nav-right"><b></b></button>
<button id="nav-submit-button" class="submit-button navigation nav-right"><b>submit</b></button>
<!--<button id="options-button" class="navigation nav-right"
title="click to load new packet or manage saved answers">options</button>-->
<button id="review-overlay-button" class="navigation nav-right"
title="click to finish exam and review answers">review</button>
<button id="fullscreen-overlay-button" class="navigation nav-right"
title="click to toggle fullscreen">fullscreen</button>
<!--<button id="logout-button" class="navigation nav-right">logout</button>-->
<button id="btn-review" class="navigation nav-right">
<b></b><br />OVERVIEW
</button>
<button
id="btn-submit-nav"
class="submit-button navigation nav-right"
>
<b>FINISH</b>
</button>
<button
id="btn-review-overlay"
class="navigation nav-right"
title="click to finish exam and review answers"
>
REVIEW
</button>
<button
id="btn-fullscreen-overlay"
class="navigation nav-right"
title="click to toggle fullscreen"
>
FULLSCREEN
</button>
<div class="app-name nav-left">RTS</div>
<div id="sync-status" class="nav-left"></div>
<div id="timer" title="click to pause"></div>
<div class="exam-name" title="currently loaded packet"></div>
<input type="button" class="navigation nav-left" value="previous" disabled="disabled">
<input type="button" class="navigation nav-left" value="next">
<button
class="navigation nav-left"
disabled="disabled"
data-nav="previous"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
class="bi bi-arrow-left-short"
viewBox="0 0 16 16"
>
<path
fill-rule="evenodd"
d="M12 8a.5.5 0 0 1-.5.5H5.707l2.147 2.146a.5.5 0 0 1-.708.708l-3-3a.5.5 0 0 1 0-.708l3-3a.5.5 0 1 1 .708.708L5.707 7.5H11.5a.5.5 0 0 1 .5.5"
/>
</svg>
<br />
<span>PREVIOUS</span>
</button>
<button class="navigation nav-left" data-nav="next">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
class="bi bi-arrow-right-short"
viewBox="0 0 16 16"
>
<path
fill-rule="evenodd"
d="M4 8a.5.5 0 0 1 .5-.5h5.793L8.146 5.354a.5.5 0 1 1 .708-.708l3 3a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708-.708L10.293 8.5H4.5A.5.5 0 0 1 4 8"
/>
</svg>
<br />
<span>NEXT</span>
</button>
</div>
<div class="main-panel">
<div class="question-list-panel" style="display: block;">
</div>
<div class="question-list-panel" style="display: block"></div>
<div class="exam-panel">
<div class="exam-content-panel">
<div class="question">
<div class="title"><span style="font-weight:700;">1</span> </div>
<div class="title"><span style="font-weight: 700">1</span></div>
<div class="stem">
<div id="thumbnails"></div>
</div>
<div class="thumbs">
</div>
<div class="thumbs"></div>
</div>
<div class="answers">
<div class="answer-title">Answer</div>
<div class="answer-panel">
<div class="answer-panel"></div>
</div>
</div>
</div>
</div>
<div
class="logout-background"
role="dialog"
aria-modal="true"
aria-labelledby="finish-title"
>
<div class="finish-header">
<div class="login-risr-box">
<img
class="login-risr-logo"
draggable="false"
alt="Logo"
src="risr-assess-on-dark.png"
/>
</div>
</div>
<div class="logout-panel finish-dialog" id="finish-dialog-panel">
<div class="logout-title" id="finish-title">Ready to finish?</div>
<div class="logout-error info">
<p>
<strong
>Answers are submitted automatically. You can complete the
exam by clicking below.</strong
>
</p>
<p class="unanswered-line">
You have <span id="unanswered-count">0</span> unanswered
questions
</p>
</div>
<div class="logout-text">
Click “Submit exam” before closing the tab or window
</div>
<div class="logout-buttons">
<button class="logout-button danger" id="button-logout">
Submit exam
</button>
<button class="logout-button primary" id="button-continue">
Continue exam
</button>
</div>
<div class="logout-background">
<div class="logout-panel">
<div class="logout-title">Ready to logout?</div>
<div class="logout-text"></div>
<button class="navigation logout-cancel">continue exam</button>
<button class="navigation logout-okay">logout now</button>
</div>
</div>
<div id="options-panel" class="fullscreen-overlay">
<h1>RTS</h1>
<input type='button' id='btn-candidate-login' class="login-button" value='Enter candidate details' onclick='void(0)' ><span
id="candidate-details"></span>
<input type='button' id='btn-user-login' class="login-button" value='User log in'>
<span id="user-details">
<span id="candidate-info">
<span id="candidate-details"></span>
</span>
<span id="user-actions">
<input
type="button"
id="btn-candidate-login"
class="login-button"
value="Enter candidate details"
onclick="void 0"
/>
<input
type="button"
id="btn-user-login"
class="login-button"
value="User log in"
/>
</span>
<!-- <a href="#" id="overlay-close" class="close"></a> -->
<div id="user" class="user-text"></div>
</span>
<div id="packets">
<p>Click buttons below to load an exam / packet</p>
<div class="global-refresh-container">
<span id="load-exam-packet-instructions">
<span>Click buttons below to load an exam / packet</span>
</span>
<button
id="btn-refresh-all"
class="refresh-button"
title="Refresh all exams and packets"
>
Refresh All
</button>
</div>
<span class="exam-wrapper">
<h2>Available Exams:</h2>
<span id="exam-list">
</span>
<h2>Exams:</h2>
<span id="exam-list"></span>
</span>
<h2>Available Packets:</h2>
<span id="packet-list">
<span class="packet-wrapper">
<h2>
Packets:
<button
id="btn-refresh-packets"
class="refresh-button"
title="Refresh packets"
>
Refresh
</button>
</h2>
<span id="packet-list"></span>
</span>
</div>
<div id="options-link">
</div>
<div id="storage-details">
</div>
<div id="options-link"></div>
<div id="storage-details" style="font-family: sans-serif; font-size: 13px; color: #aaa; margin: 15px 0 10px 0; padding-left: 5px;"></div>
<div id="cache-details">
<details>
<summary></summary>
<ul>
</ul>
<details class="cache-details-box">
<summary>Cached Exams / Questions</summary>
<ul style="margin: 10px 0 0 15px; padding: 0; font-size: 13px; color: #ccc;"></ul>
</details>
</div>
<details>
<details class="options-details">
<summary>Options</summary>
<div id="database-buttons">
<input type='button' id='btn-delete-answer-databases' value='Delete local answers'>
<input type='button' id='btn-delete-cached-questions' value='Delete cached questions'>
<input type='button' id='btn-reset-local' value='Clear all local data'>
<div id="database-buttons" class="database-btn-group">
<input
type="button"
id="btn-delete-answer-databases"
value="Delete local answers"
/>
<input
type="button"
id="btn-delete-cached-questions"
value="Delete cached questions"
/>
<input
type="button"
id="btn-reset-local"
value="Clear all local data"
/>
<details style="width: 100%; margin-top: 10px; font-size: 12px; border: 1px solid #444; border-radius: 4px; padding: 10px; background: #111; font-family: sans-serif;">
<summary style="cursor: pointer; font-weight: bold; color: #aaa; outline: none; user-select: none;">Manual Reset Instructions</summary>
<div style="margin-top: 8px; color: #bbb; line-height: 1.5;">
<p style="margin: 0 0 8px 0;">If the automatic reset parameter fails, perform a manual reset in your browser:</p>
<ol style="margin: 0; padding-left: 18px;">
<li style="margin-bottom: 4px;">Open Developer Tools by pressing <strong>F12</strong> or <strong>Ctrl+Shift+I</strong> (Cmd+Option+I on Mac).</li>
<li style="margin-bottom: 4px;">Navigate to the <strong>Application</strong> tab (Chrome/Edge) or <strong>Storage</strong> tab (Firefox).</li>
<li style="margin-bottom: 4px;">Locate storage items and select <strong>Clear Site Data</strong> or delete entries under <em>IndexedDB</em>, <em>Local Storage</em>, and <em>Session Storage</em>.</li>
<li>Reload the page.</li>
</ol>
</div>
<div id="local-fileloader">
<form id="jsonFile" name="jsonFile" enctype="multipart/form-data" method="post">
<fieldset>
</details>
</div>
<div id="local-fileloader" class="local-loader-field">
<form
id="jsonFile"
name="jsonFile"
enctype="multipart/form-data"
method="post"
>
<fieldset style="border: none; padding: 0; margin: 0;">
<h2>Load local question set</h2>
<input type='file' id='fileinput'>
<input type='button' id='btn-local-file-load' value='Load'>
<input type="file" id="fileinput" />
<input type="button" id="btn-local-file-load" value="Load" />
</fieldset>
</form>
</div>
<p style="margin: 15px 0;">
<button id="btn-view-local-answers" class="view-answers-btn">
View local saved answers
</button>
</p>
<div id="logging-controls" style="margin-top: 15px; border-top: 1px solid #333; padding-top: 15px; font-family: sans-serif; font-size: 13px;">
<label for="select-log-level" style="font-weight: bold; color: #ccc;">Logging Level:</label>
<select id="select-log-level" style="background:#222; color:#fff; border:1px solid #555; padding: 4px 8px; border-radius: 4px; font-size: 13px; margin-left: 5px; cursor: pointer; outline: none;">
<option value="silent">Disabled (Silent)</option>
<option value="error">Error</option>
<option value="warn">Warn</option>
<option value="info">Info</option>
<option value="debug">Debug</option>
</select>
</div>
</details>
</div>
<div id="review-overlay" class="fullscreen-overlay">
@@ -124,8 +292,8 @@
Overcall: <span id="overcall-number"></span><br />
Incorrect call: <span id="incorrectcall-number"></span><br />
</div>
<ul id='review-answer-list'></ul>
<table id='review-answer-table'></table>
<ul id="review-answer-list"></ul>
<table id="review-answer-table"></table>
</div>
</div>
</div>
@@ -133,24 +301,39 @@
<div id="finish-dialog" class="dialog modal">
<h3 class="dialog-title">Finish exam and review?</h3>
<div class="dialog-text">
<button id="finish-exam" class="navigation dialog-yes">Finish and review</button>
<button id="finish-cancel" class="navigation dialog-cancel">Continue exam</button>
<button id="finish-exam" class="navigation dialog-yes">
Finish and review
</button>
<button id="finish-cancel" class="navigation dialog-cancel">
Continue exam
</button>
</div>
</div>
<div id="start-dialog" class="dialog modal">
<h3 class="dialog-title">Start <span class="exam-name"></span></h3>
<div class="packet-database-options">
<input type='button' id='btn-delete-current' value='Reset current packet answers'><br />
<input type='button' id='btn-delete-current-saved-answers' value='Reset current packet saved answers'>
<input
type="button"
id="btn-delete-current"
value="Reset current packet answers"
/><br />
<input
type="button"
id="btn-delete-current-saved-answers"
value="Reset current packet saved answers"
/>
</div>
<div class="dialog-text">
Click to start exam.
<p>You will have <input type='number' size=2 class='exam-time'></input> minutes.</p>
<p>
You will have
<input type="number" size="2" class="exam-time" /> minutes.
</p>
</div>
<div id="exam-candidate-number" class="dialog-text hidden">
Check your below candidate number / user is correct.
<input type='text' size=10 id='candidate-number2' disabled></input>
<input type="text" size="10" id="candidate-number2" disabled />
</div>
<button class="start-packet-button navigation dialog-yes">Start</button>
</div>
@@ -160,24 +343,60 @@
<div class="dialog-text">
Allocated time has ended. You can review or continue.
</div>
<button id="time-up-review-button" class="navigation dialog-review">Review</button>
<button id="time-up-continue-button" class="navigation dialog-yes">Continue</button>
<button id="dialog-submit-button" class="submit-button navigation dialog-review hidden"><b>submit</b></button>
<button id="btn-time-up-review" class="navigation dialog-review">
Review
</button>
<button id="time-up-continue-button" class="navigation dialog-yes">
Continue
</button>
<button
id="dialog-submit-button"
class="submit-button navigation dialog-review hidden"
>
<b>submit</b>
</button>
</div>
<div id="login-dialog" class="dialog modal noclose">
<h3 class="dialog-title">Enter Details</h3>
<div class="dialog-text">
Candidate number: <input type="number" id="candidate-number" name="candidate" required size="10"><br />
Passcode: <input type="text" id="passcode" name="passcode" required size="10"><br />
Candidate number:
<input
type="number"
id="candidate-number"
name="candidate"
required
size="10"
/><br />
Passcode:
<input
type="text"
id="passcode"
name="passcode"
required
size="10"
/><br />
</div>
<button id="btn-login" class="navigation dialog-yes">Login</button>
</div>
<div id="resume-dialog" class="dialog modal noclose">
<h3 class="dialog-title">Resume Session?</h3>
<div class="dialog-text">
<p id="resume-text">Choose a previous session to continue, or start a new one:</p>
<div id="resume-sessions-list" style="margin: 15px 0; max-height: 200px; overflow-y: auto;">
<!-- Session options list -->
</div>
</div>
<div class="dialog-buttons" style="display: flex; gap: 10px; justify-content: flex-end; margin-top: 15px;">
<button id="btn-resume-start-new" class="navigation dialog-no" style="margin: 0;">Start New</button>
<button id="btn-resume-continue" class="navigation dialog-yes" style="margin: 0;" disabled>Continue</button>
</div>
<button id="login-button" class="navigation dialog-yes">Login</button>
</div>
<div id="loading" class="fullscreen-overlay">
<div class="progress-block">
<div id="progress">
</div>
<div id="progress"></div>
<div class="sk-cube-grid full">
<div class="sk-cube sk-cube1"></div>
<div class="sk-cube sk-cube2"></div>
@@ -200,36 +419,39 @@
</div>
</div>
<div id="pause" class="fullscreen-overlay">
</div>
<div id="pause" class="fullscreen-overlay"></div>
<div id="packet-history" class="fullscreen-overlay">
</div>
<div id="packet-history" class="fullscreen-overlay"></div>
<div id="database-error" class="fullscreen-overlay">
</div>
<script src="https://browser.sentry-cdn.com/8.48.0/bundle.min.js" crossorigin="anonymous"></script>
<div id="database-error" class="fullscreen-overlay"></div>
<script
src="https://js-de.sentry-cdn.com/017b4494d7038e3e789fe38378b53fb2.min.js"
crossorigin="anonymous"
></script>
<script src="lib/loglevel.min.js"></script>
<script src="lib/uuidv4.min.js" type="module"></script>
<script src="lib/jquery-3.4.1.min.js" type="text/javascript"></script>
<script src="lib/jq-ajax-progress.min.js" type="text/javascript"></script>
<script src="lib/easytimer.min.js" type="module"></script>
<script src="lib/hammer.js"></script>
<script src="https://unpkg.com/cornerstone-core/dist/cornerstone.js"></script>
<script src="lib/cornerstone.js"></script>
<script src="lib/dicomParser.min.js"></script>
<script src="lib/cornerstoneMath.min.js"></script>
<!-- <script src="lib/cornerstoneTools.min.js"></script> -->
<script src="https://www.unpkg.com/cornerstone-tools@5.2.0/dist/cornerstoneTools.js"></script>
<script src="lib/cornerstoneTools.js"></script>
<script src="lib/cornerstoneWebImageLoader.min.js"></script>
<script src="lib/cornerstoneWADOImageLoader.js"></script>
<script src="lib/cornerstone-base64-image-loader.umd.js"></script>
<script src="lib/dexie.js"></script>
<script src="lib/jquery.modal.min.js"></script>
<!-- <script src="packets/rr1" defer="defer"></script> -->
<script src="js/main.js" defer="defer" type="module"></script>
<script src="lib/notify.min.js" type="text/javascript"></script>
<script>
</script>
</body>
<script></script>
</body>
</html>
+16
View File
@@ -0,0 +1,16 @@
/* global Dexie */
// Set up database
export const db = new Dexie("answers_database");
db.version(1).stores({
answers: "[aid+cid+eid+qid+qidn], [aid+cid+eid], qid, ans, eid",
flags: "[aid+cid+eid+qid+qidn], [aid+cid+eid], qid",
user_answers: "[qid+type+ans], [qid+type]",
session: "[eid+aid], eid, packet, aid, status, date, score, max_score, exam_time, time_left, question_order, questions_answered, total_questions",
});
export const question_db = new Dexie("question_database");
question_db.version(1).stores({
question_data: "&[qid+type], qid, type",
saved_exams: "&eid, type, exam_mode, name, order, time, exam_json_id",
});
+90 -56
View File
@@ -3,7 +3,11 @@ import * as config from "./config.js";
/**
* Submits answers
*/
export function submitAnswers(exam_details, db, config) {
export function submitAnswers(exam_details, db, URLS) {
if (!exam_details || !exam_details.exam_mode) {
alert("Answers can only be submitted when the exam is in exam mode.");
return;
}
getJsonAnswers(exam_details, db)
.then((a) => {
let json = {
@@ -12,7 +16,7 @@ export function submitAnswers(exam_details, db, config) {
start_time: exam_details.start_time,
answers: JSON.stringify(a),
};
postAnswers(json, config, exam_details);
postAnswers(json, URLS, exam_details);
})
.catch((e) => {
console.debug(e)
@@ -44,11 +48,12 @@ export function getJsonAnswers(exam_details, db) {
* Posts answers to url
* @param {*} ans - json representation of answers
*/
export function postAnswers(ans, config, exam_details) {
export function postAnswers(ans, URLS, exam_details) {
$("#progress").html("Submitting answers...");
console.debug("postAnswers", ans, config, exam_details)
console.debug("postAnswers", ans, URLS, exam_details)
console.debug("posturl", URLS.exam_submit_url)
$.post(config.exam_submit_url, ans, null, "json")
$.post(URLS.exam_submit_url, ans, null, "json")
.done((data) => {
console.debug("returned data", data);
if (data.success) {
@@ -61,11 +66,17 @@ export function postAnswers(ans, config, exam_details) {
if (ret) {
$(document).trigger("saveSessionEvent", [true]);
if (config.exam_results_url != "") {
let url = config.exam_results_url;
if (URLS.exam_results_url != "") {
let url = URLS.exam_results_url;
if (exam_details.cid != "") {
url = url + exam_details.cid;
url = `${url}${exam_details.cid}`;
if (localStorage.getItem("cid.passcode") != null) {
url = `${url}/${localStorage.getItem("cid.passcode")}`;
}
if (exam_details.cid.startsWith("u-")) {
url = URLS.exam_user_results_url;
}
}
$("#options-link")
.empty()
@@ -80,74 +91,97 @@ export function postAnswers(ans, config, exam_details) {
alert(`Answers sucessfully submitted.`);
}
} else {
submissionError(data, ans, exam_details);
submissionError(data, ans, exam_details, URLS);
}
})
.fail((e) => {
// Will occur with server error such as 500
console.debug("error", e);
submissionError(e, ans, exam_details);
submissionError(e, ans, exam_details, URLS);
});
// $.post( "http://localhost:8000/submit_answers", JSON.stringify(ans));
}
function submissionError(data, answer_json, exam_details) {
// error will not be defined with server errors
if (data.error != undefined) {
alert(`Error submitting answers: ${data.error}`);
} else {
alert(`Error submitting answers`);
}
var docHeight = $(document).height();
export function submissionError(data, answer_json, exam_details, URLS) {
// Clear any existing overlay
$("#submit-error-overlay").remove();
let answers = JSON.parse(answer_json.answers)
let answers = JSON.parse(answer_json.answers);
let answer_map = {};
let answer_map = {}
answers.forEach((ans, n) => {
answers.forEach((ans) => {
if (!answer_map.hasOwnProperty(ans.qid)) {
answer_map[ans.qid] = [];
}
answer_map[ans.qid].push(ans);
});
let html = $("<ul></ul>");
exam_details.question_order.forEach((i, j) => {
console.debug(i, answer_map)
if (i in answer_map) {
console.debug("YES", i, answer_map)
let ans_array = answer_map[i];
ans_array.forEach((x, y) => {
$(html).append(`<li><b>Question ${j+1}.${y}:</b> ${x.ans}</li>`);
let $list = $("<ol style='margin-left: 20px; font-size: 13px; max-height: 150px; overflow-y: auto; color: #ccc;'></ol>");
exam_details.question_order.forEach((qid, idx) => {
if (qid in answer_map) {
let ans_array = answer_map[qid];
ans_array.forEach((ans_obj, sub_idx) => {
$list.append(`<li><strong>Question ${idx + 1}.${sub_idx + 1}:</strong> ${ans_obj.ans}</li>`);
});
}
});
})
let rawJsonStr = JSON.stringify(answer_json, null, 2);
console.debug(exam_details.question_order);
console.debug(answer_map);
console.debug(html);
if ($("#submit-error-overlay").length < 1) {
$("body").append(
`<div id='submit-error-overlay'><span style='color: white'><p>An error has occurred when submitting your answers. A copy of your answers are displayed below, you may wish to <a id="save-failed-answers" href="#">save a copy</a>. Please try submitting again or refresh this page to continue (answers will be saved locally in the browser unless you clear local storage).</p><p>${html.get(0).innerHTML}</p><p>${JSON.stringify(
answer_json
)}</p></span></div>`
);
$("#nav-submit-button").prependTo("#submit-error-overlay");
$("#submit-error-overlay").height(docHeight);
let file = new Blob([JSON.stringify(answer_json)], {type: "application/json"})
let link = document.getElementById("save-failed-answers");
link.href = URL.createObjectURL(file);
link.download = "answers.json";
let errorMsg = "An error occurred while submitting your answers.";
if (data && data.error) {
errorMsg = `Server returned error: ${data.error}`;
} else if (data && data.statusText) {
errorMsg = `Network error: ${data.statusText} (${data.status})`;
}
let $overlay = $(`
<div id="submit-error-overlay">
<div class="error-card">
<h3>Submission Failed</h3>
<p style="margin-bottom: 15px; color: #ef5350; font-weight: bold;">${errorMsg}</p>
<p style="font-size: 13px; color: #bbb; line-height: 1.4;">
Your answers are saved locally in the browser's database and will not be lost unless you clear your local data.
Please try to submit again, or save a backup file of your answers and contact support.
</p>
<div style="margin: 15px 0;">
<h4 style="margin: 0 0 5px 0; font-size: 14px; color: #eee;">Submitted Answers:</h4>
<div style="background: #111; border: 1px solid #333; padding: 10px; border-radius: 4px;">
${$list.prop("outerHTML")}
</div>
</div>
<details style="margin: 15px 0; font-size: 12px; cursor: pointer; color: #aaa;">
<summary style="font-weight: bold; margin-bottom: 5px;">View Raw Submission JSON</summary>
<pre style="background: #111; border: 1px solid #333; padding: 10px; overflow: auto; max-height: 150px; text-align: left; font-family: monospace; white-space: pre-wrap; margin: 0; color: #a5d6a7;">${rawJsonStr}</pre>
</details>
<div style="display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px;">
<button id="btn-err-close" class="btn-secondary">Close</button>
<a id="btn-err-backup" class="button" style="text-decoration: none;"><button class="btn-secondary">Save Backup</button></a>
<button id="btn-err-retry">Retry Submit</button>
</div>
</div>
</div>
`);
$("body").append($overlay);
// Setup download backup link
let fileBlob = new Blob([JSON.stringify(answer_json)], {type: "application/json"});
let backupUrl = URL.createObjectURL(fileBlob);
$overlay.find("#btn-err-backup").attr("href", backupUrl).attr("download", `answers_backup_${exam_details.eid.replace(/\//g, "_")}.json`);
// Close handler
$overlay.find("#btn-err-close").on("click", function() {
$("#submit-error-overlay").remove();
});
// Retry handler
$overlay.find("#btn-err-retry").on("click", function() {
postAnswers(answer_json, URLS, exam_details);
});
}
export function getQuestion(url, question_number, question_total) {
@@ -180,11 +214,11 @@ export function getQuestion(url, question_number, question_total) {
});
}
export function postSavedAnswer(type, qid, answer, e, db_object, db) {
export function postSavedAnswer(type, qid, answer, e, db_object, db, submit_url) {
console.debug("post", type, qid, answer, e)
return $.ajax({
type: "POST",
url: config.question_answer_submit_url,
url: submit_url,
data: JSON.stringify({
qid: `${type}/${qid}`,
answer: answer,
+982 -241
View File
File diff suppressed because it is too large Load Diff
+101
View File
@@ -0,0 +1,101 @@
/**
* Sentry Error Monitoring initializer for RTS.
* Optimized for Free Tier with beforeSend noise filtering.
*/
export function initSentry() {
try {
const defaultDsn = "https://a5a64d2190093ce4b8972dd9a6907828@o4511766757244928.ingest.de.sentry.io/4511766900310096";
const metaDsn = document.querySelector('meta[name="sentry-dsn"]')?.getAttribute('content');
const dsn = window.SENTRY_DSN || window.RTS_SENTRY_DSN || metaDsn || defaultDsn;
if (!dsn) {
window.SENTRY_STATUS = { loaded: false, reason: "No DSN configured" };
console.warn("[Sentry] ❌ Error monitoring disabled (No DSN provided)");
return;
}
const beforeSend = (event, hint) => {
const error = hint?.originalException;
const message = event?.message || (error && error.message) || "";
// Ignore common harmless browser/network noise to preserve Sentry free quota
const noisePatterns = [
/ResizeObserver loop/i,
/Failed to fetch/i,
/NetworkError/i,
/Load failed/i,
/Script error/i,
/top.GLOBALS/i,
/chrome-extension:\/\//i,
/moz-extension:\/\//i,
];
if (noisePatterns.some((pattern) => pattern.test(message))) {
return null;
}
return event;
};
const config = {
dsn: dsn,
environment: window.SENTRY_ENVIRONMENT || "production",
tracesSampleRate: parseFloat(window.SENTRY_TRACES_SAMPLE_RATE || "0.02"),
beforeSend: beforeSend,
ignoreErrors: [
"ResizeObserver loop limit exceeded",
"ResizeObserver loop completed with undelivered notifications.",
"NetworkError when attempting to fetch resource.",
"Failed to fetch",
"Load failed",
],
};
const onInitialized = () => {
window.SENTRY_STATUS = { loaded: true, dsn: dsn, environment: config.environment };
console.info(`[Sentry] ✅ Error monitoring initialized (Env: ${config.environment})`);
// Expose a convenient window.testSentry() function for testing from browser console
window.testSentry = function(msg = "RTS Sentry Test Event") {
if (window.Sentry && typeof window.Sentry.captureException === "function") {
const testErr = new Error(msg);
window.Sentry.captureException(testErr);
console.info("[Sentry] 🚀 Sent test error event to Sentry:", testErr);
return "Sentry test event sent!";
} else {
console.error("[Sentry] ❌ Sentry is not available to send test event");
return "Failed to send event";
}
};
};
if (window.Sentry) {
window.Sentry.init({
...config,
integrations: [
window.Sentry.browserTracingIntegration ? window.Sentry.browserTracingIntegration() : null,
].filter(Boolean),
});
onInitialized();
} else {
const script = document.createElement("script");
script.src = "https://browser.sentry-cdn.com/8.48.0/bundle.min.js";
script.crossOrigin = "anonymous";
script.onload = () => {
if (window.Sentry) {
window.Sentry.init(config);
onInitialized();
}
};
script.onerror = () => {
window.SENTRY_STATUS = { loaded: false, reason: "Failed to load Sentry CDN script" };
console.warn("[Sentry] ❌ Failed to load Sentry CDN script");
};
document.head.appendChild(script);
}
} catch (err) {
window.SENTRY_STATUS = { loaded: false, error: err };
console.warn("[Sentry] ❌ Initialization failed:", err);
}
}
+194
View File
@@ -0,0 +1,194 @@
// Background Sync Module for RTS
import { db } from "./db.js";
let syncInterval = null;
let debounceTimeout = null;
let currentStatus = "saved"; // "saved", "syncing", "failed", "practice"
/**
* Update the sync UI state
* @param {string} status - "saved", "syncing", "failed", "practice"
*/
export function updateSyncUI(status) {
currentStatus = status;
const container = document.getElementById("sync-status");
if (!container) return;
let html = "";
switch (status) {
case "practice":
html = `
<span class="sync-dot practice"></span>
<span class="sync-text">Practice mode (local only)</span>
`;
break;
case "syncing":
html = `
<span class="sync-dot syncing"></span>
<span class="sync-text">Saving to server...</span>
`;
break;
case "failed":
html = `
<span class="sync-dot failed"></span>
<span class="sync-text">Offline - Save failed</span>
`;
break;
case "saved":
default:
html = `
<span class="sync-dot saved"></span>
<span class="sync-text">Saved to server</span>
`;
break;
}
container.innerHTML = html;
}
/**
* Perform background synchronization of unsynced answers
*/
export async function performSync(exam_details, URLS) {
if (!exam_details || !exam_details.exam_mode || !exam_details.eid) return;
// Find all unsynced answers for this exam/cid/aid
const unsynced = await db.answers
.where({
aid: exam_details.aid,
cid: exam_details.cid,
eid: exam_details.eid,
})
.filter(ans => ans.synced === false)
.toArray();
if (unsynced.length === 0) {
if (currentStatus === "syncing") {
updateSyncUI("saved");
}
return;
}
updateSyncUI("syncing");
const json = {
eid: exam_details.eid,
cid: exam_details.cid,
start_time: exam_details.start_time,
answers: JSON.stringify(unsynced),
in_progress: "true",
};
try {
const response = await new Promise((resolve, reject) => {
$.ajax({
type: "POST",
url: URLS.exam_submit_url,
data: json,
dataType: "json",
global: false, // Prevents triggering global ajaxStart / ajaxStop spinners
success: resolve,
error: reject,
});
});
if (response && response.success) {
// Mark as synced in Dexie database
await db.transaction("rw", db.answers, async () => {
for (const ans of unsynced) {
ans.synced = true;
await db.answers.put(ans);
}
});
updateSyncUI("saved");
} else {
updateSyncUI("failed");
}
} catch (error) {
console.error("Autosubmission failed:", error);
updateSyncUI("failed");
}
}
/**
* Trigger an immediate, debounced sync (2s delay after last change)
*/
export function triggerImmediateSync(exam_details, URLS) {
if (!exam_details || !exam_details.exam_mode) return;
if (debounceTimeout) {
clearTimeout(debounceTimeout);
}
debounceTimeout = setTimeout(() => {
performSync(exam_details, URLS);
}, 2000);
}
/**
* Initialize background sync process
*/
export function initSync(exam_details, URLS) {
// Clear any existing sync process
if (syncInterval) {
clearInterval(syncInterval);
}
// Setup click redirect to remote user answers on the server if in exam mode
const container = document.getElementById("sync-status");
if (container) {
if (exam_details && exam_details.exam_mode) {
$(container).css("cursor", "pointer");
$(container).off("click").on("click", () => {
let url = URLS.exam_results_url;
if (url) {
if (exam_details.cid) {
if (exam_details.cid.startsWith("u-")) {
url = URLS.exam_user_results_url || url;
} else {
url = `${url}${exam_details.cid}`;
const passcode = localStorage.getItem("cid.passcode");
if (passcode) {
url = `${url}/${passcode}`;
}
}
}
window.open(url, "_blank");
}
});
} else {
$(container).css("cursor", "default");
$(container).off("click");
}
}
if (!exam_details || !exam_details.exam_mode) {
updateSyncUI("practice");
return;
}
// Check if there are unsynced answers at startup
db.answers
.where({
aid: exam_details.aid,
cid: exam_details.cid,
eid: exam_details.eid,
})
.filter(ans => ans.synced === false)
.toArray()
.then(unsynced => {
if (unsynced && unsynced.length > 0) {
updateSyncUI("failed"); // Unsynced answers exist; they aren't accepted by server yet
performSync(exam_details, URLS);
} else {
updateSyncUI("saved");
}
})
.catch(() => {
updateSyncUI("saved");
});
// Run periodic sync every 15 seconds
syncInterval = setInterval(() => {
performSync(exam_details, URLS);
}, 15000);
}
+220 -106
View File
@@ -11,6 +11,9 @@ export function loadMainImage(image, stack) {
const RotateTool = cornerstoneTools.RotateTool;
const StackScrollTool = cornerstoneTools.StackScrollTool;
const ArrowAnnotateTool = cornerstoneTools.ArrowAnnotateTool;
const LengthTool = cornerstoneTools.LengthTool;
const RectangleRoiTool = cornerstoneTools.RectangleRoiTool;
const EllipseRoiTool = cornerstoneTools.EllipseRoiTool;
const AngleTool = cornerstoneTools.AngleTool;
const CobbAngleTool = cornerstoneTools.CobbAngleTool;
const EraserTool = cornerstoneTools.EraserTool;
@@ -35,6 +38,9 @@ export function loadMainImage(image, stack) {
cornerstoneTools.addToolForElement(element, CobbAngleTool);
cornerstoneTools.addToolForElement(element, StackScrollTool);
cornerstoneTools.addToolForElement(element, EraserTool);
cornerstoneTools.addToolForElement(element, LengthTool);
cornerstoneTools.addToolForElement(element, RectangleRoiTool);
//cornerstoneTools.addToolForElement(element, EllipseRoiTool);
cornerstoneTools.addToolForElement(element, ArrowAnnotateTool, {
configuration: {
@@ -58,7 +64,9 @@ export function loadMainImage(image, stack) {
setDicomCanvasNonFullscreen(element);
cornerstone.reset(element);
element.scrollIntoView(false);
let viewerTitleBar = document.getElementById("viewer-title-bar");
viewerTitleBar.scrollIntoView();
//element.scrollIntoView(false);
//element.scrollTo(0);
$(element).dblclick(function() {
@@ -66,40 +74,13 @@ export function loadMainImage(image, stack) {
// already fullscreen (disable it)
disableFullscreen(this);
} else {
$(".content-panel").append($(".canvas-panel"));
$(".canvas-panel").toggleClass("canvas-panel canvas-panel-fullscreen");
$("#dicom-image").attr("height", "100%");
$("#dicom-image").height("100%");
// $(".cornerstone-canvas").attr("height", "100%");
// $(".cornerstone-canvas").height("100%");
cornerstone.resize(this, true);
enableFullscreen(this);
}
});
element.removeEventListener("wheel", element.wheelEventHandler);
// Add tool selector
$(".canvas-panel").append(
'<select class="control-overlay"> \
<option value="pan">pan [p]</option> \
<option value="zoom">zoom [z]</option> \
<option value="rotate">rotate [r]</option> \
<option value="angle">angle</option> \
<option value="cobbangle">cobb angle</option> \
<option value="eraser">eraser</option> \
<option value="scroll" hidden="" disabled="">scroll (1/1)</option> \
<option value="window">window ()</option> \
<option value="abdomen" hidden="" disabled="">window = abdomen [a]</option> \
<option value="pulmonary" hidden="" disabled="">window = pulmonary [u]</option> \
<option value="brain" hidden="" disabled="">window = brain [b]</option> \
<option value="bone" hidden="" disabled="">window = bone [o]</option> \
<option value="reset">reset [e]</option> \
<option value="close">close [c]</option> \
<option disabled="true" value="notes">[modality = CR][size = 9.8]</option> \
</select>'
);
$(".control-overlay")
$(".dicom-select-control")
.get(0)
.addEventListener("change", function() {
changeControlSelection();
@@ -117,7 +98,7 @@ function onImageRendered(e) {
const eventData = e.detail;
// Update ww/wl
const sel = $(".control-overlay").get(0);
const sel = $(".dicom-select-control").get(0);
sel.options[find_option(sel, "window")].firstChild.textContent =
"window (" +
Math.round(eventData.viewport.voi.windowCenter) +
@@ -176,7 +157,10 @@ export function disableFullscreen(dicom_element) {
$(".canvas-panel-fullscreen").toggleClass(
"canvas-panel canvas-panel-fullscreen"
);
$(".question").append($(".canvas-panel"));
$("#disable-fullscreen-viewer-button").hide();
$("#fullscreen-viewer-button").show();
$(".question").prepend($(".canvas-panel"));
$(".canvas-panel").get(0).scrollIntoView();
setDicomCanvasNonFullscreen(dicom_element);
@@ -279,10 +263,12 @@ export function debugCornerstone() {
/**
* Registers the selected dicom tool
*
* This also allows selecting of window levels
*/
export function changeControlSelection() {
// We also duplicate the sel that are available
const sel = $(".control-overlay").get(0);
const sel = $(".dicom-select-control").get(0);
const old = sel.oldSelectedIndex;
sel.oldSelectedIndex = sel.selectedIndex;
@@ -307,6 +293,25 @@ export function changeControlSelection() {
});
break;
}
case "measure": {
cornerstoneTools.setToolActiveForElement(dicom_element, "Length", {
mouseButtonMask: 1,
});
break;
}
case "rectangle": {
cornerstoneTools.setToolActiveForElement(dicom_element, "RectangleRoi", {
mouseButtonMask: 1,
});
break;
}
case "ellipse": {
cornerstoneTools.setToolActiveForElement(dicom_element, "EllipseRoi", {
mouseButtonMask: 1,
});
break;
}
case "angle": {
cornerstoneTools.setToolActiveForElement(dicom_element, "Angle", {
mouseButtonMask: 1,
@@ -376,24 +381,13 @@ export function changeControlSelection() {
break;
}
case "reset": {
cornerstone.reset(dicom_element);
resetViewer(dicom_element);
sel.selectedIndex = old;
sel.oldSelectedIndex = old;
break;
}
case "close": {
// disable fullscreen if required
if ($(".canvas-panel").length == 0) {
disableFullscreen(dicom_element);
}
if (dicom_element != undefined) {
// See https://github.com/cornerstonejs/cornerstoneTools/issues/1337
cornerstone.removeElementData(dicom_element);
cornerstone.disable(dicom_element);
$(".canvas-panel").remove();
$(".figure-open").removeClass("figure-open").addClass("figure");
$(dicom_element).remove();
}
closeViewer(dicom_element);
break;
}
}
@@ -403,6 +397,12 @@ export function changeControlSelection() {
}
}
function resetViewer(dicom_element) {
cornerstoneTools.clearToolState(dicom_element, "RectangleRoi");
cornerstoneTools.clearToolState(dicom_element, "Length");
cornerstone.reset(dicom_element);
}
/**
* Handle key button events
* These are the same as in the RCR pratique implementation
@@ -422,12 +422,16 @@ export function keydown_handler(event) {
if (target_element == "INPUT" || target_element == "TEXTAREA") {
return;
}
const sel = $(".control-overlay").get(0);
const sel = $(".dicom-select-control").get(0);
switch (event.code) {
case "KeyP":
sel.selectedIndex = find_option(sel, "pan");
changeControlSelection();
break;
case "KeyC":
sel.selectedIndex = find_option(sel, "rectangle");
changeControlSelection();
break;
case "KeyZ":
sel.selectedIndex = find_option(sel, "zoom");
changeControlSelection();
@@ -466,7 +470,7 @@ export function keydown_handler(event) {
changeControlSelection();
break;
// Escape and C do the same
case "KeyC":
//case "KeyC":
case "Escape":
sel.selectedIndex = find_option(sel, "close");
changeControlSelection();
@@ -527,7 +531,7 @@ export function keydown_handler(event) {
manualZoomDicom(-1);
break;
case "rotate":
manualRotateDicom(1);
manualRotateDicom(-1);
break;
case "scroll":
manualScrollDicom(1);
@@ -547,7 +551,7 @@ export function keydown_handler(event) {
manualZoomDicom(1);
break;
case "rotate":
manualRotateDicom(-1);
manualRotateDicom(1);
break;
case "scroll":
manualScrollDicom(-1);
@@ -562,16 +566,6 @@ export function keydown_handler(event) {
event.preventDefault();
}
/**
* View cornstone image
* @param {*} t
* @param {*} source
*/
export function openMainImage(current_question, t, source) {
/**
* Load image
* @param {*} images - list of images
*/
function loadAnnotation(imageId, annotation) {
const toolStateManager =
cornerstoneTools.globalImageIdSpecificToolStateManager;
@@ -588,67 +582,58 @@ export function openMainImage(current_question, t, source) {
toolStateManager.restoreToolState(tool_state);
}
async function load(images, annotations) {
//console.debug("Load function", images)
const imageIds = [];
for (let i = 0; i < images.length; i++) {
const data_url = images[i];
// Parallelize image preparation and annotation loading
const imageTasks = images.map(async (data_url, i) => {
const annotation = annotations[i];
// check stack type
let imageId;
if (data_url.startsWith("data:image")) {
const imageId = "base64://" + data_url.split(",")[1];
loadAnnotation(imageId, annotation);
imageIds.push(imageId);
//} else if (data_url.startsWith("data:application/dicom")) {
imageId = "base64://" + data_url.split(",")[1];
} else if (data_url.startsWith("data:")) {
// stack = stack.split(";")[1];
const dfile = await urltoFile(data_url, "dicom", "application/dicom");
const imageId = cornerstoneWADOImageLoader.wadouri.fileManager.add(
dfile
);
loadAnnotation(imageId, annotation);
imageIds.push(imageId);
// cornerstone.loadImage(imageId).then(function(image) {
// tempFunction(image);
// });
imageId = cornerstoneWADOImageLoader.wadouri.fileManager.add(dfile);
} else {
let url = data_url;
//if (data_url.startsWith("http")) {
// url = data_url;
//} else {
// url = window.location.href.replace(/\/\#\/?$/, "") + "/" + data_url;
//}
if (url.endsWith("dcm")) {
url = "wadouri:" + url;
}
// if there is no extension treat it as a dicom
if (/(?:\/|^)[^.\/]+$/.test(url)) {
url = "wadouri:" + url;
}
//console.debug(url)
loadAnnotation(url, annotation);
imageIds.push(url);
}
imageId = url;
}
return {imageId, annotation};
});
// Wait for all image tasks to complete
const imageResults = await Promise.all(imageTasks);
const imageIds = imageResults.map(r => r.imageId);
// Display the first image as soon as possible
cornerstone.loadAndCacheImage(imageIds[0]).then(function(image) {
const stack = {
currentImageIdIndex: 0,
imageIds,
};
cornerstone.loadAndCacheImage(imageIds[0]).then(function (image) {
loadMainImage(image, stack);
// Restore annotations for all images after display
imageResults.forEach(({imageId, annotation}) => {
loadAnnotation(imageId, annotation);
});
});
}
/**
* View cornstone image
* @param {*} t
* @param {*} source
*/
export function openMainImage(current_question, t, source) {
/**
* Load image
* @param {*} images - list of images
*/
if (current_question) {
// Check if the figure is already loaded (or if another one is)
const open_figure = $("#dicom-image").data("figure");
@@ -658,7 +643,7 @@ export function openMainImage(current_question, t, source) {
// No full size figure / dicom loaded yet
if (figure_to_load == open_figure) {
// Scroll to the image if it is already open
document.getElementById("dicom-image").scrollIntoView();
document.getElementById("viewer-title-bar").scrollIntoView();
return;
} else {
let el;
@@ -676,11 +661,97 @@ export function openMainImage(current_question, t, source) {
source.className = "figure-open";
$(".question").append(
'<div class= "canvas-panel"><div id="dicom-image" data-figure="' +
figure_to_load +
'"></div></div>'
);
let image_title = source.getElementsByClassName("figcaption")[0].textContent;
// Native DOM creation for performance
const questionElem = document.querySelector('.question');
const canvasPanel = document.createElement('div');
canvasPanel.className = 'canvas-panel';
const viewerTitleBar = document.createElement('div');
viewerTitleBar.id = 'viewer-title-bar';
const flexDiv = document.createElement('div');
flexDiv.style.flex = '1 1 auto';
const viewerTitle = document.createElement('div');
viewerTitle.id = 'viewer-title';
viewerTitle.style.color = 'white';
viewerTitle.textContent = image_title;
flexDiv.appendChild(viewerTitle);
viewerTitleBar.appendChild(flexDiv);
// Select control
const select = document.createElement('select');
select.title = 'SELECT CLICK ACTION';
select.className = 'dicom-select-control title-bar-button';
select.style.marginRight = '8px';
const options = [
{ value: 'pan', text: 'pan [p]' },
{ value: 'zoom', text: 'zoom [z]' },
{ value: 'rotate', text: 'rotate [r]' },
{ value: 'scroll', text: 'scroll [s]' },
{ value: 'window', text: 'window (128 ± 127.5) [w]' },
{ value: 'abdomen', text: 'window = abdomen [a]', hidden: true, disabled: true },
{ value: 'pulmonary', text: 'window = pulmonary [p]', hidden: true, disabled: true },
{ value: 'brain', text: 'window = brain [b]', hidden: true, disabled: true },
{ value: 'bone', text: 'window = bone [o]', hidden: true, disabled: true },
{ value: 'measure', text: 'measure [m]' },
{ value: 'ellipse', text: 'ellipse [l]' },
{ value: 'rectangle', text: 'rectangle [c]' },
{ value: 'reset', text: 'reset [e]' },
{ value: 'notes', text: '[modality = SC]', disabled: true }
];
options.forEach(opt => {
const o = document.createElement('option');
o.value = opt.value;
o.textContent = opt.text;
if (opt.disabled) o.disabled = true;
if (opt.hidden) o.hidden = true;
select.appendChild(o);
});
viewerTitleBar.appendChild(select);
// Buttons
function createButton(id, title, svg, style = '') {
const btn = document.createElement('button');
btn.id = id;
btn.className = 'title-bar-button';
btn.title = title;
btn.style.marginRight = '8px';
if (style) btn.style.display = style;
const span = document.createElement('span');
span.className = 'viewer-icon';
span.innerHTML = svg;
btn.appendChild(span);
return btn;
}
// SVGs
const svgReset = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-counterclockwise" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8 3a5 5 0 1 1-4.546 2.914.5.5 0 0 0-.908-.417A6 6 0 1 0 8 2z"/><path d="M8 4.466V.534a.25.25 0 0 0-.41-.192L5.23 2.308a.25.25 0 0 0 0 .384l2.36 1.966A.25.25 0 0 0 8 4.466"/></svg>`;
const svgFullscreen = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-fullscreen" viewBox="0 0 16 16"><path d="M1.5 1a.5.5 0 0 0-.5.5v4a.5.5 0 0 1-1 0v-4A1.5 1.5 0 0 1 1.5 0h4a.5.5 0 0 1 0 1zM10 .5a.5.5 0 0 1 .5-.5h4A1.5 1.5 0 0 1 16 1.5v4a.5.5 0 0 1-1 0v-4a.5.5 0 0 0-.5-.5h-4a.5.5 0 0 1-.5-.5M.5 10a.5.5 0 0 1 .5.5v4a.5.5 0 0 0 .5.5h4a.5.5 0 0 1 0 1h-4A1.5 1.5 0 0 1 0 14.5v-4a.5.5 0 0 1 .5-.5m15 0a.5.5 0 0 1 .5.5v4a1.5 1.5 0 0 1-1.5 1.5h-4a.5.5 0 0 1 0-1h4a.5.5 0 0 0-.5-.5v-4a.5.5 0 0 1 .5-.5"/></svg>`;
const svgFullscreenExit = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-fullscreen-exit" viewBox="0 0 16 16"><path d="M5.5 0a.5.5 0 0 1 .5.5v4A1.5 1.5 0 0 1 4.5 6h-4a.5.5 0 0 1 0-1h4a.5.5 0 0 0 .5-.5v-4a.5.5 0 0 1 .5-.5m5 0a.5.5 0 0 1 .5.5v4a.5.5 0 0 0 .5.5h4a.5.5 0 0 1 0 1h-4A1.5 1.5 0 0 1 10 4.5v-4a.5.5 0 0 1 .5-.5M0 10.5a.5.5 0 0 1 .5-.5h4A1.5 1.5 0 0 1 6 11.5v4a.5.5 0 0 1-1 0v-4a.5.5 0 0 0-.5-.5h-4a.5.5 0 0 1-.5-.5m10 1a1.5 1.5 0 0 1 1.5-1.5h4a.5.5 0 0 1 0 1h-4a.5.5 0 0 0-.5.5v4a.5.5 0 0 1-1 0z"/></svg>`;
const svgClose = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-x-lg" viewBox="0 0 16 16"><path d="M2.146 2.854a.5.5 0 1 1 .708-.708L8 7.293l5.146-5.147a.5.5 0 0 1 .708.708L8.707 8l5.147 5.146a.5.5 0 0 1-.708.708L8 8.707l-5.146 5.147a.5.5 0 0 1-.708-.708L7.293 8z"/></svg>`;
const btnReset = createButton('reset-viewer-button', 'RESET VIEW', svgReset);
const btnFullscreen = createButton('fullscreen-viewer-button', 'TOGGLE FULLSCREEN', svgFullscreen);
const btnFullscreenExit = createButton('disable-fullscreen-viewer-button', 'TOGGLE FULLSCREEN', svgFullscreenExit, 'none');
const btnClose = createButton('close-viewer-button', 'CLOSE VIEWER', svgClose);
viewerTitleBar.appendChild(btnReset);
viewerTitleBar.appendChild(btnFullscreen);
viewerTitleBar.appendChild(btnFullscreenExit);
viewerTitleBar.appendChild(btnClose);
canvasPanel.appendChild(viewerTitleBar);
// DICOM image container
const dicomImageDiv = document.createElement('div');
dicomImageDiv.id = 'dicom-image';
dicomImageDiv.setAttribute('data-figure', figure_to_load);
canvasPanel.appendChild(dicomImageDiv);
// Prepend to question
questionElem.insertBefore(canvasPanel, questionElem.firstChild);
let images = current_question.images[figure_to_load.split("-")[1]];
// images = current_question.images
@@ -698,10 +769,40 @@ export function openMainImage(current_question, t, source) {
}
}
const dicom_element = document.getElementById("dicom-image");
// Bind button actions
$("#close-viewer-button").click(function() {
closeViewer(dicom_element);
});
$("#reset-viewer-button").click(function() {
resetViewer(dicom_element);
});
$("#fullscreen-viewer-button").click(function() {
enableFullscreen(dicom_element);
});
$("#disable-fullscreen-viewer-button").click(function() {
disableFullscreen(dicom_element);
});
load(images, annotations);
}
}
function closeViewer(dicom_element) {
// disable fullscreen if required
if ($(".canvas-panel").length == 0) {
disableFullscreen(dicom_element);
}
if (dicom_element != undefined) {
// See https://github.com/cornerstonejs/cornerstoneTools/issues/1337
cornerstone.removeElementData(dicom_element);
cornerstone.disable(dicom_element);
$(".canvas-panel").remove();
$(".figure-open").removeClass("figure-open").addClass("figure");
$(dicom_element).remove();
}
}
export function urltoFile(url, filename, mimeType) {
return fetch(url)
.then(function(res) {
@@ -711,3 +812,16 @@ export function urltoFile(url, filename, mimeType) {
return new File([buf], filename, {type: mimeType});
});
}
function enableFullscreen(dicom_element) {
$(".content-panel").prepend($(".canvas-panel"));
$(".canvas-panel").toggleClass("canvas-panel canvas-panel-fullscreen");
$("#dicom-image").attr("height", "100%");
$("#dicom-image").height("100%");
$("#disable-fullscreen-viewer-button").show();
$("#fullscreen-viewer-button").hide();
// $(".cornerstone-canvas").attr("height", "100%");
// $(".cornerstone-canvas").height("100%");
cornerstone.resize(dicom_element, true);
}
+952 -467
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
/*! loglevel - v1.8.1 - https://github.com/pimterry/loglevel - (c) 2022 Tim Perry - licensed MIT */
!function(a,b){"use strict";"function"==typeof define&&define.amd?define(b):"object"==typeof module&&module.exports?module.exports=b():a.log=b()}(this,function(){"use strict";function a(a,b){var c=a[b];if("function"==typeof c.bind)return c.bind(a);try{return Function.prototype.bind.call(c,a)}catch(b){return function(){return Function.prototype.apply.apply(c,[a,arguments])}}}function b(){console.log&&(console.log.apply?console.log.apply(console,arguments):Function.prototype.apply.apply(console.log,[console,arguments])),console.trace&&console.trace()}function c(c){return"debug"===c&&(c="log"),typeof console!==i&&("trace"===c&&j?b:void 0!==console[c]?a(console,c):void 0!==console.log?a(console,"log"):h)}function d(a,b){for(var c=0;c<k.length;c++){var d=k[c];this[d]=c<a?h:this.methodFactory(d,a,b)}this.log=this.debug}function e(a,b,c){return function(){typeof console!==i&&(d.call(this,b,c),this[a].apply(this,arguments))}}function f(a,b,d){return c(a)||e.apply(this,arguments)}function g(a,b,c){function e(a){var b=(k[a]||"silent").toUpperCase();if(typeof window!==i&&m){try{return void(window.localStorage[m]=b)}catch(a){}try{window.document.cookie=encodeURIComponent(m)+"="+b+";"}catch(a){}}}function g(){var a;if(typeof window!==i&&m){try{a=window.localStorage[m]}catch(a){}if(typeof a===i)try{var b=window.document.cookie,c=b.indexOf(encodeURIComponent(m)+"=");-1!==c&&(a=/^([^;]+)/.exec(b.slice(c))[1])}catch(a){}return void 0===l.levels[a]&&(a=void 0),a}}function h(){if(typeof window!==i&&m){try{return void window.localStorage.removeItem(m)}catch(a){}try{window.document.cookie=encodeURIComponent(m)+"=; expires=Thu, 01 Jan 1970 00:00:00 UTC"}catch(a){}}}var j,l=this;b=null==b?"WARN":b;var m="loglevel";"string"==typeof a?m+=":"+a:"symbol"==typeof a&&(m=void 0),l.name=a,l.levels={TRACE:0,DEBUG:1,INFO:2,WARN:3,ERROR:4,SILENT:5},l.methodFactory=c||f,l.getLevel=function(){return j},l.setLevel=function(b,c){if("string"==typeof b&&void 0!==l.levels[b.toUpperCase()]&&(b=l.levels[b.toUpperCase()]),!("number"==typeof b&&b>=0&&b<=l.levels.SILENT))throw"log.setLevel() called with invalid level: "+b;if(j=b,!1!==c&&e(b),d.call(l,b,a),typeof console===i&&b<l.levels.SILENT)return"No console available for logging"},l.setDefaultLevel=function(a){b=a,g()||l.setLevel(a,!1)},l.resetLevel=function(){l.setLevel(b,!1),h()},l.enableAll=function(a){l.setLevel(l.levels.TRACE,a)},l.disableAll=function(a){l.setLevel(l.levels.SILENT,a)};var n=g();null==n&&(n=b),l.setLevel(n,!1)}var h=function(){},i="undefined",j=typeof window!==i&&typeof window.navigator!==i&&/Trident\/|MSIE /.test(window.navigator.userAgent),k=["trace","debug","info","warn","error"],l=new g,m={};l.getLogger=function(a){if("symbol"!=typeof a&&"string"!=typeof a||""===a)throw new TypeError("You must supply a name when creating a logger.");var b=m[a];return b||(b=m[a]=new g(a,l.getLevel(),l.methodFactory)),b};var n=typeof window!==i?window.log:void 0;return l.noConflict=function(){return typeof window!==i&&window.log===l&&(window.log=n),l},l.getLoggers=function(){return m},l.default=l,l});
+6364 -302
View File
File diff suppressed because it is too large Load Diff
+6 -4
View File
@@ -6,8 +6,10 @@
"directories": {
"lib": "lib"
},
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"test": "NODE_OPTIONS=--experimental-vm-modules jest",
"lint": "eslint --fix . && echo 'Lint complete.'"
},
"repository": {
"type": "git",
@@ -21,7 +23,7 @@
"homepage": "https://github.com/xkjq/rts#readme",
"devDependencies": {
"eslint": "^6.8.0",
"eslint-config-google": "^0.14.0"
},
"lint": "eslint --fix . && echo 'Lint complete.'"
"eslint-config-google": "^0.14.0",
"jest": "^30.4.2"
}
}
+122
View File
@@ -0,0 +1,122 @@
// Unit tests for RTS Background Sync
import { jest } from "@jest/globals";
// Mock Dexie and jQuery globally before importing modules that use them
const mockAnswersWhere = jest.fn();
const mockAnswersFilter = jest.fn();
const mockAnswersToArray = jest.fn();
const mockAnswersPut = jest.fn();
const mockTransaction = jest.fn();
globalThis.Dexie = class {
constructor(name) {
this.name = name;
this.answers = {
where: mockAnswersWhere,
put: mockAnswersPut,
};
}
version() {
return {
stores: () => {}
};
}
transaction(mode, tables, cb) {
return mockTransaction(mode, tables, cb);
}
};
// Define mock jQuery $.ajax
const mockAjax = jest.fn();
globalThis.$ = {
ajax: mockAjax,
};
// Mock DOM elements
const mockHtml = jest.fn();
globalThis.document = {
getElementById: jest.fn(() => ({
set innerHTML(val) {
mockHtml(val);
}
})),
};
// Dynamic import to avoid ES module hoisting execution before mocks are set up
let performSync, updateSyncUI;
describe("RTS Sync Unit Tests", () => {
beforeAll(async () => {
const syncModule = await import("../js/sync.js");
performSync = syncModule.performSync;
updateSyncUI = syncModule.updateSyncUI;
});
beforeEach(() => {
jest.clearAllMocks();
});
test("updateSyncUI updates the DOM container", () => {
updateSyncUI("syncing");
expect(globalThis.document.getElementById).toHaveBeenCalledWith("sync-status");
expect(mockHtml).toHaveBeenCalledWith(expect.stringContaining("Saving to server..."));
});
test("performSync does nothing if exam details are missing", async () => {
await performSync(null, {});
expect(mockAnswersWhere).not.toHaveBeenCalled();
});
test("performSync processes unsynced answers and marks them as synced", async () => {
const examDetails = {
aid: "session-123",
cid: "9999",
eid: "rapid/1",
start_time: 1234567,
exam_mode: true,
};
const URLS = {
exam_submit_url: "/submit-url",
};
const unsyncedAnswers = [
{ aid: "session-123", cid: "9999", eid: "rapid/1", qid: 1, qidn: "1", ans: "Normal", synced: false }
];
mockAnswersWhere.mockReturnValue({
filter: mockAnswersFilter.mockReturnValue({
toArray: mockAnswersToArray.mockResolvedValue(unsyncedAnswers)
})
});
mockAjax.mockImplementation((options) => {
if (options.success) {
options.success({ success: true });
}
});
mockTransaction.mockImplementation(async (mode, tables, cb) => {
await cb();
});
await performSync(examDetails, URLS);
expect(mockAnswersWhere).toHaveBeenCalledWith({
aid: examDetails.aid,
cid: examDetails.cid,
eid: examDetails.eid,
});
expect(mockAjax).toHaveBeenCalledWith(
expect.objectContaining({
type: "POST",
url: URLS.exam_submit_url,
dataType: "json",
global: false,
data: expect.objectContaining({
in_progress: "true",
}),
})
);
});
});