diff --git a/CHANGELOG.md b/CHANGELOG.md index 47368d98..8e285340 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,31 @@ Changelog ========= +## [12.0.0](https://github.com/ckeditor/ckeditor5-image/compare/v11.0.0...v12.0.0) (2018-12-05) + +### Features + +* Improved responsiveness of the text alternative view in narrow viewports (see [ckeditor/ckeditor5#416](https://github.com/ckeditor/ckeditor5/issues/416)). ([ff5394a](https://github.com/ckeditor/ckeditor5-image/commit/ff5394a)) +* Introduced the `'imageInsert'` command. Closes [#245](https://github.com/ckeditor/ckeditor5-image/issues/245). Closes [#251](https://github.com/ckeditor/ckeditor5-image/issues/251). ([cc1e7a3](https://github.com/ckeditor/ckeditor5-image/commit/cc1e7a3)) +* Support for uploading images pasted with a base64 source. Closes [#246](https://github.com/ckeditor/ckeditor5-image/issues/246). Closes [ckeditor/ckeditor5-paste-from-office#24](https://github.com/ckeditor/ckeditor5-paste-from-office/issues/24). ([89ab27e](https://github.com/ckeditor/ckeditor5-image/commit/89ab27e)) + +### Bug fixes + +* Prevent errors when (for unclear reasons) the native `DataTransfer#files` contains `null` values when drag&dropping files into the editor in Chrome. ([2a45481](https://github.com/ckeditor/ckeditor5-image/commit/2a45481)) + + Thanks to [@code-chris](https://github.com/code-chris)! + +### Other changes + +* Moved widget spacing styles from `@ckeditor/ckeditor5-theme-lark` to the feature content styles sheet (see [ckeditor/ckeditor5-theme-lark#209](https://github.com/ckeditor/ckeditor5-theme-lark/issues/209)). ([671e1b8](https://github.com/ckeditor/ckeditor5-image/commit/671e1b8)) +* Removed obsolete fill attributes in SVG icons. ([0f9dad3](https://github.com/ckeditor/ckeditor5-image/commit/0f9dad3)) ([57bd34c](https://github.com/ckeditor/ckeditor5-image/commit/57bd34c)) ([ebc27e6](https://github.com/ckeditor/ckeditor5-image/commit/ebc27e6)) ([6192cf3](https://github.com/ckeditor/ckeditor5-image/commit/6192cf3)) +* Updated translations. ([3c85c37](https://github.com/ckeditor/ckeditor5-image/commit/3c85c37)) + +### BREAKING CHANGES + +* The `ImageUploadCommand#execute()`'s `files` parameter was renamed to `file`. It can still accept an array of files. + + ## [11.0.0](https://github.com/ckeditor/ckeditor5-image/compare/v10.2.0...v11.0.0) (2018-10-08) ### Other changes diff --git a/docs/_snippets/features/image-caption.js b/docs/_snippets/features/image-caption.js index 5fbf6965..a3274dcb 100644 --- a/docs/_snippets/features/image-caption.js +++ b/docs/_snippets/features/image-caption.js @@ -14,7 +14,7 @@ ClassicEditor toolbar: [ 'imageTextAlternative' ] }, toolbar: { - viewportTopOffset: 100 + viewportTopOffset: window.getViewportTopOffsetConfig() }, cloudServices: CS_CONFIG } ) diff --git a/docs/_snippets/features/image-style-custom.js b/docs/_snippets/features/image-style-custom.js index 9a695701..9f91687a 100644 --- a/docs/_snippets/features/image-style-custom.js +++ b/docs/_snippets/features/image-style-custom.js @@ -24,7 +24,7 @@ ClassicEditor toolbar: [ 'imageTextAlternative', '|', 'imageStyle:alignLeft', 'imageStyle:full', 'imageStyle:alignRight' ] }, toolbar: { - viewportTopOffset: 100 + viewportTopOffset: window.getViewportTopOffsetConfig() }, cloudServices: CS_CONFIG } ) diff --git a/docs/_snippets/features/image-style.js b/docs/_snippets/features/image-style.js index ee0383d2..93106190 100644 --- a/docs/_snippets/features/image-style.js +++ b/docs/_snippets/features/image-style.js @@ -10,7 +10,7 @@ import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud ClassicEditor .create( document.querySelector( '#snippet-image-style' ), { toolbar: { - viewportTopOffset: 100 + viewportTopOffset: window.getViewportTopOffsetConfig() }, cloudServices: CS_CONFIG } ) diff --git a/docs/_snippets/features/image-toolbar.js b/docs/_snippets/features/image-toolbar.js index c84f50ee..0a3e65dd 100644 --- a/docs/_snippets/features/image-toolbar.js +++ b/docs/_snippets/features/image-toolbar.js @@ -14,7 +14,7 @@ ClassicEditor toolbar: [ 'imageTextAlternative' ] }, toolbar: { - viewportTopOffset: 100 + viewportTopOffset: window.getViewportTopOffsetConfig() }, cloudServices: CS_CONFIG } ) diff --git a/docs/_snippets/features/image.js b/docs/_snippets/features/image.js index 8522d5dc..2f51a30c 100644 --- a/docs/_snippets/features/image.js +++ b/docs/_snippets/features/image.js @@ -11,7 +11,7 @@ ClassicEditor .create( document.querySelector( '#snippet-image' ), { removePlugins: [ 'ImageToolbar', 'ImageCaption', 'ImageStyle' ], toolbar: { - viewportTopOffset: 100 + viewportTopOffset: window.getViewportTopOffsetConfig() }, cloudServices: CS_CONFIG } ) diff --git a/docs/features/image.md b/docs/features/image.md index 7befa97d..bb2b6fab 100644 --- a/docs/features/image.md +++ b/docs/features/image.md @@ -29,7 +29,7 @@ The {@link module:image/image~Image} feature adds support for plain images with ``` - This feature follows the markup proposed by the [Editor Recommendations](http://ckeditor.github.io/editor-recommendations/features/images.html) project. + This feature follows the markup proposed by the [Editor Recommendations](https://ckeditor.github.io/editor-recommendations/features/image.html) project. You can see the demo of an editor with the base image feature enabled below: @@ -144,7 +144,7 @@ See the result below: {@snippet features/image-style-custom} - In the example above the options used represent simple "align left" and "align right" styles. Most text editors support left, center and right alignments, however, try not to think about CKEditor 5's image styles in this way. Try to understand what use cases the system needs to support and define semantical options accordingly. Defining useful and clear styles is one of the steps towards a good user experience and clear, portable output. For example, the "side image" style can be displayed as a floated image on wide screens and as a normal image on low resolution screens. + In the example above the options used represent simple "align left" and "align right" styles. Most text editors support left, center and right alignments, however, try not to think about CKEditor 5's image styles in this way. Try to understand what use cases the system needs to support and define semantic options accordingly. Defining useful and clear styles is one of the steps towards a good user experience and clear, portable output. For example, the "side image" style can be displayed as a floated image on wide screens and as a normal image on low resolution screens. ### Defining custom styles @@ -173,9 +173,7 @@ See the {@link features/image-upload Image upload} guide. ## Responsive images -Coming soon... - - +Responsive images support in CKEditor 5 is brought by the {@link features/easy-image Easy Image} feature without any additional configuration. Learn more how to use the feature in your project in the {@link features/easy-image#responsive-images "Easy Image integration"} guide. ## Installation @@ -213,7 +211,8 @@ ClassicEditor The {@link module:image/image~Image} plugin registers: * The `'imageTextAlternative'` button. -* * The {@link module:image/imagetextalternative/imagetextalternativecommand~ImageTextAlternativeCommand `'imageTextAlternative'` command} +* The {@link module:image/imagetextalternative/imagetextalternativecommand~ImageTextAlternativeCommand `'imageTextAlternative'` command} +* The {@link module:image/image/imageinsertcommand~ImageInsertCommand `'imageInsert'` command} which accepts a source (e.g. an URL) of an image to insert. The {@link module:image/imagestyle~ImageStyle} plugin registers: diff --git a/docs/framework/guides/deep-dive/upload-adapter.md b/docs/framework/guides/deep-dive/upload-adapter.md new file mode 100644 index 00000000..f53e3c63 --- /dev/null +++ b/docs/framework/guides/deep-dive/upload-adapter.md @@ -0,0 +1,283 @@ +--- +category: framework-deep-dive +menu-title: Custom upload adapter +--- + +# Custom image upload adapter + + + Check out the comprehensive {@link features/image-upload Image upload overview} to learn about other ways to upload images into CKEditor 5. + + +## How does the image upload work? + +Before you can implement your own custom upload adapter, you should learn about the image upload process in CKEditor 5. The whole process boils down to the following steps: + +1. First, an image (or images) need to get into the rich-text editor content. There are many ways to do that, for instance: + + * pasting an image from clipboard, + * dragging a file from the file system, + * selecting an image through a file system dialog. + + The images are intercepted by the {@link module:image/imageupload~ImageUpload image upload} plugin (which is enabled in all official {@link builds/guides/overview editor builds}). +2. For every image, the image upload plugin {@link module:upload/filerepository~FileRepository#createLoader creates an instance of a file loader}. + + * The role of the **file loader** is to read the file from the disk and upload it to the server by using the upload adapter. + * The role of the **upload adapter** is, therefore, to securely send the file to the server and pass the response from the server (e.g. the URL to the saved file) back to the file loader (or handle an error, if there was one). + +3. While the images are being uploaded, the image upload plugin: + + * Creates placeholders of these images. + * Inserts them into the editor. + * Displays the progress bar for each of them. + * When an image is deleted from the editor content before the upload finishes, it aborts the upload process. + +4. Once the file is uploaded, the upload adapter notifies the editor about this fact by resolving its `Promise`. It passes the URL (or URLs in case of responsive images) to the image upload plugin which replaces the `src` and `srcset` attributes of the image placeholder in the editor content. + +This is just an overview of the image upload process. The truth is, the whole thing is more complicated. For instance, images can be copied and pasted within the WYSIWYG editor (while the upload takes place) and all potential upload errors must be handled, too. The good news is these tasks are handled transparently by the {@link module:image/imageupload~ImageUpload image upload} plugin so you do not have to worry about them. + +To sum up, for the image upload to work in the rich-text editor, two conditions must be true: + +* **The {@link module:image/imageupload~ImageUpload image upload} plugin must be enabled** in the editor. It is enabled by default in all official {@link builds/guides/overview builds}, but if you are {@link builds/guides/development/custom-builds customizing} CKEditor 5, do not forget to include it. +* **The upload adapter needs to be defined**. This can be done by using (enabling *and* configuring): + + * {@link features/image-upload#official-upload-adapters One of the existing upload adapters}. + * [Your custom upload adapter](#implementing-a-custom-upload-adapter) and handling uploaded files on your server back–end. + +## Implementing a custom upload adapter + +In this guide you are going to implement and enable a custom upload adapter that will allow you to take the **full control** over the process of sending the files to the server as well as passing the response from the server (e.g. the URL to the saved file) back to the rich-text editor. + +### The anatomy of the adapter + +Define the `MyUploadAdapter` class and fill its internals step–by–step. The adapter will use the native `XMLHttpRequest` to send files returned by the loader to a pre–configured URL on the server, handling `error`, `abort`, `load`, and `progress` events fired by the request. + +```js +class MyUploadAdapter { + constructor( loader, url ) { + // The FileLoader instance to use during the upload. It sounds scary but do not + // worry — the loader will be passed into the adapter later on in this guide. + this.loader = loader; + + // The upload URL in your server back-end. This is the address the XMLHttpRequest + // will send the image data to. + this.url = url; + } + + // ... +} +``` + +Your custom upload adapter must implement the {@link module:upload/filerepository~UploadAdapter `UploadAdapter` interface} in order to work, i.e. it must bring its own `upload()` and `abort()` methods. + +* The {@link module:upload/filerepository~UploadAdapter#upload `upload()`} method must return a promise: + * resolved by a successful image upload (with an object containing information about the uploaded file), + * rejected because of an error, in which case no image is inserted into the content. +* The {@link module:upload/filerepository~UploadAdapter#abort `abort()`} method must allow the editor to abort the upload process. It is necessary, for instance, when the image was removed from the content by the user before the upload finished. + +```js +class MyUploadAdapter { + constructor( loader, url ) { + // ... + } + + // Starts the upload process. + upload() { + return new Promise( ( resolve, reject ) => { + this._initRequest(); + this._initListeners( resolve, reject ); + this._sendRequest(); + } ); + } + + // Aborts the upload process. + abort() { + if ( this.xhr ) { + this.xhr.abort(); + } + } + + // ... +} +``` + +### Using `XMLHttpRequest` in an adapter + +Let's see what the `_initRequest()` method looks like in your custom upload adapter. It should prepare the `XMLHttpRequest` object before it can be used to upload an image. + +```js +class MyUploadAdapter { + constructor( loader, url ) { + // ... + } + + upload() { + // ... + } + + abort() { + // ... + } + + // Initializes the XMLHttpRequest object using the URL passed to the constructor. + _initRequest() { + const xhr = this.xhr = new XMLHttpRequest(); + + // Note that your request may look different. It is up to you and your editor + // integration to choose the right communication channel. This example uses + // the POST request with JSON as a data structure but your configuration + // could be different. + xhr.open( 'POST', this.url, true ); + xhr.responseType = 'json'; + } +} +``` + +Now let's focus on the `_initListeners()` method which attaches the `error`, `abort`, `load`, and `progress` event listeners to the `XMLHttpRequest` object created in the last step. + +A successful image upload will finish when the upload promise is resolved upon the `load` event fired by the `XMLHttpRequest` request. The promise must be resolved with an object containing information about the image. See the documentation of the {@link module:upload/filerepository~UploadAdapter#upload `upload()`} method to learn more. + +```js +class MyUploadAdapter { + constructor( loader, url ) { + // ... + } + + upload() { + // ... + } + + abort() { + // ... + } + + _initRequest() { + // ... + } + + // Initializes XMLHttpRequest listeners. + _initListeners( resolve, reject ) { + const xhr = this.xhr; + const loader = this.loader; + const genericErrorText = 'Couldn\'t upload file:' + ` ${ loader.file.name }.`; + + xhr.addEventListener( 'error', () => reject( genericErrorText ) ); + xhr.addEventListener( 'abort', () => reject() ); + xhr.addEventListener( 'load', () => { + const response = xhr.response; + + // This example assumes the XHR server's "response" object will come with + // an "error" which has its own "message" that can be passed to reject() + // in the upload promise. + // + // Your integration may handle upload errors in a different way so make sure + // it is done properly. The reject() function must be called when the upload fails. + if ( !response || response.error ) { + return reject( response && response.error ? response.error.message : genericErrorText ); + } + + // If the upload is successful, resolve the upload promise with an object containing + // at least the "default" URL, pointing to the image on the server. + // This URL will be used to display the image in the content. Learn more in the + // UploadAdapter#upload documentation. + resolve( { + default: response.url + } ); + } ); + + // Upload progress when it is supported. The FileLoader has the #uploadTotal and #uploaded + // properties which are used e.g. to display the upload progress bar in the editor + // user interface. + if ( xhr.upload ) { + xhr.upload.addEventListener( 'progress', evt => { + if ( evt.lengthComputable ) { + loader.uploadTotal = evt.total; + loader.uploaded = evt.loaded; + } + } ); + } + } +} +``` + +Last but not least, the `_sendRequest()` method sends the `XMLHttpRequest`. In this example, the [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData) interface is used to pass the file provided by the {@link module:upload/filerepository~FileRepository#createLoader file loader}. + +```js +class MyUploadAdapter { + constructor( loader, url ) { + // ... + } + + upload() { + // ... + } + + abort() { + // ... + } + + _initRequest() { + // ... + } + + _initListeners( resolve, reject ) { + // ... + } + + // Prepares the data and sends the request. + _sendRequest() { + // Prepare the form data. + const data = new FormData(); + data.append( 'upload', this.loader.file ); + + // Send the request. + this.xhr.send( data ); + } +} +``` + +### Activating a custom upload adapter + +Having implemented the adapter, you must figure out how to enable it in the editor. The good news is that it is pretty easy, and you do not need to {@link builds/guides/development/custom-builds rebuild the editor} to do that! + +Crate a simple standalone plugin (`MyCustomUploadAdapterPlugin`) that will {@link module:upload/filerepository~FileRepository#createLoader create an instance of the file loader} and glue it with your custom `MyUploadAdapter`. + +```js +import ClassicEditor from '@ckeditor/ckeditor5-build-classic'; + +class MyUploadAdapter { + constructor( loader, url ) { + // ... + } + + // ... +} + +function MyCustomUploadAdapterPlugin( editor ) { + editor.plugins.get( 'FileRepository' ).createUploadAdapter = ( loader ) => { + // Configure the URL to the upload script in your back-end here! + return new MyUploadAdapter( loader, 'http://example.com/image/upload/path' ); + }; +} +``` + +Enable the `MyCustomUploadAdapterPlugin` in the editor by using the {@link module:core/editor/editorconfig~EditorConfig#extraPlugins `config.extraPlugins`} option: + +```js +ClassicEditor + .create( document.querySelector( '#editor' ), { + extraPlugins: [ MyCustomUploadAdapterPlugin ], + + // ... + } ) + .catch( error => { + console.log( error ); + } ); +``` + +Run the editor and see if your implementation works. Drop an image into the WYSIWYG editor content and it should be uploaded to the server thanks to the `MyUploadAdapter`. + +## What's next? + +Check out the comprehensive {@link features/image-upload Image upload overview} to learn more about different ways of uploading images in CKEditor 5. See the {@link features/image Image feature} guide to find out more about handling images in CKEditor 5. + diff --git a/lang/translations/fr.po b/lang/translations/fr.po index 2a1f5504..d10392a3 100644 --- a/lang/translations/fr.po +++ b/lang/translations/fr.po @@ -11,7 +11,7 @@ msgstr "Objet image" msgctxt "Label for the Side image option." msgid "Side image" -msgstr "Image sur le côté" +msgstr "Image latérale" msgctxt "Label for the Full size image option." msgid "Full size image" @@ -27,7 +27,7 @@ msgstr "Image centrée" msgctxt "Label for the Right aligned image option" msgid "Right aligned image" -msgstr "Image alignée a droite" +msgstr "Image alignée à droite" msgctxt "Label for the Change image text alternative button." msgid "Change image text alternative" @@ -39,7 +39,7 @@ msgstr "Texte alternatif" msgctxt "Placeholder text for image caption displayed when caption is empty." msgid "Enter image caption" -msgstr "Saisissez la légende de l’image" +msgstr "Saisir la légende de l’image" msgctxt "Label for the insert image toolbar button." msgid "Insert image" diff --git a/package.json b/package.json index 38055919..9a744dd1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ckeditor/ckeditor5-image", - "version": "11.0.0", + "version": "12.0.0", "description": "Image feature for CKEditor 5.", "keywords": [ "ckeditor", @@ -10,28 +10,28 @@ "ckeditor5-plugin" ], "dependencies": { - "@ckeditor/ckeditor5-core": "^11.0.1", - "@ckeditor/ckeditor5-engine": "^11.0.0", - "@ckeditor/ckeditor5-theme-lark": "^11.1.0", - "@ckeditor/ckeditor5-ui": "^11.1.0", - "@ckeditor/ckeditor5-upload": "^10.0.3", - "@ckeditor/ckeditor5-utils": "^11.0.0", - "@ckeditor/ckeditor5-widget": "^10.3.0" + "@ckeditor/ckeditor5-core": "^11.1.0", + "@ckeditor/ckeditor5-engine": "^12.0.0", + "@ckeditor/ckeditor5-theme-lark": "^12.0.0", + "@ckeditor/ckeditor5-ui": "^11.2.0", + "@ckeditor/ckeditor5-upload": "^10.0.4", + "@ckeditor/ckeditor5-utils": "^11.1.0", + "@ckeditor/ckeditor5-widget": "^10.3.1" }, "devDependencies": { - "@ckeditor/ckeditor5-basic-styles": "^10.0.3", - "@ckeditor/ckeditor5-clipboard": "^10.0.3", - "@ckeditor/ckeditor5-editor-classic": "^11.0.1", - "@ckeditor/ckeditor5-enter": "^10.1.2", - "@ckeditor/ckeditor5-essentials": "^10.1.2", - "@ckeditor/ckeditor5-heading": "^10.1.0", - "@ckeditor/ckeditor5-link": "^10.0.4", - "@ckeditor/ckeditor5-list": "^11.0.2", - "@ckeditor/ckeditor5-paragraph": "^10.0.3", - "@ckeditor/ckeditor5-typing": "^11.0.1", - "@ckeditor/ckeditor5-undo": "^10.0.3", + "@ckeditor/ckeditor5-basic-styles": "^10.1.0", + "@ckeditor/ckeditor5-clipboard": "^10.0.4", + "@ckeditor/ckeditor5-editor-classic": "^11.0.2", + "@ckeditor/ckeditor5-enter": "^10.1.3", + "@ckeditor/ckeditor5-essentials": "^10.1.3", + "@ckeditor/ckeditor5-heading": "^10.1.1", + "@ckeditor/ckeditor5-link": "^10.1.0", + "@ckeditor/ckeditor5-list": "^11.0.3", + "@ckeditor/ckeditor5-paragraph": "^10.0.4", + "@ckeditor/ckeditor5-typing": "^11.0.2", + "@ckeditor/ckeditor5-undo": "^10.0.4", "eslint": "^5.5.0", - "eslint-config-ckeditor5": "^1.0.7", + "eslint-config-ckeditor5": "^1.0.9", "husky": "^0.14.3", "lint-staged": "^7.0.0" }, diff --git a/src/image/imageediting.js b/src/image/imageediting.js index 789060c8..c72d3ebf 100644 --- a/src/image/imageediting.js +++ b/src/image/imageediting.js @@ -18,13 +18,16 @@ import { import { toImageWidget } from './utils'; -import { downcastElementToElement } from '@ckeditor/ckeditor5-engine/src/conversion/downcast-converters'; -import { upcastElementToElement, upcastAttributeToAttribute } from '@ckeditor/ckeditor5-engine/src/conversion/upcast-converters'; +import ImageInsertCommand from './imageinsertcommand'; /** * The image engine plugin. - * It registers `` as a block element in the document schema, and allows `alt`, `src` and `srcset` attributes. - * It also registers converters for editing and data pipelines. + * + * It registers: + * + * * `` as a block element in the document schema, and allows `alt`, `src` and `srcset` attributes. + * * converters for editing and data pipelines. + * * `'imageInsert'` command. * * @extends module:core/plugin~Plugin */ @@ -49,15 +52,15 @@ export default class ImageEditing extends Plugin { allowAttributes: [ 'alt', 'src', 'srcset' ] } ); - conversion.for( 'dataDowncast' ).add( downcastElementToElement( { + conversion.for( 'dataDowncast' ).elementToElement( { model: 'image', view: ( modelElement, viewWriter ) => createImageViewElement( viewWriter ) - } ) ); + } ); - conversion.for( 'editingDowncast' ).add( downcastElementToElement( { + conversion.for( 'editingDowncast' ).elementToElement( { model: 'image', view: ( modelElement, viewWriter ) => toImageWidget( createImageViewElement( viewWriter ), viewWriter, t( 'image widget' ) ) - } ) ); + } ); conversion.for( 'downcast' ) .add( modelToViewAttributeConverter( 'src' ) ) @@ -65,7 +68,7 @@ export default class ImageEditing extends Plugin { .add( srcsetAttributeConverter() ); conversion.for( 'upcast' ) - .add( upcastElementToElement( { + .elementToElement( { view: { name: 'img', attributes: { @@ -73,15 +76,15 @@ export default class ImageEditing extends Plugin { } }, model: ( viewImage, modelWriter ) => modelWriter.createElement( 'image', { src: viewImage.getAttribute( 'src' ) } ) - } ) ) - .add( upcastAttributeToAttribute( { + } ) + .attributeToAttribute( { view: { name: 'img', key: 'alt' }, model: 'alt' - } ) ) - .add( upcastAttributeToAttribute( { + } ) + .attributeToAttribute( { view: { name: 'img', key: 'srcset' @@ -100,8 +103,11 @@ export default class ImageEditing extends Plugin { return value; } } - } ) ) + } ) .add( viewFigureToModel() ); + + // Register imageUpload command. + editor.commands.add( 'imageInsert', new ImageInsertCommand( editor ) ); } } diff --git a/src/image/imageinsertcommand.js b/src/image/imageinsertcommand.js new file mode 100644 index 00000000..7dcef633 --- /dev/null +++ b/src/image/imageinsertcommand.js @@ -0,0 +1,61 @@ +/** + * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import Command from '@ckeditor/ckeditor5-core/src/command'; +import { insertImage, isImageAllowed } from './utils'; + +/** + * @module image/image/imageinsertcommand + */ + +/** + * Insert image command. + * + * The command is registered by the {@link module:image/image/imageediting~ImageEditing} plugin as `'imageInsert'`. + * + * In order to insert an image at the current selection position + * (according to the {@link module:widget/utils~findOptimalInsertionPosition} algorithm), + * execute the command and specify the image source: + * + * editor.execute( 'imageInsert', { source: 'http://url.to.the/image' } ); + * + * It is also possible to insert multiple images at once: + * + * editor.execute( 'imageInsert', { + * source: [ + * 'path/to/image.jpg', + * 'path/to/other-image.jpg' + * ] + * } ); + * + * @extends module:core/command~Command + */ +export default class ImageInsertCommand extends Command { + /** + * @inheritDoc + */ + refresh() { + this.isEnabled = isImageAllowed( this.editor.model ); + } + + /** + * Executes the command. + * + * @fires execute + * @param {Object} options Options for the executed command. + * @param {String|Array.} options.source The image source or an array of image sources to insert. + */ + execute( options ) { + const model = this.editor.model; + + model.change( writer => { + const sources = Array.isArray( options.source ) ? options.source : [ options.source ]; + + for ( const src of sources ) { + insertImage( writer, model, { src } ); + } + } ); + } +} diff --git a/src/image/utils.js b/src/image/utils.js index 37728e90..052ee055 100644 --- a/src/image/utils.js +++ b/src/image/utils.js @@ -7,7 +7,7 @@ * @module image/image/utils */ -import { toWidget, isWidget } from '@ckeditor/ckeditor5-widget/src/utils'; +import { findOptimalInsertionPosition, isWidget, toWidget } from '@ckeditor/ckeditor5-widget/src/utils'; const imageSymbol = Symbol( 'isImage' ); @@ -65,3 +65,78 @@ export function isImageWidgetSelected( selection ) { export function isImage( modelElement ) { return !!modelElement && modelElement.is( 'image' ); } + +/** + * Handles inserting single file. This method unifies image insertion using {@link module:widget/utils~findOptimalInsertionPosition} method. + * + * model.change( writer => { + * insertImage( writer, model, { src: 'path/to/image.jpg' } ); + * } ); + * + * @param {module:engine/model/writer~Writer} writer + * @param {module:engine/model/model~Model} model + * @param {Object} [attributes={}] Attributes of inserted image + */ +export function insertImage( writer, model, attributes = {} ) { + const imageElement = writer.createElement( 'image', attributes ); + + const insertAtSelection = findOptimalInsertionPosition( model.document.selection, model ); + + model.insertContent( imageElement, insertAtSelection ); + + // Inserting an image might've failed due to schema regulations. + if ( imageElement.parent ) { + writer.setSelection( imageElement, 'on' ); + } +} + +/** + * Checks if image can be inserted at current model selection. + * + * @param {module:engine/model/model~Model} model + * @returns {Boolean} + */ +export function isImageAllowed( model ) { + const schema = model.schema; + const selection = model.document.selection; + + return isImageAllowedInParent( selection, schema, model ) && + !checkSelectionOnObject( selection, schema ) && + isInOtherImage( selection ); +} + +// Checks if image is allowed by schema in optimal insertion parent. +// +// @returns {Boolean} +function isImageAllowedInParent( selection, schema, model ) { + const parent = getInsertImageParent( selection, model ); + + return schema.checkChild( parent, 'image' ); +} + +// Check if selection is on object. +// +// @returns {Boolean} +function checkSelectionOnObject( selection, schema ) { + const selectedElement = selection.getSelectedElement(); + + return selectedElement && schema.isObject( selectedElement ); +} + +// Checks if selection is placed in other image (ie. in caption). +function isInOtherImage( selection ) { + return [ ...selection.focus.getAncestors() ].every( ancestor => !ancestor.is( 'image' ) ); +} + +// Returns a node that will be used to insert image with `model.insertContent` to check if image can be placed there. +function getInsertImageParent( selection, model ) { + const insertAt = findOptimalInsertionPosition( selection, model ); + + const parent = insertAt.parent; + + if ( parent.isEmpty && !parent.is( '$root' ) ) { + return parent.parent; + } + + return parent; +} diff --git a/src/imagecaption/imagecaptionediting.js b/src/imagecaption/imagecaptionediting.js index 109b839a..0211d9dd 100644 --- a/src/imagecaption/imagecaptionediting.js +++ b/src/imagecaption/imagecaptionediting.js @@ -8,7 +8,6 @@ */ import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; -import { upcastElementToElement } from '@ckeditor/ckeditor5-engine/src/conversion/upcast-converters'; import { isImage } from '../image/utils'; import { captionElementCreator, @@ -55,10 +54,10 @@ export default class ImageCaptionEditing extends Plugin { editor.model.document.registerPostFixer( writer => this._insertMissingModelCaptionElement( writer ) ); // View to model converter for the data pipeline. - editor.conversion.for( 'upcast' ).add( upcastElementToElement( { + editor.conversion.for( 'upcast' ).elementToElement( { view: matchImageCaption, model: 'caption' - } ) ); + } ); // Model to view converter for the data pipeline. const createCaptionForData = writer => writer.createContainerElement( 'figcaption' ); @@ -237,7 +236,7 @@ function captionModelToView( elementCreator, hide = true ) { // @param {module:engine/view/containerelement~ContainerElement} viewCaption // @param {module:engine/model/element~Element} modelCaption // @param {module:engine/view/containerelement~ContainerElement} viewImage -// @param {Object} conversionApi +// @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi function insertViewCaptionAndBind( viewCaption, modelCaption, viewImage, conversionApi ) { const viewPosition = conversionApi.writer.createPositionAt( viewImage, 'end' ); diff --git a/src/imagestyle/imagestyleediting.js b/src/imagestyle/imagestyleediting.js index 486cd5d6..e9a66667 100644 --- a/src/imagestyle/imagestyleediting.js +++ b/src/imagestyle/imagestyleediting.js @@ -9,7 +9,6 @@ import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; import ImageStyleCommand from './imagestylecommand'; -import ImageEditing from '../image/imageediting'; import { viewToModelStyleAttribute, modelToViewStyleAttribute } from './converters'; import { normalizeImageStyles } from './utils'; @@ -20,13 +19,6 @@ import { normalizeImageStyles } from './utils'; * @extends {module:core/plugin~Plugin} */ export default class ImageStyleEditing extends Plugin { - /** - * @inheritDoc - */ - static get requires() { - return [ ImageEditing ]; - } - /** * @inheritDoc */ diff --git a/src/imagestyle/utils.js b/src/imagestyle/utils.js index 9cdad230..8be150ee 100644 --- a/src/imagestyle/utils.js +++ b/src/imagestyle/utils.js @@ -95,9 +95,7 @@ const defaultIcons = { * @returns {Array.} */ export function normalizeImageStyles( configuredStyles = [] ) { - return configuredStyles - .map( _normalizeStyle ) - .map( style => Object.assign( {}, style ) ); + return configuredStyles.map( _normalizeStyle ); } // Normalizes an image style provided in the {@link module:image/image~ImageConfig#styles} diff --git a/src/imageupload.js b/src/imageupload.js index 9e7eb662..522ba776 100644 --- a/src/imageupload.js +++ b/src/imageupload.js @@ -15,7 +15,7 @@ import ImageUploadEditing from './imageupload/imageuploadediting'; /** * The image upload plugin. * - * For a detailed overview, check the {@glink features/image-upload image upload feature} documentation. + * For a detailed overview, check the {@glink features/image-upload/image-upload image upload feature} documentation. * * This plugin does not do anything directly, but it loads a set of specific plugins to enable image uploading: * diff --git a/src/imageupload/imageuploadcommand.js b/src/imageupload/imageuploadcommand.js index 4e18bc3a..2958b399 100644 --- a/src/imageupload/imageuploadcommand.js +++ b/src/imageupload/imageuploadcommand.js @@ -5,7 +5,7 @@ import FileRepository from '@ckeditor/ckeditor5-upload/src/filerepository'; import Command from '@ckeditor/ckeditor5-core/src/command'; -import { findOptimalInsertionPosition } from '@ckeditor/ckeditor5-widget/src/utils'; +import { insertImage, isImageAllowed } from '../image/utils'; /** * @module image/imageupload/imageuploadcommand @@ -14,6 +14,29 @@ import { findOptimalInsertionPosition } from '@ckeditor/ckeditor5-widget/src/uti /** * Image upload command. * + * The command is registered by the {@link module:image/imageupload/imageuploadediting~ImageUploadEditing} plugin as `'imageUpload'`. + * + * In order to upload an image at the current selection position + * (according to the {@link module:widget/utils~findOptimalInsertionPosition} algorithm), + * execute the command and pass the native image file instance: + * + * this.listenTo( editor.editing.view.document, 'clipboardInput', ( evt, data ) => { + * // Assuming that only images were pasted: + * const images = Array.from( data.dataTransfer.files ); + * + * // Upload the first image: + * editor.execute( 'imageUpload', { file: images[ 0 ] } ); + * } ); + * + * It is also possible to insert multiple images at once: + * + * editor.execute( 'imageUpload', { + * file: [ + * file1, + * file2 + * ] + * } ); + * * @extends module:core/command~Command */ export default class ImageUploadCommand extends Command { @@ -21,11 +44,7 @@ export default class ImageUploadCommand extends Command { * @inheritDoc */ refresh() { - const model = this.editor.model; - const selection = model.document.selection; - const schema = model.schema; - - this.isEnabled = isImageAllowedInParent( selection, schema, model ) && checkSelectionWithObject( selection, schema ); + this.isEnabled = isImageAllowed( this.editor.model ); } /** @@ -33,16 +52,19 @@ export default class ImageUploadCommand extends Command { * * @fires execute * @param {Object} options Options for the executed command. - * @param {File|Array.} options.files The image file or an array of image files to upload. + * @param {File|Array.} options.file The image file or an array of image files to upload. */ execute( options ) { const editor = this.editor; + const model = editor.model; - editor.model.change( writer => { - const filesToUpload = Array.isArray( options.files ) ? options.files : [ options.files ]; + const fileRepository = editor.plugins.get( FileRepository ); + + model.change( writer => { + const filesToUpload = Array.isArray( options.file ) ? options.file : [ options.file ]; for ( const file of filesToUpload ) { - uploadImage( writer, editor, file ); + uploadImage( writer, model, fileRepository, file ); } } ); } @@ -51,13 +73,9 @@ export default class ImageUploadCommand extends Command { // Handles uploading single file. // // @param {module:engine/model/writer~writer} writer -// @param {module:core/editor/editor~Editor} editor +// @param {module:engine/model/model~Model} model // @param {File} file -function uploadImage( writer, editor, file ) { - const model = editor.model; - const doc = model.document; - const fileRepository = editor.plugins.get( FileRepository ); - +function uploadImage( writer, model, fileRepository, file ) { const loader = fileRepository.createLoader( file ); // Do not throw when upload adapter is not set. FileRepository will log an error anyway. @@ -65,46 +83,5 @@ function uploadImage( writer, editor, file ) { return; } - const imageElement = writer.createElement( 'image', { uploadId: loader.id } ); - - const insertAtSelection = findOptimalInsertionPosition( doc.selection, model ); - - model.insertContent( imageElement, insertAtSelection ); - - // Inserting an image might've failed due to schema regulations. - if ( imageElement.parent ) { - writer.setSelection( imageElement, 'on' ); - } -} - -// Checks if image is allowed by schema in optimal insertion parent. -function isImageAllowedInParent( selection, schema, model ) { - const parent = getInsertImageParent( selection, model ); - - return schema.checkChild( parent, 'image' ); -} - -// Additional check for when the command should be disabled: -// - selection is on object -// - selection is inside object -function checkSelectionWithObject( selection, schema ) { - const selectedElement = selection.getSelectedElement(); - - const isSelectionOnObject = !!selectedElement && schema.isObject( selectedElement ); - const isSelectionInObject = !![ ...selection.focus.getAncestors() ].find( ancestor => schema.isObject( ancestor ) ); - - return !isSelectionOnObject && !isSelectionInObject; -} - -// Returns a node that will be used to insert image with `model.insertContent` to check if image can be placed there. -function getInsertImageParent( selection, model ) { - const insertAt = findOptimalInsertionPosition( selection, model ); - - let parent = insertAt.parent; - - if ( !parent.is( '$root' ) ) { - parent = parent.parent; - } - - return parent; + insertImage( writer, model, { uploadId: loader.id } ); } diff --git a/src/imageupload/imageuploadediting.js b/src/imageupload/imageuploadediting.js index f09c488d..ed6783f8 100644 --- a/src/imageupload/imageuploadediting.js +++ b/src/imageupload/imageuploadediting.js @@ -10,12 +10,13 @@ import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; import FileRepository from '@ckeditor/ckeditor5-upload/src/filerepository'; import Notification from '@ckeditor/ckeditor5-ui/src/notification/notification'; +import UpcastWriter from '@ckeditor/ckeditor5-engine/src/view/upcastwriter'; import ImageUploadCommand from '../../src/imageupload/imageuploadcommand'; -import { isImageType } from '../../src/imageupload/utils'; +import { isImageType, isLocalImage, fetchLocalImage } from '../../src/imageupload/utils'; /** - * The editing part of the image upload feature. + * The editing part of the image upload feature. It registers the `'imageUpload'` command. * * @extends module:core/plugin~Plugin */ @@ -34,6 +35,7 @@ export default class ImageUploadEditing extends Plugin { const editor = this.editor; const doc = editor.model.document; const schema = editor.model.schema; + const conversion = editor.conversion; const fileRepository = editor.plugins.get( FileRepository ); // Setup schema to allow uploadId and uploadStatus for images. @@ -44,6 +46,16 @@ export default class ImageUploadEditing extends Plugin { // Register imageUpload command. editor.commands.add( 'imageUpload', new ImageUploadCommand( editor ) ); + // 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 @@ -55,7 +67,14 @@ export default class ImageUploadEditing extends Plugin { return; } - const images = Array.from( data.dataTransfer.files ).filter( isImageType ); + const images = Array.from( data.dataTransfer.files ).filter( file => { + // See https://github.com/ckeditor/ckeditor5-image/pull/254. + if ( !file ) { + return false; + } + + return isImageType( file ); + } ); const ranges = data.targetRanges.map( viewRange => editor.editing.mapper.toModelRange( viewRange ) ); @@ -68,12 +87,53 @@ export default class ImageUploadEditing extends Plugin { // Upload images after the selection has changed in order to ensure the command's state is refreshed. editor.model.enqueueChange( 'default', () => { - editor.execute( 'imageUpload', { files: images } ); + editor.execute( 'imageUpload', { 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). + if ( editor.plugins.has( 'Clipboard' ) ) { + this.listenTo( editor.plugins.get( 'Clipboard' ), 'inputTransformation', ( evt, data ) => { + const fetchableImages = Array.from( editor.editing.view.createRangeIn( data.content ) ) + .filter( value => isLocalImage( value.item ) && !value.item.getAttribute( 'uploadProcessed' ) ) + .map( value => fetchLocalImage( value.item ) ); + + if ( !fetchableImages.length ) { + return; + } + + evt.stop(); + + Promise.all( fetchableImages ).then( items => { + const writer = new UpcastWriter(); + + for ( const item of items ) { + if ( !item.file ) { + // Failed to fetch image or create a file instance, remove image element. + writer.remove( item.image ); + } else { + // Set attribute marking the image as processed. + writer.setAttribute( 'uploadProcessed', true, item.image ); + + const loader = fileRepository.createLoader( item.file ); + + if ( loader ) { + writer.setAttribute( 'src', '', item.image ); + writer.setAttribute( 'uploadId', loader.id, item.image ); + } + } + } + + editor.plugins.get( 'Clipboard' ).fire( 'inputTransformation', data ); + } ); + } ); + } + // Prevents from the browser redirecting to the dropped image. editor.editing.view.document.on( 'dragover', ( evt, data ) => { data.preventDefault(); @@ -156,33 +216,7 @@ export default class ImageUploadEditing extends Plugin { .then( data => { model.enqueueChange( 'transparent', writer => { writer.setAttributes( { uploadStatus: 'complete', src: data.default }, imageElement ); - - // 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 != '' ) { - writer.setAttribute( 'srcset', { - data: srcsetAttribute, - width: maxWidth - }, imageElement ); - } + this._parseAndSetSrcsetAttributeOnImage( data, imageElement, writer ); } ); clean(); @@ -219,6 +253,44 @@ export default class ImageUploadEditing extends Plugin { fileRepository.destroyLoader( loader ); } } + + /** + * Creates `srcset` attribute based on a given file upload response and sets it as an attribute to a specific image element. + * + * @protected + * @param {Object} data Data object from which `srcset` will be created. + * @param {module:engine/model/element~Element} image The image element on which `srcset` attribute will be set. + * @param {module:engine/model/writer~Writer} writer + */ + _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 != '' ) { + writer.setAttribute( 'srcset', { + data: srcsetAttribute, + width: maxWidth + }, image ); + } + } } // Returns `true` if non-empty `text/html` is included in the data transfer. diff --git a/src/imageupload/imageuploadprogress.js b/src/imageupload/imageuploadprogress.js index 2823db86..7215841e 100644 --- a/src/imageupload/imageuploadprogress.js +++ b/src/imageupload/imageuploadprogress.js @@ -55,8 +55,7 @@ export default class ImageUploadProgress extends Plugin { * * @param {module:utils/eventinfo~EventInfo} evt An object containing information about the fired event. * @param {Object} data Additional information about the change. - * @param {module:engine/conversion/modelconsumable~ModelConsumable} consumable Values to consume. - * @param {Object} conversionApi + * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi */ uploadStatusChange( evt, data, conversionApi ) { const editor = this.editor; diff --git a/src/imageupload/imageuploadui.js b/src/imageupload/imageuploadui.js index d6912d76..c9d17fae 100644 --- a/src/imageupload/imageuploadui.js +++ b/src/imageupload/imageuploadui.js @@ -15,7 +15,7 @@ import { isImageType } from './utils'; /** * The image upload button plugin. * - * For a detailed overview, check the {@glink features/image-upload Image upload feature} documentation. + * For a detailed overview, check the {@glink features/image-upload/image-upload Image upload feature} documentation. * * Adds the `'imageUpload'` button to the {@link module:ui/componentfactory~ComponentFactory UI component factory}. * @@ -51,7 +51,7 @@ export default class ImageUploadUI extends Plugin { const imagesToUpload = Array.from( files ).filter( isImageType ); if ( imagesToUpload.length ) { - editor.execute( 'imageUpload', { files: imagesToUpload } ); + editor.execute( 'imageUpload', { file: imagesToUpload } ); } } ); diff --git a/src/imageupload/utils.js b/src/imageupload/utils.js index 89943aa4..fc61910c 100644 --- a/src/imageupload/utils.js +++ b/src/imageupload/utils.js @@ -7,6 +7,8 @@ * @module image/imageupload/utils */ +/* global fetch, File */ + /** * Checks if a given file is an image. * @@ -18,3 +20,80 @@ export function isImageType( file ) { return types.test( file.type ); } + +/** + * Creates a promise which fetches the image local source (base64 or blob) and returns as a `File` object. + * + * @param {module:engine/view/element~Element} image Image which source to fetch. + * @returns {Promise} A promise which resolves when image source is fetched and converted to `File` instance. + * It resolves with object holding initial image element (as `image`) and its file source (as `file`). If + * the `file` attribute is null, it means fetching failed. + */ +export function fetchLocalImage( image ) { + return new Promise( resolve => { + // Fetch works asynchronously and so does not block browser UI when processing data. + fetch( image.getAttribute( 'src' ) ) + .then( resource => resource.blob() ) + .then( blob => { + const mimeType = getImageMimeType( blob, image.getAttribute( 'src' ) ); + const ext = mimeType.replace( 'image/', '' ); + const filename = `image.${ ext }`; + const file = createFileFromBlob( blob, filename, mimeType ); + + resolve( { image, file } ); + } ) + .catch( () => { + // We always resolve a promise so `Promise.all` will not reject if one of many fetch fails. + resolve( { image, file: null } ); + } ); + } ); +} + +/** + * Checks whether given node is an image element with local source (base64 or blob). + * + * @param {module:engine/view/node~Node} node Node to check. + * @returns {Boolean} + */ +export function isLocalImage( node ) { + if ( !node.is( 'element', 'img' ) || !node.getAttribute( 'src' ) ) { + return false; + } + + return node.getAttribute( 'src' ).match( /^data:image\/\w+;base64,/g ) || + node.getAttribute( 'src' ).match( /^blob:/g ); +} + +// Extracts image type based on its blob representation or its source. +// +// @param {String} src Image src attribute value. +// @param {Blob} blob Image blob representation. +// @returns {String} +function getImageMimeType( blob, src ) { + if ( blob.type ) { + return blob.type; + } else if ( src.match( /data:(image\/\w+);base64/ ) ) { + return src.match( /data:(image\/\w+);base64/ )[ 1 ].toLowerCase(); + } else { + // Fallback to 'jpeg' as common extension. + return 'image/jpeg'; + } +} + +// Creates `File` instance from the given `Blob` instance using specified filename. +// +// @param {Blob} blob The `Blob` instance from which file will be created. +// @param {String} filename Filename used during file creation. +// @param {String} mimeType File mime type. +// @returns {File|null} The `File` instance created from the given blob or `null` if `File API` is not available. +function createFileFromBlob( blob, filename, mimeType ) { + try { + return new File( [ blob ], filename, { type: mimeType } ); + } catch ( err ) { + // Edge does not support `File` constructor ATM, see https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/9551546/. + // However, the `File` function is present (so cannot be checked with `!window.File` or `typeof File === 'function'`), but + // calling it with `new File( ... )` throws an error. This try-catch prevents that. Also when the function will + // be implemented correctly in Edge the code will start working without any changes (see #247). + return null; + } +} diff --git a/tests/image/converters.js b/tests/image/converters.js index e85c0834..934459d9 100644 --- a/tests/image/converters.js +++ b/tests/image/converters.js @@ -11,9 +11,6 @@ import { toImageWidget } from '../../src/image/utils'; import { createImageViewElement } from '../../src/image/imageediting'; import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; -import { downcastElementToElement } from '@ckeditor/ckeditor5-engine/src/conversion/downcast-converters'; -import { upcastElementToElement } from '@ckeditor/ckeditor5-engine/src/conversion/upcast-converters'; - import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view'; import { setData as setModelData, getData as getModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; import env from '@ckeditor/ckeditor5-utils/src/env'; @@ -47,10 +44,10 @@ describe( 'Image converters', () => { const editingElementCreator = ( modelElement, viewWriter ) => toImageWidget( createImageViewElement( viewWriter ), viewWriter, '' ); - editor.conversion.for( 'editingDowncast' ).add( downcastElementToElement( { + editor.conversion.for( 'editingDowncast' ).elementToElement( { model: 'image', view: editingElementCreator - } ) ); + } ); editor.conversion.for( 'downcast' ) .add( modelToViewAttributeConverter( 'src' ) ) @@ -75,7 +72,7 @@ describe( 'Image converters', () => { editor.conversion.for( 'upcast' ) .add( viewFigureToModel() ) - .add( upcastElementToElement( { + .elementToElement( { view: { name: 'img', attributes: { @@ -87,7 +84,7 @@ describe( 'Image converters', () => { return writer.createElement( 'image', { src: viewImage.getAttribute( 'src' ) } ); } - } ) ); + } ); } ); it( 'should find img element among children and convert it using already defined converters', () => { @@ -98,8 +95,8 @@ describe( 'Image converters', () => { } ); it( 'should convert children allowed by schema and omit disallowed', () => { - editor.conversion.for( 'upcast' ).add( upcastElementToElement( { view: 'foo', model: 'foo' } ) ); - editor.conversion.for( 'upcast' ).add( upcastElementToElement( { view: 'bar', model: 'bar' } ) ); + editor.conversion.for( 'upcast' ).elementToElement( { view: 'foo', model: 'foo' } ); + editor.conversion.for( 'upcast' ).elementToElement( { view: 'bar', model: 'bar' } ); schema.register( 'foo', { allowIn: 'image' } ); // Is allowed in root, but should not try to split image element. @@ -112,7 +109,7 @@ describe( 'Image converters', () => { } ); it( 'should split parent element when image is not allowed - in the middle', () => { - editor.conversion.for( 'upcast' ).add( upcastElementToElement( { view: 'div', model: 'div' } ) ); + editor.conversion.for( 'upcast' ).elementToElement( { view: 'div', model: 'div' } ); schema.register( 'div', { inheritAllFrom: '$block' } ); schema.extend( 'image', { disallowIn: 'div' } ); diff --git a/tests/image/imageediting.js b/tests/image/imageediting.js index 35da6eaa..dab907f3 100644 --- a/tests/image/imageediting.js +++ b/tests/image/imageediting.js @@ -9,6 +9,7 @@ import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtest import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; import ImageEditing from '../../src/image/imageediting'; import ImageLoadObserver from '../../src/image/imageloadobserver'; +import ImageInsertCommand from '../../src/image/imageinsertcommand'; import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view'; import { isImageWidget } from '../../src/image/utils'; @@ -58,6 +59,10 @@ describe( 'ImageEditing', () => { expect( view.getObserver( ImageLoadObserver ) ).to.be.instanceOf( ImageLoadObserver ); } ); + it( 'should register imageInsert command', () => { + expect( editor.commands.get( 'imageInsert' ) ).to.be.instanceOf( ImageInsertCommand ); + } ); + // See https://github.com/ckeditor/ckeditor5-image/issues/142. it( 'should update the ui after image has been loaded in the DOM', () => { const element = document.createElement( 'div' ); diff --git a/tests/image/imageinsertcommand.js b/tests/image/imageinsertcommand.js new file mode 100644 index 00000000..7e3c85af --- /dev/null +++ b/tests/image/imageinsertcommand.js @@ -0,0 +1,175 @@ +/** + * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; + +import ImageInsertCommand from '../../src/image/imageinsertcommand'; +import Image from '../../src/image/imageediting'; + +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import { setData as setModelData, getData as getModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; + +describe( 'ImageInsertCommand', () => { + let editor, command, model; + + beforeEach( () => { + return VirtualTestEditor + .create( { + plugins: [ Image, Paragraph ] + } ) + .then( newEditor => { + editor = newEditor; + model = editor.model; + + command = new ImageInsertCommand( editor ); + + const schema = model.schema; + schema.extend( 'image', { allowAttributes: 'uploadId' } ); + } ); + } ); + + afterEach( () => { + return editor.destroy(); + } ); + + describe( 'isEnabled', () => { + it( 'should be true when the selection directly in the root', () => { + model.enqueueChange( 'transparent', () => { + setModelData( model, '[]' ); + + command.refresh(); + expect( command.isEnabled ).to.be.true; + } ); + } ); + + it( 'should be true when the selection is in empty block', () => { + setModelData( model, '[]' ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true when the selection directly in a paragraph', () => { + setModelData( model, 'foo[]' ); + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true when the selection directly in a block', () => { + model.schema.register( 'block', { inheritAllFrom: '$block' } ); + model.schema.extend( '$text', { allowIn: 'block' } ); + editor.conversion.for( 'downcast' ).elementToElement( { model: 'block', view: 'block' } ); + + setModelData( model, 'foo[]' ); + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be false when the selection is on other image', () => { + setModelData( model, '[]' ); + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be false when the selection is inside other image', () => { + model.schema.register( 'caption', { + allowIn: 'image', + allowContentOf: '$block', + isLimit: true + } ); + editor.conversion.for( 'downcast' ).elementToElement( { model: 'caption', view: 'figcaption' } ); + setModelData( model, '[]' ); + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be false when the selection is on other object', () => { + model.schema.register( 'object', { isObject: true, allowIn: '$root' } ); + editor.conversion.for( 'downcast' ).elementToElement( { model: 'object', view: 'object' } ); + setModelData( model, '[]' ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be true when the selection is inside isLimit element which allows image', () => { + model.schema.register( 'outerObject', { isObject: true, isBlock: true, allowIn: '$root' } ); + model.schema.register( 'limit', { isLimit: true, allowIn: 'outerObject' } ); + model.schema.extend( '$block', { allowIn: 'limit' } ); + + editor.conversion.for( 'downcast' ).elementToElement( { model: 'outerObject', view: 'outerObject' } ); + editor.conversion.for( 'downcast' ).elementToElement( { model: 'limit', view: 'limit' } ); + + setModelData( model, '[]' ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true when the selection is inside isLimit element which allows image', () => { + model.schema.register( 'outerObject', { isObject: true, isBlock: true, allowIn: '$root' } ); + model.schema.register( 'limit', { isLimit: true, allowIn: 'outerObject' } ); + model.schema.extend( '$block', { allowIn: 'limit' } ); + + editor.conversion.for( 'downcast' ).elementToElement( { model: 'outerObject', view: 'outerObject' } ); + editor.conversion.for( 'downcast' ).elementToElement( { model: 'limit', view: 'limit' } ); + + setModelData( model, 'foo[]' ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be false when schema disallows image', () => { + model.schema.register( 'block', { inheritAllFrom: '$block' } ); + model.schema.extend( 'paragraph', { allowIn: 'block' } ); + // Block image in block. + model.schema.addChildCheck( ( context, childDefinition ) => { + if ( childDefinition.name === 'image' && context.last.name === 'block' ) { + return false; + } + } ); + editor.conversion.for( 'downcast' ).elementToElement( { model: 'block', view: 'block' } ); + + setModelData( model, '[]' ); + + expect( command.isEnabled ).to.be.false; + } ); + } ); + + describe( 'execute()', () => { + it( 'should insert image at selection position as other widgets', () => { + const imgSrc = 'foo/bar.jpg'; + + setModelData( model, 'f[o]o' ); + + command.execute( { source: imgSrc } ); + + expect( getModelData( model ) ).to.equal( `[]foo` ); + } ); + + it( 'should insert multiple images at selection position as other widgets', () => { + const imgSrc1 = 'foo/bar.jpg'; + const imgSrc2 = 'foo/baz.jpg'; + + setModelData( model, 'f[o]o' ); + + command.execute( { source: [ imgSrc1, imgSrc2 ] } ); + + expect( getModelData( model ) ) + .to.equal( `[]foo` ); + } ); + + it( 'should not insert image nor crash when image could not be inserted', () => { + const imgSrc = 'foo/bar.jpg'; + + model.schema.register( 'other', { + allowIn: '$root', + isLimit: true + } ); + model.schema.extend( '$text', { allowIn: 'other' } ); + + editor.conversion.for( 'downcast' ).elementToElement( { model: 'other', view: 'p' } ); + + setModelData( model, '[]' ); + + command.execute( { source: imgSrc } ); + + expect( getModelData( model ) ).to.equal( '[]' ); + } ); + } ); +} ); diff --git a/tests/image/utils.js b/tests/image/utils.js index 85985a71..0f800e8c 100644 --- a/tests/image/utils.js +++ b/tests/image/utils.js @@ -7,8 +7,12 @@ import ViewDocumentFragment from '@ckeditor/ckeditor5-engine/src/view/documentfr import ViewDowncastWriter from '@ckeditor/ckeditor5-engine/src/view/downcastwriter'; import ViewDocument from '@ckeditor/ckeditor5-engine/src/view/document'; import ModelElement from '@ckeditor/ckeditor5-engine/src/model/element'; -import { toImageWidget, isImageWidget, isImageWidgetSelected, isImage } from '../../src/image/utils'; +import { toImageWidget, isImageWidget, isImageWidgetSelected, isImage, isImageAllowed, insertImage } from '../../src/image/utils'; import { isWidget, getLabel } from '@ckeditor/ckeditor5-widget/src/utils'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; +import { setData as setModelData, getData as getModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import Image from '../../src/image/imageediting'; describe( 'image widget utils', () => { let element, image, writer; @@ -87,7 +91,7 @@ describe( 'image widget utils', () => { } ); } ); - describe( 'isImage', () => { + describe( 'isImage()', () => { it( 'should return true for image element', () => { const image = new ModelElement( 'image' ); @@ -105,4 +109,172 @@ describe( 'image widget utils', () => { expect( isImage( undefined ) ).to.be.false; } ); } ); + + describe( 'isImageAllowed()', () => { + let editor, model; + + beforeEach( () => { + return VirtualTestEditor + .create( { + plugins: [ Image, Paragraph ] + } ) + .then( newEditor => { + editor = newEditor; + model = editor.model; + + const schema = model.schema; + schema.extend( 'image', { allowAttributes: 'uploadId' } ); + } ); + } ); + + it( 'should return true when the selection directly in the root', () => { + model.enqueueChange( 'transparent', () => { + setModelData( model, '[]' ); + + expect( isImageAllowed( model ) ).to.be.true; + } ); + } ); + + it( 'should return true when the selection is in empty block', () => { + setModelData( model, '[]' ); + + expect( isImageAllowed( model ) ).to.be.true; + } ); + + it( 'should return true when the selection directly in a paragraph', () => { + setModelData( model, 'foo[]' ); + expect( isImageAllowed( model ) ).to.be.true; + } ); + + it( 'should return true when the selection directly in a block', () => { + model.schema.register( 'block', { inheritAllFrom: '$block' } ); + model.schema.extend( '$text', { allowIn: 'block' } ); + editor.conversion.for( 'downcast' ).elementToElement( { model: 'block', view: 'block' } ); + + setModelData( model, 'foo[]' ); + expect( isImageAllowed( model ) ).to.be.true; + } ); + + it( 'should return false when the selection is on other image', () => { + setModelData( model, '[]' ); + expect( isImageAllowed( model ) ).to.be.false; + } ); + + it( 'should return false when the selection is inside other image', () => { + model.schema.register( 'caption', { + allowIn: 'image', + allowContentOf: '$block', + isLimit: true + } ); + editor.conversion.for( 'downcast' ).elementToElement( { model: 'caption', view: 'figcaption' } ); + setModelData( model, '[]' ); + expect( isImageAllowed( model ) ).to.be.false; + } ); + + it( 'should return false when the selection is on other object', () => { + model.schema.register( 'object', { isObject: true, allowIn: '$root' } ); + editor.conversion.for( 'downcast' ).elementToElement( { model: 'object', view: 'object' } ); + setModelData( model, '[]' ); + + expect( isImageAllowed( model ) ).to.be.false; + } ); + + it( 'should be true when the selection is inside isLimit element which allows image', () => { + model.schema.register( 'outerObject', { isObject: true, isBlock: true, allowIn: '$root' } ); + model.schema.register( 'limit', { isLimit: true, allowIn: 'outerObject' } ); + model.schema.extend( '$block', { allowIn: 'limit' } ); + + editor.conversion.for( 'downcast' ).elementToElement( { model: 'outerObject', view: 'outerObject' } ); + editor.conversion.for( 'downcast' ).elementToElement( { model: 'limit', view: 'limit' } ); + + setModelData( model, '[]' ); + + expect( isImageAllowed( model ) ).to.be.true; + } ); + + it( 'should be true when the selection is inside isLimit element which allows image', () => { + model.schema.register( 'outerObject', { isObject: true, isBlock: true, allowIn: '$root' } ); + model.schema.register( 'limit', { isLimit: true, allowIn: 'outerObject' } ); + model.schema.extend( '$block', { allowIn: 'limit' } ); + + editor.conversion.for( 'downcast' ).elementToElement( { model: 'outerObject', view: 'outerObject' } ); + editor.conversion.for( 'downcast' ).elementToElement( { model: 'limit', view: 'limit' } ); + + setModelData( model, 'foo[]' ); + + expect( isImageAllowed( model ) ).to.be.true; + } ); + + it( 'should return false when schema disallows image', () => { + model.schema.register( 'block', { inheritAllFrom: '$block' } ); + model.schema.extend( 'paragraph', { allowIn: 'block' } ); + // Block image in block. + model.schema.addChildCheck( ( context, childDefinition ) => { + if ( childDefinition.name === 'image' && context.last.name === 'block' ) { + return false; + } + } ); + editor.conversion.for( 'downcast' ).elementToElement( { model: 'block', view: 'block' } ); + + setModelData( model, '[]' ); + + expect( isImageAllowed( model ) ).to.be.false; + } ); + } ); + + describe( 'insertImage()', () => { + let editor, model; + + beforeEach( () => { + return VirtualTestEditor + .create( { + plugins: [ Image, Paragraph ] + } ) + .then( newEditor => { + editor = newEditor; + model = editor.model; + + const schema = model.schema; + schema.extend( 'image', { allowAttributes: 'uploadId' } ); + } ); + } ); + + it( 'should insert image at selection position as other widgets', () => { + setModelData( model, 'f[o]o' ); + + model.change( writer => { + insertImage( writer, model ); + } ); + + expect( getModelData( model ) ).to.equal( '[]foo' ); + } ); + + it( 'should insert image with given attributes', () => { + setModelData( model, 'f[o]o' ); + + model.change( writer => { + insertImage( writer, model, { src: 'bar' } ); + } ); + + expect( getModelData( model ) ).to.equal( '[]foo' ); + } ); + + it( 'should not insert image nor crash when image could not be inserted', () => { + model.schema.register( 'other', { + allowIn: '$root', + isLimit: true + } ); + model.schema.extend( '$text', { allowIn: 'other' } ); + + editor.conversion.for( 'downcast' ).elementToElement( { model: 'other', view: 'p' } ); + + setModelData( model, '[]' ); + + model.change( writer => { + insertImage( writer, model ); + } ); + + expect( getModelData( model ) ).to.equal( '[]' ); + } ); + } ); } ); diff --git a/tests/imagestyle.js b/tests/imagestyle.js index 461f2e70..277f0171 100644 --- a/tests/imagestyle.js +++ b/tests/imagestyle.js @@ -4,6 +4,7 @@ */ import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; +import Image from '../src/image'; import ImageStyle from '../src/imagestyle'; import ImageStyleEditing from '../src/imagestyle/imagestyleediting'; import ImageStyleUI from '../src/imagestyle/imagestyleui'; @@ -18,7 +19,7 @@ describe( 'ImageStyle', () => { return ClassicTestEditor .create( editorElement, { - plugins: [ ImageStyle ] + plugins: [ Image, ImageStyle ] } ) .then( newEditor => { editor = newEditor; diff --git a/tests/imagestyle/imagestyleediting.js b/tests/imagestyle/imagestyleediting.js index 4a81e991..790041f1 100644 --- a/tests/imagestyle/imagestyleediting.js +++ b/tests/imagestyle/imagestyleediting.js @@ -32,7 +32,7 @@ describe( 'ImageStyleEditing', () => { beforeEach( () => { return VirtualTestEditor .create( { - plugins: [ ImageStyleEditing ], + plugins: [ ImageEditing, ImageStyleEditing ] } ) .then( newEditor => { editor = newEditor; @@ -42,17 +42,13 @@ describe( 'ImageStyleEditing', () => { it( 'should be loaded', () => { expect( editor.plugins.get( ImageStyleEditing ) ).to.be.instanceOf( ImageStyleEditing ); } ); - - it( 'should load image editing', () => { - expect( editor.plugins.get( ImageEditing ) ).to.be.instanceOf( ImageEditing ); - } ); } ); describe( 'init', () => { beforeEach( () => { return VirtualTestEditor .create( { - plugins: [ ImageStyleEditing ], + plugins: [ ImageEditing, ImageStyleEditing ], image: { styles: [ { name: 'fullStyle', title: 'foo', icon: 'object-center', isDefault: true }, @@ -72,7 +68,7 @@ describe( 'ImageStyleEditing', () => { it( 'should define image.styles config', () => { return VirtualTestEditor .create( { - plugins: [ ImageStyleEditing ] + plugins: [ ImageEditing, ImageStyleEditing ] } ) .then( newEditor => { editor = newEditor; @@ -265,7 +261,7 @@ describe( 'ImageStyleEditing', () => { it( 'should fall back to defaults when no image.styles', () => { return VirtualTestEditor .create( { - plugins: [ ImageStyleEditing ] + plugins: [ ImageEditing, ImageStyleEditing ] } ) .then( newEditor => { editor = newEditor; @@ -277,7 +273,7 @@ describe( 'ImageStyleEditing', () => { it( 'should not alter the image.styles config', () => { return VirtualTestEditor .create( { - plugins: [ ImageStyleEditing ], + plugins: [ ImageEditing, ImageStyleEditing ], image: { styles: [ 'side' @@ -294,7 +290,7 @@ describe( 'ImageStyleEditing', () => { it( 'should not alter object definitions in the image.styles config', () => { return VirtualTestEditor .create( { - plugins: [ ImageStyleEditing ], + plugins: [ ImageEditing, ImageStyleEditing ], image: { styles: [ { name: 'side' } diff --git a/tests/imagestyle/imagestyleui.js b/tests/imagestyle/imagestyleui.js index c46e3ae9..631f57dc 100644 --- a/tests/imagestyle/imagestyleui.js +++ b/tests/imagestyle/imagestyleui.js @@ -6,6 +6,7 @@ import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; import ImageStyleEditing from '../../src/imagestyle/imagestyleediting'; import ImageStyleUI from '../../src/imagestyle/imagestyleui'; +import ImageEditing from '../../src/image/imageediting'; import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview'; import global from '@ckeditor/ckeditor5-utils/src/dom/global'; import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; @@ -26,7 +27,7 @@ describe( 'ImageStyleUI', () => { return ClassicTestEditor .create( editorElement, { - plugins: [ ImageStyleEditing, ImageStyleUI ], + plugins: [ ImageEditing, ImageStyleEditing, ImageStyleUI ], image: { styles } @@ -77,7 +78,7 @@ describe( 'ImageStyleUI', () => { return ClassicTestEditor .create( editorElement, { - plugins: [ TranslationMock, ImageStyleEditing, ImageStyleUI ], + plugins: [ TranslationMock, ImageEditing, ImageStyleEditing, ImageStyleUI ], image: { styles: [ { name: 'style 1', title: 'Side image', icon: 'style1-icon', isDefault: true } @@ -99,7 +100,7 @@ describe( 'ImageStyleUI', () => { return ClassicTestEditor .create( editorElement, { - plugins: [ ImageStyleEditing, ImageStyleUI ], + plugins: [ ImageEditing, ImageStyleEditing, ImageStyleUI ], image: { styles, toolbar: [ 'foo', 'bar' ] diff --git a/tests/imageupload.js b/tests/imageupload.js index 79ef97a5..a63f6e98 100644 --- a/tests/imageupload.js +++ b/tests/imageupload.js @@ -6,6 +6,7 @@ /* globals document */ import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor'; +import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard'; import Image from '../src/image'; import ImageUpload from '../src/imageupload'; import ImageUploadEditing from '../src/imageupload/imageuploadediting'; @@ -23,7 +24,7 @@ describe( 'ImageUpload', () => { return ClassicEditor .create( editorElement, { - plugins: [ Image, ImageUpload, UploadAdapterPluginMock ] + plugins: [ Image, ImageUpload, UploadAdapterPluginMock, Clipboard ] } ) .then( newEditor => { editor = newEditor; diff --git a/tests/imageupload/imageuploadcommand.js b/tests/imageupload/imageuploadcommand.js index eaeb7dfd..40e900d1 100644 --- a/tests/imageupload/imageuploadcommand.js +++ b/tests/imageupload/imageuploadcommand.js @@ -14,7 +14,6 @@ import { createNativeFileMock, UploadAdapterMock } from '@ckeditor/ckeditor5-upl import { setData as setModelData, getData as getModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; import Image from '../../src/image/imageediting'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; -import { downcastElementToElement } from '@ckeditor/ckeditor5-engine/src/conversion/downcast-converters'; import log from '@ckeditor/ckeditor5-utils/src/log'; @@ -78,7 +77,7 @@ describe( 'ImageUploadCommand', () => { it( 'should be true when the selection directly in a block', () => { model.schema.register( 'block', { inheritAllFrom: '$block' } ); model.schema.extend( '$text', { allowIn: 'block' } ); - editor.conversion.for( 'downcast' ).add( downcastElementToElement( { model: 'block', view: 'block' } ) ); + editor.conversion.for( 'downcast' ).elementToElement( { model: 'block', view: 'block' } ); setModelData( model, 'foo[]' ); expect( command.isEnabled ).to.be.true; @@ -95,26 +94,43 @@ describe( 'ImageUploadCommand', () => { allowContentOf: '$block', isLimit: true } ); - editor.conversion.for( 'downcast' ).add( downcastElementToElement( { model: 'caption', view: 'figcaption' } ) ); + editor.conversion.for( 'downcast' ).elementToElement( { model: 'caption', view: 'figcaption' } ); setModelData( model, '[]' ); expect( command.isEnabled ).to.be.false; } ); it( 'should be false when the selection is on other object', () => { model.schema.register( 'object', { isObject: true, allowIn: '$root' } ); - editor.conversion.for( 'downcast' ).add( downcastElementToElement( { model: 'object', view: 'object' } ) ); + editor.conversion.for( 'downcast' ).elementToElement( { model: 'object', view: 'object' } ); setModelData( model, '[]' ); expect( command.isEnabled ).to.be.false; } ); - it( 'should be false when the selection is inside other object', () => { - model.schema.register( 'object', { isObject: true, allowIn: '$root' } ); - model.schema.extend( '$text', { allowIn: 'object' } ); - editor.conversion.for( 'downcast' ).add( downcastElementToElement( { model: 'object', view: 'object' } ) ); - setModelData( model, '[]' ); + it( 'should be true when the selection is inside isLimit element which allows image', () => { + model.schema.register( 'outerObject', { isObject: true, isBlock: true, allowIn: '$root' } ); + model.schema.register( 'limit', { isLimit: true, allowIn: 'outerObject' } ); + model.schema.extend( '$block', { allowIn: 'limit' } ); - expect( command.isEnabled ).to.be.false; + editor.conversion.for( 'downcast' ).elementToElement( { model: 'outerObject', view: 'outerObject' } ); + editor.conversion.for( 'downcast' ).elementToElement( { model: 'limit', view: 'limit' } ); + + setModelData( model, '[]' ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true when the selection is inside isLimit element which allows image', () => { + model.schema.register( 'outerObject', { isObject: true, isBlock: true, allowIn: '$root' } ); + model.schema.register( 'limit', { isLimit: true, allowIn: 'outerObject' } ); + model.schema.extend( '$block', { allowIn: 'limit' } ); + + editor.conversion.for( 'downcast' ).elementToElement( { model: 'outerObject', view: 'outerObject' } ); + editor.conversion.for( 'downcast' ).elementToElement( { model: 'limit', view: 'limit' } ); + + setModelData( model, 'foo[]' ); + + expect( command.isEnabled ).to.be.true; } ); it( 'should be false when schema disallows image', () => { @@ -126,7 +142,7 @@ describe( 'ImageUploadCommand', () => { return false; } } ); - editor.conversion.for( 'downcast' ).add( downcastElementToElement( { model: 'block', view: 'block' } ) ); + editor.conversion.for( 'downcast' ).elementToElement( { model: 'block', view: 'block' } ); setModelData( model, '[]' ); @@ -139,7 +155,7 @@ describe( 'ImageUploadCommand', () => { const file = createNativeFileMock(); setModelData( model, 'f[o]o' ); - command.execute( { files: file } ); + command.execute( { file } ); const id = fileRepository.getLoader( file ).id; expect( getModelData( model ) ) @@ -154,7 +170,7 @@ describe( 'ImageUploadCommand', () => { model.change( writer => { expect( writer.batch.operations ).to.length( 0 ); - command.execute( { files: file } ); + command.execute( { file } ); expect( writer.batch.operations ).to.length.above( 0 ); } ); @@ -169,11 +185,11 @@ describe( 'ImageUploadCommand', () => { } ); model.schema.extend( '$text', { allowIn: 'other' } ); - editor.conversion.for( 'downcast' ).add( downcastElementToElement( { model: 'other', view: 'p' } ) ); + editor.conversion.for( 'downcast' ).elementToElement( { model: 'other', view: 'p' } ); setModelData( model, '[]' ); - command.execute( { files: file } ); + command.execute( { file } ); expect( getModelData( model ) ).to.equal( '[]' ); } ); @@ -188,7 +204,7 @@ describe( 'ImageUploadCommand', () => { setModelData( model, 'fo[]o' ); expect( () => { - command.execute( { files: file } ); + command.execute( { file } ); } ).to.not.throw(); expect( getModelData( model ) ).to.equal( 'fo[]o' ); diff --git a/tests/imageupload/imageuploadediting.js b/tests/imageupload/imageuploadediting.js index afe81bd2..82c380b4 100644 --- a/tests/imageupload/imageuploadediting.js +++ b/tests/imageupload/imageuploadediting.js @@ -3,7 +3,7 @@ * For licensing, see LICENSE.md. */ -/* globals window, setTimeout */ +/* globals window, setTimeout, atob, URL, Blob */ import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; @@ -30,7 +30,10 @@ import Notification from '@ckeditor/ckeditor5-ui/src/notification/notification'; describe( 'ImageUploadEditing', () => { // eslint-disable-next-line max-len const base64Sample = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='; - let editor, model, view, doc, fileRepository, viewDocument, nativeReaderMock, loader, adapterMock; + const isEdgeEnv = env.isEdge; + + let adapterMocks = []; + let editor, model, view, doc, fileRepository, viewDocument, nativeReaderMock, loader; testUtils.createSinonSandbox(); @@ -39,7 +42,9 @@ describe( 'ImageUploadEditing', () => { fileRepository = this.editor.plugins.get( FileRepository ); fileRepository.createUploadAdapter = newLoader => { loader = newLoader; - adapterMock = new UploadAdapterMock( loader ); + const adapterMock = new UploadAdapterMock( loader ); + + adapterMocks.push( adapterMock ); return adapterMock; }; @@ -47,6 +52,12 @@ describe( 'ImageUploadEditing', () => { } beforeEach( () => { + if ( isEdgeEnv ) { + testUtils.sinon.stub( window, 'File' ).callsFake( () => { + return { name: 'file.jpg' }; + } ); + } + // Most tests assume non-edge environment but we do not set `contenteditable=false` on Edge so stub `env.isEdge`. testUtils.sinon.stub( env, 'isEdge' ).get( () => false ); @@ -58,7 +69,7 @@ describe( 'ImageUploadEditing', () => { return VirtualTestEditor .create( { - plugins: [ ImageEditing, ImageUploadEditing, Paragraph, UndoEditing, UploadAdapterPluginMock ] + plugins: [ ImageEditing, ImageUploadEditing, Paragraph, UndoEditing, UploadAdapterPluginMock, Clipboard ] } ) .then( newEditor => { editor = newEditor; @@ -66,10 +77,15 @@ describe( 'ImageUploadEditing', () => { doc = model.document; view = editor.editing.view; viewDocument = view.document; + + // Stub `view.scrollToTheSelection` as it will fail on VirtualTestEditor without DOM. + testUtils.sinon.stub( view, 'scrollToTheSelection' ).callsFake( () => {} ); } ); } ); afterEach( () => { + adapterMocks = []; + return editor.destroy(); } ); @@ -81,6 +97,13 @@ describe( 'ImageUploadEditing', () => { expect( editor.commands.get( 'imageUpload' ) ).to.be.instanceOf( ImageUploadCommand ); } ); + it( 'should not crash when Clipboard plugin is not available', () => { + return VirtualTestEditor + .create( { + plugins: [ ImageEditing, ImageUploadEditing, Paragraph, UndoEditing, UploadAdapterPluginMock ] + } ); + } ); + it( 'should insert image when is pasted', () => { const fileMock = createNativeFileMock(); const dataTransfer = new DataTransfer( { files: [ fileMock ], types: [ 'Files' ] } ); @@ -185,7 +208,25 @@ describe( 'ImageUploadEditing', () => { type: 'media/mp3', size: 1024 }; - const dataTransfer = new DataTransfer( { files: [ fileMock ], types: [ 'Files' ] } ); + const dataTransfer = new DataTransfer( { + files: [ fileMock ], + types: [ 'Files' ], + getData: () => '' + } ); + + setModelData( model, 'foo[]' ); + + const targetRange = doc.selection.getFirstRange(); + const targetViewRange = editor.editing.mapper.toViewRange( targetRange ); + + viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } ); + + expect( getModelData( model ) ).to.equal( 'foo[]' ); + } ); + + it( 'should not insert image when file is null', () => { + const viewDocument = editor.editing.view.document; + const dataTransfer = new DataTransfer( { files: [ null ], types: [ 'Files' ], getData: () => null } ); setModelData( model, 'foo[]' ); @@ -211,7 +252,7 @@ describe( 'ImageUploadEditing', () => { viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } ); - expect( getModelData( model ) ).to.equal( '[]foo' ); + expect( getModelData( model ) ).to.equal( 'SomeData[]foo' ); } ); it( 'should not insert image nor crash when pasted image could not be inserted', () => { @@ -275,7 +316,7 @@ describe( 'ImageUploadEditing', () => { viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } ); // Well, there's no clipboard plugin, so nothing happens. - expect( getModelData( model ) ).to.equal( '[]foo' ); + expect( getModelData( model ) ).to.equal( 'SomeData[]foo' ); } ); it( 'should not convert image\'s uploadId attribute if is consumed already', () => { @@ -294,7 +335,7 @@ describe( 'ImageUploadEditing', () => { it( 'should use read data once it is present', done => { const file = createNativeFileMock(); setModelData( model, '{}foo bar' ); - editor.execute( 'imageUpload', { files: file } ); + editor.execute( 'imageUpload', { file } ); model.once( '_change', () => { expect( getViewData( view ) ).to.equal( @@ -314,7 +355,7 @@ describe( 'ImageUploadEditing', () => { it( 'should replace read data with server response once it is present', done => { const file = createNativeFileMock(); setModelData( model, '{}foo bar' ); - editor.execute( 'imageUpload', { files: file } ); + editor.execute( 'imageUpload', { file } ); model.document.once( 'change', () => { model.document.once( 'change', () => { @@ -326,7 +367,7 @@ describe( 'ImageUploadEditing', () => { done(); }, { priority: 'lowest' } ); - adapterMock.mockSuccess( { default: 'image.png' } ); + adapterMocks[ 0 ].mockSuccess( { default: 'image.png' } ); } ); nativeReaderMock.mockSuccess( base64Sample ); @@ -345,7 +386,7 @@ describe( 'ImageUploadEditing', () => { }, { priority: 'high' } ); setModelData( model, '{}foo bar' ); - editor.execute( 'imageUpload', { files: file } ); + editor.execute( 'imageUpload', { file } ); nativeReaderMock.mockError( 'Reading error.' ); } ); @@ -361,7 +402,7 @@ describe( 'ImageUploadEditing', () => { }, { priority: 'high' } ); setModelData( model, '{}foo bar' ); - editor.execute( 'imageUpload', { files: file } ); + editor.execute( 'imageUpload', { file } ); nativeReaderMock.abort(); setTimeout( () => { @@ -385,7 +426,7 @@ describe( 'ImageUploadEditing', () => { } ); setModelData( model, '{}foo bar' ); - editor.execute( 'imageUpload', { files: file } ); + editor.execute( 'imageUpload', { file } ); sinon.assert.calledOnce( loadSpy ); @@ -422,7 +463,7 @@ describe( 'ImageUploadEditing', () => { evt.stop(); }, { priority: 'high' } ); - editor.execute( 'imageUpload', { files: file } ); + editor.execute( 'imageUpload', { file } ); model.document.once( 'change', () => { model.document.once( 'change', () => { @@ -439,7 +480,7 @@ describe( 'ImageUploadEditing', () => { it( 'should abort upload if image is removed', () => { const file = createNativeFileMock(); setModelData( model, '{}foo bar' ); - editor.execute( 'imageUpload', { files: file } ); + editor.execute( 'imageUpload', { file } ); const abortSpy = testUtils.sinon.spy( loader, 'abort' ); @@ -458,7 +499,7 @@ describe( 'ImageUploadEditing', () => { it( 'should not abort and not restart upload when image is moved', () => { const file = createNativeFileMock(); setModelData( model, '{}foo bar' ); - editor.execute( 'imageUpload', { files: file } ); + editor.execute( 'imageUpload', { file } ); const abortSpy = testUtils.sinon.spy( loader, 'abort' ); const loadSpy = testUtils.sinon.spy( loader, 'read' ); @@ -483,7 +524,7 @@ describe( 'ImageUploadEditing', () => { evt.stop(); }, { priority: 'high' } ); - editor.execute( 'imageUpload', { files: file } ); + editor.execute( 'imageUpload', { file } ); model.document.once( 'change', () => { // This is called after "manual" remove. @@ -519,7 +560,7 @@ describe( 'ImageUploadEditing', () => { it( 'should create responsive image if server return multiple images', done => { const file = createNativeFileMock(); setModelData( model, '{}foo bar' ); - editor.execute( 'imageUpload', { files: file } ); + editor.execute( 'imageUpload', { file } ); model.document.once( 'change', () => { model.document.once( 'change', () => { @@ -533,7 +574,7 @@ describe( 'ImageUploadEditing', () => { done(); }, { priority: 'lowest' } ); - adapterMock.mockSuccess( { default: 'image.png', 500: 'image-500.png', 800: 'image-800.png' } ); + adapterMocks[ 0 ].mockSuccess( { default: 'image.png', 500: 'image-500.png', 800: 'image-800.png' } ); } ); nativeReaderMock.mockSuccess( base64Sample ); @@ -548,4 +589,292 @@ describe( 'ImageUploadEditing', () => { expect( spy.calledOnce ).to.equal( true ); } ); + + it( 'should upload image with base64 src', done => { + editor.plugins.get( 'Clipboard' ).on( 'inputTransformation', () => { + const id = adapterMocks[ 0 ].loader.id; + const expected = 'bar' + + `[]` + + 'foo'; + + expectModel( done, getModelData( model ), expected ); + }, { priority: 'low' } ); + + setModelData( model, '[]foo' ); + + const clipboardHtml = `

bar

`; + const dataTransfer = mockDataTransfer( clipboardHtml ); + + const targetRange = model.createRange( model.createPositionAt( doc.getRoot(), 1 ), model.createPositionAt( doc.getRoot(), 1 ) ); + const targetViewRange = editor.editing.mapper.toViewRange( targetRange ); + + viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } ); + } ); + + it( 'should upload image with blob src', done => { + editor.plugins.get( 'Clipboard' ).on( 'inputTransformation', () => { + const id = adapterMocks[ 0 ].loader.id; + const expected = `[]` + + 'foo'; + + expectModel( done, getModelData( model ), expected ); + }, { priority: 'low' } ); + + setModelData( model, '[]foo' ); + + const clipboardHtml = ``; + const dataTransfer = mockDataTransfer( clipboardHtml ); + + const targetRange = model.createRange( model.createPositionAt( doc.getRoot(), 1 ), model.createPositionAt( doc.getRoot(), 1 ) ); + const targetViewRange = editor.editing.mapper.toViewRange( targetRange ); + + viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } ); + } ); + + it( 'should not upload image if no loader available', done => { + editor.plugins.get( 'Clipboard' ).on( 'inputTransformation', () => { + const expected = `[]foo`; + + expectModel( done, getModelData( model ), expected ); + }, { priority: 'low' } ); + + testUtils.sinon.stub( fileRepository, 'createLoader' ).callsFake( () => null ); + + setModelData( model, '[]foo' ); + + const clipboardHtml = ``; + const dataTransfer = mockDataTransfer( clipboardHtml ); + + const targetRange = model.createRange( model.createPositionAt( doc.getRoot(), 1 ), model.createPositionAt( doc.getRoot(), 1 ) ); + const targetViewRange = editor.editing.mapper.toViewRange( targetRange ); + + viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } ); + } ); + + it( 'should not upload and remove image if fetch failed', done => { + editor.plugins.get( 'Clipboard' ).on( 'inputTransformation', () => { + const expected = '[]foo'; + + expectModel( done, getModelData( model ), expected ); + }, { priority: 'low' } ); + + setModelData( model, '[]foo' ); + + const clipboardHtml = ``; + const dataTransfer = mockDataTransfer( clipboardHtml ); + + const targetRange = model.createRange( model.createPositionAt( doc.getRoot(), 1 ), model.createPositionAt( doc.getRoot(), 1 ) ); + const targetViewRange = editor.editing.mapper.toViewRange( targetRange ); + + // Stub `fetch` so it can be rejected. + testUtils.sinon.stub( window, 'fetch' ).callsFake( () => { + return new Promise( ( res, rej ) => rej() ); + } ); + + viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } ); + } ); + + it( 'should upload only images which were successfully fetched and remove failed ones', done => { + editor.plugins.get( 'Clipboard' ).on( 'inputTransformation', () => { + const expected = 'bar' + + `` + + `[]` + + 'foo'; + + expectModel( done, getModelData( model ), expected ); + }, { priority: 'low' } ); + + setModelData( model, '[]foo' ); + + const clipboardHtml = `

bar

` + + ``; + const dataTransfer = mockDataTransfer( clipboardHtml ); + + const targetRange = model.createRange( model.createPositionAt( doc.getRoot(), 1 ), model.createPositionAt( doc.getRoot(), 1 ) ); + const targetViewRange = editor.editing.mapper.toViewRange( targetRange ); + + // Stub `fetch` in a way that 2 first calls are successful and 3rd fails. + let counter = 0; + const fetch = window.fetch; + testUtils.sinon.stub( window, 'fetch' ).callsFake( src => { + counter++; + if ( counter < 3 ) { + return fetch( src ); + } else { + return new Promise( ( res, rej ) => rej() ); + } + } ); + + viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } ); + } ); + + it( 'should not upload and remove image when `File` constructor is not present', done => { + const fileFn = window.File; + + window.File = undefined; + + editor.plugins.get( 'Clipboard' ).on( 'inputTransformation', () => { + window.File = fileFn; + + const expected = 'baz[]foo'; + + expectModel( done, getModelData( model ), expected ); + }, { priority: 'low' } ); + + setModelData( model, '[]foo' ); + + const clipboardHtml = `

baz

`; + const dataTransfer = mockDataTransfer( clipboardHtml ); + + const targetRange = model.createRange( model.createPositionAt( doc.getRoot(), 1 ), model.createPositionAt( doc.getRoot(), 1 ) ); + const targetViewRange = editor.editing.mapper.toViewRange( targetRange ); + + viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } ); + } ); + + it( 'should not upload and remove image when `File` constructor is not supported', done => { + const fileFn = window.File; + + window.File = function() { + throw new Error( 'Function expected.' ); // Simulating Edge browser behaviour here. + }; + + editor.plugins.get( 'Clipboard' ).on( 'inputTransformation', () => { + window.File = fileFn; + + const expected = 'baz[]foo'; + + expectModel( done, getModelData( model ), expected ); + }, { priority: 'low' } ); + + setModelData( model, '[]foo' ); + + const clipboardHtml = `

baz

`; + const dataTransfer = mockDataTransfer( clipboardHtml ); + + const targetRange = model.createRange( model.createPositionAt( doc.getRoot(), 1 ), model.createPositionAt( doc.getRoot(), 1 ) ); + const targetViewRange = editor.editing.mapper.toViewRange( targetRange ); + + viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } ); + } ); + + // Skip this test on Edge as we mock `File` object there so there is no sense in testing it. + ( isEdgeEnv ? it.skip : it )( 'should get file extension from base64 string', done => { + editor.plugins.get( 'Clipboard' ).on( 'inputTransformation', () => { + try { + expect( loader.file.name.split( '.' ).pop() ).to.equal( 'png' ); + done(); + } catch ( err ) { + done( err ); + } + }, { priority: 'low' } ); + + setModelData( model, '[]foo' ); + + const clipboardHtml = ``; + const dataTransfer = mockDataTransfer( clipboardHtml ); + + const targetRange = model.createRange( model.createPositionAt( doc.getRoot(), 1 ), model.createPositionAt( doc.getRoot(), 1 ) ); + const targetViewRange = editor.editing.mapper.toViewRange( targetRange ); + + // Stub `fetch` to return custom blob without type. + testUtils.sinon.stub( window, 'fetch' ).callsFake( () => { + return new Promise( res => res( { + blob() { + return new Promise( res => res( new Blob( [ 'foo', 'bar' ] ) ) ); + } + } ) ); + } ); + + viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } ); + } ); + + // Skip this test on Edge as we mock `File` object there so there is no sense in testing it. + ( isEdgeEnv ? it.skip : it )( 'should use fallback file extension', done => { + editor.plugins.get( 'Clipboard' ).on( 'inputTransformation', () => { + try { + expect( loader.file.name.split( '.' ).pop() ).to.equal( 'jpeg' ); + done(); + } catch ( err ) { + done( err ); + } + }, { priority: 'low' } ); + + setModelData( model, '[]foo' ); + + const clipboardHtml = ``; + const dataTransfer = mockDataTransfer( clipboardHtml ); + + const targetRange = model.createRange( model.createPositionAt( doc.getRoot(), 1 ), model.createPositionAt( doc.getRoot(), 1 ) ); + const targetViewRange = editor.editing.mapper.toViewRange( targetRange ); + + // Stub `fetch` to return custom blob without type. + testUtils.sinon.stub( window, 'fetch' ).callsFake( () => { + return new Promise( res => res( { + blob() { + return new Promise( res => res( new Blob( [ 'foo', 'bar' ] ) ) ); + } + } ) ); + } ); + + viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } ); + } ); } ); + +// Asserts actual and expected model data. +// Note: Since this function is run inside a promise, all errors needs to be caught +// and rethrow to be correctly processed by a testing framework. +// +// @param {function} done Callback function to be called when assertion is done. +// @param {String} actual Actual model data. +// @param {String} expected Expected model data. +function expectModel( done, actual, expected ) { + try { + expect( actual ).to.equal( expected ); + done(); + } catch ( err ) { + done( err ); + } +} + +// Creates data transfer object with predefined data. +// +// @param {String} content The content returned as `text/html` when queried. +// @returns {module:clipboard/datatransfer~DataTransfer} DataTransfer object. +function mockDataTransfer( content ) { + return new DataTransfer( { + types: [ 'text/html' ], + getData: type => type === 'text/html' ? content : '' + } ); +} + +// Creates blob url from the given base64 data. +// +// @param {String} base64 The base64 string from which blob url will be generated. +// @returns {String} Blob url. +function base64ToBlobUrl( base64 ) { + return URL.createObjectURL( base64ToBlob( base64.trim() ) ); +} + +// Transforms base64 data into a blob object. +// +// @param {String} The base64 data to be transformed. +// @returns {Blob} Blob object representing given base64 data. +function base64ToBlob( base64Data ) { + const [ type, data ] = base64Data.split( ',' ); + const byteCharacters = atob( data ); + const byteArrays = []; + + for ( let offset = 0; offset < byteCharacters.length; offset += 512 ) { + const slice = byteCharacters.slice( offset, offset + 512 ); + const byteNumbers = new Array( slice.length ); + + for ( let i = 0; i < slice.length; i++ ) { + byteNumbers[ i ] = slice.charCodeAt( i ); + } + + byteArrays.push( new Uint8Array( byteNumbers ) ); + } + + return new Blob( byteArrays, { type } ); +} diff --git a/tests/imageupload/imageuploadprogress.js b/tests/imageupload/imageuploadprogress.js index ffef57b9..4eb71454 100644 --- a/tests/imageupload/imageuploadprogress.js +++ b/tests/imageupload/imageuploadprogress.js @@ -13,6 +13,7 @@ import ImageUploadEditing from '../../src/imageupload/imageuploadediting'; import ImageUploadProgress from '../../src/imageupload/imageuploadprogress'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import FileRepository from '@ckeditor/ckeditor5-upload/src/filerepository'; +import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard'; import { UploadAdapterMock, createNativeFileMock, NativeFileReaderMock } from '@ckeditor/ckeditor5-upload/tests/_utils/mocks'; import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; @@ -54,7 +55,7 @@ describe( 'ImageUploadProgress', () => { return VirtualTestEditor .create( { - plugins: [ ImageEditing, Paragraph, ImageUploadEditing, ImageUploadProgress, UploadAdapterPluginMock ] + plugins: [ ImageEditing, Paragraph, ImageUploadEditing, ImageUploadProgress, UploadAdapterPluginMock, Clipboard ] } ) .then( newEditor => { editor = newEditor; @@ -74,7 +75,7 @@ describe( 'ImageUploadProgress', () => { it( 'should convert image\'s "reading" uploadStatus attribute', () => { setModelData( model, '[]foo' ); - editor.execute( 'imageUpload', { files: createNativeFileMock() } ); + editor.execute( 'imageUpload', { file: createNativeFileMock() } ); expect( getViewData( view ) ).to.equal( '[
' + @@ -86,7 +87,7 @@ describe( 'ImageUploadProgress', () => { it( 'should convert image\'s "uploading" uploadStatus attribute', done => { setModelData( model, '[]foo' ); - editor.execute( 'imageUpload', { files: createNativeFileMock() } ); + editor.execute( 'imageUpload', { file: createNativeFileMock() } ); model.document.once( 'change', () => { expect( getViewData( view ) ).to.equal( @@ -167,7 +168,7 @@ describe( 'ImageUploadProgress', () => { it( 'should update progressbar width on progress', done => { setModelData( model, '[]foo' ); - editor.execute( 'imageUpload', { files: createNativeFileMock() } ); + editor.execute( 'imageUpload', { file: createNativeFileMock() } ); model.document.once( 'change', () => { adapterMock.mockProgress( 40, 100 ); @@ -189,7 +190,7 @@ describe( 'ImageUploadProgress', () => { const clock = testUtils.sinon.useFakeTimers(); setModelData( model, '[]foo' ); - editor.execute( 'imageUpload', { files: createNativeFileMock() } ); + editor.execute( 'imageUpload', { file: createNativeFileMock() } ); model.document.once( 'change', () => { model.document.once( 'change', () => { @@ -222,7 +223,7 @@ describe( 'ImageUploadProgress', () => { uploadProgress.placeholder = base64Sample; setModelData( model, '[]foo' ); - editor.execute( 'imageUpload', { files: createNativeFileMock() } ); + editor.execute( 'imageUpload', { file: createNativeFileMock() } ); expect( getViewData( view ) ).to.equal( '[
' + @@ -238,7 +239,7 @@ describe( 'ImageUploadProgress', () => { }, { priority: 'highest' } ); setModelData( model, '[]foo' ); - editor.execute( 'imageUpload', { files: createNativeFileMock() } ); + editor.execute( 'imageUpload', { file: createNativeFileMock() } ); expect( getViewData( view ) ).to.equal( '[
]

foo

' @@ -276,7 +277,7 @@ describe( 'ImageUploadProgress', () => { testUtils.sinon.stub( env, 'isEdge' ).get( () => true ); setModelData( model, '[]foo' ); - editor.execute( 'imageUpload', { files: createNativeFileMock() } ); + editor.execute( 'imageUpload', { file: createNativeFileMock() } ); model.document.once( 'change', () => { model.document.once( 'change', () => { diff --git a/tests/imageupload/imageuploadui.js b/tests/imageupload/imageuploadui.js index 68326d4a..9b3cabc9 100644 --- a/tests/imageupload/imageuploadui.js +++ b/tests/imageupload/imageuploadui.js @@ -15,6 +15,7 @@ import ImageUploadUI from '../../src/imageupload/imageuploadui'; import ImageUploadEditing from '../../src/imageupload/imageuploadediting'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import Notification from '@ckeditor/ckeditor5-ui/src/notification/notification'; +import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard'; import { createNativeFileMock, UploadAdapterMock } from '@ckeditor/ckeditor5-upload/tests/_utils/mocks'; import { setData as setModelData, getData as getModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; @@ -37,7 +38,7 @@ describe( 'ImageUploadUI', () => { return ClassicEditor .create( editorElement, { - plugins: [ Paragraph, Image, ImageUploadEditing, ImageUploadUI, FileRepository, UploadAdapterPluginMock ] + plugins: [ Paragraph, Image, ImageUploadEditing, ImageUploadUI, FileRepository, UploadAdapterPluginMock, Clipboard ] } ) .then( newEditor => { editor = newEditor; @@ -99,7 +100,7 @@ describe( 'ImageUploadUI', () => { button.fire( 'done', files ); sinon.assert.calledOnce( executeStub ); expect( executeStub.firstCall.args[ 0 ] ).to.equal( 'imageUpload' ); - expect( executeStub.firstCall.args[ 1 ].files ).to.deep.equal( files ); + expect( executeStub.firstCall.args[ 1 ].file ).to.deep.equal( files ); } ); it( 'should execute imageUpload command with multiple files', () => { @@ -110,7 +111,7 @@ describe( 'ImageUploadUI', () => { button.fire( 'done', files ); sinon.assert.calledOnce( executeStub ); expect( executeStub.firstCall.args[ 0 ] ).to.equal( 'imageUpload' ); - expect( executeStub.firstCall.args[ 1 ].files ).to.deep.equal( files ); + expect( executeStub.firstCall.args[ 1 ].file ).to.deep.equal( files ); } ); it( 'should optimize the insertion position', () => { @@ -171,6 +172,6 @@ describe( 'ImageUploadUI', () => { button.fire( 'done', files ); sinon.assert.calledOnce( executeStub ); expect( executeStub.firstCall.args[ 0 ] ).to.equal( 'imageUpload' ); - expect( executeStub.firstCall.args[ 1 ].files ).to.deep.equal( [ files[ 0 ] ] ); + expect( executeStub.firstCall.args[ 1 ].file ).to.deep.equal( [ files[ 0 ] ] ); } ); } ); diff --git a/tests/manual/imagestyle.js b/tests/manual/imagestyle.js index 084ba475..124a6dd9 100644 --- a/tests/manual/imagestyle.js +++ b/tests/manual/imagestyle.js @@ -56,7 +56,7 @@ ClassicEditor ], toolbar: [ 'heading', '|', 'undo', 'redo' ], image: { - styles: [ 'imageStyle:alignLeft', 'imageStyle:alignCenter', 'imageStyle:alignRight' ], + styles: [ 'alignLeft', 'alignCenter', 'alignRight' ], toolbar: [ 'imageStyle:alignLeft', 'imageStyle:alignCenter', 'imageStyle:alignRight' ] } } ) diff --git a/tests/manual/imageupload.js b/tests/manual/imageupload.js index 04d365f2..2ccfd07d 100644 --- a/tests/manual/imageupload.js +++ b/tests/manual/imageupload.js @@ -3,7 +3,7 @@ * For licensing, see LICENSE.md. */ -/* globals document, console */ +/* globals window, document, console */ import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor'; import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold'; @@ -36,6 +36,8 @@ ClassicEditor } } ) .then( editor => { + window.editor = editor; + // Register fake adapter. editor.plugins.get( 'FileRepository' ).createUploadAdapter = loader => { const adapterMock = new UploadAdapterMock( loader ); diff --git a/theme/icons/image_placeholder.svg b/theme/icons/image_placeholder.svg index 421ba1fc..dff56844 100644 --- a/theme/icons/image_placeholder.svg +++ b/theme/icons/image_placeholder.svg @@ -1 +1 @@ - + \ No newline at end of file diff --git a/theme/image.css b/theme/image.css index 89bfe02c..8ff0229a 100644 --- a/theme/image.css +++ b/theme/image.css @@ -7,6 +7,9 @@ clear: both; text-align: center; + /* Make sure there is some space between the content and the image. */ + margin: 1em 0; + & > img { /* Prevent unnecessary margins caused by line-height (see #44). */ display: block;