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}`);
};