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);
