mirror of
https://github.com/monkeytypegame/monkeytype.git
synced 2025-12-29 03:20:46 +08:00
impr(tape mode): add multiline support for tape mode (@NadAlaba, @miodec) (#5868)

***format:***
*file-name: line number on pr: `function name`*
*- message*
1. test-ui.ts: 448: `updateWordsInputPosition` & ui.ts: 109:
`debouncedEvent`
- calculate #wordsInput left position in tape mode same way when
tapeMode is off (because tape mode can have zen mode and will accept RTL
later. Also, now tape mode can have 3 lines, and #wordsInput needs to go
down to the 2nd line).
- call updateWordsInputPosition() on window resize which solves #6093 in
a simpler way than a2f6c1f.
2. test-ui.ts: 551: `updateWordsWrapperHeight`
- on zen mode make wrapper height 2 lines, and move conditions around.
- on tape mode make wrapper height min(#words.Height, .word.Height * 3)
because #words now have some padding to prevent hints from being cut off
in 1-line tap mode.
3. test-ui.ts: 285: `updateActiveElement`
- don't call scrollTape() on initial call of updateActiveElement()
because it'll be called by other functions (i.e, showWords() and
updateActiveWordLetters()).
4. test-ui.ts: 796: `updateActiveWordLetters`
- move activeWord definition to the top of the function to return if
it's not defined, and use type argument instead of "as".
5. test-ui.ts: 356: `getWordHTML` & test-ui.ts: 945:
`updateActiveWordLetters`
- add .beforeNewline and .afterNewline elements with each added .newline
element.
- the point of .afterNewline is to indent the next line, and the point
of .beforeNewline is to act as a filler for the top line when the words
of that line get removed in scrollTape() because they were overflown
horizontally.
6. test.scss: 176+188+279: `#words`+`#words.tape`
- change #words display from flex to block and make .word, .afterNewline
and .beforeNewline elements display: inline-block (but keep .newline as
block) in order to use white-space: nowrap, but still be able to break
on demand with block elements .newline.
- also, make default .word margin-bottom in tape mode 0.25em just like
in non-tape mode, since they are now practically similar.
- make the height of .beforeNewline identical to the height of .word so
that when all top-line-words are removed, the user won't feel a vertical
shift in lines.
- use vertical-align: top in .word and .beforeNewline, because in
lineJump(), we rely on their offsetTop to be the same if they are on the
same line.
- add padding-bottom: 0.5em to #words to prevent hints from being
cut-off in the last line of test when showAllLines= on and in 1-line
tape mode.
7. input-controller: 169: `backspaceToPrevious`
- remove .beforeNewline and .afterNewline elements with .newline
elements when backspacing to a higher line in zen mode.
8. test-ui.ts: 590: `updateWordsMargin`
- when tape mode is turned on, first adjust the margins and then remove
overflown elements (this is what passing true to scrollTape() does), and
when it's turned off unset the margins of both #words and .afterNewline.
9. test-ui.ts: 1168: `removeElementsBeforeWord`
- add a new helper function that removes all elements (except
.smoothScroller), before (and including) the input element and returns
the removed .word elements (removes
.newline/.afterNewline/.beforeNewline/.word elements, but returns number
of removed .words only)
10. test-ui.ts: 1191: `lineJump`
- some refactoring: save HTMLelements in const instead of repeatedly
querying the DOM.
- allow lineJump() to be called in force (even when currentTestLine ===
0), which is useful when changing Config.showAllLines to off
(currentTestLine stays at zero in showAllLines=on), in order to lineJump
and keep the active word on the 2nd line.
- make the conditions to run lineJump() similar in tapeMode on and off
(currentTestLine > 0, hideBound = currentTop - 10).
- when determining the elements to hide, save the index of the last
element to hide in a const and then remove it and everything before it
when the animation completes. It is done like this instead of saving
what needs to be hidden in an array, because .afterNewline elements have
offsetTops that cannot be relied upon to determine if they need to be
hidden or not. The new function removeElementsBeforeWord() does that.
- last element to hide is now the last .word or .newline that is higher
than the hideBound.
- #words margin-top animation is done in its own queue so that we only
.stop() margin-top animation without affecting margin-left animation.
11. test-logic.ts: 1434: `ConfigEvent.subscribe`
- remove the restriction to allow changing showAllLines without
restarting the test.
12. test-ui.ts: 492: `centerActiveLine` & ui.ts: 107: `debouncedEvent`
- add a function centerActiveLine() that finds the top of the previous
line and calls lineJump() passing that top to it. If the active word is
on the 1st line it does nothing.
- this is useful on window resize because it used to call lineJump()
passing the top of the previous word to it, causing unnecessary line
jumping if the active word was in the middle of the 2nd line (see gif
below).
- this is also useful when turning ShowAllLines off to hide (remove) all
lines higher than the previous line.

13. test-ui.ts: 190: `ConfigEvent.subscribe()`
- call updateWordsWrapperHeight() on showAllLines change, and if the
change was to 'off' call centerActiveLine() in order to keep the active
word in the middle line.
14. test-ui.ts: 954: `getNlCharWidth`
- add a new helper function that calculates the width of the nlChar
letter that is in the last .word element before the input element, and
check if the nlChar placeholder was incorrectly typed, if so return a
width of 0. This last check is to minimize next line shifting behavior,
see
[video](https://discord.com/channels/713194177403420752/713196019206324306/1283880903382274119)
15. test-ui.ts: 978: `scrollTape`
- remove leading .afterNewline elements.
- get last element to loop over which is the 2nd .afterNewline after
active word, or else the 1st one after active, or else stop at the
active word.
- in the main loop sum the widths of words before new line then add it
to the left margin of the next .afterNewline, while also determining the
widths of words before the active word (which will be in the new
margin-left of #words), and determine what words have overflown the
wrapper and need to be hidden.
- if there is anything to remove, remove the overflown elements, and
adjust margin-left of #words and .afterNewline by the width of what was
removed.
- calculate the width of the current word in tape=letter just like
before then animate the new #words margin-left and .afterNewline
elements' margin-left.
- #words margin-left animation is done in its own queue so that when we
use .stop() before the animation we'll only be stopping the margin-left
animation and not the margin-top animation which is performed in
lineJump().
16. test-ui.ts: 492: `centerActiveLine`
- scrollTape now awaits the promise centeringActiveLine which is always
resolved except if showAllLines was on and tape mode was turned on
through the commandline mid-test. In that case centeringActiveLine won't
be resolved until lineJump completes its animation/style-change, so that
scrollTape removing words won't conflict with lineJump removing words.
Closes #3907
---------
Co-authored-by: Miodec <jack@monkeytype.com>
This commit is contained in:
parent
e436671a45
commit
9a0fee2052
5 changed files with 374 additions and 147 deletions
|
|
@ -173,6 +173,7 @@
|
|||
#words {
|
||||
height: fit-content;
|
||||
height: -moz-fit-content;
|
||||
padding-bottom: 0.5em; // to account for hints of the bottom line
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
|
|
@ -184,6 +185,20 @@
|
|||
width: inherit;
|
||||
}
|
||||
|
||||
.beforeNewline {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
margin: 0.25em 0;
|
||||
box-sizing: content-box;
|
||||
height: 1em; //.word line-height
|
||||
border-top: 0.05em solid transparent; // letter border-bottom
|
||||
border-bottom: 2px solid transparent; //.word border
|
||||
}
|
||||
|
||||
.afterNewline {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
--correct-letter-color: var(--text-color);
|
||||
--correct-letter-animation: none;
|
||||
--untyped-letter-color: var(--sub-color);
|
||||
|
|
@ -261,10 +276,13 @@
|
|||
}
|
||||
|
||||
&.tape {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
width: 200vw;
|
||||
.word {
|
||||
margin: 0.25em 0.6em 0.75em 0;
|
||||
white-space: nowrap;
|
||||
margin: 0.25em 0.6em 0.25em 0;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -166,7 +166,11 @@ function backspaceToPrevious(): void {
|
|||
|
||||
for (let i = els.length - 1; i >= 0; i--) {
|
||||
const el = els[i] as HTMLElement;
|
||||
if (el.classList.contains("newline")) {
|
||||
if (
|
||||
el.classList.contains("newline") ||
|
||||
el.classList.contains("beforeNewline") ||
|
||||
el.classList.contains("afterNewline")
|
||||
) {
|
||||
el.remove();
|
||||
} else {
|
||||
break;
|
||||
|
|
@ -351,7 +355,7 @@ async function handleSpace(): Promise<void> {
|
|||
}
|
||||
|
||||
if (nextTop > currentTop) {
|
||||
TestUI.lineJump(currentTop);
|
||||
void TestUI.lineJump(currentTop);
|
||||
} //end of line wrap
|
||||
}
|
||||
|
||||
|
|
@ -730,7 +734,7 @@ function handleChar(
|
|||
TestInput.input.current.length > 1
|
||||
) {
|
||||
if (Config.mode === "zen") {
|
||||
if (!Config.showAllLines) TestUI.lineJump(activeWordTopBeforeJump);
|
||||
if (!Config.showAllLines) void TestUI.lineJump(activeWordTopBeforeJump);
|
||||
} else {
|
||||
TestInput.input.current = TestInput.input.current.slice(0, -1);
|
||||
void TestUI.updateActiveWordLetters();
|
||||
|
|
|
|||
|
|
@ -1431,7 +1431,6 @@ ConfigEvent.subscribe((eventKey, eventValue, nosave) => {
|
|||
restart();
|
||||
}
|
||||
if (eventKey === "difficulty" && !nosave) restart();
|
||||
if (eventKey === "showAllLines" && !nosave) restart();
|
||||
if (
|
||||
eventKey === "customLayoutFluid" &&
|
||||
Config.funbox.includes("layoutfluid")
|
||||
|
|
|
|||
|
|
@ -187,6 +187,13 @@ ConfigEvent.subscribe((eventKey, eventValue, nosave) => {
|
|||
updateLiveStatsMargin();
|
||||
}
|
||||
|
||||
if (eventKey === "showAllLines") {
|
||||
updateWordsWrapperHeight(true);
|
||||
if (eventValue === false) {
|
||||
void centerActiveLine();
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof eventValue !== "boolean") return;
|
||||
if (eventKey === "flipTestColors") flipColors(eventValue);
|
||||
if (eventKey === "colorfulMode") colorful(eventValue);
|
||||
|
|
@ -275,8 +282,8 @@ export function updateActiveElement(
|
|||
if (!initial && shouldUpdateWordsInputPosition()) {
|
||||
void updateWordsInputPosition();
|
||||
}
|
||||
if (Config.tapeMode !== "off") {
|
||||
scrollTape();
|
||||
if (!initial && Config.tapeMode !== "off") {
|
||||
void scrollTape();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -346,7 +353,9 @@ function getWordHTML(word: string): string {
|
|||
}
|
||||
}
|
||||
retval += "</div>";
|
||||
if (newlineafter) retval += "<div class='newline'></div>";
|
||||
if (newlineafter)
|
||||
retval +=
|
||||
"<div class='beforeNewline'></div><div class='newline'></div><div class='afterNewline'></div>";
|
||||
return retval;
|
||||
}
|
||||
|
||||
|
|
@ -437,7 +446,6 @@ function shouldUpdateWordsInputPosition(): boolean {
|
|||
|
||||
export async function updateWordsInputPosition(initial = false): Promise<void> {
|
||||
if (ActivePage.get() !== "test") return;
|
||||
if (Config.tapeMode !== "off" && !initial) return;
|
||||
|
||||
const currentLanguage = await JSONData.getCurrentLanguage(Config.language);
|
||||
const isLanguageRTL = currentLanguage.rightToLeft;
|
||||
|
|
@ -468,13 +476,7 @@ export async function updateWordsInputPosition(initial = false): Promise<void> {
|
|||
el.style.width = activeWord.offsetWidth + "px";
|
||||
}
|
||||
|
||||
if (Config.tapeMode !== "off") {
|
||||
el.style.top = targetTop + "px";
|
||||
el.style.left = Config.tapeMargin + "%";
|
||||
return;
|
||||
}
|
||||
|
||||
if (initial) {
|
||||
if (initial && Config.tapeMode === "off") {
|
||||
el.style.top = targetTop + letterHeight + activeWordMargin + 4 + "px";
|
||||
} else {
|
||||
el.style.top = targetTop + "px";
|
||||
|
|
@ -487,6 +489,38 @@ export async function updateWordsInputPosition(initial = false): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
let centeringActiveLine: Promise<void> = Promise.resolve();
|
||||
|
||||
export async function centerActiveLine(): Promise<void> {
|
||||
if (Config.showAllLines) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { resolve, promise } = Misc.promiseWithResolvers<void>();
|
||||
centeringActiveLine = promise;
|
||||
|
||||
const wordElements = document.querySelectorAll<HTMLElement>("#words .word");
|
||||
const activeWordIndex = TestState.activeWordIndex - activeWordElementOffset;
|
||||
const activeWordEl = wordElements[activeWordIndex];
|
||||
if (!activeWordEl) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const currentTop = activeWordEl.offsetTop;
|
||||
|
||||
let previousLineTop = currentTop;
|
||||
for (let i = activeWordIndex - 1; i >= 0; i--) {
|
||||
previousLineTop = wordElements[i]?.offsetTop ?? currentTop;
|
||||
if (previousLineTop < currentTop) {
|
||||
await lineJump(previousLineTop, true);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
resolve();
|
||||
}
|
||||
|
||||
export function updateWordsWrapperHeight(force = false): void {
|
||||
if (ActivePage.get() !== "test" || resultVisible) return;
|
||||
if (!force && Config.mode !== "custom") return;
|
||||
|
|
@ -514,11 +548,15 @@ export function updateWordsWrapperHeight(force = false): void {
|
|||
|
||||
const showAllLines = Config.showAllLines && !timedTest;
|
||||
|
||||
if (Config.tapeMode === "off") {
|
||||
if (showAllLines) {
|
||||
//allow the wrapper to grow and shink with the words
|
||||
wrapperEl.style.height = "";
|
||||
} else {
|
||||
if (showAllLines) {
|
||||
//allow the wrapper to grow and shink with the words
|
||||
wrapperEl.style.height = "";
|
||||
} else if (Config.mode === "zen") {
|
||||
//zen mode, showAllLines off
|
||||
wrapperEl.style.height = wordHeight * 2 + "px";
|
||||
} else {
|
||||
if (Config.tapeMode === "off") {
|
||||
//tape off, showAllLines off, non-zen mode
|
||||
let lines = 0;
|
||||
let lastTop = 0;
|
||||
let wordIndex = 0;
|
||||
|
|
@ -539,12 +577,11 @@ export function updateWordsWrapperHeight(force = false): void {
|
|||
|
||||
//limit to 3 lines
|
||||
wrapperEl.style.height = wrapperHeight + "px";
|
||||
} else {
|
||||
//show 3 lines if tape mode is on and has newlines, otherwise 1
|
||||
const linesToShow = TestWords.hasNewline ? 3 : 1;
|
||||
wrapperEl.style.height = wordHeight * linesToShow + "px";
|
||||
}
|
||||
} else {
|
||||
//tape mode
|
||||
wrapperEl.style.height = TestWords.hasNewline
|
||||
? wordHeight * 3 + "px"
|
||||
: wordHeight * 1 + "px";
|
||||
}
|
||||
|
||||
outOfFocusEl.style.maxHeight = wordHeight * 3 + "px";
|
||||
|
|
@ -552,9 +589,12 @@ export function updateWordsWrapperHeight(force = false): void {
|
|||
|
||||
function updateWordsMargin(): void {
|
||||
if (Config.tapeMode !== "off") {
|
||||
scrollTape();
|
||||
void scrollTape();
|
||||
} else {
|
||||
setTimeout(() => $("#words").css("margin-left", "unset"), 0);
|
||||
setTimeout(() => {
|
||||
$("#words").css("margin-left", "unset");
|
||||
$("#words .afterNewline").css("margin-left", "unset");
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -753,6 +793,11 @@ export async function updateActiveWordLetters(
|
|||
const currentWord = TestWords.words.getCurrent();
|
||||
if (!currentWord && Config.mode !== "zen") return;
|
||||
let ret = "";
|
||||
const activeWord =
|
||||
document.querySelectorAll<HTMLElement>("#words .word")?.[
|
||||
TestState.activeWordIndex - activeWordElementOffset
|
||||
];
|
||||
if (!activeWord) return;
|
||||
const hintIndices: number[][] = [];
|
||||
|
||||
let newlineafter = false;
|
||||
|
|
@ -887,10 +932,6 @@ export async function updateActiveWordLetters(
|
|||
}
|
||||
}
|
||||
|
||||
const activeWord = document.querySelectorAll("#words .word")?.[
|
||||
TestState.activeWordIndex - activeWordElementOffset
|
||||
] as HTMLElement;
|
||||
|
||||
activeWord.innerHTML = ret;
|
||||
|
||||
if (hintIndices?.length) {
|
||||
|
|
@ -901,62 +942,172 @@ export async function updateActiveWordLetters(
|
|||
await joinOverlappingHints(hintIndices, activeWordLetters, hintElements);
|
||||
}
|
||||
|
||||
if (newlineafter) $("#words").append("<div class='newline'></div>");
|
||||
if (newlineafter)
|
||||
$("#words").append(
|
||||
"<div class='beforeNewline'></div><div class='newline'></div><div class='afterNewline'></div>"
|
||||
);
|
||||
if (Config.tapeMode !== "off") {
|
||||
scrollTape();
|
||||
void scrollTape();
|
||||
}
|
||||
}
|
||||
|
||||
export function scrollTape(): void {
|
||||
// this is needed in tape mode because sometimes we want the newline character to appear above the next line
|
||||
// and sometimes we want it to be shifted to the left
|
||||
// (for example if the newline is typed incorrectly, or there are any extra letters after it)
|
||||
function getNlCharWidth(
|
||||
lastWordInLine?: Element | HTMLElement,
|
||||
checkIfIncorrect = true
|
||||
): number {
|
||||
let nlChar: HTMLElement | null;
|
||||
if (lastWordInLine) {
|
||||
nlChar = lastWordInLine.querySelector<HTMLElement>("letter.nlChar");
|
||||
} else {
|
||||
nlChar = document.querySelector<HTMLElement>(
|
||||
"#words > .word > letter.nlChar"
|
||||
);
|
||||
}
|
||||
if (!nlChar) return 0;
|
||||
if (checkIfIncorrect && nlChar.classList.contains("incorrect")) return 0;
|
||||
const letterComputedStyle = window.getComputedStyle(nlChar);
|
||||
const letterMargin =
|
||||
parseFloat(letterComputedStyle.marginLeft) +
|
||||
parseFloat(letterComputedStyle.marginRight);
|
||||
return nlChar.offsetWidth + letterMargin;
|
||||
}
|
||||
|
||||
export async function scrollTape(): Promise<void> {
|
||||
if (ActivePage.get() !== "test" || resultVisible) return;
|
||||
|
||||
if (!TestState.isActive) {
|
||||
$("#words")
|
||||
.stop(true, false)
|
||||
.animate(
|
||||
{
|
||||
marginLeft: Config.tapeMargin + "%",
|
||||
},
|
||||
SlowTimer.get() ? 0 : 125
|
||||
);
|
||||
return;
|
||||
}
|
||||
await centeringActiveLine;
|
||||
|
||||
let wordIndex = TestState.activeWordIndex - activeWordElementOffset;
|
||||
// index of the active word in the collection of .word elements
|
||||
const wordElementIndex = TestState.activeWordIndex - activeWordElementOffset;
|
||||
const wordsWrapperWidth = (
|
||||
document.querySelector("#wordsWrapper") as HTMLElement
|
||||
).offsetWidth;
|
||||
let fullWordsWidth = 0;
|
||||
const toHide: JQuery[] = [];
|
||||
let widthToHide = 0;
|
||||
if (wordIndex > 0) {
|
||||
for (let i = 0; i < wordIndex; i++) {
|
||||
const word = document.querySelectorAll("#words .word")[i] as HTMLElement;
|
||||
fullWordsWidth += $(word).outerWidth(true) ?? 0;
|
||||
const forWordLeft = Math.floor(word.offsetLeft);
|
||||
const forWordWidth = Math.floor(word.offsetWidth);
|
||||
if (forWordLeft < 0 - forWordWidth) {
|
||||
const toPush = $($("#words .word")[i] as HTMLElement);
|
||||
toHide.push(toPush);
|
||||
widthToHide += toPush.outerWidth(true) ?? 0;
|
||||
const wordsEl = document.getElementById("words") as HTMLElement;
|
||||
const wordsChildrenArr = [...wordsEl.children] as HTMLElement[];
|
||||
const wordElements = wordsEl.getElementsByClassName("word");
|
||||
const activeWordEl = wordElements[wordElementIndex] as
|
||||
| HTMLElement
|
||||
| undefined;
|
||||
if (!activeWordEl) return;
|
||||
const afterNewLineEls = wordsEl.getElementsByClassName("afterNewline");
|
||||
|
||||
let wordsWidthBeforeActive = 0;
|
||||
let fullLineWidths = 0;
|
||||
let wordsToRemoveCount = 0;
|
||||
let leadingNewLine = false;
|
||||
let lastAfterNewLineElement = undefined;
|
||||
let widthRemoved = 0;
|
||||
const widthRemovedFromLine: number[] = [];
|
||||
const afterNewlinesNewMargins: number[] = [];
|
||||
const toRemove: HTMLElement[] = [];
|
||||
|
||||
/* remove leading `.afterNewline` elements */
|
||||
for (const child of wordsChildrenArr) {
|
||||
if (child.classList.contains("word")) {
|
||||
// only last leading `.afterNewline` element pushes `.word`s to right
|
||||
if (lastAfterNewLineElement) {
|
||||
widthRemoved += parseFloat(lastAfterNewLineElement.style.marginLeft);
|
||||
}
|
||||
}
|
||||
if (toHide.length > 0) {
|
||||
activeWordElementOffset += toHide.length;
|
||||
toHide.forEach((e) => e.remove());
|
||||
fullWordsWidth -= widthToHide;
|
||||
//need to redefine wordIndex after removing words
|
||||
wordIndex = TestState.activeWordIndex - activeWordElementOffset;
|
||||
const currentMargin = parseInt($("#words").css("margin-left"), 10);
|
||||
$("#words").css("margin-left", `${currentMargin + widthToHide}px`);
|
||||
break;
|
||||
} else if (child.classList.contains("afterNewline")) {
|
||||
toRemove.push(child);
|
||||
leadingNewLine = true;
|
||||
lastAfterNewLineElement = child;
|
||||
}
|
||||
}
|
||||
|
||||
/* get last element to loop over */
|
||||
let lastElementIndex: number;
|
||||
// index of the active word in all #words.children
|
||||
// (which contains .word/.newline/.beforeNewline/.afterNewline elements)
|
||||
const activeWordIndex = wordsChildrenArr.indexOf(activeWordEl);
|
||||
// this will be 0 or 1
|
||||
const newLinesBeforeActiveWord = wordsChildrenArr
|
||||
.slice(0, activeWordIndex)
|
||||
.filter((child) => child.classList.contains("afterNewline")).length;
|
||||
// the second `.afterNewline` after active word is visible during line jump
|
||||
let lastVisibleAfterNewline = afterNewLineEls[
|
||||
newLinesBeforeActiveWord + 1
|
||||
] as HTMLElement | undefined;
|
||||
if (lastVisibleAfterNewline) {
|
||||
lastElementIndex = wordsChildrenArr.indexOf(lastVisibleAfterNewline);
|
||||
} else {
|
||||
lastVisibleAfterNewline = afterNewLineEls[newLinesBeforeActiveWord] as
|
||||
| HTMLElement
|
||||
| undefined;
|
||||
if (lastVisibleAfterNewline) {
|
||||
lastElementIndex = wordsChildrenArr.indexOf(lastVisibleAfterNewline);
|
||||
} else {
|
||||
lastElementIndex = activeWordIndex - 1;
|
||||
}
|
||||
}
|
||||
|
||||
const wordRightMargin = parseFloat(
|
||||
window.getComputedStyle(activeWordEl).marginRight
|
||||
);
|
||||
|
||||
/*calculate .afterNewline & #words new margins + determine elements to remove*/
|
||||
for (let i = 0; i <= lastElementIndex; i++) {
|
||||
const child = wordsChildrenArr[i] as HTMLElement;
|
||||
if (child.classList.contains("word")) {
|
||||
leadingNewLine = false;
|
||||
const wordOuterWidth = $(child).outerWidth(true) ?? 0;
|
||||
const forWordLeft = Math.floor(child.offsetLeft);
|
||||
const forWordWidth = Math.floor(child.offsetWidth);
|
||||
if (forWordLeft < 0 - forWordWidth) {
|
||||
toRemove.push(child);
|
||||
widthRemoved += wordOuterWidth;
|
||||
wordsToRemoveCount++;
|
||||
} else {
|
||||
fullLineWidths += wordOuterWidth;
|
||||
if (i < activeWordIndex) wordsWidthBeforeActive = fullLineWidths;
|
||||
}
|
||||
} else if (child.classList.contains("afterNewline")) {
|
||||
if (leadingNewLine) continue;
|
||||
const nlCharWidth = getNlCharWidth(wordsChildrenArr[i - 3]);
|
||||
fullLineWidths -= nlCharWidth + wordRightMargin;
|
||||
if (i < activeWordIndex) wordsWidthBeforeActive = fullLineWidths;
|
||||
|
||||
if (fullLineWidths < wordsEl.offsetWidth) {
|
||||
afterNewlinesNewMargins.push(fullLineWidths);
|
||||
widthRemovedFromLine.push(widthRemoved);
|
||||
} else {
|
||||
afterNewlinesNewMargins.push(wordsEl.offsetWidth);
|
||||
widthRemovedFromLine.push(widthRemoved);
|
||||
if (i < lastElementIndex) {
|
||||
// for the second .afterNewline after active word
|
||||
afterNewlinesNewMargins.push(wordsEl.offsetWidth);
|
||||
widthRemovedFromLine.push(widthRemoved);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* remove overflown elements */
|
||||
if (toRemove.length > 0) {
|
||||
activeWordElementOffset += wordsToRemoveCount;
|
||||
for (const el of toRemove) el.remove();
|
||||
for (let i = 0; i < widthRemovedFromLine.length; i++) {
|
||||
const afterNewlineEl = afterNewLineEls[i] as HTMLElement;
|
||||
const currentLineIndent =
|
||||
parseFloat(afterNewlineEl.style.marginLeft) || 0;
|
||||
afterNewlineEl.style.marginLeft = `${
|
||||
currentLineIndent - (widthRemovedFromLine[i] ?? 0)
|
||||
}px`;
|
||||
}
|
||||
const currentWordsMargin = parseFloat(wordsEl.style.marginLeft) || 0;
|
||||
wordsEl.style.marginLeft = `${currentWordsMargin + widthRemoved}px`;
|
||||
}
|
||||
|
||||
/* calculate current word width to add to #words margin */
|
||||
let currentWordWidth = 0;
|
||||
if (Config.tapeMode === "letter") {
|
||||
if (TestInput.input.current.length > 0) {
|
||||
const words = document.querySelectorAll("#words .word");
|
||||
const letters = words[wordIndex]?.querySelectorAll("letter");
|
||||
if (!letters) return;
|
||||
const letters = activeWordEl.querySelectorAll("letter");
|
||||
for (let i = 0; i < TestInput.input.current.length; i++) {
|
||||
const letter = letters[i] as HTMLElement;
|
||||
if (
|
||||
|
|
@ -969,20 +1120,34 @@ export function scrollTape(): void {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* change to new #words & .afterNewline margins */
|
||||
const tapeMargin = wordsWrapperWidth * (Config.tapeMargin / 100);
|
||||
const newMargin = tapeMargin - (fullWordsWidth + currentWordWidth);
|
||||
const newMargin = tapeMargin - (wordsWidthBeforeActive + currentWordWidth);
|
||||
|
||||
const jqWords = $(wordsEl);
|
||||
if (Config.smoothLineScroll) {
|
||||
$("#words")
|
||||
.stop(true, false)
|
||||
.animate(
|
||||
{
|
||||
marginLeft: newMargin,
|
||||
},
|
||||
SlowTimer.get() ? 0 : 125
|
||||
);
|
||||
jqWords.stop("leftMargin", true, false).animate(
|
||||
{
|
||||
marginLeft: newMargin,
|
||||
},
|
||||
{
|
||||
duration: SlowTimer.get() ? 0 : 125,
|
||||
queue: "leftMargin",
|
||||
}
|
||||
);
|
||||
jqWords.dequeue("leftMargin");
|
||||
for (let i = 0; i < afterNewlinesNewMargins.length; i++) {
|
||||
const newMargin = afterNewlinesNewMargins[i] ?? 0;
|
||||
$(afterNewLineEls[i] as Element)
|
||||
.stop(true, false)
|
||||
.animate({ marginLeft: newMargin }, SlowTimer.get() ? 0 : 125);
|
||||
}
|
||||
} else {
|
||||
$("#words").css("margin-left", `${newMargin}px`);
|
||||
wordsEl.style.marginLeft = `${newMargin}px`;
|
||||
for (let i = 0; i < afterNewlinesNewMargins.length; i++) {
|
||||
const newMargin = afterNewlinesNewMargins[i] ?? 0;
|
||||
(afterNewLineEls[i] as HTMLElement).style.marginLeft = `${newMargin}px`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1000,38 +1165,85 @@ export function updatePremid(): void {
|
|||
$(".pageTest #premidSecondsLeft").text(Config.time);
|
||||
}
|
||||
|
||||
function removeElementsBeforeWord(
|
||||
lastElementToRemoveIndex: number,
|
||||
wordsChildren?: Element[] | HTMLCollection
|
||||
): number {
|
||||
// remove all elements before lastElementToRemove (included)
|
||||
// and return removed `.word`s count
|
||||
if (!wordsChildren) {
|
||||
wordsChildren = document.getElementById("words")?.children;
|
||||
if (!wordsChildren) return 0;
|
||||
}
|
||||
|
||||
let removedWords = 0;
|
||||
for (let i = 0; i <= lastElementToRemoveIndex; i++) {
|
||||
const child = wordsChildren[i];
|
||||
if (!child || !child.isConnected) continue;
|
||||
if (child.classList.contains("word")) removedWords++;
|
||||
if (!child.classList.contains("smoothScroller")) child.remove();
|
||||
}
|
||||
return removedWords;
|
||||
}
|
||||
|
||||
let currentLinesAnimating = 0;
|
||||
|
||||
export function lineJump(currentTop: number): void {
|
||||
export async function lineJump(
|
||||
currentTop: number,
|
||||
force = false
|
||||
): Promise<void> {
|
||||
const { resolve, promise } = Misc.promiseWithResolvers<void>();
|
||||
|
||||
//last word of the line
|
||||
if (
|
||||
(Config.tapeMode === "off" && currentTestLine > 0) ||
|
||||
(Config.tapeMode !== "off" && currentTestLine >= 0)
|
||||
) {
|
||||
if (currentTestLine > 0 || force) {
|
||||
const hideBound = currentTop;
|
||||
|
||||
const wordIndex = TestState.activeWordIndex - activeWordElementOffset;
|
||||
const toHide: JQuery[] = [];
|
||||
const wordElements = $("#words .word");
|
||||
for (let i = 0; i < wordIndex; i++) {
|
||||
const el = $(wordElements[i] as HTMLElement);
|
||||
if (el.hasClass("hidden")) continue;
|
||||
const forWordTop = Math.floor((el[0] as HTMLElement).offsetTop);
|
||||
if (
|
||||
forWordTop <
|
||||
(Config.tapeMode === "off" ? hideBound - 10 : hideBound + 10)
|
||||
) {
|
||||
toHide.push($($("#words .word")[i] as HTMLElement));
|
||||
// index of the active word in the collection of .word elements
|
||||
const wordElementIndex =
|
||||
TestState.activeWordIndex - activeWordElementOffset;
|
||||
const wordsEl = document.getElementById("words") as HTMLElement;
|
||||
const wordsChildrenArr = [...wordsEl.children];
|
||||
const wordElements = wordsEl.querySelectorAll(".word");
|
||||
const activeWordEl = wordElements[wordElementIndex];
|
||||
if (!activeWordEl) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// index of the active word in all #words.children
|
||||
// (which contains .word/.newline/.beforeNewline/.afterNewline elements)
|
||||
const activeWordIndex = wordsChildrenArr.indexOf(activeWordEl);
|
||||
|
||||
let lastElementToRemoveIndex: number | undefined = undefined;
|
||||
for (let i = activeWordIndex - 1; i >= 0; i--) {
|
||||
const child = wordsChildrenArr[i] as HTMLElement;
|
||||
if (child.classList.contains("hidden")) continue;
|
||||
if (Math.floor(child.offsetTop) < hideBound) {
|
||||
if (child.classList.contains("word")) {
|
||||
lastElementToRemoveIndex = i;
|
||||
break;
|
||||
} else if (child.classList.contains("beforeNewline")) {
|
||||
// set it to .newline but check .beforeNewline.offsetTop
|
||||
// because it's more reliable
|
||||
lastElementToRemoveIndex = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const wordHeight = $(
|
||||
document.querySelector(".word") as Element
|
||||
).outerHeight(true) as number;
|
||||
if (Config.smoothLineScroll && toHide.length > 0) {
|
||||
|
||||
const wordHeight = $(activeWordEl).outerHeight(true) as number;
|
||||
const paceCaretElement = document.querySelector(
|
||||
"#paceCaret"
|
||||
) as HTMLElement;
|
||||
|
||||
if (lastElementToRemoveIndex === undefined) {
|
||||
resolve();
|
||||
} else if (Config.smoothLineScroll) {
|
||||
lineTransition = true;
|
||||
const smoothScroller = $("#words .smoothScroller");
|
||||
if (smoothScroller.length === 0) {
|
||||
$("#words").prepend(
|
||||
wordsEl.insertAdjacentHTML(
|
||||
"afterbegin",
|
||||
`<div class="smoothScroller" style="position: fixed;height:${wordHeight}px;width:100%"></div>`
|
||||
);
|
||||
} else {
|
||||
|
|
@ -1051,13 +1263,11 @@ export function lineJump(currentTop: number): void {
|
|||
$("#words .smoothScroller").remove();
|
||||
}
|
||||
);
|
||||
$("#paceCaret")
|
||||
$(paceCaretElement)
|
||||
.stop(true, false)
|
||||
.animate(
|
||||
{
|
||||
top:
|
||||
(document.querySelector("#paceCaret") as HTMLElement)?.offsetTop -
|
||||
wordHeight,
|
||||
top: paceCaretElement?.offsetTop - wordHeight,
|
||||
},
|
||||
SlowTimer.get() ? 0 : 125
|
||||
);
|
||||
|
|
@ -1066,41 +1276,43 @@ export function lineJump(currentTop: number): void {
|
|||
marginTop: `-${wordHeight * (currentLinesAnimating + 1)}px`,
|
||||
};
|
||||
|
||||
if (Config.tapeMode !== "off") {
|
||||
const wordsWrapperWidth = (
|
||||
document.querySelector("#wordsWrapper") as HTMLElement
|
||||
).offsetWidth;
|
||||
const newMargin = wordsWrapperWidth * (Config.tapeMargin / 100);
|
||||
newCss["marginLeft"] = `${newMargin}px`;
|
||||
}
|
||||
currentLinesAnimating++;
|
||||
$("#words")
|
||||
.stop(true, false)
|
||||
.animate(newCss, SlowTimer.get() ? 0 : 125, () => {
|
||||
const jqWords = $(wordsEl);
|
||||
jqWords.stop("topMargin", true, false).animate(newCss, {
|
||||
duration: SlowTimer.get() ? 0 : 125,
|
||||
queue: "topMargin",
|
||||
complete: () => {
|
||||
currentLinesAnimating = 0;
|
||||
activeWordTop = (
|
||||
document.querySelectorAll("#words .word")?.[
|
||||
wordIndex
|
||||
wordElementIndex
|
||||
] as HTMLElement
|
||||
)?.offsetTop;
|
||||
|
||||
activeWordElementOffset += toHide.length;
|
||||
activeWordElementOffset += removeElementsBeforeWord(
|
||||
lastElementToRemoveIndex,
|
||||
wordsChildrenArr
|
||||
);
|
||||
wordsEl.style.marginTop = "0";
|
||||
lineTransition = false;
|
||||
toHide.forEach((el) => el.remove());
|
||||
$("#words").css("marginTop", "0");
|
||||
});
|
||||
} else {
|
||||
toHide.forEach((el) => el.remove());
|
||||
activeWordElementOffset += toHide.length;
|
||||
$("#paceCaret").css({
|
||||
top:
|
||||
(document.querySelector("#paceCaret") as HTMLElement).offsetTop -
|
||||
wordHeight,
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
jqWords.dequeue("topMargin");
|
||||
} else {
|
||||
activeWordElementOffset += removeElementsBeforeWord(
|
||||
lastElementToRemoveIndex,
|
||||
wordsChildrenArr
|
||||
);
|
||||
paceCaretElement.style.top = `${
|
||||
paceCaretElement.offsetTop - wordHeight
|
||||
}px`;
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
currentTestLine++;
|
||||
updateWordsWrapperHeight();
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
export function setRightToLeft(isEnabled: boolean): void {
|
||||
|
|
|
|||
|
|
@ -102,23 +102,17 @@ const debouncedEvent = debounce(250, () => {
|
|||
void Caret.updatePosition();
|
||||
if (getActivePage() === "test" && !TestUI.resultVisible) {
|
||||
if (Config.tapeMode !== "off") {
|
||||
TestUI.scrollTape();
|
||||
void TestUI.scrollTape();
|
||||
} else {
|
||||
const word =
|
||||
document.querySelectorAll<HTMLElement>("#words .word")[
|
||||
TestState.activeWordIndex - TestUI.activeWordElementOffset - 1
|
||||
];
|
||||
if (word) {
|
||||
const currentTop: number = Math.floor(word.offsetTop);
|
||||
TestUI.lineJump(currentTop);
|
||||
void TestUI.centerActiveLine();
|
||||
}
|
||||
setTimeout(() => {
|
||||
void TestUI.updateWordsInputPosition();
|
||||
if ($("#wordsInput").is(":focus")) {
|
||||
Caret.show();
|
||||
}
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
setTimeout(() => {
|
||||
if ($("#wordsInput").is(":focus")) {
|
||||
Caret.show();
|
||||
}
|
||||
}, 250);
|
||||
});
|
||||
|
||||
const throttledEvent = throttle(250, () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue