@ckeditor/ckeditor5-ckbox
Version:
CKBox integration for CKEditor 5.
383 lines (382 loc) • 15.4 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
*/
import { Command } from 'ckeditor5/src/core.js';
import { createElement, toMap } from 'ckeditor5/src/utils.js';
import { blurHashToDataUrl, getImageUrls } from './utils.js';
// Defines the waiting time (in milliseconds) for inserting the chosen asset into the model. The chosen asset is temporarily stored in the
// `CKBoxCommand#_chosenAssets` and it is removed from there automatically after this time. See `CKBoxCommand#_chosenAssets` for more
// details.
const ASSET_INSERTION_WAIT_TIMEOUT = 1000;
/**
* The CKBox command. It is used by the {@link module:ckbox/ckboxediting~CKBoxEditing CKBox editing feature} to open the CKBox file manager.
* The file manager allows inserting an image or a link to a file into the editor content.
*
* ```ts
* editor.execute( 'ckbox' );
* ```
*
* **Note:** This command uses other features to perform the following tasks:
* - To insert images it uses the {@link module:image/image/insertimagecommand~InsertImageCommand 'insertImage'} command from the
* {@link module:image/image~Image Image feature}.
* - To insert links to other files it uses the {@link module:link/linkcommand~LinkCommand 'link'} command from the
* {@link module:link/link~Link Link feature}.
*/
export default class CKBoxCommand extends Command {
/**
* A set of all chosen assets. They are stored temporarily and they are automatically removed 1 second after being chosen.
* Chosen assets have to be "remembered" for a while to be able to map the given asset with the element inserted into the model.
* This association map is then used to set the ID on the model element.
*
* All chosen assets are automatically removed after the timeout, because (theoretically) it may happen that they will never be
* inserted into the model, even if the {@link module:link/linkcommand~LinkCommand `'link'`} command or the
* {@link module:image/image/insertimagecommand~InsertImageCommand `'insertImage'`} command is enabled. Such a case may arise when
* another plugin blocks the command execution. Then, in order not to keep the chosen (but not inserted) assets forever, we delete
* them automatically to prevent memory leakage. The 1 second timeout is enough to insert the asset into the model and extract the
* ID from the chosen asset.
*
* The assets are stored only if
* the {@link module:ckbox/ckboxconfig~CKBoxConfig#ignoreDataId `config.ckbox.ignoreDataId`} option is set to `false` (by default).
*
* @internal
*/
_chosenAssets = new Set();
/**
* The DOM element that acts as a mounting point for the CKBox dialog.
*/
_wrapper = null;
/**
* @inheritDoc
*/
constructor(editor) {
super(editor);
this._initListeners();
}
/**
* @inheritDoc
*/
refresh() {
this.value = this._getValue();
this.isEnabled = this._checkEnabled();
}
/**
* @inheritDoc
*/
execute() {
this.fire('ckbox:open');
}
/**
* Indicates if the CKBox dialog is already opened.
*
* @protected
* @returns {Boolean}
*/
_getValue() {
return this._wrapper !== null;
}
/**
* Checks whether the command can be enabled in the current context.
*/
_checkEnabled() {
const imageCommand = this.editor.commands.get('insertImage');
const linkCommand = this.editor.commands.get('link');
if (!imageCommand.isEnabled && !linkCommand.isEnabled) {
return false;
}
return true;
}
/**
* Creates the options object for the CKBox dialog.
*
* @returns The object with properties:
* - theme The theme for CKBox dialog.
* - language The language for CKBox dialog.
* - tokenUrl The token endpoint URL.
* - serviceOrigin The base URL of the API service.
* - forceDemoLabel Whether to force "Powered by CKBox" link.
* - assets.onChoose The callback function invoked after choosing the assets.
* - dialog.onClose The callback function invoked after closing the CKBox dialog.
* - dialog.width The dialog width in pixels.
* - dialog.height The dialog height in pixels.
* - categories.icons Allows setting custom icons for categories.
* - view.openLastView Sets if the last view visited by the user will be reopened
* on the next startup.
* - view.startupFolderId Sets the ID of the folder that will be opened on startup.
* - view.startupCategoryId Sets the ID of the category that will be opened on startup.
* - view.hideMaximizeButton Sets whether to hide the ‘Maximize’ button.
* - view.componentsHideTimeout Sets timeout after which upload components are hidden
* after completed upload.
* - view.dialogMinimizeTimeout Sets timeout after which upload dialog is minimized
* after completed upload.
*/
_prepareOptions() {
const editor = this.editor;
const ckboxConfig = editor.config.get('ckbox');
const dialog = ckboxConfig.dialog;
const categories = ckboxConfig.categories;
const view = ckboxConfig.view;
const upload = ckboxConfig.upload;
return {
theme: ckboxConfig.theme,
language: ckboxConfig.language,
tokenUrl: ckboxConfig.tokenUrl,
serviceOrigin: ckboxConfig.serviceOrigin,
forceDemoLabel: ckboxConfig.forceDemoLabel,
choosableFileExtensions: ckboxConfig.choosableFileExtensions,
assets: {
onChoose: (assets) => this.fire('ckbox:choose', assets)
},
dialog: {
onClose: () => this.fire('ckbox:close'),
width: dialog && dialog.width,
height: dialog && dialog.height
},
categories: categories && {
icons: categories.icons
},
view: view && {
openLastView: view.openLastView,
startupFolderId: view.startupFolderId,
startupCategoryId: view.startupCategoryId,
hideMaximizeButton: view.hideMaximizeButton
},
upload: upload && {
componentsHideTimeout: upload.componentsHideTimeout,
dialogMinimizeTimeout: upload.dialogMinimizeTimeout
}
};
}
/**
* Initializes various event listeners for the `ckbox:*` events, because all functionality of the `ckbox` command is event-based.
*/
_initListeners() {
const editor = this.editor;
const model = editor.model;
const shouldInsertDataId = !editor.config.get('ckbox.ignoreDataId');
const downloadableFilesConfig = editor.config.get('ckbox.downloadableFiles');
// Refresh the command after firing the `ckbox:*` event.
this.on('ckbox', () => {
this.refresh();
}, { priority: 'low' });
// Handle opening of the CKBox dialog.
this.on('ckbox:open', () => {
if (!this.isEnabled || this.value) {
return;
}
this._wrapper = createElement(document, 'div', { class: 'ck ckbox-wrapper' });
document.body.appendChild(this._wrapper);
window.CKBox.mount(this._wrapper, this._prepareOptions());
});
// Handle closing of the CKBox dialog.
this.on('ckbox:close', () => {
if (!this.value) {
return;
}
this._wrapper.remove();
this._wrapper = null;
editor.editing.view.focus();
});
// Handle choosing the assets.
this.on('ckbox:choose', (evt, assets) => {
if (!this.isEnabled) {
return;
}
const imageCommand = editor.commands.get('insertImage');
const linkCommand = editor.commands.get('link');
const assetsToProcess = prepareAssets({
assets,
downloadableFilesConfig,
isImageAllowed: imageCommand.isEnabled,
isLinkAllowed: linkCommand.isEnabled
});
const assetsCount = assetsToProcess.length;
if (assetsCount === 0) {
return;
}
// All assets are inserted in one undo step.
model.change(writer => {
for (const asset of assetsToProcess) {
const isLastAsset = asset === assetsToProcess[assetsCount - 1];
const isSingleAsset = assetsCount === 1;
this._insertAsset(asset, isLastAsset, writer, isSingleAsset);
// If asset ID must be set for the inserted model element, store the asset temporarily and remove it automatically
// after the timeout.
if (shouldInsertDataId) {
setTimeout(() => this._chosenAssets.delete(asset), ASSET_INSERTION_WAIT_TIMEOUT);
this._chosenAssets.add(asset);
}
}
});
editor.editing.view.focus();
});
// Clean up after the editor is destroyed.
this.listenTo(editor, 'destroy', () => {
this.fire('ckbox:close');
this._chosenAssets.clear();
});
}
/**
* Inserts the asset into the model.
*
* @param asset The asset to be inserted.
* @param isLastAsset Indicates if the current asset is the last one from the chosen set.
* @param writer An instance of the model writer.
* @param isSingleAsset It's true when only one asset is processed.
*/
_insertAsset(asset, isLastAsset, writer, isSingleAsset) {
const editor = this.editor;
const model = editor.model;
const selection = model.document.selection;
// Remove the `linkHref` attribute to not affect the asset to be inserted.
writer.removeSelectionAttribute('linkHref');
if (asset.type === 'image') {
this._insertImage(asset);
}
else {
this._insertLink(asset, writer, isSingleAsset);
}
// Except for the last chosen asset, move the selection to the end of the current range to avoid overwriting other, already
// inserted assets.
if (!isLastAsset) {
writer.setSelection(selection.getLastPosition());
}
}
/**
* Inserts the image by calling the `insertImage` command.
*
* @param asset The asset to be inserted.
*/
_insertImage(asset) {
const editor = this.editor;
const { imageFallbackUrl, imageSources, imageTextAlternative, imageWidth, imageHeight, imagePlaceholder } = asset.attributes;
editor.execute('insertImage', {
source: {
src: imageFallbackUrl,
sources: imageSources,
alt: imageTextAlternative,
width: imageWidth,
height: imageHeight,
...(imagePlaceholder ? { placeholder: imagePlaceholder } : null)
}
});
}
/**
* Inserts the link to the asset by calling the `link` command.
*
* @param asset The asset to be inserted.
* @param writer An instance of the model writer.
* @param isSingleAsset It's true when only one asset is processed.
*/
_insertLink(asset, writer, isSingleAsset) {
const editor = this.editor;
const model = editor.model;
const selection = model.document.selection;
const { linkName, linkHref } = asset.attributes;
// If the selection is collapsed, insert the asset name as the link label and select it.
if (selection.isCollapsed) {
const selectionAttributes = toMap(selection.getAttributes());
const textNode = writer.createText(linkName, selectionAttributes);
if (!isSingleAsset) {
const selectionLastPosition = selection.getLastPosition();
const parentElement = selectionLastPosition.parent;
// Insert new `paragraph` when selection is not in an empty `paragraph`.
if (!(parentElement.name === 'paragraph' && parentElement.isEmpty)) {
editor.execute('insertParagraph', {
position: selectionLastPosition
});
}
const range = model.insertContent(textNode);
writer.setSelection(range);
editor.execute('link', linkHref);
return;
}
const range = model.insertContent(textNode);
writer.setSelection(range);
}
editor.execute('link', linkHref);
}
}
/**
* Parses the chosen assets into the internal data format. Filters out chosen assets that are not allowed.
*/
function prepareAssets({ downloadableFilesConfig, assets, isImageAllowed, isLinkAllowed }) {
return assets
.map(asset => isImage(asset) ?
{
id: asset.data.id,
type: 'image',
attributes: prepareImageAssetAttributes(asset)
} :
{
id: asset.data.id,
type: 'link',
attributes: prepareLinkAssetAttributes(asset, downloadableFilesConfig)
})
.filter(asset => asset.type === 'image' ? isImageAllowed : isLinkAllowed);
}
/**
* Parses the assets attributes into the internal data format.
*
* @internal
*/
export function prepareImageAssetAttributes(asset) {
const { imageFallbackUrl, imageSources } = getImageUrls(asset.data.imageUrls);
const { description, width, height, blurHash } = asset.data.metadata;
const imagePlaceholder = blurHashToDataUrl(blurHash);
return {
imageFallbackUrl,
imageSources,
imageTextAlternative: description || '',
imageWidth: width,
imageHeight: height,
...(imagePlaceholder ? { imagePlaceholder } : null)
};
}
/**
* Parses the assets attributes into the internal data format.
*
* @param asset The asset to prepare the attributes for.
* @param config The CKBox download asset configuration.
*/
function prepareLinkAssetAttributes(asset, config) {
return {
linkName: asset.data.name,
linkHref: getAssetUrl(asset, config)
};
}
/**
* Checks whether the asset is an image.
*/
function isImage(asset) {
const metadata = asset.data.metadata;
if (!metadata) {
return false;
}
return metadata.width && metadata.height;
}
/**
* Creates the URL for the asset.
*
* @param asset The asset to create the URL for.
* @param config The CKBox download asset configuration.
*/
function getAssetUrl(asset, config) {
const url = new URL(asset.data.url);
if (isDownloadableAsset(asset, config)) {
url.searchParams.set('download', 'true');
}
return url.toString();
}
/**
* Determines if download should be enabled for given asset based on configuration.
*
* @param asset The asset to check.
* @param config The CKBox download asset configuration.
*/
function isDownloadableAsset(asset, config) {
if (typeof config === 'function') {
return config(asset);
}
return true;
}