From a97f1ddf9d5a07e0dc70ce764d55260ead053b1d Mon Sep 17 00:00:00 2001 From: Alquen Sarmiento Date: Wed, 22 Oct 2025 21:21:08 +0800 Subject: [PATCH 1/3] feat: onboarding tour with name changes --- interactions.php | 1 + .../components/guided-modal-tour/README.MD | 37 ++ .../components/guided-modal-tour/editor.scss | 236 ++++++++ .../components/guided-modal-tour/index.js | 106 ++++ .../guided-modal-tour/tour-conditions.js | 2 + .../components/guided-modal-tour/util.js | 53 ++ src/editor/components/index.js | 1 + .../components/interaction-panel/index.js | 4 +- src/editor/components/modal-tour/index.js | 517 ++++++++++++++++++ .../components/modal-tour/tour-steps.js | 6 + .../components/modal-tour/tours/README.md | 163 ++++++ .../components/modal-tour/tours/editor.js | 20 + .../components/modal-tour/tours/index.js | 34 ++ .../modal-tour/tours/interaction-library.js | 51 ++ .../components/modal-tour/tours/sidebar.js | 65 +++ src/editor/components/timeline/index.js | 1 + src/editor/editor.js | 2 + src/editor/getting-started.php | 61 +++ src/editor/interaction-library/index.js | 8 +- .../interaction-library/select-modal.js | 1 + .../plugins/block-toolbar-button/index.js | 1 + .../add-interaction-button.js | 2 +- .../plugins/top-toolbar-button/index.js | 4 +- 23 files changed, 1370 insertions(+), 6 deletions(-) create mode 100644 src/editor/components/guided-modal-tour/README.MD create mode 100644 src/editor/components/guided-modal-tour/editor.scss create mode 100644 src/editor/components/guided-modal-tour/index.js create mode 100644 src/editor/components/guided-modal-tour/tour-conditions.js create mode 100644 src/editor/components/guided-modal-tour/util.js create mode 100644 src/editor/components/modal-tour/index.js create mode 100644 src/editor/components/modal-tour/tour-steps.js create mode 100644 src/editor/components/modal-tour/tours/README.md create mode 100644 src/editor/components/modal-tour/tours/editor.js create mode 100644 src/editor/components/modal-tour/tours/index.js create mode 100644 src/editor/components/modal-tour/tours/interaction-library.js create mode 100644 src/editor/components/modal-tour/tours/sidebar.js create mode 100644 src/editor/getting-started.php diff --git a/interactions.php b/interactions.php index 081bfa0..cc7d91a 100644 --- a/interactions.php +++ b/interactions.php @@ -135,3 +135,4 @@ function interact_require_types() { require_once( plugin_dir_path( __FILE__ ) . 'src/rest-api/class-rest-location-rules.php' ); require_once( plugin_dir_path( __FILE__ ) . 'src/editor/interaction-library/index.php' ); +require_once( plugin_dir_path( __FILE__ ) . 'src/editor/getting-started.php' ); diff --git a/src/editor/components/guided-modal-tour/README.MD b/src/editor/components/guided-modal-tour/README.MD new file mode 100644 index 0000000..931d995 --- /dev/null +++ b/src/editor/components/guided-modal-tour/README.MD @@ -0,0 +1,37 @@ +# How to create new tours + +## 1. Create a unique tourID for the tour + +Let's say the tourId is `interaction-library` + +## 2. Add the GuidedModalTour component + +Add the GuidedModalTour component to where we want to show the tour. This tour +will be visible only once when the component is rendered. + +For example in `interaction-library` tour, we can add it in the actual interaction library +modal render: + +```js + +``` + +## 3. Add initialization code to trigger the tour if needed + +Since the `GuidedModalTour` component is only rendered when the tour is actually +shown, if we are coming from the Getting Started screen, and we want to force +the tour to open and need to perform any code for this, then we can add them in: + +`src/editor/components/guided-modal-tour/index.js` + +## 4. Add the show condition for the tour + +By default, `GuidedModalTour` only shows once. When it's finished, it will not +show again. We can change this behavior e.g. stop the tour from showing, by +adding a condition in `./tour-conditions.js`. + +## 5. Create the tour steps + +Create the tour steps in `src/editor/components/modal-tour/tours/`, refer to +`src/editor/components/modal-tour/tours/README.md` for more details + diff --git a/src/editor/components/guided-modal-tour/editor.scss b/src/editor/components/guided-modal-tour/editor.scss new file mode 100644 index 0000000..972bfd3 --- /dev/null +++ b/src/editor/components/guided-modal-tour/editor.scss @@ -0,0 +1,236 @@ +.interact-tour-modal--overlay { + z-index: 1000002; + background-color: transparent !important; + pointer-events: none; +} + +.interact-tour-modal { + pointer-events: all; + position: absolute; + --offset-x: 0px; + --offset-y: 0px; + --left: 50%; + --top: 50%; + left: var(--left); + top: var(--top); + margin-left: var(--offset-x); + margin-top: var(--offset-y); + overflow: visible; + border-radius: 16px; + + --wp-admin-theme-color: #05f; + --wp-admin-theme-color-darker-10: #0044cc; + --wp-admin-theme-color-darker-20: #0036a1; + + // Smoothly transition moving top & left. + transition: + max-width 0.4s cubic-bezier(0.4, 0, 0.2, 1), + left 0.2s cubic-bezier(0.4, 0, 0.2, 1), + top 0.2s cubic-bezier(0.4, 0, 0.2, 1), + margin-left 0.2s cubic-bezier(0.4, 0, 0.2, 1), + margin-top 0.2s cubic-bezier(0.4, 0, 0.2, 1), + opacity 0.4s ease-in-out, + transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), + box-shadow 0.2s ease-in-out; + will-change: left, top, max-width; + + display: none; + &.interact-tour-modal--visible { + display: block !important; + opacity: 0 !important; + transform: scale(0.4); + } + &.interact-tour-modal--visible-delayed { + opacity: 1 !important; + transform: scale(1); + } + + .components-modal__header-heading { + line-height: 1.2; + } + + .components-button { + border-radius: 4px; + } + + .components-modal__content { + padding: 2em; + margin: 0; + position: relative; + overflow: visible; + z-index: 1; + box-shadow: 0 22px 200px 4px #0005; + border-radius: 16px; + // border: 1px solid #f00069ad; + } + .components-modal__header { + position: relative; + padding: 0; + margin-bottom: 8px; + height: auto; + line-height: 1.2; + } + .interact-tour-modal__footer { + margin-top: 16px; + justify-content: flex-end; + } + + .interact-tour-modal__help { + position: relative; + background: #f4fbff; + padding: 8px 12px; + padding-inline-start: 30px; + border-radius: 8px; + border: 1px solid rgba(0, 0, 0, 0.1); + margin-block: 16px; + svg { + vertical-align: text-top; + margin-inline-end: 8px; + margin-top: 1px; + position: absolute; + inset-inline-start: 9px; + } + } + + .interact-tour-modal__cta { + width: 100%; + justify-content: center; + margin: 16px 0 8px; + } + + &.interact-tour-modal--right, + &.interact-tour-modal--left, + &.interact-tour-modal--top, + &.interact-tour-modal--bottom { + .components-modal__content { + box-shadow: rgba(0, 0, 0, 0.2) -20px 22px 60px -4px; + &::after { + content: ""; + position: absolute; + top: 50%; + left: -10px; + width: 30px; + height: 30px; + transform: translateY(-50%) rotate(45deg); + border-radius: 4px; + background-color: #fff; + z-index: -1; + } + } + } + &.interact-tour-modal--left { + .components-modal__content { + box-shadow: rgba(0, 0, 0, 0.2) 20px 22px 60px -4px; + &::after { + left: auto; + right: -10px; + } + } + } + &.interact-tour-modal--left-top { + .components-modal__content { + &::after { + top: 30px; + } + } + } + &.interact-tour-modal--top { + .components-modal__content { + box-shadow: rgba(0, 0, 0, 0.2) 0px 22px 60px -4px; + &::after { + top: auto; + left: 50%; + bottom: -10px; + transform: translateX(-50%) rotate(45deg); + } + } + } + &.interact-tour-modal--top-right { + .components-modal__content { + &::after { + left: auto; + right: 16px; + } + } + } + &.interact-tour-modal--bottom { + .components-modal__content { + box-shadow: rgba(0, 0, 0, 0.2) 0px -22px 60px -4px; + &::after { + left: 50%; + top: -10px; + transform: translateX(-50%) rotate(45deg); + } + } + } +} + +.interact-tour-modal__steps { + display: flex; + gap: 6px; + margin-inline-end: auto; +} +.interact-tour-modal__step { + width: 8px; + height: 8px; + border-radius: 20px; + background-color: #e1e1e1; + // cursor: pointer; + padding: 0 !important; + margin: 0 !important; + + &--active { + background: #05f; + width: 24px; + border-radius: 20px; + } + + // &:hover { + // background-color: #aaa; + // } +} + +.interact-tour-modal__glow { + position: absolute; + z-index: 1000001; + box-shadow: 0 0 20px #05f; + border-radius: 8px; + pointer-events: none; + animation: tour-modal-glow 0.7s infinite alternate; + mix-blend-mode: multiply; + will-change: transform, box-shadow; + transition: opacity 0.2s ease-in-out; + opacity: 1; + &.interact-tour-modal__glow--hidden { + opacity: 0; + } +} + +.interact-tour-modal__glow--medium, +.interact-tour-modal__glow--large { + animation: tour-modal-glow-small 0.7s infinite alternate; +} + +// Animation keyframes to grow the box-shadow like it's glowing +@keyframes tour-modal-glow { + 0% { + box-shadow: 0 0 20px #05f, 0 0 5px #0036a1; + transform: scaleX(1) scaleY(1); + } + 100% { + box-shadow: 0 0 50px #05f, 0 0 5px #0036a1; + transform: scaleX(1.05) scaleY(1.12); + } +} + +// Animation keyframes for small glow +@keyframes tour-modal-glow-small { + 0% { + box-shadow: 0 0 20px #05f, 0 0 5px #0036a1; + transform: scaleX(1) scaleY(1); + } + 100% { + box-shadow: 0 0 50px #05f, 0 0 5px #0036a1; + transform: scaleX(1.02) scaleY(1.02); + } +} diff --git a/src/editor/components/guided-modal-tour/index.js b/src/editor/components/guided-modal-tour/index.js new file mode 100644 index 0000000..6d6e374 --- /dev/null +++ b/src/editor/components/guided-modal-tour/index.js @@ -0,0 +1,106 @@ +/** + * Internal dependencies + */ +// import { TOUR_STEPS } from './tour-steps' +import { TOUR_CONDITIONS } from './tour-conditions' +import { + clearActiveTour, + isTourActive, + getActiveTourId, + addTourStateListener, +} from './util' + +/** + * External dependencies + */ +import { guidedTourStates } from 'interactions' + +/** + * WordPress dependencies + */ +import { models } from '@wordpress/api' +import { + useEffect, useState, lazy, Suspense, memo, +} from '@wordpress/element' + +// The main tour component. +const GuidedModalTour = memo( props => { + const { + tourId = '', // This is the ID of the tour, this will be used to store the tour state in the database and to get the steps. + } = props + + // On mount, check if the tour has been completed, if so, don't show it. + const [ isDone, setIsDone ] = useState( guidedTourStates.includes( tourId ) ) + + // We need this to prevent the tour from being shown again if it's just completed. + const [ justCompleted, setJustCompleted ] = useState( false ) + + // Check if another tour is already active + const [ isAnotherTourActive, setIsAnotherTourActive ] = useState( isTourActive() && getActiveTourId() !== tourId ) + + // Listen for tour state changes + useEffect( () => { + const removeListener = addTourStateListener( activeId => { + setIsAnotherTourActive( activeId !== null && activeId !== tourId ) + } ) + return removeListener + }, [ tourId ] ) + + if ( justCompleted ) { + return null + } + + // If another tour is already active, don't show this tour + if ( isAnotherTourActive ) { + return null + } + + // If there is a condition, check if it's met, if not, don't show the tour. + // condition can be true, false, or null. true will show the tour (even if + // it's already done), false will not show the tour, null will show the tour + // only once (normal behavior). + const condition = TOUR_CONDITIONS[ tourId ] + const conditionResult = condition ? condition() : null + if ( conditionResult === false ) { + return null + } else if ( conditionResult === null ) { + if ( isDone ) { + return null + } + } + + // Only lazy-load ModalTour when we're actually going to render it + const ModalTour = lazy( () => import( /* webpackChunkName: "modal-tour" */ '../modal-tour' ) ) + + return ( + + { + setIsDone( true ) + setJustCompleted( true ) + + // Clear the active tour + clearActiveTour() + + // Update the interact_guided_tour_states setting + if ( ! guidedTourStates.includes( tourId ) ) { + // eslint-disable-next-line camelcase + const settings = new models.Settings( { interact_guided_tour_states: [ ...guidedTourStates, tourId ] } ) + settings.save() + } + + // Soft update the global variable to prevent the tour from being shown again. + guidedTourStates.push( tourId ) + + // Remove the "tour" GET parameter from the URL so conditions won't get triggered again. + const url = new URL( window.location.href ) + url.searchParams.delete( 'tour' ) + window.history.replaceState( null, '', url.toString() ) + } } + /> + + ) +} ) + +export default GuidedModalTour diff --git a/src/editor/components/guided-modal-tour/tour-conditions.js b/src/editor/components/guided-modal-tour/tour-conditions.js new file mode 100644 index 0000000..b3babfc --- /dev/null +++ b/src/editor/components/guided-modal-tour/tour-conditions.js @@ -0,0 +1,2 @@ +// For each condition, true will show the tour (even if it's already done), false will not show the tour, null will show the tour only once. +export const TOUR_CONDITIONS = {} diff --git a/src/editor/components/guided-modal-tour/util.js b/src/editor/components/guided-modal-tour/util.js new file mode 100644 index 0000000..a8f2f34 --- /dev/null +++ b/src/editor/components/guided-modal-tour/util.js @@ -0,0 +1,53 @@ +/** + * Global tour state management utilities + * + * This module provides functions to manage the global state of guided modal tours, + * ensuring that only one tour can be active at a time. + */ + +// Global tour state +let activeTourId = null +const tourStateListeners = new Set() + +/** + * Set the currently active tour + * + * @param {string} tourId - The ID of the tour to set as active + */ +export const setActiveTour = tourId => { + activeTourId = tourId + tourStateListeners.forEach( listener => listener( tourId ) ) +} + +/** + * Clear the currently active tour + */ +export const clearActiveTour = () => { + activeTourId = null + tourStateListeners.forEach( listener => listener( null ) ) +} + +/** + * Check if any tour is currently active + * + * @return {boolean} True if a tour is active, false otherwise + */ +export const isTourActive = () => activeTourId !== null + +/** + * Get the currently active tour ID + * + * @return {string|null} The active tour ID or null if no tour is active + */ +export const getActiveTourId = () => activeTourId + +/** + * Add a listener for tour state changes + * + * @param {Function} listener - Function to call when tour state changes + * @return {Function} Function to remove the listener + */ +export const addTourStateListener = listener => { + tourStateListeners.add( listener ) + return () => tourStateListeners.delete( listener ) +} diff --git a/src/editor/components/index.js b/src/editor/components/index.js index bd77999..21d5e89 100644 --- a/src/editor/components/index.js +++ b/src/editor/components/index.js @@ -8,6 +8,7 @@ export { default as AddInteractionButton } from './add-interaction-button' export { default as AddActionButton } from './add-action-button' export { default as TargetSelector } from './target-selector' export { default as ImportExportModal } from './import-export-modal' +export { default as GuidedModalTour } from './guided-modal-tour' export const GridLayout = props => { const { diff --git a/src/editor/components/interaction-panel/index.js b/src/editor/components/interaction-panel/index.js index 31e58ba..de72479 100644 --- a/src/editor/components/interaction-panel/index.js +++ b/src/editor/components/interaction-panel/index.js @@ -1,5 +1,5 @@ import { - LocationRules, Timeline, Separator, + LocationRules, Timeline, Separator, GuidedModalTour, } from '~interact/editor/components' import { interactions as interactionsConfig } from 'interactions' import { cloneDeep } from 'lodash' @@ -633,6 +633,8 @@ const InteractionPanel = props => { + + { /* {} + +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 { + 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. + } = steps[ currentStep ] + + 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 modalRect = modalRef.current.querySelector( '.interact-tour-modal' ).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 > 300 || 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..a2ff363 --- /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 0ccb454..c9740a8 100644 --- a/src/editor/components/timeline/index.js +++ b/src/editor/components/timeline/index.js @@ -1110,6 +1110,7 @@ const Timeline = props => { <> { ! isPreviewing && (