foldershare-8.x-1.2/js/foldershare.ui.foldertablemenu.js

js/foldershare.ui.foldertablemenu.js
/**
 * @file
 * Implements the FolderShare folder table menu user interface.
 *
 * The folder table menu UI presents a menu button and pull-down menu that
 * lists commands that operate upon folders and files of various types.
 * Commands are plugins to the FolderShare module and implement operations
 * such as creating a new folder, uploading files, deleting, copying, moving,
 * and downloading. The server attaches command descriptions used here to
 * build commands and pre-validate them before submitting them to the server.
 *
 * Most commands use a selection, so this script supports selecting rows in
 * a file/folder table. Rows can be selected individually or in groups.
 * Double-clicking a row opens the row's file or folder by advancing to its
 * page. Right-clicking on a row shows a context menu that shows a subset of
 * the main menu. Rows can be dragged and dropped onto subfolders to move
 * and copy, and files can be dragged from the host OS into the folder to
 * initiate a file upload.
 *
 * This script requires HTML elements added by a table view that uses a name
 * field formatter that attaches attributes to name field anchors. This script
 * uses those attributes to guide prevalidation of menu items as appropriate
 * for selected rows.
 *
 * This script also requires an HTML form that provides:
 * - A field to hold the current command choice.
 * - A set of fields holding command operands, including a parent ID,
 *   destination ID, and selection.
 * - A file field holding selected files for an upload.
 * - Drupal settings that list all known commands, and sundry other attributes.
 *
 * @ingroup foldershare
 * @see \Drupal\foldershare\Plugin\Field\FieldFormatter\FolderShareName
 * @see \Drupal\foldershare\Form\UIFolderTableMenu
 */
(function($, Drupal) {
  // Define Drupal.foldershare if it hasn"t been defined yet.
  if ("foldershare" in Drupal === false) {
    Drupal.foldershare = {};
  }
 
  Drupal.foldershare.tablePoll = null;
 
  /**
   * Lists the environment objects for all relevant views found on the page.
   *
   * In typical use, this list has a single entry for a single view showing
   * a single folder or root list. However it is possible for a site to
   * configure a page with multiple blocks showing multiple folders or root
   * lists. In such a case, this list will include multiple entries.
   *
   * Entries are indexed by the unique view DOM ID selector. Each entry
   * contains an "environment" object created during attach processing of
   * a new page context. That object caches settings, state, and jQuery
   * objects relevant to further activity.
   *
   * @type {object.<string, object>}
   */
  Drupal.foldershare.environments = {};
 
  Drupal.foldershare.UIFolderTableMenu = {
    /*--------------------------------------------------------------------
     *
     * Constants - well-known commands.
     *
     * The availability of these well-known command plugins enables specific
     * functionality, including file uploads and copy and move drags.
     *
     *--------------------------------------------------------------------*/
 
    /**
     * The name of the module's standard file upload command.
     */
    uploadCommand: "foldersharecommand_upload_files",
 
    /**
     * The name of the module's standard entity copy command.
     */
    copyCommand: "foldersharecommand_copy",
 
    /**
     * The name of the module's standard entity move command, for subfolders.
     */
    moveCommand: "foldersharecommand_move",
 
    /**
     * The name of the module's entity move command for root lists by
     * authenticated users.
     */
    moveCommandOnRootList: "foldersharecommand_move_on_rootlist",
 
    /**
     * The name of the module's entity move command for root lists by
     * administrative usrs.
     */
    moveCommandAsAdmin: "foldersharecommand_move_as_admin",
 
    /*--------------------------------------------------------------------
     *
     * Constants - table attributes for drag state.
     *
     *--------------------------------------------------------------------*/
 
    /**
     * The table attribute created to track the current drag operand.
     *
     * Expected values are:
     * - "none" when no drag operation is in progress.
     * - "rows" when rows in the current table are being dragged.
     * - "files" when files are being dragged in from off browser.
     */
    tableDragOperand: "foldershare-drag-operand",
 
    /**
     * The table attribute created to track the current drag target.
     *
     * Expected values are:
     * - "none" when no drag operation is in progress.
     * - "rows" when rows in the current table are being dragged.
     * - "files" when files are being dragged in from off browser.
     */
    tableDropTarget: "foldershare-drop-target",
 
    /**
     * The table attribute created to track the current drag effects allowed.
     *
     * Expected values are any of those allowed for the "effectAllowed"
     * field of a drag event's data transfer. Values used here are:
     * - "none" when a drop is not allowed at this point.
     * - "copy" when only a copy is allowed at this point.
     * - "move" when only a copy is allowed at this point.
     * - "copyMove" when either a copy or a move is allowed at this point.
     */
    tableDragEffectAllowed: "foldershare-drag-effect-allowed",
 
    /**
     * The table attribute created to track the current drag row.
     *
     * Expected values are numeric row indexes (1 for the 1st row) or
     * "NaN" if there is no current row. The current row is the row most
     * recently under the cursor during a drag. When there is no drag in
     * progress, the value is "NaN".
     */
    tableDragRowIndex: "foldershare-drag-row-index",
 
    /*--------------------------------------------------------------------
     *
     * Initialize.
     *
     * The "environment" array created and updated by these functions
     * contains jQuery objects and assorted attributes gathered from the
     * page. Once gathered, these are passed among behavior functions so
     * that they can operate upon them without having to re-search for
     * them on the page.
     *
     *--------------------------------------------------------------------*/
 
    /**
     * Attaches the module's folder table menu UI behaviors.
     *
     * The folder table menu UI includes
     * - A command menu (e.g. open, new, upload, delete, etc.)
     * - A menu button used to present the command menu.
     * - Table row selection.
     * - Table row drag-and-drop for copy and move.
     * - File drag-and-drop for upload.
     * - A file dialog for upload.
     *
     * All UI elements and related elements are found, validated,
     * and behaviors attached.
     *
     * @param {Document} pageContext
     *   The page context for this call. Initially, this is the full document.
     *   Later, this is only portions of the document added via AJAX.
     * @param {objecT} settings
     *   The top-level Drupal settings object.
     *
     * @return {boolean}
     *   Always returns true.
     */
    attach(pageContext, settings) {
      const thisScript = Drupal.foldershare.UIFolderTableMenu;
 
      //
      // Test and exit
      // -------------
      // This method is called very frequently. It is called at least once
      // when the document is ready, and then again every time AJAX adds
      // anything to the page for any reason. This includes additions that
      // have nothing to do with this module.
      //
      // It is therefore important that this method decide quickly if the
      // context is not relevant for it, then return.
      //
      // Find top element
      // ----------------
      // The page structure contains a toolbar and table wrapper <div>,
      // then two child <div>s containing the toolbar and table:
      //
      // <div class="foldershare-toolbar-and-folder-table">
      //   <div class="foldershare-toolbar">...</div>
      //   <div class="foldershare-folder-table">...</div>
      // </div>
      //
      // The toolbar <div> contains zero or more other UI components. This
      // script adds a new menu button to the start of the toolbar.
      //
      // The table <div> contains a wrapper <div> from Views. It includes
      // two <div>s for the view's content and footer. The view content
      // contains a wrapper <div> around a <form>. And the form contains
      // exposed filters and other views-supplied form elements, and
      // finally it includes a <table> that contains rows of files and
      // folders.
      //
      // <div class="foldershare-toolbar-and-folder-table">   Top of UI
      //   <div class="foldershare-toolbar">...</div>         Toolbar
      //   <div class="foldershare-folder-table">             Table wrapper
      //     <div class="view">
      //       <div class="view-content">
      //         <table>...</table>                           Table
      //       </div>
      //       <nav class="pager">...</nav>                   View pager
      //       <div class="view-footer">...</div>             Table footer
      //     </div>
      //   </div>
      // </div>
      //
      // However, because the view can be themed, there may be more <div>s
      // added by some themes. Bootstrap-based themes, for instance, add
      // a <div> that nests the <table> within a view's <form>.
      //
      // If a view has AJAX and a pager enabled, then a page next/prev for
      // the view uses AJAX to replace the <div> with class "view" and all
      // of its content with a new page. This also replaces the inner <form>
      // and <table>. And both of those contain elements we need for behaviors.
      //
      const topSelector = ".foldershare-toolbar-and-folder-table";
      let $topElements;
 
      if (typeof pageContext.tagName === "undefined") {
        // The page context is for the full document. Search down
        // through the document to find the top element(s).
        $topElements = $(topSelector, pageContext);
        if ($topElements.length === 0) {
          // Fail. The document does not contain a top element.
          return true;
        }
      } else {
        // The page context is for a portion of the document. Search up
        // towards the document root fo find the top element.
        $topElements = $(pageContext).parents(topSelector).eq(0);
        if ($topElements.length === 0) {
          // Fail. The page context does not contain a top element.
          return true;
        }
      }
 
      //
      // Process top elements
      // --------------------
      // Process each top element. Usually there will be only one.
      $topElements.each((index, element) => {
        // Create a new environment object.
        const env = {
          settings: settings,
          $topElement: $(element),
          savedSelectionIds: [],
          refreshTimout: null
        };
 
        //
        // Gather configuration
        // --------------------
        // Find forms, form elements, and the table of files and folders.
        if (thisScript.gather(env) === false) {
          // Fail. UI elements could not be found.
          return true;
        }
 
        //
        // Gather commands
        // ---------------
        // The UI requires a list of file/folder commands installed on the
        // server, and their attributes.
        //
        // Find the full set of commands, cull them down to those
        // available to this user on this page, then categorize them.
        // Build toolbar and context menu command lists.
        thisScript.gatherCommands(env);
 
        // Check if drag-and-drop is supported.
        thisScript.checkCommandDragAndDropSupport(env);
 
        //
        // Build UI
        // --------
        // Build the UI, including its menu, and attach its behaviors.
        if (thisScript.build(env) === false) {
          // Fail. Something didn't work
          return true;
        }
 
        // Show UI, now that it has been fully built and set up.
        env.gather.$subform.removeClass("hidden");
 
        //
        // Save environment
        // ----------------
        // Save the environment to the environment list using the unique
        // view DOM ID in the gathered view selector.
        const viewSelector = env.gather.viewSelector;
        if (typeof Drupal.foldershare.environments[viewSelector] !== 'undefined') {
          // We've gather and built for this view before, but the view was
          // AJAX refreshed, such as by a page change or an automatic
          // periodic refresh. There may be a saved selection from the
          // prior presentation.
          const priorEnv = Drupal.foldershare.environments[viewSelector];
          if (priorEnv.savedSelectionIds.length !== 0) {
            thisScript.tableSetSelectionIds(env, priorEnv.savedSelectionIds);
          }
        }
 
        // Save the new environment, replacing any prior environment from a
        // prior refresh of the view.
        Drupal.foldershare.environments[viewSelector] = env;
      });
 
      return true;
    },
 
    /**
     * Gathers UI elements.
     *
     * Pages that add the UI place it within top element <div>
     * for the UI. Nested within is a <form> that contains the UI's
     * elements. The principal elements are:
     * - Multiple input fields for the command and its parameters, including
     *   the IDs of the current selection, parent, and destination, and
     *   a file upload field.
     * - An <input> to submit the command form.
     *
     * This function searches for the UI's elements and saves them
     * into the environment:
     * - env.gather.$commandForm = the <form> containing the UI.
     * - env.gather.$subform = the <div> within <form> containing the UI.
     * - env.gather.$table = the <table> containing the file/folder list.
     * - env.gather.$uploadInput = the file upload <input>.
     * - env.gather.$commandInput = the command ID <input>.
     * - env.gather.$selectionIdInput = the selection <input>.
     * - env.gather.$parentIdInput = the parent ID <input>.
     * - env.gather.$destinationIdInput = the destination ID <input>.
     * - env.gather.$commandSubmitButton = the button for submitting the form.
     * - env.gather.nameColumn = the table column name for the name & attrib.
     *
     * @param {object} env
     *   The environment object containing saved object references for
     *   elements to operate upon. The object is updated on success.
     *
     * @return {boolean}
     *   Returns TRUE on success and FALSE otherwise.
     */
    gather(env) {
      const utility = Drupal.foldershare.utility;
      const base = "foldershare-folder-table";
      const nameColumn = "views-field-name";
 
      //
      // Find form
      // ---------
      // From the top element wrapping the forms and view, search downward
      // for the <form> wrapping the main UI's elements.
      //
      //   <div class="foldershare-toolbar-and-folder-table ...">
      //     ...
      //     ... <form class="foldershare-folder-table-menu-form">
      //          ...
      //     ... </form>
      //     ...
      //   </div>
      //
      // Nesting may add intermediate <div>s throughout.
      let cls = `${base}-menu-form`;
      const $commandForm = $(`.${cls}`, env.$topElement).eq(0);
      if ($commandForm.length === 0) {
        utility.printMalformedError(
          `The required <form> with class '${cls}' could not be found.`);
        return false;
      }
 
      //
      // Find main UI's subform
      // --------------------------
      // Within the <form>, the main UI wraps its form elements within
      // a <div>. The <div> is important because the same <div> has data
      // attributes attached that describe the parent entity, access
      // permissions, etc.
      cls = `${base}-menu`;
      const $subform = $(`.${cls}`, $commandForm).eq(0);
      if ($subform.length === 0) {
        utility.printMalformedError(
          `The required <div> with class '${cls}' could not be found.`);
        return false;
      }
 
      //
      // Find submit
      // -----------
      // Normally, Drupal creates an <input> for submit buttons,
      // but some themes (such as Bootstrap) convert these to <button>.
      // Functionally, these are similar but it means we have to look
      // for either type.
      let $commandSubmitButton = $('input[type="submit"]', $commandForm).eq(0);
      if ($commandSubmitButton.length === 0) {
        $commandSubmitButton = $('button[type="submit"]', $commandForm).eq(0);
        if ($commandSubmitButton.length === 0) {
          utility.printMalformedError(
            "A submit <input> or <button> could not be found.");
          return false;
        }
      }
 
      //
      // Find inputs
      // -----------
      // The form contains several <input> items used to hold information
      // from the UI operation:
      // - A file upload <input>.
      // - A command ID <input>.
      // - A selection IDs <input>.
      // - A destination ID <input>.
      // - A parent ID <input>.
      //
      // The upload field's name uses special [] array syntax
      // imposed by the Drupal file module.
      const $uploadInput = $(
        'input[name="files[foldershare-folder-table-menu-upload][]"]',
        $commandForm).eq(0);
      if ($uploadInput.length === 0) {
        utility.printMalformedError("The main UI file input field is missing.");
        return false;
      }
 
      const $commandInput = $(
        'input[name="foldershare-folder-table-menu-commandname"]',
        $commandForm).eq(0);
      if ($commandInput.length === 0) {
        utility.printMalformedError("The main UI command ID field is missing.");
        return false;
      }
 
      const $selectionIdInput = $(
        'input[name="foldershare-folder-table-menu-selection"]',
        $commandForm).eq(0);
      if ($selectionIdInput.length === 0) {
        utility.printMalformedError(
          "The main UI selection IDs field is missing.");
        return false;
      }
 
      const $destinationIdInput = $(
        'input[name="foldershare-folder-table-menu-destinationId"]',
        $commandForm).eq(0);
      if ($destinationIdInput.length === 0) {
        utility.printMalformedError(
          "The main UI destination ID field is missing.");
        return false;
      }
 
      const $parentIdInput = $(
        'input[name="foldershare-folder-table-menu-parentId"]',
        $commandForm).eq(0);
      if ($parentIdInput.length === 0) {
        utility.printMalformedError("The main UI parent ID field is missing.");
        return false;
      }
 
      //
      // Find table
      // ----------
      // Search for a views <table>. We cannot count on a class name for
      // the table, though it is often views-table, because themes have
      // different templates.
      const $table = $(`table`, env.$topElement).eq(0);
      if ($table.length === 0) {
        utility.printMalformedError(
          `The required <table> with class '${cls}' could not be found.`);
        return false;
      }
 
      //
      // Find view
      // ---------
      // Search upwards from <table> for a parent with a class name
      // starting with 'js-view-dom-id'.
      const $view = $table.closest('[class*="js-view-dom-id"]');
      if ($view.length === 0) {
        utility.printMalformedError(
          "The required view DOM ID could not be found.");
        return false;
      }
 
      // Get the selector for the view. The selector uses a class on the
      // view DOM element starting with 'js-view-dom-id-'. The remainder
      // of the class name is the unique ID of the view.
      const viewClasses = $view.attr("class").split(" ");
      let viewSelector = "";
      for (let i = 0; i < viewClasses.length; ++i) {
        const viewClass = viewClasses[i];
        if (viewClass.startsWith("js-view-dom-id-") === true) {
          viewSelector = "." + viewClass;
          break;
        }
      }
 
      //
      // Update environment
      // ------------------
      // Save main UI objects.
      env.gather = {
        $commandForm: $commandForm,
        $subform: $subform,
        $view: $view,
        viewSelector: viewSelector,
        $table: $table,
        $tbody: $table.find("tbody"),
        $thead: $table.find("thead"),
        nameColumn: nameColumn,
        $uploadInput: $uploadInput,
        $commandInput: $commandInput,
        $selectionIdInput: $selectionIdInput,
        $destinationIdInput: $destinationIdInput,
        $parentIdInput: $parentIdInput,
        $commandSubmitButton: $commandSubmitButton
      };
 
      return true;
    },
 
    /**
     * Gathers a list of file/folder commands for use in main & context menus.
     *
     * A full list of file/folder commands installed for the Drupal module
     * is included in the DrupalSettings saved in the given environment.
     * This list is culled here into two lists:
     * - Commands for the toolbar menu.
     * - Commands for the row context menu (e.g. via a right-click).
     *
     * Commands are culled if:
     * - The page is disabled.
     * - The page's entity kind is not supported by the command.
     * - The command requires a selection but the page entity has no children.
     * - The user doesn't have necessary permissions on the parent.
     * - The user doesn't have necessary permissions on selections.
     * - The user doesn't have necessary permissions for special handling.
     *
     * The context menu command list also culls commands if:
     * - They never use a selection.
     * - They require upload or create special handling.
     *
     * Commands are then grouped by their named category. Category names may
     * be anything, but a set of well-known categories are defined by the
     * module and passed in as settings to this script. A typical
     * well-known category list includes:
     * - open
     * - import
     * - export
     * - close
     * - edit
     * - delete
     * - copymove
     * - save
     * - archive
     * - settings
     * - administer
     *
     * Unknown categories are added to the end. Any category may be empty.
     *
     * This function saves two command list objects to the environment.
     * Each object has one property for each supported command:
     * - env.mainCommands = the list of supported main menu commands.
     * - env.contextCommands = the list of supported context menu commands.
     *
     * For a command with ID 'abc', the definition is available at
     * env.mainCommands['abc'] or env.contextCommands['abc'] if it is
     * available for the main menu or context menu.
     *
     * Categorized main menu and context menu command lists are created and
     * added to the environment. Each list is an array, sorted by category
     * name. The value contains the name and an array of commands in the
     * category, also sorted by name. The commands are referred to by
     * command ID.
     * - env.mainCategories = the sorted list of categories and commands.
     * - env.contextCategories = the sorted list of categories and commands.
     *
     * @param {object} env
     *   The environment object.
     *
     * @return {boolean}
     *   Always returns true.
     */
    gatherCommands(env) {
      //
      // Cull commands
      // -------------
      // Cull the full command list into command lists for the main menu
      // and context menu. Commands are culled if:
      // - The page's entity kind is not supported by the command.
      // - The command requires a selection but the page entity has no children.
      // - The user doesn't have necessary permissions on the parent.
      // - The user doesn't have necessary permissions on selections.
      // - The user doesn't have necessary permissions for special handling.
      //
      // The context menu command list also culls commands if:
      // - They never use a selection.
      // - They require upload or create special handling.
      //
      // The mainCommands and contextCommands objects have one field per
      // command so that command information may be quickly looked up by
      // command ID.
      const mainCommands = Object.create(null);
      const contextCommands = Object.create(null);
 
      const allCommands = env.settings.foldershare.commands;
      const pageEntityKind = env.settings.foldershare.page.kind;
      const pageAccess = env.settings.foldershare.user.pageAccess;
      const pageDisabled = env.settings.foldershare.page.disabled;
 
      // Loop through all commands and cull them into main and context
      // menu lists.
      Object.keys(allCommands).forEach(commandId => {
        const def = allCommands[commandId];
 
        //
        // Parent (page) disabled.
        // -----------------------
        // If a page's entity is disabled, none of the commands are valid.
        // Being disabled is normally a temporary state while a long-running
        // operation is updating the entity. It is not safe to do any new
        // operations until that one finishes and the entity is re-enabled.
        if (pageDisabled === true) {
          return;
        }
 
        //
        // Current user suitable for command.
        // ----------------------------------
        // Check that the command's user requirements are met.
        const allowedUsers = def.userConstraints;
 
        let userOk = false;
        if (allowedUsers.includes("any") === true) {
          userOk = true;
        } else {
          allowedUsers.forEach((value) => {
            if (env.settings.foldershare.user[value] === true) {
              userOk = true;
            }
          });
        }
 
        if (userOk === false) {
          // Fail. User is not suitable for this command.
          return;
        }
 
        //
        // Parent kind suitable.
        // ---------------------
        // Check that the command's kind requirements are met.
        const allowedParentKinds = def.parentConstraints.kinds;
 
        if (allowedParentKinds.includes("any") === false &&
          allowedParentKinds.includes(pageEntityKind) === false) {
          // Fail. Parent's kind is not suitable for this command.
          return;
        }
 
        //
        // Parent access suitable.
        // -----------------------
        // Check that the command's parent access requirements are met.
        const allowedParentAccess = def.parentConstraints.access;
 
        if (pageAccess.includes(allowedParentAccess) === false) {
          // Fail. The command requires special permission for accessing
          // the parent but the user does not have it.
          return;
        }
 
        //
        // Parent ownership suitable.
        // --------------------------
        // Check that the command's parent ownership requirements are met.
        const allowedParentOwnership = def.parentConstraints.ownership;
 
        let parentOwnershipOk = false;
        if (allowedParentOwnership.includes("any") === true) {
          parentOwnershipOk = true;
        } else {
          allowedParentOwnership.forEach((value) => {
            if (env.settings.foldershare.page[value] === true) {
              parentOwnershipOk = true;
            }
          });
        }
 
        if (parentOwnershipOk === false) {
          // Fail. Parent ownership is not suitable for this command.
          return;
        }
 
        //
        // Selection type (size) suitable.
        // -------------------------------
        // Check that the command's selection size requirements are met.
        //
        // The special 'parent' value means that when there is no selection,
        // the command can default to the page's parent entity.
        //
        // If a command requires a selection, then the page entity kind
        // must be a folder or rootlist because other kinds aren't shown
        // with a list of selectable items.
        const allowedSelectionTypes = def.selectionConstraints.types;
 
        let selectable = false;
        switch (pageEntityKind) {
          case "rootlist":
          case "folder":
            selectable = true;
            break;
 
          default:
            break;
        }
 
        if (selectable === false &&
          allowedSelectionTypes.includes("none") === false &&
          allowedSelectionTypes.includes("parent") === false) {
          // Fail. The page's kind is not a folder, yet the command does not
          // support having no selection or reverting to the parent. The
          // command therefore always requires a selection, and yet no
          // selection is possible on this page.
          return;
        }
 
        //
        // Selection access suitable.
        // --------------------------
        // Check that the command's selection access requirements are met
        // by the current page.
        const allowedSelectionAccess = def.selectionConstraints.access;
 
        if (allowedSelectionAccess !== "none" &&
          pageAccess.includes(allowedSelectionAccess) === false) {
          // Fail. The command requires special permission for accessing
          // the selection but the user does not have it.
          return;
        }
 
        //
        // Main menu command.
        // ------------------
        // For the main menu, the above culling is sufficient. Commands
        // will operate on either the page entity (parent) or a selection
        // of one or more children (if available).
        mainCommands[commandId] = def;
 
        //
        // Context menu command.
        // ---------------------
        // For the context menu, there is always a selection (the row(s) the
        // menu is shown for). Further culling is needed to remove:
        // - Commands that do not use a selection.
        // - Commands that upload.
        let cullContext = false;
        if (def.specialHandling.includes("upload") === true) {
          cullContext = true;
        }
        if (allowedSelectionTypes.includes("one") === false &&
            allowedSelectionTypes.includes("many") === false) {
          cullContext = true;
        }
 
        if (cullContext === false) {
          contextCommands[commandId] = def;
        }
      });
 
      // Save the command lists back to the environment. It is possible
      // for both command lists to be empty if none of the above commands
      // met page criteria.
      env.mainCommands = mainCommands;
      env.contextCommands = contextCommands;
 
      //
      // Get well-known categories.
      // --------------------------
      // Start with a list of well-known categories and add them
      // to the category list for the main and context menus.
      let mainCategories = new Map();
      let contextCategories = new Map();
 
      const categoryTerms = env.settings.foldershare.terminology.categories;
 
      Object.keys(env.settings.foldershare.categories).forEach(key => {
        const cat = env.settings.foldershare.categories[key];
 
        // Get the translated term, if any.
        let term = cat;
        if (categoryTerms.hasOwnProperty(cat) === true) {
          term = categoryTerms[cat];
        }
 
        // Add the category.
        mainCategories.set(cat, {
          name: term,
          commandIds: []
        });
        contextCategories.set(cat, {
          name: term,
          commandIds: []
        });
      });
 
      //
      // Categorize commands.
      // --------------------
      // Loop through all commands and add them to categories. If a command
      // uses an unrecognized category, add it to a separate category list.
      const extraMainCategories = new Map();
      const extraContextCategories = new Map();
 
      Object.keys(env.mainCommands).forEach(commandId => {
        const def = env.mainCommands[commandId];
        const cat = def.category;
 
        if (mainCategories.has(cat) === true) {
          mainCategories.get(cat).commandIds.push(commandId);
        } else if (extraMainCategories.has(cat) === true) {
          extraMainCategories.get(cat).commandIds.push(commandId);
        } else {
          // Get the translated term, if any.
          let term = cat;
          if (categoryTerms.hasOwnProperty(cat) === true) {
            term = categoryTerms[cat];
          }
 
          // Convert to title-case.
          term = term.charAt(0).toUpperCase() + term.substr(1).toLowerCase();
 
          extraMainCategories.set(cat, {
            name: term,
            commandIds: [commandId]
          });
        }
      });
 
      Object.keys(env.contextCommands).forEach(commandId => {
        const def = env.contextCommands[commandId];
        const cat = def.category;
 
        if (contextCategories.has(cat) === true) {
          contextCategories.get(cat).commandIds.push(commandId);
        } else if (extraContextCategories.has(cat) === true) {
          extraContextCategories.get(cat).commandIds.push(commandId);
        } else {
          // Get the translated term, if any.
          let term = cat;
          if (categoryTerms.hasOwnProperty(cat) === true) {
            term = categoryTerms[cat];
          }
 
          // Convert to title-case.
          term = term.charAt(0).toUpperCase() + term.substr(1).toLowerCase();
 
          extraContextCategories.set(cat, {
            name: term,
            commandIds: [commandId]
          });
        }
      });
 
      //
      // Sort commands by weight and name.
      // ---------------------------------
      // Loop through all of the categories for main and context menus and:
      // - Remove empty categories.
      // - Sort category commands by their weight and name.
      const mainSortFunction = (a, b) => {
        const adef = env.mainCommands[a];
        const bdef = env.mainCommands[b];
 
        const diff = Number(adef.weight) - Number(bdef.weight);
        if (diff !== 0) {
          return diff;
        }
        if (adef.menuNameDefault < bdef.menuNameDefault) {
          return -1;
        }
        if (adef.menuNameDefault > bdef.menuNameDefault) {
          return 1;
        }
        return 0;
      };
 
      const contextSortFunction = (a, b) => {
        const adef = env.contextCommands[a];
        const bdef = env.contextCommands[b];
 
        const diff = Number(adef.weight) - Number(bdef.weight);
        if (diff !== 0) {
          return diff;
        }
        if (adef.menuNameDefault < bdef.menuNameDefault) {
          return -1;
        }
        if (adef.menuNameDefault > bdef.menuNameDefault) {
          return 1;
        }
        return 0;
      };
 
      // Copy non-empty categories, sorting each one by command name.
      let tmp = new Map();
      mainCategories.forEach((value, cat) => {
        if (mainCategories.get(cat).commandIds.length !== 0) {
          tmp.set(cat, mainCategories.get(cat));
          tmp.get(cat).commandIds.sort(mainSortFunction);
        }
      });
      mainCategories = tmp;
 
      extraMainCategories.forEach((value, cat) => {
        extraMainCategories.get(cat).commandIds.sort(mainSortFunction);
      });
 
      // Copy non-empty categories, sorting each one by command name.
      tmp = new Map();
      contextCategories.forEach((value, cat) => {
        if (contextCategories.get(cat).commandIds.length !== 0) {
          tmp.set(cat, contextCategories.get(cat));
          tmp.get(cat).commandIds.sort(contextSortFunction);
        }
      });
      contextCategories = tmp;
 
      extraContextCategories.forEach((value, cat) => {
        extraContextCategories.get(cat).commandIds.sort(contextSortFunction);
      });
 
      //
      // Sort and add extra categories by name.
      // --------------------------------------
      if (extraMainCategories.size !== 0) {
        // Append extras.
        extraMainCategories.forEach((value, cat) => {
          mainCategories.set(cat, extraMainCategories.get(cat));
        });
      }
 
      if (extraContextCategories.size !== 0) {
        // Append extras.
        extraContextCategories.forEach((value, cat) => {
          contextCategories.set(cat, extraContextCategories.get(cat));
        });
      }
 
      // Save the categorized lists back to the environment.
      env.mainCategories = mainCategories;
      env.contextCategories = contextCategories;
 
      return true;
    },
 
    /*--------------------------------------------------------------------
     *
     * Build the folder table menu UI.
     *
     *--------------------------------------------------------------------*/
 
    /**
     * Builds the UI.
     *
     * The main UI has several features:
     * - A hierarchical main menu that pops up from a menu button.
     * - A context menu that pops up from a right-click on a row.
     * - Selectable rows in the view table.
     * - Drag-and-drop of selected rows to a subfolder.
     * - Drag-and-drop from the host into the table to do an upload.
     *
     * @param {object} env
     *   The environment object.
     *
     * @return {boolean}
     *   Returns TRUE on success and FALSE otherwise.
     */
    build(env) {
      const thisScript = Drupal.foldershare.UIFolderTableMenu;
      const pageDisabled = env.settings.foldershare.page.disabled;
 
      //
      // Create main menu button
      // -----------------------
      // Create the main menu button and append it to the command subform.
      // If there is a button already there, remove it first.
      let buttonClasses = "foldershare-folder-table-mainmenu-button";
      if (pageDisabled === true) {
        buttonClasses += " foldershare-folder-table-mainmenu-button-disabled";
      }
 
      $(".foldershare-folder-table-mainmenu-button",
        env.gather.$subform).remove();
      const menuTerm = Drupal.foldershare.utility.getTerm(
        env.settings.foldershare.terminology, "menu");
      env.gather.$subform.prepend(
        `<button type="button" class="${buttonClasses}"><span>${menuTerm}</span></button>`);
      const $menuButton = $(".foldershare-folder-table-mainmenu-button",
        env.gather.$subform);
      $menuButton.button().show();
      if (pageDisabled === true) {
        $menuButton.button("disable");
      }
 
      //
      // Create main menu
      // ----------------
      // Create the main menu HTML and append it to the command subform.
      // If there is a menu already there, remove it first.
      $(".foldershare-folder-table-mainmenu", env.gather.$subform).remove();
      env.gather.$subform.append(thisScript.buildMainMenu(env));
      const $menu = $(".foldershare-folder-table-mainmenu",
        env.gather.$subform);
      $menu.menu().hide();
      $menu.removeClass("hidden");
      if (pageDisabled === true) {
        $menu.menu("disable");
      }
 
      // Disable the browser's context menu on the menu itself.
      $menu.on("contextmenu.foldershare", function(ev) {
        // Stop the default behavior.
        ev.preventDefault();
        return false;
      });
 
      //
      // Create context menu
      // -------------------
      // Create the context menu HTML and append it to the command subform.
      // If there is a menu already there, remove it first.
      $(".foldershare-folder-table-contextmenu", env.gather.$subform).remove();
      env.gather.$subform.append(thisScript.buildContextMenu(env));
      const $contextMenu = $(".foldershare-folder-table-contextmenu",
        env.gather.$subform);
      $contextMenu.menu().hide();
      $contextMenu.removeClass("hidden");
      if (pageDisabled === true) {
        $contextMenu.menu("disable");
      }
 
      // Disable the browser's context menu on the context menu itself.
      $contextMenu.on("contextmenu.foldershare", function(ev) {
        // Stop the default behavior.
        ev.preventDefault();
        return false;
      });
 
      //
      // Attach main menu button behavior
      // --------------------------------
      // When the main menu button is pressed, show the main menu.
      // When the menu is about to be shown, update all menu items to
      // enable/disable and adjust the text to reflect the selection.
      $menuButton.off("click.foldershare");
      if (pageDisabled !== true) {
        $menuButton.on("click.foldershare", ev => {
          // Hide the context menu, if shown.
          $contextMenu.hide();
 
          if ($menu.menu().is(":visible")) {
            // When the menu is already visible, hide it.
            $menu.menu().hide();
          } else {
            // Update the menu's text based on the selection.
            thisScript.menuUpdate(env, $menu);
 
            // Position the menu and show it.
            $menu.show().position({
              my: "left top",
              at: "left bottom",
              of: ev.target,
              collision: "fit"
            });
 
            // Register a handler to catch an off-menu click to hide it.
            $(document).on("click.foldershare_menu", () => {
              $menu.menu().hide();
 
              // Let the rest of the default behaviors occur.
              return true;
            });
          }
 
          // Stop the default behavior.
          ev.preventDefault();
          return false;
        });
      }
 
      //
      // Attach main menu item behavior
      // ------------------------------
      // When a menu item is selected, trigger the command.
      $menu.off("menuselect.foldershare");
      if (pageDisabled !== true) {
        $menu.on("menuselect.foldershare", (ev, ui) => {
          // Insure the menu is hidden.
          $menu.menu().hide();
 
          // Fill the server form.
          const command = $(ui.item).attr("data-foldershare-command");
          thisScript.serverCommandSetup(
            env,
            command,
            null,
            null,
            thisScript.tableGetSelectionIds(env),
            null);
 
          const specialHandling = env.mainCommands[command].specialHandling;
          if ($.inArray("upload", specialHandling) !== -1) {
            // Show file dialog.
            env.gather.$uploadInput.click();
          } else {
            // Submit form.
            thisScript.serverCommandSubmit(env);
          }
 
          // Stop the default behavior.
          ev.preventDefault();
          return false;
        });
      }
 
      //
      // Attach context menu item behavior
      // ---------------------------------
      // When a menu item is selected, trigger the command.
      $contextMenu.off("menuselect.foldershare");
      if (pageDisabled !== true) {
        $contextMenu.on("menuselect.foldershare", (ev, ui) => {
          // Insure the menu is hidden.
          $contextMenu.menu().hide();
 
          // Fill the server form.
          const command = $(ui.item).attr("data-foldershare-command");
          thisScript.serverCommandSetup(
            env,
            command,
            null,
            null,
            thisScript.tableGetSelectionIds(env),
            null);
 
          const specialHandling = env.contextCommands[command].specialHandling;
          if ($.inArray("upload", specialHandling) !== -1) {
            // Show file dialog.
            env.gather.$uploadInput.click();
          } else {
            // Submit form.
            thisScript.serverCommandSubmit(env);
          }
 
          // Stop the default behavior.
          ev.preventDefault();
          return false;
        });
      }
 
      //
      // Attach upload behavior
      // ----------------------
      // When a file dialog is closed, and there is a file selection,
      // trigger an upload command.
      env.gather.$uploadInput.off("change.foldershare");
      if (pageDisabled !== true) {
        env.gather.$uploadInput.on("change.foldershare", (ev) => {
          // When called, the upload field's file list has already been
          // set via the browser's file dialog. The other fields of the
          // command form were set up when the menu command was selected.
          thisScript.serverCommandSubmit(env);
 
          // Stop the default behavior.
          ev.preventDefault();
          return false;
        });
      }
 
      //
      // Add table behaviors
      // -------------------
      // Add table and table row behaviors, such as for row selection,
      // drag-and-drop, and the context menu.
      if (pageDisabled !== true) {
        thisScript.tableAttachBehaviors(env);
      }
 
      //
      // Update environment
      // ------------------
      // Add the menus to the environment.
      env.gather.$menu = $menu;
      env.gather.$contextMenu = $contextMenu;
      env.gather.$menuButton = $menuButton;
 
      return true;
    },
 
    /**
     * Builds the <ul> for the main menu.
     *
     * The list of commands suitable for the user and page is used to
     * create HTML containing a nested <ul> list. Each <li> in the list
     * is either an available command or the name of a submenu.
     *
     * @param {object} env
     *   The environment object.
     *
     * @return {string}
     *   Returns HTML for the main menu.
     */
    buildMainMenu(env) {
      // Start the <ul>.
      let html = '<ul class="hidden foldershare-folder-table-mainmenu">';
 
      // Loop through all main menu categories.
      const maxBeforeSub = env.settings.foldershare.module.submenuthreshold;
      let addSeparator = false;
      env.mainCategories.forEach((value, cat) => {
        // Add a separator before the next category of commands.
        if (addSeparator === true) {
          html += "<li>-</li>";
        }
        addSeparator = true;
 
        // Create a submenu if the category is large enough.
        let addSubmenu = false;
        if (env.mainCategories.get(cat).commandIds.length > maxBeforeSub) {
          html += `<li><div>${value.name}</div><ul>`;
          addSubmenu = true;
        }
 
        // Add the category's commands.
        Object.keys(env.mainCategories.get(cat).commandIds).forEach(key => {
          const commandId = env.mainCategories.get(cat).commandIds[key];
          const label = env.mainCommands[commandId].menuNameDefault;
          html += `<li data-foldershare-command="${commandId}"><div>${label}</div></li>`;
        });
 
        if (addSubmenu === true) {
          html += "</ul></li>";
        }
      });
 
      html += "</ul>";
 
      return html;
    },
 
    /**
     * Builds the <ul> for the context menu.
     *
     * The list of commands suitable for the user and page is used to
     * create HTML containing a nested <ul> list. Each <li> in the list
     * is either an available command or the name of a submenu.
     *
     * @param {object} env
     *   The environment object.
     *
     * @return {string}
     *   Returns HTML for the context menu.
     */
    buildContextMenu(env) {
      // Start the <ul>.
      let html = '<ul class="hidden foldershare-folder-table-contextmenu">';
 
      // Loop through all context menu categories.
      const maxBeforeSub = env.settings.foldershare.module.submenuthreshold;
      let addSeparator = false;
      env.contextCategories.forEach((value, cat) => {
        // Add a separator before the next category of commands.
        if (addSeparator === true) {
          html += "<li>-</li>";
        }
        addSeparator = true;
 
        // Create a submenu if the category is large enough.
        let addSubmenu = false;
        if (env.contextCategories.get(cat).commandIds.length > maxBeforeSub) {
          html += `<li><div>${value.name}</div><ul>`;
          addSubmenu = true;
        }
 
        // Add the category's commands.
        Object.keys(env.contextCategories.get(cat).commandIds).forEach(key => {
          const commandId = env.contextCategories.get(cat).commandIds[key];
          const label = env.contextCommands[commandId].menuNameDefault;
          html += `<li data-foldershare-command="${commandId}"><div>${label}</div></li>`;
        });
 
        if (addSubmenu === true) {
          html += "</ul></li>";
        }
      });
 
      html += "</ul>";
 
      return html;
    },
 
    /*--------------------------------------------------------------------
     *
     * Menu.
     *
     *--------------------------------------------------------------------*/
 
    /**
     * Updates a menu to enable/disable commands based on the selection.
     *
     * For each menu item, the selection constraints of the associated
     * command are checked against the given selection. If the constraints
     * are not met, the menu item is disabled and its text is set to generic
     * menu item text (e.g. "Delete"). If the constraints are met, the item
     * is enabled and its text is set appropriate for the selection (e.g.
     * "Delete Folder").
     *
     * @param {object} env
     *   The environment object.
     * @param {object} $menu
     *   The menu.
     */
    menuUpdate(env, $menu) {
      const thisScript = Drupal.foldershare.UIFolderTableMenu;
 
      // Count the number of selected items. There could be zero.
      const selection = thisScript.tableGetSelectionIdsByKind(env);
      let nSelected = 0;
      Object.keys(selection).forEach(kind => {
        nSelected += selection[kind].length;
      });
 
      // Get operand text describing the selection. This text may be
      // inserted into menu item labels.
      const operand = thisScript.menuGetOperandText(env, selection);
 
      // Loop through the menu and enable items that are suitable for the
      // current selection, and disable those that are not.
      $(".ui-menu-item", $menu).each((index, value) => {
        const $item = $(value);
 
        // Skip irrelevant menu items.
        if ($item.hasClass("ui-menu-divider") === true) {
          // Skip. Ignore separators.
          return true;
        }
 
        if ($item.hasClass("ui-state-broken") === true) {
          // Skip. Ignore broken menu items.
          return true;
        }
 
        // Get the menu item's command ID.
        const commandId = $item.attr("data-foldershare-command");
        if (typeof commandId === "undefined") {
          // Fail. Malformed menu item!
          return true;
        }
 
        if (commandId in env.settings.foldershare.commands === false) {
          // Fail. Unknown command. Mark it broken.
          $item.removeClass("ui-state-enabled");
          $item.addClass("ui-state-disabled");
          $item.addClass("ui-state-broken");
          return true;
        }
 
        // Validate the selection against the command's constraints.
        let text = "";
        if (thisScript.checkSelectionConstraints(
          env,
          nSelected,
          selection,
          commandId) === false) {
          // The command is not enabled in this context. Perhaps the
          // selection doesn't match what the command needs, or the
          // access permissions aren't right.
          //
          // In any case, we need to:
          // - Mark the menu item as disabled.
          // - Make its menu text generic.
          $item.removeClass("ui-state-enabled");
          $item.addClass("ui-state-disabled");
 
          // Generic text is encoded as an attribute on the menu item.
          // Get it and replace the user-visible text with that generic text.
          text = env.settings.foldershare.commands[commandId].menuNameDefault;
        } else {
          // The command is enabled in this context. The selection must
          // have satisfied the command's constraints, or perhaps it doesn't
          // need a selection.
          //
          // In any case, we need to:
          // - Mark the command as enabled.
          // - Make its menu text specific to the selection.
          $item.removeClass("ui-state-disabled");
          $item.addClass("ui-state-enabled");
 
          // Specific menu text, with a '@operand' placeholder, is encoded
          // as an attribute on the menu item. Get it, substitute '@operand'
          // with a suitable comment on the selection, then replace the
          // user-visible text of the menu item with the new text.
          text = env.settings.foldershare.commands[commandId].menuName;
          text = text.replace("@operand", operand);
        }
 
        $("div", $item).text(text);
      });
    },
 
    /**
     * Returns operand text based on the current selection.
     *
     * Operand text briefly describes the current selection. The text is
     * suitable for embedding within a menu item's name.
     *
     * @param {object} env
     *   The environment object.
     * @param {object} selection
     *   The current selection.
     *
     * @return {string}
     *   Returns text describing the current selection.
     */
    menuGetOperandText(env, selection) {
      //
      // Scan selection
      // --------------
      // Get the selection, which may be empty, then scan it to collect
      // information characterizing it:
      // - The total number of selected items.
      // - The total number of different kinds of selected items.
      // - The kind, if there is only one in use.
      let nSelected = 0;
      let nKinds = 0;
      let kind = "";
 
      Object.keys(selection).forEach(k => {
        const len = selection[k].length;
        nSelected += len;
        if (len > 0) {
          nKinds++;
          kind = k;
        }
      });
 
      const terminology = env.settings.foldershare.terminology;
 
      //
      // No selection case
      // -----------------
      // When there is no selection, use the kind of the page entity.
      if (nSelected === 0) {
        kind = env.settings.foldershare.page.kind;
        const term = Drupal.foldershare.utility.getTerm(terminology, "this", false);
        const singularKind = Drupal.foldershare.utility.getKindSingular(terminology, kind);
        return `${term} ${singularKind}`;
      }
 
      //
      // Single selection
      // ----------------
      // When there is just one item selected, use the kind of the selection.
      if (nSelected === 1) {
        return Drupal.foldershare.utility.getKindSingular(terminology, kind);
      }
 
      //
      // Multiple selection, one kind
      // ----------------------------
      // When there are multiple items selected, but all of the same kind,
      // use the kind of the selection.
      if (nKinds === 1) {
        return Drupal.foldershare.utility.getKindPlural(terminology, kind);
      }
 
      //
      // Multiple selection, multiple kinds
      // ----------------------------------
      // Otherwise there are multiple items selected and they are a mix of
      // multiple kinds. Returns return 'Items'.
      return Drupal.foldershare.utility.getKindPlural(terminology, "item");
    },
 
    /*--------------------------------------------------------------------
     *
     * Server form.
     *
     *--------------------------------------------------------------------*/
 
    /**
     * Sets up a server command.
     *
     * @param {object} env
     *   The environment object.
     * @param {string} command
     *   The id/name of the command.
     * @param {int} parentId
     *   (optional, default = null = current page) The parent entity ID.
     *   If not given, the current page's parent ID is used.
     * @param {int} destinationId
     *   (optional, default = null = none) The destination entity ID.
     *   If not given, the value is left empty.
     * @param {int[]} selectionIdList
     *   (optional, default = null = none) The list of selection IDs.
     *   If not given, the value is left empty.
     * @param {FileList} fileList
     *   (optional, default = null = none) The file list. If not given,
     *   the value is left empty.
     */
    serverCommandSetup(
      env,
      command,
      parentId = null,
      destinationId = null,
      selectionIdList = null,
      fileList = null) {
 
      // Clear any pending table refresh.
      if (env.refreshTimeout !== null) {
        clearTimeout(env.refreshTimeout);
        env.refreshTimeout = null;
      }
 
      env.gather.$commandForm[0].reset();
 
      env.gather.$commandInput.val(command);
 
      if (parentId === null) {
        env.gather.$parentIdInput.val(env.settings.foldershare.page.id);
      } else {
        env.gather.$parentIdInput.val(parentId);
      }
 
      if (destinationId !== null) {
        env.gather.$destinationIdInput.val(destinationId);
      }
 
      if (selectionIdList !== null) {
        env.gather.$selectionIdInput.val(JSON.stringify(selectionIdList));
      }
 
      if (fileList !== null) {
        // Setting the file list triggers a behavior which does
        // a form submit.
        env.gather.$uploadInput[0].files = fileList;
        this.serverCommandSubmit(env);
      }
    },
 
    /**
     * Submits a previously set up server command.
     *
     * @param {object} env
     *   The environment object.
     */
    serverCommandSubmit(env) {
      if (env.settings.foldershare.ajaxEnabled === true) {
        env.gather.$commandSubmitButton.submit();
      } else {
        env.gather.$commandForm.submit();
      }
    },
 
    /*--------------------------------------------------------------------
     *
     * Feature checks.
     *
     *--------------------------------------------------------------------*/
 
    /**
     * Checks command support of copy, move, and upload drag-and-drop.
     *
     * Copy, move, and upload drag-and-drop features are supported in the UI
     * dependant upon available commands and the type of page being shown.
     *
     * - If the page is for a file, not a folder or root list, then there is
     *   no drag-and-drop supported for the page.
     *
     * - If the copy command is available, then drag-and-drop row copy
     *   is supported.
     *
     * - If the move command is available, then drag-and-drop row move
     *   is supported.
     *
     * - If the upload command is available, then drag-and-drop of files
     *   from off browser is supported.
     *
     * Note that file drag-and-drop also requires checking if the browser
     * supports the feature. This check is done separately.
     *
     * Several environment flags are set:
     * - env.dndCopyEnabled = true if enabled.
     * - env.dndMoveEnabled = true if enabled.
     * - env.dndUploadEnabled = true if enabled.
     * - env.dndUploadChecked = false.
     *
     * @param {object} env
     *   The environment object.
     *
     * @see checkBrowserFileDragSupport()
     */
    checkCommandDragAndDropSupport(env) {
      const thisScript = Drupal.foldershare.UIFolderTableMenu;
 
      switch (env.settings.foldershare.page.kind) {
        case "rootlist":
          env.dndCopyEnabled = thisScript.copyCommand in env.mainCommands;
          if (env.settings.foldershare.user.adminpermission === true) {
            env.dndMoveEnabled = thisScript.moveCommandAsAdmin in env.mainCommands;
            if (env.dndMoveEnabled === false) {
              env.dndMoveEnabled = thisScript.moveCommandOnRootList in env.mainCommands;
            }
          }
          else {
            env.dndMoveEnabled = thisScript.moveCommandOnRootList in env.mainCommands;
          }
          env.dndUploadEnabled = thisScript.uploadCommand in env.mainCommands;
          env.dndUploadChecked = false;
          break;
 
        case "folder":
          env.dndCopyEnabled = thisScript.copyCommand in env.mainCommands;
          env.dndMoveEnabled = thisScript.moveCommand in env.mainCommands;
          env.dndUploadEnabled = thisScript.uploadCommand in env.mainCommands;
          env.dndUploadChecked = false;
          break;
 
        default:
          // For any other kind (e.g. file, image, or media), there are no
          // children so drag-and-drop doesn't make sense.
          env.dndCopyEnabled = false;
          env.dndMoveEnabled = false;
          env.dndUploadEnabled = false;
          env.dndUploadChecked = false;
          break;
      }
    },
 
    /**
     * Checks browser support of drag-and-drop of files for uploads.
     *
     * Modern browsers allow the "files" value of a file input field to
     * be set with a FileList object. The only way to get such an object
     * is from an event's dataTransfer, which is why we need to check
     * this browser ability from within an event behavior.
     *
     * If the browser throws an exception when attempting to set the
     * "files" value, then the browser is old and does not support the
     * feature. Without that feature, we cannot put the names of dragged
     * files into the file input field and therefore cannot do the upload.
     *
     * @param {object} ev
     *   The file drag event.
     * @param {object} env
     *   The environment object.
     *
     * @return {boolean}
     *   Returns false if file drag support is not available.
     */
    checkBrowserFileDragSupport(ev, env) {
      // If the file upload command is not available or if a prior call to
      // this method has already determined that file drag-and-drop is not
      // supported, then return false.
      if (env.dndUploadEnabled === false) {
        return false;
      }
 
      // If file upload support in the browser has already been checked,
      // then return true.
      if (env.dndUploadChecked === true) {
        return env.dndUploadEnabled;
      }
 
      // Check if the browser is not properly supporting data transfer
      // properties.
      if (typeof ev.originalEvent.dataTransfer === "undefined" ||
        typeof ev.originalEvent.dataTransfer.files === "undefined") {
        // Fail. The data transfer or files properties are missing.
        // The browser does not appear to be supporting drag-and-drop.
        env.dndUploadEnabled = false;
        env.dndUploadChecked = true;
        return env.dndUploadEnabled;
      }
 
      // Try to set the file upload field.
      try {
        env.gather.$uploadInput[0].files = ev.originalEvent.dataTransfer.files;
      } catch (er) {
        // Fail. Old browser does not support setting the "files" value.
        // File drag not supportable.
        env.dndUploadEnabled = false;
        env.dndUploadChecked = true;
 
        // Tell the user the drag is not supported.
        let text = "<div>";
        const translated = env.settings.foldershare.terminology.text.upload_dnd_not_supported;
        if (typeof translated === "undefined") {
          text += "<p><strong>Drag-and-drop file upload is not supported.</strong></p>";
          text += "<p>This feature is not supported by this web browser.</p>";
        } else {
          text += translated;
        }
        text += "</div>";
        Drupal.dialog(text, {}).showModal();
        return env.dndUploadEnabled;
      }
 
      env.dndUploadEnabled = true;
      env.dndUploadChecked = true;
      return env.dndUploadEnabled;
    },
 
    /*--------------------------------------------------------------------
     *
     * Operand checks.
     *
     *--------------------------------------------------------------------*/
 
    /**
     * Check if a current file drag-and-drop is valid.
     *
     * Users may select an arbitrary mix of files and folders in the
     * file browser of modern OSes, such as the Mac Finder or the Windows
     * Explorer. Those can be dragged to a browser window and into the
     * drag-and-drop area of this module in order to trigger an upload.
     *
     * HOWEVER, web browsers currently only support dragging and uploading
     * files. And yet it remains possible for a user to try to drag in a
     * folder.  This function checks the entries in the FileList being
     * dragged and verifies that they are all files, and no folders.
     * On success or failure the appropriate given function is called.
     *
     * Note that this function is ASYNCHRONOUS (because the underlying
     * file reading API is), so it will return immediately while file
     * checking continues in the background.
     *
     * @param {object} ev
     *   The file drag event.
     * @param {object} env
     *   The environment object.
     * @param {function} onValid
     *   The function to call if the file drag is valid. The function
     *   is called with the original event, environment, and file list.
     * @param {function} onInvalid
     *   The function to call if the file drag is not valid. The function
     *   is called with the original event, environment, and file list.
     */
    checkFileDragValid(ev, env, onValid, onInvalid) {
      // Check that the event has dataTransfer and files fields and
      // that there are actual files in the drag.
      if (typeof ev.originalEvent.dataTransfer === "undefined" ||
        typeof ev.originalEvent.dataTransfer.files === "undefined") {
        // Fail. Malformed event.
        onInvalid(ev, env, null);
        return;
      }
 
      const fileList = ev.originalEvent.dataTransfer.files;
      const nFiles = fileList.length;
 
      if (nFiles === 0) {
        // Fail. Empty file list.
        onInvalid(ev, env, fileList);
        return;
      }
 
      // Loop over the list and validate each entry.
      //
      // Each file entry has a name, size, type, and last modified date.
      // Unfortunately, NONE OF THESE can be used by themselves to reliably
      // detect a folder vs. a file.
      //
      // Files and folders both have non-empty names. Further, names are just
      // the file/folder name itself, without a leading path or trailing '/'.
      // So, names may not be used for validity checking.
      //
      // Files and folders both have sizes. While there are on-line claims
      // that folders will only have sizes that are a multiple of 4096 bytes,
      // this is entirely bogus. The size reported for a dragged folder
      // depends upon the OS and its configuration. So, sizes may not be used
      // for validity checking.
      //
      // Files and folders both have modified dates. There is no distinguishing
      // feature here between files and folders, so this is not useful for
      // validity checking.
      //
      // Finally, the type property contains the MIME type of the dragged item.
      // Folders do not have a MIME type, so at first this would seem to be an
      // indicator. BUT...
      // - A file with no extension also has no MIME type.
      // - A folder with an extension has a bogus MIME type.
      //
      // So the MIME type property can be empty for a file, or set for a
      // folder. This makes it not useful for validity checking.
      //
      // This means that NONE of the available properties are useful indicators
      // of files vs. folders. What we CAN DO is try to read bytes from the
      // item. Reading a file will succeed. Reading a folder will fail.
      //
      // FileReader() works asynchronously. If we try to start up too many
      // simultaneous reads, we'll get an error (varies by browser). So we
      // need to serialize this. We do this with:
      //   - load handler: called when the file starts loading. We immediately
      //     abort since we don't need to waste time reading the whole file.
      //
      //   - load end handler:  called when done reading a file, including
      //     when a file load has been aborted. We check if there is another
      //     file to read and start up that read. This continues until there
      //     are no more files to read.
      //
      // To catch errors, we use a:
      //   - error handler: called on an error, such as permission denied or
      //     trying to read a folder. Increment an error counter.
      //
      // If no errors occur, the process will go through the files in order,
      // trying to read each one, aborting, trying the next, etc. When they've
      // all been read, the validity handler is called.
      //
      // If an error occurs, the process will abort and call the invalidity
      // handler.
      let nChecked = 0;
      let nErrors = 0;
      let i = 0;
      const reader = new FileReader();
 
      reader.onerror = () => {
        ++nErrors;
      };
      reader.onload = e => {
        e.target.abort();
      };
      reader.onloadend = e => {
        // If any error has occurred, stop and report invalid.
        if (nErrors > 0) {
          onInvalid(e, env, fileList);
          return;
        }
 
        // If we're done checking files, report valid.
        ++nChecked;
        if (nChecked === nFiles) {
          onValid(e, env, fileList);
          return;
        }
 
        // Otherwise, when there is more to read, start reading the
        // next file.
        ++i;
        e.target.readAsArrayBuffer(fileList[i]);
      };
 
      // Start it up on the first file.
      reader.readAsArrayBuffer(fileList[i]);
    },
 
    /*--------------------------------------------------------------------
     *
     * Table behaviors - overview.
     *
     * These functions manage interaction behaviors on the file & folder
     * table.
     *
     * Selection.
     * ----------
     * Selection marks a row or rows as the nouns for the next verb chosen
     * from the command menu.
     *
     * Selection is indicated by giving a row the "selected" class. CSS
     * uses the "selected" class to highlight a selected row. When a command
     * is chosen, the set of selected items is found by looking for all rows
     * with the "selected" class.
     *
     * - On left mouse click or touch, the row is selected/unselected based
     *   upon modifier keys (e.g. SHIFT, CTRL, CMD, ALT).
     * - On right mouse click, if the row is not selected, then select it.
     *   Present a context menu.
     *
     * Open.
     * -----
     * Opening a row shows the view page for the row's entity.
     *
     * - On mouse double-click, open the row's item into a new page.
     *
     * Dragging - general.
     * -------------------
     * When copy and/or move are enabled, rows may be dragged and dropped
     * onto folders.
     *
     * When file uploads are enabled, files may be dragged from off browser
     * and dropped onto the table or onto folders in the table.
     *
     * Because browsers generate different event sequences for "rows" and
     * "files" drags, the event handler response is keyed by the type of drag.
     *
     * Dragging - rows drag.
     * ---------------------
     * A "rows" drag generates the following event sequence:
     * - "dragstart" starts the drag.
     * - "dragenter" is sent every time the drag enters an element.
     * - "dragleave" is sent every time the drag leaves an element.
     * - "dragover" is generated repeatedly as the mouse moves.
     * - "drop" is sent when the user releases the drag.
     * - "dragend" follows the "drop" event.
     *
     * Table styling affects the order of "dragenter" and "dragleave":
     * - If a table has no row spacing (which is typical), then "dragenter"
     *   for the entered row occurs BEFORE "dragleave" for the left row.
     *
     * - If there is row spacing (which default HTML styling does), then
     *   "dragenter" for the entered row occurs AFTER "dragleave" for the
     *   left row.
     *
     * - "dragenter" and "dragleave" are generated for every element, including
     *   nested elements within a table's rows and columns, such as <A> for
     *   anchors, <IMG> for image icons, <SPAN>, <STRONG>, etc. So the specific
     *   structure of a row's content will also affect enter/leave events.
     *
     * If the user cancels the drag (e.g. press the ESCAPE key), the "drop"
     * event does not occur, but "dragend" does.
     *
     * "rows" drag handling here uses events like this:
     * - "dragstart" starts a drag and sets up the data transfer.
     * - "dragenter" is ignored.
     * - "dragover" updates highlighting based on the current row's attributes.
     * - "dragleave" is ignored.
     * - "drop" drops the rows.
     * - "dragend" cleans up after a row "drop" or cancel.
     *
     * Dragging - files (upload) drag.
     * -------------------------------
     * A "files" drag generates the following event sequence:
     * - "dragenter" is sent every time the drag enters an element.
     * - "dragleave" is sent every time the drag leaves an element.
     * - "dragover" is generated repeatedly as the mouse moves.
     * - "drop" is sent when the user releases the drag.
     *
     * Because a files drag starts outside of the browser, the "dragstart"
     * and "dragend" events are never sent. The remaining events are the
     * same as for a "rows" drag.
     *
     * Because there is no "dragstart" event starting a files drag, code
     * must watch for the first "dragenter" event and treat it as the start.
     *
     * Because a final "dragend" event is not sent for files drags, any
     * cleanup must be done on the "drop" event.
     *
     * If the user cancels the drag (e.g. press the ESCAPE key), the "drop"
     * event does not occur. And since the drag did not start within the
     * table, a final "dragend" event does not occur either. Instead,
     * cleanup from a canceled drag must be done on "dragleave", without
     * knowing if there will be another "dragenter" to continue the drag.
     *
     * Summary of "files" drag event handling:
     * - "dragenter" is ignored.
     * - "dragleave" cleans up as if the file drag was canceled.
     * - "dragover" starts a file drag and highlights based on the current
     *   row's attributes.
     * - "drop" drops the files.
     *
     * Dragging - highlighting.
     * ------------------------
     * Highlighting needs to show a single drop target that can be either:
     * - A row.
     * - The table itself.
     *
     * In principal, "dragenter" can be used to highlight, and "dragleave"
     * to unhighlight a row. However:
     *
     * - The order of enter/leave events depends upon theme styling.
     *
     * - These events are generated on every element and nested element on
     *   a row. The number and structure of those nested elements will vary
     *   based upon the theme, field formatters, number of table columns, etc.
     *
     * - The "dragenter" event has a known bug in most browsers that leaves
     *   the event target NOT equal to the actual element under the cursor.
     *   This prevents proper checking for whether the cursor is over a blank
     *   area or something visible. And this prevents proper decision making
     *   about whether the drop target is the row or the table.
     *
     * This means we cannot reliably use "dragenter" to highlight, and
     * "dragleave" to unhighlight. Instead, we must track the current
     * drag-over row by watching for the parent row of events. On a
     * "dragover", if the parent row changes, the highlighting changes.
     *
     *--------------------------------------------------------------------*/
 
    /**
     * Attaches behaviors to the table.
     *
     * This method attaches row behaviors to support row selection
     * using mouse and touch events, row drag-and-drop, and file drag-and-drop.
     * Some or all of these features may be disabled based upon permissions
     * on the current folder (if any), subfolders (if any), and for copy,
     * move, and file uploads. Drag-and-drop features also may be disabled
     * if copy, move, and file upload commands are not available.
     *
     * @param {object} env
     *   The environment object.
     */
    tableAttachBehaviors(env) {
      const $table = env.gather.$table;
      const $tbody = env.gather.$tbody;
      const $thead = env.gather.$thead;
      const thisScript = Drupal.foldershare.UIFolderTableMenu;
 
      //
      // Wrap all text nodes with spans.
      // -------------------------------
      // During drags, we need to know if the cursor is above a blank area
      // or non-blank content, such as text, an image, a video, etc. The
      // drag event target indicates the element under the cursor, BUT text
      // nodes are not elements. Instead, the drag event will indicate the
      // parent element of the text node, such as a <div> or <td>, which
      // does not provide sufficient granularity to know if the cursor is
      // over text or just over an area that could have text.
      //
      // We resolve this by sweeping through all table body rows and
      // replacing text nodes with a <span> that contains the same text.
      // The <span> is marked with an internal attribute. During a drag
      // over the text, the <span> becomes the target element and, since
      // a span is the same size as its content, the existance of this
      // target in an event is a clear indicator that the cursor is over text.
      $tbody.find("*").addBack().contents().filter((index, element) =>
        element.nodeType === Node.TEXT_NODE && /\S/.test(element.nodeValue)
      ).wrap("<span></span>");
 
      //
      // Monitor anchor behavior.
      // ------------------------
      // Catching table row events (see below) interferes with anchors.
      // Reimplement anchors by catching anchor events.
      $("tr a", $tbody).off("click.foldershare touch.foldershare");
      $("tr a", $tbody).on("click.foldershare touch.foldershare", function(ev) {
        // Clear any pending table refresh.
        if (env.refreshTimeout !== null) {
          clearTimeout(env.refreshTimeout);
          env.refreshTimeout = null;
        }
 
        if (typeof this.target === "undefined" ||
          this.target === "" ||
          this.target === "_self" ||
          this.target === "_parent" ||
          this.target === "_top") {
          window.location = this.href;
        } else {
          window.open( this.href, this.target, '');
        }
 
        // Stop the default behavior.
        ev.preventDefault();
        return false;
      });
 
      $("tr a", $tbody).off("keyup.foldershare");
      $("tr a", $tbody).on("keyup.foldershare", function(ev) {
        // If the item is not disabled or hidden, let the default behavior
        // occur. Otherwise block it.
        //
        // Key code 13 = ENTER.
        if (ev.keyCode !== 13) {
          // Let the default behavior occur.
          return true;
        }
 
        // Clear any pending table refresh.
        if (env.refreshTimeout !== null) {
          clearTimeout(env.refreshTimeout);
          env.refreshTimeout = null;
        }
 
        if (typeof this.target === "undefined" ||
          this.target === "" ||
          this.target === "_self" ||
          this.target === "_parent" ||
          this.target === "_top") {
          window.location = this.href;
        } else {
          window.open(this.href, this.target, '');
        }
 
        // Stop the default behavior.
        ev.preventDefault();
        return false;
      });
 
      //
      // Show context menu on row right-click.
      // -------------------------------------
      // Attach a row behavior to present the context menu. Typically this
      // event is generated by a right-click, but it also may be presented
      // by a special context menu keyboard key.
      $("tr", $tbody).off("contextmenu.foldershare");
      $("tr", $tbody).on("contextmenu.foldershare", function(ev) {
        const $thisTr = $(this);
        // Hide the main menu, if shown.
        env.gather.$menu.hide();
 
        // If the context menu is visible, hide it.
        const $contextMenu = env.gather.$contextMenu;
        if ($contextMenu.menu().is(":visible")) {
          $contextMenu.menu().hide();
 
          // Stop the default behavior.
          ev.preventDefault();
          return false;
        }
 
        // If the current row is NOT selected, select it (clearing any
        // prior selection). Otherwise get the current selection.
        if ($thisTr.hasClass("selected") === false) {
          // Not selected. Select it now.
          thisScript.tableSelectRow($thisTr, env);
        }
 
        // Update the menu's text based on the selection.
        thisScript.menuUpdate(env, $contextMenu);
 
        // Position the menu and show it.
        $contextMenu.show().position({
          my: "left top",
          at: "left bottom",
          of: ev,
          collision: "fit"
        });
 
        // Register a handler to catch an off-menu click to hide it.
        $(document).on("click.foldershare_contextmenu", (ev) => {
          $contextMenu.menu().hide();
 
          // Let the rest of the default behaviors occur.
          return true;
        });
 
        // Stop the default behavior.
        ev.preventDefault();
        return false;
      });
 
      //
      // Open new page on row double-click.
      // ----------------------------------
      // For each body row, add a double-click behavior that opens the
      // view page of the row's entity.
      $("tr", $tbody).off("dblclick.foldershare");
      $("tr", $tbody).on("dblclick.foldershare", function() {
        // Hide the menus, if shown.
        env.gather.$menu.hide();
        env.gather.$contextMenu.hide();
 
        $(`td.${env.gather.nameColumn} a`, $(this))[0].click();
        return true;
      });
 
 
      //
      // Select on row click or touch.
      // -----------------------------
      // For each body row, add behaviors that respond to mouse clicks and
      // touch screen touches.
      $("tr", $tbody).once("row-click").on("click.foldershare", function(ev) {
        // Hide the menus, if shown.
        env.gather.$menu.hide();
        env.gather.$contextMenu.hide();
 
        thisScript.tableClickSelect.call(this, ev, env);
 
        // Stop any further behavior.
        ev.preventDefault();
        return false;
      });
 
      $("tr", $tbody).once("row-touch").on("touchend.foldershare", function(ev) {
        // Hide the menus, if shown.
        env.gather.$menu.hide();
        env.gather.$contextMenu.hide();
 
        thisScript.tableTouchSelect.call(this, ev, env);
 
        // Stop any further behavior.
        ev.preventDefault();
        return false;
      });
 
      //
      // Unselect all on page background.
      // --------------------------------
      // On a click on a blank part of the page, clear the selection.
      $(document).on("click.foldershare_table", () => {
        thisScript.tableUnselectAll.call(this, env);
        // Allow default behaviors.
        return true;
      });
 
      //
      // Prepare for drag behaviors.
      // ---------------------------
      // If copy, move, and/or file upload commands are enabled for this page,
      // then prepare for drag operations.
      if (env.dndCopyEnabled === true || env.dndMoveEnabled === true) {
        // Mark all rows as draggable for copy and/or move.
        $("tr", $tbody).attr("draggable", "true");
      }
 
      if (env.dndCopyEnabled === true ||
        env.dndMoveEnabled === true ||
        env.dndUploadEnabled === true) {
        // Initialize drag-related attributes.
        $table.attr(thisScript.tableDragOperand, "none");
        $table.attr(thisScript.tableDragEffectAllowed, "none");
        $table.attr(thisScript.tableDragRowIndex, "NaN");
      }
 
      //
      // Start/end row copy/move on row drags.
      // -------------------------------------
      // If copy or move are supported, respond to drag events for row drags.
      if (env.dndCopyEnabled === true || env.dndMoveEnabled === true) {
        $("tr", $tbody).off("dragstart.foldershare");
        $("tr", $tbody).on("dragstart.foldershare", function(ev) {
          thisScript.tableRowDragStart.call(this, ev, env);
        });
 
        $("tr", $tbody).off("dragend.foldershare");
        $("tr", $tbody).on("dragend.foldershare", function(ev) {
          thisScript.tableRowDragEnd.call(this, ev, env);
        });
      }
 
      //
      // Monitor rows during row and file drags.
      // ---------------------------------------
      // If copy, move, or upload are supported, respond to drag events for
      // row and file drags. A drop may occur on a row for row and file drags.
      //
      // Also respond to file drags over the table header. A drop on the
      // header drops files into the table"s entity.
      if (env.dndCopyEnabled === true ||
        env.dndMoveEnabled === true ||
        env.dndUploadEnabled === true) {
        // Body rows.
        $("tr", $tbody).off("dragover.foldershare");
        $("tr", $tbody).on("dragover.foldershare", function(ev) {
          thisScript.tableRowDragOver.call(this, ev, env);
        });
 
        $("tr", $tbody).off("dragenter.foldershare");
        $("tr", $tbody).on("dragenter.foldershare", ev => {
          ev.preventDefault();
        });
 
        $("tr", $tbody).off("dragleave.foldershare");
        $("tr", $tbody).on("dragleave.foldershare", function(ev) {
          thisScript.tableRowOrHeaderDragLeave.call(this, ev, env);
        });
 
        $("tr", $tbody).off("drop.foldershare");
        $("tr", $tbody).on("drop.foldershare", function(ev) {
          thisScript.tableRowOrHeaderDrop.call(this, ev, env);
        });
 
        // Header.
        $("tr", $thead).off("dragover.foldershare");
        $("tr", $thead).on("dragover.foldershare", function(ev) {
          thisScript.tableHeaderDragOver.call(this, ev, env);
        });
 
        $("tr", $thead).off("dragenter.foldershare");
        $("tr", $thead).on("dragenter.foldershare", ev => {
          ev.preventDefault();
        });
 
        $("tr", $thead).off("dragleave.foldershare");
        $("tr", $thead).on("dragleave.foldershare", function(ev) {
          thisScript.tableRowOrHeaderDragLeave.call(this, ev, env);
        });
 
        $("tr", $thead).off("drop.foldershare");
        $("tr", $thead).on("drop.foldershare", function(ev) {
          thisScript.tableRowOrHeaderDrop.call(this, ev, env);
        });
      }
 
      //
      // Monitor rows during background activity.
      // ----------------------------------------
      // Activity on the server and by other users can cause changes to
      // the folder or root list being viewed. Set up a refresh.
      if (env.settings.foldershare.page.kind === "rootlist" ||
        env.settings.foldershare.page.kind === "folder") {
        thisScript.attachRefresh(env);
      }
    },
 
    /**
     * Sets up a view table refresh.
     *
     * @param {object} env
     *   The environment object.
     */
    attachRefresh(env) {
      // View refreshes can use the Views module's "RefreshView" trigger,
      // but we need three special features:
      //
      // - The default progress throbber needs to be disabled so that the
      //   user isn't constantly distracted by refreshes.
      //
      // - The refresh needs to abort if the user is interacting, such as
      //   if a menu is visible or a drag is in progress. This avoids
      //   changing content just as a user is about to take an action.
      //
      // - The refresh needs to map the current selection to the new content.
      //
      // These special features are only relevant for periodic refreshes
      // and not for pager refreshes or those from exposed field forms.
      // So we need a customized additional refresh trigger.
      //
      // To create the custom refresh trigger we need to:
      //
      // - Find the view instance based upon the unique DOM ID.
      //
      // - Find the view's Ajax object that handles the standard "RefreshView"
      //   trigger.
      //
      // - Duplicate that Ajax object, but with our special features.
      //
      // - Keep track of that new Ajax object and a new custom trigger by
      //   adding it back to the view instance.
      //
      // Find view's Ajax object.
      // ------------------------
      // When a view is built and its behaviors initialized, it builds an
      // Ajax object to handle the "RefreshView" event. We need this object
      // as a starting point for creating a customized refresh.
      var viewSelector = env.gather.viewSelector;
      var ajaxView = null;
      const keys = Object.keys(Drupal.views.instances);
 
      for (let i = 0; i < keys.length; ++i) {
        const key = keys[i];
        if (Drupal.views.instances[key].refreshViewAjax.selector === viewSelector) {
          ajaxView = Drupal.views.instances[key];
          break;
        }
      }
 
      if (ajaxView === null) {
        // The view hasn't had its Ajax behaviors initialized yet.
        // Do nothing.
        return;
      }
 
      //
      // Create Ajax object configuration.
      // ---------------------------------
      // We'll be creating a new Ajax object by copying the settings of the
      // default "RefreshView" Ajax object. Those settings need to be modified:
      // - Use a new custom event name.
      // - Disable the progress spinner.
      // - Add current page information (if any).
      // - Add current sort information (if any).
      //
      // It would be nice if the Views module kept the current page and sort
      // information in an object somewhere, but it does not appear to.
      //
      // If there is a pager afte the table, then the active page link has an
      // href URL that contains arguments for the current page and sort.
      // Parse that.
      //
      // If there is no pager, then the page is "0" and the current sort has
      // to be pulled from the active table column.
      //
      // If that doesn't exist either, then we fall back to a simple refresh
      // of the current page.
      const thisScript = Drupal.foldershare.UIFolderTableMenu;
 
      let refreshSettings = $.extend({}, ajaxView.element_settings);
 
      const basePath = ajaxView.element_settings.view_base_path;
      const viewData = $.extend({}, ajaxView.settings);
 
      // Look for an active pager link.
      const $activePagerLinks = env.gather.$view.find(
        "ul.js-pager__items > li.is-active > a");
 
      if ($activePagerLinks.length > 0) {
        // Active pager link found.
        //
        // Parse the link's URL to get query and view arguments.
        const link = $activePagerLinks.get(0);
        const href = $(link).attr("href");
        const queryArgs = Drupal.Views.parseQueryString(href);
        const viewArgs = Drupal.Views.parseViewArgs(href, basePath);
        $.extend(viewData, queryArgs, viewArgs);
      } else {
        // No active pager link found. There is no pager.
        //
        // Use the current URL for view arguments.
        const viewArgs = Drupal.Views.parseViewArgs(
          window.location.href, basePath);
        $.extend(viewData, viewArgs, {
          page: "0"
        });
 
        // Look for the active table header column.
        const $activeTableHeader = env.gather.$table.find("th.is-active a");
 
        if ($activeTableHeader.length > 0) {
          // Active table header column found.
          //
          // Parse its link. HOWEVER, while the link will have the
          // correct "order" (column) for the sort, it will have the
          // OPPOSITE sort order. The idea is that if the current
          // table is sorted in ascending order, then the header link
          // is to flip the order so its href has a descending order.
          // We need to unflip this sort order to get the order of
          // the current page.
          const link = $activeTableHeader.get(0);
          const href = $(link).attr("href");
          const queryArgs = Drupal.Views.parseQueryString(href);
          if (typeof queryArgs.sort !== "undefined") {
            if (queryArgs.sort === "asc") {
              queryArgs.sort = "desc";
            } else {
              queryArgs.sort = "asc";
            }
          }
          $.extend(viewData, queryArgs);
        }
      }
 
      //
      // Build the Ajax object.
      // ----------------------
      // Use the above settings to create an Ajax object to refresh the
      // view with the proper page and column sort.
      $.extend(refreshSettings, {
        event:    "FolderShareRefreshView",
        submit:   viewData,
        base:     viewSelector,
        element:  $(viewSelector).get(0),
        progress: false,
        effect:   "none"
      });
 
      let customRefresh = Drupal.ajax(refreshSettings);
 
      //
      // Swap out success function.
      // --------------------------
      // The Ajax object's success function receives a response from the
      // Views module and uses it to replace the view table et al with the
      // new HTML. We need to swap out this function so we can decide whether
      // to abort the refresh if there is an interaction in progress.
      //
      // Get the module's configured polling interval, in seconds,
      // converted to ms, then schedule.
      const pollIntervalMicrosec =
        env.settings.foldershare.module.pollinterval * 1000.0;
 
      // Swap out the Ajax success method with one that checks if the
      // user is interacting and blocks the update if they are.
      const originalSuccess = customRefresh.success;
      customRefresh.success = function(response, status, xmlhttprequest) {
        // Postpone the refresh if there is an interaction in progress.
        let postpone = false;
 
        if (env.gather.$menu.menu().is(":visible")) {
          // Main menu is showing.
          postpone = true;
        } else if (env.gather.$contextMenu.menu().is(":visible")) {
          // Context menu is showing.
          postpone = true;
        } else if (env.gather.$table.attr(thisScript.tableDragOperand) !== "none") {
          // Drag is in progress.
          postpone = true;
        } else if ($(".foldershare-ui-dialog", document).length !== 0) {
          // Dialog is showing.
          postpone = true;
        }
 
        if (postpone === true) {
          // Postpone the refresh. Schedule a new timeout to try again.
          env.refreshTimeout = setTimeout(function() {
            env.gather.$view.trigger("FolderShareRefreshView");
          }, pollIntervalMicrosec);
          return;
        }
 
        // Get and save the IDs of the current selection, if any.
        const ids = Drupal.foldershare.UIFolderTableMenu.tableGetSelectionIds(env);
        env.savedSelectionIds = ids;
 
        // Continue with the original success function to replace the
        // table with the latest content.
        const result = originalSuccess.call(this, response, status, xmlhttprequest);
 
        return result;
      };
 
      //
      // Save the Ajax object.
      // ---------------------
      // Save the custom Ajax refresh object with its custom trigger back into
      // the view's instance. This makes it easy to trigger the refresh with
      // view.trigger('FolderShareRefreshView').
      ajaxView.FolderShareRefreshAjax = customRefresh;
 
      // Add a refresh trigger on the form too, used when some command dialogs
      // are taken down.
      env.gather.$commandForm.on("FolderShareRefreshView", function() {
        env.gather.$view.trigger("FolderShareRefreshView");
      });
 
      //
      // Schedule the refresh.
      // ---------------------
      // Clear any prior timeout, then schedule the new refresh.
      if (env.refreshTimeout !== null) {
        clearTimeout(env.refreshTimeout);
        env.refreshTimeout = null;
      }
 
      // Set a timeout before invoking our new custom table refresh.
      env.refreshTimeout = setTimeout(function() {
        env.gather.$view.trigger("FolderShareRefreshView");
      }, pollIntervalMicrosec);
    },
 
    /*--------------------------------------------------------------------
     *
     * Table behaviors - select.
     *
     *--------------------------------------------------------------------*/
 
    /**
     * Handles a touch selection event on a table row.
     *
     * Touch selection toggles the selected item on/off. It ignores keyboard
     * modifiers and therefore does not support range selection.
     *
     * @param {object} ev
     *   The row event to handle.
     * @param {object} env
     *   The environment object.
     */
    tableTouchSelect(ev, env) {
      const $tr = $(this);
      const $table = env.gather.$table;
      const $tbody = env.gather.$tbody;
 
      // If the touched row does not have a linked name column,
      // then ignore. This can happen in two ways:
      // - The table is empty and the only thing in it is a generic empty
      //   message with no name column.
      // - The table row has a name column but no link because the row is
      //   disabled or the field formatter is misconfigured. Since the link
      //   contains the data attributes we need in order to track and use
      //   the selection, if there is no link the row is unusable and
      //   therefore unselectable.
      const $tdLinkName = $(`td.${env.gather.nameColumn} a`, $tr);
      if ($tdLinkName.length === 0) {
        // Fail. No linked name column. Ignore the row.
        return;
      }
 
      // For out of range row indexes (<1), clear the selection.
      // Otherwise toggle the row selection.
      if (this.rowIndex <= 0) {
        // Header/footer click. Clear the selection.
        $("tr", $tbody).each((index, value) => {
          $(value).toggleClass("selected", false);
        });
        $table.attr("selectionFirstRowIndex", "");
        $table.attr("selectionLastRowIndex", "");
      } else {
        const newState = !$tr.hasClass("selected");
        $tr.toggleClass("selected", newState);
        if (newState === false) {
          $table.attr("selectionFirstRowIndex", "");
          $table.attr("selectionLastRowIndex", "");
        } else {
          $table.attr("selectionFirstRowIndex", this.rowIndex);
          $table.attr("selectionLastRowIndex", this.rowIndex);
        }
      }
 
      // Some browsers will also send mouse events after a touch event.
      // Such a "ghost click" is not useful here, so disable it.
      ev.preventDefault();
    },
 
    /**
     * Handles a mouse click selection event on a table row.
     *
     * Mouse selection supports range selection using keyboard modifiers
     * like shift-click, control-click (on Windows or Linux), or
     * command-click (on a Mac):
     *
     * - For all platforms, if there are no keyboard modifiers, then a mouse
     *   click clears any previous selection and starts a new one.
     *
     * - For all platforms, if the shift key is down during a click, a selection
     *   is extended from the most recent selection to the clicked on row.
     *
     * - For Windows and Linux platforms, if the control key is down during a
     *   click, a selected row is toggled.
     *
     * - For Mac platforms, if the command (meta) key is down during a
     *   click, a selected row is toggled.
     *
     * @param {object} ev
     *   The row event to handle.
     * @param {object} env
     *   The environment object.
     */
    tableClickSelect(ev, env) {
      const $tr = $(this);
      const $table = env.gather.$table;
      const $tbody = env.gather.$tbody;
      let first = $table.attr("selectionFirstRowIndex");
      let last = $table.attr("selectionLastRowIndex");
 
      // If the clicked-on row does not have a linked name column,
      // then ignore. This can happen in two ways:
      // - The table is empty and the only thing in it is a generic empty
      //   message with no name column.
      // - The table row has a name column but no link because the row is
      //   disabled or the field formatter is misconfigured. Since the link
      //   contains the data attributes we need in order to track and use
      //   the selection, if there is no link the row is unusable and
      //   therefore unselectable.
      const $tdLinkName = $(`td.${env.gather.nameColumn} a`, $tr);
      if ($tdLinkName.length === 0) {
        // Fail. No linked name column. Ignore the row.
        return;
      }
 
      const isMac = navigator.appVersion.indexOf("Mac") !== -1;
 
      // Check for keyboard modifiers and mimic Windows/Linux/Mac behavior.
      // If more than one modifier is held down, the control/command
      // modifier has a higher priority than the shift modifier.
      if ((isMac === true && ev.metaKey === true) ||
        (isMac === false && ev.ctrlKey === true)) {
        // Control/Command-click
        // ---------------------
        // On a Mac, a command-click toggles the selection state of the
        // clicked-on row.
        //
        // On all other platforms (e.g. Windows and Linux), a control-click
        // toggles the selection state of the clicked-on row.
        const newState = !$tr.hasClass("selected");
        $tr.toggleClass("selected", newState);
 
        if (newState === true) {
          // A clicked-on row always resets the range to that row.
          $table.attr("selectionFirstRowIndex", this.rowIndex);
          $table.attr("selectionLastRowIndex", this.rowIndex);
        } else {
          first = Number(first);
          last = Number(last);
 
          if (this.rowIndex === first && this.rowIndex === last) {
            // The unselected row was the only row in the selection.
            // Empty the range.
            $table.attr("selectionFirstRowIndex", "");
            $table.attr("selectionLastRowIndex", "");
          } else if (this.rowIndex === first) {
            // The unselected row was the start of the range. Shorten the
            // range to start on the next row.
            $table.attr("selectionFirstRowIndex", first + 1);
          } else if (this.rowIndex === last) {
            // The unselected row was the end of the range. Shorten the
            // range to end on the previous row.
            $table.attr("selectionLastRowIndex", last - 1);
          } else {
            // The unselected row was within the range. Shorten the range
            // to include the lower half of the range.
            $table.attr("selectionFirstRowIndex", this.rowIndex + 1);
          }
        }
      } else if (ev.shiftKey === true) {
        // Shift-click
        // -----------
        // For all platforms, a shift-click adjusts a current selection.
        //
        // The following combinations could occur:
        //
        // - No current selection. Select the clicked-on row and save the
        //   range as (first = last = clicked-on row).
        //
        // - Current selection and clicked-on row is above it. Flip the
        //   range by clearing the entire selection first, then selecting
        //   rows from the clicked-on row to through the first item of the
        //   old selection. Save the range as (first = clicked-on row) and
        //   (last = old first).
        //
        // - Current selection and clicked-on row is below it. Extend the
        //   selection by selecting rows from (last+1) through the
        //   clicked-on row. Save the range as (first = old first) and
        //   (last = clicked-on row).
        //
        // - Current selection and clicked-on row within the range.
        //   Shorten the range by clearing everything from the row after
        //   the clicked-on row through the last row of the range. Save
        //   the range as (first = old first) and (last = clicked-on row).
        //
        // Note that row indexes are 1-based, but loop/array/element
        // indexes are 0-based.
        if (typeof last === "undefined" || last === "") {
          // No prior selection. Select from 1st row thru this row.
          $("tr", $tbody).slice(0, this.rowIndex).each((index, value) => {
            // Only select rows that have a linked name and are not disabled.
            const $n = $(`td.${env.gather.nameColumn} a`, $(value));
            if ($n.length !== 0) {
              $(value).toggleClass("selected", true);
            }
          });
 
          $table.attr("selectionFirstRowIndex", 1);
          $table.attr("selectionLastRowIndex", this.rowIndex);
        } else {
          first = Number(first);
          last = Number(last);
 
          if (this.rowIndex > last) {
            // Extend selection downwards thru the clicked-on row.
            $("tr", $tbody).slice(last, this.rowIndex).each((index, value) => {
              // Only select rows that have a linked name and are not disabled.
              const $n = $(`td.${env.gather.nameColumn} a`, $(value));
              if ($n.length !== 0) {
                $(value).toggleClass("selected", true);
              }
            });
 
            $table.attr("selectionFirstRowIndex", first);
            $table.attr("selectionLastRowIndex", this.rowIndex);
          } else if (this.rowIndex < first) {
            // Flip selection upwards thru the clicked-on row. Clear the
            // current selection, except the first row, then add the new rows.
            $("tr", $tbody).slice(first, last + 1).each((index, value) => {
              $(value).toggleClass("selected", false);
            });
            $("tr", $tbody).slice(this.rowIndex - 1, first).each((index, value) => {
              // Only select rows that have a linked name and are not disabled.
              const $n = $(`td.${env.gather.nameColumn} a`, $(value));
              if ($n.length !== 0) {
                $(value).toggleClass("selected", true);
              }
            });
 
            $table.attr("selectionFirstRowIndex", this.rowIndex);
            $table.attr("selectionLastRowIndex", first);
          } else {
            // Shorten selection to end on the clicked-on row. Clear all rows
            // after the clicked-on row.
            $("tr", $tbody).slice(this.rowIndex, last).each((index, value) => {
              $(value).toggleClass("selected", false);
            });
 
            $table.attr("selectionFirstRowIndex", first);
            $table.attr("selectionLastRowIndex", this.rowIndex);
          }
        }
      } else {
        // Click
        // -----
        // When there are no keyboard modifiers, clicking on a row clears
        // the previous selection (if any) and selects the row.
        $("tr", $tbody).each((index, value) => {
          $(value).toggleClass("selected", false);
        });
 
        // For out of range row (<1), clear selection.
        // Otherwise select the clicked-on row and save its index.
        if (this.rowIndex <= 0) {
          $table.attr("selectionFirstRowIndex", "");
          $table.attr("selectionLastRowIndex", "");
        } else {
          $tr.toggleClass("selected", true);
          $table.attr("selectionFirstRowIndex", this.rowIndex);
          $table.attr("selectionLastRowIndex", this.rowIndex);
        }
      }
 
      // A click can sometimes cause a text selection if the mouse
      // moved a little between mouse down and up. Such a text
      // selection is meaningless here, so disable it.
      window.getSelection().removeAllRanges();
    },
 
    /*--------------------------------------------------------------------
     *
     * Table behaviors - drag.
     *
     *--------------------------------------------------------------------*/
 
    /**
     * Returns the current drop target.
     *
     * The drop target is one of:
     * - 'row' = the current row.
     * - 'table' = the current table, whether it represents a folder or
     *   a root list.
     * - 'none' = there is no drop target.
     *
     * This function is always called with a current row. But that row is
     * only a valid drop target if:
     * - The cursor is above a visible feature of the row (text, image, etc.).
     * - The row has a name column with necessary entity info.
     * - The entity is not disabled.
     * - The entity is a folder.
     *
     * If none of the above are TRUE, then the table itself is the drop
     * target if:
     * - The drag operation is for files, not rows.
     *
     * @param {object} $thisTr
     *   The table row under the cursor.
     * @param {object} ev
     *   The row event to handle.
     * @param {object} env
     *   The environment object.
     * @return {string}
     *   Returns the drag target as either 'row', 'table', or 'none'.
     */
    getTableDropTarget($thisTr, ev, env) {
      // To detect when the cursor is over text, earlier processing has
      // wrapped all text nodes with a <SPAN>. Unlike a text node, a <SPAN>
      // is an element and can be the target of an event.
      //
      // Our ASSUMPTION is that any element type that is not a block (e.g.
      // not <DIV> or <P>) will fairly tightly surround text or other visual
      // elements. If the cursor is over one of those, then that is enough
      // to say the drop target is a row.
      let dropTarget = "table";
      const targetStyle = window.getComputedStyle(ev.target, "");
      let $a = null;
 
      switch (targetStyle.display) {
        case "inline":
        case "inline-block":
        case "inline-flex":
        case "inline-table":
        case "marker":
          // If the row is valid, not disabled, and for a folder, then it
          // is a valid row drop target. Otherwise, revert to table.
          $a = $(`td.${env.gather.nameColumn} a`, $thisTr);
          if (typeof $a !== "undefined") {
            const rowKind = $a.attr("data-foldershare-kind");
            if (rowKind === "folder") {
              dropTarget = "row";
            }
          }
          break;
 
        default:
          break;
      }
 
      return dropTarget;
    },
 
    /**
     * Handles the start of rows drags.
     *
     * This function is called on a 'dragstart' event, which only occurs
     * for row drags, not file drags.
     *
     * An entity row drag copies or moves one or more table rows, depicting
     * entities, and drops them into a subfolder. The drag list created
     * by the drag is a list of entity IDs for the dragged rows:
     *
     * - If the drag starts on a selected item, the entire selection is
     *   added to the drag list in a pending data transfer.
     *
     * - If the drag starts on an unselected item, that single row is
     *   added to the drag list in a pending data transfer.
     *
     * In both cases, a ghost image is created that shows the names
     * of the items being dragged. The data transfer state is initialized
     * and table attributes set to record that a row drag is in progress.
     *
     * @param {object} ev
     *   The row event to handle.
     * @param {object} env
     *   The environment object.
     *
     * @return {boolean}
     *   Returns true.
     */
    tableRowDragStart(ev, env) {
      const $thisTr = $(this);
      const $thisTable = env.gather.$table;
      const thisScript = Drupal.foldershare.UIFolderTableMenu;
 
      //
      // Validate.
      // ---------
      // If the clicked-on row does not have a linked name column,
      // then ignore. This can happen in two ways:
      //
      // - The table is empty and the only thing in it is a generic empty
      //   message with no name column.
      //
      // - The table row has a name column but no link because the row is
      //   disabled or the field formatter is misconfigured. Since the link
      //   contains the data attributes we need in order to track and use
      //   the selection, if there is no link the row is unusable and
      //   therefore unselectable.
      const $tdLinkName = $(`td.${env.gather.nameColumn} a`, $thisTr);
      if ($tdLinkName.length === 0) {
        // Fail. No linked name column. Ignore the row.
        ev.preventDefault();
        ev.stopPropagation();
        return true;
      }
 
      //
      // Mark the table.
      // ---------------
      // Mark the table as having a row drag in progress. This mark is
      // removed when the drag is done and it indicates that row dragging,
      // rather than off-browser file dragging is in progress.
      $thisTable.attr(thisScript.tableDragOperand, "rows");
      $thisTable.attr(thisScript.tableDragRowIndex, this.rowIndex);
 
      //
      // Create drag ghost image.
      // ------------------------
      // The drag image during the drag is from a ghost table that contains
      // a clone of the name column (or entire row) for dragged items.
      //
      // While looping over the items to add to the ghost, collect their
      // entity ID's to use as the data transfer data.
      //
      // Warning: older browsers may not support setting the drag image.
      // If they don't support it, skip creating the ghost image.
      let $dragTable = null;
      let $dragTbody = null;
      let rowHeight = 0;
 
      const dragImageSupported =
        typeof ev.originalEvent.dataTransfer.setDragImage === "function";
      if (dragImageSupported === true) {
        $dragTable = $('<table class="dragImage">');
        $dragTbody = $("<tbody>");
        $dragTable.append($dragTbody);
      }
 
      const nameColumn = env.gather.nameColumn;
 
      // Collect selected items.
      //
      // Add clones of the current row or all selected rows to the ghost table.
      //
      // Get the height of a dragged table row to use to position the ghost
      // table under the cursor.
      const draggedList = [];
 
      if ($thisTr.hasClass("selected") === true) {
        // The user has started a drag atop a selected row.
        //
        // Add all selected rows in the table into a list of dragged rows
        // and the ghost table.
        $("tr.selected", $thisTable).each((index, value) => {
          const $td = $(`td.${nameColumn}`, $(value));
 
          // Save the row's entity ID.
          const $a = $("a", $td);
          if ($a.length === 0) {
            // Fail. No anchor? Ignore row.
            return false;
          }
 
          draggedList.push($a.attr("data-foldershare-id"));
 
          if (dragImageSupported === true) {
            // Clone the column and add it to the ghost table.
            // Remove row and column classes so we don't get any residual
            // styling of the ghost.
            if (value.offsetHeight > rowHeight) {
              rowHeight = value.offsetHeight;
            }
 
            const $newTd = $td.clone(false).removeClass();
            $dragTbody.append($("<tr>").append($newTd));
          }
        });
      } else {
        // The user has started a drag atop an unselected row.
        //
        // Add the single row to the list of dragged rows and the ghost table.
        const $td = $(`td.${nameColumn}`, $thisTr);
 
        // Save the row"s entity ID.
        const $a = $("a", $td);
        if ($a.length === 0) {
          // Fail. No anchor? Ignore drag start.
          return true;
        }
 
        draggedList.push($a.attr("data-foldershare-id"));
 
        if (dragImageSupported === true) {
          // Clone the column or row and add it to the ghost table.
          // Remove row and column classes so we don't get any residual
          // styling of the ghost.
          rowHeight = $thisTr[0].offsetHeight;
          const $oldTd = $(`td.${nameColumn}`, $thisTr);
          const $newTd = $oldTd.clone(false).removeClass();
          $dragTbody.append($("<tr>").append($newTd));
        }
      }
 
      //
      // Set up the data transfer.
      // -------------------------
      // - Set the transferred data to be a list of entity IDs.
      // - Set the drag image to be the ghost table.
      // - Set the allowed 'effects' (e.g. copy or move).
      // - Set the initial 'effect' (e.g. copy or move).
      ev.originalEvent.dataTransfer.setData(
        "foldershare/local-entity-list",
        JSON.stringify(draggedList));
 
      let allowed = "none";
      let effect = "non";
      if (env.dndCopyEnabled === true && env.dndMoveEnabled === true) {
        allowed = "copyMove";
        effect = "move";
      } else if (env.dndCopyEnabled === true) {
        allowed = "copy";
        effect = "copy";
      } else {
        allowed = "move";
        effect = "move";
      }
 
      ev.originalEvent.dataTransfer.effectAllowed = allowed;
      ev.originalEvent.dataTransfer.dropEffect = effect;
      $thisTable.attr(thisScript.tableDragEffectAllowed, allowed);
 
      if (dragImageSupported === true) {
        // The ghost table must be on the page in order to be rendered
        // and used as the ghost table. So add it temporarily.
        $("body").append($dragTable);
 
        ev.originalEvent.dataTransfer.setDragImage(
          $dragTable[0],
          0,
          rowHeight / 2);
 
        // The ghost table must exist in the body long enough for it to be
        // rendered for use as the drag image. But after that it is clutter
        // that will show up at the end of the page. To remove it as soon
        // as possible, we set a timeout function.
        setTimeout(() => {
          $dragTable.remove();
        });
      }
 
      return true;
    },
 
    /**
     * Handles the end of rows drags.
     *
     * This function is called on a 'dragend' event, which only occurs
     * for row drags, not file drags.
     *
     * An entity row drag copies or moves one or more table rows, depicting
     * entities, and drops them into a subfolder. The start of the drag has
     * already initialized the event's data transfer object to contain a list
     * of dragged entity IDs.
     *
     * A drag can end in one of two ways:
     * - The user dropped the drag.
     * - The user canceled the drag (such as by the ESC key).
     *
     * If the user dropped the drag, the "drop" event behavior has already
     * handled collecting the entity ID list from the data transfer and
     * sending a copy or move command to the server.
     *
     * This method cleans up after either a drop or a cancel by resetting
     * table attributes and unhighlighting whatever row was most recently
     * under the cursor during the drag (if any).
     *
     * @param {object} ev
     *   The row event to handle.
     * @param {object} env
     *   The environment object.
     *
     * @return {boolean}
     *   Returns false.
     */
    tableRowDragEnd(ev, env) {
      const $thisTable = env.gather.$table;
      const thisScript = Drupal.foldershare.UIFolderTableMenu;
 
      // Get the old drop target and row index.
      const oldRowIndex = Number($thisTable.attr(thisScript.tableDragRowIndex));
      const oldDropTarget = $thisTable.attr(thisScript.tableDropTarget);
 
      // Unhighlight, if any.
      switch (oldDropTarget) {
        case "table":
          $thisTable.removeClass("foldershare-draghover");
          break;
 
        case "row":
          if (Number.isNaN(oldRowIndex) === false) {
            // The event"s row index is 1-based, while jQuery is 0-based.
            $("tbody tr", $thisTable).eq(oldRowIndex - 1)
              .removeClass("foldershare-draghover");
          }
          break;
 
        default:
        case "none":
          break;
      }
 
      // Clear the table attributes.
      $thisTable.attr(thisScript.tableDragRowIndex, "NaN");
      $thisTable.attr(thisScript.tableDropTarget, "none");
      $thisTable.attr(thisScript.tableDragOperand, "none");
      $thisTable.attr(thisScript.tableDragEffectAllowed, "none");
      return false;
    },
 
    /**
     * Handles continuation of rows/files drags atop table body rows.
     *
     * This function is called on a "dragover" event for both row and file
     * drags passing over table body rows.
     *
     * It is important to keep this behavior as fast as possible because
     * browsers generate a large number of these events, whether the user's
     * cursor is moving or not.
     *
     * For entity row drags, processing determines if the current row or the
     * table should be highlighted.
     *
     * For file drags, processing is done on a "dragleave". But on the
     * first "dragover" event during a file drag, intial setup is done.
     *
     * @param {object} ev
     *   The row event to handle.
     * @param {object} env
     *   The environment object.
     *
     * @return {boolean}
     *   Returns false and prevents further event processing.
     *
     * @see tableHeaderDragOver()
     */
    tableRowDragOver(ev, env) {
      const $thisTr = $(this);
      const $thisTable = env.gather.$table;
      const thisScript = Drupal.foldershare.UIFolderTableMenu;
 
      // Get the old and new drop target and row index.
      const oldRowIndex = Number($thisTable.attr(thisScript.tableDragRowIndex));
      const oldDropTarget = $thisTable.attr(thisScript.tableDropTarget);
 
      const newRowIndex = this.rowIndex;
      const newDropTarget = thisScript.getTableDropTarget($thisTr, ev, env);
 
      let allowed = "none";
      let effect = "none";
 
      switch ($thisTable.attr(thisScript.tableDragOperand)) {
        default:
        case "rows":
          // Draging rows.
          //
          // "dragover" events occur in huge numbers. We only care about the
          // event if it changes the highlighting. And it only does that if:
          // - The row index changes, OR
          // - The drop target changes.
          if (oldRowIndex === newRowIndex && oldDropTarget === newDropTarget) {
            break;
          }
 
          // The row or the drop target have changed. Unhighlight whatever
          // was highlighted before (if anything).
          switch (oldDropTarget) {
            case "table":
              $thisTable.removeClass("foldershare-draghover");
              break;
 
            case "row":
              if (Number.isNaN(oldRowIndex) === false) {
                // The event's row index is 1-based, while jQuery is 0-based.
                $("tbody tr", $thisTable).eq(oldRowIndex - 1)
                  .removeClass("foldershare-draghover");
              }
              break;
 
            default:
            case "none":
              break;
          }
 
          // Highlight the new drop target (if anything). Determine the
          // drag allowed and effect settings.
          switch (newDropTarget) {
            case "row":
              // The event's row index is 1-based, while jQuery is 0-based.
              $("tbody tr", $thisTable).eq(newRowIndex - 1)
                .addClass("foldershare-draghover");
              if (env.dndCopyEnabled === true && env.dndMoveEnabled === true) {
                allowed = "copyMove";
                effect = "move";
              } else if (env.dndCopyEnabled === true) {
                allowed = "copy";
                effect = "copy";
              } else if (env.dndMoveEnabled === true) {
                allowed = "move";
                effect = "move";
              }
              break;
 
            default:
            case "table":
            case "none":
              break;
          }
 
          // Save the latest drop target and row index.
          $thisTable.attr(thisScript.tableDropTarget, newDropTarget);
          $thisTable.attr(thisScript.tableDragRowIndex, newRowIndex);
 
          // Save the latest drag effect.
          $thisTable.attr(thisScript.tableDragEffectAllowed, allowed);
          ev.originalEvent.dataTransfer.effectAllowed = allowed;
          ev.originalEvent.dataTransfer.dropEffect = effect;
          break;
 
        case "files":
          break;
 
        case "none":
          // Since the drag operand is still 'none', this must be the first
          // drag event for a file drag from off-browser.
          //
          // The event's dataTransfer property exists and can be configured
          // at this point, BUT the list of files being dragged is not yet
          // known. We therefore cannot confirm that the drag is valid yet.
          //
          // If the browser is old and does not support dragged files for
          // uploads, then do nothing.
          if (thisScript.checkBrowserFileDragSupport(ev, env) === false) {
            break;
          }
 
          // Highlight, if needed.
          switch (newDropTarget) {
            case "table":
              $thisTable.addClass("foldershare-draghover");
              break;
 
            case "row":
              $("tbody tr", $thisTable).eq(newRowIndex - 1)
                .addClass("foldershare-draghover");
              break;
 
            default:
            case "none":
              break;
          }
 
          // Mark the table as having a file drag in progress.
          $thisTable.attr(thisScript.tableDragOperand, "files");
 
          // File drags are always 'copy' operations.
          $thisTable.attr(thisScript.tableDragEffectAllowed, "copy");
          ev.originalEvent.dataTransfer.dropEffect = "copy";
          ev.originalEvent.dataTransfer.effectAllowed = "copy";
 
          // Save the latest drop target and row index.
          $thisTable.attr(thisScript.tableDropTarget, newDropTarget);
          $thisTable.attr(thisScript.tableDragRowIndex, newRowIndex);
 
          break;
      }
 
      ev.preventDefault();
      ev.stopPropagation();
      return false;
    },
 
    /**
     * Handles continuation of rows/files drags atop the table header.
     *
     * This function is called on a "dragover" event for both row and file
     * drags that are over the table header.
     *
     * It is important to keep this behavior as fast as possible because
     * browsers generate a large number of these events, whether the user's
     * cursor is moving or not.
     *
     * Dragging atop the table header is a simplified case of dragging atop
     * table body rows:
     *
     * - For entity row drags, the previous highlighted row (if any) is
     *   unhighlighted and the table is left unhighlighted. A row drag drop
     *   on the table header does nothing, so the pending drop target is
     *   set to none.
     *
     * - For file upload drags, if this is the first time a file upload drag
     *   has been encountered, the table is set as the drop target and
     *   highlighted.
     *
     * @param {object} ev
     *   The row event to handle.
     * @param {object} env
     *   The environment object.
     *
     * @return {boolean}
     *   Returns false and prevents further event processing.
     *
     * @see tableRowDragOver()
     */
    tableHeaderDragOver(ev, env) {
      const $thisTable = env.gather.$table;
      const thisScript = Drupal.foldershare.UIFolderTableMenu;
 
      // Get the old drop target and row index.
      const oldRowIndex = Number($thisTable.attr(thisScript.tableDragRowIndex));
      const oldDropTarget = $thisTable.attr(thisScript.tableDropTarget);
 
      switch ($thisTable.attr(thisScript.tableDragOperand)) {
        default:
        case "rows":
          // Draging rows.
          //
          // "dragover" events occur in huge numbers. We only care about the
          // event if it changes the highlighting. And it only does that if:
          // - The row index changes, OR
          // - The drop target changes.
          if (Number.isNaN(oldRowIndex) === true && oldDropTarget === "none") {
            break;
          }
 
          // The row or the drop target have changed. Unhighlight whatever
          // was highlighted before (if anything).
          switch (oldDropTarget) {
            case "table":
              $thisTable.removeClass("foldershare-draghover");
              break;
 
            case "row":
              if (Number.isNaN(oldRowIndex) === false) {
                // The event's row index is 1-based, while jQuery is 0-based.
                $("tbody tr", $thisTable).eq(oldRowIndex - 1)
                  .removeClass("foldershare-draghover");
              }
              break;
 
            default:
            case "none":
              break;
          }
 
          // Dragging atop the header always means the drop target is 'none',
          // since a drop on the header drops into the current table entity.
          // And yet a row drag is dragging items that are already in the
          // entity, so dropping there makes no changes.
          //
          // Save the latest drop target and row index.
          $thisTable.attr(thisScript.tableDropTarget, "none");
          $thisTable.attr(thisScript.tableDragRowIndex, "NaN");
 
          // Save the latest drag effect.
          $thisTable.attr(thisScript.tableDragEffectAllowed, "none");
          ev.originalEvent.dataTransfer.effectAllowed = "none";
          ev.originalEvent.dataTransfer.dropEffect = "none";
          break;
 
        case "files":
          break;
 
        case "none":
          // Since the drag operand is still 'none', this must be the first
          // drag event for a file drag from off-browser.
          //
          // The event's dataTransfer property exists and can be configured
          // at this point, BUT the list of files being dragged is not yet
          // known. We therefore cannot confirm that the drag is valid yet.
          //
          // If the browser is old and does not support dragged files for
          // uploads, then do nothing.
          if (thisScript.checkBrowserFileDragSupport(ev, env) === false) {
            break;
          }
 
          // The new drop target is always the table. Highlight the table.
          $thisTable.addClass("foldershare-draghover");
 
          // Mark the table as having a file drag in progress.
          $thisTable.attr(thisScript.tableDragOperand, "files");
 
          // File drags are always "copy' operations.
          $thisTable.attr(thisScript.tableDragEffectAllowed, "copy");
          ev.originalEvent.dataTransfer.dropEffect = "copy";
          ev.originalEvent.dataTransfer.effectAllowed = "copy";
 
          // Save the latest drop target and row index.
          $thisTable.attr(thisScript.tableDropTarget, "table");
          $thisTable.attr(thisScript.tableDragRowIndex, "NaN");
 
          break;
      }
 
      ev.preventDefault();
      ev.stopPropagation();
      return false;
    },
 
    /**
     * Handles region leave on rows/files drags atop table body rows or header.
     *
     * This function is called on a "dragleave" event for both row and file
     * drags atop table body rows or the table's header.
     *
     * For entity row drags, processing is done on a "dragover". This
     * method does nothing.
     *
     * For file drags, processing is done here. With a file drag, there
     * is no unique ending event if the drag is canceled. All we get is a
     * final "dragleave" and we cannot determine if the event is from a
     * canceled drag or just a "dragleave" as the user's cursor is moving
     * across an element boundary during a drag. This method is forced to
     * assume the drag has been canceled and clean up for it since there will
     * be no other opportunity to do so. If this is not in fact the end of
     * the file drag, then there will be another "dragover" event during
     * which we'll restart the file drag. Ugly, but necessary.
     *
     * @param {object} ev
     *   The row event to handle.
     * @param {object} env
     *   The environment object.
     *
     * @return {boolean}
     *   Returns false.
     */
    tableRowOrHeaderDragLeave(ev, env) {
      const $thisTable = env.gather.$table;
      const thisScript = Drupal.foldershare.UIFolderTableMenu;
      const oldRowIndex = Number($thisTable.attr(thisScript.tableDragRowIndex));
 
      switch ($thisTable.attr(thisScript.tableDragOperand)) {
        default:
        case "rows":
        case "none":
          break;
 
        case "files":
          // Assume this is the last event of a canceled file drag, since
          // we can't tell otherwise. End the file drag.
          // Unhighlight, if any.
          switch ($thisTable.attr(thisScript.tableDropTarget)) {
            case "table":
              $thisTable.removeClass("foldershare-draghover");
              break;
 
            case "row":
              if (Number.isNaN(oldRowIndex) === false) {
                // The event's row index is 1-based, while jQuery is 0-based.
                $("tbody tr", $thisTable).eq(oldRowIndex - 1)
                  .removeClass("foldershare-draghover");
              }
              break;
 
            default:
            case "none":
              break;
          }
 
          // Clear the table attributes.
          $thisTable.attr(thisScript.tableDragRowIndex, "NaN");
          $thisTable.attr(thisScript.tableDropTarget, "none");
          $thisTable.attr(thisScript.tableDragOperand, "none");
          $thisTable.attr(thisScript.tableDragEffectAllowed, "none");
          break;
      }
 
      return false;
    },
 
    /**
     * Handles a drop of rows/files drags atop table body rows or header.
     *
     * This function is called on a "drop" event for both row and file
     * drags atop rows in the table body or the table header.
     *
     * For entity row drags, the drop triggers a move or copy of the
     * dragged entities into a subfolder.
     *
     * For file drags, the drop triggers an upload of the dragged files
     * into the current table (if allowed) or a subfolder.
     *
     * @param {object} ev
     *   The row event to handle.
     * @param {object} env
     *   The environment object.
     *
     * @return {boolean}
     *   Returns false and prevents further event processing.
     */
    tableRowOrHeaderDrop(ev, env) {
      const $thisTr = $(this);
      const $thisTable = env.gather.$table;
      const thisScript = Drupal.foldershare.UIFolderTableMenu;
 
      //
      // Setup.
      // ------
      // Get the drop target. If it is empty, ignore the drop. If it is
      // a row, get row information for use below.
      //
      // Get the old drop target and row index.
      const oldRowIndex = Number($thisTable.attr(thisScript.tableDragRowIndex));
      const oldDropTarget = $thisTable.attr(thisScript.tableDropTarget);
 
      const newDropTarget = $thisTable.attr(thisScript.tableDropTarget);
      let dropEntityId = "";
 
      switch (newDropTarget) {
        case "table":
          // Table target. No setup required.
          dropEntityId = env.settings.foldershare.page.id;
          break;
 
        case "row":
          // Row target. Get the entity ID for the drop row.
          // Since we already got a new drop target of 'row', we know
          // that the row is valid, not disabled, and a folder.
          dropEntityId = $(`td.${env.gather.nameColumn} a`, $thisTr)
            .attr("data-foldershare-id");
          break;
 
        default:
        case "none":
          // No drop target. Ignore the drop.
          ev.preventDefault();
          ev.stopPropagation();
          return false;
      }
 
      // Unhighlight, if any.
      switch (oldDropTarget) {
        case "table":
          $thisTable.removeClass("foldershare-draghover");
          break;
 
        case "row":
          if (Number.isNaN(oldRowIndex) === false) {
            // The event's row index is 1-based, while jQuery is 0-based.
            $("tbody tr", $thisTable).eq(oldRowIndex - 1)
              .removeClass("foldershare-draghover");
          }
          break;
 
        default:
        case "none":
          break;
      }
 
      //
      // Execute the drop.
      // -----------------
      // The data transfer's "dropEffect" is handled differently by
      // different browsers:
      //
      // - Microsoft Edge and Mozilla Firefox set "dropEffect" to
      //   "copy" or "move" when earlier "dragover" behaviors have constained
      //   the allowed effect to "copyMove".
      //
      // - Apple Safari sets "dropEffect" to "none" and "effectAllowed" to
      //   "all", "copy", or "move" when we constrain the allowed effect to
      //   "copyMove".
      let effect = null;
      let command = null;
      let entityIdList = null;
      switch ($thisTable.attr(thisScript.tableDragOperand)) {
        default:
        case "none":
          // No drag in progress. This should not be possible.
          ev.preventDefault();
          ev.stopPropagation();
          return false;
 
        case "rows":
          // Drop entity rows.
          //
          // The drop target must be a row. If not, ignore the drop.
          if (newDropTarget !== "row") {
            break;
          }
 
          // Get the drag's list of entity IDs and make sure the drop row's
          // entity ID is not in the list.
          entityIdList = JSON.parse(ev.originalEvent.dataTransfer.getData(
              "foldershare/local-entity-list"));
          if ($.inArray(dropEntityId, entityIdList) !== -1) {
            // User error. Cannot drop onto self.
            break;
          }
 
          // Determine if the operation is a copy or move.
          effect = ev.originalEvent.dataTransfer.dropEffect;
          if (effect === "none") {
            switch (ev.originalEvent.dataTransfer.effectAllowed) {
              default:
              case "copyMove":
              case "linkMove":
              case "move":
              case "all":
                effect = "move";
                break;
 
              case "copyLink":
              case "copy":
                effect = "copy";
                break;
            }
 
            if (effect === "none") {
              // Still none. Cannot figure out effect.
              break;
            }
          }
 
          switch (effect) {
            default:
            case "move":
              switch (env.settings.foldershare.page.kind) {
                case "rootlist":
                  if (env.settings.foldershare.user.adminpermission === true) {
                    if (thisScript.moveCommandAsAdmin in env.mainCommands) {
                      command = thisScript.moveCommandAsAdmin;
                    }
                    else {
                      command = thisScript.moveCommandOnRootList;
                    }
                  }
                  else {
                    command = thisScript.moveCommandOnRootList;
                  }
                  break;
 
                default:
                case "folder":
                  command = thisScript.moveCommand;
                  break;
              }
              break;
 
            case "copy":
              command = thisScript.copyCommand;
              break;
          }
 
          // Issue the copy or move command.
          thisScript.serverCommandSetup(
            env,
            command,
            null,
            dropEntityId,
            entityIdList,
            null
          );
          thisScript.serverCommandSubmit(env);
          break;
 
        case "files":
          // Drop files.
          //
          // The drop target can be a row or the table.
          //
          // Clean up at the end of a file drag.
          $thisTable.attr(thisScript.tableDragOperand, "none");
          $thisTable.attr(thisScript.tableDragEffectAllowed, "none");
          $thisTable.attr(thisScript.tableDragRowIndex, "NaN");
          $thisTable.attr(thisScript.tableDropTarget, "none");
 
          thisScript.checkFileDragValid(
            ev,
            env,
            (eev, eenv, fileList) => {
              // Issue the upload command.
              thisScript.serverCommandSetup(
                eenv,
                thisScript.uploadCommand,
                dropEntityId,
                null,
                null,
                fileList);
            },
            (eev, eenv, fileList) => {
              // Tell the user the drag was not valid.
              let text = "<div>";
              if (fileList.length <= 1) {
                const translated = eenv.settings.foldershare.terminology.text
                  .upload_dnd_invalid_singular;
                if (typeof translated === "undefined") {
                  text += "<p><strong>Drag-and-drop item cannot be uploaded.</strong></p>";
                  text += "<p>You may not have access to the item, or it may be a folder. Folder upload is not supported.</p>";
                } else {
                  text += translated;
                }
              } else {
                const translated = eenv.settings.foldershare.terminology.text
                  .upload_dnd_invalid_plural;
                if (typeof translated === "undefined") {
                  text += "<p><strong>Drag-and-drop items cannot be uploaded.</strong></p>";
                  text += "<p>You may not have access to these items, or one of them may be a folder. Folder upload is not supported.</p>";
                } else {
                  text += translated;
                }
              }
              text += "</div>";
 
              Drupal.dialog(text, {}).showModal();
            });
          break;
      }
 
      ev.preventDefault();
      ev.stopPropagation();
      return false;
    },
 
    /*--------------------------------------------------------------------
     *
     * Table.
     *
     * These functions manage the table of files and folders.
     *
     *--------------------------------------------------------------------*/
 
    /**
     * Handles a single table row selection as if by a mouse click.
     *
     * Used to select a row based upon some non-mouse event, this method
     * marks a single row as selected, clearing any previous selection.
     *
     * @param {object} $tr
     *   The row to select.
     * @param {object} env
     *   The environment object.
     */
    tableSelectRow($tr, env) {
      const $table = env.gather.$table;
      const $tbody = env.gather.$tbody;
 
      // Selecting a row clears the previous selection (if any) and
      // selects the row.
      $("tr", $tbody).each((index, value) => {
        $(value).toggleClass("selected", false);
      });
 
      // For out of range row (<1), clear selection.
      // Otherwise select the clicked-on row and save its index.
      if (this.rowIndex <= 0) {
        $table.attr("selectionFirstRowIndex", "");
        $table.attr("selectionLastRowIndex", "");
      } else {
        // Only select rows that have a linked name and are not disabled.
        const $tdLinkName = $(`td.${env.gather.nameColumn} a`, $tr);
        if ($tdLinkName.length !== 0) {
          $tr.toggleClass("selected", true);
          $table.attr("selectionFirstRowIndex", this.rowIndex);
          $table.attr("selectionLastRowIndex", this.rowIndex);
        }
      }
 
      // A click can sometimes cause a text selection if the mouse
      // moved a little between mouse down and up. Such a text
      // selection is meaningless here, so disable it.
      window.getSelection().removeAllRanges();
    },
 
    /**
     * Selects table rows with the indicated IDs.
     *
     * @param {object} env
     *   The environment object.
     * @param {array} ids
     *   The array of entity IDs used to select corresponding rows.
     */
    tableSetSelectionIds(env, ids) {
      const $tbody = env.gather.$tbody;
 
      for (let i = 0; i < ids.length; ++i) {
        $(`td.${env.gather.nameColumn} a[data-foldershare-id="${ids[i]}"]`, $tbody).each((index, value) => {
          $(value).closest('tr').addClass('selected');
        });
      }
    },
 
    /**
     * Returns the current table row selection, grouped by entity kind.
     *
     * The view table is scanned for selected rows. The entity ID, kind, and
     * access information for each selected row are extracted and used to
     * bin entities into an object with one property for each kind found.
     * The value of the property is an array containing one object for each
     * entity found of that property's kind. Each of those objects has 'id'
     * and 'access' properties containing the corresponding values for the
     * entity.
     *
     * @param {object} env
     *   The environment object.
     *
     * @return {object}
     *   The returned object contains one property for each entity kind
     *   found. The value for each property is an array of objects that each
     *   contain an entity ID and access grants for that entity.
     */
    tableGetSelectionIdsByKind(env) {
      const $tbody = env.gather.$tbody;
      const result = {};
 
      $(`tr.selected td.${env.gather.nameColumn} a`, $tbody).each((index, value) => {
        // Get the entity ID, kind, and access for the entity on the row.
        // If any of these is missing, the row is malformed and ignored.
        const entityId  = $(value).attr("data-foldershare-id");
        const kind      = $(value).attr("data-foldershare-kind");
        const disabled  = $(value).attr("data-foldershare-disabled");
        const hidden    = $(value).attr("data-foldershare-hidden");
        let access      = $(value).attr("data-foldershare-access");
        const ownerid   = $(value).attr("data-foldershare-ownerid");
        const extension = $(value).attr("data-foldershare-extension");
 
        if (typeof entityId === "undefined" ||
          typeof kind === "undefined" ||
          typeof access === "undefined" ||
          typeof ownerid === "undefined") {
          // Fail. Something is missing. Ignore the row.
          return true;
        }
 
        if (disabled === "true" || hidden === "true") {
          // Item is disabled or hidden. Ignore the row.
          return true;
        }
 
        // Parse the access list into an array.
        try {
          access = access.split(",");
        } catch (err) {
          // Fail. Parse error. Ignore the row.
          return true;
        }
 
        // Add this row into the selection. Use the row kind to group
        // rows, and save the entity ID and access array.
        if (typeof result[kind] === "undefined") {
          result[kind] = [];
        }
 
        const ownedbyuser =
          $(value).attr("data-foldershare-ownedbyuser");
        const ownedbyanonymous =
          $(value).attr("data-foldershare-ownedbyanonymous");
        const ownedbyanother =
          $(value).attr("data-foldershare-ownedbyanother");
        const sharedbyuser =
          $(value).attr("data-foldershare-sharedbyuser");
        const sharedwithusertoview =
          $(value).attr("data-foldershare-sharedwithusertoview");
        const sharedwithusertoauthor =
          $(value).attr("data-foldershare-sharedwithusertoauthor");
        const sharedwithanonymoustoview =
          $(value).attr("data-foldershare-sharedwithanonymoustoview");
        const sharedwithanonymoustoauthor =
          $(value).attr("data-foldershare-sharedwithanonymoustoauthor");
 
        result[kind].push({
          id: entityId,
          access: access,
          extension: extension,
          ownerid: ownerid,
          ownedbyuser: (ownedbyuser === "true"),
          ownedbyanonymous: (ownedbyanonymous === "true"),
          ownedbyanother: (ownedbyanother === "true"),
          sharedbyuser: (sharedbyuser === "true"),
          sharedwithusertoview: (sharedwithusertoview === "true"),
          sharedwithusertoauthor: (sharedwithusertoauthor === "true"),
          sharedwithanonymoustoview: (sharedwithanonymoustoview === "true"),
          sharedwithanonymoustoauthor: (sharedwithanonymoustoauthor === "true")
        });
 
        return true;
      });
 
      return result;
    },
 
    /**
     * Returns the current table row selection as an array of entity IDs.
     *
     * The view table is scanned for selected rows. The entity ID for each
     * selected row is added to an array and the array returned.
     *
     * @param {object} env
     *   the environment object.
     *
     * @return {int[]}
     *   Returns an array of entity IDs for selected rows.
     */
    tableGetSelectionIds(env) {
      const $tbody = env.gather.$tbody;
      const result = [];
 
      $(`tr.selected td.${env.gather.nameColumn} a`, $tbody).each((index, value) => {
        const id = $(value).attr("data-foldershare-id");
        if (typeof id !== "undefined") {
          result.push(id);
        }
        return true;
      });
 
      return result;
    },
 
    /**
     * Unselects everything.
     *
     * @param {object} env
     *   the environment object.
     */
    tableUnselectAll(env) {
      const $tbody = env.gather.$tbody;
      $("tr.selected", $tbody).removeClass("selected");
    },
 
    /*--------------------------------------------------------------------
     *
     * Validate.
     *
     *--------------------------------------------------------------------*/
 
    /**
     * Validates that the selection meets a command's constraints.
     *
     * Each command has selection constraints that limit the command to
     * apply only to files, folders, or root folders, or some combination
     * of these.
     *
     * This mimics similar checking on the server and is used to disable
     * command menu items that cannot be chosen in the current context.
     *
     * @param {object} env
     *   The environment object.
     * @param {int} nSelected
     *   The number of items selected.
     * @param {object} selection
     *   An array of selected entity kinds, each with an array of entity Ids.
     * @param {string} commandId
     *   A ID of the command to check for use with the selection.
     *
     * @return {boolean}
     *   Returns true if the command's selection constraints are met in this
     *   context, and false otherwise.
     */
    checkSelectionConstraints(env, nSelected, selection, commandId) {
      //
      // Setup
      // -----
      // Get the command's selection constraints.
      const constraints =
        env.settings.foldershare.commands[commandId].selectionConstraints;
 
      //
      // Selection type (size) suitable.
      // -------------------------------
      // Insure the selection size is compatible with the command.
      //
      // If the command does not use a selection, then we can skip all of this.
      const types = constraints.types;
 
      if ($.inArray("none", types) !== -1) {
        // Command expects NO selection.
        //
        // If there isn't one, return TRUE. Otherwise FALSE.
        //
        // We can return immediately without further selection constraint
        // checking because the command doesn't use a selection.
        if (nSelected === 0) {
          return true;
        }
        return false;
      }
 
      let selectionIsPageEntity = false;
 
      // The command uses a selection, so check it.
      if (nSelected === 0) {
        // There is no selection.
        //
        // Does the command support defaulting to operating on the page entity,
        // if there is one?
        const pageEntityId = env.settings.foldershare.page.id;
        if (pageEntityId >= 0 && $.inArray("parent", types) !== -1) {
          // There is a page entity, and this command accepts defaulting the
          // selection to the parent. So create a fake selection for the
          // remainder of this function"s checking.
          const pageKind = env.settings.foldershare.page.kind;
          const pageAccess = env.settings.foldershare.user.pageAccess;
          selection[pageKind] = {
            id: pageEntityId,
            access: pageAccess
          };
          nSelected = 1;
          selectionIsPageEntity = true;
        } else {
          // The command does not default to the page entity when there is no
          // selection, and we already checked that the command does not
          // work when there is no selection.
          return false;
        }
      } else if (nSelected === 1) {
        // There is a single item selected
        if ($.inArray("one", types) === -1) {
          // But the command does not support having just one item.
          if ($.inArray("many", types) === -1) {
            // But the command does not support having many items either.
            return false;
          }
          return false;
        }
      } else if ($.inArray("many", types) === -1) {
        // There are multiple items selected.
        // But the command does not support having multiple items.
        return false;
      }
 
      //
      // Selection kinds suitable.
      // -------------------------
      // Insure the kinds of items in the selection are compatible with
      // the command.
      const allowedKinds = constraints.kinds;
 
      if ($.inArray("any", allowedKinds) === -1) {
        // Command has specific kind requirements.
        //
        // Loop through the selection and make sure each item's
        // kind is allowed.
        let result = true;
        Object.keys(selection).forEach(kind => {
          if ($.inArray(kind, allowedKinds) === -1) {
            // Kind not supported by this command.
            if (selectionIsPageEntity === true) {
              selection = {};
            }
            result = false;
          }
        });
 
        if (result === false) {
          return false;
        }
      }
 
      //
      // Selection ownership suitable.
      // -----------------------------
      // Insure the ownership of the items in the selection is compatible
      // with the command.
      const allowedOwnership = constraints.ownership;
 
      if ($.inArray("any", allowedOwnership) === -1) {
        // Command has specific ownership requirements.
        //
        // Loop through the selection and make sure each item's
        // ownership is allowed.
        let result = false;
        Object.keys(selection).forEach(kind => {
          const items = selection[kind];
          for (let i = 0, len = items.length; i < len; ++i) {
            allowedOwnership.forEach((value) => {
              if (items[i][value] === true) {
                result = true;
              }
            });
          }
        });
 
        if (result === false) {
          return false;
        }
      }
 
      //
      // Selection file name extension suitable.
      // ---------------------------------------
      // Insure the file name extensions used by the items in the selection
      // are all compatible with the command.
      const allowedExtensions = constraints.fileExtensions;
 
      if (allowedExtensions.length !== 0) {
        // Command has specific file name extension requirements.
        //
        // Loop through the selection and make sure each item's
        // extension is allowed.
        let result = true;
        Object.keys(selection).forEach(kind => {
          const items = selection[kind];
          for (let i = 0, len = items.length; i < len; ++i) {
            if ($.inArray(items[i].extension, allowedExtensions) === -1) {
              result = false;
            }
          }
        });
 
        if (result === false) {
          return false;
        }
      }
 
      //
      // Selection access suitable.
      // --------------------------
      // Insure each selected item allows the command's single access.
      const allowedAccess = constraints.access;
 
      if (allowedAccess !== "none") {
        // The command has specific access requirements. Loop through
        // the selection and make sure each selected item grants the
        // required access.
        let result = true;
        Object.keys(selection).forEach(kind => {
          const items = selection[kind];
          for (let i = 0, len = items.length; i < len; ++i) {
            if ($.inArray(allowedAccess, items[i].access) === -1) {
              if (selectionIsPageEntity === true) {
                selection = {};
              }
              result = false;
            }
          }
        });
 
        if (result === false) {
          return false;
        }
      }
 
      if (selectionIsPageEntity === true) {
        selection = {};
      }
 
      return true;
    }
  };
 
  /*--------------------------------------------------------------------
   *
   * On Drupal ready behaviors.
   *
   *--------------------------------------------------------------------*/
 
  /**
   * Add an attach behavior.
   *
   * The attach behaviors are executed after the page is fully loaded,
   * or whenever AJAX sends a new page fragment. For this module, the
   * behavior scans the page or page fragment for relevant content,
   * sets up the "environment" of saved state, and attaches UI behaviors
   * for menus, mouse clicks, touchs, drag-and-drop, and so forth.
   */
  Drupal.behaviors.foldershare_UIFolderTableMenu = {
    attach(pageContext, settings) {
      Drupal.foldershare.UIFolderTableMenu.attach(pageContext, settings);
    }
  };
 
  /*--------------------------------------------------------------------
   *
   * On return from "back" button, refresh views.
   *
   *--------------------------------------------------------------------*/
 
  /**
   * Responds when the user returns to a cached page.
   *
   * When the user views a page, then clicks a link on the page, and
   * then returns back to the page, we need to restart the periodic
   * view refresh that was stopped when the user left the page.
   */
  $(window).on("pageshow", function(ev) {
    // On the first visit to a page, the event's persisted value is FALSE.
    // On all further return visits (e.g. via the "back" button), the
    // event's persisted value is TRUE.
    if (ev.originalEvent.persisted === true) {
      // On a return visit, the periodic refresh is not active and the
      // cached content may be out of date for all views. Trigger a
      // refresh for each one.
      Object.keys(Drupal.foldershare.environments).forEach(key => {
        const env = Drupal.foldershare.environments[key];
        env.gather.$view.trigger("FolderShareRefreshView");
      });
    }
  });
 
  /*--------------------------------------------------------------------
   *
   * Override Views module's scroll-to-top behavior.
   *
   *--------------------------------------------------------------------*/
 
  /**
   * The original Views behavior.
   *
   * @type {function}
   */
  const originalViewsScrollTop = Drupal.AjaxCommands.prototype.viewsScrollTop;
 
  /**
   * Overrides the Views module's scroll-to-top behavior on views refresh.
   *
   * On an AJAX view refresh, the Views module automatically issues an AJAX
   * command to scroll the page up to see the top of the view. This may have
   * seemed appropriate when the user clicks on a pager button (though that
   * is debatable), but it is very annoying for other AJAX refreshes, such
   * as the periodic refresh to keep a folder listing uptodate. The scroll
   * causes the page to jump upwards every few seconds, without the user
   * doing anything.
   *
   * There does not appear to be a way to block this in the AJAX request.
   *
   * There does not appear to be a way to block this in the server's response
   * to the request. If the view has a pager defined (and this module's views
   * must have one), then Views will add an AJAX scroll command. In Drupal 7
   * there was an AJAX command override hook, but that is now gone.
   *
   * This method therefore overrides the module's behavior in response to
   * a scroll command. If the command is to scroll a view for this module,
   * the command is blocked. Otherwise the original behavior is executed.
   *
   * @param {Drupal.Ajax} [ajax]
   *   A {@link Drupal.ajax} object.
   * @param {object} response
   *   The AJAX response, including a ".selector" field that contains the
   *   full view DOM ID-based selector.
   */
  Drupal.AjaxCommands.prototype.viewsScrollTop = function(ajax, response) {
    // The Views module's scroll command uses the DOM ID in the selector.
    const viewSelector = response.selector;
 
    // FolderShare keeps track of views it cares about in its list of
    // environments, indexed by the same DOM ID selector.
    if (typeof Drupal.foldershare.environments[viewSelector] !== "undefined") {
      // This is a scroll for one of FolderShare's views. Ignore it!
      return;
    }
 
    // Otherwise this is a scroll for some other view. Let the original
    // behavior handle it.
    originalViewsScrollTop.call(this, ajax, response);
  };
})(jQuery, Drupal);

Главная | Обратная связь

drupal hosting | друпал хостинг | it patrol .inc