Sticky elements in Obsidian markdown views

Recently, I was working on one of my many Obsidian plugins that I haven’t released (Why do I keep doing this!), and I needed to check the scroll offset of the editor window.

I wanted to know how far the user had scrolled so that I could reposition a content element that may or may not have scrolled in or out of view.

My initial attempt failed, so here’s what I encountered and what I did instead.

My technical articles get straight to the point, so If you find yourself lost, feel free to message me at one of the links above and I can add in some context. Also note that my code examples are often in TypeScript.

Position sticky wasn’t an option

When you want an item to scroll with the page but not scroll off the page, the usual approach is to use CSS. Making the element position:sticky will allow the element to scroll normally, but stick the edge of the window instead of ever scrolling off the page. MDN has more information about this.

The problem with position:sticky, is that it only works if the whole page is scrolling. In this instance, only a div is scrolling, so it’s not an option.

Window won’t work

For similar reasons to the CSS issue, window.scrollY wouldn’t get us the result in Obsidian either. This is because the markdown view being referred to in Obsidian isn’t the window, nor is it an iFrame within the window, it’s just a div.

This means the scrollY of window will always be zero, because the window itself isn’t scrolling.

ie. This won’t work…

window.addEventListener("scroll", evt => {
    // This will get the wrong value
    let offsetY = window.scrollY;
    // Do stuff with offsetY
}

The solution

How then, do we get the scrollY of a div? We find the relevant div and get its scrollTop value. However, because I’m wanting to know the position of a content element inside the scrolling div, there’s also a little more maths to do.

// Begin monitoring for when the scrollable section is scrolled by finding the nearest parent element that's scrollable (Specific to Obsidian and CodeMirror)
const scrollableEl = theContentElementInsideTheArea.closest(".cm-scroller");
scrollableEl.addEventListener('scroll', handleScrolling);

...

// Define the function that's called as the scroll handler
const handleScrolling = (e: Event): void => {
    const scrollingEl = e.target as HTMLDivElement;
    const pageScrollY = scrollingEl.scrollTop;
    const contentEl = theContentElementInsideTheArea;

    // Get the content elements absolute y position on the page - minus its parent's absolute y position so you're left with the y position of the element within the scrollable area.
    // Note: This could be simpler depending on your content element.
    let contentPosY = contentEl.getBoundingClientRect().top;
    contentPosY -= scrollingEl.getBoundingClientRect().top || 0;

    // If the value is negative, the scrollable area has been scroll such that the content element is at least partially scrolled off the top edge of it.
    const scrolledOffTopEdge = contentPosY < 0;

    // Modifying the styling and positioning as desired for each state
    if (scrolledOffTopEdge) {
        // Make sure it stays visible by moving it down
        menuBarEl.style.top = Math.abs(blockOffsetY) + 'px';
    } else {
        // Remove previous style changes so it's in its normal position.
        menuBarEl.style.removeProperty('top');
    }
}

The element we are targeting is based on the structure of how Obsidian places the editor in the overall HTML page. This could potentially change in the future with future Obsidian updates, so keep that in mind.

Alternative approaches

Rather than directly accessing a specific div and its pixel offset, you can also access some related values from CodeMirror (Which is the framework that Obsidian uses for its editor).

This would be safer as it wouldn’t break if Obsidian changed its Html structure in the future.

It may work for what you need, but I abandoned this approach as it only provides the ability to know the number of lines that have been scrolled, or the fraction of a partially visible line.

Before abandoning it, my first thought was that I could get the scrollY in pixels by using an equation like below:

scrollFraction = (numberOfLines + remainderFraction) / totalLines;
scrollY = pageHeightPx * scrollFraction;

However, I quickly realised that line heights can differ in Obsidian depending on whether the line was a heading or even some other form of embed (perhaps even replaced by a plugin).

There may well be ways to get these correctly, but at this point it felt unnecessarily complicated and potentially becoming delicate in itself. So I didn’t pursue this further and used the solution noted above.

If you‘re interested in pursuing the lines alternative, here’s the code I started with:

const markdownView = plugin.app.workspace.getActiveViewOfType(MarkdownView);

// I wouldn't recommend using setInterval, but it’s what I used for testing.
let scrollCheckInterval = setInterval( () => {

    const scroll = markdownView.currentMode.getScroll();
    console.log('scroll', scroll);

}, 1000);

That’s it!

Thanks…

I also dissect and speculate on design and development.
Digging into subtle details and implications, and exploring broad perspectives and potential paradigm shifts.
Check out my conceptual articles on Substack or find my latest below.


You can also find me on Threads, Bluesky, Mastodon, or X for more diverse posts about ongoing projects.

My latest articles

Designing immersive experiences

In traditional cinema, TV, or even the more modern phone screen, there’s limited screen real-estate. But removing that limitation creates a design problem…

The future is not prompt engineered

Let’s not pretend the importance of prompt engineering is ubiquitous. The most prevalent power of generative AI is in the way it adapts to us, not the other way around…

The typography of dates, times, & filenames

A deep dive into carefully considered date formatting, line length and general typography attributes of filenames…

Author:

Date:

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.