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

@@ -0,0 +1,180 @@
import CustomizationService, { CustomizationScope } from './CustomizationService';
/**
* Tests for the `$reference` read-time resolution marker: composing
* customizations by name, flattening pack lists, and — crucially — replacing a
* reference wholesale with a subsequent `$set` (another reference or a
* hard-coded value).
*/
describe('CustomizationService $reference', () => {
let service: CustomizationService;
beforeEach(() => {
service = new CustomizationService({ configuration: {}, commandsManager: {} } as any);
// Capability packs registered at Default scope (as an extension would).
service.setCustomizations(
{
'cornerstone.toolbarButtons': [{ id: 'Length' }, { id: 'Pan' }],
'cornerstone.segTools': [{ id: 'Brush' }, { id: 'Eraser' }],
'other.toolbarButtons': [{ id: 'Zoom' }],
// Object-valued pack (a tool block), used to test references as an
// array item that resolves to a non-array (kept, not flattened).
'cornerstone.annotationBlock': { passive: [{ toolName: 'Length' }] },
},
CustomizationScope.Default
);
});
it('flattens a referenced array into the surrounding list', () => {
service.setCustomizations(
{ toolbarButtons: [{ $reference: 'cornerstone.toolbarButtons' }] },
CustomizationScope.Mode
);
expect(service.getCustomization('toolbarButtons')).toEqual([{ id: 'Length' }, { id: 'Pan' }]);
});
it('composes multiple packs and mixes in literals', () => {
service.setCustomizations(
{
toolbarButtons: [
{ $reference: 'cornerstone.toolbarButtons' },
{ id: 'Custom' },
{ $reference: 'other.toolbarButtons' },
],
},
CustomizationScope.Mode
);
expect(service.getCustomization('toolbarButtons')).toEqual([
{ id: 'Length' },
{ id: 'Pan' },
{ id: 'Custom' },
{ id: 'Zoom' },
]);
});
it('resolves references inside object property values (toolGroupAdditions map)', () => {
service.setCustomizations(
{
toolGroupAdditions: {
default: [{ $reference: 'cornerstone.annotationBlock' }],
mpr: [],
},
},
CustomizationScope.Mode
);
expect(service.getCustomization('toolGroupAdditions')).toEqual({
default: [{ passive: [{ toolName: 'Length' }] }],
mpr: [],
});
});
it('$push adds another reference that resolves and flattens', () => {
service.setCustomizations(
{ toolbarButtons: [{ $reference: 'cornerstone.toolbarButtons' }] },
CustomizationScope.Mode
);
service.setCustomizations(
{ toolbarButtons: { $push: [{ $reference: 'cornerstone.segTools' }] } },
CustomizationScope.Mode
);
expect(service.getCustomization('toolbarButtons')).toEqual([
{ id: 'Length' },
{ id: 'Pan' },
{ id: 'Brush' },
{ id: 'Eraser' },
]);
});
it('$set replaces a reference with a DIFFERENT reference', () => {
service.setCustomizations(
{ toolbarButtons: [{ $reference: 'cornerstone.toolbarButtons' }] },
CustomizationScope.Mode
);
service.setCustomizations(
{ toolbarButtons: { $set: [{ $reference: 'other.toolbarButtons' }] } },
CustomizationScope.Mode
);
expect(service.getCustomization('toolbarButtons')).toEqual([{ id: 'Zoom' }]);
});
it('$set replaces a reference with a HARD-CODED list', () => {
service.setCustomizations(
{ toolbarButtons: [{ $reference: 'cornerstone.toolbarButtons' }] },
CustomizationScope.Mode
);
service.setCustomizations(
{ toolbarButtons: { $set: [{ id: 'OnlyThis' }] } },
CustomizationScope.Mode
);
expect(service.getCustomization('toolbarButtons')).toEqual([{ id: 'OnlyThis' }]);
});
it('a global-scope $set overrides a mode reference by scope precedence', () => {
service.setCustomizations(
{ toolbarButtons: [{ $reference: 'cornerstone.toolbarButtons' }] },
CustomizationScope.Mode
);
service.setCustomizations(
{ toolbarButtons: { $set: [{ $reference: 'other.toolbarButtons' }] } },
CustomizationScope.Global
);
expect(service.getCustomization('toolbarButtons')).toEqual([{ id: 'Zoom' }]);
});
it('picks up live edits to the referenced pack', () => {
service.setCustomizations(
{ toolbarButtons: [{ $reference: 'cornerstone.toolbarButtons' }] },
CustomizationScope.Mode
);
expect(service.getCustomization('toolbarButtons')).toHaveLength(2);
// Extend the pack itself; the referencing value reflects it on next read.
service.setCustomizations(
{ 'cornerstone.toolbarButtons': { $push: [{ id: 'Added' }] } },
CustomizationScope.Global
);
expect(service.getCustomization('toolbarButtons')).toEqual([
{ id: 'Length' },
{ id: 'Pan' },
{ id: 'Added' },
]);
});
it('resolves a whole-value reference (alias) and chains references', () => {
service.setCustomizations(
{
'alias.buttons': { $reference: 'cornerstone.toolbarButtons' },
toolbarButtons: [{ $reference: 'alias.buttons' }],
},
CustomizationScope.Mode
);
expect(service.getCustomization('alias.buttons')).toEqual([{ id: 'Length' }, { id: 'Pan' }]);
expect(service.getCustomization('toolbarButtons')).toEqual([{ id: 'Length' }, { id: 'Pan' }]);
});
it('breaks reference cycles instead of looping', () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
service.setCustomizations(
{
cycleA: [{ $reference: 'cycleB' }],
cycleB: [{ $reference: 'cycleA' }],
},
CustomizationScope.Mode
);
// Should return without throwing; the cyclic branch resolves to nothing.
expect(() => service.getCustomization('cycleA')).not.toThrow();
expect(service.getCustomization('cycleA')).toEqual([]);
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
it('warns and drops a reference to an unregistered customization', () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
service.setCustomizations(
{ toolbarButtons: [{ $reference: 'does.not.exist' }, { id: 'Kept' }] },
CustomizationScope.Mode
);
expect(service.getCustomization('toolbarButtons')).toEqual([{ id: 'Kept' }]);
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
});
@@ -484,13 +484,98 @@ export default class CustomizationService extends PubSubService {
this.globalCustomizations.get(customizationId) ??
this.modeCustomizations.get(customizationId) ??
this.defaultCustomizations.get(customizationId);
const newTransformed = this.transform(customization);
// Apply `inheritsFrom` / `$transform`, then expand any `$reference`
// markers (see `_resolveReferences`). `seen` starts with the id being read
// so a value that references itself is caught as a cycle.
const newTransformed = this._resolveReferences(
this.transform(customization),
new Set([customizationId])
);
if (newTransformed !== undefined) {
this.transformedCustomizations.set(customizationId, newTransformed);
}
return newTransformed;
}
/**
* Expands `$reference` markers inside a resolved customization value.
*
* A `{ $reference: '<name>' }` object is replaced by the value of the
* customization `<name>` (itself resolved recursively, so references can
* chain). References may appear anywhere in a value:
* - as the whole value — an alias for another customization;
* - as an item in an **array** — if the referenced value is itself an
* array it is spread (flattened) into the parent, so a list can compose
* several capability packs by name (e.g. a mode's `toolbarButtons`);
* - as a property value of a **plain object** (e.g. each list under a
* `toolGroupAdditions` map).
*
* Because expansion happens at read time (not when customizations are
* merged), a later `$set` replaces the reference wholesale — with a different
* `{ $reference }` or a hard-coded value — and edits to the referenced target
* are picked up live. Only plain arrays/objects are walked; class instances,
* functions and React elements are returned untouched, and unchanged values
* are returned by identity so non-referencing customizations are not cloned.
* Cycles are broken and warned via `seen`.
*/
private _resolveReferences(value: any, seen: Set<string>): any {
if (!value || typeof value !== 'object' || value.$$typeof) {
return value;
}
if (typeof value.$reference === 'string') {
return this._resolveReferenceName(value.$reference, seen);
}
if (Array.isArray(value)) {
let changed = false;
const result: any[] = [];
for (const item of value) {
if (item && typeof item === 'object' && !item.$$typeof && typeof item.$reference === 'string') {
changed = true;
const resolved = this._resolveReferenceName(item.$reference, seen);
if (Array.isArray(resolved)) {
result.push(...resolved);
} else if (resolved !== undefined) {
result.push(resolved);
}
} else {
const resolved = this._resolveReferences(item, seen);
changed ||= resolved !== item;
result.push(resolved);
}
}
return changed ? result : value;
}
if (!isPlainObject(value)) {
return value;
}
let changed = false;
const result: Record<string, any> = {};
for (const [key, val] of Object.entries(value)) {
const resolved = this._resolveReferences(val, seen);
changed ||= resolved !== val;
result[key] = resolved;
}
return changed ? result : value;
}
/** Resolves a single `$reference` target name, guarding against cycles. */
private _resolveReferenceName(name: string, seen: Set<string>): any {
if (seen.has(name)) {
console.warn(`CustomizationService: $reference cycle detected at "${name}"`);
return undefined;
}
const raw =
this.globalCustomizations.get(name) ??
this.modeCustomizations.get(name) ??
this.defaultCustomizations.get(name);
if (raw === undefined) {
console.warn(`CustomizationService: no customization registered for $reference "${name}"`);
return undefined;
}
const nextSeen = new Set(seen).add(name);
return this._resolveReferences(this.transform(raw), nextSeen);
}
/**
* Returns a customization value, or the provided fallback when unset.
*/
@@ -1112,6 +1197,15 @@ export function normalizeCustomizationConfig(configuration: unknown): {
return {};
}
/** True for `{}`-literal / null-prototype objects (not arrays or class instances). */
function isPlainObject(value: any): boolean {
if (value === null || typeof value !== 'object') {
return false;
}
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
}
function hasDollarKey(value) {
if (Array.isArray(value)) {
for (const item of value) {
@@ -1128,7 +1222,11 @@ function hasDollarKey(value) {
return false;
}
for (const key of Object.keys(value)) {
if (key.startsWith('$') && key !== '$transform') {
// `$transform` and `$reference` are read-time markers resolved by the
// service (in `transform` / `_resolveReferences`), not immutability-helper
// merge commands — so a value carrying them is stored verbatim rather than
// being run through `update()`.
if (key.startsWith('$') && key !== '$transform' && key !== '$reference') {
return true;
}
if (hasDollarKey(value[key])) {