feat: Add extensibility for tmtv and segmentation modes (#6128)

* feat: Add extensibility for tmtv and segmentation modes

* Fixes for ordering issues on laod

* Remove unnecessary reference lookup

* Chane side panel timing to fix tests

* PR comments - change how mode definitions get created

* Improvements to mode customizations

* Start organizing customizations

* Misc fixes for a customization demo page

* Security fixes

* PR requested changes to naming
This commit is contained in:
Bill Wallace authored and GitHub committed 2026-07-10 12:43:17 -04:00
1 parent f79055f98e
commit b266c0a86a
49 files changed
+2653 -1215

No files matched your search

+3 -3
View File
@@ -28,9 +28,9 @@ window.config = {
// - mode: applied (Mode) on each mode enter — `*` (general) first,
// then a block keyed by the mode id / routeName.
customizationService: {
// Pulls in platform/app/public/customizations/patientBirthDate.jsonc, which
// adds a "Birth Date" column to the WorkList study list (global phase).
requires: ['patientBirthDate'],
// Pulls in platform/app/public/customizations/worklist/patientBirthDate.jsonc,
// which adds a "Birth Date" column to the WorkList study list (global phase).
requires: ['worklist/patientBirthDate'],
// The previous (legacy-array) customizations, now in the explicit `global`
// phase. A `global` block accepts the same input as setCustomizations: an
+1 -1
View File
@@ -33,7 +33,7 @@ window.config = {
// are never executed. Example (left disabled here on purpose):
//
// customizationUrlPrefixes: {
// default: './customizations/', // ?customization=ctPresets
// default: './customizations/', // ?customization=tools/ctPresets
// '/remote/': 'https://cdn.example.com/ohif-custom/', // ?customization=/remote/siteA
// },
// ----------------------------------------------------------------------------
+9 -6
View File
@@ -3,23 +3,26 @@
// Local development configuration.
//
// This is the default config for the dev server (`pnpm run dev`, `dev:fast`,
// `start`). Like config/netlify.js it is full-featured — every data source is
// enabled and the `?customization=` URL feature is ON via
// `customizationUrlPrefixes` — so the whole app can be exercised locally. The
// locked-down config/default.js is what a plain production build emits instead.
// `start`). It is intentionally kept at parity with config/netlify.js (the
// public demo deploy): every data source is enabled, the `?customization=` URL
// feature is ON via `customizationUrlPrefixes`, and the same startup
// `customizationService` modules are loaded — so the whole app, including
// customizations, can be exercised locally exactly as it runs on the demo.
// The locked-down config/default.js is what a plain production build emits
// instead.
window.config = {
name: 'config/dev.js',
routerBasename: null,
// whiteLabeling: {},
extensions: [],
modes: [],
customizationService: {},
customizationService: ['@ohif/extension-default.customizationModule.theme'],
// URL-driven customizations (?customization=). The `default` prefix (no
// slashes) is used for values without a leading slash; every other prefix
// must start AND end with a slash and matches the leading `/segment/` of the
// value. Files are fetched and parsed as JSONC data — never executed.
// e.g. ?customization=ctPresets -> ./customizations/ctPresets.jsonc
// e.g. ?customization=tools/ctPresets -> ./customizations/tools/ctPresets.jsonc
customizationUrlPrefixes: {
default: './customizations/',
},
+1 -1
View File
@@ -21,7 +21,7 @@ window.config = {
extensions: [],
modes: ['@ohif/mode-test'],
// Allow the `default` prefix so e2e specs can exercise `?customization=` (e.g.
// Customization.spec.ts loads `?customization=veterinaryOverlay`).
// Customization.spec.ts loads `?customization=veterinary/veterinaryOverlay`).
customizationUrlPrefixes: {
default: './customizations/',
},
+1 -1
View File
@@ -17,7 +17,7 @@ window.config = {
// slashes) is used for values without a leading slash; every other prefix
// must start AND end with a slash and matches the leading `/segment/` of the
// value. Files are fetched and parsed as JSONC data — never executed.
// e.g. ?customization=ctPresets -> ./customizations/ctPresets.jsonc
// e.g. ?customization=tools/ctPresets -> ./customizations/tools/ctPresets.jsonc
customizationUrlPrefixes: {
default: './customizations/',
},
@@ -0,0 +1,400 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OHIF URL Customizations</title>
<style>
:root {
color-scheme: light dark;
--bg: #f5f6f8;
--card: #ffffff;
--card-border: #e3e6ea;
--text: #1a1f27;
--muted: #5c6774;
--accent: #2563eb;
--accent-contrast: #ffffff;
--code-bg: #eef1f5;
--tag-bg: #e6edff;
--tag-text: #2952cc;
--bar-bg: #ffffff;
}
/* This catalog page follows the OS light/dark preference automatically. */
@media (prefers-color-scheme: dark) {
:root {
--bg: #0e1116;
--card: #171b22;
--card-border: #2a3038;
--text: #e6e9ee;
--muted: #99a3b0;
--accent: #4f8cff;
--accent-contrast: #0e1116;
--code-bg: #0b0e13;
--tag-bg: #1c2740;
--tag-text: #8fb2ff;
--bar-bg: #12161c;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
padding-bottom: 160px;
}
.wrap { max-width: 900px; margin: 0 auto; padding: 32px 20px 20px; }
.topbar { display: flex; align-items: flex-start; gap: 16px; }
.topbar .titles { flex: 1; min-width: 0; }
.theme-picker { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.theme-picker label { font-size: 0.8rem; color: var(--muted); }
.theme-picker select {
background: var(--card); border: 1px solid var(--card-border); border-radius: 8px;
padding: 7px 10px; color: var(--text); font-size: 0.85rem; cursor: pointer;
}
header h1 { font-size: 1.7rem; margin: 0 0 6px; }
header p { color: var(--muted); margin: 0 0 8px; max-width: 62ch; }
code {
background: var(--code-bg);
border-radius: 4px;
padding: 1px 6px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.85em;
}
.group { margin-top: 28px; }
.group-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 4px; }
.group-head h2 { font-size: 1.15rem; margin: 0; }
.group-head .dir { color: var(--muted); font-size: 0.85rem; }
.group-head .toggle {
margin-left: auto; background: none; border: none; color: var(--accent);
cursor: pointer; font-size: 0.82rem; padding: 2px 4px;
}
.group-desc { color: var(--muted); font-size: 0.9rem; margin: 0 0 12px; }
.card {
display: flex; gap: 12px; align-items: flex-start;
background: var(--card); border: 1px solid var(--card-border);
border-radius: 10px; padding: 14px 16px; margin-bottom: 10px;
cursor: pointer; transition: border-color .12s ease;
}
.card:hover { border-color: var(--accent); }
.card input { margin-top: 3px; width: 17px; height: 17px; accent-color: var(--accent); cursor: pointer; }
.card .body { flex: 1; min-width: 0; }
.card .title-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.card .name { font-weight: 600; }
.card .param { color: var(--muted); font-size: 0.8rem; }
.card .desc { color: var(--muted); font-size: 0.9rem; margin: 4px 0 0; }
.tag {
font-size: 0.7rem; font-weight: 600; letter-spacing: .02em;
background: var(--tag-bg); color: var(--tag-text);
padding: 2px 7px; border-radius: 999px; text-transform: uppercase;
}
.launch {
position: fixed; bottom: 0; left: 0; right: 0;
background: var(--bar-bg); border-top: 1px solid var(--card-border);
box-shadow: 0 -4px 16px rgba(0,0,0,0.08);
}
.launch-inner { max-width: 900px; margin: 0 auto; padding: 14px 20px; }
.launch-row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.launch-label { font-size: 0.8rem; color: var(--muted); }
.url-box {
flex: 1; min-width: 240px;
background: var(--code-bg); border: 1px solid var(--card-border); border-radius: 8px;
padding: 9px 12px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.82rem; color: var(--text); overflow-x: auto; white-space: nowrap;
}
.base-input {
background: var(--card); border: 1px solid var(--card-border); border-radius: 8px;
padding: 8px 10px; color: var(--text); font-size: 0.85rem; width: 130px;
}
button.btn {
border: none; border-radius: 8px; padding: 9px 16px; font-size: 0.88rem; font-weight: 600;
cursor: pointer;
}
.btn-primary { background: var(--accent); color: var(--accent-contrast); }
.btn-primary[aria-disabled='true'] { opacity: .45; pointer-events: none; }
.btn-ghost { background: transparent; color: var(--accent); border: 1px solid var(--card-border); }
.count { font-size: 0.8rem; color: var(--muted); }
a { color: var(--accent); }
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="topbar">
<div class="titles">
<h1>URL Customizations</h1>
</div>
<div class="theme-picker">
<label for="viewer-theme">Viewer theme</label>
<select id="viewer-theme" title="OHIF viewer theme — added as ?theme= to the launch URL">
<optgroup label="Tonal (surfaces carry the theme hue)">
<option value="default">OHIF Blue (default)</option>
<option value="orchid">Orchid</option>
<option value="arctic">Arctic</option>
<option value="verdant">Verdant</option>
</optgroup>
<optgroup label="Neutral (desaturated + one accent)">
<option value="midnight">Midnight</option>
<option value="slate">Slate</option>
<option value="deep">Deep</option>
</optgroup>
</select>
</div>
</div>
<p>
These are the example <code>?customization=</code> profiles shipped under
<code>platform/app/public/customizations/</code>, grouped by area. Each file is fetched as
JSONC <strong>data</strong> (never executed) and applied at startup. Tick the ones you want,
optionally pick a viewer theme (added as <code>?theme=</code>), then launch the viewer or copy
the generated query string onto any viewer URL. Multiple profiles are comma-separated and
applied in order.
</p>
<p style="font-size: 0.85rem">
See the
<a href="https://docs.ohif.org/platform/services/customization-service/specificCustomizations">
customization docs</a>
for how to author your own. The prefix allowlist is set by
<code>customizationUrlPrefixes</code> in the app config.
</p>
</header>
<main id="groups"></main>
</div>
<div class="launch">
<div class="launch-inner">
<div class="launch-row" style="margin-bottom: 10px">
<span class="launch-label">Viewer base</span>
<input
id="base"
class="base-input"
value="../"
title="Base viewer URL the customization query is appended to (relative to this page)"
/>
<span class="count" id="count">0 selected</span>
<button class="btn btn-ghost" id="clear" type="button">Clear all</button>
<button class="btn btn-ghost" id="copy" type="button">Copy URL</button>
<a class="btn btn-primary" id="launch" href="#" aria-disabled="true">Launch viewer &rarr;</a>
</div>
<div class="launch-row">
<div class="url-box" id="url">Select one or more profiles above&hellip;</div>
</div>
</div>
</div>
<script>
// Catalog of shipped URL customizations, grouped by area/folder. The
// `value` is the `?customization=` token (folder-relative path, no `.jsonc`).
const CATALOG = [
{
dir: 'tools/',
title: 'Tools & presets',
desc: 'General viewport tools and presets. These apply globally (every mode).',
items: [
{
value: 'tools/ctPresets',
name: 'CT window/level presets',
scope: 'global',
desc: 'Replaces the CT presets in the window-level menu with a site-specific set (soft tissue, lung, angio, bone). Other modalities keep their defaults.',
},
{
value: 'tools/smoothRotate',
name: 'Smooth Rotate button',
scope: 'global',
desc: 'Adds a "Smooth Rotate" button to the More Tools menu that activates the PlanarRotate tool — drag to rotate the image freely (unlike the fixed 90° Rotate Right).',
},
{
value: 'tools/measurementLabels',
name: 'Predefined measurement labels',
scope: 'global',
desc: 'Prompts for a label from a fixed, exclusive list (Head, Shoulder, Knee, Toe) whenever a measurement is created.',
},
],
},
{
dir: 'worklist/',
title: 'Study list',
desc: 'Customizations for the WorkList / study browser.',
items: [
{
value: 'worklist/patientBirthDate',
name: 'Birth Date column',
scope: 'global',
desc: 'Adds a "Birth Date" column to the study list, immediately after the Patient column. Shipped configs load this by default via customizationService.requires.',
},
],
},
{
dir: 'segmentation/',
title: 'Segmentation',
desc: 'Per-mode composition of the segmentation capability packs.',
items: [
{
value: 'segmentation/segmentationEditing',
name: 'Segmentation editing',
scope: 'basic / longitudinal',
desc: 'Adds the segmentation editing toolbar, tools, and panels to the basic and longitudinal (viewer) modes, and enables editing in the segmentation panel.',
},
{
value: 'segmentation/segmentationAnnotationTools',
name: 'Annotation tools in segmentation mode',
scope: 'segmentation',
desc: 'Enables the measurement/annotation tools (Length, Bidirectional, ROIs, freehand, spline, livewire) inside the segmentation mode and adds the measurement panel.',
},
],
},
{
dir: 'veterinary/',
title: 'Veterinary',
desc: 'Veterinary-oriented overlay and a chaining profile that pulls it in.',
items: [
{
value: 'veterinary/veterinary',
name: 'Veterinary profile',
scope: 'global',
desc: 'Chaining profile — declares requires: ["veterinary/veterinaryOverlay"], so selecting this one alone pulls in and applies the overlay first.',
},
{
value: 'veterinary/veterinaryOverlay',
name: 'Veterinary viewport overlay',
scope: 'global',
desc: 'Overrides the viewport overlay with a veterinary-style demographics layout (patient/ID/date on the left, species/breed on the right).',
},
],
},
];
const groupsEl = document.getElementById('groups');
const urlEl = document.getElementById('url');
const launchEl = document.getElementById('launch');
const countEl = document.getElementById('count');
const baseEl = document.getElementById('base');
const viewerThemeEl = document.getElementById('viewer-theme');
CATALOG.forEach((group, gi) => {
const section = document.createElement('section');
section.className = 'group';
section.innerHTML =
'<div class="group-head">' +
'<h2>' + group.title + '</h2>' +
'<span class="dir"><code>' + group.dir + '</code></span>' +
'<button class="toggle" type="button" data-group="' + gi + '">Select all</button>' +
'</div>' +
'<p class="group-desc">' + group.desc + '</p>';
group.items.forEach(item => {
const label = document.createElement('label');
label.className = 'card';
label.innerHTML =
'<input type="checkbox" value="' + item.value + '" />' +
'<span class="body">' +
'<span class="title-row">' +
'<span class="name">' + item.name + '</span>' +
'<span class="tag">' + item.scope + '</span>' +
'<span class="param"><code>?customization=' + item.value + '</code></span>' +
'</span>' +
'<span class="desc">' + item.desc + '</span>' +
'</span>';
section.appendChild(label);
});
groupsEl.appendChild(section);
});
function selectedValues() {
return Array.from(document.querySelectorAll('input[type=checkbox]:checked')).map(c => c.value);
}
// Builds the launch URL from the selected profiles and viewer theme. The
// `theme=` param is only added for a non-default preset (default is the
// viewer's built-in look and needs no param). Returns null when there is
// nothing to launch.
function buildUrl(base, values, theme) {
// Sanitize the DOM-derived inputs before they reach the launch href.
// Customization tokens are `folder/name` segments, so allow word chars,
// dot, dash and slash only; the theme is a plain preset id. Anything with
// query metacharacters or a URL scheme (e.g. `javascript:`) is dropped.
const TOKEN = /^[\w.-]+(?:\/[\w.-]+)*$/;
const safeValues = values.filter(v => TOKEN.test(v));
const safeTheme = /^[\w.-]+$/.test(theme || '') ? theme : 'default';
if (!safeValues.length && (!safeTheme || safeTheme === 'default')) {
return null;
}
// Resolve the (hand-typed) base against the current page and require an
// http(s) target, so the base can never turn the href into a
// javascript:/data: URL. searchParams.set() encodes the values safely.
let parsed;
try {
parsed = new URL(base, window.location.href);
} catch (_) {
return null;
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return null;
}
if (safeValues.length) {
parsed.searchParams.set('customization', safeValues.join(','));
}
if (safeTheme && safeTheme !== 'default') {
parsed.searchParams.set('theme', safeTheme);
}
return parsed.toString();
}
function refresh() {
const values = selectedValues();
countEl.textContent = values.length + ' selected';
const url = buildUrl(baseEl.value.trim() || '../', values, viewerThemeEl.value);
if (!url) {
urlEl.textContent = 'Select one or more profiles, or a viewer theme, above…';
launchEl.setAttribute('aria-disabled', 'true');
launchEl.removeAttribute('href');
return;
}
urlEl.textContent = url;
launchEl.href = url;
launchEl.setAttribute('aria-disabled', 'false');
}
groupsEl.addEventListener('change', refresh);
baseEl.addEventListener('input', refresh);
viewerThemeEl.addEventListener('change', refresh);
groupsEl.addEventListener('click', e => {
const btn = e.target.closest('.toggle');
if (!btn) return;
e.preventDefault();
const section = btn.closest('.group');
const boxes = section.querySelectorAll('input[type=checkbox]');
const allChecked = Array.from(boxes).every(b => b.checked);
boxes.forEach(b => (b.checked = !allChecked));
btn.textContent = allChecked ? 'Select all' : 'Clear';
refresh();
});
document.getElementById('clear').addEventListener('click', () => {
document.querySelectorAll('input[type=checkbox]').forEach(b => (b.checked = false));
document.querySelectorAll('.toggle').forEach(t => (t.textContent = 'Select all'));
refresh();
});
document.getElementById('copy').addEventListener('click', async () => {
const text = buildUrl(baseEl.value.trim() || '../', selectedValues(), viewerThemeEl.value);
if (!text) return;
try {
await navigator.clipboard.writeText(text);
const b = document.getElementById('copy');
const prev = b.textContent;
b.textContent = 'Copied!';
setTimeout(() => (b.textContent = prev), 1200);
} catch (_) {
/* clipboard unavailable — the URL is visible in the box to copy manually */
}
});
refresh();
</script>
</body>
</html>
@@ -0,0 +1,48 @@
// URL-loaded customization: segmentationAnnotationTools
//
// Enables the measurement/annotation tools inside the segmentation mode by
// composing capability packs the cornerstone extension already exports:
//
// - the annotation buttons are already registered by the mode (its
// `toolbarButtons` composition includes `cornerstone.toolbarButtons`), so
// the toolbar only needs a `MeasurementTools` section added to the primary
// bar,
// - the annotation tools themselves are added to the mode's tool groups via
// `cornerstone.annotationTools`,
// - and the measurement panel is appended so annotations can be reviewed.
//
// Everything is per-mode, so it all lives in the `mode` phase keyed by the
// segmentation mode's route name. The section tweak patches the mode's toolbar
// layout pack at Mode scope (reset on mode exit), and the tools/panels `$push`
// onto the values the mode seeds on enter.
//
// Load it with `?customization=segmentation/segmentationAnnotationTools`.
{
"mode": {
"segmentation": {
"cornerstone.segmentationModeToolbarSections": {
"primary": { "$unshift": ["MeasurementTools"] },
"MeasurementTools": {
"$set": [
"Length",
"Bidirectional",
"ArrowAnnotate",
"EllipticalROI",
"RectangleROI",
"CircleROI",
"PlanarFreehandROI",
"SplineROI",
"LivewireContour"
]
}
},
"toolGroupAdditions": {
"default": { "$push": [{ "$reference": "cornerstone.annotationTools" }] },
"mpr": { "$push": [{ "$reference": "cornerstone.annotationTools" }] }
},
"rightPanels": {
"$push": ["@ohif/extension-cornerstone.panelModule.panelMeasurement"]
}
}
}
}
@@ -0,0 +1,59 @@
// URL-loaded customization: segmentationEditing
//
// Adds segmentation editing to the basic and longitudinal (viewer) modes by
// composing the capability packs the cornerstone extension already exports:
//
// - the segmentation editing toolbar buttons and toolbox section wiring
// (`cornerstone.segmentationToolbarButtons` / `cornerstone.segmentationToolbarSections`),
// - the segmentation editing tools for the modes' tool groups
// (`cornerstone.segmentationTools`),
// - the segmentation panels that render the editing toolbox
// (`panelSegmentationWithToolsLabelMap` / `panelSegmentationWithToolsContour`),
// - and enables editing in the segmentation panel.
//
// Composition is per-mode, so every patch lives in the `mode` phase keyed by
// the target mode's route name (`basic`, `viewer`). Each mode seeds its own
// `toolbarButtons` / `toolbarSections` / `toolGroupAdditions` / `rightPanels`
// onto the Mode scope on enter; these blocks `$push` / `$set` on top of that,
// exactly like the panel swaps. No app-wide `global` keys are needed.
//
// Load it with `?customization=segmentation/segmentationEditing`.
{
"mode": {
"basic": {
"toolbarButtons": { "$push": [{ "$reference": "cornerstone.segmentationToolbarButtons" }] },
"toolbarSections": { "$push": [{ "$reference": "cornerstone.segmentationToolbarSections" }] },
"toolGroupAdditions": {
"default": { "$push": [{ "$reference": "cornerstone.segmentationTools" }] },
"mpr": { "$push": [{ "$reference": "cornerstone.segmentationTools" }] }
},
// The basic/longitudinal modes seed `panelSegmentation.disableEditing: true`
// from their own `modeCustomizations`; this `mode` phase value is applied
// after that (both Mode scope) and enables editing.
"panelSegmentation.disableEditing": { "$set": false },
"rightPanels": {
"$set": [
"@ohif/extension-cornerstone.panelModule.panelSegmentationWithToolsLabelMap",
"@ohif/extension-cornerstone.panelModule.panelSegmentationWithToolsContour",
"@ohif/extension-cornerstone.panelModule.panelMeasurement"
]
}
},
"viewer": {
"toolbarButtons": { "$push": [{ "$reference": "cornerstone.segmentationToolbarButtons" }] },
"toolbarSections": { "$push": [{ "$reference": "cornerstone.segmentationToolbarSections" }] },
"toolGroupAdditions": {
"default": { "$push": [{ "$reference": "cornerstone.segmentationTools" }] },
"mpr": { "$push": [{ "$reference": "cornerstone.segmentationTools" }] }
},
"panelSegmentation.disableEditing": { "$set": false },
"rightPanels": {
"$set": [
"@ohif/extension-cornerstone.panelModule.panelSegmentationWithToolsLabelMap",
"@ohif/extension-cornerstone.panelModule.panelSegmentationWithToolsContour",
"@ohif/extension-measurement-tracking.panelModule.trackedMeasurements"
]
}
}
}
}
@@ -4,7 +4,7 @@
// site-specific set (key: `cornerstone.windowLevelPresets`). Other modalities
// keep their defaults because only the `CT` entry is overridden.
//
// Load it with `?customization=ctPresets`.
// Load it with `?customization=tools/ctPresets`.
{
"global": {
"cornerstone.windowLevelPresets": {
@@ -4,7 +4,7 @@
// is created (key: `measurementLabels`). `labelOnMeasure` triggers the prompt on
// creation and `exclusive` restricts entries to the provided items.
//
// Load it with `?customization=measurementLabels`.
// Load it with `?customization=tools/measurementLabels`.
{
"global": {
"measurementLabels": {
@@ -8,7 +8,7 @@
// extension (`cornerstone.toolbarButtons` / `cornerstone.toolbarSections`) using
// immutability-helper `$push` commands, so the built-in buttons are preserved.
//
// Load it with `?customization=smoothRotate`.
// Load it with `?customization=tools/smoothRotate`.
{
"global": {
"cornerstone.toolbarButtons": {
@@ -1,5 +0,0 @@
// Example chaining module: ensures `veterinaryOverlay` is loaded and applied
// first when using `?customization=veterinary` alone.
{
"requires": ["veterinaryOverlay"]
}
@@ -0,0 +1,5 @@
// Example chaining module: ensures `veterinary/veterinaryOverlay` is loaded and
// applied first when using `?customization=veterinary/veterinary` alone.
{
"requires": ["veterinary/veterinaryOverlay"]
}
@@ -2,7 +2,7 @@
//
// Demonstrates a runtime-loaded customization that overrides the default
// viewport overlay with a veterinary-style demographics layout. Loaded via
// `?customization=veterinaryOverlay` (see CustomizationService URL handling).
// `?customization=veterinary/veterinaryOverlay` (see CustomizationService URL handling).
//
// Uses the same `global`-at-top-level shape as the cornerstone overlay samples
// and `inheritsFrom: 'ohif.overlayItem'` on each row, matching
@@ -39,14 +39,14 @@
{
"id": "PatientSpecies",
"inheritsFrom": "ohif.overlayItem",
"attribute": "PatientSpecies",
"attribute": "PatientSpeciesDescription",
"label": "Species",
"title": "Patient species"
},
{
"id": "PatientBreed",
"inheritsFrom": "ohif.overlayItem",
"attribute": "PatientBreed",
"attribute": "PatientBreedDescription",
"label": "Breed",
"title": "Patient breed"
}
@@ -8,8 +8,8 @@
// tag 00100030).
//
// This applies in the `global` phase, so it is in place before the WorkList
// renders. Load it with `?customization=patientBirthDate`, or pull it in from
// `appConfig.customizationService.requires`.
// renders. Load it with `?customization=worklist/patientBirthDate`, or pull it in
// from `appConfig.customizationService.requires`.
{
"global": {
"workList.columns": {
+23 -11
View File
@@ -93,13 +93,31 @@ async function appInit(appConfigOrFunc, defaultExtensions, defaultModes) {
const loadedExtensions = await loadModules([...defaultExtensions, ...appConfig.extensions]);
const { customizationService } = servicesManager.services;
if (!appConfig.modes) {
throw new Error('No modes are defined! Check your app-config.js');
}
// Load the mode modules and register the customizations they carry (plain
// `customizationId -> value` maps on the mode definition) at Default scope
// BEFORE the bootstrap phase applies, so bootstrap / `?customization=`
// modules can modify a mode's registered values before anything reads them.
// The mode *instances* are only created after the global phase (below), so
// they too see any modifications.
const loadedModes = await loadModules([...(appConfig.modes || []), ...defaultModes]);
for (const mode of loadedModes) {
if (mode?.customizations) {
customizationService.addReferences(mode.customizations, customizationService.Scope.Default);
}
}
// Resolve every customization module up front — from
// `appConfig.customizationService.requires` and the `?customization=` URL
// parameter — long before any mode loads, then apply the `bootstrap` phase
// BEFORE extensions register so it is in place while they initialize. Modules
// are only loaded when `appConfig.customizationUrlPrefixes` allows their
// prefix; the feature is off by default, and a value with an unconfigured
// prefix throws here (aborting startup) rather than being silently ignored.
// parameter — then apply the `bootstrap` phase BEFORE extensions register so
// it is in place while they initialize. Modules are only loaded when
// `appConfig.customizationUrlPrefixes` allows their prefix; the feature is
// off by default, and a value with an unconfigured prefix throws here
// (aborting startup) rather than being silently ignored.
await customizationService.loadAndApplyBootstrapCustomizations(extensionManager);
await extensionManager.registerExtensions(loadedExtensions, appConfig.dataSources);
@@ -114,12 +132,6 @@ async function appInit(appConfigOrFunc, defaultExtensions, defaultModes) {
// TODO: We no longer init webWorkers at app level
// TODO: We no longer init the user Manager
if (!appConfig.modes) {
throw new Error('No modes are defined! Check your app-config.js');
}
const loadedModes = await loadModules([...(appConfig.modes || []), ...defaultModes]);
// This is the name for the loaded instance object
appConfig.loadedModes = [];
const modesById = new Set();
+65 -3
View File
@@ -97,6 +97,15 @@ export default function ModeRoute({
}
}
// Mode dependency extensions register their customization modules here,
// but `registerExtension` does not merge them into the customization
// service — that otherwise only happens later in
// `extensionManager.onModeEnter`. Merge them now so anything that runs
// before setupRouteInit (e.g. a mode's layoutTemplate) can already read
// the defaults these extensions provide.
// `init` is idempotent — each extension module is merged at most once.
customizationService.init(extensionManager);
if (isMounted.current) {
setExtensionDependenciesLoaded(true);
}
@@ -180,10 +189,19 @@ export default function ModeRoute({
if (isMounted.current) {
const { leftPanels = [], rightPanels = [], ...layoutProps } = layoutData.props;
// Register panels immediately so ViewerLayout's first render sees them.
// ResizablePanelsHook only auto-expands side panels on the initial mount;
// if panels are added later, the viewport grid keeps the wrong width.
// setupRouteInit (below) resets and re-applies them after customizations
// are layered on, so URL/config modules can still modify the lists.
panelService.reset();
panelService.addPanels(panelService.PanelPosition.Left, leftPanels);
panelService.addPanels(panelService.PanelPosition.Right, rightPanels);
// Stash the layout lists for setupRouteInit to seed into the
// `leftPanels` / `rightPanels` customizations.
layoutData.panels = { leftPanels, rightPanels };
// layoutProps contains all props but leftPanels and rightPanels
layoutData.props = layoutProps;
@@ -222,11 +240,55 @@ export default function ModeRoute({
});
// `extensionManager.onModeEnter` resets the customization mode scope via
// `customizationService.onModeEnter`; now layer on the `mode` phase blocks
// for this mode — the general (`*`) block first, then any block keyed by
// this mode's id / routeName so a single mode can override the general one.
// `customizationService.onModeEnter`; the mode scope is then layered
// bottom-up so the final value of every key is decided by scope
// precedence (global > mode > default) and application order alone:
// 1. the mode's own values — its layout panel lists, seeded as the
// standard `leftPanels` / `rightPanels` customizations;
// its toolbar/tool-group composition, seeded as the plain
// `toolbarButtons` / `toolbarSections` / `toolGroupAdditions`
// customizations (resolved to concrete definitions later, when the
// mode's `onModeEnter` registers the toolbar); and its
// `modeCustomizations` block (declared as data on the mode
// instance, usually as a customization name registered at default
// scope when the mode loaded, so bootstrap/global customizations
// can modify the block itself before it is applied);
// 2. the app config / URL `mode` phase blocks — the general (`*`)
// block first, then any block keyed by this mode's id / routeName.
const { leftPanels = [], rightPanels = [] } = layoutTemplateData.current.panels ?? {};
customizationService.setCustomizations({
leftPanels,
rightPanels,
toolbarButtons: mode.toolbarButtons ?? [],
toolbarSections: mode.toolbarSections ?? [],
toolGroupAdditions: mode.toolGroupAdditions ?? {},
});
const modeCustomizations =
typeof mode.modeCustomizations === 'string'
? customizationService.getCustomization(mode.modeCustomizations)
: mode.modeCustomizations;
if (modeCustomizations) {
customizationService.setCustomizations(modeCustomizations);
}
customizationService.applyModeCustomizations([mode.id, mode.routeName]);
// Re-apply panels only when customizations changed the lists. When they
// match the layout, the panels registered in retrieveLayoutData are left
// in place so ViewerLayout's ResizablePanelsHook keeps the correct sizes
// from its one-time initial expand.
const resolvedLeftPanels = customizationService.getValue('leftPanels') ?? [];
const resolvedRightPanels = customizationService.getValue('rightPanels') ?? [];
const panelsChanged =
JSON.stringify(resolvedLeftPanels) !== JSON.stringify(leftPanels) ||
JSON.stringify(resolvedRightPanels) !== JSON.stringify(rightPanels);
if (panelsChanged) {
panelService.reset();
panelService.addPanels(panelService.PanelPosition.Left, resolvedLeftPanels);
panelService.addPanels(panelService.PanelPosition.Right, resolvedRightPanels);
}
// use the URL hangingProtocolId if it exists, otherwise use the one
// defined in the mode configuration
const hangingProtocolIdToUse = hangingProtocolService.getProtocolById(
@@ -13,6 +13,14 @@ describe('preserveQueryParameters', () => {
expect(out.getAll('configUrl')).toEqual(['foo.js']);
});
it('preserves the theme key across mode navigation', () => {
const current = new URLSearchParams();
current.append('theme', 'arctic');
const out = new URLSearchParams();
preserveQueryParameters(out, undefined, current);
expect(out.get('theme')).toBe('arctic');
});
it('preserves all repeated values for the customization key', () => {
const current = new URLSearchParams();
current.append('customization', 'a');
@@ -12,6 +12,7 @@ export const preserveKeys = [
'screenNumber',
'hangingProtocolId',
'customization',
'theme',
];
function preserveKey(query: URLSearchParams, current: URLSearchParams, key: string) {