From bbe37ff3888bdba3060c6ce2d37cbb3fe0c66996 Mon Sep 17 00:00:00 2001 From: Othmane Ataallah Date: Thu, 16 Jul 2026 10:38:36 +0100 Subject: [PATCH] feat: annotation persistence (local storage) --- extensions/cornerstone/src/index.tsx | 3 +- .../cornerstone/src/initMeasurementService.ts | 20 +- platform/core/src/index.ts | 3 + .../AnnotationPersistenceService.test.ts | 259 +++++++++++++++ .../AnnotationPersistenceService.ts | 304 ++++++++++++++++++ ...ocalStorageAnnotationPersistenceBackend.ts | 58 ++++ .../AnnotationPersistenceService/index.ts | 16 + .../registration.ts | 20 ++ .../MeasurementService/MeasurementService.ts | 39 +++ platform/core/src/services/index.ts | 2 + .../src/components/Onboarding/utilities.ts | 34 +- 11 files changed, 720 insertions(+), 38 deletions(-) create mode 100644 platform/core/src/services/AnnotationPersistenceService/AnnotationPersistenceService.test.ts create mode 100644 platform/core/src/services/AnnotationPersistenceService/AnnotationPersistenceService.ts create mode 100644 platform/core/src/services/AnnotationPersistenceService/LocalStorageAnnotationPersistenceBackend.ts create mode 100644 platform/core/src/services/AnnotationPersistenceService/index.ts create mode 100644 platform/core/src/services/AnnotationPersistenceService/registration.ts diff --git a/extensions/cornerstone/src/index.tsx b/extensions/cornerstone/src/index.tsx index 5e5fe954d..89b1ce5f8 100644 --- a/extensions/cornerstone/src/index.tsx +++ b/extensions/cornerstone/src/index.tsx @@ -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); diff --git a/extensions/cornerstone/src/initMeasurementService.ts b/extensions/cornerstone/src/initMeasurementService.ts index 84f370dc7..9dc693f37 100644 --- a/extensions/cornerstone/src/initMeasurementService.ts +++ b/extensions/cornerstone/src/initMeasurementService.ts @@ -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) { diff --git a/platform/core/src/index.ts b/platform/core/src/index.ts index e250e760b..d946a78cd 100644 --- a/platform/core/src/index.ts +++ b/platform/core/src/index.ts @@ -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, }; diff --git a/platform/core/src/services/AnnotationPersistenceService/AnnotationPersistenceService.test.ts b/platform/core/src/services/AnnotationPersistenceService/AnnotationPersistenceService.test.ts new file mode 100644 index 000000000..95a5eda80 --- /dev/null +++ b/platform/core/src/services/AnnotationPersistenceService/AnnotationPersistenceService.test.ts @@ -0,0 +1,259 @@ +import AnnotationPersistenceService from './AnnotationPersistenceService'; + +const mockAnnotationManager = { addAnnotation: jest.fn() }; +const mockAnnotations = new Map(); + +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 void>>(); + public measurements = new Map(); + 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 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); + }); +}); + diff --git a/platform/core/src/services/AnnotationPersistenceService/AnnotationPersistenceService.ts b/platform/core/src/services/AnnotationPersistenceService/AnnotationPersistenceService.ts new file mode 100644 index 000000000..d1dc4fe87 --- /dev/null +++ b/platform/core/src/services/AnnotationPersistenceService/AnnotationPersistenceService.ts @@ -0,0 +1,304 @@ +import { annotation } from '@cornerstonejs/tools'; +import LocalStorageAnnotationPersistenceBackend from './LocalStorageAnnotationPersistenceBackend'; + +export interface AnnotationPersistenceBackend { + save(record: Record): Promise | void; + update(record: Record): Promise | void; + delete(uid: string): Promise | void; + load(studyInstanceUID: string): Promise | any[]; +} + +export interface AnnotationPersistenceServiceOptions { + services: { + measurementService: any; + displaySetService?: any; + }; +} + +export type AnnotationPersistenceRecord = { + uid: string; + studyInstanceUID: string; + seriesInstanceUID?: string; + annotation: Record; + measurement: Record; +}; + +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(); + + constructor(backend: AnnotationPersistenceBackend, options: AnnotationPersistenceServiceOptions) { + this.backend = backend; + this.services = options.services; + this._bindEvents(); + this._bindDisplaySetEvents(); + } + + public async saveAnnotation(measurement: Record): Promise { + 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): Promise { + 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 { + if (!uid) { + return; + } + + try { + await this.backend.delete(uid); + } catch (error) { + console.warn('Annotation persistence delete failed:', error); + } + } + + public async restorePersistedAnnotations(studyInstanceUID: string): Promise { + 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): 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 + ): Record { + 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, + annotationPayload?: Record + ): Record { + 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 || {}) }, + }, + }; + } +} diff --git a/platform/core/src/services/AnnotationPersistenceService/LocalStorageAnnotationPersistenceBackend.ts b/platform/core/src/services/AnnotationPersistenceService/LocalStorageAnnotationPersistenceBackend.ts new file mode 100644 index 000000000..d29ed9612 --- /dev/null +++ b/platform/core/src/services/AnnotationPersistenceService/LocalStorageAnnotationPersistenceBackend.ts @@ -0,0 +1,58 @@ +export interface AnnotationPersistenceBackend { + save(record: Record): Promise | void; + update(record: Record): Promise | void; + delete(uid: string): Promise | void; + load(studyInstanceUID: string): Promise | any[]; +} + +const STORAGE_KEY = 'ohif.annotation.persistence'; + +export default class LocalStorageAnnotationPersistenceBackend + implements AnnotationPersistenceBackend +{ + public save(record: Record): void { + const entries = this._readEntries(); + const nextEntries = entries.filter(existing => existing.uid !== record.uid); + nextEntries.push(record); + this._writeEntries(nextEntries); + } + + public update(record: Record): 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[] { + 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[]): void { + if (typeof window === 'undefined' || !window.localStorage) { + return; + } + + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(entries)); + } +} diff --git a/platform/core/src/services/AnnotationPersistenceService/index.ts b/platform/core/src/services/AnnotationPersistenceService/index.ts new file mode 100644 index 000000000..b49531e69 --- /dev/null +++ b/platform/core/src/services/AnnotationPersistenceService/index.ts @@ -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; diff --git a/platform/core/src/services/AnnotationPersistenceService/registration.ts b/platform/core/src/services/AnnotationPersistenceService/registration.ts new file mode 100644 index 000000000..39d1eb98e --- /dev/null +++ b/platform/core/src/services/AnnotationPersistenceService/registration.ts @@ -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; diff --git a/platform/core/src/services/MeasurementService/MeasurementService.ts b/platform/core/src/services/MeasurementService/MeasurementService.ts index d91a61b99..f0cb6b589 100644 --- a/platform/core/src/services/MeasurementService/MeasurementService.ts +++ b/platform/core/src/services/MeasurementService/MeasurementService.ts @@ -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 diff --git a/platform/core/src/services/index.ts b/platform/core/src/services/index.ts index 4c8149d4b..029b2f470 100644 --- a/platform/core/src/services/index.ts +++ b/platform/core/src/services/index.ts @@ -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, }; diff --git a/platform/ui-next/src/components/Onboarding/utilities.ts b/platform/ui-next/src/components/Onboarding/utilities.ts index 8cb075160..fed0e49b2 100644 --- a/platform/ui-next/src/components/Onboarding/utilities.ts +++ b/platform/ui-next/src/components/Onboarding/utilities.ts @@ -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} */ -const middleware = [offset(15), shift(), flip(), customMiddleware]; +const middleware = [offset(15), shift({ padding: 24 }), flip()]; export { hasTourBeenShown, markTourAsShown, middleware, defaultShowHandler };