An Obsidian plugin that puts you back where you stopped reading when you reopen a note.
Built because the existing options kept losing my place in long notes I was reading in preview mode rather than editing. It works the same in editing mode and reading mode, and it does not expire saved positions.
Manually. Download main.js and manifest.json from the latest release, put them in YourVault/.obsidian/plugins/scroll-memory/, restart Obsidian, then enable Scroll Memory under Settings, Community plugins.
Via BRAT. Add onionviolet/obsidian-scroll-memory as a beta plugin.
There is no build step. main.js is the source, in plain CommonJS, and Obsidian loads it as-is.
| Command | Use |
|---|---|
| Restore scroll position for this note | Jump back manually, even if you have already scrolled away |
| Save scroll position for this note now | Pin the current spot without waiting for the debounce |
| Forget scroll position for this note | Drop the saved spot for the current note |
| Setting | Default | What it does |
|---|---|---|
| Restore on open | on | Jump to the saved position when a note opens |
| Confirm each restore | off | Show a notice naming the line it jumped to. Useful for a day while you decide whether you trust it |
| Save delay (ms) | 400 | How long scrolling has to pause before the position is recorded |
| Expire after (days) | 0 | 0 keeps positions forever |
Four decisions carry the whole plugin.
One listener, on the capture phase. Scroll events do not bubble, which is why plugins in this space typically bind a listener directly to the scrolling element. Obsidian has two of them: .cm-scroller in editing mode and .markdown-preview-view in reading mode, and the pair swaps out whenever you toggle modes. Binding to one element means the plugin goes deaf in the other mode and after every mode switch.
Scroll events do not bubble, but they do capture. So this plugin registers a single listener on the view's container element with { capture: true } and catches scrolling from whichever scroller happens to be mounted:
this.registerDomEvent(view.contentEl, "scroll", handler, { capture: true, passive: true });Positions are stored as line numbers, not pixels. A pixel offset is only meaningful for the exact window width, font size, and theme it was recorded in. Resize the window and a saved offset points somewhere else in the note. A line number survives all of that. In editing mode the top visible line is read back out of CodeMirror with posAtCoords against the top-left corner of the scroller; in reading mode Obsidian's own previewMode.getScroll() already returns a line-ish number.
Restoring retries. On a fresh open the document often is not laid out yet, so the first attempt has nothing to measure. Restore is attempted at 0, 50, 150, 400 and 900 ms, and each attempt gives up if the note is no longer at the top, so a link that targets a heading still wins over the saved position.
Writes are coalesced. Scrolling fires events continuously. Recording is debounced 400 ms after scrolling stops, and the write to data.json is coalesced to at most one per second, so a long scroll costs one disk write rather than dozens.
Scrolling back to the top of a note deletes its entry rather than storing a zero, which keeps the store to notes you actually left partway through.
Worth being honest about the neighborhood, because one of these may fit you better:
- Remember cursor position is the established plugin here and restores the cursor as well as the scroll. If you want cursor restoration, use it.
- Remember Scrollposition is the closest in intent. It binds to
.cm-scrolleronly, so it does not record in reading mode, and it expires saved positions after 14 days by default. - Last Position covers similar ground.
Scroll Memory is deliberately small: scroll position only, both modes, no expiry, one file you can read end to end.
node --check main.jsThat is the whole toolchain. Edit main.js, syntax-check it, reload the plugin in Obsidian.
To cut a release, bump the version in manifest.json, add the matching row to versions.json, then push a tag with the same version string. The workflow in .github/workflows/release.yml verifies that the tag and the manifest agree, then publishes main.js and manifest.json as release assets.
MIT. See LICENSE.