diff --git a/frontend/src/html/pages/settings.html b/frontend/src/html/pages/settings.html index 7049cbd4ee1f..475e4fbc55d7 100644 --- a/frontend/src/html/pages/settings.html +++ b/frontend/src/html/pages/settings.html @@ -1022,6 +1022,7 @@ +
diff --git a/frontend/src/styles/media-queries.scss b/frontend/src/styles/media-queries.scss index e9358c2d5290..c02e4ddf3756 100644 --- a/frontend/src/styles/media-queries.scss +++ b/frontend/src/styles/media-queries.scss @@ -43,7 +43,7 @@ body { @media (prefers-reduced-motion) { body:not(.ignore-reduced-motion) - *:not(.fa-spin, .animate-\[loader\], .preloader) { + *:not(.fa-spin, .animate-\[loader\], .preloader, .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 c4a7a58711c2..8aecc026e206 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") { // @@ -495,6 +497,7 @@ function updateWordWrapperClasses(): void { } function showWords(): void { + TypedEffects.clear(); wordsEl.setHtml(""); if (Config.mode === "zen") { @@ -1939,6 +1942,7 @@ export function onTestRestart(source: "testPage" | "resultPage"): void { } export function onTestFinish(): void { + TypedEffects.clear(); Caret.hide(); LiveSpeed.hide(); LiveAcc.hide(); @@ -2083,6 +2087,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 77c257a54834..a4d9eb08e7b0 100644 --- a/packages/schemas/src/configs.ts +++ b/packages/schemas/src/configs.ts @@ -182,7 +182,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"]);