@ckeditor/ckeditor5-ckbox
Version:
CKBox integration for CKEditor 5.
389 lines (388 loc) • 16.6 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/ckboxediting
*/
import { Plugin } from 'ckeditor5/src/core.js';
import { Range } from 'ckeditor5/src/engine.js';
import { logError } from 'ckeditor5/src/utils.js';
import CKBoxCommand from './ckboxcommand.js';
import CKBoxUploadAdapter from './ckboxuploadadapter.js';
import CKBoxUtils from './ckboxutils.js';
import { sendHttpRequest } from './utils.js';
const COMMAND_FORCE_DISABLE_ID = 'NoPermission';
/**
* The CKBox editing feature. It introduces the {@link module:ckbox/ckboxcommand~CKBoxCommand CKBox command} and
* {@link module:ckbox/ckboxuploadadapter~CKBoxUploadAdapter CKBox upload adapter}.
*/
export default class CKBoxEditing extends Plugin {
/**
* @inheritDoc
*/
static get pluginName() {
return 'CKBoxEditing';
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/
static get requires() {
return ['LinkEditing', 'PictureEditing', CKBoxUploadAdapter, CKBoxUtils];
}
/**
* @inheritDoc
*/
init() {
const editor = this.editor;
if (!this._shouldBeInitialised()) {
return;
}
this._checkImagePlugins();
// Registering the `ckbox` command makes sense only if the CKBox library is loaded, as the `ckbox` command opens the CKBox dialog.
if (isLibraryLoaded()) {
editor.commands.add('ckbox', new CKBoxCommand(editor));
}
// Promise is not handled intentionally. Errors should be displayed in console if there are so.
isUploadPermissionGranted(editor).then(isCreateAssetAllowed => {
if (!isCreateAssetAllowed) {
this._blockImageCommands();
}
});
}
/**
* @inheritDoc
*/
afterInit() {
const editor = this.editor;
if (!this._shouldBeInitialised()) {
return;
}
// Extending the schema, registering converters and applying fixers only make sense if the configuration option to assign
// the assets ID with the model elements is enabled.
if (!editor.config.get('ckbox.ignoreDataId')) {
this._initSchema();
this._initConversion();
this._initFixers();
}
}
/**
* Returns true only when the integrator intentionally wants to use the plugin, i.e. when the `config.ckbox` exists or
* the CKBox JavaScript library is loaded.
*/
_shouldBeInitialised() {
const editor = this.editor;
const hasConfiguration = !!editor.config.get('ckbox');
return hasConfiguration || isLibraryLoaded();
}
/**
* Blocks `uploadImage` and `ckboxImageEdit` commands.
*/
_blockImageCommands() {
const editor = this.editor;
const uploadImageCommand = editor.commands.get('uploadImage');
const imageEditingCommand = editor.commands.get('ckboxImageEdit');
if (uploadImageCommand) {
uploadImageCommand.isAccessAllowed = false;
uploadImageCommand.forceDisabled(COMMAND_FORCE_DISABLE_ID);
}
if (imageEditingCommand) {
imageEditingCommand.forceDisabled(COMMAND_FORCE_DISABLE_ID);
}
}
/**
* Checks if at least one image plugin is loaded.
*/
_checkImagePlugins() {
const editor = this.editor;
if (!editor.plugins.has('ImageBlockEditing') && !editor.plugins.has('ImageInlineEditing')) {
/**
* The CKBox feature requires one of the following plugins to be loaded to work correctly:
*
* * {@link module:image/imageblock~ImageBlock},
* * {@link module:image/imageinline~ImageInline},
* * {@link module:image/image~Image} (loads both `ImageBlock` and `ImageInline`)
*
* Please make sure your editor configuration is correct.
*
* @error ckbox-plugin-image-feature-missing
* @param {module:core/editor/editor~Editor} editor The editor instance.
*/
logError('ckbox-plugin-image-feature-missing', editor);
}
}
/**
* Extends the schema to allow the `ckboxImageId` and `ckboxLinkId` attributes for links and images.
*/
_initSchema() {
const editor = this.editor;
const schema = editor.model.schema;
schema.extend('$text', { allowAttributes: 'ckboxLinkId' });
if (schema.isRegistered('imageBlock')) {
schema.extend('imageBlock', { allowAttributes: ['ckboxImageId', 'ckboxLinkId'] });
}
if (schema.isRegistered('imageInline')) {
schema.extend('imageInline', { allowAttributes: ['ckboxImageId', 'ckboxLinkId'] });
}
schema.addAttributeCheck(context => {
// Don't allow `ckboxLinkId` on elements which do not have `linkHref` attribute.
if (!context.last.getAttribute('linkHref')) {
return false;
}
}, 'ckboxLinkId');
}
/**
* Configures the upcast and downcast conversions for the `ckboxImageId` and `ckboxLinkId` attributes.
*/
_initConversion() {
const editor = this.editor;
// Convert `ckboxLinkId` => `data-ckbox-resource-id`.
editor.conversion.for('downcast').add(dispatcher => {
// Due to custom converters for linked block images, handle the `ckboxLinkId` attribute manually.
dispatcher.on('attribute:ckboxLinkId:imageBlock', (evt, data, conversionApi) => {
const { writer, mapper, consumable } = conversionApi;
if (!consumable.consume(data.item, evt.name)) {
return;
}
const viewFigure = mapper.toViewElement(data.item);
const linkInImage = [...viewFigure.getChildren()]
.find((child) => child.name === 'a');
// No link inside an image - no conversion needed.
if (!linkInImage) {
return;
}
if (data.item.hasAttribute('ckboxLinkId')) {
writer.setAttribute('data-ckbox-resource-id', data.item.getAttribute('ckboxLinkId'), linkInImage);
}
else {
writer.removeAttribute('data-ckbox-resource-id', linkInImage);
}
}, { priority: 'low' });
dispatcher.on('attribute:ckboxLinkId', (evt, data, conversionApi) => {
const { writer, mapper, consumable } = conversionApi;
if (!consumable.consume(data.item, evt.name)) {
return;
}
// Remove the previous attribute value if it was applied.
if (data.attributeOldValue) {
const viewElement = createLinkElement(writer, data.attributeOldValue);
writer.unwrap(mapper.toViewRange(data.range), viewElement);
}
// Add the new attribute value if specified in a model element.
if (data.attributeNewValue) {
const viewElement = createLinkElement(writer, data.attributeNewValue);
if (data.item.is('selection')) {
const viewSelection = writer.document.selection;
writer.wrap(viewSelection.getFirstRange(), viewElement);
}
else {
writer.wrap(mapper.toViewRange(data.range), viewElement);
}
}
}, { priority: 'low' });
});
// Convert `data-ckbox-resource-id` => `ckboxLinkId`.
//
// The helper conversion does not handle all cases, so take care of the `data-ckbox-resource-id` attribute manually for images
// and links.
editor.conversion.for('upcast').add(dispatcher => {
dispatcher.on('element:a', (evt, data, conversionApi) => {
const { writer, consumable } = conversionApi;
// Upcast the `data-ckbox-resource-id` attribute only for valid link elements.
if (!data.viewItem.getAttribute('href')) {
return;
}
const consumableAttributes = { attributes: ['data-ckbox-resource-id'] };
if (!consumable.consume(data.viewItem, consumableAttributes)) {
return;
}
const attributeValue = data.viewItem.getAttribute('data-ckbox-resource-id');
// Missing the `data-ckbox-resource-id` attribute.
if (!attributeValue) {
return;
}
if (data.modelRange) {
// If the `<a>` element contains more than single children (e.g. a linked image), set the `ckboxLinkId` for each
// allowed child.
for (let item of data.modelRange.getItems()) {
if (item.is('$textProxy')) {
item = item.textNode;
}
// Do not copy the `ckboxLinkId` attribute when wrapping an element in a block element, e.g. when
// auto-paragraphing.
if (shouldUpcastAttributeForNode(item)) {
writer.setAttribute('ckboxLinkId', attributeValue, item);
}
}
}
else {
// Otherwise, just set the `ckboxLinkId` for the model element.
const modelElement = data.modelCursor.nodeBefore || data.modelCursor.parent;
writer.setAttribute('ckboxLinkId', attributeValue, modelElement);
}
}, { priority: 'low' });
});
// Convert `ckboxImageId` => `data-ckbox-resource-id`.
editor.conversion.for('downcast').attributeToAttribute({
model: 'ckboxImageId',
view: 'data-ckbox-resource-id'
});
// Convert `data-ckbox-resource-id` => `ckboxImageId`.
editor.conversion.for('upcast').elementToAttribute({
model: {
key: 'ckboxImageId',
value: (viewElement) => viewElement.getAttribute('data-ckbox-resource-id')
},
view: {
attributes: {
'data-ckbox-resource-id': /[\s\S]+/
}
}
});
const replaceImageSourceCommand = editor.commands.get('replaceImageSource');
if (replaceImageSourceCommand) {
this.listenTo(replaceImageSourceCommand, 'cleanupImage', (_, [writer, image]) => {
writer.removeAttribute('ckboxImageId', image);
});
}
}
/**
* Registers post-fixers that add or remove the `ckboxLinkId` and `ckboxImageId` attributes.
*/
_initFixers() {
const editor = this.editor;
const model = editor.model;
const selection = model.document.selection;
// Registers the post-fixer to sync the asset ID with the model elements.
model.document.registerPostFixer(syncDataIdPostFixer(editor));
// Registers the post-fixer to remove the `ckboxLinkId` attribute from the model selection.
model.document.registerPostFixer(injectSelectionPostFixer(selection));
}
}
/**
* A post-fixer that synchronizes the asset ID with the model element.
*/
function syncDataIdPostFixer(editor) {
return (writer) => {
let changed = false;
const model = editor.model;
const ckboxCommand = editor.commands.get('ckbox');
// The ID from chosen assets are stored in the `CKBoxCommand#_chosenAssets`. If there is no command, it makes no sense to check
// for changes in the model.
if (!ckboxCommand) {
return changed;
}
for (const entry of model.document.differ.getChanges()) {
if (entry.type !== 'insert' && entry.type !== 'attribute') {
continue;
}
const range = entry.type === 'insert' ?
new Range(entry.position, entry.position.getShiftedBy(entry.length)) :
entry.range;
const isLinkHrefAttributeRemoval = entry.type === 'attribute' &&
entry.attributeKey === 'linkHref' &&
entry.attributeNewValue === null;
for (const item of range.getItems()) {
// If the `linkHref` attribute has been removed, sync the change with the `ckboxLinkId` attribute.
if (isLinkHrefAttributeRemoval && item.hasAttribute('ckboxLinkId')) {
writer.removeAttribute('ckboxLinkId', item);
changed = true;
continue;
}
// Otherwise, the change concerns either a new model element or an attribute change. Try to find the assets for the modified
// model element.
const assets = findAssetsForItem(item, ckboxCommand._chosenAssets);
for (const asset of assets) {
const attributeName = asset.type === 'image' ? 'ckboxImageId' : 'ckboxLinkId';
if (asset.id === item.getAttribute(attributeName)) {
continue;
}
writer.setAttribute(attributeName, asset.id, item);
changed = true;
}
}
}
return changed;
};
}
/**
* A post-fixer that removes the `ckboxLinkId` from the selection if it does not represent a link anymore.
*/
function injectSelectionPostFixer(selection) {
return (writer) => {
const shouldRemoveLinkIdAttribute = !selection.hasAttribute('linkHref') && selection.hasAttribute('ckboxLinkId');
if (shouldRemoveLinkIdAttribute) {
writer.removeSelectionAttribute('ckboxLinkId');
return true;
}
return false;
};
}
/**
* Tries to find the asset that is associated with the model element by comparing the attributes:
* - the image fallback URL with the `src` attribute for images,
* - the link URL with the `href` attribute for links.
*
* For any model element, zero, one or more than one asset can be found (e.g. a linked image may be associated with the link asset and the
* image asset).
*/
function findAssetsForItem(item, assets) {
const isImageElement = item.is('element', 'imageInline') || item.is('element', 'imageBlock');
const isLinkElement = item.hasAttribute('linkHref');
return [...assets].filter(asset => {
if (asset.type === 'image' && isImageElement) {
return asset.attributes.imageFallbackUrl === item.getAttribute('src');
}
if (asset.type === 'link' && isLinkElement) {
return asset.attributes.linkHref === item.getAttribute('linkHref');
}
});
}
/**
* Creates view link element with the requested ID.
*/
function createLinkElement(writer, id) {
// Priority equal 5 is needed to merge adjacent `<a>` elements together.
const viewElement = writer.createAttributeElement('a', { 'data-ckbox-resource-id': id }, { priority: 5 });
writer.setCustomProperty('link', true, viewElement);
return viewElement;
}
/**
* Checks if the model element may have the `ckboxLinkId` attribute.
*/
function shouldUpcastAttributeForNode(node) {
if (node.is('$text')) {
return true;
}
if (node.is('element', 'imageInline') || node.is('element', 'imageBlock')) {
return true;
}
return false;
}
/**
* Returns true if the CKBox library is loaded, false otherwise.
*/
function isLibraryLoaded() {
return !!window.CKBox;
}
/**
* Checks is access allowed to upload assets.
*/
async function isUploadPermissionGranted(editor) {
const ckboxUtils = editor.plugins.get(CKBoxUtils);
const origin = editor.config.get('ckbox.serviceOrigin');
const url = new URL('permissions', origin);
const { value } = await ckboxUtils.getToken();
const response = (await sendHttpRequest({
url,
authorization: value,
signal: (new AbortController()).signal // Aborting is unnecessary.
}));
return Object.values(response).some(category => category['asset:create']);
}