UNPKG

16.3 kBJavaScriptView Raw
1/**
2 * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
3 * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
4 */
5/**
6 * @module image/imageupload/imageuploadediting
7 */
8import { Plugin } from 'ckeditor5/src/core.js';
9import { UpcastWriter } from 'ckeditor5/src/engine.js';
10import { Notification } from 'ckeditor5/src/ui.js';
11import { ClipboardPipeline } from 'ckeditor5/src/clipboard.js';
12import { FileRepository } from 'ckeditor5/src/upload.js';
13import { env } from 'ckeditor5/src/utils.js';
14import ImageUtils from '../imageutils.js';
15import UploadImageCommand from './uploadimagecommand.js';
16import { fetchLocalImage, isLocalImage } from '../../src/imageupload/utils.js';
17import { createImageTypeRegExp } from './utils.js';
18/**
19 * The editing part of the image upload feature. It registers the `'uploadImage'` command
20 * and the `imageUpload` command as an aliased name.
21 *
22 * When an image is uploaded, it fires the {@link ~ImageUploadEditing#event:uploadComplete `uploadComplete`} event
23 * that allows adding custom attributes to the {@link module:engine/model/element~Element image element}.
24 */
25export default class ImageUploadEditing extends Plugin {
26 /**
27 * @inheritDoc
28 */
29 static get requires() {
30 return [FileRepository, Notification, ClipboardPipeline, ImageUtils];
31 }
32 static get pluginName() {
33 return 'ImageUploadEditing';
34 }
35 /**
36 * @inheritDoc
37 */
38 constructor(editor) {
39 super(editor);
40 editor.config.define('image', {
41 upload: {
42 types: ['jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff']
43 }
44 });
45 this._uploadImageElements = new Map();
46 }
47 /**
48 * @inheritDoc
49 */
50 init() {
51 const editor = this.editor;
52 const doc = editor.model.document;
53 const conversion = editor.conversion;
54 const fileRepository = editor.plugins.get(FileRepository);
55 const imageUtils = editor.plugins.get('ImageUtils');
56 const clipboardPipeline = editor.plugins.get('ClipboardPipeline');
57 const imageTypes = createImageTypeRegExp(editor.config.get('image.upload.types'));
58 const uploadImageCommand = new UploadImageCommand(editor);
59 // Register `uploadImage` command and add `imageUpload` command as an alias for backward compatibility.
60 editor.commands.add('uploadImage', uploadImageCommand);
61 editor.commands.add('imageUpload', uploadImageCommand);
62 // Register upcast converter for uploadId.
63 conversion.for('upcast')
64 .attributeToAttribute({
65 view: {
66 name: 'img',
67 key: 'uploadId'
68 },
69 model: 'uploadId'
70 });
71 // Handle pasted images.
72 // For every image file, a new file loader is created and a placeholder image is
73 // inserted into the content. Then, those images are uploaded once they appear in the model
74 // (see Document#change listener below).
75 this.listenTo(editor.editing.view.document, 'clipboardInput', (evt, data) => {
76 // Skip if non empty HTML data is included.
77 // https://github.com/ckeditor/ckeditor5-upload/issues/68
78 if (isHtmlIncluded(data.dataTransfer)) {
79 return;
80 }
81 const images = Array.from(data.dataTransfer.files).filter(file => {
82 // See https://github.com/ckeditor/ckeditor5-image/pull/254.
83 if (!file) {
84 return false;
85 }
86 return imageTypes.test(file.type);
87 });
88 if (!images.length) {
89 return;
90 }
91 evt.stop();
92 editor.model.change(writer => {
93 // Set selection to paste target.
94 if (data.targetRanges) {
95 writer.setSelection(data.targetRanges.map(viewRange => editor.editing.mapper.toModelRange(viewRange)));
96 }
97 editor.execute('uploadImage', { file: images });
98 });
99 });
100 // Handle HTML pasted with images with base64 or blob sources.
101 // For every image file, a new file loader is created and a placeholder image is
102 // inserted into the content. Then, those images are uploaded once they appear in the model
103 // (see Document#change listener below).
104 this.listenTo(clipboardPipeline, 'inputTransformation', (evt, data) => {
105 const fetchableImages = Array.from(editor.editing.view.createRangeIn(data.content))
106 .map(value => value.item)
107 .filter(viewElement => isLocalImage(imageUtils, viewElement) &&
108 !viewElement.getAttribute('uploadProcessed'))
109 .map(viewElement => { return { promise: fetchLocalImage(viewElement), imageElement: viewElement }; });
110 if (!fetchableImages.length) {
111 return;
112 }
113 const writer = new UpcastWriter(editor.editing.view.document);
114 for (const fetchableImage of fetchableImages) {
115 // Set attribute marking that the image was processed already.
116 writer.setAttribute('uploadProcessed', true, fetchableImage.imageElement);
117 const loader = fileRepository.createLoader(fetchableImage.promise);
118 if (loader) {
119 writer.setAttribute('src', '', fetchableImage.imageElement);
120 writer.setAttribute('uploadId', loader.id, fetchableImage.imageElement);
121 }
122 }
123 });
124 // Prevents from the browser redirecting to the dropped image.
125 editor.editing.view.document.on('dragover', (evt, data) => {
126 data.preventDefault();
127 });
128 // Upload placeholder images that appeared in the model.
129 doc.on('change', () => {
130 // Note: Reversing changes to start with insertions and only then handle removals. If it was the other way around,
131 // loaders for **all** images that land in the $graveyard would abort while in fact only those that were **not** replaced
132 // by other images should be aborted.
133 const changes = doc.differ.getChanges({ includeChangesInGraveyard: true }).reverse();
134 const insertedImagesIds = new Set();
135 for (const entry of changes) {
136 if (entry.type == 'insert' && entry.name != '$text') {
137 const item = entry.position.nodeAfter;
138 const isInsertedInGraveyard = entry.position.root.rootName == '$graveyard';
139 for (const imageElement of getImagesFromChangeItem(editor, item)) {
140 // Check if the image element still has upload id.
141 const uploadId = imageElement.getAttribute('uploadId');
142 if (!uploadId) {
143 continue;
144 }
145 // Check if the image is loaded on this client.
146 const loader = fileRepository.loaders.get(uploadId);
147 if (!loader) {
148 continue;
149 }
150 if (isInsertedInGraveyard) {
151 // If the image was inserted to the graveyard for good (**not** replaced by another image),
152 // only then abort the loading process.
153 if (!insertedImagesIds.has(uploadId)) {
154 loader.abort();
155 }
156 }
157 else {
158 // Remember the upload id of the inserted image. If it acted as a replacement for another
159 // image (which landed in the $graveyard), the related loader will not be aborted because
160 // this is still the same image upload.
161 insertedImagesIds.add(uploadId);
162 // Keep the mapping between the upload ID and the image model element so the upload
163 // can later resolve in the context of the correct model element. The model element could
164 // change for the same upload if one image was replaced by another (e.g. image type was changed),
165 // so this may also replace an existing mapping.
166 this._uploadImageElements.set(uploadId, imageElement);
167 if (loader.status == 'idle') {
168 // If the image was inserted into content and has not been loaded yet, start loading it.
169 this._readAndUpload(loader);
170 }
171 }
172 }
173 }
174 }
175 });
176 // Set the default handler for feeding the image element with `src` and `srcset` attributes.
177 // Also set the natural `width` and `height` attributes (if not already set).
178 this.on('uploadComplete', (evt, { imageElement, data }) => {
179 const urls = data.urls ? data.urls : data;
180 this.editor.model.change(writer => {
181 writer.setAttribute('src', urls.default, imageElement);
182 this._parseAndSetSrcsetAttributeOnImage(urls, imageElement, writer);
183 imageUtils.setImageNaturalSizeAttributes(imageElement);
184 });
185 }, { priority: 'low' });
186 }
187 /**
188 * @inheritDoc
189 */
190 afterInit() {
191 const schema = this.editor.model.schema;
192 // Setup schema to allow uploadId and uploadStatus for images.
193 // Wait for ImageBlockEditing or ImageInlineEditing to register their elements first,
194 // that's why doing this in afterInit() instead of init().
195 if (this.editor.plugins.has('ImageBlockEditing')) {
196 schema.extend('imageBlock', {
197 allowAttributes: ['uploadId', 'uploadStatus']
198 });
199 }
200 if (this.editor.plugins.has('ImageInlineEditing')) {
201 schema.extend('imageInline', {
202 allowAttributes: ['uploadId', 'uploadStatus']
203 });
204 }
205 }
206 /**
207 * Reads and uploads an image.
208 *
209 * The image is read from the disk and as a Base64-encoded string it is set temporarily to
210 * `image[src]`. When the image is successfully uploaded, the temporary data is replaced with the target
211 * image's URL (the URL to the uploaded image on the server).
212 */
213 _readAndUpload(loader) {
214 const editor = this.editor;
215 const model = editor.model;
216 const t = editor.locale.t;
217 const fileRepository = editor.plugins.get(FileRepository);
218 const notification = editor.plugins.get(Notification);
219 const imageUtils = editor.plugins.get('ImageUtils');
220 const imageUploadElements = this._uploadImageElements;
221 model.enqueueChange({ isUndoable: false }, writer => {
222 writer.setAttribute('uploadStatus', 'reading', imageUploadElements.get(loader.id));
223 });
224 return loader.read()
225 .then(() => {
226 const promise = loader.upload();
227 const imageElement = imageUploadElements.get(loader.id);
228 // Force re–paint in Safari. Without it, the image will display with a wrong size.
229 // https://github.com/ckeditor/ckeditor5/issues/1975
230 /* istanbul ignore next -- @preserve */
231 if (env.isSafari) {
232 const viewFigure = editor.editing.mapper.toViewElement(imageElement);
233 const viewImg = imageUtils.findViewImgElement(viewFigure);
234 editor.editing.view.once('render', () => {
235 // Early returns just to be safe. There might be some code ran
236 // in between the outer scope and this callback.
237 if (!viewImg.parent) {
238 return;
239 }
240 const domFigure = editor.editing.view.domConverter.mapViewToDom(viewImg.parent);
241 if (!domFigure) {
242 return;
243 }
244 const originalDisplay = domFigure.style.display;
245 domFigure.style.display = 'none';
246 // Make sure this line will never be removed during minification for having "no effect".
247 domFigure._ckHack = domFigure.offsetHeight;
248 domFigure.style.display = originalDisplay;
249 });
250 }
251 model.enqueueChange({ isUndoable: false }, writer => {
252 writer.setAttribute('uploadStatus', 'uploading', imageElement);
253 });
254 return promise;
255 })
256 .then(data => {
257 model.enqueueChange({ isUndoable: false }, writer => {
258 const imageElement = imageUploadElements.get(loader.id);
259 writer.setAttribute('uploadStatus', 'complete', imageElement);
260 this.fire('uploadComplete', { data, imageElement });
261 });
262 clean();
263 })
264 .catch(error => {
265 // If status is not 'error' nor 'aborted' - throw error because it means that something else went wrong,
266 // it might be generic error and it would be real pain to find what is going on.
267 if (loader.status !== 'error' && loader.status !== 'aborted') {
268 throw error;
269 }
270 // Might be 'aborted'.
271 if (loader.status == 'error' && error) {
272 notification.showWarning(error, {
273 title: t('Upload failed'),
274 namespace: 'upload'
275 });
276 }
277 // Permanently remove image from insertion batch.
278 model.enqueueChange({ isUndoable: false }, writer => {
279 writer.remove(imageUploadElements.get(loader.id));
280 });
281 clean();
282 });
283 function clean() {
284 model.enqueueChange({ isUndoable: false }, writer => {
285 const imageElement = imageUploadElements.get(loader.id);
286 writer.removeAttribute('uploadId', imageElement);
287 writer.removeAttribute('uploadStatus', imageElement);
288 imageUploadElements.delete(loader.id);
289 });
290 fileRepository.destroyLoader(loader);
291 }
292 }
293 /**
294 * Creates the `srcset` attribute based on a given file upload response and sets it as an attribute to a specific image element.
295 *
296 * @param data Data object from which `srcset` will be created.
297 * @param image The image element on which the `srcset` attribute will be set.
298 */
299 _parseAndSetSrcsetAttributeOnImage(data, image, writer) {
300 // Srcset attribute for responsive images support.
301 let maxWidth = 0;
302 const srcsetAttribute = Object.keys(data)
303 // Filter out keys that are not integers.
304 .filter(key => {
305 const width = parseInt(key, 10);
306 if (!isNaN(width)) {
307 maxWidth = Math.max(maxWidth, width);
308 return true;
309 }
310 })
311 // Convert each key to srcset entry.
312 .map(key => `${data[key]} ${key}w`)
313 // Join all entries.
314 .join(', ');
315 if (srcsetAttribute != '') {
316 const attributes = {
317 srcset: srcsetAttribute
318 };
319 if (!image.hasAttribute('width') && !image.hasAttribute('height')) {
320 attributes.width = maxWidth;
321 }
322 writer.setAttributes(attributes, image);
323 }
324 }
325}
326/**
327 * Returns `true` if non-empty `text/html` is included in the data transfer.
328 */
329export function isHtmlIncluded(dataTransfer) {
330 return Array.from(dataTransfer.types).includes('text/html') && dataTransfer.getData('text/html') !== '';
331}
332function getImagesFromChangeItem(editor, item) {
333 const imageUtils = editor.plugins.get('ImageUtils');
334 return Array.from(editor.model.createRangeOn(item))
335 .filter(value => imageUtils.isImage(value.item))
336 .map(value => value.item);
337}