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;
}
}
