Compare commits

..

3 Commits

Author SHA1 Message Date
bbe37ff388 feat: annotation persistence (local storage)
Some checks failed
Build and Deploy Docs / build-and-deploy-docs (push) Has been cancelled
CodeQL Advanced / Analyze (${{ matrix.language }}) (none, actions) (push) Has been cancelled
CodeQL Advanced / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Has been cancelled
2026-07-16 10:38:36 +01:00
ohif-bot
f7612cdd0a chore(version): Update package versions to 3.13.0-beta.125 [skip ci] 2026-07-14 23:14:16 +00:00
Alireza
125a174298
fix(cli): link and unlink extensions/modes with pnpm instead of yarn (#6144) 2026-07-14 19:10:49 -04:00
46 changed files with 775 additions and 92 deletions

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/extension-cornerstone-dicom-pmap",
"version": "3.13.0-beta.124",
"version": "3.13.0-beta.125",
"description": "DICOM Parametric Map read workflow",
"author": "OHIF",
"license": "MIT",

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/extension-cornerstone-dicom-rt",
"version": "3.13.0-beta.124",
"version": "3.13.0-beta.125",
"description": "DICOM RT read workflow",
"author": "OHIF",
"license": "MIT",

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/extension-cornerstone-dicom-seg",
"version": "3.13.0-beta.124",
"version": "3.13.0-beta.125",
"description": "DICOM SEG read workflow",
"author": "OHIF",
"license": "MIT",

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/extension-cornerstone-dicom-sr",
"version": "3.13.0-beta.124",
"version": "3.13.0-beta.125",
"description": "OHIF extension for an SR Cornerstone Viewport",
"author": "OHIF",
"license": "MIT",

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/extension-cornerstone-dynamic-volume",
"version": "3.13.0-beta.124",
"version": "3.13.0-beta.125",
"description": "OHIF extension for 4D volumes data",
"author": "OHIF",
"license": "MIT",

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/extension-cornerstone",
"version": "3.13.0-beta.124",
"version": "3.13.0-beta.125",
"description": "OHIF extension for Cornerstone",
"author": "OHIF",
"license": "MIT",

View File

@ -8,7 +8,7 @@ import {
} from '@cornerstonejs/core';
import { Enums as cs3DToolsEnums } from '@cornerstonejs/tools';
import { utilities as csMetadataUtilities } from '@cornerstonejs/metadata';
import { Types } from '@ohif/core';
import { Types, AnnotationPersistenceService } from '@ohif/core';
import Enums from './enums';
import init from './init';
@ -216,6 +216,7 @@ const cornerstoneExtension: Types.Extensions.Extension = {
servicesManager.registerService(CornerstoneCacheService.REGISTRATION);
servicesManager.registerService(ColorbarService.REGISTRATION);
servicesManager.registerService(ViewedDataService.REGISTRATION);
servicesManager.registerService(AnnotationPersistenceService.REGISTRATION);
const { syncGroupService } = servicesManager.services;
syncGroupService.registerCustomSynchronizer('frameview', createFrameViewSynchronizer);

View File

@ -462,6 +462,12 @@ const connectMeasurementServiceToTools = ({
}
const { referenceSeriesUID, referenceStudyUID, SOPInstanceUID, metadata } = measurement;
const persistedAnnotation = data?.annotation || {};
const persistedData = persistedAnnotation.data || {};
const persistedHandles = persistedData.handles || {};
const handlePoints = Array.isArray(persistedHandles.points)
? persistedHandles.points.filter(point => Array.isArray(point) && point.length >= 2)
: [];
const instance = DicomMetadataStore.getInstance(
referenceStudyUID,
@ -506,11 +512,11 @@ const connectMeasurementServiceToTools = ({
* Don't remove this destructuring of data here.
* This is used to pass annotation specific data forward e.g. contour
*/
...(data.annotation.data || {}),
text: data.annotation.data.text,
handles: { ...data.annotation.data.handles },
cachedStats: { ...data.annotation.data.cachedStats },
label: data.annotation.data.label,
...(persistedData || {}),
text: persistedData.text,
handles: { ...persistedHandles, points: handlePoints },
cachedStats: { ...(persistedData.cachedStats || {}) },
label: persistedData.label,
frameNumber,
},
};
@ -534,7 +540,9 @@ const connectMeasurementServiceToTools = ({
commandsManager.run('cancelMeasurement');
const removedAnnotation = annotation.state.getAnnotation(removedMeasurementId);
removeAnnotation(removedMeasurementId);
if (removedAnnotation) {
removeAnnotation(removedMeasurementId);
}
// Ensure `removedAnnotation` is available before triggering the memo,
// as it can be undefined during an undo operation
if (removedAnnotation) {

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/extension-default",
"version": "3.13.0-beta.124",
"version": "3.13.0-beta.125",
"description": "Common/default features and functionality for basic image viewing",
"author": "OHIF Core Team",
"license": "MIT",

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/extension-dicom-microscopy",
"version": "3.13.0-beta.124",
"version": "3.13.0-beta.125",
"description": "OHIF extension for DICOM microscopy",
"author": "Bill Wallace, md-prog",
"license": "MIT",

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/extension-dicom-pdf",
"version": "3.13.0-beta.124",
"version": "3.13.0-beta.125",
"description": "OHIF extension for PDF display",
"author": "OHIF",
"license": "MIT",

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/extension-dicom-video",
"version": "3.13.0-beta.124",
"version": "3.13.0-beta.125",
"description": "OHIF extension for video display",
"author": "OHIF",
"license": "MIT",

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/extension-measurement-tracking",
"version": "3.13.0-beta.124",
"version": "3.13.0-beta.125",
"description": "Tracking features and functionality for basic image viewing",
"author": "OHIF Core Team",
"license": "MIT",

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/extension-test",
"version": "3.13.0-beta.124",
"version": "3.13.0-beta.125",
"description": "OHIF extension used inside e2e testing",
"author": "OHIF",
"license": "MIT",

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/extension-tmtv",
"version": "3.13.0-beta.124",
"version": "3.13.0-beta.125",
"description": "OHIF extension for Total Metabolic Tumor Volume",
"author": "OHIF",
"license": "MIT",

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/extension-ultrasound-pleura-bline",
"version": "3.13.0-beta.124",
"version": "3.13.0-beta.125",
"description": "",
"author": "Rodrigo Basilio",
"license": "MIT",

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/mode-basic-dev-mode",
"version": "3.13.0-beta.124",
"version": "3.13.0-beta.125",
"description": "Basic OHIF Viewer Using Cornerstone",
"author": "OHIF",
"license": "MIT",

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/mode-test",
"version": "3.13.0-beta.124",
"version": "3.13.0-beta.125",
"description": "Basic mode for testing",
"author": "OHIF",
"license": "MIT",

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/mode-basic",
"version": "3.13.0-beta.124",
"version": "3.13.0-beta.125",
"description": "A basic mode used to build other modes on top of",
"author": "OHIF Contributors",
"license": "MIT",

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/mode-longitudinal",
"version": "3.13.0-beta.124",
"version": "3.13.0-beta.125",
"description": "Longitudinal Workflow",
"author": "OHIF",
"license": "MIT",

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/mode-microscopy",
"version": "3.13.0-beta.124",
"version": "3.13.0-beta.125",
"description": "OHIF mode for DICOM microscopy",
"author": "OHIF",
"license": "MIT",

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/mode-preclinical-4d",
"version": "3.13.0-beta.124",
"version": "3.13.0-beta.125",
"description": "4D Workflow",
"author": "OHIF",
"license": "MIT",

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/mode-segmentation",
"version": "3.13.0-beta.124",
"version": "3.13.0-beta.125",
"description": "OHIF segmentation mode which enables labelmap segmentation read/edit/export",
"author": "@ohif",
"license": "MIT",

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/mode-tmtv",
"version": "3.13.0-beta.124",
"version": "3.13.0-beta.125",
"description": "Total Metabolic Tumor Volume Workflow",
"author": "OHIF",
"license": "MIT",

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/mode-ultrasound-pleura-bline",
"version": "3.13.0-beta.124",
"version": "3.13.0-beta.125",
"description": "Allows users to annotate ultrasound images with pleura B-line annotations.",
"author": "OHIF",
"license": "MIT",

View File

@ -124,5 +124,5 @@
"git add"
]
},
"version": "3.13.0-beta.124"
"version": "3.13.0-beta.125"
}

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/app",
"version": "3.13.0-beta.124",
"version": "3.13.0-beta.125",
"productVersion": "3.4.0",
"description": "OHIF Viewer",
"author": "OHIF Contributors",

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/cli",
"version": "3.13.0-beta.124",
"version": "3.13.0-beta.125",
"description": "A CLI to bootstrap new OHIF extension or mode",
"type": "module",
"main": "src/index.js",

View File

@ -2,7 +2,7 @@ import fs from 'fs';
import path from 'path';
import { execa } from 'execa';
import { keywords } from './enums/index.js';
import { validateYarn, addExtensionToConfig, addModeToConfig } from './utils/index.js';
import { validatePnpm, addExtensionToConfig, addModeToConfig } from './utils/index.js';
async function linkPackage(packageDir, options, addToConfig, keyword) {
const { viewerDirectory } = options;
@ -22,19 +22,18 @@ async function linkPackage(packageDir, options, addToConfig, keyword) {
const version = packageJSON.version;
// make sure yarn is installed
await validateYarn();
// make sure pnpm is installed
await validatePnpm();
// change directory to packageDir and execute yarn link
process.chdir(packageDir);
// resolve the package directory before changing the working directory
const resolvedPackageDir = path.resolve(packageDir);
let results;
results = await execa(`yarn`, ['link']);
// change directory to OHIF Platform root and execute yarn link
// change directory to the OHIF Platform root and link the local package there.
// Linking mutates the lockfile, so disable the workspace's frozen-lockfile
// default for this call (pnpm link rejects --no-frozen-lockfile).
process.chdir(`${viewerDirectory}/../..`);
results = await execa(`yarn`, ['link', packageName]);
let results = await execa('pnpm', ['link', resolvedPackageDir, '--config.frozen-lockfile=false']);
console.log(results.stdout);
// Add the node_modules of the linked package so that webpack
@ -63,7 +62,7 @@ async function linkPackage(packageDir, options, addToConfig, keyword) {
});
// run prettier on the webpack config
results = await execa(`yarn`, ['prettier', '--write', webpackPwaPath]);
results = await execa('pnpm', ['exec', 'prettier', '--write', webpackPwaPath]);
}
function linkExtension(packageDir, options) {

View File

@ -1,18 +1,20 @@
import { execa } from 'execa';
import fs from 'fs';
import path from 'path';
import { validateYarn, removeExtensionFromConfig, removeModeFromConfig } from './utils/index.js';
import { validatePnpm, removeExtensionFromConfig, removeModeFromConfig } from './utils/index.js';
const linkPackage = async (packageName, options, removeFromConfig) => {
const { viewerDirectory } = options;
// make sure yarn is installed
await validateYarn();
// make sure pnpm is installed
await validatePnpm();
// change directory to OHIF Platform root and execute yarn link
process.chdir(viewerDirectory);
// change directory to the OHIF Platform root, where the package was linked.
// Unlinking mutates the lockfile, so disable the workspace's frozen-lockfile
// default for this call (pnpm unlink rejects --no-frozen-lockfile).
process.chdir(`${viewerDirectory}/../..`);
const results = await execa(`yarn`, ['unlink', packageName]);
const results = await execa('pnpm', ['unlink', packageName, '--config.frozen-lockfile=false']);
console.log(results.stdout);
const webpackPwaPath = path.join(viewerDirectory, '.webpack', 'webpack.pwa.js');
@ -23,7 +25,7 @@ const linkPackage = async (packageName, options, removeFromConfig) => {
removeFromConfig(packageName);
// run prettier on the webpack config
await execa(`yarn`, ['prettier', '--write', webpackPwaPath]);
await execa('pnpm', ['exec', 'prettier', '--write', webpackPwaPath]);
};
async function removePathFromWebpackConfig(webpackConfigPath, packageName) {

View File

@ -19,7 +19,7 @@ import editPackageJson from './editPackageJson.js';
import createLicense from './createLicense.js';
import createReadme from './createReadme.js';
import prettyPrint from './prettyPrint.js';
import validateYarn from './validateYarn.js';
import validatePnpm from './validatePnpm.js';
export {
getYarnInfo,
@ -43,5 +43,5 @@ export {
createLicense,
createReadme,
prettyPrint,
validateYarn,
validatePnpm,
};

View File

@ -1,12 +1,12 @@
import chalk from 'chalk';
import { execa } from 'execa';
export default async function validateYarn() {
export default async function validatePnpm() {
try {
await execa('yarn', ['--version']);
await execa('pnpm', ['--version']);
} catch (err) {
console.log(
'%s Yarn is not installed, please install it before linking your extension',
'%s pnpm is not installed, please install it before linking your extension',
chalk.red.bold('ERROR')
);
process.exit(1);

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/core",
"version": "3.13.0-beta.124",
"version": "3.13.0-beta.125",
"description": "Generic business logic for web-based medical imaging applications",
"author": "OHIF Core Team",
"license": "MIT",

View File

@ -35,6 +35,7 @@ import {
WorkflowStepsService,
StudyPrefetcherService,
MultiMonitorService,
AnnotationPersistenceService,
} from './services';
import { DisplaySetMessage, DisplaySetMessageList } from './services/DisplaySetService';
@ -91,6 +92,7 @@ const OHIF = {
useActiveViewportDisplaySets,
WorkflowStepsService,
StudyPrefetcherService,
AnnotationPersistenceService,
};
export {
@ -138,6 +140,7 @@ export {
PanelService,
WorkflowStepsService,
StudyPrefetcherService,
AnnotationPersistenceService,
useSystem,
useActiveViewportDisplaySets,
};

View File

@ -0,0 +1,259 @@
import AnnotationPersistenceService from './AnnotationPersistenceService';
const mockAnnotationManager = { addAnnotation: jest.fn() };
const mockAnnotations = new Map<string, any>();
jest.mock('@cornerstonejs/tools', () => ({
annotation: {
state: {
getAnnotationManager: jest.fn(() => mockAnnotationManager),
getAnnotation: jest.fn((uid: string) => mockAnnotations.get(uid)),
},
},
}));
class FakeMeasurementService {
public EVENTS = {
MEASUREMENT_ADDED: 'MEASUREMENT_ADDED',
MEASUREMENT_UPDATED: 'MEASUREMENT_UPDATED',
MEASUREMENT_REMOVED: 'MEASUREMENT_REMOVED',
};
private listeners = new Map<string, Array<(event: any) => void>>();
public measurements = new Map<string, any>();
public addOrUpdateMeasurement = jest.fn((measurement: any) => {
this.measurements.set(measurement.uid, measurement);
return measurement.uid;
});
public getSource = jest.fn((name: string, version: string) => {
return { name, version, uid: 'test-source-uid' };
});
public subscribe(eventName: string, callback: (event: any) => void) {
if (!this.listeners.has(eventName)) {
this.listeners.set(eventName, []);
}
this.listeners.get(eventName).push(callback);
return { unsubscribe: () => undefined };
}
public emit(eventName: string, payload: any) {
this.listeners.get(eventName)?.forEach(callback => callback(payload));
}
}
class FakeDisplaySetService {
public EVENTS = {
DISPLAY_SETS_ADDED: 'DISPLAY_SETS_ADDED',
};
private listeners = new Map<string, Array<(event: any) => void>>();
public subscribe(eventName: string, callback: (event: any) => void) {
if (!this.listeners.has(eventName)) {
this.listeners.set(eventName, []);
}
this.listeners.get(eventName).push(callback);
return { unsubscribe: () => undefined };
}
public emit(eventName: string, payload: any) {
this.listeners.get(eventName)?.forEach(callback => callback(payload));
}
}
class FakeBackend {
public records: any[] = [];
public save(record: any) {
this.records.push(record);
}
public update(record: any) {
this.records = this.records.filter(existing => existing.uid !== record.uid);
this.records.push(record);
}
public delete(uid: string) {
this.records = this.records.filter(existing => existing.uid !== uid);
}
public load(studyInstanceUID: string) {
return this.records.filter(entry => entry.studyInstanceUID === studyInstanceUID);
}
}
describe('AnnotationPersistenceService', () => {
beforeEach(() => {
mockAnnotationManager.addAnnotation.mockClear();
mockAnnotations.clear();
});
it('persists created measurements and restores them for a study', async () => {
const backend = new FakeBackend();
const measurementService = new FakeMeasurementService();
const displaySetService = new FakeDisplaySetService();
const service = new AnnotationPersistenceService(
backend as any,
{
services: {
measurementService: measurementService as any,
displaySetService: displaySetService as any,
},
} as any
);
const measurement = {
uid: 'measurement-1',
toolName: 'Length',
label: 'Length',
referenceStudyUID: 'study-1',
referenceSeriesUID: 'series-1',
referencedImageId: '1.2.3.4',
frameNumber: 1,
metadata: { toolName: 'Length', referencedImageId: '1.2.3.4' },
data: { cachedStats: {} },
points: [
[1, 2, 3],
[4, 5, 6],
],
source: { name: 'cornerstone', version: '3d', uid: 'source-1' },
isLocked: false,
isVisible: true,
displayText: { primary: ['1 mm'], secondary: [] },
type: 'value_type::polyline',
};
const annotation = {
annotationUID: 'measurement-1',
metadata: { toolName: 'Length', referencedImageId: '1.2.3.4' },
data: {
label: 'Length',
handles: {
points: [
[1, 2, 3],
[4, 5, 6],
],
},
cachedStats: {},
},
};
mockAnnotations.set('measurement-1', annotation);
measurementService.emit(measurementService.EVENTS.MEASUREMENT_ADDED, {
source: measurement.source,
measurement,
});
expect(backend.records).toHaveLength(1);
expect(backend.records[0].studyInstanceUID).toBe('study-1');
expect(backend.records[0].annotation.annotationUID).toBe('measurement-1');
backend.save({
uid: 'measurement-1',
studyInstanceUID: 'study-1',
annotation,
measurement,
});
backend.records = backend.records.filter(entry => entry.uid === 'measurement-1');
await service.restorePersistedAnnotations('study-1');
expect(measurementService.addOrUpdateMeasurement).toHaveBeenCalledWith(
expect.objectContaining({ uid: 'measurement-1' }),
expect.objectContaining({
annotation: expect.objectContaining({ annotationUID: 'measurement-1' }),
})
);
});
it('uses the correct registered source on restore and skips re-saving', async () => {
const backend = new FakeBackend();
const measurementService = new FakeMeasurementService();
const displaySetService = new FakeDisplaySetService();
const service = new AnnotationPersistenceService(
backend as any,
{
services: {
measurementService: measurementService as any,
displaySetService: displaySetService as any,
},
} as any
);
const measurement = {
uid: 'measurement-2',
referenceStudyUID: 'study-2',
source: { name: 'Cornerstone3DTools', version: '0.1' },
};
backend.save({
uid: 'measurement-2',
studyInstanceUID: 'study-2',
measurement,
annotation: { annotationUID: 'measurement-2' },
});
const saveSpy = jest.spyOn(backend, 'save');
const updateSpy = jest.spyOn(backend, 'update');
await service.restorePersistedAnnotations('study-2');
expect(measurementService.getSource).toHaveBeenCalledWith('Cornerstone3DTools', '0.1');
expect(measurementService.addOrUpdateMeasurement).toHaveBeenCalledWith(
expect.objectContaining({
uid: 'measurement-2',
source: expect.objectContaining({ name: 'Cornerstone3DTools', uid: 'test-source-uid' }),
}),
expect.anything()
);
// Simulate MEASUREMENT_ADDED being fired by addOrUpdateMeasurement
measurementService.emit(measurementService.EVENTS.MEASUREMENT_ADDED, {
measurement: { uid: 'measurement-2', referenceStudyUID: 'study-2' },
});
// Should not re-save during restore
expect(saveSpy).not.toHaveBeenCalled();
// Simulate MEASUREMENT_UPDATED
measurementService.emit(measurementService.EVENTS.MEASUREMENT_UPDATED, {
measurement: { uid: 'measurement-2', referenceStudyUID: 'study-2' },
});
// Second update should save/update
measurementService.emit(measurementService.EVENTS.MEASUREMENT_UPDATED, {
measurement: { uid: 'measurement-2', referenceStudyUID: 'study-2' },
});
// Wait a tick for async updates to run
await new Promise(process.nextTick);
expect(updateSpy).toHaveBeenCalledTimes(1);
});
it('removes measurement from backend when MEASUREMENT_REMOVED fires', async () => {
const backend = new FakeBackend();
const measurementService = new FakeMeasurementService();
const service = new AnnotationPersistenceService(
backend as any,
{
services: {
measurementService: measurementService as any,
},
} as any
);
backend.records.push({ uid: 'measurement-3', studyInstanceUID: 'study-3' });
measurementService.emit(measurementService.EVENTS.MEASUREMENT_REMOVED, {
measurement: 'measurement-3',
});
// Wait a tick for async delete to run
await new Promise(process.nextTick);
expect(backend.records).toHaveLength(0);
});
});

View File

@ -0,0 +1,304 @@
import { annotation } from '@cornerstonejs/tools';
import LocalStorageAnnotationPersistenceBackend from './LocalStorageAnnotationPersistenceBackend';
export interface AnnotationPersistenceBackend {
save(record: Record<string, any>): Promise<void> | void;
update(record: Record<string, any>): Promise<void> | void;
delete(uid: string): Promise<void> | void;
load(studyInstanceUID: string): Promise<any[]> | any[];
}
export interface AnnotationPersistenceServiceOptions {
services: {
measurementService: any;
displaySetService?: any;
};
}
export type AnnotationPersistenceRecord = {
uid: string;
studyInstanceUID: string;
seriesInstanceUID?: string;
annotation: Record<string, any>;
measurement: Record<string, any>;
};
export default class AnnotationPersistenceService {
public static REGISTRATION = {
name: 'annotationPersistenceService',
altName: 'AnnotationPersistenceService',
create: ({ servicesManager }: { servicesManager: any }) => {
const backend = new LocalStorageAnnotationPersistenceBackend();
return new AnnotationPersistenceService(backend, {
services: {
measurementService: servicesManager.services.measurementService,
displaySetService: servicesManager.services.displaySetService,
},
});
},
};
private backend: AnnotationPersistenceBackend;
private services: AnnotationPersistenceServiceOptions['services'];
private currentStudyInstanceUID?: string;
private restoredUIDs = new Set<string>();
constructor(backend: AnnotationPersistenceBackend, options: AnnotationPersistenceServiceOptions) {
this.backend = backend;
this.services = options.services;
this._bindEvents();
this._bindDisplaySetEvents();
}
public async saveAnnotation(measurement: Record<string, any>): Promise<void> {
if (!measurement?.uid || !measurement.referenceStudyUID) {
return;
}
const record = this._buildRecord(measurement);
try {
await this.backend.save(record);
} catch (error) {
console.warn('Annotation persistence save failed:', error);
}
}
public async updateAnnotation(measurement: Record<string, any>): Promise<void> {
if (!measurement?.uid || !measurement.referenceStudyUID) {
return;
}
const record = this._buildRecord(measurement);
try {
await this.backend.update(record);
} catch (error) {
console.warn('Annotation persistence update failed:', error);
}
}
public async deleteAnnotation(uid: string): Promise<void> {
if (!uid) {
return;
}
try {
await this.backend.delete(uid);
} catch (error) {
console.warn('Annotation persistence delete failed:', error);
}
}
public async restorePersistedAnnotations(studyInstanceUID: string): Promise<void> {
if (!studyInstanceUID) {
return;
}
this.currentStudyInstanceUID = studyInstanceUID;
try {
const records = await this.backend.load(studyInstanceUID);
for (const record of records) {
if (!record?.measurement?.uid || this.restoredUIDs.has(record.measurement.uid)) {
continue;
}
this.restoredUIDs.add(record.measurement.uid);
this._restoreMeasurement(record);
}
} catch (error) {
console.warn('Annotation persistence restore failed:', error);
}
}
private _bindEvents(): void {
const measurementService = this.services.measurementService;
if (!measurementService) {
return;
}
measurementService.subscribe(measurementService.EVENTS.MEASUREMENT_ADDED, ({ measurement }) => {
if (this.restoredUIDs.has(measurement.uid)) {
return;
}
this.saveAnnotation(measurement).catch(() => undefined);
});
measurementService.subscribe(
measurementService.EVENTS.MEASUREMENT_UPDATED,
({ measurement }) => {
if (this.restoredUIDs.has(measurement.uid)) {
this.restoredUIDs.delete(measurement.uid);
return;
}
this.updateAnnotation(measurement).catch(() => undefined);
}
);
measurementService.subscribe(
measurementService.EVENTS.MEASUREMENT_REMOVED,
({ measurement }) => {
this.deleteAnnotation(measurement).catch(() => undefined);
}
);
}
private _bindDisplaySetEvents(): void {
const displaySetService = this.services.displaySetService;
if (!displaySetService?.subscribe) {
return;
}
displaySetService.subscribe(
displaySetService.EVENTS.DISPLAY_SETS_ADDED,
({ displaySetsAdded }) => {
const studyInstanceUIDs = displaySetsAdded?.map(displaySet => displaySet?.StudyInstanceUID);
studyInstanceUIDs?.forEach(studyInstanceUID => {
if (studyInstanceUID) {
this.restorePersistedAnnotations(studyInstanceUID).catch(() => undefined);
}
});
}
);
}
private _buildRecord(measurement: Record<string, any>): AnnotationPersistenceRecord {
const annotationManager = annotation.state.getAnnotationManager();
const annotationInstance = annotation.state.getAnnotation(measurement.uid);
const studyInstanceUID = measurement.referenceStudyUID;
const seriesInstanceUID = measurement.referenceSeriesUID;
const annotationPayload = annotationInstance || {
annotationUID: measurement.uid,
metadata: {
...measurement.metadata,
toolName: measurement.toolName,
},
data: {
label: measurement.label,
handles: measurement.points ? { points: measurement.points } : undefined,
cachedStats: measurement.data || {},
},
};
const record = {
uid: measurement.uid,
studyInstanceUID,
seriesInstanceUID,
annotation: annotationPayload,
measurement: {
...measurement,
annotationUID: measurement.uid,
metadata: {
...(measurement.metadata || {}),
toolName: measurement.toolName,
},
},
};
return record;
}
private _restoreMeasurement(record: AnnotationPersistenceRecord): void {
const measurementService = this.services.measurementService;
if (!measurementService) {
return;
}
const persistedSource = record.measurement.source;
const resolvedSource =
(persistedSource?.name && persistedSource?.version
? measurementService.getSource(persistedSource.name, persistedSource.version)
: null) ||
measurementService.getSource('Cornerstone3DTools', '0.1');
if (!resolvedSource) {
console.warn('Annotation persistence: no registered source found, skipping restore');
return;
}
const measurement = {
...record.measurement,
uid: record.measurement.uid || record.uid,
referenceStudyUID: record.studyInstanceUID,
referenceSeriesUID: record.seriesInstanceUID,
source: resolvedSource,
};
if (
this.currentStudyInstanceUID &&
measurement.referenceStudyUID !== this.currentStudyInstanceUID
) {
return;
}
const normalizedAnnotation = this._normalizePersistedAnnotation(record.annotation);
const rebuiltAnnotation = this._buildRestoredAnnotation(measurement, normalizedAnnotation);
measurementService.addOrUpdateMeasurement?.(measurement, {
annotation: rebuiltAnnotation,
});
}
private _normalizePersistedAnnotation(
annotationPayload?: Record<string, any>
): Record<string, any> {
if (!annotationPayload) {
return {};
}
const normalizedData = annotationPayload.data || {};
const normalizedHandles = normalizedData.handles || {};
const rawPoints = Array.isArray(normalizedHandles.points)
? normalizedHandles.points
: Array.isArray(normalizedData.points)
? normalizedData.points
: Array.isArray(annotationPayload.points)
? annotationPayload.points
: [];
const normalizedPoints = rawPoints.filter(point => Array.isArray(point) && point.length >= 2);
return {
...annotationPayload,
data: {
...normalizedData,
handles: {
...normalizedHandles,
points: normalizedPoints,
},
},
};
}
private _buildRestoredAnnotation(
measurement: Record<string, any>,
annotationPayload?: Record<string, any>
): Record<string, any> {
const normalizedAnnotation = this._normalizePersistedAnnotation(annotationPayload);
const persistedData = normalizedAnnotation.data || {};
const persistedHandles = persistedData.handles || {};
const points =
Array.isArray(persistedHandles.points) && persistedHandles.points.length
? persistedHandles.points
: Array.isArray(measurement.points)
? measurement.points.filter(point => Array.isArray(point) && point.length >= 2)
: [];
return {
annotationUID: measurement.uid || normalizedAnnotation.annotationUID,
metadata: {
...(normalizedAnnotation.metadata || {}),
toolName: measurement.toolName || normalizedAnnotation.metadata?.toolName,
FrameOfReferenceUID: measurement.FrameOfReferenceUID,
referencedImageId: measurement.referencedImageId,
},
data: {
...(persistedData || {}),
label: persistedData.label || measurement.label,
handles: {
...persistedHandles,
points,
},
cachedStats: { ...(persistedData.cachedStats || measurement.data || {}) },
},
};
}
}

View File

@ -0,0 +1,58 @@
export interface AnnotationPersistenceBackend {
save(record: Record<string, any>): Promise<void> | void;
update(record: Record<string, any>): Promise<void> | void;
delete(uid: string): Promise<void> | void;
load(studyInstanceUID: string): Promise<any[]> | any[];
}
const STORAGE_KEY = 'ohif.annotation.persistence';
export default class LocalStorageAnnotationPersistenceBackend
implements AnnotationPersistenceBackend
{
public save(record: Record<string, any>): void {
const entries = this._readEntries();
const nextEntries = entries.filter(existing => existing.uid !== record.uid);
nextEntries.push(record);
this._writeEntries(nextEntries);
}
public update(record: Record<string, any>): void {
this.save(record);
}
public delete(uid: string): void {
const entries = this._readEntries().filter(existing => existing.uid !== uid);
this._writeEntries(entries);
}
public load(studyInstanceUID: string): any[] {
return this._readEntries().filter(entry => entry.studyInstanceUID === studyInstanceUID);
}
private _readEntries(): Record<string, any>[] {
if (typeof window === 'undefined' || !window.localStorage) {
return [];
}
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) {
return [];
}
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch (error) {
console.warn('Failed to read persisted annotations from storage:', error);
return [];
}
}
private _writeEntries(entries: Record<string, any>[]): void {
if (typeof window === 'undefined' || !window.localStorage) {
return;
}
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
}
}

View File

@ -0,0 +1,16 @@
import AnnotationPersistenceService from './AnnotationPersistenceService';
import type {
AnnotationPersistenceBackend,
AnnotationPersistenceRecord,
AnnotationPersistenceServiceOptions,
} from './AnnotationPersistenceService';
import LocalStorageAnnotationPersistenceBackend from './LocalStorageAnnotationPersistenceBackend';
export type {
AnnotationPersistenceBackend,
AnnotationPersistenceRecord,
AnnotationPersistenceServiceOptions,
};
export { LocalStorageAnnotationPersistenceBackend };
export default AnnotationPersistenceService;

View File

@ -0,0 +1,20 @@
import AnnotationPersistenceService, {
AnnotationPersistenceServiceOptions,
} from './AnnotationPersistenceService';
import LocalStorageAnnotationPersistenceBackend from './LocalStorageAnnotationPersistenceBackend';
const registration = {
name: 'annotationPersistenceService',
altName: 'AnnotationPersistenceService',
create: ({ servicesManager }: { servicesManager: any }) => {
const backend = new LocalStorageAnnotationPersistenceBackend();
return new AnnotationPersistenceService(backend, {
services: {
measurementService: servicesManager.services.measurementService,
displaySetService: servicesManager.services.displaySetService,
},
});
},
};
export default registration;

View File

@ -385,6 +385,45 @@ class MeasurementService extends PubSubService {
return updatedMeasurement.uid;
}
public addOrUpdateMeasurement(measurement, restoreData?: any) {
if (!measurement?.uid) {
return;
}
const existingMeasurement = this.measurements.get(measurement.uid);
const restoredMeasurement = {
...(existingMeasurement || {}),
...measurement,
modifiedTimestamp: Math.floor(Date.now() / 1000),
uid: measurement.uid,
};
this.measurements.set(restoredMeasurement.uid, restoredMeasurement);
if (existingMeasurement) {
this._broadcastEvent(this.EVENTS.MEASUREMENT_UPDATED, {
source: restoredMeasurement.source,
measurement: restoredMeasurement,
notYetUpdatedAtSource: false,
});
return restoredMeasurement.uid;
}
this._broadcastEvent(this.EVENTS.MEASUREMENT_ADDED, {
source: restoredMeasurement.source,
measurement: restoredMeasurement,
});
this._broadcastEvent(this.EVENTS.RAW_MEASUREMENT_ADDED, {
source: restoredMeasurement.source,
measurement: restoredMeasurement,
data: restoreData || {},
dataSource: {},
});
return restoredMeasurement.uid;
}
/**
* Add a raw measurement into a source so that it may be
* Converted to/from annotation in the same way. E.g. import serialized data

View File

@ -18,6 +18,7 @@ import PanelService from './PanelService';
import WorkflowStepsService from './WorkflowStepsService';
import StudyPrefetcherService from './StudyPrefetcherService';
import { MultiMonitorService } from './MultiMonitorService';
import AnnotationPersistenceService from './AnnotationPersistenceService';
import type Services from '../types/Services';
@ -46,4 +47,5 @@ export {
PanelService,
WorkflowStepsService,
StudyPrefetcherService,
AnnotationPersistenceService,
};

View File

@ -1,6 +1,6 @@
{
"name": "ohif-docs",
"version": "3.13.0-beta.124",
"version": "3.13.0-beta.125",
"private": true,
"scripts": {
"docusaurus": "docusaurus",

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/i18n",
"version": "3.13.0-beta.124",
"version": "3.13.0-beta.125",
"description": "Internationalization library for The OHIF Viewer",
"author": "OHIF",
"license": "MIT",

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/ui-next",
"version": "3.13.0-beta.124",
"version": "3.13.0-beta.125",
"description": "Next version of OHIF Viewers UI, more customizable using shadcn/ui",
"author": "OHIF",
"license": "MIT",

View File

@ -1,5 +1,5 @@
import { ShepherdBase } from 'shepherd.js';
import { offset, flip, shift, detectOverflow } from '@floating-ui/dom';
import { offset, flip, shift } from '@floating-ui/dom';
/**
* Retrieves the list of tours that have been shown from localStorage.
@ -51,41 +51,13 @@ const defaultShowHandler = (Shepherd: ShepherdBase) => {
}
};
/**
* Custom middleware for adjusting Shepherd step positioning when overflowing.
*
* @type {object}
* @property {string} name - The name of the middleware.
* @property {function} fn - The function that adjusts the position of the step when overflowing.
*/
const customMiddleware = {
name: 'customOverflowMiddleware',
async fn(state) {
const overflow = await detectOverflow(state, {
boundary: document.querySelector('body'),
padding: 24,
});
const xAdjustment =
overflow.left > 0 ? overflow.left : overflow.right > 0 ? -overflow.right : 0;
const yAdjustment =
overflow.top > 0 ? overflow.top : overflow.bottom > 0 ? -overflow.bottom : 0;
return {
x: state.x + xAdjustment,
y: state.y + yAdjustment,
};
},
};
/**
* Default Floating UI middleware for positioning steps in Shepherd.js.
* Includes offset, shift, flip, and custom overflow middleware.
* Includes offset, shift, and flip middleware.
*
* @type {Array<object>}
*/
const middleware = [offset(15), shift(), flip(), customMiddleware];
const middleware = [offset(15), shift({ padding: 24 }), flip()];
export { hasTourBeenShown, markTourAsShown, middleware, defaultShowHandler };

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/ui",
"version": "3.13.0-beta.124",
"version": "3.13.0-beta.125",
"description": "A set of React components for Medical Imaging Viewers",
"author": "OHIF Contributors",
"license": "MIT",