@ckeditor/ckeditor5-image
Version:
Image feature for CKEditor 5.
476 lines (475 loc) ⢠24.5 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 image/imageupload/imageuploadediting
*/
import { Plugin } from 'ckeditor5/src/core.js';
import { UpcastWriter } from 'ckeditor5/src/engine.js';
import { Notification } from 'ckeditor5/src/ui.js';
import { ClipboardPipeline } from 'ckeditor5/src/clipboard.js';
import { FileRepository } from 'ckeditor5/src/upload.js';
import { env } from 'ckeditor5/src/utils.js';
import ImageUtils from '../imageutils.js';
import UploadImageCommand from './uploadimagecommand.js';
import { fetchLocalImage, isLocalImage } from '../../src/imageupload/utils.js';
import { createImageTypeRegExp } from './utils.js';
/**
* The editing part of the image upload feature. It registers the `'uploadImage'` command
* and the `imageUpload` command as an aliased name.
*
* When an image is uploaded, it fires the {@link ~ImageUploadEditing#event:uploadComplete `uploadComplete`} event
* that allows adding custom attributes to the {@link module:engine/model/element~Element image element}.
*/
export default class ImageUploadEditing extends Plugin {
/**
* @inheritDoc
*/
static get requires() {
return [FileRepository, Notification, ClipboardPipeline, ImageUtils];
}
static get pluginName() {
return 'ImageUploadEditing';
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* An internal mapping of {@link module:upload/filerepository~FileLoader#id file loader UIDs} and
* model elements during the upload.
*
* Model element of the uploaded image can change, for instance, when {@link module:image/image/imagetypecommand~ImageTypeCommand}
* is executed as a result of adding caption or changing image style. As a result, the upload logic must keep track of the model
* element (reference) and resolve the upload for the correct model element (instead of the one that landed in the `$graveyard`
* after image type changed).
*/
_uploadImageElements;
/**
* An internal mapping of {@link module:upload/filerepository~FileLoader#id file loader UIDs} and
* upload responses for handling images dragged during their upload process. When such images are later
* dropped, their original upload IDs no longer exist in the registry (as the original upload completed).
* This map preserves the upload responses to properly handle such cases.
*/
_uploadedImages = new Map();
/**
* @inheritDoc
*/
constructor(editor) {
super(editor);
editor.config.define('image', {
upload: {
types: ['jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff']
}
});
this._uploadImageElements = new Map();
}
/**
* @inheritDoc
*/
init() {
const editor = this.editor;
const doc = editor.model.document;
const conversion = editor.conversion;
const fileRepository = editor.plugins.get(FileRepository);
const imageUtils = editor.plugins.get('ImageUtils');
const clipboardPipeline = editor.plugins.get('ClipboardPipeline');
const imageTypes = createImageTypeRegExp(editor.config.get('image.upload.types'));
const uploadImageCommand = new UploadImageCommand(editor);
// Register `uploadImage` command and add `imageUpload` command as an alias for backward compatibility.
editor.commands.add('uploadImage', uploadImageCommand);
editor.commands.add('imageUpload', uploadImageCommand);
// Register upcast converter for uploadId.
conversion.for('upcast')
.attributeToAttribute({
view: {
name: 'img',
key: 'uploadId'
},
model: 'uploadId'
})
// Handle the case when the image is not fully uploaded yet but it's being moved.
// See more: https://github.com/ckeditor/ckeditor5/pull/17327
.add(dispatcher => dispatcher.on('element:img', (evt, data, conversionApi) => {
if (!conversionApi.consumable.test(data.viewItem, { attributes: ['data-ck-upload-id'] })) {
return;
}
const uploadId = data.viewItem.getAttribute('data-ck-upload-id');
if (!uploadId) {
return;
}
const [modelElement] = Array.from(data.modelRange.getItems({ shallow: true }));
const loader = fileRepository.loaders.get(uploadId);
if (modelElement) {
// Handle case when `uploadId` is set on the image element but the loader is not present in the registry.
// It may happen when the image was successfully uploaded and the loader was removed from the registry.
// It's still present in the `_uploadedImages` map though. It's why we do not place this line in the condition below.
conversionApi.writer.setAttribute('uploadId', uploadId, modelElement);
conversionApi.consumable.consume(data.viewItem, { attributes: ['data-ck-upload-id'] });
if (loader && loader.data) {
conversionApi.writer.setAttribute('uploadStatus', loader.status, modelElement);
}
}
}, { priority: 'low' }));
// Handle pasted images.
// For every image file, a new file loader is created and a placeholder image is
// inserted into the content. Then, those images are uploaded once they appear in the model
// (see Document#change listener below).
this.listenTo(editor.editing.view.document, 'clipboardInput', (evt, data) => {
// Skip if non empty HTML data is included.
// https://github.com/ckeditor/ckeditor5-upload/issues/68
if (isHtmlIncluded(data.dataTransfer)) {
return;
}
const images = Array.from(data.dataTransfer.files).filter(file => {
// See https://github.com/ckeditor/ckeditor5-image/pull/254.
if (!file) {
return false;
}
return imageTypes.test(file.type);
});
if (!images.length) {
return;
}
evt.stop();
editor.model.change(writer => {
// Set selection to paste target.
if (data.targetRanges) {
writer.setSelection(data.targetRanges.map(viewRange => editor.editing.mapper.toModelRange(viewRange)));
}
editor.execute('uploadImage', { file: images });
});
const uploadImageCommand = editor.commands.get('uploadImage');
if (!uploadImageCommand.isAccessAllowed) {
const notification = editor.plugins.get('Notification');
const t = editor.locale.t;
notification.showWarning(t('You have no image upload permissions.'), {
namespace: 'image'
});
}
});
// Handle HTML pasted with images with base64 or blob sources.
// For every image file, a new file loader is created and a placeholder image is
// inserted into the content. Then, those images are uploaded once they appear in the model
// (see Document#change listener below).
this.listenTo(clipboardPipeline, 'inputTransformation', (evt, data) => {
const fetchableImages = Array.from(editor.editing.view.createRangeIn(data.content))
.map(value => value.item)
.filter(viewElement => isLocalImage(imageUtils, viewElement) &&
!viewElement.getAttribute('uploadProcessed'))
.map(viewElement => { return { promise: fetchLocalImage(viewElement), imageElement: viewElement }; });
if (!fetchableImages.length) {
return;
}
const writer = new UpcastWriter(editor.editing.view.document);
for (const fetchableImage of fetchableImages) {
// Set attribute marking that the image was processed already.
writer.setAttribute('uploadProcessed', true, fetchableImage.imageElement);
const loader = fileRepository.createLoader(fetchableImage.promise);
if (loader) {
writer.setAttribute('src', '', fetchableImage.imageElement);
writer.setAttribute('uploadId', loader.id, fetchableImage.imageElement);
}
}
});
// Prevents from the browser redirecting to the dropped image.
editor.editing.view.document.on('dragover', (evt, data) => {
data.preventDefault();
});
// Upload placeholder images that appeared in the model.
doc.on('change', () => {
// Note: Reversing changes to start with insertions and only then handle removals. If it was the other way around,
// loaders for **all** images that land in the $graveyard would abort while in fact only those that were **not** replaced
// by other images should be aborted.
const changes = doc.differ.getChanges({ includeChangesInGraveyard: true }).reverse();
const insertedImagesIds = new Set();
for (const entry of changes) {
if (entry.type == 'insert' && entry.name != '$text') {
const item = entry.position.nodeAfter;
const isInsertedInGraveyard = entry.position.root.rootName == '$graveyard';
for (const imageElement of getImagesFromChangeItem(editor, item)) {
// Check if the image element still has upload id.
const uploadId = imageElement.getAttribute('uploadId');
if (!uploadId) {
continue;
}
// Check if the image is loaded on this client.
const loader = fileRepository.loaders.get(uploadId);
if (!loader) {
// If the loader does not exist, it means that the image was already uploaded
// and the loader promise was removed from the registry. In that scenario we need
// to restore response object from the internal map.
if (!isInsertedInGraveyard && this._uploadedImages.has(uploadId)) {
// Fire `uploadComplete` to set proper attributes on the image element.
editor.model.enqueueChange({ isUndoable: false }, writer => {
writer.setAttribute('uploadStatus', 'complete', imageElement);
this.fire('uploadComplete', {
data: this._uploadedImages.get(uploadId),
imageElement: imageElement
});
});
// While it makes sense to remove the image from the `_uploadedImages` map here,
// it's counterintuitive for the user that pastes image in uploading several times.
// It'll work the first time, but the next time the image will be empty because the
// `_uploadedImages` no longer contain the response.
}
continue;
}
if (isInsertedInGraveyard) {
// If the image was inserted to the graveyard for good (**not** replaced by another image),
// only then abort the loading process.
if (!insertedImagesIds.has(uploadId)) {
// ... but abort it only if all remain images that share the same loader are in the graveyard too.
// This is to prevent situation when we have two images in uploading state and one of them is being
// placed in the graveyard (e.g. using undo). The other one should not be aborted.
const allImagesThatShareUploaderInGraveyard = Array
.from(this._uploadImageElements.get(uploadId))
.every(element => element.root.rootName == '$graveyard');
if (allImagesThatShareUploaderInGraveyard) {
loader.abort();
}
}
}
else {
// Remember the upload id of the inserted image. If it acted as a replacement for another
// image (which landed in the $graveyard), the related loader will not be aborted because
// this is still the same image upload.
insertedImagesIds.add(uploadId);
// Keep the mapping between the upload ID and the image model element so the upload
// can later resolve in the context of the correct model element. The model element could
// change for the same upload if one image was replaced by another (e.g. image type was changed),
// so this may also replace an existing mapping.
if (!this._uploadImageElements.has(uploadId)) {
this._uploadImageElements.set(uploadId, new Set([imageElement]));
}
else {
this._uploadImageElements.get(uploadId).add(imageElement);
}
if (loader.status == 'idle') {
// If the image was inserted into content and has not been loaded yet, start loading it.
this._readAndUpload(loader);
}
}
}
}
}
});
// Set the default handler for feeding the image element with `src` and `srcset` attributes.
// Also set the natural `width` and `height` attributes (if not already set).
this.on('uploadComplete', (evt, { imageElement, data }) => {
const urls = data.urls ? data.urls : data;
this.editor.model.change(writer => {
writer.setAttribute('src', urls.default, imageElement);
this._parseAndSetSrcsetAttributeOnImage(urls, imageElement, writer);
imageUtils.setImageNaturalSizeAttributes(imageElement);
});
}, { priority: 'low' });
}
/**
* @inheritDoc
*/
afterInit() {
const schema = this.editor.model.schema;
// Setup schema to allow uploadId and uploadStatus for images.
// Wait for ImageBlockEditing or ImageInlineEditing to register their elements first,
// that's why doing this in afterInit() instead of init().
if (this.editor.plugins.has('ImageBlockEditing')) {
schema.extend('imageBlock', {
allowAttributes: ['uploadId', 'uploadStatus']
});
this._registerConverters('imageBlock');
}
if (this.editor.plugins.has('ImageInlineEditing')) {
schema.extend('imageInline', {
allowAttributes: ['uploadId', 'uploadStatus']
});
this._registerConverters('imageInline');
}
}
/**
* Reads and uploads an image.
*
* The image is read from the disk and as a Base64-encoded string it is set temporarily to
* `image[src]`. When the image is successfully uploaded, the temporary data is replaced with the target
* image's URL (the URL to the uploaded image on the server).
*/
_readAndUpload(loader) {
const editor = this.editor;
const model = editor.model;
const t = editor.locale.t;
const fileRepository = editor.plugins.get(FileRepository);
const notification = editor.plugins.get(Notification);
const imageUtils = editor.plugins.get('ImageUtils');
const imageUploadElements = this._uploadImageElements;
model.enqueueChange({ isUndoable: false }, writer => {
const elements = imageUploadElements.get(loader.id);
for (const element of elements) {
writer.setAttribute('uploadStatus', 'reading', element);
}
});
return loader.read()
.then(() => {
const promise = loader.upload();
if (editor.ui) {
editor.ui.ariaLiveAnnouncer.announce(t('Uploading image'));
}
for (const imageElement of imageUploadElements.get(loader.id)) {
// Force reāpaint in Safari. Without it, the image will display with a wrong size.
// https://github.com/ckeditor/ckeditor5/issues/1975
/* istanbul ignore next -- @preserve */
if (env.isSafari) {
const viewFigure = editor.editing.mapper.toViewElement(imageElement);
const viewImg = imageUtils.findViewImgElement(viewFigure);
editor.editing.view.once('render', () => {
// Early returns just to be safe. There might be some code ran
// in between the outer scope and this callback.
if (!viewImg.parent) {
return;
}
const domFigure = editor.editing.view.domConverter.mapViewToDom(viewImg.parent);
if (!domFigure) {
return;
}
const originalDisplay = domFigure.style.display;
domFigure.style.display = 'none';
// Make sure this line will never be removed during minification for having "no effect".
domFigure._ckHack = domFigure.offsetHeight;
domFigure.style.display = originalDisplay;
});
}
model.enqueueChange({ isUndoable: false }, writer => {
writer.setAttribute('uploadStatus', 'uploading', imageElement);
});
}
return promise;
})
.then(data => {
model.enqueueChange({ isUndoable: false }, writer => {
for (const imageElement of imageUploadElements.get(loader.id)) {
writer.setAttribute('uploadStatus', 'complete', imageElement);
this.fire('uploadComplete', { data, imageElement });
}
if (editor.ui) {
editor.ui.ariaLiveAnnouncer.announce(t('Image upload complete'));
}
this._uploadedImages.set(loader.id, data);
});
clean();
})
.catch(error => {
if (editor.ui) {
editor.ui.ariaLiveAnnouncer.announce(t('Error during image upload'));
}
// If status is not 'error' nor 'aborted' - throw error because it means that something else went wrong,
// it might be generic error and it would be real pain to find what is going on.
if (loader.status !== 'error' && loader.status !== 'aborted') {
throw error;
}
// Might be 'aborted'.
if (loader.status == 'error' && error) {
notification.showWarning(error, {
title: t('Upload failed'),
namespace: 'upload'
});
}
// Permanently remove image from insertion batch.
model.enqueueChange({ isUndoable: false }, writer => {
for (const imageElement of imageUploadElements.get(loader.id)) {
// Handle situation when the image has been removed and then `abort` exception was thrown.
// See: https://github.com/cksource/ckeditor5-commercial/issues/6817
if (imageElement.root.rootName !== '$graveyard') {
writer.remove(imageElement);
}
}
});
clean();
});
function clean() {
model.enqueueChange({ isUndoable: false }, writer => {
for (const imageElement of imageUploadElements.get(loader.id)) {
writer.removeAttribute('uploadId', imageElement);
writer.removeAttribute('uploadStatus', imageElement);
}
imageUploadElements.delete(loader.id);
});
fileRepository.destroyLoader(loader);
}
}
/**
* Creates the `srcset` attribute based on a given file upload response and sets it as an attribute to a specific image element.
*
* @param data Data object from which `srcset` will be created.
* @param image The image element on which the `srcset` attribute will be set.
*/
_parseAndSetSrcsetAttributeOnImage(data, image, writer) {
// Srcset attribute for responsive images support.
let maxWidth = 0;
const srcsetAttribute = Object.keys(data)
// Filter out keys that are not integers.
.filter(key => {
const width = parseInt(key, 10);
if (!isNaN(width)) {
maxWidth = Math.max(maxWidth, width);
return true;
}
})
// Convert each key to srcset entry.
.map(key => `${data[key]} ${key}w`)
// Join all entries.
.join(', ');
if (srcsetAttribute != '') {
const attributes = {
srcset: srcsetAttribute
};
if (!image.hasAttribute('width') && !image.hasAttribute('height')) {
attributes.width = maxWidth;
}
writer.setAttributes(attributes, image);
}
}
/**
* Registers image upload converters.
*
* @param imageType The type of the image.
*/
_registerConverters(imageType) {
const { conversion, plugins } = this.editor;
const fileRepository = plugins.get(FileRepository);
const imageUtils = plugins.get(ImageUtils);
// It sets `data-ck-upload-id` attribute on the view image elements that are not fully uploaded.
// It avoids the situation when image disappears when it's being moved and upload is not finished yet.
// See more: https://github.com/ckeditor/ckeditor5/issues/16967
conversion.for('dataDowncast').add(dispatcher => {
dispatcher.on(`attribute:uploadId:${imageType}`, (evt, data, conversionApi) => {
if (!conversionApi.consumable.test(data.item, evt.name)) {
return;
}
const loader = fileRepository.loaders.get(data.attributeNewValue);
if (!loader || !loader.data) {
return null;
}
const viewElement = conversionApi.mapper.toViewElement(data.item);
const img = imageUtils.findViewImgElement(viewElement);
if (img) {
conversionApi.consumable.consume(data.item, evt.name);
conversionApi.writer.setAttribute('data-ck-upload-id', loader.id, img);
}
});
});
}
}
/**
* Returns `true` if non-empty `text/html` is included in the data transfer.
*/
export function isHtmlIncluded(dataTransfer) {
return Array.from(dataTransfer.types).includes('text/html') && dataTransfer.getData('text/html') !== '';
}
function getImagesFromChangeItem(editor, item) {
const imageUtils = editor.plugins.get('ImageUtils');
return Array.from(editor.model.createRangeOn(item))
.filter(value => imageUtils.isImage(value.item))
.map(value => value.item);
}