init: sudah ganti logo, hilangin setting, dan investigational use dialog
2
platform/ui/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
storybook-static
|
||||
8
platform/ui/.storybook/OHIFTheme.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import { create } from '@storybook/theming';
|
||||
|
||||
export default create({
|
||||
base: 'light',
|
||||
brandTitle: 'OHIF',
|
||||
brandUrl: 'https://ohif.org',
|
||||
brandImage: 'ohif-logo-light.svg',
|
||||
});
|
||||
3
platform/ui/.storybook/custom.css
Normal file
@@ -0,0 +1,3 @@
|
||||
#storybook-explorer-menu svg {
|
||||
color: #5034ff;
|
||||
}
|
||||
105
platform/ui/.storybook/main.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import path, { dirname, join } from 'path';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import type { StorybookConfig } from '@storybook/react-webpack5';
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ['../src/**/*.stories.@(mdx)'],
|
||||
addons: [
|
||||
getAbsolutePath('@storybook/addon-links'),
|
||||
getAbsolutePath('@storybook/addon-essentials'),
|
||||
// Other addons go here
|
||||
{
|
||||
name: '@storybook/addon-docs',
|
||||
options: {
|
||||
mdxPluginOptions: {
|
||||
mdxCompileOptions: {
|
||||
remarkPlugins: [remarkGfm],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
core: {},
|
||||
framework: {
|
||||
name: getAbsolutePath('@storybook/react-webpack5'),
|
||||
options: {},
|
||||
},
|
||||
docs: {
|
||||
autodocs: true, // see below for alternatives
|
||||
defaultName: 'Docs', // set to change the name of generated docs entries
|
||||
},
|
||||
staticDirs: ['../static'],
|
||||
webpackFinal: async (config: any, { configType }) => {
|
||||
// `configType` has a value of 'DEVELOPMENT' or 'PRODUCTION'
|
||||
// You can change the configuration based on that.
|
||||
// 'PRODUCTION' is used when building the static version of storybook.
|
||||
|
||||
// config.module.rules[0].use[0].options.plugins[1] = [
|
||||
// '@babel/plugin-proposal-class-properties',
|
||||
// { loose: true },
|
||||
// ];
|
||||
|
||||
// config.module.rules[0].use[0].options.plugins[3] = [
|
||||
// '@babel/plugin-proposal-private-methods',
|
||||
// { loose: true },
|
||||
// ];
|
||||
|
||||
// config.module.rules[0].use[0].options.plugins[4] = [
|
||||
// '@babel/plugin-proposal-private-property-in-object',
|
||||
// { loose: true },
|
||||
// ];
|
||||
|
||||
// Make whatever fine-grained changes you need
|
||||
config.module.rules.push({
|
||||
test: /\.m?js/,
|
||||
resolve: {
|
||||
fullySpecified: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Default rule for images /\.(svg|ico|jpg|jpeg|png|gif|eot|otf|webp|ttf|woff|woff2|cur|ani|pdf)(\?.*)?$/
|
||||
const fileLoaderRule = config.module.rules.find(rule => rule.test && rule.test.test('.svg'));
|
||||
fileLoaderRule.exclude = /\.svg$/;
|
||||
|
||||
config.module.rules.push({
|
||||
test: /\.svg$/,
|
||||
use: [
|
||||
{ loader: require.resolve('babel-loader') },
|
||||
// { loader: 'svg-inline-loader' },
|
||||
],
|
||||
});
|
||||
|
||||
config.module.rules.push({
|
||||
test: /\.css$/,
|
||||
use: [
|
||||
{
|
||||
loader: 'postcss-loader',
|
||||
options: {
|
||||
// HERE: OPTIONS
|
||||
postcssOptions: {
|
||||
plugins: [require('tailwindcss'), require('autoprefixer')],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
include: path.resolve(__dirname, '../'),
|
||||
});
|
||||
|
||||
// ignore the file @icr/polyseg-wasm during the build as it is a wasm file and
|
||||
// we don't need that for ui
|
||||
config.module.rules.push({
|
||||
test: /@icr\/polyseg-wasm/,
|
||||
type: 'javascript/auto',
|
||||
loader: 'file-loader',
|
||||
});
|
||||
|
||||
// Return the altered config
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
function getAbsolutePath(value: string): any {
|
||||
return dirname(require.resolve(join(value, 'package.json')));
|
||||
}
|
||||
30
platform/ui/.storybook/manager-head.html
Normal file
@@ -0,0 +1,30 @@
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css?family=Lato:100,300,400,500,700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>OHIF UI Component</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: #f0f3ff !important;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#storybook-explorer-menu svg {
|
||||
color: #2b5282;
|
||||
}
|
||||
</style>
|
||||
<!-- Global site tag (gtag.js) - Google Analytics -->
|
||||
<script
|
||||
async
|
||||
src="https://www.googletagmanager.com/gtag/js?id=G-3S63CTHNP6"
|
||||
></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag() {
|
||||
dataLayer.push(arguments);
|
||||
}
|
||||
gtag('js', new Date());
|
||||
|
||||
gtag('config', 'G-3S63CTHNP6');
|
||||
</script>
|
||||
15
platform/ui/.storybook/manager.js
Normal file
@@ -0,0 +1,15 @@
|
||||
// .storybook/manager.js
|
||||
|
||||
import { addons } from '@storybook/addons';
|
||||
import ohifTheme from './OHIFTheme';
|
||||
|
||||
const link = document.createElement('link');
|
||||
link.setAttribute('rel', 'shortcut icon');
|
||||
document.head.appendChild(link);
|
||||
|
||||
addons.setConfig({
|
||||
theme: ohifTheme,
|
||||
});
|
||||
|
||||
window.STORYBOOK_GA_ID = 'G-3S63CTHNP6';
|
||||
window.STORYBOOK_REACT_GA_OPTIONS = {};
|
||||
79
platform/ui/.storybook/preview.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React from 'react';
|
||||
import { DocsPage, DocsContainer } from '@storybook/addon-docs';
|
||||
import {
|
||||
Heading,
|
||||
SectionName,
|
||||
Footer,
|
||||
AnchorListItem,
|
||||
LinkComponent,
|
||||
} from '../src/storybook/components';
|
||||
|
||||
import '../src/tailwind.css';
|
||||
import './custom.css';
|
||||
|
||||
// https://github.com/mondaycom/monday-ui-react-core/tree/master/.storybook
|
||||
|
||||
export const parameters = {
|
||||
docs: {
|
||||
inlineStories: true,
|
||||
container: ({ children, context }) => (
|
||||
<DocsContainer context={context}>{children}</DocsContainer>
|
||||
),
|
||||
page: DocsPage,
|
||||
components: {
|
||||
Heading,
|
||||
Footer,
|
||||
h2: SectionName,
|
||||
h3: ({ children }) => <h3 className="my-2 to-blue-900 text-xl">{children}</h3>,
|
||||
li: AnchorListItem,
|
||||
a: LinkComponent,
|
||||
p: ({ children }) => <p className="font-inter my-2 text-gray-800">{children}</p>,
|
||||
// todo: add pre and code
|
||||
},
|
||||
},
|
||||
viewMode: 'docs',
|
||||
previewTabs: {
|
||||
'storybook/docs/panel': {
|
||||
index: -1,
|
||||
},
|
||||
canvas: { title: 'Sandbox' },
|
||||
},
|
||||
actions: { argTypesRegex: '^on[A-Z].*' },
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/,
|
||||
},
|
||||
},
|
||||
viewport: {
|
||||
disable: true,
|
||||
},
|
||||
backgrounds: {
|
||||
default: 'OHIF-v3',
|
||||
values: [
|
||||
{
|
||||
name: 'White',
|
||||
value: '#FFFFFF',
|
||||
},
|
||||
{
|
||||
name: 'OHIF-v3',
|
||||
value: '#090C29',
|
||||
},
|
||||
{
|
||||
name: 'Light',
|
||||
value: '#F8F8F8',
|
||||
},
|
||||
{
|
||||
name: 'Dark',
|
||||
value: '#333333',
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
storySort: {
|
||||
order: ['Welcome', 'Contribute', 'Foundations', 'Modals', '*'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const decorators = [];
|
||||
11
platform/ui/.webpack/webpack.dev.js
Normal file
@@ -0,0 +1,11 @@
|
||||
const path = require('path');
|
||||
const webpackCommon = require('./../../../.webpack/webpack.base.js');
|
||||
const SRC_DIR = path.join(__dirname, '../src');
|
||||
const DIST_DIR = path.join(__dirname, '../dist');
|
||||
const ENTRY = {
|
||||
app: `${SRC_DIR}/index.tsx`,
|
||||
};
|
||||
|
||||
module.exports = (env, argv) => {
|
||||
return webpackCommon(env, argv, { SRC_DIR, DIST_DIR, ENTRY });
|
||||
};
|
||||
60
platform/ui/.webpack/webpack.prod.js
Normal file
@@ -0,0 +1,60 @@
|
||||
const { merge } = require('webpack-merge');
|
||||
const path = require('path');
|
||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
||||
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
|
||||
|
||||
const webpackCommon = require('./../../../.webpack/webpack.base.js');
|
||||
const pkg = require('./../package.json');
|
||||
|
||||
const ROOT_DIR = path.join(__dirname, './..');
|
||||
const SRC_DIR = path.join(__dirname, '../src');
|
||||
const DIST_DIR = path.join(__dirname, '../dist');
|
||||
|
||||
const ENTRY = {
|
||||
app: `${SRC_DIR}/index.js`,
|
||||
};
|
||||
|
||||
const outputName = `ohif-${pkg.name.split('/').pop()}`;
|
||||
|
||||
module.exports = (env, argv) => {
|
||||
const commonConfig = webpackCommon(env, argv, { SRC_DIR, DIST_DIR, ENTRY });
|
||||
|
||||
return merge(commonConfig, {
|
||||
stats: {
|
||||
colors: true,
|
||||
hash: true,
|
||||
timings: true,
|
||||
assets: true,
|
||||
chunks: false,
|
||||
chunkModules: false,
|
||||
modules: false,
|
||||
children: false,
|
||||
warnings: true,
|
||||
},
|
||||
optimization: {
|
||||
minimize: true,
|
||||
sideEffects: false,
|
||||
},
|
||||
output: {
|
||||
path: ROOT_DIR,
|
||||
library: 'ohif-ui',
|
||||
libraryTarget: 'umd',
|
||||
filename: pkg.main,
|
||||
},
|
||||
externals: [
|
||||
/\b(dcmjs)/,
|
||||
/\b(gl-matrix)/,
|
||||
{
|
||||
react: 'React',
|
||||
'react-dom': 'ReactDOM',
|
||||
},
|
||||
],
|
||||
plugins: [
|
||||
new MiniCssExtractPlugin({
|
||||
filename: `./dist/${outputName}.css`,
|
||||
chunkFilename: `./dist/${outputName}.css`,
|
||||
}),
|
||||
// new BundleAnalyzerPlugin({}),
|
||||
],
|
||||
});
|
||||
};
|
||||
BIN
platform/ui/17dd54813d5acc10bf8f.wasm
Normal file
3850
platform/ui/CHANGELOG.md
Normal file
14
platform/ui/README.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# @ohif/ui
|
||||
|
||||
## For Running Storybook
|
||||
|
||||
```
|
||||
cd platform/ui
|
||||
|
||||
yarn install
|
||||
|
||||
yarn storybook
|
||||
|
||||
```
|
||||
|
||||
Stories are available default at `http://localhost:6006/`
|
||||
BIN
platform/ui/assets/images/CT-AAA.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
platform/ui/assets/images/CT-AAA2.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
platform/ui/assets/images/CT-Air.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
platform/ui/assets/images/CT-Bone.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
platform/ui/assets/images/CT-Bones.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
platform/ui/assets/images/CT-Cardiac.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
platform/ui/assets/images/CT-Cardiac2.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
platform/ui/assets/images/CT-Cardiac3.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
platform/ui/assets/images/CT-Chest-Contrast-Enhanced.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
platform/ui/assets/images/CT-Chest-Vessels.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
platform/ui/assets/images/CT-Coronary-Arteries-2.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
platform/ui/assets/images/CT-Coronary-Arteries-3.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
platform/ui/assets/images/CT-Coronary-Arteries.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
platform/ui/assets/images/CT-Cropped-Volume-Bone.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
platform/ui/assets/images/CT-Fat.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
platform/ui/assets/images/CT-Liver-Vasculature.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
platform/ui/assets/images/CT-Lung.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
platform/ui/assets/images/CT-MIP.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
platform/ui/assets/images/CT-Muscle.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
platform/ui/assets/images/CT-Pulmonary-Arteries.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
platform/ui/assets/images/CT-Soft-Tissue.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
platform/ui/assets/images/DTI-FA-Brain.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
platform/ui/assets/images/MR-Angio.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
platform/ui/assets/images/MR-Default.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
platform/ui/assets/images/MR-MIP.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
platform/ui/assets/images/MR-T2-Brain.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
platform/ui/assets/images/VolumeRendering.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
1
platform/ui/babel.config.js
Normal file
@@ -0,0 +1 @@
|
||||
module.exports = require('../../babel.config.js');
|
||||
12
platform/ui/jest.config.js
Normal file
@@ -0,0 +1,12 @@
|
||||
const base = require('../../jest.config.base.js');
|
||||
const pkg = require('./package');
|
||||
|
||||
module.exports = {
|
||||
...base,
|
||||
displayName: pkg.name,
|
||||
// rootDir: "../.."
|
||||
// testMatch: [
|
||||
// //`<rootDir>/platform/${pack.name}/**/*.spec.js`
|
||||
// "<rootDir>/platform/app/**/*.test.js"
|
||||
// ]
|
||||
};
|
||||
87
platform/ui/package.json
Normal file
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"name": "@ohif/ui",
|
||||
"version": "3.10.0-beta.111",
|
||||
"description": "A set of React components for Medical Imaging Viewers",
|
||||
"author": "OHIF Contributors",
|
||||
"license": "MIT",
|
||||
"main": "dist/ohif-ui.umd.js",
|
||||
"module": "src/index.js",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14",
|
||||
"npm": ">=6",
|
||||
"yarn": ">=1.16.0"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"clean": "rm -rf node_modules/.cache/storybook && shx rm -rf dist",
|
||||
"clean:deep": "yarn run clean && shx rm -rf node_modules",
|
||||
"start": "yarn run build --watch",
|
||||
"build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js",
|
||||
"build:package": "yarn run build",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"dev": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@testing-library/react": "^13.1.0",
|
||||
"browser-detect": "^0.2.28",
|
||||
"classnames": "^2.3.2",
|
||||
"d3-array": "3",
|
||||
"d3-axis": "3",
|
||||
"d3-scale": "4",
|
||||
"d3-scale-chromatic": "3",
|
||||
"d3-selection": "3",
|
||||
"d3-shape": "3",
|
||||
"d3-zoom": "3",
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"lodash.merge": "^4.6.1",
|
||||
"moment": "*",
|
||||
"mousetrap": "^1.6.5",
|
||||
"react": "^18.3.1",
|
||||
"react-dates": "^21.8.0",
|
||||
"react-dnd": "14.0.2",
|
||||
"react-dnd-html5-backend": "14.0.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-draggable": "^4.4.6",
|
||||
"react-error-boundary": "^3.1.3",
|
||||
"react-modal": "3.11.2",
|
||||
"react-outside-click-handler": "^1.3.0",
|
||||
"react-select": "5.7.4",
|
||||
"react-test-renderer": "^18.3.1",
|
||||
"react-window": "^1.8.9",
|
||||
"react-with-direction": "^1.3.1",
|
||||
"swiper": "^8.4.2",
|
||||
"webpack": "5.94.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.24.7",
|
||||
"@storybook/addon-actions": "^7.6.10",
|
||||
"@storybook/addon-docs": "^7.6.10",
|
||||
"@storybook/addon-essentials": "^7.6.10",
|
||||
"@storybook/addon-links": "^7.6.10",
|
||||
"@storybook/cli": "^7.6.10",
|
||||
"@storybook/react": "^7.6.10",
|
||||
"@storybook/react-webpack5": "^7.6.10",
|
||||
"@storybook/source-loader": "^7.6.10",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"babel-loader": "^9.1.2",
|
||||
"dotenv-webpack": "^8.0.1",
|
||||
"postcss": "^8.4.23",
|
||||
"postcss-loader": "^7.2.4",
|
||||
"prop-types": "^15.8.1",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"storybook": "^7.6.10",
|
||||
"tailwindcss": "3.2.4"
|
||||
}
|
||||
}
|
||||
57
platform/ui/src/assets/styles/styles.css
Normal file
@@ -0,0 +1,57 @@
|
||||
/* CUSTOM OHIF SCROLLBAR */
|
||||
.ohif-scrollbar {
|
||||
scrollbar-color: #173239 transparent;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ohif-scrollbar-stable-gutter {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.study-min-height {
|
||||
min-height: 450px;
|
||||
}
|
||||
|
||||
.ohif-scrollbar:hover {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ohif-scrollbar::-webkit-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.ohif-scrollbar::-webkit-scrollbar-track {
|
||||
@apply rounded;
|
||||
}
|
||||
|
||||
.ohif-scrollbar::-webkit-scrollbar-thumb {
|
||||
@apply rounded;
|
||||
@apply bg-secondary-dark;
|
||||
background-color: #041c4a;
|
||||
}
|
||||
|
||||
.ohif-scrollbar::-webkit-scrollbar-thumb:window-inactive {
|
||||
@apply bg-secondary-dark;
|
||||
background-color: #041c4a;
|
||||
}
|
||||
|
||||
/* INVISIBLE SCROLLBAR */
|
||||
.invisible-scrollbar {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.invisible-scrollbar::-webkit-scrollbar {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
.invisible-scrollbar::-webkit-scrollbar-track {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
.invisible-scrollbar::-webkit-scrollbar-thumb {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
.invisible-scrollbar::-webkit-scrollbar-thumb:window-inactive {
|
||||
@apply hidden;
|
||||
}
|
||||
150
platform/ui/src/components/AboutModal/AboutModal.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import detect from 'browser-detect';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import Typography from '../Typography';
|
||||
import { Icons } from '@ohif/ui-next';
|
||||
|
||||
const Link = ({ href, children, showIcon = false }) => {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Typography
|
||||
variant="subtitle"
|
||||
component="p"
|
||||
color="primaryActive"
|
||||
className="flex items-center"
|
||||
>
|
||||
{children}
|
||||
{!!showIcon && <Icons.ExternalLink className="ml-2 w-5 text-white" />}
|
||||
</Typography>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
const Row = ({ title, value, link }) => {
|
||||
return (
|
||||
<div className="mb-4 flex">
|
||||
<Typography
|
||||
variant="subtitle"
|
||||
component="p"
|
||||
className="w-48 text-white"
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
|
||||
{link ? (
|
||||
<Link href={link}>{value}</Link>
|
||||
) : (
|
||||
<Typography
|
||||
variant="subtitle"
|
||||
component="p"
|
||||
className="w-48 text-white"
|
||||
>
|
||||
{value}
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AboutModal = ({ buildNumber, versionNumber, commitHash }) => {
|
||||
const { os, version, name } = detect();
|
||||
const browser = `${name[0].toUpperCase()}${name.substr(1)} ${version}`;
|
||||
const { t } = useTranslation('AboutModal');
|
||||
|
||||
const renderRowTitle = title => (
|
||||
<div className="mb-3 border-b-2 border-black pb-3">
|
||||
<Typography
|
||||
variant="inherit"
|
||||
color="primaryLight"
|
||||
className="text-[16px] font-semibold !leading-[1.2]"
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
{renderRowTitle(t('Important links'))}
|
||||
<div className="mb-8 flex">
|
||||
<Link
|
||||
href="https://community.ohif.org/"
|
||||
showIcon={true}
|
||||
>
|
||||
{t('Visit the forum')}
|
||||
</Link>
|
||||
<span className="ml-4">
|
||||
<Link
|
||||
href="https://github.com/OHIF/Viewers/issues/new/choose"
|
||||
showIcon={true}
|
||||
>
|
||||
{t('Report an issue')}
|
||||
</Link>
|
||||
</span>
|
||||
<span className="ml-4">
|
||||
<Link
|
||||
href="https://ohif.org/"
|
||||
showIcon={true}
|
||||
>
|
||||
{t('More details')}
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{renderRowTitle(t('Version information'))}
|
||||
<div className="flex flex-col">
|
||||
<Row
|
||||
title={t('Repository URL')}
|
||||
value="https://github.com/OHIF/Viewers/"
|
||||
link="https://github.com/OHIF/Viewers/"
|
||||
/>
|
||||
<Row
|
||||
title={t('Data citation')}
|
||||
value="https://github.com/OHIF/Viewers/blob/master/DATACITATION.md"
|
||||
link="https://github.com/OHIF/Viewers/blob/master/DATACITATION.md"
|
||||
/>
|
||||
{/* <Row
|
||||
title={t('Last master commits')}
|
||||
value="https://github.com/OHIF/Viewers/"
|
||||
link="https://github.com/OHIF/Viewers/"
|
||||
/> */}
|
||||
<Row
|
||||
title={t('Version number')}
|
||||
value={versionNumber}
|
||||
/>
|
||||
{buildNumber && (
|
||||
<Row
|
||||
title={t('Build number')}
|
||||
value={buildNumber}
|
||||
/>
|
||||
)}
|
||||
{commitHash && (
|
||||
<Row
|
||||
title={t('Commit hash')}
|
||||
value={commitHash}
|
||||
/>
|
||||
)}
|
||||
<Row
|
||||
title={t('Browser')}
|
||||
value={browser}
|
||||
/>
|
||||
<Row
|
||||
title={t('OS')}
|
||||
value={os}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
AboutModal.propTypes = {
|
||||
buildNumber: PropTypes.string,
|
||||
versionNumber: PropTypes.string,
|
||||
};
|
||||
|
||||
export default AboutModal;
|
||||
@@ -0,0 +1,50 @@
|
||||
import AboutModal from '../../AboutModal';
|
||||
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
|
||||
import {
|
||||
createComponentTemplate,
|
||||
createStoryMetaSettings,
|
||||
} from '../../../storybook/functions/create-component-story';
|
||||
|
||||
export const argTypes = {
|
||||
component: AboutModal,
|
||||
title: 'Modals/About',
|
||||
};
|
||||
|
||||
<Meta
|
||||
title="Modals/About"
|
||||
component={AboutModal}
|
||||
/>
|
||||
|
||||
export const aboutTemplate = args => (
|
||||
<div className="bg-primary-dark">
|
||||
<AboutModal {...args} />
|
||||
</div>
|
||||
);
|
||||
|
||||
<Heading
|
||||
title="About Modal"
|
||||
componentRelativePath="AboutModal/AboutModal.tsx"
|
||||
/>
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Props](#props)
|
||||
- [Usage](#usage)
|
||||
- [Contribute](#contribute)
|
||||
|
||||
## Overview
|
||||
|
||||
OHIF about modal component provides information about the application version and build number
|
||||
|
||||
<Canvas>
|
||||
<Story name="Overview">{aboutTemplate.bind({})}</Story>
|
||||
</Canvas>
|
||||
|
||||
## Props
|
||||
|
||||
<ArgsTable of={AboutModal} />
|
||||
|
||||
## Usage
|
||||
|
||||
## Contribute
|
||||
|
||||
<Footer componentRelativePath="AboutModal/__stories__/aboutModal.stories.mdx" />
|
||||
2
platform/ui/src/components/AboutModal/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
import AboutModal from './AboutModal';
|
||||
export default AboutModal;
|
||||
36
platform/ui/src/components/ActionButtons/ActionButtons.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Button, ButtonEnums } from '../../components';
|
||||
|
||||
function ActionButtons({ actions, disabled = false, t }) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
{actions.map((action, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
onClick={action.onClick}
|
||||
disabled={disabled || action.disabled}
|
||||
type={ButtonEnums.type.secondary}
|
||||
size={ButtonEnums.size.small}
|
||||
className={index > 0 ? 'ml-2' : ''}
|
||||
>
|
||||
{t ? t(action.label) : action.label}
|
||||
</Button>
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
ActionButtons.propTypes = {
|
||||
actions: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
label: PropTypes.string.isRequired,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
disabled: PropTypes.bool,
|
||||
})
|
||||
).isRequired,
|
||||
disabled: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default ActionButtons;
|
||||
3
platform/ui/src/components/ActionButtons/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import ActionButtons from './ActionButtons';
|
||||
|
||||
export default ActionButtons;
|
||||
@@ -0,0 +1,72 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import { PanelSection, Tooltip } from '../../components';
|
||||
import ToolSettings from './ToolSettings';
|
||||
import { Icons } from '@ohif/ui-next';
|
||||
|
||||
/**
|
||||
* Use Toolbox component instead of this although it doesn't have "Advanced" in its name
|
||||
* it is better to use it instead of this one
|
||||
*/
|
||||
const AdvancedToolbox = ({ title, items }) => {
|
||||
const [activeItemName, setActiveItemName] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
// see if any of the items are active from the outside
|
||||
const activeItem = items?.find(item => item.active);
|
||||
setActiveItemName(activeItem ? activeItem.name : null);
|
||||
}, [items]);
|
||||
|
||||
const activeItemOptions = items?.find(item => item.name === activeItemName)?.options;
|
||||
|
||||
return (
|
||||
<PanelSection
|
||||
title={title}
|
||||
childrenClassName="flex-shrink-0"
|
||||
>
|
||||
<div className="flex flex-col bg-black">
|
||||
<div className="bg-primary-dark mt-0.5 flex flex-wrap py-2">
|
||||
{items?.map(item => {
|
||||
return (
|
||||
<Tooltip
|
||||
position="bottom"
|
||||
content={<span className="text-white">{item.name}</span>}
|
||||
key={item.name}
|
||||
>
|
||||
<div
|
||||
className="ml-2 mb-2"
|
||||
onClick={() => {
|
||||
if (item.disabled) {
|
||||
return;
|
||||
}
|
||||
setActiveItemName(item.name);
|
||||
item.onClick(item.name);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={classnames(
|
||||
'text-primary-active grid h-[40px] w-[40px] place-items-center rounded-md bg-black',
|
||||
activeItemName === item.name && 'bg-primary-light text-black',
|
||||
item.disabled && 'opacity-50',
|
||||
!item.disabled &&
|
||||
'hover:bg-primary-light cursor-pointer hover:cursor-pointer hover:text-black'
|
||||
)}
|
||||
>
|
||||
<Icons.ByName name={item.icon} />
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="bg-primary-dark h-auto px-2">
|
||||
<ToolSettings options={activeItemOptions} />
|
||||
</div>
|
||||
</div>
|
||||
</PanelSection>
|
||||
);
|
||||
};
|
||||
|
||||
AdvancedToolbox.propTypes = {};
|
||||
|
||||
export default AdvancedToolbox;
|
||||
128
platform/ui/src/components/AdvancedToolbox/ToolSettings.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import React from 'react';
|
||||
import { ButtonGroup, InputDoubleRange, InputRange } from '../../components';
|
||||
|
||||
const SETTING_TYPES = {
|
||||
RANGE: 'range',
|
||||
RADIO: 'radio',
|
||||
CUSTOM: 'custom',
|
||||
DOUBLE_RANGE: 'double-range',
|
||||
};
|
||||
|
||||
function ToolSettings({ options }) {
|
||||
if (!options) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof options === 'function') {
|
||||
return options();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2 py-2 text-white">
|
||||
{options?.map(option => {
|
||||
if (option.condition && option.condition?.({ options }) === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (option.type) {
|
||||
case SETTING_TYPES.RANGE:
|
||||
return renderRangeSetting(option);
|
||||
case SETTING_TYPES.RADIO:
|
||||
return renderRadioSetting(option);
|
||||
case SETTING_TYPES.DOUBLE_RANGE:
|
||||
return renderDoubleRangeSetting(option);
|
||||
case SETTING_TYPES.CUSTOM:
|
||||
return renderCustomSetting(option);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderRangeSetting = option => {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center"
|
||||
key={option.id}
|
||||
>
|
||||
<div className="w-1/3 text-[13px]">{option.name}</div>
|
||||
<div className="w-2/3">
|
||||
<InputRange
|
||||
minValue={option.min}
|
||||
maxValue={option.max}
|
||||
step={option.step}
|
||||
value={option.value}
|
||||
onChange={value => option.commands?.(value)}
|
||||
allowNumberEdit={true}
|
||||
showAdjustmentArrows={false}
|
||||
inputClassName="ml-1 w-4/5 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderRadioSetting = option => {
|
||||
const renderButtons = option => {
|
||||
return option.values?.map(({ label, value: optionValue }, index) => (
|
||||
<button
|
||||
onClick={() => {
|
||||
option.commands?.(optionValue);
|
||||
}}
|
||||
key={`button-${option.id}-${index}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-between text-[13px]"
|
||||
key={option.id}
|
||||
>
|
||||
<span>{option.name}</span>
|
||||
<div className="max-w-1/2">
|
||||
<ButtonGroup
|
||||
className="border-secondary-light rounded-md border"
|
||||
activeIndex={option.values.findIndex(({ value }) => value === option.value) || 0}
|
||||
>
|
||||
{renderButtons(option)}
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderDoubleRangeSetting = option => {
|
||||
return (
|
||||
<div
|
||||
className="flex w-full items-center"
|
||||
key={option.id}
|
||||
>
|
||||
<InputDoubleRange
|
||||
values={option.value}
|
||||
onChange={option.commands}
|
||||
minValue={option.min}
|
||||
maxValue={option.max}
|
||||
step={option.step}
|
||||
showLabel={true}
|
||||
allowNumberEdit={true}
|
||||
showAdjustmentArrows={false}
|
||||
containerClassName="w-full"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderCustomSetting = option => {
|
||||
return (
|
||||
<div key={option.id}>
|
||||
{typeof option.children === 'function' ? option.children() : option.children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToolSettings;
|
||||
@@ -0,0 +1,144 @@
|
||||
import AdvancedToolbox from '../../AdvancedToolbox';
|
||||
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
|
||||
import {
|
||||
createComponentTemplate,
|
||||
createStoryMetaSettings,
|
||||
} from '../../../storybook/functions/create-component-story';
|
||||
|
||||
export const argTypes = {
|
||||
component: AdvancedToolbox,
|
||||
title: 'Components/AdvancedToolbox',
|
||||
};
|
||||
|
||||
<Meta
|
||||
title="Components/AdvancedToolbox"
|
||||
component={AdvancedToolbox}
|
||||
/>
|
||||
|
||||
export const advancedToolboxTemplate = args => (
|
||||
<div className="bg-primary-dark h-96 w-64">
|
||||
<AdvancedToolbox {...args} />
|
||||
</div>
|
||||
);
|
||||
|
||||
<Heading
|
||||
title="Advanced Toolbox"
|
||||
componentRelativePath="AdvancedToolbox/AdvancedToolbox.tsx"
|
||||
/>
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Props](#props)
|
||||
- [Usage](#usage)
|
||||
- [Contribute](#contribute)
|
||||
|
||||
## Overview
|
||||
|
||||
OHIF advanced toolbox which can host set of tools that require more space for customization.
|
||||
|
||||
<Canvas>
|
||||
<Story
|
||||
name="Overview"
|
||||
args={{
|
||||
title: 'Segmentation Tools',
|
||||
items: [
|
||||
{
|
||||
name: 'Brush',
|
||||
icon: 'icon-tool-brush',
|
||||
active: false,
|
||||
onClick: () => console.log('Brush clicked'),
|
||||
options: [
|
||||
{
|
||||
name: 'Radius (mm)',
|
||||
type: 'range',
|
||||
min: 1,
|
||||
max: 10,
|
||||
value: 5,
|
||||
step: 1,
|
||||
onChange: value => console.log('Brush size changed', value),
|
||||
},
|
||||
{
|
||||
name: 'Mode',
|
||||
type: 'radio',
|
||||
value: 'Circle',
|
||||
values: [
|
||||
{ value: 'Circle', label: 'Circle' },
|
||||
{ value: 'Sphere', label: 'Sphere' },
|
||||
{ value: 'Rectangle', label: 'Rectangle' },
|
||||
],
|
||||
onChange: value => console.log('Brush mode changed', value),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Eraser',
|
||||
icon: 'icon-tool-eraser',
|
||||
onClick: () => console.log('eraser clicked'),
|
||||
options: [
|
||||
{
|
||||
name: 'Mode',
|
||||
type: 'radio',
|
||||
value: 'EraserSphere',
|
||||
values: [
|
||||
{ value: 'EraserCircle', label: 'Circle' },
|
||||
{ value: 'EraserSphere', label: 'Sphere' },
|
||||
],
|
||||
onChange: value => console.log('Brush mode changed', value),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Threshold',
|
||||
icon: 'icon-tool-threshold',
|
||||
active: true,
|
||||
onClick: () => console.log('eraser clicked'),
|
||||
options: [
|
||||
{
|
||||
name: 'Radius (mm)',
|
||||
type: 'range',
|
||||
min: 1,
|
||||
max: 10,
|
||||
value: 5,
|
||||
step: 1,
|
||||
onChange: value => console.log('Brush size changed', value),
|
||||
},
|
||||
{
|
||||
name: 'Mode',
|
||||
type: 'radio',
|
||||
value: 'Circle',
|
||||
values: [
|
||||
{ value: 'Circle', label: 'Circle' },
|
||||
{ value: 'Sphere', label: 'Sphere' },
|
||||
{ value: 'Rectangle', label: 'Rectangle' },
|
||||
],
|
||||
onChange: value => console.log('Brush mode changed', value),
|
||||
},
|
||||
{
|
||||
name: 'custom',
|
||||
type: 'custom',
|
||||
children: () => {
|
||||
return (
|
||||
<div>
|
||||
<div>Custom</div>
|
||||
<input type="text" />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
{advancedToolboxTemplate.bind({})}
|
||||
</Story>
|
||||
</Canvas>
|
||||
|
||||
## Props
|
||||
|
||||
<ArgsTable of={AdvancedToolbox} />
|
||||
|
||||
## Usage
|
||||
|
||||
## Contribute
|
||||
|
||||
<Footer componentRelativePath="AdvancedToolbox/__stories__/AdvancedToolbox.stories.mdx" />
|
||||
4
platform/ui/src/components/AdvancedToolbox/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import AdvancedToolbox from './AdvancedToolbox';
|
||||
import ToolSettings from './ToolSettings';
|
||||
export default AdvancedToolbox;
|
||||
export { ToolSettings };
|
||||
27
platform/ui/src/components/AllInOneMenu/BackItem.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Icons } from '@ohif/ui-next';
|
||||
import DividerItem from './DividerItem';
|
||||
|
||||
type BackItemProps = {
|
||||
backLabel?: string;
|
||||
onBackClick: () => void;
|
||||
};
|
||||
|
||||
const BackItem = ({ backLabel, onBackClick }: BackItemProps) => {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="all-in-one-menu-item all-in-one-menu-item-effects"
|
||||
onClick={onBackClick}
|
||||
>
|
||||
<Icons.ByName name="content-prev"></Icons.ByName>
|
||||
|
||||
<div className="pl-2">{backLabel || 'Back to Display Options'}</div>
|
||||
</div>
|
||||
<DividerItem></DividerItem>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default BackItem;
|
||||
11
platform/ui/src/components/AllInOneMenu/DividerItem.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
|
||||
const DividerItem = () => {
|
||||
return (
|
||||
<div className="flex h-3.5 shrink-0 items-center px-2">
|
||||
<div className="bg-primary-dark h-[2px] w-full"></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DividerItem;
|
||||
13
platform/ui/src/components/AllInOneMenu/HeaderItem.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
|
||||
type HeaderItemProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const HeaderItem = ({ children }: HeaderItemProps) => {
|
||||
return (
|
||||
<div className="text-aqua-pale mx-2 flex h-6 shrink-0 items-center text-[11px]">{children}</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeaderItem;
|
||||
83
platform/ui/src/components/AllInOneMenu/IconMenu.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import OutsideClickHandler from 'react-outside-click-handler';
|
||||
import { MenuProps } from './Menu';
|
||||
import classNames from 'classnames';
|
||||
import { AllInOneMenu } from '..';
|
||||
import { Icons } from '@ohif/ui-next';
|
||||
|
||||
export interface IconMenuProps extends MenuProps {
|
||||
icon: string;
|
||||
iconClassName?: string;
|
||||
horizontalDirection?: AllInOneMenu.HorizontalDirection;
|
||||
verticalDirection?: AllInOneMenu.VerticalDirection;
|
||||
menuKey?: number | string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An IconMenu allows for a div wrapped icon to be clicked to show and hide
|
||||
* an AllInOneMenu.Menu. Based on the direction(s) specified, the menu is
|
||||
* positioned relative to the icon.
|
||||
*
|
||||
* HorizontalDirection.LeftToRight - the left edges of the icon and menu are aligned
|
||||
* HorizontalDirection.RightRoLeft - the right edges of the icon and menu are aligned
|
||||
* VerticalDirection.TopToBottom - the top edge of the menu appears directly below the bottom edge of the icon
|
||||
* VerticalDirection.BottomToTop - the bottom edge of the menu appears directly above the top edge of the icon
|
||||
*
|
||||
* For example, if an IconMenu were situated in the bottom-left corner of a container,
|
||||
* it would be best to use BottomToTop and LeftToRight directions for it.
|
||||
*/
|
||||
export default function IconMenu({
|
||||
icon,
|
||||
iconClassName,
|
||||
horizontalDirection,
|
||||
verticalDirection,
|
||||
children,
|
||||
backLabel,
|
||||
menuClassName,
|
||||
menuStyle,
|
||||
onVisibilityChange,
|
||||
menuKey,
|
||||
}: IconMenuProps) {
|
||||
const [isMenuVisible, setIsMenuVisible] = useState(false);
|
||||
|
||||
const toggleMenuVisibility = useCallback(() => setIsMenuVisible(isVisible => !isVisible), []);
|
||||
|
||||
return (
|
||||
<OutsideClickHandler
|
||||
onOutsideClick={toggleMenuVisibility}
|
||||
disabled={!isMenuVisible}
|
||||
>
|
||||
<div className="relative">
|
||||
<div
|
||||
className={iconClassName}
|
||||
onClick={toggleMenuVisibility}
|
||||
>
|
||||
<Icons.ByName name={icon} />
|
||||
</div>
|
||||
<AllInOneMenu.Menu
|
||||
key={menuKey}
|
||||
isVisible={isMenuVisible}
|
||||
backLabel={backLabel}
|
||||
menuClassName={classNames(
|
||||
menuClassName,
|
||||
'absolute',
|
||||
verticalDirection === AllInOneMenu.VerticalDirection.TopToBottom
|
||||
? 'top-[100%]'
|
||||
: 'bottom-[100%]',
|
||||
horizontalDirection === AllInOneMenu.HorizontalDirection.LeftToRight
|
||||
? 'left-0'
|
||||
: 'right-0'
|
||||
)}
|
||||
menuStyle={menuStyle}
|
||||
onVisibilityChange={isVis => {
|
||||
setIsMenuVisible(isVis);
|
||||
onVisibilityChange?.(isVis);
|
||||
}}
|
||||
horizontalDirection={horizontalDirection}
|
||||
>
|
||||
{children}
|
||||
</AllInOneMenu.Menu>
|
||||
</div>
|
||||
</OutsideClickHandler>
|
||||
);
|
||||
}
|
||||
45
platform/ui/src/components/AllInOneMenu/Item.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React, { ReactNode, useCallback, useContext } from 'react';
|
||||
import { MenuContext } from './Menu';
|
||||
|
||||
type ItemProps = {
|
||||
label: string;
|
||||
secondaryLabel?: string;
|
||||
icon?: ReactNode;
|
||||
onClick?: () => void;
|
||||
onMouseEnter?: () => void;
|
||||
onMouseLeave?: () => void;
|
||||
rightIcon?: ReactNode;
|
||||
};
|
||||
|
||||
const Item = ({
|
||||
label,
|
||||
secondaryLabel,
|
||||
icon,
|
||||
rightIcon,
|
||||
onClick,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
}: ItemProps) => {
|
||||
const { hideMenu } = useContext(MenuContext);
|
||||
|
||||
const onClickHandler = useCallback(() => {
|
||||
hideMenu();
|
||||
onClick?.();
|
||||
}, [hideMenu, onClick]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="all-in-one-menu-item all-in-one-menu-item-effects"
|
||||
onClick={onClickHandler}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
{icon && <div className="pr-2">{icon}</div>}
|
||||
<span>{label}</span>
|
||||
{secondaryLabel != null && <span className="text-aqua-pale ml-[1ch]">{secondaryLabel}</span>}
|
||||
{rightIcon && <div className="ml-auto">{rightIcon}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Item;
|
||||
29
platform/ui/src/components/AllInOneMenu/ItemPanel.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React, { ReactNode, useContext, useEffect } from 'react';
|
||||
import { MenuContext } from './Menu';
|
||||
|
||||
type ItemPanelProps = {
|
||||
label?: string;
|
||||
index?: number;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const ItemPanel = ({ label, index = 0, children }: ItemPanelProps) => {
|
||||
const { addItemPanel, activePanelIndex } = useContext(MenuContext);
|
||||
|
||||
useEffect(() => {
|
||||
addItemPanel(index, label);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
activePanelIndex === index && (
|
||||
<div
|
||||
style={{ scrollbarGutter: 'auto' }}
|
||||
className="ohif-scrollbar flex flex-col overflow-auto"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default ItemPanel;
|
||||
188
platform/ui/src/components/AllInOneMenu/Menu.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import React, { createContext, ReactNode, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import './allInOneMenu.css';
|
||||
import DividerItem from './DividerItem';
|
||||
import PanelSelector from './PanelSelector';
|
||||
import classNames from 'classnames';
|
||||
import BackItem from './BackItem';
|
||||
|
||||
/**
|
||||
* The vertical direction that the menu will be opened/used with.
|
||||
*
|
||||
* A TopToBottom menu would be used for cases where the menu is opened "near"
|
||||
* the top edge of its container. Likewise a BottomToTop menu would be used
|
||||
* for cases where the menu is opened "near" the bottom edge of its container.
|
||||
*
|
||||
* See IconMenu for more information.
|
||||
*/
|
||||
export enum VerticalDirection {
|
||||
TopToBottom,
|
||||
BottomToTop,
|
||||
}
|
||||
|
||||
/**
|
||||
* The horizontal direction that the menu is opened/used with.
|
||||
* This direction dictates the general direction sub-menus and
|
||||
* back-to-menus are opened with. For example, a RightToLeft menu
|
||||
* will have sub-menu items indicated with a left pointing chevron
|
||||
* and aligned with the left edge of the menu. Similarly back-to items of a
|
||||
* RightToLeft menu are indicated with a right pointing chevron and
|
||||
* aligned with the right edge of the menu.
|
||||
*
|
||||
* It is also worth noting that a LeftToRight menu would be used for
|
||||
* cases where a menu is opened "near" the left edge of its container.
|
||||
* Likewise, a RightToLeft menu would be used for cases where a menu is opened
|
||||
* "near" the right edge of its container.
|
||||
*
|
||||
* See IconMenu for more information.
|
||||
*/
|
||||
export enum HorizontalDirection {
|
||||
LeftToRight,
|
||||
RightToLeft,
|
||||
}
|
||||
|
||||
export interface MenuProps {
|
||||
menuStyle?: unknown;
|
||||
menuClassName?: string;
|
||||
isVisible?: boolean;
|
||||
preventHideMenu?: boolean;
|
||||
backLabel?: string;
|
||||
headerComponent?: ReactNode;
|
||||
showHeaderDivider?: boolean;
|
||||
activePanelIndex?: number;
|
||||
onVisibilityChange?: (isVisible: boolean) => void;
|
||||
horizontalDirection?: HorizontalDirection;
|
||||
children: ReactNode;
|
||||
}
|
||||
type MenuContextProps = {
|
||||
showSubMenu: (subMenuProps: MenuProps) => void;
|
||||
hideMenu: () => void;
|
||||
addItemPanel: (index: number, label: string) => void;
|
||||
horizontalDirection: HorizontalDirection;
|
||||
activePanelIndex: number;
|
||||
};
|
||||
|
||||
type MenuPathState = {
|
||||
props: MenuProps;
|
||||
activePanelIndex: number;
|
||||
};
|
||||
|
||||
export const MenuContext = createContext<MenuContextProps>(null);
|
||||
|
||||
const Menu = (props: MenuProps) => {
|
||||
const {
|
||||
isVisible,
|
||||
onVisibilityChange,
|
||||
activePanelIndex,
|
||||
preventHideMenu,
|
||||
menuClassName,
|
||||
menuStyle,
|
||||
horizontalDirection = HorizontalDirection.LeftToRight,
|
||||
} = props;
|
||||
|
||||
const [isMenuVisible, setIsMenuVisible] = useState(isVisible);
|
||||
|
||||
// The menuPath is an array consisting of this top Menu and every SubMenu
|
||||
// that has been traversed/opened by the user with the last item in the array
|
||||
// being the current (sub)menu that is currently visible. This allows for the previously
|
||||
// viewed menus to be returned to via the Back button at the top of the menu.
|
||||
const [menuPath, setMenuPath] = useState<Array<MenuPathState>>([
|
||||
{ props, activePanelIndex: activePanelIndex || 0 },
|
||||
]);
|
||||
const [itemPanelLabels, setItemPanelLabels] = useState<Array<string>>([]);
|
||||
|
||||
const hideMenu = useCallback(() => {
|
||||
if (preventHideMenu) {
|
||||
return;
|
||||
}
|
||||
setMenuPath(path => [path[0]]);
|
||||
setItemPanelLabels([]);
|
||||
setIsMenuVisible(false);
|
||||
onVisibilityChange?.(false);
|
||||
}, [preventHideMenu, onVisibilityChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
setIsMenuVisible(isVisible);
|
||||
onVisibilityChange?.(isVisible);
|
||||
} else {
|
||||
hideMenu();
|
||||
}
|
||||
}, [hideMenu, isVisible, onVisibilityChange]);
|
||||
|
||||
const showSubMenu = useCallback((subMenuProps: MenuProps) => {
|
||||
setMenuPath(path => {
|
||||
return [
|
||||
...path,
|
||||
{ props: subMenuProps, activePanelIndex: subMenuProps.activePanelIndex || 0 },
|
||||
];
|
||||
});
|
||||
setItemPanelLabels([]);
|
||||
}, []);
|
||||
|
||||
const addItemPanel = useCallback((index, label) => {
|
||||
setItemPanelLabels(labels => {
|
||||
return [...labels.slice(0, index), label, ...labels.slice(index + 1, labels.length)];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onActivePanelIndexChange = useCallback(index => {
|
||||
setMenuPath(path => {
|
||||
return [
|
||||
...path.slice(0, path.length - 1),
|
||||
{ ...path[path.length - 1], activePanelIndex: index },
|
||||
];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onBackClick = useCallback(() => {
|
||||
setMenuPath(path => [...path.slice(0, path.length - 1)]);
|
||||
setItemPanelLabels([]);
|
||||
}, []);
|
||||
|
||||
const { props: currentMenuProps, activePanelIndex: currentMenuActivePanelIndex } =
|
||||
menuPath[menuPath.length - 1];
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuContext.Provider
|
||||
value={{
|
||||
showSubMenu,
|
||||
hideMenu,
|
||||
addItemPanel,
|
||||
activePanelIndex: currentMenuActivePanelIndex,
|
||||
horizontalDirection,
|
||||
}}
|
||||
>
|
||||
{isMenuVisible && (
|
||||
<div
|
||||
className={classNames(
|
||||
'bg-secondary-dark flex select-none flex-col rounded px-1 py-1.5 text-white opacity-90',
|
||||
menuClassName
|
||||
)}
|
||||
style={menuStyle}
|
||||
>
|
||||
{menuPath.length > 1 && (
|
||||
<BackItem
|
||||
backLabel={menuPath[menuPath.length - 2].props.backLabel}
|
||||
onBackClick={onBackClick}
|
||||
/>
|
||||
)}
|
||||
{itemPanelLabels.length > 1 && (
|
||||
<PanelSelector
|
||||
panelLabels={itemPanelLabels}
|
||||
activeIndex={currentMenuActivePanelIndex}
|
||||
onActiveIndexChange={onActivePanelIndexChange}
|
||||
></PanelSelector>
|
||||
)}
|
||||
{currentMenuProps.headerComponent}
|
||||
{currentMenuProps.showHeaderDivider && <DividerItem />}
|
||||
{currentMenuProps.children}
|
||||
</div>
|
||||
)}
|
||||
</MenuContext.Provider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Menu;
|
||||
31
platform/ui/src/components/AllInOneMenu/PanelSelector.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { ButtonGroup } from '../../components';
|
||||
|
||||
type PanelSelectorProps = {
|
||||
panelLabels: Array<ReactNode>;
|
||||
onActiveIndexChange: (index: number) => void;
|
||||
activeIndex: number;
|
||||
};
|
||||
|
||||
const PanelSelector = ({ panelLabels, onActiveIndexChange, activeIndex }: PanelSelectorProps) => {
|
||||
const getButtons = () => {
|
||||
return panelLabels.map((panelLabel, index) => {
|
||||
return {
|
||||
children: panelLabel,
|
||||
key: index,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-2 my-1 flex justify-center">
|
||||
<ButtonGroup
|
||||
buttons={getButtons()}
|
||||
onActiveIndexChange={onActiveIndexChange}
|
||||
defaultActiveIndex={activeIndex}
|
||||
></ButtonGroup>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PanelSelector;
|
||||
35
platform/ui/src/components/AllInOneMenu/SubMenu.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React, { useCallback, useContext } from 'react';
|
||||
import { MenuContext, MenuProps } from './Menu';
|
||||
import { Icons } from '@ohif/ui-next';
|
||||
export interface SubMenuProps extends MenuProps {
|
||||
itemLabel: string;
|
||||
onClick?: () => void;
|
||||
itemIcon?: string;
|
||||
}
|
||||
|
||||
const SubMenu = (props: SubMenuProps) => {
|
||||
const { showSubMenu } = useContext(MenuContext);
|
||||
|
||||
const onClickHandler = useCallback(() => {
|
||||
showSubMenu(props);
|
||||
props.onClick?.();
|
||||
}, [showSubMenu, props]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="all-in-one-menu-item all-in-one-menu-item-effects flex items-center"
|
||||
onClick={onClickHandler}
|
||||
>
|
||||
{props.itemIcon && (
|
||||
<Icons.ByName
|
||||
name={props.itemIcon}
|
||||
className="mr-2"
|
||||
></Icons.ByName>
|
||||
)}
|
||||
<div className="mr-auto">{props.itemLabel}</div>
|
||||
<Icons.ByName name="content-next"></Icons.ByName>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubMenu;
|
||||
@@ -0,0 +1,129 @@
|
||||
import { DividerItem, HeaderItem, Item, ItemPanel, Menu, SubMenu } from '..';
|
||||
import InputRange from '../../InputRange/index.js';
|
||||
import SwitchButton from '../../SwitchButton/index.js';
|
||||
|
||||
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
|
||||
|
||||
export const argTypes = {
|
||||
component: Menu,
|
||||
title: 'Components/AllInOneMenu',
|
||||
};
|
||||
|
||||
<Meta
|
||||
title="Components/AllInOneMenu"
|
||||
component={Menu}
|
||||
/>
|
||||
|
||||
export const AllInOneMenuTemplate = args => (
|
||||
<div className="w-80">
|
||||
<Menu {...args}>
|
||||
<ItemPanel>
|
||||
<Item
|
||||
label="Item 1"
|
||||
key="0"
|
||||
onClick={() => console.info('Item 1 clicked.')}
|
||||
></Item>
|
||||
<Item
|
||||
label="Item 2"
|
||||
secondaryLabel="Alt item 2"
|
||||
key="1"
|
||||
></Item>
|
||||
<div
|
||||
className="all-in-one-menu-item py-1"
|
||||
style={{ flexBasis: 'content' }}
|
||||
>
|
||||
<div>Arbitrary item component:</div>
|
||||
<InputRange
|
||||
minValue={1}
|
||||
maxValue={10}
|
||||
value={5}
|
||||
onChange={() => {}}
|
||||
></InputRange>
|
||||
</div>
|
||||
<DividerItem key="2"></DividerItem>
|
||||
<SubMenu
|
||||
key="3"
|
||||
backLabel="Back to Level 2"
|
||||
itemLabel="Item 3 opens a sub menu"
|
||||
onClick={() => console.info('Sub menu item clicked.')}
|
||||
showHeaderDivider={true}
|
||||
headerComponent={
|
||||
<div className="all-in-one-menu-item flex w-full justify-center">
|
||||
<SwitchButton label="Switch item in header" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ItemPanel
|
||||
index={0}
|
||||
label="Panel A"
|
||||
>
|
||||
<Item label="Panel A item"></Item>
|
||||
</ItemPanel>
|
||||
<ItemPanel
|
||||
index={1}
|
||||
label="Panel B"
|
||||
>
|
||||
<Item label="Panel B item"></Item>
|
||||
<SubMenu
|
||||
itemLabel="Opens a sub, sub menu"
|
||||
headerComponent={<HeaderItem>Header for scrolling list of items</HeaderItem>}
|
||||
>
|
||||
<ItemPanel label="Sub sub Panel">
|
||||
<Item label="Sub sub menu item 1"></Item>
|
||||
<Item label="Sub sub menu item 2"></Item>
|
||||
<Item label="Sub sub menu item 3"></Item>
|
||||
<Item label="Sub sub menu item 4"></Item>
|
||||
<Item label="Sub sub menu item 5"></Item>
|
||||
<Item label="Sub sub menu item 6"></Item>
|
||||
</ItemPanel>
|
||||
</SubMenu>
|
||||
</ItemPanel>
|
||||
</SubMenu>
|
||||
</ItemPanel>
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
|
||||
<Heading
|
||||
title="AllInOneMenu"
|
||||
componentRelativePath="AllInOneMenu/AllInOneMenu.tsx"
|
||||
/>
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Props](#props)
|
||||
- [Contribute](#contribute)
|
||||
|
||||
## Overview
|
||||
|
||||
AllInOneMenu is a component that renders a menu with various menu items, sub-menus and
|
||||
sub-components. The particular feature of the AllInOneMenu is its ability to render a sub-menu by
|
||||
replacing the parent menu on screen. It also provides the ability to return to the parent menu with
|
||||
a back menu item from the sub-menu. Furthermore, each menu level can be split into item panes - that
|
||||
is several panes of menu items that are switched in and out of view like a tabbed pane.
|
||||
|
||||
<Canvas>
|
||||
<Story
|
||||
name="Overview"
|
||||
args={{
|
||||
className: 'max-h-[210px]',
|
||||
isVisible: true,
|
||||
preventHideMenu: true,
|
||||
backLabel: 'Back to Level 1',
|
||||
onVisibilityChange: (isVisible) => console.info(`The menu visibility: ${isVisible}`),
|
||||
}}
|
||||
>
|
||||
|
||||
{AllInOneMenuTemplate.bind({})}
|
||||
|
||||
</Story>
|
||||
</Canvas>
|
||||
|
||||
## Props
|
||||
|
||||
<ArgsTable of={Menu} />
|
||||
|
||||
## Usage
|
||||
|
||||
## Contribute
|
||||
|
||||
<Footer componentRelativePath="AllInOneMenu/__stories__/AllInOneMenu.stories.mdx" />
|
||||
11
platform/ui/src/components/AllInOneMenu/allInOneMenu.css
Normal file
@@ -0,0 +1,11 @@
|
||||
.all-in-one-menu-item {
|
||||
@apply h-8 px-2 text-[14px] w-full;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.all-in-one-menu-item-effects {
|
||||
@apply cursor-pointer hover:bg-primary-dark hover:rounded;
|
||||
}
|
||||
19
platform/ui/src/components/AllInOneMenu/index.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import Menu, { HorizontalDirection, VerticalDirection } from './Menu';
|
||||
import DividerItem from './DividerItem';
|
||||
import HeaderItem from './HeaderItem';
|
||||
import IconMenu from './IconMenu';
|
||||
import Item from './Item';
|
||||
import ItemPanel from './ItemPanel';
|
||||
import SubMenu from './SubMenu';
|
||||
|
||||
export {
|
||||
Menu,
|
||||
DividerItem,
|
||||
HeaderItem,
|
||||
IconMenu,
|
||||
Item,
|
||||
ItemPanel,
|
||||
SubMenu,
|
||||
HorizontalDirection,
|
||||
VerticalDirection,
|
||||
};
|
||||
150
platform/ui/src/components/Button/Button.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import React, { useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
import * as ButtonEnums from './ButtonEnums';
|
||||
import Tooltip from '../Tooltip/Tooltip';
|
||||
|
||||
const sizeClasses = {
|
||||
[ButtonEnums.size.small]: 'h-[26px] text-[13px]',
|
||||
[ButtonEnums.size.medium]: 'h-[32px] text-[14px]',
|
||||
};
|
||||
|
||||
const layoutClasses =
|
||||
'box-content inline-flex flex-row items-center justify-center gap-[5px] justify center px-[10px] outline-none rounded';
|
||||
|
||||
const baseFontTextClasses = 'leading-[1.2] font-sans text-center whitespace-nowrap';
|
||||
|
||||
const fontTextClasses = {
|
||||
[ButtonEnums.type.primary]: classnames(baseFontTextClasses, 'font-semibold'),
|
||||
[ButtonEnums.type.secondary]: classnames(baseFontTextClasses, 'font-400'),
|
||||
};
|
||||
|
||||
const baseEnabledEffectClasses = 'transition duration-300 ease-in-out focus:outline-none';
|
||||
|
||||
const enabledEffectClasses = {
|
||||
[ButtonEnums.type.primary]: classnames(
|
||||
baseEnabledEffectClasses,
|
||||
'hover:bg-customblue-80 active:bg-customblue-40'
|
||||
),
|
||||
[ButtonEnums.type.secondary]: classnames(
|
||||
baseEnabledEffectClasses,
|
||||
'hover:bg-customblue-50 active:bg-customblue-20'
|
||||
),
|
||||
};
|
||||
|
||||
const baseEnabledClasses = 'text-white';
|
||||
|
||||
const enabledClasses = {
|
||||
[ButtonEnums.type.primary]: classnames(
|
||||
'bg-primary-main',
|
||||
baseEnabledClasses,
|
||||
enabledEffectClasses[ButtonEnums.type.primary]
|
||||
),
|
||||
[ButtonEnums.type.secondary]: classnames(
|
||||
'bg-customblue-30',
|
||||
baseEnabledClasses,
|
||||
enabledEffectClasses[ButtonEnums.type.secondary]
|
||||
),
|
||||
};
|
||||
|
||||
const disabledClasses = 'bg-inputfield-placeholder text-common-light cursor-default';
|
||||
|
||||
const defaults = {
|
||||
color: 'default',
|
||||
disabled: false,
|
||||
rounded: 'small',
|
||||
size: ButtonEnums.size.medium,
|
||||
type: ButtonEnums.type.primary,
|
||||
};
|
||||
|
||||
const Button = ({
|
||||
children = '',
|
||||
size = defaults.size,
|
||||
disabled = defaults.disabled,
|
||||
type = defaults.type,
|
||||
startIcon: startIconProp,
|
||||
endIcon: endIconProp,
|
||||
name,
|
||||
className,
|
||||
onClick = () => {},
|
||||
dataCY,
|
||||
startIconTooltip = null,
|
||||
endIconTooltip = null,
|
||||
}) => {
|
||||
dataCY = dataCY || `${name}-btn`;
|
||||
|
||||
const startIcon = startIconProp && (
|
||||
<>
|
||||
{React.cloneElement(startIconProp, {
|
||||
className: classnames('w-4 h-4 fill-current', startIconProp?.props?.className),
|
||||
})}
|
||||
</>
|
||||
);
|
||||
|
||||
const endIcon = endIconProp && (
|
||||
<>
|
||||
{React.cloneElement(endIconProp, {
|
||||
className: classnames('w-4 h-4 fill-current', endIconProp?.props?.className),
|
||||
})}
|
||||
</>
|
||||
);
|
||||
const buttonElement = useRef(null);
|
||||
|
||||
const handleOnClick = e => {
|
||||
buttonElement.current.blur();
|
||||
if (!disabled) {
|
||||
onClick(e);
|
||||
}
|
||||
};
|
||||
|
||||
const finalClassName = classnames(
|
||||
layoutClasses,
|
||||
fontTextClasses[type],
|
||||
disabled ? disabledClasses : enabledClasses[type],
|
||||
sizeClasses[size],
|
||||
children ? 'min-w-[32px]' : '', // minimum width for buttons with text; icon only button does NOT get a minimum width
|
||||
className
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
className={finalClassName}
|
||||
disabled={disabled}
|
||||
ref={buttonElement}
|
||||
onClick={handleOnClick}
|
||||
data-cy={dataCY}
|
||||
>
|
||||
{startIconTooltip ? <Tooltip content={startIconTooltip}>{startIcon}</Tooltip> : startIcon}
|
||||
{children}
|
||||
{endIconTooltip ? <Tooltip content={endIconTooltip}>{endIcon}</Tooltip> : endIcon}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
Button.propTypes = {
|
||||
/** What is inside the button, can be text or react component */
|
||||
children: PropTypes.node,
|
||||
/** Callback to be called when the button is clicked */
|
||||
onClick: PropTypes.func.isRequired,
|
||||
/** Button size */
|
||||
size: PropTypes.oneOf([ButtonEnums.size.medium, ButtonEnums.size.small]),
|
||||
/** Whether the button should be disabled */
|
||||
disabled: PropTypes.bool,
|
||||
/** Button type */
|
||||
type: PropTypes.oneOf([ButtonEnums.type.primary, ButtonEnums.type.secondary]),
|
||||
name: PropTypes.string,
|
||||
/** Button start icon name - if any icon is specified */
|
||||
startIcon: PropTypes.node,
|
||||
/** Button end icon name - if any icon is specified */
|
||||
endIcon: PropTypes.node,
|
||||
/** Additional TailwindCSS classnames */
|
||||
className: PropTypes.string,
|
||||
/** Tooltip for the start icon */
|
||||
startIconTooltip: PropTypes.node,
|
||||
/** Tooltip for the end icon */
|
||||
endIconTooltip: PropTypes.node,
|
||||
/** Data attribute for testing */
|
||||
dataCY: PropTypes.string,
|
||||
};
|
||||
|
||||
export default Button;
|
||||
15
platform/ui/src/components/Button/ButtonEnums.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
enum type {
|
||||
primary = 'primary',
|
||||
secondary = 'secondary',
|
||||
}
|
||||
enum size {
|
||||
medium = 'medium',
|
||||
small = 'small',
|
||||
}
|
||||
|
||||
enum orientation {
|
||||
horizontal = 'horizontal',
|
||||
vertical = 'vertical',
|
||||
}
|
||||
|
||||
export { type, size, orientation };
|
||||
171
platform/ui/src/components/Button/__stories__/button.stories.mdx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { Button, ButtonEnums } from '../../../components';
|
||||
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
|
||||
import {
|
||||
createComponentTemplate,
|
||||
createStoryMetaSettings,
|
||||
} from '../../../storybook/functions/create-component-story';
|
||||
|
||||
export const argTypes = {
|
||||
component: Button,
|
||||
title: 'Components/Button',
|
||||
};
|
||||
|
||||
<Meta
|
||||
title="Components/Button"
|
||||
component={Button}
|
||||
/>
|
||||
|
||||
export const buttonTemplate = createComponentTemplate(Button);
|
||||
|
||||
<Heading
|
||||
title="Button"
|
||||
componentRelativePath="Button/Button.tsx"
|
||||
/>
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Props](#props)
|
||||
- [Usage](#usage)
|
||||
- [Contribute](#contribute)
|
||||
|
||||
## Overview
|
||||
|
||||
You can use the button component to create a button. It can be used in different ways, the default
|
||||
button is a simple button with text.
|
||||
|
||||
<Canvas>
|
||||
<Story
|
||||
name="Overview"
|
||||
args={{ children: 'Button', color: 'default' }}
|
||||
>
|
||||
{buttonTemplate.bind({})}
|
||||
</Story>
|
||||
</Canvas>
|
||||
|
||||
## Props
|
||||
|
||||
<ArgsTable of={Button} />
|
||||
|
||||
## Usage
|
||||
|
||||
### Types
|
||||
|
||||
There can be different types of buttons: `primary`, and `secondary`.
|
||||
|
||||
<Canvas>
|
||||
<Story name="Types">
|
||||
<div className="flex space-x-2">
|
||||
<Button type={ButtonEnums.type.primary}>Primary Button</Button>
|
||||
<Button type={ButtonEnums.type.secondary}>Secondary Button</Button>
|
||||
</div>
|
||||
</Story>
|
||||
</Canvas>
|
||||
|
||||
### Sizes
|
||||
|
||||
There are different sizes for the button: `small`, and `medium`. The size refers to the button's
|
||||
height.
|
||||
|
||||
<Canvas>
|
||||
<Story name="Sizes">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button size={ButtonEnums.size.small}>Small Button</Button>
|
||||
<Button size={ButtonEnums.size.medium}>Medium Button</Button>
|
||||
</div>
|
||||
</Story>
|
||||
</Canvas>
|
||||
|
||||
### Mixing props
|
||||
|
||||
You can mix different props together to create a button.
|
||||
|
||||
<Canvas>
|
||||
<Story name="Custom">
|
||||
<Button
|
||||
type={ButtonEnums.type.secondary}
|
||||
size={ButtonEnums.size.small}
|
||||
>
|
||||
Small, Secondary Button
|
||||
</Button>
|
||||
</Story>
|
||||
</Canvas>
|
||||
|
||||
### Disabled
|
||||
|
||||
You can disable the button by setting the `disabled` property to true.
|
||||
|
||||
<Canvas>
|
||||
<Story name="Disabled">
|
||||
<Button disabled={true}>Disabled Button</Button>
|
||||
</Story>
|
||||
</Canvas>
|
||||
|
||||
### Start/End Icons
|
||||
|
||||
You can add an icon to the start of the button. It accepts an icon component.
|
||||
|
||||
<Canvas>
|
||||
<Story name="Start Icon">
|
||||
{() => {
|
||||
// svg icon for github
|
||||
const Github = () => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="feather feather-github"
|
||||
>
|
||||
<path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"></path>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
return <Button startIcon={<Github />}>Start Icon Button</Button>;
|
||||
}}
|
||||
</Story>
|
||||
</Canvas>
|
||||
|
||||
End Icon is the same as start icon, but for the end of the button.
|
||||
|
||||
<Canvas>
|
||||
<Story name="End Icon">
|
||||
{() => {
|
||||
// svg icon for github
|
||||
const Github = () => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="feather feather-github"
|
||||
>
|
||||
<path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"></path>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<Button
|
||||
startIcon={<Github />}
|
||||
endIcon={<Github />}
|
||||
>
|
||||
Start and End Icon Button
|
||||
</Button>
|
||||
);
|
||||
}}
|
||||
</Story>
|
||||
</Canvas>
|
||||
|
||||
## Contribute
|
||||
|
||||
<Footer componentRelativePath="Button/__stories__/button.stories.mdx" />
|
||||
5
platform/ui/src/components/Button/index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import Button from './Button';
|
||||
import * as ButtonEnums from './ButtonEnums';
|
||||
|
||||
export { ButtonEnums };
|
||||
export default Button;
|
||||
105
platform/ui/src/components/ButtonGroup/ButtonGroup.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React, { useState, useEffect, cloneElement, Children } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
import { ButtonEnums } from '../../components';
|
||||
|
||||
const ButtonGroup = ({
|
||||
children,
|
||||
className,
|
||||
orientation = ButtonEnums.orientation.horizontal,
|
||||
activeIndex: defaultActiveIndex = 0,
|
||||
onActiveIndexChange,
|
||||
separated = false,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const [activeIndex, setActiveIndex] = useState(defaultActiveIndex);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveIndex(defaultActiveIndex);
|
||||
}, [defaultActiveIndex]);
|
||||
|
||||
const handleButtonClick = index => {
|
||||
setActiveIndex(index);
|
||||
onActiveIndexChange && onActiveIndexChange(index);
|
||||
};
|
||||
|
||||
const orientationClasses = {
|
||||
horizontal: 'flex-row',
|
||||
vertical: 'flex-col',
|
||||
};
|
||||
|
||||
const wrapperClasses = classnames(
|
||||
`${separated ? '' : 'inline-flex'}`,
|
||||
orientationClasses[orientation],
|
||||
className
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classnames(wrapperClasses, ' text-[13px]', {
|
||||
' rounded-md bg-black': !separated,
|
||||
})}
|
||||
>
|
||||
{!separated && (
|
||||
<div className="flex h-[32px] w-full">
|
||||
{Children.map(children, (child, index) => {
|
||||
if (React.isValidElement(child)) {
|
||||
return cloneElement(child, {
|
||||
key: index,
|
||||
className: classnames(
|
||||
'rounded-[4px] px-2 py-1',
|
||||
index === activeIndex
|
||||
? 'bg-customblue-40 text-white'
|
||||
: 'text-primary-active bg-black',
|
||||
child.props.className,
|
||||
child.props.disabled ? 'ohif-disabled' : ''
|
||||
),
|
||||
onClick: e => {
|
||||
child.props.onClick && child.props.onClick(e);
|
||||
handleButtonClick(index);
|
||||
},
|
||||
});
|
||||
}
|
||||
return child;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{separated && (
|
||||
<div className="flex space-x-2">
|
||||
{Children.map(children, (child, index) => {
|
||||
if (React.isValidElement(child)) {
|
||||
return cloneElement(child, {
|
||||
key: index,
|
||||
className: classnames(
|
||||
'rounded-[4px] px-2 py-1',
|
||||
index === activeIndex
|
||||
? 'bg-customblue-40 text-white'
|
||||
: 'text-primary-active bg-black border-secondary-light rounded-[5px] border',
|
||||
child.props.className,
|
||||
child.props.disabled ? 'ohif-disabled' : ''
|
||||
),
|
||||
onClick: e => {
|
||||
child.props.onClick && child.props.onClick(e);
|
||||
handleButtonClick(index);
|
||||
},
|
||||
});
|
||||
}
|
||||
return child;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ButtonGroup.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
orientation: PropTypes.oneOf(Object.values(ButtonEnums.orientation)),
|
||||
activeIndex: PropTypes.number,
|
||||
onActiveIndexChange: PropTypes.func,
|
||||
className: PropTypes.string,
|
||||
disabled: PropTypes.bool,
|
||||
separated: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default ButtonGroup;
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Button, ButtonGroup, ButtonEnums } from '../../../components';
|
||||
|
||||
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
|
||||
import {
|
||||
createComponentTemplate,
|
||||
createStoryMetaSettings,
|
||||
} from '../../../storybook/functions/create-component-story';
|
||||
|
||||
export const argTypes = {
|
||||
component: ButtonGroup,
|
||||
title: 'Components/ButtonGroup',
|
||||
};
|
||||
|
||||
<Meta
|
||||
title="Components/ButtonGroup"
|
||||
component={ButtonGroup}
|
||||
/>
|
||||
|
||||
<Heading
|
||||
title="ButtonGroup"
|
||||
componentRelativePath="ButtonGroup/ButtonGroup.tsx"
|
||||
/>
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Props](#props)
|
||||
- [Usage](#usage)
|
||||
- [Contribute](#contribute)
|
||||
|
||||
## Overview
|
||||
|
||||
ButtonGroup is a container for a group of buttons.
|
||||
|
||||
<Canvas>
|
||||
<Story name="Overview">
|
||||
<ButtonGroup
|
||||
orientation={ButtonEnums.orientation.horizontal}
|
||||
activeIndex={0}
|
||||
onActiveIndexChange={index => console.log(index)}
|
||||
>
|
||||
<button onClick={() => console.log('Button 1111 clicked')}>Button 1</button>
|
||||
<button onClick={() => console.log('Button 222 clicked')}>Button 2</button>
|
||||
<button onClick={() => console.log('Button 33 clicked')}>Button 3</button>
|
||||
</ButtonGroup>
|
||||
</Story>
|
||||
</Canvas>
|
||||
|
||||
## Props
|
||||
|
||||
<ArgsTable of={ButtonGroup} />
|
||||
|
||||
## Usage
|
||||
|
||||
## Contribute
|
||||
|
||||
<Footer componentRelativePath="ButtonGroup/__stories__/buttonGroup.stories.mdx" />
|
||||
2
platform/ui/src/components/ButtonGroup/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
import ButtonGroup from './ButtonGroup';
|
||||
export default ButtonGroup;
|
||||
59
platform/ui/src/components/CheckBox/CheckBox.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Typography } from '../../';
|
||||
import { Icons } from '@ohif/ui-next';
|
||||
|
||||
/**
|
||||
* REACT CheckBox component
|
||||
* it has two props, checked and onChange
|
||||
* checked is a boolean value
|
||||
* onChange is a function that will be called when the checkbox is clicked
|
||||
*
|
||||
* CheckBox is a component that allows you to use as a boolean
|
||||
*/
|
||||
|
||||
const CheckBox: React.FC<{
|
||||
checked: boolean;
|
||||
onChange: (state) => void;
|
||||
className?: string;
|
||||
label: string;
|
||||
labelClassName?: string;
|
||||
labelVariant?: string;
|
||||
}> = ({ checked, onChange, label, labelClassName, labelVariant = 'body', className }) => {
|
||||
const [isChecked, setIsChecked] = useState(checked);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
setIsChecked(!isChecked);
|
||||
onChange(!isChecked);
|
||||
}, [isChecked, onChange]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex cursor-pointer items-center space-x-1 ${className ? className : ''}`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{isChecked ? (
|
||||
<Icons.ByName name="checkbox-checked" />
|
||||
) : (
|
||||
<Icons.ByName name="checkbox-unchecked" />
|
||||
)}
|
||||
<Typography
|
||||
variant={labelVariant ?? 'subtitle'}
|
||||
component="p"
|
||||
className={labelClassName ?? 'text-white'}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
CheckBox.propTypes = {
|
||||
checked: PropTypes.bool,
|
||||
onChange: PropTypes.func,
|
||||
label: PropTypes.string,
|
||||
labelClassName: PropTypes.string,
|
||||
labelVariant: PropTypes.string,
|
||||
};
|
||||
|
||||
export default CheckBox;
|
||||
@@ -0,0 +1,49 @@
|
||||
import CheckBox from '../CheckBox';
|
||||
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
|
||||
|
||||
export const argTypes = {
|
||||
component: CheckBox,
|
||||
title: 'Components/CheckBox',
|
||||
};
|
||||
|
||||
<Meta
|
||||
title="Components/CheckBox"
|
||||
component={CheckBox}
|
||||
/>
|
||||
|
||||
export const CheckBoxTemplate = args => <CheckBox {...args} />;
|
||||
|
||||
<Heading
|
||||
title="CheckBox"
|
||||
componentRelativePath="CheckBox/CheckBox.tsx"
|
||||
/>
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Props](#props)
|
||||
- [Contribute](#contribute)
|
||||
|
||||
## Overview
|
||||
|
||||
CheckBox is a component that allows you to use as a boolean value
|
||||
|
||||
<Canvas>
|
||||
<Story
|
||||
name="Overview"
|
||||
args={{
|
||||
checked: false,
|
||||
onChange: () => console.log('on change callback'),
|
||||
label: 'checkBoxLabel',
|
||||
labelClassName: 'text-black text-sm',
|
||||
}}
|
||||
>
|
||||
{CheckBoxTemplate.bind({})}
|
||||
</Story>
|
||||
</Canvas>
|
||||
|
||||
## Props
|
||||
|
||||
<ArgsTable of={CheckBox} />
|
||||
|
||||
## Contribute
|
||||
|
||||
<Footer componentRelativePath="CheckBox/__stories__/CheckBox.stories.mdx" />
|
||||
3
platform/ui/src/components/CheckBox/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import CheckBox from './CheckBox';
|
||||
|
||||
export default CheckBox;
|
||||
3
platform/ui/src/components/CinePlayer/CinePlayer.css
Normal file
@@ -0,0 +1,3 @@
|
||||
.cine-fps-range-tooltip .tooltip.tooltip-top {
|
||||
bottom: 85% !important;
|
||||
}
|
||||
189
platform/ui/src/components/CinePlayer/CinePlayer.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import debounce from 'lodash.debounce';
|
||||
|
||||
import Tooltip from '../Tooltip';
|
||||
import InputRange from '../InputRange';
|
||||
import { Icons } from '@ohif/ui-next';
|
||||
import './CinePlayer.css';
|
||||
|
||||
export type CinePlayerProps = {
|
||||
className: string;
|
||||
isPlaying: boolean;
|
||||
minFrameRate?: number;
|
||||
maxFrameRate?: number;
|
||||
stepFrameRate?: number;
|
||||
frameRate?: number;
|
||||
onFrameRateChange: (value: number) => void;
|
||||
onPlayPauseChange: (value: boolean) => void;
|
||||
onClose: () => void;
|
||||
updateDynamicInfo?: () => void;
|
||||
dynamicInfo?: {
|
||||
dimensionGroupNumber: number;
|
||||
numDimensionGroups: number;
|
||||
label?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const fpsButtonClassNames =
|
||||
'cursor-pointer text-primary-active active:text-primary-light hover:bg-customblue-300 w-4 flex items-center justify-center';
|
||||
|
||||
const CinePlayer: React.FC<CinePlayerProps> = ({
|
||||
className,
|
||||
isPlaying = false,
|
||||
minFrameRate = 1,
|
||||
maxFrameRate = 90,
|
||||
stepFrameRate = 1,
|
||||
frameRate: defaultFrameRate = 24,
|
||||
onFrameRateChange = () => {},
|
||||
onPlayPauseChange = () => {},
|
||||
onClose = () => {},
|
||||
dynamicInfo = {},
|
||||
updateDynamicInfo,
|
||||
}) => {
|
||||
const isDynamic = !!dynamicInfo?.numDimensionGroups;
|
||||
const [frameRate, setFrameRate] = useState(defaultFrameRate);
|
||||
const debouncedSetFrameRate = useCallback(debounce(onFrameRateChange, 100), [onFrameRateChange]);
|
||||
|
||||
const getPlayPauseIconName = () => (isPlaying ? 'icon-pause' : 'icon-play');
|
||||
|
||||
const handleSetFrameRate = (frameRate: number) => {
|
||||
if (frameRate < minFrameRate || frameRate > maxFrameRate) {
|
||||
return;
|
||||
}
|
||||
setFrameRate(frameRate);
|
||||
debouncedSetFrameRate(frameRate);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setFrameRate(defaultFrameRate);
|
||||
}, [defaultFrameRate]);
|
||||
|
||||
const handleDimensionGroupNumberChange = useCallback(
|
||||
(newGroupNumber: number) => {
|
||||
if (isDynamic && dynamicInfo) {
|
||||
// Here, you would update the component's state or context that controls the current time point index
|
||||
// For demonstration, assuming a hypothetical function that updates the time point index
|
||||
updateDynamicInfo({
|
||||
...dynamicInfo,
|
||||
dimensionGroupNumber: newGroupNumber,
|
||||
});
|
||||
}
|
||||
},
|
||||
[isDynamic, dynamicInfo]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{isDynamic && dynamicInfo && (
|
||||
<InputRange
|
||||
value={dynamicInfo.dimensionGroupNumber}
|
||||
onChange={handleDimensionGroupNumberChange}
|
||||
minValue={1}
|
||||
maxValue={dynamicInfo.numDimensionGroups}
|
||||
step={1}
|
||||
containerClassName="mb-3 w-full"
|
||||
labelClassName="text-xs text-white"
|
||||
leftColor="#3a3f99"
|
||||
rightColor="#3a3f99"
|
||||
trackHeight="4px"
|
||||
thumbColor="#348cfd"
|
||||
thumbColorOuter="#000000"
|
||||
showLabel={false}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={
|
||||
'border-secondary-light/60 bg-primary-dark inline-flex select-none items-center gap-2 rounded border px-2 py-2'
|
||||
}
|
||||
>
|
||||
<Icons.ByName
|
||||
name={getPlayPauseIconName()}
|
||||
className="active:text-primary-light hover:bg-customblue-300 cursor-pointer text-white hover:rounded"
|
||||
onClick={() => onPlayPauseChange(!isPlaying)}
|
||||
data-cy={'cine-player-play-pause'}
|
||||
/>
|
||||
{isDynamic && dynamicInfo && (
|
||||
<div className="min-w-16 max-w-44 flex flex-col text-white">
|
||||
{/* Add Tailwind classes for monospace font and center alignment */}
|
||||
<div className="text-[11px]">
|
||||
<span className="w-2 text-white">{dynamicInfo.dimensionGroupNumber}</span>{' '}
|
||||
<span className="text-aqua-pale">{`/${dynamicInfo.numDimensionGroups}`}</span>
|
||||
</div>
|
||||
<div className="text-aqua-pale text-xs">{dynamicInfo.label}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-secondary-light ml-4 flex h-6 items-stretch gap-1 rounded border">
|
||||
<div
|
||||
className={`${fpsButtonClassNames} rounded-l`}
|
||||
onClick={() => handleSetFrameRate(frameRate - 1)}
|
||||
data-cy={'cine-player-left-arrow'}
|
||||
>
|
||||
<Icons.ChevronLeft />
|
||||
</div>
|
||||
<Tooltip
|
||||
position="top"
|
||||
className="group/fps cine-fps-range-tooltip"
|
||||
tight={true}
|
||||
content={
|
||||
<InputRange
|
||||
containerClassName="h-9 px-2"
|
||||
inputClassName="w-40"
|
||||
value={frameRate}
|
||||
minValue={minFrameRate}
|
||||
maxValue={maxFrameRate}
|
||||
step={stepFrameRate}
|
||||
onChange={handleSetFrameRate}
|
||||
showLabel={false}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<div className="flex-shrink-0 text-center text-sm leading-[22px] text-white">
|
||||
<span className="inline-block text-right">{`${frameRate} `}</span>
|
||||
<span className="text-aqua-pale whitespace-nowrap text-xs">{' FPS'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<div
|
||||
className={`${fpsButtonClassNames} rounded-r`}
|
||||
onClick={() => handleSetFrameRate(frameRate + 1)}
|
||||
data-cy={'cine-player-right-arrow'}
|
||||
>
|
||||
<Icons.ChevronRight />
|
||||
</div>
|
||||
</div>
|
||||
<Icons.Close
|
||||
className="text-primary-active active:text-primary-light hover:bg-customblue-300 cursor-pointer hover:rounded"
|
||||
onClick={onClose}
|
||||
data-cy={'cine-player-close'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
CinePlayer.propTypes = {
|
||||
/** Minimum value for range slider */
|
||||
minFrameRate: PropTypes.number,
|
||||
/** Maximum value for range slider */
|
||||
maxFrameRate: PropTypes.number,
|
||||
/** Increment range slider can "step" in either direction */
|
||||
stepFrameRate: PropTypes.number,
|
||||
frameRate: PropTypes.number,
|
||||
/** 'true' if playing, 'false' if paused */
|
||||
isPlaying: PropTypes.bool.isRequired,
|
||||
onPlayPauseChange: PropTypes.func,
|
||||
onFrameRateChange: PropTypes.func,
|
||||
onClose: PropTypes.func,
|
||||
isDynamic: PropTypes.bool,
|
||||
dynamicInfo: PropTypes.shape({
|
||||
dimensionGroupNumber: PropTypes.number,
|
||||
numDimensionGroups: PropTypes.number,
|
||||
label: PropTypes.string,
|
||||
}),
|
||||
};
|
||||
|
||||
export default CinePlayer;
|
||||
@@ -0,0 +1,57 @@
|
||||
import { CinePlayer } from '../../../components';
|
||||
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
|
||||
|
||||
export const argTypes = {
|
||||
component: CinePlayer,
|
||||
title: 'Components/CinePlayer',
|
||||
};
|
||||
|
||||
<Meta
|
||||
title="Components/CinePlayer"
|
||||
component={CinePlayer}
|
||||
/>
|
||||
|
||||
export const CinePlayerTemplate = args => (
|
||||
<div className="relative h-96 w-96 bg-black">
|
||||
<div className="absolute left-1/2 bottom-3 -translate-x-1/2">
|
||||
<CinePlayer {...args} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
<Heading
|
||||
title="CinePlayer"
|
||||
componentRelativePath="CinePlayer/CinePlayer.tsx"
|
||||
/>
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Props](#props)
|
||||
- [Contribute](#contribute)
|
||||
|
||||
## Overview
|
||||
|
||||
CinePlayer is a component that allows you to use as a boolean value
|
||||
|
||||
<Canvas>
|
||||
<Story
|
||||
name="Overview"
|
||||
args={{
|
||||
isDynamic: true,
|
||||
dynamicInfo: {
|
||||
timePointIndex: 5,
|
||||
numTimePoints: 20,
|
||||
label: 'TemporalPositionIdentifier',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{CinePlayerTemplate.bind({})}
|
||||
</Story>
|
||||
</Canvas>
|
||||
|
||||
## Props
|
||||
|
||||
<ArgsTable of={CinePlayer} />
|
||||
|
||||
## Contribute
|
||||
|
||||
<Footer componentRelativePath="CinePlayer/__stories__/CinePlayer.stories.mdx" />
|
||||
2
platform/ui/src/components/CinePlayer/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
import CinePlayer from './CinePlayer';
|
||||
export default CinePlayer;
|
||||
69
platform/ui/src/components/ContextMenu/ContextMenu.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Typography from '../Typography';
|
||||
import { Icons } from '@ohif/ui-next';
|
||||
|
||||
const ContextMenu = ({ items, ...props }) => {
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!contextMenuRef?.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contextMenu = contextMenuRef.current;
|
||||
|
||||
const boundingClientRect = contextMenu.getBoundingClientRect();
|
||||
if (boundingClientRect.bottom > window.innerHeight) {
|
||||
props.defaultPosition.y = props.defaultPosition.y - boundingClientRect.height;
|
||||
}
|
||||
if (boundingClientRect.right > window.innerWidth) {
|
||||
props.defaultPosition.x = props.defaultPosition.x - boundingClientRect.width;
|
||||
}
|
||||
}, [props.defaultPosition]);
|
||||
|
||||
if (!items) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={contextMenuRef}
|
||||
data-cy="context-menu"
|
||||
className="bg-secondary-dark relative z-50 block w-48 rounded"
|
||||
onContextMenu={e => e.preventDefault()}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
data-cy="context-menu-item"
|
||||
onClick={() => item.action(item, props)}
|
||||
style={{ justifyContent: 'space-between' }}
|
||||
className="hover:bg-primary-dark border-primary-dark flex cursor-pointer items-center border-b px-4 py-3 transition duration-300 last:border-b-0"
|
||||
>
|
||||
<Typography>{item.label}</Typography>
|
||||
{item.iconRight && (
|
||||
<Icons.ByName
|
||||
name={item.iconRight}
|
||||
className="inline text-white"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ContextMenu.propTypes = {
|
||||
defaultPosition: PropTypes.shape({
|
||||
x: PropTypes.number,
|
||||
y: PropTypes.number,
|
||||
}),
|
||||
items: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
label: PropTypes.string.isRequired,
|
||||
action: PropTypes.func.isRequired,
|
||||
})
|
||||
),
|
||||
};
|
||||
|
||||
export default ContextMenu;
|
||||
@@ -0,0 +1,65 @@
|
||||
import ContextMenu from '../ContextMenu';
|
||||
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
|
||||
import { createComponentTemplate } from '../../../storybook/functions/create-component-story';
|
||||
|
||||
export const argTypes = {
|
||||
component: ContextMenu,
|
||||
title: 'Modals/ContextMenu',
|
||||
};
|
||||
|
||||
<Meta
|
||||
title="Modals/ContextMenu"
|
||||
component={ContextMenu}
|
||||
/>
|
||||
|
||||
export const contextMenueTemplate = createComponentTemplate(ContextMenu);
|
||||
|
||||
<Heading
|
||||
title="ContextMenu"
|
||||
componentRelativePath="ContextMenu/ContextMenu.tsx"
|
||||
/>
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Props](#props)
|
||||
- [Contribute](#contribute)
|
||||
|
||||
## Overview
|
||||
|
||||
Context Menu is a component that is used to display a list of options to the user. This component
|
||||
can be used for use cases such as opening a list of options on user right click.
|
||||
|
||||
<Canvas>
|
||||
<Story
|
||||
name="Overview"
|
||||
args={{
|
||||
items: [
|
||||
{
|
||||
label: 'Delete measurement',
|
||||
actionType: 'Delete',
|
||||
action: item => {
|
||||
window.alert(`${item.label} clicked`);
|
||||
},
|
||||
value: {},
|
||||
},
|
||||
{
|
||||
label: 'Add Label',
|
||||
actionType: 'setLabel',
|
||||
action: item => {
|
||||
window.alert(`${item.label} clicked`);
|
||||
},
|
||||
value: {},
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
{contextMenueTemplate.bind({})}
|
||||
</Story>
|
||||
</Canvas>
|
||||
|
||||
## Props
|
||||
|
||||
<ArgsTable of={ContextMenu} />
|
||||
|
||||
## Contribute
|
||||
|
||||
<Footer componentRelativePath="ContextMenu/__stories__/contextMenu.stories.mdx" />
|
||||
1
platform/ui/src/components/ContextMenu/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './ContextMenu';
|
||||
99
platform/ui/src/components/DateRange/DateRange.css
Normal file
@@ -0,0 +1,99 @@
|
||||
/** CONTAINER STYLES **/
|
||||
|
||||
.DateRangePickerInput {
|
||||
@apply flex border-0 bg-transparent;
|
||||
}
|
||||
|
||||
.DateRangePicker_picker {
|
||||
@apply -mt-1;
|
||||
}
|
||||
|
||||
/** INPUT DIV STYLES **/
|
||||
|
||||
.DateInput {
|
||||
background: transparent;
|
||||
@apply flex w-auto flex-1;
|
||||
}
|
||||
|
||||
/** INPUT FIELD COMMON STYLES **/
|
||||
|
||||
.DateInput_input {
|
||||
/* used data:image as background-image because svg import with relative url didn't work */
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><g fill="%236b6b6b" fill-rule="evenodd"><path d="M20 20h-4v-4h4v4zm-6-10h-4v4h4v-4zm6 0h-4v4h4v-4zm-12 6h-4v4h4v-4zm6 0h-4v4h4v-4zm-6-6h-4v4h4v-4zm16-8v22h-24v-22h3v1c0 1.103.897 2 2 2s2-.897 2-2v-1h10v1c0 1.103.897 2 2 2s2-.897 2-2v-1h3zm-2 6h-20v14h20v-14zm-2-7c0-.552-.447-1-1-1s-1 .448-1 1v2c0 .552.447 1 1 1s1-.448 1-1v-2zm-14 2c0 .552-.447 1-1 1s-1-.448-1-1v-2c0-.552.447-1 1-1s1 .448 1 1v2z"/></g></svg>');
|
||||
|
||||
background-size: 14px;
|
||||
background-position: 10px center;
|
||||
@apply bg-no-repeat;
|
||||
}
|
||||
.DateInput_input {
|
||||
@apply border-primary-main mt-2 w-full cursor-pointer appearance-none rounded border-t border-l border-r border-b border-solid bg-black py-2 px-3 pl-8 text-sm font-light leading-tight text-white shadow transition duration-300;
|
||||
}
|
||||
.DateInput_input:hover {
|
||||
@apply border-gray-500;
|
||||
}
|
||||
.DateInput_input:focus {
|
||||
@apply border-gray-500 outline-none;
|
||||
}
|
||||
/** FIRST INPUT STYLES **/
|
||||
.DateInput:first-child .DateInput_input {
|
||||
@apply rounded-r-none;
|
||||
}
|
||||
|
||||
.DateInput:first-child .DateInput_input:hover,
|
||||
.DateInput:first-child .DateInput_input:focus {
|
||||
@apply relative z-10;
|
||||
}
|
||||
|
||||
/** SECOND INPUT STYLES **/
|
||||
.DateInput:last-child .DateInput_input {
|
||||
@apply rounded-l-none;
|
||||
margin-left: -1px;
|
||||
}
|
||||
/** ARROW STYLES **/
|
||||
.DateRangePickerInput_arrow {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
/* SELECT MONTH PICKER */
|
||||
.DateRangePicker_select {
|
||||
@apply text-secondary-active border-common-dark cursor-pointer appearance-none rounded border bg-white py-1 pl-2 pr-5 text-base; /* NEEDED FOR ARROW DOWN */
|
||||
background-image: linear-gradient(45deg, transparent 50%, gray 50%),
|
||||
linear-gradient(135deg, gray 50%, transparent 50%);
|
||||
background-position:
|
||||
calc(100% - 11px) 11px,
|
||||
calc(100% - 6px) calc(11px);
|
||||
background-size:
|
||||
5px 5px,
|
||||
5px 5px;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
/* CALENDAR DAYS */
|
||||
.CalendarDay {
|
||||
@apply rounded-full border-0;
|
||||
}
|
||||
|
||||
.CalendarDay:hover,
|
||||
.CalendarDay__selected,
|
||||
.CalendarDay__selected:active,
|
||||
.CalendarDay__selected:hover {
|
||||
@apply bg-primary-main border-primary-main border-0 text-white;
|
||||
}
|
||||
|
||||
.CalendarDay__blocked_out_of_range:hover {
|
||||
@apply text-common-dark cursor-not-allowed border-0 bg-white;
|
||||
}
|
||||
|
||||
.CalendarDay__selected_span {
|
||||
@apply bg-primary-light border-0;
|
||||
}
|
||||
|
||||
/* MONTH NAVIGATION BUTTONS */
|
||||
.DayPickerNavigation_button__horizontalDefault,
|
||||
.DayPickerNavigation_button__horizontalDefault:hover {
|
||||
@apply border-common-dark text-common-dark;
|
||||
top: 24px;
|
||||
padding: 3px 9px;
|
||||
}
|
||||
.DayPickerNavigation_svg__horizontal {
|
||||
@apply fill-current;
|
||||
}
|
||||
178
platform/ui/src/components/DateRange/DateRange.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import moment from 'moment';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/** REACT DATES */
|
||||
import { DateRangePicker, isInclusivelyBeforeDay } from 'react-dates';
|
||||
import 'react-dates/initialize';
|
||||
import 'react-dates/lib/css/_datepicker.css';
|
||||
import './DateRange.css';
|
||||
|
||||
const renderYearsOptions = () => {
|
||||
const currentYear = moment().year();
|
||||
const options = [];
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const year = currentYear - i;
|
||||
options.push(
|
||||
<option
|
||||
key={year}
|
||||
value={year}
|
||||
>
|
||||
{year}
|
||||
</option>
|
||||
);
|
||||
}
|
||||
|
||||
return options;
|
||||
};
|
||||
|
||||
const DateRange = props => {
|
||||
const { id = '', onChange, startDate = null, endDate = null } = props;
|
||||
const [focusedInput, setFocusedInput] = useState(null);
|
||||
const renderYearsOptionsCallback = useCallback(renderYearsOptions, []);
|
||||
const { t } = useTranslation('DatePicker');
|
||||
const today = moment();
|
||||
const lastWeek = moment().subtract(7, 'day');
|
||||
const lastMonth = moment().subtract(1, 'month');
|
||||
const studyDatePresets = [
|
||||
{
|
||||
text: t('Today'),
|
||||
start: today,
|
||||
end: today,
|
||||
},
|
||||
{
|
||||
text: t('Last 7 days'),
|
||||
start: lastWeek,
|
||||
end: today,
|
||||
},
|
||||
{
|
||||
text: t('Last 30 days'),
|
||||
start: lastMonth,
|
||||
end: today,
|
||||
},
|
||||
];
|
||||
|
||||
const renderDatePresets = () => {
|
||||
return (
|
||||
<div className="PresetDateRangePicker_panel flex justify-between">
|
||||
{studyDatePresets.map(({ text, start, end }) => {
|
||||
return (
|
||||
<button
|
||||
key={text}
|
||||
type="button"
|
||||
className={`bg-primary-main m-0 rounded border-0 py-2 px-3 text-base text-white transition duration-300 hover:opacity-80`}
|
||||
onClick={() =>
|
||||
onChange({
|
||||
startDate: start ? start.format('YYYYMMDD') : undefined,
|
||||
endDate: end ? end.format('YYYYMMDD') : undefined,
|
||||
preset: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
{text}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const renderMonthElement = ({ month, onMonthSelect, onYearSelect }) => {
|
||||
renderMonthElement.propTypes = {
|
||||
month: PropTypes.object,
|
||||
onMonthSelect: PropTypes.func,
|
||||
onYearSelect: PropTypes.func,
|
||||
};
|
||||
|
||||
const handleMonthChange = event => {
|
||||
onMonthSelect(month, event.target.value);
|
||||
};
|
||||
|
||||
const handleYearChange = event => {
|
||||
onYearSelect(month, event.target.value);
|
||||
};
|
||||
|
||||
const handleOnBlur = () => {};
|
||||
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<div className="my-0 mx-1">
|
||||
<select
|
||||
className="DateRangePicker_select"
|
||||
value={month.month()}
|
||||
onChange={handleMonthChange}
|
||||
onBlur={handleOnBlur}
|
||||
>
|
||||
{moment.months().map((label, value) => (
|
||||
<option
|
||||
key={value}
|
||||
value={value}
|
||||
>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="my-0 mx-1">
|
||||
<select
|
||||
className="DateRangePicker_select"
|
||||
value={month.year()}
|
||||
onChange={handleYearChange}
|
||||
onBlur={handleOnBlur}
|
||||
>
|
||||
{renderYearsOptionsCallback()}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Moment
|
||||
const parsedStartDate = startDate ? moment(startDate, 'YYYYMMDD') : null;
|
||||
const parsedEndDate = endDate ? moment(endDate, 'YYYYMMDD') : null;
|
||||
|
||||
return (
|
||||
<DateRangePicker
|
||||
/** REQUIRED */
|
||||
startDate={parsedStartDate}
|
||||
startDateId={`date-range-${id}-start-date`}
|
||||
endDate={parsedEndDate}
|
||||
endDateId={`date-range-${id}-end-date`}
|
||||
onDatesChange={({ startDate: newStartDate, endDate: newEndDate }) => {
|
||||
onChange({
|
||||
startDate: newStartDate ? newStartDate.format('YYYYMMDD') : undefined,
|
||||
endDate: newEndDate ? newEndDate.format('YYYYMMDD') : undefined,
|
||||
});
|
||||
}}
|
||||
focusedInput={focusedInput}
|
||||
onFocusChange={updatedVal => setFocusedInput(updatedVal)}
|
||||
/** OPTIONAL */
|
||||
renderCalendarInfo={renderDatePresets}
|
||||
renderMonthElement={renderMonthElement}
|
||||
startDatePlaceholderText={t('Start Date')}
|
||||
endDatePlaceholderText={t('End Date')}
|
||||
phrases={{
|
||||
closeDatePicker: t('Close'),
|
||||
clearDates: t('Clear dates'),
|
||||
}}
|
||||
isOutsideRange={day => !isInclusivelyBeforeDay(day, moment())}
|
||||
hideKeyboardShortcutsPanel={true}
|
||||
numberOfMonths={1}
|
||||
showClearDates={false}
|
||||
anchorDirection="left"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
DateRange.propTypes = {
|
||||
id: PropTypes.string,
|
||||
/** YYYYMMDD (19921022) */
|
||||
startDate: PropTypes.string,
|
||||
/** YYYYMMDD (19921022) */
|
||||
endDate: PropTypes.string,
|
||||
/** Callback that received { startDate: string(YYYYMMDD), endDate: string(YYYYMMDD)} */
|
||||
onChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default DateRange;
|
||||
@@ -0,0 +1,52 @@
|
||||
import DateRange from '../DateRange';
|
||||
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
|
||||
|
||||
export const argTypes = {
|
||||
component: DateRange,
|
||||
title: 'Components/DateRange',
|
||||
};
|
||||
|
||||
<Meta
|
||||
title="Components/DateRange"
|
||||
component={DateRange}
|
||||
/>
|
||||
|
||||
export const DateRangeTemplate = args => (
|
||||
<div className="h-96">
|
||||
<DateRange {...args} />
|
||||
</div>
|
||||
);
|
||||
|
||||
<Heading
|
||||
title="DateRange"
|
||||
componentRelativePath="DateRange/DateRange.tsx"
|
||||
/>
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Props](#props)
|
||||
- [Contribute](#contribute)
|
||||
|
||||
## Overview
|
||||
|
||||
DateRange is a component that allows you to select a range of dates.
|
||||
|
||||
<Canvas>
|
||||
<Story
|
||||
name="Overview"
|
||||
args={{
|
||||
id: 'date-range-1',
|
||||
startDate: '1990-05-01',
|
||||
endDate: '2022-01-01',
|
||||
}}
|
||||
>
|
||||
{DateRangeTemplate.bind({})}
|
||||
</Story>
|
||||
</Canvas>
|
||||
|
||||
## Props
|
||||
|
||||
<ArgsTable of={DateRange} />
|
||||
|
||||
## Contribute
|
||||
|
||||
<Footer componentRelativePath="DateRange/__stories__/dateRange.stories.mdx" />
|
||||
3
platform/ui/src/components/DateRange/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import DateRange from './DateRange';
|
||||
|
||||
export default DateRange;
|
||||
27
platform/ui/src/components/Dialog/Body.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import Typography from '../Typography';
|
||||
|
||||
const Body = ({ text, className }) => {
|
||||
const theme = 'bg-primary-dark';
|
||||
return (
|
||||
<div className={classNames('relative flex-auto', theme, className)}>
|
||||
<Typography
|
||||
variant="inherit"
|
||||
color="initial"
|
||||
className="text-[14px] !leading-[1.2]"
|
||||
>
|
||||
{text}
|
||||
</Typography>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Body.propTypes = {
|
||||
text: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
export default Body;
|
||||
84
platform/ui/src/components/Dialog/Dialog.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import Footer from './Footer';
|
||||
import Body from './Body';
|
||||
import Header from './Header';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const Dialog = ({
|
||||
title,
|
||||
text,
|
||||
onClose,
|
||||
noCloseButton,
|
||||
actions,
|
||||
onShow,
|
||||
onSubmit,
|
||||
header: HeaderComponent = Header,
|
||||
body: BodyComponent = Body,
|
||||
footer: FooterComponent = Footer,
|
||||
value: defaultValue = {},
|
||||
}) => {
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
|
||||
const theme = 'bg-primary-dark';
|
||||
const flex = 'flex flex-col';
|
||||
const border = 'border-0 rounded';
|
||||
const outline = 'outline-none focus:outline-none';
|
||||
const position = 'relative';
|
||||
const width = 'w-full';
|
||||
const padding = 'px-[20px] pb-[20px] pt-[13px]';
|
||||
|
||||
useEffect(() => {
|
||||
if (onShow) {
|
||||
onShow();
|
||||
}
|
||||
}, [onShow]);
|
||||
|
||||
return (
|
||||
<div className={classNames(theme, flex, border, outline, position, width, padding)}>
|
||||
<HeaderComponent
|
||||
title={title}
|
||||
noCloseButton={noCloseButton}
|
||||
onClose={onClose}
|
||||
value={value}
|
||||
setValue={setValue}
|
||||
/>
|
||||
<BodyComponent
|
||||
text={text}
|
||||
value={value}
|
||||
setValue={setValue}
|
||||
/>
|
||||
<FooterComponent
|
||||
actions={actions}
|
||||
onSubmit={onSubmit}
|
||||
value={value}
|
||||
setValue={setValue}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Dialog.propTypes = {
|
||||
title: PropTypes.string,
|
||||
text: PropTypes.string,
|
||||
onClose: PropTypes.func,
|
||||
noCloseButton: PropTypes.bool,
|
||||
header: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
|
||||
body: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
|
||||
footer: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
value: PropTypes.object,
|
||||
actions: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
text: PropTypes.string.isRequired,
|
||||
value: PropTypes.any,
|
||||
type: PropTypes.oneOf(['primary', 'secondary', 'cancel']).isRequired,
|
||||
})
|
||||
).isRequired,
|
||||
onShow: PropTypes.func,
|
||||
};
|
||||
|
||||
export default Dialog;
|
||||
50
platform/ui/src/components/Dialog/Footer.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import Button, { ButtonEnums } from '../Button';
|
||||
|
||||
const Footer = ({ actions = [], className, onSubmit = () => {}, value }) => {
|
||||
const flex = 'flex items-center justify-end';
|
||||
const padding = 'pt-[20px]';
|
||||
|
||||
return (
|
||||
<div className={classNames(flex, padding, className)}>
|
||||
{actions?.map((action, index) => {
|
||||
const isFirst = index === 0;
|
||||
|
||||
const onClickHandler = event => onSubmit({ action, value, event });
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={index}
|
||||
name={action.text}
|
||||
className={classNames({ 'ml-2': !isFirst }, action.classes)}
|
||||
type={action.type}
|
||||
onClick={onClickHandler}
|
||||
>
|
||||
{action.text}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
Footer.propTypes = {
|
||||
className: PropTypes.string,
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
actions: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
text: PropTypes.string.isRequired,
|
||||
value: PropTypes.any,
|
||||
type: PropTypes.oneOf([ButtonEnums.type.primary, ButtonEnums.type.secondary]).isRequired,
|
||||
classes: PropTypes.arrayOf(PropTypes.string),
|
||||
})
|
||||
).isRequired,
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
48
platform/ui/src/components/Dialog/Header.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import Typography from '../Typography';
|
||||
import { Icons } from '@ohif/ui-next';
|
||||
|
||||
const CloseButton = ({ onClick }) => {
|
||||
return (
|
||||
<Icons.Close
|
||||
data-cy="close-button"
|
||||
onClick={onClick}
|
||||
className="text-primary-active cursor-pointer"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
CloseButton.propTypes = {
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
const Header = ({ title, noCloseButton = false, onClose }) => {
|
||||
const theme = 'bg-primary-dark';
|
||||
const flex = 'flex items-center justify-between';
|
||||
const padding = 'pb-[20px]';
|
||||
|
||||
return (
|
||||
<div className={classNames(theme, flex, padding)}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
color="primaryLight"
|
||||
className="!leading-[1.2]"
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
{!noCloseButton && <CloseButton onClick={onClose} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Header.propTypes = {
|
||||
className: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
noCloseButton: PropTypes.bool,
|
||||
onClose: PropTypes.func,
|
||||
};
|
||||
|
||||
export default Header;
|
||||
@@ -0,0 +1,73 @@
|
||||
import Dialog from '../Dialog';
|
||||
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
|
||||
import { createComponentTemplate } from '../../../storybook/functions/create-component-story';
|
||||
|
||||
export const argTypes = {
|
||||
component: Dialog,
|
||||
title: 'Modals/Dialog',
|
||||
};
|
||||
|
||||
<Meta
|
||||
title="Modals/Dialog"
|
||||
component={Dialog}
|
||||
/>
|
||||
|
||||
export const DialogTemplate = createComponentTemplate(Dialog);
|
||||
|
||||
<Heading
|
||||
title="Dialog"
|
||||
componentRelativePath="Dialog/Dialog.tsx"
|
||||
/>
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Props](#props)
|
||||
- [Usage](#usage)
|
||||
- [Contribute](#contribute)
|
||||
|
||||
## Overview
|
||||
|
||||
Dialog is a modal dialog component which enabled to show a modal dialog.
|
||||
|
||||
<Canvas>
|
||||
<Story name="Overview">{DialogTemplate.bind({})}</Story>
|
||||
</Canvas>
|
||||
|
||||
## Props
|
||||
|
||||
<ArgsTable of={Dialog} />
|
||||
|
||||
## Usage
|
||||
|
||||
It can be used to show a modal dialog to submit a form or show a message.
|
||||
|
||||
<Canvas>
|
||||
<Story
|
||||
name="Submit"
|
||||
args={{
|
||||
title: 'Dialog Title',
|
||||
text: 'Dialog Text',
|
||||
onClose: () => {
|
||||
window.alert('Dialog closed');
|
||||
},
|
||||
noCloseButton: false,
|
||||
actions: [
|
||||
{
|
||||
id: 'cancel',
|
||||
text: 'Cancel',
|
||||
type: 'cancel',
|
||||
},
|
||||
{
|
||||
id: 'submit',
|
||||
text: 'Submit',
|
||||
type: 'primary',
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
{DialogTemplate.bind({})}
|
||||
</Story>
|
||||
</Canvas>
|
||||
|
||||
## Contribute
|
||||
|
||||
<Footer componentRelativePath="Dialog/__stories__/dialog.stories.mdx" />
|
||||
2
platform/ui/src/components/Dialog/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
import Dialog from './Dialog';
|
||||
export default Dialog;
|
||||
@@ -0,0 +1,71 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import PortalTooltip from '../Tooltip/PortalTooltip';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Icons } from '@ohif/ui-next';
|
||||
/**
|
||||
* Displays a tooltip with a list of messages of a displaySet
|
||||
* @param param0
|
||||
* @returns
|
||||
*/
|
||||
const DisplaySetMessageListTooltip = ({ messages, id }): React.ReactNode => {
|
||||
const { t } = useTranslation('Messages');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
if (messages?.size()) {
|
||||
return (
|
||||
<>
|
||||
<Icons.ByName
|
||||
id={id}
|
||||
onMouseOver={() => setIsOpen(true)}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
onMouseOut={() => setIsOpen(false)}
|
||||
onBlur={() => setIsOpen(false)}
|
||||
name="status-alert-warning"
|
||||
/>
|
||||
<PortalTooltip
|
||||
active={isOpen}
|
||||
position="right"
|
||||
arrow="center"
|
||||
parent={`#${id}`}
|
||||
>
|
||||
<div className="bg-primary-dark border-secondary-light max-w-64 rounded border text-left text-base text-white">
|
||||
<div
|
||||
className="break-normal text-base font-bold text-blue-300"
|
||||
style={{
|
||||
marginLeft: '12px',
|
||||
marginTop: '12px',
|
||||
}}
|
||||
>
|
||||
{t('Display Set Messages')}
|
||||
</div>
|
||||
<ol
|
||||
style={{
|
||||
marginLeft: '12px',
|
||||
marginRight: '12px',
|
||||
}}
|
||||
>
|
||||
{messages.messages.map((message, index) => (
|
||||
<li
|
||||
style={{
|
||||
marginTop: '6px',
|
||||
marginBottom: '6px',
|
||||
}}
|
||||
key={index}
|
||||
>
|
||||
{index + 1}. {t(message.id)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</PortalTooltip>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <></>;
|
||||
};
|
||||
|
||||
DisplaySetMessageListTooltip.propTypes = {
|
||||
messages: PropTypes.object,
|
||||
};
|
||||
|
||||
export default DisplaySetMessageListTooltip;
|
||||
@@ -0,0 +1,2 @@
|
||||
import DisplaySetMessageListTooltip from './DisplaySetMessageListTooltip';
|
||||
export default DisplaySetMessageListTooltip;
|
||||
221
platform/ui/src/components/Dropdown/Dropdown.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import React, { useEffect, useCallback, useState, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import Typography from '../Typography';
|
||||
import { Icons } from '@ohif/ui-next';
|
||||
|
||||
const borderStyle = 'border-b last:border-b-0 border-secondary-main';
|
||||
|
||||
const Dropdown = ({
|
||||
id,
|
||||
children,
|
||||
showDropdownIcon = true,
|
||||
list,
|
||||
itemsClassName,
|
||||
titleClassName,
|
||||
showBorders = true,
|
||||
alignment,
|
||||
// By default the max characters per line is the longest title
|
||||
// if you wish to override this, you can pass in a number
|
||||
maxCharactersPerLine = 20,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const elementRef = useRef(null);
|
||||
const dropdownRef = useRef(null);
|
||||
const [coords, setCoords] = useState({ x: 0, y: 0 });
|
||||
|
||||
// choose the max characters per line based on the longest title
|
||||
const longestTitle = list.reduce((acc, item) => {
|
||||
if (item.title.length > acc) {
|
||||
return item.title.length;
|
||||
}
|
||||
return acc;
|
||||
}, 0);
|
||||
|
||||
maxCharactersPerLine = maxCharactersPerLine ?? longestTitle;
|
||||
|
||||
const DropdownItem = useCallback(
|
||||
({ id, title, icon, onClick }) => {
|
||||
// Split the title into lines of length maxCharactersPerLine
|
||||
const lines = [];
|
||||
for (let i = 0; i < title.length; i += maxCharactersPerLine) {
|
||||
lines.push(title.substring(i, i + maxCharactersPerLine));
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={title}
|
||||
className={classnames(
|
||||
'hover:bg-secondary-main flex cursor-pointer items-center px-4 py-2 transition duration-300',
|
||||
titleClassName,
|
||||
showBorders && borderStyle
|
||||
)}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onClick();
|
||||
}}
|
||||
data-cy={id}
|
||||
>
|
||||
{!!icon && (
|
||||
<Icons.ByName
|
||||
name={icon}
|
||||
className="mr-2 w-4 text-white"
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{title.length > maxCharactersPerLine && (
|
||||
<div>
|
||||
{lines.map((line, index) => (
|
||||
<Typography
|
||||
key={index}
|
||||
className={itemsClassName}
|
||||
>
|
||||
{line}
|
||||
</Typography>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{title.length <= maxCharactersPerLine && (
|
||||
<Typography className={itemsClassName}>{title}</Typography>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[maxCharactersPerLine, itemsClassName, titleClassName, showBorders]
|
||||
);
|
||||
|
||||
const renderTitleElement = () => {
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
{children}
|
||||
{showDropdownIcon && (
|
||||
<Icons.ByName
|
||||
name="chevron-down"
|
||||
className="ml-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const toggleList = () => {
|
||||
setOpen(s => !s);
|
||||
};
|
||||
|
||||
const handleClick = e => {
|
||||
if (elementRef.current && !elementRef.current.contains(e.target)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (elementRef.current && dropdownRef.current) {
|
||||
const triggerRect = elementRef.current.getBoundingClientRect();
|
||||
const dropdownRect = dropdownRef.current.getBoundingClientRect();
|
||||
let x, y;
|
||||
|
||||
switch (alignment) {
|
||||
case 'right':
|
||||
x = triggerRect.right + window.scrollX - dropdownRect.width;
|
||||
y = triggerRect.bottom + window.scrollY;
|
||||
break;
|
||||
case 'left':
|
||||
x = triggerRect.left + window.scrollX;
|
||||
y = triggerRect.bottom + window.scrollY;
|
||||
break;
|
||||
default:
|
||||
x = triggerRect.left + window.scrollX;
|
||||
y = triggerRect.bottom + window.scrollY;
|
||||
break;
|
||||
}
|
||||
setCoords({ x, y });
|
||||
}
|
||||
}, [open, alignment, elementRef.current, dropdownRef.current]);
|
||||
|
||||
const renderList = () => {
|
||||
const portalElement = document.getElementById('react-portal');
|
||||
|
||||
const listElement = (
|
||||
<div
|
||||
className={classnames(
|
||||
'top-100 border-secondary-main w-max-content absolute mt-2 transform rounded border bg-black shadow transition duration-300',
|
||||
{
|
||||
'right-0 origin-top-right': alignment === 'right',
|
||||
'left-0 origin-top-left': alignment === 'left',
|
||||
}
|
||||
)}
|
||||
ref={dropdownRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: `${coords.y}px`,
|
||||
left: open ? `${coords.x}px` : -999999,
|
||||
zIndex: 9999,
|
||||
}}
|
||||
data-cy={`${id}-dropdown`}
|
||||
>
|
||||
{list.map((item, idx) => (
|
||||
<DropdownItem
|
||||
id={item.id}
|
||||
title={item.title}
|
||||
icon={item.icon}
|
||||
onClick={item.onClick}
|
||||
key={idx}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
return ReactDOM.createPortal(listElement, portalElement);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('click', handleClick);
|
||||
|
||||
if (!open) {
|
||||
document.removeEventListener('click', handleClick);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-cy="dropdown"
|
||||
ref={elementRef}
|
||||
className="relative"
|
||||
>
|
||||
<div
|
||||
className="flex cursor-pointer items-center"
|
||||
onClick={toggleList}
|
||||
>
|
||||
{renderTitleElement()}
|
||||
</div>
|
||||
|
||||
{renderList()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Dropdown.propTypes = {
|
||||
id: PropTypes.string,
|
||||
children: PropTypes.node.isRequired,
|
||||
showDropdownIcon: PropTypes.bool,
|
||||
titleClassName: PropTypes.string,
|
||||
/** Items to render in the select's drop down */
|
||||
list: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
title: PropTypes.string.isRequired,
|
||||
icon: PropTypes.string,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
})
|
||||
).isRequired,
|
||||
alignment: PropTypes.oneOf(['left', 'right']),
|
||||
maxCharactersPerLine: PropTypes.number,
|
||||
showBorders: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default Dropdown;
|
||||
@@ -0,0 +1,84 @@
|
||||
import Dropdown from '../Dropdown';
|
||||
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
|
||||
import { createComponentTemplate } from '../../../storybook/functions/create-component-story';
|
||||
|
||||
export const argTypes = {
|
||||
component: Dropdown,
|
||||
title: 'Components/Dropdown',
|
||||
};
|
||||
|
||||
<Meta
|
||||
title="Components/Dropdown"
|
||||
component={Dropdown}
|
||||
/>
|
||||
|
||||
export const DropdownTemplate = args => (
|
||||
// Todo: this should not set a background
|
||||
<div className="flex h-32">
|
||||
<Dropdown {...args} />
|
||||
</div>
|
||||
);
|
||||
|
||||
<Heading
|
||||
title="Dropdown"
|
||||
componentRelativePath="Dropdown/Dropdown.tsx"
|
||||
/>
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Props](#props)
|
||||
- [Usage](#usage)
|
||||
- [Contribute](#contribute)
|
||||
|
||||
## Overview
|
||||
|
||||
Dropdown is a modal Dropdown component which enabled to show a modal Dropdown.
|
||||
|
||||
<Canvas>
|
||||
<Story
|
||||
name="Overview"
|
||||
args={{ id: 'dropdown-d', list: [] }}
|
||||
>
|
||||
{DropdownTemplate.bind({})}
|
||||
</Story>
|
||||
</Canvas>
|
||||
|
||||
## Props
|
||||
|
||||
<ArgsTable of={Dropdown} />
|
||||
|
||||
## Usage
|
||||
|
||||
You can list the items in the dropdown.
|
||||
|
||||
<Canvas>
|
||||
<Story
|
||||
name="List"
|
||||
args={{
|
||||
id: 'dropdown-1',
|
||||
children: <div className="text-black">Drop Down</div>,
|
||||
showDropdownIcon: true,
|
||||
list: [
|
||||
{
|
||||
title: 'Item 1',
|
||||
icon: 'clipboard',
|
||||
onClick: () => {
|
||||
alert('Item 1 clicked');
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Item 2',
|
||||
icon: 'tracked',
|
||||
onClick: () => {
|
||||
alert('Item 2 clicked');
|
||||
},
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
{DropdownTemplate.bind({})}
|
||||
</Story>
|
||||
</Canvas>
|
||||
|
||||
## Contribute
|
||||
|
||||
<Footer componentRelativePath="Dropdown/__stories__/dropdown.stories.mdx" />
|
||||
1
platform/ui/src/components/Dropdown/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './Dropdown';
|
||||
28
platform/ui/src/components/EmptyStudies/EmptyStudies.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import Typography from '../Typography';
|
||||
import { Icons } from '@ohif/ui-next';
|
||||
// TODO: Add loading spinner to OHIF + use it here.
|
||||
const EmptyStudies = ({ className = '' }) => {
|
||||
const { t } = useTranslation('StudyList');
|
||||
return (
|
||||
<div className={classnames('inline-flex flex-col items-center', className)}>
|
||||
<Icons.Magnifier className="mb-4" />
|
||||
<Typography
|
||||
className="text-primary-light"
|
||||
variant="h5"
|
||||
>
|
||||
{t('No studies available')}
|
||||
</Typography>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
EmptyStudies.propTypes = {
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
export default EmptyStudies;
|
||||
2
platform/ui/src/components/EmptyStudies/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
import EmptyStudies from './EmptyStudies';
|
||||
export default EmptyStudies;
|
||||
124
platform/ui/src/components/ErrorBoundary/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ErrorBoundary as ReactErrorBoundary } from 'react-error-boundary';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import i18n from 'i18next';
|
||||
|
||||
import Modal from '../Modal';
|
||||
import IconButton from '../IconButton';
|
||||
import { Icons } from '@ohif/ui-next';
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
const DefaultFallback = ({ error, context, resetErrorBoundary = () => {}, fallbackRoute }) => {
|
||||
const { t } = useTranslation('ErrorBoundary');
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const title = `${t('Something went wrong')}${!isProduction && ` ${t('in')} ${context}`}.`;
|
||||
const subtitle = t('Sorry, something went wrong there. Try again.');
|
||||
return (
|
||||
<div
|
||||
className="ErrorFallback bg-primary-dark h-full w-full"
|
||||
role="alert"
|
||||
>
|
||||
<p className="text-primary-light text-xl">{title}</p>
|
||||
<p className="text-primary-light text-base">{subtitle}</p>
|
||||
{!isProduction && (
|
||||
<div className="bg-secondary-dark mt-5 space-y-2 rounded-md p-5 font-mono">
|
||||
<p className="text-primary-light">
|
||||
{t('Context')}: {context}
|
||||
</p>
|
||||
<p className="text-primary-light">
|
||||
{t('Error Message')}: {error.message}
|
||||
</p>
|
||||
|
||||
<IconButton
|
||||
variant="contained"
|
||||
color="inherit"
|
||||
size="initial"
|
||||
className="text-primary-active"
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
>
|
||||
<React.Fragment>
|
||||
<div>{t('Stack Trace')}</div>
|
||||
<Icons.ChevronOpen />
|
||||
</React.Fragment>
|
||||
</IconButton>
|
||||
|
||||
{showDetails && (
|
||||
<pre className="text-primary-light whitespace-pre-wrap px-4">Stack: {error.stack}</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
DefaultFallback.propTypes = {
|
||||
error: PropTypes.object.isRequired,
|
||||
resetErrorBoundary: PropTypes.func,
|
||||
componentStack: PropTypes.string,
|
||||
};
|
||||
|
||||
const ErrorBoundary = ({
|
||||
context = 'OHIF',
|
||||
onReset = () => {},
|
||||
onError = () => {},
|
||||
fallbackComponent: FallbackComponent = DefaultFallback,
|
||||
children,
|
||||
fallbackRoute = null,
|
||||
isPage,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
|
||||
const onErrorHandler = (error, componentStack) => {
|
||||
console.error(`${context} Error Boundary`, error, componentStack, context);
|
||||
onError(error, componentStack, context);
|
||||
};
|
||||
|
||||
const onResetHandler = (...args) => onReset(...args);
|
||||
|
||||
const withModal = Component => props => (
|
||||
<Modal
|
||||
closeButton
|
||||
shouldCloseOnEsc
|
||||
isOpen={isOpen}
|
||||
title={i18n.t('ErrorBoundary:Something went wrong')}
|
||||
onClose={() => {
|
||||
setIsOpen(false);
|
||||
if (fallbackRoute && typeof window !== 'undefined') {
|
||||
window.location = fallbackRoute;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Component {...props} />
|
||||
</Modal>
|
||||
);
|
||||
|
||||
const Fallback = isPage ? FallbackComponent : withModal(FallbackComponent);
|
||||
|
||||
return (
|
||||
<ReactErrorBoundary
|
||||
fallbackRender={props => (
|
||||
<Fallback
|
||||
{...props}
|
||||
context={context}
|
||||
fallbackRoute={fallbackRoute}
|
||||
/>
|
||||
)}
|
||||
onReset={onResetHandler}
|
||||
onError={onErrorHandler}
|
||||
>
|
||||
{children}
|
||||
</ReactErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
ErrorBoundary.propTypes = {
|
||||
context: PropTypes.string,
|
||||
onReset: PropTypes.func,
|
||||
onError: PropTypes.func,
|
||||
fallbackComponent: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
|
||||
children: PropTypes.node.isRequired,
|
||||
fallbackRoute: PropTypes.string,
|
||||
};
|
||||
|
||||
export default ErrorBoundary;
|
||||
2
platform/ui/src/components/ErrorBoundary/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
import ErrorBoundary from './ErrorBoundary';
|
||||
export default ErrorBoundary;
|
||||