Chris@18: /** Chris@18: * @file Chris@18: * Attaches the behaviors for the Layout Builder module. Chris@18: */ Chris@18: Chris@18: (($, Drupal) => { Chris@18: const { ajax, behaviors, debounce, announce, formatPlural } = Drupal; Chris@18: Chris@18: /* Chris@18: * Boolean that tracks if block listing is currently being filtered. Declared Chris@18: * outside of behaviors so value is retained on rebuild. Chris@18: */ Chris@18: let layoutBuilderBlocksFiltered = false; Chris@18: Chris@18: /** Chris@18: * Provides the ability to filter the block listing in Add Block dialog. Chris@18: * Chris@18: * @type {Drupal~behavior} Chris@18: * Chris@18: * @prop {Drupal~behaviorAttach} attach Chris@18: * Attach block filtering behavior to Add Block dialog. Chris@18: */ Chris@18: behaviors.layoutBuilderBlockFilter = { Chris@18: attach(context) { Chris@18: const $categories = $('.js-layout-builder-categories', context); Chris@18: const $filterLinks = $categories.find('.js-layout-builder-block-link'); Chris@18: Chris@18: /** Chris@18: * Filters the block list. Chris@18: * Chris@18: * @param {jQuery.Event} e Chris@18: * The jQuery event for the keyup event that triggered the filter. Chris@18: */ Chris@18: const filterBlockList = e => { Chris@18: const query = $(e.target) Chris@18: .val() Chris@18: .toLowerCase(); Chris@18: Chris@18: /** Chris@18: * Shows or hides the block entry based on the query. Chris@18: * Chris@18: * @param {number} index Chris@18: * The index in the loop, as provided by `jQuery.each` Chris@18: * @param {HTMLElement} link Chris@18: * The link to add the block. Chris@18: */ Chris@18: const toggleBlockEntry = (index, link) => { Chris@18: const $link = $(link); Chris@18: const textMatch = Chris@18: $link Chris@18: .text() Chris@18: .toLowerCase() Chris@18: .indexOf(query) !== -1; Chris@18: $link.toggle(textMatch); Chris@18: }; Chris@18: Chris@18: // Filter if the length of the query is at least 2 characters. Chris@18: if (query.length >= 2) { Chris@18: // Attribute to note which categories are closed before opening all. Chris@18: $categories Chris@18: .find('.js-layout-builder-category:not([open])') Chris@18: .attr('remember-closed', ''); Chris@18: Chris@18: // Open all categories so every block is available to filtering. Chris@18: $categories.find('.js-layout-builder-category').attr('open', ''); Chris@18: // Toggle visibility of links based on query. Chris@18: $filterLinks.each(toggleBlockEntry); Chris@18: Chris@18: // Only display categories containing visible links. Chris@18: $categories Chris@18: .find( Chris@18: '.js-layout-builder-category:not(:has(.js-layout-builder-block-link:visible))', Chris@18: ) Chris@18: .hide(); Chris@18: Chris@18: announce( Chris@18: formatPlural( Chris@18: $categories.find('.js-layout-builder-block-link:visible').length, Chris@18: '1 block is available in the modified list.', Chris@18: '@count blocks are available in the modified list.', Chris@18: ), Chris@18: ); Chris@18: layoutBuilderBlocksFiltered = true; Chris@18: } else if (layoutBuilderBlocksFiltered) { Chris@18: layoutBuilderBlocksFiltered = false; Chris@18: // Remove "open" attr from categories that were closed pre-filtering. Chris@18: $categories Chris@18: .find('.js-layout-builder-category[remember-closed]') Chris@18: .removeAttr('open') Chris@18: .removeAttr('remember-closed'); Chris@18: $categories.find('.js-layout-builder-category').show(); Chris@18: $filterLinks.show(); Chris@18: announce(Drupal.t('All available blocks are listed.')); Chris@18: } Chris@18: }; Chris@18: Chris@18: $('input.js-layout-builder-filter', context) Chris@18: .once('block-filter-text') Chris@18: .on('keyup', debounce(filterBlockList, 200)); Chris@18: }, Chris@18: }; Chris@18: Chris@18: /** Chris@18: * Provides the ability to drag blocks to new positions in the layout. Chris@18: * Chris@18: * @type {Drupal~behavior} Chris@18: * Chris@18: * @prop {Drupal~behaviorAttach} attach Chris@18: * Attach block drag behavior to the Layout Builder UI. Chris@18: */ Chris@18: behaviors.layoutBuilderBlockDrag = { Chris@14: attach(context) { Chris@17: $(context) Chris@18: .find('.js-layout-builder-region') Chris@17: .sortable({ Chris@18: items: '> .js-layout-builder-block', Chris@18: connectWith: '.js-layout-builder-region', Chris@17: placeholder: 'ui-state-drop', Chris@14: Chris@17: /** Chris@17: * Updates the layout with the new position of the block. Chris@17: * Chris@17: * @param {jQuery.Event} event Chris@17: * The jQuery Event object. Chris@17: * @param {Object} ui Chris@17: * An object containing information about the item being sorted. Chris@17: */ Chris@17: update(event, ui) { Chris@17: // Check if the region from the event and region for the item match. Chris@18: const itemRegion = ui.item.closest('.js-layout-builder-region'); Chris@17: if (event.target === itemRegion[0]) { Chris@17: // Find the destination delta. Chris@17: const deltaTo = ui.item Chris@17: .closest('[data-layout-delta]') Chris@17: .data('layout-delta'); Chris@17: // If the block didn't leave the original delta use the destination. Chris@17: const deltaFrom = ui.sender Chris@17: ? ui.sender.closest('[data-layout-delta]').data('layout-delta') Chris@17: : deltaTo; Chris@17: ajax({ Chris@17: url: [ Chris@17: ui.item Chris@17: .closest('[data-layout-update-url]') Chris@17: .data('layout-update-url'), Chris@17: deltaFrom, Chris@17: deltaTo, Chris@17: itemRegion.data('region'), Chris@17: ui.item.data('layout-block-uuid'), Chris@17: ui.item Chris@17: .prev('[data-layout-block-uuid]') Chris@17: .data('layout-block-uuid'), Chris@17: ] Chris@17: .filter(element => element !== undefined) Chris@17: .join('/'), Chris@17: }).execute(); Chris@17: } Chris@17: }, Chris@17: }); Chris@14: }, Chris@14: }; Chris@18: Chris@18: /** Chris@18: * Disables interactive elements in previewed blocks. Chris@18: * Chris@18: * @type {Drupal~behavior} Chris@18: * Chris@18: * @prop {Drupal~behaviorAttach} attach Chris@18: * Attach disabling interactive elements behavior to the Layout Builder UI. Chris@18: */ Chris@18: behaviors.layoutBuilderDisableInteractiveElements = { Chris@18: attach() { Chris@18: // Disable interactive elements inside preview blocks. Chris@18: const $blocks = $('#layout-builder [data-layout-block-uuid]'); Chris@18: $blocks.find('input, textarea, select').prop('disabled', true); Chris@18: $blocks Chris@18: .find('a') Chris@18: // Don't disable contextual links. Chris@18: // @see \Drupal\contextual\Element\ContextualLinksPlaceholder Chris@18: .not( Chris@18: (index, element) => Chris@18: $(element).closest('[data-contextual-id]').length > 0, Chris@18: ) Chris@18: .on('click mouseup touchstart', e => { Chris@18: e.preventDefault(); Chris@18: e.stopPropagation(); Chris@18: }); Chris@18: Chris@18: /* Chris@18: * In preview blocks, remove from the tabbing order all input elements Chris@18: * and elements specifically assigned a tab index, other than those Chris@18: * related to contextual links. Chris@18: */ Chris@18: $blocks Chris@18: .find( Chris@18: 'button, [href], input, select, textarea, iframe, [tabindex]:not([tabindex="-1"]):not(.tabbable)', Chris@18: ) Chris@18: .not( Chris@18: (index, element) => Chris@18: $(element).closest('[data-contextual-id]').length > 0, Chris@18: ) Chris@18: .attr('tabindex', -1); Chris@18: }, Chris@18: }; Chris@18: Chris@18: // After a dialog opens, highlight element that the dialog is acting on. Chris@18: $(window).on('dialog:aftercreate', (event, dialog, $element) => { Chris@18: if (Drupal.offCanvas.isOffCanvas($element)) { Chris@18: // Start by removing any existing highlighted elements. Chris@18: $('.is-layout-builder-highlighted').removeClass( Chris@18: 'is-layout-builder-highlighted', Chris@18: ); Chris@18: Chris@18: /* Chris@18: * Every dialog has a single 'data-layout-builder-target-highlight-id' Chris@18: * attribute. Every dialog-opening element has a unique Chris@18: * 'data-layout-builder-highlight-id' attribute. Chris@18: * Chris@18: * When the value of data-layout-builder-target-highlight-id matches Chris@18: * an element's value of data-layout-builder-highlight-id, the class Chris@18: * 'is-layout-builder-highlighted' is added to element. Chris@18: */ Chris@18: const id = $element Chris@18: .find('[data-layout-builder-target-highlight-id]') Chris@18: .attr('data-layout-builder-target-highlight-id'); Chris@18: if (id) { Chris@18: $(`[data-layout-builder-highlight-id="${id}"]`).addClass( Chris@18: 'is-layout-builder-highlighted', Chris@18: ); Chris@18: } Chris@18: Chris@18: // Remove wrapper class added by move block form. Chris@18: $('#layout-builder').removeClass('layout-builder--move-blocks-active'); Chris@18: Chris@18: /** Chris@18: * If dialog has a data-add-layout-builder-wrapper attribute, get the Chris@18: * value and add it as a class to the Layout Builder UI wrapper. Chris@18: * Chris@18: * Currently, only the move block form uses Chris@18: * data-add-layout-builder-wrapper, but any dialog can use this attribute Chris@18: * to add a class to the Layout Builder UI while opened. Chris@18: */ Chris@18: const layoutBuilderWrapperValue = $element Chris@18: .find('[data-add-layout-builder-wrapper]') Chris@18: .attr('data-add-layout-builder-wrapper'); Chris@18: if (layoutBuilderWrapperValue) { Chris@18: $('#layout-builder').addClass(layoutBuilderWrapperValue); Chris@18: } Chris@18: } Chris@18: }); Chris@18: Chris@18: /* Chris@18: * When a Layout Builder dialog is triggered, the main canvas resizes. After Chris@18: * the resize transition is complete, see if the target element is still Chris@18: * visible in viewport. If not, scroll page so the target element is again Chris@18: * visible. Chris@18: * Chris@18: * @todo Replace this custom solution when a general solution is made Chris@18: * available with https://www.drupal.org/node/3033410 Chris@18: */ Chris@18: if (document.querySelector('[data-off-canvas-main-canvas]')) { Chris@18: const mainCanvas = document.querySelector('[data-off-canvas-main-canvas]'); Chris@18: Chris@18: // This event fires when canvas CSS transitions are complete. Chris@18: mainCanvas.addEventListener('transitionend', () => { Chris@18: const $target = $('.is-layout-builder-highlighted'); Chris@18: Chris@18: if ($target.length > 0) { Chris@18: // These four variables are used to determine if the element is in the Chris@18: // viewport. Chris@18: const targetTop = $target.offset().top; Chris@18: const targetBottom = targetTop + $target.outerHeight(); Chris@18: const viewportTop = $(window).scrollTop(); Chris@18: const viewportBottom = viewportTop + $(window).height(); Chris@18: Chris@18: // If the element is not in the viewport, scroll it into view. Chris@18: if (targetBottom < viewportTop || targetTop > viewportBottom) { Chris@18: const viewportMiddle = (viewportBottom + viewportTop) / 2; Chris@18: const scrollAmount = targetTop - viewportMiddle; Chris@18: Chris@18: // Check whether the browser supports scrollBy(options). If it does Chris@18: // not, use scrollBy(x-coord, y-coord) instead. Chris@18: if ('scrollBehavior' in document.documentElement.style) { Chris@18: window.scrollBy({ Chris@18: top: scrollAmount, Chris@18: left: 0, Chris@18: behavior: 'smooth', Chris@18: }); Chris@18: } else { Chris@18: window.scrollBy(0, scrollAmount); Chris@18: } Chris@18: } Chris@18: } Chris@18: }); Chris@18: } Chris@18: Chris@18: $(window).on('dialog:afterclose', (event, dialog, $element) => { Chris@18: if (Drupal.offCanvas.isOffCanvas($element)) { Chris@18: // Remove the highlight from all elements. Chris@18: $('.is-layout-builder-highlighted').removeClass( Chris@18: 'is-layout-builder-highlighted', Chris@18: ); Chris@18: Chris@18: // Remove wrapper class added by move block form. Chris@18: $('#layout-builder').removeClass('layout-builder--move-blocks-active'); Chris@18: } Chris@18: }); Chris@18: Chris@18: /** Chris@18: * Toggles content preview in the Layout Builder UI. Chris@18: * Chris@18: * @type {Drupal~behavior} Chris@18: * Chris@18: * @prop {Drupal~behaviorAttach} attach Chris@18: * Attach content preview toggle to the Layout Builder UI. Chris@18: */ Chris@18: behaviors.layoutBuilderToggleContentPreview = { Chris@18: attach(context) { Chris@18: const $layoutBuilder = $('#layout-builder'); Chris@18: Chris@18: // The content preview toggle. Chris@18: const $layoutBuilderContentPreview = $('#layout-builder-content-preview'); Chris@18: Chris@18: // data-content-preview-id specifies the layout being edited. Chris@18: const contentPreviewId = $layoutBuilderContentPreview.data( Chris@18: 'content-preview-id', Chris@18: ); Chris@18: Chris@18: /** Chris@18: * Tracks if content preview is enabled for this layout. Defaults to true Chris@18: * if no value has previously been set. Chris@18: */ Chris@18: const isContentPreview = Chris@18: JSON.parse(localStorage.getItem(contentPreviewId)) !== false; Chris@18: Chris@18: /** Chris@18: * Disables content preview in the Layout Builder UI. Chris@18: * Chris@18: * Disabling content preview hides block content. It is replaced with the Chris@18: * value of the block's data-layout-content-preview-placeholder-label Chris@18: * attribute. Chris@18: * Chris@18: * @todo Revisit in https://www.drupal.org/node/3043215, it may be Chris@18: * possible to remove all but the first line of this function. Chris@18: */ Chris@18: const disableContentPreview = () => { Chris@18: $layoutBuilder.addClass('layout-builder--content-preview-disabled'); Chris@18: Chris@18: /** Chris@18: * Iterate over all Layout Builder blocks to hide their content and add Chris@18: * placeholder labels. Chris@18: */ Chris@18: $('[data-layout-content-preview-placeholder-label]', context).each( Chris@18: (i, element) => { Chris@18: const $element = $(element); Chris@18: Chris@18: // Hide everything in block that isn't contextual link related. Chris@18: $element.children(':not([data-contextual-id])').hide(0); Chris@18: Chris@18: const contentPreviewPlaceholderText = $element.attr( Chris@18: 'data-layout-content-preview-placeholder-label', Chris@18: ); Chris@18: Chris@18: const contentPreviewPlaceholderLabel = Drupal.theme( Chris@18: 'layoutBuilderPrependContentPreviewPlaceholderLabel', Chris@18: contentPreviewPlaceholderText, Chris@18: ); Chris@18: $element.prepend(contentPreviewPlaceholderLabel); Chris@18: }, Chris@18: ); Chris@18: }; Chris@18: Chris@18: /** Chris@18: * Enables content preview in the Layout Builder UI. Chris@18: * Chris@18: * When content preview is enabled, the Layout Builder UI returns to its Chris@18: * default experience. This is accomplished by removing placeholder Chris@18: * labels and un-hiding block content. Chris@18: * Chris@18: * @todo Revisit in https://www.drupal.org/node/3043215, it may be Chris@18: * possible to remove all but the first line of this function. Chris@18: */ Chris@18: const enableContentPreview = () => { Chris@18: $layoutBuilder.removeClass('layout-builder--content-preview-disabled'); Chris@18: Chris@18: // Remove all placeholder labels. Chris@18: $('.js-layout-builder-content-preview-placeholder-label').remove(); Chris@18: Chris@18: // Iterate over all blocks. Chris@18: $('[data-layout-content-preview-placeholder-label]').each( Chris@18: (i, element) => { Chris@18: $(element) Chris@18: .children() Chris@18: .show(); Chris@18: }, Chris@18: ); Chris@18: }; Chris@18: Chris@18: $('#layout-builder-content-preview', context).on('change', event => { Chris@18: const isChecked = $(event.currentTarget).is(':checked'); Chris@18: Chris@18: localStorage.setItem(contentPreviewId, JSON.stringify(isChecked)); Chris@18: Chris@18: if (isChecked) { Chris@18: enableContentPreview(); Chris@18: announce( Chris@18: Drupal.t('Block previews are visible. Block labels are hidden.'), Chris@18: ); Chris@18: } else { Chris@18: disableContentPreview(); Chris@18: announce( Chris@18: Drupal.t('Block previews are hidden. Block labels are visible.'), Chris@18: ); Chris@18: } Chris@18: }); Chris@18: Chris@18: /** Chris@18: * On rebuild, see if content preview has been set to disabled. If yes, Chris@18: * disable content preview in the Layout Builder UI. Chris@18: */ Chris@18: if (!isContentPreview) { Chris@18: $layoutBuilderContentPreview.attr('checked', false); Chris@18: disableContentPreview(); Chris@18: } Chris@18: }, Chris@18: }; Chris@18: Chris@18: /** Chris@18: * Creates content preview placeholder label markup. Chris@18: * Chris@18: * @param {string} contentPreviewPlaceholderText Chris@18: * The text content of the placeholder label Chris@18: * Chris@18: * @return {string} Chris@18: * A HTML string of the placeholder label. Chris@18: */ Chris@18: Drupal.theme.layoutBuilderPrependContentPreviewPlaceholderLabel = contentPreviewPlaceholderText => { Chris@18: const contentPreviewPlaceholderLabel = document.createElement('div'); Chris@18: contentPreviewPlaceholderLabel.className = Chris@18: 'layout-builder-block__content-preview-placeholder-label js-layout-builder-content-preview-placeholder-label'; Chris@18: contentPreviewPlaceholderLabel.innerHTML = contentPreviewPlaceholderText; Chris@18: Chris@18: return `