diff --git a/frontend/src/styles/media-queries.scss b/frontend/src/styles/media-queries.scss index b080cdc9b3b7..00e0589dbd62 100644 --- a/frontend/src/styles/media-queries.scss +++ b/frontend/src/styles/media-queries.scss @@ -47,7 +47,8 @@ body { .fa-spin, .animate-\[loader\], .preloader, - .typed-effect-fade .word.typed + .typed-effect-fade .word.typed, + .fall-clone ) { animation: none !important; transition: none !important; diff --git a/frontend/src/styles/test.scss b/frontend/src/styles/test.scss index 9b9961a7a9af..333bb34fb3b4 100644 --- a/frontend/src/styles/test.scss +++ b/frontend/src/styles/test.scss @@ -528,6 +528,12 @@ } } + &.typed-effect-fall { + .word.typed:not(.error) { + opacity: 0; + } + } + &.typed-effect-dots { /* transform already typed letters into appropriately colored dots */ @@ -597,6 +603,14 @@ } } +.word.fall-clone { + display: inline-block; + position: fixed; + margin: 0; + pointer-events: none; + z-index: 1000; +} + .word { position: relative; font-size: 1em; diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index eea4bfef67fd..2b59d8cee1da 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -59,6 +59,7 @@ import * as ThemeController from "../controllers/theme-controller"; import * as ModesNotice from "../elements/modes-notice"; import * as Last10Average from "../elements/last-10-average"; import * as MemoryFunboxTimer from "./funbox/memory-funbox-timer"; +import * as TypedEffects from "./typed-effects"; import { ElementsWithUtils, ElementWithUtils, @@ -147,6 +148,7 @@ export function updateActiveElement( if (previousActiveWord !== null) { if (direction === "forward") { previousActiveWord.addClass("typed"); + TypedEffects.onWordTyped(previousActiveWord); Ligatures.set(previousActiveWord, true); } else if (direction === "back") { // @@ -494,6 +496,7 @@ function updateWordWrapperClasses(): void { } function showWords(): void { + TypedEffects.clear(); wordsEl.setHtml(""); if (Config.mode === "zen") { @@ -1933,6 +1936,7 @@ export function onTestRestart(source: "testPage" | "resultPage"): void { } export function onTestFinish(): void { + TypedEffects.clear(); Caret.hide(); LiveSpeed.hide(); LiveAcc.hide(); @@ -2077,6 +2081,9 @@ configEvent.subscribe(({ key, newValue }) => { "tapeMargin", ].includes(key) ) { + if (key === "typedEffect" && newValue !== "fall") { + TypedEffects.clear(); + } if (key !== "fontFamily") updateWordWrapperClasses(); if (["typedEffect", "fontFamily", "fontSize"].includes(key)) { Ligatures.update(key, wordsEl); diff --git a/frontend/src/ts/test/typed-effects.ts b/frontend/src/ts/test/typed-effects.ts new file mode 100644 index 000000000000..d59c7919bf62 --- /dev/null +++ b/frontend/src/ts/test/typed-effects.ts @@ -0,0 +1,50 @@ +import { animate } from "animejs"; // v4: named export, no default +import { Config } from "../config/store"; +import { ElementWithUtils, qsa, qsr } from "../utils/dom"; + +const FALL_DURATION_MS = 1700; + +export function onWordTyped(word: ElementWithUtils): void { + switch (Config.typedEffect) { + case "fall": + triggerFall(word); + return; + default: + return; + } +} + +export function clear(): void { + qsa(".fall-clone").remove(); +} + +function triggerFall(word: ElementWithUtils): void { + if (word.hasClass("error")) return; + + const rect = word.native.getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) return; + + const clone = word.native.cloneNode(true) as HTMLElement; + + clone.classList.remove("active"); + clone.classList.add("fall-clone"); + clone.style.top = `${rect.top}px`; + clone.style.left = `${rect.left}px`; + clone.style.width = `${rect.width}px`; + clone.style.height = `${rect.height}px`; + + qsr("#words").native.appendChild(clone); + + const randomRotation = (Math.random() - 0.5) * 45; + const randomX = (Math.random() - 0.5) * 100; + + animate(clone, { + translateX: randomX, + translateY: window.innerHeight - rect.top, + rotate: randomRotation, + opacity: [1, 1, 0], + duration: FALL_DURATION_MS, + easing: "easeInQuad", + onComplete: () => clone.remove(), + }); +} diff --git a/packages/schemas/src/configs.ts b/packages/schemas/src/configs.ts index c2cd9ee56b7f..2b357d0e98d5 100644 --- a/packages/schemas/src/configs.ts +++ b/packages/schemas/src/configs.ts @@ -192,7 +192,13 @@ export const HighlightModeSchema = z.enum([ ]); export type HighlightMode = z.infer; -export const TypedEffectSchema = z.enum(["keep", "hide", "fade", "dots"]); +export const TypedEffectSchema = z.enum([ + "keep", + "hide", + "fade", + "dots", + "fall", +]); export type TypedEffect = z.infer; export const TapeModeSchema = z.enum(["off", "letter", "word"]);