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"]);