Plugin Directory

Changeset 3679318


Ignore:
Timestamp:
09/03/2026 08:19:55 AM (7 weeks ago)
Author:
hadi.omid
Message:

Release version 3.0.0

Location:
navrail-toc-for-elementor
Files:
10 edited
1 copied

Legend:

Unmodified
Added
Removed
  • navrail-toc-for-elementor/tags/3.0.0/assets/css/navrail-nav.css

    r3671253 r3679318  
    2121        --navrail-nav-media-direction: row;
    2222        --navrail-nav-item-height: 0px;
     23        --navrail-nav-items-gap: 24px;
     24        --navrail-nav-item-padding-block: 8px;
     25        --navrail-nav-item-padding-inline: 16px;
    2326
    2427        display: flex;
    … …  
    5053        flex-wrap: nowrap;
    5154        align-items: center;
     55        gap: var(--navrail-nav-items-gap);
    5256        list-style: none;
    5357        margin: 0;
    … …  
    8084        gap: var(--navrail-nav-title-spacing);
    8185        min-height: var(--navrail-nav-item-height);
     86        padding-block: var(--navrail-nav-item-padding-block);
     87        padding-inline: var(--navrail-nav-item-padding-inline);
    8288        max-width: 100%;
    8389        white-space: nowrap;
    … …  
    128134   the link's accessible name instead of being removed from the DOM. */
    129135.navrail-nav__title--visually-hidden {
     136        position: absolute;
     137        width: 1px;
     138        height: 1px;
     139        padding: 0;
     140        margin: -1px;
     141        overflow: hidden;
     142        clip: rect(0, 0, 0, 0);
     143        white-space: nowrap;
     144        border: 0;
     145}
     146
     147/* Used by the Gutenberg "Navrail Navigation" block only: since its Items
     148   are static, individually-saved child blocks that can't know the parent's
     149   "Show Titles" setting at save time (block context isn't available inside
     150   a static block's save()), the parent's own wrapper carries this class
     151   instead and hides every descendant title with one rule — functionally
     152   identical to the per-item class above that Elementor's PHP applies. */
     153.navrail-nav--hide-titles .navrail-nav__title {
    130154        position: absolute;
    131155        width: 1px;
  • navrail-toc-for-elementor/tags/3.0.0/assets/css/navrail-toc.css

    r3661787 r3679318  
    2121        --htoc-separator-width: 1px;
    2222        --htoc-separator-height: 16px;
     23        --htoc-items-gap: 24px;
     24        --htoc-item-padding-block: 8px;
     25        --htoc-item-padding-inline: 16px;
    2326
    2427        display: flex;
    … …  
    6770        flex-wrap: nowrap;
    6871        align-items: center;
     72        gap: var(--htoc-items-gap);
    6973        list-style: none;
    7074        margin: 0;
    … …  
    9599        display: inline-flex;
    96100        align-items: center;
     101        padding-block: var(--htoc-item-padding-block);
     102        padding-inline: var(--htoc-item-padding-inline);
    97103        max-width: 100%;
    98104        white-space: nowrap;
  • navrail-toc-for-elementor/tags/3.0.0/assets/js/navrail-core.js

    r3671253 r3679318  
    2121        var NavrailCore = {};
    2222
     23        var _rtlUsesNegativeScrollLeft = null;
     24
     25        /**
     26         * One-time, cached, purely behavioral detection of which `scrollLeft`
     27         * convention this browser engine uses for RTL scroll containers.
     28         * Every evergreen engine today follows the CSSOM View spec (0 at
     29         * reading-start, running down to `-max` at reading-end), but two
     30         * older conventions existed historically: a positive-only scheme
     31         * (`max` at reading-start, down to `0` at reading-end) and a scheme
     32         * that left `scrollLeft` completely unadjusted for direction
     33         * (numerically identical to LTR — `0` to `+max`). Both older schemes
     34         * share the trait this test relies on: neither accepts a negative
     35         * `scrollLeft` value, so assigning one gets silently clamped back to
     36         * `0`. Only the modern convention actually allows it to go negative.
     37         * A single assignment-and-read-back on a disposable probe element
     38         * distinguishes the two cases without depending on any element's
     39         * live scroll position — unlike inferring the convention from the
     40         * sign of a real track's current `scrollLeft`, which is ambiguous
     41         * exactly at the boundary where that value is `0`.
     42         *
     43         * @return {boolean} True if this engine uses the modern (negative)
     44         *                    convention.
     45         */
     46        function rtlUsesNegativeScrollLeft() {
     47                if ( null !== _rtlUsesNegativeScrollLeft ) {
     48                        return _rtlUsesNegativeScrollLeft;
     49                }
     50
     51                if ( 'undefined' === typeof document || ! document.body ) {
     52                        // No DOM available to probe. Assume the modern spec — true
     53                        // for every current evergreen engine.
     54                        return true;
     55                }
     56
     57                var probe = document.createElement( 'div' );
     58                probe.setAttribute( 'dir', 'rtl' );
     59                probe.style.cssText = 'position:absolute;visibility:hidden;top:-9999px;left:-9999px;width:2px;height:2px;overflow:scroll;';
     60
     61                var inner = document.createElement( 'div' );
     62                inner.style.cssText = 'width:6px;height:1px;';
     63                probe.appendChild( inner );
     64
     65                document.body.appendChild( probe );
     66                probe.scrollLeft = -1;
     67                _rtlUsesNegativeScrollLeft = ( probe.scrollLeft < 0 );
     68                document.body.removeChild( probe );
     69
     70                return _rtlUsesNegativeScrollLeft;
     71        }
     72
    2373        /**
    2474         * @param {Function} fn
    … …  
    64114                this.resizeObserver = null;
    65115                this._fadeRaf = null;
     116                this._isProgrammaticScroll = false;
    66117
    67118                this.onClick = this.onClick.bind( this );
    … …  
    228279
    229280                var activeItem = this.getItemById( id );
    230                 if ( activeItem ) {
     281                if ( activeItem && ! this._isProgrammaticScroll ) {
    231282                        this.scrollItemIntoView( activeItem.linkEl );
    232283                }
    … …  
    234285
    235286        /**
    236          * Brings the active item into view inside the horizontally scrollable
    237          * track using the native `scrollIntoView` `inline` alignment option.
    238          * This is deliberately used instead of manually computing `scrollLeft`,
    239          * because browsers have historically disagreed on how `scrollLeft`
    240          * behaves in RTL contexts; `scrollIntoView`'s `inline` option is
    241          * handled internally by the browser per the current CSSOM View spec
    242          * and is consistent across engines regardless of that discrepancy.
    243          * `block: 'nearest'` prevents any accidental vertical page scroll.
     287         * Brings the active item into view by setting `this.track`'s own
     288         * `scrollLeft` directly — never `Element.scrollIntoView()`.
     289         *
     290         * `scrollIntoView()` was used originally because manually computing
     291         * `scrollLeft` in RTL is awkward (see `getNormalizedScrollLeft`).
     292         * But `scrollIntoView()` also walks up and scrolls every *other*
     293         * ancestor scroll container needed to bring the element into view —
     294         * including the page itself — via its `block` option. `block:
     295         * 'nearest'` only minimizes that vertical scroll, it does not
     296         * eliminate it: once this widget's own bar has scrolled out of the
     297         * vertical viewport (routine during organic scrolling past earlier
     298         * headings, since the bar itself lives at a fixed point on the page),
     299         * "nearest" for the *link* is no longer "no scroll" — the browser
     300         * still has to move the page to make an off-screen link visible at
     301         * all, which fights whatever scroll the reader is already doing.
     302         *
     303         * Setting `scrollLeft` on `this.track` touches only that element.
     304         * There is no `block` option and no ancestor involved, so this is
     305         * structurally incapable of moving the page vertically — regardless
     306         * of whether the track happens to be on-screen at the moment.
    244307         *
    245308         * @param {HTMLElement} linkEl
    246309         */
    247310        Base.prototype.scrollItemIntoView = function ( linkEl ) {
     311                if ( ! this.track ) {
     312                        return;
     313                }
     314
     315                var max = this.track.scrollWidth - this.track.clientWidth;
     316
     317                if ( max <= 0 ) {
     318                        return;
     319                }
     320
    248321                var align = this.getResponsiveValue( this.config.align ) || 'nearest';
     322                var viewportWidth = this.track.clientWidth;
     323                var edges = this.getContentEdges( linkEl, max );
     324                var current = this.getNormalizedScrollLeft( this.track, max );
     325                var target;
     326
     327                if ( 'start' === align ) {
     328                        target = edges.start;
     329                } else if ( 'end' === align ) {
     330                        target = edges.end - viewportWidth;
     331                } else if ( 'center' === align ) {
     332                        target = ( edges.start + edges.end ) / 2 - ( viewportWidth / 2 );
     333                } else if ( edges.start < current ) {
     334                        // 'nearest': item's start edge has scrolled past the
     335                        // viewport's start edge — pull it back into view there.
     336                        target = edges.start;
     337                } else if ( edges.end > current + viewportWidth ) {
     338                        // Item's end edge is past the viewport's end edge.
     339                        target = edges.end - viewportWidth;
     340                } else {
     341                        // Already fully visible; 'nearest' means "do nothing".
     342                        return;
     343                }
     344
     345                target = Math.max( 0, Math.min( max, target ) );
     346
    249347                var reducedMotion = !! ( window.matchMedia && window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches );
    250 
    251                 try {
    252                         linkEl.scrollIntoView( {
    253                                 behavior: reducedMotion ? 'auto' : 'smooth',
    254                                 block: 'nearest',
    255                                 inline: align
    256                         } );
    257                 } catch ( e ) {
    258                         linkEl.scrollIntoView();
     348                var track = this.track;
     349                var previousBehavior = track.style.scrollBehavior;
     350
     351                // The stylesheet sets `scroll-behavior: auto` on the track so
     352                // drag/wheel scrolling stays instant; switching to `smooth` only
     353                // for this JS-driven move, then reverting once it's had time to
     354                // finish, keeps that native drag/wheel feel intact.
     355                track.style.scrollBehavior = reducedMotion ? 'auto' : 'smooth';
     356                this.setNormalizedScrollLeft( track, max, target );
     357
     358                if ( ! reducedMotion ) {
     359                        setTimeout( function () {
     360                                track.style.scrollBehavior = previousBehavior;
     361                        }, 500 );
     362                } else {
     363                        track.style.scrollBehavior = previousBehavior;
    259364                }
    260365        };
    … …  
    429534                }
    430535
     536                // While a programmatic scroll to a target is in flight, the
     537                // scroll-spy observer below still fires as the viewport passes
     538                // other sections along the way — that's fine for updating which
     539                // link shows as active, but setActive() must not also snap the
     540                // horizontal track to whichever link that is. This widget's own
     541                // bar commonly scrolls out of the vertical viewport during a
     542                // scroll to a distant target, and scrollIntoView({block:'nearest'})
     543                // on a now off-screen link pulls the page back to reveal the bar,
     544                // directly fighting the scroll this function just started — which
     545                // is exactly why clicking any link could end up settling back near
     546                // wherever the bar itself lives instead of the intended target.
     547                this._isProgrammaticScroll = true;
     548
    431549                var lastTarget = computeTarget();
    432550
    … …  
    437555
    438556                if ( reducedMotion ) {
     557                        setTimeout( function () {
     558                                self._isProgrammaticScroll = false;
     559                        }, 100 );
    439560                        return;
    440561                }
    … …  
    455576                        if ( attempts < maxAttempts ) {
    456577                                setTimeout( correct, 120 );
     578                        } else {
     579                                // A little extra margin past the last correction for the
     580                                // browser's own smooth-scroll animation to fully settle.
     581                                setTimeout( function () {
     582                                        self._isProgrammaticScroll = false;
     583                                }, 200 );
    457584                        }
    458585                }() );
    … …  
    477604        /**
    478605         * Normalizes `scrollLeft` in RTL to a value that always runs from 0
    479          * (reading-start) to `max` (reading-end), regardless of which of the
    480          * three historical browser conventions for RTL `scrollLeft` is in
    481          * effect (0 -> -max, 0 -> +max, or +max -> 0). The convention is
    482          * detected at read-time from the sign of the live value rather than by
    483          * sniffing the browser, which keeps this correct across engines and
    484          * future browser versions.
     606         * (reading-start) to `max` (reading-end), regardless of which RTL
     607         * `scrollLeft` convention this engine uses (see
     608         * `rtlUsesNegativeScrollLeft()`).
    485609         *
    486610         * @param {HTMLElement} el
    … …  
    489613         */
    490614        Base.prototype.getNormalizedScrollLeft = function ( el, max ) {
    491                 var scrollLeft = el.scrollLeft;
    492 
    493615                if ( ! this.isRTL ) {
    494                         return scrollLeft;
    495                 }
    496 
    497                 return scrollLeft > 0 ? ( max - scrollLeft ) : ( -scrollLeft );
     616                        return el.scrollLeft;
     617                }
     618
     619                return rtlUsesNegativeScrollLeft() ? ( -el.scrollLeft ) : ( max - el.scrollLeft );
     620        };
     621
     622        /**
     623         * The write-side inverse of `getNormalizedScrollLeft()`: takes a
     624         * normalized value (0 = reading-start, `max` = reading-end) and
     625         * assigns the correctly-converted raw `scrollLeft` for whichever RTL
     626         * convention this engine uses.
     627         *
     628         * @param {HTMLElement} el
     629         * @param {number} max
     630         * @param {number} value
     631         */
     632        Base.prototype.setNormalizedScrollLeft = function ( el, max, value ) {
     633                if ( ! this.isRTL ) {
     634                        el.scrollLeft = value;
     635                        return;
     636                }
     637
     638                el.scrollLeft = rtlUsesNegativeScrollLeft() ? ( -value ) : ( max - value );
     639        };
     640
     641        /**
     642         * Computes `linkEl`'s horizontal position as two scroll-invariant
     643         * offsets — `start` and `end` — expressed in the same 0-to-`max`
     644         * normalized coordinate space as `getNormalizedScrollLeft()`
     645         * (distance along reading order from the track's reading-start edge).
     646         * Being scroll-invariant is what makes these directly comparable
     647         * against a current or target normalized `scrollLeft`: the values
     648         * don't change no matter what the track's scroll position was at the
     649         * moment this is called.
     650         *
     651         * Built from `getBoundingClientRect()` (physical viewport pixels,
     652         * always left-to-right regardless of `dir`) plus the track's current
     653         * normalized scroll offset, rather than `offsetLeft`, so this doesn't
     654         * depend on assumptions about which ancestor happens to be the
     655         * element's `offsetParent`.
     656         *
     657         * @param {HTMLElement} linkEl
     658         * @param {number} max
     659         * @return {{start: number, end: number}}
     660         */
     661        Base.prototype.getContentEdges = function ( linkEl, max ) {
     662                var trackRect = this.track.getBoundingClientRect();
     663                var linkRect = linkEl.getBoundingClientRect();
     664                var current = this.getNormalizedScrollLeft( this.track, max );
     665                var physLeft = linkRect.left - trackRect.left;
     666                var physRight = linkRect.right - trackRect.left;
     667
     668                if ( ! this.isRTL ) {
     669                        return { start: physLeft + current, end: physRight + current };
     670                }
     671
     672                var viewportWidth = this.track.clientWidth;
     673
     674                // Reading order runs right-to-left, so the link's reading-order
     675                // "start" edge is its physical *right* edge, and its "end" edge
     676                // is its physical *left* edge.
     677                return {
     678                        start: viewportWidth + current - physRight,
     679                        end: viewportWidth + current - physLeft
     680                };
    498681        };
    499682
    … …  
    641824
    642825                /**
    643                  * Elementor's own frontend script can, depending on the site's
    644                  * asset loading configuration (e.g. deferred/module script
    645                  * loading), finish initializing either before or after this file
    646                  * executes — the order scripts appear in the HTML does not
    647                  * guarantee execution order once defer/module scripts are
    648                  * involved. Guard both directions: register immediately if
    649                  * `elementorFrontend` is already ready, otherwise wait for the
    650                  * `elementor/frontend/init` event that Elementor fires exactly
    651                  * once, globally, the moment it finishes initializing — this is
    652                  * the documented, timing-safe hook point for third-party scripts.
     826                 * Catch-all for every matching element Elementor's own lifecycle will
     827                 * never reach — chiefly this widget's markup rendered by a Gutenberg
     828                 * block, but really anything not inside Elementor-managed content.
     829                 * This runs unconditionally, even on a site where Elementor happens
     830                 * to be active for OTHER content on the same page (its frontend
     831                 * script commonly loads site-wide), since that alone doesn't mean
     832                 * *this* element is one of its widgets.
     833                 *
     834                 * Elementor-rendered instances are deliberately skipped here (even
     835                 * if Elementor hasn't reached them yet) so each is only ever
     836                 * initialized once, via the frontend/element_ready hook above —
     837                 * which, unlike this one-time scan, also fires on every live-preview
     838                 * re-render inside the Elementor editor.
    653839                 */
     840                function scanForUninitialized() {
     841                        var nodes = document.querySelectorAll( rootSelector );
     842                        Array.prototype.forEach.call( nodes, function ( el ) {
     843                                if ( el[ instanceProp ] || el.closest( '.elementor' ) ) {
     844                                        return;
     845                                }
     846                                initWidget( el );
     847                        } );
     848                }
     849
    654850                if ( window.elementorFrontend && window.elementorFrontend.hooks ) {
    655851                        registerElementReadyHook();
    656852                } else if ( window.jQuery ) {
    657853                        window.jQuery( window ).on( 'elementor/frontend/init', registerElementReadyHook );
     854                }
     855
     856                if ( 'loading' === document.readyState ) {
     857                        document.addEventListener( 'DOMContentLoaded', scanForUninitialized );
    658858                } else {
    659                         document.addEventListener( 'DOMContentLoaded', function () {
    660                                 if ( window.elementorFrontend && window.elementorFrontend.hooks ) {
    661                                         registerElementReadyHook();
    662                                 }
    663                         } );
     859                        scanForUninitialized();
    664860                }
    665861        };
  • navrail-toc-for-elementor/tags/3.0.0/navrail-toc-for-elementor.php

    r3671253 r3679318  
    22/**
    33 * Plugin Name:       Navrail – Horizontal Navigation & Table of Contents
    4  * Description:       Horizontal, auto-scrolling navigation bars — built automatically from headings (Navrail TOC) or manually, item by item (Navrail Navigation). Currently available as an Elementor widget.
    5  * Version:           2.0.0
     4 * Description:       Horizontal, auto-scrolling navigation bars — built automatically from headings (Navrail TOC) or manually, item by item (Navrail Navigation). Available as Elementor widgets and Gutenberg blocks.
     5 * Version:           3.0.0
    66 * Requires at least: 6.3
    77 * Requires PHP:      7.4
    … …  
    2020}
    2121
    22 define( 'NAVRAIL_VERSION', '2.0.0' );
     22define( 'NAVRAIL_VERSION', '3.0.0' );
    2323define( 'NAVRAIL_PLUGIN_FILE', __FILE__ );
    2424define( 'NAVRAIL_PATH', plugin_dir_path( __FILE__ ) );
    … …  
    2828 * Main plugin bootstrap class.
    2929 *
    30  * Kept as a single, small bootstrap class on purpose: this plugin registers
    31  * exactly one widget, so a larger loader/service-container architecture would
    32  * add indirection without adding value.
     30 * Registers, depending on what's active: Gutenberg blocks (always, since
     31 * the block editor is WordPress core) and Elementor widgets (only when
     32 * Elementor is installed and active). Both integrations share the same
     33 * underlying JS/CSS — see register_scripts()/register_styles().
    3334 */
    3435final class NAVRAIL_Plugin {
    … …  
    7374                }
    7475
     76                // Scripts, styles, and Gutenberg blocks are registered regardless of
     77                // Elementor: the block editor is a WordPress core feature, and the
     78                // same shared assets now serve both integrations (see
     79                // register_scripts() for how the two are told apart at runtime).
     80                add_action( 'init', array( $this, 'register_scripts' ) );
     81                add_action( 'init', array( $this, 'register_styles' ) );
     82                add_action( 'init', array( $this, 'register_blocks' ) );
     83
    7584                if ( ! did_action( 'elementor/loaded' ) ) {
    7685                        add_action( 'admin_notices', array( $this, 'admin_notice_missing_elementor' ) );
    … …  
    8594                add_action( 'elementor/widgets/register', array( $this, 'register_widgets' ) );
    8695                add_action( 'elementor/elements/categories_registered', array( $this, 'register_category' ) );
    87                 add_action( 'elementor/frontend/before_register_scripts', array( $this, 'register_scripts' ) );
    88                 add_action( 'elementor/frontend/before_register_styles', array( $this, 'register_styles' ) );
    89         }
    90 
    91         /**
    92          * Registers script/style handles with Elementor's asset manager without
    93          * enqueueing them. Elementor only prints/enqueues these when a widget
    94          * whose get_script_depends()/get_style_depends() references them is
    95          * actually present on the rendered page (frontend or editor preview).
     96        }
     97
     98        /**
     99         * Per-file cache-busting version: the file's own `filemtime()` when
     100         * it can be read, falling back to `NAVRAIL_VERSION` (e.g. the file
     101         * is missing, or the filesystem call is unavailable/restricted).
     102         * Using the real modification time — rather than one shared plugin
     103         * version for every asset — means a browser is guaranteed to fetch
     104         * the new copy of exactly the file that changed the moment it
     105         * changes, with no reliance on remembering to bump a version string.
     106         *
     107         * @param string $relative_path Path relative to the plugin root, e.g. 'assets/js/navrail-core.js'.
     108         * @return string
     109         */
     110        private function get_asset_version( $relative_path ) {
     111                $absolute_path = NAVRAIL_PATH . $relative_path;
     112                $mtime         = @filemtime( $absolute_path );
     113
     114                return $mtime ? (string) $mtime : NAVRAIL_VERSION;
     115        }
     116
     117        /**
     118         * Registers script handles on `init` — early enough for the Gutenberg
     119         * blocks' block.json `viewScript`/`style` references (bare handle
     120         * names, not `file:` paths) to resolve correctly, and equally usable by
     121         * Elementor's widgets via get_script_depends(), which doesn't care when
     122         * or how a handle was registered, only that it exists by print time.
    96123         */
    97124        public function register_scripts() {
     125                $elementor_active = (bool) did_action( 'elementor/loaded' );
     126
     127                $core_deps = array( 'jquery' );
     128                if ( $elementor_active ) {
     129                        $core_deps[] = 'elementor-frontend';
     130                }
     131
    98132                wp_register_script(
    99133                        'navrail-core',
    100134                        NAVRAIL_URL . 'assets/js/navrail-core.js',
    101                         array( 'jquery', 'elementor-frontend' ),
    102                         NAVRAIL_VERSION,
     135                        $core_deps,
     136                        $this->get_asset_version( 'assets/js/navrail-core.js' ),
    103137                        true
    104138                );
    … …  
    108142                        NAVRAIL_URL . 'assets/js/navrail-toc.js',
    109143                        array( 'navrail-core' ),
    110                         NAVRAIL_VERSION,
     144                        $this->get_asset_version( 'assets/js/navrail-toc.js' ),
    111145                        true
    112146                );
    … …  
    116150                        NAVRAIL_URL . 'assets/js/navrail-nav.js',
    117151                        array( 'navrail-core' ),
    118                         NAVRAIL_VERSION,
     152                        $this->get_asset_version( 'assets/js/navrail-nav.js' ),
    119153                        true
    120154                );
    … …  
    134168                        NAVRAIL_URL . 'assets/css/navrail-toc.css',
    135169                        array(),
    136                         NAVRAIL_VERSION
     170                        $this->get_asset_version( 'assets/css/navrail-toc.css' )
    137171                );
    138172
    … …  
    141175                        NAVRAIL_URL . 'assets/css/navrail-nav.css',
    142176                        array(),
    143                         NAVRAIL_VERSION
    144                 );
     177                        $this->get_asset_version( 'assets/css/navrail-nav.css' )
     178                );
     179        }
     180
     181        /**
     182         * Registers the Gutenberg blocks from their compiled build/ directories.
     183         * Each block.json's `viewScript`/`style` fields reference the same
     184         * handles registered in register_scripts()/register_styles() above by
     185         * name, so the exact same frontend JS/CSS shipped for the Elementor
     186         * widgets also runs these blocks — nothing is duplicated.
     187         */
     188        public function register_blocks() {
     189                foreach ( array( 'toc', 'navigation', 'navigation-item' ) as $block ) {
     190                        $path = NAVRAIL_PATH . 'build/' . $block;
     191
     192                        if ( file_exists( $path . '/block.json' ) ) {
     193                                register_block_type( $path );
     194                        }
     195                }
    145196        }
    146197
  • navrail-toc-for-elementor/tags/3.0.0/readme.txt

    r3671253 r3679318  
    11=== Navrail – Horizontal Navigation & Table of Contents ===
    22Contributors: hadiomid
    3 Tags: elementor, table of contents, toc, navigation
     3Tags: elementor, gutenberg, table of contents, toc, navigation
    44Requires at least: 6.3
    55Tested up to: 7.1
    66Requires PHP: 7.4
    7 Stable tag: 2.0.0
     7Stable tag: 3.0.0
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    1010
    11 Create smooth horizontal navigation from your headings, or build custom navigation with icons and images. Made for Elementor.
     11Turn your headings into a smooth horizontal navigation bar automatically, or build one by hand. Works with Elementor or Gutenberg.
    1212
    1313== Description ==
    1414
    15 Navrail adds two widgets for horizontal, auto-scrolling navigation bars, the same pattern used by modern landing pages, documentation sites, and long-form pages to help visitors jump straight to the section they care about. Currently available as Elementor widgets.
     15Navrail adds a horizontal, auto-scrolling navigation bar to your page. This is the same style of navigation used on many landing pages, documentation sites, and long articles, so visitors can jump straight to the part they want. It works with Elementor and with the WordPress block editor (Gutenberg). Elementor is optional.
    1616
    17 **Navrail TOC** builds the navigation for you. Pick which heading levels to track, and it detects your sections automatically, smooth-scrolls to them on click, highlights the active one as visitors scroll, and keeps it in view. No manual list-building.
     17**Navrail TOC** builds the navigation for you. Choose which heading levels to track (H2, H3, and so on), and it finds your sections automatically. It smooth-scrolls to a section when clicked, and highlights the current section as visitors scroll. No manual list-building needed.
    1818
    19 **Navrail Navigation** builds the navigation from a list you configure by hand: one item at a time, each with its own title, optional icon or image, and a link, whether to a section on the page (with the same smooth scroll and active-item tracking as Navrail TOC) or to any normal URL, `tel:`, or `mailto:` link.
     19**Navrail Navigation** lets you build the navigation by hand. Add items one by one, each with a title, an optional icon or image, and a link. A link can point to a section on the same page (with the same smooth scroll and highlighting as Navrail TOC), or to any normal URL, phone number, or email address.
    2020
    21 Either widget works well for:
     21Good for:
    2222
    23 * Landing page section navigation
    24 * Long-form sales pages
     23* Landing page navigation
     24* Long sales pages
    2525* Documentation
    2626* Service and product pages
    2727* One-page websites
    28 * Long articles built with Elementor
     28* Long articles
    2929
    30 **Navrail TOC**
     30**Navrail TOC features**
    3131
    32 * Works with any Elementor page structure: headings, sections, anything with a heading tag
    33 * Include/Exclude selectors for precise control over which headings are picked up
    34 * Optional item separators and hierarchical numbering (1, 1.1, 1.2, 2, ...)
    35 * Optional leading text with Dynamic Tags support
    36 * Optional title truncation, by width, character count, or word count
     32* Works with any page: headings, sections, anything with a heading tag
     33* Choose which parts of the page to include or skip
     34* Optional numbers before each item (1, 1.1, 1.2, 2, ...)
     35* Optional line separators between items
     36* Optional fixed label before the list
     37* Shortens long titles automatically (by width, character count, or word count)
     38* Optional "stick to top/bottom" behavior
    3739
    38 **Navrail Navigation**
     40**Navrail Navigation features**
    3941
    40 * Manually-configured items via a repeater: title, optional icon or image, link
    41 * Icon/image position (before, after, above, or below the title), set independently per breakpoint
    42 * Min Height control to keep items a consistent height when some have an icon/image and others don't
    43 * Independent Normal/Hover/Active styling for both the item and its title
     42* Add items by hand: title, optional icon or image, and a link
     43* Place the icon/image before, after, above, or below the title
     44* Keep all items the same height, even when some have an icon/image and others don't
     45* Separate colors and styles for Normal, Hover, and Active states
    4446
    45 **Shared by both widgets**
     47**Works the same in Elementor and the block editor**
    4648
    47 * Smooth scroll with active-item highlighting
    48 * Auto-scrolling, swipeable navigation bar with edge fade
    49 * Adjustable width, alignment, and full style control: colors, borders, spacing, independent Normal/Hover/Active states
    50 * Sticky positioning support
    51 * RTL-ready
    52 * Lightweight, no external libraries, loads only where the widget is used
     49* Smooth scroll with highlighting of the current item
     50* Auto-scrolling, swipeable bar with a soft fade at each edge
     51* Right-to-left (RTL) language support
     52* Lightweight: no external libraries, and only loads on pages where it's used
     53* Elementor also lets you set separate values for Desktop, Tablet, and Mobile; the block editor uses one value for all screen sizes
    5354
    5455== Installation ==
    … …  
    56571. Upload the plugin ZIP via Plugins → Add New → Upload Plugin, or extract it into `wp-content/plugins/`.
    57582. Activate "Navrail".
    58 3. Edit any page with Elementor and drag either the "Navrail TOC" widget (auto-detects headings) or the "Navrail Navigation" widget (manually-configured items) — both are in the "Navrail" category — onto the page.
     593. **Elementor**: edit a page with Elementor and drag in the "Navrail TOC" widget (finds headings automatically) or the "Navrail Navigation" widget (build the list yourself). Both are in the "Navrail" category.
     60   **Block editor**: open the block inserter and search "Navrail". Add the "Navrail TOC" block, or the "Navrail Navigation" block (add "Navigation Item" blocks inside it for each link).
    5961
    6062== Frequently Asked Questions ==
    6163
     64= Is Elementor required? =
     65
     66No. The blocks work in the standard WordPress block editor with no other plugin installed. Elementor is only needed if you want to use the Elementor widgets instead.
     67
    6268= What's the difference between Navrail TOC and Navrail Navigation? =
    6369
    64 Navrail TOC builds its list automatically from headings already on the page. Navrail Navigation is fully manual: you add each item, its label, an optional icon or image, and a link yourself, whether or not it points at this page.
     70Navrail TOC builds its list automatically from the headings already on your page. Navrail Navigation is fully manual: you add each item, its label, an optional icon or image, and a link yourself.
    6571
    66 = Is this only for Table of Contents? =
     72= Is this only for a Table of Contents? =
    6773
    68 No. It's a general-purpose horizontal section navigation. Table of Contents is one common use, but it works just as well for landing page section jumps, documentation, service/product page navigation, and long single-page sites.
     74No. It's a general horizontal navigation bar. A Table of Contents is one common use, but it also works well for landing page navigation, documentation, product pages, and one-page sites.
    6975
    7076= Is this plugin free? =
    7177
    72 Yes, completely. Every feature described above is included, no premium version, no upsells, no ads.
    73 
    74 = Does this require Elementor Pro? =
    75 
    76 No. Only the free Elementor plugin is required.
     78Yes, completely free. No premium version, no upsells, no ads.
    7779
    7880= Why don't I see any items? =
    7981
    80 For Navrail TOC: the widget builds its navigation from headings already on the page. Make sure the selected Heading Levels match the headings actually used in your content. For Navrail Navigation: make sure the repeater has items added under Content → Navigation Items.
     82For Navrail TOC: it builds its list from headings already on the page, so check that the Heading Levels you selected match the headings you actually used. For Navrail Navigation: make sure you've added items (the repeater panel in Elementor, or "Navigation Item" child blocks in the block editor).
    8183
    82 This plugin is an independent, third-party extension for Elementor and is not affiliated with or endorsed by Elementor.com.
     84This plugin is an independent, third-party extension and is not affiliated with or endorsed by Elementor.com or the WordPress Gutenberg project.
    8385
    8486== Screenshots ==
    … …  
    87892. Frontend display of the horizontal TOC navigation bar
    88903. Frontend display of a manually-configured Navrail Navigation bar
     914. Style settings panel in the block editor (Gutenberg)
    8992
    9093== Changelog ==
     94
     95= 3.0.0 =
     96* Added: full support for the WordPress block editor (Gutenberg) — "Navrail TOC" and "Navrail Navigation" blocks, with the same features as the Elementor widgets: numbering, separators, a leading label, title shortening, sticky positioning, and separate Normal/Hover/Active styling. The block editor's leading label is plain text; Elementor's version also supports Dynamic Tags.
     97* Changed: renamed the plugin from "Navrail TOC for Elementor" to "Navrail – Horizontal Navigation & Table of Contents", since it no longer needs Elementor. The plugin's link on WordPress.org stays the same.
     98* Changed: Elementor is now optional. The plugin and its blocks work with no other plugin installed; the Elementor widgets simply stay inactive (with a friendly notice) until Elementor is installed.
    9199
    92100= 2.0.0 =
  • navrail-toc-for-elementor/trunk/assets/css/navrail-nav.css

    r3671253 r3679318  
    2121        --navrail-nav-media-direction: row;
    2222        --navrail-nav-item-height: 0px;
     23        --navrail-nav-items-gap: 24px;
     24        --navrail-nav-item-padding-block: 8px;
     25        --navrail-nav-item-padding-inline: 16px;
    2326
    2427        display: flex;
    … …  
    5053        flex-wrap: nowrap;
    5154        align-items: center;
     55        gap: var(--navrail-nav-items-gap);
    5256        list-style: none;
    5357        margin: 0;
    … …  
    8084        gap: var(--navrail-nav-title-spacing);
    8185        min-height: var(--navrail-nav-item-height);
     86        padding-block: var(--navrail-nav-item-padding-block);
     87        padding-inline: var(--navrail-nav-item-padding-inline);
    8288        max-width: 100%;
    8389        white-space: nowrap;
    … …  
    128134   the link's accessible name instead of being removed from the DOM. */
    129135.navrail-nav__title--visually-hidden {
     136        position: absolute;
     137        width: 1px;
     138        height: 1px;
     139        padding: 0;
     140        margin: -1px;
     141        overflow: hidden;
     142        clip: rect(0, 0, 0, 0);
     143        white-space: nowrap;
     144        border: 0;
     145}
     146
     147/* Used by the Gutenberg "Navrail Navigation" block only: since its Items
     148   are static, individually-saved child blocks that can't know the parent's
     149   "Show Titles" setting at save time (block context isn't available inside
     150   a static block's save()), the parent's own wrapper carries this class
     151   instead and hides every descendant title with one rule — functionally
     152   identical to the per-item class above that Elementor's PHP applies. */
     153.navrail-nav--hide-titles .navrail-nav__title {
    130154        position: absolute;
    131155        width: 1px;
  • navrail-toc-for-elementor/trunk/assets/css/navrail-toc.css

    r3661787 r3679318  
    2121        --htoc-separator-width: 1px;
    2222        --htoc-separator-height: 16px;
     23        --htoc-items-gap: 24px;
     24        --htoc-item-padding-block: 8px;
     25        --htoc-item-padding-inline: 16px;
    2326
    2427        display: flex;
    … …  
    6770        flex-wrap: nowrap;
    6871        align-items: center;
     72        gap: var(--htoc-items-gap);
    6973        list-style: none;
    7074        margin: 0;
    … …  
    9599        display: inline-flex;
    96100        align-items: center;
     101        padding-block: var(--htoc-item-padding-block);
     102        padding-inline: var(--htoc-item-padding-inline);
    97103        max-width: 100%;
    98104        white-space: nowrap;
  • navrail-toc-for-elementor/trunk/assets/js/navrail-core.js

    r3671253 r3679318  
    2121        var NavrailCore = {};
    2222
     23        var _rtlUsesNegativeScrollLeft = null;
     24
     25        /**
     26         * One-time, cached, purely behavioral detection of which `scrollLeft`
     27         * convention this browser engine uses for RTL scroll containers.
     28         * Every evergreen engine today follows the CSSOM View spec (0 at
     29         * reading-start, running down to `-max` at reading-end), but two
     30         * older conventions existed historically: a positive-only scheme
     31         * (`max` at reading-start, down to `0` at reading-end) and a scheme
     32         * that left `scrollLeft` completely unadjusted for direction
     33         * (numerically identical to LTR — `0` to `+max`). Both older schemes
     34         * share the trait this test relies on: neither accepts a negative
     35         * `scrollLeft` value, so assigning one gets silently clamped back to
     36         * `0`. Only the modern convention actually allows it to go negative.
     37         * A single assignment-and-read-back on a disposable probe element
     38         * distinguishes the two cases without depending on any element's
     39         * live scroll position — unlike inferring the convention from the
     40         * sign of a real track's current `scrollLeft`, which is ambiguous
     41         * exactly at the boundary where that value is `0`.
     42         *
     43         * @return {boolean} True if this engine uses the modern (negative)
     44         *                    convention.
     45         */
     46        function rtlUsesNegativeScrollLeft() {
     47                if ( null !== _rtlUsesNegativeScrollLeft ) {
     48                        return _rtlUsesNegativeScrollLeft;
     49                }
     50
     51                if ( 'undefined' === typeof document || ! document.body ) {
     52                        // No DOM available to probe. Assume the modern spec — true
     53                        // for every current evergreen engine.
     54                        return true;
     55                }
     56
     57                var probe = document.createElement( 'div' );
     58                probe.setAttribute( 'dir', 'rtl' );
     59                probe.style.cssText = 'position:absolute;visibility:hidden;top:-9999px;left:-9999px;width:2px;height:2px;overflow:scroll;';
     60
     61                var inner = document.createElement( 'div' );
     62                inner.style.cssText = 'width:6px;height:1px;';
     63                probe.appendChild( inner );
     64
     65                document.body.appendChild( probe );
     66                probe.scrollLeft = -1;
     67                _rtlUsesNegativeScrollLeft = ( probe.scrollLeft < 0 );
     68                document.body.removeChild( probe );
     69
     70                return _rtlUsesNegativeScrollLeft;
     71        }
     72
    2373        /**
    2474         * @param {Function} fn
    … …  
    64114                this.resizeObserver = null;
    65115                this._fadeRaf = null;
     116                this._isProgrammaticScroll = false;
    66117
    67118                this.onClick = this.onClick.bind( this );
    … …  
    228279
    229280                var activeItem = this.getItemById( id );
    230                 if ( activeItem ) {
     281                if ( activeItem && ! this._isProgrammaticScroll ) {
    231282                        this.scrollItemIntoView( activeItem.linkEl );
    232283                }
    … …  
    234285
    235286        /**
    236          * Brings the active item into view inside the horizontally scrollable
    237          * track using the native `scrollIntoView` `inline` alignment option.
    238          * This is deliberately used instead of manually computing `scrollLeft`,
    239          * because browsers have historically disagreed on how `scrollLeft`
    240          * behaves in RTL contexts; `scrollIntoView`'s `inline` option is
    241          * handled internally by the browser per the current CSSOM View spec
    242          * and is consistent across engines regardless of that discrepancy.
    243          * `block: 'nearest'` prevents any accidental vertical page scroll.
     287         * Brings the active item into view by setting `this.track`'s own
     288         * `scrollLeft` directly — never `Element.scrollIntoView()`.
     289         *
     290         * `scrollIntoView()` was used originally because manually computing
     291         * `scrollLeft` in RTL is awkward (see `getNormalizedScrollLeft`).
     292         * But `scrollIntoView()` also walks up and scrolls every *other*
     293         * ancestor scroll container needed to bring the element into view —
     294         * including the page itself — via its `block` option. `block:
     295         * 'nearest'` only minimizes that vertical scroll, it does not
     296         * eliminate it: once this widget's own bar has scrolled out of the
     297         * vertical viewport (routine during organic scrolling past earlier
     298         * headings, since the bar itself lives at a fixed point on the page),
     299         * "nearest" for the *link* is no longer "no scroll" — the browser
     300         * still has to move the page to make an off-screen link visible at
     301         * all, which fights whatever scroll the reader is already doing.
     302         *
     303         * Setting `scrollLeft` on `this.track` touches only that element.
     304         * There is no `block` option and no ancestor involved, so this is
     305         * structurally incapable of moving the page vertically — regardless
     306         * of whether the track happens to be on-screen at the moment.
    244307         *
    245308         * @param {HTMLElement} linkEl
    246309         */
    247310        Base.prototype.scrollItemIntoView = function ( linkEl ) {
     311                if ( ! this.track ) {
     312                        return;
     313                }
     314
     315                var max = this.track.scrollWidth - this.track.clientWidth;
     316
     317                if ( max <= 0 ) {
     318                        return;
     319                }
     320
    248321                var align = this.getResponsiveValue( this.config.align ) || 'nearest';
     322                var viewportWidth = this.track.clientWidth;
     323                var edges = this.getContentEdges( linkEl, max );
     324                var current = this.getNormalizedScrollLeft( this.track, max );
     325                var target;
     326
     327                if ( 'start' === align ) {
     328                        target = edges.start;
     329                } else if ( 'end' === align ) {
     330                        target = edges.end - viewportWidth;
     331                } else if ( 'center' === align ) {
     332                        target = ( edges.start + edges.end ) / 2 - ( viewportWidth / 2 );
     333                } else if ( edges.start < current ) {
     334                        // 'nearest': item's start edge has scrolled past the
     335                        // viewport's start edge — pull it back into view there.
     336                        target = edges.start;
     337                } else if ( edges.end > current + viewportWidth ) {
     338                        // Item's end edge is past the viewport's end edge.
     339                        target = edges.end - viewportWidth;
     340                } else {
     341                        // Already fully visible; 'nearest' means "do nothing".
     342                        return;
     343                }
     344
     345                target = Math.max( 0, Math.min( max, target ) );
     346
    249347                var reducedMotion = !! ( window.matchMedia && window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches );
    250 
    251                 try {
    252                         linkEl.scrollIntoView( {
    253                                 behavior: reducedMotion ? 'auto' : 'smooth',
    254                                 block: 'nearest',
    255                                 inline: align
    256                         } );
    257                 } catch ( e ) {
    258                         linkEl.scrollIntoView();
     348                var track = this.track;
     349                var previousBehavior = track.style.scrollBehavior;
     350
     351                // The stylesheet sets `scroll-behavior: auto` on the track so
     352                // drag/wheel scrolling stays instant; switching to `smooth` only
     353                // for this JS-driven move, then reverting once it's had time to
     354                // finish, keeps that native drag/wheel feel intact.
     355                track.style.scrollBehavior = reducedMotion ? 'auto' : 'smooth';
     356                this.setNormalizedScrollLeft( track, max, target );
     357
     358                if ( ! reducedMotion ) {
     359                        setTimeout( function () {
     360                                track.style.scrollBehavior = previousBehavior;
     361                        }, 500 );
     362                } else {
     363                        track.style.scrollBehavior = previousBehavior;
    259364                }
    260365        };
    … …  
    429534                }
    430535
     536                // While a programmatic scroll to a target is in flight, the
     537                // scroll-spy observer below still fires as the viewport passes
     538                // other sections along the way — that's fine for updating which
     539                // link shows as active, but setActive() must not also snap the
     540                // horizontal track to whichever link that is. This widget's own
     541                // bar commonly scrolls out of the vertical viewport during a
     542                // scroll to a distant target, and scrollIntoView({block:'nearest'})
     543                // on a now off-screen link pulls the page back to reveal the bar,
     544                // directly fighting the scroll this function just started — which
     545                // is exactly why clicking any link could end up settling back near
     546                // wherever the bar itself lives instead of the intended target.
     547                this._isProgrammaticScroll = true;
     548
    431549                var lastTarget = computeTarget();
    432550
    … …  
    437555
    438556                if ( reducedMotion ) {
     557                        setTimeout( function () {
     558                                self._isProgrammaticScroll = false;
     559                        }, 100 );
    439560                        return;
    440561                }
    … …  
    455576                        if ( attempts < maxAttempts ) {
    456577                                setTimeout( correct, 120 );
     578                        } else {
     579                                // A little extra margin past the last correction for the
     580                                // browser's own smooth-scroll animation to fully settle.
     581                                setTimeout( function () {
     582                                        self._isProgrammaticScroll = false;
     583                                }, 200 );
    457584                        }
    458585                }() );
    … …  
    477604        /**
    478605         * Normalizes `scrollLeft` in RTL to a value that always runs from 0
    479          * (reading-start) to `max` (reading-end), regardless of which of the
    480          * three historical browser conventions for RTL `scrollLeft` is in
    481          * effect (0 -> -max, 0 -> +max, or +max -> 0). The convention is
    482          * detected at read-time from the sign of the live value rather than by
    483          * sniffing the browser, which keeps this correct across engines and
    484          * future browser versions.
     606         * (reading-start) to `max` (reading-end), regardless of which RTL
     607         * `scrollLeft` convention this engine uses (see
     608         * `rtlUsesNegativeScrollLeft()`).
    485609         *
    486610         * @param {HTMLElement} el
    … …  
    489613         */
    490614        Base.prototype.getNormalizedScrollLeft = function ( el, max ) {
    491                 var scrollLeft = el.scrollLeft;
    492 
    493615                if ( ! this.isRTL ) {
    494                         return scrollLeft;
    495                 }
    496 
    497                 return scrollLeft > 0 ? ( max - scrollLeft ) : ( -scrollLeft );
     616                        return el.scrollLeft;
     617                }
     618
     619                return rtlUsesNegativeScrollLeft() ? ( -el.scrollLeft ) : ( max - el.scrollLeft );
     620        };
     621
     622        /**
     623         * The write-side inverse of `getNormalizedScrollLeft()`: takes a
     624         * normalized value (0 = reading-start, `max` = reading-end) and
     625         * assigns the correctly-converted raw `scrollLeft` for whichever RTL
     626         * convention this engine uses.
     627         *
     628         * @param {HTMLElement} el
     629         * @param {number} max
     630         * @param {number} value
     631         */
     632        Base.prototype.setNormalizedScrollLeft = function ( el, max, value ) {
     633                if ( ! this.isRTL ) {
     634                        el.scrollLeft = value;
     635                        return;
     636                }
     637
     638                el.scrollLeft = rtlUsesNegativeScrollLeft() ? ( -value ) : ( max - value );
     639        };
     640
     641        /**
     642         * Computes `linkEl`'s horizontal position as two scroll-invariant
     643         * offsets — `start` and `end` — expressed in the same 0-to-`max`
     644         * normalized coordinate space as `getNormalizedScrollLeft()`
     645         * (distance along reading order from the track's reading-start edge).
     646         * Being scroll-invariant is what makes these directly comparable
     647         * against a current or target normalized `scrollLeft`: the values
     648         * don't change no matter what the track's scroll position was at the
     649         * moment this is called.
     650         *
     651         * Built from `getBoundingClientRect()` (physical viewport pixels,
     652         * always left-to-right regardless of `dir`) plus the track's current
     653         * normalized scroll offset, rather than `offsetLeft`, so this doesn't
     654         * depend on assumptions about which ancestor happens to be the
     655         * element's `offsetParent`.
     656         *
     657         * @param {HTMLElement} linkEl
     658         * @param {number} max
     659         * @return {{start: number, end: number}}
     660         */
     661        Base.prototype.getContentEdges = function ( linkEl, max ) {
     662                var trackRect = this.track.getBoundingClientRect();
     663                var linkRect = linkEl.getBoundingClientRect();
     664                var current = this.getNormalizedScrollLeft( this.track, max );
     665                var physLeft = linkRect.left - trackRect.left;
     666                var physRight = linkRect.right - trackRect.left;
     667
     668                if ( ! this.isRTL ) {
     669                        return { start: physLeft + current, end: physRight + current };
     670                }
     671
     672                var viewportWidth = this.track.clientWidth;
     673
     674                // Reading order runs right-to-left, so the link's reading-order
     675                // "start" edge is its physical *right* edge, and its "end" edge
     676                // is its physical *left* edge.
     677                return {
     678                        start: viewportWidth + current - physRight,
     679                        end: viewportWidth + current - physLeft
     680                };
    498681        };
    499682
    … …  
    641824
    642825                /**
    643                  * Elementor's own frontend script can, depending on the site's
    644                  * asset loading configuration (e.g. deferred/module script
    645                  * loading), finish initializing either before or after this file
    646                  * executes — the order scripts appear in the HTML does not
    647                  * guarantee execution order once defer/module scripts are
    648                  * involved. Guard both directions: register immediately if
    649                  * `elementorFrontend` is already ready, otherwise wait for the
    650                  * `elementor/frontend/init` event that Elementor fires exactly
    651                  * once, globally, the moment it finishes initializing — this is
    652                  * the documented, timing-safe hook point for third-party scripts.
     826                 * Catch-all for every matching element Elementor's own lifecycle will
     827                 * never reach — chiefly this widget's markup rendered by a Gutenberg
     828                 * block, but really anything not inside Elementor-managed content.
     829                 * This runs unconditionally, even on a site where Elementor happens
     830                 * to be active for OTHER content on the same page (its frontend
     831                 * script commonly loads site-wide), since that alone doesn't mean
     832                 * *this* element is one of its widgets.
     833                 *
     834                 * Elementor-rendered instances are deliberately skipped here (even
     835                 * if Elementor hasn't reached them yet) so each is only ever
     836                 * initialized once, via the frontend/element_ready hook above —
     837                 * which, unlike this one-time scan, also fires on every live-preview
     838                 * re-render inside the Elementor editor.
    653839                 */
     840                function scanForUninitialized() {
     841                        var nodes = document.querySelectorAll( rootSelector );
     842                        Array.prototype.forEach.call( nodes, function ( el ) {
     843                                if ( el[ instanceProp ] || el.closest( '.elementor' ) ) {
     844                                        return;
     845                                }
     846                                initWidget( el );
     847                        } );
     848                }
     849
    654850                if ( window.elementorFrontend && window.elementorFrontend.hooks ) {
    655851                        registerElementReadyHook();
    656852                } else if ( window.jQuery ) {
    657853                        window.jQuery( window ).on( 'elementor/frontend/init', registerElementReadyHook );
     854                }
     855
     856                if ( 'loading' === document.readyState ) {
     857                        document.addEventListener( 'DOMContentLoaded', scanForUninitialized );
    658858                } else {
    659                         document.addEventListener( 'DOMContentLoaded', function () {
    660                                 if ( window.elementorFrontend && window.elementorFrontend.hooks ) {
    661                                         registerElementReadyHook();
    662                                 }
    663                         } );
     859                        scanForUninitialized();
    664860                }
    665861        };
  • navrail-toc-for-elementor/trunk/navrail-toc-for-elementor.php

    r3671253 r3679318  
    22/**
    33 * Plugin Name:       Navrail – Horizontal Navigation & Table of Contents
    4  * Description:       Horizontal, auto-scrolling navigation bars — built automatically from headings (Navrail TOC) or manually, item by item (Navrail Navigation). Currently available as an Elementor widget.
    5  * Version:           2.0.0
     4 * Description:       Horizontal, auto-scrolling navigation bars — built automatically from headings (Navrail TOC) or manually, item by item (Navrail Navigation). Available as Elementor widgets and Gutenberg blocks.
     5 * Version:           3.0.0
    66 * Requires at least: 6.3
    77 * Requires PHP:      7.4
    … …  
    2020}
    2121
    22 define( 'NAVRAIL_VERSION', '2.0.0' );
     22define( 'NAVRAIL_VERSION', '3.0.0' );
    2323define( 'NAVRAIL_PLUGIN_FILE', __FILE__ );
    2424define( 'NAVRAIL_PATH', plugin_dir_path( __FILE__ ) );
    … …  
    2828 * Main plugin bootstrap class.
    2929 *
    30  * Kept as a single, small bootstrap class on purpose: this plugin registers
    31  * exactly one widget, so a larger loader/service-container architecture would
    32  * add indirection without adding value.
     30 * Registers, depending on what's active: Gutenberg blocks (always, since
     31 * the block editor is WordPress core) and Elementor widgets (only when
     32 * Elementor is installed and active). Both integrations share the same
     33 * underlying JS/CSS — see register_scripts()/register_styles().
    3334 */
    3435final class NAVRAIL_Plugin {
    … …  
    7374                }
    7475
     76                // Scripts, styles, and Gutenberg blocks are registered regardless of
     77                // Elementor: the block editor is a WordPress core feature, and the
     78                // same shared assets now serve both integrations (see
     79                // register_scripts() for how the two are told apart at runtime).
     80                add_action( 'init', array( $this, 'register_scripts' ) );
     81                add_action( 'init', array( $this, 'register_styles' ) );
     82                add_action( 'init', array( $this, 'register_blocks' ) );
     83
    7584                if ( ! did_action( 'elementor/loaded' ) ) {
    7685                        add_action( 'admin_notices', array( $this, 'admin_notice_missing_elementor' ) );
    … …  
    8594                add_action( 'elementor/widgets/register', array( $this, 'register_widgets' ) );
    8695                add_action( 'elementor/elements/categories_registered', array( $this, 'register_category' ) );
    87                 add_action( 'elementor/frontend/before_register_scripts', array( $this, 'register_scripts' ) );
    88                 add_action( 'elementor/frontend/before_register_styles', array( $this, 'register_styles' ) );
    89         }
    90 
    91         /**
    92          * Registers script/style handles with Elementor's asset manager without
    93          * enqueueing them. Elementor only prints/enqueues these when a widget
    94          * whose get_script_depends()/get_style_depends() references them is
    95          * actually present on the rendered page (frontend or editor preview).
     96        }
     97
     98        /**
     99         * Per-file cache-busting version: the file's own `filemtime()` when
     100         * it can be read, falling back to `NAVRAIL_VERSION` (e.g. the file
     101         * is missing, or the filesystem call is unavailable/restricted).
     102         * Using the real modification time — rather than one shared plugin
     103         * version for every asset — means a browser is guaranteed to fetch
     104         * the new copy of exactly the file that changed the moment it
     105         * changes, with no reliance on remembering to bump a version string.
     106         *
     107         * @param string $relative_path Path relative to the plugin root, e.g. 'assets/js/navrail-core.js'.
     108         * @return string
     109         */
     110        private function get_asset_version( $relative_path ) {
     111                $absolute_path = NAVRAIL_PATH . $relative_path;
     112                $mtime         = @filemtime( $absolute_path );
     113
     114                return $mtime ? (string) $mtime : NAVRAIL_VERSION;
     115        }
     116
     117        /**
     118         * Registers script handles on `init` — early enough for the Gutenberg
     119         * blocks' block.json `viewScript`/`style` references (bare handle
     120         * names, not `file:` paths) to resolve correctly, and equally usable by
     121         * Elementor's widgets via get_script_depends(), which doesn't care when
     122         * or how a handle was registered, only that it exists by print time.
    96123         */
    97124        public function register_scripts() {
     125                $elementor_active = (bool) did_action( 'elementor/loaded' );
     126
     127                $core_deps = array( 'jquery' );
     128                if ( $elementor_active ) {
     129                        $core_deps[] = 'elementor-frontend';
     130                }
     131
    98132                wp_register_script(
    99133                        'navrail-core',
    100134                        NAVRAIL_URL . 'assets/js/navrail-core.js',
    101                         array( 'jquery', 'elementor-frontend' ),
    102                         NAVRAIL_VERSION,
     135                        $core_deps,
     136                        $this->get_asset_version( 'assets/js/navrail-core.js' ),
    103137                        true
    104138                );
    … …  
    108142                        NAVRAIL_URL . 'assets/js/navrail-toc.js',
    109143                        array( 'navrail-core' ),
    110                         NAVRAIL_VERSION,
     144                        $this->get_asset_version( 'assets/js/navrail-toc.js' ),
    111145                        true
    112146                );
    … …  
    116150                        NAVRAIL_URL . 'assets/js/navrail-nav.js',
    117151                        array( 'navrail-core' ),
    118                         NAVRAIL_VERSION,
     152                        $this->get_asset_version( 'assets/js/navrail-nav.js' ),
    119153                        true
    120154                );
    … …  
    134168                        NAVRAIL_URL . 'assets/css/navrail-toc.css',
    135169                        array(),
    136                         NAVRAIL_VERSION
     170                        $this->get_asset_version( 'assets/css/navrail-toc.css' )
    137171                );
    138172
    … …  
    141175                        NAVRAIL_URL . 'assets/css/navrail-nav.css',
    142176                        array(),
    143                         NAVRAIL_VERSION
    144                 );
     177                        $this->get_asset_version( 'assets/css/navrail-nav.css' )
     178                );
     179        }
     180
     181        /**
     182         * Registers the Gutenberg blocks from their compiled build/ directories.
     183         * Each block.json's `viewScript`/`style` fields reference the same
     184         * handles registered in register_scripts()/register_styles() above by
     185         * name, so the exact same frontend JS/CSS shipped for the Elementor
     186         * widgets also runs these blocks — nothing is duplicated.
     187         */
     188        public function register_blocks() {
     189                foreach ( array( 'toc', 'navigation', 'navigation-item' ) as $block ) {
     190                        $path = NAVRAIL_PATH . 'build/' . $block;
     191
     192                        if ( file_exists( $path . '/block.json' ) ) {
     193                                register_block_type( $path );
     194                        }
     195                }
    145196        }
    146197
  • navrail-toc-for-elementor/trunk/readme.txt

    r3671253 r3679318  
    11=== Navrail – Horizontal Navigation & Table of Contents ===
    22Contributors: hadiomid
    3 Tags: elementor, table of contents, toc, navigation
     3Tags: elementor, gutenberg, table of contents, toc, navigation
    44Requires at least: 6.3
    55Tested up to: 7.1
    66Requires PHP: 7.4
    7 Stable tag: 2.0.0
     7Stable tag: 3.0.0
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    1010
    11 Create smooth horizontal navigation from your headings, or build custom navigation with icons and images. Made for Elementor.
     11Turn your headings into a smooth horizontal navigation bar automatically, or build one by hand. Works with Elementor or Gutenberg.
    1212
    1313== Description ==
    1414
    15 Navrail adds two widgets for horizontal, auto-scrolling navigation bars, the same pattern used by modern landing pages, documentation sites, and long-form pages to help visitors jump straight to the section they care about. Currently available as Elementor widgets.
     15Navrail adds a horizontal, auto-scrolling navigation bar to your page. This is the same style of navigation used on many landing pages, documentation sites, and long articles, so visitors can jump straight to the part they want. It works with Elementor and with the WordPress block editor (Gutenberg). Elementor is optional.
    1616
    17 **Navrail TOC** builds the navigation for you. Pick which heading levels to track, and it detects your sections automatically, smooth-scrolls to them on click, highlights the active one as visitors scroll, and keeps it in view. No manual list-building.
     17**Navrail TOC** builds the navigation for you. Choose which heading levels to track (H2, H3, and so on), and it finds your sections automatically. It smooth-scrolls to a section when clicked, and highlights the current section as visitors scroll. No manual list-building needed.
    1818
    19 **Navrail Navigation** builds the navigation from a list you configure by hand: one item at a time, each with its own title, optional icon or image, and a link, whether to a section on the page (with the same smooth scroll and active-item tracking as Navrail TOC) or to any normal URL, `tel:`, or `mailto:` link.
     19**Navrail Navigation** lets you build the navigation by hand. Add items one by one, each with a title, an optional icon or image, and a link. A link can point to a section on the same page (with the same smooth scroll and highlighting as Navrail TOC), or to any normal URL, phone number, or email address.
    2020
    21 Either widget works well for:
     21Good for:
    2222
    23 * Landing page section navigation
    24 * Long-form sales pages
     23* Landing page navigation
     24* Long sales pages
    2525* Documentation
    2626* Service and product pages
    2727* One-page websites
    28 * Long articles built with Elementor
     28* Long articles
    2929
    30 **Navrail TOC**
     30**Navrail TOC features**
    3131
    32 * Works with any Elementor page structure: headings, sections, anything with a heading tag
    33 * Include/Exclude selectors for precise control over which headings are picked up
    34 * Optional item separators and hierarchical numbering (1, 1.1, 1.2, 2, ...)
    35 * Optional leading text with Dynamic Tags support
    36 * Optional title truncation, by width, character count, or word count
     32* Works with any page: headings, sections, anything with a heading tag
     33* Choose which parts of the page to include or skip
     34* Optional numbers before each item (1, 1.1, 1.2, 2, ...)
     35* Optional line separators between items
     36* Optional fixed label before the list
     37* Shortens long titles automatically (by width, character count, or word count)
     38* Optional "stick to top/bottom" behavior
    3739
    38 **Navrail Navigation**
     40**Navrail Navigation features**
    3941
    40 * Manually-configured items via a repeater: title, optional icon or image, link
    41 * Icon/image position (before, after, above, or below the title), set independently per breakpoint
    42 * Min Height control to keep items a consistent height when some have an icon/image and others don't
    43 * Independent Normal/Hover/Active styling for both the item and its title
     42* Add items by hand: title, optional icon or image, and a link
     43* Place the icon/image before, after, above, or below the title
     44* Keep all items the same height, even when some have an icon/image and others don't
     45* Separate colors and styles for Normal, Hover, and Active states
    4446
    45 **Shared by both widgets**
     47**Works the same in Elementor and the block editor**
    4648
    47 * Smooth scroll with active-item highlighting
    48 * Auto-scrolling, swipeable navigation bar with edge fade
    49 * Adjustable width, alignment, and full style control: colors, borders, spacing, independent Normal/Hover/Active states
    50 * Sticky positioning support
    51 * RTL-ready
    52 * Lightweight, no external libraries, loads only where the widget is used
     49* Smooth scroll with highlighting of the current item
     50* Auto-scrolling, swipeable bar with a soft fade at each edge
     51* Right-to-left (RTL) language support
     52* Lightweight: no external libraries, and only loads on pages where it's used
     53* Elementor also lets you set separate values for Desktop, Tablet, and Mobile; the block editor uses one value for all screen sizes
    5354
    5455== Installation ==
    … …  
    56571. Upload the plugin ZIP via Plugins → Add New → Upload Plugin, or extract it into `wp-content/plugins/`.
    57582. Activate "Navrail".
    58 3. Edit any page with Elementor and drag either the "Navrail TOC" widget (auto-detects headings) or the "Navrail Navigation" widget (manually-configured items) — both are in the "Navrail" category — onto the page.
     593. **Elementor**: edit a page with Elementor and drag in the "Navrail TOC" widget (finds headings automatically) or the "Navrail Navigation" widget (build the list yourself). Both are in the "Navrail" category.
     60   **Block editor**: open the block inserter and search "Navrail". Add the "Navrail TOC" block, or the "Navrail Navigation" block (add "Navigation Item" blocks inside it for each link).
    5961
    6062== Frequently Asked Questions ==
    6163
     64= Is Elementor required? =
     65
     66No. The blocks work in the standard WordPress block editor with no other plugin installed. Elementor is only needed if you want to use the Elementor widgets instead.
     67
    6268= What's the difference between Navrail TOC and Navrail Navigation? =
    6369
    64 Navrail TOC builds its list automatically from headings already on the page. Navrail Navigation is fully manual: you add each item, its label, an optional icon or image, and a link yourself, whether or not it points at this page.
     70Navrail TOC builds its list automatically from the headings already on your page. Navrail Navigation is fully manual: you add each item, its label, an optional icon or image, and a link yourself.
    6571
    66 = Is this only for Table of Contents? =
     72= Is this only for a Table of Contents? =
    6773
    68 No. It's a general-purpose horizontal section navigation. Table of Contents is one common use, but it works just as well for landing page section jumps, documentation, service/product page navigation, and long single-page sites.
     74No. It's a general horizontal navigation bar. A Table of Contents is one common use, but it also works well for landing page navigation, documentation, product pages, and one-page sites.
    6975
    7076= Is this plugin free? =
    7177
    72 Yes, completely. Every feature described above is included, no premium version, no upsells, no ads.
    73 
    74 = Does this require Elementor Pro? =
    75 
    76 No. Only the free Elementor plugin is required.
     78Yes, completely free. No premium version, no upsells, no ads.
    7779
    7880= Why don't I see any items? =
    7981
    80 For Navrail TOC: the widget builds its navigation from headings already on the page. Make sure the selected Heading Levels match the headings actually used in your content. For Navrail Navigation: make sure the repeater has items added under Content → Navigation Items.
     82For Navrail TOC: it builds its list from headings already on the page, so check that the Heading Levels you selected match the headings you actually used. For Navrail Navigation: make sure you've added items (the repeater panel in Elementor, or "Navigation Item" child blocks in the block editor).
    8183
    82 This plugin is an independent, third-party extension for Elementor and is not affiliated with or endorsed by Elementor.com.
     84This plugin is an independent, third-party extension and is not affiliated with or endorsed by Elementor.com or the WordPress Gutenberg project.
    8385
    8486== Screenshots ==
    … …  
    87892. Frontend display of the horizontal TOC navigation bar
    88903. Frontend display of a manually-configured Navrail Navigation bar
     914. Style settings panel in the block editor (Gutenberg)
    8992
    9093== Changelog ==
     94
     95= 3.0.0 =
     96* Added: full support for the WordPress block editor (Gutenberg) — "Navrail TOC" and "Navrail Navigation" blocks, with the same features as the Elementor widgets: numbering, separators, a leading label, title shortening, sticky positioning, and separate Normal/Hover/Active styling. The block editor's leading label is plain text; Elementor's version also supports Dynamic Tags.
     97* Changed: renamed the plugin from "Navrail TOC for Elementor" to "Navrail – Horizontal Navigation & Table of Contents", since it no longer needs Elementor. The plugin's link on WordPress.org stays the same.
     98* Changed: Elementor is now optional. The plugin and its blocks work with no other plugin installed; the Elementor widgets simply stay inactive (with a friendly notice) until Elementor is installed.
    9199
    92100= 2.0.0 =
Note: See TracChangeset for help on using the changeset viewer.