Merge branch 'IbrahimCSAE-feat/interactive-onboarding-guides'
This commit is contained in:
commit
579a592812
@ -6,6 +6,7 @@ import { HangingProtocolService, CommandsManager } from '@ohif/core';
|
||||
import { useAppConfig } from '@state';
|
||||
import ViewerHeader from './ViewerHeader';
|
||||
import SidePanelWithServices from '../Components/SidePanelWithServices';
|
||||
import { Onboarding } from '@ohif/ui-next';
|
||||
|
||||
function ViewerLayout({
|
||||
// From Extension Module Params
|
||||
@ -155,7 +156,7 @@ function ViewerLayout({
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
</div>
|
||||
|
||||
<Onboarding />
|
||||
<InvestigationalUseDialog dialogConfiguration={appConfig?.investigationalUseDialog} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -98,6 +98,8 @@
|
||||
"react-resize-detector": "^10.0.1",
|
||||
"react-router": "^6.23.1",
|
||||
"react-router-dom": "^6.8.1",
|
||||
"react-shepherd": "^6.1.1",
|
||||
"shepherd.js": "^13.0.3",
|
||||
"url-loader": "^4.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -291,4 +291,212 @@ window.config = {
|
||||
keys: ['9'],
|
||||
},
|
||||
],
|
||||
tours: [
|
||||
{
|
||||
id: 'basicViewerTour',
|
||||
route: '/viewer',
|
||||
steps: [
|
||||
{
|
||||
id: 'scroll',
|
||||
title: 'Scrolling Through Images',
|
||||
text: 'You can scroll through the images using the mouse wheel or scrollbar.',
|
||||
attachTo: {
|
||||
element: '.viewport-element',
|
||||
on: 'top',
|
||||
},
|
||||
advanceOn: {
|
||||
selector: '.cornerstone-viewport-element',
|
||||
event: 'CORNERSTONE_TOOLS_MOUSE_WHEEL',
|
||||
},
|
||||
beforeShowPromise: () => waitForElement('.viewport-element'),
|
||||
},
|
||||
{
|
||||
id: 'zoom',
|
||||
title: 'Zooming In and Out',
|
||||
text: 'You can zoom the images using the right click.',
|
||||
attachTo: {
|
||||
element: '.viewport-element',
|
||||
on: 'left',
|
||||
},
|
||||
advanceOn: {
|
||||
selector: '.cornerstone-viewport-element',
|
||||
event: 'CORNERSTONE_TOOLS_MOUSE_UP',
|
||||
},
|
||||
beforeShowPromise: () => waitForElement('.viewport-element'),
|
||||
},
|
||||
{
|
||||
id: 'pan',
|
||||
title: 'Panning the Image',
|
||||
text: 'You can pan the images using the middle click.',
|
||||
attachTo: {
|
||||
element: '.viewport-element',
|
||||
on: 'top',
|
||||
},
|
||||
advanceOn: {
|
||||
selector: '.cornerstone-viewport-element',
|
||||
event: 'CORNERSTONE_TOOLS_MOUSE_UP',
|
||||
},
|
||||
beforeShowPromise: () => waitForElement('.viewport-element'),
|
||||
},
|
||||
{
|
||||
id: 'windowing',
|
||||
title: 'Adjusting Window Level',
|
||||
text: 'You can modify the window level using the left click.',
|
||||
attachTo: {
|
||||
element: '.viewport-element',
|
||||
on: 'left',
|
||||
},
|
||||
advanceOn: {
|
||||
selector: '.cornerstone-viewport-element',
|
||||
event: 'CORNERSTONE_TOOLS_MOUSE_UP',
|
||||
},
|
||||
beforeShowPromise: () => waitForElement('.viewport-element'),
|
||||
},
|
||||
{
|
||||
id: 'length',
|
||||
title: 'Using the Measurement Tools',
|
||||
text: 'You can measure the length of a region using the Length tool.',
|
||||
attachTo: {
|
||||
element: '[data-cy="MeasurementTools-split-button-primary"]',
|
||||
on: 'bottom',
|
||||
},
|
||||
advanceOn: {
|
||||
selector: '[data-cy="MeasurementTools-split-button-primary"]',
|
||||
event: 'click',
|
||||
},
|
||||
beforeShowPromise: () =>
|
||||
waitForElement('[data-cy="MeasurementTools-split-button-primary]'),
|
||||
},
|
||||
{
|
||||
id: 'drawAnnotation',
|
||||
title: 'Drawing Length Annotations',
|
||||
text: 'Use the length tool on the viewport to measure the length of a region.',
|
||||
attachTo: {
|
||||
element: '.viewport-element',
|
||||
on: 'right',
|
||||
},
|
||||
advanceOn: {
|
||||
selector: 'body',
|
||||
event: 'event::measurement_added',
|
||||
},
|
||||
beforeShowPromise: () => waitForElement('.viewport-element'),
|
||||
},
|
||||
{
|
||||
id: 'trackMeasurement',
|
||||
title: 'Tracking Measurements in the Panel',
|
||||
text: 'Click yes to track the measurements in the measurement panel.',
|
||||
attachTo: {
|
||||
element: '[data-cy="prompt-begin-tracking-yes-btn"]',
|
||||
on: 'bottom',
|
||||
},
|
||||
advanceOn: {
|
||||
selector: '[data-cy="prompt-begin-tracking-yes-btn"]',
|
||||
event: 'click',
|
||||
},
|
||||
beforeShowPromise: () => waitForElement('[data-cy="prompt-begin-tracking-yes-btn"]'),
|
||||
},
|
||||
{
|
||||
id: 'openMeasurementPanel',
|
||||
title: 'Opening the Measurements Panel',
|
||||
text: 'Click the measurements button to open the measurements panel.',
|
||||
attachTo: {
|
||||
element: '#trackedMeasurements-btn',
|
||||
on: 'left-start',
|
||||
},
|
||||
advanceOn: {
|
||||
selector: '#trackedMeasurements-btn',
|
||||
event: 'click',
|
||||
},
|
||||
beforeShowPromise: () => waitForElement('#trackedMeasurements-btn'),
|
||||
},
|
||||
{
|
||||
id: 'scrollAwayFromMeasurement',
|
||||
title: 'Scrolling Away from a Measurement',
|
||||
text: 'Scroll the images using the mouse wheel away from the measurement.',
|
||||
attachTo: {
|
||||
element: '.viewport-element',
|
||||
on: 'left',
|
||||
},
|
||||
advanceOn: {
|
||||
selector: '.cornerstone-viewport-element',
|
||||
event: 'CORNERSTONE_TOOLS_MOUSE_WHEEL',
|
||||
},
|
||||
beforeShowPromise: () => waitForElement('.viewport-element'),
|
||||
},
|
||||
{
|
||||
id: 'jumpToMeasurement',
|
||||
title: 'Jumping to Measurements in the Panel',
|
||||
text: 'Click the measurement in the measurement panel to jump to it.',
|
||||
attachTo: {
|
||||
element: '[data-cy="measurement-item"]',
|
||||
on: 'left-start',
|
||||
},
|
||||
advanceOn: {
|
||||
selector: '[data-cy="measurement-item"]',
|
||||
event: 'click',
|
||||
},
|
||||
beforeShowPromise: () => waitForElement('[data-cy="measurement-item"]'),
|
||||
},
|
||||
{
|
||||
id: 'changeLayout',
|
||||
title: 'Changing Layout',
|
||||
text: 'You can change the layout of the viewer using the layout button.',
|
||||
attachTo: {
|
||||
element: '[data-cy="Layout"]',
|
||||
on: 'bottom',
|
||||
},
|
||||
advanceOn: {
|
||||
selector: '[data-cy="Layout"]',
|
||||
event: 'click',
|
||||
},
|
||||
beforeShowPromise: () => waitForElement('[data-cy="Layout"]'),
|
||||
},
|
||||
{
|
||||
id: 'selectLayout',
|
||||
title: 'Selecting the MPR Layout',
|
||||
text: 'Select the MPR layout to view the images in MPR mode.',
|
||||
attachTo: {
|
||||
element: '[data-cy="MPR"]',
|
||||
on: 'left-start',
|
||||
},
|
||||
advanceOn: {
|
||||
selector: '[data-cy="MPR"]',
|
||||
event: 'click',
|
||||
},
|
||||
beforeShowPromise: () => waitForElement('[data-cy="MPR"]'),
|
||||
},
|
||||
],
|
||||
tourOptions: {
|
||||
useModalOverlay: true,
|
||||
defaultStepOptions: {
|
||||
buttons: [
|
||||
{
|
||||
text: 'Skip all',
|
||||
action() {
|
||||
this.complete();
|
||||
},
|
||||
secondary: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function waitForElement(selector, maxAttempts = 20, interval = 25) {
|
||||
return new Promise(resolve => {
|
||||
let attempts = 0;
|
||||
|
||||
const checkForElement = setInterval(() => {
|
||||
const element = document.querySelector(selector);
|
||||
|
||||
if (element || attempts >= maxAttempts) {
|
||||
clearInterval(checkForElement);
|
||||
resolve();
|
||||
}
|
||||
|
||||
attempts++;
|
||||
}, interval);
|
||||
});
|
||||
}
|
||||
|
||||
@ -247,4 +247,212 @@ window.config = {
|
||||
keys: ['9'],
|
||||
},
|
||||
],
|
||||
tours: [
|
||||
{
|
||||
id: 'basicViewerTour',
|
||||
route: '/viewer',
|
||||
steps: [
|
||||
{
|
||||
id: 'scroll',
|
||||
title: 'Scrolling Through Images',
|
||||
text: 'You can scroll through the images using the mouse wheel or scrollbar.',
|
||||
attachTo: {
|
||||
element: '.viewport-element',
|
||||
on: 'top',
|
||||
},
|
||||
advanceOn: {
|
||||
selector: '.cornerstone-viewport-element',
|
||||
event: 'CORNERSTONE_TOOLS_MOUSE_WHEEL',
|
||||
},
|
||||
beforeShowPromise: () => waitForElement('.viewport-element'),
|
||||
},
|
||||
{
|
||||
id: 'zoom',
|
||||
title: 'Zooming In and Out',
|
||||
text: 'You can zoom the images using the right click.',
|
||||
attachTo: {
|
||||
element: '.viewport-element',
|
||||
on: 'left',
|
||||
},
|
||||
advanceOn: {
|
||||
selector: '.cornerstone-viewport-element',
|
||||
event: 'CORNERSTONE_TOOLS_MOUSE_UP',
|
||||
},
|
||||
beforeShowPromise: () => waitForElement('.viewport-element'),
|
||||
},
|
||||
{
|
||||
id: 'pan',
|
||||
title: 'Panning the Image',
|
||||
text: 'You can pan the images using the middle click.',
|
||||
attachTo: {
|
||||
element: '.viewport-element',
|
||||
on: 'top',
|
||||
},
|
||||
advanceOn: {
|
||||
selector: '.cornerstone-viewport-element',
|
||||
event: 'CORNERSTONE_TOOLS_MOUSE_UP',
|
||||
},
|
||||
beforeShowPromise: () => waitForElement('.viewport-element'),
|
||||
},
|
||||
{
|
||||
id: 'windowing',
|
||||
title: 'Adjusting Window Level',
|
||||
text: 'You can modify the window level using the left click.',
|
||||
attachTo: {
|
||||
element: '.viewport-element',
|
||||
on: 'left',
|
||||
},
|
||||
advanceOn: {
|
||||
selector: '.cornerstone-viewport-element',
|
||||
event: 'CORNERSTONE_TOOLS_MOUSE_UP',
|
||||
},
|
||||
beforeShowPromise: () => waitForElement('.viewport-element'),
|
||||
},
|
||||
{
|
||||
id: 'length',
|
||||
title: 'Using the Measurement Tools',
|
||||
text: 'You can measure the length of a region using the Length tool.',
|
||||
attachTo: {
|
||||
element: '[data-cy="MeasurementTools-split-button-primary"]',
|
||||
on: 'bottom',
|
||||
},
|
||||
advanceOn: {
|
||||
selector: '[data-cy="MeasurementTools-split-button-primary"]',
|
||||
event: 'click',
|
||||
},
|
||||
beforeShowPromise: () =>
|
||||
waitForElement('[data-cy="MeasurementTools-split-button-primary]'),
|
||||
},
|
||||
{
|
||||
id: 'drawAnnotation',
|
||||
title: 'Drawing Length Annotations',
|
||||
text: 'Use the length tool on the viewport to measure the length of a region.',
|
||||
attachTo: {
|
||||
element: '.viewport-element',
|
||||
on: 'right',
|
||||
},
|
||||
advanceOn: {
|
||||
selector: 'body',
|
||||
event: 'event::measurement_added',
|
||||
},
|
||||
beforeShowPromise: () => waitForElement('.viewport-element'),
|
||||
},
|
||||
{
|
||||
id: 'trackMeasurement',
|
||||
title: 'Tracking Measurements in the Panel',
|
||||
text: 'Click yes to track the measurements in the measurement panel.',
|
||||
attachTo: {
|
||||
element: '[data-cy="prompt-begin-tracking-yes-btn"]',
|
||||
on: 'bottom',
|
||||
},
|
||||
advanceOn: {
|
||||
selector: '[data-cy="prompt-begin-tracking-yes-btn"]',
|
||||
event: 'click',
|
||||
},
|
||||
beforeShowPromise: () => waitForElement('[data-cy="prompt-begin-tracking-yes-btn"]'),
|
||||
},
|
||||
{
|
||||
id: 'openMeasurementPanel',
|
||||
title: 'Opening the Measurements Panel',
|
||||
text: 'Click the measurements button to open the measurements panel.',
|
||||
attachTo: {
|
||||
element: '#trackedMeasurements-btn',
|
||||
on: 'left-start',
|
||||
},
|
||||
advanceOn: {
|
||||
selector: '#trackedMeasurements-btn',
|
||||
event: 'click',
|
||||
},
|
||||
beforeShowPromise: () => waitForElement('#trackedMeasurements-btn'),
|
||||
},
|
||||
{
|
||||
id: 'scrollAwayFromMeasurement',
|
||||
title: 'Scrolling Away from a Measurement',
|
||||
text: 'Scroll the images using the mouse wheel away from the measurement.',
|
||||
attachTo: {
|
||||
element: '.viewport-element',
|
||||
on: 'left',
|
||||
},
|
||||
advanceOn: {
|
||||
selector: '.cornerstone-viewport-element',
|
||||
event: 'CORNERSTONE_TOOLS_MOUSE_WHEEL',
|
||||
},
|
||||
beforeShowPromise: () => waitForElement('.viewport-element'),
|
||||
},
|
||||
{
|
||||
id: 'jumpToMeasurement',
|
||||
title: 'Jumping to Measurements in the Panel',
|
||||
text: 'Click the measurement in the measurement panel to jump to it.',
|
||||
attachTo: {
|
||||
element: '[data-cy="measurement-item"]',
|
||||
on: 'left-start',
|
||||
},
|
||||
advanceOn: {
|
||||
selector: '[data-cy="measurement-item"]',
|
||||
event: 'click',
|
||||
},
|
||||
beforeShowPromise: () => waitForElement('[data-cy="measurement-item"]'),
|
||||
},
|
||||
{
|
||||
id: 'changeLayout',
|
||||
title: 'Changing Layout',
|
||||
text: 'You can change the layout of the viewer using the layout button.',
|
||||
attachTo: {
|
||||
element: '[data-cy="Layout"]',
|
||||
on: 'bottom',
|
||||
},
|
||||
advanceOn: {
|
||||
selector: '[data-cy="Layout"]',
|
||||
event: 'click',
|
||||
},
|
||||
beforeShowPromise: () => waitForElement('[data-cy="Layout"]'),
|
||||
},
|
||||
{
|
||||
id: 'selectLayout',
|
||||
title: 'Selecting the MPR Layout',
|
||||
text: 'Select the MPR layout to view the images in MPR mode.',
|
||||
attachTo: {
|
||||
element: '[data-cy="MPR"]',
|
||||
on: 'left-start',
|
||||
},
|
||||
advanceOn: {
|
||||
selector: '[data-cy="MPR"]',
|
||||
event: 'click',
|
||||
},
|
||||
beforeShowPromise: () => waitForElement('[data-cy="MPR"]'),
|
||||
},
|
||||
],
|
||||
tourOptions: {
|
||||
useModalOverlay: true,
|
||||
defaultStepOptions: {
|
||||
buttons: [
|
||||
{
|
||||
text: 'Skip all',
|
||||
action() {
|
||||
this.complete();
|
||||
},
|
||||
secondary: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function waitForElement(selector, maxAttempts = 20, interval = 25) {
|
||||
return new Promise(resolve => {
|
||||
let attempts = 0;
|
||||
|
||||
const checkForElement = setInterval(() => {
|
||||
const element = document.querySelector(selector);
|
||||
|
||||
if (element || attempts >= maxAttempts) {
|
||||
clearInterval(checkForElement);
|
||||
resolve();
|
||||
}
|
||||
|
||||
attempts++;
|
||||
}, interval);
|
||||
});
|
||||
}
|
||||
|
||||
@ -31,6 +31,7 @@ import { AppConfigProvider } from '@state';
|
||||
import createRoutes from './routes';
|
||||
import appInit from './appInit.js';
|
||||
import OpenIdConnectRoutes from './utils/OpenIdConnectRoutes';
|
||||
import { ShepherdJourneyProvider } from 'react-shepherd';
|
||||
|
||||
let commandsManager: CommandsManager,
|
||||
extensionManager: ExtensionManager,
|
||||
@ -115,6 +116,7 @@ function App({
|
||||
[SnackbarProvider, { service: uiNotificationService }],
|
||||
[DialogProvider, { service: uiDialogService }],
|
||||
[ModalProvider, { service: uiModalService, modal: Modal }],
|
||||
[ShepherdJourneyProvider],
|
||||
];
|
||||
|
||||
// Loop through and register each of the service providers registered with the ServiceProvidersManager.
|
||||
|
||||
@ -34,6 +34,7 @@ import {
|
||||
import { Types } from '@ohif/ui';
|
||||
|
||||
import i18n from '@ohif/i18n';
|
||||
import { Onboarding } from '@ohif/ui-next';
|
||||
|
||||
const PatientInfoVisibility = Types.PatientInfoVisibility;
|
||||
|
||||
@ -411,7 +412,7 @@ function WorkList({
|
||||
disabled={!isValidMode}
|
||||
startIconTooltip={
|
||||
!isValidMode ? (
|
||||
<div className="font-inter flex w-[206px] whitespace-normal text-left text-xs font-normal text-white ">
|
||||
<div className="font-inter flex w-[206px] whitespace-normal text-left text-xs font-normal text-white">
|
||||
{invalidModeDescription}
|
||||
</div>
|
||||
) : null
|
||||
@ -534,6 +535,7 @@ function WorkList({
|
||||
WhiteLabeling={appConfig.whiteLabeling}
|
||||
showPatientInfo={PatientInfoVisibility.DISABLED}
|
||||
/>
|
||||
<Onboarding />
|
||||
<InvestigationalUseDialog dialogConfiguration={appConfig?.investigationalUseDialog} />
|
||||
<div className="ohif-scrollbar ohif-scrollbar-stable-gutter flex grow flex-col overflow-y-auto sm:px-5">
|
||||
<StudyListFilter
|
||||
|
||||
@ -54,6 +54,8 @@
|
||||
"moment": "*",
|
||||
"object-hash": "2.1.1",
|
||||
"query-string": "^6.14.0",
|
||||
"react-shepherd": "^6.1.1",
|
||||
"shepherd.js": "^13.0.3",
|
||||
"validate.js": "^0.12.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -80,6 +80,9 @@ function _broadcastEvent(eventName, callbackProps) {
|
||||
const hasListeners = Object.keys(this.listeners).length > 0;
|
||||
const hasCallbacks = Array.isArray(this.listeners[eventName]);
|
||||
|
||||
const event = new CustomEvent(eventName, { detail: callbackProps });
|
||||
document.body.dispatchEvent(event);
|
||||
|
||||
if (hasListeners && hasCallbacks) {
|
||||
this.listeners[eventName].forEach(listener => {
|
||||
listener.callback(callbackProps);
|
||||
|
||||
@ -22,6 +22,8 @@ import ExtensionManagerType from '../extensions/ExtensionManager';
|
||||
|
||||
import Hotkey from '../classes/Hotkey';
|
||||
|
||||
import { StepOptions, TourOptions } from 'shepherd.js';
|
||||
|
||||
declare global {
|
||||
namespace AppTypes {
|
||||
export type ServicesManager = ServicesManagerType;
|
||||
@ -129,6 +131,12 @@ declare global {
|
||||
maxNumPrefetchRequests: number;
|
||||
order: 'closest' | 'downward' | 'upward';
|
||||
};
|
||||
tours?: Array<{
|
||||
id: string;
|
||||
steps: StepOptions[];
|
||||
tourOptions: TourOptions;
|
||||
route: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface Test {
|
||||
|
||||
BIN
platform/docs/docs/configuration/tour-demo.gif
Normal file
BIN
platform/docs/docs/configuration/tour-demo.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.5 MiB |
151
platform/docs/docs/configuration/tours.md
Normal file
151
platform/docs/docs/configuration/tours.md
Normal file
@ -0,0 +1,151 @@
|
||||
---
|
||||
sidebar_position: 3
|
||||
sidebar_label: Tours
|
||||
---
|
||||
|
||||
# Configuring Tours in OHIF with Shepherd.js
|
||||
|
||||
In OHIF, you can configure guided tours for users by leveraging [Shepherd.js](https://shepherdjs.dev/), a JavaScript library for building feature tours. This page explains how you can define and customize these tours within your app configuration file.
|
||||
|
||||
## Overview
|
||||
|
||||
Tours allow you to provide step-by-step guidance to users, explaining different features of your mode/extension or the viewer. Each tour is associated with a route and consists of several steps, each guiding the user through specific interactions in the viewer.
|
||||
|
||||
### Adding a Tour to your Configuration
|
||||
|
||||
Here’s an example of adding a tour to your configuration file:
|
||||
|
||||
```javascript
|
||||
window.config = {
|
||||
tours: [
|
||||
{
|
||||
id: 'basicViewerTour',
|
||||
route: '/viewer',
|
||||
steps: [
|
||||
{
|
||||
id: 'scroll',
|
||||
title: 'Scrolling Through Images',
|
||||
text: 'You can scroll through the images using the mouse wheel or scrollbar.',
|
||||
attachTo: {
|
||||
element: '.viewport-element',
|
||||
on: 'top',
|
||||
},
|
||||
advanceOn: {
|
||||
selector: '.cornerstone-viewport-element',
|
||||
event: 'CORNERSTONE_TOOLS_MOUSE_WHEEL',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'zoom',
|
||||
title: 'Zooming In and Out',
|
||||
text: 'You can zoom the images using the right click.',
|
||||
attachTo: {
|
||||
element: '.viewport-element',
|
||||
on: 'left',
|
||||
},
|
||||
advanceOn: {
|
||||
selector: '.cornerstone-viewport-element',
|
||||
event: 'CORNERSTONE_TOOLS_MOUSE_UP',
|
||||
},
|
||||
},
|
||||
// Add more steps as needed
|
||||
],
|
||||
tourOptions: {
|
||||
useModalOverlay: true,
|
||||
defaultStepOptions: {
|
||||
buttons: [
|
||||
{
|
||||
text: 'Skip all',
|
||||
action() {
|
||||
this.complete();
|
||||
},
|
||||
secondary: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
## Explanation of Parameters
|
||||
|
||||
### `tours` Array
|
||||
|
||||
Each item in the `tours` array defines a specific tour for a particular route. The object contains the following properties:
|
||||
|
||||
- **`id`**: A unique identifier for the tour. This helps in tracking whether the tour has been shown.
|
||||
- **`route`**: The route in the application where the tour is applicable. When the user navigates to this route, the tour can automatically trigger if it hasn't been shown before.
|
||||
- **`steps`**: An array of steps that define the individual guide elements in the tour. Each step corresponds to a UI element and guides the user through interactions.
|
||||
- **`tourOptions`**: An object that allows you to configure the overall behavior of the tour, such as using a modal overlay or defining default step options.
|
||||
|
||||
### `steps` Array
|
||||
|
||||
Each step defines a part of the tour. Here's a breakdown of the properties you can define:
|
||||
|
||||
- **`id`**: A unique identifier for the step within the tour.
|
||||
- **`title`**: The title of the step, which appears at the top of the tooltip for the step.
|
||||
- **`text`**: The content or description of the step, explaining what the user needs to do or understand.
|
||||
- **`attachTo`**: Specifies where the step should be attached in the DOM. It includes:
|
||||
- `element`: A string selector or a DOM element that the step should attach to.
|
||||
- `on`: Specifies the position of the tooltip relative to the element (e.g., 'top', 'left', 'bottom', 'right').
|
||||
- **`advanceOn`**: Defines an event that will automatically advance the tour to the next step. This is useful for actions like clicking a button or scrolling.
|
||||
- `selector`: The CSS selector for the element that triggers the advance.
|
||||
- `event`: The event name that advances the step, this can be a OHIF service event, or a cornerstone event, or any native JS event (e.g., 'click', 'CORNERSTONE_TOOLS_MOUSE_WHEEL').
|
||||
- **`beforeShowPromise`**: A function that returns a promise. When the promise resolves, the rest of the show logic for the step will execute. You can use this to ensure that the target element is ready before the step shows.
|
||||
|
||||
### `tourOptions`
|
||||
|
||||
The `tourOptions` object allows you to configure the overall behavior of the tour. Here's a breakdown of the available properties:
|
||||
|
||||
- **`useModalOverlay`**: A boolean that, if set to `true`, places the tour steps above a darkened modal overlay. The overlay creates an opening around the target element so it can remain interactive.
|
||||
- **`defaultStepOptions`**: Default options that apply to all steps in the tour. You can override these in individual steps. The following are some options available:
|
||||
- `buttons`: An array of button objects that appear in the footer of each step. Each button can trigger actions like advancing the tour or skipping it. For example:
|
||||
- **`text`**: The label text on the button.
|
||||
- **`action`**: A function to execute when the button is clicked. You can advance the tour using `this.next()`, or complete it using `this.complete()`.
|
||||
- **`secondary`**: A boolean that, when set to `true`, styles the button as secondary (often for actions like skipping).
|
||||
|
||||
### `floatingUIOptions`
|
||||
|
||||
You can define positioning options for the steps using **Floating UI** middleware. This helps control how the steps are positioned, especially near the browser edges.
|
||||
|
||||
For example, you can ensure that the steps maintain a margin of 24px from the viewport edges by configuring `preventOverflow` middleware:
|
||||
|
||||
```javascript
|
||||
floatingUIOptions: {
|
||||
middleware: [
|
||||
preventOverflow({ padding: 24 }),
|
||||
flip(), // Allows the step to flip if it is overflowing
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Shepherd.js Lifecycle Events
|
||||
|
||||
Each step and tour can have lifecycle events like `show`, `hide`, `complete`, or `cancel`. These events allow you to hook into the tour’s lifecycle to perform actions when certain events are triggered.
|
||||
|
||||
For example:
|
||||
|
||||
```javascript
|
||||
when: {
|
||||
show() {
|
||||
console.log('Step shown!');
|
||||
},
|
||||
hide() {
|
||||
console.log('Step hidden.');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Customizing Your Tour
|
||||
|
||||
Once you have a basic tour in place, you can extend it with more advanced features like custom scrolling behavior, dynamic elements, and event-based step advancement. For more details, check out the [Shepherd.js documentation](https://shepherdjs.dev/).
|
||||
|
||||
## Demo
|
||||
|
||||

|
||||
|
||||
## Conclusion
|
||||
|
||||
By leveraging **Shepherd.js**, you can provide users with interactive and informative guided tours of the viewer. This can greatly improve the user experience and help users understand how to use key features.
|
||||
@ -52,11 +52,16 @@
|
||||
"next-themes": "^0.3.0",
|
||||
"react": "^18.3.1",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-shepherd": "^6.1.1",
|
||||
"shepherd.js": "^13.0.3",
|
||||
"sonner": "^1.4.41",
|
||||
"tailwind-merge": "^2.3.0",
|
||||
"tailwindcss": "3.2.4",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/plugin-proposal-private-property-in-object": "^7.16.7"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "OHIF",
|
||||
"license": "MIT"
|
||||
|
||||
48
platform/ui-next/src/components/Onboarding/Onboarding.css
Normal file
48
platform/ui-next/src/components/Onboarding/Onboarding.css
Normal file
@ -0,0 +1,48 @@
|
||||
.shepherd-header {
|
||||
@apply !bg-popover !w-[100%] !p-0;
|
||||
}
|
||||
|
||||
.shepherd-title {
|
||||
@apply !text-highlight !w-[100%] !break-words !text-lg !leading-[1.5];
|
||||
}
|
||||
|
||||
.shepherd-content {
|
||||
@apply flex flex-col gap-[8px] p-[12px];
|
||||
}
|
||||
|
||||
.shepherd-element {
|
||||
@apply !bg-popover !max-w-[260px];
|
||||
}
|
||||
|
||||
.shepherd-text {
|
||||
@apply text-foreground !w-[100%] p-0 text-base leading-normal;
|
||||
}
|
||||
|
||||
.shepherd-footer {
|
||||
@apply !w-[100%] p-0;
|
||||
}
|
||||
|
||||
.shepherd-button {
|
||||
@apply !inline-flex !h-[36px] !min-w-[62px] !flex-row !items-center !justify-center !gap-[5px] !whitespace-nowrap !rounded !bg-[#348cfd] !px-[10px] !text-center !font-sans !text-[14px] !leading-[1.2] !text-white !outline-none !transition !duration-300 !ease-in-out focus:!outline-none;
|
||||
}
|
||||
|
||||
.shepherd-button.shepherd-button-secondary {
|
||||
@apply !bg-transparent !text-[#348cfd];
|
||||
}
|
||||
|
||||
.shepherd-arrow::before {
|
||||
@apply !bg-popover !h-[30px] !w-[30px];
|
||||
}
|
||||
|
||||
.shepherd-element[data-popper-placement^='left'] > .shepherd-arrow {
|
||||
right: 3px !important;
|
||||
top: 6px !important;
|
||||
}
|
||||
|
||||
.shepherd-element[data-popper-placement^='top'] > .shepherd-arrow {
|
||||
bottom: 2px !important;
|
||||
}
|
||||
|
||||
.shepherd-modal-overlay-container.shepherd-modal-is-visible {
|
||||
@apply !opacity-70;
|
||||
}
|
||||
57
platform/ui-next/src/components/Onboarding/Onboarding.tsx
Normal file
57
platform/ui-next/src/components/Onboarding/Onboarding.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useShepherd } from 'react-shepherd';
|
||||
import { StepOptions, TourOptions } from 'shepherd.js';
|
||||
import { useLocation } from 'react-router';
|
||||
import 'shepherd.js/dist/css/shepherd.css';
|
||||
import './Onboarding.css';
|
||||
|
||||
import { hasTourBeenShown, markTourAsShown, defaultShowHandler, middleware } from './utilities';
|
||||
|
||||
const Onboarding = () => {
|
||||
const Shepherd = useShepherd();
|
||||
const location = useLocation();
|
||||
const tours = window.config.tours as Array<{
|
||||
id: string;
|
||||
route: string;
|
||||
tourOptions: TourOptions;
|
||||
steps: StepOptions[];
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Show the tour if it hasn't been shown yet based on the current route.
|
||||
* Constructs a tour instance and adds steps to it based on the matching tour.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!tours) {
|
||||
return;
|
||||
}
|
||||
|
||||
const matchingTour = tours.find(tour => tour.route === location.pathname);
|
||||
if (!matchingTour || hasTourBeenShown(matchingTour.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tourInstance = new Shepherd.Tour({
|
||||
...matchingTour.tourOptions,
|
||||
defaultStepOptions: {
|
||||
...matchingTour.tourOptions?.defaultStepOptions,
|
||||
floatingUIOptions: matchingTour.tourOptions?.defaultStepOptions?.floatingUIOptions || {
|
||||
middleware,
|
||||
},
|
||||
when: {
|
||||
...matchingTour.tourOptions?.defaultStepOptions?.when,
|
||||
show:
|
||||
matchingTour.tourOptions?.defaultStepOptions?.when?.show ||
|
||||
(() => defaultShowHandler(Shepherd)),
|
||||
},
|
||||
},
|
||||
});
|
||||
matchingTour.steps.forEach(step => tourInstance.addStep(step));
|
||||
tourInstance.start();
|
||||
markTourAsShown(matchingTour.id);
|
||||
}, [Shepherd, tours, location.pathname]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export { Onboarding };
|
||||
3
platform/ui-next/src/components/Onboarding/index.ts
Normal file
3
platform/ui-next/src/components/Onboarding/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { Onboarding } from './Onboarding';
|
||||
|
||||
export { Onboarding };
|
||||
91
platform/ui-next/src/components/Onboarding/utilities.ts
Normal file
91
platform/ui-next/src/components/Onboarding/utilities.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import { ShepherdBase } from 'shepherd.js';
|
||||
import { offset, flip, shift, detectOverflow } from '@floating-ui/dom';
|
||||
|
||||
/**
|
||||
* Retrieves the list of tours that have been shown from localStorage.
|
||||
* @returns {string[]} An array of tour IDs that have been shown.
|
||||
*/
|
||||
|
||||
const getShownTours = () => JSON.parse(localStorage.getItem('shownTours')) || [];
|
||||
|
||||
/**
|
||||
* Checks if a specific tour has been shown.
|
||||
* @param {string} tourId - The ID of the tour to check.
|
||||
* @returns {boolean} True if the tour has been shown, false otherwise.
|
||||
*/
|
||||
const hasTourBeenShown = (tourId: string) => getShownTours().includes(tourId);
|
||||
|
||||
/**
|
||||
* Marks a specific tour as shown by adding it to localStorage.
|
||||
* @param {string} tourId - The ID of the tour to mark as shown.
|
||||
* @returns {void}
|
||||
*/
|
||||
const markTourAsShown = (tourId: string) => {
|
||||
const shownTours = getShownTours();
|
||||
if (!shownTours.includes(tourId)) {
|
||||
shownTours.push(tourId);
|
||||
localStorage.setItem('shownTours', JSON.stringify(shownTours));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Default handler for the 'show' event in Shepherd steps.
|
||||
* Adds a progress indicator to the footer of the current step.
|
||||
*
|
||||
* @param {ShepherdBase} Shepherd - The Shepherd.js instance.
|
||||
* @returns {void}
|
||||
*/
|
||||
const defaultShowHandler = (Shepherd: ShepherdBase) => {
|
||||
const currentStep = Shepherd.activeTour?.getCurrentStep();
|
||||
if (currentStep) {
|
||||
const progress = document.createElement('span');
|
||||
progress.className = 'shepherd-progress text-base text-muted-foreground';
|
||||
progress.innerText = `${Shepherd.activeTour?.steps.indexOf(currentStep) + 1}/${Shepherd.activeTour?.steps.length}`;
|
||||
progress.style.position = 'absolute';
|
||||
progress.style.left = '13px';
|
||||
progress.style.bottom = '20px';
|
||||
progress.style.zIndex = '1';
|
||||
|
||||
const footer = currentStep?.getElement()?.querySelector('.shepherd-footer');
|
||||
footer?.appendChild(progress);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom middleware for adjusting Shepherd step positioning when overflowing.
|
||||
*
|
||||
* @type {object}
|
||||
* @property {string} name - The name of the middleware.
|
||||
* @property {function} fn - The function that adjusts the position of the step when overflowing.
|
||||
*/
|
||||
|
||||
const customMiddleware = {
|
||||
name: 'customOverflowMiddleware',
|
||||
async fn(state) {
|
||||
const overflow = await detectOverflow(state, {
|
||||
boundary: document.querySelector('body'),
|
||||
padding: 24,
|
||||
});
|
||||
|
||||
const xAdjustment =
|
||||
overflow.left > 0 ? overflow.left : overflow.right > 0 ? -overflow.right : 0;
|
||||
const yAdjustment =
|
||||
overflow.top > 0 ? overflow.top : overflow.bottom > 0 ? -overflow.bottom : 0;
|
||||
|
||||
return {
|
||||
x: state.x + xAdjustment,
|
||||
y: state.y + yAdjustment,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Default Floating UI middleware for positioning steps in Shepherd.js.
|
||||
* Includes offset, shift, flip, and custom overflow middleware.
|
||||
*
|
||||
* @type {Array<object>}
|
||||
*/
|
||||
|
||||
const middleware = [offset(15), shift(), flip(), customMiddleware];
|
||||
|
||||
export { hasTourBeenShown, markTourAsShown, middleware, defaultShowHandler };
|
||||
@ -67,6 +67,7 @@ import {
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
} from './DropdownMenu';
|
||||
import { Onboarding } from './Onboarding';
|
||||
|
||||
export {
|
||||
Button,
|
||||
@ -145,4 +146,5 @@ export {
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
Onboarding,
|
||||
};
|
||||
|
||||
@ -33,6 +33,7 @@ import {
|
||||
DisplaySetMessageListTooltip,
|
||||
Toolbox,
|
||||
ToolboxUI,
|
||||
Onboarding,
|
||||
} from './components';
|
||||
|
||||
import { useNotification, NotificationProvider } from './contextProviders';
|
||||
@ -76,4 +77,5 @@ export {
|
||||
DisplaySetMessageListTooltip,
|
||||
Toolbox,
|
||||
ToolboxUI,
|
||||
Onboarding,
|
||||
};
|
||||
|
||||
@ -17,6 +17,7 @@ function LayoutPreset({
|
||||
onClick={() => {
|
||||
onSelection(commandOptions);
|
||||
}}
|
||||
data-cy={title}
|
||||
>
|
||||
<Icon
|
||||
name={icon}
|
||||
|
||||
50
yarn.lock
50
yarn.lock
@ -561,7 +561,7 @@
|
||||
resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz#7844f9289546efa9febac2de4cfe358a050bd703"
|
||||
integrity sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==
|
||||
|
||||
"@babel/plugin-proposal-private-property-in-object@^7.21.11":
|
||||
"@babel/plugin-proposal-private-property-in-object@^7.16.7", "@babel/plugin-proposal-private-property-in-object@^7.21.11":
|
||||
version "7.21.11"
|
||||
resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.11.tgz#69d597086b6760c4126525cfa154f34631ff272c"
|
||||
integrity sha512-0QZ8qP/3RLDVBwBFoWAwCtgcDZJVwA5LUJRZU8x2YFfKNuFq161wK3cuGrALu5yiPu+vzwTAg/sMWVNeWeNyaw==
|
||||
@ -2584,6 +2584,13 @@
|
||||
dependencies:
|
||||
"@floating-ui/utils" "^0.2.0"
|
||||
|
||||
"@floating-ui/core@^1.6.0":
|
||||
version "1.6.8"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.6.8.tgz#aa43561be075815879305965020f492cdb43da12"
|
||||
integrity sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==
|
||||
dependencies:
|
||||
"@floating-ui/utils" "^0.2.8"
|
||||
|
||||
"@floating-ui/dom@^1.0.0", "@floating-ui/dom@^1.0.1":
|
||||
version "1.6.5"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.6.5.tgz#323f065c003f1d3ecf0ff16d2c2c4d38979f4cb9"
|
||||
@ -2592,6 +2599,14 @@
|
||||
"@floating-ui/core" "^1.0.0"
|
||||
"@floating-ui/utils" "^0.2.0"
|
||||
|
||||
"@floating-ui/dom@^1.6.5":
|
||||
version "1.6.11"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.6.11.tgz#8631857838d34ee5712339eb7cbdfb8ad34da723"
|
||||
integrity sha512-qkMCxSR24v2vGkhYDo/UzxfJN3D4syqSjyuTFz6C7XcpU1pASPRieNI0Kj5VP3/503mOfYiGY891ugBX1GlABQ==
|
||||
dependencies:
|
||||
"@floating-ui/core" "^1.6.0"
|
||||
"@floating-ui/utils" "^0.2.8"
|
||||
|
||||
"@floating-ui/react-dom@^2.0.0":
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.1.0.tgz#4f0e5e9920137874b2405f7d6c862873baf4beff"
|
||||
@ -2604,6 +2619,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.2.tgz#d8bae93ac8b815b2bd7a98078cf91e2724ef11e5"
|
||||
integrity sha512-J4yDIIthosAsRZ5CPYP/jQvUAQtlZTTD/4suA08/FEnlxqW3sKS9iAhgsa9VYLZ6vDHn/ixJgIqRQPotoBjxIw==
|
||||
|
||||
"@floating-ui/utils@^0.2.8":
|
||||
version "0.2.8"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.8.tgz#21a907684723bbbaa5f0974cf7730bd797eb8e62"
|
||||
integrity sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==
|
||||
|
||||
"@gar/promisify@^1.1.3":
|
||||
version "1.1.3"
|
||||
resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6"
|
||||
@ -4415,6 +4435,11 @@
|
||||
dependencies:
|
||||
any-observable "^0.3.0"
|
||||
|
||||
"@scarf/scarf@^1.3.0":
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@scarf/scarf/-/scarf-1.3.0.tgz#f8c75560d0dace4452dee1e31995e6396e61f3ee"
|
||||
integrity sha512-lHKK8M5CTcpFj2hZDB3wIjb0KAbEOgDmiJGDv1WBRfQgRm/a8/XMEkG/N1iM01xgbUDsPQwi42D+dFo1XPAKew==
|
||||
|
||||
"@sideway/address@^4.1.5":
|
||||
version "4.1.5"
|
||||
resolved "https://registry.yarnpkg.com/@sideway/address/-/address-4.1.5.tgz#4bc149a0076623ced99ca8208ba780d65a99b9d5"
|
||||
@ -7277,7 +7302,7 @@ axobject-query@^3.2.1:
|
||||
dependencies:
|
||||
dequal "^2.0.3"
|
||||
|
||||
b4a@^1.6.4, b4a@^1.6.6:
|
||||
b4a@^1.6.4, b4a@^1.6.6, b4a@^1.6.6:
|
||||
version "1.6.7"
|
||||
resolved "https://registry.yarnpkg.com/b4a/-/b4a-1.6.7.tgz#a99587d4ebbfbd5a6e3b21bdb5d5fa385767abe4"
|
||||
integrity sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==
|
||||
@ -9568,6 +9593,11 @@ deep-is@^0.1.3:
|
||||
resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831"
|
||||
integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==
|
||||
|
||||
deepmerge-ts@^5.1.0:
|
||||
version "5.1.0"
|
||||
resolved "https://registry.yarnpkg.com/deepmerge-ts/-/deepmerge-ts-5.1.0.tgz#c55206cc4c7be2ded89b9c816cf3608884525d7a"
|
||||
integrity sha512-eS8dRJOckyo9maw9Tu5O5RUi/4inFLrnoLkBe3cPfDMx3WZioXtmOew4TXQaxq7Rhl4xjDtR7c6x8nNTxOvbFw==
|
||||
|
||||
deepmerge@^1.5.2:
|
||||
version "1.5.2"
|
||||
resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-1.5.2.tgz#10499d868844cdad4fee0842df8c7f6f0c95a753"
|
||||
@ -18625,6 +18655,13 @@ react-shallow-renderer@^16.15.0:
|
||||
object-assign "^4.1.1"
|
||||
react-is "^16.12.0 || ^17.0.0 || ^18.0.0"
|
||||
|
||||
react-shepherd@^6.1.1:
|
||||
version "6.1.1"
|
||||
resolved "https://registry.yarnpkg.com/react-shepherd/-/react-shepherd-6.1.1.tgz#41d384cc4e97e26c9625b221d8b5289f4f924626"
|
||||
integrity sha512-lylVKsH8w9gV7674RznDhl4uPrTXLYuc2E0+gYJPrz4FymHrhUpDqYvYvqESPODigRK+TFFpTZAUdAZzwzPvRg==
|
||||
dependencies:
|
||||
shepherd.js "13.0.3"
|
||||
|
||||
react-simple-code-editor@^0.10.0:
|
||||
version "0.10.0"
|
||||
resolved "https://registry.yarnpkg.com/react-simple-code-editor/-/react-simple-code-editor-0.10.0.tgz#73e7ac550a928069715482aeb33ccba36efe2373"
|
||||
@ -19762,6 +19799,15 @@ shelljs@0.8.5, shelljs@^0.8.5:
|
||||
interpret "^1.0.0"
|
||||
rechoir "^0.6.2"
|
||||
|
||||
shepherd.js@13.0.3, shepherd.js@^13.0.3:
|
||||
version "13.0.3"
|
||||
resolved "https://registry.yarnpkg.com/shepherd.js/-/shepherd.js-13.0.3.tgz#78fc4b7a8c9df5a03b83c7893967c92b39396553"
|
||||
integrity sha512-1lQtQUNQYi+8k9BAmbUZh7D2QxFfkxiWKU0XFTbzYaIrCkB4nR0DLQuarH5G7Ym6L8wfbadxP3hJhZ2HzVktaA==
|
||||
dependencies:
|
||||
"@floating-ui/dom" "^1.6.5"
|
||||
"@scarf/scarf" "^1.3.0"
|
||||
deepmerge-ts "^5.1.0"
|
||||
|
||||
shx@^0.3.3:
|
||||
version "0.3.4"
|
||||
resolved "https://registry.yarnpkg.com/shx/-/shx-0.3.4.tgz#74289230b4b663979167f94e1935901406e40f02"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user