{}
+
+const ModalTour = memo( props => {
+ const {
+ tourId,
+ onClose = NOOP,
+ } = props
+
+ const [ currentStep, setCurrentStep ] = useState( 0 )
+ const [ isVisible, setIsVisible ] = useState( false )
+ const [ isVisibleDelayed, setIsVisibleDelayed ] = useState( false )
+ const [ forceRefresh, setForceRefresh ] = useState( 0 )
+ const [ isTransitioning, setIsTransitioning ] = useState( false )
+ const [ direction, setDirection ] = useState( 'forward' )
+ const modalRef = useRef( null )
+ const glowElementRef = useRef( null )
+
+ const {
+ steps = [],
+ hasConfetti = true,
+ initialize = NOOP,
+ } = useMemo( () =>
+ TOUR_STEPS[ tourId ] || {},
+ [ tourId ] )
+
+ const step = ( steps && steps[ currentStep ] ) || {}
+
+ const {
+ title,
+ description,
+ help = null, // If provided, a help text will be shown below the description.
+ ctaLabel = null, // If provided, a button will be shown with this label.
+ ctaOnClick = NOOP, // This will be called when the button is clicked, we will move to the next step after.
+ size = 'small', // Size of the modal. Can be 'small', 'medium', 'large'.
+ anchor = null, // This is a selector for the element to anchor the modal to. Defaults to middle of the screen.
+ position = 'center', // This is the position to place the modal relative to the anchor. Can be 'left', 'right', 'top', 'bottom', 'center'.
+ offsetX = '0px', // This is the X offset of the modal relative to the anchor.
+ offsetY = '0px', // This is the Y offset of the modal relative to the anchor.
+ showNext = true, // If true, a "Next" button will be shown.
+ nextEventTarget = null, // If provided, this is a selector for the element to trigger the next event if there is one.
+ nextEvent = 'click', // This is the event to listen for to trigger the next step.
+ glowTarget = null, // If provided, this is a selector for the element to glow when the step is active.
+ // eslint-disable-next-line no-unused-vars
+ preStep = NOOP, // If provided, this is a function to run before the step is shown.
+ // eslint-disable-next-line no-unused-vars
+ postStep = NOOP, // If provided, this is a function to run after the step is shown.
+ skipIf = NOOP, // If provided, this is a function to check if the step should be skipped.
+ } = step
+
+ useEffect( () => {
+ setTimeout( () => {
+ initialize()
+ }, 50 )
+ }, [ initialize ] )
+
+ // Set active tour when modal becomes visible
+ useEffect( () => {
+ if ( isVisible ) {
+ setActiveTour( tourId )
+ }
+ }, [ isVisible, tourId ] )
+
+ // Clear active tour when component unmounts
+ useEffect( () => {
+ return () => {
+ if ( getActiveTourId() === tourId ) {
+ clearActiveTour()
+ }
+ }
+ }, [ tourId ] )
+
+ // While the modal is visible, just keep on force refreshing the modal in an interval to make sure the modal is always in the correct position.
+ useEffect( () => {
+ let interval
+ if ( isVisible && ! isTransitioning ) {
+ interval = setInterval( () => {
+ setForceRefresh( forceRefresh => forceRefresh + 1 )
+ }, 500 )
+ }
+ return () => clearInterval( interval )
+ }, [ isVisible, isVisibleDelayed, isTransitioning ] )
+
+ // Create a stable function reference for the event listener
+ const handleNextEvent = useCallback( () => {
+ // Hide modal during transition
+ setIsVisible( false )
+ setIsVisibleDelayed( false )
+ setIsTransitioning( true )
+ setDirection( 'forward' )
+
+ // If at the last step, just close
+ if ( currentStep === steps.length - 1 ) {
+ steps[ currentStep ]?.postStep?.( currentStep )
+ if ( hasConfetti ) {
+ throwConfetti()
+ }
+ onClose()
+ return
+ }
+
+ setTimeout( () => {
+ setCurrentStep( currentStep => {
+ setTimeout( () => {
+ steps[ currentStep ]?.postStep?.( currentStep )
+ }, 50 )
+ const nextStep = currentStep + 1
+ setTimeout( () => {
+ steps[ nextStep ]?.preStep?.( nextStep )
+ }, 50 )
+ return nextStep
+ } )
+
+ // Show modal after 200ms delay
+ setTimeout( () => {
+ setIsVisible( true )
+ setTimeout( () => {
+ setIsVisibleDelayed( true )
+ setIsTransitioning( false )
+ }, 150 )
+ }, 200 )
+ }, 100 )
+
+ setTimeout( () => {
+ setForceRefresh( forceRefresh => forceRefresh + 1 )
+ }, 350 )
+ setTimeout( () => {
+ setForceRefresh( forceRefresh => forceRefresh + 1 )
+ }, 650 )
+ }, [ currentStep, steps, hasConfetti ] )
+
+ const handleBackEvent = useCallback( () => {
+ // Hide modal during transition
+ setIsVisible( false )
+ setIsVisibleDelayed( false )
+ setIsTransitioning( true )
+ setDirection( 'backward' )
+
+ setTimeout( () => {
+ setCurrentStep( currentStep => {
+ // steps[ currentStep ]?.postStep?.( currentStep )
+ const nextStep = currentStep - 1
+ steps[ nextStep ]?.preStep?.( nextStep )
+ return nextStep
+ } )
+
+ // Show modal after 200ms delay
+ setTimeout( () => {
+ setIsVisible( true )
+ setTimeout( () => {
+ setIsVisibleDelayed( true )
+ setIsTransitioning( false )
+ }, 150 )
+ }, 200 )
+ }, 100 )
+
+ setTimeout( () => {
+ setForceRefresh( forceRefresh => forceRefresh + 1 )
+ }, 350 )
+ setTimeout( () => {
+ setForceRefresh( forceRefresh => forceRefresh + 1 )
+ }, 650 )
+ }, [ currentStep, steps ] )
+
+ // If we just moved to this step, even before showing it check if we should skip it, if so, move to the next/prev step.
+ useEffect( () => {
+ if ( skipIf() ) {
+ if ( direction === 'forward' ) {
+ handleNextEvent()
+ } else {
+ handleBackEvent()
+ }
+ }
+ }, [ currentStep, direction ] )
+
+ // Show modal after 1 second delay
+ useEffect( () => {
+ const timer = setTimeout( () => {
+ setIsVisible( true )
+ setTimeout( () => {
+ setIsVisibleDelayed( true )
+ }, 150 )
+ }, 1050 )
+
+ return () => clearTimeout( timer )
+ }, [] )
+
+ useEffect( () => {
+ let clickListener = null
+
+ if ( nextEventTarget ) {
+ if ( nextEvent === 'click' || nextEvent === 'mousedown' || nextEvent === 'mouseup' ) {
+ clickListener = event => {
+ // Check if the event target matches the selector or is inside an element that matches
+ if (
+ event.target.matches( nextEventTarget ) ||
+ event.target.closest( nextEventTarget )
+ ) {
+ handleNextEvent()
+ }
+ }
+ // Use ownerDocument instead of document directly
+ const doc = modalRef.current?.ownerDocument || document
+ doc.addEventListener( nextEvent, clickListener )
+ } else {
+ const elements = document.querySelectorAll( nextEventTarget )
+ for ( let i = 0; i < elements.length; i++ ) {
+ elements[ i ].addEventListener( nextEvent, handleNextEvent )
+ }
+ }
+ }
+
+ return () => {
+ if ( nextEventTarget ) {
+ if ( ( nextEvent === 'click' || nextEvent === 'mousedown' || nextEvent === 'mouseup' ) && clickListener ) {
+ // Use ownerDocument instead of document directly
+ const doc = modalRef.current?.ownerDocument || document
+ doc.removeEventListener( nextEvent, clickListener )
+ } else {
+ const elements = document.querySelectorAll( nextEventTarget )
+ for ( let i = 0; i < elements.length; i++ ) {
+ elements[ i ].removeEventListener( nextEvent, handleNextEvent )
+ }
+ }
+ }
+ }
+ }, [ currentStep, nextEventTarget, nextEvent, handleNextEvent ] )
+
+ // Create the glow element while this component is mounted.
+ useEffect( () => {
+ // Create the element.
+ const element = document.createElement( 'div' )
+ element.className = `interact-tour-modal__glow interact-tour-modal__glow--hidden`
+ document.body.appendChild( element )
+
+ // Keep track of the element.
+ glowElementRef.current = element
+
+ return () => {
+ glowElementRef.current = null
+ element.remove()
+ }
+ }, [] )
+
+ // These are the X and Y offsets of the modal relative to the anchor. This will be
+ const [ modalOffsetX, modalOffsetY ] = useMemo( () => {
+ if ( ! modalRef.current ) {
+ return [ '', '' ] // This is for the entire screen.
+ }
+
+ const modalEl = modalRef.current?.querySelector?.( '.interact-tour-modal' )
+ if ( ! modalEl ) {
+ return [ '', '' ]
+ }
+ const modalRect = modalEl.getBoundingClientRect()
+
+ const defaultOffset = [ `${ ( window.innerWidth / 2 ) - ( modalRect.width / 2 ) }px`, `${ ( window.innerHeight / 2 ) - ( modalRect.height / 2 ) }px` ]
+
+ if ( ! anchor ) {
+ return defaultOffset // This is for the entire screen.
+ }
+
+ // Based on the anchor and position, calculate the X and Y offsets of the modal relative to the anchor.
+ // We have the modalRef.current which we can use to get the modal's bounding client rect.
+ const anchorRect = document.querySelector( anchor )?.getBoundingClientRect()
+
+ if ( ! anchorRect ) {
+ return defaultOffset
+ }
+
+ switch ( position ) {
+ case 'left':
+ // Left, middle
+ return [ `${ anchorRect.left - modalRect.width - 16 }px`, `${ anchorRect.top + ( anchorRect.height / 2 ) - ( modalRect.height / 2 ) }px` ]
+ case 'left-top':
+ return [ `${ anchorRect.left - modalRect.width - 16 }px`, `${ anchorRect.top + 16 }px` ]
+ case 'left-bottom':
+ return [ `${ anchorRect.left - modalRect.width - 16 }px`, `${ anchorRect.bottom - modalRect.height - 16 }px` ]
+ case 'right':
+ // Right, middle
+ return [ `${ anchorRect.right + 16 }px`, `${ anchorRect.top + ( anchorRect.height / 2 ) - ( modalRect.height / 2 ) }px` ]
+ case 'right-top':
+ return [ `${ anchorRect.right + 16 }px`, `${ anchorRect.top + 16 }px` ]
+ case 'right-bottom':
+ return [ `${ anchorRect.right + 16 }px`, `${ anchorRect.bottom - modalRect.height - 16 }px` ]
+ case 'top':
+ // Center, top
+ return [ `${ anchorRect.left + ( anchorRect.width / 2 ) - ( modalRect.width / 2 ) }px`, `${ anchorRect.top - modalRect.height - 16 }px` ]
+ case 'top-left':
+ return [ `${ anchorRect.left + 16 }px`, `${ anchorRect.top - modalRect.height - 16 }px` ]
+ case 'top-right':
+ return [ `${ anchorRect.right - modalRect.width - 16 }px`, `${ anchorRect.top - modalRect.height - 16 }px` ]
+ case 'bottom':
+ // Center, bottom
+ return [ `${ anchorRect.left + ( anchorRect.width / 2 ) - ( modalRect.width / 2 ) }px`, `${ anchorRect.bottom + 16 }px` ]
+ case 'bottom-left':
+ return [ `${ anchorRect.left + 16 }px`, `${ anchorRect.bottom + 16 }px` ]
+ case 'bottom-right':
+ return [ `${ anchorRect.right - modalRect.width - 16 }px`, `${ anchorRect.bottom + 16 }px` ]
+ case 'center':
+ return [ `${ anchorRect.left + ( anchorRect.width / 2 ) - ( modalRect.width / 2 ) }px`, `${ anchorRect.top + ( anchorRect.height / 2 ) - ( modalRect.height / 2 ) }px` ]
+ case 'center-top':
+ return [ `${ anchorRect.left + ( anchorRect.width / 2 ) - ( modalRect.width / 2 ) }px`, `${ anchorRect.top + 16 }px` ]
+ case 'center-bottom':
+ return [ `${ anchorRect.left + ( anchorRect.width / 2 ) - ( modalRect.width / 2 ) }px`, `${ anchorRect.bottom - modalRect.height - 16 }px` ]
+ default:
+ return defaultOffset
+ }
+ }, [ anchor, position, modalRef.current, isVisible, isVisibleDelayed, isTransitioning, forceRefresh ] )
+
+ // If we have a glow target, create a new element in the body, placed on the top of the target, below the modal.
+ useEffect( () => {
+ if ( glowTarget && isVisibleDelayed ) {
+ // Get the top, left, width, and height of the target.
+ const target = document.querySelector( glowTarget )
+ if ( target ) {
+ const targetRect = target.getBoundingClientRect()
+
+ // Estimate the size of the glow target based on the size of the target.
+ const glowTargetSize = targetRect.width > 600 || targetRect.height > 200 ? 'large'
+ : targetRect.width > 300 || targetRect.height > 100 ? 'medium'
+ : 'small'
+
+ // Create the element.
+ if ( glowElementRef.current ) {
+ glowElementRef.current.className = `interact-tour-modal__glow interact-tour-modal__glow--${ glowTargetSize }`
+ glowElementRef.current.style.top = `${ targetRect.top - 8 }px`
+ glowElementRef.current.style.left = `${ targetRect.left - 8 }px`
+ glowElementRef.current.style.width = `${ targetRect.width + 16 }px`
+ glowElementRef.current.style.height = `${ targetRect.height + 16 }px`
+ }
+ }
+ } else if ( glowElementRef.current ) {
+ glowElementRef.current.className = `interact-tour-modal__glow interact-tour-modal__glow--hidden`
+ }
+ }, [ glowTarget, currentStep, isVisible, isVisibleDelayed, isTransitioning, forceRefresh ] )
+
+ // When unmounted, do not call onClose. So we need to do this handler on our own.
+ useEffect( () => {
+ const handleHeaderClick = () => {
+ onClose()
+ }
+ if ( modalRef.current ) {
+ modalRef.current.querySelector( '.components-modal__header > .components-button' ).addEventListener( 'click', handleHeaderClick )
+ }
+ return () => {
+ if ( modalRef.current ) {
+ modalRef.current.querySelector( '.components-modal__header > .components-button' ).removeEventListener( 'click', handleHeaderClick )
+ }
+ }
+ }, [ modalRef.current, onClose ] )
+
+ if ( ! isVisible ) {
+ return null
+ }
+
+ return (
+
+
+ { description }
+ { help && (
+
+
+ { help }
+
+ ) }
+ { ctaLabel && (
+
+ ) }
+
+
+ { currentStep > 0 && (
+
+ ) }
+ { showNext && (
+
+ ) }
+
+
+ )
+} )
+
+const throwConfetti = () => {
+ confetti( {
+ particleCount: 50,
+ angle: 60,
+ spread: 70,
+ origin: { x: 0 },
+ zIndex: 100000,
+ disableForReducedMotion: true,
+ } )
+ confetti( {
+ particleCount: 50,
+ angle: 120,
+ spread: 70,
+ origin: { x: 1 },
+ zIndex: 100000,
+ disableForReducedMotion: true,
+ } )
+ setTimeout( () => {
+ confetti( {
+ particleCount: 50,
+ angle: -90,
+ spread: 90,
+ origin: { y: -0.3 },
+ zIndex: 100000,
+ disableForReducedMotion: true,
+ } )
+ }, 150 )
+}
+
+const Steps = memo( props => {
+ const {
+ numSteps = 3,
+ currentStep = 0,
+ } = props
+
+ if ( numSteps === 1 ) {
+ return null
+ }
+
+ return (
+
+ { Array.from( { length: numSteps } ).map( ( _, index ) => {
+ const classes = classNames( [
+ 'interact-tour-modal__step',
+ currentStep === index && 'interact-tour-modal__step--active',
+ ] )
+
+ return (
+
+ )
+ } ) }
+
+ )
+} )
+
+export default ModalTour
diff --git a/src/editor/components/modal-tour/tour-steps.js b/src/editor/components/modal-tour/tour-steps.js
new file mode 100644
index 0000000..bb5acc8
--- /dev/null
+++ b/src/editor/components/modal-tour/tour-steps.js
@@ -0,0 +1,6 @@
+// Import all tour files from the tours directory
+import { tours } from './tours/index.js'
+
+export const TOUR_STEPS = {
+ ...tours,
+}
diff --git a/src/editor/components/modal-tour/tours/README.md b/src/editor/components/modal-tour/tours/README.md
new file mode 100644
index 0000000..4f2d414
--- /dev/null
+++ b/src/editor/components/modal-tour/tours/README.md
@@ -0,0 +1,163 @@
+# Guided Modal Tour Documentation
+
+> For creating new Tours, refer to `src/editor/components/guided-modal-tour/README.MD`
+
+This directory contains individual tour configurations for the Interactions guided
+modal tour system. Each tour is defined in its own JavaScript file and
+automatically imported into the main tour system.
+
+## How It Works
+
+The tour system uses `require.context()` to automatically discover and import
+all `.js` files in this directory. Each tour's ID (`tourId`) is derived from its
+filename in kebab-case (e.g., `interaction-library.js` becomes `interaction-library`).
+Each tour file should export a named export corresponding to the tour's purpose.
+
+## Tour Structure
+
+Each tour file should export an object with the following structure:
+
+```javascript
+import { createInterpolateElement } from '@wordpress/element'
+
+export const tourName = {
+ // Tour-level properties
+ hasConfetti: false,
+ condition: () => { /* condition logic */ },
+ initialize: () => { /* optional initialization */ },
+ steps: [
+ // Array of step objects
+ ]
+}
+```
+
+## Tour-Level Properties
+
+### `steps` (array)
+The array of step objects that define the tour flow.
+
+### `hasConfetti` (boolean)
+If `true`, confetti is shown on the last step. Default is `true`.
+
+### `condition` (function)
+A function that returns:
+- `true` - Show the tour (even if it's already been completed)
+- `false` - Do not show the tour
+- `null` - Show the tour only once (default behavior)
+
+### `initialize` (function, optional)
+A function called when the tour starts. Useful for setting up initial state or content.
+
+## Step Properties
+
+Each step in a tour is an object with the following possible properties:
+
+### `title` (string)
+The title text displayed at the top of the modal.
+
+### `description` (string|ReactNode)
+The main content or instructions for the step.
+
+### `help` (string|ReactNode, optional)
+If provided, a help text will be shown below the description.
+
+### `size` (string, optional)
+The size of the modal. Can be:
+- `'small'` (default)
+- `'medium'`
+- `'large'`
+
+### `anchor` (string, optional)
+A CSS selector for the element to which the modal should be anchored. If not provided, modal is centered.
+
+### `position` (string, optional)
+The position of the modal relative to the anchor. Can be:
+- `'left'`
+- `'right'`
+- `'top'`
+- `'bottom'`
+- `'center'` (default)
+
+### `offsetX` (number, optional)
+X-axis offset in pixels for fine-tuning the modal's position relative to the anchor.
+
+### `offsetY` (number, optional)
+Y-axis offset in pixels for fine-tuning the modal's position relative to the anchor.
+
+### `ctaLabel` (string, optional)
+If provided, a call-to-action button will be shown with this label.
+
+### `ctaOnClick` (function, optional)
+Function to call when the CTA button is clicked. The tour will move to the next step after this is called.
+
+### `showNext` (boolean, optional)
+If `true`, a "Next" button is shown. Default is `true`.
+
+### `nextEventTarget` (string, optional)
+A CSS selector for an element. If provided, the tour will wait for the specified event on this element before moving to the next step.
+
+### `nextEvent` (string, optional)
+The event name to listen for on `nextEventTarget` (e.g., 'click'). Default is 'click'.
+
+### `glowTarget` (string, optional)
+A CSS selector for an element to highlight/glow during this step.
+
+### `preStep` (function, optional)
+Function called before the step is displayed. Useful for setup or preparation.
+
+### `postStep` (function, optional)
+Function called after the step is completed. Useful for cleanup or triggering actions.
+
+### `skipIf` (function, optional)
+Function that returns `true` if this step should be skipped. Useful for conditional steps.
+
+## Example Tour
+
+```javascript
+import { __ } from '@wordpress/i18n'
+import { createInterpolateElement } from '@wordpress/element'
+
+export const exampleTour = {
+ hasConfetti: false,
+ condition: () => {
+ // Only show if there's a specific URL parameter
+ return window?.location?.search?.includes('tour=example') ? true : null
+ },
+ steps: [
+ {
+ title: 'Welcome',
+ description: 'This is the first step.',
+ size: 'medium',
+ anchor: '.my-element',
+ position: 'bottom',
+ offsetX: 10,
+ offsetY: 0,
+ ctaLabel: 'Get Started',
+ ctaOnClick: () => { console.log('CTA clicked!') },
+ showNext: false,
+ nextEventTarget: '.my-button',
+ nextEvent: 'click',
+ glowTarget: '.my-element',
+ },
+ {
+ title: 'Second Step',
+ description: 'This is the second step.',
+ help: createInterpolateElement(
+ 'Click the Continue button to proceed.',
+ { strong: }
+ ),
+ anchor: '.another-element',
+ position: 'right',
+ nextEventTarget: '.continue-button',
+ glowTarget: '.another-element',
+ }
+ ]
+}
+```
+
+## Creating New Tours
+
+1. Create a new `.js` file in this directory
+2. Import the necessary dependencies (`__`, `createInterpolateElement`)
+3. Export a named export with your tour configuration
+4. The tour will be automatically discovered and included in the tour system
diff --git a/src/editor/components/modal-tour/tours/editor.js b/src/editor/components/modal-tour/tours/editor.js
new file mode 100644
index 0000000..6d0947b
--- /dev/null
+++ b/src/editor/components/modal-tour/tours/editor.js
@@ -0,0 +1,20 @@
+import { __ } from '@wordpress/i18n'
+import { createInterpolateElement } from '@wordpress/element'
+
+export const editor = {
+ hasConfetti: false,
+ steps: [
+ {
+ title: '👋 ' + __( 'Welcome to Interactions', 'interactions' ),
+ description: __( 'Transform your WordPress site with animations and dynamic interactions that bring your content to life. Let’s get started by exploring the Interaction Library.', 'interactions' ),
+ help: createInterpolateElement( __( 'Click the Interactions button to continue.', 'interactions' ), {
+ strong: ,
+ } ),
+ anchor: '.interact-insert-library-button',
+ position: 'bottom',
+ nextEventTarget: '.interact-insert-library-button',
+ glowTarget: '.interact-insert-library-button',
+ showNext: false,
+ },
+ ],
+}
diff --git a/src/editor/components/modal-tour/tours/index.js b/src/editor/components/modal-tour/tours/index.js
new file mode 100644
index 0000000..e47c7ab
--- /dev/null
+++ b/src/editor/components/modal-tour/tours/index.js
@@ -0,0 +1,34 @@
+// This file automatically imports all tour files from the tours directory
+// and exports them as a single object for use in TOUR_STEPS
+
+// Dynamically import all tour files from the tours directory
+const tourContext = require.context( './', false, /\.js$/ )
+const tours = {}
+
+// Import all tour files and populate the tours object using the filename as the key
+tourContext.keys().forEach( fileName => {
+ // Skip this index.js file itself
+ if ( fileName === './index.js' ) {
+ return
+ }
+
+ // Import the tour module
+ const tourModule = tourContext( fileName )
+
+ // Use the filename (without extension) as the key
+ const tourName = fileName.replace( './', '' ).replace( '.js', '' )
+
+ // Prefer default export, fallback to first named export if available
+ if ( tourModule.default ) {
+ tours[ tourName ] = tourModule.default
+ } else {
+ // If no default export, use the first named export (if any)
+ const namedExports = Object.keys( tourModule ).filter( name => name !== 'default' )
+ if ( namedExports.length > 0 ) {
+ tours[ tourName ] = tourModule[ namedExports[ 0 ] ]
+ }
+ }
+} )
+
+// Export all tours as a single object
+export { tours }
diff --git a/src/editor/components/modal-tour/tours/interaction-library.js b/src/editor/components/modal-tour/tours/interaction-library.js
new file mode 100644
index 0000000..9c18f9b
--- /dev/null
+++ b/src/editor/components/modal-tour/tours/interaction-library.js
@@ -0,0 +1,51 @@
+import { __ } from '@wordpress/i18n'
+import { createInterpolateElement } from '@wordpress/element'
+
+export const interactionLibrary = {
+ steps: [
+ {
+ title: '👋 ' + __( 'Interaction Library', 'interactions' ),
+ description: __( 'The Interaction Library contains curated presets for both simple and advanced animations that you can insert in just one click.', 'interactions' ),
+ offsetX: '-400px',
+ },
+ {
+ title: __( 'Filtering by Categories', 'interactions' ),
+ description: __( 'Quickly find the type of interaction you need by filtering presets by category or using the search bar above.', 'interactions' ),
+ help: createInterpolateElement( __( 'Select the Button category from the sidebar filter.', 'interactions' ), {
+ strong: ,
+ } ),
+ anchor: '.interact-interaction-library__select__category--button',
+ position: 'right',
+ nextEventTarget: '.interact-interaction-library__select__category--button',
+ glowTarget: '.interact-interaction-library__select__category--button',
+ showNext: false,
+ },
+ {
+ title: __( 'Insert or Customize', 'interactions' ),
+ description: __( 'You can insert an interaction as-is, or go deeper by customizing its settings.', 'interactions' ),
+ help: createInterpolateElement( __( 'Hover over the interaction and click Customize on a preset to continue.', 'interactions' ), {
+ strong: ,
+ } ),
+ anchor: '.interact-interaction-library__select__preset-card',
+ position: 'bottom',
+ nextEventTarget: '.interact-interaction-library__select__preset-card',
+ glowTarget: '.interact-interaction-library__select__preset-card',
+ offsetY: '-100px',
+ showNext: false,
+ },
+ {
+ title: __( 'Customize Section', 'interactions' ),
+ description: __( 'Each interaction can be tailored to your needs. Adjust interaction type, triggers, and animation styles with intuitive controls.', 'interactions' ),
+ help: createInterpolateElement( __( 'Tweak a few settings and click Insert.', 'interactions' ), {
+ strong: ,
+ } ),
+ anchor: '.interact-interaction-library__configure-middle',
+ position: 'left',
+ nextEventTarget: '.interact-interaction-library__configure__apply-button__button',
+ glowTarget: '.interact-interaction-library__configure__apply-button__button',
+ offsetY: '200px',
+ showNext: false,
+ },
+ ],
+}
+
diff --git a/src/editor/components/modal-tour/tours/sidebar.js b/src/editor/components/modal-tour/tours/sidebar.js
new file mode 100644
index 0000000..96f1406
--- /dev/null
+++ b/src/editor/components/modal-tour/tours/sidebar.js
@@ -0,0 +1,65 @@
+import { __ } from '@wordpress/i18n'
+import { createInterpolateElement } from '@wordpress/element'
+import { select, dispatch } from '@wordpress/data'
+
+export const sidebar = {
+ steps: [
+ {
+ title: __( 'Inspector Panel', 'interactions' ),
+ description: __( 'You can create your own interaction in the inspector or further adjust the interaction inserted from the library.', 'interactions' ),
+ help: __( 'Explore the inspector settings to see how it works.', 'interactions' ),
+ anchor: '.interact-interaction-card',
+ position: 'left',
+ glowTarget: '.interact-sidebar',
+ },
+ {
+ title: __( 'Previewing Interaction', 'interactions' ),
+ description: __( 'Once you’re satisfied, preview your page to see interactions in action. Remember, you can always come back and edit later.', 'interactions' ),
+ help: createInterpolateElement( __( 'Click Preview to view the result, then save your changes.', 'interactions' ), {
+ strong: ,
+ } ),
+ size: 'medium',
+ anchor: '.interact-timeline__preview-button',
+ position: 'left',
+ nextEventTarget: '.interact-timeline__preview-button',
+ glowTarget: '.interact-timeline__preview-button',
+ preStep: () => {
+ // Scroll to the preview button before moving to the next step.
+ document.querySelector( '.interact-timeline__preview-button' )?.scrollIntoView( {
+ behavior: 'smooth',
+ block: 'center',
+ } )
+ },
+ },
+ {
+ title: __( 'Apply to Existing Elements', 'interactions' ),
+ description: __( 'Interactions aren’t limited to new content. You can also add animations to elements you’ve already created.', 'interactions' ),
+ help: createInterpolateElement( __( 'Select an existing block and click the Interactions logo to open the library.', 'interactions' ), {
+ strong: ,
+ } ),
+ size: 'medium',
+ anchor: '.interact-block-toolbar-button',
+ position: 'bottom',
+ nextEventTarget: '.interact-block-toolbar-button',
+ glowTarget: '.interact-block-toolbar-button',
+ preStep: () => {
+ // Select the last block in the editor
+ const blocks = select( 'core/block-editor' ).getBlocks()
+
+ // Recursively find the last innermost block
+ const getLastInnermostBlock = block => {
+ if ( block.innerBlocks && block.innerBlocks.length ) {
+ return getLastInnermostBlock( block.innerBlocks[ block.innerBlocks.length - 1 ] )
+ }
+ return block
+ }
+
+ if ( blocks.length ) {
+ const lastBlock = blocks[ blocks.length - 1 ]
+ const innermostBlock = getLastInnermostBlock( lastBlock )
+ dispatch( 'core/block-editor' ).selectBlock( innermostBlock.clientId )
+ }
+ },
+ },
+ ],
+}
diff --git a/src/editor/components/timeline/index.js b/src/editor/components/timeline/index.js
index 3f09bf8..064a021 100644
--- a/src/editor/components/timeline/index.js
+++ b/src/editor/components/timeline/index.js
@@ -1145,6 +1145,7 @@ const Timeline = props => {
<>
{ ! isPreviewing && (
: null
+ return (
+ <>
+ { interactionLibraryMode ? : null }
+
+ >
+ )
}
registerPlugin( 'interact-editor', {
diff --git a/src/editor/getting-started.php b/src/editor/getting-started.php
new file mode 100644
index 0000000..67c282b
--- /dev/null
+++ b/src/editor/getting-started.php
@@ -0,0 +1,96 @@
+ 'array',
+ 'description' => __( 'An array of strings representing completed block tours.', 'interactions' ),
+ 'sanitize_callback' => array( $this, 'sanitize_array_setting' ),
+ 'show_in_rest' => array(
+ 'schema' => array(
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'string',
+ ),
+ ),
+ ),
+ 'default' => array(),
+ )
+ );
+ }
+
+ public function sanitize_array_setting( $input ) {
+ if ( ! is_array( $input ) ) {
+ return array();
+ }
+ return array_map( 'sanitize_text_field', $input );
+ }
+
+ public function register_route() {
+ // Use a custom route because /wp/v2/settings requires manage_options.
+ // Editors can complete tours, so they need a capability-appropriate save path.
+ register_rest_route( 'interact/v1', '/guided_tour_states', array(
+ 'methods' => 'POST',
+ 'callback' => array( $this, 'update_guided_tour_states' ),
+ 'permission_callback' => function () {
+ return current_user_can( 'edit_posts' );
+ },
+ 'args' => array(
+ 'states' => array(
+ 'required' => true,
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'string',
+ ),
+ ),
+ ),
+ ) );
+ }
+
+ public function update_guided_tour_states( $request ) {
+ // Store completion per user so each editor sees each tour only once.
+ $states = $this->sanitize_array_setting( $request->get_param( 'states' ) );
+ update_user_meta( get_current_user_id(), 'interact_guided_tour_states', $states );
+
+ return rest_ensure_response( $states );
+ }
+
+ public function add_localize_script( $args ) {
+ $user_states = get_user_meta( get_current_user_id(), 'interact_guided_tour_states', true );
+ // Include legacy option-based states and current-user states.
+ $args['guidedTourStates'] = array_values( array_unique( array_merge(
+ get_option( 'interact_guided_tour_states', array() ),
+ is_array( $user_states ) ? $user_states : array()
+ ) ) );
+ return $args;
+ }
+ }
+
+ new Interact_Getting_Started_Screen();
+}
diff --git a/src/editor/interaction-library/configure-modal.js b/src/editor/interaction-library/configure-modal.js
index 59bf2a7..efb5ae6 100644
--- a/src/editor/interaction-library/configure-modal.js
+++ b/src/editor/interaction-library/configure-modal.js
@@ -57,100 +57,99 @@ export const ConfigureModal = props => {
} = useInteractions()
const handleApply = () => {
- const config = getConfig( selectedPreset.config )
- let isRunDefaultConfig = true
- // Allow an entry to handle configurations and block generation for complex interactions
- if ( config ) {
+ setTimeout( () => {
try {
- isRunDefaultConfig = config( selectedPreset, optionValues, interactionSetup )
- } catch ( error ) {
- console.error( 'Interactions Library configuration error:', error.message ) // eslint-disable-line no-console
- isRunDefaultConfig = false
- }
- }
-
- if ( isRunDefaultConfig ) {
- // Edit the timelines based on the user's configuration
- configurableOptions.forEach( option => {
- const value = optionValues[ option.key ] ?? option.controls.default ?? ''
- option.mappings.forEach( mapping => {
- const transformFn = getConfig( mapping.transformFn ) ?? ( v => v )
- const transformedValue = transformFn( value )
- setValueAtPath( interactionSetup, mapping.path, transformedValue )
- } )
- } )
+ const config = getConfig( selectedPreset.config )
+ let isRunDefaultConfig = true
+ // Allow an entry to handle configurations and block generation for complex interactions
+ if ( config ) {
+ isRunDefaultConfig = config( selectedPreset, optionValues, interactionSetup )
+ }
- const targetMappings = selectedPreset.targetMappings
+ if ( isRunDefaultConfig ) {
+ // Edit the timelines based on the user's configuration
+ configurableOptions.forEach( option => {
+ const value = optionValues[ option.key ] ?? option.controls.default ?? ''
+ option.mappings.forEach( mapping => {
+ const transformFn = getConfig( mapping.transformFn ) ?? ( v => v )
+ const transformedValue = transformFn( value )
+ setValueAtPath( interactionSetup, mapping.path, transformedValue )
+ } )
+ } )
- // If mode is inset, create a new block based on the seralized example.
- // Otherwise, use the target from target selector.
- if ( mode === 'insert' ) {
- const block = parse( selectedPreset.serializedBlockExample ?? '' )[ 0 ]
- if ( ! block ) {
- return
+ const targetMappings = selectedPreset.targetMappings
+
+ // If mode is inset, create a new block based on the seralized example.
+ // Otherwise, use the target from target selector.
+ if ( mode === 'insert' ) {
+ const block = parse( selectedPreset.serializedBlockExample ?? '' )[ 0 ]
+ if ( ! block ) {
+ throw new Error( 'Error: Invalid block example provided for this interaction.' )
+ }
+ dispatch( 'core/block-editor' ).insertBlocks( block )
+
+ // If target mappings are provided, dynamically create target for each.
+ applyTargetMappings( interactionSetup, targetMappings, block, )
+ } else if ( mode === 'apply' ) {
+ applyTargetMappings( interactionSetup, targetMappings, selectedTarget )
+ }
}
- dispatch( 'core/block-editor' ).insertBlocks( block )
- // If target mappings are provided, dynamically create target for each.
- applyTargetMappings( interactionSetup, targetMappings, block, )
- } else if ( mode === 'apply' ) {
- applyTargetMappings( interactionSetup, targetMappings, selectedTarget )
- }
- }
-
- // Ensure the interactions are in an array format
- const interactions = Array.isArray( interactionSetup )
- ? interactionSetup
- : [ interactionSetup ]
-
- // Create new actions based on import to regenerate action key
- // and fill missing properties with defaults.
- interactions.forEach( ( interaction, index, array ) => {
- const timelines = interaction.timelines ?? []
- interaction.timelines = timelines.map( timeline => {
- const actions = ( timeline.actions ?? [] ).map( action =>
- createNewAction( {
- actionType: action.type ?? '',
- start: action.timing?.start ?? 0,
- targetType: action.target?.type ?? '',
- props: { ...action },
+ // Ensure the interactions are in an array format
+ const interactions = Array.isArray( interactionSetup )
+ ? interactionSetup
+ : [ interactionSetup ]
+
+ // Create new actions based on import to regenerate action key
+ // and fill missing properties with defaults.
+ interactions.forEach( ( interaction, index, array ) => {
+ const timelines = interaction.timelines ?? []
+ interaction.timelines = timelines.map( timeline => {
+ const actions = ( timeline.actions ?? [] ).map( action =>
+ createNewAction( {
+ actionType: action.type ?? '',
+ start: action.timing?.start ?? 0,
+ targetType: action.target?.type ?? '',
+ props: { ...action },
+ } )
+ )
+
+ return { ...timeline, actions }
} )
- )
-
- return { ...timeline, actions }
- } )
-
- // Bypass anchor change confirmation since the change is only for the newly created block.
- dispatch( 'interact/interactions' ).setDidModifyPostContent( false )
-
- // Save through the editor if it's the last interaction
- if ( index === array.length - 1 ) {
- // Create the new interaction
- window.dispatchEvent( new window.CustomEvent( 'interact/add-interaction', {
- detail: {
- type: interaction?.type ?? '',
- target: interaction.target,
- props: interaction,
- },
- } ) )
-
- setTimeout( () => {
- window?.dispatchEvent( new CustomEvent( 'interact/save-interaction' ) )
- dispatch( 'core/editor' ).savePost()
- }, 100 )
- } else {
- const newInteraction = createNewInteraction(
- interaction?.type ?? '',
- interaction.target,
- interaction,
- )
- updateInteraction( newInteraction )
+
+ // Bypass anchor change confirmation since the change is only for the newly created block.
+ dispatch( 'interact/interactions' ).setDidModifyPostContent( false )
+
+ // Save through the editor if it's the last interaction
+ if ( index === array.length - 1 ) {
+ setTimeout( () => {
+ window.dispatchEvent( new window.CustomEvent( 'interact/add-interaction', {
+ detail: {
+ type: interaction?.type ?? '',
+ target: interaction.target,
+ props: interaction,
+ },
+ } ) )
+ window?.dispatchEvent( new CustomEvent( 'interact/save-interaction' ) )
+ dispatch( 'core/editor' ).savePost()
+ }, 100 )
+ } else {
+ const newInteraction = createNewInteraction(
+ interaction?.type ?? '',
+ interaction.target,
+ interaction,
+ )
+ updateInteraction( newInteraction )
+ }
+ } )
+
+ // Close the modal, and open the sidebar for the new interaction.
+ closeModal()
+ openInteractionsSidebar()
+ } catch ( error ) {
+ console.error( 'Interactions Library configuration error:', error.message ) // eslint-disable-line no-console
}
} )
-
- // Close the modal, and open the sidebar for the new interaction.
- closeModal()
- openInteractionsSidebar()
}
// If skipConfig is true, just apply the interaction user without configuration.
diff --git a/src/editor/interaction-library/index.js b/src/editor/interaction-library/index.js
index 1c153a7..5d0215a 100644
--- a/src/editor/interaction-library/index.js
+++ b/src/editor/interaction-library/index.js
@@ -9,6 +9,7 @@ import { isPresetApplicable, useInteractionPresets } from './util'
/**
* External deprendencies
*/
+import { GuidedModalTour } from '~interact/editor/components'
/**
* WordPress deprendencies
@@ -241,6 +242,7 @@ export const InteractionLibrary = () => {
className="interact-modal interact-interaction-library-modal"
onRequestClose={ handleClose }
>
+
{ isLoadingPresets ? (
{
type="button"
className={ classNames(
'interact-interaction-library__select__category',
+ 'interact-interaction-library__select__category--' + category.value,
{ active: selectedCategory === category.value }
) }
onClick={ () => setSelectedCategory( category.value ) }
diff --git a/src/editor/plugins/block-toolbar-button/index.js b/src/editor/plugins/block-toolbar-button/index.js
index 7ad49be..c9c5ba7 100644
--- a/src/editor/plugins/block-toolbar-button/index.js
+++ b/src/editor/plugins/block-toolbar-button/index.js
@@ -127,6 +127,7 @@ const withBlockToolbarButton = createHigherOrderComponent( BlockEdit => {
}
title={ label }
onClick={ () => setIsPopoverOpen( value => ! value ) }
diff --git a/src/editor/plugins/top-toolbar-button/add-interaction-button.js b/src/editor/plugins/top-toolbar-button/add-interaction-button.js
index 738b080..189c06c 100644
--- a/src/editor/plugins/top-toolbar-button/add-interaction-button.js
+++ b/src/editor/plugins/top-toolbar-button/add-interaction-button.js
@@ -23,7 +23,7 @@ const AddInteractionButton = () => {
ev.preventDefault()
}
} }
- className="ugb-insert-library-button"
+ className="interact-insert-library-button"
icon={ }
>
{ __( 'Interactions', 'interactions' ) }
diff --git a/src/editor/plugins/top-toolbar-button/index.js b/src/editor/plugins/top-toolbar-button/index.js
index 42c046d..3595e1f 100644
--- a/src/editor/plugins/top-toolbar-button/index.js
+++ b/src/editor/plugins/top-toolbar-button/index.js
@@ -21,8 +21,8 @@ const mountAddButton = () => {
if ( toolbar ) {
// If the button gets lost, just attach it again.
if ( ! toolbar.querySelector( '.interact-add-interaction-button-wrapper' ) ) {
- // If .ugb-insert-library-button__wrapper button is present, add after this button.
- const insertLibraryButton = toolbar.querySelector( '.ugb-insert-library-button__wrapper' )
+ // If .interact-insert-library-button__wrapper button is present, add after this button.
+ const insertLibraryButton = toolbar.querySelector( '.interact-insert-library-button__wrapper' )
if ( insertLibraryButton ) {
insertLibraryButton.after( buttonDiv )
} else {