How to allow the links on a RichText markdown's Table of Contents to scroll to the selected section

What I’m trying to do:
I have a form with markdown. It has a Table of Contents. When a link is clicked within the Table of Contents, I’d like the page to scroll to the selected section that was clicked.

What I’ve tried and what’s not working:
I’ve tried the below markdown sample which seems to work as intended on most markdown editors online (ie: clicking the link scrolls to the appropriate section of the page).

Code Sample:

# Table of Contents

- [Introduction](#introduction)
- [Features](#features)
  - [Feature A](#feature-a)
  - [Feature B](#feature-b)
  - [Feature C](#feature-c)
- [Details](#details)
- [Conclusion](#conclusion)

## Introduction

This is the introduction section. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus lacinia odio vitae vestibulum. Donec in efficitur leo. Sed nec tempor nunc. Nulla facilisi. Suspendisse potenti. Proin euismod sapien vel orci pharetra, vitae dictum erat tempor. Fusce id nulla orci. Praesent fringilla, ligula at auctor viverra, metus justo commodo justo, in viverra mauris justo nec erat.

## Features

Here we discuss the features.

### Feature A

Feature A is amazing. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus lacinia odio vitae vestibulum. Donec in efficitur leo. Sed nec tempor nunc. Nulla facilisi. Suspendisse potenti. Proin euismod sapien vel orci pharetra, vitae dictum erat tempor. Fusce id nulla orci. Praesent fringilla, ligula at auctor viverra, metus justo commodo justo, in viverra mauris justo nec erat.

### Feature B

Feature B is even better. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus lacinia odio vitae vestibulum. Donec in efficitur leo. Sed nec tempor nunc. Nulla facilisi. Suspendisse potenti. Proin euismod sapien vel orci pharetra, vitae dictum erat tempor. Fusce id nulla orci. Praesent fringilla, ligula at auctor viverra, metus justo commodo justo, in viverra mauris justo nec erat.

### Feature C

Feature C is the best of all. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus lacinia odio vitae vestibulum. Donec in efficitur leo. Sed nec tempor nunc. Nulla facilisi. Suspendisse potenti. Proin euismod sapien vel orci pharetra, vitae dictum erat tempor. Fusce id nulla orci. Praesent fringilla, ligula at auctor viverra, metus justo commodo justo, in viverra mauris justo nec erat.

## Details

Here are some additional details. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus lacinia odio vitae vestibulum. Donec in efficitur leo. Sed nec tempor nunc. Nulla facilisi. Suspendisse potenti. Proin euismod sapien vel orci pharetra, vitae dictum erat tempor. Fusce id nulla orci. Praesent fringilla, ligula at auctor viverra, metus justo commodo justo, in viverra mauris justo nec erat.

## Conclusion

This is the conclusion. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus lacinia odio vitae vestibulum. Donec in efficitur leo. Sed nec tempor nunc. Nulla facilisi. Suspendisse potenti. Proin euismod sapien vel orci pharetra, vitae dictum erat tempor. Fusce id nulla orci. Praesent fringilla, ligula at auctor viverra, metus justo commodo justo, in viverra mauris justo nec erat.

I’ve also tried with anchors as well but still doesn’t seem to work.

<a id="introduction"></a>
## Introduction

... content ...

Adding this to the form_show event or whenever the content changes, seems to be doing the job (unless the first click doesn’t want to work, see below):

import anvil.js
import re
[...]
    def form_show(self, **event_args):
        node = anvil.js.get_dom_node(get_open_form().rich_text_1)
        inner_html = node.innerHTML
        for match in re.finditer(r'<h(\d)>(.*?)</h\d>', inner_html):
            print(match, match.groups())
            h, text = match.groups()
            text_id = text.replace(' ', '_')
            inner_html = inner_html.replace(
                f'<h{h}>{text}</h{h}>',
                f'<h{h} id="{text_id}">{text}</h{h}>'
            )
        node.innerHTML = inner_html

This adds the id attribute to the h tags. The id is the text of the tag after replacing spaces with underscores. This replacement may need some refining if you want to add more characters to the h tags, like quotes or characters that need to be html encoded. They could be removed instead of replaced.

I tested in the IDE, and the first click reloads the app, while the second click works as expected. I haven’t tried in the production app, but I have the feeling that it will work as expected.


Notice that I have slightly changed the id in the content:

  - [Feature A](#Feature_A)

I guess most editor do something like this, which is not part of the standard Markdown.

2 Likes

I tested from the IDE in debug and also on a published URL (using Anvil Environments) and encountered the same behavior (noted below).

The first time a link is clicked, the page reloads. However, the second time I click on one of the links from the Table of Contents, I can see the URL appended (ie: with #details for example) but the page never scrolls down to the appropriate section.

1 Like

Try to inspect the html and see if the ids are there, and make sure you respect the case (detail vs Detail).

You can also try to use the full url instead of the relative one. There is a function that gives you the current url, but I’m on the phone now and can’t check.

how’s this:

from anvil.js import get_dom_node, window

pathname = window.location.pathname
if not pathname.endswith("/"):
    window.history.replaceState(window.history.state, None, pathname.replace("/_/debug", "") + "/")

def add_anchors_to_headings(rt):
    for h in get_dom_node(rt).querySelectorAll("h1, h2, h3"):
        h.id = h.textContent.replace(" ", "-").lower()

class Form1(Form1Template):
    def __init__(self, **properties):
        self.init_components(**properties)
        add_anchors_to_headings(self.rich_text_1)

We fix up the page url to make sure it ends with a “/”
We also remove the debug suffix from the url
This ensures the a.href point to the right location relative to the current page

Rather than replacing the innerHTML it’s probably better just to walk the dom nodes
this can be done in the __init__ because the dom nodes exist already.

1 Like

Awesome, that worked like a charm! Thank you!

1 Like

@stucork Your solution worked perfectly for my PoC app. However, when I applied those same changes for my production app which uses the anvil_extras routing package, I received the below error when clicking on a Table of Contents link (perhaps somewhat expected).

LookupError: 'introduction' does not exist
at app/anvil_extras/routing/_router.py:302
called from app/anvil_extras/routing/_router.py:343
called from app/anvil_extras/routing/_router.py:267
called from app/anvil_extras/routing/_router.py:151
called from app/anvil_extras/routing/_router.py:151
called from app/anvil_extras/routing/_navigation.py:54

My current form in which I am trying to display the RichText component on is shown below. You can see it will have a url hash of #support appended to the path already. Not quite sure how/if I can navigate around this.

from ._anvil_designer import SupportPageTemplate
from anvil import *
import anvil.server
import anvil.users
import anvil.tables as tables
import anvil.tables.query as q
from anvil.tables import app_tables
from anvil_extras import routing
from anvil.js import get_dom_node, window

pathname = window.location.pathname
if not pathname.endswith("/"):
    window.history.replaceState(window.history.state, None, pathname.replace("/_/debug", "") + "/")

def add_anchors_to_headings(rt):
    for h in get_dom_node(rt).querySelectorAll("h1, h2, h3"):
        h.id = h.textContent.replace(" ", "-").lower()

@routing.route('support', title='Support Center') 
class SupportPage(SupportPageTemplate):
  def __init__(self, **properties):
    # Set Form properties and Data Bindings.
    self.init_components(**properties)
    add_anchors_to_headings(self.rt_support_markdown)

    # Any code you write here will run before the form opens.

You can’t use hash anchor elements with hash routing. Hash routing listens for changes to the hash in the browser. And by using hash anchor elements you’re causing hash routing to fire off its routing process.

An alternative would be to and click event listeners to your links rather than hrefs

(And don’t do the history.replaceState change - that’s unnecessary with this suggestion)

from anvil.js import get_dom_node, window
from anvil.js.window import document

def scrollToHeading(event):
    document.getElementById(event.target.dataset.anchor).scrollIntoView()

def add_anchors_to_headings(rt):
    rt_node = get_dom_node(rt)
    for a in rt_node.querySelectorAll("a"):
        href = a.getAttribute("href")
        if href[0] == "#":
            a.dataset.anchor = href[1:]
            a.setAttribute("href", "javascript:void(0)")
            a.addEventListener("click", scrollToHeading)
    for h in get_dom_node(rt).querySelectorAll("h1, h2, h3"):
        h.id = h.textContent.replace(" ", "-").lower()

1 Like

@stucork Awesome, that worked! Thank you!

1 Like