settingsManager.js

import { sleep } from "./utils";
import WindowSettings from "./WindowSettings";

/** SettingsManager class for handling user settings and making them persist between sessions.
 * Logic for {@link WindowSettings} is managed here.
 * "Flags" should follow the same styling as `.classList()` and should not contain spaces.
 * A flag should always be false by default.
 * When a flag is false, it will not exist in the "flags" Array.
 * (Therefore, "flags" should be `[]` by default)
 * If it exists in the "flags" Array, then the flag is `true`.
 * @class SettingsManager
 * @since 0.91.11
 * @example
 * {
 *   "uuid": "497dcba3-ecbf-4587-a2dd-5eb0665e6880",
 *   "telemetry": 1,
 *   "flags": ["hl-noTrans", "ftr-oWin", "te-noSkip"],
 *   "highlight": [[1,0,-1],[1,-1,0],[2,1,0],[1,0,1]],
 *   "filter": [-2,0,4,5,6,29,63]
 * }
 */
export default class SettingsManager extends WindowSettings {

  /** Constructor for the SettingsManager class
   * @param {string} name - The name of the userscript
   * @param {string} version - The version of the userscript
   * @param {Object} userSettings - The user settings as an object
   * @since 0.91.11
   */
  constructor(name, version, userSettings) {
    super(name, version); // Executes WindowSettings constructor
    
    this.userSettings = userSettings; // User settings as an Object
    this.userSettings.flags ??= []; // Makes sure the key "flags" always exists
    this.userSettingsOld = structuredClone(this.userSettings); // Creates a duplicate of the user settings to store the old version of user settings from 5+ seconds ago
    this.userSettingsSaveLocation = 'bmUserSettings'; // Storage save location

    this.updateFrequency = 5000; // Cooldown between saving to storage (throttle)
    this.lastUpdateTime = 0; // When this unix timestamp is within the last 5 seconds, we should save this.userSettings to storage

    setInterval(this.updateUserStorage.bind(this), this.updateFrequency); // Runs every X seconds (see updateFrequency)
  }

  /** Updates the user settings in userscript storage
   * @since 0.91.39
   */
  async updateUserStorage() {

    // Turns the objects into a string
    const userSettingsCurrent = JSON.stringify(this.userSettings);
    const userSettingsOld = JSON.stringify(this.userSettingsOld);

    // If the user settings have changed, AND the last update to user storage was over 5 seconds ago (5sec throttle)...
    if ((userSettingsCurrent != userSettingsOld) && ((Date.now() - this.lastUpdateTime) > this.updateFrequency)) {
      await GM.setValue(this.userSettingsSaveLocation, userSettingsCurrent); // Updates user storage
      this.userSettingsOld = structuredClone(this.userSettings); // Updates the old user settings with a duplicate of the current user settings
      this.lastUpdateTime = Date.now(); // Updates the variable that contains the last time updated
      console.log(userSettingsCurrent);
    }
  }

  /** Toggles a boolean flag to the state that was passed in.
   * If no state was passed in, the flag will flip to the opposite state.
   * The existence of the flag determines its state. If it exists, it is `true`.
   * @param {string} flagName - The name of the flag to toggle
   * @param {boolean} [state=undefined] - (Optional) The state to change the flag to
   * @since 0.91.60
   */
  toggleFlag(flagName, state = undefined) {

    const flagIndex = this.userSettings?.flags?.indexOf(flagName) ?? -1; // Is the flag `true`?

    // If the flag is enabled, AND the user does not want to force the flag to be true...
    if ((flagIndex != -1) && (state !== true)) {

      this.userSettings?.flags?.splice(flagIndex, 1); // Remove the flag (makes it false)
    } else if ((flagIndex == -1) && (state !== false)) {
      // Else if the flag is disabled, AND the user does not want to force the flag to be false...
      this.userSettings?.flags?.push(flagName); // Add the flag (makes it true)
    }
  }

  // This is one of the most insane OOP setups I have ever laid my eyes on

  /** Builds the "highlight" category of the settings window
   * @since 0.91.18
   * @see WindowSettings#buildHighlight
   */
  buildHighlight() {

    const highlightPresetOff = '<svg viewBox="0 0 3 3"><path d="M0,0H3V3H0ZM0,1H3M0,2H3M1,0V3M2,0V3" fill="#fff"/><path d="M1,1H2V2H1Z" fill="#2f4f4f"/></svg>';
    const highlightPresetCross = '<svg viewBox="0 0 3 3"><path d="M0,0H3V3H0Z" fill="#fff"/><path d="M1,0H2V1H3V2H2V3H1V2H0V1H1Z" fill="brown"/><path d="M1,1H2V2H1Z" fill="#2f4f4f"/></svg>';
    
    // Obtains user settings for highlight from storage, or the default array if nothing was found
    const storedHighlight = this.userSettings?.highlight ?? [[1, 0, 1], [2, 0, 0], [1, -1, 0], [1, 1, 0], [1, 0, -1]];

    // Constructs the category and adds it to the window
    this.window = this.addDiv({'class': 'bm-container'})
      .addHeader(2, {'textContent': 'Pixel Highlight'}).buildElement()
      .addHr().buildElement()
      .addDiv({'class': 'bm-container', 'style': 'margin-left: 1.5ch;'})
        .addCheckbox({'textContent': 'Highlight transparent pixels'}, (instance, label, checkbox) => {
          checkbox.checked = !this.userSettings?.flags?.includes('hl-noTrans'); // Makes the checkbox match the last stored user setting
          checkbox.onchange = (event) => this.toggleFlag('hl-noTrans', !event.target.checked); // Forces the flag to be the opposite state as the checkbox. E.g. "Checked" means 'hl-noTrans' is false (does not exist).
        }).buildElement()
        .addP({'id': 'bm-highlight-preset-label', 'textContent': 'Choose a preset:', 'style': 'font-weight: 700;'}).buildElement()
        .addDiv({'class': 'bm-flex-center', 'role': 'group', 'aria-labelledby': 'bm-highlight-preset-label'})
          .addDiv({'class': 'bm-highlight-preset-container'})
            .addSpan({'textContent': 'None'}).buildElement()
            .addButton({'innerHTML': highlightPresetOff, 'aria-label': 'Preset "None"'}, (instance, button) => {button.onclick = () => this.#updateHighlightToPreset('None')}).buildElement()
          .buildElement()
          .addDiv({'class': 'bm-highlight-preset-container'})
            .addSpan({'textContent': 'Cross'}).buildElement()
            .addButton({'innerHTML': highlightPresetCross, 'aria-label': 'Preset "Cross Shape"'}, (instance, button) => {button.onclick = () => this.#updateHighlightToPreset('Cross')}).buildElement()
          .buildElement()
          .addDiv({'class': 'bm-highlight-preset-container'})
            .addSpan({'textContent': 'X'}).buildElement()
            .addButton({'innerHTML': highlightPresetCross.replace('d="M1,0H2V1H3V2H2V3H1V2H0V1H1Z"', 'd="M0,0V1H3V0H2V3H3V2H0V3H1V0Z"'), 'aria-label': 'Preset "X Shape"'}, (instance, button) => {button.onclick = () => this.#updateHighlightToPreset('X')}).buildElement()
          .buildElement()
          .addDiv({'class': 'bm-highlight-preset-container'})
            .addSpan({'textContent': 'Full'}).buildElement()
            .addButton({'innerHTML': highlightPresetOff.replace('#fff', '#2f4f4f'), 'aria-label': 'Preset "Full Template"'}, (instance, button) => {button.onclick = () => this.#updateHighlightToPreset('Full')}).buildElement()
          .buildElement()
        .buildElement()
        .addP({'id': 'bm-highlight-grid-label', 'textContent': 'Create a custom pattern:', 'style': 'font-weight: 700;'}).buildElement()
        .addDiv({'class': 'bm-highlight-grid', 'role': 'group', 'aria-labelledby': 'bm-highlight-grid-label'});
          // We leave this open so we can add buttons

          // For each of the 9 buttons...
          for (let buttonY = -1; buttonY <= 1; buttonY++) {
            for (let buttonX = -1; buttonX <= 1; buttonX++) {
              const buttonState = storedHighlight[storedHighlight.findIndex(([, x, y]) => ((x == buttonX) && (y == buttonY)))]?.[0] ?? 0;
              let buttonStateName = 'Disabled';
              if (buttonState == 1) {
                buttonStateName = 'Incorrect';
              } else if (buttonState == 2) {
                buttonStateName = 'Template';
              }
              this.window = this.addButton({
                'data-status': buttonStateName,
                'aria-label': `Sub-pixel ${buttonStateName.toLowerCase()}`
              }, (instance, button) => {
                button.onclick = () => this.#updateHighlightSettings(button, [buttonX, buttonY])
              }).buildElement();
            }
          }

          // Resumes from where we left off before we added buttons
        this.window = this.buildElement()
      .buildElement()
    .buildElement();
  }

  /** Updates the display of the highlight buttons in the settings window.
   * Additionally, it will update user settings with the new selection.
   * @param {HTMLButtonElement} button - The button that was pressed
   * @param {Array<number, number>} coords - The relative coordinates of the button
   * @since 0.91.46
   */
  #updateHighlightSettings(button, coords) {

    button.disabled = true; // Disabled the button until we are done

    const status = button.dataset['status']; // Obtains the current status of the button

    /** Obtains the old highlight storage, or sets it to default. @type {Array<number[]>} */
    const userStorageOld = this.userSettings?.highlight ?? [[1, 0, 1], [2, 0, 0], [1, -1, 0], [1, 1, 0], [1, 0, -1]];

    let userStorageChange = [2, 0, 0]; // The new change to the user storage

    const userStorageNew = userStorageOld; // The old storage with the new change

    // For each different type of status...
    switch (status) {

      // If the button was in the "Disabled" state
      case 'Disabled':

        // Change to "Incorrect"
        button.dataset['status'] = 'Incorrect';
        button.ariaLabel = 'Sub-pixel incorrect';
        userStorageChange = [1, ...coords];
        break;
      
      // If the button was in the "Incorrect" state
      case 'Incorrect':

        // Change to "Template"
        button.dataset['status'] = 'Template';
        button.ariaLabel = 'Sub-pixel template';
        userStorageChange = [2, ...coords];
        break;
      
      // If the button was in the "Template" state
      case 'Template':

        // Change to "Disabled"
        button.dataset['status'] = 'Disabled';
        button.ariaLabel = 'Sub-pixel disabled';
        userStorageChange = [0, ...coords];
        break;
    }

    // Finds the index of the pixel to change
    const indexOfChange = userStorageOld.findIndex(([, x, y]) => ((x == userStorageChange[1]) && (y == userStorageChange[2])));

    // If the new sub-pixel state is NOT disabled
    if (userStorageChange[0] != 0) {

      // If a sub-pixel was found...
      if (indexOfChange != -1) {
        userStorageNew[indexOfChange] = userStorageChange;
      } else {
        userStorageNew.push(userStorageChange);
      }
    } else if (indexOfChange != -1) {
      // Else, it is disabled. We want to remove it if it exists.
      userStorageNew.splice(indexOfChange, 1); // Removes 1 index from the array at the index of the pixel change
    }

    this.userSettings['highlight'] = userStorageNew;
    // TODO: Add timer update here

    button.disabled = false; // Reenables the button since we are done
  }

  /** Changes the highlight buttons to the clicked preset.
   * @param {string} preset - The name of the preset
   * @since 0.91.49
   */
  async #updateHighlightToPreset(preset) {

    // Obtains all preset buttons as a NodeList
    const presetButtons = document.querySelectorAll('.bm-highlight-preset-container button');

    // For each preset...
    for (const button of presetButtons) {
      button.disabled = true; // Disables the button
    }

    let presetArray = [0,0,0,0,2,0,0,0,0]; // The preset "None"

    // Selects the preset passed in
    switch (preset) {
      case 'Cross':
        presetArray = [0,1,0,1,2,1,0,1,0]; // The preset "Cross"
        break;
      case 'X':
        presetArray = [1,0,1,0,2,0,1,0,1]; // The preset "X"
        break;
      case 'Full': 
        presetArray = [2,2,2,2,2,2,2,2,2]; // The preset "Full"
        break;
    }

    // Obtains the buttons to click as a NodeList
    const buttons = document.querySelector('.bm-highlight-grid')?.childNodes ?? [];

    // For each button...
    for (let buttonIndex = 0; buttonIndex < buttons.length; buttonIndex++) {

      const button = buttons[buttonIndex]; // Gets the current button to check

      // Gets the state of the button as a number
      let buttonState = button.dataset['status'];
      buttonState = (buttonState != 'Disabled') ? ((buttonState != 'Incorrect') ? 2 : 1) : 0;

      // Finds the difference between the preset and the button
      let buttonStateDelta = presetArray[buttonIndex] - buttonState;

      // Since there is no difference, the button matches, so we skip it
      if (buttonStateDelta == 0) {continue;}

      // Makes the difference positive
      buttonStateDelta += (buttonStateDelta < 0) ? 3 : 0;

      /** At this point, these are the possible options:
       * 1. The preset is zero and the button is two (-2) so we need to click once
       * 2. The preset is one and the button is two (-1) so we need to click twice
       * 3. The preset is one ahead of the button (1) so we need to click once
       * 4. The preset is two ahead of the button (2) so we need to click twice
       * Due to the addition of three in the line above, options 1 & 3 combine, and options 2 & 4 combine.
       * Now the only options we have are:
       * 1. If (1) then click once
       * 2. If (2) then click twice
       * Also due to the addition of three in the line above, our two options are POSITIVE numbers
       */

      button.click(); // Clicks once
      
      // Clicks a second time if needed
      if (buttonStateDelta == 2) {

        // For 0.2 seconds, or when the button is NOT disabled, wait for 10 milliseconds before attempting to continue
        for (let timeWaited = 0; timeWaited < 200; timeWaited += 10) {
          if (!button.disabled) {break;} // Breaks early once the button is enabled
          await sleep(10);
        }

        button.click(); // Clicks again
      }
    }

    // For each preset...
    for (const button of presetButtons) {
      button.disabled = false; // Re-enables the button
    }
  }

  /** Build the "template" category of settings window
   * @since 0.91.68
   * @see WindowSettings#buildTemplate
   */
  buildTemplate() {

    this.window = this.addDiv({'class': 'bm-container'})
      .addHeader(2, {'textContent': 'Pixel Highlight'}).buildElement()
      .addHr().buildElement()
      .addDiv({'class': 'bm-container', 'style': 'margin-left: 1.5ch;'})
        .addCheckbox({'textContent': 'Template creation should skip transparent tiles'}, (instance, label, checkbox) => {
          checkbox.checked = !this.userSettings?.flags?.includes('hl-noSkip'); // Makes the checkbox match the last stored user setting
          checkbox.onchange = (event) => this.toggleFlag('hl-noSkip', !event.target.checked); // If the user wants to skip, then the checkbox is NOT checked
        }).buildElement()
        .addCheckbox({'innerHTML': 'Experimental: Template creation should <em>aggressively</em> skip transparent tiles'}, (instance, label, checkbox) => {
          checkbox.checked = this.userSettings?.flags?.includes('hl-agSkip'); // Makes the checkbox match the last stored user setting
          checkbox.onchange = (event) => this.toggleFlag('hl-agSkip', event.target.checked); // If the user wants to aggressively skip, then the checkbox is checked
        }).buildElement()
      .buildElement()
    .buildElement()
  }
}