diff --git a/frontend/src/html/pages/settings.html b/frontend/src/html/pages/settings.html index 28fb1dacec1b..6062b40573b8 100644 --- a/frontend/src/html/pages/settings.html +++ b/frontend/src/html/pages/settings.html @@ -1236,6 +1236,59 @@ + + + +
+
+ + keymap custom + +
+
Import or paste your custom keymap.
+
+
+ +
+
+ + + +
+
+
+ or +
+
+
+
+
+ + +
diff --git a/frontend/src/styles/keymap.scss b/frontend/src/styles/keymap.scss index 078df6d6ceb8..ec46c4635716 100644 --- a/frontend/src/styles/keymap.scss +++ b/frontend/src/styles/keymap.scss @@ -544,4 +544,39 @@ position: relative; } } + &.custom { + grid-template-rows: 2rem 2rem 2rem 2rem; + .r1, + .r2, + .r3, + .r4, + .r5, + .r6 { + display: grid; + justify-content: start; + gap: 0; + } + + .keyboard-grid { + display: grid; + grid-template-columns: repeat(var(--columns), var(--colSpan)); + grid-template-rows: repeat(var(--rows), var(--rowSpan)); + } + + .keymapKey { + width: 100%; + height: auto; + margin: 0.125rem; + } + + .keySpace { + font-size: 0.5rem; + } + + .keymapSplitSpacer { + display: block; + width: 2rem; + height: 2rem; + } + } } diff --git a/frontend/src/styles/settings.scss b/frontend/src/styles/settings.scss index f1a923d2722f..7f694d8f88ac 100644 --- a/frontend/src/styles/settings.scss +++ b/frontend/src/styles/settings.scss @@ -144,6 +144,29 @@ } } + .textareaAndButton { + display: grid; + grid-template-columns: 1fr; + gap: 0.5rem; + margin-bottom: 0.5rem; + + span { + display: flex; + gap: 0.5rem; + } + .button { + height: auto; + + .fas { + margin-right: 0rem; + } + } + .textarea { + height: 200px; + resize: none; + } + } + .rangeGroup { display: grid; grid-template-columns: auto 1fr; @@ -169,6 +192,7 @@ } &[data-config-name="fontFamily"], + &[data-config-name="keymapCustom"], &[data-config-name="customBackgroundSize"] { .separator { margin-bottom: 0.5rem; @@ -187,6 +211,7 @@ } } + &[data-config-name="keymapCustom"], &[data-config-name="fontFamily"] { grid-template-areas: "title title" @@ -198,6 +223,7 @@ .separator { margin-bottom: 0; } + .usingLocalKeymap, .usingLocalFont { button { width: 100%; diff --git a/frontend/src/ts/commandline/commandline-metadata.ts b/frontend/src/ts/commandline/commandline-metadata.ts index 4cc09cad4f56..7e5fdec2061f 100644 --- a/frontend/src/ts/commandline/commandline-metadata.ts +++ b/frontend/src/ts/commandline/commandline-metadata.ts @@ -26,7 +26,8 @@ type ConfigKeysWithoutCommands = | "autoSwitchTheme" | "themeLight" | "themeDark" - | "burstHeatmap"; + | "burstHeatmap" + | "keymapCustom"; type SkippedConfigKeys = | "minBurst" //this is skipped for now because it has 2 nested inputs; diff --git a/frontend/src/ts/commandline/lists.ts b/frontend/src/ts/commandline/lists.ts index bd4d2931425e..e489de27c300 100644 --- a/frontend/src/ts/commandline/lists.ts +++ b/frontend/src/ts/commandline/lists.ts @@ -6,6 +6,7 @@ import NavigationCommands from "./lists/navigation"; import ResultScreenCommands from "./lists/result-screen"; import CustomBackgroundCommands from "./lists/custom-background"; import FontFamilyCommands from "./lists/font-family"; +import KeymapStyleCommands from "./lists/keymap-style"; import CustomBackgroundFilterCommands from "./lists/background-filter"; import AddOrRemoveThemeToFavorite from "./lists/add-or-remove-theme-to-favorites"; import TagsCommands from "./lists/tags"; @@ -178,7 +179,7 @@ export const commands: CommandsSubgroup = { "fontSize", ...FontFamilyCommands, "keymapMode", - "keymapStyle", + ...KeymapStyleCommands, "keymapLegendStyle", "keymapSize", keymapLayoutCommand, diff --git a/frontend/src/ts/commandline/lists/keymap-style.ts b/frontend/src/ts/commandline/lists/keymap-style.ts new file mode 100644 index 000000000000..77b868f506e4 --- /dev/null +++ b/frontend/src/ts/commandline/lists/keymap-style.ts @@ -0,0 +1,48 @@ +import { buildCommandForConfigKey } from "../util"; +import { KeymapCustom } from "@monkeytype/schemas/configs"; +import { stringToKeymap } from "../../utils/custom-keymap"; +import * as UpdateConfig from "../../config"; +import * as TestLogic from "../../test/test-logic"; +import { Command } from "../types"; + +const fromMeta = buildCommandForConfigKey("keymapStyle"); + +if (fromMeta.subgroup) { + const indexCustom = fromMeta.subgroup.list.findIndex( + (command) => command.id === "setKeymapStyleCustom" + ); + fromMeta.subgroup.list.splice(indexCustom, 1, { + id: "setKeymapStyleCustom", + display: "custom...", + configValue: "custom", + icon: "fa-keyboard", + subgroup: { + title: "Set custom keymap?", + list: [ + { + id: "setKeymapStyleCustomNew", + display: "new keymap", + input: true, + exec: ({ input }) => { + if (input === undefined || input === "") return; + const keymap: KeymapCustom = stringToKeymap(input); + UpdateConfig.setKeymapCustom(keymap); + UpdateConfig.setKeymapStyle("custom"); + TestLogic.restart(); + }, + }, + { + id: "setKeymapStyleCustomLoad", + display: "load keymap", + exec: () => { + UpdateConfig.setKeymapStyle("custom"); + }, + }, + ], + }, + }); +} + +const commands: Command[] = [fromMeta]; + +export default commands; diff --git a/frontend/src/ts/commandline/util.ts b/frontend/src/ts/commandline/util.ts index 347baeddc49f..451efb0f9359 100644 --- a/frontend/src/ts/commandline/util.ts +++ b/frontend/src/ts/commandline/util.ts @@ -1,6 +1,6 @@ import Config, { genericSet } from "../config"; import { ConfigMetadata, configMetadata } from "../config-metadata"; -import { capitalizeFirstLetter } from "../utils/strings"; +import { capitalizeFirstLetter, stringifyConfigValue } from "../utils/strings"; import { CommandlineConfigMetadata, commandlineConfigMetadata, @@ -171,13 +171,13 @@ function buildSubgroupCommand( } else if (value === false) { displayString = "off"; } else { - displayString = value.toString(); + displayString = stringifyConfigValue(value); } } return { id: `set${capitalizeFirstLetter(key)}${capitalizeFirstLetter( - val.toString() + stringifyConfigValue(val) )}`, display: displayString, configValueMode: commandConfigValueMode?.(value), @@ -223,7 +223,8 @@ function buildInputCommand({ const result = { id: `set${capitalizeFirstLetter(key)}Custom`, defaultValue: - inputProps?.defaultValue ?? (() => Config[key]?.toString() ?? ""), + inputProps?.defaultValue ?? + (() => stringifyConfigValue(Config[key]) ?? ""), configValue: inputProps !== undefined && "configValue" in inputProps ? inputProps.configValue ?? undefined diff --git a/frontend/src/ts/config-metadata.ts b/frontend/src/ts/config-metadata.ts index cbd64a2390e2..bb65f4bb0b18 100644 --- a/frontend/src/ts/config-metadata.ts +++ b/frontend/src/ts/config-metadata.ts @@ -583,6 +583,13 @@ export const configMetadata: ConfigMetadataObject = { overrideConfig: ({ currentConfig }) => currentConfig.keymapMode === "off" ? { keymapMode: "static" } : {}, }, + keymapCustom: { + icon: "fa-keyboard", + displayString: "keymap custom", + changeRequiresRestart: false, + overrideConfig: ({ currentConfig }) => + currentConfig.keymapMode === "off" ? { keymapMode: "static" } : {}, + }, keymapLegendStyle: { icon: "fa-keyboard", displayString: "keymap legend style", diff --git a/frontend/src/ts/config.ts b/frontend/src/ts/config.ts index cf732b09f53a..207329d856d4 100644 --- a/frontend/src/ts/config.ts +++ b/frontend/src/ts/config.ts @@ -27,6 +27,7 @@ import { ZodSchema } from "zod"; import * as TestState from "./test/test-state"; import { ConfigMetadataObject, configMetadata } from "./config-metadata"; import { FontName } from "@monkeytype/schemas/fonts"; +import { stringifyConfigValue } from "./utils/strings"; const configLS = new LocalStorageWithSchema({ key: "config", @@ -178,7 +179,11 @@ export function genericSet( const set = genericSet(targetKey, targetValue, nosave); if (!set) { throw new Error( - `Failed to set config key "${targetKey}" with value "${targetValue}" for ${metadata.displayString} config override.` + `Failed to set config key "${stringifyConfigValue( + targetKey + )}" with value "${stringifyConfigValue(targetValue)}" for ${ + metadata.displayString + } config override.` ); } } @@ -712,6 +717,13 @@ export function setKeymapStyle( return genericSet("keymapStyle", style, nosave); } +export function setKeymapCustom( + keymapCustom: ConfigSchemas.KeymapCustom, + nosave?: boolean +): boolean { + return genericSet("keymapCustom", keymapCustom, nosave); +} + export function setKeymapLayout( layout: ConfigSchemas.KeymapLayout, nosave?: boolean diff --git a/frontend/src/ts/constants/data-keys.ts b/frontend/src/ts/constants/data-keys.ts new file mode 100644 index 000000000000..f620c4c81f20 --- /dev/null +++ b/frontend/src/ts/constants/data-keys.ts @@ -0,0 +1,48 @@ +export const dataKeys: { [key: string]: string } = { + a: "aA", + b: "bB", + c: "cC", + d: "dD", + e: "eE", + f: "fF", + g: "gG", + h: "hH", + i: "iI", + j: "jJ", + k: "kK", + l: "lL", + m: "mM", + n: "nN", + o: "oO", + p: "pP", + q: "qQ", + r: "rR", + s: "sS", + t: "tT", + u: "uU", + v: "vV", + w: "wW", + x: "xX", + y: "yY", + z: "zZ", + 1: "1!", + 2: "2@", + 3: "3#", + 4: "4$", + 5: "5%", + 6: "6^", + 7: "7&", + 8: "8*", + 9: "9(", + 0: "0)", + "-": "-_", + "=": "=+", + "[": "[{", + "]": "]}", + "\\": "\\|", + ";": ";:", + "'": "'"", + ",": ",<", + ".": ".>", + "/": "/?", +}; diff --git a/frontend/src/ts/constants/default-config.ts b/frontend/src/ts/constants/default-config.ts index 61b2458f4034..8545ea188b5c 100644 --- a/frontend/src/ts/constants/default-config.ts +++ b/frontend/src/ts/constants/default-config.ts @@ -58,6 +58,7 @@ const obj = { keymapLayout: "overrideSync", keymapShowTopRow: "layout", keymapSize: 1, + keymapCustom: [[]], fontFamily: "Roboto_Mono", smoothLineScroll: false, alwaysShowDecimalPlaces: false, diff --git a/frontend/src/ts/constants/layouts.ts b/frontend/src/ts/constants/layouts.ts index ff8f5a9ec340..04f5f09b41ed 100644 --- a/frontend/src/ts/constants/layouts.ts +++ b/frontend/src/ts/constants/layouts.ts @@ -1,3 +1,3 @@ import { LayoutName, LayoutNameSchema } from "@monkeytype/schemas/layouts"; -export const LayoutsList:LayoutName[] = LayoutNameSchema._def.values; \ No newline at end of file +export const LayoutsList: LayoutName[] = LayoutNameSchema._def.values; diff --git a/frontend/src/ts/controllers/challenge-controller.ts b/frontend/src/ts/controllers/challenge-controller.ts index 30b6c5b6cc56..71d01c8cfad4 100644 --- a/frontend/src/ts/controllers/challenge-controller.ts +++ b/frontend/src/ts/controllers/challenge-controller.ts @@ -20,6 +20,7 @@ import { CompletedEvent } from "@monkeytype/schemas/results"; import { areUnsortedArraysEqual } from "../utils/arrays"; import { tryCatch } from "@monkeytype/util/trycatch"; import { Challenge } from "@monkeytype/schemas/challenges"; +import { stringifyConfigValue } from "../utils/strings"; let challengeLoading = false; @@ -133,7 +134,11 @@ function verifyRequirement( const configValue = requirementValue[configKey]; if (Config[configKey as keyof ConfigType] !== configValue) { requirementsMet = false; - failReasons.push(`${configKey} not set to ${configValue}`); + failReasons.push( + `${stringifyConfigValue(configKey)} not set to ${stringifyConfigValue( + configValue + )}` + ); } } } diff --git a/frontend/src/ts/elements/keymap.ts b/frontend/src/ts/elements/keymap.ts index da8a8dae4065..08a41ba28df3 100644 --- a/frontend/src/ts/elements/keymap.ts +++ b/frontend/src/ts/elements/keymap.ts @@ -14,6 +14,12 @@ import * as ShiftTracker from "../test/shift-tracker"; import * as AltTracker from "../test/alt-tracker"; import * as KeyConverter from "../utils/key-converter"; import { getActiveFunboxNames } from "../test/funbox/list"; +import { getCustomKeymapSyle } from "../utils/custom-keymap"; +import { + KeymapCustom, + KeymapLayout, + KeymapLegendStyle, +} from "@monkeytype/schemas/configs"; import { areSortedArraysEqual } from "../utils/arrays"; import { LayoutObject } from "@monkeytype/schemas/layouts"; @@ -458,6 +464,22 @@ export async function refresh(): Promise { }); } + if (Config.keymapStyle === "custom") { + const { + keymapCustom, + keymapLayout, + keymapLegendStyle, + }: { + keymapCustom: KeymapCustom; + keymapLayout: KeymapLayout; + keymapLegendStyle: KeymapLegendStyle; + } = Config; + keymapElement = getCustomKeymapSyle( + keymapCustom, + keymapLayout, + keymapLegendStyle + ); + } $("#keymap").html(keymapElement); $("#keymap").removeClass("staggered"); @@ -467,6 +489,7 @@ export async function refresh(): Promise { $("#keymap").removeClass("alice"); $("#keymap").removeClass("steno"); $("#keymap").removeClass("steno_matrix"); + $("#keymap").removeClass("custom"); $("#keymap").addClass(Config.keymapStyle); } catch (e) { if (e instanceof Error) { @@ -552,7 +575,11 @@ async function updateLegends(): Promise { } for (let i = 0; i < layoutKeys.length; i++) { - const layoutKey = layoutKeys[i] as string[]; + let layoutKey = layoutKeys[i] as string[]; + + if (Config.keymapStyle === "custom") { + layoutKey = layoutKey[0]?.split("") ?? ["", ""]; + } const key = keys[i]; const lowerCaseCharacter = layoutKey[0]; const upperCaseCharacter = layoutKey[1]; @@ -561,7 +588,9 @@ async function updateLegends(): Promise { key === undefined || layoutKey === undefined || lowerCaseCharacter === undefined || - upperCaseCharacter === undefined + lowerCaseCharacter === "" || + upperCaseCharacter === undefined || + upperCaseCharacter === "" ) continue; diff --git a/frontend/src/ts/elements/settings/custom-keymap-picker.ts b/frontend/src/ts/elements/settings/custom-keymap-picker.ts new file mode 100644 index 000000000000..6d6f01d2b554 --- /dev/null +++ b/frontend/src/ts/elements/settings/custom-keymap-picker.ts @@ -0,0 +1,84 @@ +import FileStorage from "../../utils/file-storage"; +import * as Notifications from "../notifications"; +import { keymapToString, stringToKeymap } from "../../utils/custom-keymap"; +import * as UpdateConfig from "../../config"; +import { KeymapCustom } from "@monkeytype/schemas/configs"; + +const parentEl = document.querySelector( + ".pageSettings .section[data-config-name='keymapCustom']" +); +const usingLocalKeymapEl = parentEl?.querySelector(".usingLocalKeymap"); +const separatorEl = parentEl?.querySelector(".separator"); +const uploadContainerEl = parentEl?.querySelector(".uploadContainer"); +const inputAndButtonEl = parentEl?.querySelector(".textareaAndButton"); + +async function readFileAsData(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsText(file); + }); +} + +async function applyCustomKeymap(keymap: KeymapCustom): Promise { + $( + ".pageSettings .section[data-config-name='keymapCustom'] .textareaAndButton textarea" + ).val(""); + const didConfigSave = UpdateConfig.setKeymapCustom(keymap); + if (didConfigSave) { + Notifications.add("Saved", 1, { + duration: 1, + }); + } +} + +export async function updateUI(): Promise { + if (await FileStorage.hasFile("LocalKeymapFile")) { + usingLocalKeymapEl?.classList.remove("hidden"); + separatorEl?.classList.add("hidden"); + uploadContainerEl?.classList.add("hidden"); + inputAndButtonEl?.classList.add("hidden"); + } else { + usingLocalKeymapEl?.classList.add("hidden"); + separatorEl?.classList.remove("hidden"); + uploadContainerEl?.classList.remove("hidden"); + inputAndButtonEl?.classList.remove("hidden"); + } +} + +usingLocalKeymapEl + ?.querySelector("button") + ?.addEventListener("click", async () => { + await FileStorage.deleteFile("LocalKeymapFile"); + await updateUI(); + await applyCustomKeymap([]); + }); + +uploadContainerEl + ?.querySelector("input[type='file']") + ?.addEventListener("change", async (e) => { + const fileInput = e.target as HTMLInputElement; + const file = fileInput.files?.[0]; + + if (!file) { + return; + } + + // check type + if (!file.type.match(/application\/json/)) { + Notifications.add("Unsupported keymap format", 0); + fileInput.value = ""; + return; + } + + //sanitize input + const data = await readFileAsData(file); + const keymapData = stringToKeymap(data); + await FileStorage.storeFile("LocalKeymapFile", keymapToString(keymapData)); + + await updateUI(); + await applyCustomKeymap(keymapData); + + fileInput.value = ""; + }); diff --git a/frontend/src/ts/pages/settings.ts b/frontend/src/ts/pages/settings.ts index 63e5fa7b5c09..f360d9c14355 100644 --- a/frontend/src/ts/pages/settings.ts +++ b/frontend/src/ts/pages/settings.ts @@ -24,6 +24,7 @@ import { FunboxName, ConfigKeySchema, ConfigKey, + KeymapCustom, } from "@monkeytype/schemas/configs"; import { getAllFunboxes, checkCompatibility } from "@monkeytype/funbox"; import { getActiveFunboxNames } from "../test/funbox/list"; @@ -40,8 +41,10 @@ import { z } from "zod"; import { handleConfigInput } from "../elements/input-validation"; import { Fonts } from "../constants/fonts"; import * as CustomBackgroundPicker from "../elements/settings/custom-background-picker"; +import * as CustomKeymapPicker from "../elements/settings/custom-keymap-picker"; import * as CustomFontPicker from "../elements/settings/custom-font-picker"; import * as AuthEvent from "../observables/auth-event"; +import { keymapToString, stringToKeymap } from "../utils/custom-keymap"; let settingsInitialized = false; @@ -117,6 +120,9 @@ async function initGroups(): Promise { $(".pageSettings .section[data-config-name='keymapSize']").addClass( "hidden" ); + $(".pageSettings .section[data-config-name='keymapCustom']").addClass( + "hidden" + ); } else { $( ".pageSettings .section[data-config-name='keymapStyle']" @@ -133,6 +139,18 @@ async function initGroups(): Promise { $( ".pageSettings .section[data-config-name='keymapSize']" ).removeClass("hidden"); + $( + ".pageSettings .section[data-config-name='keymapSize']" + ).removeClass("hidden"); + if (Config.keymapStyle === "custom") { + $( + ".pageSettings .section[data-config-name='keymapCustom']" + ).removeClass("hidden"); + } else { + $( + ".pageSettings .section[data-config-name='keymapCustom']" + ).addClass("hidden"); + } } }, } @@ -142,6 +160,11 @@ async function initGroups(): Promise { UpdateConfig.setKeymapStyle, "button" ); + groups["keymapCustom"] = new SettingsGroup( + "keymapCustom", + UpdateConfig.setKeymapCustom, + "button" + ); groups["keymapLayout"] = new SettingsGroup( "keymapLayout", UpdateConfig.setKeymapLayout, @@ -857,6 +880,7 @@ export async function update( await CustomBackgroundPicker.updateUI(); await updateFilterSectionVisibility(); await CustomFontPicker.updateUI(); + await CustomKeymapPicker.updateUI(); const setInputValue = ( key: ConfigKey, @@ -918,6 +942,12 @@ export async function update( Config.fontSize ); + setInputValue( + "keymapCustom", + ".pageSettings .section[data-config-name='keymapCustom'] .textareaAndButton textarea", + keymapToString(Config.keymapCustom) + ); + setInputValue( "maxLineWidth", ".pageSettings .section[data-config-name='maxLineWidth'] input", @@ -1118,6 +1148,38 @@ $(".pageSettings .quickNav .links a").on("click", (e) => { } }); +$( + ".pageSettings .section[data-config-name='keymapCustom'] .textareaAndButton button.save" +).on("click", () => { + const stringValue = $( + ".pageSettings .section[data-config-name='keymapCustom'] .textareaAndButton textarea" + ).val() as string; + const keymap: KeymapCustom = stringToKeymap(stringValue); + const didConfigSave = UpdateConfig.setKeymapCustom(keymap); + if (didConfigSave) { + Notifications.add("Saved", 1, { + duration: 1, + }); + } +}); + +$( + ".pageSettings .section[data-config-name='keymapCustom'] .textareaAndButton textarea" +).on("keypress", (e) => { + if (e.key === "Enter") { + const stringValue = $( + ".pageSettings .section[data-config-name='keymapCustom'] .textareaAndButton textarea" + ).val() as string; + const keymap: KeymapCustom = stringToKeymap(stringValue); + const didConfigSave = UpdateConfig.setKeymapCustom(keymap); + if (didConfigSave) { + Notifications.add("Saved", 1, { + duration: 1, + }); + } + } +}); + let configEventDisabled = false; export function setEventDisabled(value: boolean): void { configEventDisabled = value; @@ -1209,11 +1271,15 @@ ConfigEvent.subscribe((eventKey, eventValue) => { if (eventKey === "fullConfigChangeFinished") setEventDisabled(false); if (eventKey === "themeLight") { $( - `.pageSettings .section[data-config-name='autoSwitchThemeInputs'] select.light option[value="${eventValue}"]` + `.pageSettings .section[data-config-name='autoSwitchThemeInputs'] select.light option[value="${Strings.stringifyConfigValue( + eventValue + )}"]` ).attr("selected", "true"); } else if (eventKey === "themeDark") { $( - `.pageSettings .section[data-config-name='autoSwitchThemeInputs'] select.dark option[value="${eventValue}"]` + `.pageSettings .section[data-config-name='autoSwitchThemeInputs'] select.dark option[value="${Strings.stringifyConfigValue( + eventValue + )}"]` ).attr("selected", "true"); } //make sure the page doesnt update a billion times when applying a preset/config at once diff --git a/frontend/src/ts/test/funbox/funbox-validation.ts b/frontend/src/ts/test/funbox/funbox-validation.ts index c01624f854bc..600e0f1241b1 100644 --- a/frontend/src/ts/test/funbox/funbox-validation.ts +++ b/frontend/src/ts/test/funbox/funbox-validation.ts @@ -142,7 +142,9 @@ export function canSetConfigWithCurrentFunboxes( Notifications.add( `You can't set ${Strings.camelCaseToWords( key - )} to ${value.toString()} with currently active funboxes.`, + )} to ${Strings.stringifyConfigValue( + value + )} with currently active funboxes.`, 0, { duration: 5, @@ -184,7 +186,7 @@ export function canSetFunboxWithConfig( errorStrings.push( `${Strings.capitalizeFirstLetter( Strings.camelCaseToWords(error.key) - )} cannot be set to ${error.value.toString()}.` + )} cannot be set to ${Strings.stringifyConfigValue(error.value)}.` ); } Notifications.add( diff --git a/frontend/src/ts/utils/custom-keymap.ts b/frontend/src/ts/utils/custom-keymap.ts new file mode 100644 index 000000000000..6512ef6e69c9 --- /dev/null +++ b/frontend/src/ts/utils/custom-keymap.ts @@ -0,0 +1,370 @@ +import { + KeyProperties, + KeymapCustom, + KeymapCustomSchema, + KeymapLegendStyle, + KeymapLayout as Layout, +} from "@monkeytype/schemas/configs"; +import { dataKeys as keyToDataObject } from "../constants/data-keys"; +import { sanitizeString } from "@monkeytype/util/strings"; +import { parseWithSchema } from "@monkeytype/util/json"; +import { stringifyConfigValue } from "./strings"; + +const columnMultiplier = 8; +const rowMultiplier = 8; +const margin = 0.125; +let basicSpan = 2; + +function keyToData(key: string): string { + return (key && keyToDataObject[key]) ?? ""; +} + +function isKeyProperties( + element: KeyProperties | string +): element is KeyProperties { + return typeof element === "object" && element !== null; +} + +function isOnlyInvisibleKey( + element: KeyProperties | string +): element is KeyProperties { + return ( + typeof element === "object" && + element !== null && + element.x !== undefined && + element.w === undefined && + element.h === undefined && + element.y === undefined && + element.rx === undefined && + element.ry === undefined && + element.r === undefined + ); +} + +function roundUpKeys(element: KeyProperties): KeyProperties { + for (let key in element) { + const k = key as keyof KeyProperties; + element[k] = + element[k] !== undefined + ? Math.round(element[k] * columnMultiplier) / columnMultiplier + : undefined; + } + return element; +} + +function sanitizeKeymap(keymap: KeymapCustom): KeymapCustom { + return keymap.map((row: (KeyProperties | string)[]) => { + return row + .map((element: KeyProperties | string) => { + if (typeof element === "string") { + return sanitizeString(element); + } + if ( + typeof element === "object" && + element !== null && + Object.keys(element).length > 0 + ) { + return roundUpKeys(element); + } else { + return null; + } + }) + .filter((el): el is KeyProperties | string => el !== null); + }); +} + +export function stringToKeymap(keymap: string): KeymapCustom { + try { + const isMatrix = /^\s*\[\s*\[[\s\S]*?\]\s*(,\s*\[[\s\S]*?\]\s*)*\]\s*$/; + const quoteKeymap = keymap.replace(/([{,])\s*(\w+)\s*:/g, '$1"$2":'); + const processedKeymap = isMatrix.test(keymap) + ? quoteKeymap + : `[${quoteKeymap}]`; + const jsonKeymap: KeymapCustom = parseWithSchema( + processedKeymap, + KeymapCustomSchema + ); + const test = sanitizeKeymap(jsonKeymap); + return test; + } catch (error) { + throw new Error("Wrong keymap, make sure you copy the right JSON file!"); + } +} + +export function keymapToString(keymap: KeymapCustom): string { + try { + if ( + (keymap?.length === 1 && keymap[0]?.length === 0) || + keymap?.length === 0 + ) { + return ""; + } + let jsonString = JSON.stringify(keymap ?? ""); + + jsonString = jsonString.replace(/"(\w+)":/g, "$1:"); + + return jsonString; + } catch (error) { + console.error("Error converting keymap to string:", error); + return ""; + } +} + +function createHtmlKey( + keyString: string, + legendStyle: string, + column: number, + size: number, + row: number, + rowSpan: number, + isInvisible: boolean, + rotationAngle: number | undefined, + columnOffset: number, + rowOffset: number +): string { + const dataKey = !isInvisible ? keyToData(keyString) : ""; + const fontSize = keyString.length > 2 ? "font-size: 0.6rem; " : ""; + const span = !isInvisible + ? `${keyString}` + : ""; + const remUnit = (basicSpan + 2 * margin) / columnMultiplier; + let transform = + rotationAngle !== undefined + ? `transform-origin: ${(columnOffset ?? 0) * remUnit}rem ${ + (rowOffset ?? 0) * remUnit + }rem; transform: rotate(${rotationAngle}deg);` + : ""; + return `
${span} +
`.replace(/(\r\n|\r|\n|\s{2,})/g, ""); +} + +function createSpaceKey( + layout: Layout, + legendStyle: string, + column: number, + size: number, + row: number, + rowSpan: number, + rotationAngle: number | undefined, + columnOffset: number, + rowOffset: number +): string { + const remUnit = (basicSpan + 2 * margin) / columnMultiplier; + let transform = + rotationAngle !== undefined + ? `transform-origin: ${(columnOffset ?? 0) * remUnit}rem ${ + (rowOffset ?? 0) * remUnit + }rem; transform: rotate(${rotationAngle}deg);` + : ""; + return `
+
${sanitizeString(layout)}
+
`.replace(/(\r\n|\r|\n|\s{2,})/g, ""); +} + +function createKey( + keyString: string, + layout: Layout, + legendStyle: string, + column: number, + size: number, + row: number, + rowSpan: number, + isInvisible: boolean, + rotationAngle: number | undefined, + columnOffset: number, + rowOffset: number +): string { + if (keyString === "spc") { + return createSpaceKey( + layout, + legendStyle, + column, + size, + row, + rowSpan, + rotationAngle, + columnOffset, + rowOffset + ); + } else { + return createHtmlKey( + keyString, + legendStyle, + column, + size, + row, + rowSpan, + isInvisible, + rotationAngle, + columnOffset, + rowOffset + ); + } +} + +function calculateRowSpan(): void { + const flag: Element | null = document.querySelector( + "#mobileTestConfigButton" + ); + if (flag !== null) { + const flagValue = getComputedStyle(flag).display; + const grid: HTMLElement | null = document.querySelector(".keyboard-grid"); + // checking wheter or not the Test Setting is visible and use this as a change point + basicSpan = flagValue !== "none" ? 1.25 : 2; + + const newSpanSize = (basicSpan + 2 * margin) / columnMultiplier; + + if (grid !== null && grid !== undefined) { + grid.style.setProperty("--colSpan", `${newSpanSize}rem`); + grid.style.setProperty("--rowSpan", `${newSpanSize}rem`); + } + } +} + +export function getCustomKeymapSyle( + keymapStyle: KeymapCustom, + layout: Layout, + keymapLegendStyle: KeymapLegendStyle +): string { + calculateRowSpan(); + const keymapCopy = [...keymapStyle]; + let legendStyle = ""; + if (keymapLegendStyle === "uppercase") { + legendStyle = "text-transform: capitalize;"; + } else if (keymapLegendStyle === "blank") { + legendStyle = "display: none; transition: none;"; + } + let maxColumn = 1, + maxRow = 1, + rotationRow = 1, + currentRow = 1, + rowOffset = 0, + rotationAngle: number | undefined, + isRotationSectionStarted: boolean, + rotationColumn: number | undefined; + const keymapHtml = keymapCopy.map((row: (KeyProperties | string)[]) => { + const rowCopy = [...row]; + let currentColumn = rotationColumn !== undefined ? rotationColumn : 1, + columnOffset = 0; + const rowHtml = rowCopy.map( + (element: KeyProperties | string, index: number) => { + let keyHtml: string = "", + keyString: string = + typeof element === "string" + ? sanitizeString(element).toLowerCase() + : "", + columnSpan = 1, + rowSpan = 1, + isInvisible = false; + if (isOnlyInvisibleKey(element) && element.x !== undefined) { + maxColumn = currentColumn > maxColumn ? currentColumn : maxColumn; + currentColumn += element.x * columnMultiplier; + if (isRotationSectionStarted) { + columnOffset += -1 * element.x * columnMultiplier; + } + return; + } else if (isKeyProperties(element)) { + if (element.w !== undefined && "w" in element) { + columnSpan = element.w; + } + if (element.y !== undefined && "y" in element) { + currentRow += element.y * rowMultiplier; + if (isRotationSectionStarted) { + rowOffset += -1 * element.y * rowMultiplier; + } + } + if (element.x !== undefined && "x" in element) { + currentColumn += element.x * columnMultiplier; + if (isRotationSectionStarted) { + columnOffset += -1 * element.x * columnMultiplier; + } + } + if (element.h !== undefined && "h" in element) { + rowSpan = element.h; + } + if (element.r !== undefined && "r" in element) { + rotationAngle = element.r; + columnOffset = -(currentColumn - 1); + rowOffset = -(currentRow - 1); + if (element.rx !== undefined || element.ry !== undefined) { + currentColumn = + element.rx !== undefined + ? element.rx * columnMultiplier + 1 + : rotationColumn ?? 1; + currentRow = + element.ry !== undefined + ? element.ry * rowMultiplier + 1 + : rotationRow ?? 1; + rotationColumn = currentColumn; + rotationRow = currentRow; + rowOffset = 0; + columnOffset = 0; + if (element.y !== undefined && "y" in element) { + currentRow += element.y * rowMultiplier; + rowOffset = -1 * element.y * rowMultiplier; + } + if (element.x !== undefined && "x" in element) { + currentColumn += element.x * columnMultiplier; + columnOffset = -1 * element.x * columnMultiplier; + } + } + isRotationSectionStarted = true; + } + keyString = sanitizeString( + stringifyConfigValue(rowCopy[index + 1]) ?? "" + ).toLowerCase(); + rowCopy.splice(index, 1); + } + + keyHtml += createKey( + keyString, + layout, + legendStyle, + currentColumn, + columnSpan, + currentRow, + rowSpan, + isInvisible, + rotationAngle, + columnOffset, + rowOffset + ); + maxColumn = currentColumn > maxColumn ? currentColumn : maxColumn; + currentColumn += columnSpan * columnMultiplier; + if (isRotationSectionStarted) { + columnOffset += -1 * columnSpan * columnMultiplier; + } + return keyHtml; + } + ); + maxRow = currentRow > maxRow ? currentRow : maxRow; + currentRow += rowMultiplier; + if (isRotationSectionStarted) { + rowOffset += -1 * rowMultiplier; + } + return rowHtml.join(""); + }); + const cols = maxColumn + columnMultiplier - 1, + rows = maxRow + rowMultiplier - 1, + colSpan = (basicSpan + 2 * margin) / columnMultiplier, + rowSpan = (basicSpan + 2 * margin) / rowMultiplier; + + return `
${keymapHtml.join("")}
`; +} + +window.addEventListener("resize", calculateRowSpan); diff --git a/frontend/src/ts/utils/file-storage.ts b/frontend/src/ts/utils/file-storage.ts index bb8915109e57..55fba8673f19 100644 --- a/frontend/src/ts/utils/file-storage.ts +++ b/frontend/src/ts/utils/file-storage.ts @@ -7,7 +7,10 @@ type FileDB = DBSchema & { }; }; -type Filename = "LocalBackgroundFile" | "LocalFontFamilyFile"; +type Filename = + | "LocalBackgroundFile" + | "LocalFontFamilyFile" + | "LocalKeymapFile"; class FileStorage { private dbPromise: Promise>; diff --git a/frontend/src/ts/utils/strings.ts b/frontend/src/ts/utils/strings.ts index 0786bd3e41d0..d47395f309dc 100644 --- a/frontend/src/ts/utils/strings.ts +++ b/frontend/src/ts/utils/strings.ts @@ -265,3 +265,16 @@ export function isWordRightToLeft( export const __testing = { hasRTLCharacters, }; + +/** + * Stringify properly JSON objects to avoid no-base-to-string + * eslint errors + */ +export function stringifyConfigValue(value: unknown): string { + if (value === null || value === undefined) return ""; + if (typeof value !== "object") { + // eslint-disable-next-line @typescript-eslint/no-base-to-string + return value.toString(); + } + return JSON.stringify(value); +} diff --git a/packages/schemas/src/configs.ts b/packages/schemas/src/configs.ts index c5f6ebbe7452..32b11480806b 100644 --- a/packages/schemas/src/configs.ts +++ b/packages/schemas/src/configs.ts @@ -91,9 +91,31 @@ export const KeymapStyleSchema = z.enum([ "split_matrix", "steno", "steno_matrix", + "custom", ]); export type KeymapStyle = z.infer; +export const KeyPropertiesSchema = z.object({ + w: z.number().optional(), + h: z.number().optional(), + x: z.number().optional(), + y: z.number().optional(), + r: z.number().optional(), + rx: z.number().optional(), + ry: z.number().optional(), +}); + +export type KeyProperties = z.infer; + +export const KeymapCustomSchema = z.array( + z + .array(z.union([z.string(), KeyPropertiesSchema.partial()])) + .refine((val) => JSON.stringify(val).length <= 4096, { + message: "Keymap data must be less than 4096 chars.", + }) +); +export type KeymapCustom = z.infer; + export const KeymapLegendStyleSchema = z.enum([ "lowercase", "uppercase", @@ -435,6 +457,7 @@ export const ConfigSchema = z keymapMode: KeymapModeSchema, keymapLayout: KeymapLayoutSchema, keymapStyle: KeymapStyleSchema, + keymapCustom: KeymapCustomSchema, keymapLegendStyle: KeymapLegendStyleSchema, keymapShowTopRow: KeymapShowTopRowSchema, keymapSize: KeymapSizeSchema, @@ -570,6 +593,7 @@ export const ConfigGroupsLiteral = { keymapMode: "appearance", keymapLayout: "appearance", keymapStyle: "appearance", + keymapCustom: "appearance", keymapLegendStyle: "appearance", keymapShowTopRow: "appearance", keymapSize: "appearance", diff --git a/packages/util/src/strings.ts b/packages/util/src/strings.ts new file mode 100644 index 000000000000..dce3d0020680 --- /dev/null +++ b/packages/util/src/strings.ts @@ -0,0 +1,15 @@ +export function sanitizeString(str: string): string { + if (str === "") { + return ""; + } + + return str + .replace(/`/g, "`") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") + .replace(/\\/g, "\") + .replace(/\n/, "") + .replace(/script/g, ""); +}