let autostart = true
window.addEventListener("load",() => {
if( autostart ) {
startTypingPractice()
}
})
const getAttribution = () => {
let attribution
try {
attribution = attrib
} catch(e) {
attribution = undefined
}
return attribution
}
const startTypingPractice = () => {
const source = document.body.innerHTML
document.body.innerHTML = "<div class='loading'>Loading...</div>";
setTimeout(() => {
document.body.innerHTML = ""
const container = document.body
const typingPractice = new TypingPractice(container, source, getAttribution())
typingPractice.init()
},0)
}
const startTypingPracticeWith = (source) => {
document.body.innerHTML = "<div class='loading'>Loading...</div>";
setTimeout(() => {
document.body.innerHTML = ""
const container = document.body
const typingPractice = new TypingPractice(container, source, getAttribution())
typingPractice.init()
},0)
}
const onedp = x => Math.floor(x*10)/10
class ScoreBoard {
constructor(parent, container) {
this.dom = document.createElement("div")
this.dom.classList.add("score_board")
this.parent = parent
this.container = container
this.percentage = document.createElement("span")
this.percentage.classList.add("scoreboard_element","percentage")
this.wpmSpan = document.createElement("span")
this.wpmSpan.classList.add("scoreboard_element","wpm")
this.wpmSpan.innerText = "WPM"
this.lineNumber = document.createElement("span")
this.lineNumber.classList.add("scoreboard_element","line_number")
this.dom.appendChild(this.percentage)
this.dom.appendChild(this.wpmSpan)
this.dom.appendChild(this.lineNumber)
container.appendChild(this.dom)
}
i = 0
startTime = null
lastTickTime = null
lastKeysCorrect = null
timerPeriod = 1000
wpms = []
startTimer() {
if( this.interval ) clearInterval(this.interval)
this.lastKeysCorrect = 0
const currentTime = new Date().getTime()
this.lastTickTime = currentTime
this.parent.keyPressesCorrect = 0
this.parent.keyPressesTotal = 0
this.parent.keyPressesCorrect = 0
this.parent.keyPressesErrors = 0
this.lastKeysCorrect = 0
this.wpms = []
this.startTime = this.lastTickTime = new Date().getTime()
this.interval = setInterval(this.tick.bind(this),this.timerPeriod)
this.tick.bind(this)()
}
tick() {
const { keyPressesCorrect } = this.parent
const currentTime = new Date().getTime()
const dt = currentTime - this.lastTickTime
if( dt === 0 ) {
console.log("dt=0")
return
}
const Dt = currentTime - this.startTime
this.lastTickTime = currentTime
const dk = keyPressesCorrect - this.lastKeysCorrect
this.lastKeysCorrect = keyPressesCorrect
const wpmi = 60*1000*dk/(5*dt)
const wpm = 60*1000*keyPressesCorrect/(5*Dt)
this.wpms.push(wpmi)
if( this.wpms.length > 60 ) {
this.wpms = this.wpms.slice(this.wpms.length-60)
}
const wpm_sum = this.wpms.reduce((x,y)=>x+y,0)
const avg_wpm = wpm_sum/this.wpms.length
this.wpmSpan.innerText = `WPM [ ${onedp(wpmi)} ${onedp(avg_wpm)} ${onedp(wpm)} ]`
}
update() {
const { keyPressesTotal, keyPressesCorrect, keyPressesErrors } = this.parent
if( keyPressesTotal === 0 ) {
this.percentage.innerText = "Start"
} else {
this.percentage.innerText = `Accuracy: ${keyPressesCorrect}/${keyPressesTotal} = ${Math.floor(1000*keyPressesCorrect/keyPressesTotal)/10}%`
}
const textArea = this.parent.textArea
const numLines = textArea.lines.length
const row = textArea.row + 1
this.lineNumber.innerText = `Line: ${row}/${numLines}`
}
}
class TextLine {
constructor(parent, container, source, rownum) {
this.source = source.replace(/\s+$/,"")
this.container = container
this.parent = parent
this.errorCount = 0
this.dom = document.createElement("div")
this.dom.classList.add("text_line")
this.left = document.createElement("span")
this.right = document.createElement("span")
this.mid = document.createElement("span")
this.errors = document.createElement("span")
this.left.classList.add("left")
this.right.classList.add("right")
this.mid.classList.add("mid")
this.errors.classList.add("errors")
this.dom.appendChild(this.left)
this.dom.appendChild(this.mid)
this.dom.appendChild(this.right)
this.dom.appendChild(this.errors)
this.rownum = rownum
this.container.appendChild(this.dom)
}
incErrorCount() {
this.errorCount++
this.updateErrors()
}
clearErrors() {
this.errorCount = 0
this.updateErrors()
}
updateErrors() {
if( this.errorCount > 0 ) {
this.errors.innerText = `(${this.errorCount})`
} else {
this.errors.innerText = ""
}
}
update(row,col) {
this.dom.classList.remove("before")
this.dom.classList.remove("current")
this.dom.classList.remove("after")
if( this.rownum < row ) {
this.left.innerText = this.source
this.mid.innerText = ""
this.right.innerText = ""
this.dom.classList.add("before")
} else if( this.rownum > row ) {
this.left.innerText = this.source
this.mid.innerText = ""
this.right.innerText = ""
this.dom.classList.add("after")
} else {
this.left.innerText = this.source.slice(0,col)
this.mid.innerText = this.source[col]
this.right.innerText = this.source.slice(col+1)
this.dom.classList.add("current")
}
}
charAt(col) {
return this.source[col]
}
get length() {
return this.source.length
}
}
class TextArea {
row = 0
col = 0
constructor(parent, container, source) {
this.parent = parent
this.container = container
this.source = source
this.lastKeys = parent.lastKeys
this.dom = document.createElement("div")
this.dom.classList.add("textarea")
this.container.appendChild(this.dom)
const source_lines = source.split("\n")
this.lines = source_lines.map((line,i) => new TextLine(this,this.dom,line,i))
this.setPos(0,0)
}
clearErrors() {
this.lines.forEach(line => line.clearErrors())
}
testKey(key) {
const expected = this.currentLine.charAt(this.col)
const { row, col } = this
if( expected != " " ) {
if( key == " " && col == 0 ) {
this.parent.signalAllowedSpace()
return
}
}
this.lastKeys.append(key,expected)
if( key === expected ) {
this.parent.signalCorrect()
this.advanceChar()
} else {
this.parent.signalError()
this.error()
}
}
setPos(row,col) {
this.row = (row+this.lines.length)%this.lines.length
this.currentLine = this.lines[this.row]
const l = this.currentLine.length
if( col < 0 ) {
this.col = 0
} else if( col >= l ) {
this.col = l-1
} else {
this.col = col
}
this.lines.forEach(line => line.update(this.row,this.col))
this.updateLines()
this.currentLine.dom.scrollIntoView({
block: "center"
})
}
advanceChar() {
const l = this.currentLine.length
this.col++
if( this.col >= this.currentLine.length ) {
return this.advanceLine()
}
this.updateLines()
}
advanceLine() {
this.setPos(this.row+1, 0)
this.updateLines()
}
updateLines() {
this.lines.forEach(line => line.update(this.row,this.col))
}
error() {
}
moveToStartOfLine() {
this.setPos(this.row,0)
this.updateLines()
}
moveUp() {
this.setPos(this.row-1,this.col)
this.updateLines()
}
moveDown() {
this.setPos(this.row+1,this.col)
this.updateLines()
}
pageUp() {
const newrow = Math.max(this.row - 20, 0)
this.setPos(newrow,this.col)
this.updateLines()
}
pageDown() {
const newrow = Math.min(this.row + 20, this.lines.length-1)
this.setPos(newrow,this.col)
this.updateLines()
}
moveRight() {
this.setPos(this.row,this.col+1)
this.updateLines()
}
moveLeft() {
this.setPos(this.row,this.col-1)
this.updateLines()
}
skipWhitespace() {
while(this.currentLine.charAt(this.col) === " ") {
this.advanceChar()
}
}
}
class LastKeys {
lastKeyCount = 80
lastKeys = []
constructor(parent, container) {
this.dom = document.createElement("div")
this.dom.classList.add("last_keys")
this.parent = parent
this.container = container
container.appendChild(this.dom)
for(let x of "Last Keys:") {
this.append(x,x)
}
}
append(key,expected) {
this.lastKeys.push({
key, expected
})
if( this.lastKeys.length > this.lastKeyCount ) {
this.lastKeys = this.lastKeys.slice(this.lastKeys.length - this.lastKeyCount)
}
}
update() {
const spans = this.lastKeys.map(this.makeSpan)
this.dom.innerHTML = ""
spans.forEach(span => this.dom.appendChild(span))
}
makeSpan({key,expected}) {
const span = document.createElement("span")
span.innerText = key
span.classList.add(key === expected ? 'correct' : 'wrong' )
return span
}
}
const escapeText = text => text.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">")
const unescapeText = text => text.replace(/>/g,">").replace(/</g,"<").replace(/&/g,"&")
const isUrl = text => text.match(/^https?:\/\
class TypingPractice {
started = false
constructor(container, source, attribution) {
this.container = container
this.source = this.processSource(source)
if( attribution ) {
this.attribution = attribution.trim()
}
if( this.source.length === 0 ) {
this.source = "Source is empty!"
throw "Source is empty!"
}
}
createDom() {
this.appDom = document.createElement("div")
this.appDom.classList.add("typing_app")
this.header = document.createElement("div")
this.header.classList.add("ui_area","header")
this.footer = document.createElement("div")
this.footer.classList.add("ui_area","footer")
this.mainArea = document.createElement("div")
this.mainArea.classList.add("ui_area","main")
this.appDom.appendChild(this.header)
this.appDom.appendChild(this.mainArea)
this.appDom.appendChild(this.footer)
this.scoreBoard = new ScoreBoard(this,this.header)
this.lastKeys = new LastKeys(this,this.footer)
if( this.attribution ) {
this.attributionDom = document.createElement("p")
this.attributionDom.classList.add("attribution")
if( isUrl(this.attribution) ) {
this.attributionDom.innerHTML = `From: <a href="${this.attribution}">${this.attribution}</a>`
} else {
this.attributionDom.innerText = `From: ${this.attribution}`
}
this.mainArea.appendChild(this.attributionDom)
}
this.textArea = new TextArea(this,this.mainArea,this.source)
this.scoreBoard.update()
this.lastKeys.update()
this.container.appendChild(this.appDom)
this.mainAreaNormalColour = this.mainArea.style.backgroundColor
this.mainAreaErrorColour = "red"
}
flashTimeout = 100
keyPressesTotal = 0
keyPressesCorrect = 0
keyPressesErrors = 0
flashBackround(color) {
this.mainArea.style.backgroundColor = color
setTimeout(_ => this.mainArea.style.backgroundColor = this.mainAreaNormalColour, this.flashTimeout)
}
signalAllowedSpace() {
this.flashBackround("yellow")
}
signalCorrect() {
this.keyPressesTotal++
this.keyPressesCorrect++
this.scoreBoard.update()
}
signalError() {
this.keyPressesTotal++
this.keyPressesErrors++
this.textArea.currentLine.incErrorCount()
this.scoreBoard.update()
this.flashBackround("red")
}
processSource(source) {
const trimmed = source.trim()
const unescaped = unescapeText(trimmed)
.replace(/[“”]/g,'"')
.replace(/[‘’]/g,"'")
.replace(/—/g,"---")
const tabsExpanded = unescaped.replace(/\t/g," ")
const split = tabsExpanded.split("\n")
const filtered = split.filter(x => ! x.match(/^\s*$/))
const trimmed_lines = filtered.map(x => x.replace(/\s+$/,""))
const joined = trimmed_lines.join("\n")
return joined
}
init() {
this.dom = this.createDom()
this.start()
}
start() {
window.addEventListener("keydown",e => this.handleKey(e))
}
handleKey(e) {
if( e.altKey && e.key === "r" ) {
console.log("Restart timer")
this.scoreBoard.startTimer()
this.scoreBoard.update()
this.textArea.clearErrors()
}
if( e.altKey || e.ctrlKey ) return
e.preventDefault()
const key = e.key
if( key === "ArrowLeft" ) {
this.textArea.moveLeft()
} else if( key === "ArrowRight" ) {
this.textArea.moveRight()
} else if( key === "ArrowUp" ) {
this.textArea.moveUp()
} else if( key === "ArrowDown" ) {
this.textArea.moveDown()
} else if( key === "PageUp" ) {
this.textArea.pageUp()
} else if( key === "PageDown" ) {
this.textArea.pageDown()
} else if( key === "Escape" ) {
this.textArea.moveToStartOfLine()
} else if( key === "Enter" ) {
this.textArea.advanceLine()
} else if( key === "Tab" ) {
this.textArea.skipWhitespace()
} else if( key.length === 1 ) {
if( !this.started ) {
this.started = true
this.scoreBoard.startTimer()
}
this.textArea.testKey(key)
} else {
console.log(key)
}
this.lastKeys.update()
this.scoreBoard.update()
}
}