Chris@0: /** Chris@0: * @file Chris@0: * Attaches behaviors for the Contextual module. Chris@0: */ Chris@0: Chris@17: (function($, Drupal, drupalSettings, _, Backbone, JSON, storage) { Chris@17: const options = $.extend( Chris@17: drupalSettings.contextual, Chris@0: // Merge strings on top of drupalSettings so that they are not mutable. Chris@0: { Chris@0: strings: { Chris@0: open: Drupal.t('Open'), Chris@0: close: Drupal.t('Close'), Chris@0: }, Chris@0: }, Chris@0: ); Chris@0: Chris@0: // Clear the cached contextual links whenever the current user's set of Chris@0: // permissions changes. Chris@17: const cachedPermissionsHash = storage.getItem( Chris@17: 'Drupal.contextual.permissionsHash', Chris@17: ); Chris@0: const permissionsHash = drupalSettings.user.permissionsHash; Chris@0: if (cachedPermissionsHash !== permissionsHash) { Chris@0: if (typeof permissionsHash === 'string') { Chris@17: _.chain(storage) Chris@17: .keys() Chris@17: .each(key => { Chris@17: if (key.substring(0, 18) === 'Drupal.contextual.') { Chris@17: storage.removeItem(key); Chris@17: } Chris@17: }); Chris@0: } Chris@0: storage.setItem('Drupal.contextual.permissionsHash', permissionsHash); Chris@0: } Chris@0: Chris@0: /** Chris@0: * Determines if a contextual link is nested & overlapping, if so: adjusts it. Chris@0: * Chris@0: * This only deals with two levels of nesting; deeper levels are not touched. Chris@0: * Chris@0: * @param {jQuery} $contextual Chris@0: * A contextual links placeholder DOM element, containing the actual Chris@0: * contextual links as rendered by the server. Chris@0: */ Chris@0: function adjustIfNestedAndOverlapping($contextual) { Chris@0: const $contextuals = $contextual Chris@0: // @todo confirm that .closest() is not sufficient Chris@17: .parents('.contextual-region') Chris@17: .eq(-1) Chris@0: .find('.contextual'); Chris@0: Chris@0: // Early-return when there's no nesting. Chris@0: if ($contextuals.length <= 1) { Chris@0: return; Chris@0: } Chris@0: Chris@0: // If the two contextual links overlap, then we move the second one. Chris@0: const firstTop = $contextuals.eq(0).offset().top; Chris@0: const secondTop = $contextuals.eq(1).offset().top; Chris@0: if (firstTop === secondTop) { Chris@0: const $nestedContextual = $contextuals.eq(1); Chris@0: Chris@0: // Retrieve height of nested contextual link. Chris@0: let height = 0; Chris@0: const $trigger = $nestedContextual.find('.trigger'); Chris@0: // Elements with the .visually-hidden class have no dimensions, so this Chris@0: // class must be temporarily removed to the calculate the height. Chris@0: $trigger.removeClass('visually-hidden'); Chris@0: height = $nestedContextual.height(); Chris@0: $trigger.addClass('visually-hidden'); Chris@0: Chris@0: // Adjust nested contextual link's position. Chris@0: $nestedContextual.css({ top: $nestedContextual.position().top + height }); Chris@0: } Chris@0: } Chris@0: Chris@0: /** Chris@17: * Initializes a contextual link: updates its DOM, sets up model and views. Chris@17: * Chris@17: * @param {jQuery} $contextual Chris@17: * A contextual links placeholder DOM element, containing the actual Chris@17: * contextual links as rendered by the server. Chris@17: * @param {string} html Chris@17: * The server-side rendered HTML for this contextual link. Chris@17: */ Chris@17: function initContextual($contextual, html) { Chris@17: const $region = $contextual.closest('.contextual-region'); Chris@17: const contextual = Drupal.contextual; Chris@17: Chris@17: $contextual Chris@17: // Update the placeholder to contain its rendered contextual links. Chris@17: .html(html) Chris@17: // Use the placeholder as a wrapper with a specific class to provide Chris@17: // positioning and behavior attachment context. Chris@17: .addClass('contextual') Chris@17: // Ensure a trigger element exists before the actual contextual links. Chris@17: .prepend(Drupal.theme('contextualTrigger')); Chris@17: Chris@17: // Set the destination parameter on each of the contextual links. Chris@17: const destination = `destination=${Drupal.encodePath( Chris@17: Drupal.url(drupalSettings.path.currentPath), Chris@17: )}`; Chris@17: $contextual.find('.contextual-links a').each(function() { Chris@17: const url = this.getAttribute('href'); Chris@17: const glue = url.indexOf('?') === -1 ? '?' : '&'; Chris@17: this.setAttribute('href', url + glue + destination); Chris@17: }); Chris@17: Chris@17: // Create a model and the appropriate views. Chris@17: const model = new contextual.StateModel({ Chris@17: title: $region Chris@17: .find('h2') Chris@17: .eq(0) Chris@17: .text() Chris@17: .trim(), Chris@17: }); Chris@17: const viewOptions = $.extend({ el: $contextual, model }, options); Chris@17: contextual.views.push({ Chris@17: visual: new contextual.VisualView(viewOptions), Chris@17: aural: new contextual.AuralView(viewOptions), Chris@17: keyboard: new contextual.KeyboardView(viewOptions), Chris@17: }); Chris@17: contextual.regionViews.push( Chris@17: new contextual.RegionView($.extend({ el: $region, model }, options)), Chris@17: ); Chris@17: Chris@17: // Add the model to the collection. This must happen after the views have Chris@17: // been associated with it, otherwise collection change event handlers can't Chris@17: // trigger the model change event handler in its views. Chris@17: contextual.collection.add(model); Chris@17: Chris@17: // Let other JavaScript react to the adding of a new contextual link. Chris@17: $(document).trigger('drupalContextualLinkAdded', { Chris@17: $el: $contextual, Chris@17: $region, Chris@17: model, Chris@17: }); Chris@17: Chris@17: // Fix visual collisions between contextual link triggers. Chris@17: adjustIfNestedAndOverlapping($contextual); Chris@17: } Chris@17: Chris@17: /** Chris@0: * Attaches outline behavior for regions associated with contextual links. Chris@0: * Chris@0: * Events Chris@0: * Contextual triggers an event that can be used by other scripts. Chris@0: * - drupalContextualLinkAdded: Triggered when a contextual link is added. Chris@0: * Chris@0: * @type {Drupal~behavior} Chris@0: * Chris@0: * @prop {Drupal~behaviorAttach} attach Chris@0: * Attaches the outline behavior to the right context. Chris@0: */ Chris@0: Drupal.behaviors.contextual = { Chris@0: attach(context) { Chris@0: const $context = $(context); Chris@0: Chris@0: // Find all contextual links placeholders, if any. Chris@17: let $placeholders = $context Chris@17: .find('[data-contextual-id]') Chris@17: .once('contextual-render'); Chris@0: if ($placeholders.length === 0) { Chris@0: return; Chris@0: } Chris@0: Chris@0: // Collect the IDs for all contextual links placeholders. Chris@0: const ids = []; Chris@17: $placeholders.each(function() { Chris@17: ids.push({ Chris@17: id: $(this).attr('data-contextual-id'), Chris@17: token: $(this).attr('data-contextual-token'), Chris@17: }); Chris@0: }); Chris@0: Chris@17: const uncachedIDs = []; Chris@17: const uncachedTokens = []; Chris@17: ids.forEach(contextualID => { Chris@17: const html = storage.getItem(`Drupal.contextual.${contextualID.id}`); Chris@0: if (html && html.length) { Chris@0: // Initialize after the current execution cycle, to make the AJAX Chris@0: // request for retrieving the uncached contextual links as soon as Chris@0: // possible, but also to ensure that other Drupal behaviors have had Chris@0: // the chance to set up an event listener on the Backbone collection Chris@0: // Drupal.contextual.collection. Chris@0: window.setTimeout(() => { Chris@17: initContextual( Chris@17: $context.find(`[data-contextual-id="${contextualID.id}"]`), Chris@17: html, Chris@17: ); Chris@0: }); Chris@17: return; Chris@0: } Chris@17: uncachedIDs.push(contextualID.id); Chris@17: uncachedTokens.push(contextualID.token); Chris@0: }); Chris@0: Chris@0: // Perform an AJAX request to let the server render the contextual links Chris@0: // for each of the placeholders. Chris@0: if (uncachedIDs.length > 0) { Chris@0: $.ajax({ Chris@0: url: Drupal.url('contextual/render'), Chris@0: type: 'POST', Chris@17: data: { 'ids[]': uncachedIDs, 'tokens[]': uncachedTokens }, Chris@0: dataType: 'json', Chris@0: success(results) { Chris@0: _.each(results, (html, contextualID) => { Chris@0: // Store the metadata. Chris@0: storage.setItem(`Drupal.contextual.${contextualID}`, html); Chris@0: // If the rendered contextual links are empty, then the current Chris@0: // user does not have permission to access the associated links: Chris@0: // don't render anything. Chris@0: if (html.length > 0) { Chris@0: // Update the placeholders to contain its rendered contextual Chris@0: // links. Usually there will only be one placeholder, but it's Chris@0: // possible for multiple identical placeholders exist on the Chris@0: // page (probably because the same content appears more than Chris@0: // once). Chris@17: $placeholders = $context.find( Chris@17: `[data-contextual-id="${contextualID}"]`, Chris@17: ); Chris@0: Chris@0: // Initialize the contextual links. Chris@0: for (let i = 0; i < $placeholders.length; i++) { Chris@0: initContextual($placeholders.eq(i), html); Chris@0: } Chris@0: } Chris@0: }); Chris@0: }, Chris@0: }); Chris@0: } Chris@0: }, Chris@0: }; Chris@0: Chris@0: /** Chris@0: * Namespace for contextual related functionality. Chris@0: * Chris@0: * @namespace Chris@0: */ Chris@0: Drupal.contextual = { Chris@0: /** Chris@0: * The {@link Drupal.contextual.View} instances associated with each list Chris@0: * element of contextual links. Chris@0: * Chris@0: * @type {Array} Chris@0: */ Chris@0: views: [], Chris@0: Chris@0: /** Chris@0: * The {@link Drupal.contextual.RegionView} instances associated with each Chris@0: * contextual region element. Chris@0: * Chris@0: * @type {Array} Chris@0: */ Chris@0: regionViews: [], Chris@0: }; Chris@0: Chris@0: /** Chris@0: * A Backbone.Collection of {@link Drupal.contextual.StateModel} instances. Chris@0: * Chris@0: * @type {Backbone.Collection} Chris@0: */ Chris@17: Drupal.contextual.collection = new Backbone.Collection([], { Chris@17: model: Drupal.contextual.StateModel, Chris@17: }); Chris@0: Chris@0: /** Chris@0: * A trigger is an interactive element often bound to a click handler. Chris@0: * Chris@0: * @return {string} Chris@0: * A string representing a DOM fragment. Chris@0: */ Chris@17: Drupal.theme.contextualTrigger = function() { Chris@0: return ''; Chris@0: }; Chris@14: Chris@14: /** Chris@14: * Bind Ajax contextual links when added. Chris@14: * Chris@14: * @param {jQuery.Event} event Chris@14: * The `drupalContextualLinkAdded` event. Chris@14: * @param {object} data Chris@14: * An object containing the data relevant to the event. Chris@14: * Chris@14: * @listens event:drupalContextualLinkAdded Chris@14: */ Chris@14: $(document).on('drupalContextualLinkAdded', (event, data) => { Chris@14: Drupal.ajax.bindAjaxLinks(data.$el[0]); Chris@14: }); Chris@17: })( Chris@17: jQuery, Chris@17: Drupal, Chris@17: drupalSettings, Chris@17: _, Chris@17: Backbone, Chris@17: window.JSON, Chris@17: window.sessionStorage, Chris@17: );