display_builder-1.0.x-dev/components/contextual_menu/contextual_menu.js

components/contextual_menu/contextual_menu.js
/**
 * @file
 * Contextual menu for Display Builder.
 *
 * @todo missing some aria-expanded attribute?
 */
/* cspell:ignore uidom */
/* eslint no-use-before-define: 0 */
/* eslint camelcase: 0 */
/* eslint no-unused-expressions: 0 */
/* eslint no-unused-vars: 0 */
((Drupal, once, { computePosition, offset, shift, flip }) => {
  /**
   * Initialize display builder contextual menu.
   *
   * @type {Drupal~behavior}
   *
   * @prop {Drupal~behaviorAttach} attach
   *   Attaches the behaviors for display builder contextual menu functionality.
   */
  Drupal.behaviors.displayBuilderContextualMenu = {
    attach(context) {
      once('dbContextualMenu', '.db-display-builder', context).forEach(
        (builder) => {
          alterHtmxEvents(builder);
        },
      );
      // Enable context menu only on IslandType::View.
      once('dbIslandContextualMenu', '.db-island-view', context).forEach(
        (island) => {
          const menu = document.querySelector('.db-context-menu');
          if (!menu) return;
          initContextMenu(menu.dataset.dbId, menu, island);
        },
      );
    },
  };

  /**
   * Sets up event listeners for HTMX requests on a builder element.
   *
   * @param {HTMLElement} builder - The builder element to attach events to
   * @listens htmx:configRequest
   * @listens htmx:afterRequest
   */
  function alterHtmxEvents(builder) {
    builder.addEventListener('htmx:configRequest', (event) => {
      // Handle context menu path, a data attribute allow to find only the
      // contextual menu.
      // The menu is built with a placeholder for the instance id and slot_id as
      // we do not know yet what will be clicked. Slot id can be null, not
      // instance id. They are set in the handleContextMenu().
      // @see src/Plugin/display_builder/Island/Menu.php
      if (!event.target.dataset?.contextualMenu) return;

      let instanceId = event.target.dataset?.instanceId;
      if (!instanceId) return;

      let _parent_id = '__none__';
      let _slot_id = '__none__';
      let _slot_position = 0;

      if (event.target.dataset.contextualAction === 'paste') {
        _parent_id = event.target.dataset.slotInstanceId;
        instanceId = event.target.dataset.copyInstanceId;
        _slot_id = event.target.dataset.slotId;
        _slot_position = event.target.dataset?.slotPosition ?? 0;
      }

      if (event.target.dataset.contextualAction === 'duplicate') {
        _parent_id = event.target.dataset.slotInstanceId;
        _slot_id = event.target.dataset?.slotId;
        // On duplicate, slot position is 0 to ease.
        // @todo move to last position?
        _slot_position = event.target.dataset?.slotPosition ?? 0;
      }

      event.detail.path = event.detail.path.replace(
        '__instance_id__',
        instanceId,
      );
      event.detail.path = event.detail.path.replace(
        '__parent_id__',
        _parent_id,
      );
      event.detail.path = event.detail.path.replace('__slot_id__', _slot_id);
      event.detail.path = event.detail.path.replace(
        '__slot_position__',
        _slot_position,
      );
    });

    builder.addEventListener('htmx:afterRequest', (event) => {
      // Handle contextual menu hiding after click.
      if (!event.target.dataset?.contextualMenu) return;
      const menu = event.target.closest('sl-menu');
      if (!menu) return;
      menu.style.display = 'none';
    });
  }

  /**
   * Init the context menu for a island.
   *
   * @param {string} builderId - The builder id.
   * @param {HTMLElement} menu - The menu element.
   * @param {HTMLElement} island - The island element.
   */
  function initContextMenu(builderId, menu, island) {
    island.addEventListener('contextmenu', (event) => {
      event.preventDefault();
      handleContextMenu(builderId, event, menu);
    });
    setupGlobalClickHandler(menu);
  }

  /**
   * Handle the context menu event.
   *
   * @param {string} builderId - The builder id.
   * @param {MouseEvent} event - The context menu event.
   * @param {HTMLElement} menu - The context menu element.
   */
  function handleContextMenu(builderId, event, menu) {
    // Not on an instance, hide the menu.
    const instance = getInstance(event.target);
    if (!instance.id) {
      menu.style.display = 'none';
      return;
    }

    setupMenuSelectHandler(builderId, menu);

    const slotData = getSlotData(event.target, instance);
    setMenuLabel(menu, gePathLabel(event.target));

    const copyInstance = Drupal.displayBuilder.LocalStorageManager.get(
      builderId,
      'copy',
      { id: null, title: null },
    );
    updateMenuItems(menu, instance, slotData, copyInstance);

    updateMenuPosition(menu, event.clientX, event.clientY);

    // insertDebugInfo(menu, instance, slotData, copyInstance);
  }

  /**
   * Inserts a <pre><code> element with the debug information before the closing </sl-menu>.
   *
   * @param {HTMLElement} menu - The context menu element.
   * @param {Object} instance - The instance object.
   * @param {Object} slotData - The slot object.
   * @param {Object} copyInstance - The copy instance object.
   */
  function insertDebugInfo(menu, instance, slotData, copyInstance) {
    // Check if the debug element already exists and remove it to avoid duplication.
    const existingDebug = menu.querySelector('pre.debug-info');
    if (existingDebug) {
      existingDebug.remove();
    }

    // Create the <pre><code> element.
    const pre = document.createElement('pre');
    pre.classList.add('debug-info');
    const code = document.createElement('code');

    // Add debug information.
    let debugData = `<strong>Instance:</strong>${JSON.stringify(instance, null, 2)} `;
    if (slotData?.id) {
      debugData += `Slot Data:</strong>${JSON.stringify(slotData, null, 2)}`;
    }
    if (copyInstance?.id || copyInstance?.title) {
      debugData += `<strong>Copy Instance:</strong>${JSON.stringify(copyInstance, null, 2)}`;
    }
    code.innerHTML = debugData;

    pre.appendChild(code);

    // Insert the <pre><code> element before the closing </sl-menu>.
    menu.appendChild(pre);
  }

  /**
   * Update the position of the context menu.
   *
   * @param {HTMLElement} menu - The context menu element.
   * @param {number} clientX - The X-coordinate of the click event.
   * @param {number} clientY - The Y-coordinate of the click event.
   */
  function updateMenuPosition(menu, clientX, clientY) {
    menu.style.display = 'block';
    const virtualEl = {
      getBoundingClientRect() {
        return {
          width: 0,
          height: 0,
          x: clientX,
          y: clientY,
          top: clientY,
          left: clientX,
          right: clientX,
          bottom: clientY,
        };
      },
    };
    computePosition(virtualEl, menu, {
      middleware: [
        offset({ mainAxis: 5, alignmentAxis: 4 }),
        flip({
          fallbackPlacements: ['left-start'],
        }),
        shift({ padding: 10 }),
      ],
      placement: 'right-start',
    }).then(({ x, y }) => {
      Object.assign(menu.style, {
        left: `${x}px`,
        top: `${y}px`,
      });
    });
  }

  /**
   * Try to generate the current instance path name for menu label.
   *
   * @param {HTMLElement} target - The DOM element to extract instance data from.
   * @return {string} - The path.
   */
  function gePathLabel(target) {
    const name = [];

    const currentInstance = target.closest('[data-instance-id]');
    const parentId =
      currentInstance.closest('[data-slot-title]')?.dataset?.instanceId;

    if (parentId) {
      const parentInstanceName = currentInstance.closest(
        `[data-instance-title][data-instance-id="${parentId}"]`,
      )?.dataset?.instanceTitle;
      name.push(formatName(parentInstanceName));
    } else {
      name.push(Drupal.t('Base'));
    }

    const slotTitle =
      currentInstance.closest('[data-slot-title]')?.dataset?.slotTitle;
    if (slotTitle) {
      name.push(slotTitle);
    }

    const instanceTitle = currentInstance.dataset?.instanceTitle;
    if (instanceTitle) {
      name.push(formatName(instanceTitle));
    }

    const slotPosition = currentInstance.dataset?.slotPosition;
    if (slotPosition) {
      name.push(parseInt(slotPosition, 10) + 1);
    }

    return name.join(' / ');
  }

  /**
   * Formats a given string by replacing underscores with spaces and capitalizing
   * the first letter of the string.
   *
   * @param {string} name - The string to format.
   * @return {string} The formatted string with the first letter capitalized and
   * underscores replaced by spaces. Returns an empty string if the input is falsy.
   */
  function formatName(name) {
    name = name.replace('_', ' ');
    return name ? name[0].toUpperCase() + name.slice(1) : '';
  }

  /**
   * Get the instance ID from the target element or its closest ancestor.
   *
   * @param {HTMLElement} target - The DOM element to extract instance data from.
   * @return {Object} An object containing the instance data:
   *   - `id` {string|null}: The value of the `data-instance-id` attribute, or `null` if not found.
   *   - `title` {string|null}: The value of the `data-instance-title` attribute of the element, or `null` if not found.
   */
  function getInstance(target) {
    let isChild = false;
    let title = '';
    if (target?.dataset?.instanceId) {
      if (!target.dataset.instanceTitle) {
        title = target.closest('[data-instance-title]')?.dataset?.instanceTitle;
        isChild = true;
      } else {
        title = target.dataset.instanceTitle;
      }

      const parent = target.closest(
        `[data-instance-id]:not([data-instance-id="${target.dataset.instanceId}"])`,
      );

      return {
        id: target.dataset.instanceId,
        position: target?.dataset?.slotPosition ?? 1,
        title,
        parentId: parent?.dataset?.instanceId ?? null,
        parentTitle: parent?.dataset?.instanceTitle ?? null,
        isChild,
      };
    }

    // Look for parent only.
    // @todo exclude itself?
    const parent = target.closest(
      `[data-instance-id]:not([data-instance-id="${target.dataset.instanceId}"])`,
    );
    // const parent = target.closest('[data-instance-id]');
    if (parent?.dataset?.instanceId) {
      if (!parent.dataset.instanceTitle) {
        title = parent.closest('[data-instance-title]')?.dataset?.instanceTitle;
        isChild = true;
      } else {
        title = parent.dataset.instanceTitle;
      }

      let position = null;
      if (target?.dataset?.slotPosition) {
        position = parseInt(target.dataset.slotPosition, 10) + 1;
      }

      const grandParent = parent.closest(
        `[data-instance-id]:not([data-instance-id="${parent.dataset.instanceId}"])`,
      );

      return {
        id: parent.dataset.instanceId,
        position,
        title,
        parentId: grandParent?.dataset?.instanceId ?? null,
        parentTitle: grandParent?.dataset?.instanceTitle ?? null,
        isChild,
      };
    }

    return {
      id: null,
      title: null,
      position: null,
      isChild,
    };
  }

  /**
   * Retrieves slot data from a given DOM element or its closest ancestor with a `data-slot-id` attribute.
   *
   * @param {HTMLElement} target - The DOM element to extract slot data from.
   * @param {Object|null} currentInstance - The instance object.
   * @return {Object} An object containing the slot data:
   *   - `id` {string|null}: The value of the `data-slot-id` attribute, or `null` if not found.
   *   - `title` {string|null}: The `title` attribute of the element, or `null` if not found.
   *   - `instanceId` {string|null}: The instance id of the slot, or `null` if not found.
   */
  function getSlotData(target, currentInstance) {
    const name = [];
    if (target.dataset?.slotId) {
      // Click on a slot without position mean we need to look for parent.
      const { slotId } = target.dataset;
      const slotTitle = target.dataset?.slotTitle ?? '';
      const { instanceId } = target.dataset;
      let instanceTitle = target.dataset?.instanceTitle;
      if (!instanceTitle) {
        instanceTitle = target.closest(
          `[data-instance-title][data-instance-id="${target.dataset.instanceId}"]`,
        )?.dataset?.instanceTitle;
      }
      name.push(instanceTitle, slotTitle);

      return {
        name: name.join(' / '),
        id: slotId,
        title: slotTitle ?? '',
        position: target.dataset?.slotPosition
          ? parseInt(target.dataset.slotPosition, 10) + 1
          : 0,
        instanceId,
        instanceTitle,
      };
    }

    // Look for parent first.
    const parent = target.closest(
      `[data-slot-id]:not([data-instance-id="${target.dataset.instanceId}"])`,
    );
    if (parent && parent?.dataset?.slotId) {
      let instanceTitle = parent.dataset?.instanceTitle;
      let position = target.dataset?.slotPosition;
      if (!position) {
        position =
          target.closest(`[data-slot-position]`)?.dataset?.slotPosition;
      }

      // We have a position, need to find the parent instance.
      if (!instanceTitle && position) {
        const parentInstance = target.closest(
          `[data-instance-title]:not([data-instance-id="${target.dataset.instanceId}"])`,
        );
        instanceTitle = parentInstance
          ? parentInstance?.dataset?.instanceTitle
          : null;
      }

      currentInstance.isChild
        ? ''
        : name.push(
            instanceTitle,
            parent.dataset?.slotTitle ?? '',
            formatName(currentInstance.title),
            parseInt(position, 10) + 1,
          );

      return {
        name: name.join(' / '),
        id: parent.dataset.slotId,
        title: parent.dataset?.slotTitle ?? '',
        position: position ? parseInt(position, 10) + 1 : 0,
        instanceId: parent.dataset.instanceId,
        instanceTitle,
      };
    }

    // Look for child.
    const children = target.querySelector('[data-slot-id]');
    if (children && children?.dataset?.slotId) {
      currentInstance.isChild
        ? ''
        : name.push(Drupal.t('Base'), formatName(currentInstance.title));
      if (currentInstance?.position) {
        name.push(parseInt(currentInstance.position, 10) + 1);
      }

      return {
        name: name.join(' / '),
        id: children.dataset.slotId,
        title: children.dataset?.slotTitle ?? '',
        position: target.dataset?.slotPosition
          ? parseInt(target.dataset.slotPosition, 10) + 1
          : 0,
        instanceId: currentInstance?.id,
      };
    }

    return {
      id: null,
      title: null,
      position: 0,
      instanceId: null,
      instanceTitle: null,
    };
  }

  /**
   * Update the menu label based on the current context.
   *
   * @param {HTMLElement} menu - The context menu element.
   * @param {string} dataLabel - The label to set.
   */
  function setMenuLabel(menu, dataLabel) {
    const menuLabel = menu.querySelector('sl-menu-label');
    if (menuLabel) {
      menuLabel.textContent = dataLabel;
    }
  }

  /**
   * Update the menu items based on the current context.
   *
   * @param {HTMLElement} menu - The context menu element.
   * @param {Object} instance - An object containing the instance data with id and title.
   * @param {Object} slotData - An object containing the slot data with keys id and title.
   * @param {Object} copyInstance - The instance stored in local storage with keys id and title.
   */
  function updateMenuItems(menu, instance, slotData, copyInstance) {
    const copyMenu = menu.querySelector('sl-menu-item[value="copy"]');
    const pasteMenu = menu.querySelector('sl-menu-item[value="paste"]');

    pasteMenu ? (pasteMenu.disabled = !copyInstance?.id) : '';
    copyMenu ? (copyMenu.disabled = copyInstance?.id === instance.id) : '';

    menu.setAttribute('data-instance-id', instance.id);

    menu.querySelectorAll('sl-menu-item').forEach((item) => {
      // This is very important to add information to the menu so we know what
      // to do when clicked, these values will be replaced in the htmx url.
      item.setAttribute('data-instance-title', formatName(instance.title));
      item.setAttribute('data-instance-id', instance.id);
      item.setAttribute('data-slot-id', slotData?.id ?? '__root__');
      item.setAttribute('data-slot-position', slotData?.position ?? 0);
      item.setAttribute(
        'data-slot-instance-id',
        slotData?.instanceId ?? '__root__',
      );
      if (copyInstance?.id) {
        item.setAttribute('data-copy-instance-id', copyInstance.id);
      }

      switch (item.value) {
        case 'copy':
          if (copyMenu.disabled) {
            item.textContent = Drupal.t('Copied (!label)', {
              '!label': formatName(instance.title),
            });
          } else {
            item.textContent = Drupal.t('Copy !label', {
              '!label': formatName(instance.title),
            });
          }
          break;

        case 'paste':
          if (copyInstance?.id) {
            item.textContent = Drupal.t('Paste !label', {
              '!label': formatName(copyInstance.title),
            });
          }
          break;

        case 'duplicate':
          item.textContent = Drupal.t('Duplicate !label', {
            '!label': formatName(instance.title),
          });
          break;

        case 'remove':
          item.textContent = Drupal.t('Remove !label', {
            '!label': formatName(instance.title),
          });
          break;

        default:
          break;
      }

      item.checked = false;
    });
  }

  /**
   * Set up the menu select handler for the context menu.
   *
   * @param {string} builderId - The builder id.
   * @param {HTMLElement} menu - The context menu element.
   */
  function setupMenuSelectHandler(builderId, menu) {
    menu.addEventListener('sl-select', (menuEvent) => {
      const { item } = menuEvent.detail;
      if (item.checked) {
        if (item.value === 'copy') {
          Drupal.displayBuilder.LocalStorageManager.set(builderId, 'copy', {
            id: item.dataset.instanceId,
            title: item.dataset.instanceTitle ?? '',
          });
        }

        menu.style.display = 'none';
        item.checked = false;
      }
    });
  }

  /**
   * Set up a global click handler to close the context menu.
   *
   * @param {HTMLElement} menu - The context menu element.
   */
  function setupGlobalClickHandler(menu) {
    document.addEventListener('click', (event) => {
      if (!event.target.dataset.instanceId) {
        menu.style.display = 'none';
      }
    });
  }
})(Drupal, once, FloatingUIDOM);

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

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