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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;") const unescapeText = text => text.replace(/&gt;/g,">").replace(/&lt;/g,"<").replace(/&amp;/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() } }