UNPKG

@ckeditor/ckeditor5-image

Version:

Image feature for CKEditor 5.

338 lines (337 loc) • 16.2 kB
/** * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ /** * @module image/imageupload/imageuploadediting */ import { Plugin } from 'ckeditor5/src/core'; import { UpcastWriter } from 'ckeditor5/src/engine'; import { Notification } from 'ckeditor5/src/ui'; import { ClipboardPipeline } from 'ckeditor5/src/clipboard'; import { FileRepository } from 'ckeditor5/src/upload'; import { env } from 'ckeditor5/src/utils'; import ImageUtils from '../imageutils'; import UploadImageCommand from './uploadimagecommand'; import { fetchLocalImage, isLocalImage } from '../../src/imageupload/utils'; import { createImageTypeRegExp } from './utils'; /** * 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 */ 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 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 }); }); }); // 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) { 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)) { 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. this._uploadImageElements.set(uploadId, 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'] }); } if (this.editor.plugins.has('ImageInlineEditing')) { schema.extend('imageInline', { allowAttributes: ['uploadId', 'uploadStatus'] }); } } /** * 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 => { writer.setAttribute('uploadStatus', 'reading', imageUploadElements.get(loader.id)); }); return loader.read() .then(() => { const promise = loader.upload(); const imageElement = 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 => { const imageElement = imageUploadElements.get(loader.id); writer.setAttribute('uploadStatus', 'complete', imageElement); this.fire('uploadComplete', { data, imageElement }); }); clean(); }) .catch(error => { // 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 => { writer.remove(imageUploadElements.get(loader.id)); }); clean(); }); function clean() { model.enqueueChange({ isUndoable: false }, writer => { const imageElement = 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); } } } /** * 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); }