diff --git a/frontend/__tests__/utils/numbers.spec.ts b/frontend/__tests__/utils/numbers.spec.ts index ff3a147b1f41..ebb06b50ab50 100644 --- a/frontend/__tests__/utils/numbers.spec.ts +++ b/frontend/__tests__/utils/numbers.spec.ts @@ -46,4 +46,33 @@ describe("numbers", () => { expect(Numbers.abbreviateNumber((number *= 1000))).toEqual("1.0d"); }); }); + describe("parseIntOptional", () => { + it("should return a number when given a valid string", () => { + expect(Numbers.parseIntOptional("123")).toBe(123); + expect(Numbers.parseIntOptional("42")).toBe(42); + expect(Numbers.parseIntOptional("0")).toBe(0); + }); + + it("should return undefined when given null", () => { + expect(Numbers.parseIntOptional(null)).toBeUndefined(); + }); + + it("should return undefined when given undefined", () => { + expect(Numbers.parseIntOptional(undefined)).toBeUndefined(); + }); + + it("should handle non-numeric strings", () => { + expect(Numbers.parseIntOptional("abc")).toBeNaN(); + expect(Numbers.parseIntOptional("12abc")).toBe(12); // parseInt stops at non-numeric chars + }); + + it("should handle leading and trailing spaces", () => { + expect(Numbers.parseIntOptional(" 42 ")).toBe(42); + }); + it("should return a number when given a valid string and radix", () => { + expect(Numbers.parseIntOptional("1010", 2)).toBe(10); + expect(Numbers.parseIntOptional("CF", 16)).toBe(207); + expect(Numbers.parseIntOptional("C", 26)).toBe(12); + }); + }); }); diff --git a/frontend/src/ts/commandline/commandline.ts b/frontend/src/ts/commandline/commandline.ts index 5df7d9e2e87d..b2370049d860 100644 --- a/frontend/src/ts/commandline/commandline.ts +++ b/frontend/src/ts/commandline/commandline.ts @@ -11,6 +11,9 @@ import * as ActivePage from "../states/active-page"; import { focusWords } from "../test/test-ui"; import * as Loader from "../elements/loader"; import { Command, CommandsSubgroup } from "./types"; +import { areSortedArraysEqual } from "../utils/arrays"; +import { parseIntOptional } from "../utils/numbers"; +import { debounce } from "throttle-debounce"; type CommandlineMode = "search" | "input"; type InputModeParams = { @@ -325,6 +328,7 @@ function hideCommands(): void { throw new Error("Commandline element not found"); } element.innerHTML = ""; + lastList = undefined; } let cachedSingleSubgroup: CommandsSubgroup | null = null; @@ -349,6 +353,8 @@ async function getList(): Promise { return (await getSubgroup()).list; } +let lastList: Command[] | undefined; + async function showCommands(): Promise { const element = document.querySelector("#commandLine .suggestions"); if (element === null) { @@ -356,11 +362,15 @@ async function showCommands(): Promise { } if (inputValue === "" && usingSingleList) { - element.innerHTML = ""; + hideCommands(); return; } const list = (await getList()).filter((c) => c.found === true); + if (lastList && areSortedArraysEqual(list, lastList)) { + return; + } + lastList = list; let html = ""; let index = 0; @@ -458,28 +468,8 @@ async function showCommands(): Promise { if (firstActive !== null && !usingSingleList) { activeIndex = firstActive; } - element.innerHTML = html; - for (const command of element.querySelectorAll(".command")) { - command.addEventListener("mouseenter", async () => { - if (!mouseMode) return; - activeIndex = parseInt(command.getAttribute("data-index") ?? "0"); - await updateActiveCommand(); - }); - command.addEventListener("mouseleave", async () => { - if (!mouseMode) return; - activeIndex = parseInt(command.getAttribute("data-index") ?? "0"); - await updateActiveCommand(); - }); - command.addEventListener("click", async () => { - const previous = activeIndex; - activeIndex = parseInt(command.getAttribute("data-index") ?? "0"); - if (previous !== activeIndex) { - await updateActiveCommand(); - } - await runActiveCommand(); - }); - } + element.innerHTML = html; } async function updateActiveCommand(): Promise { @@ -573,23 +563,20 @@ async function runActiveCommand(): Promise { } } +let lastActiveIndex: string | undefined; function keepActiveCommandInView(): void { if (mouseMode) return; - try { - const scroll = - Math.abs( - ($(".suggestions").offset()?.top as number) - - ($(".command.active").offset()?.top as number) - - ($(".suggestions").scrollTop() as number) - ) - - ($(".suggestions").outerHeight() as number) / 2 + - ($($(".command")[0] as HTMLElement).outerHeight() as number); - $(".suggestions").scrollTop(scroll); - } catch (e) { - if (e instanceof Error) { - console.log("could not scroll suggestions: " + e.message); - } + + const active: HTMLElement | null = document.querySelector( + ".suggestions .command.active" + ); + + if (active === null || active.dataset["index"] === lastActiveIndex) { + return; } + + active.scrollIntoView({ behavior: "auto", block: "center" }); + lastActiveIndex = active.dataset["index"]; } function updateInput(setInput?: string): void { @@ -665,22 +652,25 @@ const modal = new AnimatedModal({ setup: async (modalEl): Promise => { const input = modalEl.querySelector("input") as HTMLInputElement; - input.addEventListener("input", async (e) => { - inputValue = (e.target as HTMLInputElement).value; - if (subgroupOverride === null) { - if (Config.singleListCommandLine === "on") { - usingSingleList = true; - } else { - usingSingleList = inputValue.startsWith(">"); + input.addEventListener( + "input", + debounce(50, async (e) => { + inputValue = (e.target as HTMLInputElement).value; + if (subgroupOverride === null) { + if (Config.singleListCommandLine === "on") { + usingSingleList = true; + } else { + usingSingleList = inputValue.startsWith(">"); + } } - } - if (mode !== "search") return; - mouseMode = false; - activeIndex = 0; - await filterSubgroup(); - await showCommands(); - await updateActiveCommand(); - }); + if (mode !== "search") return; + mouseMode = false; + activeIndex = 0; + await filterSubgroup(); + await showCommands(); + await updateActiveCommand(); + }) + ); input.addEventListener("keydown", async (e) => { mouseMode = false; @@ -740,5 +730,36 @@ const modal = new AnimatedModal({ modalEl.addEventListener("mousemove", (_e) => { mouseMode = true; }); + + const suggestions = document.querySelector(".suggestions") as HTMLElement; + let lastHover: HTMLElement | undefined; + + suggestions.addEventListener("mousemove", async (e) => { + const target = e.target as HTMLElement | null; + if (target === lastHover) return; + + const dataIndex = parseIntOptional(target?.getAttribute("data-index")); + + if (!dataIndex) return; + + lastHover = e.target as HTMLElement; + activeIndex = dataIndex; + await updateActiveCommand(); + }); + + suggestions.addEventListener("click", async (e) => { + const target = e.target as HTMLElement | null; + + const dataIndex = parseIntOptional(target?.getAttribute("data-index")); + + if (!dataIndex) return; + + const previous = activeIndex; + activeIndex = dataIndex; + if (previous !== activeIndex) { + await updateActiveCommand(); + } + await runActiveCommand(); + }); }, }); diff --git a/frontend/src/ts/utils/numbers.ts b/frontend/src/ts/utils/numbers.ts index d4a831f8e91f..7e2a30a0a6cf 100644 --- a/frontend/src/ts/utils/numbers.ts +++ b/frontend/src/ts/utils/numbers.ts @@ -133,3 +133,19 @@ export function findLineByLeastSquares( ]; return [returnpoint1, returnpoint2]; } + +/** + * Parses a string into an integer if it is not null or undefined, otherwise returns undefined. + * + * @param The string to parse or null or undefined. + * @param radix A value between 2 and 36 that specifies the base of the number in `string`. + * @returns A number if a string is provided, otherwise undefined. + */ +export function parseIntOptional( + value: T, + radix: number = 10 +): T extends string ? number : undefined { + return ( + value !== null && value !== undefined ? parseInt(value, radix) : undefined + ) as T extends string ? number : undefined; +}