|
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); |
|
}; |
im using it