Anchor link not working

Hi all and thanks for the great tool I have near no web experience, and it enabled me to make dashboards easily.
I am trying to make an index to a lengthy report in the sidebar the problem is when I click the link it change link appearing but the page still the same.
image
I use panel 1.3.1
this is minimal code the reproduce the issue.

import panel as pn
pn.extension('mathjax')
vanilla = pn.template.VanillaTemplate(theme='dark')
page = pn.Column(sizing_mode='stretch_width')
vanilla.main.append(page)
camp_g_names=['test1','test2']
graphs = []
indexes = ''
for camp_g_name in camp_g_names:
    graphs += ['<h1 style = "color: white;font-size:20px">'+camp_g_name+'<a class="anchor-link" href="#'+camp_g_name+'" name="'+camp_g_name+'"  id="'+camp_g_name+'" ></a></h1>',(camp_g_name+" content\n")*40]

    indexes += '<a href="#'+camp_g_name+'">'+camp_g_name+'</a>\n'
    vanilla.sidebar.append('<a href="#'+camp_g_name+'">'+camp_g_name+'</a>')
vanilla.main[0].objects = graphs
vanilla.servable()

Thanks

Hi @da-ahmed-hanafi

It turns out the difficulty you have to get this to work stems from a bug in Panel, I’ve opened an issue to track it Shadow DOM prevents anchor links to other elements from working · Issue #6156 · holoviz/panel · GitHub.

Have you already found a workaround?

Hi @maximlt

thanks a lot for caring I will flow the bug.
no I didn’t, once I do I will post it here.

Long time lurker, first time poster here, so, here goes…

I first modified the original example to instead rely on Markdown panes. This was my situation because I use markdown panes to introduce other panel components. I believe the same concept can be used with the original if needed but I think there are some advantages to Markdown panes & the same technique can be adapted for other panel components.

I noticed Markdown panes automatically generate anchor links for headings but I also noticed they were not working. This post came up as my first search result so I’m very grateful to the authors as I’m sure it saved me a ton of time investigating.

Panel’s shadow DOM situation is something I’ve encountered in the past so I had some functions already to help with finding nodes in those. I combined that with a modified version of the suggestion linked to in the bug report.

My suggested solution could be more self-contained by including the JS inline with the Python code as noted in my comments. However, I tend to prefer separate JS files to facilitate the reuse of resources and permit automatic styling in my editor. A “scripts” subdirectory in the Python file directory is expected here. One of my favorite aspects of Panel is the ability to fill in JS where there are gaps left by Python.

I haven’t explored the suffix convention applied to the automatically generated hashes of the Markdown headings. This solution ignores them so it will probably break if multiple headings with the same name are employed.

I’ve been using Panel for about a year and JS/Python on and off for a bit longer so I’m far from an expert in any of this.

Hopefully this makes some sense and is helpful to someone!!

Python code:

# tested with:
# jupyterlab 4.0.10
# panel 1.3.6

import panel as pn
# pn.extension('mathjax')

jsvers = 0 #to bust browser cache when developing
pn.extension(
    'mathjax',
    # static dir set in show/serve:
    js_files={'anchor_links': f'scripts/anchorLinks.js?v=bustCache{jsvers}'}) #if JS as separate file

vanilla = pn.template.VanillaTemplate(theme='dark')

# page = pn.Column(sizing_mode='stretch_width')
# vanilla.main.append(page)
# camp_g_names=['test1','test2']
# graphs = []
# indexes = ''
# for camp_g_name in camp_g_names:
#     graphs += ['<h1 style = "color: white;font-size:20px">'+camp_g_name+'<a class="anchor-link" href="#'+camp_g_name+'" name="'+camp_g_name+'"  id="'+camp_g_name+'" ></a></h1>',(camp_g_name+" content\n")*40]
#     indexes += '<a href="#'+camp_g_name+'">'+camp_g_name+'</a>\n'
#     vanilla.sidebar.append('<a href="#'+camp_g_name+'">'+camp_g_name+'</a>')

camp_g_names=['test1','test2', 'test3', 'test4', 'test5'] #unique headings
nav_md = ''
content_md_panes = []  
for camp_g_name in camp_g_names:
    nav_md += f'- __[{camp_g_name}](#{camp_g_name})__' + '\n'
    # anchor link hash automatically added to MD headers (but note suffixes -x)
    content_md = f'# {camp_g_name}' + '\n' + (f'{camp_g_name} content' + '\n')*40 
    content_md_panes.append(pn.pane.Markdown(content_md, css_classes=[f'{camp_g_name}-content']))

nav_row = pn.Row(pn.pane.Markdown(nav_md, height=250, css_classes=['sidebar-jumpto']))
content_col = pn.Column()
content_col.objects = content_md_panes

customJS = r"""
<script type="text/javascript">

**JS FILE TEXT HERE**

</script>
"""

# vanilla.main[0].objects = graphs
vanilla.main.append(content_col)
vanilla.sidebar.append(nav_row)
# vanilla.sidebar.append(pn.pane.HTML(customJS, visible=False)) # if inlining JS
vanilla.servable()

# Serve from here (if wasn't loaded with panel serve)
# e.g., panel serve --show "path/to/notebook.ipynb" --static-dirs scripts="path/to/scripts"
if not __name__.startswith('bokeh'):
    try:
        srv.stop() #kill last if running
    except:
        pass
        
    srv = vanilla.show(
        static_dirs={'scripts': 'scripts'} #relative location of anchorLinks.js file
    )

JavaScript code (as a separate file or inline as string using html pane):

// Example workaround for hash location/navigation links not working in panel:
// https://github.com/holoviz/panel/issues/6156
// https://discourse.holoviz.org/t/anchor-link-not-working/6651

// permits linking to location e.g., http://localhost:port/#test4 both internally and externally 

var anchorLinks = ['test1','test2', 'test3', 'test4', 'test5']; //anchor names
var anchorNodes = {}; //locations to link TO
var jumpLinkContainer; //container for navigation links

if (document.readyState !== 'loading') {
    setupAnchors();
} else {
    document.addEventListener("DOMContentLoaded", function(event) { 
        // does not always wait for all panel elements! 
        // better luck with jquery but still not 100%
        // can use waitForNodes (below) or mutation observer
        setupAnchors();
    });
}

function setupAnchors(){
    waitForNodes(anchorNodeSearch).then(()=>{
        scrollToAnchor(); //if page loaded with hash in url
        waitForNodes(jumpToNodeSearch).then(()=>{attachListenerToAnchorLinks()});
    });
};

function anchorNodeSearch(){
    allAnchorNodes = [];
    anchorLinks.forEach((topic)=>{
        anchorNodes[topic] = {'content': findNodeByClass(topic + '-content')};
        allAnchorNodes.push(anchorNodes[topic]['content']);
        if (anchorNodes[topic]['content']){
            anchorNodes[topic]['link'] = ShadowFrameFind('a[href*="#"]', anchorNodes[topic]['content']);
            allAnchorNodes.push(anchorNodes[topic]['link']);
        } else {
            allAnchorNodes.push(null)
        };
    });
    return allAnchorNodes;
};

function jumpToNodeSearch(){
    //search for the nav links in the side panel
    var sidenav = findNodeByClass('sidenav');
    var jumpTo = ShadowFrameFind('.sidebar-jumpto', sidenav);
    if (jumpTo){
        jumpLinkContainer = jumpTo.shadowRoot;
    };
    return [sidenav, jumpTo, jumpLinkContainer];
};

function attachListenerToAnchorLinks(){
    try {
        jumpLinkContainer.querySelectorAll('a').forEach((el)=>{
            el.addEventListener("click", (e) => {
                //Rely on default action to place hash in url then update page (async)
                setTimeout(scrollToAnchor, 200); 
            }); 
        });
    } catch(err){console.log(err)}; 
};

function getHash(){
    return window.top.location.hash.substring(1);  
};

function scrollToAnchor(){
    //panel markdown panes add -number suffixes to anchor link hashes
    //probably (?) to avoid duplicate hashes
    //Limitation: this method will probably break on encountering duplicate markdown headings!!
    urlHash = getHash().replace(/-\d+$/,'')
    if (urlHash){
        for (node_k in anchorNodes){
            try {
                anchor = anchorNodes[node_k]['link'].href.split('#')[1].replace(/-\d+$/,'')
                if (anchor==urlHash){
                    anchorNodes[node_k]['content'].scrollIntoView({behavior: 'smooth'});
                    break;
                };
            } catch(err){};
        }; 
    };
};

////////////////////////////////////////////////////////////
// General utility functions useful for tweaking panel apps
////////////////////////////////////////////////////////////

function waitForNodes(nodeSearcher, maxWait = 3000, delay=200, resolveOnFail=false){
    //nodeSearcher f searches for specific nodes by selectors and parent elements
    //nodeSearcher should return a list of nodes searched & return null element when node not found
    //on ready does not always wait for pn elements to load 
    //Alternative: mutation observer
    
    const start = +new Date;
    nodePromise = new Promise((resolve, reject)=> {
        checkForNodes = function(){
            // sidenav = findNodeByClass('sidenav');
            // jumpTo = $ShadowFrames('.sidebar-jumpto', sidenav);
            // jumpNodes = [sidenav, jumpTo]
            nodeList = nodeSearcher();
            if (!nodeList.includes(null)){
                resolve();                    
            } else if (+new Date > start + maxWait) {
                console.log('Could not find nodes!!');
                if (resolveOnFail){resolve()};
            } else {
                setTimeout(checkForNodes, delay);
            };
        };
        checkForNodes();
    });

    return nodePromise;
};

function ShadowFrameFind(selector, element = document.body) {
    // panel's shadow elements can be hard to find. 
    // This recursively searches inside and outside shadows & iframes 
    // RETURNS: *FIRST* found element matching selector under start element
    // TODO: hits a wall sometimes requiring more than one step from doc.body
    // TODO: find all option
    const found = element.querySelector(selector);
    if (found){ 
        return found;
    } else if (element.shadowRoot) {
        element = element.shadowRoot;
        const found = element.querySelector(selector);
        if (found) return found;
    } else if (element.tagName === 'IFRAME') {
        element = element.contentDocument.body;
        const found = element.querySelector(selector);
        if (found) return found;
    };
    for (let i = 0; i < element.children.length; i++) {
        const child = element.children[i];
        const found = ShadowFrameFind(selector, child);
        if (found) {
            return found;
        };
    };
    return null;
};

function findNodeByClass(className){
    return ShadowFrameFind(`.${className}`);
};
1 Like

Hi @chuck

thank you very much
it was very helpful

I played with the concept to integrate it with my code
sound that panel or the JS do not see the class if I introduced it beside anchor-link
like that <h1 style = "color: white;font-size:20px">'+camp_g_name+'<a class="anchor-link '+camp_g_name+'-content" href="#'+camp_g_name+'" name="'+camp_g_name+'" id="'+camp_g_name+'" ></a></h1>' it has to be added through pn.pane.Markdown(content_md, css_classes=[f'{camp_g_name}-content'])
or some thing like that

I used the inline option as the list of anchorLinks is changing so I use the list and integrate it in your JS solution like that

customJS = r"""
<script type="text/javascript">

// Example workaround for hash location/navigation links not working in panel:
// https://github.com/holoviz/panel/issues/6156
// https://discourse.holoviz.org/t/anchor-link-not-working/6651

// permits linking to location e.g., http://localhost:port/#test4 both internally and externally 

var anchorLinks = """+str(camp_g_names)+"""; //anchor names
var anchorNodes = {}; //locations to link TO
var jumpLinkContainer; //container for navigation links

if (document.readyState !== 'loading') {
    setupAnchors();
} else {
    document.addEventListener("DOMContentLoaded", function(event) { 
        // does not always wait for all panel elements! 
        // better luck with jquery but still not 100%
        // can use waitForNodes (below) or mutation observer
        setupAnchors();
    });
}

function setupAnchors(){
    waitForNodes(anchorNodeSearch).then(()=>{
        scrollToAnchor(); //if page loaded with hash in url
        waitForNodes(jumpToNodeSearch).then(()=>{attachListenerToAnchorLinks()});
    });
};

function anchorNodeSearch(){
    allAnchorNodes = [];
    anchorLinks.forEach((topic)=>{
        anchorNodes[topic] = {'content': findNodeByClass(topic + '-content')};
        allAnchorNodes.push(anchorNodes[topic]['content']);
        if (anchorNodes[topic]['content']){
            anchorNodes[topic]['link'] = ShadowFrameFind('a[href*="#"]', anchorNodes[topic]['content']);
            allAnchorNodes.push(anchorNodes[topic]['link']);
        } else {
            allAnchorNodes.push(null)
        };
    });
    return allAnchorNodes;
};

function jumpToNodeSearch(){
    //search for the nav links in the side panel
    var sidenav = findNodeByClass('sidenav');
    var jumpTo = ShadowFrameFind('.sidebar-jumpto', sidenav);
    if (jumpTo){
        jumpLinkContainer = jumpTo.shadowRoot;
    };
    return [sidenav, jumpTo, jumpLinkContainer];
};

function attachListenerToAnchorLinks(){
    try {
        jumpLinkContainer.querySelectorAll('a').forEach((el)=>{
            el.addEventListener("click", (e) => {
                //Rely on default action to place hash in url then update page (async)
                setTimeout(scrollToAnchor, 200); 
            }); 
        });
    } catch(err){console.log(err)}; 
};

function getHash(){
    return window.top.location.hash.substring(1);  
};

function scrollToAnchor(){
    //panel markdown panes add -number suffixes to anchor link hashes
    //probably (?) to avoid duplicate hashes
    //Limitation: this method will probably break on encountering duplicate markdown headings!!
    urlHash = getHash().replace(/-\d+$/,'')
    if (urlHash){
        for (node_k in anchorNodes){
            try {
                anchor = anchorNodes[node_k]['link'].href.split('#')[1].replace(/-\d+$/,'')
                if (anchor==urlHash){
                    anchorNodes[node_k]['content'].scrollIntoView({behavior: 'smooth'});
                    break;
                };
            } catch(err){};
        }; 
    };
};

////////////////////////////////////////////////////////////
// General utility functions useful for tweaking panel apps
////////////////////////////////////////////////////////////

function waitForNodes(nodeSearcher, maxWait = 3000, delay=200, resolveOnFail=false){
    //nodeSearcher f searches for specific nodes by selectors and parent elements
    //nodeSearcher should return a list of nodes searched & return null element when node not found
    //on ready does not always wait for pn elements to load 
    //Alternative: mutation observer
    
    const start = +new Date;
    nodePromise = new Promise((resolve, reject)=> {
        checkForNodes = function(){
            // sidenav = findNodeByClass('sidenav');
            // jumpTo = $ShadowFrames('.sidebar-jumpto', sidenav);
            // jumpNodes = [sidenav, jumpTo]
            nodeList = nodeSearcher();
            if (!nodeList.includes(null)){
                resolve();                    
            } else if (+new Date > start + maxWait) {
                console.log('Could not find nodes!!');
                if (resolveOnFail){resolve()};
            } else {
                setTimeout(checkForNodes, delay);
            };
        };
        checkForNodes();
    });

    return nodePromise;
};

function ShadowFrameFind(selector, element = document.body) {
    // panel's shadow elements can be hard to find. 
    // This recursively searches inside and outside shadows & iframes 
    // RETURNS: *FIRST* found element matching selector under start element
    // TODO: hits a wall sometimes requiring more than one step from doc.body
    // TODO: find all option
    const found = element.querySelector(selector);
    if (found){ 
        return found;
    } else if (element.shadowRoot) {
        element = element.shadowRoot;
        const found = element.querySelector(selector);
        if (found) return found;
    } else if (element.tagName === 'IFRAME') {
        element = element.contentDocument.body;
        const found = element.querySelector(selector);
        if (found) return found;
    };
    for (let i = 0; i < element.children.length; i++) {
        const child = element.children[i];
        const found = ShadowFrameFind(selector, child);
        if (found) {
            return found;
        };
    };
    return null;
};

function findNodeByClass(className){
    return ShadowFrameFind(`.${className}`);
};

</script>
"""

thanks very much again

Markdown panes aren’t required. It’s just whatever DOM structure is used on the Python side must be found by the JS side. (via class, id, location, etc… )

In my example anchorNodeSearch and jumpToNodeSearch were responsible for finding the respective nodes of interest on the JS side. Without markdown panes, and sticking to the original html, you can reuse the anchorNodeSearch function as is if you put the content class in the h1 tag as opposed to the a. jumpToNodeSearch will have to be tweaked because there is no longer a Markdown pane to introduce a shadowRoot to contain the jump/nav links. I moved the sidebar-jumpto class to the a tag in that case to find the nav links and got the parent of the first one to get to the container. That’s just one possibility. So long as they all find each other they will be happy.

Excerpt of revised Python:

# tested with:
# jupyterlab 4.0.10
# panel 1.3.6

import panel as pn
# pn.extension('mathjax')

jsvers = 0 #to bust browser cache when developing
pn.extension(
    'mathjax',
    # static dir set in show/serve:
    js_files={'anchor_links': f'scripts/anchorLinks.js?v=bustCache{jsvers}'}) #if JS as separate file

vanilla = pn.template.VanillaTemplate(theme='dark')

page = pn.Column(sizing_mode='stretch_width')
vanilla.main.append(page)
# camp_g_names=['test1','test2']
camp_g_names=['test1','test2', 'test3', 'test4', 'test5'] #for big screen :-)
graphs = []
indexes = ''
for camp_g_name in camp_g_names:
    #original:
    # graphs += ['<h1 style = "color: white;font-size:20px">'+camp_g_name+'<a class="anchor-link" href="#'+camp_g_name+'" name="'+camp_g_name+'"  id="'+camp_g_name+'" ></a></h1>',(camp_g_name+" content\n")*40]
    # indexes += '<a href="#'+camp_g_name+'">'+camp_g_name+'</a>\n'

    #### 'indexes' seems to lack further reference in original...

    #To mirror the markdown-based solution, add the class to a *container* of the anchors and you can leave anchorNodeSearch function unchanged
    graphs += ['<h1 class="anchor-link '+camp_g_name+'-content"style = "color: white;font-size:20px">'+camp_g_name+'<a class="anchor-link" href="#'+camp_g_name+'" name="'+camp_g_name+'"  id="'+camp_g_name+'" ></a></h1>',(camp_g_name+" content\n")*40]
    #Need to find these too! can add the class here but need to adjust jumpToNodeSearch to look to the parentNode instead of shadowRoot for the link container
    indexes += '<a href="#'+camp_g_name+'" class="sidebar-jumpto">'+camp_g_name+'</a>\n' 
    # Perhaps instead of this line...
    # vanilla.sidebar.append('<a href="#'+camp_g_name+'" class="sidebar-jumpto">'+camp_g_name+'</a>') 
vanilla.sidebar.append(indexes) # ...you meant to do this?
vanilla.main[0].objects = graphs
vanilla.servable()

Excerpt of revised JS:

function jumpToNodeSearch(){
    //search for the nav links in the side panel
    var sidenav = findNodeByClass('sidenav');
    var jumpTo = ShadowFrameFind('.sidebar-jumpto', sidenav);
    if (jumpTo){
        // jumpLinkContainer = jumpTo.shadowRoot; //using markdown pane in original answer
        jumpLinkContainer = jumpTo.parentNode; //using revised raw html
        
    };
    return [sidenav, jumpTo, jumpLinkContainer];
};

hth

1 Like

thanks very much @chuck that was really helpful and clarified the behavior of the JS
I think if it could be done so that the JS is searching for the id or name as it is it would be more natural to ship this with panel but may need tweaking