image_to_media_swapper-2.x-dev/js/ckeditor5_plugins/mediaSwapper/src/MediaSwapper.js

js/ckeditor5_plugins/mediaSwapper/src/MediaSwapper.js
/* jshint esversion: 8 */
'use es9';
import {Plugin} from 'ckeditor5/src/core';
import {ButtonView} from 'ckeditor5/src/ui';
import icon from '../../../../icons/media-swapper.svg';

export default class MediaSwapper extends Plugin {
  /**
   * @inheritdoc
   */
  static get requires() {
    // Make sure to require Link plugin if using link commands
    return ['Link'];
  }

  init() {
    const editor = this.editor;

    // Check if Linkit is available
    this.isLinkitAvailable = this.detectLinkitAvailability();

    // Cache for security tokens
    this.securityTokens = null;

    // Add main toolbar button for conversion
    editor.ui.componentFactory.add('mediaSwapper', locale => {
      const button = new ButtonView(locale);

      button.set({
        label: 'Convert to Media',
        icon: icon,
        tooltip: true,
        isEnabled: false
      });

      // Update button state based on current selection
      const updateButtonState = () => {
        const imageBlock = this.getSelectedImageBlock();
        const linkCommand = editor.commands.get('link');
        const isFileLink = linkCommand && linkCommand.value && this.isFileLink(linkCommand.value);

        // Only enable file link processing if Linkit is available
        const canProcessFileLink = isFileLink && this.isLinkitAvailable;

        button.isEnabled = !!(imageBlock || canProcessFileLink);
      };

      // Listen for selection changes to update button state
      editor.model.document.selection.on('change', updateButtonState);
      editor.model.document.on('change:data', updateButtonState);

      // Listen for link command changes
      const linkCommand = editor.commands.get('link');
      if (linkCommand) {
        linkCommand.on('change:value', updateButtonState);
      }

      // Initial state update
      updateButtonState();

      // Button click: handle both image and PDF conversion
      button.on('execute', async () => {
        await this.handleConversion();
      });

      return button;
    });
  }

  /**
   * Fetches security tokens from the server.
   */
  async getSecurityTokens() {
    // Return cached tokens if available and not expired (5 minutes).
    if (this.securityTokens && (Date.now() - this.securityTokens.timestamp < 300000)) {
      return this.securityTokens;
    }

    try {
      const response = await fetch('/media-api/security-tokens', {
        method: 'GET',
        headers: {
          Accept: 'application/json'
        }
      });

      if (!response.ok) {
        return new Error(`HTTP ${response.status}: ${response.statusText}`);
      }

      this.securityTokens = await response.json();
      return this.securityTokens;
    }
    catch (error) {
      await this.showConfirmationDialog('Failed to get security tokens. Please refresh the page and try again.', true);
      throw error;
    }
  }

  /**
   * Makes a secure API request with proper headers and tokens.
   */
  async makeSecureApiRequest(endpoint, body) {
    const tokens = await this.getSecurityTokens();

    // Add security tokens to the request body
    const secureBody = {
      ...body,
      csrf_token: tokens.csrf_token,
      user_uuid: tokens.user_uuid
    };

    const response = await fetch(endpoint, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Accept': 'application/json',
        'X-CSRF-Token': tokens.csrf_token,
        'Origin': window.location.origin
      },
      body: JSON.stringify(secureBody)
    });

    if (!response.ok) {
      const errorData = await response.json().catch(() => ({}));
      throw new Error(errorData.error || `HTTP ${response.status}: ${response.statusText}`);
    }

    return response.json();
  }

  /**
   * Handles both image and file conversion based on current selection.
   */
  async handleConversion() {
    // Check for file links first (only if Linkit is available)
    const linkCommand = this.editor.commands.get('link');
    const isFileLink = linkCommand && linkCommand.value && this.isFileLink(linkCommand.value);

    if (isFileLink && this.isLinkitAvailable) {
      await this.handleFileConversion();
      return;
    }

    // Otherwise handle image conversion
    await this.handleImageConversion();
  }

  /**
   * Handles image conversion from the main toolbar.
   */
  async handleImageConversion() {
    const imageBlock = this.getSelectedImageBlock();
    if (!imageBlock) {
      await this.showConfirmationDialog('No image block selected.', true);
      return;
    }

    const fileUuid = imageBlock.getAttribute('dataEntityUuid');
    const filePath = imageBlock.getAttribute('src');
    imageBlock.getAttribute('dataAlign');
    if (!fileUuid && !filePath) {
      await this.showConfirmationDialog('No file UUID or file path found in the selected image.', true);
      return;
    }

    const confirmed = await this.showConfirmationDialog('Convert this file-based image to a media entity?');
    if (!confirmed) {
      return;
    }

    let endPoint;
    let body;
    if (filePath) {
      // Check if the filePath is an absolute URL.
      const isAbsoluteUrl = this.isAbsoluteUrl(filePath);
      if (isAbsoluteUrl) {
        // Check if the filePath is on the same domain as the current site.
        const currentDomain = window.location.origin;
        if (filePath.startsWith(currentDomain)) {
          // If it's an absolute URL on the same domain, use the file path directly.
          endPoint = '/media-api/swap-file-to-media/local-path';
          body = {filepath: filePath};
        }
        else {
          // If it's an absolute URL on a different domain, use the remote endpoint.
          endPoint = '/media-api/swap-file-to-media/remote-uri';
          body = {remote_file: filePath};
        }
      }
      else {
        // Relative path - use file path endpoint
        endPoint = '/media-api/swap-file-to-media/local-path';
        body = {filepath: filePath};
      }
    }
    if (fileUuid) {
      endPoint = '/media-api/swap-file-to-media/file-uuid';
      body = {uuid: fileUuid};
    }
    if (!endPoint || !body) {
      return;
    }

    let data;
    try {
      data = await this.makeSecureApiRequest(endPoint, body);
      if (data.error || !data.uuid[0]) {
        await this.showConfirmationDialog(data.error || 'Unknown error occurred', true);
        return;
      }
    }
    catch (error) {
      return;
    }

    try {
      // Get current selection position before removing.
      const selection = this.editor.model.document.selection;
      const insertPosition = selection.getFirstPosition();

      // Remove the existing image block.
      const imageBlock = this.getSelectedImageBlock();
      const imageStyle = imageBlock.getAttribute('imageStyle');
      if (imageBlock) {
        this.editor.model.change(writer => {
          writer.remove(imageBlock);
        });
      }

      // Insert the drupalMedia element using model manipulation.
      this.editor.model.change(writer => {
        // Prepare attributes for the new drupalMedia element.
        const mediaAttributes = {
          drupalMediaEntityUuid: data.uuid[0].value,
          drupalMediaEntityType: 'media',
          drupalElementStyleViewMode: 'default'
        };

        // Preserve image alignment if it exists on the original image.
        if (imageStyle) {
          mediaAttributes.imageStyle = imageStyle;
        }

        const drupalMedia = writer.createElement('drupalMedia', mediaAttributes);

        writer.insert(drupalMedia, insertPosition);
        writer.setSelection(drupalMedia, 'on');
      });
    }
    catch (error) {
      await this.showConfirmationDialog('Error updating editor content.', true);
    }
  }

  /**
   * Handles file conversion from the link toolbar.
   */
  async handleFileConversion() {
    const linkCommand = this.editor.commands.get('link');
    const currentUrl = linkCommand.value;

    if (!currentUrl || !this.isFileLink(currentUrl)) {
      await this.showConfirmationDialog('No file link found at current selection.', true);
      return;
    }

    // Create a link element object for processing.
    const linkElement = {
      getAttribute: (attr) => {
        if (attr === 'linkHref') {
          return currentUrl;
        }
        return this.editor.model.document.selection.getAttribute(attr) || null;
      },
      getHref: () => currentUrl,
      _isCommandBased: true
    };

    await this.handleFileLink(linkElement);
  }

  showConfirmationDialog(message, isInformational = false) {
    return new Promise(resolve => {
      const container = document.createElement('div');
      container.classList.add('custom-confirm-container');
      if (isInformational) {
        container.innerHTML = `
        <div class="custom-confirm-box">
          <p>${message}</p>
          <button class="confirm-yes">Ok</button>
        </div>
      `;
      }
      else {
        container.innerHTML = `
        <div class="custom-confirm-box">
          <p>${message}</p>
          <button class="confirm-yes">Yes</button>
          <button class="confirm-no">Cancel</button>
        </div>
      `;
      }

      document.body.appendChild(container);

      container.querySelector('.confirm-yes').addEventListener('click', () => {
        container.remove();
        resolve(true);
      });

      const cancelButton = container.querySelector('.confirm-no');
      if (cancelButton) {
        cancelButton.addEventListener('click', () => {
          container.remove();
          resolve(false);
        });
      }
    });
  }

  getSelectedImageBlock() {
    const selection = this.editor.model.document.selection;
    const selectedElement = selection.getSelectedElement();

    // Case 1: Widget selected directly.
    if (
      selectedElement &&
      (selectedElement.name === 'imageBlock' || selectedElement.name === 'imageInline') &&
      (selectedElement.getAttribute('dataEntityType') === 'file' || selectedElement.getAttribute('src'))
    ) {
      return selectedElement;
    }

    // Case 2: Selection inside widget — walk up from position.
    const position = selection.getFirstPosition();

    if (!position) {
      return null;
    }

    let parent = position.parent;
    while (parent) {
      if (
        (parent.name === 'imageBlock' || parent.name === 'imageInline') &&
        parent.getAttribute('dataEntityType') === 'file'
      ) {
        return parent;
      }
      parent = parent.parent;
    }

    // Fallback: treat selected drupalEntity file as image.
    if (
      selectedElement &&
      selectedElement.name === 'drupalEntity' &&
      selectedElement.getAttribute('entityType') === 'file'
    ) {
      return selectedElement;
    }

    return null;
  }

  /**
   * Detects if Linkit is available in the editor.
   *
   * @returns {boolean} True if Linkit is available.
   */
  detectLinkitAvailability() {
    const editor = this.editor;

    // Method 1: Check if Linkit plugin is loaded.
    if (editor.plugins.has('Linkit')) {
      return true;
    }

    // Method 2: Check if Linkit schema attributes are supported.
    const schema = editor.model.schema;
    if (schema.checkAttribute('$text', 'linkDataEntityType')) {
      return true;
    }

    // Method 3: Check if Linkit config exists.
    const linkitConfig = editor.config.get('linkit');
    return !!linkitConfig;


  }

  /**
   * Checks if a given URL is a supported file link.
   *
   * @param {string} url - The URL to check.
   * @returns {boolean} True if the URL appears to be a supported file.
   */
  isFileLink(url) {
    if (!url || typeof url !== 'string') {
      return false;
    }

    // Get supported extensions from drupalSettings if available.
    const supportedExtensions = this.getSupportedExtensions();
    // Check for supported file extensions.
    const urlLower = url.toLowerCase();
    return supportedExtensions.some(ext =>
      urlLower.includes(`.${ext}`) || urlLower.includes(`type=${ext}`) || urlLower.includes(`format=${ext}`)
    );
  }

  /**
   * Gets supported file extensions from drupalSettings or defaults.
   *
   * @returns {array} Array of supported file extensions.
   */
  getSupportedExtensions() {
    // Try to get from drupalSettings if available.
    if (typeof drupalSettings !== 'undefined' &&
      drupalSettings.imageToMediaSwapper &&
      drupalSettings.imageToMediaSwapper.supportedExtensions) {
      return drupalSettings.imageToMediaSwapper.supportedExtensions;
    }

    // Default supported extensions.
    return [
      'pdf',
      'doc',
      'docx',
      'xls',
      'xlsx',
      'ppt',
      'pptx',
      'txt',
      'zip',
      'rar',
      'mp3',
      'mp4',
      'jpg',
      'jpeg',
      'png',
      'gif'
    ];
  }

  /**
   * Handles the conversion of a file link to a media entity.
   *
   * @param {Object} linkElement - The selected link element.
   */
  async handleFileLink(linkElement) {
    const href = linkElement.getAttribute('linkHref') || linkElement.getHref();
    const dataMediaUuid = linkElement.getAttribute('data-media-uuid');

    if (!href) {
      await this.showConfirmationDialog('No href found in the selected file link.', true);
      return;
    }

    const confirmed = await this.showConfirmationDialog('Convert this file link to a media entity?');
    if (!confirmed) {
      return;
    }

    let endPoint;
    let body;

    // Priority: data-media-uuid takes precedence
    if (dataMediaUuid) {
      endPoint = '/media-api/swap-file-to-media/file-uuid';
      body = {uuid: dataMediaUuid};
    }
    else {
      // Check if it's an absolute URL.
      const isAbsoluteUrl = this.isAbsoluteUrl(href);

      if (isAbsoluteUrl) {
        const currentDomain = window.location.origin;
        if (href.startsWith(currentDomain)) {
          // Same domain - use file path endpoint.
          endPoint = '/media-api/swap-file-to-media/local-path';
          body = {filepath: href};
        }
        else {
          // Remote domain - use remote file endpoint.
          endPoint = '/media-api/swap-file-to-media/remote-uri';
          body = {remote_file: href};
        }
      }
      else {
        // Relative path - use file path endpoint.
        endPoint = '/media-api/swap-file-to-media/local-path';
        body = {filepath: href};
      }
    }

    if (!endPoint || !body) {
      await this.showConfirmationDialog('Unable to determine appropriate endpoint for file conversion.', true);
      return;
    }

    let data;
    try {
      data = await this.makeSecureApiRequest(endPoint, body);
      if (data.error || !data.uuid || !data.uuid[0]) {
        await this.showConfirmationDialog(data.error || 'Unknown error occurred during file conversion', true);
        return;
      }
    }
    catch (error) {
      await this.showConfirmationDialog('Error occurred during file conversion: ' + error.message, true);
      return;
    }

    try {
      // Since we've already confirmed Linkit is available,
      // use full Linkit integration.
      await this.updateLinkAttributes(linkElement, {
        linkHref: `/media/${data.mid[0].value}`,
        linkDataEntityType: 'media',
        linkDataEntityUuid: data.uuid[0].value,
        linkDataEntitySubstitution: 'media'
      });

      // Show a dialog to confirm the update.
      await this.showConfirmationDialog('File link updated successfully with Linkit attributes.', true);
    }
    catch (error) {
      await this.showConfirmationDialog('Error updating file link.' +
        ' The media entity was created but the link was not updated.', true);
    }
  }

  /**
   * Updates link attributes in the CKEditor model using the Linkit approach.
   *
   * @param {Object} linkElement - The link element or pseudo-element.
   * @param {Object} newAttributes - The new attributes to apply.
   */
  async updateLinkAttributes(linkElement, newAttributes) {
    return new Promise((resolve, reject) => {
      try {
        const linkCommand = this.editor.commands.get('link');

        if (linkCommand) {
          // Use the Linkit pattern: execute link command with decorators.
          // This matches the pattern from linkit/src/index.js lines 108-132.
          const href = newAttributes.linkHref;
          const decorators = {
            linkDataEntityType: newAttributes.linkDataEntityType,
            linkDataEntityUuid: newAttributes.linkDataEntityUuid,
            linkDataEntitySubstitution: newAttributes.linkDataEntitySubstitution
          };

          // Execute the link command with both URL and Linkit attributes
          linkCommand.execute(href, decorators);
          resolve();
        }
        else {
          reject(new Error('Link command not available'));
        }
      }
      catch (error) {
        reject(error);
      }
    });
  }

  /**
   * Utility method to check if a URL is absolute.
   *
   * @param {string} url - The URL to check.
   * @returns {boolean} True if the URL is absolute.
   */
  isAbsoluteUrl(url) {
    // Handle null, undefined, or empty strings
    if (!url || typeof url !== 'string') {
      return false;
    }

    // Quick check for common absolute URL patterns
    if (url.startsWith('http://') || url.startsWith('https://')) {
      try {
        new URL(url);
        return true;
      }
      catch (error) {
        return false;
      }
    }

    return false;
  }

}

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

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