diff --git a/frontend/src/ts/test/funbox/funbox-functions.ts b/frontend/src/ts/test/funbox/funbox-functions.ts index d26512871475..df9ce8be0c6d 100644 --- a/frontend/src/ts/test/funbox/funbox-functions.ts +++ b/frontend/src/ts/test/funbox/funbox-functions.ts @@ -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; }; @@ -601,6 +604,80 @@ const list: Partial> = { .replace(/\n+/g, "\n"); //make sure there is only one enter }, }, + slow_scroll: { + start(): void { + const words = document.querySelector("#words"); + const caret = document.querySelector("#caret"); + const paceCaret = document.querySelector("#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("#words"); + const caret = document.querySelector("#caret"); + const paceCaret = document.querySelector("#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("#words"); + const caret = document.querySelector("#caret"); + const paceCaret = document.querySelector("#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("#words"); + const caret = document.querySelector("#caret"); + const paceCaret = document.querySelector("#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("#words"); + const caret = document.querySelector("#caret"); + const paceCaret = document.querySelector("#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("#words"); + const caret = document.querySelector("#caret"); + const paceCaret = document.querySelector("#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); diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 5e98d856b18b..38852110923b 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -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(); @@ -953,6 +956,10 @@ export async function finish(difficultyFailed = false): Promise { 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(); diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index a56078b923a9..4474e9c50365 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -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"; @@ -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", @@ -93,6 +97,8 @@ ConfigEvent.subscribe((eventKey, eventValue, nosave) => { updateWordWrapperClasses(); } } + + // Some config changes affect hint positions if ( ["fontSize", "fontFamily", "blindMode", "hideExtraLetters"].includes( eventKey @@ -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", @@ -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); @@ -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) { @@ -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, @@ -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(); } } diff --git a/frontend/static/funbox/slow_scroll.css b/frontend/static/funbox/slow_scroll.css new file mode 100644 index 000000000000..f9c14a6078c4 --- /dev/null +++ b/frontend/static/funbox/slow_scroll.css @@ -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; +} diff --git a/packages/funbox/src/list.ts b/packages/funbox/src/list.ts index 84aab9025a9f..7d5505bf7fcf 100644 --- a/packages/funbox/src/list.ts +++ b/packages/funbox/src/list.ts @@ -466,6 +466,21 @@ const list: Record = { 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; diff --git a/packages/schemas/src/configs.ts b/packages/schemas/src/configs.ts index 3007b5435f4c..60128f2a86f4 100644 --- a/packages/schemas/src/configs.ts +++ b/packages/schemas/src/configs.ts @@ -304,6 +304,7 @@ export const FunboxNameSchema = z.enum([ "ALL_CAPS", "polyglot", "asl", + "slow_scroll", "no_quit", ]); export type FunboxName = z.infer;