diff --git a/app/assets/javascript/image-streaming.js b/app/assets/javascript/image-streaming.js index a26bde24..844594dc 100644 --- a/app/assets/javascript/image-streaming.js +++ b/app/assets/javascript/image-streaming.js @@ -12,12 +12,18 @@ if (configEl) { console.warn('image-streaming: could not parse config', e) } - if (config && config.enabled && !config.alreadyReceived && config.images && config.images.length) { + if ( + config && + config.enabled && + !config.alreadyReceived && + config.images && + config.images.length + ) { initStreaming(config) } } -function initStreaming (config) { +function initStreaming(config) { const { intervalMs = 3000, images = [], totalImages = images.length } = config // Find all view slots rendered by mammogram-image-display.njk @@ -26,7 +32,7 @@ function initStreaming (config) { // Build a map of view code → { el, revealedCount } const slotMap = {} - slots.forEach(slot => { + slots.forEach((slot) => { const view = slot.dataset.view if (view) { slotMap[view] = { el: slot, revealedCount: 0 } @@ -34,7 +40,7 @@ function initStreaming (config) { }) // Set all slots to awaiting state before any images arrive - slots.forEach(slot => setSlotAwaiting(slot)) + slots.forEach((slot) => setSlotAwaiting(slot)) // Counter element in the inset text const countEl = document.getElementById('streaming-received-count') @@ -68,7 +74,7 @@ function initStreaming (config) { scheduleNext() // Right arrow key immediately advances to the next image (useful for demos) - document.addEventListener('keydown', e => { + document.addEventListener('keydown', (e) => { if (e.key !== 'ArrowRight') return if (e.target.matches('input, textarea, select')) return if (timer) { @@ -82,8 +88,10 @@ function initStreaming (config) { // Replace a slot's content with a placeholder, collapsing to a single wrapper. // Uses the existing missing-image markup so styles and sizing are consistent. -function setSlotAwaiting (slot) { - const wrappers = slot.querySelectorAll('.app-mammogram-thumbnail__image-wrapper') +function setSlotAwaiting(slot) { + const wrappers = slot.querySelectorAll( + '.app-mammogram-thumbnail__image-wrapper' + ) if (!wrappers.length) return const firstWrapper = wrappers[0] @@ -95,12 +103,17 @@ function setSlotAwaiting (slot) { // Capture label text before replacing inner content const labelEl = firstWrapper.querySelector('.app-mammogram-thumbnail__label') - const labelText = labelEl ? labelEl.textContent.trim() : (slot.dataset.view || '') - - // Set an explicit width so the wrapper doesn't collapse when there's no image content. - // (width: 100% on the wrapper resolves to 0 when the flex parent has no intrinsic width) - const isLarge = firstWrapper.classList.contains('app-mammogram-thumbnail__image-wrapper--large') - firstWrapper.style.width = isLarge ? '200px' : '120px' + const labelText = labelEl + ? labelEl.textContent.trim() + : slot.dataset.view || '' + + // Give the slot a preferred width so the wrapper's width: 100% can resolve. + // Keep max-width at 100% so placeholders still shrink on smaller screens. + const isLarge = firstWrapper.classList.contains( + 'app-mammogram-thumbnail__image-wrapper--large' + ) + slot.style.width = isLarge ? '200px' : '120px' + slot.style.maxWidth = '100%' // Show a spinner on every placeholder from the start — images may load too fast // for a spinner added only at reveal time to be visible @@ -113,7 +126,7 @@ function setSlotAwaiting (slot) { // Reveal an image in the appropriate slot. // immediate=true skips the spinner delay (used for the first image, already received). -function revealImage (imageData, slotMap, immediate = false) { +function revealImage(imageData, slotMap, immediate = false) { const slotInfo = slotMap[imageData.view] if (!slotInfo) return @@ -121,7 +134,9 @@ function revealImage (imageData, slotMap, immediate = false) { if (slotInfo.revealedCount === 0) { // First image for this view — replace placeholder content in the existing wrapper - const wrapper = slot.querySelector('.app-mammogram-thumbnail__image-wrapper') + const wrapper = slot.querySelector( + '.app-mammogram-thumbnail__image-wrapper' + ) if (!wrapper) return // Re-read the label before wiping innerHTML @@ -132,11 +147,13 @@ function revealImage (imageData, slotMap, immediate = false) { // Skip the spinner for the first image (already received when we arrived on this page). const showImage = () => { wrapper.innerHTML = `${labelText}` - // Reset the explicit width we set in setSlotAwaiting - wrapper.style.width = '' + // Reset placeholder-only sizing once a real image is shown. + slot.style.width = '' + slot.style.maxWidth = '' const img = document.createElement('img') - img.className = 'app-mammogram-thumbnail__image app-mammogram-thumbnail__image--diagram' + img.className = + 'app-mammogram-thumbnail__image app-mammogram-thumbnail__image--diagram' img.src = imageData.src img.alt = `${imageData.view} view` wrapper.appendChild(img) @@ -154,11 +171,18 @@ function revealImage (imageData, slotMap, immediate = false) { } } else { // Additional image for this view (repeat or extra) — append a new wrapper - const existingWrapper = slot.querySelector('.app-mammogram-thumbnail__image-wrapper') - const isLarge = existingWrapper && existingWrapper.classList.contains('app-mammogram-thumbnail__image-wrapper--large') + const existingWrapper = slot.querySelector( + '.app-mammogram-thumbnail__image-wrapper' + ) + const isLarge = + existingWrapper && + existingWrapper.classList.contains( + 'app-mammogram-thumbnail__image-wrapper--large' + ) const newWrapper = document.createElement('div') - newWrapper.className = 'app-mammogram-thumbnail__image-wrapper' + + newWrapper.className = + 'app-mammogram-thumbnail__image-wrapper' + (isLarge ? ' app-mammogram-thumbnail__image-wrapper--large' : '') const label = document.createElement('span') @@ -166,7 +190,8 @@ function revealImage (imageData, slotMap, immediate = false) { label.textContent = slot.dataset.view const img = document.createElement('img') - img.className = 'app-mammogram-thumbnail__image app-mammogram-thumbnail__image--diagram' + img.className = + 'app-mammogram-thumbnail__image app-mammogram-thumbnail__image--diagram' img.src = imageData.src img.alt = `${imageData.view} view` diff --git a/app/assets/javascript/reading-scroll.js b/app/assets/javascript/reading-scroll.js new file mode 100644 index 00000000..e3ceda4b --- /dev/null +++ b/app/assets/javascript/reading-scroll.js @@ -0,0 +1,12 @@ +// app/assets/javascript/reading-scroll.js + +// Reading workflow: scroll the status bar into view on page load. +// Uses getBoundingClientRect for an exact pixel position rather than +// scrollIntoView, which can behave oddly near sticky/fixed elements. + +document.addEventListener('DOMContentLoaded', () => { + const statusBar = document.querySelector('.app-status-bar') + if (!statusBar) return + const top = statusBar.getBoundingClientRect().top + window.scrollY + window.scrollTo({ top, behavior: 'instant' }) +}) diff --git a/app/assets/sass/_misc.scss b/app/assets/sass/_misc.scss index 99acc7dd..bf98e5d9 100644 --- a/app/assets/sass/_misc.scss +++ b/app/assets/sass/_misc.scss @@ -80,7 +80,7 @@ body.js-enabled .app-no-js-only { // } .app-page-width--wide .nhsuk-width-container { - max-width: 1200px; + @include nhsuk-width-container(1200px); } .event-row:focus { diff --git a/app/assets/sass/components/_compact.scss b/app/assets/sass/components/_compact.scss index 8fe13695..1860fc81 100644 --- a/app/assets/sass/components/_compact.scss +++ b/app/assets/sass/components/_compact.scss @@ -209,6 +209,11 @@ } } + .nhsuk-back-link, + .nhsuk-forward-link { + @include nhsuk-responsive-margin(3, "top"); + } + // Reduce spacing after buttons .nhsuk-button { @include nhsuk-responsive-margin(4, "bottom", $adjustment: $nhsuk-button-shadow-size); diff --git a/app/assets/sass/components/_reading.scss b/app/assets/sass/components/_reading.scss index 95908317..c8d1a364 100644 --- a/app/assets/sass/components/_reading.scss +++ b/app/assets/sass/components/_reading.scss @@ -390,3 +390,5 @@ padding-top: nhsuk-spacing(4); } } + + diff --git a/app/routes/reading.js b/app/routes/reading.js index 3631637b..ef78258b 100644 --- a/app/routes/reading.js +++ b/app/routes/reading.js @@ -26,7 +26,7 @@ const { const { getShortName } = require('../lib/utils/participants') const { userRequestedPriors } = require('../lib/utils/prior-mammograms') const { camelCase, snakeCase } = require('../lib/utils/strings') -const { modalBreakout } = require('../lib/utils/referrers') +const { modalBreakout, getReturnUrl } = require('../lib/utils/referrers') const dayjs = require('dayjs') const generateId = require('../lib/utils/id-generator') @@ -632,6 +632,32 @@ module.exports = (router) => { } } + // If submitted from an existing-read page (e.g. editing reason), return there + const priorsReferrerChain = req.query.referrerChain + if (priorsReferrerChain) { + // In edit mode, also update reason on mammograms already pending/requested by this user + if (event && event.previousMammograms && reason) { + event.previousMammograms.forEach((mammogram) => { + if ( + (mammogram.requestStatus === 'pending' || + mammogram.requestStatus === 'requested') && + mammogram.requestedBy === currentUserId + ) { + mammogram.requestReason = reason + } + }) + if (data.event && data.event.id === eventId) { + data.event.previousMammograms = event.previousMammograms + } + } + const returnUrl = getReturnUrl( + `/reading/session/${sessionId}/events/${eventId}/existing-read`, + priorsReferrerChain + ) + res.redirect(modalBreakout(returnUrl)) + return + } + // Top up the batch with the next eligible event if under target size topUpSession(data, sessionId) @@ -747,6 +773,17 @@ module.exports = (router) => { } } + // If submitted from an existing-read page (e.g. editing reason), return there + const referrerChain = req.query.referrerChain + if (referrerChain) { + const returnUrl = getReturnUrl( + `/reading/session/${sessionId}/events/${eventId}/existing-read`, + referrerChain + ) + res.redirect(modalBreakout(returnUrl)) + return + } + // Top up the session with the next eligible event if under target size topUpSession(data, sessionId) @@ -1389,8 +1426,11 @@ module.exports = (router) => { // form data (all views) at opinion-details-complete, overwriting the clean data. // save-opinion reads from session (imageReadingTemp), not the POST body, so // it works correctly when reached via GET through the skip-confirmation path. + // Pass referrer chain through so save-opinion can return to the origin page + const referrerChain = req.query.referrerChain + const chainParam = referrerChain ? `?referrerChain=${encodeURIComponent(referrerChain)}` : '' res.redirect( - `/reading/session/${sessionId}/events/${eventId}/opinion-details-complete` + `/reading/session/${sessionId}/events/${eventId}/opinion-details-complete${chainParam}` ) } ) @@ -1559,26 +1599,32 @@ module.exports = (router) => { 307, `/reading/session/${sessionId}/events/${eventId}/save-opinion` ) - case 'technical_recall': + case 'technical_recall': { + const trReferrer = req.query.referrerChain + const trChainParam = trReferrer ? `?referrerChain=${encodeURIComponent(trReferrer)}` : '' if (data.settings?.reading?.confirmTechnicalRecall !== 'false') { return res.redirect( - `/reading/session/${sessionId}/events/${eventId}/review` + `/reading/session/${sessionId}/events/${eventId}/review${trChainParam}` ) } return res.redirect( 307, - `/reading/session/${sessionId}/events/${eventId}/save-opinion` + `/reading/session/${sessionId}/events/${eventId}/save-opinion${trChainParam}` ) - case 'recall_for_assessment': + } + case 'recall_for_assessment': { + const rfaReferrer = req.query.referrerChain + const rfaChainParam = rfaReferrer ? `?referrerChain=${encodeURIComponent(rfaReferrer)}` : '' if (data.settings?.reading?.confirmRecallForAssessment !== 'false') { return res.redirect( - `/reading/session/${sessionId}/events/${eventId}/review` + `/reading/session/${sessionId}/events/${eventId}/review${rfaChainParam}` ) } return res.redirect( 307, - `/reading/session/${sessionId}/events/${eventId}/save-opinion` + `/reading/session/${sessionId}/events/${eventId}/save-opinion${rfaChainParam}` ) + } default: return res.redirect( `/reading/session/${sessionId}/events/${eventId}/review` @@ -1662,6 +1708,17 @@ module.exports = (router) => { } } + // If submitted from an existing-read or review page (e.g. editing technical recall), return there + const saveReferrerChain = req.query.referrerChain + if (saveReferrerChain) { + const returnUrl = getReturnUrl( + `/reading/session/${sessionId}/events/${eventId}/existing-read`, + saveReferrerChain + ) + res.redirect(modalBreakout(returnUrl)) + return + } + // Redirect to next unread event or end-of-session page if (nextUnreadEvent) { res.redirect( diff --git a/app/views/_includes/reading/image-warnings.njk b/app/views/_includes/reading/image-warnings.njk index c0d3b32b..0e528810 100644 --- a/app/views/_includes/reading/image-warnings.njk +++ b/app/views/_includes/reading/image-warnings.njk @@ -27,7 +27,7 @@ {% endset %} {% set warningsHtml %} {{ warningsHtml | safe }} -
{{ additionalImagesDetail | trim }}
{% endset %} {% endif %} @@ -73,7 +73,7 @@ {% set warningsHtml %} {{ warningsHtml | safe }} -{{ event.mammogramData.notesForReader }}
{% endset %} {% endif %} diff --git a/app/views/_includes/summary-lists/read-summary.njk b/app/views/_includes/summary-lists/read-summary.njk index 310b06ad..e4c06c5c 100644 --- a/app/views/_includes/summary-lists/read-summary.njk +++ b/app/views/_includes/summary-lists/read-summary.njk @@ -165,7 +165,7 @@ actions: { items: [ { - href: "./technical-recall", + href: "./technical-recall" | urlWithReferrer(currentUrl), text: "Change", visuallyHiddenText: "views" } | openInModal @@ -219,7 +219,7 @@ actions: { items: [ { - href: "./recall-for-assessment-details", + href: "./recall-for-assessment-details" | urlWithReferrer(currentUrl), text: "Change", visuallyHiddenText: "detailed opinion" } @@ -240,7 +240,7 @@ actions: { items: [ { - href: "./recall-for-assessment-details", + href: "./recall-for-assessment-details" | urlWithReferrer(currentUrl), text: "Change", visuallyHiddenText: "annotations" } diff --git a/app/views/_templates/layout-reading.html b/app/views/_templates/layout-reading.html index d73144be..a0d5a2cf 100755 --- a/app/views/_templates/layout-reading.html +++ b/app/views/_templates/layout-reading.html @@ -8,6 +8,11 @@ {# Script to show loading state in PACS viewer when navigating away, and inspect panel (press I) #} {% block bodyEnd %} {{ super() }} + + {# Fullscreen toggle and auto-scroll to status bar on page load #} + {% if isReadingWorkflow %} + + {% endif %} {% if isReadingWorkflow and participant %}