Compare commits

...
Author SHA1 Message Date
ohif-bot 6cba41dd8b chore(release): publish [skip ci]
- @ohif/extension-vtk@1.2.18
 - @ohif/core@2.4.1
 - @ohif/i18n@0.52.6
 - @ohif/ui@1.2.1
 - @ohif/viewer@3.6.2
2020-02-12 20:36:52 +00:00
2f30e7a821 fix: Combined Hotkeys for special characters (#1233)
* fix: Combined Hotkeys for special characters

* add record method to hotkey manager

* fix record plugin

* remove unused component

* add record to modal props

* rename record method

* replace handlers to use hotkeyRecord

* fix combined keys

* change expected result count from 18 to 17

* autoformat

* Remove duplicate test, that was testing the wrong things; fix label; update configs

* Revert "Remove duplicate test, that was testing the wrong things; fix label; update configs"

This reverts commit 4292f4fe67351962d61cae623b920dcbd87dd71d.

* Fix the record plugin's registration

* fix exposed record method usage

* adding logging for info level items

* Hotkey definitions don't need to be globally reactive; use localstorage/appconfig as sources of truth; not redux

* Tidy up test

* Remove unused code from UserPreferencesForm

* Log info when we run a command

* fix hotkey preference restore

* use application configured hotkeys if there are no user preferred

* Avoid logging circular ref

* Fix callouts

* Fix small issue with array

* Fix langua issue after refactor and merge

* Refactor on recordCurrentCombo as Rodrigo did before

* Separating components in 2 files

* WIP Refactor to simplify the user preferences and move into each form the save and controll functionalities

* Remove context

* Remove unused import

* Initial work on Field treatment

* Refactor General preferences

* Small refactor removing type from HotkeyField

* small update on style

* Refactor and layout fixed

* Make hotkeys preferences working with old hotkeys row

* Move error handling out of hotkey row/input component

* WIP custom form

* Moving validation function to component

* Exposing hotkeyRecord as it does not depend on HotkeyManager Class

* Making hotkeyField as much detached possible from parent component

* Small refactors

* Refactor on user preferences

* Clean up into the changes

* Small fix to let save working

* Style finish

* move about docs into about folder

* Fix double tap on single keys

* Style refactor

* Remove log

* Fix log issues on unit tests

* Fix unit test breaking on ohif/core index

* Fixing hotkeys unpause unit test issue

* Rename file to adopt lowercase

* Rename file to adopt lowercase

* Fixing callouts

* Big refactor miving some of the components into viewer and creating small components into ohif/ui

* Typo on folder name

* Updating ohif ui docs

* Remove comments

* Fix binding of combo keys

* Fix some cypress tests failures

* Fixing onCancel button

* Fixing e2e tests

* Small style update

* Fixing unit tests failing after fix issue

* Remove some not used code

* Remove left over after debug

* Adding prevent default on hotkeys events

* Fixinf existing hotkeys validator with 3 keys pressed

* Exposing hotkeys as root level on ohif-core

* Clean up

* Exposing all availableLanguages with labels and fixing an issue on language switcher

* Fixing e2e cypress tests

* Preveinting some simple errors

* Treating error once we try to set hotkey definitions

* Adding ui notification on setHotkeys errors

* Implementing a service queue request to hold until functions are implemented

* Making sure toFixed is only called on Numbers

Co-authored-by: Danny Brown <danny.ri.brown@gmail.com>
Co-authored-by: Gustavo André Lelis <galelis@gmail.com>
2020-02-12 15:35:04 -05:00
ohif-bot 9a62c28b3f chore(release): publish [skip ci]
- @ohif/extension-lesion-tracker@0.2.0
 - @ohif/extension-vtk@1.2.17
 - @ohif/ui@1.2.0
 - @ohif/viewer@3.6.1
2020-02-10 20:36:28 +00:00
Igor Octaviano 98a649b455 feat: Lesion tracker right panel (#1428)
* WIP

* Finish mock

* Update name, remove vendor prefix

* Remove lesion tracker from defaults

* Refactor measurement table changes

* Remove unused code

* Update naming of collections
2020-02-10 15:33:06 -05:00
ohif-bot 3529474a16 chore(release): publish [skip ci]
- @ohif/extension-cornerstone@2.3.0
 - @ohif/extension-vtk@1.2.16
 - @ohif/core@2.4.0
 - @ohif/viewer@3.6.0
2020-02-10 19:17:19 +00:00
Igor OctavianoandDanny Brown 0c37a406d9 feat: 🎸 MeasurementService (#1314)
* feat: 🎸 MeasurementService

Work in progress...

* Remove cornerstone tools import

* Second iteration

* CR Update: Add update / added events example

* Add new props to measurements

* Update event log

* Add new measurementid to annotation

* Add context support

* Add value types

* Add area

* Add todo

* Wip measurement to annotation map

* Change points representation

* Add props to annotation mapping

* Add tests

* Extract formatter from init and add tests

* Sketch matchers

* Fix events and valuetypes imports

* Remove context support

* Rename formatter to mappings

* Sketching source and source definitions

* Adjust matching criteria in addOrUpdate

* CR Updates: Extract private functions and rename variables

* Fix broken tests

* Add more measurement service tests

* Update broken mapping tests

* Update test description

* Update getAnnotation to get mapping based on def and source

Co-authored-by: Danny Brown <danny.ri.brown@gmail.com>
2020-02-10 14:15:05 -05:00
100 changed files with 2868 additions and 1732 deletions

No files matched your search

+11
View File
@@ -3,6 +3,17 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [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
+1
View File
@@ -0,0 +1 @@
module.exports = require("../../babel.config.js");
+13
View File
@@ -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"
// ]
};
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@ohif/extension-cornerstone",
"version": "2.2.2",
"version": "2.3.0",
"description": "OHIF extension for Cornerstone",
"author": "OHIF",
"license": "MIT",
@@ -25,7 +25,9 @@
"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",
+115 -3
View File
@@ -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,
@@ -107,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: {
@@ -182,3 +186,111 @@ export default function init({ servicesManager, configuration }) {
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);
}
);
});
}
);
};
@@ -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;
@@ -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);
});
});
});
+11
View File
@@ -3,6 +3,17 @@
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)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ohif/extension-lesion-tracker",
"version": "0.1.0",
"version": "0.2.0",
"description": "OHIF extension for Lesion Tracker",
"author": "OHIF",
"license": "MIT",
@@ -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;
+24 -73
View File
@@ -1,12 +1,4 @@
// SEE: Async imports (code-splitting)
// import asyncComponent from './asyncComponent.js';
// import OHIFDicomPDFSopClassHandler from './OHIFDicomPDFSopClassHandler.js';
// const ConnectedOHIFDicomPDFViewer = asyncComponent(() =>
// import(
// /* webpackChunkName: "ConnectedOHIFDicomPDFViewer" */ './ConnectedOHIFDicomPDFViewer'
// )
// );
import MeasurementComparisonTable from './components/MeasurementComparisonTable';
export default {
/**
@@ -20,56 +12,21 @@ export default {
* @param {CommandsManager} params.commandsManager
*/
getPanelModule({ servicesManager, commandsManager }) {
return undefined;
// {
// TODO:
// - Right, alternative StudyBrowser
// - Left, alternative Measurements Table
// menuOptions: [
// {
// // A suggested icon
// // Available icons determined by consuming app
// icon: 'list',
// // A suggested label
// label: 'Magic',
// // 'right' or 'left'
// from: 'right',
// // The target component to toggle open/close
// target: 'target-component-id',
// // Overrides `defaultContext`, if specified
// context: ['ACTIVE_VIEWPORT:MAGIC'],
// },
// ],
// components: [
// {
// id: 'target-component-id',
// component: MyComponent,
// },
// ],
// defaultContext: ['ROUTE:VIEWER'],
// };
},
/**
* @param {object} params
* @param {ServicesManager} params.servicesManager
* @param {CommandsManager} params.commandsManager
* @returns Object
*/
getToolbarModule({ servicesManager, commandsManager }) {
// TODO: Zoom, Levels, Pan, More (see lesiontracker.ohif.org)
// These may be driven through configuration of `cornerstone` extension instead of defined here
return {
definitions: [
menuOptions: [
{
id: 'say-hell-world',
label: '🎉 HELLO WORLD 🎉',
icon: 'exclamation-triangle',
type: 'command',
commandName: 'sayHelloWorld',
icon: 'th-list',
label: 'Measurements',
target: 'lesion-tracker-panel',
},
],
defaultContext: 'VIEWER',
components: [
{
id: 'lesion-tracker-panel',
component: MeasurementComparisonTable,
},
],
defaultContext: ['VIEWER']
};
},
@@ -79,23 +36,17 @@ export default {
* @param {CommandsManager} params.commandsManager
* @returns Object
*/
getCommandsModule({ servicesManager }) {
const { UINotificationService } = servicesManager.services;
return {
definitions: {
sayHelloWorld: {
commandFn: function() {
console.log(UINotificationService);
UINotificationService.show({
title: 'What does a nosey pepper do?',
message: 'Gets jalapeno business!',
});
},
storeContexts: [],
options: {},
},
},
defaultContext: ['VIEWER'],
};
getToolbarModule() {
return null;
},
/**
* @param {object} params
* @param {ServicesManager} params.servicesManager
* @param {CommandsManager} params.commandsManager
* @returns Object
*/
getCommandsModule() {
return null;
},
};
+24
View File
@@ -3,6 +3,30 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [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
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@ohif/extension-vtk",
"version": "1.2.15",
"version": "1.2.18",
"description": "OHIF extension for VTK.js",
"author": "OHIF",
"license": "MIT",
@@ -52,8 +52,8 @@
"react-vtkjs-viewport": "^0.8.3"
},
"devDependencies": {
"@ohif/core": "^2.3.9",
"@ohif/ui": "^1.1.9",
"@ohif/core": "^2.4.1",
"@ohif/ui": "^1.2.1",
"cornerstone-tools": "^4.12.0",
"cornerstone-wado-image-loader": "^3.0.0",
"dicom-parser": "^1.8.3",
+2 -1
View File
@@ -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/"
};
+22
View File
@@ -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.
## [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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ohif/core",
"version": "2.3.9",
"version": "2.4.1",
"description": "Generic business logic for web-based medical imaging applications",
"author": "OHIF Core Team",
"license": "MIT",
+1
View File
@@ -1,4 +1,5 @@
export default {
warn: jest.fn(),
error: jest.fn(),
info: jest.fn(),
};
+50 -19
View File
@@ -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;
+6 -1
View File
@@ -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 };
+2
View File
@@ -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;
@@ -1,12 +1,6 @@
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: '' },
@@ -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.error(`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.error.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
View File
@@ -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,
};
+8
View File
@@ -0,0 +1,8 @@
import Mousetrap from 'mousetrap';
import pausePlugin from './pausePlugin';
import recordPlugin from './recordPlugin';
recordPlugin(Mousetrap);
pausePlugin(Mousetrap);
export default Mousetrap;
@@ -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);
+3
View File
@@ -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;
+1
View File
@@ -18,6 +18,7 @@ describe('Top level exports', () => {
'DicomLoaderService',
'urlUtil',
'makeCancelable',
'hotkeys',
].sort();
const exports = Object.keys(utils.default).sort();
+11
View File
@@ -3,6 +3,17 @@
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)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ohif/i18n",
"version": "0.52.5",
"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;
}
+3
View File
@@ -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;
+22
View File
@@ -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.
## [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)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ohif/ui",
"version": "1.1.9",
"version": "1.2.1",
"description": "A set of React components for Medical Imaging Viewers",
"author": "OHIF Contributors",
"license": "MIT",
@@ -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';
+8 -7
View File
@@ -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>
@@ -0,0 +1,105 @@
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 } = 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 } = 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
@@ -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>
);
}
}
@@ -1,219 +0,0 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import { useSnackbarContext } from '@ohif/ui';
import './UserPreferencesForm.styl';
import { useTranslation } from 'react-i18next';
// Tabs Component wrapper
import { UserPreferencesTabs } from './UserPreferencesTabs';
// Tabs
import { HotKeysPreferences } from './HotKeysPreferences';
import { WindowLevelPreferences } from './WindowLevelPreferences';
import { GeneralPreferences } from './GeneralPreferences';
/**
@typedef TabObject
@type {Object}
@property {string} name Name for given tab
@property {ReactComponent} Component React component for given tab.
@property {object} props Props State for given tab component
@property {boolean} [hidden] To hidden tab or not
*/
/**
* Create tabs obj.
* @returns {TabObject[]} Array of TabObjs.
*/
const createTabs = () => {
return [
{
name: 'Hotkeys',
Component: HotKeysPreferences,
props: {},
},
{
name: 'General',
Component: GeneralPreferences,
props: {},
},
{
name: 'Window Level',
Component: WindowLevelPreferences,
props: {},
hidden: true,
},
];
};
/**
* Main form component to render preferences tabs and buttons
* @param {object} props component props
* @param {string} props.name Tab`s name
* @param {object} props.hotkeyDefinitions Hotkeys Data
* @param {object} props.windowLevelData Window level data
* @param {function} props.onSave Callback function when saving
* @param {function} props.onClose Callback function when closing
* @param {function} props.onResetToDefaults Callback function when resetting
*/
function UserPreferencesForm({
onClose,
onSave,
onResetToDefaults,
windowLevelData,
hotkeyDefinitions,
generalPreferences,
hotkeysManager,
defaultLanguage,
hotkeyDefaults,
}) {
const [tabs, setTabs] = useState(createTabs());
const createTabsState = (
windowLevelData,
hotkeyDefinitions,
generalPreferences
) => {
return {
Hotkeys: { hotkeyDefinitions },
'Window Level': { windowLevelData },
General: { generalPreferences },
};
};
const [tabsState, setTabsState] = useState(
createTabsState(windowLevelData, hotkeyDefinitions, generalPreferences)
);
const [tabsError, setTabsError] = useState(
tabs.reduce((acc, tab) => {
acc[tab.name] = false;
return acc;
}, {})
);
const snackbar = useSnackbarContext();
const { t, ready: translationsAreReady } = useTranslation(
'UserPreferencesModal'
);
const onTabStateChanged = (tabName, newState) => {
setTabsState({ ...tabsState, [tabName]: newState });
};
const onTabErrorChanged = (tabName, hasError) => {
setTabsError({ ...tabsError, [tabName]: hasError });
};
const hasAnyError = () => {
return Object.values(tabsError).reduce((acc, value) => acc || value);
};
const onResetPreferences = () => {
const defaultHotKeyDefitions = {};
hotkeyDefaults.map(item => {
const { commandName, ...values } = item;
defaultHotKeyDefitions[commandName] = { ...values };
});
// update local state
setTabsState({
...tabsState,
Hotkeys: { hotkeyDefinitions: defaultHotKeyDefitions },
General: { generalPreferences: { language: defaultLanguage } },
});
// update tabs state
setTabs(createTabs(windowLevelData, hotkeyDefinitions, generalPreferences));
// reset errors
setTabsError(
tabs.reduce((acc, tab) => {
acc[tab.name] = false;
return acc;
}, {})
);
snackbar.show({
message: (
<div dangerouslySetInnerHTML={{ __html: t('ResetDefaultMessage') }} />
),
type: 'info',
});
};
const onSavePreferences = event => {
const toSave = Object.values(tabsState).reduce((acc, tabState) => {
return { ...acc, ...tabState };
}, {});
onSave(toSave);
snackbar.show({
message: t('SaveMessage'),
type: 'success',
});
};
// update local state if prop values changes
useEffect(() => {
setTabsState(
createTabsState(windowLevelData, hotkeyDefinitions, generalPreferences)
);
}, [windowLevelData, hotkeyDefinitions, generalPreferences]);
return translationsAreReady ? (
<div className="UserPreferencesForm">
<UserPreferencesTabs
tabs={tabs}
tabsState={tabsState}
tabsError={tabsError}
onTabStateChanged={onTabStateChanged}
onTabErrorChanged={onTabErrorChanged}
/>
<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={onClose}
data-cy="cancel-btn"
className="btn btn-default"
>
{t('Cancel')}
</div>
<button
className="btn btn-primary"
data-cy="save-btn"
disabled={hasAnyError()}
onClick={onSavePreferences}
>
{t('Save')}
</button>
</div>
</div>
</div>
) : null;
}
UserPreferencesForm.propTypes = {
onClose: PropTypes.func,
onSave: PropTypes.func,
onResetToDefaults: PropTypes.func,
windowLevelData: PropTypes.object,
hotkeyDefinitions: PropTypes.object,
generalPreferences: PropTypes.object,
hotkeysManager: PropTypes.object,
defaultLanguage: PropTypes.string,
hotkeyDefaults: PropTypes.array,
};
export { UserPreferencesForm };
@@ -1,23 +0,0 @@
@import './../../design/styles/common/navbar.styl'
@import './../../design/styles/common/global.styl'
@import './../../design/styles/common/modal.styl'
@import './../../design/styles/common/button.styl'
.close
float: right;
font-size: 21px;
font-weight: 700;
line-height: 1;
color: #000;
text-shadow: 0 1px 0 #fff;
opacity: .2;
.UserPreferencesForm
.footer
display: flex
flex-direction: row
justify-content: space-between
div
button:last-child
margin-left: 10px
@@ -1,107 +0,0 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import './UserPreferencesTabs.styl';
/**
* Render tab component
* @param {object} tab TabObject containing tab data
* @param {function} onTabStateChanged Callback Function in case tab changes its state
* @param {function} onTabErrorChanged Callback Function in case any error on tab
*/
const renderTab = (
tab = {},
tabsState,
tabsError,
onTabStateChanged,
onTabErrorChanged
) => {
const { props, Component, name, hidden = false } = tab;
const tabState = tabsState[name];
const tabError = tabsError[name];
return !hidden ? (
<form className="form-themed themed tabs">
<div className="form-content">
<Component
key={name}
{...tabState}
{...props}
name={name}
tabError={tabError}
onTabStateChanged={onTabStateChanged}
onTabErrorChanged={onTabErrorChanged}
></Component>
</div>
</form>
) : null;
};
const renderTabsHeader = (tabs, activeTabIndex, onHeaderChanged) => {
return tabs.length > 0
? tabs.map((tab, index) => {
const { name, hidden = false } = tab;
const cypressSelectorId = name.toLowerCase();
const tabClass =
index === activeTabIndex ? 'nav-link active' : 'nav-link';
return !hidden ? (
<li
key={name}
onClick={() => {
onHeaderChanged(index);
}}
className={tabClass}
data-cy={cypressSelectorId}
>
<button>{name}</button>
</li>
) : null;
})
: null;
};
/**
* Component to render tabs based on currentActiveTabIndex
*
* In case any tab changes its state this current component tells parent through function callback
* @param {object} props Component props
*/
function UserPreferencesTabs({
tabs,
tabsState,
tabsError,
onTabStateChanged,
onTabErrorChanged,
}) {
const [activeTabIndex, setActiveTabIndex] = useState(0);
return (
<div className="UserPreferencesTabs">
<div className="UserPreferencesTabs__selector">
<div className="dialog-separator-after">
<ul className="nav nav-tabs">
{renderTabsHeader(tabs, activeTabIndex, setActiveTabIndex)}
</ul>
</div>
</div>
{renderTab(
tabs[activeTabIndex],
tabsState,
tabsError,
onTabStateChanged,
onTabErrorChanged
)}
</div>
);
}
UserPreferencesTabs.propTypes = {
tabs: PropTypes.array.isRequired,
tabsState: PropTypes.object.isRequired,
tabsError: PropTypes.object.isRequired,
onTabStateChanged: PropTypes.func.isRequired,
onTabErrorChanged: PropTypes.func.isRequired,
};
export { UserPreferencesTabs };
@@ -1,29 +0,0 @@
@import './../../design/styles/common/form.styl'
@import './../../design/styles/common/navbar.styl'
@import './../../design/styles/common/state.styl'
@import './../../design/styles/common/global.styl'
.UserPreferencesTabs
display: flex
flex-direction: column
&__selector
border-bottom: 3px solid black
.errorMessage
color: var(--state-error-text)
font-size: 10px
text-transform: uppercase;
.form-content
border-bottom: 3px solid var(--primary-background-color)
margin-bottom: 20px
margin-left: -20px
margin-right: -20px
max-height: 70vh
overflow-y: auto
padding: 20px
min-height: 500px
.popover
width: 300px
@@ -1,132 +0,0 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import './WindowLevelPreferences.styl';
/**
* WindowLevelPreferencesRow
* Renders row for window level 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.description description for given preset
* @param {number} props.window window value
* @param {number} props.level level value
* @param {string} props.rowName name of given row to identify it
* @param {function} props.onSuccessChanged Callback function to communicate parent in case its states changes
*/
function WindowLevelPreferencesRow({
description,
window,
level,
rowName,
onSuccessChanged,
// onFailureChanged
}) {
const [rowState, setRowState] = useState({ description, window, level });
const onInputChanged = (event, name) => {
const newValue = event.target.value;
setRowState({ ...rowState, [name]: newValue });
};
useEffect(() => {
onSuccessChanged(rowName, rowState);
}, [rowState]);
const renderTd = (value, name, type) => {
return (
<td className="p-r-1">
<label className="wrapperLabel">
<input
value={value}
type={type}
className="form-control"
onChange={event => {
onInputChanged(event, name);
}}
/>
</label>
</td>
);
};
return (
<tr key={rowName}>
<td className="p-r-1 text-center">{rowName}</td>
{renderTd(rowState.description, 'description', 'text')}
{renderTd(rowState.window, 'window', 'number')}
{renderTd(rowState.level, 'level', 'number')}
</tr>
);
}
WindowLevelPreferencesRow.propTypes = {
description: PropTypes.string.isRequired,
window: PropTypes.number.isRequired,
level: PropTypes.number.isRequired,
rowName: PropTypes.string.isRequired,
onSuccessChanged: PropTypes.func.isRequired,
//onFailureChanged: PropTypes.func.isRequired,
};
/**
* WindowLevelPreferences tab
* It renders all window level presets
*
* 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.windowLevelData Data for initial state
* @param {function} props.onTabStateChanged Callback function to communicate parent in case its states changes
*/
function WindowLevelPreferences({
windowLevelData,
name,
onTabStateChanged /*onTabErrorChanged*/,
}) {
const [tabState, setTabState] = useState(windowLevelData);
// TODO to be used once error handling is implemented
//const [tabError, setTabError] = useState(false);
const onWindowLevelChanged = (key, state) => {
setTabState({ ...tabState, [key]: state });
};
// tell parent to update its state
useEffect(() => {
onTabStateChanged(name, { windowLevelData: tabState });
}, [tabState]);
return (
<table className="full-width">
<thead>
<tr>
<th className="p-x-1 text-center presetIndex">Preset</th>
<th className="p-x-1">Description</th>
<th className="p-x-1">Window</th>
<th className="p-x-1">Level</th>
</tr>
</thead>
<tbody>
{Object.keys(tabState).map(objKey => (
<WindowLevelPreferencesRow
onSuccessChanged={onWindowLevelChanged}
rowName={objKey}
key={objKey}
description={tabState[objKey].description}
window={tabState[objKey].window}
level={tabState[objKey].level}
></WindowLevelPreferencesRow>
))}
</tbody>
</table>
);
}
WindowLevelPreferences.propTypes = {
windowLevelData: PropTypes.object.isRequired,
name: PropTypes.string.isRequired,
onTabStateChanged: PropTypes.func.isRequired,
//onTabErrorChanged: PropTypes.func.isRequired,
};
export { WindowLevelPreferences };
@@ -1,4 +0,0 @@
@import './UserPreferencesTabs.styl'
.presetIndex
padding: 0px 10px 0px 10px
@@ -1,14 +0,0 @@
export default {
currentLanguage: 'en',
languages: [
{
value: 'en',
label: 'English',
},
{
value: 'es',
label: 'Spanish',
},
],
onChange: language => {},
};
@@ -1,75 +0,0 @@
export default {
defaultTool: { label: 'Default Tool', keys: ['ESC'], column: 0 },
zoom: { label: 'Zoom', keys: ['Z'], column: 0 },
wwwc: { label: 'W/L', keys: ['W'], column: 0 },
pan: { label: 'Pan', keys: ['P'], column: 0 },
angle: { label: 'Angle measurement', keys: ['A'], column: 0 },
stackScroll: { label: 'Scroll stack', keys: ['S'], column: 0 },
magnify: { label: 'Magnify', keys: ['M'], column: 0 },
length: { label: 'Length measurement', keys: [''], column: 0 },
annotate: { label: 'Annotate', keys: [''], column: 0 },
dragProbe: { label: 'Pixel probe', keys: [''], column: 0 },
ellipticalRoi: { label: 'Elliptical ROI', keys: [''], column: 0 },
rectangleRoi: { label: 'Rectangle ROI', keys: [''], column: 0 },
// Viewport hotkeys
flipH: { label: 'Flip Horizontally', keys: ['H'], column: 0 },
flipV: { label: 'Flip Vertically', keys: ['V'], column: 0 },
rotateR: { label: 'Rotate Right', keys: ['R'], column: 0 },
rotateL: { label: 'Rotate Left', keys: ['L'], column: 0 },
invert: { label: 'Invert', keys: ['I'], column: 0 },
zoomIn: { label: 'Zoom In', keys: [''], column: 0 },
zoomOut: { label: 'Zoom Out', keys: [''], column: 0 },
zoomToFit: { label: 'Zoom to Fit', keys: [''], column: 0 },
resetViewport: { label: 'Reset', keys: [''], column: 0 },
clearTools: { label: 'Clear Tools', keys: [''], column: 0 },
// 2nd column
// Viewport navigation hotkeys
scrollDown: { label: 'Scroll Down', keys: ['DOWN'], column: 1 },
scrollUp: { label: 'Scroll Up', keys: ['UP'], column: 1 },
scrollLastImage: { label: 'Scroll to Last Image', keys: ['END'], column: 1 },
scrollFirstImage: {
label: 'Scroll to First Image',
keys: ['HOME'],
column: 1,
},
previousDisplaySet: {
label: 'Previous Series',
keys: ['PAGEUP'],
column: 1,
},
nextDisplaySet: { label: 'Next Series', keys: ['PAGEDOWN'], column: 1 },
nextPanel: { label: 'Next Image Viewport', keys: ['RIGHT'], column: 1 },
previousPanel: {
label: 'Previous Image Viewport',
keys: ['LEFT'],
column: 1,
},
// Miscellaneous hotkeys
toggleOverlayTags: {
label: 'Toggle Image Info Overlay',
keys: ['O'],
column: 1,
},
toggleCinePlay: { label: 'Play/Pause Cine', keys: ['SPACE'], column: 1 },
toggleCineDialog: {
label: 'Show/Hide Cine Controls',
keys: [''],
column: 1,
},
// Preset hotkeys
WLPreset0: { label: 'W/L Preset 0 (Soft Tissue)', keys: ['1'], column: 1 },
WLPreset1: { label: 'W/L Preset 1 (Lung)', keys: ['2'], column: 1 },
WLPreset2: { label: 'W/L Preset 2 (Liver)', keys: ['3'], column: 1 },
WLPreset3: { label: 'W/L Preset 3 (Bone)', keys: ['4'], column: 1 },
WLPreset4: { label: 'W/L Preset 4 (Brain)', keys: ['5'], column: 1 },
WLPreset5: { label: 'W/L Preset 5', keys: ['6'], column: 1 },
WLPreset6: { label: 'W/L Preset 6', keys: ['7'], column: 1 },
WLPreset7: { label: 'W/L Preset 7', keys: ['8'], column: 1 },
WLPreset8: { label: 'W/L Preset 8', keys: ['9'], column: 1 },
WLPreset9: { label: 'W/L Preset 0', keys: ['0'], column: 1 },
};
@@ -1,54 +0,0 @@
---
name: User Preferences Form
menu: Components
route: /components/user-preferences-form
---
import { Playground, Props } from 'docz'
import { State } from 'react-powerplug'
import { UserPreferencesForm } from './../index.js'
import NameSpace from '../../../__docs__/NameSpace'
//
import windowLevelDefaults from './windowLevelDefaults.js'
import hotkeyDefaults from './hotkeyDefaults.js'
# User Preferences Form
## Basic usage
<Playground>
<State initial={{
isOpen: false,
windowLevelData: windowLevelDefaults,
hotkeyDefinitions: hotkeyDefaults,
}}>
{({ state, setState }) => (
<React.Fragment>
<button
className="btn btn-primary"
type="button"
onClick={ () => setState({ isOpen: true }) }
>
Open user preferences
</button>
<UserPreferencesForm
{...state}
onCancel={() => setState({ isOpen: false })}
onSave={() => alert('on save')}
onResetToDefaults={() => alert('on reset')}
/>
</React.Fragment>
)}
</State>
</Playground>
## API
<Props of={UserPreferencesForm} />
## Translation Namespace
<NameSpace name="UserPreferencesForm" />
@@ -1,13 +0,0 @@
export default {
0: { description: 'Soft tissue', window: 400, level: 40 },
1: { description: 'Lung', window: 1500, level: -600 },
2: { description: 'Liver', window: 150, level: 90 },
3: { description: 'Bone', window: 2500, level: 480 },
4: { description: 'Brain', window: 80, level: 40 },
5: { description: '', window: '', level: '' },
6: { description: '', window: '', level: '' },
7: { description: '', window: '', level: '' },
8: { description: '', window: '', level: '' },
9: { description: '', window: '', level: '' },
10: { description: '', window: '', level: '' },
};
@@ -1,4 +0,0 @@
export { UserPreferencesTabs } from './UserPreferencesTabs.js';
export { AboutContent } from '../content/aboutContent/AboutContent.js';
export { UserPreferencesForm } from './UserPreferencesForm.js';
export { GeneralPreferences } from './GeneralPreferences.js';
+8 -4
View File
@@ -19,13 +19,15 @@ import {
TableList,
TableListItem,
Thumbnail,
TabComponents,
TabFooter,
HotkeyField,
LanguageSwitcher,
TableSearchFilter,
TablePagination,
ToolbarSection,
Tooltip,
AboutContent,
UserPreferences,
UserPreferencesForm,
OHIFModal,
} from './components';
import { useDebounce, useMedia } from './hooks';
@@ -98,6 +100,10 @@ export {
TableList,
TableListItem,
Thumbnail,
TabComponents,
TabFooter,
HotkeyField,
LanguageSwitcher,
TableSearchFilter,
TablePagination,
Toolbar,
@@ -105,8 +111,6 @@ export {
ToolbarSection,
Tooltip,
AboutContent,
UserPreferences,
UserPreferencesForm,
ViewerbaseDragDropContext,
SnackbarProvider,
useSnackbarContext,
+30
View File
@@ -3,6 +3,36 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [3.6.2](https://github.com/OHIF/Viewers/compare/@ohif/viewer@3.6.1...@ohif/viewer@3.6.2) (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))
## [3.6.1](https://github.com/OHIF/Viewers/compare/@ohif/viewer@3.6.0...@ohif/viewer@3.6.1) (2020-02-10)
**Note:** Version bump only for package @ohif/viewer
# [3.6.0](https://github.com/OHIF/Viewers/compare/@ohif/viewer@3.5.1...@ohif/viewer@3.6.0) (2020-02-10)
### Features
* 🎸 MeasurementService ([#1314](https://github.com/OHIF/Viewers/issues/1314)) ([0c37a40](https://github.com/OHIF/Viewers/commit/0c37a406d963569af8c3be24c697dafd42712dfc))
## [3.5.1](https://github.com/OHIF/Viewers/compare/@ohif/viewer@3.5.0...@ohif/viewer@3.5.1) (2020-02-07)
**Note:** Version bump only for package @ohif/viewer
@@ -30,9 +30,8 @@ describe('OHIF User Preferences', () => {
});
it('checks translation by selecting Spanish language', function() {
cy.get('@userPreferencesGeneralTab')
.click()
.should('have.class', 'active');
cy.changePreferencesTab('@userPreferencesGeneralTab');
cy.get('@userPreferencesGeneralTab').should('have.class', 'active');
// Language dropdown should be displayed
cy.get('#language-select').should('be.visible');
@@ -65,14 +64,14 @@ describe('OHIF User Preferences', () => {
cy.get('[data-cy="options-menu"]').click();
});
it('checks if user can cancel the language selection and application will be in English', function() {
it('checks if user can cancel the language selection and application will be in "English (USA)"', function() {
// Set language to English and save
cy.setLanguage('English');
cy.setLanguage('English (USA)');
// Set language to Spanish and cancel
cy.setLanguage('Spanish', false);
// Header should be kept in English
// Header should be kept in "English (USA)"
cy.get('.research-use')
.scrollIntoView()
.should('have.text', 'INVESTIGATIONAL USE ONLY');
@@ -93,7 +92,7 @@ describe('OHIF User Preferences', () => {
cy.get('[data-cy="options-menu"]').click();
});
it('checks if user can restore to default the language selection and application will be in English', function() {
it('checks if user can restore to default the language selection and application will be in "English (USA)"', function() {
// Set language to Spanish
cy.setLanguage('Spanish');
@@ -101,7 +100,7 @@ describe('OHIF User Preferences', () => {
cy.openPreferences();
// Go to general tab
cy.get('@userPreferencesGeneralTab').click();
cy.changePreferencesTab('@userPreferencesGeneralTab');
cy.get('@restoreBtn')
.scrollIntoView()
@@ -112,12 +111,12 @@ describe('OHIF User Preferences', () => {
.scrollIntoView()
.click();
// Header should be in English
// Header should be in "English (USA)"
cy.get('.research-use')
.scrollIntoView()
.should('have.text', 'INVESTIGATIONAL USE ONLY');
// Options menu should be in English
// Options menu should be in "English (USA)"
cy.get('[data-cy="options-menu"]')
.should('have.text', 'Options')
.click();
@@ -135,9 +134,8 @@ describe('OHIF User Preferences', () => {
it('checks if Preferences set in Study List Page will be consistent on Viewer Page', function() {
// Go go hotkeys tab
cy.get('@userPreferencesHotkeysTab')
.click()
.should('have.class', 'active');
cy.changePreferencesTab('@userPreferencesHotkeysTab');
cy.get('@userPreferencesHotkeysTab').should('have.class', 'active');
// Set new hotkey for 'Rotate Right' function
cy.setNewHotkeyShortcutOnUserPreferencesModal('Rotate Right', '{shift}Q');
@@ -150,7 +148,7 @@ describe('OHIF User Preferences', () => {
cy.openPreferences();
// Go to General tab
cy.get('@userPreferencesGeneralTab').click();
cy.changePreferencesTab('@userPreferencesGeneralTab');
// Set language to Spanish
cy.setLanguage('Spanish');
@@ -221,9 +219,8 @@ describe('OHIF User Preferences', () => {
});
it('checks translation by selecting Spanish language', function() {
cy.get('@userPreferencesGeneralTab')
.click()
.should('have.class', 'active');
cy.changePreferencesTab('@userPreferencesGeneralTab');
cy.get('@userPreferencesGeneralTab').should('have.class', 'active');
// Language dropdown should be displayed
cy.get('#language-select').should('be.visible');
@@ -256,14 +253,14 @@ describe('OHIF User Preferences', () => {
cy.get('[data-cy="options-menu"]').click();
});
it('checks if user can cancel the language selection and application will be in English', function() {
it('checks if user can cancel the language selection and application will be in "English (USA)"', function() {
// Set language to English and save
cy.setLanguage('English');
cy.setLanguage('English (USA)');
// Set language to Spanish and cancel
cy.setLanguage('Spanish', false);
// Header should be kept in English
// Header should be kept in "English (USA)"
cy.get('.research-use')
.scrollIntoView()
.should('have.text', 'INVESTIGATIONAL USE ONLY');
@@ -283,10 +280,9 @@ describe('OHIF User Preferences', () => {
cy.get('[data-cy="options-menu"]').click();
});
it('checks if user can restore to default the language selection and application will be in English', function() {
cy.get('@userPreferencesGeneralTab')
.click()
.should('have.class', 'active');
it('checks if user can restore to default the language selection and application will be in "English (USA)', function() {
cy.changePreferencesTab('@userPreferencesGeneralTab');
cy.get('@userPreferencesGeneralTab').should('have.class', 'active');
// Language dropdown should be displayed
cy.get('#language-select').should('be.visible');
@@ -298,7 +294,7 @@ describe('OHIF User Preferences', () => {
cy.openPreferences();
// Go to general tab
cy.get('@userPreferencesGeneralTab').click();
cy.changePreferencesTab('@userPreferencesGeneralTab');
cy.get('@restoreBtn')
.scrollIntoView()
@@ -308,12 +304,12 @@ describe('OHIF User Preferences', () => {
.scrollIntoView()
.click();
// Header should be in English
// Header should be in "English (USA)""
cy.get('.research-use')
.scrollIntoView()
.should('have.text', 'INVESTIGATIONAL USE ONLY');
// Options menu should be in English
// Options menu should be in "English (USA)"
cy.get('[data-cy="options-menu"]')
.should('have.text', 'Options')
.click();
@@ -330,9 +326,8 @@ describe('OHIF User Preferences', () => {
it('checks new hotkeys for "Rotate Right" and "Rotate Left"', function() {
// Go go hotkeys tab
cy.get('@userPreferencesHotkeysTab')
.click()
.should('have.class', 'active');
cy.changePreferencesTab('@userPreferencesHotkeysTab');
cy.get('@userPreferencesHotkeysTab').should('have.class', 'active');
// Set new hotkey for 'Rotate Right' function
cy.setNewHotkeyShortcutOnUserPreferencesModal(
@@ -360,24 +355,17 @@ describe('OHIF User Preferences', () => {
});
it('checks new hotkeys for "Next" and "Previous" Image on Viewport', function() {
// Go go hotkeys tab
cy.get('@userPreferencesHotkeysTab')
.click()
.should('have.class', 'active');
// Set new hotkey for 'Next Image Viewport' function
// Update hotkeys for 'Next/Previous Viewport'
cy.changePreferencesTab('@userPreferencesHotkeysTab');
cy.get('@userPreferencesHotkeysTab').should('have.class', 'active');
cy.setNewHotkeyShortcutOnUserPreferencesModal(
'Next Image Viewport',
'Next Viewport',
'{shift}{rightarrow}'
);
// Set new hotkey for 'Previous Image Viewport' function
cy.setNewHotkeyShortcutOnUserPreferencesModal(
'Previous Image Viewport',
'Previous Viewport',
'{shift}{leftarrow}'
);
// Save new hotkeys
cy.get('@saveBtn')
.scrollIntoView()
.click();
@@ -386,20 +374,25 @@ describe('OHIF User Preferences', () => {
cy.setLayout(3, 1);
cy.waitViewportImageLoading();
// Rotate Right and Invert colors on Viewport #1
cy.get('body').type('RI');
// Check that image was rotated
// Reset, Rotate Right and Invert colors on Viewport #1
cy.get('body').type(' ');
cy.get('body').type('r');
cy.get('body').type('i');
// Shift active viewport to next
// Reset, Rotate Left and Invert colors on Viewport #2
cy.get('body').type('{shift}{rightarrow}');
cy.get('body').type(' ');
cy.get('body').type('l');
cy.get('body').type('i');
// Verify 1st viewport was rotated
cy.get('@viewportInfoMidTop').should('contains.text', 'R');
//Move to Next Viewport
cy.get('body').type('{shift}{rightarrow}');
// Rotate Left and Invert colors on Viewport #2
cy.get('body').type('LI');
// Get overlay information from viewport #2
// Verify 2nd viewport was rotated
cy.get(
':nth-child(2) > .viewport-wrapper > .viewport-element > .ViewportOrientationMarkers.noselect > .top-mid.orientation-marker'
).as('viewport2InfoMidTop');
// Check that image was rotated
cy.get('@viewport2InfoMidTop').should('contains.text', 'P');
//Move to Previous Viewport
@@ -417,24 +410,18 @@ describe('OHIF User Preferences', () => {
it('checks error message when duplicated hotkeys are inserted', function() {
// Go go hotkeys tab
cy.get('@userPreferencesHotkeysTab').click();
cy.changePreferencesTab('@userPreferencesHotkeysTab');
// Set duplicated hotkey for 'Rotate Right' function
cy.setNewHotkeyShortcutOnUserPreferencesModal(
'Rotate Right',
'{rightarrow}'
);
cy.setNewHotkeyShortcutOnUserPreferencesModal('Rotate Right', '{i}');
// Check error message
cy.get('.HotKeysPreferences').within(() => {
cy.get('.HotkeysPreferences').within(() => {
cy.contains('Rotate Right') // label we're looking for
.parent()
.find('.errorMessage')
.as('errorMsg')
.should(
'have.text',
'"Next Image Viewport" is already using the "right" shortcut.'
);
.should('have.text', '"Invert" is already using the "i" shortcut.');
});
//Cancel hotkeys
cy.get('@cancelBtn')
@@ -444,13 +431,13 @@ describe('OHIF User Preferences', () => {
it('checks error message when invalid hotkey is inserted', function() {
// Go go hotkeys tab
cy.get('@userPreferencesHotkeysTab').click();
cy.changePreferencesTab('@userPreferencesHotkeysTab');
// Set invalid hotkey for 'Rotate Right' function
cy.setNewHotkeyShortcutOnUserPreferencesModal('Rotate Right', '{ctrl}Z');
// Check error message
cy.get('.HotKeysPreferences').within(() => {
cy.get('.HotkeysPreferences').within(() => {
cy.contains('Rotate Right') // label we're looking for
.parent()
.find('.errorMessage')
@@ -466,12 +453,12 @@ describe('OHIF User Preferences', () => {
it('checks error message when only modifier keys are inserted', function() {
// Go go hotkeys tab
cy.get('@userPreferencesHotkeysTab').click();
cy.changePreferencesTab('@userPreferencesHotkeysTab');
// Set invalid modifier key: ctrl
cy.setNewHotkeyShortcutOnUserPreferencesModal('Zoom Out', '{ctrl}');
// Check error message
cy.get('.HotKeysPreferences').within(() => {
cy.get('.HotkeysPreferences').within(() => {
cy.contains('Zoom Out') // label we're looking for
.parent()
.find('.errorMessage')
@@ -506,7 +493,7 @@ describe('OHIF User Preferences', () => {
it('checks if user can cancel changes made on User Preferences Hotkeys tab', function() {
// Go go hotkeys tab
cy.get('@userPreferencesHotkeysTab').click();
cy.changePreferencesTab('@userPreferencesHotkeysTab');
// Set new hotkey for 'Rotate Right' function
cy.setNewHotkeyShortcutOnUserPreferencesModal(
@@ -523,7 +510,7 @@ describe('OHIF User Preferences', () => {
cy.openPreferences();
//Check that hotkey for 'Rotate Right' function was not changed
cy.get('.HotKeysPreferences').within(() => {
cy.get('.HotkeysPreferences').within(() => {
cy.contains('Rotate Right') // label we're looking for
.parent()
.find('input')
@@ -534,7 +521,7 @@ describe('OHIF User Preferences', () => {
it('checks if user can reset to default values on User Preferences Hotkeys tab', function() {
// Go go hotkeys tab
cy.get('@userPreferencesHotkeysTab').click();
cy.changePreferencesTab('@userPreferencesHotkeysTab');
// Set new hotkey for 'Rotate Right' function
cy.setNewHotkeyShortcutOnUserPreferencesModal(
@@ -559,7 +546,7 @@ describe('OHIF User Preferences', () => {
cy.openPreferences();
//Check that hotkey for 'Rotate Right' function was not changed
cy.get('.HotKeysPreferences').within(() => {
cy.get('.HotkeysPreferences').within(() => {
cy.contains('Rotate Right') // label we're looking for
.parent()
.find('input')
@@ -21,9 +21,8 @@ describe('Visual Regression - OHIF User Preferences', () => {
});
it('checks translation by selecting Spanish language', function() {
cy.get('@userPreferencesGeneralTab')
.click()
.should('have.class', 'active');
cy.changePreferencesTab('@userPreferencesGeneralTab');
cy.get('@userPreferencesGeneralTab').should('have.class', 'active');
// Language dropdown should be displayed
cy.get('#language-select').should('be.visible');
@@ -76,9 +75,8 @@ describe('Visual Regression - OHIF User Preferences', () => {
});
it('checks translation by selecting Spanish language', function() {
cy.get('@userPreferencesGeneralTab')
.click()
.should('have.class', 'active');
cy.changePreferencesTab('@userPreferencesGeneralTab');
cy.get('@userPreferencesGeneralTab').should('have.class', 'active');
// Visual comparison
cy.percyCanvasSnapshot(
@@ -102,9 +100,8 @@ describe('Visual Regression - OHIF User Preferences', () => {
});
it('checks if user can restore to default the language selection and application will be in English', function() {
cy.get('@userPreferencesGeneralTab')
.click()
.should('have.class', 'active');
cy.changePreferencesTab('@userPreferencesGeneralTab');
cy.get('@userPreferencesGeneralTab').should('have.class', 'active');
// Set language to Spanish
cy.setLanguage('Spanish');
@@ -113,7 +110,7 @@ describe('Visual Regression - OHIF User Preferences', () => {
cy.openPreferences();
// Go to general tab
cy.get('@userPreferencesGeneralTab').click();
cy.changePreferencesTab('@userPreferencesGeneralTab');
cy.get('@restoreBtn')
.scrollIntoView()
@@ -139,9 +136,8 @@ describe('Visual Regression - OHIF User Preferences', () => {
it('checks new hotkeys for "Next" and "Previous" Image on Viewport', function() {
// Go go hotkeys tab
cy.get('@userPreferencesHotkeysTab')
.click()
.should('have.class', 'active');
cy.changePreferencesTab('@userPreferencesHotkeysTab');
cy.get('@userPreferencesHotkeysTab').should('have.class', 'active');
// Set new hotkey for 'Next Image Viewport' function
cy.setNewHotkeyShortcutOnUserPreferencesModal(
+8 -3
View File
@@ -84,7 +84,12 @@ export function initPreferencesModalAliases() {
cy.get('.OHIFModal').as('preferencesModal');
cy.get('[data-cy="hotkeys"]').as('userPreferencesHotkeysTab');
cy.get('[data-cy="general"]').as('userPreferencesGeneralTab');
cy.get('[data-cy="reset-default-btn"]').as('restoreBtn');
cy.get('[data-cy="cancel-btn"]').as('cancelBtn');
cy.get('[data-cy="save-btn"]').as('saveBtn');
initPreferencesModalFooterBtnAliases();
}
//Creating aliases for User Preferences modal
export function initPreferencesModalFooterBtnAliases() {
cy.get('.active [data-cy="reset-default-btn"]').as('restoreBtn');
cy.get('.active [data-cy="cancel-btn"]').as('cancelBtn');
cy.get('.active [data-cy="save-btn"]').as('saveBtn');
}
+18 -11
View File
@@ -9,6 +9,7 @@ import {
initStudyListAliasesOnDesktop,
initStudyListAliasesOnTablet,
initPreferencesModalAliases,
initPreferencesModalFooterBtnAliases,
} from './aliases.js';
// ***********************************************
@@ -169,13 +170,13 @@ Cypress.Commands.add(
);
Cypress.Commands.add('expectMinimumThumbnails', (seriesToWait = 1) => {
cy.get('[data-cy=thumbnail-list]', { timeout: 20000 }).should($itemList => {
cy.get('[data-cy=thumbnail-list]', { timeout: 50000 }).should($itemList => {
expect($itemList.length >= seriesToWait).to.be.true;
});
});
//Command to wait DICOM image to load into the viewport
Cypress.Commands.add('waitDicomImage', (timeout = 20000) => {
Cypress.Commands.add('waitDicomImage', (timeout = 50000) => {
const loaded = cy.isPageLoaded();
if (loaded) {
@@ -441,6 +442,12 @@ Cypress.Commands.add('openPreferences', () => {
});
});
Cypress.Commands.add('changePreferencesTab', tabAlias => {
cy.initPreferencesModalAliases();
cy.get(tabAlias).click();
initPreferencesModalFooterBtnAliases();
});
Cypress.Commands.add('resetUserHoktkeyPreferences', () => {
// Open User Preferences modal
cy.openPreferences();
@@ -457,7 +464,7 @@ Cypress.Commands.add(
(function_label, shortcut) => {
// Within scopes all `.get` and `.contains` to within the matched elements
// dom instead of checking from document
cy.get('.HotKeysPreferences')
cy.get('.HotkeysPreferences')
.within(() => {
cy.contains(function_label) // label we're looking for
.parent()
@@ -487,16 +494,16 @@ Cypress.Commands.add('setLanguage', (language, save = true) => {
.click()
.should('have.class', 'active');
initPreferencesModalFooterBtnAliases();
// Language dropdown should be displayed
cy.get('#language-select').should('be.visible');
// Select Language and Save/Cancel
cy.get('#language-select')
.select(language)
.then(() => {
const toClick = save ? '@saveBtn' : '@cancelBtn';
cy.get(toClick)
.scrollIntoView()
.click();
});
cy.get('#language-select').select(language);
const toClick = save ? '@saveBtn' : '@cancelBtn';
cy.get(toClick)
.scrollIntoView()
.click();
});
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@ohif/viewer",
"version": "3.5.1",
"version": "3.6.2",
"description": "OHIF Viewer",
"author": "OHIF Contributors",
"license": "MIT",
@@ -47,15 +47,15 @@
},
"dependencies": {
"@babel/runtime": "^7.5.5",
"@ohif/core": "^2.3.9",
"@ohif/extension-cornerstone": "^2.2.2",
"@ohif/core": "^2.4.1",
"@ohif/extension-cornerstone": "^2.3.0",
"@ohif/extension-dicom-html": "^1.1.0",
"@ohif/extension-dicom-microscopy": "^0.50.6",
"@ohif/extension-dicom-pdf": "^1.0.1",
"@ohif/extension-lesion-tracker": "^0.1.0",
"@ohif/extension-vtk": "^1.2.15",
"@ohif/i18n": "^0.52.5",
"@ohif/ui": "^1.1.9",
"@ohif/extension-lesion-tracker": "^0.2.0",
"@ohif/extension-vtk": "^1.2.18",
"@ohif/i18n": "^0.52.6",
"@ohif/ui": "^1.2.1",
"@tanem/react-nprogress": "^1.1.25",
"classnames": "^2.2.6",
"core-js": "^3.2.1",
+2 -2
View File
@@ -25,12 +25,12 @@ window.config = {
// ~ Global
{
commandName: 'incrementActiveViewport',
label: 'Next Image Viewport',
label: 'Next Viewport',
keys: ['right'],
},
{
commandName: 'decrementActiveViewport',
label: 'Previous Image Viewport',
label: 'Previous Viewport',
keys: ['left'],
},
// Supported Keys: https://craig.is/killing/mice
+2 -2
View File
@@ -20,12 +20,12 @@ window.config = {
hotkeys: [
{
commandName: 'incrementActiveViewport',
label: 'Next Image Viewport',
label: 'Next Viewport',
keys: ['right'],
},
{
commandName: 'decrementActiveViewport',
label: 'Previous Image Viewport',
label: 'Previous Viewport',
keys: ['left'],
},
{ commandName: 'rotateViewportCW', label: 'Rotate Right', keys: ['r'] },
+2 -2
View File
@@ -20,12 +20,12 @@ window.config = {
// ~ Global
{
commandName: 'incrementActiveViewport',
label: 'Next Image Viewport',
label: 'Next Viewport',
keys: ['right'],
},
{
commandName: 'decrementActiveViewport',
label: 'Previous Image Viewport',
label: 'Previous Viewport',
keys: ['left'],
},
// Supported Keys: https://craig.is/killing/mice
+29 -26
View File
@@ -23,6 +23,7 @@ import {
UINotificationService,
UIModalService,
UIDialogService,
MeasurementService,
utils,
redux as reduxOHIF,
} from '@ohif/core';
@@ -52,7 +53,6 @@ import store from './store';
import WhiteLabellingContext from './context/WhiteLabellingContext';
import UserManagerContext from './context/UserManagerContext';
import AppContext from './context/AppContext';
const { setUserPreferences } = reduxOHIF.actions;
/** ~~~~~~~~~~~~~ Application Setup */
const commandsManagerConfig = {
@@ -62,8 +62,8 @@ const commandsManagerConfig = {
/** Managers */
const commandsManager = new CommandsManager(commandsManagerConfig);
const hotkeysManager = new HotkeysManager(commandsManager);
const servicesManager = new ServicesManager();
const hotkeysManager = new HotkeysManager(commandsManager, servicesManager);
let extensionManager;
/** ~~~~~~~~~~~~~ End Application Setup */
@@ -115,14 +115,19 @@ class App extends Component {
const {
servers,
hotkeys,
hotkeys: appConfigHotkeys,
cornerstoneExtensionConfig,
extensions,
oidc,
} = this._appConfig;
this.initUserManager(oidc);
_initServices([UINotificationService, UIModalService, UIDialogService]);
_initServices([
UINotificationService,
UIModalService,
UIDialogService,
MeasurementService,
]);
_initExtensions(
[...defaultExtensions, ...extensions],
cornerstoneExtensionConfig,
@@ -133,7 +138,7 @@ class App extends Component {
* Must run after extension commands are registered
* if there is no hotkeys from localStorage set up from config.
*/
_initHotkeys(hotkeys);
_initHotkeys(appConfigHotkeys);
_initServers(servers);
initWebWorkers();
}
@@ -144,6 +149,7 @@ class App extends Component {
UINotificationService,
UIDialogService,
UIModalService,
MeasurementService,
} = servicesManager.services;
if (this._userManager) {
@@ -256,30 +262,27 @@ function _initExtensions(extensions, cornerstoneExtensionConfig, appConfig) {
extensionManager.registerExtensions(mergedExtensions);
}
function _initHotkeys(hotkeys) {
const { hotkeyDefinitions = {} } = store.getState().preferences || {};
let updateStore = false;
let hotkeysToUse = hotkeyDefinitions;
/**
*
* @param {Object} appConfigHotkeys - Default hotkeys, as defined by app config
*/
function _initHotkeys(appConfigHotkeys) {
// TODO: Use something more resilient
// TODO: Mozilla has a special library for this
const userPreferredHotkeys = JSON.parse(
localStorage.getItem('hotkey-definitions') || '{}'
);
if (!Object.keys(hotkeyDefinitions).length) {
hotkeysToUse = hotkeys;
updateStore = true;
// TODO: hotkeysManager.isValidDefinitionObject(/* */)
const hasUserPreferences =
userPreferredHotkeys && Object.keys(userPreferredHotkeys).length > 0;
if (hasUserPreferences) {
hotkeysManager.setHotkeys(userPreferredHotkeys);
} else {
hotkeysManager.setHotkeys(appConfigHotkeys);
}
if (hotkeysToUse) {
hotkeysManager.setHotkeys(hotkeysToUse);
/* Set hotkeys default based on app config. */
hotkeysManager.setDefaultHotKeys(hotkeys);
if (updateStore) {
const { hotkeyDefinitions } = hotkeysManager;
const windowLevelData = {};
store.dispatch(
setUserPreferences({ windowLevelData, hotkeyDefinitions })
);
}
}
hotkeysManager.setDefaultHotKeys(appConfigHotkeys);
}
function _initServers(servers) {
@@ -1,10 +1,12 @@
import React, { useState, useEffect } from 'react';
import { Link, withRouter } from 'react-router-dom';
import { withTranslation } from 'react-i18next';
import PropTypes from 'prop-types';
import ConnectedUserPreferencesForm from '../../connectedComponents/ConnectedUserPreferencesForm';
import { Dropdown, AboutContent, withModal } from '@ohif/ui';
import { UserPreferences } from './../UserPreferences';
import OHIFLogo from '../OHIFLogo/OHIFLogo.js';
import './Header.css';
@@ -42,7 +44,7 @@ function Header(props) {
},
onClick: () =>
show({
content: ConnectedUserPreferencesForm,
content: UserPreferences,
title: t('User Preferences'),
}),
},
@@ -0,0 +1,72 @@
import React, { useState, useSelector } from 'react';
import PropTypes from 'prop-types';
import i18n from '@ohif/i18n';
import { TabFooter, LanguageSwitcher, useSnackbarContext } from '@ohif/ui';
import { useTranslation } from 'react-i18next';
import './GeneralPreferences.styl';
/**
* General Preferences tab
* It renders the General Preferences content
*
* @param {object} props component props
* @param {function} props.onClose
*/
function GeneralPreferences({ onClose }) {
const { t } = useTranslation('UserPreferencesModal');
const snackbar = useSnackbarContext();
const currentLanguage = i18n.language;
const { availableLanguages } = i18n;
const [language, setLanguage] = useState(currentLanguage);
const onResetPreferences = () => {
setLanguage(i18n.defaultLanguage);
};
const onSave = () => {
i18n.changeLanguage(language);
onClose();
snackbar.show({
message: t('SaveMessage'),
type: 'success',
});
};
const hasErrors = false;
return (
<React.Fragment>
<div className="GeneralPreferences">
<div className="language">
<label htmlFor="language-select" className="languageLabel">
Language
</label>
<LanguageSwitcher
language={language}
onLanguageChange={setLanguage}
languages={availableLanguages}
/>
</div>
</div>
<TabFooter
onResetPreferences={onResetPreferences}
onSave={onSave}
onCancel={onClose}
hasErrors={hasErrors}
t={t}
/>
</React.Fragment>
);
}
GeneralPreferences.propTypes = {
onClose: PropTypes.func,
};
export { GeneralPreferences };
@@ -0,0 +1,18 @@
.GeneralPreferences
display: flex;
padding: 20px 0;
.language
display: flex;
width: 50%;
.languageLabel
display: flex;
flex-basis: 0;
flex-grow: 1.5;
justify-content: flex-end;
padding-right: 20px;
.language-select
flex-grow: 1.5;
flex-basis: 0;
@@ -0,0 +1,201 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { useSnackbarContext, TabFooter, HotkeyField } from '@ohif/ui';
import { useTranslation } from 'react-i18next';
import { hotkeysValidators } from './hotkeysValidators';
import { MODIFIER_KEYS } from './hotkeysConfig';
import { hotkeysManager } from '../../App';
import './HotkeysPreferences.styl';
/**
* Take hotkeyDefenintions and build an initialState to be used into the component state
*
* @param {Object} hotkeyDefinitions
* @returns {Object} initialState
*/
const initialState = hotkeyDefinitions => ({
hotkeys: { ...hotkeyDefinitions },
errors: {},
});
/**
* Take the updated command and keys and validate the changes with all validators
*
* @param {Object} arguments
* @param {string} arguments.commandName command name string to be updated
* @param {array} arguments.pressedKeys new array of keys to be added for the commandName
* @param {array} arguments.hotkeys all hotkeys currently into the app
* @returns {Object} {errorMessage} errorMessage coming from any of the validator or undefined if none
*/
const validateCommandKey = ({ commandName, pressedKeys, hotkeys }) => {
for (const validator of hotkeysValidators) {
const validation = validator({
commandName,
pressedKeys,
hotkeys,
});
if (validation && validation.hasError) {
return validation;
}
}
return {
errorMessage: undefined,
};
};
/**
* Take all hotkeys and split the list into two lists
*
* @param {array} hotkeys list of all hotkeys
* @returns {array} array containing two arrays of keys
*/
const splitHotkeys = hotkeys => {
const splitedHotkeys = [];
const arrayHotkeys = Object.entries(hotkeys);
if (arrayHotkeys.length) {
const halfwayThrough = Math.ceil(arrayHotkeys.length / 2);
splitedHotkeys.push(arrayHotkeys.slice(0, halfwayThrough));
splitedHotkeys.push(
arrayHotkeys.slice(halfwayThrough, arrayHotkeys.length)
);
}
return splitedHotkeys;
};
/**
* 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.onClose
*/
function HotkeysPreferences({ onClose }) {
const { t } = useTranslation('UserPreferencesModal');
const { hotkeyDefaults, hotkeyDefinitions } = hotkeysManager;
const [state, setState] = useState(initialState(hotkeyDefinitions));
const snackbar = useSnackbarContext();
const onResetPreferences = () => {
const defaultHotKeyDefinitions = {};
hotkeyDefaults.map(item => {
const { commandName, ...values } = item;
defaultHotKeyDefinitions[commandName] = { ...values };
});
setState(initialState(defaultHotKeyDefinitions));
};
const onSave = () => {
const { hotkeys } = state;
hotkeysManager.setHotkeys(hotkeys);
localStorage.setItem('hotkey-definitions', JSON.stringify(hotkeys));
onClose();
snackbar.show({
message: t('SaveMessage'),
type: 'success',
});
};
const onHotkeyChanged = (commandName, hotkeyDefinition, keys) => {
const { errorMessage } = validateCommandKey({
commandName,
pressedKeys: keys,
hotkeys: state.hotkeys,
});
setState(prevState => ({
hotkeys: {
...prevState.hotkeys,
[commandName]: { ...hotkeyDefinition, keys },
},
errors: {
...prevState.errors,
[commandName]: errorMessage,
},
}));
};
const hasErrors = Object.keys(state.errors).some(key => !!state.errors[key]);
const hasHotkeys = Object.keys(state.hotkeys).length;
const splitedHotkeys = splitHotkeys(state.hotkeys);
return (
<React.Fragment>
<div className="HotkeysPreferences">
{hasHotkeys ? (
<div className="hotkeyTable">
{splitedHotkeys.map((hotkeys, index) => {
return (
<div className="hotkeyColumn" key={index}>
<div className="hotkeyHeader">
<div className="headerItemText text-right">Function</div>
<div className="headerItemText text-center">Shortcut</div>
</div>
{hotkeys.map(hotkey => {
const commandName = hotkey[0];
const hotkeyDefinition = hotkey[1];
const { keys, label } = hotkeyDefinition;
const errorMessage = state.errors[hotkey[0]];
const handleChange = keys => {
onHotkeyChanged(commandName, hotkeyDefinition, keys);
};
return (
<div key={commandName} className="hotkeyRow">
<div className="hotkeyLabel">{label}</div>
<div
data-key="defaultTool"
className={classnames(
'wrapperHotkeyInput',
errorMessage ? 'stateError' : ''
)}
>
<HotkeyField
keys={keys}
modifier_keys={MODIFIER_KEYS}
handleChange={handleChange}
classNames={'hotkeyInput'}
></HotkeyField>
<span className="errorMessage">{errorMessage}</span>
</div>
</div>
);
})}
</div>
);
})}
</div>
) : (
'Hotkeys definitions is empty'
)}
</div>
<TabFooter
onResetPreferences={onResetPreferences}
onSave={onSave}
onCancel={onClose}
hasErrors={hasErrors}
t={t}
/>
</React.Fragment>
);
}
HotkeysPreferences.propTypes = {
onClose: PropTypes.func,
};
export { HotkeysPreferences };
@@ -0,0 +1,73 @@
.HotkeysPreferences
display: flex
padding: 20px
.errorMessage
color: var(--state-error-text)
font-size: 10px
text-transform: uppercase
.hotkeyTable
display: flex
flex-direction: row
flex-basis: 0
flex-grow: 1.5
.hotkeyColumn
display: flex
flex-direction: column
flex-basis: 0
flex-grow: 1.5
.hotkeyHeader
display: flex
flex-direction: row
margin-bottom: 10px
.headerItemText
flex-basis: 0
flex-grow: 1.5
padding: 5px 15px 5px 0
.hotkeyRow
display: flex
.wrapperHotkeyInput
margin-bottom: 5px
cursor: pointer
display: flex
flex-direction: column
flex-basis: 0
flex-grow: 1.5
.hotkeyInput
font-weight: 400
cursor: pointer
transition: background-color .3s ease,border-color .3s ease
background-color: var(--ui-gray)
color: var(--text-primary-color)
border-color: var(--ui-border-coolor)
border: 0
border-radius: 2px
font-size: 14px
height: 30px
width: 100%
line-height: 16px
padding: 8px 9px 6px
text-align: center
.hotkeyInput:focus
border-color: var(--active-color)
background-color: var(--ui-gray-dark)
box-shadow: 0 0 0 2px var(--active-color) !important
outline: 0
.hotkeyLabel
padding: 5px 15px 5px 0
text-align: right
flex-basis: 0
flex-grow: 1.5
.stateError
.hotkeyInput
background-color: var(--state-error)
@@ -0,0 +1,43 @@
import React from 'react';
import PropTypes from 'prop-types';
import { TabComponents } from '@ohif/ui';
// Tabs
import { HotkeysPreferences } from './HotkeysPreferences';
import { WindowLevelPreferences } from './WindowLevelPreferences';
import { GeneralPreferences } from './GeneralPreferences';
const tabs = [
{
name: 'Hotkeys',
Component: HotkeysPreferences,
customProps: {},
hidden: false,
},
{
name: 'General',
Component: GeneralPreferences,
customProps: {},
hidden: false,
},
{
name: 'Window Level',
Component: WindowLevelPreferences,
customProps: {},
hidden: true,
},
];
function UserPreferences({ hide }) {
const customProps = {
onClose: hide,
};
return <TabComponents tabs={tabs} customProps={customProps} />;
}
UserPreferences.propTypes = {
hide: PropTypes.func,
};
export { UserPreferences };
@@ -0,0 +1,32 @@
import React from 'react';
import PropTypes from 'prop-types';
import { TabFooter } from '@ohif/ui';
import { useTranslation } from 'react-i18next';
function WindowLevelPreferences({ onClose }) {
const { t } = useTranslation('UserPreferencesModal');
const onResetPreferences = () => {};
const onSave = () => {};
const hasErrors = false;
return (
<React.Fragment>
<div className="">Component content: {name}</div>
<div className="">TDB!</div>
<TabFooter
onResetPreferences={onResetPreferences}
onSave={onSave}
onCancel={onClose}
hasErrors={hasErrors}
t={t}
/>
</React.Fragment>
);
}
WindowLevelPreferences.propTypes = {
onClose: PropTypes.func,
};
export { WindowLevelPreferences };
@@ -2,7 +2,9 @@ const range = (start, end) => {
return new Array(end - start).fill().map((d, i) => i + start);
};
export const disallowedCombinations = {
export const MODIFIER_KEYS = ['ctrl', 'alt', 'shift'];
export const DISALLOWED_COMBINATIONS = {
'': [],
alt: ['space'],
shift: [],
@@ -34,18 +36,7 @@ export const disallowedCombinations = {
'ctrl+shift': ['q', 'w', 'r', 't', 'p', 'a', 'h', 'v', 'b', 'n'],
};
export const allowedKeys = [
...[8, 13, 27, 32, 46], // backspace, enter, escape, space, delete
...[12, 106, 107, 109, 110, 111], // Numpad keys
...range(218, 220), // [\]
...range(185, 190), // ;=,-./
...range(111, 131), // F1-F19
...range(32, 41), // arrow keys, home/end, pg dn/up
...range(47, 58), // 0-9
...range(64, 91), // A-Z
];
export const specialKeys = {
export const SPECIAL_KEYS = {
8: 'backspace',
9: 'tab',
13: 'return',
@@ -0,0 +1,97 @@
import { MODIFIER_KEYS, DISALLOWED_COMBINATIONS } from './hotkeysConfig';
const formatPressedKeys = pressedKeysArray => pressedKeysArray.join('+');
const findConflictingCommand = (hotkeys, currentCommandName, pressedKeys) => {
let firstConflictingCommand = undefined;
const formatedPressedHotkeys = formatPressedKeys(pressedKeys);
for (const commandName in hotkeys) {
const toolHotkeys = hotkeys[commandName].keys;
const formatedToolHotkeys = formatPressedKeys(toolHotkeys);
if (
formatedPressedHotkeys === formatedToolHotkeys &&
commandName !== currentCommandName
) {
firstConflictingCommand = hotkeys[commandName];
break;
}
}
return firstConflictingCommand;
};
const ERROR_MESSAGES = {
MODIFIER:
"It's not possible to define only modifier keys (ctrl, alt and shift) as a shortcut",
EMPTY: "Field can't be empty.",
};
// VALIDATORS
const modifierValidator = ({ pressedKeys }) => {
const lastPressedKey = pressedKeys[pressedKeys.length - 1];
// Check if it has a valid modifier
const isModifier = MODIFIER_KEYS.includes(lastPressedKey);
if (isModifier) {
return {
hasError: true,
errorMessage: ERROR_MESSAGES.MODIFIER,
};
}
};
const emptyValidator = ({ pressedKeys = [] }) => {
if (!pressedKeys.length) {
return {
hasError: true,
errorMessage: ERROR_MESSAGES.EMPTY,
};
}
};
const conflictingValidator = ({ commandName, pressedKeys, hotkeys }) => {
const conflictingCommand = findConflictingCommand(
hotkeys,
commandName,
pressedKeys
);
if (conflictingCommand) {
return {
hasError: true,
errorMessage: `"${conflictingCommand.label}" is already using the "${pressedKeys}" shortcut.`,
};
}
};
const disallowedValidator = ({ pressedKeys = [] }) => {
const lastPressedKey = pressedKeys[pressedKeys.length - 1];
const modifierCommand = formatPressedKeys(
pressedKeys.slice(0, pressedKeys.length - 1)
);
const disallowedCombination = DISALLOWED_COMBINATIONS[modifierCommand];
const hasDisallowedCombinations = disallowedCombination
? disallowedCombination.includes(lastPressedKey)
: false;
if (hasDisallowedCombinations) {
return {
hasError: true,
errorMessage: `"${formatPressedKeys(
pressedKeys
)}" shortcut combination is not allowed`,
};
}
};
const hotkeysValidators = [
emptyValidator,
modifierValidator,
conflictingValidator,
disallowedValidator,
];
export { hotkeysValidators };
@@ -0,0 +1 @@
export { UserPreferences } from './UserPreferences';
@@ -1,58 +0,0 @@
import { connect } from 'react-redux';
import { UserPreferencesForm } from '@ohif/ui';
import OHIF from '@ohif/core';
import i18n from '@ohif/i18n';
import { hotkeysManager } from '../App.js';
const { setUserPreferences } = OHIF.redux.actions;
const mapStateToProps = (state, ownProps) => {
const { defaultLanguage } = i18n;
const { hotkeyDefinitions, windowLevelData = {}, generalPreferences } =
state.preferences || {};
const { hotkeyDefaults } = hotkeysManager;
return {
onClose: ownProps.hide,
windowLevelData,
hotkeyDefinitions,
generalPreferences,
hotkeysManager,
hotkeyDefaults,
defaultLanguage,
};
};
const mapDispatchToProps = (dispatch, ownProps) => {
return {
onSave: ({ windowLevelData, hotkeyDefinitions, generalPreferences }) => {
// TODO improve this strategy on windowLevel implementation
hotkeysManager.setHotkeys(hotkeyDefinitions);
const { language } = generalPreferences;
// set new language
i18n.changeLanguage(language);
if (ownProps.hide) {
ownProps.hide();
}
dispatch(
setUserPreferences({
windowLevelData,
hotkeyDefinitions,
generalPreferences,
})
);
},
};
};
const ConnectedUserPreferencesForm = connect(
mapStateToProps,
mapDispatchToProps
)(UserPreferencesForm);
export default ConnectedUserPreferencesForm;