Skip to content

Instantly share code, notes, and snippets.

@fidel-perez
Last active December 4, 2025 06:27
Show Gist options
  • Select an option

  • Save fidel-perez/a9b20e17f97bb37bfd0d2fbbdf3b7d6f to your computer and use it in GitHub Desktop.

Select an option

Save fidel-perez/a9b20e17f97bb37bfd0d2fbbdf3b7d6f to your computer and use it in GitHub Desktop.
Speed reading for Obsidian!
const carriageReturnIndicator = "⏭️"; //WARNING: There is a regexp replace that uses this literal value.
const spaceReplacerChar = " "; // ◽
const pluginClassName = "speedReadingPlugin";
function updateReadTimeEstimate(phrases, speedWPM) {
var readTimeEstimateEl = document.getElementById("readTimeEstimate");
readTimeEstimateEl.innerText =
"Expected time to read the whole document at current speed: " +
((phrases.length * 60000) / speedWPM / 1000 / 60).toFixed(1) +
"min.";
}
function getProgressIndexFromAbsoluteProgress(
absoluteProgress,
splittedPhrases
) {
var spaceCount = 0;
for (const [index, phrase] of splittedPhrases.entries()) {
spaceCount = spaceCount + 1 + (phrase[0].match(/ /g) || []).length;
if (spaceCount > absoluteProgress) {
if (index - 1 < 0) {
return 0;
} else {
return index - 1;
}
}
}
return index;
}
function getAbsoluteProgressFromProgressIndex(progressIndex, splittedPhrases) {
return splittedPhrases
.slice(0, progressIndex)
.flatMap((phrase) => phrase[0].split(" ")).length;
}
class SpeedReader {
constructor(speedReaderConfig) {
this.totalTime = 0;
this.totalWordsRead = 0;
this.obsidianWindow = speedReaderConfig.obsidian;
this.tp = speedReaderConfig.tp;
this.filePath = false;
this.speedWPM = speedReaderConfig.speedWPM;
this.maxReadableCharacters = speedReaderConfig.maxReadableCharacters;
this.running = false;
this.activeIntervalId = false;
this.textFontSize = speedReaderConfig.textFontSize;
this.createSpeedReadingWidget();
this.changeSpeed(0);
document.getElementById("read_hotkey_focus").focus();
}
readToggle() {
if (this.running) {
this.pauseReading();
} else {
this.startReading();
}
}
hotkeyPressed(event) {
if (
event.defaultPrevented ||
document.activeElement.id != "read_hotkey_focus"
) {
return; // Do nothing if the event was already processed
}
switch (event.key) {
case "Down": // IE/Edge specific value
case "ArrowDown":
case "s":
document.getElementById("read_slower").click();
break;
case "Up": // IE/Edge specific value
case "ArrowUp":
case "w":
document.getElementById("read_faster").click();
break;
case "Left": // IE/Edge specific value
case "ArrowLeft":
case "a":
document.getElementById("read_rewind").click();
break;
case "Right": // IE/Edge specific value
case "ArrowRight":
case "d":
document.getElementById("read_forward").click();
break;
// case "Enter":
// // Do something for "enter" or "return" key press.
// break;
case "Esc": // IE/Edge specific value
case "Escape":
document.getElementById("read_kill").click();
break;
case " ":
document.getElementById("read_toggle").click();
break;
default:
return; // Quit when this doesn't handle the key event.
}
// Cancel the default action to avoid it being handled twice
event.preventDefault();
}
createSpeedReadingWidget() {
const alreadyExists = document.getElementsByClassName(pluginClassName);
if (alreadyExists.length > 0) {
document.getElementById("read_kill").click();
}
var parentElement = document.getElementsByClassName(
"CodeMirror cm-s-obsidian"
)[0];
var newEl = createElementFromHTML(readHtmlString);
newEl.classList.add(pluginClassName);
parentElement.insertBefore(newEl, parentElement.firstChild);
addStyle(styleAsString(this.textFontSize), pluginClassName);
this.attachButtonFunctions();
window.addEventListener("keydown", (evt) => this.hotkeyPressed(evt), true);
}
killSpeedReader() {
this.running = false;
this.stopActiveInterval();
window.removeEventListener(
"keydown",
(evt) => this.hotkeyPressed(evt),
true
);
const alreadyExists = document.getElementsByClassName(pluginClassName);
Array.from(alreadyExists).forEach((elem) =>
elem.parentNode.removeChild(elem)
);
}
setReadProgressToCursor() {
let cmEditor = this.obsidianWindow.app.workspace.activeLeaf.view.editor;
const line = parseInt(cmEditor.getCursor("from").line);
var idx = 0;
var lastValidIndex = 0;
for (const phrase of this.splittedPhrases) {
const currentLine = phrase[1][0];
if (line <= currentLine) {
this.progressIndex = lastValidIndex;
break;
}
lastValidIndex = idx;
idx++;
}
}
goToLocation() {
const [useless, open, close] = this.splittedPhrases[this.progressIndex];
const from = { line: open[0], ch: open[1] };
const to = { line: close[0], ch: close[1] };
let cmEditor = this.obsidianWindow.app.workspace.activeLeaf.view.editor;
cmEditor.setSelection(from, to);
let scrollInfo = cmEditor.getScrollInfo();
cmEditor.scrollTo(0, scrollInfo.top + 2 * scrollInfo.clientHeight);
cmEditor.scrollIntoView({
from: { line: Math.abs(open[0] - 3), ch: open[1] },
to: { line: Math.abs(close[0] - 3), ch: close[1] },
});
}
attachButtonFunctions() {
document
.getElementById("read_progress_to_cursor")
.addEventListener("click", (evt) => this.setReadProgressToCursor());
document
.getElementById("read_wpm")
.addEventListener("click", (evt) => this.goToLocation(evt));
document
.getElementById("read_kill")
.addEventListener("click", (evt) => this.killSpeedReader());
document
.getElementById("read_toggle")
.addEventListener("click", (evt) => this.readToggle(evt));
document
.getElementById("read_faster")
.addEventListener("click", (evt) => this.changeSpeed(+25));
document
.getElementById("read_slower")
.addEventListener("click", (evt) => this.changeSpeed(-25));
document
.getElementById("read_rewind")
.addEventListener("click", (evt) => this.forwardLines(-10));
document
.getElementById("read_forward")
.addEventListener("click", (evt) => this.forwardLines(10));
}
loadTextFromNote() {
this.filePath = this.obsidianWindow.app.workspace.getActiveFile().path;
var textToRead = this.tp.file.content;
let maybeAbsoluteProgress = parseInt(this.tp.frontmatter.readProgress);
let absoluteProgress = isNaN(maybeAbsoluteProgress)
? 0
: maybeAbsoluteProgress;
this.splittedPhrases = this.textToArrayToShow(textToRead);
this.progressIndex = getProgressIndexFromAbsoluteProgress(
absoluteProgress,
this.splittedPhrases
);
updateReadTimeEstimate(
this.splittedPhrases.slice(
this.progressIndex,
this.splittedPhrases.length
),
this.speedWPM
);
}
updateValues(i) {
var p = getPhraseCenter(this.splittedPhrases[i][0]);
document.getElementById("read_result").innerHTML = p;
document.getElementById("read_progress").value =
(100 * this.progressIndex) / this.splittedPhrases.length;
this.goToLocation();
}
textToArrayToShow(input) {
const charsNeedSpacing = ["?", "-", "—", "!", ":", ";", ")", "-", "]", "["];
var splittedText = input
.replace(/(\r\n|\n|\r)/gm, carriageReturnIndicator)
.replace(/()+/gm, carriageReturnIndicator + " ");
charsNeedSpacing.forEach(
(x) => (splittedText = splittedText.replaceAll(x, x + " "))
);
splittedText = splittedText.split(/\s+/);
const phrasedText = mergeSmallWords(
splittedText,
this.maxReadableCharacters
);
var indexedPhrasedText = [];
var inputLine = 0;
var inputCol = 0;
var phrasedTextIndex = 0;
var phrasedTextCol = 0;
var opening = 0;
input.split("").forEach((inputCharacter, inputIndex) => {
if (phrasedTextIndex == phrasedText.length) {
console.log(inputCharacter); // We finished our arranged text but there are still chars on the input text
} else {
const phrasedTextString = phrasedText[phrasedTextIndex]
.split("")
.filter((char) => char.match(/[A-Z0-9]/gi));
const phrasedTextCharacter = phrasedTextString[phrasedTextCol];
if (inputCharacter == "\n") {
inputLine++;
inputCol = 0;
} else {
if (
inputCharacter == phrasedTextCharacter &&
inputCharacter.match(/[A-Z0-9]/gi)
) {
if (phrasedTextCol == 0) {
opening = [inputLine, inputCol];
}
if (phrasedTextCol == phrasedTextString.length - 1) {
indexedPhrasedText.push([
phrasedText[phrasedTextIndex],
opening,
[inputLine, inputCol + 1],
]);
phrasedTextIndex++;
phrasedTextCol = 0;
} else {
phrasedTextCol++;
}
}
inputCol++;
}
}
});
// Returning: [phrase, startingAbsolutePos, endingAbsolutePos]
return indexedPhrasedText;
}
startReading() {
document.getElementById("read_toggle").textContent = "⏸️";
// Going to stick to the originally open file
let currentFileTextIsLoaded = this.filePath; // && this.filePath == this.obsidianWindow.app.workspace.getActiveFile().path;
if (!currentFileTextIsLoaded) {
this.loadTextFromNote();
}
this.running = true;
this.startReadingProgressIndex = this.progressIndex;
this.startReadingTime = new Date().getTime();
this.startInterval();
}
intervalUpdateValues(speedReader) {
if (
speedReader.running &&
speedReader.progressIndex < speedReader.splittedPhrases.length
) {
speedReader.updateValues(speedReader.progressIndex);
speedReader.progressIndex++;
} else {
speedReader.pauseReading();
}
}
changeSpeed(amount) {
this.speedWPM = parseInt(this.speedWPM) + amount;
const currentStatus = this.running;
document.getElementById("read_wpm").textContent = this.speedWPM + " WPM";
if (currentStatus) {
this.stopActiveInterval(true);
this.startInterval();
}
}
calculateUserInfo(thisSessionTime, thisSessionWords) {
var userInfo = "";
var end = new Date().getTime();
var time = (
parseInt(thisSessionTime) +
(end - this.startReadingTime) / 1000
).toFixed(0);
userInfo +=
"Time read: " + time + "sec OR " + (time / 60).toFixed(1) + "min. ";
const totalWordsRead =
this.splittedPhrases
.slice(this.startReadingProgressIndex, this.progressIndex)
.flatMap((phrase) =>
phrase[0].replace(spaceReplacerChar, " ").split(" ")
)
.filter((word) => word.replace(/[^A-Z0-9]/gi, "").length > 0).length +
thisSessionWords;
userInfo += "Speed: " + ((60 * totalWordsRead) / time).toFixed(0) + " wpm.";
return [userInfo, time, totalWordsRead];
}
forwardLines(amountOfWords = 10) {
const newCW = this.progressIndex + amountOfWords;
this.progressIndex = newCW < 0 ? 0 : newCW;
}
startInterval() {
this.activeIntervalId = setInterval(
this.intervalUpdateValues,
60000 / this.speedWPM,
this
);
}
stopActiveInterval(keepReading = false) {
this.running = keepReading;
if (this.activeIntervalId) {
clearInterval(this.activeIntervalId);
this.activeIntervalId = false;
}
}
async pauseReading() {
this.stopActiveInterval();
updateReadTimeEstimate(
this.splittedPhrases.slice(
this.progressIndex,
this.splittedPhrases.length
),
this.speedWPM
);
let readProgress = getAbsoluteProgressFromProgressIndex(
this.progressIndex,
this.splittedPhrases
);
const { update } = this.obsidianWindow.app.plugins.plugins["metaedit"].api;
await update("readProgress", readProgress, this.filePath);
const [userInfoText, totalTime, totalWordsRead] = this.calculateUserInfo(
this.totalTime,
this.totalWordsRead
);
this.totalTime = totalTime;
this.totalWordsRead = totalWordsRead;
document.getElementById("lastWordsReadInfo").innerText = userInfoText;
document.getElementById("read_toggle").textContent = "▶️";
}
}
function mergeSmallWords(splittedText, maxReadableCharacters) {
var newSplittedText = [];
var lastWord = "";
for (const word of splittedText.filter((word) => word.trim() != "")) {
// We only count alphanumeric, so we avoid newlines with just a parenthesis close
const possibleNewMixedWord = lastWord.trim() + " " + word.trim();
const readableCharsInNewWord = possibleNewMixedWord.replace(
/[^A-Z0-9]/gi,
""
).length;
if (word.replace(/[^A-Z0-9]/gi, "") == "") {
lastWord = possibleNewMixedWord.trim();
} else if (
readableCharsInNewWord > maxReadableCharacters ||
possibleNewMixedWord.includes(carriageReturnIndicator) ||
possibleNewMixedWord.includes(".") // new lines in dots to make everything more readable.
) {
if (lastWord.replace(/[^A-Z0-9]/gi, "") != "") {
newSplittedText.push(
lastWord.trim().replace(carriageReturnIndicator, "") // Removed the indicator because in texts with bad carriage return the text became illegible.
);
}
lastWord = word.trim();
} else {
lastWord = possibleNewMixedWord.trim();
}
}
if (lastWord.replace(/[^A-Z0-9]/gi, "") != "") {
newSplittedText.push(lastWord.trim());
}
return newSplittedText;
}
function getPhraseCenter(phrase) {
var length = phrase.length;
var highlightIndex = parseInt((length / 2).toFixed(0)) - 1;
var highlightChar = phrase[highlightIndex];
if (highlightChar == " ") {
highlightChar = spaceReplacerChar;
}
var result =
'<div class="leftSide">' +
phrase.slice(0, highlightIndex) +
'</div><div class="highlight">' +
highlightChar +
'</div><div class="rightSide">' +
phrase.slice(highlightIndex + 1, phrase.length) +
"</div>";
return result;
}
// HTML and CSS as string
let readHtmlString = `<div id="read_holder">
<div id="read_container" style="width:800px;">
<button type="button" id="read_wpm"></button>
<button type="button" id="read_toggle">▶️</button>
<button type="button" id="read_rewind">⬅️</button>
<button type="button" id="read_forward">➡️</button>
<button type="button" id="read_faster">⬆️</button>
<button type="button" id="read_slower">⬇️</button>
<button type="button" id="read_kill">❌</button>
<input type="input" placeholder="Focusme for hotkeys" id="read_hotkey_focus"></input>
<details>
<summary>
<progress id="read_progress" max="100" value="0"></progress> Show stats<button id="read_progress_to_cursor">Set progress to cursor position</button>
</summary>
<p id="readTimeEstimate"></p>
<p id="lastWordsReadInfo">Stats will be available as soon as you pause your reading. Instructions:<br>Click on the input for the hotkeys to work: <br>Space -> play/pause. <br>Escape -> Close the reader. <br>Left right arrows: advance / go back 10 words. <br>Up down arrows: Faster / Slower reading. <br>Click on the "WPM" button to jump to the current word being read.<br>Click on the "Set progress to cursor position" button to keep reading on the selected line. This can only be done after having started reading previously.</p>
</details>
<div class="leftSide"></div>
<div class="highlight">↓</div>
<div class="rightSide"></div>
<div id="read_result"> ▶️ : Start reading. ⬅️/➡️: forward/back 10 words, ⬆️ /⬇️ faster/slower. </div>
<div class="leftSide"></div>
<div class="highlight">↑</div>
<div class="rightSide"></div>
</div>
</div>
</div>`;
/**
* Utility function to add replaceable CSS.
* @param {string} styleString
*/
function addStyle(styleString, pluginClassName) {
const style = document.createElement("style");
document.head.append(style);
style.classList.add(pluginClassName);
style.textContent = styleString;
}
function styleAsString(textFontSize) {
return (
`
.highlight {
/*color: red;*/
white-space: pre-wrap;
font-weight: bold;
font-family: "Droid Sans Mono", sans-serif;
font-size: ` +
textFontSize +
`px;
display: table-cell;
}
.leftSide {
white-space: pre-wrap;
display: table-cell;
font-family: "Droid Sans Mono", sans-serif;
font-size: ` +
textFontSize +
`px;
width: 40%;
text-align: right;
}
.rightSide {
white-space: pre-wrap;
display: table-cell;
font-family: "Droid Sans Mono", sans-serif;
font-size: ` +
textFontSize +
`px;
width: 60%;
text-align: left;
}
#maxWantedCharacters {
width: 60px;
}
#read_container {
background-color: #eeeeee;
/* 600px+; small tablet portrait */
margin-left: auto;
margin-right: auto;
line-height: 43px;
}
#read_spacer {
min-height: 105px;
}`
);
}
function createElementFromHTML(htmlString) {
var div = document.createElement("div");
div.innerHTML = htmlString.trim();
// Change this to div.childNodes to support multiple top-level nodes
return div.firstChild;
}
module.exports = function (speedReaderConfig) {
new SpeedReader(speedReaderConfig);
};

<%* // Requirements: MetaEdit plugin, templater plugin with the scripts folder defined so the .js file can be read.

var speedReaderConfig = { // Basic config: speedWPM: 350, // Initial approximate, not perfectly accurate, relates to the time between jumps really. textFontSize:64, maxReadableCharacters: 8, // Phrases will be split to match this amount of maximum alphanumeric characters in total. // Advanced config: // No need to touch this: obsidian: this, tp: tp }

tp.user.speedReading(speedReaderConfig);

var prefix = ---\nreadProgress: 0\n---\n

var noteContent = tp.file.content

if (!noteContent.includes("readProgress: ")) { noteContent = prefix+tp.file.content

//select all in note let cmEditorAct = this.app.workspace.activeLeaf.view.editor; cmEditorAct.setSelection({ line: 0, ch: 0 }, { line: 9999, ch: 9999 });

//replace content + set cursor at the start tR = noteContent; } else {tR = ""}

%>

@fidel-perez
Copy link
Author

I am updating and improving this rather frequently, if someone else is using it please let me know and I will include changelog.

@Chaoticlearner
Copy link

im using it

@fidel-perez
Copy link
Author

Glad someone found this useful!
Just uploaded the final version (I didn't need to change it in a loooong time).
Enjoy!

@bjornmartensson
Copy link

Hi! This looks very interesting. Do you have plans to make it available as a community plugin in obsidian? I'm a bit stumped with how to install it 🙂

@fidel-perez
Copy link
Author

Hey @bjornmartensson , sorry for the delay, I don't really monitor this thread.
I wont be making a plugin but I can try to help with the instructions:

  • Install templater plugin
  • Define on its config a folder where to look for scripts, and put the .js included in this gist inside
  • Create a template for templater with the templater.speedReadNote.md

There you go, you can apply that template to any note you have open and it will show the speedreading tool.

@bjornmartensson
Copy link

Hey @fidel-perez ,
Thanks for the reply! I gave it a shot, but get "tp.user.speedReading is not a function" error. I'll leave this for now, but might look into it in the future.

@mjreddy1205
Copy link

@fidel-perez when i try to use the code in obsidan, i get an error "Default export is not a function" How do i fix it?
Thanks

@CouchPotato7373
Copy link

Hey @fidel-perez , Thanks for the reply! I gave it a shot, but get "tp.user.speedReading is not a function" error. I'll leave this for now, but might look into it in the future.

Experiencing the same issue. Does anyone know how to fix?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment