Skip to content
77 changes: 77 additions & 0 deletions frontend/src/ts/test/funbox/funbox-functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ export type FunboxFunctions = {
getResultContent?: () => string;
start?: () => void;
restart?: () => void;
stop?: () => void;
pause?: () => void;
resume?: () => void;
getWordHtml?: (char: string, letterTag?: boolean) => string;
getWordsFrequencyMode?: () => FunboxWordsFrequency;
};
Expand Down Expand Up @@ -601,6 +604,80 @@ const list: Partial<Record<FunboxName, FunboxFunctions>> = {
.replace(/\n+/g, "\n"); //make sure there is only one enter
},
},
slow_scroll: {
start(): void {
const words = document.querySelector<HTMLElement>("#words");
const caret = document.querySelector<HTMLElement>("#caret");
const paceCaret = document.querySelector<HTMLElement>("#paceCaret");
if (!TestState.isActive) return;

if (words) words.classList.add("slow-scroll-running");
if (caret) caret.classList.add("slow-scroll-running");
if (paceCaret) paceCaret.classList.add("slow-scroll-running");
},
restart(): void {
const words = document.querySelector<HTMLElement>("#words");
const caret = document.querySelector<HTMLElement>("#caret");
const paceCaret = document.querySelector<HTMLElement>("#paceCaret");
if (words) {
words.classList.remove("slow-scroll-running");
}
if (caret) {
caret.classList.remove("slow-scroll-running");
}
if (paceCaret) {
paceCaret.classList.remove("slow-scroll-running");
}
// Force reflow on the words container so animation restarts consistently
const trigger = words ?? caret ?? paceCaret;
if (trigger) {
void trigger.offsetWidth;
}
// Only re-enable the animation while the test is active; during
// initialization/restart the test is inactive and the animation should
// stay paused so it doesn't impair the user pre-start.
if (!TestState.isActive) return;

if (words) words.classList.add("slow-scroll-running");
if (caret) caret.classList.add("slow-scroll-running");
if (paceCaret) paceCaret.classList.add("slow-scroll-running");
},
clearGlobal(): void {
const words = document.querySelector<HTMLElement>("#words");
const caret = document.querySelector<HTMLElement>("#caret");
const paceCaret = document.querySelector<HTMLElement>("#paceCaret");
if (words) words.classList.remove("slow-scroll-running");
if (caret) caret.classList.remove("slow-scroll-running");
if (paceCaret) paceCaret.classList.remove("slow-scroll-running");
},
stop(): void {
// Called when a test stops. Ensure the animation is removed immediately.
const words = document.querySelector<HTMLElement>("#words");
const caret = document.querySelector<HTMLElement>("#caret");
const paceCaret = document.querySelector<HTMLElement>("#paceCaret");
if (words) words.classList.remove("slow-scroll-running");
if (caret) caret.classList.remove("slow-scroll-running");
if (paceCaret) paceCaret.classList.remove("slow-scroll-running");
},
pause(): void {
// Pause the animation without resetting it
const words = document.querySelector<HTMLElement>("#words");
const caret = document.querySelector<HTMLElement>("#caret");
const paceCaret = document.querySelector<HTMLElement>("#paceCaret");
if (words) words.style.animationPlayState = "paused";
if (caret) caret.style.animationPlayState = "paused";
if (paceCaret) paceCaret.style.animationPlayState = "paused";
},
resume(): void {
// Resume previously paused animation
const words = document.querySelector<HTMLElement>("#words");
const caret = document.querySelector<HTMLElement>("#caret");
const paceCaret = document.querySelector<HTMLElement>("#paceCaret");
if (words) words.style.animationPlayState = "running";
if (caret) caret.style.animationPlayState = "running";
if (paceCaret) paceCaret.style.animationPlayState = "running";
},
},
morse: {
alterText(word: string): string {
return GetText.getMorse(word);
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/ts/test/test-logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,9 @@ export function restart(options = {} as RestartOptions): void {
AltTracker.reset();
Caret.hide();
TestState.setActive(false);
for (const fb of getActiveFunboxesWithFunction("stop")) {
fb.functions.stop();
}
Replay.stopReplayRecording();
LiveSpeed.hide();
LiveAcc.hide();
Expand Down Expand Up @@ -953,6 +956,10 @@ export async function finish(difficultyFailed = false): Promise<void> {

TestState.setResultVisible(true);
TestState.setActive(false);
// Ensure any test-specific funbox behaviour is stopped when the test ends
for (const fb of getActiveFunboxesWithFunction("stop")) {
fb.functions.stop();
}
Replay.stopReplayRecording();
Caret.hide();
LiveSpeed.hide();
Expand Down
28 changes: 27 additions & 1 deletion frontend/src/ts/test/test-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { TimerColor, TimerOpacity } from "@monkeytype/schemas/configs";
import { convertRemToPixels } from "../utils/numbers";
import {
findSingleActiveFunboxWithFunction,
isFunboxActive,
isFunboxActiveWithProperty,
} from "./funbox/list";
import * as TestState from "./test-state";
Expand Down Expand Up @@ -77,12 +78,15 @@ export const updateHintsPositionDebounced = Misc.debounceUntilResolved(
);

ConfigEvent.subscribe((eventKey, eventValue, nosave) => {
// Run language/funbox dependent checks
if (
(eventKey === "language" || eventKey === "funbox") &&
Config.funbox.includes("zipf")
) {
debouncedZipfCheck();
}

// Update font size for caret/pace elements and refresh layout when needed
if (eventKey === "fontSize") {
$("#caret, #paceCaret, #liveStatsMini, #typingTest, #wordsInput").css(
"fontSize",
Expand All @@ -93,6 +97,8 @@ ConfigEvent.subscribe((eventKey, eventValue, nosave) => {
updateWordWrapperClasses();
}
}

// Some config changes affect hint positions
if (
["fontSize", "fontFamily", "blindMode", "hideExtraLetters"].includes(
eventKey
Expand All @@ -101,13 +107,18 @@ ConfigEvent.subscribe((eventKey, eventValue, nosave) => {
void updateHintsPositionDebounced();
}

// Theme change can affect pre-rendered heatmaps
if (eventKey === "theme") void applyBurstHeatmap();

// If the new value is undefined, no further updates are needed
if (eventValue === undefined) return;

// Update the active element if highlight mode changed while on test page
if (eventKey === "highlightMode") {
if (ActivePage.get() === "test") updateActiveElement();
}

// A bunch of config changes require re-applying classes / layout updates
if (
[
"highlightMode",
Expand All @@ -131,6 +142,7 @@ ConfigEvent.subscribe((eventKey, eventValue, nosave) => {
}
}

// Final boolean-valued config toggles
if (typeof eventValue !== "boolean") return;
if (eventKey === "flipTestColors") flipColors(eventValue);
if (eventKey === "colorfulMode") colorful(eventValue);
Expand Down Expand Up @@ -436,9 +448,13 @@ function updateWordWrapperClasses(): void {
if (Config.tapeMode !== "off") {
$("#words").addClass("tape");
$("#wordsWrapper").addClass("tape");
$("#caret").addClass("tape");
$("#paceCaret").addClass("tape");
} else {
$("#words").removeClass("tape");
$("#wordsWrapper").removeClass("tape");
$("#caret").removeClass("tape");
$("#paceCaret").removeClass("tape");
}

if (Config.blindMode) {
Expand Down Expand Up @@ -1167,7 +1183,10 @@ export async function lineJump(
Caret.caret.handleLineJump(caretLineJumpOptions);
PaceCaret.caret.handleLineJump(caretLineJumpOptions);

if (Config.smoothLineScroll) {
// Don't animate words element if slow_scroll is active to avoid interfering with the animation
const isSlowScrollActive = isFunboxActive("slow_scroll");

if (Config.smoothLineScroll && !isSlowScrollActive) {
lineTransition = true;
animate(wordsEl, {
marginTop: newMarginTop,
Expand All @@ -1182,8 +1201,15 @@ export async function lineJump(
},
});
} else {
// For slow_scroll or when smooth scroll is off, just update immediately
if (!isSlowScrollActive) {
wordsEl.style.marginTop = newMarginTop + "px";
}
currentLinesJumping = 0;
removeTestElements(lastElementIndexToRemove);
if (!isSlowScrollActive) {
wordsEl.style.marginTop = "0";
}
resolve();
}
}
Expand Down
69 changes: 69 additions & 0 deletions frontend/static/funbox/slow_scroll.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
@keyframes slow_scroll_up {
0% {
transform: translateY(0);
}

100% {
transform: translateY(-40vh);
}
}

@keyframes slow_scroll_left {
0% {
transform: translateX(0);
}

100% {
transform: translateX(-100vw);
}
}

/* Vertical scrolling for normal mode */
#words.slow-scroll-running:not(.tape) {
animation: slow_scroll_up 120s linear infinite;
will-change: transform;
}

/* Horizontal scrolling for tape mode */
#words.slow-scroll-running.tape {
animation: slow_scroll_left 120s linear infinite;
will-change: transform;
}

/* also animate the carets so they visually remain synced with the word list */
/* Vertical scrolling for normal mode */
#caret.slow-scroll-running:not(.tape),
#paceCaret.slow-scroll-running:not(.tape) {
animation: slow_scroll_up 120s linear infinite;
will-change: transform;
}

/* Horizontal scrolling for tape mode - carets need to scroll with the words */
#caret.slow-scroll-running.tape,
#paceCaret.slow-scroll-running.tape {
animation: slow_scroll_left 120s linear infinite;
will-change: transform;
}

@media (prefers-reduced-motion: reduce) {
#words.slow-scroll-running,
#caret.slow-scroll-running,
#paceCaret.slow-scroll-running {
animation: none !important;
}
}

body.ignore-reduced-motion #words.slow-scroll-running:not(.tape),
body.ignore-reduced-motion #caret.slow-scroll-running:not(.tape),
body.ignore-reduced-motion #paceCaret.slow-scroll-running:not(.tape) {
animation: slow_scroll_up 240s linear infinite !important;
}

body.ignore-reduced-motion #words.slow-scroll-running.tape {
animation: slow_scroll_left 240s linear infinite !important;
}

body.ignore-reduced-motion #caret.slow-scroll-running.tape,
body.ignore-reduced-motion #paceCaret.slow-scroll-running.tape {
animation: slow_scroll_left 240s linear infinite !important;
}
15 changes: 15 additions & 0 deletions packages/funbox/src/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,21 @@ const list: Record<FunboxName, FunboxMetadata> = {
difficultyLevel: 0,
name: "no_quit",
},
slow_scroll: {
name: "slow_scroll",
description: "Wait, where are you going? Come back... no... I'm sorry...",
canGetPb: false,
difficultyLevel: 2,
properties: ["hasCssFile", "ignoreReducedMotion"],
frontendFunctions: [
"start",
"restart",
"clearGlobal",
"stop",
"pause",
"resume",
],
},
};

export function getFunbox(name: FunboxName): FunboxMetadata;
Expand Down
1 change: 1 addition & 0 deletions packages/schemas/src/configs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,7 @@ export const FunboxNameSchema = z.enum([
"ALL_CAPS",
"polyglot",
"asl",
"slow_scroll",
"no_quit",
]);
export type FunboxName = z.infer<typeof FunboxNameSchema>;
Expand Down