Pre-release
AdventureJS Docs Downloads
Score: 0 Moves: 0
// SaveManager.js

// https://eligrey.com/demos/FileSaver.js/
// https://www.dropzonejs.com/
// https://gist.github.com/liabru/11263260#file-save-file-local-js
(function () {
  /*global adventurejs A*/

  /**
   *
   * @class adventurejs.SaveManager
   * @ajsnavheading FrameworkReference
   * @param {Game} game A reference to the game instance.
   * @summary Manages the process of saving games.
   * @todo Save to adventurejs.com web server.
   * @classdesc
   * <p>
   * <strong>SaveManager</strong> manages the job of saving games.
   * It contains all the methods needed to create the
   * Save pop-up screen. SaveManager can save a game to
   * a local save file, to browser cookies, or to the
   * <a href="https://adventurejs.com">adventurejs.com</a>
   * web server.
   * </p>
   * <p>
   * SaveManager is created automatically
   * by {@link adventurejs.Game|Game}. This is an internal class that
   * authors should not need to construct or modify. However, if
   * you'd like to try, you can find styles for the Save & Restore
   * pop-ups in <a href="/css/adventurejs.css">adventurejs.css</a>.
   * All relevant styles are prefixed with '.save_' or '.restore_'.
   * </p>
   */
  class SaveManager {
    constructor(game) {
      /**
       * A reference back to the main {@link adventurejs.Game|Game} object.
       * @var {Object} adventurejs.SaveManager#game
       * @default {}
       */
      this.game = game;

      /**
       * Collection of HTML elements: the action buttons for
       * the different save methods.
       * @var {Array} adventurejs.SaveManager#saveButtons
       * @default []
       */
      this.saveButtons = [];

      /**
       * Div element to contain the Save dialog.
       * @var {HTMLElement} adventurejs.SaveManager#save_dialog
       * @default {}
       */
      this.save_dialog = document.createElement("div");
      this.save_dialog.classList.add("ajs-save-dialog", "ajs-dialog");
      this.save_dialog.setAttribute("aria-hidden", true);
      this.save_dialog.setAttribute("aria-modal", true);
      this.save_dialog.role = "dialog";
      this.save_dialog.style.display = "none";
      this.save_dialog.tabIndex = "1";
      this.save_dialog.setAttribute(
        "aria-labelledby",
        `${this.game.game_name}-save-dialog-title`
      );
      this.game.display.dialogsEl.appendChild(this.save_dialog);

      // Source HTML is stored in saveManager.html
      this.save_dialog.innerHTML = `
  <div class="ajs-save-dialog-outer ajs-dialog-outer">
    <div class="ajs-save-dialog-inner ajs-dialog-inner">

      <h2 id="${this.game.game_name}-save-dialog-title" class="ajs-save-dialog-title ajs-dialog-title">SAVE GAME</h2>

      <div class="ajs-save-dialog-content ajs-dialog-content">
        <div id="${this.game.game_name}-save-radiogroup" role="radiogroup" aria-labelledby="${this.game.game_name}-save-radiogroup-label">
          <p id="${this.game.game_name}-save-radiogroup-label" class="ajs-hidden-label">Select an option:</p>
          <div class="ajs-dialog-radio-tab-container">
            <label class="ajs-dialog-radio-tab">
              <input
                type="radio"
                name="saveoptions"
                value="save_to_file"
                aria-describedby="${this.game.game_name}-save-descriptions"
                checked
              />
              Save to File
            </label>

            <label class="ajs-dialog-radio-tab">
              <input
                type="radio"
                name="saveoptions"
                value="save_to_browser"
                aria-describedby="${this.game.game_name}-save-descriptions"
              />
              Save to Browser
            </label>

            <label class="ajs-dialog-radio-tab">
              <input
                type="radio"
                name="saveoptions"
                value="save_to_server"
                aria-describedby="${this.game.game_name}-save-descriptions"
              />
              Save to Server
            </label>
          </div>
          <!-- /ajs-dialog-radio-tab-container -->
        </div>
        <!-- /radiogroup -->

      <!-- CONTEXTUAL DESCRIPTIONS -->

      <div id="${this.game.game_name}-save-descriptions" class="ajs-dialog-option-descriptions" aria-live="polite">
          <!-- Select an option to see more details. -->
        </div>

        <div id="${this.game.game_name}-save-dialog-input-container" class="ajs-save-dialog-input-container">
          <label class="ajs-save-dialog-input-label"
            >Enter a name for your save file: <br /><input
              id="${this.game.game_name}-save-dialog-input"
              class="ajs-save-dialog-input"
              pattern="[_A-Za-z0-9-]{64}"
            />
          </label>
        </div>

        <div class="ajs-save-dialog-buttons ajs-dialog-button-container">
          <button
            id="${this.game.game_name}-save-dialog-cancel-button"
            class="ajs-save-dialog-cancel-button ajs-cancel-button ajs-dialog-button button-secondary"
            type="button"
            value="Cancel"
            name="saveCancel"
          >
            Cancel
          </button>
          <button
            id="${this.game.game_name}-save-dialog-submit-button"
            class="ajs-save-dialog-submit-button ajs-save-button ajs-dialog-button button-primary"
            type="button"
            value="submit"
            name="save_submit"
          >
            Save
          </button>
        </div>
        <!-- /ajs-save-dialog-buttons -->
      </div>
      <!-- /ajs-save-dialog-content -->

    </div> 
    <!-- /ajs-save-dialog-inner -->
  </div>
  <!-- /ajs-save-dialog-outer -->
      `;

      // --------------------------------------------------
      // RADIO BUTTONS
      // --------------------------------------------------

      this.radiogroup = this.game.display.displayEl.querySelector(
        `#${this.game.game_name}-save-radiogroup`
      );

      const radios = this.game.display.displayEl.querySelectorAll(
        'input[name="saveoptions"]'
      );
      const descriptions = this.game.display.displayEl.querySelector(
        `#${this.game.game_name}-save-descriptions`
      );

      const optionDescriptions = {
        save_to_file: `
          <div class="ajs-dialog-option-description">
            <p class="">
              The <span class="ajs-dialog-emphasis">Save to File</span>
              option will download a saved game file to your computer's hard
              drive.
            </p>
            <p class="">
              <strong>PRO:</strong> You can restore these save files in any browser
              that can run this game, and copy them to other computers.
            </p>
            <p class="">
              <strong>CON:</strong> For reasons of security, the browser won't open a
              file dialog to let you choose where to put it. It should go to your
              browser's default Download folder.
            </p>
            <div
              class="ajs-file-reader-unsupported"
            >
              <div class="alert alert-danger">
                <p class="">
                  Unfortunately, your web browser doesn't appear to support this
                  save/restore method. Please try one of the other methods, or try
                  playing with a different web browser.
                </p>
              </div>
            </div>
          </div>
        `,

        save_to_browser: `
          <div class="ajs-dialog-option-description">
            <p class="">
              The <span class="ajs-dialog-emphasis">Save to Browser</span>
              option will save your game to your web browser's
              <span class="ajs-dialog-emphasis">Local Storage</span>. It's like a
              cookie, but bigger. 
            </p>
            <p class="">
              <strong>PRO:</strong> Easy to manage saves if you
              only play the game in this browser on this computer. Best for short
              games that you're unlikely to replay. 
            </p>
            <p class="">
              <strong>CON:</strong> Storage is
              limited. You can only access these saves from this browser on this
              computer. If you clear your browser's cache, you may erase your
              saved games.
            </p>
          </div>
        `,

        save_to_server: `
          <div class="save_server_pane ajs-dialog-option-description active">
            <p class="">
              The <span class="ajs-dialog-emphasis">Save to Server</span>
              option will save your game to the adventurejs.com web
              server.
            </p>
            <p class="">
              <strong>PRO:</strong> It's easy to manage your saved games, and you can
              restore them to any browser on any computer that runs this game.
            </p>
            <p class="">
              <strong>CON:</strong> Saved games will be unavailable while you are
              offline.
            </p>
          </div>
        `,
      };

      descriptions.innerHTML =
        optionDescriptions[Object.keys(optionDescriptions)[0]];

      radios.forEach((radio) => {
        radio.addEventListener("change", (event) => {
          const selectedOption = event.target.value;
          descriptions.innerHTML =
            optionDescriptions[selectedOption] ||
            "Select an option to see more details.";
        });
      });

      // ensure that the first radio is checked
      setTimeout(() => {
        // Select all radio buttons within the group
        const radioGroup = this.game.display.displayEl.querySelectorAll(
          `#${this.game.game_name}-save-radiogroup input[name="saveoptions"]`
        );

        // Check if any radio button is selected
        const isChecked = Array.from(radioGroup).some((radio) => radio.checked);

        // If none are checked, check the first option
        if (!isChecked && radioGroup.length > 0) {
          radioGroup[0].checked = true; // Check the first radio button
        }
      }, 50);

      // --------------------------------------------------
      // CANCEL BUTTON
      // --------------------------------------------------

      /**
       * Button element to dispel the Save dialog.
       * @var {HTMLElement} adventurejs.SaveManager#saveCancel
       * @default {}
       */
      this.saveCancel = this.game.display.displayEl.querySelector(
        `#${this.game.game_name}-save-dialog-cancel-button`
      );
      this.saveCancel.manager = this;
      this.saveCancel.addEventListener("click", function () {
        this.manager.clickClose();
      });

      // --------------------------------------------------
      // SAVE BUTTON
      // --------------------------------------------------

      this.save_submit = this.game.display.displayEl.querySelector(
        `#${this.game.game_name}-save-dialog-submit-button`
      );
      this.save_submit.manager = this;

      this.save_submit.addEventListener("click", function () {
        this.manager.clickSubmit();
      });

      // --------------------------------------------------
      // FILENAME INPUT
      // --------------------------------------------------
      /**
       * Div element to contain the Save row input.
       * @var {HTMLElement} adventurejs.SaveManager#saveRowInput
       * @default {}
       */
      this.save_input = document.createElement("input");
      this.save_input = this.game.display.displayEl.querySelector(
        `#${this.game.game_name}-save-dialog-input`
      );
      this.save_input.pattern = "[_A-Za-z0-9\\-]{64}";
      this.save_input.manager = this;
      this.save_input.addEventListener("input", function () {
        this.manager.saveInputOninput(this);
      });
      this.save_input.addEventListener("onkeydown", function () {
        this.manager.saveInputOninput(this);
      });

      this.handleEscape = this.handleEscape.bind(this); // Bind once

      //
    }

    /**
     * Open the Save modal dialog.
     * @memberOf adventurejs.SaveManager
     * @method adventurejs.SaveManager#openDialog
     * @kind function
     */
    openDialog() {
      // this.resetDialog();

      // deactivate main content
      document.activeElement.blur();
      this.game.display.contentEl.blur();
      this.game.display.contentEl.setAttribute("aria-hidden", true);
      this.game.display.contentEl.setAttribute("inert", "");

      // activate dialog
      this.save_dialog.style.display = "block";
      this.save_dialog.classList.add("active");
      this.save_dialog.setAttribute("aria-hidden", false);
      this.save_dialog.focus();

      // set a default save name
      this.save_input.value =
        this.game.titleSerialized + "_" + new Date().getTime().toString();

      document.addEventListener("keyup", this.handleEscape);

      // delay because the select doesn't work without it
      setTimeout(
        function (saveManager) {
          saveManager.save_input.select();
        },
        50,
        this
      );
    }

    /**
     * Close the Save modal dialog.
     * @memberOf adventurejs.SaveManager
     * @method adventurejs.SaveManager#closeDialog
     * @kind function
     */
    closeDialog() {
      document.removeEventListener("keyup", this.handleEscape);
      document.activeElement.blur();

      this.save_dialog.classList.remove("active");
      this.save_dialog.setAttribute("aria-hidden", true);

      setTimeout(() => {
        this.save_dialog.style.display = "none";
        // set focus on input
        this.game.display.contentEl.setAttribute("aria-hidden", false);
        this.game.display.contentEl.removeAttribute("inert");
        this.game.display.inputEl.focus();
      }, 250);
    }

    /**
     * Function that closes the dialog on escape keyup.
     * @param {*} event
     */
    handleEscape(event) {
      var key = event.which || event.keyCode;
      if (key === 27) {
        event.stopPropagation();
        this.clickClose(this);
      }
    }

    /**
     * Function that gets called by the Close button.
     * @memberOf adventurejs.SaveManager
     * @method adventurejs.SaveManager#clickClose
     * @kind function
     */
    clickClose() {
      console.log("clickClose");
      this.closeDialog();
      var msg = "Save cancelled.";
      this.game.print(msg);
      return;
    }

    /**
     * Save.
     * @memberOf adventurejs.SaveManager
     * @method adventurejs.SaveManager#clickSubmit
     * @kind function
     */
    clickSubmit() {
      console.warn("clickSubmit", this.radiogroup);

      // Get the selected radio button in the group
      const selectedRadio = this.radiogroup.querySelector(
        'input[name="saveoptions"]:checked'
      );

      // Check if a radio button is selected
      if (selectedRadio) {
        console.log(`Selected value: ${selectedRadio.value}`);
        if (selectedRadio.value === "save_to_file") this.saveToFile();
        if (selectedRadio.value === "save_to_browser") this.saveToBrowser();
        if (selectedRadio.value === "save_to_server") this.saveToServer();
      } else {
        console.log("No radio button is selected.");
      }
    }

    /**
     * Save to file.
     * @memberOf adventurejs.SaveManager
     * @method adventurejs.SaveManager#saveToFile
     * @kind function
     * @todo Investigate FileSaver.js
     */
    saveToFile() {
      console.log("saveToFile");
      var inputFileName = this.sanitizeInputFileName();

      // get diff between baseline and current
      var text = A.getBaselineDiff.call(this.game);

      var blob = new Blob([text], { type: "application/json" });
      console.warn("blob", blob);
      var anchor = document.createElement("a");
      anchor.download = inputFileName + ".json";
      anchor.href = (window.webkitURL || window.URL).createObjectURL(blob);
      anchor.dataset.downloadurl = [
        "application/json",
        anchor.download,
        anchor.href,
      ].join(":");
      console.warn("anchor", anchor);
      anchor.click();
      //URL.revokeObjectURL(anchor.href);
      setTimeout(function () {
        URL.revokeObjectURL(anchor.href);
      }, 200);

      this.closeDialog();
      var msg =
        "Game saved! " +
        "<span class='text-success'>" +
        inputFileName +
        ".json" +
        "</span> " +
        "has been saved to your Downloads folder.";
      this.game.print(msg);
      return true;
    }

    /**
     * Save game to browser cookie.
     * <br><br>
     * Player can name their save however they want. However, we always
     * prepend the game name because local storage applies
     * to all pages on a given domain, meaning that all saves for all
     * games played at, i.e., adventurejs.com will be saved in the same
     * local storage. And, local storage appears not to have any method
     * for nesting data, so we can't have a parent object or 'folder'.
     * Instead, we need to name them in such a way that we
     * can identify them by game.
     * @memberOf adventurejs.SaveManager
     * @method adventurejs.SaveManager#saveToBrowser
     * @kind function
     */
    saveToBrowser() {
      console.log("saveToBrowser");
      var inputFileName = this.sanitizeInputFileName();

      //if( this.game.titleSerialized !== inputFileName.substr( 0, this.game.titleSerialized.length ) ) // @deprecated
      if (
        this.game.titleSerialized !==
        inputFileName.substring(0, this.game.titleSerialized.length)
      ) {
        inputFileName = this.game.titleSerialized + "_" + inputFileName;
      }

      var storage = window.localStorage;

      // get diff between baseline and current
      storage.setItem(inputFileName, A.getBaselineDiff.call(this.game));

      this.closeDialog();
      var msg = "Game saved to your browser's Local Storage!";
      this.game.print(msg);
      return true;
    }

    /**
     * Save game to server.
     * @memberOf adventurejs.SaveManager
     * @method adventurejs.SaveManager#saveToServer
     * @kind function
     * @todo Everything.
     */
    saveToServer() {
      console.log("saveToServer");
      var inputFileName = this.sanitizeInputFileName();
      // TODO ...
    }

    /**
     * OnInput or OnKeyDown, activate or deactivate save buttons
     * depending on state of input field. Deactivate if empty,
     * otherwise activate.
     * @memberOf adventurejs.SaveManager
     * @method adventurejs.SaveManager#
     * @kind function
     */
    saveInputOninput(save_input) {
      var activeState = save_input.value === "" ? "add" : "remove";
      this.save_submit.classList[activeState]("inactive");
      this.save_submit.disabled = save_input.value === "" ? true : false;
    }

    /**
     * Sanitize input file name. Permitted characters are
     * a-z, A-Z, 0-9, accented vowels áéíóúñü, _underscore, and -hyphen.
     * @memberOf adventurejs.SaveManager
     * @method adventurejs.SaveManager#sanitizeInputFileName
     * @kind function
     */
    sanitizeInputFileName() {
      console.log("validate input");
      console.log("this.save_input.value", this.save_input.value);
      var inputFileName = this.save_input.value;
      inputFileName = inputFileName.replace(/ /gim, "_");
      inputFileName = inputFileName.replace(/[^a-zA-Z0-9áéíóúñü_-]/gim, "");
      if (!inputFileName) {
        inputFileName =
          this.game.titleSerialized + "_" + new Date().getTime().toString();
      }
      return inputFileName;
    }

    /**
     * Provides a chainable shortcut method for setting a number of properties on the instance.
     * @method adventurejs.SaveManager#set
     * @param {Object} props A generic object containing properties to copy to the DisplayObject instance.
     * @returns {adventurejs.SaveManager} Returns the instance the method is called on (useful for chaining calls.)
     * @chainable
     */
    set(props) {
      return A.deepSet.call(this.game, props, this);
    }
  }
  adventurejs.SaveManager = SaveManager;
})();