Compare commits
86
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06075ecb51 | ||
|
|
509a7b7e57 | ||
|
|
4bbf24b250 | ||
|
|
ebc01a8ca2 | ||
|
|
4fd0c6dfcb | ||
|
|
06a4af06fa | ||
|
|
6cba41dd8b | ||
|
|
2f30e7a821 | ||
|
|
9a62c28b3f | ||
|
|
98a649b455 | ||
|
|
3529474a16 | ||
|
|
0c37a406d9 | ||
|
|
e8d56c11f0 | ||
|
|
a6b92a4505 | ||
|
|
a4c9a86772 | ||
|
|
73e440968c | ||
|
|
3f7eb8a5d3 | ||
|
|
7b2d44f2c1 | ||
|
|
225da81135 | ||
|
|
686d12da5c | ||
|
|
c22756051b | ||
|
|
8fdfcc0c7d | ||
|
|
44ab65c981 | ||
|
|
b9574b6efc | ||
|
|
96a8e26786 | ||
|
|
80f5b23622 | ||
|
|
2b4950e954 | ||
|
|
e16f14089d | ||
|
|
c3807a89ed | ||
|
|
049ef4a666 | ||
|
|
d5fa880a29 | ||
|
|
1b9a6db73f | ||
|
|
ed2692908e | ||
|
|
0c6c5a5d51 | ||
|
|
b449f8eb0e | ||
|
|
cf5160fb13 | ||
|
|
4c1c91c006 | ||
|
|
9ed39c149b | ||
|
|
b3f0fe029d | ||
|
|
a1b78afdb9 | ||
|
|
3d3cfb1b41 | ||
|
|
b812fe03d2 | ||
|
|
84f087bca4 | ||
|
|
a7e612eb88 | ||
|
|
9e3afda2f1 | ||
|
|
4d7a91a584 | ||
|
|
1dc320edcd | ||
|
|
1d02f5485f | ||
|
|
4c9a977920 | ||
|
|
b5db09c25e | ||
|
|
8dc4a5b82a | ||
|
|
44add9ce2f | ||
|
|
c19eceeede | ||
|
|
72bc5a5ae5 | ||
|
|
dfdf46dbdd | ||
|
|
571dff2c09 | ||
|
|
b9a66d4424 | ||
|
|
7ab2e3e60d | ||
|
|
a5d6bc6a51 | ||
|
|
df612ca4a0 | ||
|
|
a91ce57c43 | ||
|
|
342884f83e | ||
|
|
0fb39bad84 | ||
|
|
c74c80bb6f | ||
|
|
910d5c65d5 | ||
|
|
d0fb4843ab | ||
|
|
f488b857fe | ||
|
|
256e8b1116 | ||
|
|
168dd87747 | ||
|
|
13c41e6d86 | ||
|
|
7ab0bbb325 | ||
|
|
2213187b14 | ||
|
|
4c4d8a61b6 | ||
|
|
67f58f5f09 | ||
|
|
dfc35885bb | ||
|
|
e9c449d1db | ||
|
|
6dac50c7c6 | ||
|
|
9be54f6164 | ||
|
|
341cc5a4eb | ||
|
|
7b80171203 | ||
|
|
ab0af86379 | ||
|
|
86426cb024 | ||
|
|
348840ef26 | ||
|
|
52ef934961 | ||
|
|
4ee2b135a7 | ||
|
|
388fe04e09 |
No files matched your search
@@ -328,6 +328,7 @@ workflows:
|
||||
working_directory: platform/viewer
|
||||
build: npx cross-env QUICK_BUILD=true yarn run build
|
||||
start: yarn run test:e2e:serve
|
||||
spec: 'cypress/integration/common/**/*,cypress/integration/pwa/**/*'
|
||||
wait-on: 'http://localhost:3000'
|
||||
cache-key: 'yarn-packages-{{ checksum "yarn.lock" }}'
|
||||
no-workspace: true # Don't persist workspace
|
||||
@@ -352,7 +353,7 @@ workflows:
|
||||
working_directory: platform/viewer
|
||||
build: npx cross-env QUICK_BUILD=true yarn run build:package
|
||||
start: yarn run test:e2e:serve
|
||||
spec: 'cypress/integration/common/**/*'
|
||||
spec: 'cypress/integration/common/**/*,cypress/integration/script-tag/**/*'
|
||||
wait-on: 'http://localhost:3000'
|
||||
cache-key: 'yarn-packages-{{ checksum "yarn.lock" }}'
|
||||
no-workspace: true # Don't persist workspace
|
||||
|
||||
@@ -1,17 +1,31 @@
|
||||
#!/bin/bash
|
||||
|
||||
# If CLIENT_ID is specified, use the google.js configuration with the modified ID
|
||||
if [ ! -z "$CLIENT_ID" ]
|
||||
if [ -n "$CLIENT_ID" ] || [ -n "$HEALTHCARE_API_ENDPOINT" ]
|
||||
then
|
||||
echo "Google Cloud Healthcare $CLIENT_ID has been provided: "
|
||||
echo $CLIENT_ID
|
||||
echo "Updating config..."
|
||||
# If CLIENT_ID is specified, use the google.js configuration with the modified ID
|
||||
if [ -n "$CLIENT_ID" ]
|
||||
then
|
||||
echo "Google Cloud Healthcare \$CLIENT_ID has been provided: "
|
||||
echo "$CLIENT_ID"
|
||||
echo "Updating config..."
|
||||
|
||||
# - Use SED to replace the CLIENT_ID that is currently in google.js
|
||||
sed -i -e "s/YOURCLIENTID.apps.googleusercontent.com/$CLIENT_ID/g" /usr/share/nginx/html/google.js
|
||||
# - Use SED to replace the CLIENT_ID that is currently in google.js
|
||||
sed -i -e "s/YOURCLIENTID.apps.googleusercontent.com/$CLIENT_ID/g" /usr/share/nginx/html/google.js
|
||||
fi
|
||||
|
||||
# - Copy google.js to overwrite app-config.js
|
||||
cp /usr/share/nginx/html/google.js /usr/share/nginx/html/app-config.js
|
||||
# If HEALTHCARE_API_ENDPOINT is specified, use the google.js configuration with the modified endpoint
|
||||
if [ -n "$HEALTHCARE_API_ENDPOINT" ]
|
||||
then
|
||||
echo "Google Cloud Healthcare \$HEALTHCARE_API_ENDPOINT has been provided: "
|
||||
echo "$HEALTHCARE_API_ENDPOINT"
|
||||
echo "Updating config..."
|
||||
|
||||
# - Use SED to replace the HEALTHCARE_API_ENDPOINT that is currently in google.js
|
||||
sed -i -e "s+https://healthcare.googleapis.com/v1beta1+$HEALTHCARE_API_ENDPOINT+g" /usr/share/nginx/html/google.js
|
||||
fi
|
||||
|
||||
# - Copy google.js to overwrite app-config.js
|
||||
cp /usr/share/nginx/html/google.js /usr/share/nginx/html/app-config.js
|
||||
fi
|
||||
|
||||
echo "Starting Nginx to serve the OHIF Viewer..."
|
||||
|
||||
+2
-1
@@ -12,10 +12,11 @@ exemptLabels:
|
||||
- 'Task: CI/Tooling :robot:'
|
||||
- 'Task: Docs 📖'
|
||||
- 'Task: Docs :book:'
|
||||
- 'Task: Refactor 🛠'
|
||||
- 'Task: Refactor :hammer_and_wrench:'
|
||||
- 'Task: Tests :microscope:'
|
||||
- 'PR: Awaiting Review 👀'
|
||||
- 'Triage :white_flag:'
|
||||
- 'Extension: Discussion'
|
||||
- 'Announcement 🎉'
|
||||
# Label to use when marking an issue as stale
|
||||
staleLabel: 'Stale :baguette_bread:'
|
||||
|
||||
@@ -3,6 +3,47 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [2.4.0](https://github.com/OHIF/Viewers/compare/@ohif/extension-cornerstone@2.3.1...@ohif/extension-cornerstone@2.4.0) (2020-02-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* [#1342](https://github.com/OHIF/Viewers/issues/1342) - Window level tab ([#1429](https://github.com/OHIF/Viewers/issues/1429)) ([ebc01a8](https://github.com/OHIF/Viewers/commit/ebc01a8ca238d5a3437b44d81f75aa8a5e8d0574))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.3.1](https://github.com/OHIF/Viewers/compare/@ohif/extension-cornerstone@2.3.0...@ohif/extension-cornerstone@2.3.1) (2020-02-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Creating 2 commands to activate zoom tool and also to move between displaySets ([#1446](https://github.com/OHIF/Viewers/issues/1446)) ([06a4af0](https://github.com/OHIF/Viewers/commit/06a4af06faaecf6fa06ccd90cdfa879ee8d53053))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [2.3.0](https://github.com/OHIF/Viewers/compare/@ohif/extension-cornerstone@2.2.2...@ohif/extension-cornerstone@2.3.0) (2020-02-10)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* 🎸 MeasurementService ([#1314](https://github.com/OHIF/Viewers/issues/1314)) ([0c37a40](https://github.com/OHIF/Viewers/commit/0c37a406d963569af8c3be24c697dafd42712dfc))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.2.2](https://github.com/OHIF/Viewers/compare/@ohif/extension-cornerstone@2.2.1...@ohif/extension-cornerstone@2.2.2) (2020-01-28)
|
||||
|
||||
**Note:** Version bump only for package @ohif/extension-cornerstone
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.2.1](https://github.com/OHIF/Viewers/compare/@ohif/extension-cornerstone@2.2.0...@ohif/extension-cornerstone@2.2.1) (2019-12-20)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require("../../babel.config.js");
|
||||
@@ -0,0 +1,13 @@
|
||||
const base = require('../../jest.config.base.js');
|
||||
const pkg = require('./package');
|
||||
|
||||
module.exports = {
|
||||
...base,
|
||||
name: pkg.name,
|
||||
displayName: pkg.name,
|
||||
// rootDir: "../.."
|
||||
// testMatch: [
|
||||
// //`<rootDir>/platform/${pack.name}/**/*.spec.js`
|
||||
// "<rootDir>/platform/viewer/**/*.test.js"
|
||||
// ]
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ohif/extension-cornerstone",
|
||||
"version": "2.2.1",
|
||||
"version": "2.4.0",
|
||||
"description": "OHIF extension for Cornerstone",
|
||||
"author": "OHIF",
|
||||
"license": "MIT",
|
||||
@@ -25,14 +25,16 @@
|
||||
"build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js",
|
||||
"build:package": "yarn run build",
|
||||
"prepublishOnly": "yarn run build",
|
||||
"start": "yarn run dev"
|
||||
"start": "yarn run dev",
|
||||
"test:unit": "jest --watchAll",
|
||||
"test:unit:ci": "jest --ci --runInBand --collectCoverage"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@ohif/core": "^0.50.0",
|
||||
"@ohif/ui": "^0.50.0",
|
||||
"cornerstone-core": "^2.2.8",
|
||||
"cornerstone-math": "^0.1.8",
|
||||
"cornerstone-tools": "^4.9.0",
|
||||
"cornerstone-tools": "^4.12.0",
|
||||
"cornerstone-wado-image-loader": "^3.0.0",
|
||||
"dcmjs": "^0.8.2",
|
||||
"dicom-parser": "^1.8.3",
|
||||
|
||||
@@ -74,10 +74,6 @@ const commandsModule = ({ servicesManager }) => {
|
||||
}
|
||||
cornerstoneTools.setToolActive(toolName, { mouseButtonMask: 1 });
|
||||
},
|
||||
updateViewportDisplaySet: ({ direction }) => {
|
||||
// TODO
|
||||
console.warn('updateDisplaySet: ', direction);
|
||||
},
|
||||
clearAnnotations: ({ viewports }) => {
|
||||
const element = getEnabledElement(viewports.activeViewportIndex);
|
||||
if (!element) {
|
||||
@@ -245,7 +241,19 @@ const commandsModule = ({ servicesManager }) => {
|
||||
},
|
||||
setCornerstoneLayout: () => {
|
||||
setCornerstoneLayout();
|
||||
}
|
||||
},
|
||||
setWindowLevel: ({ viewports, window, level }) => {
|
||||
const enabledElement = getEnabledElement(viewports.activeViewportIndex);
|
||||
|
||||
if (enabledElement) {
|
||||
let viewport = cornerstone.getViewport(enabledElement);
|
||||
viewport.voi = {
|
||||
windowWidth: Number(window),
|
||||
windowCenter: Number(level),
|
||||
};
|
||||
cornerstone.setViewport(enabledElement, viewport);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const definitions = {
|
||||
@@ -334,30 +342,28 @@ const commandsModule = ({ servicesManager }) => {
|
||||
storeContexts: ['viewports'],
|
||||
options: {},
|
||||
},
|
||||
// TODO: First/Last image
|
||||
// Next/Previous series/DisplaySet
|
||||
nextViewportDisplaySet: {
|
||||
commandFn: actions.updateViewportDisplaySet,
|
||||
storeContexts: [],
|
||||
options: { direction: 1 },
|
||||
},
|
||||
previousViewportDisplaySet: {
|
||||
commandFn: actions.updateViewportDisplaySet,
|
||||
storeContexts: [],
|
||||
options: { direction: -1 },
|
||||
},
|
||||
// TOOLS
|
||||
setToolActive: {
|
||||
commandFn: actions.setToolActive,
|
||||
storeContexts: [],
|
||||
options: {},
|
||||
},
|
||||
setZoomTool: {
|
||||
commandFn: actions.setToolActive,
|
||||
storeContexts: [],
|
||||
options: { toolName: 'Zoom' },
|
||||
},
|
||||
setCornerstoneLayout: {
|
||||
commandFn: actions.setCornerstoneLayout,
|
||||
storeContexts: [],
|
||||
options: {},
|
||||
context: 'VIEWER',
|
||||
},
|
||||
setWindowLevel: {
|
||||
commandFn: actions.setWindowLevel,
|
||||
storeContexts: ['viewports'],
|
||||
options: {},
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -5,6 +5,7 @@ import csTools from 'cornerstone-tools';
|
||||
import merge from 'lodash.merge';
|
||||
import queryString from 'query-string';
|
||||
import initCornerstoneTools from './initCornerstoneTools.js';
|
||||
import measurementServiceMappingsFactory from './utils/measurementServiceMappings/measurementServiceMappingsFactory';
|
||||
|
||||
function fallbackMetaDataProvider(type, imageId) {
|
||||
if (!imageId.includes('wado?requestType=WADO')) {
|
||||
@@ -17,7 +18,7 @@ function fallbackMetaDataProvider(type, imageId) {
|
||||
const wadoRoot = window.store.getState().servers.servers[0].wadoRoot;
|
||||
const wadoRsImageId = `wadors:${wadoRoot}/studies/${qs.studyUID}/series/${
|
||||
qs.seriesUID
|
||||
}/instances/${qs.objectUID}/frames/${qs.frame || 1}`;
|
||||
}/instances/${qs.objectUID}/frames/${qs.frame || 1}`;
|
||||
|
||||
return cornerstone.metaData.get(type, wadoRsImageId);
|
||||
}
|
||||
@@ -32,9 +33,9 @@ cornerstone.metaData.addProvider(fallbackMetaDataProvider, -1);
|
||||
* @param {Object|Array} configuration.csToolsConfig
|
||||
*/
|
||||
export default function init({ servicesManager, configuration }) {
|
||||
const callInputDialog = (data, event, callback) => {
|
||||
const { UIDialogService } = servicesManager.services;
|
||||
const { UIDialogService, MeasurementService } = servicesManager.services;
|
||||
|
||||
const callInputDialog = (data, event, callback) => {
|
||||
if (UIDialogService) {
|
||||
let dialogId = UIDialogService.create({
|
||||
centralize: true,
|
||||
@@ -98,6 +99,7 @@ export default function init({ servicesManager, configuration }) {
|
||||
csTools.MagnifyTool,
|
||||
csTools.StackScrollTool,
|
||||
csTools.StackScrollMouseWheelTool,
|
||||
csTools.OverlayTool,
|
||||
],
|
||||
};
|
||||
|
||||
@@ -106,6 +108,9 @@ export default function init({ servicesManager, configuration }) {
|
||||
tools.push(...toolsGroupedByType[toolsGroup])
|
||||
);
|
||||
|
||||
/* Measurement Service */
|
||||
_connectToolsToMeasurementService(MeasurementService);
|
||||
|
||||
/* Add extension tools configuration here. */
|
||||
const internalToolsConfig = {
|
||||
ArrowAnnotate: {
|
||||
@@ -179,4 +184,113 @@ export default function init({ servicesManager, configuration }) {
|
||||
csTools.setToolActive('StackScrollMouseWheel', {}); // TODO: Empty options should not be required
|
||||
csTools.setToolActive('PanMultiTouch', { pointers: 2 }); // TODO: Better error if no options
|
||||
csTools.setToolActive('ZoomTouchPinch', {});
|
||||
csTools.setToolEnabled('Overlay', {});
|
||||
}
|
||||
|
||||
const _initMeasurementService = measurementService => {
|
||||
/* Initialization */
|
||||
const { toAnnotation, toMeasurement } = measurementServiceMappingsFactory(measurementService);
|
||||
const csToolsVer4MeasurementSource = measurementService.createSource(
|
||||
'CornerstoneTools',
|
||||
'4'
|
||||
);
|
||||
|
||||
/* Matching Criterias */
|
||||
const matchingCriteria = {
|
||||
valueType: measurementService.VALUE_TYPES.POLYLINE,
|
||||
points: 2,
|
||||
};
|
||||
|
||||
/* Mappings */
|
||||
measurementService.addMapping(
|
||||
csToolsVer4MeasurementSource,
|
||||
'Length',
|
||||
matchingCriteria,
|
||||
toAnnotation,
|
||||
toMeasurement
|
||||
);
|
||||
|
||||
return csToolsVer4MeasurementSource;
|
||||
};
|
||||
|
||||
const _connectToolsToMeasurementService = measurementService => {
|
||||
const csToolsVer4MeasurementSource = _initMeasurementService(measurementService);
|
||||
const {
|
||||
id: sourceId,
|
||||
addOrUpdate,
|
||||
getAnnotation,
|
||||
} = csToolsVer4MeasurementSource;
|
||||
|
||||
/* Measurement Service Events */
|
||||
cornerstone.events.addEventListener(
|
||||
cornerstone.EVENTS.ELEMENT_ENABLED,
|
||||
event => {
|
||||
const {
|
||||
MEASUREMENT_ADDED,
|
||||
MEASUREMENT_UPDATED,
|
||||
} = measurementService.EVENTS;
|
||||
|
||||
measurementService.subscribe(
|
||||
MEASUREMENT_ADDED,
|
||||
({ source, measurement }) => {
|
||||
if (![sourceId].includes(source.id)) {
|
||||
const annotation = getAnnotation('Length', measurement.id);
|
||||
|
||||
console.log(
|
||||
'Measurement Service [Cornerstone]: Measurement added',
|
||||
measurement
|
||||
);
|
||||
console.log('Mapped annotation:', annotation);
|
||||
}
|
||||
});
|
||||
|
||||
measurementService.subscribe(
|
||||
MEASUREMENT_UPDATED,
|
||||
({ source, measurement }) => {
|
||||
if (![sourceId].includes(source.id)) {
|
||||
const annotation = getAnnotation('Length', measurement.id);
|
||||
|
||||
console.log(
|
||||
'Measurement Service [Cornerstone]: Measurement updated',
|
||||
measurement
|
||||
);
|
||||
console.log('Mapped annotation:', annotation);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const addOrUpdateMeasurement = csToolsAnnotation => {
|
||||
try {
|
||||
const { toolName, toolType, measurementData } = csToolsAnnotation;
|
||||
const csTool = toolName || measurementData.toolType || toolType;
|
||||
csToolsAnnotation.id = measurementData._measurementServiceId;
|
||||
const measurementServiceId = addOrUpdate(csTool, csToolsAnnotation);
|
||||
|
||||
if (!measurementData._measurementServiceId) {
|
||||
addMeasurementServiceId(measurementServiceId, csToolsAnnotation);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to add or update measurement:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const addMeasurementServiceId = (id, csToolsAnnotation) => {
|
||||
const { measurementData } = csToolsAnnotation;
|
||||
Object.assign(measurementData, { _measurementServiceId: id });
|
||||
};
|
||||
|
||||
[
|
||||
csTools.EVENTS.MEASUREMENT_ADDED,
|
||||
csTools.EVENTS.MEASUREMENT_MODIFIED,
|
||||
].forEach(csToolsEvtName => {
|
||||
event.detail.element.addEventListener(
|
||||
csToolsEvtName,
|
||||
({ detail: csToolsAnnotation }) => {
|
||||
console.log(`Cornerstone Element Event: ${csToolsEvtName}`);
|
||||
addOrUpdateMeasurement(csToolsAnnotation);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
};
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
import cornerstone from 'cornerstone-core';
|
||||
|
||||
const SUPPORTED_TOOLS = ['Length', 'EllipticalRoi', 'RectangleRoi', 'ArrowAnnotate'];
|
||||
|
||||
const measurementServiceMappingsFactory = measurementService => {
|
||||
/**
|
||||
* Maps measurement service format object to cornerstone annotation object.
|
||||
*
|
||||
* @param {Measurement} measurement The measurement instance
|
||||
* @param {string} definition The source definition
|
||||
* @return {Object} Cornerstone annotation data
|
||||
*/
|
||||
const toAnnotation = (measurement, definition) => {
|
||||
const {
|
||||
id,
|
||||
label,
|
||||
description,
|
||||
points,
|
||||
unit,
|
||||
sopInstanceUID,
|
||||
frameOfReferenceUID,
|
||||
referenceSeriesUID,
|
||||
} = measurement;
|
||||
|
||||
return {
|
||||
toolName: definition,
|
||||
measurementData: {
|
||||
sopInstanceUid: sopInstanceUID,
|
||||
frameOfReferenceUid: frameOfReferenceUID,
|
||||
seriesInstanceUid: referenceSeriesUID,
|
||||
unit,
|
||||
text: label,
|
||||
description,
|
||||
handles: _getHandlesFromPoints(points),
|
||||
_measurementServiceId: id,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps cornerstone annotation event data to measurement service format.
|
||||
*
|
||||
* @param {Object} cornerstone Cornerstone event data
|
||||
* @return {Measurement} Measurement instance
|
||||
*/
|
||||
const toMeasurement = csToolsAnnotation => {
|
||||
const { element, measurementData } = csToolsAnnotation;
|
||||
const tool =
|
||||
csToolsAnnotation.toolType ||
|
||||
csToolsAnnotation.toolName ||
|
||||
measurementData.toolType;
|
||||
|
||||
const validToolType = toolName => SUPPORTED_TOOLS.includes(toolName);
|
||||
|
||||
if (!validToolType(tool)) {
|
||||
throw new Error('Tool not supported');
|
||||
}
|
||||
|
||||
const {
|
||||
sopInstanceUid,
|
||||
frameOfReferenceUid,
|
||||
seriesInstanceUid,
|
||||
} = _getAttributes(element);
|
||||
|
||||
const points = [];
|
||||
points.push(measurementData.handles);
|
||||
|
||||
return {
|
||||
id: measurementData._measurementServiceId,
|
||||
sopInstanceUID: sopInstanceUid,
|
||||
frameOfReferenceUID: frameOfReferenceUid,
|
||||
referenceSeriesUID: seriesInstanceUid,
|
||||
label: measurementData.text,
|
||||
description: measurementData.description,
|
||||
unit: measurementData.unit,
|
||||
area: measurementData.cachedStats && measurementData.cachedStats.area, /* TODO: Add concept names instead (descriptor) */
|
||||
type: _getValueTypeFromToolType(tool),
|
||||
points: _getPointsFromHandles(measurementData.handles),
|
||||
};
|
||||
};
|
||||
|
||||
const _getAttributes = element => {
|
||||
const enabledElement = cornerstone.getEnabledElement(element);
|
||||
const imageId = enabledElement.image.imageId;
|
||||
const sopInstance = cornerstone.metaData.get('instance', imageId);
|
||||
const sopInstanceUid = sopInstance.sopInstanceUid;
|
||||
const frameOfReferenceUid = sopInstance.frameOfReferenceUID;
|
||||
const series = cornerstone.metaData.get('series', imageId);
|
||||
const seriesInstanceUid = series.seriesInstanceUid;
|
||||
|
||||
return { sopInstanceUid, frameOfReferenceUid, seriesInstanceUid };
|
||||
};
|
||||
|
||||
const _getValueTypeFromToolType = toolType => {
|
||||
const { POLYLINE, ELLIPSE, POINT } = measurementService.VALUE_TYPES;
|
||||
|
||||
/* TODO: Relocate static value types */
|
||||
const TOOL_TYPE_TO_VALUE_TYPE = {
|
||||
Length: POLYLINE,
|
||||
EllipticalRoi: ELLIPSE,
|
||||
RectangleRoi: POLYLINE,
|
||||
ArrowAnnotate: POINT,
|
||||
};
|
||||
|
||||
return TOOL_TYPE_TO_VALUE_TYPE[toolType];
|
||||
};
|
||||
|
||||
const _getPointsFromHandles = handles => {
|
||||
let points = [];
|
||||
Object.keys(handles).map(handle => {
|
||||
if (['start', 'end'].includes(handle)) {
|
||||
let point = {};
|
||||
if (handles[handle].x) point.x = handles[handle].x;
|
||||
if (handles[handle].y) point.y = handles[handle].y;
|
||||
points.push(point);
|
||||
}
|
||||
});
|
||||
return points;
|
||||
};
|
||||
|
||||
const _getHandlesFromPoints = points => {
|
||||
return points
|
||||
.map((p, i) => (i % 10 === 0 ? { start: p } : { end: p }))
|
||||
.reduce((obj, item) => Object.assign(obj, { ...item }), {});
|
||||
};
|
||||
|
||||
return {
|
||||
toAnnotation,
|
||||
toMeasurement,
|
||||
};
|
||||
};
|
||||
|
||||
export default measurementServiceMappingsFactory;
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
import measurementServiceMappingsFactory from './measurementServiceMappingsFactory';
|
||||
|
||||
jest.mock('cornerstone-core', () => ({
|
||||
...jest.requireActual('cornerstone-core'),
|
||||
getEnabledElement: () => ({
|
||||
image: { imageId: 123 },
|
||||
}),
|
||||
metaData: {
|
||||
...jest.requireActual('cornerstone-core').metaData,
|
||||
get: () => ({
|
||||
sopInstanceUid: "123",
|
||||
frameOfReferenceUID: "123",
|
||||
seriesInstanceUid: "123",
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('measurementServiceMappings.js', () => {
|
||||
let mappings;
|
||||
let handles;
|
||||
let points;
|
||||
let csToolsAnnotation;
|
||||
let measurement;
|
||||
let measurementServiceMock;
|
||||
let definition = 'Length';
|
||||
|
||||
beforeEach(() => {
|
||||
measurementServiceMock = {
|
||||
VALUE_TYPES: {
|
||||
POLYLINE: 'value_type::polyline',
|
||||
POINT: 'value_type::point',
|
||||
ELLIPSE: 'value_type::ellipse',
|
||||
MULTIPOINT: 'value_type::multipoint',
|
||||
CIRCLE: 'value_type::circle',
|
||||
},
|
||||
};
|
||||
mappings = measurementServiceMappingsFactory(measurementServiceMock);
|
||||
handles = { start: { x: 1, y: 2 }, end: { x: 1, y: 2 } };
|
||||
points = [{ x: 1, y: 2 }, { x: 1, y: 2 }];
|
||||
csToolsAnnotation = {
|
||||
toolName: definition,
|
||||
measurementData: {
|
||||
_measurementServiceId: 1,
|
||||
sopInstanceUid: '123',
|
||||
frameOfReferenceUid: '123',
|
||||
seriesInstanceUid: '123',
|
||||
handles,
|
||||
text: 'Test',
|
||||
description: 'Test',
|
||||
unit: 'mm',
|
||||
},
|
||||
};
|
||||
measurement = {
|
||||
id: 1,
|
||||
sopInstanceUID: '123',
|
||||
frameOfReferenceUID: '123',
|
||||
referenceSeriesUID: '123',
|
||||
label: 'Test',
|
||||
description: 'Test',
|
||||
unit: 'mm',
|
||||
type: measurementServiceMock.VALUE_TYPES.POLYLINE,
|
||||
points: points,
|
||||
};
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('toAnnotation()', () => {
|
||||
it('map measurement service format to annotation', async () => {
|
||||
const mappedMeasurement = await mappings.toAnnotation(measurement, definition);
|
||||
expect(mappedMeasurement).toEqual(csToolsAnnotation);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toMeasurement()', () => {
|
||||
it('map annotation to measurement service format', async () => {
|
||||
const mappedAnnotation = await mappings.toMeasurement(csToolsAnnotation);
|
||||
expect(mappedAnnotation).toEqual(measurement);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
const path = require('path');
|
||||
const webpackCommon = require('./../../../.webpack/webpack.commonjs.js');
|
||||
const SRC_DIR = path.join(__dirname, '../src');
|
||||
const DIST_DIR = path.join(__dirname, '../dist');
|
||||
|
||||
module.exports = (env, argv) => {
|
||||
return webpackCommon(env, argv, { SRC_DIR, DIST_DIR });
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
const merge = require('webpack-merge');
|
||||
const path = require('path');
|
||||
const webpackCommon = require('./../../../.webpack/webpack.commonjs.js');
|
||||
const pkg = require('./../package.json');
|
||||
|
||||
const ROOT_DIR = path.join(__dirname, './..');
|
||||
const SRC_DIR = path.join(__dirname, '../src');
|
||||
const DIST_DIR = path.join(__dirname, '../dist');
|
||||
|
||||
module.exports = (env, argv) => {
|
||||
const commonConfig = webpackCommon(env, argv, { SRC_DIR, DIST_DIR });
|
||||
|
||||
return merge(commonConfig, {
|
||||
devtool: 'source-map',
|
||||
stats: {
|
||||
colors: true,
|
||||
hash: true,
|
||||
timings: true,
|
||||
assets: true,
|
||||
chunks: false,
|
||||
chunkModules: false,
|
||||
modules: false,
|
||||
children: false,
|
||||
warnings: true,
|
||||
},
|
||||
optimization: {
|
||||
minimize: true,
|
||||
sideEffects: true,
|
||||
},
|
||||
output: {
|
||||
path: ROOT_DIR,
|
||||
library: 'OHIFExtLesionTracker',
|
||||
libraryTarget: 'umd',
|
||||
libraryExport: 'default',
|
||||
filename: pkg.main,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
# Change Log
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [0.2.0](https://github.com/OHIF/Viewers/compare/@ohif/extension-lesion-tracker@0.1.0...@ohif/extension-lesion-tracker@0.2.0) (2020-02-10)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Lesion tracker right panel ([#1428](https://github.com/OHIF/Viewers/issues/1428)) ([98a649b](https://github.com/OHIF/Viewers/commit/98a649b455ffc712938fc5035cdef40695e58440))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# 0.1.0 (2020-02-06)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* lesion-tracker extension ([#1420](https://github.com/OHIF/Viewers/issues/1420)) ([73e4409](https://github.com/OHIF/Viewers/commit/73e440968ce4699d081a9c9f2d21dd68095b3056))
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2018 Open Health Imaging Foundation
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,38 @@
|
||||
# @ohif/extension-lesion-tracker
|
||||
|
||||
This project is an OHIF extension that can be used with the Core OHIF Platform,
|
||||
either at runtime, or as an ES6 dependency, to create a medical image viewing
|
||||
application similar to that of the [legacy Lesion
|
||||
Tracker][legacy-lesion-tracker] viewer.
|
||||
|
||||
## About
|
||||
|
||||
...
|
||||
|
||||
## Scope
|
||||
|
||||
...
|
||||
|
||||
### Configuration
|
||||
|
||||
...
|
||||
|
||||
### Extensions
|
||||
|
||||
...
|
||||
|
||||
## Build & Deploy
|
||||
|
||||
...
|
||||
|
||||
## Funding/Support
|
||||
|
||||
...
|
||||
|
||||
<!--
|
||||
LINKS
|
||||
-->
|
||||
|
||||
<!-- prettier-ignore-start -->
|
||||
[legacy-lesion-tracker]: http://lesiontracker.ohif.org/studylist
|
||||
<!-- prettier-ignore-end -->
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@ohif/extension-lesion-tracker",
|
||||
"version": "0.2.0",
|
||||
"description": "OHIF extension for Lesion Tracker",
|
||||
"author": "OHIF",
|
||||
"license": "MIT",
|
||||
"repository": "OHIF/Viewers",
|
||||
"main": "dist/index.umd.js",
|
||||
"module": "src/index.js",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10",
|
||||
"npm": ">=6",
|
||||
"yarn": ">=1.16.0"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "cross-env NODE_ENV=development webpack --config .webpack/webpack.dev.js --watch --debug --output-pathinfo",
|
||||
"dev:lesion-tracker": "yarn run dev",
|
||||
"build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js",
|
||||
"build:package": "yarn run build",
|
||||
"prepublishOnly": "yarn run build",
|
||||
"start": "yarn run dev"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@ohif/core": "^0.50.0",
|
||||
"prop-types": "^15.6.2",
|
||||
"react": "^16.8.6",
|
||||
"react-dom": "^16.8.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.5.5",
|
||||
"classnames": "^2.2.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
.MeasurementComparisonTable {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.MeasurementComparisonTable .displayTexts {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.MeasurementComparisonTable .displayTexts .measurementDisplayText {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.MeasurementComparisonTable .measurementTableHeader {
|
||||
display: flex;
|
||||
position: relative;
|
||||
padding-left: 38px;
|
||||
}
|
||||
|
||||
.MeasurementComparisonTable .measurementTableHeader .warning-status {
|
||||
left: 0;
|
||||
top: 3px;
|
||||
cursor: pointer;
|
||||
width: 40px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.MeasurementComparisonTable .measurementTableHeader .warning-status .warning-border {
|
||||
padding: 2px 3px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.MeasurementComparisonTable .measurementTableHeader .warning-status svg {
|
||||
width: 20px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.MeasurementComparisonTable .measurementTableHeader .measurementTableHeaderItem {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.MeasurementComparisonTable .generate-report {
|
||||
background-color: #151a1f;
|
||||
margin-top: 2px;
|
||||
padding: 10px 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.MeasurementComparisonTable .generate-report button {
|
||||
font-size: 12px;
|
||||
color: black;
|
||||
background-color: var(--active-color);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import React from 'react';
|
||||
|
||||
import { MeasurementTable } from '@ohif/ui';
|
||||
|
||||
import './MeasurementComparisonTable.css';
|
||||
|
||||
const overallWarnings = {
|
||||
warningList: [
|
||||
'All measurements should have a location',
|
||||
'Nodal lesions must be >= 15mm short axis AND >= double the acquisition slice thickness by CT and MR',
|
||||
],
|
||||
};
|
||||
|
||||
const measurements = [
|
||||
{
|
||||
measurementId: '125',
|
||||
measurementNumber: '125',
|
||||
itemNumber: 1,
|
||||
label: '(No description)',
|
||||
data: [{ displayText: '12.5 x 4.6' }],
|
||||
},
|
||||
{
|
||||
measurementId: '124',
|
||||
measurementNumber: '124',
|
||||
itemNumber: 2,
|
||||
label: '(No description)',
|
||||
data: [{ displayText: '32.5 x 1.6' }],
|
||||
},
|
||||
{
|
||||
measurementId: '123',
|
||||
measurementNumber: '123',
|
||||
itemNumber: 3,
|
||||
hasWarnings: true,
|
||||
warningList: [
|
||||
'All measurements should have a location',
|
||||
'Nodal lesions must be >= 15mm short axis AND >= double the acquisition slice thickness by CT and MR',
|
||||
],
|
||||
label: '(No description)',
|
||||
data: [{ displayText: '5.5 x 9.2' }],
|
||||
},
|
||||
];
|
||||
|
||||
const additionalFindings = [
|
||||
{
|
||||
measurementId: '122',
|
||||
measurementNumber: '122',
|
||||
itemNumber: 1,
|
||||
hasWarnings: true,
|
||||
warningList: [
|
||||
'All measurements should have a location',
|
||||
'Nodal lesions must be >= 15mm short axis AND >= double the acquisition slice thickness by CT and MR',
|
||||
],
|
||||
label: '(No description)',
|
||||
data: [{ displayText: '23.5 x 9.2' }],
|
||||
},
|
||||
{
|
||||
measurementId: '121',
|
||||
measurementNumber: '121',
|
||||
itemNumber: 2,
|
||||
hasWarnings: true,
|
||||
warningList: [
|
||||
'All measurements should have a location',
|
||||
'Nodal lesions must be >= 15mm short axis AND >= double the acquisition slice thickness by CT and MR',
|
||||
],
|
||||
label: '(No description)',
|
||||
data: [{ displayText: '11.2 x 9.2' }],
|
||||
},
|
||||
{
|
||||
measurementId: '120',
|
||||
measurementNumber: '120',
|
||||
itemNumber: 3,
|
||||
label: '(No description)',
|
||||
data: [{ displayText: '2.9 x 9.2' }],
|
||||
},
|
||||
];
|
||||
|
||||
const currentCollections = [
|
||||
{
|
||||
selectorAction: () => { },
|
||||
maxMeasurements: 3,
|
||||
groupName: 'Measurements',
|
||||
measurements: measurements,
|
||||
},
|
||||
{
|
||||
selectorAction: () => { },
|
||||
groupName: 'Additional Findings',
|
||||
measurements: additionalFindings,
|
||||
},
|
||||
];
|
||||
|
||||
const comparisonColletions = [
|
||||
{
|
||||
selectorAction: () => { },
|
||||
maxMeasurements: 3,
|
||||
groupName: 'Measurements',
|
||||
measurements: measurements,
|
||||
},
|
||||
{
|
||||
selectorAction: () => { },
|
||||
groupName: 'Additional Findings',
|
||||
measurements: additionalFindings,
|
||||
},
|
||||
];
|
||||
|
||||
const comparisonCollections = currentCollections.map((group, index) => {
|
||||
return {
|
||||
...group,
|
||||
measurements: group.measurements.map((measurement, measurementIndex) => {
|
||||
const comparisonCollection = comparisonColletions[index].measurements;
|
||||
if (measurementIndex < comparisonCollection.length) {
|
||||
return {
|
||||
...measurement,
|
||||
data: [
|
||||
...measurement.data,
|
||||
...comparisonCollection[measurementIndex].data,
|
||||
],
|
||||
};
|
||||
}
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const comparisonTimepoints = [
|
||||
{
|
||||
key: 'Current',
|
||||
date: '10-Apr-18',
|
||||
},
|
||||
{
|
||||
key: 'Comparison',
|
||||
date: '15-Jun-18',
|
||||
}
|
||||
];
|
||||
|
||||
const MeasurementComparisonTable = () => {
|
||||
return (
|
||||
<div className="MeasurementComparisonTable">
|
||||
<MeasurementTable
|
||||
timepoints={comparisonTimepoints}
|
||||
overallWarnings={overallWarnings}
|
||||
measurementCollection={comparisonCollections}
|
||||
onRelabelClick={() => { }}
|
||||
onEditDescriptionClick={() => { }}
|
||||
/>
|
||||
<div className="generate-report">
|
||||
<button className="btn btn-primary">Generate Report</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MeasurementComparisonTable;
|
||||
@@ -0,0 +1,52 @@
|
||||
import MeasurementComparisonTable from './components/MeasurementComparisonTable';
|
||||
|
||||
export default {
|
||||
/**
|
||||
* Only required property. Should be a unique value across all extensions.
|
||||
*/
|
||||
id: 'lesion-tracker',
|
||||
|
||||
/**
|
||||
* @param {object} params
|
||||
* @param {ServicesManager} params.servicesManager
|
||||
* @param {CommandsManager} params.commandsManager
|
||||
*/
|
||||
getPanelModule({ servicesManager, commandsManager }) {
|
||||
return {
|
||||
menuOptions: [
|
||||
{
|
||||
icon: 'th-list',
|
||||
label: 'Measurements',
|
||||
target: 'lesion-tracker-panel',
|
||||
},
|
||||
],
|
||||
components: [
|
||||
{
|
||||
id: 'lesion-tracker-panel',
|
||||
component: MeasurementComparisonTable,
|
||||
},
|
||||
],
|
||||
defaultContext: ['VIEWER']
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {object} params
|
||||
* @param {ServicesManager} params.servicesManager
|
||||
* @param {CommandsManager} params.commandsManager
|
||||
* @returns Object
|
||||
*/
|
||||
getToolbarModule() {
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {object} params
|
||||
* @param {ServicesManager} params.servicesManager
|
||||
* @param {CommandsManager} params.commandsManager
|
||||
* @returns Object
|
||||
*/
|
||||
getCommandsModule() {
|
||||
return null;
|
||||
},
|
||||
};
|
||||
@@ -3,6 +3,129 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.2.20](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@1.2.19...@ohif/extension-vtk@1.2.20) (2020-02-21)
|
||||
|
||||
**Note:** Version bump only for package @ohif/extension-vtk
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [1.2.19](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@1.2.18...@ohif/extension-vtk@1.2.19) (2020-02-20)
|
||||
|
||||
**Note:** Version bump only for package @ohif/extension-vtk
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [1.2.18](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@1.2.17...@ohif/extension-vtk@1.2.18) (2020-02-12)
|
||||
|
||||
**Note:** Version bump only for package @ohif/extension-vtk
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [1.2.17](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@1.2.16...@ohif/extension-vtk@1.2.17) (2020-02-10)
|
||||
|
||||
**Note:** Version bump only for package @ohif/extension-vtk
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [1.2.16](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@1.2.15...@ohif/extension-vtk@1.2.16) (2020-02-10)
|
||||
|
||||
**Note:** Version bump only for package @ohif/extension-vtk
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [1.2.15](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@1.2.14...@ohif/extension-vtk@1.2.15) (2020-02-07)
|
||||
|
||||
**Note:** Version bump only for package @ohif/extension-vtk
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [1.2.14](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@1.2.13...@ohif/extension-vtk@1.2.14) (2020-02-06)
|
||||
|
||||
**Note:** Version bump only for package @ohif/extension-vtk
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [1.2.13](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@1.2.12...@ohif/extension-vtk@1.2.13) (2020-01-30)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Set VTK viewport as active by interaction ([#1139](https://github.com/OHIF/Viewers/issues/1139)) ([686d12d](https://github.com/OHIF/Viewers/commit/686d12da5c9d3d435b1e326c2a5caee36e2ed27c))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [1.2.12](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@1.2.11...@ohif/extension-vtk@1.2.12) (2020-01-30)
|
||||
|
||||
**Note:** Version bump only for package @ohif/extension-vtk
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [1.2.11](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@1.2.10...@ohif/extension-vtk@1.2.11) (2020-01-28)
|
||||
|
||||
**Note:** Version bump only for package @ohif/extension-vtk
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [1.2.10](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@1.2.9...@ohif/extension-vtk@1.2.10) (2020-01-28)
|
||||
|
||||
**Note:** Version bump only for package @ohif/extension-vtk
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [1.2.9](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@1.2.8...@ohif/extension-vtk@1.2.9) (2020-01-27)
|
||||
|
||||
**Note:** Version bump only for package @ohif/extension-vtk
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [1.2.8](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@1.2.7...@ohif/extension-vtk@1.2.8) (2020-01-24)
|
||||
|
||||
**Note:** Version bump only for package @ohif/extension-vtk
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [1.2.7](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@1.2.6...@ohif/extension-vtk@1.2.7) (2020-01-08)
|
||||
|
||||
**Note:** Version bump only for package @ohif/extension-vtk
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [1.2.6](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@1.2.5...@ohif/extension-vtk@1.2.6) (2020-01-07)
|
||||
|
||||
**Note:** Version bump only for package @ohif/extension-vtk
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [1.2.5](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@1.2.4...@ohif/extension-vtk@1.2.5) (2020-01-06)
|
||||
|
||||
**Note:** Version bump only for package @ohif/extension-vtk
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ohif/extension-vtk",
|
||||
"version": "1.2.5",
|
||||
"version": "1.2.20",
|
||||
"description": "OHIF extension for VTK.js",
|
||||
"author": "OHIF",
|
||||
"license": "MIT",
|
||||
@@ -49,12 +49,12 @@
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.5.5",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"react-vtkjs-viewport": "^0.3.9"
|
||||
"react-vtkjs-viewport": "^0.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ohif/core": "^2.3.2",
|
||||
"@ohif/ui": "^1.1.7",
|
||||
"cornerstone-tools": "^4.9.0",
|
||||
"@ohif/core": "^2.5.1",
|
||||
"@ohif/ui": "^1.3.0",
|
||||
"cornerstone-tools": "^4.12.0",
|
||||
"cornerstone-wado-image-loader": "^3.0.0",
|
||||
"dicom-parser": "^1.8.3",
|
||||
"gh-pages": "^2.0.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import OHIF from '@ohif/core';
|
||||
import { View2D } from 'react-vtkjs-viewport';
|
||||
import { connect } from 'react-redux';
|
||||
import VTKViewport from './VTKViewport';
|
||||
|
||||
const { setViewportActive, setViewportSpecificData } = OHIF.redux.actions;
|
||||
|
||||
@@ -18,6 +18,7 @@ const mapStateToProps = (state, ownProps) => {
|
||||
const pluginDetails = viewportLayout.vtk || {};
|
||||
|
||||
return {
|
||||
activeViewportIndex: state.viewports.activeViewportIndex,
|
||||
layout: state.viewports.layout,
|
||||
isActive,
|
||||
...pluginDetails,
|
||||
@@ -75,6 +76,6 @@ const ConnectedVTKViewport = connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps,
|
||||
mergeProps
|
||||
)(View2D);
|
||||
)(VTKViewport);
|
||||
|
||||
export default ConnectedVTKViewport;
|
||||
@@ -77,6 +77,11 @@ class OHIFVTKViewport extends Component {
|
||||
}),
|
||||
viewportIndex: PropTypes.number,
|
||||
children: PropTypes.node,
|
||||
onScroll: PropTypes.func,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
onScroll: () => {},
|
||||
};
|
||||
|
||||
static id = 'OHIFVTKViewport';
|
||||
@@ -386,6 +391,7 @@ class OHIFVTKViewport extends Component {
|
||||
this.state.paintFilterBackgroundImageData
|
||||
}
|
||||
viewportIndex={this.props.viewportIndex}
|
||||
onScroll={this.props.onScroll}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.vtk-viewport-handler svg {
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import { View2D } from 'react-vtkjs-viewport';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import './VTKViewport.css';
|
||||
|
||||
const VTKViewport = props => {
|
||||
const style = { width: '100%', height: '100%', position: 'relative' };
|
||||
|
||||
const setViewportActiveHandler = useCallback(() => {
|
||||
const { setViewportActive, viewportIndex, activeViewportIndex } = props;
|
||||
|
||||
if (viewportIndex !== activeViewportIndex) {
|
||||
// set in Connected
|
||||
setViewportActive();
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleScrollEvent = evt => {
|
||||
const vtkViewportApiReference = props.onScroll(props.viewportIndex) || {};
|
||||
const viewportUid = vtkViewportApiReference.uid;
|
||||
const viewportWasScrolled = viewportUid === evt.detail.uid;
|
||||
|
||||
if (viewportWasScrolled) {
|
||||
setViewportActiveHandler();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('vtkscrollevent', handleScrollEvent);
|
||||
return () =>
|
||||
window.removeEventListener('vtkscrollevent', handleScrollEvent);
|
||||
}, [props, props.onScroll, props.viewportIndex, setViewportActiveHandler]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="vtk-viewport-handler"
|
||||
style={style}
|
||||
onClick={setViewportActiveHandler}
|
||||
>
|
||||
<View2D {...props} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
VTKViewport.propTypes = {
|
||||
setViewportActive: PropTypes.func.isRequired,
|
||||
viewportIndex: PropTypes.number.isRequired,
|
||||
activeViewportIndex: PropTypes.number.isRequired,
|
||||
/* Receives viewportIndex */
|
||||
onScroll: PropTypes.func,
|
||||
};
|
||||
|
||||
VTKViewport.defaultProps = {
|
||||
onScroll: () => {},
|
||||
};
|
||||
|
||||
export default VTKViewport;
|
||||
@@ -103,6 +103,9 @@ const commandsModule = ({ commandsManager }) => {
|
||||
}
|
||||
|
||||
const actions = {
|
||||
getVtkApis: ({ index }) => {
|
||||
return apis[index];
|
||||
},
|
||||
axial: async ({ viewports }) => {
|
||||
const api = await _getActiveViewportVTKApi(viewports);
|
||||
|
||||
@@ -234,7 +237,7 @@ const commandsModule = ({ commandsManager }) => {
|
||||
},
|
||||
},
|
||||
{
|
||||
// Sagital
|
||||
// Sagittal
|
||||
orientation: {
|
||||
sliceNormal: [1, 0, 0],
|
||||
viewUp: [0, 0, 1],
|
||||
@@ -266,11 +269,12 @@ const commandsModule = ({ commandsManager }) => {
|
||||
'crosshairsWidget'
|
||||
);
|
||||
|
||||
const uid = api.uid;
|
||||
const istyle = vtkInteractorStyleMPRCrosshairs.newInstance();
|
||||
|
||||
api.setInteractorStyle({
|
||||
istyle,
|
||||
configuration: { apis, apiIndex },
|
||||
configuration: { apis, apiIndex, uid },
|
||||
});
|
||||
});
|
||||
},
|
||||
@@ -296,55 +300,45 @@ const commandsModule = ({ commandsManager }) => {
|
||||
},
|
||||
enableRotateTool: {
|
||||
commandFn: actions.enableRotateTool,
|
||||
storeContexts: [],
|
||||
options: {},
|
||||
},
|
||||
enableCrosshairsTool: {
|
||||
commandFn: actions.enableCrosshairsTool,
|
||||
storeContexts: [],
|
||||
options: {},
|
||||
},
|
||||
enableLevelTool: {
|
||||
commandFn: actions.enableLevelTool,
|
||||
storeContexts: [],
|
||||
options: {},
|
||||
},
|
||||
setBlendModeToComposite: {
|
||||
commandFn: actions.setBlendModeToComposite,
|
||||
storeContexts: [],
|
||||
options: { blendMode: BlendMode.COMPOSITE_BLEND },
|
||||
},
|
||||
setBlendModeToMaximumIntensity: {
|
||||
commandFn: actions.setBlendModeToMaximumIntensity,
|
||||
storeContexts: [],
|
||||
options: { blendMode: BlendMode.MAXIMUM_INTENSITY_BLEND },
|
||||
},
|
||||
setBlendModeToMinimumIntensity: {
|
||||
commandFn: actions.setBlendMode,
|
||||
storeContexts: [],
|
||||
options: { blendMode: BlendMode.MINIMUM_INTENSITY_BLEND },
|
||||
},
|
||||
setBlendModeToAverageIntensity: {
|
||||
commandFn: actions.setBlendMode,
|
||||
storeContexts: [],
|
||||
options: { blendMode: BlendMode.AVERAGE_INTENSITY_BLEND },
|
||||
},
|
||||
setSlabThickness: {
|
||||
// TODO: How do we pass in a function argument?
|
||||
commandFn: actions.setSlabThickness,
|
||||
storeContexts: [],
|
||||
options: {},
|
||||
},
|
||||
increaseSlabThickness: {
|
||||
commandFn: actions.changeSlabThickness,
|
||||
storeContexts: [],
|
||||
options: {
|
||||
change: 3,
|
||||
},
|
||||
},
|
||||
decreaseSlabThickness: {
|
||||
commandFn: actions.changeSlabThickness,
|
||||
storeContexts: [],
|
||||
options: {
|
||||
change: -3,
|
||||
},
|
||||
@@ -355,6 +349,10 @@ const commandsModule = ({ commandsManager }) => {
|
||||
options: {},
|
||||
context: 'VIEWER',
|
||||
},
|
||||
getVtkApiForViewportIndex: {
|
||||
commandFn: actions.getVtkApis,
|
||||
context: 'VIEWER',
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncComponent from './asyncComponent.js';
|
||||
import commandsModule from './commandsModule.js';
|
||||
import toolbarModule from './toolbarModule.js';
|
||||
import withCommandsManager from './withCommandsManager.js';
|
||||
// This feels weird
|
||||
// import loadLocales from './loadLocales';
|
||||
|
||||
@@ -14,8 +15,8 @@ const vtkExtension = {
|
||||
*/
|
||||
id: 'vtk',
|
||||
|
||||
getViewportModule() {
|
||||
return OHIFVTKViewport;
|
||||
getViewportModule({ commandsManager }) {
|
||||
return withCommandsManager(OHIFVTKViewport, commandsManager);
|
||||
},
|
||||
getToolbarModule() {
|
||||
return toolbarModule;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function withCommandsManager(Component, commandsManager = {}) {
|
||||
return class WithCommandsManager extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<Component
|
||||
{...this.props}
|
||||
onScroll={viewportIndex =>
|
||||
commandsManager.runCommand('getVtkApiForViewportIndex', {
|
||||
index: viewportIndex,
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
+2
-1
@@ -10,7 +10,8 @@ module.exports = {
|
||||
// https://jestjs.io/docs/en/configuration#projects-array-string-projectconfig
|
||||
projects: [
|
||||
// "<rootDir>/extensions/*/jest.config.js",
|
||||
"<rootDir>/platform/*/jest.config.js"
|
||||
"<rootDir>/platform/*/jest.config.js",
|
||||
"<rootDir>/extensions/*/jest.config.js"
|
||||
],
|
||||
coverageDirectory: "<rootDir>/coverage/"
|
||||
};
|
||||
+3
-1
@@ -13,11 +13,13 @@
|
||||
"scripts": {
|
||||
"cm": "npx git-cz",
|
||||
"build": "lerna run build:viewer --stream",
|
||||
"build:lt": "lerna run build:viewer:lesion-tracker --stream",
|
||||
"build:ci": "lerna run build:viewer:ci --stream",
|
||||
"build:demo": "lerna run build:viewer:demo --stream",
|
||||
"build:package": "lerna run build:viewer:package --stream",
|
||||
"build:package-all": "lerna run build:package --parallel --stream",
|
||||
"dev": "lerna run dev:viewer --stream",
|
||||
"dev:lt": "lerna run dev:viewer:lesion-tracker --stream",
|
||||
"dev:project": ".scripts/dev.sh",
|
||||
"dev:orthanc": "lerna run dev:orthanc --stream",
|
||||
"orthanc:up": "docker-compose -f .docker/Nginx-Orthanc/docker-compose.yml up",
|
||||
@@ -59,6 +61,7 @@
|
||||
"copy-webpack-plugin": "^5.0.3",
|
||||
"cross-env": "^5.2.0",
|
||||
"css-loader": "^3.2.0",
|
||||
"cypress-file-upload": "^3.5.3",
|
||||
"dotenv": "^8.1.0",
|
||||
"eslint": "5.16.0",
|
||||
"eslint-config-prettier": "^6.4.0",
|
||||
@@ -72,7 +75,6 @@
|
||||
"eslint-plugin-react": "7.x",
|
||||
"eslint-plugin-react-hooks": "1.5.0",
|
||||
"extract-css-chunks-webpack-plugin": "^4.5.4",
|
||||
"favicons-webpack-plugin": "^1.0.2",
|
||||
"html-webpack-plugin": "^3.2.0",
|
||||
"husky": "^3.0.0",
|
||||
"jest": "^24.8.0",
|
||||
|
||||
@@ -3,6 +3,112 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [2.5.1](https://github.com/OHIF/Viewers/compare/@ohif/core@2.5.0...@ohif/core@2.5.1) (2020-02-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* 💡 Update log to warn ([#1454](https://github.com/OHIF/Viewers/issues/1454)) ([509a7b7](https://github.com/OHIF/Viewers/commit/509a7b7e57834a0653add98cc320b2a5dbefd51d)), closes [#1451](https://github.com/OHIF/Viewers/issues/1451)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [2.5.0](https://github.com/OHIF/Viewers/compare/@ohif/core@2.4.1...@ohif/core@2.5.0) (2020-02-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* [#1342](https://github.com/OHIF/Viewers/issues/1342) - Window level tab ([#1429](https://github.com/OHIF/Viewers/issues/1429)) ([ebc01a8](https://github.com/OHIF/Viewers/commit/ebc01a8ca238d5a3437b44d81f75aa8a5e8d0574))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.4.1](https://github.com/OHIF/Viewers/compare/@ohif/core@2.4.0...@ohif/core@2.4.1) (2020-02-12)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Combined Hotkeys for special characters ([#1233](https://github.com/OHIF/Viewers/issues/1233)) ([2f30e7a](https://github.com/OHIF/Viewers/commit/2f30e7a821a238144c49c56f37d8e5565540b4bd))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [2.4.0](https://github.com/OHIF/Viewers/compare/@ohif/core@2.3.9...@ohif/core@2.4.0) (2020-02-10)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* 🎸 MeasurementService ([#1314](https://github.com/OHIF/Viewers/issues/1314)) ([0c37a40](https://github.com/OHIF/Viewers/commit/0c37a406d963569af8c3be24c697dafd42712dfc))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.3.9](https://github.com/OHIF/Viewers/compare/@ohif/core@2.3.8...@ohif/core@2.3.9) (2020-02-07)
|
||||
|
||||
**Note:** Version bump only for package @ohif/core
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.3.8](https://github.com/OHIF/Viewers/compare/@ohif/core@2.3.7...@ohif/core@2.3.8) (2020-02-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Remove trash data from redux storage after updates ([#1358](https://github.com/OHIF/Viewers/issues/1358)) ([7b2d44f](https://github.com/OHIF/Viewers/commit/7b2d44f2c18241ea521b8d3652aee32e36eaddb8))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.3.7](https://github.com/OHIF/Viewers/compare/@ohif/core@2.3.6...@ohif/core@2.3.7) (2020-01-30)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Set VTK viewport as active by interaction ([#1139](https://github.com/OHIF/Viewers/issues/1139)) ([686d12d](https://github.com/OHIF/Viewers/commit/686d12da5c9d3d435b1e326c2a5caee36e2ed27c))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.3.6](https://github.com/OHIF/Viewers/compare/@ohif/core@2.3.5...@ohif/core@2.3.6) (2020-01-28)
|
||||
|
||||
**Note:** Version bump only for package @ohif/core
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.3.5](https://github.com/OHIF/Viewers/compare/@ohif/core@2.3.4...@ohif/core@2.3.5) (2020-01-28)
|
||||
|
||||
**Note:** Version bump only for package @ohif/core
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.3.4](https://github.com/OHIF/Viewers/compare/@ohif/core@2.3.3...@ohif/core@2.3.4) (2020-01-27)
|
||||
|
||||
**Note:** Version bump only for package @ohif/core
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.3.3](https://github.com/OHIF/Viewers/compare/@ohif/core@2.3.2...@ohif/core@2.3.3) (2020-01-24)
|
||||
|
||||
**Note:** Version bump only for package @ohif/core
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [2.3.2](https://github.com/OHIF/Viewers/compare/@ohif/core@2.3.1...@ohif/core@2.3.2) (2020-01-06)
|
||||
|
||||
**Note:** Version bump only for package @ohif/core
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ohif/core",
|
||||
"version": "2.3.2",
|
||||
"version": "2.5.1",
|
||||
"description": "Generic business logic for web-based medical imaging applications",
|
||||
"author": "OHIF Core Team",
|
||||
"license": "MIT",
|
||||
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"cornerstone-core": "^2.2.8",
|
||||
"cornerstone-tools": "^4.9.0",
|
||||
"cornerstone-tools": "^4.12.0",
|
||||
"cornerstone-wado-image-loader": "^3.0.0",
|
||||
"dicom-parser": "^1.8.3"
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export default {
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
info: jest.fn(),
|
||||
};
|
||||
@@ -161,7 +161,7 @@ export class CommandsManager {
|
||||
return;
|
||||
}
|
||||
|
||||
const { commandFn, storeContexts } = definition;
|
||||
const { commandFn, storeContexts = [] } = definition;
|
||||
const definitionOptions = definition.options;
|
||||
|
||||
let commandParams = {};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import hotkeys from './hotkeys';
|
||||
import hotkeys from './../utils/hotkeys';
|
||||
import log from './../log.js';
|
||||
|
||||
/**
|
||||
@@ -11,7 +11,7 @@ import log from './../log.js';
|
||||
*/
|
||||
|
||||
export class HotkeysManager {
|
||||
constructor(commandsManager) {
|
||||
constructor(commandsManager, servicesManager) {
|
||||
this.hotkeyDefinitions = {};
|
||||
this.hotkeyDefaults = [];
|
||||
this.isEnabled = true;
|
||||
@@ -22,9 +22,19 @@ export class HotkeysManager {
|
||||
);
|
||||
}
|
||||
|
||||
this._servicesManager = servicesManager;
|
||||
this._commandsManager = commandsManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exposes Mousetrap.js's `.record` method, added by the record plugin.
|
||||
*
|
||||
* @param {*} event
|
||||
*/
|
||||
record(event) {
|
||||
return hotkeys.record(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables all hotkeys. Hotkeys added while disabled will not listen for
|
||||
* input.
|
||||
@@ -45,38 +55,57 @@ export class HotkeysManager {
|
||||
/**
|
||||
* Registers a list of hotkeydefinitions.
|
||||
*
|
||||
* @param {HotkeyDefinition[] | Object} hotkeyDefinitions Contains hotkeys definitions
|
||||
* @param {HotkeyDefinition[] | Object} [hotkeyDefinitions=[]] Contains hotkeys definitions
|
||||
*/
|
||||
setHotkeys(hotkeyDefinitions) {
|
||||
const definitions = Array.isArray(hotkeyDefinitions)
|
||||
? [...hotkeyDefinitions]
|
||||
: this._parseToArrayLike(hotkeyDefinitions);
|
||||
setHotkeys(hotkeyDefinitions = []) {
|
||||
try {
|
||||
const definitions = this._getValidDefinitions(hotkeyDefinitions);
|
||||
|
||||
definitions.forEach(definition => this.registerHotkeys(definition));
|
||||
definitions.forEach(definition => this.registerHotkeys(definition));
|
||||
} catch (error) {
|
||||
const { UINotificationService } = this._servicesManager.services;
|
||||
UINotificationService.show({
|
||||
title: 'Hotkeys Manager',
|
||||
message: 'Erro while setting hotkeys',
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set default hotkey bindings. These
|
||||
* values are used in `this.restoreDefaultBindings`.
|
||||
*
|
||||
* @param {HotkeyDefinition[] | Object} hotkeyDefinitions Contains hotkeys definitions
|
||||
* @param {HotkeyDefinition[] | Object} [hotkeyDefinitions=[]] Contains hotkeys definitions
|
||||
*/
|
||||
setDefaultHotKeys(hotkeyDefinitions) {
|
||||
setDefaultHotKeys(hotkeyDefinitions = []) {
|
||||
const definitions = this._getValidDefinitions(hotkeyDefinitions);
|
||||
|
||||
this.hotkeyDefaults = definitions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Take hotkey definitions that can be an array or object and make sure that it
|
||||
* returns an array of hotkeys
|
||||
*
|
||||
* @param {HotkeyDefinition[] | Object} [hotkeyDefinitions=[]] Contains hotkeys definitions
|
||||
*/
|
||||
_getValidDefinitions(hotkeyDefinitions) {
|
||||
const definitions = Array.isArray(hotkeyDefinitions)
|
||||
? [...hotkeyDefinitions]
|
||||
: this._parseToArrayLike(hotkeyDefinitions);
|
||||
|
||||
this.hotkeyDefaults = definitions;
|
||||
return definitions;
|
||||
}
|
||||
|
||||
/**
|
||||
* It parses given object containing hotkeyDefinition to array like.
|
||||
* Each property of given object will be mapped to an object of an array. And its property name will be the value of a property named as commandName
|
||||
*
|
||||
* @param {HotkeyDefinition[] | Object} hotkeyDefinitions Contains hotkeys definitions
|
||||
* @param {HotkeyDefinition[] | Object} [hotkeyDefinitions={}] Contains hotkeys definitions
|
||||
* @returns {HotkeyDefinition[]}
|
||||
*/
|
||||
_parseToArrayLike(hotkeyDefinitionsObj) {
|
||||
_parseToArrayLike(hotkeyDefinitionsObj = {}) {
|
||||
const copy = { ...hotkeyDefinitionsObj };
|
||||
return Object.entries(copy).map(entryValue =>
|
||||
this._parseToHotKeyObj(entryValue[0], entryValue[1])
|
||||
@@ -127,11 +156,13 @@ export class HotkeysManager {
|
||||
if (previouslyRegisteredDefinition) {
|
||||
const previouslyRegisteredKeys = previouslyRegisteredDefinition.keys;
|
||||
this._unbindHotkeys(commandName, previouslyRegisteredKeys);
|
||||
log.info(`Unbinding ${commandName} from ${previouslyRegisteredKeys}`);
|
||||
}
|
||||
|
||||
// Set definition & bind
|
||||
this.hotkeyDefinitions[commandName] = { keys, label };
|
||||
this._bindHotkeys(commandName, keys);
|
||||
log.info(`Binding ${commandName} to ${keys}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -167,12 +198,11 @@ export class HotkeysManager {
|
||||
}
|
||||
|
||||
const isKeyArray = keys instanceof Array;
|
||||
if (isKeyArray) {
|
||||
keys.forEach(key => this._bindHotkeys(commandName, key));
|
||||
return;
|
||||
}
|
||||
const combinedKeys = isKeyArray ? keys.join('+') : keys;
|
||||
|
||||
hotkeys.bind(keys, evt => {
|
||||
hotkeys.bind(combinedKeys, evt => {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
this._commandsManager.runCommand(commandName, { evt });
|
||||
});
|
||||
}
|
||||
@@ -193,7 +223,8 @@ export class HotkeysManager {
|
||||
|
||||
const isKeyArray = keys instanceof Array;
|
||||
if (isKeyArray) {
|
||||
keys.forEach(key => this._unbindHotkeys(commandName, key));
|
||||
const combinedKeys = keys.join('+');
|
||||
this._unbindHotkeys(commandName, combinedKeys);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import CommandsManager from './CommandsManager.js';
|
||||
import HotkeysManager from './HotkeysManager.js';
|
||||
import hotkeys from './hotkeys';
|
||||
import hotkeys from './../utils/hotkeys';
|
||||
import log from './../log.js';
|
||||
|
||||
jest.mock('./CommandsManager.js');
|
||||
jest.mock('./hotkeys');
|
||||
jest.mock('./../utils/hotkeys');
|
||||
jest.mock('./../log.js');
|
||||
|
||||
describe('HotkeysManager', () => {
|
||||
@@ -60,7 +60,10 @@ describe('HotkeysManager', () => {
|
||||
});
|
||||
|
||||
describe('enable()', () => {
|
||||
beforeEach(() => hotkeys.unpause.mockClear());
|
||||
beforeEach(() => {
|
||||
hotkeys.unpause = jest.fn();
|
||||
hotkeys.unpause.mockClear();
|
||||
});
|
||||
|
||||
it('sets isEnabled property to true', () => {
|
||||
hotkeysManager.disable();
|
||||
@@ -139,20 +142,18 @@ describe('HotkeysManager', () => {
|
||||
expectedHotkeyDefinition
|
||||
);
|
||||
});
|
||||
it('calls hotkeys.bind for all keys in array', () => {
|
||||
const definition = { commandName: 'dance', keys: ['h', 'e', 'l', 'o'] };
|
||||
it('calls hotkeys.bind for the group of keys', () => {
|
||||
const definition = { commandName: 'dance', keys: ['shift', 'e'] };
|
||||
|
||||
hotkeysManager.registerHotkeys(definition);
|
||||
|
||||
expect(hotkeys.bind.mock.calls.length).toBe(definition.keys.length);
|
||||
definition.keys.forEach((key, i) =>
|
||||
expect(hotkeys.bind.mock.calls[i][0]).toBe(key)
|
||||
);
|
||||
expect(hotkeys.bind.mock.calls.length).toBe(1);
|
||||
expect(hotkeys.bind.mock.calls[0][0]).toBe('shift+e');
|
||||
});
|
||||
it('calls hotkeys.unbind if commandName was previously registered, for each previously registered set of keys', () => {
|
||||
const firstDefinition = {
|
||||
commandName: 'dance',
|
||||
keys: ['h', 'e', 'l', 'o'],
|
||||
keys: ['alt', 'e'],
|
||||
};
|
||||
const secondDefinition = { commandName: 'dance', keys: 'a' };
|
||||
|
||||
@@ -161,12 +162,8 @@ describe('HotkeysManager', () => {
|
||||
// Second call
|
||||
hotkeysManager.registerHotkeys(secondDefinition);
|
||||
|
||||
expect(hotkeys.unbind.mock.calls.length).toBe(
|
||||
firstDefinition.keys.length
|
||||
);
|
||||
firstDefinition.keys.forEach((key, i) =>
|
||||
expect(hotkeys.unbind.mock.calls[i][0]).toBe(key)
|
||||
);
|
||||
expect(hotkeys.unbind.mock.calls.length).toBe(1);
|
||||
expect(hotkeys.unbind.mock.calls[0][0]).toBe('alt+e');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
// Only imported in environment w/ `window`
|
||||
// So we need to mock these for tests
|
||||
import Mousetrap from 'mousetrap';
|
||||
import pausePlugin from 'mousetrap/plugins/pause/mousetrap-pause.js';
|
||||
import recordPlugin from 'mousetrap/plugins/record/mousetrap-record.js';
|
||||
|
||||
// import pausePlugin from './pausePlugin.js';
|
||||
// import recordPlugin from './recordPlugin.js';
|
||||
|
||||
// // // TODO: May need to bind these so Mousetrap = this in plugins;
|
||||
// pausePlugin(Mousetrap);
|
||||
// recordPlugin(Mousetrap);
|
||||
|
||||
// console.log(Mousetrap);
|
||||
// console.log(Object.keys(Mousetrap));
|
||||
|
||||
export default Mousetrap;
|
||||
@@ -18,12 +18,13 @@ import string from './string.js';
|
||||
import studies from './studies/';
|
||||
import ui from './ui';
|
||||
import user from './user.js';
|
||||
import utils from './utils/';
|
||||
import utils, { hotkeys } from './utils/';
|
||||
|
||||
import {
|
||||
UINotificationService,
|
||||
UIModalService,
|
||||
UIDialogService,
|
||||
MeasurementService,
|
||||
} from './services';
|
||||
|
||||
const OHIF = {
|
||||
@@ -35,6 +36,7 @@ const OHIF = {
|
||||
ServicesManager,
|
||||
//
|
||||
utils,
|
||||
hotkeys,
|
||||
studies,
|
||||
redux,
|
||||
classes,
|
||||
@@ -55,6 +57,7 @@ const OHIF = {
|
||||
UINotificationService,
|
||||
UIModalService,
|
||||
UIDialogService,
|
||||
MeasurementService,
|
||||
};
|
||||
|
||||
export {
|
||||
@@ -66,6 +69,7 @@ export {
|
||||
ServicesManager,
|
||||
//
|
||||
utils,
|
||||
hotkeys,
|
||||
studies,
|
||||
redux,
|
||||
classes,
|
||||
@@ -85,6 +89,7 @@ export {
|
||||
UINotificationService,
|
||||
UIModalService,
|
||||
UIDialogService,
|
||||
MeasurementService,
|
||||
};
|
||||
|
||||
export { OHIF };
|
||||
|
||||
@@ -13,8 +13,10 @@ describe('Top level exports', () => {
|
||||
'UINotificationService',
|
||||
'UIModalService',
|
||||
'UIDialogService',
|
||||
'MeasurementService',
|
||||
//
|
||||
'utils',
|
||||
'hotkeys',
|
||||
'studies',
|
||||
'redux',
|
||||
'classes',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const displayFunction = data => {
|
||||
let text = '';
|
||||
if (data.rAngle) {
|
||||
if (data.rAngle && !isNaN(data.rAngle)) {
|
||||
text = data.rAngle.toFixed(2) + String.fromCharCode(parseInt('00B0', 16));
|
||||
}
|
||||
return text;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const displayFunction = data => {
|
||||
let meanValue = '';
|
||||
const { cachedStats } = data;
|
||||
if (cachedStats && cachedStats.mean) {
|
||||
if (cachedStats && cachedStats.mean && !isNaN(cachedStats.mean)) {
|
||||
meanValue = cachedStats.mean.toFixed(2) + ' HU';
|
||||
}
|
||||
return meanValue;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const displayFunction = data => {
|
||||
let meanValue = '';
|
||||
const { cachedStats } = data;
|
||||
if (cachedStats && cachedStats.mean) {
|
||||
if (cachedStats && cachedStats.mean && !isNaN(cachedStats.mean)) {
|
||||
meanValue = cachedStats.mean.toFixed(2) + ' HU';
|
||||
}
|
||||
return meanValue;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const displayFunction = data => {
|
||||
let meanValue = '';
|
||||
if (data.meanStdDev && data.meanStdDev.mean) {
|
||||
if (data.meanStdDev && data.meanStdDev.mean && !isNaN(data.meanStdDev.mean)) {
|
||||
meanValue = data.meanStdDev.mean.toFixed(2) + ' HU';
|
||||
}
|
||||
return meanValue;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const displayFunction = data => {
|
||||
let lengthValue = '';
|
||||
if (data.length) {
|
||||
if (data.length && !isNaN(data.length)) {
|
||||
lengthValue = data.length.toFixed(2) + ' mm';
|
||||
}
|
||||
return lengthValue;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const displayFunction = data => {
|
||||
let meanValue = '';
|
||||
const { cachedStats } = data;
|
||||
if (cachedStats && cachedStats.mean) {
|
||||
if (cachedStats && cachedStats.mean && !isNaN(cachedStats.mean)) {
|
||||
meanValue = cachedStats.mean.toFixed(2) + ' HU';
|
||||
}
|
||||
return meanValue;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
SET_VIEWPORT_ACTIVE,
|
||||
SET_VIEWPORT_LAYOUT,
|
||||
SET_VIEWPORT_LAYOUT_AND_DATA,
|
||||
SET_USER_PREFERENCES,
|
||||
} from './constants/ActionTypes.js';
|
||||
|
||||
/**
|
||||
@@ -89,7 +90,7 @@ export const clearStudyLoadingProgress = progressId => ({
|
||||
});
|
||||
|
||||
export const setUserPreferences = state => ({
|
||||
type: 'SET_USER_PREFERENCES',
|
||||
type: SET_USER_PREFERENCES,
|
||||
state,
|
||||
});
|
||||
|
||||
|
||||
@@ -20,3 +20,8 @@ export const SET_SERVERS = 'SET_SERVERS';
|
||||
* EXTENSIONS
|
||||
*/
|
||||
export const SET_EXTENSION_DATA = 'SET_EXTENSION_DATA';
|
||||
|
||||
/**
|
||||
* PREFERENCES
|
||||
* */
|
||||
export const SET_USER_PREFERENCES = 'SET_USER_PREFERENCES';
|
||||
@@ -1,31 +1,30 @@
|
||||
import cloneDeep from 'lodash.clonedeep';
|
||||
|
||||
const defaultState = {
|
||||
// First tab
|
||||
hotkeyDefinitions: [
|
||||
// commandName, label, keys
|
||||
// [{ zoom: { label: 'Zoom', keys: ['z'] }}]
|
||||
],
|
||||
// Second tab
|
||||
windowLevelData: {
|
||||
// order, description, window (int), level (int)
|
||||
// 0: { description: 'Soft tissue', window: '', level: '' },
|
||||
1: { description: 'Soft tissue', window: '550', level: '40' },
|
||||
2: { description: 'Lung', window: '150', level: '-600' },
|
||||
3: { description: 'Liver', window: '150', level: '90' },
|
||||
4: { description: 'Bone', window: '2500', level: '480' },
|
||||
5: { description: 'Brain', window: '80', level: '40' },
|
||||
6: { description: 'Trest', window: '1', level: '1' },
|
||||
7: { description: '', window: '', level: '' },
|
||||
8: { description: '', window: '', level: '' },
|
||||
9: { description: '', window: '', level: '' },
|
||||
10: { description: '', window: '', level: '' },
|
||||
},
|
||||
generalPreferences: {
|
||||
// language: 'en-US'
|
||||
},
|
||||
};
|
||||
|
||||
const preferences = (state, action) => {
|
||||
const preferences = (state = defaultState, action) => {
|
||||
switch (action.type) {
|
||||
case 'SET_USER_PREFERENCES': {
|
||||
const newState = action.state || cloneDeep(defaultState);
|
||||
|
||||
return Object.assign({}, state, newState);
|
||||
return Object.assign({}, state, action.state);
|
||||
}
|
||||
default:
|
||||
return cloneDeep(state) || cloneDeep(defaultState);
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export { defaultState };
|
||||
export default preferences;
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Reducer } from 'redux-testkit';
|
||||
|
||||
import reducer, { defaultState } from './preferences';
|
||||
import { SET_USER_PREFERENCES } from './../constants/ActionTypes.js';
|
||||
|
||||
describe('preferences reducer', () => {
|
||||
it('should return the initial state', () => {
|
||||
expect(reducer(undefined, {})).toEqual(defaultState);
|
||||
});
|
||||
|
||||
it('should set user preferences state and properly merge with current state', () => {
|
||||
const initialState = defaultState;
|
||||
|
||||
const action = {
|
||||
type: SET_USER_PREFERENCES,
|
||||
state: { generalPreferences: { language: 'es' } },
|
||||
};
|
||||
|
||||
const expectedState = {
|
||||
windowLevelData: defaultState.windowLevelData,
|
||||
generalPreferences: {
|
||||
language: 'es',
|
||||
},
|
||||
};
|
||||
|
||||
Reducer(reducer)
|
||||
.withState(initialState)
|
||||
.expect(action)
|
||||
.toReturnState(expectedState);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,16 @@ const servers = (state = defaultState, action) => {
|
||||
servers.forEach(s => (s.active = true));
|
||||
return { ...state, servers };
|
||||
|
||||
case 'ACTIVATE_SERVER': {
|
||||
const newServer = { ...action.server, active: true };
|
||||
const newServers = state.servers;
|
||||
newServers.forEach(s => (s.active = false));
|
||||
return {
|
||||
...state,
|
||||
servers: uniqBy([...newServers, newServer], 'wadoRoot'),
|
||||
};
|
||||
}
|
||||
|
||||
case 'SET_SERVERS':
|
||||
return { ...state, servers: action.servers };
|
||||
|
||||
|
||||
@@ -11,20 +11,63 @@ import {
|
||||
SET_VIEWPORT_LAYOUT_AND_DATA,
|
||||
} from './../constants/ActionTypes.js';
|
||||
|
||||
const DEFAULT_STATE = {
|
||||
export const DEFAULT_STATE = {
|
||||
numRows: 1,
|
||||
numColumns: 1,
|
||||
activeViewportIndex: 0,
|
||||
layout: {
|
||||
viewports: [
|
||||
{
|
||||
// plugin: 'cornerstone',
|
||||
},
|
||||
],
|
||||
viewports: [{}],
|
||||
},
|
||||
viewportSpecificData: {},
|
||||
};
|
||||
|
||||
/**
|
||||
* Take the new number of rows and columns, delete all not used viewport data and also set
|
||||
* active viewport as default in case current one is not available anymore.
|
||||
*
|
||||
* @param {Number} numRows
|
||||
* @param {Number} numColumns
|
||||
* @param {Object} currentViewportSpecificData
|
||||
* @returns
|
||||
*/
|
||||
const findActiveViewportSpecificData = (
|
||||
numRows,
|
||||
numColumns,
|
||||
currentViewportSpecificData = {}
|
||||
) => {
|
||||
const numberOfViewports = numRows * numColumns;
|
||||
const viewportSpecificData = cloneDeep(currentViewportSpecificData);
|
||||
|
||||
if (numberOfViewports < Object.keys(viewportSpecificData).length) {
|
||||
Object.keys(viewportSpecificData).forEach(key => {
|
||||
if (key > numberOfViewports - 1) {
|
||||
delete viewportSpecificData[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return viewportSpecificData;
|
||||
};
|
||||
/**
|
||||
* Take new number of rows and columns and make sure the current active viewport index is still available, if not, return the default
|
||||
*
|
||||
* @param {Number} numRows
|
||||
* @param {Number} numColumns
|
||||
* @param {Number} currentActiveViewportIndex
|
||||
* @returns
|
||||
*/
|
||||
const getActiveViewportIndex = (
|
||||
numRows,
|
||||
numColumns,
|
||||
currentActiveViewportIndex
|
||||
) => {
|
||||
const numberOfViewports = numRows * numColumns;
|
||||
|
||||
return currentActiveViewportIndex > numberOfViewports - 1
|
||||
? DEFAULT_STATE.activeViewportIndex
|
||||
: currentActiveViewportIndex;
|
||||
};
|
||||
|
||||
/**
|
||||
* The definition of a viewport action.
|
||||
*
|
||||
@@ -50,7 +93,12 @@ const viewports = (state = DEFAULT_STATE, action) => {
|
||||
* @return {Object} New state.
|
||||
*/
|
||||
case SET_VIEWPORT_ACTIVE: {
|
||||
return { ...state, activeViewportIndex: action.viewportIndex };
|
||||
const activeViewportIndex = getActiveViewportIndex(
|
||||
state.numRows,
|
||||
state.numColumns,
|
||||
action.viewportIndex
|
||||
);
|
||||
return { ...state, activeViewportIndex };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,11 +107,25 @@ const viewports = (state = DEFAULT_STATE, action) => {
|
||||
* @return {Object} New state.
|
||||
*/
|
||||
case SET_VIEWPORT_LAYOUT: {
|
||||
const { numRows, numColumns } = action;
|
||||
const viewportSpecificData = findActiveViewportSpecificData(
|
||||
numRows,
|
||||
numColumns,
|
||||
state.viewportSpecificData
|
||||
);
|
||||
const activeViewportIndex = getActiveViewportIndex(
|
||||
numRows,
|
||||
numColumns,
|
||||
state.activeViewportIndex
|
||||
);
|
||||
|
||||
return {
|
||||
...state,
|
||||
numRows: action.numRows,
|
||||
numColumns: action.numColumns,
|
||||
layout: { viewports: [...action.viewports] },
|
||||
viewportSpecificData,
|
||||
activeViewportIndex,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -73,12 +135,25 @@ const viewports = (state = DEFAULT_STATE, action) => {
|
||||
* @return {Object} New state.
|
||||
*/
|
||||
case SET_VIEWPORT_LAYOUT_AND_DATA: {
|
||||
const { numRows, numColumns } = action;
|
||||
const viewportSpecificData = findActiveViewportSpecificData(
|
||||
numRows,
|
||||
numColumns,
|
||||
action.viewportSpecificData
|
||||
);
|
||||
const activeViewportIndex = getActiveViewportIndex(
|
||||
numRows,
|
||||
numColumns,
|
||||
state.activeViewportIndex
|
||||
);
|
||||
|
||||
return {
|
||||
...state,
|
||||
numRows: action.numRows,
|
||||
numColumns: action.numColumns,
|
||||
layout: { viewports: [...action.viewports] },
|
||||
viewportSpecificData: cloneDeep(action.viewportSpecificData),
|
||||
viewportSpecificData,
|
||||
activeViewportIndex,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,35 +1,61 @@
|
||||
// https://medium.com/@netxm/testing-redux-reducers-with-jest-6653abbfe3e1
|
||||
import reducer from './viewports.js';
|
||||
import { Reducer } from 'redux-testkit';
|
||||
|
||||
import reducer, { DEFAULT_STATE } from './viewports.js';
|
||||
import * as types from './../constants/ActionTypes.js';
|
||||
|
||||
describe('viewports reducer', () => {
|
||||
it('should return the initial state', () => {
|
||||
expect(reducer(undefined, {})).toEqual({
|
||||
activeViewportIndex: 0,
|
||||
numRows: 1,
|
||||
numColumns: 1,
|
||||
layout: {
|
||||
viewports: [{}],
|
||||
},
|
||||
viewportSpecificData: {},
|
||||
});
|
||||
expect(reducer(undefined, {})).toEqual(DEFAULT_STATE);
|
||||
});
|
||||
|
||||
it('should handle SET_VIEWPORT_ACTIVE', () => {
|
||||
const setViewportActiveAction = {
|
||||
it('should handle SET_VIEWPORT_ACTIVE with inexistent viewport index', () => {
|
||||
const initialState = {
|
||||
numRows: 4,
|
||||
numColumns: 4,
|
||||
activeViewportIndex: 0,
|
||||
};
|
||||
|
||||
const action = {
|
||||
type: types.SET_VIEWPORT_ACTIVE,
|
||||
viewportIndex: 100,
|
||||
};
|
||||
|
||||
const updatedState = reducer({}, setViewportActiveAction);
|
||||
const expectedToChangeInState = {
|
||||
activeViewportIndex: 0,
|
||||
};
|
||||
|
||||
expect(updatedState.activeViewportIndex).toEqual(
|
||||
setViewportActiveAction.viewportIndex
|
||||
);
|
||||
Reducer(reducer)
|
||||
.withState(initialState)
|
||||
.expect(action)
|
||||
.toChangeInState(expectedToChangeInState);
|
||||
});
|
||||
|
||||
it('should handle SET_VIEWPORT_ACTIVE with existent viewport index', () => {
|
||||
const initialState = {
|
||||
numRows: 4,
|
||||
numColumns: 4,
|
||||
activeViewportIndex: 0,
|
||||
};
|
||||
|
||||
const action = {
|
||||
type: types.SET_VIEWPORT_ACTIVE,
|
||||
viewportIndex: 5,
|
||||
};
|
||||
|
||||
const expectedToChangeInState = {
|
||||
activeViewportIndex: 5,
|
||||
};
|
||||
|
||||
Reducer(reducer)
|
||||
.withState(initialState)
|
||||
.expect(action)
|
||||
.toChangeInState(expectedToChangeInState);
|
||||
});
|
||||
|
||||
it('should handle SET_VIEWPORT_LAYOUT', () => {
|
||||
const setViewportLayoutAction = {
|
||||
const initialState = DEFAULT_STATE;
|
||||
|
||||
const action = {
|
||||
type: types.SET_VIEWPORT_LAYOUT,
|
||||
numRows: 1,
|
||||
numColumns: 2,
|
||||
@@ -43,18 +69,193 @@ describe('viewports reducer', () => {
|
||||
],
|
||||
};
|
||||
|
||||
const updatedState = reducer({}, setViewportLayoutAction);
|
||||
const expectedToChangeInState = {
|
||||
numRows: 1,
|
||||
numColumns: 2,
|
||||
layout: {
|
||||
viewports: [
|
||||
{
|
||||
plugin: 'cornerstone',
|
||||
},
|
||||
{
|
||||
plugin: 'vtk',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
expect(updatedState.numRows).toEqual(setViewportLayoutAction.numRows);
|
||||
expect(updatedState.numColumns).toEqual(setViewportLayoutAction.numColumns);
|
||||
expect(updatedState.layout.viewports).toEqual(
|
||||
setViewportLayoutAction.viewports
|
||||
);
|
||||
Reducer(reducer)
|
||||
.withState(initialState)
|
||||
.expect(action)
|
||||
.toChangeInState(expectedToChangeInState);
|
||||
});
|
||||
|
||||
it('should handle SET_VIEWPORT_LAYOUT when we reduce the number of viewports', () => {
|
||||
const initialState = {
|
||||
numRows: 1,
|
||||
numColumns: 2,
|
||||
viewportSpecificData: {
|
||||
0: { viewportData0: 'viewportData0' },
|
||||
1: { viewportData1: 'viewportData1' },
|
||||
},
|
||||
layout: {
|
||||
viewports: [],
|
||||
},
|
||||
activeViewportIndex: 0,
|
||||
};
|
||||
|
||||
const action = {
|
||||
type: types.SET_VIEWPORT_LAYOUT,
|
||||
numRows: 1,
|
||||
numColumns: 1,
|
||||
viewports: [],
|
||||
};
|
||||
|
||||
const expectedState = {
|
||||
numRows: 1,
|
||||
numColumns: 1,
|
||||
viewportSpecificData: {
|
||||
0: { viewportData0: 'viewportData0' },
|
||||
},
|
||||
layout: {
|
||||
viewports: [],
|
||||
},
|
||||
activeViewportIndex: 0,
|
||||
};
|
||||
|
||||
Reducer(reducer)
|
||||
.withState(initialState)
|
||||
.expect(action)
|
||||
.toReturnState(expectedState);
|
||||
});
|
||||
|
||||
it('should handle SET_VIEWPORT_LAYOUT_AND_DATA', () => {
|
||||
const initialState = {
|
||||
numRows: 1,
|
||||
numColumns: 1,
|
||||
viewportSpecificData: {
|
||||
0: { viewportData0: 'data0' },
|
||||
},
|
||||
layout: {
|
||||
viewports: [{ plugin: 'cornerstone' }],
|
||||
},
|
||||
activeViewportIndex: 0,
|
||||
};
|
||||
|
||||
const action = {
|
||||
type: types.SET_VIEWPORT_LAYOUT_AND_DATA,
|
||||
numRows: 1,
|
||||
numColumns: 2,
|
||||
viewports: [{ plugin: 'cornerstone' }, { plugin: 'cornerstone' }],
|
||||
viewportSpecificData: {
|
||||
0: { viewportData0: 'NEWdata0' },
|
||||
1: { viewportData1: 'NEWdata1' },
|
||||
},
|
||||
};
|
||||
|
||||
const expectedState = {
|
||||
numRows: 1,
|
||||
numColumns: 2,
|
||||
viewportSpecificData: {
|
||||
0: { viewportData0: 'NEWdata0' },
|
||||
1: { viewportData1: 'NEWdata1' },
|
||||
},
|
||||
layout: {
|
||||
viewports: [{ plugin: 'cornerstone' }, { plugin: 'cornerstone' }],
|
||||
},
|
||||
activeViewportIndex: 0,
|
||||
};
|
||||
|
||||
Reducer(reducer)
|
||||
.withState(initialState)
|
||||
.expect(action)
|
||||
.toReturnState(expectedState);
|
||||
});
|
||||
|
||||
it('should handle SET_VIEWPORT_LAYOUT_AND_DATA when we reduce the number of viewports', () => {
|
||||
const initialState = {
|
||||
numRows: 1,
|
||||
numColumns: 3,
|
||||
viewportSpecificData: {
|
||||
0: { viewportData0: 'vtkData0' },
|
||||
1: { viewportData1: 'vtkData1' },
|
||||
2: { viewportData2: 'vtkData2' },
|
||||
},
|
||||
layout: {
|
||||
viewports: [{ plugin: 'vtk' }, { plugin: 'vtk' }, { plugin: 'vtk' }],
|
||||
},
|
||||
activeViewportIndex: 0,
|
||||
};
|
||||
|
||||
const action = {
|
||||
type: types.SET_VIEWPORT_LAYOUT_AND_DATA,
|
||||
numRows: 1,
|
||||
numColumns: 1,
|
||||
viewports: [{ plugin: 'cornerstone' }],
|
||||
viewportSpecificData: {
|
||||
0: { viewportData0: 'cornerstoneData0' },
|
||||
},
|
||||
};
|
||||
|
||||
const expectedState = {
|
||||
numRows: 1,
|
||||
numColumns: 1,
|
||||
viewportSpecificData: {
|
||||
0: { viewportData0: 'cornerstoneData0' },
|
||||
},
|
||||
layout: {
|
||||
viewports: [{ plugin: 'cornerstone' }],
|
||||
},
|
||||
activeViewportIndex: 0,
|
||||
};
|
||||
|
||||
Reducer(reducer)
|
||||
.withState(initialState)
|
||||
.expect(action)
|
||||
.toReturnState(expectedState);
|
||||
});
|
||||
|
||||
it('should handle SET_VIEWPORT when we only set one viewport specific data', () => {
|
||||
const initialState = {
|
||||
numRows: 1,
|
||||
numColumns: 2,
|
||||
viewportSpecificData: {
|
||||
0: { viewportData0: 'data0' },
|
||||
1: { viewportData1: 'data1' },
|
||||
},
|
||||
layout: {
|
||||
viewports: [{ plugin: 'cornerstone' }, { plugin: 'cornerstone' }],
|
||||
},
|
||||
activeViewportIndex: 0,
|
||||
};
|
||||
|
||||
const action = {
|
||||
type: types.SET_VIEWPORT,
|
||||
viewportIndex: 1,
|
||||
viewportSpecificData: {
|
||||
viewportData1: 'NEWdata1',
|
||||
},
|
||||
};
|
||||
|
||||
const expectedState = {
|
||||
numRows: 1,
|
||||
numColumns: 2,
|
||||
viewportSpecificData: {
|
||||
0: { viewportData0: 'data0' },
|
||||
1: { viewportData1: 'NEWdata1' },
|
||||
},
|
||||
layout: {
|
||||
viewports: [{ plugin: 'cornerstone' }, { plugin: 'cornerstone' }],
|
||||
},
|
||||
activeViewportIndex: 0,
|
||||
};
|
||||
|
||||
Reducer(reducer)
|
||||
.withState(initialState)
|
||||
.expect(action)
|
||||
.toReturnState(expectedState);
|
||||
});
|
||||
|
||||
// If there were previous keys, this would have
|
||||
// "merge" behavior, not a clear & set
|
||||
// May be worth another test?
|
||||
it('should handle SET_VIEWPORT', () => {
|
||||
const viewportToSet = 0;
|
||||
const setViewportAction = {
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
import log from '../../log';
|
||||
import guid from '../../utils/guid';
|
||||
|
||||
/**
|
||||
* Measurement source schema
|
||||
*
|
||||
* @typedef {Object} MeasurementSource
|
||||
* @property {number} id -
|
||||
* @property {string} name -
|
||||
* @property {string} version -
|
||||
*/
|
||||
|
||||
/**
|
||||
* Measurement schema
|
||||
*
|
||||
* @typedef {Object} Measurement
|
||||
* @property {number} id -
|
||||
* @property {string} sopInstanceUID -
|
||||
* @property {string} frameOfReferenceUID -
|
||||
* @property {string} referenceSeriesUID -
|
||||
* @property {string} label -
|
||||
* @property {string} description -
|
||||
* @property {string} type -
|
||||
* @property {string} unit -
|
||||
* @property {number} area -
|
||||
* @property {Array} points -
|
||||
* @property {MeasurementSource} source -
|
||||
*/
|
||||
|
||||
/* Measurement schema keys for object validation. */
|
||||
const MEASUREMENT_SCHEMA_KEYS = [
|
||||
'id',
|
||||
'sopInstanceUID',
|
||||
'frameOfReferenceUID',
|
||||
'referenceSeriesUID',
|
||||
'label',
|
||||
'description',
|
||||
'type',
|
||||
'unit',
|
||||
'area', // TODO: Add concept names instead (descriptor)
|
||||
'points',
|
||||
'source',
|
||||
];
|
||||
|
||||
const EVENTS = {
|
||||
MEASUREMENT_UPDATED: 'event::measurement_updated',
|
||||
MEASUREMENT_ADDED: 'event::measurement_added',
|
||||
};
|
||||
|
||||
const VALUE_TYPES = {
|
||||
POLYLINE: 'value_type::polyline',
|
||||
POINT: 'value_type::point',
|
||||
ELLIPSE: 'value_type::ellipse',
|
||||
MULTIPOINT: 'value_type::multipoint',
|
||||
CIRCLE: 'value_type::circle',
|
||||
};
|
||||
|
||||
class MeasurementService {
|
||||
constructor() {
|
||||
this.sources = {};
|
||||
this.mappings = {};
|
||||
this.measurements = {};
|
||||
this.listeners = {};
|
||||
Object.defineProperty(this, 'EVENTS', {
|
||||
value: EVENTS,
|
||||
writable: false,
|
||||
enumerable: true,
|
||||
configurable: false,
|
||||
});
|
||||
Object.defineProperty(this, 'VALUE_TYPES', {
|
||||
value: VALUE_TYPES,
|
||||
writable: false,
|
||||
enumerable: true,
|
||||
configurable: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all measurements.
|
||||
*
|
||||
* @return {Measurement[]} Array of measurements
|
||||
*/
|
||||
getMeasurements() {
|
||||
const measurements = this._arrayOfObjects(this.measurements);
|
||||
return (
|
||||
measurements &&
|
||||
measurements.map(m => this.measurements[Object.keys(m)[0]])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get specific measurement by its id.
|
||||
*
|
||||
* @param {string} id If of the measurement
|
||||
* @return {Measurement} Measurement instance
|
||||
*/
|
||||
getMeasurement(id) {
|
||||
let measurement = null;
|
||||
const measurements = this.measurements[id];
|
||||
|
||||
if (measurements && Object.keys(measurements).length > 0) {
|
||||
measurement = this.measurements[id];
|
||||
}
|
||||
|
||||
return measurement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new source.
|
||||
*
|
||||
* @param {string} name Name of the source
|
||||
* @param {string} version Source name
|
||||
* @return {MeasurementSource} Measurement source instance
|
||||
*/
|
||||
createSource(name, version) {
|
||||
if (!name) {
|
||||
log.warn('Source name not provided. Exiting early.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!version) {
|
||||
log.warn('Source version not provided. Exiting early.');
|
||||
return;
|
||||
}
|
||||
|
||||
const id = guid();
|
||||
const source = {
|
||||
id,
|
||||
name,
|
||||
version,
|
||||
};
|
||||
source.addOrUpdate = (definition, measurement) => {
|
||||
return this.addOrUpdate(source, definition, measurement);
|
||||
};
|
||||
source.getAnnotation = (definition, measurementId) => {
|
||||
return this.getAnnotation(source, definition, measurementId);
|
||||
};
|
||||
|
||||
log.info(`New '${name}@${version}' source added.`);
|
||||
this.sources[id] = source;
|
||||
|
||||
return source;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new measurement matching criteria along with mapping functions.
|
||||
*
|
||||
* @param {MeasurementSource} source Measurement source instance
|
||||
* @param {string} definition Definition of the measurement (Annotation Type)
|
||||
* @param {MatchingCriteria} matchingCriteria The matching criteria
|
||||
* @param {Function} toSourceSchema Mapping function to source schema
|
||||
* @param {Function} toMeasurementSchema Mapping function to measurement schema
|
||||
* @return void
|
||||
*/
|
||||
addMapping(
|
||||
source,
|
||||
definition,
|
||||
matchingCriteria,
|
||||
toSourceSchema,
|
||||
toMeasurementSchema
|
||||
) {
|
||||
if (!this._isValidSource(source)) {
|
||||
log.warn('Invalid source. Exiting early.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!matchingCriteria) {
|
||||
log.warn('Matching criteria not provided. Exiting early.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!definition) {
|
||||
log.warn('Definition not provided. Exiting early.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!toSourceSchema) {
|
||||
log.warn('Source mapping function not provided. Exiting early.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!toMeasurementSchema) {
|
||||
log.warn('Measurement mapping function not provided. Exiting early.');
|
||||
return;
|
||||
}
|
||||
|
||||
const mapping = {
|
||||
matchingCriteria,
|
||||
definition,
|
||||
toSourceSchema,
|
||||
toMeasurementSchema,
|
||||
};
|
||||
|
||||
if (Array.isArray(this.mappings[source.id])) {
|
||||
this.mappings[source.id].push(mapping);
|
||||
} else {
|
||||
this.mappings[source.id] = [mapping];
|
||||
}
|
||||
|
||||
log.info(`New measurement mapping added to source '${this._getSourceInfo(source)}'.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get annotation for specific source.
|
||||
*
|
||||
* @param {MeasurementSource} source Measurement source instance
|
||||
* @param {string} definition The source definition
|
||||
* @param {string} measurementId The measurement service measurement id
|
||||
* @return {Object} Source measurement schema
|
||||
*/
|
||||
getAnnotation(source, definition, measurementId) {
|
||||
if (!this._isValidSource(source)) {
|
||||
log.warn('Invalid source. Exiting early.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!definition) {
|
||||
log.warn('No source definition provided. Exiting early.');
|
||||
return;
|
||||
}
|
||||
|
||||
const mapping = this._getMappingByMeasurementSource(measurementId, definition);
|
||||
if (mapping) return mapping.toSourceSchema(measurement, definition);
|
||||
|
||||
const measurement = this.getMeasurement(measurementId);
|
||||
const matchingMapping = this._getMatchingMapping(source, definition, measurement);
|
||||
|
||||
if (matchingMapping) {
|
||||
log.info('Matching mapping found:', matchingMapping);
|
||||
const { toSourceSchema, definition } = matchingMapping;
|
||||
return toSourceSchema(measurement, definition);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds or update persisted measurements.
|
||||
*
|
||||
* @param {MeasurementSource} source The measurement source instance
|
||||
* @param {string} definition The source definition
|
||||
* @param {Measurement} measurement The source measurement
|
||||
* @return {string} A measurement id
|
||||
*/
|
||||
addOrUpdate(source, definition, sourceMeasurement) {
|
||||
if (!this._isValidSource(source)) {
|
||||
log.warn('Invalid source. Exiting early.');
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceInfo = this._getSourceInfo(source);
|
||||
|
||||
if (!definition) {
|
||||
log.warn('No source definition provided. Exiting early.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._sourceHasMappings(source)) {
|
||||
log.warn(`No measurement mappings found for '${sourceInfo}' source. Exiting early.`);
|
||||
return;
|
||||
}
|
||||
|
||||
let measurement = {};
|
||||
try {
|
||||
const sourceMappings = this.mappings[source.id];
|
||||
const { toMeasurementSchema } = sourceMappings.find(
|
||||
mapping => mapping.definition === definition
|
||||
);
|
||||
|
||||
/* Convert measurement */
|
||||
measurement = toMeasurementSchema(sourceMeasurement);
|
||||
|
||||
/* Assign measurement source instance */
|
||||
measurement.source = source;
|
||||
} catch (error) {
|
||||
log.warn(`Failed to map '${sourceInfo}' measurement for definition ${definition}:`, error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._isValidMeasurement(measurement)) {
|
||||
log.warn(
|
||||
`Attempting to add or update a invalid measurement provided by '${sourceInfo}'. Exiting early.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let internalId = sourceMeasurement.id;
|
||||
if (!internalId) {
|
||||
internalId = guid();
|
||||
log.warn(`Measurement ID not found. Generating UID: ${internalId}`);
|
||||
}
|
||||
|
||||
const newMeasurement = {
|
||||
...measurement,
|
||||
modifiedTimestamp: Math.floor(Date.now() / 1000),
|
||||
id: internalId,
|
||||
};
|
||||
|
||||
if (this.measurements[internalId]) {
|
||||
log.info(`Measurement already defined. Updating measurement.`, newMeasurement);
|
||||
this.measurements[internalId] = newMeasurement;
|
||||
this._broadcastChange(this.EVENTS.MEASUREMENT_UPDATED, source, newMeasurement);
|
||||
} else {
|
||||
log.info(`Measurement added.`, newMeasurement);
|
||||
this.measurements[internalId] = newMeasurement;
|
||||
this._broadcastChange(this.EVENTS.MEASUREMENT_ADDED, source, newMeasurement);
|
||||
}
|
||||
|
||||
return newMeasurement.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to measurement updates.
|
||||
*
|
||||
* @param {string} eventName The name of the event
|
||||
* @param {Function} callback Events callback
|
||||
* @return {Object} Observable object with actions
|
||||
*/
|
||||
subscribe(eventName, callback) {
|
||||
if (this._isValidEvent(eventName)) {
|
||||
const listenerId = guid();
|
||||
const subscription = { id: listenerId, callback };
|
||||
|
||||
console.info(`Subscribing to '${eventName}'.`);
|
||||
if (Array.isArray(this.listeners[eventName])) {
|
||||
this.listeners[eventName].push(subscription);
|
||||
} else {
|
||||
this.listeners[eventName] = [subscription];
|
||||
}
|
||||
|
||||
return {
|
||||
unsubscribe: () => this._unsubscribe(eventName, listenerId),
|
||||
};
|
||||
} else {
|
||||
throw new Error(`Event ${eventName} not supported.`);
|
||||
}
|
||||
}
|
||||
|
||||
_getMappingByMeasurementSource(measurementId, definition) {
|
||||
const measurement = this.getMeasurement(measurementId);
|
||||
if (this._isValidSource(measurement.source)) {
|
||||
return this.mappings[measurement.source.id].find(
|
||||
m => m.definition === definition
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get measurement mapping function if matching criteria.
|
||||
*
|
||||
* @param {MeasurementSource} source Measurement source instance
|
||||
* @param {string} definition The source definition
|
||||
* @param {string} measurement The measurement serice measurement
|
||||
* @return {Object} The mapping based on matched criteria
|
||||
*/
|
||||
_getMatchingMapping(source, definition, measurement) {
|
||||
const sourceMappings = this.mappings[source.id];
|
||||
|
||||
const sourceMappingsByDefinition = sourceMappings.filter(
|
||||
mapping => mapping.definition === definition
|
||||
);
|
||||
|
||||
/* Criteria Matching */
|
||||
return sourceMappingsByDefinition.find(({ matchingCriteria }) => {
|
||||
return (
|
||||
measurement.points &&
|
||||
measurement.points.length === matchingCriteria.points
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns formatted string with source info.
|
||||
*
|
||||
* @param {MeasurementSource} source Measurement source
|
||||
* @return {string} Source information
|
||||
*/
|
||||
_getSourceInfo(source) {
|
||||
return `${source.name}@${source.version}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if given source is valid.
|
||||
*
|
||||
* @param {MeasurementSource} source Measurement source
|
||||
* @return {boolean} Measurement source validation
|
||||
*/
|
||||
_isValidSource(source) {
|
||||
return source && this.sources[source.id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a given source has mappings.
|
||||
*
|
||||
* @param {MeasurementSource} source The measurement source
|
||||
* @return {boolean} Validation if source has mappings
|
||||
*/
|
||||
_sourceHasMappings(source) {
|
||||
return (
|
||||
Array.isArray(this.mappings[source.id]) && this.mappings[source.id].length
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcasts measurement changes.
|
||||
*
|
||||
* @param {string} measurementId The measurement id
|
||||
* @param {MeasurementSource} source The measurement source
|
||||
* @param {string} eventName The event name
|
||||
* @return void
|
||||
*/
|
||||
_broadcastChange(eventName, source, measurement) {
|
||||
const hasListeners = Object.keys(this.listeners).length > 0;
|
||||
const hasCallbacks = Array.isArray(this.listeners[eventName]);
|
||||
|
||||
if (hasListeners && hasCallbacks) {
|
||||
this.listeners[eventName].forEach(listener => {
|
||||
listener.callback({ source, measurement });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe to measurement updates.
|
||||
*
|
||||
* @param {string} eventName The name of the event
|
||||
* @param {string} listenerId The listeners id
|
||||
* @return void
|
||||
*/
|
||||
_unsubscribe(eventName, listenerId) {
|
||||
if (!this.listeners[eventName]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const listeners = this.listeners[eventName];
|
||||
if (Array.isArray(listeners)) {
|
||||
this.listeners[eventName] = listeners.filter(
|
||||
({ id }) => id !== listenerId
|
||||
);
|
||||
} else {
|
||||
this.listeners[eventName] = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a given measurement data is valid.
|
||||
*
|
||||
* @param {Measurement} measurementData Measurement data
|
||||
* @return {boolean} Measurement validation
|
||||
*/
|
||||
_isValidMeasurement(measurementData) {
|
||||
Object.keys(measurementData).forEach(key => {
|
||||
if (!MEASUREMENT_SCHEMA_KEYS.includes(key)) {
|
||||
log.warn(`Invalid measurement key: ${key}`);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a given measurement service event is valid.
|
||||
*
|
||||
* @param {string} eventName The name of the event
|
||||
* @return {boolean} Event name validation
|
||||
*/
|
||||
_isValidEvent(eventName) {
|
||||
return Object.values(this.EVENTS).includes(eventName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts object of objects to array.
|
||||
*
|
||||
* @return {Array} Array of objects
|
||||
*/
|
||||
_arrayOfObjects = obj => {
|
||||
return Object.entries(obj).map(e => ({ [e[0]]: e[1] }));
|
||||
};
|
||||
}
|
||||
|
||||
export default MeasurementService;
|
||||
export { EVENTS, VALUE_TYPES };
|
||||
@@ -0,0 +1,405 @@
|
||||
import MeasurementService from './MeasurementService.js';
|
||||
import log from '../../log';
|
||||
|
||||
jest.mock('../../log.js', () => ({
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('MeasurementService.js', () => {
|
||||
let measurementService;
|
||||
let measurement;
|
||||
let source;
|
||||
let definition;
|
||||
let matchingCriteria;
|
||||
let toAnnotation;
|
||||
let toMeasurement;
|
||||
let annotation;
|
||||
|
||||
beforeEach(() => {
|
||||
measurementService = new MeasurementService();
|
||||
source = measurementService.createSource('Test', '1');
|
||||
definition = 'Length';
|
||||
annotation = {
|
||||
toolName: definition,
|
||||
measurementData: {},
|
||||
};
|
||||
measurement = {
|
||||
sopInstanceUID: '123',
|
||||
frameOfReferenceUID: '1234',
|
||||
referenceSeriesUID: '12345',
|
||||
label: 'Label',
|
||||
description: 'Description',
|
||||
unit: 'mm',
|
||||
area: 123,
|
||||
type: measurementService.VALUE_TYPES.POLYLINE,
|
||||
points: [{ x: 1, y: 2 }, { x: 1, y: 2 }],
|
||||
source: source,
|
||||
};
|
||||
toAnnotation = () => annotation;
|
||||
toMeasurement = () => measurement;
|
||||
matchingCriteria = {
|
||||
valueType: measurementService.VALUE_TYPES.POLYLINE,
|
||||
points: 2,
|
||||
};
|
||||
log.warn.mockClear();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('createSource()', () => {
|
||||
it('creates new source with name and version', () => {
|
||||
measurementService.createSource('Testing', '1');
|
||||
});
|
||||
|
||||
it('logs warning and return early if no name provided', () => {
|
||||
measurementService.createSource(null, '1');
|
||||
|
||||
expect(log.warn.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('logs warning and return early if no version provided', () => {
|
||||
measurementService.createSource('Testing', null);
|
||||
|
||||
expect(log.warn.mock.calls.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addMapping()', () => {
|
||||
it('adds new mapping', () => {
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
definition,
|
||||
matchingCriteria,
|
||||
toAnnotation,
|
||||
toMeasurement
|
||||
);
|
||||
});
|
||||
|
||||
it('logs warning and return early if no matching criteria provided', () => {
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
definition,
|
||||
null,
|
||||
toAnnotation,
|
||||
toMeasurement
|
||||
);
|
||||
|
||||
expect(log.warn.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('logs warning and return early if invalid source provided', () => {
|
||||
const invalidSoure = {};
|
||||
|
||||
measurementService.addMapping(
|
||||
invalidSoure,
|
||||
definition,
|
||||
matchingCriteria,
|
||||
toAnnotation,
|
||||
toMeasurement
|
||||
);
|
||||
|
||||
expect(log.warn.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('logs warning and return early if no source provided', () => {
|
||||
measurementService.addMapping(
|
||||
null /* source */,
|
||||
definition,
|
||||
matchingCriteria,
|
||||
toAnnotation,
|
||||
toMeasurement
|
||||
);
|
||||
|
||||
expect(log.warn.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('logs warning and return early if no definition provided', () => {
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
null /* definition */,
|
||||
matchingCriteria,
|
||||
toAnnotation,
|
||||
toMeasurement
|
||||
);
|
||||
|
||||
expect(log.warn.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('logs warning and return early if no measurement mapping function provided', () => {
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
definition,
|
||||
matchingCriteria,
|
||||
null /* toAnnotation */,
|
||||
toMeasurement
|
||||
);
|
||||
|
||||
expect(log.warn.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('logs warning and return early if no annotation mapping function provided', () => {
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
definition,
|
||||
matchingCriteria,
|
||||
toAnnotation,
|
||||
null /* toMeasurement */
|
||||
);
|
||||
|
||||
expect(log.warn.mock.calls.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAnnotation()', () => {
|
||||
it('get annotation based on matched criteria', () => {
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
definition,
|
||||
matchingCriteria,
|
||||
toAnnotation,
|
||||
toMeasurement
|
||||
);
|
||||
const measurementId = source.addOrUpdate(definition, annotation);
|
||||
const mappedAnnotation = source.getAnnotation(definition, measurementId);
|
||||
|
||||
expect(annotation).toBe(mappedAnnotation);
|
||||
});
|
||||
|
||||
it('get annotation based on source and definition', () => {
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
definition,
|
||||
{},
|
||||
toAnnotation,
|
||||
toMeasurement
|
||||
);
|
||||
const measurementId = source.addOrUpdate(definition, annotation);
|
||||
const mappedAnnotation = source.getAnnotation(definition, measurementId);
|
||||
|
||||
expect(annotation).toBe(mappedAnnotation);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMeasurements()', () => {
|
||||
it('return all measurement service measurements', () => {
|
||||
const anotherMeasurement = {
|
||||
...measurement,
|
||||
label: 'Label2',
|
||||
unit: 'HU',
|
||||
};
|
||||
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
definition,
|
||||
matchingCriteria,
|
||||
toAnnotation,
|
||||
toMeasurement
|
||||
);
|
||||
|
||||
source.addOrUpdate(definition, measurement);
|
||||
source.addOrUpdate(definition, anotherMeasurement);
|
||||
|
||||
const measurements = measurementService.getMeasurements();
|
||||
|
||||
expect(measurements.length).toEqual(2);
|
||||
expect(measurements.length).toEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMeasurement()', () => {
|
||||
it('return measurement service measurement with given id', () => {
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
definition,
|
||||
matchingCriteria,
|
||||
toAnnotation,
|
||||
toMeasurement
|
||||
);
|
||||
|
||||
const id = source.addOrUpdate(definition, measurement);
|
||||
const returnedMeasurement = measurementService.getMeasurement(id);
|
||||
|
||||
/* Clear dynamic data */
|
||||
delete returnedMeasurement.modifiedTimestamp;
|
||||
|
||||
expect({ id, ...measurement }).toEqual(returnedMeasurement);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addOrUpdate()', () => {
|
||||
it('adds new measurements', () => {
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
definition,
|
||||
matchingCriteria,
|
||||
toAnnotation,
|
||||
toMeasurement
|
||||
);
|
||||
|
||||
source.addOrUpdate(definition, measurement);
|
||||
source.addOrUpdate(definition, measurement);
|
||||
|
||||
const measurements = measurementService.getMeasurements();
|
||||
|
||||
expect(measurements.length).toBe(2);
|
||||
});
|
||||
|
||||
it('fails to add new measurements when no mapping', () => {
|
||||
source.addOrUpdate(definition, measurement);
|
||||
|
||||
expect(log.warn.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('fails to add new measurements when invalid mapping function', () => {
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
definition,
|
||||
matchingCriteria,
|
||||
toAnnotation,
|
||||
1 /* Invalid */
|
||||
);
|
||||
|
||||
source.addOrUpdate(definition, measurement);
|
||||
|
||||
expect(log.warn.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('adds new measurement with custom id', () => {
|
||||
const newMeasurement = { id: 1, ...measurement };
|
||||
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
definition,
|
||||
matchingCriteria,
|
||||
toAnnotation,
|
||||
toMeasurement
|
||||
);
|
||||
|
||||
/* Add new measurement */
|
||||
source.addOrUpdate(definition, newMeasurement);
|
||||
const savedMeasurement = measurementService.getMeasurement(newMeasurement.id);
|
||||
|
||||
/* Clear dynamic data */
|
||||
delete newMeasurement.modifiedTimestamp;
|
||||
delete savedMeasurement.modifiedTimestamp;
|
||||
|
||||
expect(newMeasurement).toEqual(savedMeasurement);
|
||||
});
|
||||
|
||||
it('logs warning and return if adding invalid measurement', () => {
|
||||
measurement.invalidProperty = {};
|
||||
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
definition,
|
||||
matchingCriteria,
|
||||
toAnnotation,
|
||||
toMeasurement
|
||||
);
|
||||
|
||||
source.addOrUpdate(definition, measurement);
|
||||
|
||||
expect(log.warn.mock.calls.length).toBe(2);
|
||||
});
|
||||
|
||||
it('updates existent measurement', () => {
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
definition,
|
||||
matchingCriteria,
|
||||
toAnnotation,
|
||||
toMeasurement
|
||||
);
|
||||
|
||||
const id = source.addOrUpdate(definition, measurement);
|
||||
|
||||
measurement.unit = 'HU';
|
||||
|
||||
source.addOrUpdate(definition, { id, ...measurement });
|
||||
const updatedMeasurement = measurementService.getMeasurement(id);
|
||||
|
||||
expect(updatedMeasurement.unit).toBe('HU');
|
||||
});
|
||||
});
|
||||
|
||||
describe('subscribe()', () => {
|
||||
it('subscribers receive broadcasted add event', () => {
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
definition,
|
||||
matchingCriteria,
|
||||
toAnnotation,
|
||||
toMeasurement
|
||||
);
|
||||
|
||||
const { MEASUREMENT_ADDED } = measurementService.EVENTS;
|
||||
let addCallbackWasCalled = false;
|
||||
|
||||
/* Subscribe to add event */
|
||||
measurementService.subscribe(
|
||||
MEASUREMENT_ADDED,
|
||||
() => (addCallbackWasCalled = true)
|
||||
);
|
||||
|
||||
/* Add new measurement */
|
||||
source.addOrUpdate(definition, measurement);
|
||||
|
||||
expect(addCallbackWasCalled).toBe(true);
|
||||
});
|
||||
|
||||
it('subscribers receive broadcasted update event', () => {
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
definition,
|
||||
matchingCriteria,
|
||||
toAnnotation,
|
||||
toMeasurement
|
||||
);
|
||||
|
||||
const { MEASUREMENT_UPDATED } = measurementService.EVENTS;
|
||||
let updateCallbackWasCalled = false;
|
||||
|
||||
/* Subscribe to update event */
|
||||
measurementService.subscribe(
|
||||
MEASUREMENT_UPDATED,
|
||||
() => (updateCallbackWasCalled = true)
|
||||
);
|
||||
|
||||
/* Create measurement */
|
||||
const id = source.addOrUpdate(definition, measurement);
|
||||
|
||||
/* Update measurement */
|
||||
source.addOrUpdate(definition, { id, ...measurement });
|
||||
|
||||
expect(updateCallbackWasCalled).toBe(true);
|
||||
});
|
||||
|
||||
it('unsubscribes a listener', () => {
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
definition,
|
||||
matchingCriteria,
|
||||
toAnnotation,
|
||||
toMeasurement
|
||||
);
|
||||
|
||||
let updateCallbackWasCalled = false;
|
||||
const { MEASUREMENT_ADDED } = measurementService.EVENTS;
|
||||
|
||||
/* Subscribe to Add event */
|
||||
const { unsubscribe } = measurementService.subscribe(
|
||||
MEASUREMENT_ADDED,
|
||||
() => (updateCallbackWasCalled = true)
|
||||
);
|
||||
|
||||
/* Unsubscribe */
|
||||
unsubscribe();
|
||||
|
||||
/* Create measurement */
|
||||
source.addOrUpdate(definition, measurement);
|
||||
|
||||
expect(updateCallbackWasCalled).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import MeasurementService from './MeasurementService';
|
||||
|
||||
export default {
|
||||
name: 'MeasurementService',
|
||||
create: ({ configuration = {} }) => {
|
||||
return new MeasurementService();
|
||||
},
|
||||
};
|
||||
@@ -12,6 +12,8 @@
|
||||
|
||||
const name = 'UINotificationService';
|
||||
|
||||
const serviceShowRequestQueue = [];
|
||||
|
||||
const publicAPI = {
|
||||
name,
|
||||
hide: _hide,
|
||||
@@ -21,7 +23,11 @@ const publicAPI = {
|
||||
|
||||
const serviceImplementation = {
|
||||
_hide: () => console.warn('hide() NOT IMPLEMENTED'),
|
||||
_show: () => console.warn('show() NOT IMPLEMENTED'),
|
||||
_show: showArguments => {
|
||||
serviceShowRequestQueue.push(showArguments);
|
||||
|
||||
console.warn('show() NOT IMPLEMENTED');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -76,6 +82,11 @@ function setServiceImplementation({
|
||||
}
|
||||
if (showImplementation) {
|
||||
serviceImplementation._show = showImplementation;
|
||||
|
||||
while (serviceShowRequestQueue.length > 0) {
|
||||
const showArguments = serviceShowRequestQueue.pop();
|
||||
serviceImplementation._show(showArguments);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,12 @@ import ServicesManager from './ServicesManager.js';
|
||||
import UINotificationService from './UINotificationService';
|
||||
import UIModalService from './UIModalService';
|
||||
import UIDialogService from './UIDialogService';
|
||||
import MeasurementService from './MeasurementService';
|
||||
|
||||
export {
|
||||
UINotificationService,
|
||||
UIModalService,
|
||||
UIDialogService,
|
||||
ServicesManager,
|
||||
MeasurementService,
|
||||
};
|
||||
@@ -46,7 +46,7 @@ export default class RetrieveMetadataLoader {
|
||||
}
|
||||
|
||||
if (loaders.next().done && !result) {
|
||||
throw 'cant find data';
|
||||
throw new Error('RetrieveMetadataLoader failed');
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { api } from 'dicomweb-client';
|
||||
import DICOMWeb from '../../../DICOMWeb';
|
||||
|
||||
const WADOProxy = {
|
||||
@@ -21,6 +22,43 @@ function parseFloatArray(obj) {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple cache schema for retrieved color palettes.
|
||||
*/
|
||||
const paletteColorCache = {
|
||||
count: 0,
|
||||
maxAge: 24 * 60 * 60 * 1000, // 24h cache?
|
||||
entries: {},
|
||||
isValidUID: function(paletteUID) {
|
||||
return typeof paletteUID === 'string' && paletteUID.length > 0;
|
||||
},
|
||||
get: function(paletteUID) {
|
||||
let entry = null;
|
||||
if (this.entries.hasOwnProperty(paletteUID)) {
|
||||
entry = this.entries[paletteUID];
|
||||
// check how the entry is...
|
||||
if (Date.now() - entry.time > this.maxAge) {
|
||||
// entry is too old... remove entry.
|
||||
delete this.entries[paletteUID];
|
||||
this.count--;
|
||||
entry = null;
|
||||
}
|
||||
}
|
||||
return entry;
|
||||
},
|
||||
add: function(entry) {
|
||||
if (this.isValidUID(entry.uid)) {
|
||||
let paletteUID = entry.uid;
|
||||
if (this.entries.hasOwnProperty(paletteUID) !== true) {
|
||||
this.count++; // increment cache entry count...
|
||||
}
|
||||
entry.time = Date.now();
|
||||
this.entries[paletteUID] = entry;
|
||||
// @TODO: Add logic to get rid of old entries and reduce memory usage...
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a plain JS object that describes a study (a study descriptor object)
|
||||
* @param {Object} server Object with server configuration parameters
|
||||
@@ -299,40 +337,41 @@ async function makeSOPInstance(server, study, instance) {
|
||||
return sopInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert String to ArrayBuffer
|
||||
*
|
||||
* @param {String} str Input String
|
||||
* @return {ArrayBuffer} Output converted ArrayBuffer
|
||||
*/
|
||||
function str2ab(str) {
|
||||
const strLen = str.length;
|
||||
const bytes = new Uint8Array(strLen);
|
||||
|
||||
for (let i = 0; i < strLen; i++) {
|
||||
bytes[i] = str.charCodeAt(i);
|
||||
}
|
||||
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
function getPaletteColor(server, instance, tag, lutDescriptor) {
|
||||
const numLutEntries = lutDescriptor[0];
|
||||
const bits = lutDescriptor[2];
|
||||
|
||||
let uri = WADOProxy.convertURL(instance[tag].BulkDataURI, server);
|
||||
|
||||
// TODO: Workaround for dcm4chee behind SSL-terminating proxy returning
|
||||
// incorrect bulk data URIs
|
||||
if (server.wadoRoot.indexOf('https') === 0 && !uri.includes('https')) {
|
||||
uri = uri.replace('http', 'https');
|
||||
}
|
||||
|
||||
const config = {
|
||||
url: server.wadoRoot, //BulkDataURI is absolute, so this isn't used
|
||||
headers: DICOMWeb.getAuthorizationHeader(server),
|
||||
};
|
||||
const dicomWeb = new api.DICOMwebClient(config);
|
||||
const options = {
|
||||
BulkDataURI: uri,
|
||||
};
|
||||
|
||||
const readUInt16 = (byteArray, position) => {
|
||||
return byteArray[position] + byteArray[position + 1] * 256;
|
||||
};
|
||||
|
||||
const arrayBufferToPaletteColorLUT = result => {
|
||||
const arraybuffer = result[0];
|
||||
const arrayBufferToPaletteColorLUT = arraybuffer => {
|
||||
const byteArray = new Uint8Array(arraybuffer);
|
||||
const lut = [];
|
||||
|
||||
for (let i = 0; i < numLutEntries; i++) {
|
||||
if (bits === 16) {
|
||||
if (bits === 16) {
|
||||
for (let i = 0; i < numLutEntries; i++) {
|
||||
lut[i] = readUInt16(byteArray, i * 2);
|
||||
} else {
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < numLutEntries; i++) {
|
||||
lut[i] = byteArray[i];
|
||||
}
|
||||
}
|
||||
@@ -340,7 +379,38 @@ function getPaletteColor(server, instance, tag, lutDescriptor) {
|
||||
return lut;
|
||||
};
|
||||
|
||||
return dicomWeb.retrieveBulkData(options).then(arrayBufferToPaletteColorLUT);
|
||||
if (instance[tag].BulkDataURI) {
|
||||
let uri = WADOProxy.convertURL(instance[tag].BulkDataURI, server);
|
||||
|
||||
// TODO: Workaround for dcm4chee behind SSL-terminating proxy returning
|
||||
// incorrect bulk data URIs
|
||||
if (server.wadoRoot.indexOf('https') === 0 && !uri.includes('https')) {
|
||||
uri = uri.replace('http', 'https');
|
||||
}
|
||||
|
||||
const config = {
|
||||
url: server.wadoRoot, //BulkDataURI is absolute, so this isn't used
|
||||
headers: DICOMWeb.getAuthorizationHeader(server),
|
||||
};
|
||||
const dicomWeb = new api.DICOMwebClient(config);
|
||||
const options = {
|
||||
BulkDataURI: uri,
|
||||
};
|
||||
|
||||
return dicomWeb
|
||||
.retrieveBulkData(options)
|
||||
.then(result => result[0])
|
||||
.then(arrayBufferToPaletteColorLUT);
|
||||
} else if (instance[tag].InlineBinary) {
|
||||
const inlineBinaryData = atob(instance[tag].InlineBinary);
|
||||
const arraybuf = str2ab(inlineBinaryData);
|
||||
|
||||
return arrayBufferToPaletteColorLUT(arraybuf);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Palette Color LUT was not provided as InlineBinary or BulkDataURI'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import Mousetrap from 'mousetrap';
|
||||
import pausePlugin from './pausePlugin';
|
||||
import recordPlugin from './recordPlugin';
|
||||
|
||||
recordPlugin(Mousetrap);
|
||||
pausePlugin(Mousetrap);
|
||||
|
||||
export default Mousetrap;
|
||||
File renamed without changes.
+26
-8
@@ -66,7 +66,7 @@ export default function(Mousetrap) {
|
||||
_recordCurrentCombo();
|
||||
}
|
||||
|
||||
for (i = 0; i < modifiers.length; ++i) {
|
||||
for (let i = 0; i < modifiers.length; ++i) {
|
||||
_recordKey(modifiers[i]);
|
||||
}
|
||||
_recordKey(character);
|
||||
@@ -85,10 +85,8 @@ export default function(Mousetrap) {
|
||||
* @returns void
|
||||
*/
|
||||
function _recordKey(key) {
|
||||
var i;
|
||||
|
||||
// one-off implementation of Array.indexOf, since IE6-9 don't support it
|
||||
for (i = 0; i < _currentRecordedKeys.length; ++i) {
|
||||
for (let i = 0; i < _currentRecordedKeys.length; ++i) {
|
||||
if (_currentRecordedKeys[i] === key) {
|
||||
return;
|
||||
}
|
||||
@@ -111,7 +109,7 @@ export default function(Mousetrap) {
|
||||
_recordedSequence.push(_currentRecordedKeys);
|
||||
_currentRecordedKeys = [];
|
||||
_recordedCharacterKey = false;
|
||||
_restartRecordTimer();
|
||||
_finishRecording();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,9 +122,7 @@ export default function(Mousetrap) {
|
||||
* @returns void
|
||||
*/
|
||||
function _normalizeSequence(sequence) {
|
||||
var i;
|
||||
|
||||
for (i = 0; i < sequence.length; ++i) {
|
||||
for (let i = 0; i < sequence.length; ++i) {
|
||||
sequence[i].sort(function(x, y) {
|
||||
// modifier keys always come first, in alphabetical order
|
||||
if (x.length > 1 && y.length === 1) {
|
||||
@@ -191,6 +187,28 @@ export default function(Mousetrap) {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* stop recording
|
||||
*
|
||||
* @param {Function} callback
|
||||
* @returns void
|
||||
*/
|
||||
Mousetrap.prototype.stopRecord = function() {
|
||||
var self = this;
|
||||
self.recording = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* start recording
|
||||
*
|
||||
* @param {Function} callback
|
||||
* @returns void
|
||||
*/
|
||||
Mousetrap.prototype.startRecording = function() {
|
||||
var self = this;
|
||||
self.recording = true;
|
||||
};
|
||||
|
||||
Mousetrap.prototype.handleKey = function() {
|
||||
var self = this;
|
||||
_handleKey.apply(self, arguments);
|
||||
@@ -12,6 +12,7 @@ import DicomLoaderService from './dicomLoaderService.js';
|
||||
import b64toBlob from './b64toBlob.js';
|
||||
import * as urlUtil from './urlUtil';
|
||||
import makeCancelable from './makeCancelable';
|
||||
import hotkeys from './hotkeys';
|
||||
|
||||
const utils = {
|
||||
guid,
|
||||
@@ -29,6 +30,7 @@ const utils = {
|
||||
DicomLoaderService,
|
||||
urlUtil,
|
||||
makeCancelable,
|
||||
hotkeys,
|
||||
};
|
||||
|
||||
export {
|
||||
@@ -47,6 +49,7 @@ export {
|
||||
DicomLoaderService,
|
||||
urlUtil,
|
||||
makeCancelable,
|
||||
hotkeys,
|
||||
};
|
||||
|
||||
export default utils;
|
||||
@@ -18,6 +18,7 @@ describe('Top level exports', () => {
|
||||
'DicomLoaderService',
|
||||
'urlUtil',
|
||||
'makeCancelable',
|
||||
'hotkeys',
|
||||
].sort();
|
||||
|
||||
const exports = Object.keys(utils.default).sort();
|
||||
|
||||
@@ -67,6 +67,12 @@ function processSingleframe(instances) {
|
||||
if (instances.length > 2) {
|
||||
const firstIpp = _getImagePositionPatient(firstImage);
|
||||
const lastIpp = _getImagePositionPatient(instances[instances.length - 1]);
|
||||
|
||||
// We can't reconstruct if we are missing imagePositionPatient values
|
||||
if (!firstIpp || !lastIpp) {
|
||||
return { value: false };
|
||||
}
|
||||
|
||||
const averageSpacingBetweenFrames =
|
||||
_getPerpendicularDistance(firstIpp, lastIpp) / (instances.length - 1);
|
||||
|
||||
@@ -136,8 +142,13 @@ function _getSpacingIssue(spacing, averageSpacing) {
|
||||
}
|
||||
|
||||
function _getImagePositionPatient(instance) {
|
||||
return instance
|
||||
.getTagValue('x00200032')
|
||||
const tagValue = instance
|
||||
.getTagValue('x00200032');
|
||||
if (!tagValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
return tagValue
|
||||
.split('\\')
|
||||
.map(element => Number(element));
|
||||
}
|
||||
|
||||
@@ -3,6 +3,28 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [0.52.6](https://github.com/OHIF/Viewers/compare/@ohif/i18n@0.52.5...@ohif/i18n@0.52.6) (2020-02-12)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Combined Hotkeys for special characters ([#1233](https://github.com/OHIF/Viewers/issues/1233)) ([2f30e7a](https://github.com/OHIF/Viewers/commit/2f30e7a821a238144c49c56f37d8e5565540b4bd))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [0.52.5](https://github.com/OHIF/Viewers/compare/@ohif/i18n@0.52.4...@ohif/i18n@0.52.5) (2020-01-30)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* download tool fixes & improvements ([#1235](https://github.com/OHIF/Viewers/issues/1235)) ([b9574b6](https://github.com/OHIF/Viewers/commit/b9574b6efcfeb85cde35b5cae63282f8e1b35be6))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [0.52.4](https://github.com/OHIF/Viewers/compare/@ohif/i18n@0.52.3...@ohif/i18n@0.52.4) (2019-12-16)
|
||||
|
||||
**Note:** Version bump only for package @ohif/i18n
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ohif/i18n",
|
||||
"version": "0.52.4",
|
||||
"version": "0.52.6",
|
||||
"description": "Internationalization library for The OHIF Viewer",
|
||||
"author": "OHIF",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
const languagesMap = {
|
||||
ar: 'Arabic',
|
||||
am: 'Amharic',
|
||||
bg: 'Bulgarian',
|
||||
bn: 'Bengali',
|
||||
ca: 'Catalan',
|
||||
cs: 'Czech',
|
||||
da: 'Danish',
|
||||
de: 'German',
|
||||
el: 'Greek',
|
||||
en: 'English',
|
||||
'en-GB': 'English (Great Britain)',
|
||||
'en-US': 'English (USA)',
|
||||
es: 'Spanish',
|
||||
et: 'Estonian',
|
||||
fa: 'Persian',
|
||||
fi: 'Finnish',
|
||||
fil: 'Filipino',
|
||||
fr: 'French',
|
||||
gu: 'Gujarati',
|
||||
he: 'Hebrew',
|
||||
hi: 'Hindi',
|
||||
hr: 'Croatian',
|
||||
hu: 'Hungarian',
|
||||
id: 'Indonesian',
|
||||
it: 'Italian',
|
||||
ja: 'Japanese',
|
||||
'ja-JP': 'Japanese (Japan)',
|
||||
kn: 'Kannada',
|
||||
ko: 'Korean',
|
||||
lt: 'Lithuanian',
|
||||
lv: 'Latvian',
|
||||
ml: 'Malayalam',
|
||||
mr: 'Marathi',
|
||||
ms: 'Malay',
|
||||
nl: 'Dutch',
|
||||
no: 'Norwegian',
|
||||
pl: 'Polish',
|
||||
'pt-BR': 'Portuguese (Brazil)',
|
||||
'pt-PT': 'Portuguese (Portugal)',
|
||||
ro: 'Romanian',
|
||||
ru: 'Russian',
|
||||
sk: 'Slovak',
|
||||
sl: 'Slovenian',
|
||||
sr: 'Serbian',
|
||||
sv: 'Swedish',
|
||||
sw: 'Swahili',
|
||||
ta: 'Tamil',
|
||||
te: 'Telugu',
|
||||
th: 'Thai',
|
||||
tr: 'Turkish',
|
||||
uk: 'Ukrainian',
|
||||
vi: 'Vietnamese',
|
||||
zh: 'Chinese',
|
||||
'zh-CN': 'Chinese (China)',
|
||||
'zh-TW': 'Chinese (Taiwan)',
|
||||
};
|
||||
|
||||
export default function getAvailableLanguagesInfo(locales) {
|
||||
const availableLanguagesInfo = [];
|
||||
|
||||
Object.keys(locales).forEach(key => {
|
||||
availableLanguagesInfo.push({
|
||||
value: key,
|
||||
label: languagesMap[key] || key,
|
||||
});
|
||||
});
|
||||
|
||||
return availableLanguagesInfo;
|
||||
}
|
||||
@@ -140,4 +140,7 @@ i18n.initI18n = initI18n;
|
||||
i18n.addLocales = addLocales;
|
||||
i18n.defaultLanguage = DEFAULT_LANGUAGE;
|
||||
|
||||
import getAvailableLanguagesInfo from './getAvailableLanguagesInfo.js';
|
||||
i18n.availableLanguages = getAvailableLanguagesInfo(locales);
|
||||
|
||||
export default i18n;
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"emptyFilenameError": "The file name cannot be empty.",
|
||||
"fileType": "File Type",
|
||||
"filename": "File Name",
|
||||
"formTitle": "Please specify the dimensions, filename, and desired type for the output image.",
|
||||
"imageHeight": "Image height (px)",
|
||||
"imagePreview": "Image Preview",
|
||||
"imageWidth": "Image width (px)",
|
||||
"keepAspectRatio": "Keep aspect ratio",
|
||||
"loadingPreview": "Loading Image Preview...",
|
||||
"minHeightError": "The minimum valid height is 100px.",
|
||||
"minWidthError": "The minimum valid width is 100px.",
|
||||
"showAnnotations": "Show Annotations"
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import Header from './Header.json';
|
||||
import MeasurementTable from './MeasurementTable.json';
|
||||
import StudyList from './StudyList.json';
|
||||
import UserPreferencesModal from './UserPreferencesModal.json';
|
||||
import ViewportDownloadForm from './ViewportDownloadForm.json';
|
||||
|
||||
export default {
|
||||
'en-US': {
|
||||
@@ -19,5 +20,6 @@ export default {
|
||||
MeasurementTable,
|
||||
StudyList,
|
||||
UserPreferencesModal,
|
||||
ViewportDownloadForm,
|
||||
},
|
||||
};
|
||||
@@ -3,6 +3,61 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [1.3.0](https://github.com/OHIF/Viewers/compare/@ohif/ui@1.2.1...@ohif/ui@1.3.0) (2020-02-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* [#1342](https://github.com/OHIF/Viewers/issues/1342) - Window level tab ([#1429](https://github.com/OHIF/Viewers/issues/1429)) ([ebc01a8](https://github.com/OHIF/Viewers/commit/ebc01a8ca238d5a3437b44d81f75aa8a5e8d0574))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [1.2.1](https://github.com/OHIF/Viewers/compare/@ohif/ui@1.2.0...@ohif/ui@1.2.1) (2020-02-12)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Combined Hotkeys for special characters ([#1233](https://github.com/OHIF/Viewers/issues/1233)) ([2f30e7a](https://github.com/OHIF/Viewers/commit/2f30e7a821a238144c49c56f37d8e5565540b4bd))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [1.2.0](https://github.com/OHIF/Viewers/compare/@ohif/ui@1.1.9...@ohif/ui@1.2.0) (2020-02-10)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Lesion tracker right panel ([#1428](https://github.com/OHIF/Viewers/issues/1428)) ([98a649b](https://github.com/OHIF/Viewers/commit/98a649b455ffc712938fc5035cdef40695e58440))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [1.1.9](https://github.com/OHIF/Viewers/compare/@ohif/ui@1.1.8...@ohif/ui@1.1.9) (2020-01-30)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* download tool fixes & improvements ([#1235](https://github.com/OHIF/Viewers/issues/1235)) ([b9574b6](https://github.com/OHIF/Viewers/commit/b9574b6efcfeb85cde35b5cae63282f8e1b35be6))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [1.1.8](https://github.com/OHIF/Viewers/compare/@ohif/ui@1.1.7...@ohif/ui@1.1.8) (2020-01-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* measurements panel css and delete button visibility ([#1352](https://github.com/OHIF/Viewers/issues/1352)) ([7ab0bbb](https://github.com/OHIF/Viewers/commit/7ab0bbb32581dcba16ee16b49b92406e2856ac76))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [1.1.7](https://github.com/OHIF/Viewers/compare/@ohif/ui@1.1.6...@ohif/ui@1.1.7) (2019-12-20)
|
||||
|
||||
**Note:** Version bump only for package @ohif/ui
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ohif/ui",
|
||||
"version": "1.1.7",
|
||||
"version": "1.3.0",
|
||||
"description": "A set of React components for Medical Imaging Viewers",
|
||||
"author": "OHIF Contributors",
|
||||
"license": "MIT",
|
||||
|
||||
File renamed without changes.
@@ -1,9 +1,16 @@
|
||||
import React, { useEffect, useState, createRef } from 'react';
|
||||
import React, {
|
||||
useRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
createRef,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import './ViewportDownloadForm.styl';
|
||||
import { TextInput, Select } from '@ohif/ui';
|
||||
import { TextInput, Select, Icon } from '@ohif/ui';
|
||||
import classnames from 'classnames';
|
||||
|
||||
const FILE_TYPE_OPTIONS = [
|
||||
{
|
||||
@@ -17,6 +24,7 @@ const FILE_TYPE_OPTIONS = [
|
||||
];
|
||||
|
||||
const DEFAULT_FILENAME = 'image';
|
||||
const REFRESH_VIEWPORT_TIMEOUT = 1000;
|
||||
|
||||
const ViewportDownloadForm = ({
|
||||
activeViewport,
|
||||
@@ -32,7 +40,7 @@ const ViewportDownloadForm = ({
|
||||
maximumSize,
|
||||
canvasClass,
|
||||
}) => {
|
||||
const [t] = useTranslation('Buttons');
|
||||
const [t] = useTranslation('ViewportDownloadForm');
|
||||
|
||||
const [filename, setFilename] = useState(DEFAULT_FILENAME);
|
||||
const [fileType, setFileType] = useState('jpg');
|
||||
@@ -44,6 +52,12 @@ const ViewportDownloadForm = ({
|
||||
|
||||
const [showAnnotations, setShowAnnotations] = useState(true);
|
||||
|
||||
const [keepAspect, setKeepAspect] = useState(true);
|
||||
const [aspectMultiplier, setAspectMultiplier] = useState({
|
||||
width: 1,
|
||||
height: 1,
|
||||
});
|
||||
|
||||
const [viewportElement, setViewportElement] = useState();
|
||||
const [viewportElementDimensions, setViewportElementDimensions] = useState({
|
||||
width: defaultSize,
|
||||
@@ -62,41 +76,129 @@ const ViewportDownloadForm = ({
|
||||
height: defaultSize,
|
||||
});
|
||||
|
||||
// Cornerstone's `enable/disable`
|
||||
useEffect(() => {
|
||||
enableViewport(viewportElement);
|
||||
const [error, setError] = useState({
|
||||
width: false,
|
||||
height: false,
|
||||
filename: false,
|
||||
});
|
||||
|
||||
return () => {
|
||||
disableViewport(viewportElement);
|
||||
};
|
||||
}, [disableViewport, enableViewport, viewportElement]);
|
||||
const hasError = Object.values(error).includes(true);
|
||||
|
||||
useEffect(() => {
|
||||
const { width, height } = viewportElementDimensions;
|
||||
const validSize = value => (value >= minimumSize ? value : minimumSize);
|
||||
const loadAndUpdateViewports = async () => {
|
||||
await loadImage(activeViewport, viewportElement, width, height);
|
||||
toggleAnnotations(showAnnotations, viewportElement);
|
||||
const refreshViewport = useRef(null);
|
||||
|
||||
const {
|
||||
dataUrl,
|
||||
width: viewportElementWidth,
|
||||
height: viewportElementHeight,
|
||||
} = await updateViewportPreview(
|
||||
viewportElement,
|
||||
downloadCanvas.ref.current,
|
||||
fileType
|
||||
const downloadImage = () => {
|
||||
downloadBlob(
|
||||
filename || DEFAULT_FILENAME,
|
||||
fileType,
|
||||
viewportElement,
|
||||
downloadCanvas.ref.current
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {object} event - Input change event
|
||||
* @param {string} dimension - "height" | "width"
|
||||
*/
|
||||
const onDimensionsChange = (event, dimension) => {
|
||||
const oppositeDimension = dimension === 'height' ? 'width' : 'height';
|
||||
const sanitizedTargetValue = event.target.value.replace(/\D/, '');
|
||||
const isEmpty = sanitizedTargetValue === '';
|
||||
const newDimensions = { ...dimensions };
|
||||
const updatedDimension = isEmpty
|
||||
? ''
|
||||
: Math.min(sanitizedTargetValue, maximumSize);
|
||||
|
||||
if (updatedDimension === dimensions[dimension]) {
|
||||
return;
|
||||
}
|
||||
|
||||
newDimensions[dimension] = updatedDimension;
|
||||
|
||||
if (keepAspect && newDimensions[oppositeDimension] !== '') {
|
||||
newDimensions[oppositeDimension] = Math.round(
|
||||
newDimensions[dimension] * aspectMultiplier[oppositeDimension]
|
||||
);
|
||||
}
|
||||
|
||||
setViewportPreview(state => ({
|
||||
// In current code, keepAspect is always `true`
|
||||
// And we always start w/ a square width/height
|
||||
setDimensions(newDimensions);
|
||||
|
||||
// Only update if value is non-empty
|
||||
if (!isEmpty) {
|
||||
setViewportElementDimensions(newDimensions);
|
||||
setDownloadCanvas(state => ({
|
||||
...state,
|
||||
src: dataUrl,
|
||||
width: validSize(viewportElementWidth),
|
||||
height: validSize(viewportElementHeight),
|
||||
...newDimensions,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const error_messages = {
|
||||
width: t('minWidthError'),
|
||||
height: t('minHeightError'),
|
||||
filename: t('emptyFilenameError'),
|
||||
};
|
||||
|
||||
const renderErrorHandler = errorType => {
|
||||
if (!error[errorType]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className="input-error">{error_messages[errorType]}</div>;
|
||||
};
|
||||
|
||||
const onKeepAspectToggle = () => {
|
||||
const { width, height } = dimensions;
|
||||
const aspectMultiplier = { ...aspectMultiplier };
|
||||
if (!keepAspect) {
|
||||
const base = Math.min(width, height);
|
||||
aspectMultiplier.width = width / base;
|
||||
aspectMultiplier.height = height / base;
|
||||
setAspectMultiplier(aspectMultiplier);
|
||||
}
|
||||
|
||||
setKeepAspect(!keepAspect);
|
||||
};
|
||||
|
||||
const validSize = value => (value >= minimumSize ? value : minimumSize);
|
||||
const loadAndUpdateViewports = useCallback(async () => {
|
||||
const { width: scaledWidth, height: scaledHeight } = await loadImage(
|
||||
activeViewport,
|
||||
viewportElement,
|
||||
dimensions.width,
|
||||
dimensions.height
|
||||
);
|
||||
|
||||
toggleAnnotations(showAnnotations, viewportElement);
|
||||
|
||||
const scaledDimensions = {
|
||||
height: validSize(scaledHeight),
|
||||
width: validSize(scaledWidth),
|
||||
};
|
||||
|
||||
loadAndUpdateViewports();
|
||||
setViewportElementDimensions(scaledDimensions);
|
||||
setDownloadCanvas(state => ({
|
||||
...state,
|
||||
...scaledDimensions,
|
||||
}));
|
||||
|
||||
const {
|
||||
dataUrl,
|
||||
width: viewportElementWidth,
|
||||
height: viewportElementHeight,
|
||||
} = await updateViewportPreview(
|
||||
viewportElement,
|
||||
downloadCanvas.ref.current,
|
||||
fileType
|
||||
);
|
||||
|
||||
setViewportPreview(state => ({
|
||||
...state,
|
||||
src: dataUrl,
|
||||
width: validSize(viewportElementWidth),
|
||||
height: validSize(viewportElementHeight),
|
||||
}));
|
||||
}, [
|
||||
activeViewport,
|
||||
viewportElement,
|
||||
@@ -111,76 +213,96 @@ const ViewportDownloadForm = ({
|
||||
viewportElementDimensions,
|
||||
]);
|
||||
|
||||
/**
|
||||
* @param {object} event - Input change event
|
||||
* @param {string} dimension - "height" | "width"
|
||||
*/
|
||||
const onDimensionsChange = (event, dimension) => {
|
||||
const sanitizedTargetValue = event.target.value.replace(/\D/, '');
|
||||
const isEmpty = sanitizedTargetValue === '';
|
||||
const updatedDimension = isEmpty
|
||||
? ''
|
||||
: Math.min(sanitizedTargetValue, maximumSize);
|
||||
useEffect(() => {
|
||||
enableViewport(viewportElement);
|
||||
|
||||
if (updatedDimension === dimensions.width) {
|
||||
return;
|
||||
return () => {
|
||||
disableViewport(viewportElement);
|
||||
};
|
||||
}, [disableViewport, enableViewport, viewportElement]);
|
||||
|
||||
useEffect(() => {
|
||||
if (refreshViewport.current !== null) {
|
||||
clearTimeout(refreshViewport.current);
|
||||
}
|
||||
|
||||
// In current code, keepAspect is always `true`
|
||||
// And we always start w/ a square width/height
|
||||
setDimensions({
|
||||
width: updatedDimension,
|
||||
height: updatedDimension,
|
||||
});
|
||||
refreshViewport.current = setTimeout(() => {
|
||||
refreshViewport.current = null;
|
||||
loadAndUpdateViewports();
|
||||
}, REFRESH_VIEWPORT_TIMEOUT);
|
||||
}, [
|
||||
activeViewport,
|
||||
viewportElement,
|
||||
showAnnotations,
|
||||
dimensions,
|
||||
loadImage,
|
||||
toggleAnnotations,
|
||||
updateViewportPreview,
|
||||
fileType,
|
||||
downloadCanvas.ref,
|
||||
minimumSize,
|
||||
maximumSize,
|
||||
]);
|
||||
|
||||
// Only update if value is non-empty
|
||||
if (!isEmpty) {
|
||||
setViewportElementDimensions({
|
||||
height: updatedDimension,
|
||||
width: updatedDimension,
|
||||
});
|
||||
setDownloadCanvas(state => ({
|
||||
...state,
|
||||
height: updatedDimension,
|
||||
width: updatedDimension,
|
||||
}));
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
const { width, height } = dimensions;
|
||||
const hasError = {
|
||||
width: width < minimumSize,
|
||||
height: height < minimumSize,
|
||||
filename: !filename,
|
||||
};
|
||||
|
||||
const downloadImage = () => {
|
||||
downloadBlob(
|
||||
filename || DEFAULT_FILENAME,
|
||||
fileType,
|
||||
viewportElement,
|
||||
downloadCanvas.ref.current
|
||||
);
|
||||
};
|
||||
setError({ ...hasError });
|
||||
}, [dimensions, filename, minimumSize]);
|
||||
|
||||
return (
|
||||
<div className="ViewportDownloadForm">
|
||||
<div className="title">
|
||||
{t(
|
||||
'Please specify the dimensions, filename, and desired type for the output image.'
|
||||
)}
|
||||
</div>
|
||||
<div className="title">{t('formTitle')}</div>
|
||||
|
||||
<div className="file-info-container" data-cy="file-info-container">
|
||||
<div className="col">
|
||||
<div className="width">
|
||||
<TextInput
|
||||
data-cy="image-width"
|
||||
value={dimensions.width}
|
||||
label={t('Image width (px)')}
|
||||
onChange={evt => onDimensionsChange(evt, 'height')}
|
||||
/>
|
||||
<div className="dimension-wrapper">
|
||||
<div className="dimensions">
|
||||
<div className="width">
|
||||
<TextInput
|
||||
type="number"
|
||||
min={minimumSize}
|
||||
max={maximumSize}
|
||||
value={dimensions.width}
|
||||
label={t('imageWidth')}
|
||||
onChange={evt => onDimensionsChange(evt, 'width')}
|
||||
data-cy="image-width"
|
||||
/>
|
||||
{renderErrorHandler('width')}
|
||||
</div>
|
||||
<div className="height">
|
||||
<TextInput
|
||||
type="number"
|
||||
min={minimumSize}
|
||||
max={maximumSize}
|
||||
value={dimensions.height}
|
||||
label={t('imageHeight')}
|
||||
onChange={evt => onDimensionsChange(evt, 'height')}
|
||||
data-cy="image-height"
|
||||
/>
|
||||
{renderErrorHandler('height')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="height">
|
||||
<TextInput
|
||||
data-cy="image-height"
|
||||
value={dimensions.height}
|
||||
label={t('Image height (px)')}
|
||||
onChange={evt => onDimensionsChange(evt, 'width')}
|
||||
/>
|
||||
<div className="keep-aspect-wrapper">
|
||||
<button
|
||||
id="keep-aspect"
|
||||
className={classnames(
|
||||
'form-button btn',
|
||||
keepAspect ? 'active' : ''
|
||||
)}
|
||||
data-cy="keep-aspect"
|
||||
alt={t('keepAspectRatio')}
|
||||
onClick={onKeepAspectToggle}
|
||||
>
|
||||
<Icon
|
||||
name={keepAspect ? 'link' : 'unlink'}
|
||||
alt={keepAspect ? 'Dismiss Aspect' : 'Keep Aspect'}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -191,9 +313,10 @@ const ViewportDownloadForm = ({
|
||||
data-cy="file-name"
|
||||
value={filename}
|
||||
onChange={event => setFilename(event.target.value)}
|
||||
label={t('File name')}
|
||||
label={t('filename')}
|
||||
id="file-name"
|
||||
/>
|
||||
{renderErrorHandler('filename')}
|
||||
</div>
|
||||
<div className="file-type">
|
||||
<Select
|
||||
@@ -201,7 +324,7 @@ const ViewportDownloadForm = ({
|
||||
data-cy="file-type"
|
||||
onChange={event => setFileType(event.target.value)}
|
||||
options={FILE_TYPE_OPTIONS}
|
||||
label={t('File type')}
|
||||
label={t('fileType')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -217,7 +340,7 @@ const ViewportDownloadForm = ({
|
||||
checked={showAnnotations}
|
||||
onChange={event => setShowAnnotations(event.target.checked)}
|
||||
/>
|
||||
{t('Show Annotations')}
|
||||
{t('showAnnotations')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -245,15 +368,23 @@ const ViewportDownloadForm = ({
|
||||
></canvas>
|
||||
</div>
|
||||
|
||||
<div className="preview" data-cy="image-preview">
|
||||
<h4> {t('Image Preview')}</h4>
|
||||
<img
|
||||
className="viewport-preview"
|
||||
src={viewportPreview.src}
|
||||
alt="Viewport Preview"
|
||||
data-cy="viewport-preview-img"
|
||||
/>
|
||||
</div>
|
||||
{viewportPreview.src ? (
|
||||
<div className="preview" data-cy="image-preview">
|
||||
<div className="preview-header"> {t('imagePreview')}</div>
|
||||
<img
|
||||
className="viewport-preview"
|
||||
src={viewportPreview.src}
|
||||
alt={t('imagePreview')}
|
||||
data-cy="image-preview"
|
||||
data-cy="viewport-preview-img"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="loading-image">
|
||||
<Icon name="circle-notch" className="icon-spin" />
|
||||
{t('loadingPreview')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="actions">
|
||||
<div className="action-cancel">
|
||||
@@ -263,16 +394,17 @@ const ViewportDownloadForm = ({
|
||||
className="btn btn-danger"
|
||||
onClick={onClose}
|
||||
>
|
||||
{t('Cancel')}
|
||||
{t('Buttons:Cancel')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="action-save">
|
||||
<button
|
||||
disabled={hasError}
|
||||
onClick={downloadImage}
|
||||
className="btn btn-primary"
|
||||
data-cy="download-btn"
|
||||
>
|
||||
{t('Download')}
|
||||
{t('Buttons:Download')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
input, select
|
||||
max-height: 30px;
|
||||
|
||||
#keep-aspect svg
|
||||
margin-top: 3px;
|
||||
|
||||
.title
|
||||
margin: 0;
|
||||
font-weight: bold;
|
||||
@@ -17,85 +20,117 @@
|
||||
.file-info-container
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-around;
|
||||
margin: 20px 0;
|
||||
border-radius: 5px;
|
||||
padding: 20px 10px 0;
|
||||
background-color: #16202b;
|
||||
|
||||
.form-control.input-ohif
|
||||
padding: 6px 12px;
|
||||
padding: 20px 10px;
|
||||
background-color: var(--ui-gray-dark);
|
||||
|
||||
@media screen and (max-width: 1023px)
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
.col
|
||||
flex-grow: 1;
|
||||
.width,
|
||||
.height,
|
||||
.file-name,
|
||||
.file-type
|
||||
height: 56px;
|
||||
|
||||
.input-ohif
|
||||
margin-left: 15px;
|
||||
.input-ohif
|
||||
margin: 0 5px;
|
||||
|
||||
.file-name,
|
||||
.file-type
|
||||
.select-ohif, .input-ohif
|
||||
width: 170px;
|
||||
|
||||
.input-ohif-label, .select-ohif-label
|
||||
width: 90px;
|
||||
display: inline-block;
|
||||
@media screen and (max-width: 1023px)
|
||||
margin-left: 0;
|
||||
margin-top: 5px;
|
||||
width: 100%;
|
||||
width: 120px;
|
||||
|
||||
.file-type
|
||||
.select-ohif
|
||||
margin-left: 17px;
|
||||
.dimension-wrapper
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
.dimensions
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.input-ohif-label
|
||||
width: 120px;
|
||||
display: inline-block;
|
||||
|
||||
.input-ohif
|
||||
@media screen and (max-width: 1023px)
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
width: 170px;
|
||||
|
||||
.show-annotations
|
||||
font-weight: bold;
|
||||
line-height: 30px;
|
||||
input
|
||||
margin-right: 7px;
|
||||
vertical-align: middle;
|
||||
label
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
.keep-aspect-wrapper
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
height: 86px;
|
||||
|
||||
.show-annotations
|
||||
font-weight: bold;
|
||||
line-height: 30px;
|
||||
input
|
||||
margin-right: 7px;
|
||||
vertical-align: middle;
|
||||
label
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.loading-image
|
||||
height: 580px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--active-color);
|
||||
font-size: 20px;
|
||||
|
||||
.icon-spin
|
||||
margin-right: 15px;
|
||||
|
||||
.preview
|
||||
display: flex;
|
||||
flex-direction column;
|
||||
height: fit-content;
|
||||
background-color: #16202b;
|
||||
width: fit-content;
|
||||
flex-direction: column;
|
||||
background-color: var(--ui-gray-dark);
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
align-self: center;
|
||||
margin-bottom: 20px;
|
||||
@media screen and (max-width: 1023px)
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
height: 580px;
|
||||
|
||||
.viewport-preview
|
||||
max-height: 512px;
|
||||
max-width: 512px;
|
||||
|
||||
h4
|
||||
.preview-header
|
||||
width: 100%;
|
||||
text-align center;
|
||||
font-size: 1.3em;
|
||||
margin: 0 0 10px;
|
||||
|
||||
.preview-container
|
||||
width: auto;
|
||||
height: 100%;
|
||||
max-height: 400px;
|
||||
object-fit contain;
|
||||
|
||||
.actions
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
.action-cancel
|
||||
margin: 0 20px;
|
||||
.actions-save
|
||||
margin: 0 0 0 10px;
|
||||
margin-top: 20px;
|
||||
|
||||
.btn
|
||||
margin: 0 10px;
|
||||
|
||||
.input-error
|
||||
font-size: 12px;
|
||||
color: red;
|
||||
text-align: center;
|
||||
margin: 3px 0;
|
||||
|
||||
|
||||
.modal-dialog
|
||||
height: 100%;
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { hotkeys } from '@ohif/core';
|
||||
|
||||
/**
|
||||
* Take the pressed key array and return the readable string for the keys
|
||||
*
|
||||
* @param {Array} [keys=[]]
|
||||
* @returns {string} string representation of an array of keys
|
||||
*/
|
||||
const formatKeysForInput = (keys = []) => keys.join('+');
|
||||
|
||||
/**
|
||||
* formats given keys sequence to insert the modifier keys in the first index of the array
|
||||
* @param {string} sequence keys sequence from MouseTrap Record -> "shift+left"
|
||||
* @returns {Array} keys in array-format -> ['shift','left']
|
||||
*/
|
||||
const getKeys = ({ sequence, modifier_keys }) => {
|
||||
const keysArray = sequence.join(' ').split('+');
|
||||
let keys = [];
|
||||
let modifiers = [];
|
||||
keysArray.forEach(key => {
|
||||
if (modifier_keys && modifier_keys.includes(key)) {
|
||||
modifiers.push(key);
|
||||
} else {
|
||||
keys.push(key);
|
||||
}
|
||||
});
|
||||
return [...modifiers, ...keys];
|
||||
};
|
||||
|
||||
/**
|
||||
* HotkeyField
|
||||
* Renders a hotkey input
|
||||
*
|
||||
* @param {object} props component props
|
||||
* @param {Array[]} props.keys array of keys to be controlled by this field
|
||||
* @param {function} props.handleChange Callback function to communicate parent once value is changed
|
||||
* @param {string} props.classNames string caontaining classes to be added in the input field
|
||||
* @param {Array[]} props.modifier_keys
|
||||
*/
|
||||
function HotkeyField({ keys, handleChange, classNames, modifier_keys }) {
|
||||
const inputValue = formatKeysForInput(keys);
|
||||
|
||||
const onInputKeyDown = event => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
hotkeys.record(sequence => {
|
||||
const keys = getKeys({ sequence, modifier_keys });
|
||||
hotkeys.unpause();
|
||||
handleChange(keys);
|
||||
});
|
||||
};
|
||||
|
||||
const onFocus = () => {
|
||||
hotkeys.pause();
|
||||
hotkeys.startRecording();
|
||||
};
|
||||
|
||||
return (
|
||||
<input
|
||||
readOnly={true}
|
||||
type="text"
|
||||
value={inputValue}
|
||||
className={classNames}
|
||||
onKeyDown={onInputKeyDown}
|
||||
onFocus={onFocus}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
HotkeyField.propTypes = {
|
||||
keys: PropTypes.array.isRequired,
|
||||
handleChange: PropTypes.func.isRequired,
|
||||
classNames: PropTypes.string,
|
||||
modifier_keys: PropTypes.array,
|
||||
allowed_keys: PropTypes.array,
|
||||
};
|
||||
|
||||
export { HotkeyField };
|
||||
@@ -0,0 +1 @@
|
||||
export { HotkeyField } from './HotkeyField';
|
||||
@@ -3,11 +3,10 @@ import { LayoutButton, LayoutChooser } from './layoutButton';
|
||||
import { MeasurementTable, MeasurementTableItem } from './measurementTable';
|
||||
import { Overlay, OverlayTrigger } from './overlayTrigger';
|
||||
import { TableList, TableListItem } from './tableList';
|
||||
import {
|
||||
AboutContent,
|
||||
UserPreferences,
|
||||
UserPreferencesForm,
|
||||
} from './userPreferencesForm';
|
||||
import { AboutContent } from './content/aboutContent/AboutContent';
|
||||
import { TabComponents, TabFooter } from './tabComponents';
|
||||
import { HotkeyField } from './customForm';
|
||||
import { LanguageSwitcher } from './languageSwitcher';
|
||||
|
||||
import { Checkbox } from './checkbox';
|
||||
import { CineDialog } from './cineDialog';
|
||||
@@ -48,12 +47,14 @@ export {
|
||||
TableList,
|
||||
TableListItem,
|
||||
Thumbnail,
|
||||
TabComponents,
|
||||
TabFooter,
|
||||
HotkeyField,
|
||||
LanguageSwitcher,
|
||||
TableSearchFilter,
|
||||
TablePagination,
|
||||
ToolbarSection,
|
||||
Tooltip,
|
||||
AboutContent,
|
||||
UserPreferences,
|
||||
UserPreferencesForm,
|
||||
OHIFModal,
|
||||
};
|
||||
@@ -2,26 +2,11 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import './LanguageSwitcher.styl';
|
||||
import { withTranslation } from '../../contextProviders';
|
||||
|
||||
const LanguageSwitcher = ({ language, onLanguageChange }) => {
|
||||
const parseLanguage = lang => lang.split('-')[0];
|
||||
|
||||
const languages = [
|
||||
// TODO: list of available languages should come from i18n.options.resources
|
||||
{
|
||||
value: 'en',
|
||||
label: 'English',
|
||||
},
|
||||
{
|
||||
value: 'es',
|
||||
label: 'Spanish',
|
||||
},
|
||||
];
|
||||
|
||||
const LanguageSwitcher = ({ language, onLanguageChange, languages }) => {
|
||||
const onChange = event => {
|
||||
const { value } = event.target;
|
||||
onLanguageChange(parseLanguage(value));
|
||||
onLanguageChange(value);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -29,7 +14,7 @@ const LanguageSwitcher = ({ language, onLanguageChange }) => {
|
||||
name="language-select"
|
||||
id="language-select"
|
||||
className="language-select"
|
||||
value={parseLanguage(language)}
|
||||
value={language}
|
||||
onChange={onChange}
|
||||
>
|
||||
{languages.map(lng => (
|
||||
@@ -43,7 +28,8 @@ const LanguageSwitcher = ({ language, onLanguageChange }) => {
|
||||
|
||||
LanguageSwitcher.propTypes = {
|
||||
language: PropTypes.string.isRequired,
|
||||
languages: PropTypes.array.isRequired,
|
||||
onLanguageChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default withTranslation('UserPreferencesModal')(LanguageSwitcher);
|
||||
export { LanguageSwitcher };
|
||||
@@ -1 +1 @@
|
||||
export { default } from './LanguageSwitcher';
|
||||
export { LanguageSwitcher } from './LanguageSwitcher';
|
||||
@@ -184,10 +184,12 @@ class MeasurementTable extends Component {
|
||||
};
|
||||
|
||||
getTimepointsHeader = () => {
|
||||
return this.props.timepoints.map((timepoint, index) => {
|
||||
const { timepoints, t } = this.props;
|
||||
|
||||
return timepoints.map((timepoint, index) => {
|
||||
return (
|
||||
<div key={index} className="measurementTableHeaderItem">
|
||||
<div className="timepointLabel">{this.props.t(timepoint.key)}</div>
|
||||
<div className="timepointLabel">{t(timepoint.key)}</div>
|
||||
<div className="timepointDate">{timepoint.date}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -84,7 +84,6 @@
|
||||
position: relative
|
||||
top: 2px
|
||||
|
||||
|
||||
.tooltip-warning
|
||||
&.in
|
||||
opacity 1
|
||||
|
||||
@@ -44,8 +44,8 @@ class MeasurementTableItem extends Component {
|
||||
<div>{this.getTableListItem()}</div>
|
||||
</OverlayTrigger>
|
||||
) : (
|
||||
<React.Fragment>{this.getTableListItem()}</React.Fragment>
|
||||
)}
|
||||
<React.Fragment>{this.getTableListItem()}</React.Fragment>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
@@ -101,7 +101,7 @@ class MeasurementTableItem extends Component {
|
||||
nsSeparator: '|',
|
||||
})}
|
||||
</div>
|
||||
<div>{this.getDataDisplayText()}</div>
|
||||
<div className="displayTexts">{this.getDataDisplayText()}</div>
|
||||
<div className="rowActions">{actionButtons}</div>
|
||||
</div>
|
||||
</TableListItem>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
.measurementItem
|
||||
.rowActions
|
||||
margin-left: -1px;
|
||||
background-color: var(--ui-gray-darker)
|
||||
height: 0
|
||||
overflow: hidden
|
||||
@@ -28,7 +29,6 @@
|
||||
visibility: visible
|
||||
|
||||
.measurementLocation
|
||||
margin-left: 9px;
|
||||
margin-top: 9px;
|
||||
overflow: hidden;
|
||||
max-width: calc(100% - 50px);
|
||||
@@ -39,7 +39,6 @@
|
||||
.measurementDisplayText
|
||||
display: inline-block;
|
||||
margin-top: 9px;
|
||||
margin-left: 9px;
|
||||
padding-left: 9px;
|
||||
width: 90px;
|
||||
border-left: 1px solid var(--text-secondary-color);
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
|
||||
// Style
|
||||
import './TabComponents.styl';
|
||||
|
||||
/**
|
||||
* Take name of the tab and create the data-cy value for it
|
||||
*
|
||||
* @param {string} [name=''] tab name
|
||||
* @returns {string} data-cy value
|
||||
*/
|
||||
const getDataCy = (name = '') => {
|
||||
return name
|
||||
.split(' ')
|
||||
.join('-')
|
||||
.toLowerCase();
|
||||
};
|
||||
|
||||
/**
|
||||
* Single tab data information
|
||||
*
|
||||
* @typedef {Object} tabData
|
||||
* @property {string} name - name of the tab
|
||||
* @property {Object} Component - tab component to be rendered
|
||||
* @property {Object} customProps - tab custom properties
|
||||
* @property {bool} hidden - bool to define if tab is hidden of not
|
||||
*/
|
||||
|
||||
/**
|
||||
* Take a list of components data and render then into tabs
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {[tabData]} props.tabs array of tab data
|
||||
* @param {Object} props.customProps common custom properties
|
||||
*/
|
||||
function TabComponents({ tabs, customProps = {} }) {
|
||||
const [currentTabIndex, setCurrentTabIndex] = useState(0);
|
||||
|
||||
return (
|
||||
tabs.length > 0 && (
|
||||
<div className="TabComponents">
|
||||
<div className="TabComponents_tabHeader">
|
||||
<div className="TabComponents_tabHeader_selector">
|
||||
<div className="dialog-separator-after">
|
||||
<ul className="nav nav-tabs">
|
||||
{tabs.map((tab, index) => {
|
||||
const { name, hidden = false } = tab;
|
||||
return (
|
||||
!hidden && (
|
||||
<li
|
||||
key={index}
|
||||
onClick={() => {
|
||||
setCurrentTabIndex(index);
|
||||
}}
|
||||
className={classnames(
|
||||
'nav-link',
|
||||
index === currentTabIndex && 'active'
|
||||
)}
|
||||
data-cy={getDataCy(name)}
|
||||
>
|
||||
<button>{name}</button>
|
||||
</li>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{tabs.map((tab, index) => {
|
||||
const {
|
||||
Component,
|
||||
customProps: tabCustomProps,
|
||||
hidden = false,
|
||||
} = tab;
|
||||
return (
|
||||
!hidden && (
|
||||
<div
|
||||
key={index}
|
||||
className={classnames(
|
||||
'TabComponents_content',
|
||||
index === currentTabIndex && 'active'
|
||||
)}
|
||||
>
|
||||
<Component {...customProps} {...tabCustomProps} />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
TabComponents.propTypes = {
|
||||
tabs: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
name: PropTypes.string,
|
||||
Component: PropTypes.any,
|
||||
customProps: PropTypes.object,
|
||||
hidden: PropTypes.bool,
|
||||
})
|
||||
),
|
||||
customProps: PropTypes.object,
|
||||
};
|
||||
|
||||
export { TabComponents };
|
||||
@@ -0,0 +1,21 @@
|
||||
@import './../../design/styles/common/navbar.styl'
|
||||
|
||||
.TabComponents
|
||||
.TabComponents_tabHeader
|
||||
display: flex
|
||||
flex-direction: column
|
||||
margin-left: -20px
|
||||
margin-right: -20px
|
||||
|
||||
.TabComponents_tabHeader_selector
|
||||
border-bottom: 3px solid black
|
||||
padding: 0 25px
|
||||
|
||||
.TabComponents_content
|
||||
min-height: 450px
|
||||
display: none
|
||||
|
||||
&.active
|
||||
display: flex
|
||||
flex-direction: column
|
||||
justify-content: space-between
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import './TabFooter.styl';
|
||||
|
||||
// In case translate is not passed
|
||||
const translate = word => word;
|
||||
|
||||
function TabFooter({
|
||||
onResetPreferences,
|
||||
onSave,
|
||||
onCancel,
|
||||
hasErrors,
|
||||
t = translate,
|
||||
}) {
|
||||
return (
|
||||
<div className="footer">
|
||||
<button
|
||||
className="btn btn-danger pull-left"
|
||||
data-cy="reset-default-btn"
|
||||
onClick={onResetPreferences}
|
||||
>
|
||||
{t('Reset to Defaults')}
|
||||
</button>
|
||||
<div>
|
||||
<div
|
||||
onClick={onCancel}
|
||||
data-cy="cancel-btn"
|
||||
className="btn btn-default"
|
||||
>
|
||||
{t('Cancel')}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
data-cy="save-btn"
|
||||
disabled={hasErrors}
|
||||
onClick={onSave}
|
||||
>
|
||||
{t('Save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
TabFooter.propTypes = {
|
||||
onResetPreferences: PropTypes.func,
|
||||
onSave: PropTypes.func,
|
||||
onCancel: PropTypes.func,
|
||||
hasErrors: PropTypes.bool,
|
||||
t: PropTypes.func,
|
||||
};
|
||||
|
||||
export { TabFooter };
|
||||
@@ -0,0 +1,11 @@
|
||||
.footer
|
||||
display: flex
|
||||
flex-direction: row
|
||||
justify-content: space-between
|
||||
padding: 20px 20px 0 20px
|
||||
margin: 0 -20px
|
||||
border-top: 3px solid var(--primary-background-color)
|
||||
|
||||
div
|
||||
button:last-child
|
||||
margin-left: 10px
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: Tab Components
|
||||
menu: Components
|
||||
route: /components/tab-components
|
||||
---
|
||||
|
||||
import { Playground, Props } from 'docz'
|
||||
import { State } from 'react-powerplug'
|
||||
import { TabComponents } from './../index.js'
|
||||
|
||||
// Data
|
||||
import tabs from './tabs.js'
|
||||
|
||||
# Tab Components
|
||||
|
||||
## Basic usage
|
||||
|
||||
<Playground>
|
||||
<TabComponents
|
||||
tabs={tabs}
|
||||
customProps={}
|
||||
/>
|
||||
</Playground>
|
||||
|
||||
## API
|
||||
|
||||
<Props of={TabComponents} />
|
||||
@@ -0,0 +1,26 @@
|
||||
export default tabs = [
|
||||
{
|
||||
name: 'tabName1',
|
||||
Component: () => {
|
||||
return <div>tab 1 Content</div>;
|
||||
},
|
||||
customProps: {},
|
||||
hidden: false,
|
||||
},
|
||||
{
|
||||
name: 'tabName2',
|
||||
Component: () => {
|
||||
return <div>tab 2 Content</div>;
|
||||
},
|
||||
customProps: {},
|
||||
hidden: false,
|
||||
},
|
||||
{
|
||||
name: 'tabName3',
|
||||
Component: () => {
|
||||
return <div>tab 3 Content</div>;
|
||||
},
|
||||
customProps: {},
|
||||
hidden: true,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,2 @@
|
||||
export { TabComponents } from './TabComponents';
|
||||
export { TabFooter } from './TabFooter';
|
||||
@@ -25,6 +25,20 @@ $headerRowHeight = 63px
|
||||
text-align: left
|
||||
flex: 1;
|
||||
|
||||
.tableListHeaderSelector
|
||||
cursor: pointer
|
||||
max-width: 30px
|
||||
padding-left: 2px
|
||||
align-items: stretch
|
||||
flex: 1
|
||||
justify-content: space-around
|
||||
text-align: center
|
||||
|
||||
svg
|
||||
fill: #c1d8e3
|
||||
height: 63px
|
||||
max-width: 11px
|
||||
|
||||
.numberOfItems
|
||||
color: var(--ui-sky-blue)
|
||||
float: right
|
||||
|
||||
@@ -10,7 +10,10 @@ export class TableListItem extends Component {
|
||||
children: PropTypes.node,
|
||||
itemClass: PropTypes.string,
|
||||
itemIndex: PropTypes.number,
|
||||
itemKey: PropTypes.oneOfType(['number', 'string']),
|
||||
itemKey: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.number,
|
||||
]),
|
||||
onItemClick: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
transition(all 0.3s ease)
|
||||
font-size: 14px
|
||||
font-weight: 400
|
||||
margin-right: 5px
|
||||
margin-right: 6px
|
||||
padding: 10px
|
||||
text-align: center
|
||||
-webkit-box-sizing: initial;
|
||||
@@ -38,9 +38,8 @@
|
||||
.itemContent
|
||||
flex: 1
|
||||
color: var(--text-secondary-color)
|
||||
width: calc(100% - 30px);
|
||||
width: calc(100% - 51px);
|
||||
font-weight: 400
|
||||
margin-left: 9px;
|
||||
margin-top: 9px;
|
||||
|
||||
.warning-icon
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import LanguageSwitcher from '../languageSwitcher';
|
||||
import i18n from '@ohif/i18n';
|
||||
|
||||
/**
|
||||
* General Preferences tab
|
||||
*/
|
||||
|
||||
/**
|
||||
* General Preferences tab
|
||||
* It renders the General Preferences content
|
||||
*
|
||||
* It stores current state and whenever it changes, component messages parent of new value (through function callback)
|
||||
* @param {object} props component props
|
||||
* @param {string} props.name Tab`s name
|
||||
* @param {object} props.generalPreferences Data for initial state
|
||||
* @param {function} props.onTabStateChanged Callback function to communicate parent in case its states changes
|
||||
* @param {function} props.onTabErrorChanged Callback Function in case any error on tab
|
||||
*/
|
||||
function GeneralPreferences({
|
||||
generalPreferences,
|
||||
name,
|
||||
onTabStateChanged,
|
||||
onTabErrorChanged,
|
||||
}) {
|
||||
const { language = i18n.language } = generalPreferences;
|
||||
|
||||
const onLanguageChange = language => {
|
||||
onTabStateChanged(name, {
|
||||
generalPreferences: { ...generalPreferences, language },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="general-preferences-wrapper">
|
||||
<div className="col-sm-3">
|
||||
<label htmlFor="language-select" className="p-r-1">
|
||||
Language
|
||||
</label>
|
||||
<LanguageSwitcher
|
||||
language={language}
|
||||
onLanguageChange={onLanguageChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
GeneralPreferences.propTypes = {
|
||||
generalPreferences: PropTypes.any,
|
||||
name: PropTypes.string,
|
||||
onTabStateChanged: PropTypes.func,
|
||||
onTabErrorChanged: PropTypes.func,
|
||||
};
|
||||
|
||||
export { GeneralPreferences };
|
||||
@@ -1,452 +0,0 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import './HotKeysPreferences.styl';
|
||||
|
||||
import {
|
||||
allowedKeys,
|
||||
disallowedCombinations,
|
||||
specialKeys,
|
||||
} from './hotKeysConfig.js';
|
||||
|
||||
import isEqual from 'lodash.isequal';
|
||||
|
||||
const getKeysPressedArray = keyDownEvent => {
|
||||
const keysPressedArray = [];
|
||||
const { ctrlKey, altKey, shiftKey } = keyDownEvent;
|
||||
|
||||
if (ctrlKey && !altKey) {
|
||||
keysPressedArray.push('ctrl');
|
||||
}
|
||||
|
||||
if (shiftKey && !altKey) {
|
||||
keysPressedArray.push('shift');
|
||||
}
|
||||
|
||||
if (altKey && !ctrlKey) {
|
||||
keysPressedArray.push('alt');
|
||||
}
|
||||
|
||||
return keysPressedArray;
|
||||
};
|
||||
|
||||
const findConflictingCommand = (
|
||||
originalHotKeys,
|
||||
currentCommandName,
|
||||
currentHotKeys
|
||||
) => {
|
||||
let firstConflictingCommand = undefined;
|
||||
|
||||
for (const commandName in originalHotKeys) {
|
||||
const toolHotKeys = originalHotKeys[commandName].keys;
|
||||
|
||||
if (
|
||||
isEqual(toolHotKeys, currentHotKeys) &&
|
||||
commandName !== currentCommandName
|
||||
) {
|
||||
firstConflictingCommand = originalHotKeys[commandName];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return firstConflictingCommand;
|
||||
};
|
||||
|
||||
/**
|
||||
* Splits given keysObj into arrays. Each array item will be a representation of column
|
||||
* @param {obj} keysObj objects to be splitted into columns
|
||||
* @param {number} columnSize How many rows per column
|
||||
*/
|
||||
const getHotKeysArrayColumns = (keysObj = {}, columnSize) => {
|
||||
if (isNaN(columnSize)) {
|
||||
return keysObj;
|
||||
}
|
||||
|
||||
const keys = Object.keys(keysObj);
|
||||
const keysValues = Object.values(keysObj);
|
||||
const keysLength = keys.length;
|
||||
|
||||
// Columns from left should be bigger;
|
||||
let currentColumn = 0;
|
||||
const dividedKeys = [];
|
||||
|
||||
for (
|
||||
let it = 0;
|
||||
it < keysLength;
|
||||
it++, it % columnSize === 0 ? currentColumn++ : currentColumn
|
||||
) {
|
||||
if (!dividedKeys[currentColumn]) {
|
||||
dividedKeys[currentColumn] = [];
|
||||
}
|
||||
|
||||
dividedKeys[currentColumn][keys[it]] = keysValues[it];
|
||||
}
|
||||
|
||||
return dividedKeys;
|
||||
};
|
||||
|
||||
const NO_FIELD_ERROR_MESSAGE = undefined;
|
||||
const formatPressedKeys = pressedKeysArray => pressedKeysArray.join('+');
|
||||
const unFormatPressedKeys = (pressedKeysStr = '') => pressedKeysStr.split('+');
|
||||
const inputValidators = (
|
||||
commandName,
|
||||
inputValue,
|
||||
pressedKeys,
|
||||
lastPressedKey,
|
||||
originalHotKeys
|
||||
) => {
|
||||
let hasError = false;
|
||||
let errorMessage = NO_FIELD_ERROR_MESSAGE;
|
||||
|
||||
const modifierValidator = ({ lastPressedKey }) => {
|
||||
// Check if it has a valid modifier
|
||||
const isModifier = ['ctrl', 'alt', 'shift'].includes(lastPressedKey);
|
||||
if (isModifier) {
|
||||
hasError = true;
|
||||
errorMessage =
|
||||
"It's not possible to define only modifier keys (ctrl, alt and shift) as a shortcut";
|
||||
return {
|
||||
hasError,
|
||||
errorMessage,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const emptyValidator = ({ inputValue }) => {
|
||||
if (!inputValue) {
|
||||
hasError = true;
|
||||
errorMessage = "Field can't be empty.";
|
||||
return {
|
||||
hasError,
|
||||
errorMessage,
|
||||
};
|
||||
}
|
||||
};
|
||||
const conflictingValidator = ({
|
||||
commandName,
|
||||
pressedKeys,
|
||||
originalHotKeys,
|
||||
}) => {
|
||||
const conflictingCommand = findConflictingCommand(
|
||||
originalHotKeys,
|
||||
commandName,
|
||||
pressedKeys
|
||||
);
|
||||
if (conflictingCommand) {
|
||||
hasError = true;
|
||||
errorMessage = `"${conflictingCommand.label}" is already using the "${pressedKeys}" shortcut.`;
|
||||
return {
|
||||
hasError,
|
||||
errorMessage,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const disallowedValidator = ({ inputValue, pressedKeys, lastPressedKey }) => {
|
||||
const modifierCommand = formatPressedKeys(
|
||||
pressedKeys.slice(0, pressedKeys.length - 1)
|
||||
);
|
||||
|
||||
const disallowedCombination = disallowedCombinations[modifierCommand];
|
||||
const hasDisallowedCombinations = disallowedCombination
|
||||
? disallowedCombination.includes(lastPressedKey)
|
||||
: false;
|
||||
|
||||
if (hasDisallowedCombinations) {
|
||||
hasError = true;
|
||||
errorMessage = `"${inputValue}" shortcut combination is not allowed`;
|
||||
return {
|
||||
hasError,
|
||||
errorMessage,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const validators = [
|
||||
emptyValidator,
|
||||
modifierValidator,
|
||||
conflictingValidator,
|
||||
disallowedValidator,
|
||||
];
|
||||
|
||||
for (const validator of validators) {
|
||||
const validation = validator({
|
||||
commandName,
|
||||
inputValue,
|
||||
pressedKeys,
|
||||
lastPressedKey,
|
||||
originalHotKeys,
|
||||
});
|
||||
if (validation && validation.hasError) {
|
||||
return validation;
|
||||
}
|
||||
}
|
||||
|
||||
// validation has passed successfully
|
||||
return {
|
||||
hasError,
|
||||
errorMessage,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* HotKeysPreferencesRow
|
||||
* Renders row for hotkey preference
|
||||
* It stores current state and whenever it changes, component messages parent of new value (through function callback)
|
||||
* @param {object} props component props
|
||||
* @param {string} props.commandName command name associated to given row
|
||||
* @param {string[]} props.hotkeys keys associated to given command
|
||||
* @param {object} props.originalHotKeys original hotkeys values
|
||||
* @param {function} props.onSuccessChanged Callback function to communicate parent in case its states changes
|
||||
* @param {function} props.onFailureChanged Callback Function in case any error on row
|
||||
*/
|
||||
function HotKeyPreferencesRow({
|
||||
commandName,
|
||||
hotkeys,
|
||||
label,
|
||||
originalHotKeys,
|
||||
tabError,
|
||||
onSuccessChanged,
|
||||
onFailureChanged,
|
||||
}) {
|
||||
const [inputValue, setInputValue] = useState(formatPressedKeys(hotkeys));
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const [fieldErrorMessage, setFieldErrorMessage] = useState(
|
||||
NO_FIELD_ERROR_MESSAGE
|
||||
);
|
||||
|
||||
// reset error count if tab has no errors
|
||||
useEffect(() => {
|
||||
if (!tabError) {
|
||||
setError(false);
|
||||
setFieldErrorMessage(NO_FIELD_ERROR_MESSAGE);
|
||||
setInputValue(formatPressedKeys(hotkeys));
|
||||
}
|
||||
}, [tabError]);
|
||||
|
||||
// update state values if props changes
|
||||
useEffect(() => {
|
||||
setInputValue(formatPressedKeys(hotkeys));
|
||||
}, [hotkeys]);
|
||||
|
||||
const updateInputText = (keyDownEvent, displayPressedKey = false) => {
|
||||
const pressedKeys = getKeysPressedArray(keyDownEvent);
|
||||
|
||||
if (displayPressedKey) {
|
||||
const specialKeyName = specialKeys[keyDownEvent.which];
|
||||
const keyName =
|
||||
specialKeyName ||
|
||||
keyDownEvent.key ||
|
||||
String.fromCharCode(keyDownEvent.keyCode);
|
||||
|
||||
// ensure lowerCase
|
||||
pressedKeys.push(keyName.toLowerCase());
|
||||
}
|
||||
|
||||
setInputValue(formatPressedKeys(pressedKeys));
|
||||
};
|
||||
|
||||
// validate input value
|
||||
const validateInput = () => {
|
||||
const pressedKeys = unFormatPressedKeys(inputValue);
|
||||
const lastPressedKey = pressedKeys[pressedKeys.length - 1];
|
||||
|
||||
const {
|
||||
hasError = false,
|
||||
errorMessage = NO_FIELD_ERROR_MESSAGE,
|
||||
} = inputValidators(
|
||||
commandName,
|
||||
inputValue,
|
||||
pressedKeys,
|
||||
lastPressedKey,
|
||||
originalHotKeys
|
||||
);
|
||||
|
||||
if (hasError) {
|
||||
setInputValue('');
|
||||
} else {
|
||||
onSuccessChanged([inputValue]);
|
||||
}
|
||||
|
||||
if (hasError !== error) {
|
||||
setError(hasError);
|
||||
}
|
||||
|
||||
setFieldErrorMessage(errorMessage);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
onFailureChanged(error);
|
||||
}, [error]);
|
||||
|
||||
const onInputKeyDown = event => {
|
||||
// Prevent ESC key from propagating and closing the modal
|
||||
if (event.key === 'Escape') {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
updateInputText(event, allowedKeys.includes(event.keyCode));
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
return (
|
||||
<tr key={commandName}>
|
||||
<td className="text-right p-r-1">{label}</td>
|
||||
<td width="200">
|
||||
<label
|
||||
className={`wrapperLabel ${
|
||||
fieldErrorMessage !== undefined ? 'state-error' : ''
|
||||
} `}
|
||||
data-key="defaultTool"
|
||||
>
|
||||
<input
|
||||
readOnly={true}
|
||||
type="text"
|
||||
value={inputValue}
|
||||
className="form-control hotkey text-center"
|
||||
onKeyDown={onInputKeyDown}
|
||||
onBlur={validateInput}
|
||||
/>
|
||||
<span className="wrapperText" />
|
||||
<span className="errorMessage">{fieldErrorMessage}</span>
|
||||
</label>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
HotKeyPreferencesRow.propTypes = {
|
||||
commandName: PropTypes.string.isRequired,
|
||||
hotkeys: PropTypes.array.isRequired,
|
||||
label: PropTypes.string.isRequired,
|
||||
originalHotKeys: PropTypes.object.isRequired,
|
||||
tabError: PropTypes.bool.isRequired,
|
||||
onSuccessChanged: PropTypes.func.isRequired,
|
||||
onFailureChanged: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
/**
|
||||
* HotKeysPreferences tab
|
||||
* It renders all hotkeys displayed into columns/rows
|
||||
*
|
||||
* It stores current state and whenever it changes, component messages parent of new value (through function callback)
|
||||
* @param {object} props component props
|
||||
* @param {string} props.name Tab`s name
|
||||
* @param {object} props.hotkeyDefinitions Data for initial state
|
||||
* @param {function} props.onTabStateChanged Callback function to communicate parent in case its states changes
|
||||
* @param {function} props.onTabErrorChanged Callback Function in case any error on tab
|
||||
*/
|
||||
function HotKeysPreferences({
|
||||
hotkeyDefinitions,
|
||||
name,
|
||||
tabError,
|
||||
onTabStateChanged,
|
||||
onTabErrorChanged,
|
||||
}) {
|
||||
const [tabState, setTabState] = useState(hotkeyDefinitions);
|
||||
const [tabErrorCounter, setTabErrorCounter] = useState(0);
|
||||
|
||||
const [numColumns] = useState(2);
|
||||
const [columnSize] = useState(() =>
|
||||
Math.ceil(Object.keys(tabState || {}).length / numColumns)
|
||||
);
|
||||
|
||||
const splittedHotKeys = getHotKeysArrayColumns(tabState, columnSize);
|
||||
|
||||
const onHotKeyChanged = (commandName, hotkeyDefinition, keys) => {
|
||||
const newState = {
|
||||
...tabState,
|
||||
[commandName]: { ...hotkeyDefinition, keys },
|
||||
};
|
||||
setTabState(newState);
|
||||
onTabStateChanged(name, { hotkeyDefinitions: newState });
|
||||
};
|
||||
|
||||
const onErrorChanged = (toInc = true) => {
|
||||
const increment = toInc ? 1 : -1;
|
||||
const newValue = tabErrorCounter + increment;
|
||||
if (newValue >= 0) {
|
||||
setTabErrorCounter(newValue);
|
||||
}
|
||||
};
|
||||
|
||||
// reset error count if tab has no errors
|
||||
useEffect(() => {
|
||||
if (!tabError) {
|
||||
setTabErrorCounter(0);
|
||||
// update tab state
|
||||
setTabState({ ...hotkeyDefinitions });
|
||||
}
|
||||
}, [tabError]);
|
||||
|
||||
// tell parent to update its state
|
||||
useEffect(() => {
|
||||
if (tabErrorCounter === 0) {
|
||||
onTabErrorChanged(name, false);
|
||||
}
|
||||
|
||||
if (tabErrorCounter === 1) {
|
||||
onTabErrorChanged(name, true);
|
||||
}
|
||||
}, [tabErrorCounter]);
|
||||
|
||||
// update local state if parent updates
|
||||
useEffect(() => {
|
||||
setTabState({ ...hotkeyDefinitions });
|
||||
}, [hotkeyDefinitions]);
|
||||
|
||||
return (
|
||||
<div className="HotKeysPreferences">
|
||||
{splittedHotKeys.length > 0
|
||||
? splittedHotKeys.map((columnHotKeys, index) => {
|
||||
return (
|
||||
<div className="column" key={index}>
|
||||
<table className="full-width">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-right p-r-1">Function</th>
|
||||
<th className="text-center">Shortcut</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(columnHotKeys).map(
|
||||
hotkeyDefinitionTuple => (
|
||||
<HotKeyPreferencesRow
|
||||
key={hotkeyDefinitionTuple[0]}
|
||||
commandName={hotkeyDefinitionTuple[0]}
|
||||
hotkeys={hotkeyDefinitionTuple[1].keys}
|
||||
label={hotkeyDefinitionTuple[1].label}
|
||||
originalHotKeys={tabState}
|
||||
tabError={tabError}
|
||||
onSuccessChanged={keys =>
|
||||
onHotKeyChanged(
|
||||
hotkeyDefinitionTuple[0],
|
||||
hotkeyDefinitionTuple[1],
|
||||
keys
|
||||
)
|
||||
}
|
||||
onFailureChanged={onErrorChanged}
|
||||
></HotKeyPreferencesRow>
|
||||
)
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
HotKeysPreferences.propTypes = {
|
||||
hotkeyDefinitions: PropTypes.any,
|
||||
name: PropTypes.string,
|
||||
tabError: PropTypes.bool,
|
||||
onTabStateChanged: PropTypes.func,
|
||||
onTabErrorChanged: PropTypes.func,
|
||||
};
|
||||
|
||||
export { HotKeysPreferences };
|
||||
@@ -1,12 +0,0 @@
|
||||
.HotKeysPreferences
|
||||
display: flex;
|
||||
|
||||
.column
|
||||
width: 50%;
|
||||
padding-right: 15px;
|
||||
padding-left: 15px;
|
||||
|
||||
.column-full
|
||||
width: 100%;
|
||||
padding-right: 15px;
|
||||
padding-left: 15px;
|
||||
@@ -1,132 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { HotKeysPreferences } from './HotKeysPreferences';
|
||||
import { WindowLevelPreferences } from './WindowLevelPreferences';
|
||||
import { GeneralPreferences } from './GeneralPreferences';
|
||||
import './UserPreferences.styl';
|
||||
|
||||
export class UserPreferences extends Component {
|
||||
static defaultProps = {
|
||||
hotkeyDefinitions: [],
|
||||
windowLevelData: {},
|
||||
generalPreferences: {},
|
||||
};
|
||||
|
||||
// TODO: Make this more generic. Tabs should not be restricted to these entries
|
||||
static propTypes = {
|
||||
hotkeyDefinitions: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
commandName: PropTypes.string,
|
||||
keys: PropTypes.arrayOf(PropTypes.string),
|
||||
label: PropTypes.string,
|
||||
})
|
||||
).isRequired,
|
||||
windowLevelData: PropTypes.object.isRequired,
|
||||
generalPreferences: PropTypes.object.isRequired,
|
||||
updatePropValue: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
tabIndex: 0,
|
||||
};
|
||||
|
||||
tabClick(tabIndex) {
|
||||
this.setState({ tabIndex });
|
||||
}
|
||||
|
||||
renderHotkeysTab() {
|
||||
return (
|
||||
<form className="form-themed themed">
|
||||
<div className="form-content">
|
||||
<HotKeysPreferences
|
||||
hotkeyDefinitions={this.props.hotkeyDefinitions}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
renderWindowLevelTab() {
|
||||
if (this.props.windowLevelData) {
|
||||
return (
|
||||
<form className="form-themed themed">
|
||||
<div className="form-content">
|
||||
<WindowLevelPreferences
|
||||
windowLevelData={this.props.windowLevelData}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
renderGeneralTab() {
|
||||
return (
|
||||
<form className="form-themed themed">
|
||||
<div className="form-content">
|
||||
<GeneralPreferences
|
||||
generalPreferences={this.props.generalPreferences}
|
||||
updatePropValue={this.props.updatePropValue}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
renderTabs(tabIndex) {
|
||||
switch (tabIndex) {
|
||||
case 0:
|
||||
return this.renderHotkeysTab();
|
||||
/* case 1:
|
||||
return this.renderWindowLevelTab(); */
|
||||
case 2:
|
||||
return this.renderGeneralTab();
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
getTabClass(tabIndex) {
|
||||
return tabIndex === this.state.tabIndex ? 'nav-link active' : 'nav-link';
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="UserPreferences">
|
||||
<div className="UserPreferences__selector">
|
||||
<ul className="nav nav-tabs">
|
||||
<li
|
||||
onClick={() => {
|
||||
this.tabClick(0);
|
||||
}}
|
||||
className={this.getTabClass(0)}
|
||||
>
|
||||
<button>Hotkeys</button>
|
||||
</li>
|
||||
{false && (
|
||||
<li
|
||||
onClick={() => {
|
||||
this.tabClick(1);
|
||||
}}
|
||||
className={this.getTabClass(1)}
|
||||
>
|
||||
<button>Window Level</button>
|
||||
</li>
|
||||
)}
|
||||
<li
|
||||
onClick={() => {
|
||||
this.tabClick(2);
|
||||
}}
|
||||
className={this.getTabClass(2)}
|
||||
>
|
||||
<button>General</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{this.renderTabs(this.state.tabIndex)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
Loaded 100 of 179 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user