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:
1 parent
f79055f98e
commit
b266c0a86a
49 files changed
+2653
-1215
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
// },
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
@@ -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/',
|
||||
},
|
||||
|
||||
@@ -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/',
|
||||
},
|
||||
|
||||
@@ -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 →</a>
|
||||
</div>
|
||||
<div class="launch-row">
|
||||
<div class="url-box" id="url">Select one or more profiles above…</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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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": {
|
||||
+1
-1
@@ -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": {
|
||||
+1
-1
@@ -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"]
|
||||
}
|
||||
+3
-3
@@ -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"
|
||||
}
|
||||
+2
-2
@@ -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
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user