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,6 @@
|
||||
.dicom-upload-drop-area-border-dash {
|
||||
background-image: repeating-linear-gradient(to right, #7BB2CE 0%, #7BB2CE 50%, transparent 50%, transparent 100%), repeating-linear-gradient(to right, #7BB2CE 0%, #7BB2CE 50%, transparent 50%, transparent 100%), repeating-linear-gradient(to bottom, #7BB2CE 0%, #7BB2CE 50%, transparent 50%, transparent 100%), repeating-linear-gradient(to bottom, #7BB2CE 0%, #7BB2CE 50%, transparent 50%, transparent 100%);
|
||||
background-position: left top, left bottom, left top, right top;
|
||||
background-repeat: repeat-x, repeat-x, repeat-y, repeat-y;
|
||||
background-size: 20px 3px, 20px 3px, 3px 20px, 3px 20px;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { ReactElement } from 'react';
|
||||
import Dropzone from 'react-dropzone';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import DicomFileUploader from '../../utils/DicomFileUploader';
|
||||
import DicomUploadProgress from './DicomUploadProgress';
|
||||
import { Button } from '@ohif/ui';
|
||||
import './DicomUpload.css';
|
||||
|
||||
type DicomUploadProps = {
|
||||
dataSource;
|
||||
onComplete: () => void;
|
||||
onStarted: () => void;
|
||||
};
|
||||
|
||||
function DicomUpload({
|
||||
dataSource,
|
||||
onComplete,
|
||||
onStarted,
|
||||
}: DicomUploadProps): ReactElement {
|
||||
const baseClassNames = 'min-h-[520px] flex flex-col bg-black select-none';
|
||||
const [dicomFileUploaderArr, setDicomFileUploaderArr] = useState([]);
|
||||
|
||||
const onDrop = useCallback(async acceptedFiles => {
|
||||
onStarted();
|
||||
setDicomFileUploaderArr(
|
||||
acceptedFiles.map(file => new DicomFileUploader(file, dataSource))
|
||||
);
|
||||
}, []);
|
||||
|
||||
const getDropZoneComponent = (): ReactElement => {
|
||||
return (
|
||||
<Dropzone
|
||||
onDrop={acceptedFiles => {
|
||||
onDrop(acceptedFiles);
|
||||
}}
|
||||
noClick
|
||||
>
|
||||
{({ getRootProps }) => (
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className="m-5 dicom-upload-drop-area-border-dash flex flex-col items-center justify-center h-full"
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<Dropzone onDrop={onDrop} noDrag>
|
||||
{({ getRootProps, getInputProps }) => (
|
||||
<div {...getRootProps()}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={false}
|
||||
onClick={() => {}}
|
||||
>
|
||||
{'Add files'}
|
||||
<input {...getInputProps()} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Dropzone>
|
||||
<Dropzone onDrop={onDrop} noDrag>
|
||||
{({ getRootProps, getInputProps }) => (
|
||||
<div {...getRootProps()}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primaryDark"
|
||||
border="primaryActive"
|
||||
disabled={false}
|
||||
onClick={() => {}}
|
||||
>
|
||||
{'Add folder'}
|
||||
<input
|
||||
{...getInputProps()}
|
||||
webkitdirectory="true"
|
||||
mozdirectory="true"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Dropzone>
|
||||
</div>
|
||||
<div className="pt-5">or drag images or folders here</div>
|
||||
<div className="pt-3 text-aqua-pale text-lg">
|
||||
(DICOM files supported)
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Dropzone>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{dicomFileUploaderArr.length ? (
|
||||
<div className={classNames('h-[calc(100vh-300px)]', baseClassNames)}>
|
||||
<DicomUploadProgress
|
||||
dicomFileUploaderArr={Array.from(dicomFileUploaderArr)}
|
||||
onComplete={onComplete}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={classNames('h-[520px]', baseClassNames)}>
|
||||
{getDropZoneComponent()}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
DicomUpload.propTypes = {
|
||||
dataSource: PropTypes.object.isRequired,
|
||||
onComplete: PropTypes.func.isRequired,
|
||||
onStarted: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default DicomUpload;
|
||||
@@ -0,0 +1,436 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
ReactElement,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Button, Icon, ProgressLoadingBar } from '@ohif/ui';
|
||||
import DicomFileUploader, {
|
||||
EVENTS,
|
||||
UploadStatus,
|
||||
DicomFileUploaderProgressEvent,
|
||||
UploadRejection,
|
||||
} from '../../utils/DicomFileUploader';
|
||||
import DicomUploadProgressItem from './DicomUploadProgressItem';
|
||||
import classNames from 'classnames';
|
||||
|
||||
type DicomUploadProgressProps = {
|
||||
dicomFileUploaderArr: DicomFileUploader[];
|
||||
onComplete: () => void;
|
||||
};
|
||||
|
||||
const ONE_SECOND = 1000;
|
||||
const ONE_MINUTE = ONE_SECOND * 60;
|
||||
const ONE_HOUR = ONE_MINUTE * 60;
|
||||
|
||||
// The base/initial interval time length used to calculate the
|
||||
// rate of the upload and in turn estimate the
|
||||
// the amount of time remaining for the upload. This is the length
|
||||
// of the very first interval to get a reasonable estimate on screen in
|
||||
// a reasonable amount of time. The length of each interval after the first
|
||||
// is based on the upload rate calculated. Faster rates use this base interval
|
||||
// length. Slower rates below UPLOAD_RATE_THRESHOLD get longer interval times
|
||||
// to obtain more accurate upload rates.
|
||||
const BASE_INTERVAL_TIME = 15000;
|
||||
|
||||
// The upload rate threshold to determine the length of the interval to
|
||||
// calculate the upload rate.
|
||||
const UPLOAD_RATE_THRESHOLD = 75;
|
||||
|
||||
const NO_WRAP_ELLIPSIS_CLASS_NAMES =
|
||||
'text-ellipsis whitespace-nowrap overflow-hidden';
|
||||
|
||||
function DicomUploadProgress({
|
||||
dicomFileUploaderArr,
|
||||
onComplete,
|
||||
}: DicomUploadProgressProps): ReactElement {
|
||||
const [totalUploadSize] = useState(
|
||||
dicomFileUploaderArr.reduce(
|
||||
(acc, fileUploader) => acc + fileUploader.getFileSize(),
|
||||
0
|
||||
)
|
||||
);
|
||||
|
||||
const currentUploadSizeRef = useRef<number>(0);
|
||||
|
||||
const uploadRateRef = useRef(0);
|
||||
|
||||
const [timeRemaining, setTimeRemaining] = useState<number>(null);
|
||||
|
||||
const [percentComplete, setPercentComplete] = useState(0);
|
||||
|
||||
const [numFilesCompleted, setNumFilesCompleted] = useState(0);
|
||||
|
||||
const [numFails, setNumFails] = useState(0);
|
||||
|
||||
const [showFailedOnly, setShowFailedOnly] = useState(false);
|
||||
|
||||
const progressBarContainerRef = useRef<HTMLElement>();
|
||||
|
||||
/**
|
||||
* The effect for measuring and setting the current upload rate. This is
|
||||
* done by measuring the amount of data uploaded in a set interval time.
|
||||
*/
|
||||
useEffect(() => {
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
|
||||
// The amount of data already uploaded at the start of the interval.
|
||||
let intervalStartUploadSize = 0;
|
||||
|
||||
// The starting time of the interval.
|
||||
let intervalStartTime = Date.now();
|
||||
|
||||
const setUploadRateRef = () => {
|
||||
const uploadSizeFromStartOfInterval =
|
||||
currentUploadSizeRef.current - intervalStartUploadSize;
|
||||
|
||||
const now = Date.now();
|
||||
const timeSinceStartOfInterval = now - intervalStartTime;
|
||||
|
||||
// Calculate and set the upload rate (ref)
|
||||
uploadRateRef.current =
|
||||
uploadSizeFromStartOfInterval / timeSinceStartOfInterval;
|
||||
|
||||
// Reset the interval starting values.
|
||||
intervalStartUploadSize = currentUploadSizeRef.current;
|
||||
intervalStartTime = now;
|
||||
|
||||
// Only start a new interval if there is more to upload.
|
||||
if (totalUploadSize - currentUploadSizeRef.current > 0) {
|
||||
if (uploadRateRef.current >= UPLOAD_RATE_THRESHOLD) {
|
||||
timeoutId = setTimeout(setUploadRateRef, BASE_INTERVAL_TIME);
|
||||
} else {
|
||||
// The current upload rate is relatively slow, so use a larger
|
||||
// time interval to get a better upload rate estimate.
|
||||
timeoutId = setTimeout(setUploadRateRef, BASE_INTERVAL_TIME * 2);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// The very first interval is just the base time interval length.
|
||||
timeoutId = setTimeout(setUploadRateRef, BASE_INTERVAL_TIME);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* The effect for: updating the overall percentage complete; setting the
|
||||
* estimated time remaining; updating the number of files uploaded; and
|
||||
* detecting if any error has occurred.
|
||||
*/
|
||||
useEffect(() => {
|
||||
let currentTimeRemaining = null;
|
||||
|
||||
// For each uploader, listen for the progress percentage complete and
|
||||
// add promise catch/finally callbacks to detect errors and count number
|
||||
// of uploads complete.
|
||||
const subscriptions = dicomFileUploaderArr.map(fileUploader => {
|
||||
let currentFileUploadSize = 0;
|
||||
|
||||
const updateProgress = (percentComplete: number) => {
|
||||
const previousFileUploadSize = currentFileUploadSize;
|
||||
|
||||
currentFileUploadSize = Math.round(
|
||||
(percentComplete / 100) * fileUploader.getFileSize()
|
||||
);
|
||||
|
||||
currentUploadSizeRef.current = Math.min(
|
||||
totalUploadSize,
|
||||
currentUploadSizeRef.current -
|
||||
previousFileUploadSize +
|
||||
currentFileUploadSize
|
||||
);
|
||||
|
||||
setPercentComplete(
|
||||
(currentUploadSizeRef.current / totalUploadSize) * 100
|
||||
);
|
||||
|
||||
if (uploadRateRef.current !== 0) {
|
||||
const uploadSizeRemaining =
|
||||
totalUploadSize - currentUploadSizeRef.current;
|
||||
|
||||
const timeRemaining = Math.round(
|
||||
uploadSizeRemaining / uploadRateRef.current
|
||||
);
|
||||
|
||||
if (currentTimeRemaining === null) {
|
||||
currentTimeRemaining = timeRemaining;
|
||||
setTimeRemaining(currentTimeRemaining);
|
||||
return;
|
||||
}
|
||||
|
||||
// Do not show an increase in the time remaining by two seconds or minutes
|
||||
// so as to prevent jumping the time remaining up and down constantly
|
||||
// due to rounding, inaccuracies in the estimate and slight variations
|
||||
// in upload rates over time.
|
||||
if (timeRemaining < ONE_MINUTE) {
|
||||
const currentSecondsRemaining = Math.ceil(
|
||||
currentTimeRemaining / ONE_SECOND
|
||||
);
|
||||
const secondsRemaining = Math.ceil(timeRemaining / ONE_SECOND);
|
||||
const delta = secondsRemaining - currentSecondsRemaining;
|
||||
if (delta < 0 || delta > 2) {
|
||||
currentTimeRemaining = timeRemaining;
|
||||
setTimeRemaining(currentTimeRemaining);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (timeRemaining < ONE_HOUR) {
|
||||
const currentMinutesRemaining = Math.ceil(
|
||||
currentTimeRemaining / ONE_MINUTE
|
||||
);
|
||||
const minutesRemaining = Math.ceil(timeRemaining / ONE_MINUTE);
|
||||
const delta = minutesRemaining - currentMinutesRemaining;
|
||||
if (delta < 0 || delta > 2) {
|
||||
currentTimeRemaining = timeRemaining;
|
||||
setTimeRemaining(currentTimeRemaining);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Hours remaining...
|
||||
currentTimeRemaining = timeRemaining;
|
||||
setTimeRemaining(currentTimeRemaining);
|
||||
}
|
||||
};
|
||||
|
||||
const progressCallback = (
|
||||
progressEvent: DicomFileUploaderProgressEvent
|
||||
) => {
|
||||
updateProgress(progressEvent.percentComplete);
|
||||
};
|
||||
|
||||
// Use the uploader promise to flag any error and count the number of
|
||||
// uploads completed.
|
||||
fileUploader
|
||||
.load()
|
||||
.catch((rejection: UploadRejection) => {
|
||||
if (rejection.status === UploadStatus.Failed) {
|
||||
setNumFails(numFails => numFails + 1);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
// If any error occurred, the percent complete progress stops firing
|
||||
// but this call to updateProgress nicely puts all finished uploads at 100%.
|
||||
updateProgress(100);
|
||||
setNumFilesCompleted(numCompleted => numCompleted + 1);
|
||||
});
|
||||
|
||||
return fileUploader.subscribe(EVENTS.PROGRESS, progressCallback);
|
||||
});
|
||||
return () => {
|
||||
subscriptions.forEach(subscription => subscription.unsubscribe());
|
||||
};
|
||||
}, []);
|
||||
|
||||
const cancelAllUploads = useCallback(async () => {
|
||||
for (const dicomFileUploader of dicomFileUploaderArr) {
|
||||
// Important: we need a non-blocking way to cancel every upload,
|
||||
// otherwise the UI will freeze and the user will not be able
|
||||
// to interact with the app and progress will not be updated.
|
||||
const promise = new Promise<void>((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
dicomFileUploader.cancel();
|
||||
resolve();
|
||||
}, 0);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getFormattedTimeRemaining = useCallback((): string => {
|
||||
if (timeRemaining == null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (timeRemaining < ONE_MINUTE) {
|
||||
const secondsRemaining = Math.ceil(timeRemaining / ONE_SECOND);
|
||||
return `${secondsRemaining} ${
|
||||
secondsRemaining === 1 ? 'second' : 'seconds'
|
||||
}`;
|
||||
}
|
||||
|
||||
if (timeRemaining < ONE_HOUR) {
|
||||
const minutesRemaining = Math.ceil(timeRemaining / ONE_MINUTE);
|
||||
return `${minutesRemaining} ${
|
||||
minutesRemaining === 1 ? 'minute' : 'minutes'
|
||||
}`;
|
||||
}
|
||||
|
||||
const hoursRemaining = Math.ceil(timeRemaining / ONE_HOUR);
|
||||
return `${hoursRemaining} ${hoursRemaining === 1 ? 'hour' : 'hours'}`;
|
||||
}, [timeRemaining]);
|
||||
|
||||
const getPercentCompleteRounded = useCallback(
|
||||
() => Math.min(100, Math.round(percentComplete)),
|
||||
[percentComplete]
|
||||
);
|
||||
|
||||
/**
|
||||
* Determines if the progress bar should show the infinite animation or not.
|
||||
* Show the infinite animation for progress less than 1% AND if less than
|
||||
* one pixel of the progress bar would be displayed.
|
||||
*/
|
||||
const showInfiniteProgressBar = useCallback((): boolean => {
|
||||
return (
|
||||
getPercentCompleteRounded() < 1 &&
|
||||
(progressBarContainerRef?.current?.offsetWidth ?? 0) *
|
||||
(percentComplete / 100) <
|
||||
1
|
||||
);
|
||||
}, [getPercentCompleteRounded, percentComplete]);
|
||||
|
||||
/**
|
||||
* Gets the css style for the 'n of m' (files completed) text. The only css attribute
|
||||
* of the style is width such that the 'n of m' is always a fixed width and thus
|
||||
* as each file completes uploading the text on screen does not constantly shift
|
||||
* left and right.
|
||||
*/
|
||||
const getNofMFilesStyle = useCallback(() => {
|
||||
// the number of digits accounts for the digits being on each side of the ' of '
|
||||
const numDigits = 2 * dicomFileUploaderArr.length.toString().length;
|
||||
// the number of digits + 2 spaces and 2 characters for ' of '
|
||||
const numChars = numDigits + 4;
|
||||
return { width: `${numChars}ch` };
|
||||
}, []);
|
||||
|
||||
const getNumCompletedAndTimeRemainingComponent = (): ReactElement => {
|
||||
return (
|
||||
<div className="text-lg px-1 pb-4 h-14 flex bg-primary-dark items-center">
|
||||
{numFilesCompleted === dicomFileUploaderArr.length ? (
|
||||
<>
|
||||
<span className={NO_WRAP_ELLIPSIS_CLASS_NAMES}>{`${
|
||||
dicomFileUploaderArr.length
|
||||
} ${
|
||||
dicomFileUploaderArr.length > 1 ? 'files' : 'file'
|
||||
} completed.`}</span>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={false}
|
||||
className="ml-auto"
|
||||
onClick={onComplete}
|
||||
>
|
||||
{'Open Viewer'}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
style={getNofMFilesStyle()}
|
||||
className={classNames(NO_WRAP_ELLIPSIS_CLASS_NAMES, 'text-end')}
|
||||
>
|
||||
{`${numFilesCompleted} of ${dicomFileUploaderArr.length}`}
|
||||
</span>
|
||||
<span className={NO_WRAP_ELLIPSIS_CLASS_NAMES}>
|
||||
{' files completed.'}
|
||||
</span>
|
||||
<span className={NO_WRAP_ELLIPSIS_CLASS_NAMES}>
|
||||
{timeRemaining
|
||||
? `Less than ${getFormattedTimeRemaining()} remaining. `
|
||||
: ''}
|
||||
</span>
|
||||
<span
|
||||
className={classNames(
|
||||
NO_WRAP_ELLIPSIS_CLASS_NAMES,
|
||||
'cursor-pointer text-primary-active hover:text-primary-light active:text-aqua-pale ml-auto'
|
||||
)}
|
||||
onClick={cancelAllUploads}
|
||||
>
|
||||
Cancel All Uploads
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getShowFailedOnlyIconComponent = (): ReactElement => {
|
||||
return (
|
||||
<div className="ml-auto flex justify-center w-6">
|
||||
{numFails > 0 && (
|
||||
<div
|
||||
onClick={() =>
|
||||
setShowFailedOnly(currentShowFailedOnly => !currentShowFailedOnly)
|
||||
}
|
||||
>
|
||||
<Icon className="cursor-pointer" name="icon-status-alert"></Icon>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getPercentCompleteComponent = (): ReactElement => {
|
||||
return (
|
||||
<div className="overflow-y-scroll ohif-scrollbar px-2 border-b border-secondary-light">
|
||||
<div className="flex w-full p-2.5 items-center min-h-14">
|
||||
{numFilesCompleted === dicomFileUploaderArr.length ? (
|
||||
<>
|
||||
<div className="text-xl text-primary-light">
|
||||
{numFails > 0
|
||||
? `Completed with ${numFails} ${
|
||||
numFails > 1 ? 'errors' : 'error'
|
||||
}!`
|
||||
: 'Completed!'}
|
||||
</div>
|
||||
{getShowFailedOnlyIconComponent()}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div ref={progressBarContainerRef} className="flex-grow">
|
||||
<ProgressLoadingBar
|
||||
progress={
|
||||
showInfiniteProgressBar()
|
||||
? undefined
|
||||
: Math.min(100, percentComplete)
|
||||
}
|
||||
></ProgressLoadingBar>
|
||||
</div>
|
||||
<div className="w-24 ml-1 flex items-center">
|
||||
<div className="w-10 text-right">{`${getPercentCompleteRounded()}%`}</div>
|
||||
{getShowFailedOnlyIconComponent()}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col grow">
|
||||
{getNumCompletedAndTimeRemainingComponent()}
|
||||
<div className="flex flex-col bg-black text-lg overflow-hidden grow">
|
||||
{getPercentCompleteComponent()}
|
||||
<div className="overflow-y-scroll ohif-scrollbar px-2 grow h-1">
|
||||
{dicomFileUploaderArr
|
||||
.filter(
|
||||
dicomFileUploader =>
|
||||
!showFailedOnly ||
|
||||
dicomFileUploader.getStatus() === UploadStatus.Failed
|
||||
)
|
||||
.map(dicomFileUploader => (
|
||||
<DicomUploadProgressItem
|
||||
key={dicomFileUploader.getFileId()}
|
||||
dicomFileUploader={dicomFileUploader}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
DicomUploadProgress.propTypes = {
|
||||
dicomFileUploaderArr: PropTypes.arrayOf(
|
||||
PropTypes.instanceOf(DicomFileUploader)
|
||||
).isRequired,
|
||||
onComplete: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default DicomUploadProgress;
|
||||
@@ -0,0 +1,117 @@
|
||||
import React, {
|
||||
ReactElement,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import DicomFileUploader, {
|
||||
DicomFileUploaderProgressEvent,
|
||||
EVENTS,
|
||||
UploadRejection,
|
||||
UploadStatus,
|
||||
} from '../../utils/DicomFileUploader';
|
||||
import { Icon } from '@ohif/ui';
|
||||
|
||||
type DicomUploadProgressItemProps = {
|
||||
dicomFileUploader: DicomFileUploader;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react/display-name
|
||||
const DicomUploadProgressItem = memo(
|
||||
({ dicomFileUploader }: DicomUploadProgressItemProps): ReactElement => {
|
||||
const [percentComplete, setPercentComplete] = useState(
|
||||
dicomFileUploader.getPercentComplete()
|
||||
);
|
||||
const [failedReason, setFailedReason] = useState('');
|
||||
const [status, setStatus] = useState(dicomFileUploader.getStatus());
|
||||
|
||||
console.info(`${dicomFileUploader.getFileId()}`);
|
||||
const isComplete = useCallback(() => {
|
||||
return (
|
||||
status === UploadStatus.Failed ||
|
||||
status === UploadStatus.Cancelled ||
|
||||
status === UploadStatus.Success
|
||||
);
|
||||
}, [status]);
|
||||
|
||||
useEffect(() => {
|
||||
const progressSubscription = dicomFileUploader.subscribe(
|
||||
EVENTS.PROGRESS,
|
||||
(dicomFileUploaderProgressEvent: DicomFileUploaderProgressEvent) => {
|
||||
setPercentComplete(dicomFileUploaderProgressEvent.percentComplete);
|
||||
}
|
||||
);
|
||||
|
||||
dicomFileUploader
|
||||
.load()
|
||||
.catch((reason: UploadRejection) => {
|
||||
setStatus(reason.status);
|
||||
setFailedReason(reason.message ?? '');
|
||||
})
|
||||
.finally(() => setStatus(dicomFileUploader.getStatus()));
|
||||
|
||||
return () => progressSubscription.unsubscribe();
|
||||
}, []);
|
||||
|
||||
const cancelUpload = useCallback(() => {
|
||||
dicomFileUploader.cancel();
|
||||
}, []);
|
||||
|
||||
const getStatusIcon = (): ReactElement => {
|
||||
switch (dicomFileUploader.getStatus()) {
|
||||
case UploadStatus.Success:
|
||||
return (
|
||||
<Icon name="status-tracked" className="text-primary-light"></Icon>
|
||||
);
|
||||
case UploadStatus.InProgress:
|
||||
return <Icon name="icon-transferring"></Icon>;
|
||||
case UploadStatus.Failed:
|
||||
return <Icon name="icon-alert-small"></Icon>;
|
||||
case UploadStatus.Cancelled:
|
||||
return <Icon name="icon-alert-outline"></Icon>;
|
||||
default:
|
||||
return <></>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-full p-2.5 text-lg min-h-14 items-center border-b border-secondary-light overflow-hidden">
|
||||
<div className="flex flex-col gap-1 self-top w-0 grow shrink">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex w-6 justify-center items-center shrink-0">
|
||||
{getStatusIcon()}
|
||||
</div>
|
||||
<div className="text-ellipsis whitespace-nowrap overflow-hidden">
|
||||
{dicomFileUploader.getFileName()}
|
||||
</div>
|
||||
</div>
|
||||
{failedReason && <div className="pl-10">{failedReason}</div>}
|
||||
</div>
|
||||
<div className="w-24 flex items-center">
|
||||
{!isComplete() && (
|
||||
<>
|
||||
{dicomFileUploader.getStatus() === UploadStatus.InProgress && (
|
||||
<div className="w-10 text-right">{percentComplete}%</div>
|
||||
)}
|
||||
<div className="flex cursor-pointer ml-auto">
|
||||
<Icon
|
||||
className="w-6 h-6 self-center text-primary-active"
|
||||
name="close"
|
||||
onClick={cancelUpload}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
DicomUploadProgressItem.propTypes = {
|
||||
dicomFileUploader: PropTypes.instanceOf(DicomFileUploader).isRequired,
|
||||
};
|
||||
|
||||
export default DicomUploadProgressItem;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Enums } from '@cornerstonejs/tools';
|
||||
import { toolNames } from './initCornerstoneTools';
|
||||
import DicomUpload from './components/DicomUpload/DicomUpload';
|
||||
|
||||
const tools = {
|
||||
active: [
|
||||
@@ -22,6 +23,13 @@ const tools = {
|
||||
|
||||
function getCustomizationModule() {
|
||||
return [
|
||||
{
|
||||
name: 'cornerstoneDicomUploadComponent',
|
||||
value: {
|
||||
id: 'dicomUploadComponent',
|
||||
component: DicomUpload,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'default',
|
||||
value: [
|
||||
|
||||
@@ -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