/** * Global window objects for the USBuilder ecosystem: * - $usb: Core mounting and extension initialization. * - $usbcore: Builder and extension helper functions. * - $ush: UpSolution Helper Library. */ ! function( $, _undefined ) { const _window = window; if ( ! _window.$usb ) { return; } _window.$ush = _window.$ush || {}; _window.$usbcore = _window.$usbcore || {}; var itemTemplate; // Private temp data. const _tempData = { dragDropInitialized: false, isDragStarted: false, }; /** * Navigator - Shortcode navigator functionality in the page content (right sidebar). */ function Navigator() { const self = this; // Bindable events self._events = { cancelDrag: self.cancelDrag.bind( self ), contentChange: self._contentChange.bind( self ), duplicateElm: self._duplicateElm.bind( self ), endDrag: self.endDrag.bind( self ), maybeCancelDrag: self.maybeCancelDrag.bind( self ), expand: self._expand.bind( self ), expandAll: self._expandAll.bind( self ), hide: self._hide.bind( self ), iframeReady: self._iframeReady.bind( self ), maybeDrag: self.maybeDrag.bind( self ), maybeStartDrag: self.maybeStartDrag.bind( self ), navigatorLeave: self.navigatorLeave.bind( self ), panelShowMessage: self._panelShowMessage.bind( self ), removeElm: self._removeElm.bind( self ), scrollTo: self._scrollTo.bind( self ), selectedElm: self.selectedElm.bind( self ), shortcodeChanged: self._shortcodeChanged.bind( self ), showPreloader: self._showPreloader.bind( self ), switch: self._switch.bind( self ), urlManager: self._urlManager.bind( self ), }; $( () => { // Elements self.$navigator = $( '#usb-navigator' ); self.$body = $( '.usb-navigator-body', self.$navigator ); // Actions self.$actionHide = $( '.usb_action_navigator_hide', self.$navigator ); self.$actionSwitch = $( '.usb_action_switch_navigator', $usb.$panel ); self.$actionExpandAll = $( '.usb_action_navigator_expand_all', self.$navigator ); // Events self.$navigator // Handler for hide the navigator .on( 'click', '.usb_action_navigator_hide', self._events.hide ) // Handler for open or close all containers in the navigator .on( 'click', '.usb_action_navigator_expand_all', self._events.expandAll ) // Handler for open or close container in the navigator .on( 'click', '.usb_action_navigator_expand', self._events.expand ) // Handler for selected element via navigator .on( 'click', '.usb-navigator-item-header', self._events.selectedElm ) // Handler for duplicate element via navigator .on( 'click', '.usb_action_navigator_duplicate_elm', self._events.duplicateElm ) // Handler for remove element via navigator .on( 'click', '.usb_action_navigator_remove_elm', self._events.removeElm ); $usb.$panel // Handler for switch navigator .on( 'click', '.usb_action_switch_navigator', self._events.switch ); // Get item template var $itemTemplate = $( '#usb-navigator-item-template', self.$navigator ); if ( $itemTemplate.length ) { itemTemplate = $ush.toString( $itemTemplate[0].innerHTML ); $itemTemplate.remove(); } } ); // Private events self.on( 'showPreloader', self._events.showPreloader ); $usb .on( 'iframeReady', self._events.iframeReady ) .on( 'builder.contentChange', self._events.contentChange ) .on( 'navigator.scrollTo', self._events.scrollTo ) .on( 'navigator.showPreloader', self._events.showPreloader ) .on( 'shortcodeChanged', self._events.shortcodeChanged ) .on( 'panel.showMessage', self._events.panelShowMessage ) .on( 'urlManager.changed', self._events.urlManager ); } // Navigator API $.extend( Navigator.prototype, $ush.mixinEvents, { /** * Check ready state. * * @returns {Boolean} True if ready. */ isReady: function() { return ! $ush.isUndefined( this.$navigator ); }, /** * Check navigator visibility. * * @returns {Boolean} True if visible. */ isVisible: function() { return $usb.urlManager.hasParam( 'navigator', 'show' ); }, /** * Find closest parent with 'data-element' attribute. * * @param {HTMLElement} node Starting element. * @returns {HTMLElement|null} Found element or null. */ findClosestElement: function( node ) { if ( $ush.isNode( node ) ) { return node.closest( '[data-element]' ); } return null; }, /** * Handle display toggle. * * @event handler */ _switch: function() { const urlManager = $usb.urlManager; if ( ! this.isVisible() ) { urlManager.setParam( 'navigator', 'show' ); } else { urlManager.removeParam( 'navigator' ); } urlManager.push(); }, /** * Hide navigator. * * @event handler */ _hide: function() { $usb.urlManager.removeParam( 'navigator' ).push(); }, /** * Show navigator. */ show: function() { const self = this; if ( $usb.builder.isEmptyContent() ) { return; } self.$navigator.addClass( 'show' ); self.$actionSwitch.addClass( 'active' ); self.redraw(); self.initDragDrop(); }, /** * Hide navigator. */ hide: function() { const self = this; self.$navigator.removeClass( 'show' ); self.$actionSwitch.removeClass( 'active' ); self.destroyDragDrop(); }, /** * Scroll to navigator item if needed. * * @event handler * @param {String} id Element ID. */ _scrollTo: function( id ) { const self = this; if ( ! self.isVisible() || ! $usb.builder.isValidId( id ) ) { return; } const $body = self.$body; const $item = $( `[data-element="${id}"]`, $body ); if ( ! $item.length ) { return; } // If the element is not outside the view, then exit const rect = $ush.$rect( $item[0] ); if ( ! ( rect.top < 0 || rect.bottom > ( $body.height() || rect.height ) ) ) { return; } // Get the navigator header height var headerHeight = $( '.usb-navigator-header', self.$navigator ).height(); $body[0].scrollTo( 0, rect.top + $body.scrollTop() - headerHeight ); }, /** * Show preloader. * * @event handler * @param {String} id Shortcode's usbid, e.g. "us_btn:1" */ _showPreloader: function( id ) { const self = this; if ( ! self.isVisible() || ! $usb.builder.doesElmExist( id ) ) { return; } // Get item node and create clone node var $item = $( `[data-element="${id}"]`, self.$body ), $duplicateItem = $item.clone() .removeAttr( 'data-element' ) .removeClass('expand active') .addClass( 'duplicate' ); // Add and show preloader $( '> .usb-navigator-item-header .usb-navigator-item-title > i', $duplicateItem ) .after( '' ) .addClass( 'hidden' ); // Add clone node to body $item.after( $duplicateItem ); }, /** * Toggle navigator item state. * * @event handler * @param {Event} e */ _expand: function( e ) { const $item = $( e.target ).closest( '[data-element]' ); if ( $item.length ) { $item.toggleClass( 'expand', ! $item.hasClass( 'expand' ) ); } }, /** * Toggle all navigator items. * * @event handler */ _expandAll: function() { const self = this; const $action = self.$actionExpandAll; const $items = $( '[data-element].has_children', self.$body ).add( $action ); if ( ! $action.hasClass( 'expand' ) ) { $items.addClass( 'expand' ); } else { $items.removeClass( 'expand' ); } }, /** * Handle navigator selection. * * @event handler * @param {Event} e */ selectedElm: function( e ) { const self = this; const $target = $( e.target ); if ( self.isDragStarted() || $target.hasClass( 'usb_action_navigator_expand' ) ) { return } const id = $target.closest( '[data-element]' ).data( 'element' ); $ush.timeout( () => { $usb.postMessage( 'doAction', [ 'scrollToOutsideElm', id ] ); }, 100 ); if ( $usb.builder.selectedElmId !== id ) { $usb.trigger( 'builder.elmSelected', id ); } }, /** * Duplicate element via navigator. * * @event handler * @param {Event} e * @returns {Boolean} Stops further handling if false. */ _duplicateElm: function( e ) { const self = this; const id = ( self.findClosestElement( e.target ) || {} ).dataset['element'] || _undefined; if ( ! $usb.builder.doesElmExist( id ) || self.isDragStarted() ) { return false; } self.trigger( 'showPreloader', id ); $usb.trigger( 'builder.elmDuplicate', id ); return false; }, /** * Remove element via navigator. * * @event handler * @param {Event} e */ _removeElm: function( e ) { const id = $( e.target ).closest( '[data-element]' ).data( 'element' ); if ( $usb.builder.doesElmExist( id ) ) { $usb.trigger( 'builder.elmDelete', id ); } }, /** * Remove an element via navigator. * * @param {String} id Element ID (e.g. "vc_row:1"). */ removeElm: function( id ) { const self = this; if ( ! self.isVisible() || self.isDragStarted() || ! $usb.builder.isValidId( id ) ) { return; } $( `[data-element="${id}"]:first`, self.$body ).remove(); }, /** * Set the active navigator item. * * @param {String} id Element ID (e.g. "vc_row:1"). * @param {Boolean} expandParents Whether to expand parent items. */ setActive: function( id, expandParents ) { const self = this; if ( ! self.isVisible() || ! $usb.builder.doesElmExist( id ) ) { return; } self.resetActive(); // Activate the selected item and expand all parents $( `[data-element="${id}"]`, self.$body ) .addClass( 'active' ) .parents( '[data-element]' ) .toggleClass( 'expand', !! expandParents ); }, /** * Reset an active item in navigator. */ resetActive: function() { if ( this.isVisible() ) { $( '[data-element].active', this.$body ).removeClass( 'active' ); } }, /** * Toggle button state. * * @param {Boolean} isDisabled Disabled state. */ buttonControl: function( isDisabled ) { this.$actionSwitch.toggleClass( 'disabled', isDisabled ); }, /** * Redraw the items. * * NOTE: The synchronization method can be called many times, so it must be fast! */ redraw: function() { const self = this; if ( $usb.iframeIsReady !== true || $usb.builder.isEmptyContent() || ! self.isVisible() ) { return; } /** * Builds element navigation. * * @param {String} id Element ID. * @param {Node|DocumentFragment} node Target container. * @returns {DocumentFragment|Node} Generated structure. */ const getItems = ( elmsId, node, level ) => { if ( ! Array.isArray( elmsId ) || elmsId.length === 0 ) { return node; } level++; elmsId.map( ( elmId ) => { const attrId = $usb.builder.getElmValue( elmId, 'el_id', '' ); // Create a navigator node from a template const $item = $( $usb.buildString( itemTemplate, { attr_id: ( attrId ? '#' + attrId : '' ), in_editor_name: $usb.builder.getElmValue( elmId, 'row_title', '' ), elm_icon: $usb.config( 'elm_icons.' + $usb.builder.getElmName( elmId ), 'no-icon' ), // the element icon elm_title: $usb.builder.getElmTitle( elmId ), elm_type: $usb.builder.getElmType( elmId ), usbid: elmId, } ) ); // Get the children of the current item var itemChildren = $usb.builder.getElmChildren( elmId ); if ( itemChildren.length ) { getItems( itemChildren, $item, level ); // expand of containers if previously expanded $item .addClass( 'has_children' ) .toggleClass( 'expand', expandedIds.has( elmId ) ); } $item.addClass( `level_${level}` ); node.append( $item.get(0) ); } ); return node; } // Snapshot currently-expanded containers once. Previously each container ran a // separate `[data-element].expand` selector scan of the whole navigator body, // making a redraw O(containers × DOM size); one query keeps it O(n). const expandedIds = new Set(); self.$body[0].querySelectorAll( '[data-element].expand' ).forEach( ( node ) => { expandedIds.add( node.dataset.element ); } ); // Get the structure of elements start from the $usb.builder.rootContainer self.$body.html( getItems( $usb.builder.getElmChildren( $usb.builder.rootContainer ), new DocumentFragment, /* level */0 ) ); // Set the active item self.setActive( $usb.builder.selectedElmId, /* expand parent */true ); }, /** * Show the panel messages. * * @event handler */ _panelShowMessage: function() { this.resetActive(); }, /** * Handler for changed in shortcode. * * @param {{}} data The updated data * @event handler */ _shortcodeChanged: function( data ) { const self = this; if ( ! $.isPlainObject( data ) ) { return; } // Reactive update of the id attribute display in the navigator if ( self.isVisible() && data.name === 'el_id' ) { if ( data.value ) { data.value = '#' + data.value; } $( `[data-element="${data.id}"] .for_attr_id:first`, self.$body ).text( data.value ); } }, /** * Handle page changes. * * @event handler */ _contentChange: function() { const self = this; // Disabled/Enable switch navigation button const isEmptyContent = $usb.builder.isEmptyContent(); if ( isEmptyContent ) { self.hide(); } self.buttonControl( /* isDisabled */isEmptyContent ); // Redraw the element tree if ( ! isEmptyContent && self.isVisible() ) { $ush.debounce_fn_1ms( self.redraw.bind( self ) ); } }, /** * Handle iframe ready event. * * @event handler */ _iframeReady: function() { const self = this; const isEmptyContent = $usb.builder.isEmptyContent(); self.buttonControl( isEmptyContent ); if ( ! isEmptyContent ) { self._urlManager( $usb.urlManager.getDataOfChange() ); } if ( self.isVisible() ) { $ush.timeout( self.redraw.bind( self ), 1 ); } }, /** * Handle history navigation events. * * @event handler * @param {{}} state History state object. */ _urlManager: function( state ) { const self = this; if ( ! self.isReady() ) { return; } // Hide for the "Site Settings" page if ( self.isVisible() && state.setParams.action === $usb.config( 'actions.site_settings' ) ) { self.hide(); return; } // Show or hide "Navigator" if ( $usb.urlManager.hasParam( 'navigator', 'show' ) ) { self.show(); } else { self.hide(); } } } ); // Drag & Drop $.extend( Navigator.prototype, { /** * Init Drag & Drop. */ initDragDrop: function() { const self = this; if ( self.isDragDropInitialized() ) { return; } _tempData.dragDropInitialized = true; // Track events for Drag & Drop $usb.$document .on( 'pointerdown', self._events.maybeStartDrag ) .on( 'pointermove', self._events.maybeDrag ) .on( 'pointerup', self._events.endDrag ) // Abort (not commit) the drag when the interaction is cancelled by the browser // (touch interruption, context menu, ...) or the user presses Escape. .on( 'pointercancel', self._events.cancelDrag ) .on( 'keydown', self._events.maybeCancelDrag ); // Use direct binding on $navigator for reliable pointerleave detection self.$navigator .on( 'pointerleave', self._events.navigatorLeave ); }, /** * Destroy Drag & Drop. */ destroyDragDrop: function() { const self = this; if ( ! self.isDragDropInitialized() ) { return; } _tempData.dragDropInitialized = false; // Remove events $usb.$document .off( 'pointerdown', self._events.maybeStartDrag ) .off( 'pointermove', self._events.maybeDrag ) .off( 'pointerup', self._events.endDrag ) .off( 'pointercancel', self._events.cancelDrag ) .off( 'keydown', self._events.maybeCancelDrag ); self.$navigator .off( 'pointerleave', self._events.navigatorLeave ); $usbcore.cache( 'drag_navigator_data' ).flush(); }, /** * Check if Drag & Drop is initialized. * * @returns {boolean} True if initialized, false otherwise. */ isDragDropInitialized: function() { return _tempData.dragDropInitialized === true; }, /** * Check if a drag operation is active. * * @return {boolean} True if dragging, false otherwise. */ isDragStarted: function() { return _tempData.isDragStarted === true; }, /** * Handles the start of element movement. * * @event handler * @param {Event} e */ maybeStartDrag: function( e ) { const self = this; if ( ! self.isDragDropInitialized() ) { return; } const movedElement = self.findClosestElement( e.target ); if ( ! $ush.isNode( movedElement ) ) { return; } const movedElementId = movedElement.dataset.element; $usbcore.cache( 'drag_navigator_data' ).set( { isContainer: $usb.builder.isChildElmContainer( movedElementId ), movedElementId: movedElementId, movedElement: movedElement, startX: e.pageX || 0, startY: e.pageY || 0, } ); }, /** * Handles possible drag start. * * @event handler * @param {Event} e */ maybeDrag: function( e ) { const self = this; if ( ! self.isDragDropInitialized() ) { return; } const cache = $usbcore.cache( 'drag_navigator_data' ); if ( cache.isEmpty() ) { return; } const dragData = cache.data(); const dragThreshold = $usb.builder.DRAG_SHIFT_THRESHOLD; if ( Math.abs( dragData.startX - e.pageX ) <= dragThreshold && Math.abs( dragData.startY - e.pageY ) <= dragThreshold ) { return; } // Drag is now active — flag the navigator root so row hover states are suppressed via // CSS (analog of the preview's body.usb_dragging). Idempotent; drag logic is untouched. $usbcore.$addClass( self.$navigator[0], 'usb_dragging' ); const dropElement = self.findClosestElement( e.target ); if ( ! $ush.isNode( dropElement ) ) { return; } const dropElementId = dropElement.dataset.element; // READ PHASE: read cursor geometry BEFORE moving the transit ghost below, so this // getBoundingClientRect() call doesn't force a synchronous reflow against the pending // style write (a write→read layout thrash that fired on every pointermove). // The navigator is a 1-D vertical list, so "insert before vs after" is decided purely by // the cursor's Y against the row's vertical midpoint. getCursorDirection() is a 2-D wedge // whose diagonal boundaries would otherwise let horizontal cursor movement flip the insert // order (#4448); it is kept ONLY for the closed-container left-edge "drop inside" // affordance below, where an X-based decision is intended. const cursorDirection = $ush.getCursorDirection( e, dropElement ); const dropRect = $ush.$rect( dropElement ); const insertAfter = ( e.clientY - dropRect.top ) > ( dropRect.height / 2 ); // WRITE PHASE: the transit ghost follows the cursor on every move. It must represent the // element being dragged, not whatever row is currently under the cursor. if ( ! $usb.builder.hasTransit() ) { $usb.builder.showTransit( dragData.movedElementId, e.pageX, e.pageY ); } else { $usb.builder.setTransitPosition( e.pageX, e.pageY ); } if ( dropElementId === dragData.movedElementId || $.contains( dragData.movedElement, dropElement ) ) { return; } // Skip redundant DOM churn when the drop target and direction are unchanged since // the previous move — the dropplace classes and cached insert position already hold. if ( dragData.dropElement === dropElement && dragData.lastCursorDirection === cursorDirection && dragData.lastInsertAfter === insertAfter ) { return; } const dropElementIsContainer = $usb.builder.isElmContainer( dropElementId ); const isClosedContainer = ( dropElementIsContainer && ! dragData.isContainer && ! $usbcore.$hasClass( dropElement, 'expand' ) ); const dropInsideContainer = ( isClosedContainer && cursorDirection === $ush.DIRECTION_LEFT ); var dropContainerId; if ( dropInsideContainer ) { dropContainerId = dropElementId; } else { dropContainerId = $usb.builder.getElmParentId( dropElementId ); } if ( $usb.builder.hasSameTypeParent( dragData.movedElementId, dropContainerId ) ) { return; } const isDefaultRoot = ( ! $usb.builder.isEmptyContent() && $usb.builder.isDefaultRootContainer( dropContainerId ) && ! $usb.builder.isRow( dropElementId ) ); const strictMode = ( dropElementIsContainer || isDefaultRoot ); if ( ! $usb.builder.canBeChildOf( dragData.movedElementId, dropContainerId, strictMode ) ) { return; } $usbcore.$removeClass( dragData.dropElement, 'dropplace pos_bottom pos_around' ); if ( ! $usbcore.$hasClass( dragData.movedElement, 'draggable_element' ) ) { $usbcore.$addClass( dragData.movedElement, 'draggable_element' ); } dragData.dropElement = dropElement; dragData.lastCursorDirection = cursorDirection; dragData.lastInsertAfter = insertAfter; // Temporarily removes an element from the active map and stashes it. $usb.builder.excludeElement( dragData.movedElementId ); const children = $usb.builder.getElmChildren( dropContainerId ); const maxPossibleIndex = children.length; var insertIndex = children.indexOf( dropElementId ); if ( insertIndex < 0 ) { insertIndex = maxPossibleIndex - 1; } if ( ! $usbcore.$hasClass( dropElement, 'dropplace' ) ) { $usbcore.$addClass( dropElement, 'dropplace' ); } // Insert after when the cursor is in the lower half of the row; a closed-container "drop // inside" always enters here (append into the container) regardless of the vertical half. if ( dropInsideContainer || insertAfter ) { const dropPlace = dropInsideContainer ? 'pos_around' : 'pos_bottom'; $usbcore.$addClass( dropElement, dropPlace ); insertIndex = Math.min( insertIndex + 1, maxPossibleIndex ); } dragData.insertIndex = insertIndex; dragData.dropContainerId = dropContainerId; _tempData.isDragStarted = true; }, /** * Finalizes a drag and clears all visual state. * * @param {boolean} commit When true, commit the move (if a valid drop target was reached); * when false, abort and restore the element excluded during the drag. */ _finishDrag: function( commit ) { const self = this; const cache = $usbcore.cache( 'drag_navigator_data' ); const dragData = cache.data(); // Always restore row hover states when the drag ends. $usbcore.$removeClass( self.$navigator[0], 'usb_dragging' ); if ( cache.isEmpty() ) { return; } if ( commit && self.isDragStarted() ) { $usb.builder.moveElm( dragData.movedElementId, dragData.dropContainerId, dragData.insertIndex ); } else { // Aborting: restore the element that maybeDrag() excluded from the map, otherwise it // is stranded outside elementsMap (moveElm would normally restore it on commit). $usb.builder.includeExcludedElements(); } // Cleanup runs unconditionally: the transit ghost and drop indicators are shown as soon // as the pointer starts moving — before isDragStarted flips true — so a drag that ended // without a valid target must still clear them. if ( $ush.isNode( dragData.dropElement ) ) { $usbcore.$removeClass( dragData.dropElement, 'dropplace pos_bottom pos_around' ); } $usbcore.$removeClass( dragData.movedElement, 'draggable_element' ); $usb.builder.hideTransit(); cache.flush(); $ush.timeout( () => { _tempData.isDragStarted = false; }, 25 ); }, /** * Handles drag end (pointerup): commits the move. * * @event handler */ endDrag: function() { this._finishDrag( true ); }, /** * Aborts an in-progress drag without moving the element (pointercancel / Escape / leave). * * @event handler */ cancelDrag: function() { this._finishDrag( false ); }, /** * Cancels the drag on the Escape key. * * @event handler * @param {Event} e */ maybeCancelDrag: function( e ) { if ( this.isDragStarted() && e && e.key === 'Escape' ) { this.cancelDrag(); } }, /** * Handles dragged item leaving the navigator area: aborts the drag rather than committing * it, so a stray pointer move past the navigator edge (button still held) never silently * reorders content. The user commits only by releasing (pointerup) inside the navigator. */ navigatorLeave: function() { if ( this.isDragStarted() ) { this.cancelDrag(); } }, } ); $usb.navigator = new Navigator(); } ( jQuery );