Skip to content
Snippets Groups Projects
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
aidmiui.js 13.84 KiB
import { Plugin } from 'ckeditor5/src/core';
import {
  ButtonView,
  FocusCycler,
  LabeledFieldView,
  SwitchButtonView,
  View,
  ViewCollection,
  createLabeledInputText,
  injectCssTransitionDisabler,
  submitHandler,
} from 'ckeditor5/src/ui';

import iconAidmi from '../../../../icons/aidmi.svg';
import iconAidmiLoading from '../../../../icons/aidmi_loading.svg';

export default class AIDmiUI extends Plugin {
  init() {        
    const editor = this.editor;

    // Check if the SourceEditing plugin is available.
    const sourceEditing = editor.plugins.get('SourceEditing');

    // This will register the AIDmi toolbar button.
    editor.ui.componentFactory.add('aidmi', (locale) => {
      const button = new ButtonView(locale);

      // Create the toolbar button.
      button.set({
        label: editor.t('AI, describe my image!'),
        icon: iconAidmi,
        tooltip: true,
        isEnabled: !sourceEditing || !sourceEditing.isSourceEditingMode  // Initially disable if in source mode
      });

      this.listenTo(button, 'execute', async () => {
        try {
          // Show loading state
          button.label = 'Processing...';
          button.isEnabled = false; // Disable the button

          // Change to loading icon.
          button.icon = iconAidmiLoading; 

          let output = null;
          let parsedOutput = null;             
          let imagesJSON = this._aidmiJSONImages(editor.getData());
          if (imagesJSON.length > 0) {
            output = await this._aidmiContentAjax(editor.getData(), imagesJSON);
          }                    
          if (output && output.length > 0) {
            parsedOutput = JSON.parse(output);
            this._openDialog(editor, parsedOutput, imagesJSON);
          }
        } 
        catch (error) {
          console.log("Error:", error);
        }
        finally {
          // Restore button state after processing is done
          button.label = 'AI, describe my image!';

          button.icon = iconAidmi; // Restore the original icon
          button.isEnabled = true; // Re-enable the button
        }

      });      

      // Add a listener to enable or disable the button based on content changes.
      editor.model.document.on('change:data', () => {
        // Leverage the _aidmiJSONImages function to check for images in the content
        const hasImages = this._aidmiJSONImages(editor.getData()).length > 0;
        button.isEnabled = hasImages && (!sourceEditing || !sourceEditing.isSourceEditingMode);
      });      

      // Listen for changes in the source editing mode if the plugin is available.
      if (sourceEditing) {
        sourceEditing.on('change:isSourceEditingMode', (evt, name, value) => {
          button.isEnabled = !value;  // Disable button if in source editing mode.
        });
      }

      return button;
    });

  }

  _aidmiJSONImages(content) {
    // Create a temporary DOM element to parse the HTML string
    const tempDiv = document.createElement('div');
    tempDiv.innerHTML = content; // Convert the HTML string to DOM elements
    // Find all images with the class 'aidmi-dialog-image'
    let images = tempDiv.querySelectorAll('img, drupal-media');

    // Initialize an empty array to store the JSON data
    let imageJsonArray = [];

    // Loop through each image and extract 'data-entity-uuid' and 'src'
    images.forEach((image) => {
      let imageObject = {
        "data-entity-uuid": image.getAttribute('data-entity-uuid'),
        "type": image.tagName.toLowerCase()
      };
      
      // Push the image object to the array
      imageJsonArray.push(imageObject);
    });
    
    // Convert the array to a JSON string for output or further use
    return imageJsonArray;
  }

  _aidmiContentAjax(content, imagesJSON) {   
    const $ = jQuery;
    return new Promise((resolve, reject) => {
      $.ajax({
        url: '/admin/aidmi/describe-content-ajax',
        method: 'POST',
        data: {
          content: content,
          imagesJSON: JSON.stringify(imagesJSON)
        },
        success: function(response) {
          resolve(response);
        },
        error: function(xhr, status, error) {
          reject(error);
        }
      });
    });
  }

  _openDialog(editor, data, callback) {
    let aidmiEditedText;
    // Create a temporary container to hold the content.
    const tempElement = document.createElement('div');
    tempElement.setAttribute('role', 'dialog');
    tempElement.setAttribute('aria-labelledby', 'aidmi-dialog-title');
    tempElement.setAttribute('aria-describedby', 'aidmi-dialog-description');
    tempElement.setAttribute('aria-modal', 'true'); // Ensures screen readers treat the dialog as a modal.

    // Set title text.
    let tempETitle = 'AIDmi Description';
    let tempESubTitle = 'Please evaluate and edit the description as needed.';

    // Create dialog content with a textarea for editing.
    tempElement.innerHTML = `<p id="aidmi-dialog-subtitle" class="aidmi-dialog-subtitle">${Drupal.t(tempESubTitle)}</p>`;

    // Variable to store the number of images
    let imageCount = 0;
    data.forEach(item => {
      if (item.images && Array.isArray(item.images)) {
        imageCount += item.images.length;
      }
    });
    // Loop through the images
    data.forEach(item => {
      // If item has an images array, loop through it
      if (item.images && Array.isArray(item.images)) {
        item.images.forEach((image) => {
          let formImageInnerHTML = '';
          // Start of image div alt option area.
          formImageInnerHTML += `<div class="image-option-group" id="option-group-${image['data-entity-uuid']}">`;
          // Add Image Display.
          formImageInnerHTML += `
            <!-- Image Display -->
            <img class="aidmi-dialog-image" data-entity-uuid="${image['data-entity-uuid']}" src="${image.src}" alt="${Drupal.t(image.before_alt)}" />`;
          // Add Current Alt Tag Section.
          let imageOldSelectDisabled = false;
          let imageLabel = 'Current Alt Tag - Read only';
          let imageBeforeAlt = image.before_alt;
          if (!imageBeforeAlt || imageBeforeAlt.length < 6) {
            // Old alt shouldn't be an allowed option.
            imageLabel += ' (Option disabled since alt is either blank or too short)';
            imageOldSelectDisabled = true;
          }
          formImageInnerHTML += `
            <!-- Current Alt Tag Section -->
            <div>
              <label for="aidmi-alt-old-${image['data-entity-uuid']}" class="aidmi-dialog-label">
                <input type="radio" name="aidmi-alt-selection-${image['data-entity-uuid']}" id="aidmi-alt-old-${image['data-entity-uuid']}" value="current" aria-labelledby="label-old-${image['data-entity-uuid']}" ${imageOldSelectDisabled ? 'disabled' : ''} required/>
                <span id="label-old-${image['data-entity-uuid']}">${Drupal.t(imageLabel)}</span>
              </label>`; 

          // If radio is disabled, don't show the alt textarea.
          if (!imageOldSelectDisabled) {
            formImageInnerHTML += `
                <textarea id="aidmi-dialog-textarea-oldalt-${image['data-entity-uuid']}" class="aidmi-dialog-textarea" rows="3" aria-labelledby="label-old-${image['data-entity-uuid']}" readonly="readonly">${Drupal.t(image.before_alt)}</textarea>`;
          }
          
          formImageInnerHTML += `
            </div>`;   
          
          // Add Suggested Alt Tag Section.
          formImageInnerHTML += `
            <!-- Suggested Alt Tag Section -->
            <div>
              <label for="aidmi-alt-new-${image['data-entity-uuid']}" class="aidmi-dialog-label">
                <input type="radio" name="aidmi-alt-selection-${image['data-entity-uuid']}" id="aidmi-alt-new-${image['data-entity-uuid']}" value="suggested" aria-labelledby="label-new-${image['data-entity-uuid']}" required>
                <span id="label-new-${image['data-entity-uuid']}">${Drupal.t('Suggested Alt Tag - Review and modify as required')}</span>
              </label>
              <textarea id="aidmi-dialog-textarea-newalt-${image['data-entity-uuid']}" class="aidmi-dialog-textarea" rows="3" aria-labelledby="label-new-${image['data-entity-uuid']}">${Drupal.t(image.recommendation)}</textarea>
            </div>`;
          // Add Set as Decorative Image.
          formImageInnerHTML += `
            <!-- Set as Decorative Image -->
            <div>
              <label for="aidmi-alt-decorative-${image['data-entity-uuid']}" class="aidmi-dialog-label">
                <input type="radio" name="aidmi-alt-selection-${image['data-entity-uuid']}" id="aidmi-alt-decorative-${image['data-entity-uuid']}" value="decorative" aria-labelledby="label-decorative-${image['data-entity-uuid']}" required>
                <span id="label-decorative-${image['data-entity-uuid']}">${Drupal.t('Set as Decorative Image')}</span>
              </label>
            </div>`;
          // End of image div alt option area.
          formImageInnerHTML += `</div><hr/>`;
          
          // Insert it.
          tempElement.innerHTML += formImageInnerHTML;
        });
      }            
    });

    // Use the Drupal off-canvas dialog to show the content.
    const options = {
      dialogClass: 'aidmi-dialog',
      title: tempETitle,
      resizable: true,
      width: '700px',
      buttons: [
        {
          text: Drupal.t('Insert Text'),
          class: 'aidmi-insert-button', // Add a class to the insert button for reference
          click: () => {
            // Validate all selections before inserting text
            const selections = this._collectSelections(data);
            if (selections) {
              // Modify the images in the editor content based on the selections
              this._modifyImagesInContent(editor, selections);
              dialogInstance.close();
            } else {
              // Show a message if validation fails
              alert(Drupal.t('Please select an option for each image before inserting.'));
            }
          }
        },
        {
          text: Drupal.t('Cancel'),
          click: function () {
            // Close the dialog.
            dialogInstance.close();
          }
        }
      ]
    };

    // Open the dialog using Drupal's dialog API.
    const dialogInstance = Drupal.dialog(tempElement, options);
    dialogInstance.showModal();

    // Add event listeners to all radio buttons to validate the selection and remove highlights
    document.querySelectorAll('input[type="radio"]').forEach(radio => {
      radio.addEventListener('change', (event) => {
        const groupId = event.target.name.replace('aidmi-alt-selection-', 'option-group-');
        // Remove the highlight class when a selection is made
        document.getElementById(groupId).classList.remove('aidmi-highlight-missing');
      });
    });

    // Set focus on the first input for screen readers
    document.querySelector('input[type="radio"]').focus();
  }

  _collectSelections(data) {
    let allSelected = true;
    const selections = [];
  
    // Loop through the images
    data.forEach(item => {
      if (item.images && Array.isArray(item.images)) {
        item.images.forEach((image) => {
          const uuid = image['data-entity-uuid'];
          const selectedRadio = document.querySelector(`input[name="aidmi-alt-selection-${uuid}"]:checked`);
          
          // If no radio button is selected, set `allSelected` to false
          if (!selectedRadio) {
            allSelected = false;
            // Highlight the missing selection group
            document.getElementById(`option-group-${uuid}`).classList.add('aidmi-highlight-missing');
          } else {
            // Get the selected value and corresponding alt text or set as decorative
            const altValue = selectedRadio.value;
            let altText = '';
            if (altValue === 'current') {
              altText = document.getElementById(`aidmi-dialog-textarea-oldalt-${uuid}`).value;
            } else if (altValue === 'suggested') {
              altText = document.getElementById(`aidmi-dialog-textarea-newalt-${uuid}`).value;
            }
            selections.push({
              uuid: uuid,
              altText: altValue === 'decorative' ? '' : altText, // Leave alt text empty for decorative images
              isDecorative: altValue === 'decorative'
            });
          }
        });
      }
    });
  
    return allSelected ? selections : null;
  }

  // Function to modify the images in the CKEditor content based on the selected options
  _modifyImagesInContent(editor, selections) {
    // Get the current content of the editor
    let content = editor.getData();

    // Create a temporary DOM element to manipulate the HTML content
    const tempDiv = document.createElement('div');
    tempDiv.innerHTML = content; // Convert the HTML string to DOM elements

    // Loop through the selections and modify the corresponding images in the content
    selections.forEach(selection => {
      // Find the image element by its data-entity-uuid attribute
      const imageElement = tempDiv.querySelector(`[data-entity-uuid="${selection.uuid}"]`);
      
      if (imageElement) {
        // Update the alt attribute or set as decorative based on the selection
        if (selection.isDecorative) {
          imageElement.setAttribute('alt', ''); // Set alt attribute to empty for decorative images
          imageElement.setAttribute('role', 'presentation'); // Add role="presentation" to decorative images
        } else {
          imageElement.setAttribute('alt', selection.altText); // Update the alt attribute
          imageElement.removeAttribute('role'); // Remove role attribute if it's not a decorative image
        }
        
      } else {
        console.warn(`Image with UUID ${selection.uuid} not found in content.`);
      }
    });

    // Set the modified content back to the editor
    editor.setData(tempDiv.innerHTML);

    // Optional: Trigger the change event to update the editor state
    editor.editing.view.document.fire('change:data');
  }   

}