feat(viewer): Enhance viewer functionality with sidebar and popout support

This commit is contained in:
Ross
2026-05-15 20:11:31 +01:00
parent 0cf655325c
commit c947cba5a7
2 changed files with 435 additions and 54 deletions
@@ -101,39 +101,59 @@
</div>
{% endif %}
{% if collection.show_ohif_viewer %}
<div>
{% if question_completed %}
<iframe id="viewer" class="viewer-frame-ohif" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json_review' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_review_legacy' collection.pk case.pk %}{% endif %}"></iframe>
{% else %}
<iframe id="viewer" class="viewer-frame-ohif" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_legacy' collection.pk case.pk %}{% endif %}"></iframe>
{% endif %}
{% if collection.show_ohif_viewer or collection.show_ohif_viewer_link or collection.show_built_in_viewer %}
<div id="viewer-panel" class="mb-3">
{% if collection.show_built_in_viewer %}
<div class="d-flex align-items-center gap-2 py-1 px-2 mb-1 rounded bg-body-secondary">
<span class="text-muted fw-semibold small me-auto">Viewer</span>
<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-2" id="viewer-sidebar-btn" title="Move viewer to sidebar">
<i class="bi bi-layout-sidebar-reverse"></i> <span class="d-none d-sm-inline">Sidebar</span>
</button>
<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-2" id="viewer-popout-btn" title="Open viewer in a separate window">
<i class="bi bi-box-arrow-up-right"></i> <span class="d-none d-sm-inline">Pop&nbsp;out</span>
</button>
</div>
<div id="viewer-moveable-home">
<div id="viewer-moveable">
<div id="viewer-container" class="viewer-resizable-container">
<details id="dicom-viewer-details" open>
<summary class="py-1 small text-muted">Series</summary>
<div id="main_viewer" class="dicom-viewer-root viewer-frame-standard"
data-auto-cache-stack="false"
data-named-stacks='{{casedetail.get_case_named_stacks}}'
></div>
</details>
</div>
<div id="viewer-resize-handle" class="viewer-resize-handle" title="Drag to resize viewer">
<div class="viewer-resize-grip-inner"></div>
</div>
</div>
</div>
{% endif %}
{% if collection.show_ohif_viewer %}
<div{% if collection.show_built_in_viewer %} class="mt-3"{% endif %}>
{% if question_completed %}
<iframe id="viewer" class="viewer-frame-ohif" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json_review' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_review_legacy' collection.pk case.pk %}{% endif %}"></iframe>
{% else %}
<iframe id="viewer" class="viewer-frame-ohif" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_legacy' collection.pk case.pk %}{% endif %}"></iframe>
{% endif %}
</div>
{% endif %}
{% if collection.show_ohif_viewer_link %}
<div class="mt-2">
{% if question_completed %}
<a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json_review' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_review_legacy' collection.pk case.pk %}{% endif %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a>
{% else %}
<a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_legacy' collection.pk case.pk %}{% endif %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a>
{% endif %}
</div>
{% endif %}
</div>
{% endif %}
{% if collection.show_ohif_viewer_link %}
<div>
{% if question_completed %}
<a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json_review' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_review_legacy' collection.pk case.pk %}{% endif %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a>
{% else %}
<a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_legacy' collection.pk case.pk %}{% endif %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a>
{% endif %}
</div>
{% endif %}
{% if collection.show_built_in_viewer %}
<details id="dicom-viewer-details" open>
<summary>Viewer</summary>
<div id="main_viewer" class="dicom-viewer-root viewer-frame-standard"
data-auto-cache-stack="false"
data-named-stacks='{{casedetail.get_case_named_stacks}}'
></div>
</details>
{% comment %} <div class="pre-whitespace multi-image-block">
<details open>
<summary>
@@ -165,8 +185,6 @@
</div>
</div> {% endcomment %}
{% else %}
{% endif %}
{% if show_discussion and case.discussion %}
<details class="card mb-3">
@@ -335,6 +353,14 @@
</div>
</aside>
<aside id="viewerSidebar" class="viewer-sidebar" aria-label="Viewer sidebar">
<div class="viewer-sidebar-header">
<span class="fw-semibold">Viewer</span>
<button type="button" class="btn btn-sm btn-outline-secondary" id="viewer-sidebar-close-btn">Close</button>
</div>
<div id="viewerSidebarBody" class="viewer-sidebar-body"></div>
</aside>
<style>
label {
vertical-align: top;
@@ -443,6 +469,116 @@
overflow: auto;
flex: 1 1 auto;
}
.viewer-resizable-container {
position: relative;
overflow: hidden;
height: min(70vh, 700px);
min-height: 220px;
max-height: 85vh;
resize: vertical;
}
.viewer-resizable-container .dicom-viewer-root {
width: 100% !important;
height: 100% !important;
min-height: 0;
}
.viewer-resizable-container #dicom-viewer-details {
height: 100%;
display: flex;
flex-direction: column;
}
.viewer-resizable-container #dicom-viewer-details > summary {
flex: 0 0 auto;
}
.viewer-resizable-container #dicom-viewer-details > div {
flex: 1 1 auto;
min-height: 0;
}
.viewer-resize-handle {
height: 10px;
background: var(--bs-secondary-bg);
border: 1px solid var(--bs-border-color);
border-top: none;
border-radius: 0 0 4px 4px;
cursor: ns-resize;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
touch-action: none;
display: none;
}
.viewer-resize-handle:hover {
background: var(--bs-tertiary-bg);
}
.viewer-resize-grip-inner {
width: 40px;
height: 3px;
background: var(--bs-secondary-color);
border-radius: 2px;
opacity: 0.4;
}
.viewer-sidebar {
position: fixed;
top: 0;
right: 0;
width: min(850px, 95vw);
height: 100vh;
background: var(--bs-body-bg);
border-left: 1px solid var(--bs-border-color);
box-shadow: -8px 0 18px rgba(0, 0, 0, 0.35);
z-index: 1045;
transform: translateX(100%);
transition: transform 0.2s ease-in-out;
display: flex;
flex-direction: column;
}
.viewer-sidebar.open {
transform: translateX(0);
}
.viewer-sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--bs-border-color);
background: var(--bs-secondary-bg);
flex-shrink: 0;
}
.viewer-sidebar-body {
flex: 1 1 auto;
overflow: hidden;
display: flex;
flex-direction: column;
}
.viewer-sidebar-body #viewer-moveable {
flex: 1 1 auto;
display: flex;
flex-direction: column;
overflow: hidden;
}
.viewer-sidebar-body #viewer-container {
flex: 1 1 auto;
height: auto !important;
}
.viewer-sidebar-body .viewer-resize-handle {
display: none;
}
</style>
{% endblock %}
@@ -823,4 +959,191 @@
</script>
<script type="text/javascript">
(function () {
function initViewerControls() {
var COLL_PK = '{{ collection.pk }}';
var HEIGHT_KEY = 'rad_viewer_h_' + COLL_PK;
var MODE_KEY = 'rad_viewer_mode_' + COLL_PK;
var POPOUT_STATE_KEY = 'rad_local_viewer_popout_state';
var panel = document.getElementById('viewer-panel');
var moveableHome = document.getElementById('viewer-moveable-home');
var moveable = document.getElementById('viewer-moveable');
var container = document.getElementById('viewer-container');
var resizeHandle = document.getElementById('viewer-resize-handle');
var sidebarEl = document.getElementById('viewerSidebar');
var sidebarBody = document.getElementById('viewerSidebarBody');
var sidebarBtn = document.getElementById('viewer-sidebar-btn');
var sidebarCloseBtn = document.getElementById('viewer-sidebar-close-btn');
var popoutBtn = document.getElementById('viewer-popout-btn');
var mainViewerEl = document.getElementById('main_viewer');
if (!panel || !moveableHome || !moveable || !container || !mainViewerEl) {
return;
}
if (panel.dataset.viewerControlsBound === '1') {
return;
}
panel.dataset.viewerControlsBound = '1';
var savedH = localStorage.getItem(HEIGHT_KEY);
if (savedH && !isNaN(parseInt(savedH, 10))) {
container.style.height = parseInt(savedH, 10) + 'px';
}
var persistHeight = function () {
localStorage.setItem(HEIGHT_KEY, Math.round(container.getBoundingClientRect().height));
};
if ('ResizeObserver' in window) {
var ro = new ResizeObserver(function () {
persistHeight();
});
ro.observe(container);
} else {
window.addEventListener('mouseup', persistHeight);
}
if (resizeHandle) {
resizeHandle.addEventListener('pointerdown', function (e) {
e.preventDefault();
});
}
function openViewerSidebar() {
if (!sidebarEl || !sidebarBody) return;
sidebarBody.appendChild(moveable);
sidebarEl.classList.add('open');
if (sidebarBtn) {
sidebarBtn.innerHTML = '<i class="bi bi-x-lg"></i> <span class="d-none d-sm-inline">Close sidebar</span>';
sidebarBtn.classList.replace('btn-outline-secondary', 'btn-secondary');
}
localStorage.setItem(MODE_KEY, 'sidebar');
}
function closeViewerSidebar() {
if (!sidebarEl) return;
moveableHome.appendChild(moveable);
sidebarEl.classList.remove('open');
if (sidebarBtn) {
sidebarBtn.innerHTML = '<i class="bi bi-layout-sidebar-reverse"></i> <span class="d-none d-sm-inline">Sidebar</span>';
sidebarBtn.classList.replace('btn-secondary', 'btn-outline-secondary');
}
localStorage.removeItem(MODE_KEY);
}
if (sidebarBtn) {
sidebarBtn.addEventListener('click', function () {
if (sidebarEl && sidebarEl.classList.contains('open')) {
closeViewerSidebar();
} else {
openViewerSidebar();
}
});
}
if (sidebarCloseBtn) {
sidebarCloseBtn.addEventListener('click', closeViewerSidebar);
}
var bc = null;
try { bc = new BroadcastChannel('viva_load'); } catch (e) {}
function getViewerPayload() {
var namedStacks = mainViewerEl.getAttribute('data-named-stacks');
var images = mainViewerEl.getAttribute('data-images');
var viewerState = window.exportViewerState_main_viewer ? window.exportViewerState_main_viewer() : '';
var annotations = window.exportAnnotations_main_viewer ? window.exportAnnotations_main_viewer() : '';
return {
type: 'open',
namedStacks: namedStacks || '',
images: images || '',
viewerstate: viewerState || '',
annotations: annotations || ''
};
}
function broadcastViewerState(payload) {
if (!payload) return;
localStorage.setItem(POPOUT_STATE_KEY, JSON.stringify(payload));
if (bc) {
try { bc.postMessage(payload); } catch (e) {}
}
}
function showPopoutPlaceholder() {
var existing = panel.querySelector('.viewer-popout-placeholder');
if (existing) return;
var placeholder = document.createElement('div');
placeholder.className = 'alert alert-secondary small py-2 mb-0 mt-1 d-flex align-items-center gap-2 viewer-popout-placeholder';
placeholder.innerHTML = '<i class="bi bi-box-arrow-up-right"></i> Viewer is open in a separate window. <button type="button" class="btn btn-sm btn-outline-secondary ms-auto" id="viewer-popout-bring-back">Bring back</button>';
panel.insertBefore(placeholder, moveableHome);
moveableHome.style.display = 'none';
document.getElementById('viewer-popout-bring-back').addEventListener('click', closePopout);
}
function hidePopoutPlaceholder() {
var placeholder = panel.querySelector('.viewer-popout-placeholder');
if (placeholder) placeholder.remove();
moveableHome.style.display = '';
}
function openPopout() {
if (sidebarEl && sidebarEl.classList.contains('open')) {
closeViewerSidebar();
}
var payload = getViewerPayload();
broadcastViewerState(payload);
window.open('{% url "atlas:collection_viewer_local" %}', 'collectionTakeViewer', 'width=1200,height=900,noopener');
setTimeout(function () { broadcastViewerState(payload); }, 700);
localStorage.setItem(MODE_KEY, 'popout');
showPopoutPlaceholder();
if (popoutBtn) {
popoutBtn.innerHTML = '<i class="bi bi-x-lg"></i> <span class="d-none d-sm-inline">Bring back</span>';
popoutBtn.classList.replace('btn-outline-secondary', 'btn-secondary');
}
}
function closePopout() {
hidePopoutPlaceholder();
localStorage.removeItem(MODE_KEY);
if (popoutBtn) {
popoutBtn.innerHTML = '<i class="bi bi-box-arrow-up-right"></i> <span class="d-none d-sm-inline">Pop&nbsp;out</span>';
popoutBtn.classList.replace('btn-secondary', 'btn-outline-secondary');
}
}
if (popoutBtn) {
popoutBtn.addEventListener('click', function () {
if (localStorage.getItem(MODE_KEY) === 'popout') {
closePopout();
} else {
openPopout();
}
});
}
var savedMode = localStorage.getItem(MODE_KEY);
if (savedMode === 'sidebar') {
openViewerSidebar();
} else if (savedMode === 'popout' && popoutBtn) {
showPopoutPlaceholder();
setTimeout(function () { broadcastViewerState(getViewerPayload()); }, 300);
popoutBtn.innerHTML = '<i class="bi bi-x-lg"></i> <span class="d-none d-sm-inline">Bring back</span>';
popoutBtn.classList.replace('btn-outline-secondary', 'btn-secondary');
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initViewerControls);
} else {
initViewerControls();
}
})();
</script>
{% endblock js %}
+81 -23
View File
@@ -86,6 +86,7 @@
document.title = "Case: {{current_case.title}}";
console.log('ready');
const bc = new BroadcastChannel("viva_load");
const POPOUT_STATE_KEY = 'rad_local_viewer_popout_state';
// Centralized hide helper and delegated close handling for local viewer
function hideResourceLocal(){ const disp=document.getElementById('resourceDisplay'); const inner=document.getElementById('resourceInner'); if(disp) disp.style.display='none'; if(inner) inner.innerHTML=''; }
@@ -114,6 +115,83 @@
} catch (err) { console.warn('Viewer initialization deferred/fallback:', err); }
}
function applyViewerPayload(payload) {
if (!payload || payload.type !== 'open') {
return;
}
var root = document.getElementById('root');
var awaiting = document.getElementById('awaiting-modal');
if (!root) {
return;
}
if (awaiting) {
awaiting.style.display = 'none';
}
if (payload.namedStacks) {
root.dataset.namedStacks = payload.namedStacks;
} else {
delete root.dataset.namedStacks;
}
if (payload.images) {
root.dataset.images = payload.images;
} else {
delete root.dataset.images;
}
if (payload.viewerstate && Object.keys(payload.viewerstate).length > 0) {
root.dataset.viewerstate = payload.viewerstate;
} else {
delete root.dataset.viewerstate;
}
if (payload.annotations && Object.keys(payload.annotations).length > 0) {
root.dataset.annotationjson = payload.annotations;
} else {
delete root.dataset.annotationjson;
}
mountDicomViewersSafely();
}
function persistViewerPayload(payload) {
try {
localStorage.setItem(POPOUT_STATE_KEY, JSON.stringify(payload));
} catch (e) {
console.warn('Could not persist viewer payload', e);
}
}
function readPersistedViewerPayload() {
try {
var saved = localStorage.getItem(POPOUT_STATE_KEY);
return saved ? JSON.parse(saved) : null;
} catch (e) {
console.warn('Could not read persisted viewer payload', e);
return null;
}
}
var savedPayload = readPersistedViewerPayload();
if (savedPayload) {
applyViewerPayload(savedPayload);
}
window.addEventListener('storage', function (event) {
if (event.key !== POPOUT_STATE_KEY || !event.newValue) {
return;
}
try {
applyViewerPayload(JSON.parse(event.newValue));
} catch (e) {
console.warn('Could not parse updated viewer payload', e);
}
});
bc.onmessage = (event) => {
// Ensure any existing overlay is closed on new load
try { hideResourceLocal(); } catch(_) {}
@@ -179,33 +257,13 @@
if (event.data.url && isMediaUrl(event.data.url)) {
// show resource overlay and do not attempt to mount DICOM viewer
if (showResource(event.data.url)) {
persistViewerPayload(event.data);
bc.postMessage({"type": "open-complete-local"});
return;
}
}
// Hide modal when data is loaded
document.getElementById("awaiting-modal").style.display = "none";
document.getElementById("root").dataset.images = event.data.images;
if (
event.data.viewerstate &&
Object.keys(event.data.viewerstate).length > 0
) {
document.getElementById("root").dataset.viewerstate = event.data.viewerstate;
} else {
delete document.getElementById("root").dataset.viewerstate;
}
console.log("Annotations:", event.data.annotations);
if (
event.data.annotations &&
Object.keys(event.data.annotations).length > 0
) {
document.getElementById("root").dataset.annotationjson = event.data.annotations;
} else {
delete document.getElementById("root").dataset.annotationjson;
}
mountDicomViewersSafely();
persistViewerPayload(event.data);
applyViewerPayload(event.data);
bc.postMessage({"type": "open-complete-local"});
}
};