Rewrite input system to use <input> content (#1325)

* Rewrite input system to use <input> content

* Tab/Escape, Backspace and Enter are always handled by
  $(document).keydown.
* The rest of characters are handled by either $("#wordsInput").on("input")
  (default) or $(document).keydown (layout emulation).
* New special handling for dead keys, compose keys and diacritics in
  general with the new regex Misc.trailingComposeChars.

input-controller.js has been updated to use the above changes:
* handleBackspace() replaced with simplified backspaceToPrevious().
  On PC, a space is immediately re-added to the end of the input to make
  use of the browser/OS's Backspace. This lets the browser handle input-
  specific things like ctrl+backspace.
* handleSpace() refactored a bit to repeat less logic when word is correct
  or incorrect.

* test-ui.js updated to highlight the Misc.trailingComposeChars
correctly, and also refactored a bit to make logic easier to follow.

* AFK checking has also been simplified, now just set with a boolean
flag (TestStats.) setKeypressNotAfk() instead of checking every key and
modifier press (so incrementKeypressMod() was removed as it wasn't used
for anything else).

* Refactor input controller

New function isCharCorrect().

* Remove caps lock backspace setting

Not supported with the input rewrite anymore because we're relying on the
browser's/OS's actual backspace effects. There's no way to emulate this
keypress.

* Refactor input controller

* Reimplement opposite shift mode

* Reimplement the layout emulator

* Fix replay events for input rewrite

Now it's more flexible for a variable amount of backspacing or letter
replacing.

* Pad input with space to handle backspace on mobile

Backspace isn't fired as an event on current mobile browsers, so I worked
around that by adding a permanent space at the start of the input and
treating its removal as a fallback to Backspace to the previous word.

* Force caret to end of input on focus

Fixes initial selection on iOS.

* Use offsetTop from the DOM instead of TestUI

I didn't wanna mess too much with what happens in test-ui.js. Basically,
on a restart after having completed a test, TestUI.activeWordTop is always
wrong for some reason. This caused swipe/instant input after a restart to
always drop the first few characters.

* Prevent pasting on the input

* Revert "Reimplement opposite shift mode"

This reverts commit 9a716ad39b004f0719b05f486465ea03060430ca.

* Use key code to check opposite shift usage

Today I learned what closure actually meant.

* Accept all whitespace as word space
This commit is contained in:
SeerLite 2021-09-29 20:22:38 -03:00 committed by GitHub
parent 6039820c26
commit c323efea26
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 523 additions and 722 deletions

View file

@ -331,29 +331,6 @@ let commandsLiveWpm = {
],
};
let commandsCapsLockBackspace = {
title: "Caps lock backspace...",
configKey: "capsLockBackspace",
list: [
{
id: "setCapsLockBackspaceOff",
display: "off",
configValue: false,
exec: () => {
UpdateConfig.setShowCapsLockBackspace(false);
},
},
{
id: "setCapsLockBackspaceOn",
display: "on",
configValue: true,
exec: () => {
UpdateConfig.setShowCapsLockBackspace(true);
},
},
],
};
let commandsLiveAcc = {
title: "Live accuracy...",
configKey: "showLiveAcc",
@ -2713,12 +2690,6 @@ export let defaultCommands = {
icon: "fa-gamepad",
subgroup: commandsFunbox,
},
{
id: "changeCapsLockBackspace",
display: "Caps lock backspace...",
icon: "fa-backspace",
subgroup: commandsCapsLockBackspace,
},
{
id: "changeLayout",
display: "Layout...",

View file

@ -74,7 +74,6 @@ let defaultConfig = {
caretStyle: "default",
paceCaretStyle: "default",
flipTestColors: false,
capsLockBackspace: false,
layout: "default",
funbox: "none",
confidenceMode: "off",
@ -1366,18 +1365,6 @@ export function setMonkey(monkey, nosave) {
if (!nosave) saveToLocalStorage();
}
export function setCapsLockBackspace(capsLockBackspace, nosave) {
if (capsLockBackspace === null || capsLockBackspace === undefined) {
capsLockBackspace = false;
}
config.capsLockBackspace = capsLockBackspace;
if (!nosave) saveToLocalStorage();
}
export function toggleCapsLockBackspace() {
setCapsLockBackspace(!config.capsLockBackspace, false);
}
export function setKeymapMode(mode, nosave) {
if (mode == null || mode == undefined) {
mode = "off";
@ -1624,7 +1611,6 @@ export function apply(configObj) {
setQuoteLength(configObj.quoteLength, true);
setWordCount(configObj.words, true);
setLanguage(configObj.language, true);
setCapsLockBackspace(configObj.capsLockBackspace, true);
// setSavedLayout(configObj.savedLayout, true);
setLayout(configObj.layout, true);
setFontSize(configObj.fontSize, true);

File diff suppressed because it is too large Load diff

View file

@ -764,3 +764,5 @@ String.prototype.lastIndexOfRegex = function (regex) {
var match = this.match(regex);
return match ? this.lastIndexOf(match[match.length - 1]) : -1;
};
export const trailingComposeChars = /[\u02B0-\u02FF`´^¨~]+$|⎄.*$/;

View file

@ -107,7 +107,7 @@ $(`${popup} .randomInputFields .time input`).keypress((e) => {
});
$("#customTextPopup .apply").click(() => {
let text = $("#customTextPopup textarea").val();
let text = $("#customTextPopup textarea").val().normalize();
text = text.trim();
// text = text.replace(/[\r]/gm, " ");
text = text.replace(/\\\\t/gm, "\t");

View file

@ -86,21 +86,23 @@ function handleDisplayLogic(item, nosound = false) {
//if letter is an extra
myElement = document.createElement("letter");
myElement.classList.add("extra");
myElement.innerHTML = item.letter;
myElement.innerHTML = item.value;
activeWord.appendChild(myElement);
}
myElement = activeWord.children[curPos];
myElement.classList.add("incorrect");
curPos++;
} else if (item.action === "deleteLetter") {
} else if (item.action === "setLetterIndex") {
if (!nosound) playSound();
let myElement = activeWord.children[curPos - 1];
if (myElement.classList.contains("extra")) {
myElement.remove();
} else {
myElement.className = "";
curPos = item.value;
// remove all letters from cursor to end of word
for (const myElement of [...activeWord.children].slice(curPos)) {
if (myElement.classList.contains("extra")) {
myElement.remove();
} else {
myElement.className = "";
}
}
curPos--;
} else if (item.action === "submitCorrectWord") {
if (!nosound) playSound();
wordPos++;
@ -110,15 +112,6 @@ function handleDisplayLogic(item, nosound = false) {
activeWord.classList.add("error");
wordPos++;
curPos = 0;
} else if (item.action === "clearWord") {
if (!nosound) playSound();
let promptWord = document.createElement("div");
let wordArr = wordsList[wordPos].split("");
wordArr.forEach((letter) => {
promptWord.innerHTML += `<letter>${letter}</letter>`;
});
activeWord.innerHTML = promptWord.innerHTML;
curPos = 0;
} else if (item.action === "backWord") {
if (!nosound) playSound();
wordPos--;
@ -195,16 +188,13 @@ function stopReplayRecording() {
replayRecording = false;
}
function addReplayEvent(action, letter = undefined) {
if (replayRecording === false) {
function addReplayEvent(action, value) {
if (!replayRecording) {
return;
}
let timeDelta = performance.now() - replayStartTime;
if (action === "incorrectLetter" || action === "correctLetter") {
replayData.push({ action: action, letter: letter, time: timeDelta });
} else {
replayData.push({ action: action, time: timeDelta });
}
replayData.push({ action: action, value: value, time: timeDelta });
}
function playReplay() {

View file

@ -196,10 +196,6 @@ async function initGroups() {
"smoothLineScroll",
UpdateConfig.setSmoothLineScroll
);
groups.capsLockBackspace = new SettingsGroup(
"capsLockBackspace",
UpdateConfig.setCapsLockBackspace
);
groups.lazyMode = new SettingsGroup("lazyMode", UpdateConfig.setLazyMode);
groups.layout = new SettingsGroup("layout", UpdateConfig.setLayout);
groups.language = new SettingsGroup("language", UpdateConfig.setLanguage);

View file

@ -14,10 +14,7 @@ function hide() {
$(document).keydown(function (event) {
try {
if (
!Config.capsLockBackspace &&
event.originalEvent.getModifierState("CapsLock")
) {
if (event.originalEvent.getModifierState("CapsLock")) {
show();
} else {
hide();

View file

@ -36,6 +36,9 @@ export async function updatePosition() {
let caret = $("#caret");
let inputLen = TestLogic.input.current.length;
inputLen = Misc.trailingComposeChars.test(TestLogic.input.current)
? TestLogic.input.current.search(Misc.trailingComposeChars) + 1
: inputLen;
let currentLetterIndex = inputLen - 1;
if (currentLetterIndex == -1) {
currentLetterIndex = 0;

View file

@ -2,104 +2,76 @@ import Config from "./config";
import * as Misc from "./misc";
import Layouts from "./layouts";
export function updateEvent(event) {
export function getCharFromEvent(event) {
function emulatedLayoutShouldShiftKey(event, newKeyPreview) {
if (Config.capsLockBackspace) return event.shiftKey;
const isCapsLockHeld = event.originalEvent.getModifierState("CapsLock");
if (isCapsLockHeld)
return Misc.isASCIILetter(newKeyPreview) !== event.shiftKey;
return event.shiftKey;
}
function replaceEventKey(event, keyCode) {
const newKey = String.fromCharCode(keyCode);
event.keyCode = keyCode;
event.charCode = keyCode;
event.which = keyCode;
event.key = newKey;
event.code = "Key" + newKey.toUpperCase();
}
const keyEventCodes = [
"Backquote",
"Digit1",
"Digit2",
"Digit3",
"Digit4",
"Digit5",
"Digit6",
"Digit7",
"Digit8",
"Digit9",
"Digit0",
"Minus",
"Equal",
"KeyQ",
"KeyW",
"KeyE",
"KeyR",
"KeyT",
"KeyY",
"KeyU",
"KeyI",
"KeyO",
"KeyP",
"BracketLeft",
"BracketRight",
"Backslash",
"KeyA",
"KeyS",
"KeyD",
"KeyF",
"KeyG",
"KeyH",
"KeyJ",
"KeyK",
"KeyL",
"Semicolon",
"Quote",
"IntlBackslash",
"KeyZ",
"KeyX",
"KeyC",
"KeyV",
"KeyB",
"KeyN",
"KeyM",
"Comma",
"Period",
"Slash",
"Space",
];
const layoutMap = Layouts[Config.layout].keys;
let newEvent = event;
try {
if (Config.layout === "default") {
//override the caps lock modifier for the default layout if needed
if (Config.capsLockBackspace && Misc.isASCIILetter(newEvent.key)) {
replaceEventKey(
newEvent,
newEvent.shiftKey
? newEvent.key.toUpperCase().charCodeAt(0)
: newEvent.key.toLowerCase().charCodeAt(0)
);
}
return newEvent;
let mapIndex = null;
for (let i = 0; i < keyEventCodes.length; i++) {
if (event.code == keyEventCodes[i]) {
mapIndex = i;
}
const keyEventCodes = [
"Backquote",
"Digit1",
"Digit2",
"Digit3",
"Digit4",
"Digit5",
"Digit6",
"Digit7",
"Digit8",
"Digit9",
"Digit0",
"Minus",
"Equal",
"KeyQ",
"KeyW",
"KeyE",
"KeyR",
"KeyT",
"KeyY",
"KeyU",
"KeyI",
"KeyO",
"KeyP",
"BracketLeft",
"BracketRight",
"Backslash",
"KeyA",
"KeyS",
"KeyD",
"KeyF",
"KeyG",
"KeyH",
"KeyJ",
"KeyK",
"KeyL",
"Semicolon",
"Quote",
"IntlBackslash",
"KeyZ",
"KeyX",
"KeyC",
"KeyV",
"KeyB",
"KeyN",
"KeyM",
"Comma",
"Period",
"Slash",
"Space",
];
const layoutMap = Layouts[Config.layout].keys;
let mapIndex;
for (let i = 0; i < keyEventCodes.length; i++) {
if (newEvent.code == keyEventCodes[i]) {
mapIndex = i;
}
}
const newKeyPreview = layoutMap[mapIndex][0];
const shift = emulatedLayoutShouldShiftKey(newEvent, newKeyPreview) ? 1 : 0;
const newKey = layoutMap[mapIndex][shift];
replaceEventKey(newEvent, newKey.charCodeAt(0));
} catch (e) {
return event;
}
return newEvent;
if (!mapIndex) return null;
const newKeyPreview = layoutMap[mapIndex][0];
const shift = emulatedLayoutShouldShiftKey(event, newKeyPreview) ? 1 : 0;
const char = layoutMap[mapIndex][shift];
return char;
}

View file

@ -906,6 +906,7 @@ export function restart(
TestUI.focusWords();
Funbox.resetMemoryTimer();
RateQuotePopup.clearQuoteStats();
$("#wordsInput").val(" ");
TestUI.reset();
@ -1517,15 +1518,9 @@ export async function finish(difficultyFailed = false) {
ChartController.result.data.datasets[2].data = errorsArray;
let kps = TestStats.keypressPerSecond.slice(
Math.max(TestStats.keypressPerSecond.length - 5, 0)
);
let kps = TestStats.keypressPerSecond.slice(-5);
kps = kps.map((a) => a.count + a.mod);
kps = kps.reduce((a, b) => a + b, 0);
let afkDetected = kps === 0 ? true : false;
let afkDetected = kps.every((second) => second.afk);
if (bailout) afkDetected = false;

View file

@ -12,9 +12,9 @@ export let burstHistory = [];
export let keypressPerSecond = [];
export let currentKeypress = {
count: 0,
mod: 0,
errors: 0,
words: [],
afk: true,
};
export let lastKeypress;
export let currentBurstStart = 0;
@ -94,9 +94,9 @@ export function restart() {
keypressPerSecond = [];
currentKeypress = {
count: 0,
mod: 0,
errors: 0,
words: [],
afk: true,
};
currentBurstStart = 0;
// errorsPerSecond = [];
@ -179,8 +179,8 @@ export function incrementKeypressCount() {
currentKeypress.count++;
}
export function incrementKeypressMod() {
currentKeypress.mod++;
export function setKeypressNotAfk() {
currentKeypress.afk = false;
}
export function incrementKeypressErrors() {
@ -195,9 +195,9 @@ export function pushKeypressesToHistory() {
keypressPerSecond.push(currentKeypress);
currentKeypress = {
count: 0,
mod: 0,
errors: 0,
words: [],
afk: true,
};
}
@ -217,7 +217,7 @@ export function calculateAfkSeconds(testSeconds) {
// `gonna add extra ${extraAfk} seconds of afk because of no keypress data`
// );
}
let ret = keypressPerSecond.filter((x) => x.count == 0 && x.mod == 0).length;
let ret = keypressPerSecond.filter((x) => x.afk).length;
return ret + extraAfk;
}
@ -233,7 +233,7 @@ export function calculateBurst() {
let timeToWrite = (performance.now() - currentBurstStart) / 1000;
let wordLength;
if (Config.mode === "zen") {
wordLength = TestLogic.input.getCurrent().length;
wordLength = TestLogic.input.current.length;
} else {
wordLength = TestLogic.words.getCurrent().length;
}

View file

@ -275,9 +275,7 @@ export async function screenshot() {
}, 3000);
}
export function updateWordElement(showError) {
// if (Config.mode == "zen") return;
export function updateWordElement(showError = !Config.blindMode) {
let input = TestLogic.input.current;
let wordAtIndex;
let currentWord;
@ -295,28 +293,31 @@ export function updateWordElement(showError) {
newlineafter = true;
ret += `<letter class='nlChar correct' style="opacity: 0"><i class="fas fa-angle-down"></i></letter>`;
} else {
ret +=
`<letter class="correct">` + TestLogic.input.current[i] + `</letter>`;
ret += `<letter class="correct">${TestLogic.input.current[i]}</letter>`;
}
}
} else {
let correctSoFar = false;
if (currentWord.slice(0, input.length) == input) {
// this is when input so far is correct
// slice earlier if input has trailing compose characters
const inputWithoutComposeLength = Misc.trailingComposeChars.test(input)
? input.search(Misc.trailingComposeChars)
: input.length;
if (
input.search(Misc.trailingComposeChars) < currentWord.length &&
currentWord.slice(0, inputWithoutComposeLength) ===
input.slice(0, inputWithoutComposeLength)
) {
correctSoFar = true;
}
let wordHighlightClassString = correctSoFar ? "correct" : "incorrect";
if (Config.blindMode) {
wordHighlightClassString = "correct";
}
for (let i = 0; i < input.length; i++) {
let charCorrect;
if (currentWord[i] == input[i]) {
charCorrect = true;
} else {
charCorrect = false;
}
let charCorrect = currentWord[i] == input[i];
let correctClass = "correct";
if (Config.highlightMode == "off") {
@ -334,51 +335,64 @@ export function updateWordElement(showError) {
currentLetter = `<i class="fas fa-angle-down"></i>`;
}
if (
Misc.trailingComposeChars.test(input) &&
i > input.search(Misc.trailingComposeChars)
)
continue;
if (charCorrect) {
ret += `<letter class="${
Config.highlightMode == "word"
? wordHighlightClassString
: correctClass
} ${tabChar}${nlChar}">${currentLetter}</letter>`;
} else {
if (!showError) {
if (currentLetter !== undefined) {
ret += `<letter class="${
Config.highlightMode == "word"
? wordHighlightClassString
: correctClass
} ${tabChar}${nlChar}">${currentLetter}</letter>`;
}
} else {
if (currentLetter == undefined) {
if (!Config.hideExtraLetters) {
let letter = input[i];
if (letter == " " || letter == "\t" || letter == "\n") {
letter = "_";
}
ret += `<letter class="${
Config.highlightMode == "word"
? wordHighlightClassString
: "incorrect"
} extra ${tabChar}${nlChar}">${letter}</letter>`;
}
} else {
ret +=
`<letter class="${
Config.highlightMode == "word"
? wordHighlightClassString
: "incorrect"
} ${tabChar}${nlChar}">` +
currentLetter +
(Config.indicateTypos ? `<hint>${input[i]}</hint>` : "") +
"</letter>";
}
} else if (
currentLetter !== undefined &&
Misc.trailingComposeChars.test(input) &&
i === input.search(Misc.trailingComposeChars)
) {
ret += `<letter class="${
Config.highlightMode == "word" ? wordHighlightClassString : ""
} dead">${currentLetter}</letter>`;
} else if (!showError) {
if (currentLetter !== undefined) {
ret += `<letter class="${
Config.highlightMode == "word"
? wordHighlightClassString
: correctClass
} ${tabChar}${nlChar}">${currentLetter}</letter>`;
}
} else if (currentLetter === undefined) {
if (!Config.hideExtraLetters) {
let letter = input[i];
if (letter == " " || letter == "\t" || letter == "\n") {
letter = "_";
}
ret += `<letter class="${
Config.highlightMode == "word"
? wordHighlightClassString
: "incorrect"
} extra ${tabChar}${nlChar}">${letter}</letter>`;
}
} else {
ret +=
`<letter class="${
Config.highlightMode == "word"
? wordHighlightClassString
: "incorrect"
} ${tabChar}${nlChar}">` +
currentLetter +
(Config.indicateTypos ? `<hint>${input[i]}</hint>` : "") +
"</letter>";
}
}
if (input.length < currentWord.length) {
for (let i = input.length; i < currentWord.length; i++) {
const inputWithSingleComposeLength = Misc.trailingComposeChars.test(input)
? input.search(Misc.trailingComposeChars) + 1
: input.length;
if (inputWithSingleComposeLength < currentWord.length) {
for (let i = inputWithSingleComposeLength; i < currentWord.length; i++) {
if (currentWord[i] === "\t") {
ret += `<letter class='tabChar'><i class="fas fa-long-arrow-alt-right"></i></letter>`;
} else if (currentWord[i] === "\n") {

View file

@ -2498,12 +2498,17 @@ key {
}
#wordsInput {
height: 0;
opacity: 0;
padding: 0;
margin: 0;
border: none;
outline: none;
display: block;
resize: none;
position: fixed;
z-index: -1;
cursor: default;
pointer-events: none;
}
#wordsTitle {

View file

@ -839,7 +839,15 @@
</div>
<div id="memoryTimer">Time left to memorise all words: 0s</div>
<div id="testModesNotice"></div>
<input id="wordsInput" class="" tabindex="0" autocomplete="off" />
<input
id="wordsInput"
class=""
tabindex="0"
type="text"
autocomplete="off"
autocapitalize="off"
autocorrect="off"
>
<div id="timerNumber" class="timerMain">
<div>60</div>
</div>
@ -2424,18 +2432,6 @@
</div>
</div>
</div>
<div class="section capsLockBackspace">
<h1>caps lock backspace</h1>
<div class="text">Makes caps lock act like backspace.</div>
<div class="buttons">
<div class="button off" tabindex="0" onclick="this.blur();">
off
</div>
<div class="button on" tabindex="0" onclick="this.blur();">
on
</div>
</div>
</div>
<div class="section lazyMode">
<h1>lazy mode</h1>
<div class="text">