Skip to content

Instantly share code, notes, and snippets.

@fidel-perez
Last active March 5, 2024 00:44
Show Gist options
  • Star 14 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save fidel-perez/a9b20e17f97bb37bfd0d2fbbdf3b7d6f to your computer and use it in GitHub Desktop.
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

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