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.
Leave a Reply