fix(indicate typos): letters not displaying correctly in RTL languages or with ligatures when set to below (NadAlaba) (#5113)

This commit is contained in:
Nad Alaba 2024-03-25 19:14:53 +03:00 committed by GitHub
parent b0cf7bc4be
commit c20964d185
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 202 additions and 35 deletions

View file

@ -347,6 +347,7 @@
}
.word {
position: relative;
font-size: 1em;
line-height: 1em;
margin: 0.25em;
@ -440,12 +441,11 @@
.word letter.incorrect {
color: var(--error-color);
position: relative;
}
.word letter.incorrect hint {
.word .hints hint {
position: absolute;
bottom: -1em;
bottom: -1.1em;
color: var(--text-color);
line-height: initial;
font-size: 0.75em;
@ -454,9 +454,9 @@
left: 0;
opacity: 0.5;
text-align: center;
width: 100%;
display: grid;
justify-content: center;
transform: translate(-50%);
}
.word letter.incorrect.extra {

View file

@ -145,7 +145,7 @@ function backspaceToPrevious(): void {
TestUI.setCurrentWordElementIndex(TestUI.currentWordElementIndex - 1);
TestUI.updateActiveElement(true);
Funbox.toggleScript(TestWords.words.getCurrent());
TestUI.updateWordElement();
void TestUI.updateWordElement();
if (Config.mode === "zen") {
TimerProgress.update();
@ -210,7 +210,7 @@ function handleSpace(): void {
TestInput.incrementAccuracy(isWordCorrect);
if (isWordCorrect) {
if (Config.indicateTypos !== "off" && Config.stopOnError === "letter") {
TestUI.updateWordElement();
void TestUI.updateWordElement();
}
PaceCaret.handleSpace(true, currentWord);
TestInput.input.pushHistory();
@ -260,7 +260,7 @@ function handleSpace(): void {
if (Config.stopOnError === "word") {
dontInsertSpace = false;
Replay.addReplayEvent("incorrectLetter", "_");
TestUI.updateWordElement(true);
void TestUI.updateWordElement(true);
void Caret.updatePosition();
}
return;
@ -561,7 +561,7 @@ function handleChar(
!Config.language.startsWith("korean")
) {
TestInput.input.current = resultingWord;
TestUI.updateWordElement();
void TestUI.updateWordElement();
void Caret.updatePosition();
return;
}
@ -642,7 +642,7 @@ function handleChar(
!thisCharCorrect
) {
if (Config.indicateTypos !== "off") {
TestUI.updateWordElement(undefined, TestInput.input.current + char);
void TestUI.updateWordElement(undefined, TestInput.input.current + char);
}
return;
}
@ -708,7 +708,7 @@ function handleChar(
const activeWordTopBeforeJump = document.querySelector<HTMLElement>(
"#words .word.active"
)?.offsetTop as number;
TestUI.updateWordElement();
void TestUI.updateWordElement();
if (!Config.hideExtraLetters) {
const newActiveTop = document.querySelector<HTMLElement>(
@ -729,7 +729,7 @@ function handleChar(
if (!Config.showAllLines) TestUI.lineJump(currentTop);
} else {
TestInput.input.current = TestInput.input.current.slice(0, -1);
TestUI.updateWordElement();
void TestUI.updateWordElement();
}
}
}
@ -1353,7 +1353,7 @@ $("#wordsInput").on("input", (event) => {
TestInput.input.current = inputValue;
}
TestUI.updateWordElement();
void TestUI.updateWordElement();
void Caret.updatePosition();
if (!CompositionState.getComposing()) {
const keyStroke = event?.originalEvent as InputEvent;
@ -1395,7 +1395,7 @@ $("#wordsInput").on("input", (event) => {
const stateafter = CompositionState.getComposing();
if (statebefore !== stateafter) {
TestUI.updateWordElement();
void TestUI.updateWordElement();
}
// force caret at end of input

View file

@ -72,10 +72,14 @@ export async function updatePosition(noAnim = false): Promise<void> {
const inputLen = TestInput.input.current.length;
const currentLetterIndex = inputLen;
const activeWordEl = document?.querySelector("#words .active") as HTMLElement;
//insert temporary character so the caret will work in zen mode
const activeWordEmpty = $("#words .active").children().length === 0;
const activeWordEmpty = activeWordEl?.children.length === 0;
if (activeWordEmpty) {
$("#words .active").append('<letter style="opacity: 0;">_</letter>');
activeWordEl.insertAdjacentHTML(
"beforeend",
'<letter style="opacity: 0;">_</letter>'
);
}
const currentWordNodeList = document
@ -112,13 +116,16 @@ export async function updatePosition(noAnim = false): Promise<void> {
const diff = letterHeight - caret.offsetHeight;
let newTop = letterPosTop + diff / 2;
let newTop = activeWordEl.offsetTop + letterPosTop + diff / 2;
if (Config.caretStyle === "underline") {
newTop = letterPosTop - caret.offsetHeight / 2;
newTop = activeWordEl.offsetTop + letterPosTop - caret.offsetHeight / 2;
}
let newLeft = letterPosLeft - (fullWidthCaret ? 0 : caretWidth / 2);
let newLeft =
activeWordEl.offsetLeft +
letterPosLeft -
(fullWidthCaret ? 0 : caretWidth / 2);
const wordsWrapperWidth =
$(document.querySelector("#wordsWrapper") as HTMLElement).width() ?? 0;
@ -199,7 +206,7 @@ export async function updatePosition(noAnim = false): Promise<void> {
}
}
if (activeWordEmpty) {
$("#words .active").children().remove();
activeWordEl?.replaceChildren();
}
}

View file

@ -31,7 +31,7 @@ export function setLastTestWpm(wpm: number): void {
}
}
function resetCaretPosition(): void {
async function resetCaretPosition(): Promise<void> {
if (Config.paceCaret === "off" && !TestState.isPaceRepeat) return;
if (!$("#paceCaret").hasClass("hidden")) {
$("#paceCaret").addClass("hidden");
@ -47,10 +47,15 @@ function resetCaretPosition(): void {
if (firstLetter === undefined || firstLetterHeight === undefined) return;
const currentLanguage = await Misc.getCurrentLanguage(Config.language);
const isLanguageRightToLeft = currentLanguage.rightToLeft;
caret.stop(true, true).animate(
{
top: firstLetter.offsetTop - firstLetterHeight / 4,
left: firstLetter.offsetLeft,
left:
firstLetter.offsetLeft +
(isLanguageRightToLeft ? firstLetter.offsetWidth : 0),
},
0,
"linear"
@ -121,10 +126,10 @@ export async function init(): Promise<void> {
wordsStatus: {},
timeout: null,
};
resetCaretPosition();
await resetCaretPosition();
}
export function update(expectedStepEnd: number): void {
export async function update(expectedStepEnd: number): Promise<void> {
if (settings === null || !TestState.isActive || TestUI.resultVisible) {
return;
}
@ -210,15 +215,26 @@ export function update(expectedStepEnd: number): void {
);
}
const currentLanguage = await Misc.getCurrentLanguage(Config.language);
const isLanguageRightToLeft = currentLanguage.rightToLeft;
newTop =
word.offsetTop +
currentLetter.offsetTop -
Config.fontSize * Misc.convertRemToPixels(1) * 0.1;
newLeft;
if (settings.currentLetterIndex === -1) {
newLeft = currentLetter.offsetLeft;
newLeft =
word.offsetLeft +
currentLetter.offsetLeft -
caretWidth / 2 +
(isLanguageRightToLeft ? currentLetterWidth : 0);
} else {
newLeft =
currentLetter.offsetLeft + currentLetterWidth - caretWidth / 2;
word.offsetLeft +
currentLetter.offsetLeft -
caretWidth / 2 +
(isLanguageRightToLeft ? 0 : currentLetterWidth);
}
caret.removeClass("hidden");
} catch (e) {
@ -254,11 +270,9 @@ export function update(expectedStepEnd: number): void {
}
}
settings.timeout = setTimeout(() => {
try {
update(expectedStepEnd + (settings?.spc ?? 0) * 1000);
} catch (e) {
update(expectedStepEnd + (settings?.spc ?? 0) * 1000).catch(() => {
settings = null;
}
});
}, duration);
} catch (e) {
console.error(e);
@ -296,7 +310,7 @@ export function handleSpace(correct: boolean, currentWord: string): void {
}
export function start(): void {
update(performance.now() + (settings?.spc ?? 0) * 1000);
void update(performance.now() + (settings?.spc ?? 0) * 1000);
}
ConfigEvent.subscribe((eventKey) => {

View file

@ -29,6 +29,81 @@ async function gethtml2canvas(): Promise<typeof import("html2canvas").default> {
return (await import("html2canvas")).default;
}
function createHintsHtml(
incorrectLtrIndices: number[][],
activeWordLetters: NodeListOf<Element>
): string {
let hintsHtml = "";
for (const adjacentLetters of incorrectLtrIndices) {
for (const indx of adjacentLetters) {
const blockLeft = (activeWordLetters[indx] as HTMLElement).offsetLeft;
const blockWidth = (activeWordLetters[indx] as HTMLElement).offsetWidth;
const blockIndices = `[${indx}]`;
const blockChars = TestInput.input.current[indx];
hintsHtml +=
`<hint data-length=1 data-chars-index=${blockIndices}` +
` style="left: ${blockLeft + blockWidth / 2}px;">${blockChars}</hint>`;
}
}
hintsHtml = `<div class="hints">${hintsHtml}</div>`;
return hintsHtml;
}
async function joinOverlappingHints(
incorrectLtrIndices: number[][],
activeWordLetters: NodeListOf<Element>,
hintElements: HTMLCollection
): Promise<void> {
const currentLanguage = await Misc.getCurrentLanguage(Config.language);
const isLanguageRTL = currentLanguage.rightToLeft;
let i = 0;
for (const adjacentLetters of incorrectLtrIndices) {
for (let j = 0; j < adjacentLetters.length - 1; j++) {
const block1El = hintElements[i] as HTMLElement;
const block2El = hintElements[i + 1] as HTMLElement;
const leftBlock = isLanguageRTL ? block2El : block1El;
const rightBlock = isLanguageRTL ? block1El : block2El;
/** HintBlock.offsetLeft is at the center line of corresponding letters
* then "transform: translate(-50%)" aligns hints with letters */
if (
leftBlock.offsetLeft + leftBlock.offsetWidth / 2 >
rightBlock.offsetLeft - rightBlock.offsetWidth / 2
) {
block1El.dataset["length"] = (
parseInt(block1El.dataset["length"] ?? "1") +
parseInt(block2El.dataset["length"] ?? "1")
).toString();
const block1Indices = block1El.dataset["charsIndex"] ?? "[]";
const block2Indices = block2El.dataset["charsIndex"] ?? "[]";
block1El.dataset["charsIndex"] =
block1Indices.slice(0, -1) + "," + block2Indices.slice(1);
const letter1Index = adjacentLetters[j] ?? 0;
const newLeft =
(activeWordLetters[letter1Index] as HTMLElement).offsetLeft +
(isLanguageRTL
? (activeWordLetters[letter1Index] as HTMLElement).offsetWidth
: 0) +
(block2El.offsetLeft - block1El.offsetLeft);
block1El.style.left = newLeft.toString() + "px";
block1El.insertAdjacentHTML("beforeend", block2El.innerHTML);
block2El.remove();
adjacentLetters.splice(j + 1, 1);
i -= j === 0 ? 1 : 2;
j -= j === 0 ? 1 : 2;
}
i++;
}
i++;
}
}
const debouncedZipfCheck = debounce(250, async () => {
const supports = await Misc.checkIfLanguageSupportsZipf(Config.language);
if (supports === "no") {
@ -67,6 +142,10 @@ ConfigEvent.subscribe((eventKey, eventValue, nosave) => {
updateWordsHeight(true);
updateWordsInputPosition(true);
}
if (eventKey === "fontSize" || eventKey === "fontFamily")
updateHintsPosition().catch((e) => {
console.error(e);
});
if (eventKey === "theme") void applyBurstHeatmap();
@ -79,7 +158,7 @@ ConfigEvent.subscribe((eventKey, eventValue, nosave) => {
if (typeof eventValue !== "boolean") return;
if (eventKey === "flipTestColors") flipColors(eventValue);
if (eventKey === "colorfulMode") colorful(eventValue);
if (eventKey === "highlightMode") updateWordElement(eventValue);
if (eventKey === "highlightMode") void updateWordElement(eventValue);
if (eventKey === "burstHeatmap") void applyBurstHeatmap();
});
@ -168,6 +247,53 @@ export function updateActiveElement(
}
}
async function updateHintsPosition(): Promise<void> {
if (
ActivePage.get() !== "test" ||
resultVisible ||
Config.indicateTypos !== "below"
)
return;
const currentLanguage = await Misc.getCurrentLanguage(Config.language);
const isLanguageRTL = currentLanguage.rightToLeft;
let wordEl: HTMLElement | undefined;
let letterElements: NodeListOf<Element> | undefined;
const hintElements = document
.getElementById("words")
?.querySelectorAll("div.word > div.hints > hint");
for (let i = 0; i < (hintElements?.length ?? 0); i++) {
const hintEl = hintElements?.[i] as HTMLElement;
if (!wordEl || hintEl.parentElement?.parentElement !== wordEl) {
wordEl = hintEl.parentElement?.parentElement as HTMLElement;
letterElements = wordEl?.querySelectorAll("letter");
}
const letterIndices = hintEl.dataset["charsIndex"]
?.slice(1, -1)
.split(",")
.map((indx) => parseInt(indx));
const leftmostIndx = isLanguageRTL
? parseInt(hintEl.dataset["length"] ?? "1") - 1
: 0;
let newLeft = (
letterElements?.[letterIndices?.[leftmostIndx] ?? 0] as HTMLElement
).offsetLeft;
const lettersWidth =
letterIndices?.reduce(
(accum, curr) =>
accum + (letterElements?.[curr] as HTMLElement).offsetWidth,
0
) ?? 0;
newLeft += lettersWidth / 2;
hintEl.style.left = newLeft.toString() + "px";
}
}
function getWordHTML(word: string): string {
let newlineafter = false;
let retval = `<div class='word'>`;
@ -570,15 +696,18 @@ export async function screenshot(): Promise<void> {
}, 3000);
}
export function updateWordElement(
export async function updateWordElement(
showError = !Config.blindMode,
inputOverride?: string
): void {
): Promise<void> {
const input = inputOverride ?? TestInput.input.current;
const wordAtIndex = document.querySelector("#words .word.active") as Element;
const wordAtIndex = document.querySelector(
"#words .word.active"
) as HTMLElement;
const currentWord = TestWords.words.getCurrent();
if (!currentWord && Config.mode !== "zen") return;
let ret = "";
const hintIndices: number[][] = [];
let newlineafter = false;
@ -711,8 +840,15 @@ export function updateWordElement(
? "_"
: input[i]
: currentLetter) +
(Config.indicateTypos === "below" ? `<hint>${input[i]}</hint>` : "") +
"</letter>";
if (Config.indicateTypos === "below") {
if (!hintIndices?.length) hintIndices.push([i]);
else {
const lastblock = hintIndices[hintIndices.length - 1];
if (lastblock?.[lastblock.length - 1] === i - 1) lastblock.push(i);
else hintIndices.push([i]);
}
}
}
}
@ -741,7 +877,17 @@ export function updateWordElement(
}
}
}
wordAtIndex.innerHTML = ret;
if (hintIndices?.length) {
const activeWordLetters = wordAtIndex.querySelectorAll("letter");
const hintsHtml = createHintsHtml(hintIndices, activeWordLetters);
wordAtIndex.insertAdjacentHTML("beforeend", hintsHtml);
const hintElements = wordAtIndex.getElementsByTagName("hint");
await joinOverlappingHints(hintIndices, activeWordLetters, hintElements);
}
if (newlineafter) $("#words").append("<div class='newline'></div>");
}