Parcourir la source

feat: implement new text input "prompt" dialog

Sv443 il y a 7 mois
Parent
commit
bcb1b0d67e

+ 1 - 1
changelog.md

@@ -66,7 +66,7 @@
     - `createRipple()` to create a click ripple animation effect on a given element (experimental)
     - `showToast()` to show a custom toast notification with a message string or element and duration
     - `showIconToast()` to show a custom toast notification with a message string or element, icon and duration
-    - `showPrompt()` to show a styled prompt dialog that replaces the `confirm()` and `alert()` functions
+    - `showPrompt()` to show a styled dialog that replaces the `confirm()`, `alert()` and `prompt()` functions
     - `ExImDialog` class for creating a BytmDialog instance that is designed for exporting and importing generic data as a string
   - Changed components:
     - BytmDialog now has the option `removeListenersOnDestroy` (true by default) to configure removing all event listeners when the dialog is destroyed

+ 23 - 11
contributing.md

@@ -396,7 +396,7 @@ Functions marked with 🔒 need to be passed a per-session and per-plugin authen
   - [createRipple()](#createripple) - Creates a click ripple effect on the given element
   - [showToast()](#showtoast) - Shows a toast notification and a message string or element
   - [showIconToast()](#showicontoast) - Shows a toast notification with an icon and a message string or element
-  - [showPrompt()](#showprompt) - Shows a styled prompt of the type `confirm` or `alert`
+  - [showPrompt()](#showprompt) - Shows a styled prompt dialog of the type `confirm`, `alert` or `prompt`
 - Translations:
   - [setLocale()](#setlocale) 🔒 - Sets the locale for BetterYTM
   - [getLocale()](#getlocale) - Returns the currently set locale
@@ -1969,33 +1969,45 @@ Functions marked with 🔒 need to be passed a per-session and per-plugin authen
 > Shows a prompt dialog with the specified message and type.  
 > If another prompt is already shown, it will be closed (and resolve as closed or canceled) and the new one will be shown immediately afterwards.  
 >   
-> If the type is `alert` (default), the user can only close the prompt.  
+> If the type is `alert`, the user can only close the prompt.  
 > In this case the Promise always resolves with `true`.  
 >   
 > For the type `confirm`, the user can choose between confirming or canceling the prompt.  
 > In this case the Promise resolves with `true` if the user confirmed and `false` if the user canceled or closed.  
 >   
+> If the type `prompt` is used, the user can input a text value.  
+> In this case the Promise resolves with the entered text if the user confirmed and `null` if the user canceled or closed.  
+> If the user confirms with an empty text field, the Promise resolves with an empty string.  
+> Additionally, the property `defaultValue` can be used to set the preset value for the input field.  
+>   
 > Properties:  
 > - `message: string` - The message to show in the prompt
-> - `type?: "confirm" | "alert"` - The type of the prompt. Can be "confirm" or "alert" (default)
+> - `type: "confirm" | "alert" | "prompt"` - The type of the prompt. Can be "confirm", "alert" or "prompt"
+> - `defaultValue?: string` - The default value for the input field (only when using type "prompt")
 >   
 > <details><summary><b>Example <i>(click to expand)</i></b></summary>
 > 
 > ```ts
-> const confirmed = await unsafeWindow.BYTM.showPrompt({
->   message: "Are you sure you want to delete this?",
+> const itemName = await unsafeWindow.BYTM.showPrompt({
+>   type: "prompt",
+>   message: "Enter the name of the item to delete:",
+>   defaultValue: "My Item",
+> });
+> 
+> const confirmed = itemName && await unsafeWindow.BYTM.showPrompt({
 >   type: "confirm",
+>   message: "Are you sure you want to delete this?",
 > });
->
-> if(confirmed) {
->   await deleteSomething();
+> 
+> if(confirmed && itemName) {
+>   await deleteItem(itemName);
 >   unsafeWindow.BYTM.showPrompt({
->     // uses type "alert" by default
->     message: "Deleted successfully.",
+>     type: "alert",
+>     message: `Deleted "${itemName}" successfully.`,
 >   });
 > }
 > else
->   console.log("The user canceled the prompt.");
+>   console.log("The user canceled one of the prompts.");
 > ```
 > </details>
 

+ 7 - 7
src/dialogs/autoLike.ts

@@ -68,9 +68,9 @@ export async function getAutoLikeDialog() {
           log("Trying to import auto-like data:", parsed);
 
           if(!parsed || typeof parsed !== "object")
-            return await showPrompt({ message: t("import_error_invalid") });
+            return await showPrompt({ type: "alert", message: t("import_error_invalid") });
           if(!parsed.channels || typeof parsed.channels !== "object" || Object.keys(parsed.channels).length === 0)
-            return await showPrompt({ message: t("import_error_no_data") });
+            return await showPrompt({ type: "alert", message: t("import_error_no_data") });
 
           await autoLikeStore.setData(parsed);
           emitSiteEvent("autoLikeChannelsUpdated");
@@ -211,12 +211,12 @@ async function renderBody() {
       resourceName: "icon-edit",
       title: t("edit_entry"),
       async onClick() {
-        const newNamePr = prompt(t("auto_like_channel_edit_name_prompt"), chanName)?.trim();
+        const newNamePr = (await showPrompt({ type: "prompt", message: t("auto_like_channel_edit_name_prompt"), defaultValue: chanName }))?.trim();
         if(!newNamePr || newNamePr.length === 0)
           return;
         const newName = newNamePr.length > 0 ? newNamePr : chanName;
 
-        const newIdPr = prompt(t("auto_like_channel_edit_id_prompt"), chanId)?.trim();
+        const newIdPr = (await showPrompt({ type: "prompt", message: t("auto_like_channel_edit_id_prompt"), defaultValue: chanId }))?.trim();
         if(!newIdPr || newIdPr.length === 0)
           return;
         const newId = newIdPr.length > 0 ? getChannelIdFromPrompt(newIdPr) ?? chanId : chanId;
@@ -291,14 +291,14 @@ async function openImportExportAutoLikeChannelsDialog() {
 async function addAutoLikeEntryPrompts() {
   await autoLikeStore.loadData();
 
-  const idPrompt = prompt(t("add_auto_like_channel_id_prompt"))?.trim();
+  const idPrompt = (await showPrompt({ type: "prompt", message: t("add_auto_like_channel_id_prompt") }))?.trim();
   if(!idPrompt)
     return;
 
   const id = parseChannelIdFromUrl(idPrompt) ?? (isValidChannelId(idPrompt) ? idPrompt : null);
 
   if(!id || id.length <= 0)
-    return await showPrompt({ message: t("add_auto_like_channel_invalid_id") });
+    return await showPrompt({ type: "alert", message: t("add_auto_like_channel_invalid_id") });
 
   let overwriteName = false;
 
@@ -310,7 +310,7 @@ async function addAutoLikeEntryPrompts() {
     overwriteName = true;
   }
 
-  const name = prompt(t("add_auto_like_channel_name_prompt"), hasChannelEntry?.name)?.trim();
+  const name = (await showPrompt({ type: "prompt", message: t("add_auto_like_channel_name_prompt"), defaultValue: hasChannelEntry?.name }))?.trim();
   if(!name || name.length === 0)
     return;
 

+ 5 - 5
src/dialogs/importCfg.ts

@@ -74,11 +74,11 @@ async function renderFooter() {
       log("Trying to import config object:", parsed);
 
       if(!parsed || typeof parsed !== "object")
-        return await showPrompt({ message: t("import_error_invalid") });
+        return await showPrompt({ type: "alert", message: t("import_error_invalid") });
       if(typeof parsed.formatVersion !== "number")
-        return await showPrompt({ message: t("import_error_no_format_version") });
+        return await showPrompt({ type: "alert", message: t("import_error_no_format_version") });
       if(typeof parsed.data !== "object" || parsed.data === null || Object.keys(parsed.data).length === 0)
-        return await showPrompt({ message: t("import_error_no_data") });
+        return await showPrompt({ type: "alert", message: t("import_error_no_data") });
       if(parsed.formatVersion < formatVersion) {
         let newData = JSON.parse(JSON.stringify(parsed.data));
         const sortedMigrations = Object.entries(migrations)
@@ -103,7 +103,7 @@ async function renderFooter() {
         parsed.data = newData;
       }
       else if(parsed.formatVersion !== formatVersion)
-        return await showPrompt({ message: t("import_error_wrong_format_version", formatVersion, parsed.formatVersion) });
+        return await showPrompt({ type: "alert", message: t("import_error_wrong_format_version", formatVersion, parsed.formatVersion) });
 
       await setFeatures({ ...getFeatures(), ...parsed.data });
 
@@ -118,7 +118,7 @@ async function renderFooter() {
     }
     catch(err) {
       warn("Couldn't import configuration:", err);
-      await showPrompt({ message: t("import_error_invalid") });
+      await showPrompt({ type: "alert", message: t("import_error_invalid") });
     }
   });
 

+ 64 - 28
src/dialogs/prompt.ts

@@ -4,20 +4,33 @@ import { getOS, resourceAsString, setInnerHtml, t } from "../utils/index.js";
 import { BytmDialog, type BytmDialogEvents } from "../components/index.js";
 import "./prompt.css";
 
-export type PromptDialogRenderProps = {
-  type: "confirm" | "alert";
+export type PromptDialogRenderProps = ConfirmRenderProps | AlertRenderProps | PromptRenderProps;
+
+type ConfirmRenderProps = BaseRenderProps & {
+  type: "confirm";
+};
+
+type AlertRenderProps = BaseRenderProps & {
+  type: "alert";
+};
+
+type PromptRenderProps = BaseRenderProps & {
+  type: "prompt";
+  defaultValue?: string;
+};
+
+type BaseRenderProps = {
   message: Stringifiable;
 };
 
 export type ShowPromptProps = Partial<PromptDialogRenderProps> & Required<Pick<PromptDialogRenderProps, "message">>;
 
 export type PromptDialogEmitter = Emitter<BytmDialogEvents & {
-  resolve: (result: boolean) => void;
+  resolve: (result: boolean | string | null) => void;
 }>;
 
 let promptDialog: PromptDialog | null = null;
 
-// TODO: implement prompt() equivalent for text input
 class PromptDialog extends BytmDialog {
   constructor(props: PromptDialogRenderProps) {
     super({
@@ -44,8 +57,8 @@ class PromptDialog extends BytmDialog {
     return headerEl;
   }
 
-  async renderBody({ type, message }: PromptDialogRenderProps) {
-    const resolve = (val: boolean) => (this.events as PromptDialogEmitter).emit("resolve", val);
+  async renderBody({ type, message, ...rest }: PromptDialogRenderProps) {
+    const resolve = (val: boolean | string | null) => (this.events as PromptDialogEmitter).emit("resolve", val);
 
     const contElem = document.createElement("div");
 
@@ -56,6 +69,28 @@ class PromptDialog extends BytmDialog {
     messageElem.textContent = String(message);
     contElem.appendChild(messageElem);
 
+    if(type === "prompt") {
+      const inputCont = document.createElement("div");
+      inputCont.id = "bytm-prompt-dialog-input-cont";
+
+      const inputLabel = document.createElement("label");
+      inputLabel.id = "bytm-prompt-dialog-input-label";
+      inputLabel.htmlFor = "bytm-prompt-dialog-input";
+      inputLabel.textContent = t("prompt_input_label");
+      inputCont.appendChild(inputLabel);
+
+      const inputElem = document.createElement("input");
+      inputElem.id = "bytm-prompt-dialog-input";
+      inputElem.type = "text";
+      inputElem.autocomplete = "off";
+      inputElem.spellcheck = false;
+      inputElem.value = "defaultValue" in rest ? rest.defaultValue ?? "" : "";
+      inputElem.autofocus = true;
+      inputCont.appendChild(inputElem);
+
+      contElem.appendChild(inputCont);
+    }
+
     const buttonsWrapper = document.createElement("div");
     buttonsWrapper.id = "bytm-prompt-dialog-button-wrapper";
 
@@ -63,16 +98,16 @@ class PromptDialog extends BytmDialog {
     buttonsCont.id = "bytm-prompt-dialog-buttons-cont";
 
     let confirmBtn: HTMLButtonElement | undefined;
-    if(type === "confirm") {
+    if(type === "confirm" || type === "prompt") {
       confirmBtn = document.createElement("button");
       confirmBtn.id = "bytm-prompt-dialog-confirm";
       confirmBtn.classList.add("bytm-prompt-dialog-button");
       confirmBtn.textContent = t("prompt_confirm");
       confirmBtn.ariaLabel = confirmBtn.title = t("click_to_confirm_tooltip");
       confirmBtn.tabIndex = 0;
-      confirmBtn.autofocus = true;
+      confirmBtn.autofocus = type === "confirm";
       confirmBtn.addEventListener("click", () => {
-        resolve(true);
+        resolve(type === "confirm" ? true : (document.querySelector<HTMLInputElement>("#bytm-prompt-dialog-input"))?.value?.trim() ?? null);
         promptDialog?.close();
       }, { once: true });
     }
@@ -86,7 +121,12 @@ class PromptDialog extends BytmDialog {
     if(type === "alert")
       closeBtn.autofocus = true;
     closeBtn.addEventListener("click", () => {
-      resolve(type === "alert");
+      const resVals: Record<PromptDialogRenderProps["type"], boolean | null> = {
+        alert: true,
+        confirm: false,
+        prompt: null,
+      };
+      resolve(resVals[type]);
       promptDialog?.close();
     }, { once: true });
 
@@ -101,30 +141,27 @@ class PromptDialog extends BytmDialog {
   }
 }
 
-/** Shows a prompt dialog of the specified type and resolves true if the user confirms it or false if they cancel it - always resolves true with type "alert" */
-export function showPrompt({
-  type = "alert",
-  message,
-}: ShowPromptProps) {
-  return new Promise<boolean>((resolve) => {
+/** Shows a confirm() or alert() prompt dialog of the specified type and resolves true if the user confirms it or false if they cancel it - always resolves true with type "alert" */
+export function showPrompt(props: ConfirmRenderProps | AlertRenderProps): Promise<boolean>;
+/** Shows a prompt() dialog with the specified message and default value and resolves the entered value if the user confirms it or null if they cancel it */
+export function showPrompt(props: PromptRenderProps): Promise<string | null>;
+export function showPrompt({ type, ...rest }: PromptDialogRenderProps): Promise<boolean | string | null> {
+  return new Promise<boolean | string | null>((resolve) => {
     if(BytmDialog.getOpenDialogs().includes("prompt-dialog"))
       promptDialog?.close();
 
-    promptDialog = new PromptDialog({
-      type,
-      message,
-    });
+    promptDialog = new PromptDialog({ type, ...rest });
 
     // make config menu inert while prompt dialog is open
     promptDialog.once("open", () => document.querySelector("#bytm-cfg-menu")?.setAttribute("inert", "true"));
     promptDialog.once("close", () => document.querySelector("#bytm-cfg-menu")?.removeAttribute("inert"));
 
-    let resolveVal: boolean | undefined;
-    const tryResolve = () => resolve(typeof resolveVal === "boolean" ? resolveVal : false);
+    let resolveVal: boolean | string | null | undefined;
+    const tryResolve = () => resolve(typeof resolveVal !== "undefined" ? resolveVal : false);
 
-    const resolveUnsub = promptDialog.on("resolve" as "_", (val: boolean) => {
+    const resolveUnsub = promptDialog.on("resolve" as "_", (val: boolean | string | null) => {
       resolveUnsub();
-      if(resolveVal)
+      if(resolveVal !== undefined)
         return;
       resolveVal = val;
       tryResolve();
@@ -132,15 +169,14 @@ export function showPrompt({
 
     const closeUnsub = promptDialog.on("close", () => {
       closeUnsub();
-      if(resolveVal)
+      if(resolveVal !== undefined)
         return;
       resolveVal = type === "alert";
+      if(type === "prompt")
+        resolveVal = null;
       tryResolve();
     });
 
     promptDialog.open();
   });
 }
-
-//@ts-ignore
-unsafeWindow.showPrompt = showPrompt;

+ 1 - 1
src/features/index.ts

@@ -628,7 +628,7 @@ export const featInfo = {
       const formattedEntries = entries.toLocaleString(getLocale().replace(/_/g, "-"), { style: "decimal", maximumFractionDigits: 0 });
       if(await showPrompt({ type: "confirm", message: tp("lyrics_clear_cache_confirm_prompt", entries, formattedEntries) })) {
         await clearLyricsCache();
-        await showPrompt({ message: t("lyrics_clear_cache_success") });
+        await showPrompt({ type: "alert", message: t("lyrics_clear_cache_success") });
       }
     },
     advanced: true,

+ 2 - 2
src/features/lyrics.ts

@@ -239,7 +239,7 @@ export async function fetchLyricsUrls(artist: string, song: string): Promise<Omi
 
     if(fetchRes.status === 429) {
       const waitSeconds = Number(fetchRes.headers.get("retry-after") ?? geniUrlRatelimitTimeframe);
-      await showPrompt({ message: tp("lyrics_rate_limited", waitSeconds, waitSeconds) });
+      await showPrompt({ type: "alert", message: tp("lyrics_rate_limited", waitSeconds, waitSeconds) });
       return undefined;
     }
     else if(fetchRes.status < 200 || fetchRes.status >= 300) {
@@ -339,7 +339,7 @@ export async function createLyricsBtn(geniusUrl?: string, hideIfLoading = true)
       e.preventDefault();
       e.stopPropagation();
 
-      const search = prompt(t("open_lyrics_search_prompt"));
+      const search = await showPrompt({ type: "prompt", message: t("open_lyrics_search_prompt") });
       if(search && search.length > 0)
         openInTab(`https://genius.com/search?q=${encodeURIComponent(search)}`);
     }

+ 1 - 1
src/features/versionCheck.ts

@@ -37,7 +37,7 @@ export async function doVersionCheck(notifyNoUpdatesFound = false) {
   });
 
   // TODO: small dialog for "no update found" message?
-  const noUpdateFound = () => notifyNoUpdatesFound ? showPrompt({ message: t("no_updates_found") }) : undefined;
+  const noUpdateFound = () => notifyNoUpdatesFound ? showPrompt({ type: "alert", message: t("no_updates_found") }) : undefined;
 
   const latestTag = res.finalUrl.split("/").pop()?.replace(/[a-zA-Z]/g, "");
 

+ 5 - 5
src/index.ts

@@ -6,7 +6,7 @@ import { dbg, error, getDomain, info, getSessionId, log, setLogLevel, initTransl
 import { initSiteEvents } from "./siteEvents.js";
 import { emitInterface, initInterface, initPlugins } from "./interface.js";
 import { initObservers, addSelectorListener, globservers } from "./observers.js";
-import { getWelcomeDialog } from "./dialogs/index.js";
+import { getWelcomeDialog, showPrompt } from "./dialogs/index.js";
 import type { FeatureConfig } from "./types.js";
 import {
   // layout
@@ -414,7 +414,7 @@ function registerDevCommands() {
   }, "d");
 
   GM.registerMenuCommand("Delete GM values by name (comma separated)", async () => {
-    const keys = prompt("Enter the name(s) of the GM value to delete (comma separated).\nEmpty input cancels the operation.");
+    const keys = await showPrompt({ type: "prompt", message: "Enter the name(s) of the GM value to delete (comma separated).\nEmpty input cancels the operation." });
     if(!keys)
       return;
     for(const key of keys?.split(",") ?? []) {
@@ -455,7 +455,7 @@ function registerDevCommands() {
   }, "s");
 
   GM.registerMenuCommand("Compress value", async () => {
-    const input = prompt("Enter the value to compress.\nSee console for output.");
+    const input = await showPrompt({ type: "prompt", message: "Enter the value to compress.\nSee console for output." });
     if(input && input.length > 0) {
       const compressed = await compress(input, compressionFormat);
       dbg(`Compression result (${input.length} chars -> ${compressed.length} chars)\nValue: ${compressed}`);
@@ -463,7 +463,7 @@ function registerDevCommands() {
   });
 
   GM.registerMenuCommand("Decompress value", async () => {
-    const input = prompt("Enter the value to decompress.\nSee console for output.");
+    const input = await showPrompt({ type: "prompt", message: "Enter the value to decompress.\nSee console for output." });
     if(input && input.length > 0) {
       const decompressed = await decompress(input, compressionFormat);
       dbg(`Decompresion result (${input.length} chars -> ${decompressed.length} chars)\nValue: ${decompressed}`);
@@ -477,7 +477,7 @@ function registerDevCommands() {
   });
 
   GM.registerMenuCommand("Import using DataStoreSerializer", async () => {
-    const input = prompt("Enter the serialized data to import:");
+    const input = await showPrompt({ type: "prompt", message: "Enter the serialized data to import:" });
     if(input && input.length > 0) {
       await storeSerializer.deserialize(input);
       alert("Imported data. Reload the page to apply changes.");

+ 5 - 5
src/menu/menu_old.ts

@@ -204,11 +204,11 @@ async function mountCfgMenu() {
         log("Trying to import configuration:", parsed);
 
         if(!parsed || typeof parsed !== "object")
-          return await showPrompt({ message: t("import_error_invalid") });
+          return await showPrompt({ type: "alert", message: t("import_error_invalid") });
         if(typeof parsed.formatVersion !== "number")
-          return await showPrompt({ message: t("import_error_no_format_version") });
+          return await showPrompt({ type: "alert", message: t("import_error_no_format_version") });
         if(typeof parsed.data !== "object" || parsed.data === null || Object.keys(parsed.data).length === 0)
-          return await showPrompt({ message: t("import_error_no_data") });
+          return await showPrompt({ type: "alert", message: t("import_error_no_data") });
         if(parsed.formatVersion < formatVersion) {
           let newData = JSON.parse(JSON.stringify(parsed.data));
           const sortedMigrations = Object.entries(migrations)
@@ -233,7 +233,7 @@ async function mountCfgMenu() {
           parsed.data = newData;
         }
         else if(parsed.formatVersion !== formatVersion)
-          return await showPrompt({ message: t("import_error_wrong_format_version", formatVersion, parsed.formatVersion) });
+          return await showPrompt({ type: "alert", message: t("import_error_wrong_format_version", formatVersion, parsed.formatVersion) });
   
         await setFeatures({ ...getFeatures(), ...parsed.data });
   
@@ -247,7 +247,7 @@ async function mountCfgMenu() {
       }
       catch(err) {
         warn("Couldn't import configuration:", err);
-        await showPrompt({ message: t("import_error_invalid") });
+        await showPrompt({ type: "alert", message: t("import_error_invalid") });
       }
     },
     title: () => t("bytm_config_export_import_title"),

+ 1 - 1
src/utils/dom.ts

@@ -233,7 +233,7 @@ export function copyToClipboard(text: Stringifiable) {
     GM.setClipboard(String(text));
   }
   catch {
-    showPrompt({ message: t("copy_to_clipboard_error", String(text)) });
+    showPrompt({ type: "alert", message: t("copy_to_clipboard_error", String(text)) });
   }
 }