@ckeditor/ckeditor5-ckbox
Version:
CKBox integration for CKEditor 5.
318 lines (317 loc) • 12.1 kB
JavaScript
/**
* @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module ckbox/ckboximageedit/ckboximageeditcommand
*/
import { Command, PendingActions } from 'ckeditor5/src/core.js';
import { CKEditorError, abortableDebounce, createElement, retry, delay } from 'ckeditor5/src/utils.js';
import { Notification } from 'ckeditor5/src/ui.js';
import { isEqual } from 'es-toolkit/compat';
import { sendHttpRequest } from '../utils.js';
import { prepareImageAssetAttributes } from '../ckboxcommand.js';
import { createEditabilityChecker } from './utils.js';
import CKBoxUtils from '../ckboxutils.js';
/**
* The CKBox edit image command.
*
* Opens the CKBox dialog for editing the image.
*/
export default class CKBoxImageEditCommand extends Command {
/**
* The DOM element that acts as a mounting point for the CKBox Edit Image dialog.
*/
_wrapper = null;
/**
* The states of image processing in progress.
*/
_processInProgress = new Set();
/**
* Determines if the element can be edited.
*/
_canEdit;
/**
* A wrapper function to prepare mount options. Ensures that at most one preparation is in-flight.
*/
_prepareOptions;
/**
* CKBox's onClose function runs before the final cleanup, potentially causing
* page layout changes after it finishes. To address this, we use a setTimeout hack
* to ensure that floating elements on the page maintain their correct position.
*
* See: https://github.com/ckeditor/ckeditor5/issues/16153.
*/
_updateUiDelayed = delay(() => this.editor.ui.update(), 0);
/**
* @inheritDoc
*/
constructor(editor) {
super(editor);
this.value = false;
this._canEdit = createEditabilityChecker(editor.config.get('ckbox.allowExternalImagesEditing'));
this._prepareOptions = abortableDebounce((signal, state) => this._prepareOptionsAbortable(signal, state));
this._prepareListeners();
}
/**
* @inheritDoc
*/
refresh() {
const editor = this.editor;
this.value = this._getValue();
const selectedElement = editor.model.document.selection.getSelectedElement();
this.isEnabled =
!!selectedElement &&
this._canEdit(selectedElement) &&
!this._checkIfElementIsBeingProcessed(selectedElement);
}
/**
* Opens the CKBox Image Editor dialog for editing the image.
*/
execute() {
if (this._getValue()) {
return;
}
const wrapper = createElement(document, 'div', { class: 'ck ckbox-wrapper' });
this._wrapper = wrapper;
this.value = true;
document.body.appendChild(this._wrapper);
const imageElement = this.editor.model.document.selection.getSelectedElement();
const processingState = {
element: imageElement,
controller: new AbortController()
};
this._prepareOptions(processingState).then(options => window.CKBox.mountImageEditor(wrapper, options), error => {
const editor = this.editor;
const t = editor.t;
const notification = editor.plugins.get(Notification);
notification.showWarning(t('Failed to determine category of edited image.'), {
namespace: 'ckbox'
});
console.error(error);
this._handleImageEditorClose();
});
}
/**
* @inheritDoc
*/
destroy() {
this._handleImageEditorClose();
this._prepareOptions.abort();
this._updateUiDelayed.cancel();
for (const state of this._processInProgress.values()) {
state.controller.abort();
}
super.destroy();
}
/**
* Indicates if the CKBox Image Editor dialog is already opened.
*/
_getValue() {
return this._wrapper !== null;
}
/**
* Creates the options object for the CKBox Image Editor dialog.
*/
async _prepareOptionsAbortable(signal, state) {
const editor = this.editor;
const ckboxConfig = editor.config.get('ckbox');
const ckboxUtils = editor.plugins.get(CKBoxUtils);
const { element } = state;
let imageMountOptions;
const ckboxImageId = element.getAttribute('ckboxImageId');
if (ckboxImageId) {
imageMountOptions = {
assetId: ckboxImageId
};
}
else {
const imageUrl = new URL(element.getAttribute('src'), document.baseURI).href;
const uploadCategoryId = await ckboxUtils.getCategoryIdForFile(imageUrl, { signal });
imageMountOptions = {
imageUrl,
uploadCategoryId
};
}
return {
...imageMountOptions,
imageEditing: {
allowOverwrite: false
},
tokenUrl: ckboxConfig.tokenUrl,
...(ckboxConfig.serviceOrigin && { serviceOrigin: ckboxConfig.serviceOrigin }),
onClose: () => this._handleImageEditorClose(),
onSave: (asset) => this._handleImageEditorSave(state, asset)
};
}
/**
* Initializes event lister for an event of removing an image.
*/
_prepareListeners() {
// Abort editing processing when the image has been removed.
this.listenTo(this.editor.model.document, 'change:data', () => {
const processingStates = this._getProcessingStatesOfDeletedImages();
processingStates.forEach(processingState => {
processingState.controller.abort();
});
});
}
/**
* Gets processing states of images that have been deleted in the mean time.
*/
_getProcessingStatesOfDeletedImages() {
const states = [];
for (const state of this._processInProgress.values()) {
if (state.element.root.rootName == '$graveyard') {
states.push(state);
}
}
return states;
}
_checkIfElementIsBeingProcessed(selectedElement) {
for (const { element } of this._processInProgress) {
if (isEqual(element, selectedElement)) {
return true;
}
}
return false;
}
/**
* Closes the CKBox Image Editor dialog.
*/
_handleImageEditorClose() {
if (!this._wrapper) {
return;
}
this._wrapper.remove();
this._wrapper = null;
this.editor.editing.view.focus();
this._updateUiDelayed();
this.refresh();
}
/**
* Save edited image. In case server respond with "success" replace with edited image,
* otherwise show notification error.
*/
_handleImageEditorSave(state, asset) {
const t = this.editor.locale.t;
const notification = this.editor.plugins.get(Notification);
const pendingActions = this.editor.plugins.get(PendingActions);
const action = pendingActions.add(t('Processing the edited image.'));
this._processInProgress.add(state);
this._showImageProcessingIndicator(state.element, asset);
this.refresh();
this._waitForAssetProcessed(asset.data.id, state.controller.signal)
.then(asset => {
this._replaceImage(state.element, asset);
}, error => {
// Remove processing indicator. It was added only to ViewElement.
this.editor.editing.reconvertItem(state.element);
if (state.controller.signal.aborted) {
return;
}
if (!error || error instanceof CKEditorError) {
notification.showWarning(t('Server failed to process the image.'), {
namespace: 'ckbox'
});
}
else {
console.error(error);
}
}).finally(() => {
this._processInProgress.delete(state);
pendingActions.remove(action);
this.refresh();
});
}
/**
* Get asset's status on server. If server responds with "success" status then
* image is already proceeded and ready for saving.
*/
async _getAssetStatusFromServer(id, signal) {
const ckboxUtils = this.editor.plugins.get(CKBoxUtils);
const url = new URL('assets/' + id, this.editor.config.get('ckbox.serviceOrigin'));
const response = await sendHttpRequest({
url,
signal,
authorization: (await ckboxUtils.getToken()).value
});
const status = response.metadata.metadataProcessingStatus;
if (!status || status == 'queued') {
/**
* Image has not been processed yet.
*
* @error ckbox-image-not-processed
*/
throw new CKEditorError('ckbox-image-not-processed');
}
return { data: { ...response } };
}
/**
* Waits for an asset to be processed.
* It retries retrieving asset status from the server in case of failure.
*/
async _waitForAssetProcessed(id, signal) {
const result = await retry(() => this._getAssetStatusFromServer(id, signal), {
signal,
maxAttempts: 5
});
if (result.data.metadata.metadataProcessingStatus != 'success') {
/**
* The image processing failed.
*
* @error ckbox-image-processing-failed
*/
throw new CKEditorError('ckbox-image-processing-failed');
}
return result;
}
/**
* Shows processing indicator while image is processing.
*
* @param asset Data about certain asset.
*/
_showImageProcessingIndicator(element, asset) {
const editor = this.editor;
editor.editing.view.change(writer => {
const imageElementView = editor.editing.mapper.toViewElement(element);
const imageUtils = this.editor.plugins.get('ImageUtils');
const img = imageUtils.findViewImgElement(imageElementView);
writer.removeStyle('aspect-ratio', img);
writer.setAttribute('width', asset.data.metadata.width, img);
writer.setAttribute('height', asset.data.metadata.height, img);
writer.setStyle('width', `${asset.data.metadata.width}px`, img);
writer.setStyle('height', `${asset.data.metadata.height}px`, img);
writer.addClass('image-processing', imageElementView);
});
}
/**
* Replace the edited image with the new one.
*/
_replaceImage(element, asset) {
const editor = this.editor;
const { imageFallbackUrl, imageSources, imageWidth, imageHeight, imagePlaceholder } = prepareImageAssetAttributes(asset);
const previousSelectionRanges = Array.from(editor.model.document.selection.getRanges());
editor.model.change(writer => {
writer.setSelection(element, 'on');
editor.execute('insertImage', {
imageType: element.is('element', 'imageInline') ? 'imageInline' : null,
source: {
src: imageFallbackUrl,
sources: imageSources,
width: imageWidth,
height: imageHeight,
...(imagePlaceholder ? { placeholder: imagePlaceholder } : null),
...(element.hasAttribute('alt') ? { alt: element.getAttribute('alt') } : null)
}
});
const previousChildren = element.getChildren();
element = editor.model.document.selection.getSelectedElement();
for (const child of previousChildren) {
writer.append(writer.cloneElement(child), element);
}
writer.setAttribute('ckboxImageId', asset.data.id, element);
writer.setSelection(previousSelectionRanges);
});
}
}