Skip to content

Commit 5243d14

Browse files
authored
refactor(hints): improve readability of hints functions (@nadalaba) (monkeytypegame#6629)
- Refactoring `joinOverlappingHints()` solves the issue of hints joining when they correspond to letters of different lines, which was one of the issues [monkeytypegame#6636](monkeytypegame#6636) was supposed to solve. - Also add a utility that debounces/locks async functions.
1 parent 83cc759 commit 5243d14

File tree

3 files changed

+243
-121
lines changed

3 files changed

+243
-121
lines changed

frontend/src/ts/test/test-ui.ts

Lines changed: 175 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -27,83 +27,6 @@ import { convertRemToPixels } from "../utils/numbers";
2727
import { findSingleActiveFunboxWithFunction } from "./funbox/list";
2828
import * as TestState from "./test-state";
2929

30-
function createHintsHtml(
31-
incorrectLtrIndices: number[][],
32-
activeWordLetters: NodeListOf<Element>,
33-
inputWord: string
34-
): string {
35-
const inputChars = Strings.splitIntoCharacters(inputWord);
36-
let hintsHtml = "";
37-
for (const adjacentLetters of incorrectLtrIndices) {
38-
for (const indx of adjacentLetters) {
39-
const blockLeft = (activeWordLetters[indx] as HTMLElement).offsetLeft;
40-
const blockWidth = (activeWordLetters[indx] as HTMLElement).offsetWidth;
41-
const blockIndices = `[${indx}]`;
42-
const blockChars = inputChars[indx];
43-
44-
hintsHtml +=
45-
`<hint data-length=1 data-chars-index=${blockIndices}` +
46-
` style="left: ${blockLeft + blockWidth / 2}px;">${blockChars}</hint>`;
47-
}
48-
}
49-
hintsHtml = `<div class="hints">${hintsHtml}</div>`;
50-
return hintsHtml;
51-
}
52-
53-
async function joinOverlappingHints(
54-
incorrectLtrIndices: number[][],
55-
activeWordLetters: NodeListOf<Element>,
56-
hintElements: HTMLCollection
57-
): Promise<void> {
58-
const currentLanguage = await JSONData.getCurrentLanguage(Config.language);
59-
const isLanguageRTL = currentLanguage.rightToLeft;
60-
61-
let i = 0;
62-
for (const adjacentLetters of incorrectLtrIndices) {
63-
for (let j = 0; j < adjacentLetters.length - 1; j++) {
64-
const block1El = hintElements[i] as HTMLElement;
65-
const block2El = hintElements[i + 1] as HTMLElement;
66-
const leftBlock = isLanguageRTL ? block2El : block1El;
67-
const rightBlock = isLanguageRTL ? block1El : block2El;
68-
69-
/** HintBlock.offsetLeft is at the center line of corresponding letters
70-
* then "transform: translate(-50%)" aligns hints with letters */
71-
if (
72-
leftBlock.offsetLeft + leftBlock.offsetWidth / 2 >
73-
rightBlock.offsetLeft - rightBlock.offsetWidth / 2
74-
) {
75-
block1El.dataset["length"] = (
76-
parseInt(block1El.dataset["length"] ?? "1") +
77-
parseInt(block2El.dataset["length"] ?? "1")
78-
).toString();
79-
80-
const block1Indices = block1El.dataset["charsIndex"] ?? "[]";
81-
const block2Indices = block2El.dataset["charsIndex"] ?? "[]";
82-
block1El.dataset["charsIndex"] =
83-
block1Indices.slice(0, -1) + "," + block2Indices.slice(1);
84-
85-
const letter1Index = adjacentLetters[j] ?? 0;
86-
const newLeft =
87-
(activeWordLetters[letter1Index] as HTMLElement).offsetLeft +
88-
(isLanguageRTL
89-
? (activeWordLetters[letter1Index] as HTMLElement).offsetWidth
90-
: 0) +
91-
(block2El.offsetLeft - block1El.offsetLeft);
92-
block1El.style.left = newLeft.toString() + "px";
93-
94-
block1El.insertAdjacentHTML("beforeend", block2El.innerHTML);
95-
96-
block2El.remove();
97-
adjacentLetters.splice(j + 1, 1);
98-
i -= j === 0 ? 1 : 2;
99-
j -= j === 0 ? 1 : 2;
100-
}
101-
i++;
102-
}
103-
i++;
104-
}
105-
}
106-
10730
const debouncedZipfCheck = debounce(250, async () => {
10831
const supports = await JSONData.checkIfLanguageSupportsZipf(Config.language);
10932
if (supports === "no") {
@@ -130,6 +53,11 @@ const debouncedZipfCheck = debounce(250, async () => {
13053
}
13154
});
13255

56+
export const updateHintsPositionDebounced = Misc.debounceUntilResolved(
57+
updateHintsPosition,
58+
{ rejectSkippedCalls: false }
59+
);
60+
13361
ConfigEvent.subscribe((eventKey, eventValue, nosave) => {
13462
if (
13563
(eventKey === "language" || eventKey === "funbox") &&
@@ -146,9 +74,7 @@ ConfigEvent.subscribe((eventKey, eventValue, nosave) => {
14674
eventKey
14775
)
14876
) {
149-
updateHintsPosition().catch((e: unknown) => {
150-
console.error(e);
151-
});
77+
void updateHintsPositionDebounced();
15278
}
15379

15480
if (eventKey === "theme") void applyBurstHeatmap();
@@ -269,51 +195,175 @@ export function updateActiveElement(
269195
}
270196
}
271197

272-
export async function updateHintsPosition(): Promise<void> {
198+
function createHintsHtml(
199+
incorrectLettersIndices: number[][],
200+
activeWordLetters: NodeListOf<Element>,
201+
input: string | string[],
202+
wrapWithDiv: boolean = true
203+
): string {
204+
// if input is an array, it contains only incorrect letters input.
205+
// if input is a string, it contains the whole word input.
206+
const isFullWord = typeof input === "string";
207+
const inputChars = isFullWord ? Strings.splitIntoCharacters(input) : input;
208+
209+
let hintsHtml = "";
210+
let currentHint = 0;
211+
212+
for (const adjacentLetters of incorrectLettersIndices) {
213+
for (const letterIndex of adjacentLetters) {
214+
const letter = activeWordLetters[letterIndex] as HTMLElement;
215+
const blockIndices = `${letterIndex}`;
216+
const blockChars = isFullWord
217+
? inputChars[letterIndex]
218+
: inputChars[currentHint++];
219+
220+
hintsHtml += `<hint data-chars-index=${blockIndices} style="left:${
221+
letter.offsetLeft + letter.offsetWidth / 2
222+
}px;">${blockChars}</hint>`;
223+
}
224+
}
225+
if (wrapWithDiv) hintsHtml = `<div class="hints">${hintsHtml}</div>`;
226+
return hintsHtml;
227+
}
228+
229+
async function joinOverlappingHints(
230+
incorrectLettersIndices: number[][],
231+
activeWordLetters: NodeListOf<Element>,
232+
hintElements: HTMLCollection
233+
): Promise<void> {
234+
const currentLanguage = await JSONData.getCurrentLanguage(Config.language);
235+
const isLanguageRTL = currentLanguage.rightToLeft;
236+
237+
let firstHintInSeq = 0;
238+
for (const adjacentLettersSequence of incorrectLettersIndices) {
239+
const lastHintInSeq = firstHintInSeq + adjacentLettersSequence.length - 1;
240+
joinHintsOfAdjacentLetters(firstHintInSeq, lastHintInSeq);
241+
firstHintInSeq += adjacentLettersSequence.length;
242+
}
243+
244+
function joinHintsOfAdjacentLetters(
245+
firstHintInSequence: number,
246+
lastHintInSequence: number
247+
): void {
248+
let currentHint = firstHintInSequence;
249+
250+
while (currentHint < lastHintInSequence) {
251+
const block1El = hintElements[currentHint] as HTMLElement;
252+
const block2El = hintElements[currentHint + 1] as HTMLElement;
253+
254+
const block1Indices = block1El.dataset["charsIndex"]?.split(",") ?? [];
255+
const block2Indices = block2El.dataset["charsIndex"]?.split(",") ?? [];
256+
257+
const block1Letter1Indx = parseInt(block1Indices[0] ?? "0");
258+
const block2Letter1Indx = parseInt(block2Indices[0] ?? "0");
259+
260+
const block1Letter1 = activeWordLetters[block1Letter1Indx] as HTMLElement;
261+
const block2Letter1 = activeWordLetters[block2Letter1Indx] as HTMLElement;
262+
263+
const leftBlock = isLanguageRTL ? block2El : block1El;
264+
const rightBlock = isLanguageRTL ? block1El : block2El;
265+
266+
// block edge is offset half its width because of transform: translate(-50%)
267+
const leftBlockEnds = leftBlock.offsetLeft + leftBlock.offsetWidth / 2;
268+
const rightBlockStarts =
269+
rightBlock.offsetLeft - rightBlock.offsetWidth / 2;
270+
271+
const sameTop = block1Letter1.offsetTop === block2Letter1.offsetTop;
272+
273+
if (sameTop && leftBlockEnds > rightBlockStarts) {
274+
// join hint blocks
275+
block1El.dataset["charsIndex"] = [
276+
...block1Indices,
277+
...block2Indices,
278+
].join(",");
279+
280+
const block1Letter1Pos =
281+
block1Letter1.offsetLeft +
282+
(isLanguageRTL ? block1Letter1.offsetWidth : 0);
283+
const bothBlocksLettersWidthHalved =
284+
block2El.offsetLeft - block1El.offsetLeft;
285+
block1El.style.left =
286+
block1Letter1Pos + bothBlocksLettersWidthHalved + "px";
287+
288+
block1El.insertAdjacentHTML("beforeend", block2El.innerHTML);
289+
block2El.remove();
290+
291+
// after joining blocks, the sequence is shorter
292+
lastHintInSequence--;
293+
// check if the newly formed block overlaps with the previous one
294+
currentHint--;
295+
if (currentHint < firstHintInSeq) currentHint = firstHintInSeq;
296+
} else {
297+
currentHint++;
298+
}
299+
}
300+
}
301+
}
302+
303+
async function updateHintsPosition(): Promise<void> {
273304
if (
274305
ActivePage.get() !== "test" ||
275306
resultVisible ||
276307
Config.indicateTypos !== "below"
277308
)
278309
return;
279310

280-
const currentLanguage = await JSONData.getCurrentLanguage(Config.language);
281-
const isLanguageRTL = currentLanguage.rightToLeft;
311+
let previousHintsContainer: HTMLElement | undefined;
312+
let hintIndices: number[][] = [];
313+
let hintText: string[] = [];
282314

283-
let wordEl: HTMLElement | undefined;
284-
let letterElements: NodeListOf<Element> | undefined;
315+
const hintElements = document.querySelectorAll<HTMLElement>(".hints > hint");
285316

286-
const hintElements = document
287-
.getElementById("words")
288-
?.querySelectorAll("div.word > div.hints > hint");
289-
for (let i = 0; i < (hintElements?.length ?? 0); i++) {
290-
const hintEl = hintElements?.[i] as HTMLElement;
317+
for (const hintEl of hintElements) {
318+
const hintsContainer = hintEl.parentElement as HTMLElement;
291319

292-
if (!wordEl || hintEl.parentElement?.parentElement !== wordEl) {
293-
wordEl = hintEl.parentElement?.parentElement as HTMLElement;
294-
letterElements = wordEl?.querySelectorAll("letter");
320+
if (hintsContainer !== previousHintsContainer) {
321+
await adjustHintsContainer(previousHintsContainer, hintIndices, hintText);
322+
previousHintsContainer = hintsContainer;
323+
hintIndices = [];
324+
hintText = [];
295325
}
296326

297327
const letterIndices = hintEl.dataset["charsIndex"]
298-
?.slice(1, -1)
299-
.split(",")
300-
.map((indx) => parseInt(indx));
301-
const leftmostIndx = isLanguageRTL
302-
? parseInt(hintEl.dataset["length"] ?? "1") - 1
303-
: 0;
304-
305-
const el = letterElements?.[
306-
letterIndices?.[leftmostIndx] ?? 0
307-
] as HTMLElement;
308-
let newLeft = el.offsetLeft;
309-
const lettersWidth =
310-
letterIndices?.reduce((accum, curr) => {
311-
const el = letterElements?.[curr] as HTMLElement;
312-
return accum + el.offsetWidth;
313-
}, 0) ?? 0;
314-
newLeft += lettersWidth / 2;
315-
316-
hintEl.style.left = newLeft.toString() + "px";
328+
?.split(",")
329+
.map((index) => parseInt(index));
330+
331+
if (letterIndices === undefined || letterIndices.length === 0) continue;
332+
333+
for (const currentLetterIndex of letterIndices) {
334+
const lastBlock = hintIndices[hintIndices.length - 1];
335+
if (
336+
lastBlock &&
337+
lastBlock[lastBlock.length - 1] === currentLetterIndex - 1
338+
) {
339+
lastBlock.push(currentLetterIndex);
340+
} else {
341+
hintIndices.push([currentLetterIndex]);
342+
}
343+
}
344+
345+
hintText.push(...Strings.splitIntoCharacters(hintEl.innerHTML));
346+
}
347+
await adjustHintsContainer(previousHintsContainer, hintIndices, hintText);
348+
349+
async function adjustHintsContainer(
350+
hintsContainer: HTMLElement | undefined,
351+
hintIndices: number[][],
352+
hintText: string[]
353+
): Promise<void> {
354+
if (!hintsContainer || hintIndices.length === 0) return;
355+
356+
const wordElement = hintsContainer.parentElement as HTMLElement;
357+
const letterElements = wordElement.querySelectorAll<HTMLElement>("letter");
358+
359+
hintsContainer.innerHTML = createHintsHtml(
360+
hintIndices,
361+
letterElements,
362+
hintText,
363+
false
364+
);
365+
const wordHintsElements = wordElement.getElementsByTagName("hint");
366+
await joinOverlappingHints(hintIndices, letterElements, wordHintsElements);
317367
}
318368
}
319369

@@ -589,7 +639,7 @@ export function updateWordsWrapperHeight(force = false): void {
589639

590640
function updateWordsMargin(): void {
591641
if (Config.tapeMode !== "off") {
592-
void scrollTape(true);
642+
void scrollTape(true, updateHintsPositionDebounced);
593643
} else {
594644
const wordsEl = document.getElementById("words") as HTMLElement;
595645
const afterNewlineEls =
@@ -603,6 +653,7 @@ function updateWordsMargin(): void {
603653
{
604654
duration: SlowTimer.get() ? 0 : 125,
605655
queue: "leftMargin",
656+
complete: updateHintsPositionDebounced,
606657
}
607658
);
608659
jqWords.dequeue("leftMargin");
@@ -614,6 +665,7 @@ function updateWordsMargin(): void {
614665
for (const afterNewline of afterNewlineEls) {
615666
afterNewline.style.marginLeft = `0`;
616667
}
668+
void updateHintsPositionDebounced();
617669
}
618670
}
619671
}
@@ -760,12 +812,10 @@ export async function updateActiveWordLetters(
760812
: currentLetter) +
761813
"</letter>";
762814
if (Config.indicateTypos === "below") {
763-
if (!hintIndices?.length) hintIndices.push([i]);
764-
else {
765-
const lastblock = hintIndices[hintIndices.length - 1];
766-
if (lastblock?.[lastblock.length - 1] === i - 1) lastblock.push(i);
767-
else hintIndices.push([i]);
768-
}
815+
const lastBlock = hintIndices[hintIndices.length - 1];
816+
if (lastBlock && lastBlock[lastBlock.length - 1] === i - 1)
817+
lastBlock.push(i);
818+
else hintIndices.push([i]);
769819
}
770820
}
771821
}
@@ -827,7 +877,10 @@ function getNlCharWidth(
827877
return nlChar.offsetWidth + letterMargin;
828878
}
829879

830-
export async function scrollTape(noRemove = false): Promise<void> {
880+
export async function scrollTape(
881+
noRemove = false,
882+
afterCompleteFn?: () => void
883+
): Promise<void> {
831884
if (ActivePage.get() !== "test" || resultVisible) return;
832885

833886
await centeringActiveLine;
@@ -1007,6 +1060,7 @@ export async function scrollTape(noRemove = false): Promise<void> {
10071060
{
10081061
duration: SlowTimer.get() ? 0 : 125,
10091062
queue: "leftMargin",
1063+
complete: afterCompleteFn,
10101064
}
10111065
);
10121066
jqWords.dequeue("leftMargin");
@@ -1022,6 +1076,7 @@ export async function scrollTape(noRemove = false): Promise<void> {
10221076
const newMargin = afterNewlinesNewMargins[i] ?? 0;
10231077
(afterNewLineEls[i] as HTMLElement).style.marginLeft = `${newMargin}px`;
10241078
}
1079+
if (afterCompleteFn) afterCompleteFn();
10251080
}
10261081
}
10271082

0 commit comments

Comments
 (0)