Widget Wednesday - Floating TOC in Confluence

I missed Topic Tuesday!  The holiday threw me off, but this gives me a chance to share something I just worked on in Confluence.  I had a request from a group to incorporate a floating table of contents into some of their pages.  In the past I have used the Custom HTML or Stylesheet areas of the admin look and feel to inject some CSS and/or javascript, but I have never felt comfortable doing that since it impacts the entire site.  That's when I thought of the HTML Macro.

If you've never used it before, it is basically just a macro that allows you to inject code directly into the page and, as you can imagine, it is disabled by default.  Because of the power this macro has, I wouldn't recommend turning this on in an environment where you don't trust the users.  Since our Confluence has only internal users, we have enabled it (details for turning it on are at https://confluence.atlassian.com/doc/html-macro-38273085.html).

After that, it's simply a matter of adding your CSS, javascript, and/or HTML to the body of the macro.  I'm not the world's best front-end developer so it took me awhile to get what I wanted and I relied heavily on Google, but the end result turned out pretty good:

TOC.gif

 

I'm putting the code I used below, but not giving much explanation so feel free to ask questions if you want to understand how it does what it does.  The trick was finding out what Confluence gives you on the page and then targeting the page elements and applying scripts and styling to give the desired effect.

Step-by-step guide

There are two pieces required for getting this to work.  First you must add a Table of Contents macro with the correct settings and then you must add an HTML macro with the CSS and Javascript.

Table of Contents Macro

  1. Divide the layout of your page so that there is an empty column on the right of the page where the menu will float.
  2. Add a Table of Contents macro and set the heading levels appropriately (the special formatting in the floating menu will only work for the first 3 levels, but you can start anywhere, ie 2-4, or 1-3, or 3-5, etc):
  3. Uncheck the box for Printable and enter ts-toc-btf as the CSS Class Name:
  4. Save the page.

HTML Macro

  1. Add an HTML macro anywhere on the page (it isn't visible and it is easier to keep track of if you put it under the Table of Contents macro):

  2. Copy the Minimized code below and paste it into the HTML macro.
  3. Tweak style settings if desired–the Readable code below can help identify what changes can be made.

Code


<!-- Title: Floating Table of Contents
    Author: Wade Tracy
 
 
 
    This code sets up the floating table of contents
    - Formatting is configured for up to 3 levels of headings
    - Control your heading levels from the TOC macro
 
 
    How to use:
    1. Use a page layout that puts at least one column on the right to reserve space for the TOC
    2. Insert a TOC macro in the right column of the layout
    3. Change the TOC settings and specify ts-toc-btf for the 'CSS Class Name'
    4. Somewhere in the page (it won't be visible) insert an HTML macro and paste in this code
    5. Feel free to modify css to get the styling you want
-->
 
<style>
    .ts-toc-btf {
        float: right;
        position: fixed;
        width: inherit;
        right: 0px;
        top: 300px;
        background: rgba(229, 232, 232, 0.5);
        overflow-wrap: normal;
        visibility: hidden;
    }
     
    .navigation ul {
      list-style-type: none;
      padding: 0px;
    }
 
    .navigation>ul {
        padding: 10px 15px 10px 10px;
    }
     
    .navigation ul li {
      white-space: nowrap;
    }
 
    .navigation ul li a {
      padding: 0 0 0 10px;
    }
 
 
    .navigation ul li ul li a {
      padding: 0 0 0 20px;
    }
 
 
    .navigation ul li ul li ul li a {
      padding: 0 0 0 30px;
    }
 
    .navigation ul li li {
      white-space: nowrap;
    }
     
    .navigation ul li span.active a {
      font-weight: bold;
      color: #222222;
      border-left: 2px solid #222222;
    }
     
    .navigation ul li a:link,
    .navigation ul li a:visited,
    .navigation ul li a:active,
    .navigation ul li a:hover,
    .navigation ul li a:focus {
      color: #4a72c2;
      text-decoration: none;
    }
     
</style>
 
 
<script type="text/javascript">
    /*!
     * Scrollspy Plugin
     * Author: r3plica
     * Licensed under the MIT license
     */
    ; (function ($, window, document, undefined) {
        // Add our plugin to fn
        $.fn.extend({
            // Scrollspy is the name of the plugin
            scrollspy: function (options) {
                // Define our defaults
                var defaults = {
                    namespace: 'scrollspy',
                    activeClass: 'active',
                    animate: false,
                    duration: 1000,
                    offset: 0,
                    container: window,
                    replaceState: false
                };
     
                // Add any overriden options to a new object
                options = $.extend({}, defaults, options);
                
                var add = function (ex1, ex2) {
                    return parseInt(ex1, 10) + parseInt(ex2, 10);
                };
     
                // Find our elements
                var findElements = function (links) {
                    var elements = [];
     
                    // Loop through the links
                    for (var i = 0; i < links.length; i++) {
                        var link = links[i];
     
                        // Get our hash
                        var hash = $(link).attr("href");
     
                        // Store our has as an element
                        var element = $("[id='" + hash.replace('#','') + "']");
     
                        // If we have an element matching the hash
                        if (element.length > 0) {
     
                            // Get our offset
                            var top = Math.floor(element.offset().top),
                                bottom = top + Math.floor(element.outerHeight());
     
                            // Add to our array
                            elements.push({ element: element, hash: hash, top: top, bottom: bottom });
                        }
                    }

                    return elements;
                };
     
                // Find our link from a hash
                var findLink = function (links, hash) {
                    for (var i = 0; i < links.length; i++) {
                        var link = $(links[i]);
     
                        // If our hash matches the link href
                        if (link.attr("href") === hash) {
     
                            // Return the link
                            return link;
                        }
                    }
                };
     
                // Reset classes on our elements
                var resetClasses = function (links) {
     
                    // For each link
                    for (var i = 0; i < links.length; i++) {
     
                        // Get our current link
                        var link = $(links[i]);
     
                        // Remove the active class
                        link.parent().removeClass(options.activeClass);
                    }
                };
     
                // Find the nearest heading whether at the top of the screen or off the screen
                var getActiveHeadingHash = function (elements) {
     
                    // Get the position and store in an object
                    var position = {
                        top: add($(this).scrollTop(), Math.abs(options.offset)),
                        left: $(this).scrollLeft()
                    };
     
                    // Store the nearest heading off the top of the page
                    var nearest = null;
     
                    // Loop through our elements
                    for (var i = 0; i < elements.length; i++) {
     
                        // Get our current item
                        var current = elements[i];
     
                        // If we are within the boundaries of our element return that link
                        if (position.top >= current.top && position.top <= current.bottom)
                            return current.hash;
                        // Otherwise, return the closest of the top of the screen--they are in order
                        else if (position.top > current.bottom) {
                            nearest = current.hash;
                        }
                    }
     
                    return nearest;
                };
     
                // Store last fired scroll event
                var scrollArea = '';
     
                // For each scrollspy instance
                return this.each(function () {
     
                    // Declare our global variables
                    var element = this,
                        container = $(options.container);
     
                    // Get our objects
                    var links = $(element).find('a');
     
                    // Loop through our links
                    for (var i = 0; i < links.length; i++) {
     
                        // Get our current link
                        var link = links[i];
     
                        // Bind the click event
                        $(link).on("click", function (e) {
     
                            // Get our target
                            var target = $(this).attr("href"),
                                $target = $(target);
     
                            // If we have the element
                            if ($target.length > 0) {
     
                                // Get it's scroll position
                                var top = add($target.offset().top, options.offset);
     
                                // If animation is on
                                if (options.animate) {
     
                                    // Animate our scroll
                                    $('html, body').animate({ scrollTop: top }, options.duration);
                                } else {
     
                                    // Scroll to our position
                                    window.scrollTo(0, top);
                                }
                                 
                                location.hash = target;
     
                                // Prevent our link
                                e.preventDefault();
                            }
                        });
                    }
     
                    // Set links
                    resetClasses(links);
     
                    // Get our elements (targets of the navigation links)
                    var elements = findElements(links);
     
                    var trackChanged = function() {
     
                        // Create a variable for our link
                        var link;
     
                        // get our element
                        var hash = getActiveHeadingHash(elements);
     
                        // Get the link
                        link = findLink(links, hash);
     
                        // If we have a link
                        if (link) {
                            // If we have an onChange function
                            if (options.onChange && (scrollArea !== hash)) {
     
                                // Fire our onChange function
                                options.onChange(current.element, $(element), position);
     
                                // set scrollArea
                                scrollArea = hash;
     
                            }
     
                            // Update url
                            if (options.replaceState) {
                                history.replaceState( {}, '', '/' + hash );
                            }
     
                            // Reset the classes on all other link
                            resetClasses(links);
     
                            // Add our active link to our parent
                            link.parent().addClass(options.activeClass);
                        }
     
                        // If we don't have a link and we have a exit function
                        if (!link && (scrollArea !== 'exit') && options.onExit) {
     
                            // Fire our onChange function
                            options.onExit($(element), position);
     
                            // Reset the classes on all other link
                            resetClasses(links);
     
                            // set scrollArea
                            scrollArea = 'exit';
     
                            // Update url
                            if (options.replaceState) {
                                history.replaceState( {}, '', '/' );
                            }
     
                        }
                    };
     
                    // Add a listener to the window
                    container.bind('scroll.' + options.namespace, function () {
                        trackChanged();
                    });
     
                    $(document).ready(function (e) {
                        trackChanged();
                    });
                });
            }
        });
    })(jQuery, window, document, undefined);
 
    var getMenuWidth = function() {
        // Need to get the padding of the main window since menu goes to edge of window
        var mainPadding = ($('#main').innerWidth() - $('#main').width()) / 2;
        var parentWidth = AJS.$(".ts-toc-btf").parent().parent().outerWidth();
        return parentWidth + mainPadding;
    };
     
    AJS.$(window).load(function(){
 
        var content_top = AJS.$('#content').offset().top;
        var menu_width = getMenuWidth();
        var max_width = AJS.$('.ts-toc-btf').width();
 
        AJS.$(".ts-toc-btf")
            .addClass('navigation')
            .attr('id', 'nav')
            .css({'top': content_top, 'visibility': 'visible', 'width': menu_width, 'max-width': max_width});
        AJS.$("#nav").scrollspy();
    });
 
    AJS.$(window).resize(function() {
        var menu_width = getMenuWidth();
 
        AJS.$(".ts-toc-btf")
            .css({'width': menu_width});
    });
</script>

 

3 comments

We use the HTML Macro frequently for embedding Issue Collectors on Confluence pages. 

That's a great idea!  I had never thought to do that and it could be helpful in so many ways.  I haven't spent much time working with collectors and so I didn't realize that people could use them even if they don't have Jira licenses which would be very useful for us.  Thanks for the tip!

I am glad to provide a useful tip. Here is an example of what the Issue Collector code looks like in the HTML Macro.

The top highligted part identifies the Issue Collector ID, which can be interchangeable for reuse via copy/paste. Most of the code comes right from the Issue Collector configuration page in Jira, which provides the code to embed.

The part circled in red is custom JavaScript to pre-populate specific field values. 

HTML Issue Collector.JPG

I'll also add the Jira license part can be tricky with Issue Collectors. If they don't have a license there are some workarounds, like setting a fixed Reporter. But then it can be tricky to find out who the user is if their email is not captured or provided. 

Awesome!  Thanks for sharing the details.  I'll definitely give this a try.

We recently released a free macro plugin. This floating table of contents free plugin works just fine and easy to use. You don't need to understand or write any codes by yourself.

https://marketplace.atlassian.com/1221271

This is exactly what I was looking for. Thank you! I can't seem to get the HTML insert method working. I may keep trying to troubleshoot. Any issues you've run into that I can look out for?

Probably should mention that I'm on the cloud version so I'm a bit limited in enabling marketplace macros. 

Yeah, I'm not sure if this would work for Cloud or not...I haven't used Cloud much.

Comment

Log in or Sign up to comment
TAGS