feat(DICOM Upload): Added new DICOM upload dialogue launched from the worklist page (#3326)
* feat(DICOM Upload) OHIF #3297 - DicomWebDataSource.store.dicom now accepts an ArrayBuffer of data sets to store - DicomWebDataSource.store.dicom now also accepts optional callbacks to track upload and an AbortSignal object to cancel upload - Added DicomFileUploader class that performs and tracks the upload of a DICOM file - Added various UI pieces for the upload: DicomUpload, DicomUploadProgress, DicomUploadProgressItem - ProgressLoadingBar was extracted from LoadingIndicatorProgress so it can be reused - Modal dialogues can now optionally prevent an outside click from closing the Modal * Passing an XMLHttpRequest to the dataSource.store.dicom method instead of callbacks and AbortSignal. Cleanup of various UI pieces to minimize code and increase readability. * Made the DicomUpload component a customization module exported by the cornerstone extension. * Exposed a copy of the data source configuration via the IWebApiDataSource interface. Added dicomUploadEnabled to a data source's configuration. * Code clean up. * Upgraded the dicomweb-client version to one that provides the ability to pass a custom HTTP request. DICOM upload uses that custom HTTP request to track progress and cancel requests. * Distinguished between failed and cancelled uploads. * Allow no selection in the upload dialogue. Fixed the styling of various progress information so that everything aligns. * Switched from cornerstone wado image loader to cornerstone dicom image loader for DICOM upload. * Added special cancelled icon to differentiate from failed. * Added a bit of spacing between the upload progress bar and percentage. * Fixed minor issue with upload rejection. * Performance improvement for cancel all uploads: - use React memo for each upload item progress (row) - do not await each request of a cancel all * Fixed various padding/spacing for the DICOM upload drop zone component. Changed the border dashing for the DICOM upload drop zone to be a background image gradient. Added hover and active effects to the 'Cancel All Uploads' text.
This commit is contained in:
1 parent
f377153b60
commit
66f6e3eade
32 files changed
+1126
-350
No files matched your search
@@ -0,0 +1,220 @@
|
||||
import dicomImageLoader from '@cornerstonejs/dicom-image-loader';
|
||||
|
||||
import { PubSubService } from '@ohif/core';
|
||||
|
||||
export const EVENTS = {
|
||||
PROGRESS: 'event:DicomFileUploader:progress',
|
||||
};
|
||||
|
||||
export interface DicomFileUploaderEvent {
|
||||
fileId: number;
|
||||
}
|
||||
|
||||
export interface DicomFileUploaderProgressEvent extends DicomFileUploaderEvent {
|
||||
percentComplete: number;
|
||||
}
|
||||
|
||||
export enum UploadStatus {
|
||||
NotStarted,
|
||||
InProgress,
|
||||
Success,
|
||||
Failed,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
type CancelOrFailed = UploadStatus.Cancelled | UploadStatus.Failed;
|
||||
|
||||
export class UploadRejection {
|
||||
message: string;
|
||||
status: CancelOrFailed;
|
||||
|
||||
constructor(status: CancelOrFailed, message: string) {
|
||||
this.message = message;
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export default class DicomFileUploader extends PubSubService {
|
||||
private _file;
|
||||
private _fileId;
|
||||
private _dataSource;
|
||||
private _loadPromise;
|
||||
private _abortController = new AbortController();
|
||||
private _status: UploadStatus = UploadStatus.NotStarted;
|
||||
private _percentComplete = 0;
|
||||
|
||||
constructor(file, dataSource) {
|
||||
super(EVENTS);
|
||||
this._file = file;
|
||||
this._fileId = dicomImageLoader.wadouri.fileManager.add(file);
|
||||
this._dataSource = dataSource;
|
||||
}
|
||||
|
||||
getFileId(): string {
|
||||
return this._fileId;
|
||||
}
|
||||
|
||||
getFileName(): string {
|
||||
return this._file.name;
|
||||
}
|
||||
|
||||
getFileSize(): number {
|
||||
return this._file.size;
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this._abortController.abort();
|
||||
}
|
||||
|
||||
getStatus(): UploadStatus {
|
||||
return this._status;
|
||||
}
|
||||
|
||||
getPercentComplete(): number {
|
||||
return this._percentComplete;
|
||||
}
|
||||
|
||||
async load(): Promise<void> {
|
||||
if (this._loadPromise) {
|
||||
// Already started loading, return the load promise.
|
||||
return this._loadPromise;
|
||||
}
|
||||
|
||||
this._loadPromise = new Promise<void>((resolve, reject) => {
|
||||
// The upload listeners: fire progress events and/or settle the promise.
|
||||
const uploadCallbacks = {
|
||||
progress: evt => {
|
||||
if (!evt.lengthComputable) {
|
||||
// Progress computation is not possible.
|
||||
return;
|
||||
}
|
||||
|
||||
this._status = UploadStatus.InProgress;
|
||||
|
||||
this._percentComplete = Math.round((100 * evt.loaded) / evt.total);
|
||||
this._broadcastEvent(EVENTS.PROGRESS, {
|
||||
fileId: this._fileId,
|
||||
percentComplete: this._percentComplete,
|
||||
});
|
||||
},
|
||||
timeout: () => {
|
||||
this._reject(
|
||||
reject,
|
||||
new UploadRejection(UploadStatus.Failed, 'The request timed out.')
|
||||
);
|
||||
},
|
||||
abort: () => {
|
||||
this._reject(
|
||||
reject,
|
||||
new UploadRejection(UploadStatus.Cancelled, 'Cancelled')
|
||||
);
|
||||
},
|
||||
error: () => {
|
||||
this._reject(
|
||||
reject,
|
||||
new UploadRejection(UploadStatus.Failed, 'The request failed.')
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// First try to load the file.
|
||||
dicomImageLoader.wadouri
|
||||
.loadFileRequest(this._fileId)
|
||||
.then(dicomFile => {
|
||||
if (this._abortController.signal.aborted) {
|
||||
this._reject(
|
||||
reject,
|
||||
new UploadRejection(UploadStatus.Cancelled, 'Cancelled')
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._checkDicomFile(dicomFile)) {
|
||||
// The file is not DICOM
|
||||
this._reject(
|
||||
reject,
|
||||
new UploadRejection(
|
||||
UploadStatus.Failed,
|
||||
'Not a valid DICOM file.'
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const request = new XMLHttpRequest();
|
||||
this._addRequestCallbacks(request, uploadCallbacks);
|
||||
|
||||
// Do the actual upload by supplying the DICOM file and upload callbacks/listeners.
|
||||
return this._dataSource.store
|
||||
.dicom(dicomFile, request)
|
||||
.then(() => {
|
||||
this._status = UploadStatus.Success;
|
||||
resolve();
|
||||
})
|
||||
.catch(reason => {
|
||||
this._reject(reject, reason);
|
||||
});
|
||||
})
|
||||
.catch(reason => {
|
||||
this._reject(reject, reason);
|
||||
});
|
||||
});
|
||||
|
||||
return this._loadPromise;
|
||||
}
|
||||
|
||||
private _isRejected(): boolean {
|
||||
return (
|
||||
this._status === UploadStatus.Failed ||
|
||||
this._status === UploadStatus.Cancelled
|
||||
);
|
||||
}
|
||||
|
||||
private _reject(reject: (reason?: any) => void, reason: any) {
|
||||
if (this._isRejected()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (reason instanceof UploadRejection) {
|
||||
this._status = reason.status;
|
||||
reject(reason);
|
||||
return;
|
||||
}
|
||||
|
||||
this._status = UploadStatus.Failed;
|
||||
|
||||
if (reason.message) {
|
||||
reject(new UploadRejection(UploadStatus.Failed, reason.message));
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new UploadRejection(UploadStatus.Failed, reason));
|
||||
}
|
||||
|
||||
private _addRequestCallbacks(request: XMLHttpRequest, uploadCallbacks) {
|
||||
const abortCallback = () => request.abort();
|
||||
this._abortController.signal.addEventListener('abort', abortCallback);
|
||||
|
||||
for (const [eventName, callback] of Object.entries(uploadCallbacks)) {
|
||||
request.upload.addEventListener(eventName, callback);
|
||||
}
|
||||
|
||||
const cleanUpCallback = () => {
|
||||
this._abortController.signal.removeEventListener('abort', abortCallback);
|
||||
|
||||
for (const [eventName, callback] of Object.entries(uploadCallbacks)) {
|
||||
request.upload.removeEventListener(eventName, callback);
|
||||
}
|
||||
|
||||
request.removeEventListener('loadend', cleanUpCallback);
|
||||
};
|
||||
request.addEventListener('loadend', cleanUpCallback);
|
||||
}
|
||||
|
||||
private _checkDicomFile(arrayBuffer: ArrayBuffer) {
|
||||
if (arrayBuffer.length <= 132) return false;
|
||||
const arr = new Uint8Array(arrayBuffer.slice(128, 132));
|
||||
// bytes from 128 to 132 must be "DICM"
|
||||
return Array.from('DICM').every((char, i) => char.charCodeAt(0) === arr[i]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user