/**
* @file
* Overrides tabledrag.js that provides dragging capabilities.
*
* - New Drupal.theme.tableDragHandle() function for tabledrag handle markup
* (https://www.drupal.org/node/3077938).
* - New Drupal.theme.tableDragToggle() function for tabledrag toggle markup
* (@todo: https://www.drupal.org/node/3084916).
* - New Drupal.theme.tableDragToggleWrapper() function for the wrapper of the
* tabledrag toggle (@todo: https://www.drupal.org/node/3084916).
* - Tabledrag functionality can be disabled
* (https://www.drupal.org/node/3083039).
* - The initial content of the tabledrag-cell is wrapped into a new DOM element
* ".tabledrag-cell-content__item". This new element is moved into an another
* ".tabledrag-cell-content" division that contains the drag handle, the
* indentation elements and the tabledrag changed mark as well.
* This is needed to keep all of these element in a single line
* (https://www.drupal.org/node/3083044).
* Claro introduced two theme functions for these:
* - Drupal.theme.tableDragCellContentWrapper() provides the output of the
* original content of the first table cell.
* - Drupal.theme.tableDragCellItemsWrapper() provides the markup of the
* common wrapper for every tabledrag cell elements including the
* indentation(s), the drag-handle, the original content and the tabledrag
* changed marker.
* - Fixes the RTL bug of the original tabledrag.js
* (https://www.drupal.org/node/197641).
* - Tabledrag changed mark is added next to the drag-handle, and not after the
* last item. (@todo: https://www.drupal.org/node/3084910).
*
* The '_slicedToArray' shim added for handling destructured arrays breaks IE11,
* that is why the 'prefer-destructuring' rule is disabled.
* @see https://github.com/babel/babel/issues/7597.
*
* @todo Refactor after https://www.drupal.org/node/3077938,
* https://www.drupal.org/node/3083039, https://www.drupal.org/node/3083044
* and https://www.drupal.org/node/197641 are in.
*/
/**
* Triggers when weights columns are toggled.
*
* @event columnschange
*/
/* eslint-disable default-case, new-cap, prefer-destructuring */
(($, Drupal, drupalSettings) => {
/**
* Store the state of weight columns display for all tables.
*
* Default value is to hide weight columns.
*/
let showWeight = JSON.parse(
localStorage.getItem('Drupal.tableDrag.showWeight'),
);
/**
* Drag and drop table rows with field manipulation.
*
* Using the drupal_attach_tabledrag() function, any table with weights or
* parent relationships may be made into draggable tables. Columns containing
* a field may optionally be hidden, providing a better user experience.
*
* Created tableDrag instances may be modified with custom behaviors by
* overriding the .onDrag, .onDrop, .row.onSwap, and .row.onIndent methods.
* See blocks.js for an example of adding additional functionality to
* tableDrag.
*
* @type {Drupal~behavior}
*/
Drupal.behaviors.tableDrag = {
attach(context, settings) {
function initTableDrag(table, base) {
if (table.length) {
// Create the new tableDrag instance. Save in the Drupal variable
// to allow other scripts access to the object.
Drupal.tableDrag[base] = new Drupal.tableDrag(
table[0],
settings.tableDrag[base],
);
}
}
Object.keys(settings.tableDrag || {}).forEach((base) => {
initTableDrag($(context).find(`#${base}`).once('tabledrag'), base);
});
},
};
/**
* Provides table and field manipulation.
*
* @constructor
*
* @param {HTMLElement} table
* DOM object for the table to be made draggable.
* @param {object} tableSettings
* Settings for the table added via drupal_add_dragtable().
*/
Drupal.tableDrag = function init(table, tableSettings) {
const self = this;
const $table = $(table);
/**
* @type {jQuery}
*/
this.$table = $(table);
/**
*
* @type {HTMLElement}
*/
this.table = table;
/**
* @type {object}
*/
this.tableSettings = tableSettings;
/**
* Used to hold information about a current drag operation.
*
* @type {?HTMLElement}
*/
this.dragObject = null;
/**
* Provides operations for row manipulation.
*
* @type {?HTMLElement}
*/
this.rowObject = null;
/**
* Remember the previous element.
*
* @type {?HTMLElement}
*/
this.oldRowElement = null;
/**
* Used to determine up or down direction from last mouse move.
*
* @type {?number}
*/
this.oldY = null;
/**
* Whether anything in the entire table has changed.
*
* @type {bool}
*/
this.changed = false;
/**
* Maximum amount of allowed parenting.
*
* @type {number}
*/
this.maxDepth = 0;
/**
* Direction of the table.
*
* @type {number}
*/
this.rtl = $(this.table).css('direction') === 'rtl' ? -1 : 1;
/**
*
* @type {bool}
*/
this.striping = $(this.table).data('striping') === 1;
/**
* Configure the scroll settings.
*
* @type {object}
*
* @prop {number} amount
* @prop {number} interval
* @prop {number} trigger
*/
this.scrollSettings = { amount: 4, interval: 50, trigger: 70 };
/**
*
* @type {?number}
*/
this.scrollInterval = null;
/**
*
* @type {number}
*/
this.scrollY = 0;
/**
*
* @type {number}
*/
this.windowHeight = 0;
/**
* Check this table's settings for parent relationships.
*
* For efficiency, large sections of code can be skipped if we don't need to
* track horizontal movement and indentations.
*
* @type {bool}
*/
this.indentEnabled = false;
Object.keys(tableSettings || {}).forEach((group) => {
Object.keys(tableSettings[group] || {}).forEach((n) => {
if (tableSettings[group][n].relationship === 'parent') {
this.indentEnabled = true;
}
if (tableSettings[group][n].limit > 0) {
this.maxDepth = tableSettings[group][n].limit;
}
});
});
if (this.indentEnabled) {
/**
* Total width of indents, set in makeDraggable.
*
* @type {number}
*/
this.indentCount = 1;
// Find the width of indentations to measure mouse movements against.
// Because the table doesn't need to start with any indentations, we
// manually append 2 indentations in the first draggable row, measure
// the offset, then remove.
const indent = Drupal.theme('tableDragIndentation');
const testRow = $('<tr></tr>').addClass('draggable').appendTo(table);
const testCell = $('<td></td>')
.appendTo(testRow)
.prepend(indent)
.prepend(indent);
const $indentation = testCell.find('.js-indentation');
/**
* @type {number}
*/
this.indentAmount =
$indentation.get(1).offsetLeft - $indentation.get(0).offsetLeft;
testRow.remove();
}
// Make each applicable row draggable.
// Match immediate children of the parent element to allow nesting.
$table
.find('> tr.draggable, > tbody > tr.draggable')
.each(function initDraggable() {
self.makeDraggable(this);
});
// Add the toggle link wrapper before the table that will contain the toggle
// for users to show or hide weight columns.
$table.before(
$(Drupal.theme('tableDragToggleWrapper'))
.addClass('js-tabledrag-toggle-weight-wrapper')
.on(
'click',
'.js-tabledrag-toggle-weight',
$.proxy(function toggleColumns(event) {
event.preventDefault();
this.toggleColumns();
}, this),
),
);
// Initialize the specified columns (for example, weight or parent columns)
// to show or hide according to user preference. This aids accessibility
// so that, e.g., screen reader users can choose to enter weight values and
// manipulate form elements directly, rather than using drag-and-drop..
self.initColumns();
// Add event bindings to the document. The self variable is passed along
// as event handlers do not have direct access to the tableDrag object.
$(document).on('touchmove', (event) =>
self.dragRow(event.originalEvent.touches[0], self),
);
$(document).on('touchend', (event) =>
self.dropRow(event.originalEvent.touches[0], self),
);
$(document).on('mousemove pointermove', (event) =>
self.dragRow(event, self),
);
$(document).on('mouseup pointerup', (event) => self.dropRow(event, self));
// React to localStorage event showing or hiding weight columns.
$(window).on(
'storage',
$.proxy(function weightColumnDisplayChange(event) {
// Only react to 'Drupal.tableDrag.showWeight' value change.
if (event.originalEvent.key === 'Drupal.tableDrag.showWeight') {
// This was changed in another window, get the new value for this
// window.
showWeight = JSON.parse(event.originalEvent.newValue);
this.displayColumns(showWeight);
}
}, this),
);
};
$.extend(
Drupal.tableDrag.prototype,
/** @lends Drupal.tableDrag# */ {
/**
* Initialize columns containing form elements to be hidden by default.
*
* Identify and mark each cell with a CSS class so we can easily toggle
* show/hide it. Finally, hide columns if user does not have a
* 'Drupal.tableDrag.showWeight' localStorage value.
*/
initColumns() {
const { $table } = this;
let hidden;
let cell;
let columnIndex;
Object.keys(this.tableSettings || {}).forEach((group) => {
// Find the first field in this group.
Object.keys(this.tableSettings[group]).some((tableSetting) => {
const field = $table
.find(`.${this.tableSettings[group][tableSetting].target}`)
.eq(0);
if (
field.length &&
this.tableSettings[group][tableSetting].hidden
) {
hidden = this.tableSettings[group][tableSetting].hidden;
cell = field.closest('td');
return true;
}
return false;
});
// Mark the column containing this field so it can be hidden.
if (hidden && cell[0]) {
// Add 1 to our indexes. The nth-child selector is 1 based, not 0
// based. Match immediate children of the parent element to allow
// nesting.
columnIndex = cell.parent().find('> td').index(cell.get(0)) + 1;
$table
.find('> thead > tr, > tbody > tr, > tr')
.each(this.addColspanClass(columnIndex));
}
});
this.displayColumns(showWeight);
},
/**
* Mark cells that have colspan.
*
* In order to adjust the colspan instead of hiding them altogether.
*
* @param {number} columnIndex
* The column index to add colspan class to.
*
* @return {function}
* Function to add colspan class.
*/
addColspanClass(columnIndex) {
return function addColspanClass() {
// Get the columnIndex and adjust for any colspans in this row.
const $row = $(this);
let index = columnIndex;
const cells = $row.children();
let cell;
cells.each(function checkColspan(n) {
if (n < index && this.colSpan && this.colSpan > 1) {
index -= this.colSpan - 1;
}
});
if (index > 0) {
cell = cells.filter(`:nth-child(${index})`);
if (cell[0].colSpan && cell[0].colSpan > 1) {
// If this cell has a colspan, mark it so we can reduce the colspan.
cell.addClass('tabledrag-has-colspan');
} else {
// Mark this cell so we can hide it.
cell.addClass('tabledrag-hide');
}
}
};
},
/**
* Hide or display weight columns. Triggers an event on change.
*
* @fires event:columnschange
*
* @param {bool} displayWeight
* 'true' will show weight columns.
*/
displayColumns(displayWeight) {
if (displayWeight) {
this.showColumns();
}
// Default action is to hide columns.
else {
this.hideColumns();
}
// Trigger an event to allow other scripts to react to this display change.
// Force the extra parameter as a bool.
$('table')
.findOnce('tabledrag')
.trigger('columnschange', !!displayWeight);
},
/**
* Toggle the weight column depending on 'showWeight' value.
*
* Store only default override.
*/
toggleColumns() {
showWeight = !showWeight;
this.displayColumns(showWeight);
if (showWeight) {
// Save default override.
localStorage.setItem('Drupal.tableDrag.showWeight', showWeight);
} else {
// Reset the value to its default.
localStorage.removeItem('Drupal.tableDrag.showWeight');
}
},
/**
* Hide the columns containing weight/parent form elements.
*
* Undo showColumns().
*/
hideColumns() {
const $tables = $('table').findOnce('tabledrag');
// Hide weight/parent cells and headers.
$tables.find('.tabledrag-hide').css('display', 'none');
// Show TableDrag handles.
$tables.find('.js-tabledrag-handle').css('display', '');
// Reduce the colspan of any effected multi-span columns.
$tables.find('.tabledrag-has-colspan').each(function decreaseColspan() {
this.colSpan -= -1;
});
// Change link text.
$('.js-tabledrag-toggle-weight-wrapper').each(
function addShowWeightToggle() {
const $wrapper = $(this);
const toggleWasFocused = $wrapper.find(
'.js-tabledrag-toggle-weight:focus',
).length;
$wrapper
.empty()
.append(
$(
Drupal.theme(
'tableDragToggle',
'show',
Drupal.t('Show row weights'),
),
).addClass('js-tabledrag-toggle-weight'),
);
if (toggleWasFocused) {
$wrapper.find('.js-tabledrag-toggle-weight').trigger('focus');
}
},
);
},
/**
* Show the columns containing weight/parent form elements.
*
* Undo hideColumns().
*/
showColumns() {
const $tables = $('table').findOnce('tabledrag');
// Show weight/parent cells and headers.
$tables.find('.tabledrag-hide').css('display', '');
// Hide TableDrag handles.
$tables.find('.js-tabledrag-handle').css('display', 'none');
// Increase the colspan for any columns where it was previously reduced.
$tables.find('.tabledrag-has-colspan').each(function increaseColspan() {
this.colSpan += 1;
});
// Change link text.
$('.js-tabledrag-toggle-weight-wrapper').each(
function addHideWeightToggle() {
const $wrapper = $(this);
const toggleWasFocused = $wrapper.find(
'.js-tabledrag-toggle-weight:focus',
).length;
$wrapper
.empty()
.append(
$(
Drupal.theme(
'tableDragToggle',
'hide',
Drupal.t('Hide row weights'),
),
).addClass('js-tabledrag-toggle-weight'),
);
if (toggleWasFocused) {
$wrapper.find('.js-tabledrag-toggle-weight').trigger('focus');
}
},
);
},
/**
* Find the target used within a particular row and group.
*
* @param {string} group
* Group selector.
* @param {HTMLElement} row
* The row HTML element.
*
* @return {object}
* The table row settings.
*/
rowSettings(group, row) {
const field = $(row).find(`.${group}`);
const tableSettingsGroup = this.tableSettings[group];
return Object.keys(tableSettingsGroup)
.map((delta) => {
const targetClass = tableSettingsGroup[delta].target;
let rowSettings;
if (field.is(`.${targetClass}`)) {
// Return a copy of the row settings.
rowSettings = {};
Object.keys(tableSettingsGroup[delta]).forEach((n) => {
rowSettings[n] = tableSettingsGroup[delta][n];
});
}
return rowSettings;
})
.filter((rowSetting) => rowSetting)[0];
},
/**
* Take an item and add event handlers to make it become draggable.
*
* @param {HTMLElement} item
* The item to add event handlers to.
*/
makeDraggable(item) {
const self = this;
const $item = $(item);
const $firstCell = $item
.find('td:first-of-type')
.wrapInner(Drupal.theme.tableDragCellContentWrapper())
.wrapInner(
$(Drupal.theme('tableDragCellItemsWrapper')).addClass(
'js-tabledrag-cell-content',
),
);
const $targetElem = $firstCell.find('.js-tabledrag-cell-content').length
? $firstCell.find('.js-tabledrag-cell-content')
: $firstCell.addClass('js-tabledrag-cell-content');
// Move indentations into the '.js-tabledrag-cell-content' target.
$targetElem.find('.js-indentation').detach().prependTo($targetElem);
// Add a class to the title link.
$targetElem.find('a').addClass('menu-item__link');
// Create the handle.
const handle = $(Drupal.theme.tableDragHandle())
.addClass('js-tabledrag-handle')
.attr('title', Drupal.t('Drag to re-order'));
// Insert the handle after indentations (if any).
const $indentationLast = $targetElem.find('.js-indentation').eq(-1);
if ($indentationLast.length) {
$indentationLast.after(handle);
// Update the total width of indentation in this entire table.
self.indentCount = Math.max(
$item.find('.js-indentation').length,
self.indentCount,
);
} else {
$targetElem.prepend(handle);
}
// Prevent the anchor tag from jumping us to the top of the page.
handle.on('click', (event) => {
event.preventDefault();
});
// Don't do anything if tabledrag is disabled.
if (handle.closest('.js-tabledrag-disabled').length) {
return;
}
handle.on('mousedown touchstart pointerdown', (event) => {
event.preventDefault();
if (event.originalEvent.type === 'touchstart') {
event = event.originalEvent.touches[0];
}
self.dragStart(event, self, item);
});
// Set blur cleanup when a handle is focused.
handle.on('focus', () => {
self.safeBlur = true;
});
// On blur, fire the same function as a touchend/mouseup. This is used to
// update values after a row has been moved through the keyboard support.
handle.on('blur', (event) => {
if (self.rowObject && self.safeBlur) {
self.dropRow(event, self);
}
});
// Add arrow-key support to the handle.
handle.on('keydown', (event) => {
// If a rowObject doesn't yet exist and this isn't the tab key.
if (event.keyCode !== 9 && !self.rowObject) {
self.rowObject = new self.row(
item,
'keyboard',
self.indentEnabled,
self.maxDepth,
true,
);
}
let keyChange = false;
let groupHeight;
/* eslint-disable no-fallthrough */
switch (event.keyCode) {
// Left arrow.
case 37:
// Safari left arrow.
case 63234:
keyChange = true;
self.rowObject.indent(-1 * self.rtl);
break;
// Up arrow.
case 38:
// Safari up arrow.
case 63232: {
let $previousRow = $(self.rowObject.element).prev('tr').eq(0);
let previousRow = $previousRow.get(0);
while (previousRow && $previousRow.is(':hidden')) {
$previousRow = $(previousRow).prev('tr').eq(0);
previousRow = $previousRow.get(0);
}
if (previousRow) {
// Do not allow the onBlur cleanup.
self.safeBlur = false;
self.rowObject.direction = 'up';
keyChange = true;
if ($(item).is('.tabledrag-root')) {
// Swap with the previous top-level row.
groupHeight = 0;
while (
previousRow &&
$previousRow.find('.js-indentation').length
) {
$previousRow = $(previousRow).prev('tr').eq(0);
previousRow = $previousRow.get(0);
groupHeight += $previousRow.is(':hidden')
? 0
: previousRow.offsetHeight;
}
if (previousRow) {
self.rowObject.swap('before', previousRow);
// No need to check for indentation, 0 is the only valid one.
window.scrollBy(0, -groupHeight);
}
} else if (
self.table.tBodies[0].rows[0] !== previousRow ||
$previousRow.is('.draggable')
) {
// Swap with the previous row (unless previous row is the first
// one and undraggable).
self.rowObject.swap('before', previousRow);
self.rowObject.interval = null;
self.rowObject.indent(0);
window.scrollBy(0, -parseInt(item.offsetHeight, 10));
}
// Regain focus after the DOM manipulation.
handle.trigger('focus');
}
break;
}
// Right arrow.
case 39:
// Safari right arrow.
case 63235:
keyChange = true;
self.rowObject.indent(self.rtl);
break;
// Down arrow.
case 40:
// Safari down arrow.
case 63233: {
let $nextRow = $(self.rowObject.group).eq(-1).next('tr').eq(0);
let nextRow = $nextRow.get(0);
while (nextRow && $nextRow.is(':hidden')) {
$nextRow = $(nextRow).next('tr').eq(0);
nextRow = $nextRow.get(0);
}
if (nextRow) {
// Do not allow the onBlur cleanup.
self.safeBlur = false;
self.rowObject.direction = 'down';
keyChange = true;
if ($(item).is('.tabledrag-root')) {
// Swap with the next group (necessarily a top-level one).
groupHeight = 0;
const nextGroup = new self.row(
nextRow,
'keyboard',
self.indentEnabled,
self.maxDepth,
false,
);
if (nextGroup) {
$(nextGroup.group).each(function groupIterator() {
groupHeight += $(this).is(':hidden')
? 0
: this.offsetHeight;
});
const nextGroupRow = $(nextGroup.group).eq(-1).get(0);
self.rowObject.swap('after', nextGroupRow);
// No need to check for indentation, 0 is the only valid one.
window.scrollBy(0, parseInt(groupHeight, 10));
}
} else {
// Swap with the next row.
self.rowObject.swap('after', nextRow);
self.rowObject.interval = null;
self.rowObject.indent(0);
window.scrollBy(0, parseInt(item.offsetHeight, 10));
}
// Regain focus after the DOM manipulation.
handle.trigger('focus');
}
break;
}
}
/* eslint-enable no-fallthrough */
if (self.rowObject && self.rowObject.changed === true) {
$(item).addClass('drag');
if (self.oldRowElement) {
$(self.oldRowElement).removeClass('drag-previous');
}
self.oldRowElement = item;
if (self.striping === true) {
self.restripeTable();
}
self.onDrag();
}
// Returning false if we have an arrow key to prevent scrolling.
if (keyChange) {
return false;
}
});
// Compatibility addition, return false on keypress to prevent unwanted
// scrolling. IE and Safari will suppress scrolling on keydown, but all
// other browsers need to return false on keypress.
// http://www.quirksmode.org/js/keys.html
handle.on('keypress', (event) => {
/* eslint-disable no-fallthrough */
switch (event.keyCode) {
// Left arrow.
case 37:
// Up arrow.
case 38:
// Right arrow.
case 39:
// Down arrow.
case 40:
return false;
}
/* eslint-enable no-fallthrough */
});
},
/**
* Pointer event initiator, creates drag object and information.
*
* @param {jQuery.Event} event
* The event object that trigger the drag.
* @param {Drupal.tableDrag} self
* The drag handle.
* @param {HTMLElement} item
* The item that is being dragged.
*/
dragStart(event, self, item) {
// Create a new dragObject recording the pointer information.
self.dragObject = {};
self.dragObject.initOffset = self.getPointerOffset(item, event);
self.dragObject.initPointerCoords = self.pointerCoords(event);
if (self.indentEnabled) {
self.dragObject.indentPointerPos = self.dragObject.initPointerCoords;
}
// If there's a lingering row object from the keyboard, remove its focus.
if (self.rowObject) {
$(self.rowObject.element)
.find('.js-tabledrag-handle')
.trigger('blur');
}
// Create a new rowObject for manipulation of this row.
self.rowObject = new self.row(
item,
'pointer',
self.indentEnabled,
self.maxDepth,
true,
);
// Save the position of the table.
self.table.topY = $(self.table).offset().top;
self.table.bottomY = self.table.topY + self.table.offsetHeight;
// Add classes to the handle and row.
$(item).addClass('drag');
// Set the document to use the move cursor during drag.
$('body').addClass('drag');
if (self.oldRowElement) {
$(self.oldRowElement).removeClass('drag-previous');
}
// Set the initial y coordinate so the direction can be calculated in
// dragRow().
self.oldY = self.pointerCoords(event).y;
},
/**
* Pointer movement handler, bound to document.
*
* @param {jQuery.Event} event
* The pointer event.
* @param {Drupal.tableDrag} self
* The tableDrag instance.
*
* @return {bool|undefined}
* Undefined if no dragObject is defined, false otherwise.
*/
dragRow(event, self) {
if (self.dragObject) {
self.currentPointerCoords = self.pointerCoords(event);
const y = self.currentPointerCoords.y - self.dragObject.initOffset.y;
const x = self.currentPointerCoords.x - self.dragObject.initOffset.x;
// Check for row swapping and vertical scrolling.
if (y !== self.oldY) {
self.rowObject.direction = y > self.oldY ? 'down' : 'up';
// Update the old value.
self.oldY = y;
// Check if the window should be scrolled (and how fast).
const scrollAmount = self.checkScroll(self.currentPointerCoords.y);
// Stop any current scrolling.
clearInterval(self.scrollInterval);
// Continue scrolling if the mouse has moved in the scroll direction.
if (
(scrollAmount > 0 && self.rowObject.direction === 'down') ||
(scrollAmount < 0 && self.rowObject.direction === 'up')
) {
self.setScroll(scrollAmount);
}
// If we have a valid target, perform the swap and restripe the table.
const currentRow = self.findDropTargetRow(x, y);
if (currentRow) {
if (self.rowObject.direction === 'down') {
self.rowObject.swap('after', currentRow, self);
} else {
self.rowObject.swap('before', currentRow, self);
}
if (self.striping === true) {
self.restripeTable();
}
}
}
// Similar to row swapping, handle indentations.
if (self.indentEnabled) {
const xDiff =
self.currentPointerCoords.x - self.dragObject.indentPointerPos.x;
// Set the number of indentations the pointer has been moved left or
// right.
const indentDiff = Math.round(xDiff / self.indentAmount);
// Indent the row with our estimated diff, which may be further
// restricted according to the rows around this row.
const indentChange = self.rowObject.indent(indentDiff);
// Update table and pointer indentations.
self.dragObject.indentPointerPos.x +=
self.indentAmount * indentChange;
self.indentCount = Math.max(
self.indentCount,
self.rowObject.indents,
);
}
return false;
}
},
/**
* Pointerup behavior.
*
* @param {jQuery.Event} event
* The pointer event.
* @param {Drupal.tableDrag} self
* The tableDrag instance.
*/
dropRow(event, self) {
let droppedRow;
let $droppedRow;
// Drop row functionality.
if (self.rowObject !== null) {
droppedRow = self.rowObject.element;
$droppedRow = $(droppedRow);
// The row is already in the right place so we just release it.
if (self.rowObject.changed === true) {
// Update the fields in the dropped row.
self.updateFields(droppedRow);
// If a setting exists for affecting the entire group, update all the
// fields in the entire dragged group.
Object.keys(self.tableSettings || {}).forEach((group) => {
const rowSettings = self.rowSettings(group, droppedRow);
if (rowSettings.relationship === 'group') {
Object.keys(self.rowObject.children || {}).forEach((n) => {
self.updateField(self.rowObject.children[n], group);
});
}
});
self.rowObject.markChanged();
if (self.changed === false) {
const $messageTarget = $(self.table).prevAll(
'.js-tabledrag-toggle-weight-wrapper',
).length
? $(self.table)
.prevAll('.js-tabledrag-toggle-weight-wrapper')
.last()
: self.table;
$(Drupal.theme('tableDragChangedWarning'))
.insertBefore($messageTarget)
.hide()
.fadeIn('slow');
self.changed = true;
}
}
if (self.indentEnabled) {
self.rowObject.removeIndentClasses();
}
if (self.oldRowElement) {
$(self.oldRowElement).removeClass('drag-previous');
}
$droppedRow.removeClass('drag').addClass('drag-previous');
self.oldRowElement = droppedRow;
self.onDrop();
self.rowObject = null;
}
// Functionality specific only to pointerup events.
if (self.dragObject !== null) {
self.dragObject = null;
$('body').removeClass('drag');
clearInterval(self.scrollInterval);
}
},
/**
* Get the coordinates from the event (allowing for browser differences).
*
* @param {jQuery.Event} event
* The pointer event.
*
* @return {object}
* An object with `x` and `y` keys indicating the position.
*/
pointerCoords(event) {
if (event.pageX || event.pageY) {
return { x: event.pageX, y: event.pageY };
}
return {
x:
event.clientX +
(document.body.scrollLeft - document.body.clientLeft),
y:
event.clientY + (document.body.scrollTop - document.body.clientTop),
};
},
/**
* Get the event offset from the target element.
*
* Given a target element and a pointer event, get the event offset from that
* element. To do this we need the element's position and the target position.
*
* @param {HTMLElement} target
* The target HTML element.
* @param {jQuery.Event} event
* The pointer event.
*
* @return {object}
* An object with `x` and `y` keys indicating the position.
*/
getPointerOffset(target, event) {
const docPos = $(target).offset();
const pointerPos = this.pointerCoords(event);
return { x: pointerPos.x - docPos.left, y: pointerPos.y - docPos.top };
},
/**
* Find the row the mouse is currently over.
*
* This row is then taken and swapped with the one being dragged.
*
* @param {number} x
* The x coordinate of the mouse on the page (not the screen).
* @param {number} y
* The y coordinate of the mouse on the page (not the screen).
*
* @return {*}
* The drop target row, if found.
*/
findDropTargetRow(x, y) {
const rows = $(this.table.tBodies[0].rows).not(':hidden');
for (let n = 0; n < rows.length; n++) {
let row = rows[n];
let $row = $(row);
const rowY = $row.offset().top;
let rowHeight;
// Because Safari does not report offsetHeight on table rows, but does on
// table cells, grab the firstChild of the row and use that instead.
// http://jacob.peargrove.com/blog/2006/technical/table-row-offsettop-bug-in-safari.
if (row.offsetHeight === 0) {
rowHeight = parseInt(row.firstChild.offsetHeight, 10) / 2;
}
// Other browsers.
else {
rowHeight = parseInt(row.offsetHeight, 10) / 2;
}
// Because we always insert before, we need to offset the height a bit.
if (y > rowY - rowHeight && y < rowY + rowHeight) {
if (this.indentEnabled) {
// Check that this row is not a child of the row being dragged.
if (
Object.keys(this.rowObject.group).some(
(o) => this.rowObject.group[o] === row,
)
) {
return null;
}
}
// Do not allow a row to be swapped with itself.
else if (row === this.rowObject.element) {
return null;
}
// Check that swapping with this row is allowed.
if (!this.rowObject.isValidSwap(row)) {
return null;
}
// We may have found the row the mouse just passed over, but it doesn't
// take into account hidden rows. Skip backwards until we find a
// draggable row.
while ($row.is(':hidden') && $row.prev('tr').is(':hidden')) {
$row = $row.prev('tr:first-of-type');
row = $row.get(0);
}
return row;
}
}
return null;
},
/**
* After the row is dropped, update the table fields.
*
* @param {HTMLElement} changedRow
* DOM object for the row that was just dropped.
*/
updateFields(changedRow) {
Object.keys(this.tableSettings || {}).forEach((group) => {
// Each group may have a different setting for relationship, so we find
// the source rows for each separately.
this.updateField(changedRow, group);
});
},
/**
* After the row is dropped, update a single table field.
*
* @param {HTMLElement} changedRow
* DOM object for the row that was just dropped.
* @param {string} group
* The settings group on which field updates will occur.
*/
updateField(changedRow, group) {
let rowSettings = this.rowSettings(group, changedRow);
const $changedRow = $(changedRow);
let sourceRow;
let $previousRow;
let previousRow;
let useSibling;
// Set the row as its own target.
if (
rowSettings.relationship === 'self' ||
rowSettings.relationship === 'group'
) {
sourceRow = changedRow;
}
// Siblings are easy, check previous and next rows.
else if (rowSettings.relationship === 'sibling') {
$previousRow = $changedRow.prev('tr:first-of-type');
previousRow = $previousRow.get(0);
const $nextRow = $changedRow.next('tr:first-of-type');
const nextRow = $nextRow.get(0);
sourceRow = changedRow;
if (
$previousRow.is('.draggable') &&
$previousRow.find(`.${group}`).length
) {
if (this.indentEnabled) {
if (
$previousRow.find('.js-indentations').length ===
$changedRow.find('.js-indentations').length
) {
sourceRow = previousRow;
}
} else {
sourceRow = previousRow;
}
} else if (
$nextRow.is('.draggable') &&
$nextRow.find(`.${group}`).length
) {
if (this.indentEnabled) {
if (
$nextRow.find('.js-indentations').length ===
$changedRow.find('.js-indentations').length
) {
sourceRow = nextRow;
}
} else {
sourceRow = nextRow;
}
}
}
// Parents, look up the tree until we find a field not in this group.
// Go up as many parents as indentations in the changed row.
else if (rowSettings.relationship === 'parent') {
$previousRow = $changedRow.prev('tr');
previousRow = $previousRow;
while (
$previousRow.length &&
$previousRow.find('.js-indentation').length >=
this.rowObject.indents
) {
$previousRow = $previousRow.prev('tr');
previousRow = $previousRow;
}
// If we found a row.
if ($previousRow.length) {
sourceRow = $previousRow.get(0);
}
// Otherwise we went all the way to the left of the table without finding
// a parent, meaning this item has been placed at the root level.
else {
// Use the first row in the table as source, because it's guaranteed to
// be at the root level. Find the first item, then compare this row
// against it as a sibling.
sourceRow = $(this.table).find('tr.draggable:first-of-type').get(0);
if (sourceRow === this.rowObject.element) {
sourceRow = $(
this.rowObject.group[this.rowObject.group.length - 1],
)
.next('tr.draggable')
.get(0);
}
useSibling = true;
}
}
// Because we may have moved the row from one category to another,
// take a look at our sibling and borrow its sources and targets.
this.copyDragClasses(sourceRow, changedRow, group);
rowSettings = this.rowSettings(group, changedRow);
// In the case that we're looking for a parent, but the row is at the top
// of the tree, copy our sibling's values.
if (useSibling) {
rowSettings.relationship = 'sibling';
rowSettings.source = rowSettings.target;
}
const targetClass = `.${rowSettings.target}`;
const targetElement = $changedRow.find(targetClass).get(0);
// Check if a target element exists in this row.
if (targetElement) {
const sourceClass = `.${rowSettings.source}`;
const sourceElement = $(sourceClass, sourceRow).get(0);
switch (rowSettings.action) {
case 'depth':
// Get the depth of the target row.
targetElement.value = $(sourceElement)
.closest('tr')
.find('.js-indentation').length;
break;
case 'match':
// Update the value.
targetElement.value = sourceElement.value;
break;
case 'order': {
const siblings = this.rowObject.findSiblings(rowSettings);
if ($(targetElement).is('select')) {
// Get a list of acceptable values.
const values = [];
$(targetElement)
.find('option')
.each(function collectValues() {
values.push(this.value);
});
const maxVal = values[values.length - 1];
// Populate the values in the siblings.
$(siblings)
.find(targetClass)
.each(function assignValues() {
// If there are more items than possible values, assign the
// maximum value to the row.
if (values.length > 0) {
this.value = values.shift();
} else {
this.value = maxVal;
}
});
} else {
// Assume a numeric input field.
let weight =
parseInt($(siblings[0]).find(targetClass).val(), 10) || 0;
$(siblings)
.find(targetClass)
.each(function assignWeight() {
this.value = weight;
weight += 1;
});
}
break;
}
}
}
},
/**
* Copy all tableDrag related classes from one row to another.
*
* Copy all special tableDrag classes from one row's form elements to a
* different one, removing any special classes that the destination row
* may have had.
*
* @param {HTMLElement} sourceRow
* The element for the source row.
* @param {HTMLElement} targetRow
* The element for the target row.
* @param {string} group
* The group selector.
*/
copyDragClasses(sourceRow, targetRow, group) {
const sourceElement = $(sourceRow).find(`.${group}`);
const targetElement = $(targetRow).find(`.${group}`);
if (sourceElement.length && targetElement.length) {
targetElement[0].className = sourceElement[0].className;
}
},
/**
* Check the suggested scroll of the table.
*
* @param {number} cursorY
* The Y position of the cursor.
*
* @return {number}
* The suggested scroll.
*/
checkScroll(cursorY) {
const de = document.documentElement;
const b = document.body;
const windowHeight =
window.innerHeight ||
(de.clientHeight && de.clientWidth !== 0
? de.clientHeight
: b.offsetHeight);
this.windowHeight = windowHeight;
let scrollY;
if (document.all) {
scrollY = !de.scrollTop ? b.scrollTop : de.scrollTop;
} else {
scrollY = window.pageYOffset ? window.pageYOffset : window.scrollY;
}
this.scrollY = scrollY;
const { trigger } = this.scrollSettings;
let delta = 0;
// Return a scroll speed relative to the edge of the screen.
if (cursorY - scrollY > windowHeight - trigger) {
delta = trigger / (windowHeight + (scrollY - cursorY));
delta = delta > 0 && delta < trigger ? delta : trigger;
return delta * this.scrollSettings.amount;
}
if (cursorY - scrollY < trigger) {
delta = trigger / (cursorY - scrollY);
delta = delta > 0 && delta < trigger ? delta : trigger;
return -delta * this.scrollSettings.amount;
}
},
/**
* Set the scroll for the table.
*
* @param {number} scrollAmount
* The amount of scroll to apply to the window.
*/
setScroll(scrollAmount) {
const self = this;
this.scrollInterval = setInterval(() => {
// Update the scroll values stored in the object.
self.checkScroll(self.currentPointerCoords.y);
const aboveTable = self.scrollY > self.table.topY;
const belowTable =
self.scrollY + self.windowHeight < self.table.bottomY;
if (
(scrollAmount > 0 && belowTable) ||
(scrollAmount < 0 && aboveTable)
) {
window.scrollBy(0, scrollAmount);
}
}, this.scrollSettings.interval);
},
/**
* Command to restripe table properly.
*/
restripeTable() {
// :even and :odd are reversed because jQuery counts from 0 and
// we count from 1, so we're out of sync.
// Match immediate children of the parent element to allow nesting.
$(this.table)
.find('> tbody > tr.draggable, > tr.draggable')
.filter(':visible')
.filter(':odd')
.removeClass('odd')
.addClass('even')
.end()
.filter(':even')
.removeClass('even')
.addClass('odd');
},
/**
* Stub function. Allows a custom handler when a row begins dragging.
*
* @return {null}
* Returns null when the stub function is used.
*/
onDrag() {
return null;
},
/**
* Stub function. Allows a custom handler when a row is dropped.
*
* @return {null}
* Returns null when the stub function is used.
*/
onDrop() {
return null;
},
/**
* Constructor to make a new object to manipulate a table row.
*
* @param {HTMLElement} tableRow
* The DOM element for the table row we will be manipulating.
* @param {string} method
* The method in which this row is being moved. Either 'keyboard' or
* 'mouse'.
* @param {bool} indentEnabled
* Whether the containing table uses indentations. Used for optimizations.
* @param {number} maxDepth
* The maximum amount of indentations this row may contain.
* @param {bool} addClasses
* Whether we want to add classes to this row to indicate child
* relationships.
*/
row(tableRow, method, indentEnabled, maxDepth, addClasses) {
const $tableRow = $(tableRow);
this.element = tableRow;
this.method = method;
this.group = [tableRow];
this.groupDepth = $tableRow.find('.js-indentation').length;
this.changed = false;
this.table = $tableRow.closest('table')[0];
this.indentEnabled = indentEnabled;
this.maxDepth = maxDepth;
// Direction the row is being moved.
this.direction = '';
if (this.indentEnabled) {
this.indents = $tableRow.find('.js-indentation').length;
this.children = this.findChildren(addClasses);
this.group = $.merge(this.group, this.children);
// Find the depth of this entire group.
for (let n = 0; n < this.group.length; n++) {
this.groupDepth = Math.max(
$(this.group[n]).find('.js-indentation').length,
this.groupDepth,
);
}
}
},
},
);
$.extend(Drupal.tableDrag.prototype.row.prototype, /** @lends Drupal.tableDrag#.row# */{
/**
* Find all children of rowObject by indentation.
*
* @param {bool} addClasses
* Whether we want to add classes to this row to indicate child
* relationships.
*
* @return {Array}
* An array of children of the row.
*/
findChildren(addClasses) {
const parentIndentation = this.indents;
let currentRow = $(this.element, this.table).next('tr.draggable');
const rows = [];
let child = 0;
function rowIndentation(indentNum, el) {
const self = $(el);
if (child === 1 && indentNum === parentIndentation) {
self.addClass('tree-child-first');
}
if (indentNum === parentIndentation) {
self.addClass('tree-child');
} else if (indentNum > parentIndentation) {
self.addClass('tree-child-horizontal');
}
}
while (currentRow.length) {
// A greater indentation indicates this is a child.
if (currentRow.find('.js-indentation').length > parentIndentation) {
child += 1;
rows.push(currentRow[0]);
if (addClasses) {
currentRow.find('.js-indentation').each(rowIndentation);
}
} else {
break;
}
currentRow = currentRow.next('tr.draggable');
}
if (addClasses && rows.length) {
$(rows[rows.length - 1])
.find(`.js-indentation:nth-child(${parentIndentation + 1})`)
.addClass('tree-child-last');
}
return rows;
},
/**
* Ensure that two rows are allowed to be swapped.
*
* @param {HTMLElement} row
* DOM object for the row being considered for swapping.
*
* @return {bool}
* Whether the swap is a valid swap or not.
*/
isValidSwap(row) {
const $row = $(row);
if (this.indentEnabled) {
let prevRow;
let nextRow;
if (this.direction === 'down') {
prevRow = row;
nextRow = $row.next('tr').get(0);
} else {
prevRow = $row.prev('tr').get(0);
nextRow = row;
}
this.interval = this.validIndentInterval(prevRow, nextRow);
// We have an invalid swap if the valid indentations interval is empty.
if (this.interval.min > this.interval.max) {
return false;
}
}
// Do not let an un-draggable first row have anything put before it.
if (
this.table.tBodies[0].rows[0] === row &&
$row.is(':not(.draggable)')
) {
return false;
}
return true;
},
/**
* Perform the swap between two rows.
*
* @param {string} position
* Whether the swap will occur 'before' or 'after' the given row.
* @param {HTMLElement} row
* DOM element what will be swapped with the row group.
*/
swap(position, row) {
// Makes sure only DOM object are passed to Drupal.detachBehaviors().
this.group.forEach((detachedRow) => {
Drupal.detachBehaviors(detachedRow, drupalSettings, 'move');
});
$(row)[position](this.group);
// Makes sure only DOM object are passed to Drupal.attachBehaviors()s.
this.group.forEach((attachedRow) => {
Drupal.attachBehaviors(attachedRow, drupalSettings);
});
this.changed = true;
this.onSwap(row);
},
/**
* Determine the valid indentations interval for the row at a given position.
*
* @param {?HTMLElement} prevRow
* DOM object for the row before the tested position
* (or null for first position in the table).
* @param {?HTMLElement} nextRow
* DOM object for the row after the tested position
* (or null for last position in the table).
*
* @return {object}
* An object with the keys `min` and `max` to indicate the valid indent
* interval.
*/
validIndentInterval(prevRow, nextRow) {
const $prevRow = $(prevRow);
let maxIndent;
// Minimum indentation:
// Do not orphan the next row.
const minIndent = nextRow ? $(nextRow).find('.js-indentation').length : 0;
// Maximum indentation:
if (
!prevRow ||
$prevRow.is(':not(.draggable)') ||
$(this.element).is('.tabledrag-root')
) {
// Do not indent:
// - the first row in the table,
// - rows dragged below a non-draggable row,
// - 'root' rows.
maxIndent = 0;
} else {
// Do not go deeper than as a child of the previous row.
maxIndent =
$prevRow.find('.js-indentation').length +
($prevRow.is('.tabledrag-leaf') ? 0 : 1);
// Limit by the maximum allowed depth for the table.
if (this.maxDepth) {
maxIndent = Math.min(
maxIndent,
this.maxDepth - (this.groupDepth - this.indents),
);
}
}
return { min: minIndent, max: maxIndent };
},
/**
* Indent a row within the legal bounds of the table.
*
* @param {number} indentDiff
* The number of additional indentations proposed for the row (can be
* positive or negative). This number will be adjusted to nearest valid
* indentation level for the row.
*
* @return {number}
* The number of indentations applied.
*/
indent(indentDiff) {
const $group = $(this.group);
// Determine the valid indentations interval if not available yet.
if (!this.interval) {
const prevRow = $(this.element).prev('tr').get(0);
const nextRow = $group.eq(-1).next('tr').get(0);
this.interval = this.validIndentInterval(prevRow, nextRow);
}
// Adjust to the nearest valid indentation.
let indent = this.indents + indentDiff;
indent = Math.max(indent, this.interval.min);
indent = Math.min(indent, this.interval.max);
indentDiff = indent - this.indents;
for (let n = 1; n <= Math.abs(indentDiff); n++) {
// Add or remove indentations.
if (indentDiff < 0) {
$group.find('.js-indentation:first-of-type').remove();
this.indents -= 1;
} else {
$group
.find('.js-tabledrag-cell-content')
.prepend(Drupal.theme('tableDragIndentation'));
this.indents += 1;
}
}
if (indentDiff) {
// Update indentation for this row.
this.changed = true;
this.groupDepth += indentDiff;
this.onIndent();
}
return indentDiff;
},
/**
* Find all siblings for a row.
*
* According to its subgroup or indentation. Note that the passed-in row is
* included in the list of siblings.
*
* @param {object} rowSettings
* The field settings we're using to identify what constitutes a sibling.
*
* @return {Array}
* An array of siblings.
*/
findSiblings(rowSettings) {
const siblings = [];
const directions = ['prev', 'next'];
const rowIndentation = this.indents;
let checkRowIndentation;
for (let d = 0; d < directions.length; d++) {
let checkRow = $(this.element)[directions[d]]();
while (checkRow.length) {
// Check that the sibling contains a similar target field.
if (checkRow.find(`.${rowSettings.target}`)) {
// Either add immediately if this is a flat table, or check to
// ensure that this row has the same level of indentation.
if (this.indentEnabled) {
checkRowIndentation = checkRow.find('.js-indentation').length;
}
if (!this.indentEnabled || checkRowIndentation === rowIndentation) {
siblings.push(checkRow[0]);
} else if (checkRowIndentation < rowIndentation) {
// No need to keep looking for siblings when we get to a parent.
break;
}
} else {
break;
}
checkRow = checkRow[directions[d]]();
}
// Since siblings are added in reverse order for previous, reverse the
// completed list of previous siblings. Add the current row and
// continue.
if (directions[d] === 'prev') {
siblings.reverse();
siblings.push(this.element);
}
}
return siblings;
},
/**
* Remove indentation helper classes from the current row group.
*/
removeIndentClasses() {
Object.keys(this.children || {}).forEach((n) => {
$(this.children[n])
.find('.js-indentation')
.removeClass('tree-child')
.removeClass('tree-child-first')
.removeClass('tree-child-last')
.removeClass('tree-child-horizontal');
});
},
/**
* Add an asterisk or other marker to the changed row.
*/
markChanged() {
const marker = $(Drupal.theme('tableDragChangedMarker')).addClass(
'js-tabledrag-changed-marker',
);
const cell = $(this.element).find('td:first-of-type');
if (cell.find('.js-tabledrag-changed-marker').length === 0) {
cell.find('.js-tabledrag-handle').after(marker);
}
},
/**
* Stub function. Allows a custom handler when a row is indented.
*
* @return {null}
* Returns null when the stub function is used.
*/
onIndent() {
return null;
},
/**
* Stub function. Allows a custom handler when a row is swapped.
*
* @param {HTMLElement} swappedRow
* The element for the swapped row.
*
* @return {null}
* Returns null when the stub function is used.
*/
// eslint-disable-next-line no-unused-vars
onSwap(swappedRow) {
return null;
},
});
$.extend(
Drupal.theme,
/** @lends Drupal.theme */ {
/**
* @return {string}
* Markup for the marker.
*/
tableDragChangedMarker() {
return `<abbr class="warning tabledrag-changed" title="${Drupal.t(
'Changed',
)}">*</abbr>`;
},
/**
* @return {string}
* Markup for the indentation.
*/
tableDragIndentation() {
return '<div class="js-indentation indentation"><svg xmlns="http://www.w3.org/2000/svg" class="tree" width="25" height="25" viewBox="0 0 25 25"><path class="tree__item tree__item-child-ltr tree__item-child-last-ltr tree__item-horizontal tree__item-horizontal-right" d="M12,12.5 H25" stroke="#888"/><path class="tree__item tree__item-child-rtl tree__item-child-last-rtl tree__item-horizontal tree__horizontal-left" d="M0,12.5 H13" stroke="#888"/><path class="tree__item tree__item-child-ltr tree__item-child-rtl tree__item-child-last-ltr tree__item-child-last-rtl tree__vertical tree__vertical-top" d="M12.5,12 v-99" stroke="#888"/><path class="tree__item tree__item-child-ltr tree__item-child-rtl tree__vertical tree__vertical-bottom" d="M12.5,12 v99" stroke="#888"/></svg></div>';
},
/**
* @return {string}
* Markup for the warning.
*/
tableDragChangedWarning() {
return `<div class="tabledrag-changed-warning messages messages--warning" role="alert">${Drupal.theme(
'tableDragChangedMarker',
)} ${Drupal.t('You have unsaved changes.')}</div>`;
},
/**
* Constructs the table drag handle.
*
* @return {string}
* A string representing a DOM fragment.
*/
tableDragHandle() {
return '<a href="#" class="tabledrag-handle"></a>';
},
/**
* Constructs the wrapper for the whole table drag cell.
*
* @return {string}
* A string representing a DOM fragment.
*/
tableDragCellItemsWrapper() {
return '<div class="tabledrag-cell-content"></div>';
},
/**
* Constructs the wrapper for the initial content of the drag cell.
*
* @return {string}
* A string representing a DOM fragment.
*/
tableDragCellContentWrapper() {
return '<div class="tabledrag-cell-content__item"></div>';
},
/**
* Constructs the weight column toggle.
*
* The 'tabledrag-toggle-weight' CSS class should be kept since it is used
* elsewhere as well (e.g. in tests).
*
* @param {string} action
* The action the toggle will perform.
* @param {string} text
* The text content of the toggle.
*
* @return {string}
* A string representing a DOM fragment.
*/
tableDragToggle(action, text) {
const classes = [
'action-link',
'action-link--extrasmall',
'tabledrag-toggle-weight',
];
switch (action) {
case 'show':
classes.push('action-link--icon-show');
break;
default:
classes.push('action-link--icon-hide');
break;
}
return `<a href="#" class="${classes.join(' ')}">${text}</a>`;
},
/**
* Constructs the wrapper of the weight column toggle.
*
* The 'tabledrag-toggle-weight-wrapper' CSS class should be kept since it is used
* by Views UI and inside off-canvas dialogs.
*
* @return {string}
* A string representing a DOM fragment.
*/
tableDragToggleWrapper() {
return '<div class="tabledrag-toggle-weight-wrapper"></div>';
},
},
);
})(jQuery, Drupal, drupalSettings);