diff --git a/backend/api/schemas/config-schema.ts b/backend/api/schemas/config-schema.ts index b3b7c0459..a7daea0dd 100644 --- a/backend/api/schemas/config-schema.ts +++ b/backend/api/schemas/config-schema.ts @@ -88,6 +88,7 @@ const CONFIG_SCHEMA = joi.object({ minWpm: joi.string().valid("off", "custom"), minWpmCustomSpeed: joi.number().min(0), highlightMode: joi.string().valid("off", "letter", "word"), + tapeMode: joi.string().valid("off", "letter", "word"), alwaysShowCPM: joi.boolean(), enableAds: joi.string().valid("off", "on", "max"), hideExtraLetters: joi.boolean(), diff --git a/frontend/src/scripts/config.ts b/frontend/src/scripts/config.ts index dc024fb5f..f6536a83e 100644 --- a/frontend/src/scripts/config.ts +++ b/frontend/src/scripts/config.ts @@ -528,6 +528,11 @@ export function setCapsLockWarning(val: boolean, nosave?: boolean): boolean { export function setShowAllLines(sal: boolean, nosave?: boolean): boolean { if (!isConfigValueValid("show all lines", sal, ["boolean"])) return false; + if (sal && config.tapeMode !== "off") { + Notifications.add("Show all lines doesn't support tape mode", 0); + return false; + } + config.showAllLines = sal; saveToLocalStorage("showAllLines", nosave); ConfigEvent.dispatch("showAllLines", config.showAllLines, nosave); @@ -832,6 +837,25 @@ export function setHighlightMode( return true; } +export function setTapeMode( + mode: MonkeyTypes.TapeMode, + nosave?: boolean +): boolean { + if (!isConfigValueValid("tape mode", mode, [["off", "letter", "word"]])) { + return false; + } + + if (mode !== "off" && config.showAllLines === true) { + setShowAllLines(false, true); + } + + config.tapeMode = mode; + saveToLocalStorage("tapeMode", nosave); + ConfigEvent.dispatch("tapeMode", config.tapeMode); + + return true; +} + export function setHideExtraLetters(val: boolean, nosave?: boolean): boolean { if (!isConfigValueValid("hide extra letters", val, ["boolean"])) return false; @@ -1793,6 +1817,7 @@ export function apply( setBritishEnglish(configObj.britishEnglish, true); setLazyMode(configObj.lazyMode, true); setShowAverage(configObj.showAverage, true); + setTapeMode(configObj.tapeMode, true); try { setEnableAds(configObj.enableAds, true); diff --git a/frontend/src/scripts/constants/default-config.ts b/frontend/src/scripts/constants/default-config.ts index 8121906de..e354efdc4 100644 --- a/frontend/src/scripts/constants/default-config.ts +++ b/frontend/src/scripts/constants/default-config.ts @@ -96,4 +96,5 @@ export default { britishEnglish: false, lazyMode: false, showAverage: "off", + tapeMode: "off", }; diff --git a/frontend/src/scripts/controllers/input-controller.ts b/frontend/src/scripts/controllers/input-controller.ts index 1df462b4e..5bd9fe305 100644 --- a/frontend/src/scripts/controllers/input-controller.ts +++ b/frontend/src/scripts/controllers/input-controller.ts @@ -255,7 +255,11 @@ function handleSpace(): void { nextTop = 0; } - if (nextTop > currentTop && !TestUI.lineTransition) { + if ( + Config.tapeMode === "off" && + nextTop > currentTop && + !TestUI.lineTransition + ) { TestUI.lineJump(currentTop); } } //end of line wrap @@ -816,6 +820,9 @@ $(document).keydown(async (event) => { handleChar(char, TestInput.input.current.length); updateUI(); setWordsInput(" " + TestInput.input.current); + if (Config.tapeMode !== "off") { + TestUI.scrollTape(); + } } } }); @@ -888,6 +895,9 @@ $("#wordsInput").on("input", (event) => { setWordsInput(" " + TestInput.input.current); updateUI(); + if (Config.tapeMode !== "off") { + TestUI.scrollTape(); + } // force caret at end of input // doing it on next cycle because Chromium on Android won't let me edit diff --git a/frontend/src/scripts/elements/commandline-lists.ts b/frontend/src/scripts/elements/commandline-lists.ts index f4f7d201d..f7ef87e62 100644 --- a/frontend/src/scripts/elements/commandline-lists.ts +++ b/frontend/src/scripts/elements/commandline-lists.ts @@ -1729,6 +1729,37 @@ const commandsHighlightMode: MonkeyTypes.CommandsGroup = { ], }; +const commandsTapeMode: MonkeyTypes.CommandsGroup = { + title: "Tape mode...", + configKey: "tapeMode", + list: [ + { + id: "setTapeModeOff", + display: "off", + configValue: "off", + exec: (): void => { + UpdateConfig.setTapeMode("off"); + }, + }, + { + id: "setTapeModeLetter", + display: "letter", + configValue: "letter", + exec: (): void => { + UpdateConfig.setTapeMode("letter"); + }, + }, + { + id: "setTapeModeWord", + display: "word", + configValue: "word", + exec: (): void => { + UpdateConfig.setTapeMode("word"); + }, + }, + ], +}; + const commandsTimerStyle: MonkeyTypes.CommandsGroup = { title: "Timer/progress style...", configKey: "timerStyle", @@ -2948,6 +2979,12 @@ export const defaultCommands: MonkeyTypes.CommandsGroup = { icon: "fa-highlighter", subgroup: commandsHighlightMode, }, + { + id: "changeTapeMode", + display: "Tape mode...", + icon: "fa-tape", + subgroup: commandsTapeMode, + }, { id: "changeShowAverage", display: "Show average...", diff --git a/frontend/src/scripts/pages/settings.ts b/frontend/src/scripts/pages/settings.ts index 3f011e20c..60a1e8639 100644 --- a/frontend/src/scripts/pages/settings.ts +++ b/frontend/src/scripts/pages/settings.ts @@ -334,6 +334,11 @@ async function initGroups(): Promise { UpdateConfig.setHighlightMode, "button" ); + groups["tapeMode"] = new SettingsGroup( + "tapeMode", + UpdateConfig.setTapeMode, + "button" + ); groups["timerOpacity"] = new SettingsGroup( "timerOpacity", UpdateConfig.setTimerOpacity, diff --git a/frontend/src/scripts/test/caret.ts b/frontend/src/scripts/test/caret.ts index d33a28090..65efaac47 100644 --- a/frontend/src/scripts/test/caret.ts +++ b/frontend/src/scripts/test/caret.ts @@ -36,6 +36,9 @@ export async function updatePosition(): Promise { // } const caret = $("#caret"); + const caretWidth = Math.round( + document.querySelector("#caret")?.getBoundingClientRect().width ?? 0 + ); let inputLen = TestInput.input.current.length; inputLen = Misc.trailingComposeChars.test(TestInput.input.current) @@ -78,18 +81,43 @@ export async function updatePosition(): Promise { let newLeft = 0; newTop = currentLetterPosTop - Math.round(letterHeight / 5); - if (inputLen == 0) { - newLeft = isLanguageLeftToRight - ? currentLetterPosLeft - (caret.width() as number) / 2 - : currentLetterPosLeft + (caret.width() as number) / 2; + + if (Config.tapeMode === "letter") { + newLeft = + ($(document.querySelector("#wordsWrapper")).width() ?? 0) / + 2 - + caretWidth / 2; + } else if (Config.tapeMode === "word") { + if (inputLen == 0) { + newLeft = + ($(document.querySelector("#wordsWrapper")).width() ?? 0) / + 2 - + caretWidth / 2; + } else { + let inputWidth = 0; + for (let i = 0; i < inputLen; i++) { + inputWidth += $(currentWordNodeList[i]).outerWidth(true) as number; + } + newLeft = + ($(document.querySelector("#wordsWrapper")).width() ?? 0) / + 2 + + inputWidth - + caretWidth / 2; + } } else { - newLeft = isLanguageLeftToRight - ? currentLetterPosLeft + - ($(currentLetter).width() as number) - - (caret.width() as number) / 2 - : currentLetterPosLeft - - ($(currentLetter).width() as number) + - (caret.width() as number) / 2; + if (inputLen == 0) { + newLeft = isLanguageLeftToRight + ? currentLetterPosLeft - caretWidth / 2 + : currentLetterPosLeft + caretWidth / 2; + } else { + newLeft = isLanguageLeftToRight + ? currentLetterPosLeft + + ($(currentLetter).width() as number) - + caretWidth / 2 + : currentLetterPosLeft - + ($(currentLetter).width() as number) + + caretWidth / 2; + } } let smoothlinescroll = $("#words .smoothScroller").height(); diff --git a/frontend/src/scripts/test/test-logic.ts b/frontend/src/scripts/test/test-logic.ts index 8bd5bfdc0..e1c88f889 100644 --- a/frontend/src/scripts/test/test-logic.ts +++ b/frontend/src/scripts/test/test-logic.ts @@ -1736,8 +1736,9 @@ $(document).on("click", "#top #menu #startTestButton, #top .logo", () => { ConfigEvent.subscribe((eventKey, eventValue, nosave) => { if (eventKey === "difficulty" && !nosave) restart(false, nosave); - if (eventKey === "showAllLines" && !nosave) restart(); + if (eventKey === "showAllLines" && !nosave) restart(false, nosave); if (eventKey === "keymapMode" && !nosave) restart(false, nosave); + if (eventKey === "tapeMode" && !nosave) restart(false, nosave); if (eventKey === "lazyMode" && eventValue === false && !nosave) { rememberLazyMode = false; } diff --git a/frontend/src/scripts/test/test-ui.ts b/frontend/src/scripts/test/test-ui.ts index 7509b70fb..823d54e6d 100644 --- a/frontend/src/scripts/test/test-ui.ts +++ b/frontend/src/scripts/test/test-ui.ts @@ -167,13 +167,29 @@ export function showWords(): void { } $(".outOfFocusWarning").css("line-height", nh + "px"); } else { - $("#words") - .css("height", wordHeight * 4 + "px") - .css("overflow", "hidden"); - $("#wordsWrapper") - .css("height", wordHeight * 3 + "px") - .css("overflow", "hidden"); - $(".outOfFocusWarning").css("line-height", wordHeight * 3 + "px"); + if (Config.tapeMode !== "off") { + $("#words") + .css("height", wordHeight * 2 + "px") + .css("overflow", "hidden") + .css("width", "200%") + .css("margin-left", "50%"); + $("#words").addClass("tape"); + $("#wordsWrapper") + .css("height", wordHeight * 1 + "px") + .css("overflow", "hidden"); + $(".outOfFocusWarning").css("line-height", wordHeight * 1 + "px"); + } else { + $("#words") + .css("height", wordHeight * 4 + "px") + .css("overflow", "hidden") + .css("width", "100%") + .css("margin-left", "unset"); + $("#words").removeClass("tape"); + $("#wordsWrapper") + .css("height", wordHeight * 3 + "px") + .css("overflow", "hidden"); + $(".outOfFocusWarning").css("line-height", wordHeight * 3 + "px"); + } } if (Config.mode === "zen") { @@ -481,6 +497,60 @@ export function updateWordElement(showError = !Config.blindMode): void { if (newlineafter) $("#words").append("
"); } +export function scrollTape(): void { + const wordsWrapperWidth = (( + document.querySelector("#wordsWrapper") + )).offsetWidth; + let fullWordsWidth = 0; + const toHide: JQuery[] = []; + let widthToHide = 0; + if (currentWordElementIndex > 0) { + for (let i = 0; i < currentWordElementIndex; i++) { + const word = document.querySelectorAll("#words .word")[i]; + fullWordsWidth += $(word).outerWidth(true) ?? 0; + const forWordLeft = Math.floor(word.offsetLeft); + const forWordWidth = Math.floor(word.offsetWidth); + if (forWordLeft < 0 - forWordWidth) { + const toPush = $($("#words .word")[i]); + toHide.push(toPush); + widthToHide += toPush.outerWidth(true) ?? 0; + } + } + if (toHide.length > 0) { + currentWordElementIndex -= toHide.length; + toHide.forEach((e) => e.remove()); + fullWordsWidth -= widthToHide; + const currentMargin = parseInt($("#words").css("margin-left"), 10); + $("#words").css("margin-left", `${currentMargin + widthToHide}px`); + } + } + let currentWordWidth = 0; + if (Config.tapeMode === "letter") { + if (TestInput.input.current.length > 0) { + for (let i = 0; i < TestInput.input.current.length; i++) { + const words = document.querySelectorAll("#words .word"); + currentWordWidth += + $( + words[currentWordElementIndex].querySelectorAll("letter")[i] + ).outerWidth(true) ?? 0; + } + } + } + const newMargin = wordsWrapperWidth / 2 - (fullWordsWidth + currentWordWidth); + if (Config.smoothLineScroll) { + $("#words") + .stop(true, false) + .animate( + { + marginLeft: newMargin, + }, + SlowTimer.get() ? 0 : 125 + ); + } else { + $("#words").css("margin-left", `${newMargin}px`); + } +} + export function lineJump(currentTop: number): void { //last word of the line if (currentTestLine > 0) { diff --git a/frontend/src/scripts/types/types.d.ts b/frontend/src/scripts/types/types.d.ts index 87a278164..7c07ef297 100644 --- a/frontend/src/scripts/types/types.d.ts +++ b/frontend/src/scripts/types/types.d.ts @@ -71,6 +71,8 @@ declare namespace MonkeyTypes { type ShowAverage = "off" | "wpm" | "acc" | "both"; + type TapeMode = "off" | "letter" | "word"; + type SingleListCommandLine = "manual" | "on"; /* @@ -377,6 +379,7 @@ declare namespace MonkeyTypes { britishEnglish: boolean; lazyMode: boolean; showAverage: ShowAverage; + tapeMode: TapeMode; } type ConfigValues = diff --git a/frontend/src/styles/test.scss b/frontend/src/styles/test.scss index ab94b8053..b61573edf 100644 --- a/frontend/src/styles/test.scss +++ b/frontend/src/styles/test.scss @@ -253,6 +253,33 @@ } } } + + &.tape { + &.size125 .word { + margin: 0.31rem; + margin: 0.31rem 0.62rem 0.31rem 0; + } + + &.size15 .word { + margin: 0.37rem; + margin: 0.37rem 0.74rem 0.37rem 0; + } + + &.size2 .word { + margin: 0.5rem; + margin: 0.5rem 1rem 0.5rem 0; + } + + &.size3 .word { + margin: 0.75rem; + margin: 0.75rem 1.5rem 0.75rem 0; + } + + &.size4 .word { + margin: 1rem; + margin: 1rem 2rem 1rem 0; + } + } } .word { diff --git a/frontend/static/challenges/_list.json b/frontend/static/challenges/_list.json index cc2bcec28..858708828 100644 --- a/frontend/static/challenges/_list.json +++ b/frontend/static/challenges/_list.json @@ -246,19 +246,34 @@ "name": "inAGalaxyFarFarAway", "display": "In a galaxy far far away", "type": "script", - "parameters": ["episode4.txt",null,"space_balls"] + "parameters": ["episode4.txt",null,"space_balls"], + "requirements": { + "config": { + "tapeMode": "off" + } + } } ,{ "name": "whosYourDaddy", "display": "Who's your daddy?", "type": "script", - "parameters": ["episode5.txt",null,"space_balls"] + "parameters": ["episode5.txt",null,"space_balls"], + "requirements": { + "config": { + "tapeMode": "off" + } + } } ,{ "name": "itsATrap", "display": "It's a trap!", "type": "script", - "parameters": ["episode6.txt",null,"space_balls"] + "parameters": ["episode6.txt",null,"space_balls"], + "requirements": { + "config": { + "tapeMode": "off" + } + } } ,{ "name": "jolly", @@ -399,7 +414,12 @@ "name": "mnemonist", "display": "Mnemonist", "type": "funbox", - "parameters": ["memory","words",25,"master"] + "parameters": ["memory","words",25,"master"], + "requirements": { + "config": { + "tapeMode": "off" + } + } } ,{ "name": "earfquake", @@ -461,6 +481,9 @@ }, "funbox": { "exact": "read_ahead" + }, + "config": { + "tapeMode": "off" } } } @@ -479,6 +502,9 @@ }, "funbox": { "exact": "read_ahead_hard" + }, + "config": { + "tapeMode": "off" } } } diff --git a/frontend/static/html/pages/settings.html b/frontend/static/html/pages/settings.html index 0a86be8d6..78b60e50a 100644 --- a/frontend/static/html/pages/settings.html +++ b/frontend/static/html/pages/settings.html @@ -1257,6 +1257,31 @@ +
+

tape mode

+
+ Only shows one line which scrolls horizontally. Setting this to 'word' + will make it scroll after every word and 'letter' will scroll after + every keypress. Works best with smooth line scroll enabled and a + monospace font. +
+
+
+ off +
+
+ letter +
+
+ word +
+
+

smooth line scroll