diff --git a/frontend/__tests__/controllers/preset-controller.spec.ts b/frontend/__tests__/controllers/preset-controller.spec.ts index ce6b9b5686b5..df10421fc3e4 100644 --- a/frontend/__tests__/controllers/preset-controller.spec.ts +++ b/frontend/__tests__/controllers/preset-controller.spec.ts @@ -12,6 +12,9 @@ describe("PresetController", () => { vi.mock("../../src/ts/test/test-logic", () => ({ restart: vi.fn(), })); + vi.mock("../../src/ts/test/pace-caret", () => ({ + // + })); const dbGetSnapshotMock = vi.spyOn(DB, "getSnapshot"); const configApplyMock = vi.spyOn(UpdateConfig, "apply"); const configSaveFullConfigMock = vi.spyOn( diff --git a/frontend/package.json b/frontend/package.json index b43aa9ed0579..c480709f5e30 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -54,7 +54,7 @@ "eslint-plugin-compat": "6.0.2", "firebase-tools": "13.15.1", "fontawesome-subset": "4.4.0", - "happy-dom": "20.0.0", + "happy-dom": "20.0.2", "madge": "8.0.0", "magic-string": "0.30.17", "normalize.css": "8.0.1", diff --git a/frontend/src/html/popups.html b/frontend/src/html/popups.html index 6ee8facc89e8..d74cd8989c77 100644 --- a/frontend/src/html/popups.html +++ b/frontend/src/html/popups.html @@ -67,6 +67,7 @@ + diff --git a/frontend/src/styles/caret.scss b/frontend/src/styles/caret.scss index 2b8fadc50116..962cc4c7c2a3 100644 --- a/frontend/src/styles/caret.scss +++ b/frontend/src/styles/caret.scss @@ -20,6 +20,14 @@ #caret, #paceCaret { + &.debug { + outline: 1px solid white; + &.hidden { + opacity: 0.5 !important; + display: block !important; + } + } + &.off { width: 0; } @@ -50,7 +58,7 @@ width: 0.5em; border-radius: 0; z-index: -1; - border-radius: calc(var(--roundness) / 4); + border-radius: 0.05em; } &.outline { @@ -58,12 +66,11 @@ animation-name: none; background: transparent; border: 0.05em solid var(--caret-color); - border-radius: calc(var(--roundness) / 4); + border-radius: 0.05em; } &.underline { height: 0.1em; width: 0.5em; - margin-top: 1.2em; } } diff --git a/frontend/src/styles/test.scss b/frontend/src/styles/test.scss index 72631e36668f..5d954f1de99a 100644 --- a/frontend/src/styles/test.scss +++ b/frontend/src/styles/test.scss @@ -246,6 +246,15 @@ } } + &.debugCaret { + outline: 1px solid var(--sub-color); + } + &.debugCaretTarget { + outline: 2px solid red; + } + &.debugCaretTarget2 { + outline: 2px solid yellow; + } &.correct { color: var(--correct-letter-color); animation: var(--correct-letter-animation); diff --git a/frontend/src/ts/controllers/input-controller.ts b/frontend/src/ts/controllers/input-controller.ts index fb5aed906663..07ece1feba5a 100644 --- a/frontend/src/ts/controllers/input-controller.ts +++ b/frontend/src/ts/controllers/input-controller.ts @@ -178,7 +178,7 @@ function backspaceToPrevious(): void { } } - void Caret.updatePosition(); + Caret.updatePosition(); Replay.addReplayEvent("backWord"); } @@ -258,7 +258,7 @@ async function handleSpace(): Promise { dontInsertSpace = false; Replay.addReplayEvent("incorrectLetter", "_"); void TestUI.updateActiveWordLetters(); - void Caret.updatePosition(); + Caret.updatePosition(); } return; } @@ -346,7 +346,7 @@ async function handleSpace(): Promise { } } //end of line wrap - void Caret.updatePosition(); + Caret.updatePosition(); // enable if i decide that auto tab should also work after a space // if ( @@ -565,7 +565,7 @@ async function handleChar( ) { TestInput.input.current = resultingWord; void TestUI.updateActiveWordLetters(); - void Caret.updatePosition(); + Caret.updatePosition(); return; } @@ -768,7 +768,7 @@ async function handleChar( }, 0); if (char !== "\n") { - void Caret.updatePosition(); + Caret.updatePosition(); } } @@ -1377,7 +1377,7 @@ $("#wordsInput").on("input", async (event) => { } void TestUI.updateActiveWordLetters(); - void Caret.updatePosition(); + Caret.updatePosition(); if (!CompositionState.getComposing()) { const keyStroke = event?.originalEvent as InputEvent; if (keyStroke.inputType === "deleteWordBackward") { diff --git a/frontend/src/ts/modals/dev-options.ts b/frontend/src/ts/modals/dev-options.ts index 649d6ecba317..6bf3234f4f24 100644 --- a/frontend/src/ts/modals/dev-options.ts +++ b/frontend/src/ts/modals/dev-options.ts @@ -7,6 +7,7 @@ import { signIn } from "../auth"; import * as Loader from "../elements/loader"; import { update } from "../elements/xp-bar"; import { toggleUserFakeChartData } from "../test/result"; +import { toggleCaretDebug } from "../utils/caret"; let mediaQueryDebugLevel = 0; @@ -89,6 +90,9 @@ async function setup(modalEl: HTMLElement): Promise { ?.addEventListener("click", () => { toggleUserFakeChartData(); }); + modalEl.querySelector(".toggleCaretDebug")?.addEventListener("click", () => { + toggleCaretDebug(); + }); } const modal = new AnimatedModal({ diff --git a/frontend/src/ts/test/caret.ts b/frontend/src/ts/test/caret.ts index ce54653cf042..88ff3063927f 100644 --- a/frontend/src/ts/test/caret.ts +++ b/frontend/src/ts/test/caret.ts @@ -1,284 +1,60 @@ import Config from "../config"; import * as TestInput from "./test-input"; -import * as SlowTimer from "../states/slow-timer"; import * as TestState from "../test/test-state"; -import * as TestWords from "./test-words"; -import { convertRemToPixels } from "../utils/numbers"; -import { splitIntoCharacters, isWordRightToLeft } from "../utils/strings"; -import { safeNumber } from "@monkeytype/util/numbers"; import { subscribe } from "../observables/config-event"; - -export let caretAnimating = true; -const caret = document.querySelector("#caret") as HTMLElement; +import { Caret } from "../utils/caret"; export function stopAnimation(): void { - if (caretAnimating) { - caret.style.animationName = "none"; - caret.style.opacity = "1"; - caretAnimating = false; - } + caret.stopBlinking(); } export function startAnimation(): void { - if (!caretAnimating) { - if (Config.smoothCaret !== "off" && !SlowTimer.get()) { - caret.style.animationName = "caretFlashSmooth"; - } else { - caret.style.animationName = "caretFlashHard"; - } - caretAnimating = true; - } + caret.startBlinking(); } export function hide(): void { - caret.classList.add("hidden"); + caret.hide(); } -function getSpaceWidth(wordElement?: HTMLElement): number { - if (!wordElement) { - const el = document.querySelector("#words .word"); - if (el) { - wordElement = el; - } else { - return 0; - } - } - const wordComputedStyle = window.getComputedStyle(wordElement); - return ( - parseInt(wordComputedStyle.marginRight) + - parseInt(wordComputedStyle.marginLeft) - ); -} - -function getTargetPositionLeft( - fullWidthCaret: boolean, - activeWordElement: HTMLElement, - currentWordNodeList: NodeListOf, - fullWidthCaretWidth: number, - wordLen: number, - inputLen: number, - currentWord?: string -): number { - const invisibleExtraLetters = Config.blindMode || Config.hideExtraLetters; - let result = 0; - - // use word-specific direction if available and different from language direction - const isWordRTL = isWordRightToLeft( - currentWord, - TestState.isLanguageRightToLeft, - TestState.isDirectionReversed - ); - - if (Config.tapeMode === "off") { - let positionOffsetToWord = 0; - - const currentLetter = currentWordNodeList[inputLen]; - const lastWordLetter = currentWordNodeList[wordLen - 1]; - const lastInputLetter = currentWordNodeList[inputLen - 1]; - - if (isWordRTL) { - if (inputLen <= wordLen && currentLetter) { - // at word beginning in zen mode both lengths are 0, but currentLetter is defined "_" - positionOffsetToWord = - currentLetter.offsetLeft + (fullWidthCaret ? 0 : fullWidthCaretWidth); - } else if (!invisibleExtraLetters) { - positionOffsetToWord = - (lastInputLetter?.offsetLeft ?? 0) - - (fullWidthCaret ? fullWidthCaretWidth : 0); - } else { - positionOffsetToWord = - (lastWordLetter?.offsetLeft ?? 0) - - (fullWidthCaret ? fullWidthCaretWidth : 0); - } - } else { - if (inputLen < wordLen && currentLetter) { - positionOffsetToWord = currentLetter.offsetLeft; - } else if (!invisibleExtraLetters) { - positionOffsetToWord = - (lastInputLetter?.offsetLeft ?? 0) + - (lastInputLetter?.offsetWidth ?? 0); - } else { - positionOffsetToWord = - (lastWordLetter?.offsetLeft ?? 0) + - (lastWordLetter?.offsetWidth ?? 0); - } - } - result = activeWordElement.offsetLeft + positionOffsetToWord; - } else { - const wordsWrapperWidth = - $(document.querySelector("#wordsWrapper") as HTMLElement).width() ?? 0; - const tapeMargin = - wordsWrapperWidth * - (isWordRTL ? 1 - Config.tapeMargin / 100 : Config.tapeMargin / 100); - - result = - tapeMargin - (fullWidthCaret && isWordRTL ? fullWidthCaretWidth : 0); - - if (Config.tapeMode === "word" && inputLen > 0) { - let currentWordWidth = 0; - let lastPositiveLetterWidth = 0; - for (let i = 0; i < inputLen; i++) { - if (invisibleExtraLetters && i >= wordLen) break; - const letterOuterWidth = - $(currentWordNodeList[i] as Element).outerWidth(true) ?? 0; - currentWordWidth += letterOuterWidth; - if (letterOuterWidth > 0) lastPositiveLetterWidth = letterOuterWidth; - } - // if current letter has zero width move the caret to previous positive width letter - if ($(currentWordNodeList[inputLen] as Element).outerWidth(true) === 0) - currentWordWidth -= lastPositiveLetterWidth; - if (isWordRTL) currentWordWidth *= -1; - result += currentWordWidth; - } - } - - return result; +export function resetPosition(): void { + caret.clearMargins(); + caret.stopAllAnimations(); + caret.goTo({ + wordIndex: 0, + letterIndex: 0, + isLanguageRightToLeft: TestState.isLanguageRightToLeft, + isDirectionReversed: TestState.isDirectionReversed, + animate: false, + }); } -function getLetterWidth( - currentLetter: HTMLElement | undefined, - activeWordEl: HTMLElement, - wordLength: number, - inputLength: number, - currentWordNodeList: NodeListOf -): number { - let letterWidth = currentLetter?.offsetWidth; - if (letterWidth === undefined || wordLength === 0) { - // at word beginning in zen mode current letter is defined "_" but wordLen is 0 - letterWidth = getSpaceWidth(activeWordEl); - } else if (letterWidth === 0) { - // current letter is a zero-width character e.g, diacritics) - for (let i = inputLength; i >= 0; i--) { - letterWidth = (currentWordNodeList[i] as HTMLElement)?.offsetWidth; - if (letterWidth) break; - } - } - return letterWidth ?? 0; +export function updatePosition(noAnim = false): void { + caret.goTo({ + wordIndex: TestState.activeWordIndex, + letterIndex: TestInput.input.current.length, + isLanguageRightToLeft: TestState.isLanguageRightToLeft, + isDirectionReversed: TestState.isDirectionReversed, + animate: Config.smoothCaret !== "off" && !noAnim, + }); } -export async function updatePosition(noAnim = false): Promise { - const caretComputedStyle = window.getComputedStyle(caret); - const caretWidth = parseInt(caretComputedStyle.width) || 0; - const caretHeight = parseInt(caretComputedStyle.height) || 0; - - const fullWidthCaret = ["block", "outline", "underline"].includes( - Config.caretStyle - ); - - let wordLen = splitIntoCharacters(TestWords.words.getCurrent()).length; - const inputLen = splitIntoCharacters(TestInput.input.current).length; - if (Config.mode === "zen") wordLen = inputLen; - const activeWordEl = document.querySelector( - `#words .word[data-wordindex='${TestState.activeWordIndex}']` - ); - if (!activeWordEl) return; - - const currentWordNodeList = - activeWordEl.querySelectorAll("letter"); - if (!currentWordNodeList?.length) return; - - const currentLetter = currentWordNodeList[inputLen]; - const lastInputLetter = currentWordNodeList[inputLen - 1]; - const lastWordLetter = currentWordNodeList[wordLen - 1]; - - // in blind mode, and hide extra letters, extra letters have zero offsets - // offsetHeight is the same for all visible letters - // so is offsetTop (for same line letters) - const letterHeight = - (safeNumber(currentLetter?.offsetHeight) ?? 0) || - (safeNumber(lastInputLetter?.offsetHeight) ?? 0) || - (safeNumber(lastWordLetter?.offsetHeight) ?? 0) || - Config.fontSize * convertRemToPixels(1); - - const letterPosTop = - currentLetter?.offsetTop ?? - lastInputLetter?.offsetTop ?? - lastWordLetter?.offsetTop ?? - 0; - const diff = letterHeight - caretHeight; - let newTop = activeWordEl.offsetTop + letterPosTop + diff / 2; - if (Config.caretStyle === "underline") { - newTop = activeWordEl.offsetTop + letterPosTop - caretHeight / 2; - } - - const letterWidth = getLetterWidth( - currentLetter, - activeWordEl, - wordLen, - inputLen, - currentWordNodeList - ); - - // in zen mode, use the input content to determine word direction - const currentWordForDirection = - Config.mode === "zen" - ? TestInput.input.current - : TestWords.words.getCurrent(); - - const letterPosLeft = getTargetPositionLeft( - fullWidthCaret, - activeWordEl, - currentWordNodeList, - letterWidth, - wordLen, - inputLen, - currentWordForDirection - ); - const newLeft = letterPosLeft - (fullWidthCaret ? 0 : caretWidth / 2); - - const jqcaret = $(caret); - - jqcaret.css("display", "block"); //for some goddamn reason adding width animation sets the display to none ???????? - - const animation: { top: number; left: number; width?: string } = { - top: newTop - TestState.lineScrollDistance, - left: newLeft, - }; - - if (fullWidthCaret) { - animation.width = `${letterWidth}px`; - } - - const smoothCaretSpeed = - Config.smoothCaret === "off" - ? 0 - : Config.smoothCaret === "slow" - ? 150 - : Config.smoothCaret === "medium" - ? 100 - : Config.smoothCaret === "fast" - ? 85 - : 0; - - jqcaret - .stop(true, false) - .animate(animation, SlowTimer.get() || noAnim ? 0 : smoothCaretSpeed); -} - -function updateStyle(): void { - caret.style.width = ""; - caret.classList.remove( - ...["off", "default", "underline", "outline", "block", "carrot", "banana"] - ); - caret.classList.add(Config.caretStyle); -} +export const caret = new Caret( + document.getElementById("caret") as HTMLElement, + Config.caretStyle +); subscribe((eventKey) => { if (eventKey === "caretStyle") { - updateStyle(); - void updatePosition(true); + caret.setStyle(Config.caretStyle); + updatePosition(true); } if (eventKey === "smoothCaret") { - if (Config.smoothCaret === "off") { - caret.style.animationName = "caretFlashHard"; - } else { - caret.style.animationName = "caretFlashSmooth"; - } + caret.updateBlinkingAnimation(); } }); export function show(noAnim = false): void { - caret.classList.remove("hidden"); - void updatePosition(noAnim); + caret.show(); + updatePosition(noAnim); startAnimation(); } diff --git a/frontend/src/ts/test/pace-caret.ts b/frontend/src/ts/test/pace-caret.ts index 196d623e2303..77c96157ff40 100644 --- a/frontend/src/ts/test/pace-caret.ts +++ b/frontend/src/ts/test/pace-caret.ts @@ -1,13 +1,11 @@ import * as TestWords from "./test-words"; import Config from "../config"; import * as DB from "../db"; -import * as SlowTimer from "../states/slow-timer"; import * as Misc from "../utils/misc"; import * as TestState from "./test-state"; import * as ConfigEvent from "../observables/config-event"; -import { convertRemToPixels } from "../utils/numbers"; import { getActiveFunboxes } from "./funbox/list"; -import { isWordRightToLeft } from "../utils/strings"; +import { Caret } from "../utils/caret"; type Settings = { wpm: number; @@ -22,6 +20,11 @@ type Settings = { export let settings: Settings | null = null; +export const caret = new Caret( + document.getElementById("paceCaret") as HTMLElement, + Config.paceCaretStyle +); + let lastTestWpm = 0; export function setLastTestWpm(wpm: number): void { @@ -33,42 +36,25 @@ export function setLastTestWpm(wpm: number): void { } } -async function resetCaretPosition(): Promise { +export function resetCaretPosition(): void { if (Config.paceCaret === "off" && !TestState.isPaceRepeat) return; - if (!$("#paceCaret").hasClass("hidden")) { - $("#paceCaret").addClass("hidden"); - } if (Config.mode === "zen") return; - const caret = $("#paceCaret"); - const firstLetter = document - ?.querySelector("#words .word") - ?.querySelector("letter") as HTMLElement; - - const firstLetterHeight = $(firstLetter).height(); - - if (firstLetter === undefined || firstLetterHeight === undefined) return; - - const currentWord = TestWords.words.get(settings?.currentWordIndex ?? 0); - - const isWordRTL = isWordRightToLeft( - currentWord, - TestState.isLanguageRightToLeft, - TestState.isDirectionReversed - ); - - caret.stop(true, true).animate( - { - top: firstLetter.offsetTop - firstLetterHeight / 4, - left: firstLetter.offsetLeft + (isWordRTL ? firstLetter.offsetWidth : 0), - }, - 0, - "linear" - ); + caret.hide(); + caret.stopAllAnimations(); + caret.clearMargins(); + + caret.goTo({ + wordIndex: 0, + letterIndex: 0, + isLanguageRightToLeft: TestState.isLanguageRightToLeft, + isDirectionReversed: TestState.isDirectionReversed, + animate: false, + }); } export async function init(): Promise { - $("#paceCaret").addClass("hidden"); + caret.hide(); const mode2 = Misc.getMode2(Config, TestWords.currentQuote); let wpm = 0; if (Config.paceCaret === "pb") { @@ -137,29 +123,65 @@ export async function init(): Promise { spc: spc, correction: 0, currentWordIndex: 0, - currentLetterIndex: -1, + currentLetterIndex: 0, wordsStatus: {}, timeout: null, }; - await resetCaretPosition(); } -export async function update(expectedStepEnd: number): Promise { +export async function update(duration: number): Promise { if (settings === null || !TestState.isActive || TestState.resultVisible) { return; } - // if ($("#paceCaret").hasClass("hidden")) { - // $("#paceCaret").removeClass("hidden"); - // } + + if (caret.isHidden()) { + caret.show(); + } + + incrementLetterIndex(); + + try { + caret.goTo({ + wordIndex: settings.currentWordIndex, + letterIndex: settings.currentLetterIndex, + isLanguageRightToLeft: TestState.isLanguageRightToLeft, + isDirectionReversed: TestState.isDirectionReversed, + animate: true, + animationOptions: { + duration, + easing: "linear", + }, + }); + settings.timeout = setTimeout(() => { + update((settings?.spc ?? 0) * 1000).catch(() => { + settings = null; + }); + }, duration); + } catch (e) { + console.error(e); + caret.hide(); + return; + } +} + +export function reset(): void { + if (settings?.timeout !== null && settings?.timeout !== undefined) { + clearTimeout(settings.timeout); + } + settings = null; +} + +function incrementLetterIndex(): void { + if (settings === null) return; try { settings.currentLetterIndex++; if ( settings.currentLetterIndex >= - TestWords.words.get(settings.currentWordIndex).length + TestWords.words.get(settings.currentWordIndex).length + 1 ) { //go to the next word - settings.currentLetterIndex = -1; + settings.currentLetterIndex = 0; settings.currentWordIndex++; } if (!Config.blindMode) { @@ -182,7 +204,7 @@ export async function update(expectedStepEnd: number): Promise { TestWords.words.get(settings.currentWordIndex).length ) { //go to the next word - settings.currentLetterIndex = -1; + settings.currentLetterIndex = 0; settings.currentWordIndex++; } settings.correction--; @@ -192,117 +214,10 @@ export async function update(expectedStepEnd: number): Promise { } catch (e) { //out of words settings = null; - $("#paceCaret").addClass("hidden"); + console.log("pace caret out of words"); + caret.hide(); return; } - - try { - const caret = $("#paceCaret"); - let currentLetter; - let newTop; - let newLeft; - try { - const word = document.querySelector( - `#words .word[data-wordindex='${settings.currentWordIndex}']` - ); - - if (!word) { - throw new Error("Word element not found"); - } - - if (settings.currentLetterIndex === -1) { - currentLetter = word.querySelectorAll("letter")[0] as HTMLElement; - } else { - currentLetter = word.querySelectorAll("letter")[ - settings.currentLetterIndex - ] as HTMLElement; - } - - const currentLetterHeight = $(currentLetter).height(), - currentLetterWidth = $(currentLetter).width(), - caretWidth = caret.width(); - - if ( - currentLetterHeight === undefined || - currentLetterWidth === undefined || - caretWidth === undefined - ) { - throw new Error( - "Undefined current letter height, width or caret width." - ); - } - - const currentWord = TestWords.words.get(settings.currentWordIndex); - - const isWordRTL = isWordRightToLeft( - currentWord, - TestState.isLanguageRightToLeft, - TestState.isDirectionReversed - ); - - newTop = - word.offsetTop + - currentLetter.offsetTop - - Config.fontSize * convertRemToPixels(1) * 0.1; - if (settings.currentLetterIndex === -1) { - newLeft = - word.offsetLeft + - currentLetter.offsetLeft - - caretWidth / 2 + - (isWordRTL ? currentLetterWidth : 0); - } else { - newLeft = - word.offsetLeft + - currentLetter.offsetLeft - - caretWidth / 2 + - (isWordRTL ? 0 : currentLetterWidth); - } - caret.removeClass("hidden"); - } catch (e) { - caret.addClass("hidden"); - } - - const duration = expectedStepEnd - performance.now(); - - if (newTop !== undefined) { - $("#paceCaret").css({ - top: newTop - TestState.lineScrollDistance, - }); - - if (Config.smoothCaret !== "off") { - caret.stop(true, true).animate( - { - left: newLeft, - }, - SlowTimer.get() ? 0 : duration, - "linear" - ); - } else { - caret.stop(true, true).animate( - { - left: newLeft, - }, - 0, - "linear" - ); - } - } - settings.timeout = setTimeout(() => { - update(expectedStepEnd + (settings?.spc ?? 0) * 1000).catch(() => { - settings = null; - }); - }, duration); - } catch (e) { - console.error(e); - $("#paceCaret").addClass("hidden"); - } -} - -export function reset(): void { - if (settings?.timeout !== null && settings?.timeout !== undefined) { - clearTimeout(settings.timeout); - } - settings = null; } export function handleSpace(correct: boolean, currentWord: string): void { @@ -328,24 +243,12 @@ export function handleSpace(correct: boolean, currentWord: string): void { } export function start(): void { - void update(performance.now() + (settings?.spc ?? 0) * 1000); -} - -function updateStyle(): void { - const paceCaret = $("#paceCaret"); - paceCaret.removeClass([ - "off", - "default", - "underline", - "outline", - "block", - "carrot", - "banana", - ]); - paceCaret.addClass(Config.paceCaretStyle); + void update((settings?.spc ?? 0) * 1000); } ConfigEvent.subscribe((eventKey) => { if (eventKey === "paceCaret") void init(); - if (eventKey === "paceCaretStyle") updateStyle(); + if (eventKey === "paceCaretStyle") { + caret.setStyle(Config.paceCaretStyle); + } }); diff --git a/frontend/src/ts/test/test-state.ts b/frontend/src/ts/test/test-state.ts index cc7c35d4fdcc..2cebf3531ea5 100644 --- a/frontend/src/ts/test/test-state.ts +++ b/frontend/src/ts/test/test-state.ts @@ -10,7 +10,6 @@ export let bailedOut = false; export let selectedQuoteId = 1; export let activeWordIndex = 0; export let testInitSuccess = true; -export let lineScrollDistance = 0; export let isLanguageRightToLeft = false; export let isDirectionReversed = false; export let testRestarting = false; @@ -60,10 +59,6 @@ export function setTestInitSuccess(tf: boolean): void { testInitSuccess = tf; } -export function setLineScrollDistance(val: number): void { - lineScrollDistance = val; -} - export function setIsLanguageRightToLeft(rtl: boolean): void { isLanguageRightToLeft = rtl; } diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index 778c5711f030..434b86b187b4 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -23,6 +23,7 @@ import { TimerColor, TimerOpacity } from "@monkeytype/schemas/configs"; import { convertRemToPixels } from "../utils/numbers"; import { findSingleActiveFunboxWithFunction } from "./funbox/list"; import * as TestState from "./test-state"; +import * as PaceCaret from "./pace-caret"; import { requestDebouncedAnimationFrame } from "../utils/debounced-animation-frame"; const debouncedZipfCheck = debounce(250, async () => { @@ -134,9 +135,11 @@ export function reset(): void { currentTestLine = 0; } -export function focusWords(): void { +export function focusWords(force = false): void { const wordsInput = document.querySelector("#wordsInput"); - wordsInput?.blur(); + if (force) { + wordsInput?.blur(); + } wordsInput?.focus({ preventScroll: true, }); @@ -462,11 +465,16 @@ function updateWordWrapperClasses(): void { updateWordsWidth(); updateWordsWrapperHeight(true); - updateWordsMargin(updateWordsInputPosition, []); + updateWordsMargin(); + updateWordsInputPosition(); + void updateHintsPositionDebounced(); + Caret.updatePosition(); } export function showWords(): void { - $("#words").empty(); + const words = $("#words"); + + words.empty(); if (Config.mode === "zen") { appendEmptyWordElement(); @@ -475,14 +483,12 @@ export function showWords(): void { for (let i = 0; i < TestWords.words.length; i++) { wordsHTML += buildWordHTML(TestWords.words.get(i), i); } - $("#words").html(wordsHTML); + words.html(wordsHTML); } updateActiveElement(undefined, true); updateWordWrapperClasses(); - setTimeout(() => { - void Caret.updatePosition(); - }, 125); + PaceCaret.resetCaretPosition(); } export function appendEmptyWordElement( @@ -645,42 +651,20 @@ export function updateWordsWrapperHeight(force = false): void { outOfFocusEl.style.maxHeight = wordHeight * 3 + "px"; } -function updateWordsMargin( - afterCompleteFn: (...args: T) => void, - args: T -): void { - const afterComplete = (): void => { - afterCompleteFn(...args); - void updateHintsPositionDebounced(); - }; +function updateWordsMargin(): void { if (Config.tapeMode !== "off") { - void scrollTape(true, afterComplete); + void scrollTape(true); } else { const wordsEl = document.getElementById("words") as HTMLElement; + + $(wordsEl).stop(true, false); + const afterNewlineEls = wordsEl.querySelectorAll(".afterNewline"); - if (Config.smoothLineScroll) { - const jqWords = $(wordsEl); - jqWords.stop("leftMargin", true, false).animate( - { - marginLeft: 0, - }, - { - duration: SlowTimer.get() ? 0 : 125, - queue: "leftMargin", - complete: afterComplete, - } - ); - jqWords.dequeue("leftMargin"); - $(afterNewlineEls) - .stop(true, false) - .animate({ marginLeft: 0 }, SlowTimer.get() ? 0 : 125); - } else { - wordsEl.style.marginLeft = `0`; - for (const afterNewline of afterNewlineEls) { - afterNewline.style.marginLeft = `0`; - } - afterComplete(); + wordsEl.style.marginLeft = `0`; + wordsEl.style.marginTop = `0`; + for (const afterNewline of afterNewlineEls) { + afterNewline.style.marginLeft = `0`; } } } @@ -892,10 +876,7 @@ function getNlCharWidth( return nlChar.offsetWidth + letterMargin; } -export async function scrollTape( - noRemove = false, - afterCompleteFn?: () => void -): Promise { +export async function scrollTape(noAnimation = false): Promise { if (ActivePage.get() !== "test" || TestState.resultVisible) return; await centeringActiveLine; @@ -1013,7 +994,7 @@ export async function scrollTape( } /* remove overflown elements */ - if (toRemove.length > 0 && !noRemove) { + if (toRemove.length > 0) { for (const el of toRemove) el.remove(); for (let i = 0; i < widthRemovedFromLine.length; i++) { const afterNewlineEl = afterNewLineEls[i] as HTMLElement; @@ -1026,6 +1007,8 @@ export async function scrollTape( if (isTestRightToLeft) widthRemoved *= -1; const currentWordsMargin = parseFloat(wordsEl.style.marginLeft) || 0; wordsEl.style.marginLeft = `${currentWordsMargin + widthRemoved}px`; + Caret.caret.handleTapeWordsRemoved(widthRemoved); + PaceCaret.caret.handleTapeWordsRemoved(widthRemoved); } /* calculate current word width to add to #words margin */ @@ -1052,30 +1035,40 @@ export async function scrollTape( } /* change to new #words & .afterNewline margins */ - let newMargin = - wordsWrapperWidth * (Config.tapeMargin / 100) - - wordsWidthBeforeActive - - currentWordWidth; - if (isTestRightToLeft) newMargin = wordRightMargin - newMargin; + const tapeMarginPx = wordsWrapperWidth * (Config.tapeMargin / 100); + let newMarginOffset = wordsWidthBeforeActive + currentWordWidth; + let newMargin = tapeMarginPx - newMarginOffset; + if (isTestRightToLeft) { + newMarginOffset *= -1; + newMargin = wordRightMargin - newMargin; + } + + const duration = noAnimation ? 0 : SlowTimer.get() ? 0 : 125; + const caretScrollOptions = { + newValue: newMarginOffset * -1, + duration: Config.smoothLineScroll ? duration : 0, + }; + + Caret.caret.handleTapeScroll(caretScrollOptions); + PaceCaret.caret.handleTapeScroll(caretScrollOptions); - const jqWords = $(wordsEl); if (Config.smoothLineScroll) { - jqWords.stop("leftMargin", true, false).animate( + const jqWords = $(wordsEl).stop("marginLeft", true, false); + jqWords.animate( { marginLeft: newMargin, }, { - duration: SlowTimer.get() ? 0 : 125, - queue: "leftMargin", - complete: afterCompleteFn, + duration, + queue: "marginLeft", } ); - jqWords.dequeue("leftMargin"); + jqWords.dequeue("marginLeft"); 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); + .animate({ marginLeft: newMargin }, duration); } } else { wordsEl.style.marginLeft = `${newMargin}px`; @@ -1083,7 +1076,6 @@ export async function scrollTape( const newMargin = afterNewlinesNewMargins[i] ?? 0; (afterNewLineEls[i] as HTMLElement).style.marginLeft = `${newMargin}px`; } - if (afterCompleteFn) afterCompleteFn(); } } @@ -1113,7 +1105,7 @@ function removeTestElements(lastElementIndexToRemove: number): void { } } -let currentLinesAnimating = 0; +let currentLinesJumping = 0; export async function lineJump( currentTop: number, @@ -1153,58 +1145,48 @@ export async function lineJump( } } - const wordHeight = $(activeWordEl).outerHeight(true) as number; - const paceCaretElement = document.querySelector( - "#paceCaret" - ) as HTMLElement; - if (lastElementIndexToRemove === undefined) { resolve(); - } else if (Config.smoothLineScroll) { - lineTransition = true; + currentTestLine++; + updateWordsWrapperHeight(); + return promise; + } - $(paceCaretElement) - .stop(true, false) - .animate( - { - top: paceCaretElement?.offsetTop - wordHeight, - }, - SlowTimer.get() ? 0 : 125 - ); + currentLinesJumping++; - const scrollDistance = TestState.lineScrollDistance + wordHeight; - TestState.setLineScrollDistance(scrollDistance); - currentLinesAnimating++; - const newCss: Record = { - marginTop: `-${wordHeight * currentLinesAnimating}px`, - }; + const wordHeight = $(activeWordEl).outerHeight(true) as number; + const newMarginTop = -1 * wordHeight * currentLinesJumping; + const duration = SlowTimer.get() ? 0 : 125; + const caretLineJumpOptions = { + newMarginTop, + duration: Config.smoothLineScroll ? duration : 0, + }; + Caret.caret.handleLineJump(caretLineJumpOptions); + PaceCaret.caret.handleLineJump(caretLineJumpOptions); + + if (Config.smoothLineScroll) { + lineTransition = true; const jqWords = $(wordsEl); - jqWords.stop("topMargin", true, false).animate(newCss, { - duration: SlowTimer.get() ? 0 : 125, - queue: "topMargin", - step: (now, fx) => { - const completionRate = (now - fx.start) / (fx.end - fx.start); - TestState.setLineScrollDistance( - scrollDistance * (1 - completionRate) - ); - }, - complete: () => { - currentLinesAnimating = 0; - TestState.setLineScrollDistance(0); - activeWordTop = activeWordEl.offsetTop; - removeTestElements(lastElementIndexToRemove); - wordsEl.style.marginTop = "0"; - lineTransition = false; - resolve(); - }, - }); - jqWords.dequeue("topMargin"); + jqWords.stop("marginTop", true, false).animate( + { marginTop: `${newMarginTop}px` }, + { + duration, + queue: "marginTop", + complete: () => { + currentLinesJumping = 0; + activeWordTop = activeWordEl.offsetTop; + removeTestElements(lastElementIndexToRemove); + wordsEl.style.marginTop = "0"; + lineTransition = false; + resolve(); + }, + } + ); + jqWords.dequeue("marginTop"); } else { + currentLinesJumping = 0; removeTestElements(lastElementIndexToRemove); - paceCaretElement.style.top = `${ - paceCaretElement.offsetTop - wordHeight - }px`; resolve(); } } diff --git a/frontend/src/ts/ui.ts b/frontend/src/ts/ui.ts index 0b9c96e3a82b..af93a4c30370 100644 --- a/frontend/src/ts/ui.ts +++ b/frontend/src/ts/ui.ts @@ -108,6 +108,7 @@ const debouncedEvent = debounce(250, () => { setTimeout(() => { TestUI.updateWordsInputPosition(); TestUI.focusWords(); + Caret.show(); }, 250); } }); diff --git a/frontend/src/ts/utils/caret.ts b/frontend/src/ts/utils/caret.ts new file mode 100644 index 000000000000..f06db5ead920 --- /dev/null +++ b/frontend/src/ts/utils/caret.ts @@ -0,0 +1,541 @@ +import { CaretStyle } from "@monkeytype/schemas/configs"; +import Config from "../config"; +import * as SlowTimer from "../states/slow-timer"; +import * as TestWords from "../test/test-words"; +import { getTotalInlineMargin } from "./misc"; +import { isWordRightToLeft } from "./strings"; +import { requestDebouncedAnimationFrame } from "./debounced-animation-frame"; + +const wordsCache = document.querySelector("#words") as HTMLElement; +const wordsWrapperCache = document.querySelector( + "#wordsWrapper" +) as HTMLElement; + +let lockedMainCaretInTape = true; +let caretDebug = false; + +export function toggleCaretDebug(): void { + caretDebug = !caretDebug; + if (!caretDebug) { + for (const l of document.querySelectorAll(".word letter")) { + l.classList.remove("debugCaret"); + l.classList.remove("debugCaretTarget"); + l.classList.remove("debugCaretTarget2"); + } + } else { + for (const l of document.querySelectorAll(".word letter")) { + l.classList.add("debugCaret"); + } + } +} + +export class Caret { + private id: string; + private element: HTMLElement; + private style: CaretStyle = "default"; + private readyToResetMarginTop: boolean = false; + private readyToResetMarginLeft: boolean = false; + private isMainCaret: boolean = false; + private cumulativeTapeMarginCorrection: number = 0; + + constructor(element: HTMLElement, style: CaretStyle) { + this.id = element.id; + this.element = element; + this.setStyle(style); + if (this.id === "caret") { + this.isMainCaret = true; + } + } + + public setStyle(style: CaretStyle): void { + this.style = style; + this.element.style.width = ""; + this.element.classList.remove( + ...["off", "default", "underline", "outline", "block", "carrot", "banana"] + ); + this.element.classList.add(style); + } + + public getElement(): HTMLElement { + return this.element; + } + + public show(): void { + this.element.classList.remove("hidden"); + this.element.style.display = ""; + } + + public hide(): void { + this.element.classList.add("hidden"); + } + + public isHidden(): boolean { + return this.element.classList.contains("hidden"); + } + + public getWidth(): number { + return this.element.offsetWidth; + } + + public resetWidth(): void { + this.element.style.width = ""; + } + + public getHeight(): number { + if (!this.isHidden()) { + return this.element.offsetHeight; + } + + let height = 0; + this.show(); + height = this.element.offsetHeight; + this.hide(); + return height; + } + + public isFullWidth(): boolean { + return ["block", "outline", "underline"].includes(this.style); + } + + public setPosition(options: { + left: number; + top: number; + width?: number; + }): void { + this.element.style.left = `${options.left}px`; + this.element.style.top = `${options.top}px`; + if (options.width !== undefined) { + this.element.style.width = `${options.width}px`; + } + } + + public startBlinking(): void { + if (Config.smoothCaret !== "off" && !SlowTimer.get()) { + this.element.style.animationName = "caretFlashSmooth"; + } else { + this.element.style.animationName = "caretFlashHard"; + } + } + + public stopBlinking(): void { + this.element.style.animationName = "none"; + this.element.style.opacity = "1"; + } + + public updateBlinkingAnimation(): void { + if (Config.smoothCaret === "off") { + this.element.style.animationName = "caretFlashHard"; + } else { + this.element.style.animationName = "caretFlashSmooth"; + } + } + + public stopAllAnimations(): void { + $(this.element).stop(true, false); + } + + public clearMargins(): void { + this.element.style.marginTop = ""; + this.element.style.marginLeft = ""; + this.readyToResetMarginTop = false; + this.readyToResetMarginLeft = false; + this.cumulativeTapeMarginCorrection = 0; + } + + public handleTapeWordsRemoved(widthRemoved: number): void { + this.cumulativeTapeMarginCorrection += widthRemoved; + } + + public handleTapeScroll(options: { + newValue: number; + duration: number; + }): void { + if (this.isMainCaret && lockedMainCaretInTape) return; + this.readyToResetMarginLeft = false; + + /** + * If we didn't reset marginLeft, then options.newValue gives the correct caret + * position by adding up the widths of all typed characters. But since we reset + * caret.style.marginLeft during the test, the caret ends up too far left. + * + * To fix this, we track how much marginLeft we've reset so far (cumulativeTapeMarginCorrection), + * and subtract it from options.newValue to get the correct newMarginLeft. + */ + const newMarginLeft = + options.newValue - this.cumulativeTapeMarginCorrection; + + if (options.duration === 0) { + $(this.element).stop("marginLeft", true, false).css({ + marginLeft: newMarginLeft, + }); + this.readyToResetMarginLeft = true; + return; + } + + $(this.element) + .stop("marginLeft", true, false) + .animate( + { + marginLeft: newMarginLeft, + }, + { + // this NEEDS to be the same duration as the + // line scroll otherwise it will look weird + duration: options.duration, + queue: "marginLeft", + complete: () => { + this.readyToResetMarginLeft = true; + }, + } + ); + $(this.element).dequeue("marginLeft"); + } + + public handleLineJump(options: { + newMarginTop: number; + duration: number; + }): void { + // smooth line jump works by animating the words top margin. + // to sync the carets to the lines, we need to do the same here. + + // using a readyToResetMarginTop flag here to make sure the animation + // is fully finished before we reset the marginTop to 0 + + // making sure to use a separate animation queue so that it doesnt + // affect the position animations + if (this.isMainCaret && options.duration === 0) return; + this.readyToResetMarginTop = false; + + if (options.duration === 0) { + $(this.element).stop("marginTop", true, false).css({ + marginTop: options.newMarginTop, + }); + this.readyToResetMarginTop = true; + return; + } + + $(this.element) + .stop("marginTop", true, false) + .animate( + { + marginTop: options.newMarginTop, + }, + { + // this NEEDS to be the same duration as the + // line scroll otherwise it will look weird + duration: options.duration, + queue: "marginTop", + complete: () => { + this.readyToResetMarginTop = true; + }, + } + ); + $(this.element).dequeue("marginTop"); + } + + public animatePosition(options: { + left: number; + top: number; + duration?: number; + easing?: string; + width?: number; + }): void { + const smoothCaretSpeed = + Config.smoothCaret === "off" + ? 0 + : Config.smoothCaret === "slow" + ? 150 + : Config.smoothCaret === "medium" + ? 100 + : Config.smoothCaret === "fast" + ? 85 + : 0; + + const finalDuration = SlowTimer.get() + ? 0 + : options.duration ?? smoothCaretSpeed; + + const animation: Record = { + left: options.left, + top: options.top, + }; + + if (options.width !== undefined) { + animation["width"] = options.width; + } + + $(this.element) + .stop("pos", true, false) + .animate(animation, { + duration: finalDuration, + easing: options.easing ?? "swing", + queue: "pos", + }); + $(this.element).dequeue("pos"); + } + + public goTo(options: { + wordIndex: number; + letterIndex: number; + isLanguageRightToLeft: boolean; + isDirectionReversed: boolean; + animate?: boolean; + animationOptions?: { + duration?: number; + easing?: string; + }; + }): void { + if (this.style === "off") return; + requestDebouncedAnimationFrame(`caret.${this.id}.goTo`, () => { + const word = wordsCache.querySelector( + `.word[data-wordindex="${options.wordIndex}"]` + ); + const letters = word?.querySelectorAll("letter") ?? []; + const wordText = TestWords.words.get(options.wordIndex); + + // caret can be either on the left side of the target letter or the right + // we stick to the left side unless we are on the last letter or beyond + // then we switch to the right side + + // we also clamp the letterIndex to be within the range of actual letters + // anything beyond just goes to the edge of the word + let side: "beforeLetter" | "afterLetter" = "beforeLetter"; + if (options.letterIndex >= letters.length) { + side = "afterLetter"; + options.letterIndex = letters.length - 1; + } + if (options.letterIndex < 0) { + options.letterIndex = 0; + } + + let letter = + word?.querySelectorAll("letter")[options.letterIndex]; + + if (word === null || letter === undefined) { + return; + } + + if (caretDebug) { + if (this.id === "paceCaret") { + for (const l of document.querySelectorAll(".word letter")) { + l.classList.remove("debugCaretTarget"); + l.classList.remove("debugCaretTarget2"); + l.classList.add("debugCaret"); + } + letter?.classList.add("debugCaretTarget"); + this.element.classList.add("debug"); + } + } else { + this.element.classList.remove("debug"); + } + + const { left, top, width } = this.getTargetPositionAndWidth({ + word, + letter, + wordText, + side, + isLanguageRightToLeft: options.isLanguageRightToLeft, + isDirectionReversed: options.isDirectionReversed, + }); + + // animation uses inline styles, so its fine to read inline here instead + // of computed styles which would be much slower + + // if the margin animation finished, we reset it here by removing the margin + // and offsetting the top by the same amount + let currentMarginTop = parseFloat(this.element.style.marginTop || "0"); + if (this.readyToResetMarginTop) { + this.readyToResetMarginTop = false; + const currentTop = parseFloat(this.element.style.top || "0"); + + $(this.element).css({ + marginTop: 0, + top: currentTop + currentMarginTop, + }); + currentMarginTop = 0; + } + + // same for marginLeft + let currentMarginLeft = parseFloat(this.element.style.marginLeft || "0"); + if (this.readyToResetMarginLeft) { + this.readyToResetMarginLeft = false; + const currentLeft = parseFloat(this.element.style.left || "0"); + + $(this.element).css({ + marginLeft: 0, + left: currentLeft + currentMarginLeft, + }); + this.cumulativeTapeMarginCorrection += currentMarginLeft; + currentMarginLeft = 0; + } + + /** + * we subtract the margin from the target position in order to arrive at the intended location + * if my margin is +20 and I wanna go to +50, then if I set my inline style left/top to +50 + * I will arrive to +70. However if I set it to (50 - 20), my left/top will be +30 and my margin + * will be +20 and I will end up at (30 + 20) = 50 + */ + + const animateOrPositionOptions = { + left: left - currentMarginLeft, + top: top - currentMarginTop, + ...(this.isFullWidth() && { width }), + ...(options.animate && options.animationOptions), + }; + + if (options.animate) { + this.animatePosition(animateOrPositionOptions); + } else { + this.setPosition(animateOrPositionOptions); + } + }); + } + + private getTargetPositionAndWidth(options: { + word: HTMLElement; + letter: HTMLElement; + wordText: string; + side: "beforeLetter" | "afterLetter"; + isLanguageRightToLeft: boolean; + isDirectionReversed: boolean; + }): { left: number; top: number; width: number } { + const isWordRTL = isWordRightToLeft( + options.wordText, + options.isLanguageRightToLeft, + options.isDirectionReversed + ); + + //if the letter is not visible, use the closest visible letter (but only for full width carets) + const isLetterVisible = options.letter.offsetWidth > 0; + if (!isLetterVisible && this.isFullWidth()) { + const letters = options.word.querySelectorAll("letter"); + if (letters.length === 0) { + throw new Error("Caret getLeftTopWidth: no letters found in word"); + } + + // ignore letters after the current letter + let ignore = true; + for (let i = letters.length - 1; i >= 0; i--) { + const loopLetter = letters[i] as HTMLElement; + if (loopLetter === options.letter) { + // at the current letter, stop ignoring, continue to the next + ignore = false; + continue; + } + if (ignore) continue; + + // found the closest visible letter before the current letter + if (loopLetter.offsetWidth > 0) { + options.letter = loopLetter; + break; + } + } + if (caretDebug) { + options.letter.classList.add("debugCaretTarget2"); + } + } + + const spaceWidth = getTotalInlineMargin(options.word); + let width = spaceWidth; + if (this.isFullWidth() && options.side === "beforeLetter") { + width = options.letter.offsetWidth; + } + + let left = 0; + let top = 0; + + // yes, this is all super verbose, but its easier to maintain and understand + if (isWordRTL) { + let afterLetterCorrection = 0; + if (options.side === "afterLetter") { + if (this.isFullWidth()) { + afterLetterCorrection += spaceWidth * -1; + } else { + afterLetterCorrection += options.letter.offsetWidth * -1; + } + } + if (Config.tapeMode === "off") { + if (!this.isFullWidth()) { + left += options.letter.offsetWidth; + } + left += options.letter.offsetLeft; + left += options.word.offsetLeft; + left += afterLetterCorrection; + } else if (Config.tapeMode === "word") { + if (!this.isFullWidth()) { + left += options.letter.offsetWidth; + } + left += options.word.offsetWidth * -1; + left += options.letter.offsetLeft; + left += afterLetterCorrection; + if (this.isMainCaret && lockedMainCaretInTape) { + left += wordsWrapperCache.offsetWidth * (Config.tapeMargin / 100); + } else { + left += options.word.offsetLeft; + left += options.word.offsetWidth; + } + } else if (Config.tapeMode === "letter") { + if (this.isFullWidth()) { + left += width * -1; + } + if (this.isMainCaret && lockedMainCaretInTape) { + left += wordsWrapperCache.offsetWidth * (Config.tapeMargin / 100); + } else { + left += options.letter.offsetLeft; + left += options.word.offsetLeft; + left += afterLetterCorrection; + left += width; + } + } + } else { + let afterLetterCorrection = 0; + if (options.side === "afterLetter") { + afterLetterCorrection += options.letter.offsetWidth; + } + if (Config.tapeMode === "off") { + left += options.letter.offsetLeft; + left += options.word.offsetLeft; + left += afterLetterCorrection; + } else if (Config.tapeMode === "word") { + left += options.letter.offsetLeft; + left += afterLetterCorrection; + if (this.isMainCaret && lockedMainCaretInTape) { + left += wordsWrapperCache.offsetWidth * (Config.tapeMargin / 100); + } else { + left += options.word.offsetLeft; + } + } else if (Config.tapeMode === "letter") { + if (this.isMainCaret && lockedMainCaretInTape) { + left += wordsWrapperCache.offsetWidth * (Config.tapeMargin / 100); + } else { + left += options.letter.offsetLeft; + left += options.word.offsetLeft; + left += afterLetterCorrection; + } + } + } + + //top position + top += options.letter.offsetTop; + top += options.word.offsetTop; + + if (this.style === "underline") { + // if style is underline, add the height of the letter to the top + top += options.letter.offsetHeight; + } else { + // else center vertically in the letter + top += (options.letter.offsetHeight - this.getHeight()) / 2; + } + + // also center horizontally + if (!this.isFullWidth()) { + left += (this.getWidth() / 2) * -1; + } + + return { + left, + top, + width, + }; + } +} diff --git a/frontend/src/ts/utils/misc.ts b/frontend/src/ts/utils/misc.ts index 5a41ca8a126d..2a9dbf02e120 100644 --- a/frontend/src/ts/utils/misc.ts +++ b/frontend/src/ts/utils/misc.ts @@ -768,4 +768,11 @@ export function addToGlobal(items: Record): void { } } +export function getTotalInlineMargin(element: HTMLElement): number { + const computedStyle = window.getComputedStyle(element); + return ( + parseInt(computedStyle.marginRight) + parseInt(computedStyle.marginLeft) + ); +} + // DO NOT ALTER GLOBAL OBJECTSONSTRUCTOR, IT WILL BREAK RESULT HASHES diff --git a/frontend/src/ts/utils/typo-list.ts b/frontend/src/ts/utils/typo-list.ts index be126caa763c..897e45cc60f8 100644 --- a/frontend/src/ts/utils/typo-list.ts +++ b/frontend/src/ts/utils/typo-list.ts @@ -89,7 +89,7 @@ export default [ "homail.de", "hotmai.de", "hotmal.de", - "mx.de", + "@mx.de", "mx.net", "@otmail.com", "r-online.de", diff --git a/frontend/static/quotes/english.json b/frontend/static/quotes/english.json index 6a58ecc53798..fc40f8a7a238 100644 --- a/frontend/static/quotes/english.json +++ b/frontend/static/quotes/english.json @@ -38979,6 +38979,42 @@ "source": "Severance", "id": 7709, "length": 166 + }, + { + "text": "We go back. We trot off silently in single file one behind the other. The wounded are taken to the dressing-station. The morning is cloudy. The bearers make a fuss about numbers and tickets, the wounded whimper. It beings to rain. An hour later we reach our lorries and climb in. There is more room now than there was. The rain becomes heavier. We take out waterproof sheets and spread them over our heads. The rain rattles down, and flows off at the sides in streams. The lorries bump through the holes, and we rock to and fro in a half-sleep. Two men in the front of the lorry have long forked poles. They watch for telephone wires which hang crosswise over the road so low that they might easily pull our heads off. The two fellows take them at the right moment on their poles and lift them over behind us. We hear their call 'Mind-wire-,' dip the knee in a half-sleep and straighten up again.", + "source": "All Quiet on The Western Front", + "id": 7710, + "length": 896 + }, + { + "text": "The thunder of the guns swells to a single heavy roar and then breaks up again into separate explosions. The dry bursts of the machine-guns rattle; above us the air teems with invisible swift movement, with howls, pipings, and hisses. They are smaller shells; and amongst them, booming through the night like an organ, go the great coal-boxes and the heavies. They have a hoarse, distant bellow like a rutting stag and make their way high above the howl and whistle of the smaller shells. It reminds me of flocks of wild geese when I hear them. Last autumn the wild geese flew day after day across the path of the shells. The searchlights begin to sweep the dark sky. They slide along it like gigantic tapering rulers. One of them pauses, and quivers a little. Immediately a second is beside it; a black insect is caught between them and tries to escape - the airman. He hesitates, is blinded and falls.", + "source": "All Quiet on the Western Front", + "id": 7711, + "length": 903 + }, + { + "text": "Life wants to be lived, and I live it, even though it goes against logic. Very well, so I don't believe in the order of things, but the sticky leaf buds that open in spring are dear to me, as is the blue sky, as are certain people whom, would you believe it, sometimes one loves one knows not why.", + "source": "The Brothers Karamazov", + "id": 7712, + "length": 297 + }, + { + "text": "A man cannot realize that above such shattered bodies there are still human faces in which life goes its daily round. And this is only one hospital, one single station; there are hundreds of thousands in Germany, hundreds of thousands in France, hundreds of thousands in Russia. How senseless is everything that can ever be written, done, or thought, when such things are possible. It must all be lies and of no account when the culture of a thousand years could not prevent this stream of blood being poured out, these torture-chambers in their hundreds of thousands. A hospital alone shows what war is.", + "source": "All Quiet on The Western Front", + "id": 7713, + "length": 604 + }, + { + "text": "I am young, I am twenty years old; yet I know nothing of life but despair, death, fear, and fatuous superficiality cast over an abyss of sorrow. I see how peoples are set against one another, and in silence, unknowingly, foolishly, obediently, innocently slay one another. I see that the keenest brains of the world invent weapons and words to make it yet more refined and enduring. And all men of my age, here and over there, throughout the whole world, see these things; all my generation is experiencing these things with me. What would our fathers do if we suddenly stood up and came before them and proffered our account? What do they expect of us if a time ever comes when the war is over? Through the years our business has been killing; it was our first calling in life. Our knowledge of life is limited to death. What will happen afterwards? And what shall come out of us?", + "source": "All Quiet on the Western Front", + "id": 7714, + "length": 881 + }, + { + "text": "The sun had nearly reached the meridian, and his scorching rays fell full on the rocks, which seemed themselves sensible of the heat. Thousands of grasshoppers, hidden in the bushes, chirped with a monotonous and dull note; the leaves of the myrtle and olive trees waved and rustled in the wind. At every step that Edmond took he disturbed the lizards glittering with the hues of the emerald; afar off he saw the wild goats bounding from crag to crag. In a word, the island was inhabited, yet Edmond felt himself alone, guided by the hand of God. He felt an indescribable sensation somewhat akin to dread-that dread of the daylight which even in the desert makes us fear we are watched and observed. This feeling was so strong that at the moment when Edmond was about to begin his labor, he stopped, laid down his pickaxe, seized his gun, mounted to the summit of the highest rock, and from thence gazed round in every direction.", + "source": "The Count of Monte Cristo", + "id": 7715, + "length": 929 } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 87cde1ab20e7..024b3e2e77cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,7 +19,7 @@ importers: version: link:packages/release '@vitest/coverage-v8': specifier: 3.2.4 - version: 3.2.4(vitest@3.2.4(@types/node@20.5.1)(happy-dom@20.0.0)(sass@1.70.0)(terser@5.44.0)(tsx@4.16.2)(yaml@2.5.0)) + version: 3.2.4(vitest@3.2.4(@types/node@20.5.1)(happy-dom@20.0.2)(sass@1.70.0)(terser@5.44.0)(tsx@4.16.2)(yaml@2.5.0)) conventional-changelog: specifier: 6.0.0 version: 6.0.0(conventional-commits-filter@5.0.0) @@ -49,7 +49,7 @@ importers: version: 2.5.6 vitest: specifier: 3.2.4 - version: 3.2.4(@types/node@20.5.1)(happy-dom@20.0.0)(sass@1.70.0)(terser@5.44.0)(tsx@4.16.2)(yaml@2.5.0) + version: 3.2.4(@types/node@20.5.1)(happy-dom@20.0.2)(sass@1.70.0)(terser@5.44.0)(tsx@4.16.2)(yaml@2.5.0) backend: dependencies: @@ -230,7 +230,7 @@ importers: version: 10.0.0 '@vitest/coverage-v8': specifier: 3.2.4 - version: 3.2.4(vitest@3.2.4(@types/node@20.14.11)(happy-dom@20.0.0)(sass@1.70.0)(terser@5.44.0)(tsx@4.16.2)(yaml@2.5.0)) + version: 3.2.4(vitest@3.2.4(@types/node@20.14.11)(happy-dom@20.0.2)(sass@1.70.0)(terser@5.44.0)(tsx@4.16.2)(yaml@2.5.0)) concurrently: specifier: 8.2.2 version: 8.2.2 @@ -263,7 +263,7 @@ importers: version: 5.5.4 vitest: specifier: 3.2.4 - version: 3.2.4(@types/node@20.14.11)(happy-dom@20.0.0)(sass@1.70.0)(terser@5.44.0)(tsx@4.16.2)(yaml@2.5.0) + version: 3.2.4(@types/node@20.14.11)(happy-dom@20.0.2)(sass@1.70.0)(terser@5.44.0)(tsx@4.16.2)(yaml@2.5.0) frontend: dependencies: @@ -411,7 +411,7 @@ importers: version: 5.0.2 '@vitest/coverage-v8': specifier: 3.2.4 - version: 3.2.4(vitest@3.2.4(@types/node@20.14.11)(happy-dom@20.0.0)(sass@1.70.0)(terser@5.44.0)(tsx@4.16.2)(yaml@2.5.0)) + version: 3.2.4(vitest@3.2.4(@types/node@20.14.11)(happy-dom@20.0.2)(sass@1.70.0)(terser@5.44.0)(tsx@4.16.2)(yaml@2.5.0)) autoprefixer: specifier: 10.4.20 version: 10.4.20(postcss@8.4.31) @@ -434,8 +434,8 @@ importers: specifier: 4.4.0 version: 4.4.0(@fortawesome/fontawesome-free@5.15.4) happy-dom: - specifier: 20.0.0 - version: 20.0.0 + specifier: 20.0.2 + version: 20.0.2 madge: specifier: 8.0.0 version: 8.0.0(typescript@5.5.4) @@ -495,7 +495,7 @@ importers: version: 1.0.0(vite@6.3.6(@types/node@20.14.11)(sass@1.70.0)(terser@5.44.0)(tsx@4.16.2)(yaml@2.5.0))(workbox-build@7.1.1)(workbox-window@7.1.0) vitest: specifier: 3.2.4 - version: 3.2.4(@types/node@20.14.11)(happy-dom@20.0.0)(sass@1.70.0)(terser@5.44.0)(tsx@4.16.2)(yaml@2.5.0) + version: 3.2.4(@types/node@20.14.11)(happy-dom@20.0.2)(sass@1.70.0)(terser@5.44.0)(tsx@4.16.2)(yaml@2.5.0) packages/contracts: dependencies: @@ -535,7 +535,7 @@ importers: version: 5.5.4 vitest: specifier: 3.2.4 - version: 3.2.4(@types/node@20.14.11)(happy-dom@20.0.0)(sass@1.70.0)(terser@5.44.0)(tsx@4.16.2)(yaml@2.5.0) + version: 3.2.4(@types/node@20.14.11)(happy-dom@20.0.2)(sass@1.70.0)(terser@5.44.0)(tsx@4.16.2)(yaml@2.5.0) packages/eslint-config: devDependencies: @@ -599,7 +599,7 @@ importers: version: 5.5.4 vitest: specifier: 3.2.4 - version: 3.2.4(@types/node@20.14.11)(happy-dom@20.0.0)(sass@1.70.0)(terser@5.44.0)(tsx@4.16.2)(yaml@2.5.0) + version: 3.2.4(@types/node@20.14.11)(happy-dom@20.0.2)(sass@1.70.0)(terser@5.44.0)(tsx@4.16.2)(yaml@2.5.0) packages/oxlint-config: {} @@ -660,7 +660,7 @@ importers: version: 5.5.4 vitest: specifier: 3.2.4 - version: 3.2.4(@types/node@20.14.11)(happy-dom@20.0.0)(sass@1.70.0)(terser@5.44.0)(tsx@4.16.2)(yaml@2.5.0) + version: 3.2.4(@types/node@20.14.11)(happy-dom@20.0.2)(sass@1.70.0)(terser@5.44.0)(tsx@4.16.2)(yaml@2.5.0) packages/tsup-config: dependencies: @@ -711,7 +711,7 @@ importers: version: 5.5.4 vitest: specifier: 3.2.4 - version: 3.2.4(@types/node@20.14.11)(happy-dom@20.0.0)(sass@1.70.0)(terser@5.44.0)(tsx@4.16.2)(yaml@2.5.0) + version: 3.2.4(@types/node@20.14.11)(happy-dom@20.0.2)(sass@1.70.0)(terser@5.44.0)(tsx@4.16.2)(yaml@2.5.0) zod: specifier: 3.23.8 version: 3.23.8 @@ -5517,8 +5517,8 @@ packages: hangul-js@0.2.6: resolution: {integrity: sha512-48axU8LgjCD30FEs66Xc04/8knxMwCMQw0f67l67rlttW7VXT3qRJgQeHmhiuGwWXGvSbk6YM0fhQlcjE1JFQA==} - happy-dom@20.0.0: - resolution: {integrity: sha512-GkWnwIFxVGCf2raNrxImLo397RdGhLapj5cT3R2PT7FwL62Ze1DROhzmYW7+J3p9105DYMVenEejEbnq5wA37w==} + happy-dom@20.0.2: + resolution: {integrity: sha512-pYOyu624+6HDbY+qkjILpQGnpvZOusItCk+rvF5/V+6NkcgTKnbOldpIy22tBnxoaLtlM9nXgoqAcW29/B7CIw==} engines: {node: '>=20.0.0'} hard-rejection@2.1.0: @@ -12737,7 +12737,7 @@ snapshots: '@ungap/structured-clone@1.2.0': {} - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@20.14.11)(happy-dom@20.0.0)(sass@1.70.0)(terser@5.44.0)(tsx@4.16.2)(yaml@2.5.0))': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@20.14.11)(happy-dom@20.0.2)(sass@1.70.0)(terser@5.44.0)(tsx@4.16.2)(yaml@2.5.0))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -12752,11 +12752,11 @@ snapshots: std-env: 3.9.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/node@20.14.11)(happy-dom@20.0.0)(sass@1.70.0)(terser@5.44.0)(tsx@4.16.2)(yaml@2.5.0) + vitest: 3.2.4(@types/node@20.14.11)(happy-dom@20.0.2)(sass@1.70.0)(terser@5.44.0)(tsx@4.16.2)(yaml@2.5.0) transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@20.5.1)(happy-dom@20.0.0)(sass@1.70.0)(terser@5.44.0)(tsx@4.16.2)(yaml@2.5.0))': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@20.5.1)(happy-dom@20.0.2)(sass@1.70.0)(terser@5.44.0)(tsx@4.16.2)(yaml@2.5.0))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -12771,7 +12771,7 @@ snapshots: std-env: 3.9.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/node@20.5.1)(happy-dom@20.0.0)(sass@1.70.0)(terser@5.44.0)(tsx@4.16.2)(yaml@2.5.0) + vitest: 3.2.4(@types/node@20.5.1)(happy-dom@20.0.2)(sass@1.70.0)(terser@5.44.0)(tsx@4.16.2)(yaml@2.5.0) transitivePeerDependencies: - supports-color @@ -15730,7 +15730,7 @@ snapshots: hangul-js@0.2.6: {} - happy-dom@20.0.0: + happy-dom@20.0.2: dependencies: '@types/node': 20.14.11 '@types/whatwg-mimetype': 3.0.2 @@ -20165,7 +20165,7 @@ snapshots: tsx: 4.16.2 yaml: 2.5.0 - vitest@3.2.4(@types/node@20.14.11)(happy-dom@20.0.0)(sass@1.70.0)(terser@5.44.0)(tsx@4.16.2)(yaml@2.5.0): + vitest@3.2.4(@types/node@20.14.11)(happy-dom@20.0.2)(sass@1.70.0)(terser@5.44.0)(tsx@4.16.2)(yaml@2.5.0): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 @@ -20192,7 +20192,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 20.14.11 - happy-dom: 20.0.0 + happy-dom: 20.0.2 transitivePeerDependencies: - jiti - less @@ -20207,7 +20207,7 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/node@20.5.1)(happy-dom@20.0.0)(sass@1.70.0)(terser@5.44.0)(tsx@4.16.2)(yaml@2.5.0): + vitest@3.2.4(@types/node@20.5.1)(happy-dom@20.0.2)(sass@1.70.0)(terser@5.44.0)(tsx@4.16.2)(yaml@2.5.0): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 @@ -20234,7 +20234,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 20.5.1 - happy-dom: 20.0.0 + happy-dom: 20.0.2 transitivePeerDependencies: - jiti - less