diff --git a/frontend/src/html/pages/settings.html b/frontend/src/html/pages/settings.html index 7049cbd4ee1f..80ccc42e1769 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/animations.scss b/frontend/src/styles/animations.scss index 2ddc5002d2ca..d5928cc1598c 100644 --- a/frontend/src/styles/animations.scss +++ b/frontend/src/styles/animations.scss @@ -173,3 +173,17 @@ color: transparent; } } + +@keyframes typedEffectTumble { + 0% { + transform: translate(0, 0) rotate(0deg); + opacity: 1; + } + 25% { + opacity: 1; + } + 100% { + transform: translate(var(--fall-x), 100vh) rotate(var(--fall-rotation)); + opacity: 0; + } +} diff --git a/frontend/src/styles/test.scss b/frontend/src/styles/test.scss index 317e16f7b67e..cd8d4692cf38 100644 --- a/frontend/src/styles/test.scss +++ b/frontend/src/styles/test.scss @@ -533,6 +533,18 @@ } } + &.typed-effect-tumble { + .word.typed:not(.error) { + opacity: 0; + } + + @media (prefers-reduced-motion) { + .word.typed:not(.error) { + opacity: 1; + } + } + } + &.typed-effect-dots { /* transform already typed letters into appropriately colored dots */ @@ -602,6 +614,13 @@ } } +.tumble-clone { + animation: typedEffectTumble 1s cubic-bezier(0.5, 0, 1, 1) forwards; + display: inline-block; + 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 71e3ca0fa80c..07309d0ba367 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") { // @@ -164,6 +166,7 @@ export function updateActiveElement( newActiveWord.addClass("active"); newActiveWord.removeClass("error"); newActiveWord.removeClass("typed"); + newActiveWord.setStyle({ opacity: "" }); Ligatures.set(newActiveWord, false); activeWordTop = newActiveWord.getOffsetTop(); @@ -495,6 +498,7 @@ function updateWordWrapperClasses(): void { } function showWords(): void { + TypedEffects.clear(); wordsEl.setHtml(""); if (Config.mode === "zen") { @@ -1937,6 +1941,7 @@ export function onTestRestart(source: "testPage" | "resultPage"): void { } export function onTestFinish(): void { + TypedEffects.clear(); Caret.hide(); LiveSpeed.hide(); LiveAcc.hide(); @@ -2081,6 +2086,9 @@ configEvent.subscribe(({ key, newValue }) => { "tapeMargin", ].includes(key) ) { + if (key === "typedEffect") { + 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..ac03f0c39936 --- /dev/null +++ b/frontend/src/ts/test/typed-effects.ts @@ -0,0 +1,59 @@ +import { Config } from "../config/store"; +import * as Misc from "../utils/misc"; +import { ElementWithUtils, qsa, qsr } from "../utils/dom"; + +const wordsEl = qsr(".pageTest #words"); +const TUMBLE_DURATION_MS = 1000; + +export function onWordTyped(word: ElementWithUtils): void { + switch (Config.typedEffect) { + case "tumble": + triggerTumble(word); + return; + default: + return; + } +} + +export function clear(): void { + qsa(".tumble-clone").remove(); + wordsEl.qsa(".word").setStyle({ opacity: "" }); +} + +function triggerTumble(word: ElementWithUtils): void { + if (word.hasClass("error")) return; + if (Misc.prefersReducedMotion()) return; + + const rect = word.native.getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) return; + + const computedStyle = window.getComputedStyle(word.native); + const clone = word.native.cloneNode(true) as HTMLElement; + const randomRotation = (Math.random() - 0.5) * 45; + const randomX = (Math.random() - 0.5) * 100; + + clone.classList.add("tumble-clone"); + clone.style.position = "fixed"; + clone.style.top = `${rect.top}px`; + clone.style.left = `${rect.left}px`; + clone.style.width = `${rect.width}px`; + clone.style.height = `${rect.height}px`; + clone.style.fontSize = computedStyle.fontSize; + clone.style.fontFamily = computedStyle.fontFamily; + clone.style.color = computedStyle.color; + clone.style.margin = "0"; + clone.style.pointerEvents = "none"; + clone.style.zIndex = "1000"; + clone.style.setProperty("--fall-rotation", `${randomRotation}deg`); + clone.style.setProperty("--fall-x", `${randomX}px`); + + document.body.appendChild(clone); + word.setStyle({ opacity: "0" }); + + const cleanup = (): void => { + clone.remove(); + }; + + clone.addEventListener("animationend", cleanup, { once: true }); + window.setTimeout(cleanup, TUMBLE_DURATION_MS); +} diff --git a/packages/schemas/src/configs.ts b/packages/schemas/src/configs.ts index 77c257a54834..15ef1c92c88f 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", + "tumble", +]); export type TypedEffect = z.infer; export const TapeModeSchema = z.enum(["off", "letter", "word"]);