Merge pull request #1836 from OHIF/feat/ohif-179
OHIF-179: UI for successful posting of SR data to PACS.
This commit is contained in:
commit
4d2774c87b
@ -5,9 +5,6 @@ import { DicomMetadataStore, DICOMSR } from '@ohif/core';
|
|||||||
import { useDebounce } from '@hooks';
|
import { useDebounce } from '@hooks';
|
||||||
import ActionButtons from './ActionButtons';
|
import ActionButtons from './ActionButtons';
|
||||||
import { useTrackedMeasurements } from '../../getContextModule';
|
import { useTrackedMeasurements } from '../../getContextModule';
|
||||||
import cornerstoneTools from 'cornerstone-tools';
|
|
||||||
import cornerstone from 'cornerstone-core';
|
|
||||||
import dcmjs from 'dcmjs';
|
|
||||||
|
|
||||||
const DISPLAY_STUDY_SUMMARY_INITIAL_VALUE = {
|
const DISPLAY_STUDY_SUMMARY_INITIAL_VALUE = {
|
||||||
key: undefined, //
|
key: undefined, //
|
||||||
@ -24,7 +21,7 @@ function PanelMeasurementTableTracking({ servicesManager, extensionManager }) {
|
|||||||
measurementChangeTimestamp,
|
measurementChangeTimestamp,
|
||||||
200
|
200
|
||||||
);
|
);
|
||||||
const { MeasurementService, DisplaySetService } = servicesManager.services;
|
const { MeasurementService, UINotificationService, UIDialogService, DisplaySetService } = servicesManager.services;
|
||||||
const [
|
const [
|
||||||
trackedMeasurements,
|
trackedMeasurements,
|
||||||
sendTrackedMeasurementsEvent,
|
sendTrackedMeasurementsEvent,
|
||||||
@ -118,27 +115,44 @@ function PanelMeasurementTableTracking({ servicesManager, extensionManager }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const createReport = async () => {
|
const createReport = async () => {
|
||||||
const measurements = MeasurementService.getMeasurements();
|
const loadingDialogId = UIDialogService.create({
|
||||||
const trackedMeasurements = measurements.filter(
|
showOverlay: true,
|
||||||
m =>
|
isDraggable: false,
|
||||||
trackedStudy === m.referenceStudyUID &&
|
centralize: true,
|
||||||
trackedSeries.includes(m.referenceSeriesUID)
|
// TODO: Create a loading indicator component + zeplin design?
|
||||||
);
|
content: () => <div className="text-primary-active">Loading...</div>
|
||||||
|
});
|
||||||
|
|
||||||
const dataSources = extensionManager.getDataSources();
|
try {
|
||||||
// TODO -> Eventually deal with multiple dataSources.
|
const measurements = MeasurementService.getMeasurements();
|
||||||
// Would need some way of saying which one is the "push" dataSource
|
const trackedMeasurements = measurements.filter(
|
||||||
const dataSource = dataSources[0];
|
m =>
|
||||||
|
trackedStudy === m.referenceStudyUID &&
|
||||||
|
trackedSeries.includes(m.referenceSeriesUID)
|
||||||
|
);
|
||||||
|
|
||||||
DICOMSR.storeMeasurements(
|
const dataSources = extensionManager.getDataSources();
|
||||||
trackedMeasurements,
|
// TODO -> Eventually deal with multiple dataSources.
|
||||||
dataSource,
|
// Would need some way of saying which one is the "push" dataSource
|
||||||
naturalizedReport => {
|
const dataSource = dataSources[0];
|
||||||
DisplaySetService.makeDisplaySets([naturalizedReport], {
|
|
||||||
madeInClient: true,
|
const naturalizedReport = await DICOMSR.storeMeasurements(trackedMeasurements, dataSource);
|
||||||
});
|
|
||||||
}
|
DisplaySetService.makeDisplaySets([naturalizedReport], { madeInClient: true });
|
||||||
);
|
UINotificationService.show({
|
||||||
|
title: 'STOW SR',
|
||||||
|
message: 'Measurements saved successfully',
|
||||||
|
type: 'success'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
UINotificationService.show({
|
||||||
|
title: 'STOW SR',
|
||||||
|
message: error.message || 'Failed to store measurements',
|
||||||
|
type: 'error',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
UIDialogService.dismiss({ id: loadingDialogId });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -155,7 +169,7 @@ function PanelMeasurementTableTracking({ servicesManager, extensionManager }) {
|
|||||||
title="Measurements"
|
title="Measurements"
|
||||||
amount={displayMeasurements.length}
|
amount={displayMeasurements.length}
|
||||||
data={displayMeasurements}
|
data={displayMeasurements}
|
||||||
onClick={() => {}}
|
onClick={() => { }}
|
||||||
onEdit={id => alert(`Edit: ${id}`)}
|
onEdit={id => alert(`Edit: ${id}`)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -79,8 +79,9 @@ const generateReport = measurementData => {
|
|||||||
* @param {object[]} measurementData An array of measurements from the measurements service
|
* @param {object[]} measurementData An array of measurements from the measurements service
|
||||||
* that you wish to serialize.
|
* that you wish to serialize.
|
||||||
* @param {object} dataSource The dataSource that you wish to use to persist the data.
|
* @param {object} dataSource The dataSource that you wish to use to persist the data.
|
||||||
|
* @return {object} The naturalized report
|
||||||
*/
|
*/
|
||||||
const storeMeasurements = async (measurementData, dataSource, onSuccess) => {
|
const storeMeasurements = async (measurementData, dataSource) => {
|
||||||
// TODO -> Eventually use the measurements directly and not the dcmjs adapter,
|
// TODO -> Eventually use the measurements directly and not the dcmjs adapter,
|
||||||
// But it is good enough for now whilst we only have cornerstone as a datasource.
|
// But it is good enough for now whilst we only have cornerstone as a datasource.
|
||||||
log.info('[DICOMSR] storeMeasurements');
|
log.info('[DICOMSR] storeMeasurements');
|
||||||
@ -90,28 +91,22 @@ const storeMeasurements = async (measurementData, dataSource, onSuccess) => {
|
|||||||
return Promise.reject({});
|
return Promise.reject({});
|
||||||
}
|
}
|
||||||
|
|
||||||
const naturalizedReport = generateReport(measurementData);
|
|
||||||
const { StudyInstanceUID } = naturalizedReport;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const naturalizedReport = generateReport(measurementData);
|
||||||
|
const { StudyInstanceUID } = naturalizedReport;
|
||||||
|
|
||||||
await dataSource.store.dicom(naturalizedReport);
|
await dataSource.store.dicom(naturalizedReport);
|
||||||
|
|
||||||
if (StudyInstanceUID) {
|
if (StudyInstanceUID) {
|
||||||
dataSource.deleteStudyMetadataPromise(StudyInstanceUID);
|
dataSource.deleteStudyMetadataPromise(StudyInstanceUID);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (onSuccess) {
|
return naturalizedReport;
|
||||||
onSuccess(naturalizedReport);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
message: 'Measurements saved successfully',
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error(
|
log.error(
|
||||||
`[DICOMSR] Error while saving the measurements: ${error.message}`
|
`[DICOMSR] Error while saving the measurements: ${error.message}`
|
||||||
);
|
);
|
||||||
throw new Error('Error while saving the measurements.');
|
throw new Error(error.message || 'Error while saving the measurements.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="19" height="19" viewBox="0 0 19 19">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 19 19">
|
||||||
<g fill="currentColor" fill-rule="evenodd">
|
<g fill="currentColor" fill-rule="evenodd">
|
||||||
<g stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5">
|
<g stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5">
|
||||||
<path d="M.188.187L8.813 8.812M8.813.187L.188 8.812" transform="translate(5 5)"/>
|
<path d="M.188.187L8.813 8.812M8.813.187L.188 8.812" transform="translate(5 5)"/>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 355 B After Width: | Height: | Size: 332 B |
@ -113,7 +113,7 @@ const IconButton = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
IconButton.defaultProps = {
|
IconButton.defaultProps = {
|
||||||
onClick: () => {},
|
onClick: () => { },
|
||||||
color: 'default',
|
color: 'default',
|
||||||
disabled: false,
|
disabled: false,
|
||||||
fullWidth: false,
|
fullWidth: false,
|
||||||
|
|||||||
115
platform/ui/src/components/Snackbar/Snackbar.css
Normal file
115
platform/ui/src/components/Snackbar/Snackbar.css
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
/* TODO: Create tailwind styles for this component */
|
||||||
|
.sb-topLeft {
|
||||||
|
@apply top-0 left-0 bottom-auto right-auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-topCenter {
|
||||||
|
transform: translateX(-50%);
|
||||||
|
@apply top-0 bottom-auto left-1/2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-topRight {
|
||||||
|
@apply right-0 top-0 left-auto bottom-auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-bottomLeft {
|
||||||
|
@apply right-auto left-0 bottom-0 top-auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-bottomCenter {
|
||||||
|
@apply top-auto bottom-0 left-1/2;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-bottomRight {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
@apply top-auto bottom-0 left-auto right-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-topLeft .sb-item,
|
||||||
|
.sb-topCenter .sb-item,
|
||||||
|
.sb-topRight .sb-item {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-bottomLeft .sb-item,
|
||||||
|
.sb-bottomCenter .sb-item,
|
||||||
|
.sb-bottomRight .sb-item {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-closeBtn {
|
||||||
|
text-shadow: none;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
right: 5px;
|
||||||
|
top: 5px;
|
||||||
|
@apply overflow-hidden opacity-100 rounded-full p-1 bg-white cursor-pointer absolute text-center duration-300 transition-all ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-closeBtn:hover {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-closeIcon {
|
||||||
|
@apply w-full relative overflow-hidden h-full block leading-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-closeIcon:after,
|
||||||
|
.sb-closeIcon:before {
|
||||||
|
content: ' ';
|
||||||
|
height: 2px;
|
||||||
|
width: 12px;
|
||||||
|
@apply duration-300 transition-all ease-in-out block bg-black opacity-100 absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-closeIcon:before {
|
||||||
|
left: 4px;
|
||||||
|
top: 3px;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
transform-origin: 0px 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-closeIcon:after {
|
||||||
|
right: 3px;
|
||||||
|
top: 5px;
|
||||||
|
transform: rotate(-45deg);
|
||||||
|
transform-origin: calc(100% - 3px) 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-title {
|
||||||
|
@apply break-normal text-lg font-bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-message {
|
||||||
|
@apply break-normal text-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-item {
|
||||||
|
animation: fadein 1s;
|
||||||
|
box-shadow: 0 6px 10px 0 rgba(0, 0, 0, 0.2), 0 1px 18px 0 rgba(0, 0, 0, 0.12),
|
||||||
|
0 3px 5px -1px rgba(0, 0, 0, 0.14);
|
||||||
|
@apply relative p-5 text-white overflow-hidden rounded-md transition-height ease-in-out duration-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadein {
|
||||||
|
from {
|
||||||
|
top: 30px;
|
||||||
|
@apply opacity-0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
@apply opacity-100 top-0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Internet Explorer */
|
||||||
|
@-ms-keyframes fadein {
|
||||||
|
from {
|
||||||
|
top: 30px;
|
||||||
|
@apply opacity-0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
@apply opacity-100;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,16 +2,18 @@ import React from 'react';
|
|||||||
import SnackbarItem from './SnackbarItem';
|
import SnackbarItem from './SnackbarItem';
|
||||||
import { useSnackbar } from '../../contextProviders';
|
import { useSnackbar } from '../../contextProviders';
|
||||||
|
|
||||||
|
import './Snackbar.css';
|
||||||
|
|
||||||
const SnackbarContainer = () => {
|
const SnackbarContainer = () => {
|
||||||
const { snackbarItems, hide } = useSnackbar();
|
const { snackbarItems, hide } = useSnackbar();
|
||||||
|
|
||||||
const renderItem = item => {
|
const renderItem = item => (
|
||||||
return <SnackbarItem key={item.itemId} options={item} onClose={hide} />;
|
<SnackbarItem
|
||||||
};
|
key={item.itemId}
|
||||||
|
options={item}
|
||||||
if (!snackbarItems) {
|
onClose={hide}
|
||||||
return null;
|
/>
|
||||||
}
|
);
|
||||||
|
|
||||||
const renderItems = () => {
|
const renderItems = () => {
|
||||||
const items = {
|
const items = {
|
||||||
@ -23,11 +25,9 @@ const SnackbarContainer = () => {
|
|||||||
bottomRight: [],
|
bottomRight: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
snackbarItems.map(item => {
|
snackbarItems.forEach(item => items[item.position].push(item));
|
||||||
items[item.position].push(item);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return snackbarItems && (
|
||||||
<div>
|
<div>
|
||||||
{Object.keys(items).map(pos => {
|
{Object.keys(items).map(pos => {
|
||||||
if (!items[pos].length) {
|
if (!items[pos].length) {
|
||||||
@ -35,7 +35,7 @@ const SnackbarContainer = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={pos} className={`sb-container sb-${pos}`}>
|
<div key={pos} className={`fixed z-50 p-6 box-border h-auto sb-${pos}`}>
|
||||||
{items[pos].map((item, index) => (
|
{items[pos].map((item, index) => (
|
||||||
<div key={item.id + index}>{renderItem(item)}</div>
|
<div key={item.id + index}>{renderItem(item)}</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -1,25 +1,38 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import SnackbarTypes from './SnackbarTypes';
|
||||||
|
|
||||||
const SnackbarItem = ({ options, onClose }) => {
|
const SnackbarItem = ({ options, onClose }) => {
|
||||||
const handleClose = () => {
|
const handleClose = () => onClose(options.id);
|
||||||
onClose(options.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (options.autoClose) {
|
if (options.autoClose) {
|
||||||
setTimeout(() => {
|
setTimeout(() => handleClose(), options.duration);
|
||||||
handleClose();
|
|
||||||
}, options.duration);
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const typeClasses = {
|
||||||
|
[SnackbarTypes.INFO]: 'bg-primary-active',
|
||||||
|
[SnackbarTypes.WARNING]: 'bg-yellow-600',
|
||||||
|
[SnackbarTypes.SUCCESS]: 'bg-green-600',
|
||||||
|
[SnackbarTypes.ERROR]: 'bg-red-600'
|
||||||
|
};
|
||||||
|
|
||||||
|
const hidden = 'duration-300 transition-all ease-in-out h-0 opacity-0 pt-0 mb-0 pb-0';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div
|
||||||
<span onClick={handleClose}>
|
className={classNames(
|
||||||
<span>x</span>
|
`${options.visible ? '' : hidden} sb-item`,
|
||||||
|
typeClasses[options.type]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="sb-closeBtn" onClick={handleClose}>
|
||||||
|
<span className="sb-closeIcon">x</span>
|
||||||
</span>
|
</span>
|
||||||
{options.title && <div>{options.title}</div>}
|
{options.title && <div className="sb-title">{options.title}</div>}
|
||||||
{options.message && <div>{options.message}</div>}
|
{options.message && <div className="sb-message">{options.message}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
8
platform/ui/src/contextProviders/DialogProvider.css
Normal file
8
platform/ui/src/contextProviders/DialogProvider.css
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
/* TODO: Find a better way to set the cursor for all contents of dialog. */
|
||||||
|
.DraggableItem.draggable div {
|
||||||
|
cursor: grab !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.DraggableItem.draggable.dragging div {
|
||||||
|
cursor: grabbing !important;
|
||||||
|
}
|
||||||
@ -5,11 +5,14 @@ import React, {
|
|||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import Draggable from 'react-draggable';
|
import Draggable from 'react-draggable';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import { utils } from '@ohif/core';
|
import { utils } from '@ohif/core';
|
||||||
|
|
||||||
|
import './DialogProvider.css';
|
||||||
|
|
||||||
const DialogContext = createContext(null);
|
const DialogContext = createContext(null);
|
||||||
|
|
||||||
@ -150,6 +153,7 @@ const DialogProvider = ({ children, service }) => {
|
|||||||
onStart,
|
onStart,
|
||||||
onStop,
|
onStop,
|
||||||
onDrag,
|
onDrag,
|
||||||
|
showOverlay,
|
||||||
} = dialog;
|
} = dialog;
|
||||||
|
|
||||||
let position =
|
let position =
|
||||||
@ -158,13 +162,13 @@ const DialogProvider = ({ children, service }) => {
|
|||||||
position = centerPositions.find(position => position.id === id);
|
position = centerPositions.find(position => position.id === id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const dragableItem = () => (
|
||||||
<Draggable
|
<Draggable
|
||||||
key={id}
|
key={id}
|
||||||
disabled={!isDraggable}
|
disabled={!isDraggable}
|
||||||
position={position}
|
position={position}
|
||||||
defaultPosition={position}
|
defaultPosition={position}
|
||||||
bounds="parent"
|
bounds='parent'
|
||||||
onStart={event => {
|
onStart={event => {
|
||||||
const e = event || window.event;
|
const e = event || window.event;
|
||||||
const target = e.target || e.srcElement;
|
const target = e.target || e.srcElement;
|
||||||
@ -215,6 +219,21 @@ const DialogProvider = ({ children, service }) => {
|
|||||||
</div>
|
</div>
|
||||||
</Draggable>
|
</Draggable>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const withOverlay = component => {
|
||||||
|
const background = 'bg-black bg-opacity-50';
|
||||||
|
const overlay = 'fixed z-50 left-0 top-0 w-full h-full overflow-auto';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames(overlay, background)}
|
||||||
|
key={id}
|
||||||
|
>
|
||||||
|
{component}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return showOverlay ? withOverlay(dragableItem()) : dragableItem();
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -236,13 +255,11 @@ const DialogProvider = ({ children, service }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogContext.Provider value={{ create, dismiss, dismissAll, isEmpty }}>
|
<DialogContext.Provider value={{ create, dismiss, dismissAll, isEmpty }}>
|
||||||
<div className="DraggableArea">
|
{!isEmpty() &&
|
||||||
{dialogs.some(dialog => dialog.showOverlay) ? (
|
<div className='w-full h-full absolute'>
|
||||||
<div className="Overlay active">{renderDialogs()}</div>
|
{renderDialogs()}
|
||||||
) : (
|
</div>
|
||||||
renderDialogs()
|
}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{children}
|
{children}
|
||||||
</DialogContext.Provider>
|
</DialogContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -329,6 +329,7 @@ module.exports = {
|
|||||||
auto: 'auto',
|
auto: 'auto',
|
||||||
full: '100%',
|
full: '100%',
|
||||||
viewport: '0.5rem',
|
viewport: '0.5rem',
|
||||||
|
'1/2': '50%',
|
||||||
'viewport-scrollbar': '1.3rem'
|
'viewport-scrollbar': '1.3rem'
|
||||||
},
|
},
|
||||||
letterSpacing: {
|
letterSpacing: {
|
||||||
@ -683,6 +684,7 @@ module.exports = {
|
|||||||
transitionProperty: {
|
transitionProperty: {
|
||||||
none: 'none',
|
none: 'none',
|
||||||
all: 'all',
|
all: 'all',
|
||||||
|
'height': 'height',
|
||||||
default:
|
default:
|
||||||
'background-color, border-color, color, fill, stroke, opacity, box-shadow, transform',
|
'background-color, border-color, color, fill, stroke, opacity, box-shadow, transform',
|
||||||
colors: 'background-color, border-color, color, fill, stroke',
|
colors: 'background-color, border-color, color, fill, stroke',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user