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);
diff --git a/platform/core/src/types/AppTypes.ts b/platform/core/src/types/AppTypes.ts
index 7d5d35f5e..579dfa313 100644
--- a/platform/core/src/types/AppTypes.ts
+++ b/platform/core/src/types/AppTypes.ts
@@ -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 {
diff --git a/platform/docs/docs/configuration/tour-demo.gif b/platform/docs/docs/configuration/tour-demo.gif
new file mode 100644
index 000000000..f351f759b
Binary files /dev/null and b/platform/docs/docs/configuration/tour-demo.gif differ
diff --git a/platform/docs/docs/configuration/tours.md b/platform/docs/docs/configuration/tours.md
new file mode 100644
index 000000000..3c8828a8d
--- /dev/null
+++ b/platform/docs/docs/configuration/tours.md
@@ -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
+
+![Tour 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.
diff --git a/platform/ui-next/package.json b/platform/ui-next/package.json
index ca62fb19f..a0f19c1c2 100644
--- a/platform/ui-next/package.json
+++ b/platform/ui-next/package.json
@@ -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"
diff --git a/platform/ui-next/src/components/Onboarding/Onboarding.css b/platform/ui-next/src/components/Onboarding/Onboarding.css
new file mode 100644
index 000000000..2df4130f3
--- /dev/null
+++ b/platform/ui-next/src/components/Onboarding/Onboarding.css
@@ -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;
+}
diff --git a/platform/ui-next/src/components/Onboarding/Onboarding.tsx b/platform/ui-next/src/components/Onboarding/Onboarding.tsx
new file mode 100644
index 000000000..7b9fc0852
--- /dev/null
+++ b/platform/ui-next/src/components/Onboarding/Onboarding.tsx
@@ -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 };
diff --git a/platform/ui-next/src/components/Onboarding/index.ts b/platform/ui-next/src/components/Onboarding/index.ts
new file mode 100644
index 000000000..459553f0b
--- /dev/null
+++ b/platform/ui-next/src/components/Onboarding/index.ts
@@ -0,0 +1,3 @@
+import { Onboarding } from './Onboarding';
+
+export { Onboarding };
diff --git a/platform/ui-next/src/components/Onboarding/utilities.ts b/platform/ui-next/src/components/Onboarding/utilities.ts
new file mode 100644
index 000000000..bcd8c646a
--- /dev/null
+++ b/platform/ui-next/src/components/Onboarding/utilities.ts
@@ -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