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 | */
|
8 | import { Plugin } from 'ckeditor5/src/core.js';
|
9 | import { UpcastWriter } from 'ckeditor5/src/engine.js';
|
10 | import { Notification } from 'ckeditor5/src/ui.js';
|
11 | import { ClipboardPipeline } from 'ckeditor5/src/clipboard.js';
|
12 | import { FileRepository } from 'ckeditor5/src/upload.js';
|
13 | import { env } from 'ckeditor5/src/utils.js';
|
14 | import ImageUtils from '../imageutils.js';
|
15 | import UploadImageCommand from './uploadimagecommand.js';
|
16 | import { fetchLocalImage, isLocalImage } from '../../src/imageupload/utils.js';
|
17 | import { 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 | */
|
25 | export 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 | */
|
329 | export function isHtmlIncluded(dataTransfer) {
|
330 | return Array.from(dataTransfer.types).includes('text/html') && dataTransfer.getData('text/html') !== '';
|
331 | }
|
332 | function 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 | }
|