8
0
Просмотр исходного кода

Merge branch 'master' into i/7822

# Conflicts:
#	packages/ckeditor5-ui/src/componentfactory.js
Maciej Gołaszewski 5 лет назад
Родитель
Сommit
b8db656423
64 измененных файлов с 5374 добавлено и 157 удалено
  1. 7 0
      packages/ckeditor5-image/docs/_snippets/features/image-insert-via-url.html
  2. 31 0
      packages/ckeditor5-image/docs/_snippets/features/image-insert-via-url.js
  3. 25 0
      packages/ckeditor5-image/docs/features/image.md
  4. 6 1
      packages/ckeditor5-image/lang/contexts.json
  5. 1 0
      packages/ckeditor5-image/package.json
  6. 54 0
      packages/ckeditor5-image/src/imageupload.js
  7. 4 1
      packages/ckeditor5-image/src/imageupload/imageuploadcommand.js
  8. 144 24
      packages/ckeditor5-image/src/imageupload/imageuploadui.js
  9. 103 0
      packages/ckeditor5-image/src/imageupload/ui/imageuploadformrowview.js
  10. 303 0
      packages/ckeditor5-image/src/imageupload/ui/imageuploadpanelview.js
  11. 69 0
      packages/ckeditor5-image/src/imageupload/utils.js
  12. 3 2
      packages/ckeditor5-image/tests/imageupload/imageuploadcommand.js
  13. 1 1
      packages/ckeditor5-image/tests/imageupload/imageuploadediting.js
  14. 478 109
      packages/ckeditor5-image/tests/imageupload/imageuploadui.js
  15. 100 0
      packages/ckeditor5-image/tests/imageupload/ui/imageuploadformrowview.js
  16. 298 0
      packages/ckeditor5-image/tests/imageupload/ui/imageuploadpanelview.js
  17. 147 2
      packages/ckeditor5-image/tests/imageupload/utils.js
  18. 28 0
      packages/ckeditor5-image/tests/manual/imageuploadviaurl.html
  19. 61 0
      packages/ckeditor5-image/tests/manual/imageuploadviaurl.js
  20. 18 0
      packages/ckeditor5-image/tests/manual/imageuploadviaurl.md
  21. 16 0
      packages/ckeditor5-image/theme/imageupload.css
  22. 29 0
      packages/ckeditor5-image/theme/imageuploadformrowview.css
  23. 20 0
      packages/ckeditor5-list/docs/_snippets/features/lists-basic.html
  24. 57 0
      packages/ckeditor5-list/docs/_snippets/features/lists-basic.js
  25. 0 0
      packages/ckeditor5-list/docs/_snippets/features/lists-source.html
  26. 12 0
      packages/ckeditor5-list/docs/_snippets/features/lists-source.js
  27. 17 0
      packages/ckeditor5-list/docs/_snippets/features/lists-style.html
  28. 58 0
      packages/ckeditor5-list/docs/_snippets/features/lists-style.js
  29. 93 0
      packages/ckeditor5-list/docs/features/lists.md
  30. 4 0
      packages/ckeditor5-list/docs/features/todo-lists.md
  31. 21 1
      packages/ckeditor5-list/lang/contexts.json
  32. 2 0
      packages/ckeditor5-list/package.json
  33. 6 0
      packages/ckeditor5-list/src/converters.js
  34. 12 0
      packages/ckeditor5-list/src/indentcommand.js
  35. 11 0
      packages/ckeditor5-list/src/listcommand.js
  36. 36 0
      packages/ckeditor5-list/src/liststyle.js
  37. 197 0
      packages/ckeditor5-list/src/liststylecommand.js
  38. 471 0
      packages/ckeditor5-list/src/liststyleediting.js
  39. 230 0
      packages/ckeditor5-list/src/liststyleui.js
  40. 21 1
      packages/ckeditor5-list/tests/indentcommand.js
  41. 29 0
      packages/ckeditor5-list/tests/listcommand.js
  42. 18 0
      packages/ckeditor5-list/tests/liststyle.js
  43. 391 0
      packages/ckeditor5-list/tests/liststylecommand.js
  44. 896 0
      packages/ckeditor5-list/tests/liststyleediting.js
  45. 480 0
      packages/ckeditor5-list/tests/liststyleui.js
  46. 182 0
      packages/ckeditor5-list/tests/manual/list-style.html
  47. 65 0
      packages/ckeditor5-list/tests/manual/list-style.js
  48. 17 0
      packages/ckeditor5-list/tests/manual/list-style.md
  49. 1 0
      packages/ckeditor5-list/theme/icons/liststylecircle.svg
  50. 1 0
      packages/ckeditor5-list/theme/icons/liststyledecimal.svg
  51. 0 0
      packages/ckeditor5-list/theme/icons/liststyledecimalleadingzero.svg
  52. 1 0
      packages/ckeditor5-list/theme/icons/liststyledisc.svg
  53. 0 0
      packages/ckeditor5-list/theme/icons/liststylelowerlatin.svg
  54. 1 0
      packages/ckeditor5-list/theme/icons/liststylelowerroman.svg
  55. 1 0
      packages/ckeditor5-list/theme/icons/liststylesquare.svg
  56. 1 0
      packages/ckeditor5-list/theme/icons/liststyleupperlatin.svg
  57. 1 0
      packages/ckeditor5-list/theme/icons/liststyleupperroman.svg
  58. 12 0
      packages/ckeditor5-list/theme/liststyles.css
  59. 6 0
      packages/ckeditor5-markdown-gfm/LICENSE.md
  60. 4 0
      packages/ckeditor5-theme-lark/theme/ckeditor5-image/imageupload.css
  61. 45 0
      packages/ckeditor5-theme-lark/theme/ckeditor5-list/liststyles.css
  62. 11 13
      packages/ckeditor5-ui/tests/componentfactory.js
  63. 9 1
      scripts/continuous-integration-script.js
  64. 8 1
      tests/manual/all-features.js

+ 7 - 0
packages/ckeditor5-image/docs/_snippets/features/image-insert-via-url.html

@@ -0,0 +1,7 @@
+<div id="snippet-image-insert-via-url">
+	<p>Insert a new image or edit the source URL of the image below:</p>
+
+	<figure class="image">
+		<img src="%BASE_PATH%/assets/img/fields.jpg" alt="Autumn fields">
+	</figure>
+</div>

+ 31 - 0
packages/ckeditor5-image/docs/_snippets/features/image-insert-via-url.js

@@ -0,0 +1,31 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals ClassicEditor, console, window, document */
+
+import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config.js';
+
+ClassicEditor
+	.create( document.querySelector( '#snippet-image-insert-via-url' ), {
+		removePlugins: [ 'ImageToolbar', 'ImageCaption', 'ImageStyle', 'ImageResize', 'LinkImage' ],
+		toolbar: {
+			viewportTopOffset: window.getViewportTopOffsetConfig()
+		},
+		image: {
+			toolbar: [ 'imageStyle:full', 'imageStyle:side', '|', 'imageTextAlternative' ],
+			upload: {
+				panel: {
+					items: [ 'insertImageViaUrl' ]
+				}
+			}
+		},
+		cloudServices: CS_CONFIG
+	} )
+	.then( editor => {
+		window.imageViaUrl = editor;
+	} )
+	.catch( err => {
+		console.error( err );
+	} );

+ 25 - 0
packages/ckeditor5-image/docs/features/image.md

@@ -82,6 +82,31 @@ By default, if the image caption is empty, the `<figcaption>` element is not vis
 
 See the {@link features/image-upload Image upload} guide.
 
+## Inserting images via source URL
+
+Besides the ability to insert images by uploading them directly from your disk or via CKFinder, you can also configure the editor to allow inserting images via source URL.
+
+In order to enable this option configure `image.upload.panel.items` like below:
+
+```js
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		// ...
+		image: {
+			// ...
+			upload: {
+				panel: {
+					items: [ 'insertImageViaUrl' ]
+				}
+			}
+		}
+	} )
+```
+
+This will extend the standalone **Insert image** button in the toolbar by adding the dropdown panel with the new feature (click the arrow next to the button). To see how it works, please take a look at the demo below:
+
+{@snippet features/image-insert-via-url}
+
 ## Responsive images
 
 Support for responsive images in CKEditor 5 is brought by the {@link features/easy-image Easy Image} feature without any additional configuration. Refer to the {@link features/easy-image#responsive-images Easy Image integration} guide to learn how to use the feature in your project.

+ 6 - 1
packages/ckeditor5-image/lang/contexts.json

@@ -15,5 +15,10 @@
 	"Resize image to %0": "The label used for the standalone resize options buttons in the image toolbar",
 	"Resize image to the original size": "The accessibility label of the standalone image resize reset option button in the image toolbar for the screen readers",
 	"Original": "Default label for the resize option that resets the size of the image.",
-	"Image resize list": "The accessibility label of the image resize dropdown list for the screen readers."
+	"Image resize list": "The accessibility label of the image resize dropdown list for the screen readers.",
+	"Insert": "The label of submit form button if image src URL input has no value",
+	"Update": "The label of submit form button if image src URL input has value",
+	"Cancel": "The label of cancel form button",
+	"Insert image via URL": "The input label for the Insert image via URL form",
+	"Paste the image source URL.": "The tip label below the Insert image via URL form"
 }

+ 1 - 0
packages/ckeditor5-image/package.json

@@ -22,6 +22,7 @@
     "@ckeditor/ckeditor5-basic-styles": "^21.0.0",
     "@ckeditor/ckeditor5-block-quote": "^21.0.0",
     "@ckeditor/ckeditor5-cloud-services": "^21.0.0",
+    "@ckeditor/ckeditor5-ckfinder": "^21.0.0",
     "@ckeditor/ckeditor5-editor-classic": "^21.0.0",
     "@ckeditor/ckeditor5-enter": "^21.0.0",
     "@ckeditor/ckeditor5-easy-image": "^21.0.0",

+ 54 - 0
packages/ckeditor5-image/src/imageupload.js

@@ -83,3 +83,57 @@ export default class ImageUpload extends Plugin {
  * @member {Array.<String>} module:image/imageupload~ImageUploadConfig#types
  * @default [ 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff' ]
  */
+
+/**
+ * Image upload panel view configuration.
+ *
+ * @protected
+ * @member {module:image/imageupload~ImageUploadPanelConfig} module:image/imageupload~ImageUploadConfig#panel
+ */
+
+/**
+ * The configuration of the image upload dropdown panel view. Used by the image upload feature in the `@ckeditor/ckeditor5-image` package.
+ *
+ *		ClassicEditor
+ *			.create( editorElement, {
+ * 				image: {
+ * 					upload: {
+ * 						panel: ... // panel settings goes here
+ * 					}
+ * 				}
+ *			} )
+ *			.then( ... )
+ *			.catch( ... );
+ *
+ * See {@link module:core/editor/editorconfig~EditorConfig all editor options}.
+ *
+ * @protected
+ * @interface module:image/imageupload~ImageUploadPanelConfig
+ */
+
+/**
+ * The list of {@link module:image/imageupload~ImageUpload} integrations.
+ *
+ * The option accepts string tokens.
+ * * for predefined integrations, we have two special strings: `insertImageViaUrl` and `openCKFinder`.
+ * The former adds **Insert image via URL** feature, while the latter adds built-in **CKFinder** integration.
+ * * for custom integrations, each string should be a name of the already registered component.
+ * If you have a plugin `PluginX` that registers `pluginXButton` component, then the integration token
+ * in that case should be `pluginXButton`.
+ *
+ *		// Add `insertImageViaUrl`, `openCKFinder` and custom `pluginXButton` integrations.
+ *		const imageUploadConfig = {
+ *			upload: {
+ *				panel: {
+ *					items: [
+ *						'insertImageViaUrl',
+ *						'openCKFinder',
+ *						'pluginXButton'
+ *					]
+ *				}
+ *			}
+ *		};
+ *
+ * @member {Array.<String>} module:image/imageupload~ImageUploadPanelConfig#items
+ * @default [ 'insertImageViaUrl' ]
+ */

+ 4 - 1
packages/ckeditor5-image/src/imageupload/imageuploadcommand.js

@@ -44,7 +44,10 @@ export default class ImageUploadCommand extends Command {
 	 * @inheritDoc
 	 */
 	refresh() {
-		this.isEnabled = isImageAllowed( this.editor.model );
+		const imageElement = this.editor.model.document.selection.getSelectedElement();
+		const isImage = imageElement && imageElement.name === 'image' || false;
+
+		this.isEnabled = isImageAllowed( this.editor.model ) || isImage;
 	}
 
 	/**

+ 144 - 24
packages/ckeditor5-image/src/imageupload/imageuploadui.js

@@ -8,56 +8,176 @@
  */
 
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import ImageUploadPanelView from './ui/imageuploadpanelview';
+
 import FileDialogButtonView from '@ckeditor/ckeditor5-upload/src/ui/filedialogbuttonview';
+import { createImageTypeRegExp, prepareIntegrations } from './utils';
+
 import imageIcon from '@ckeditor/ckeditor5-core/theme/icons/image.svg';
-import { createImageTypeRegExp } from './utils';
+
+import { isImage } from '../image/utils';
 
 /**
  * The image upload button plugin.
  *
  * 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}.
+ * Adds the `'imageUpload'` dropdown to the {@link module:ui/componentfactory~ComponentFactory UI component factory}.
  *
  * @extends module:core/plugin~Plugin
  */
 export default class ImageUploadUI extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'ImageUploadUI';
+	}
+
 	/**
 	 * @inheritDoc
 	 */
 	init() {
 		const editor = this.editor;
-		const t = editor.t;
+		const isImageUploadPanelViewEnabled = !!editor.config.get( 'image.upload.panel.items' );
 
-		// Setup `imageUpload` button.
 		editor.ui.componentFactory.add( 'imageUpload', locale => {
-			const view = new FileDialogButtonView( locale );
-			const command = editor.commands.get( 'imageUpload' );
-			const imageTypes = editor.config.get( 'image.upload.types' );
-			const imageTypesRegExp = createImageTypeRegExp( imageTypes );
+			if ( isImageUploadPanelViewEnabled ) {
+				return this._createDropdownView( locale );
+			} else {
+				return this._createFileDialogButtonView( locale );
+			}
+		} );
+	}
 
-			view.set( {
-				acceptedType: imageTypes.map( type => `image/${ type }` ).join( ',' ),
-				allowMultipleFiles: true
-			} );
+	/**
+	 * Sets up the dropdown view.
+	 *
+	 * @param {module:ui/dropdown/dropdownview~DropdownView} dropdownView A dropdownView.
+	 * @param {module:image/imageupload/ui/imageuploadpanelview~ImageUploadPanelView} imageUploadView An imageUploadView.
+	 * @param {module:core/command~Command} command An imageUpload command
+	 *
+	 * @private
+	 * @returns {module:ui/dropdown/dropdownview~DropdownView}
+	 */
+	_setUpDropdown( dropdownView, imageUploadView, command ) {
+		const editor = this.editor;
+		const t = editor.t;
+		const insertButtonView = imageUploadView.insertButtonView;
 
-			view.buttonView.set( {
-				label: t( 'Insert image' ),
-				icon: imageIcon,
-				tooltip: true
-			} );
+		dropdownView.bind( 'isEnabled' ).to( command );
 
-			view.buttonView.bind( 'isEnabled' ).to( command );
+		dropdownView.on( 'change:isOpen', () => {
+			const selectedElement = editor.model.document.selection.getSelectedElement();
 
-			view.on( 'done', ( evt, files ) => {
-				const imagesToUpload = Array.from( files ).filter( file => imageTypesRegExp.test( file.type ) );
+			if ( dropdownView.isOpen ) {
+				imageUploadView.focus();
 
-				if ( imagesToUpload.length ) {
-					editor.execute( 'imageUpload', { file: imagesToUpload } );
+				if ( isImage( selectedElement ) ) {
+					imageUploadView.imageURLInputValue = selectedElement.getAttribute( 'src' );
+					insertButtonView.label = t( 'Update' );
+				} else {
+					imageUploadView.imageURLInputValue = '';
+					insertButtonView.label = t( 'Insert' );
 				}
-			} );
+			}
+		} );
+
+		imageUploadView.delegate( 'submit', 'cancel' ).to( dropdownView );
+		this.delegate( 'cancel' ).to( dropdownView );
+
+		dropdownView.on( 'submit', () => {
+			closePanel();
+			onSubmit();
+		} );
+
+		dropdownView.on( 'cancel', () => {
+			closePanel();
+		} );
+
+		function onSubmit() {
+			const selectedElement = editor.model.document.selection.getSelectedElement();
+
+			if ( isImage( selectedElement ) ) {
+				editor.model.change( writer => {
+					writer.setAttribute( 'src', imageUploadView.imageURLInputValue, selectedElement );
+					writer.removeAttribute( 'srcset', selectedElement );
+					writer.removeAttribute( 'sizes', selectedElement );
+				} );
+			} else {
+				editor.execute( 'imageInsert', { source: imageUploadView.imageURLInputValue } );
+			}
+		}
 
-			return view;
+		function closePanel() {
+			editor.editing.view.focus();
+			dropdownView.isOpen = false;
+		}
+
+		return dropdownView;
+	}
+
+	/**
+	 * Creates the dropdown view.
+	 *
+	 * @param {module:utils/locale~Locale} locale The localization services instance.
+	 *
+	 * @private
+	 * @returns {module:ui/dropdown/dropdownview~DropdownView}
+	 */
+	_createDropdownView( locale ) {
+		const editor = this.editor;
+		const imageUploadView = new ImageUploadPanelView( locale, prepareIntegrations( editor ) );
+		const command = editor.commands.get( 'imageUpload' );
+
+		const dropdownView = imageUploadView.dropdownView;
+		const panelView = dropdownView.panelView;
+		const splitButtonView = dropdownView.buttonView;
+
+		splitButtonView.actionView = this._createFileDialogButtonView( locale );
+
+		panelView.children.add( imageUploadView );
+
+		return this._setUpDropdown( dropdownView, imageUploadView, command );
+	}
+
+	/**
+	 * Creates and sets up file dialog button view.
+	 *
+	 * @param {module:utils/locale~Locale} locale The localization services instance.
+	 *
+	 * @private
+	 * @returns {module:upload/ui/filedialogbuttonview~FileDialogButtonView}
+	 */
+	_createFileDialogButtonView( locale ) {
+		const editor = this.editor;
+		const t = locale.t;
+		const imageTypes = editor.config.get( 'image.upload.types' );
+		const fileDialogButtonView = new FileDialogButtonView( locale );
+		const imageTypesRegExp = createImageTypeRegExp( imageTypes );
+		const command = editor.commands.get( 'imageUpload' );
+
+		fileDialogButtonView.set( {
+			acceptedType: imageTypes.map( type => `image/${ type }` ).join( ',' ),
+			allowMultipleFiles: true
+		} );
+
+		fileDialogButtonView.buttonView.set( {
+			label: t( 'Insert image' ),
+			icon: imageIcon,
+			tooltip: true
 		} );
+
+		fileDialogButtonView.buttonView.bind( 'isEnabled' ).to( command );
+
+		fileDialogButtonView.on( 'done', ( evt, files ) => {
+			const imagesToUpload = Array.from( files ).filter( file => imageTypesRegExp.test( file.type ) );
+
+			if ( imagesToUpload.length ) {
+				editor.execute( 'imageUpload', { file: imagesToUpload } );
+			}
+		} );
+
+		return fileDialogButtonView;
 	}
 }

+ 103 - 0
packages/ckeditor5-image/src/imageupload/ui/imageuploadformrowview.js

@@ -0,0 +1,103 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module image/imageupload/ui/imageuploadformrowview
+ */
+
+import View from '@ckeditor/ckeditor5-ui/src/view';
+
+import '../../../theme/imageuploadformrowview.css';
+
+/**
+ * The class representing a single row in a complex form,
+ * used by {@link module:image/imageupload/ui/imageuploadpanelview~ImageUploadPanelView}.
+ *
+ * **Note**: For now this class is private. When more use cases arrive (beyond ckeditor5-table and ckeditor5-image),
+ * it will become a component in ckeditor5-ui.
+ *
+ * @private
+ * @extends module:ui/view~View
+ */
+export default class ImageUploadFormRowView extends View {
+	/**
+	 * Creates an instance of the form row class.
+	 *
+	 * @param {module:utils/locale~Locale} locale The locale instance.
+	 * @param {Object} options
+	 * @param {Array.<module:ui/view~View>} [options.children]
+	 * @param {String} [options.class]
+	 * @param {module:ui/view~View} [options.labelView] When passed, the row gets the `group` and `aria-labelledby`
+	 * DOM attributes and gets described by the label.
+	 */
+	constructor( locale, options = {} ) {
+		super( locale );
+
+		const bind = this.bindTemplate;
+
+		/**
+		 * An additional CSS class added to the {@link #element}.
+		 *
+		 * @observable
+		 * @member {String} #class
+		 */
+		this.set( 'class', options.class || null );
+
+		/**
+		 * A collection of row items (buttons, dropdowns, etc.).
+		 *
+		 * @readonly
+		 * @member {module:ui/viewcollection~ViewCollection}
+		 */
+		this.children = this.createCollection();
+
+		if ( options.children ) {
+			options.children.forEach( child => this.children.add( child ) );
+		}
+
+		/**
+		 * The role property reflected by the `role` DOM attribute of the {@link #element}.
+		 *
+		 * **Note**: Used only when a `labelView` is passed to constructor `options`.
+		 *
+		 * @private
+		 * @observable
+		 * @member {String} #role
+		 */
+		this.set( '_role', null );
+
+		/**
+		 * The ARIA property reflected by the `aria-labelledby` DOM attribute of the {@link #element}.
+		 *
+		 * **Note**: Used only when a `labelView` is passed to constructor `options`.
+		 *
+		 * @private
+		 * @observable
+		 * @member {String} #ariaLabelledBy
+		 */
+		this.set( '_ariaLabelledBy', null );
+
+		if ( options.labelView ) {
+			this.set( {
+				_role: 'group',
+				_ariaLabelledBy: options.labelView.id
+			} );
+		}
+
+		this.setTemplate( {
+			tag: 'div',
+			attributes: {
+				class: [
+					'ck',
+					'ck-form__row',
+					bind.to( 'class' )
+				],
+				role: bind.to( '_role' ),
+				'aria-labelledby': bind.to( '_ariaLabelledBy' )
+			},
+			children: this.children
+		} );
+	}
+}

+ 303 - 0
packages/ckeditor5-image/src/imageupload/ui/imageuploadpanelview.js

@@ -0,0 +1,303 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module image/imageupload/ui/imageuploadpanelview
+ */
+
+import View from '@ckeditor/ckeditor5-ui/src/view';
+
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+import SplitButtonView from '@ckeditor/ckeditor5-ui/src/dropdown/button/splitbuttonview';
+import ImageUploadFormRowView from './imageuploadformrowview';
+import { createDropdown } from '@ckeditor/ckeditor5-ui/src/dropdown/utils';
+
+import Collection from '@ckeditor/ckeditor5-utils/src/collection';
+import ViewCollection from '@ckeditor/ckeditor5-ui/src/viewcollection';
+import submitHandler from '@ckeditor/ckeditor5-ui/src/bindings/submithandler';
+import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker';
+import FocusCycler from '@ckeditor/ckeditor5-ui/src/focuscycler';
+import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler';
+
+import imageIcon from '@ckeditor/ckeditor5-core/theme/icons/image.svg';
+import checkIcon from '@ckeditor/ckeditor5-core/theme/icons/check.svg';
+import cancelIcon from '@ckeditor/ckeditor5-core/theme/icons/cancel.svg';
+
+import '../../../theme/imageupload.css';
+
+/**
+ * The insert an image via URL view controller class.
+ *
+ * See {@link module:image/imageupload/ui/imageuploadpanelview~ImageUploadPanelView}.
+ *
+ * @extends module:ui/view~View
+ */
+export default class ImageUploadPanelView extends View {
+	/**
+	 * Creates a view for the dropdown panel of {@link module:image/imageupload/imageuploadui~ImageUploadUI}.
+	 *
+	 * @param {module:utils/locale~Locale} [locale] The localization services instance..
+	 * @param {Object} [integrations] Integrations object that contain
+	 * components (or tokens for components) to be shown in the panel view.
+	 */
+	constructor( locale, integrations ) {
+		super( locale );
+
+		const { insertButtonView, cancelButtonView } = this._createActionButtons( locale );
+
+		/**
+		 * The "insert/update" button view.
+		 *
+		 * @member {module:ui/button/buttonview~ButtonView}
+		 */
+		this.insertButtonView = insertButtonView;
+
+		/**
+		 * The "cancel" button view.
+		 *
+		 * @member {module:ui/button/buttonview~ButtonView}
+		 */
+		this.cancelButtonView = cancelButtonView;
+
+		/**
+		 * The dropdown view.
+		 *
+		 * @member {module:ui/dropdown/dropdownview~DropdownView}
+		 */
+		this.dropdownView = this._createDropdownView( locale );
+
+		/**
+		 * Value of the URL input.
+		 *
+		 * @member {String} #imageURLInputValue
+		 * @observable
+		 */
+		this.set( 'imageURLInputValue', '' );
+
+		/**
+		 * Tracks information about DOM focus in the form.
+		 *
+		 * @readonly
+		 * @member {module:utils/focustracker~FocusTracker}
+		 */
+		this.focusTracker = new FocusTracker();
+
+		/**
+		 * An instance of the {@link module:utils/keystrokehandler~KeystrokeHandler}.
+		 *
+		 * @readonly
+		 * @member {module:utils/keystrokehandler~KeystrokeHandler}
+		 */
+		this.keystrokes = new KeystrokeHandler();
+
+		/**
+		 * A collection of views that can be focused in the form.
+		 *
+		 * @readonly
+		 * @protected
+		 * @member {module:ui/viewcollection~ViewCollection}
+		 */
+		this._focusables = new ViewCollection();
+
+		/**
+		 * Helps cycling over {@link #_focusables} in the form.
+		 *
+		 * @readonly
+		 * @protected
+		 * @member {module:ui/focuscycler~FocusCycler}
+		 */
+		this._focusCycler = new FocusCycler( {
+			focusables: this._focusables,
+			focusTracker: this.focusTracker,
+			keystrokeHandler: this.keystrokes,
+			actions: {
+				// Navigate form fields backwards using the Shift + Tab keystroke.
+				focusPrevious: 'shift + tab',
+
+				// Navigate form fields forwards using the Tab key.
+				focusNext: 'tab'
+			}
+		} );
+
+		/**
+		 * Collection of the defined integrations for inserting the images.
+		 *
+		 * @private
+		 * @member {module:utils/collection~Collection}
+		 */
+		this.set( '_integrations', new Collection() );
+
+		if ( integrations ) {
+			for ( const [ integration, integrationView ] of Object.entries( integrations ) ) {
+				if ( integration === 'insertImageViaUrl' ) {
+					integrationView.fieldView.bind( 'value' ).to( this, 'imageURLInputValue', value => value || '' );
+
+					integrationView.fieldView.on( 'input', () => {
+						this.imageURLInputValue = integrationView.fieldView.element.value;
+					} );
+				}
+
+				this._integrations.add( integrationView );
+			}
+		}
+
+		this.setTemplate( {
+			tag: 'form',
+
+			attributes: {
+				class: [
+					'ck',
+					'ck-image-upload-form'
+				],
+
+				tabindex: '-1'
+			},
+
+			children: [
+				...this._integrations,
+				new ImageUploadFormRowView( locale, {
+					children: [
+						this.insertButtonView,
+						this.cancelButtonView
+					],
+					class: 'ck-image-upload-form__action-row'
+				} )
+			]
+		} );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	render() {
+		super.render();
+
+		submitHandler( {
+			view: this
+		} );
+
+		const childViews = [
+			...this._integrations,
+			this.insertButtonView,
+			this.cancelButtonView
+		];
+
+		childViews.forEach( v => {
+			// Register the view as focusable.
+			this._focusables.add( v );
+
+			// Register the view in the focus tracker.
+			this.focusTracker.add( v.element );
+		} );
+
+		// Start listening for the keystrokes coming from #element.
+		this.keystrokes.listenTo( this.element );
+
+		const stopPropagation = data => data.stopPropagation();
+
+		// Since the form is in the dropdown panel which is a child of the toolbar, the toolbar's
+		// keystroke handler would take over the key management in the URL input. We need to prevent
+		// this ASAP. Otherwise, the basic caret movement using the arrow keys will be impossible.
+		this.keystrokes.set( 'arrowright', stopPropagation );
+		this.keystrokes.set( 'arrowleft', stopPropagation );
+		this.keystrokes.set( 'arrowup', stopPropagation );
+		this.keystrokes.set( 'arrowdown', stopPropagation );
+
+		// Intercept the "selectstart" event, which is blocked by default because of the default behavior
+		// of the DropdownView#panelView.
+		// TODO: blocking "selectstart" in the #panelView should be configurable per–drop–down instance.
+		this.listenTo( childViews[ 0 ].element, 'selectstart', ( evt, domEvt ) => {
+			domEvt.stopPropagation();
+		}, { priority: 'high' } );
+	}
+
+	/**
+	 * Creates dropdown view.
+	 *
+	 * @param {module:utils/locale~Locale} locale The localization services instance.
+	 *
+	 * @private
+	 * @returns {module:ui/dropdown/dropdownview~DropdownView}
+	 */
+	_createDropdownView( locale ) {
+		const t = locale.t;
+		const dropdownView = createDropdown( locale, SplitButtonView );
+		const splitButtonView = dropdownView.buttonView;
+		const panelView = dropdownView.panelView;
+
+		splitButtonView.set( {
+			label: t( 'Insert image' ),
+			icon: imageIcon,
+			tooltip: true
+		} );
+
+		panelView.extendTemplate( {
+			attributes: {
+				class: 'ck-image-upload__panel'
+			}
+		} );
+
+		return dropdownView;
+	}
+
+	/**
+	 * Creates the following form controls:
+	 *
+	 * * {@link #insertButtonView},
+	 * * {@link #cancelButtonView}.
+	 *
+	 * @param {module:utils/locale~Locale} locale The localization services instance.
+	 *
+	 * @private
+	 * @returns {Object.<String,module:ui/view~View>}
+	 */
+	_createActionButtons( locale ) {
+		const t = locale.t;
+		const insertButtonView = new ButtonView( locale );
+		const cancelButtonView = new ButtonView( locale );
+
+		insertButtonView.set( {
+			label: t( 'Insert' ),
+			icon: checkIcon,
+			class: 'ck-button-save',
+			type: 'submit',
+			withText: true,
+			isEnabled: this.imageURLInputValue
+		} );
+
+		cancelButtonView.set( {
+			label: t( 'Cancel' ),
+			icon: cancelIcon,
+			class: 'ck-button-cancel',
+			withText: true
+		} );
+
+		insertButtonView.bind( 'isEnabled' ).to( this, 'imageURLInputValue' );
+		insertButtonView.delegate( 'execute' ).to( this, 'submit' );
+		cancelButtonView.delegate( 'execute' ).to( this, 'cancel' );
+
+		return { insertButtonView, cancelButtonView };
+	}
+
+	/**
+	 * Focuses the fist {@link #_focusables} in the form.
+	 */
+	focus() {
+		this._focusCycler.focusFirst();
+	}
+}
+
+/**
+ * Fired when the form view is submitted (when one of the children triggered the submit event),
+ * e.g. click on {@link #insertButtonView}.
+ *
+ * @event submit
+ */
+
+/**
+ * Fired when the form view is canceled, e.g. click on {@link #cancelButtonView}.
+ *
+ * @event cancel
+ */

+ 69 - 0
packages/ckeditor5-image/src/imageupload/utils.js

@@ -9,6 +9,9 @@
 
 /* global fetch, File */
 
+import LabeledFieldView from '@ckeditor/ckeditor5-ui/src/labeledfield/labeledfieldview';
+import { createLabeledInputText } from '@ckeditor/ckeditor5-ui/src/labeledfield/utils';
+
 /**
  * Creates a regular expression used to test for image files.
  *
@@ -82,3 +85,69 @@ function getImageMimeType( blob, src ) {
 		return 'image/jpeg';
 	}
 }
+
+/**
+ * Creates integrations object that will be passed to the
+ * {@link module:image/imageupload/ui/imageuploadpanelview~ImageUploadPanelView}.
+ *
+ * @param {module:core/editor/editor~Editor} editor Editor instance.
+ *
+ * @returns {Object.<String, module:ui/view~View>} Integrations object.
+ */
+export function prepareIntegrations( editor ) {
+	const panelItems = editor.config.get( 'image.upload.panel.items' );
+	const imageUploadUIPlugin = editor.plugins.get( 'ImageUploadUI' );
+
+	const PREDEFINED_INTEGRATIONS = {
+		'insertImageViaUrl': createLabeledInputView( editor.locale )
+	};
+
+	if ( !panelItems ) {
+		return PREDEFINED_INTEGRATIONS;
+	}
+
+	// Prepares ckfinder component for the `openCKFinder` integration token.
+	if ( panelItems.find( item => item === 'openCKFinder' ) && editor.ui.componentFactory.has( 'ckfinder' ) ) {
+		const ckFinderButton = editor.ui.componentFactory.create( 'ckfinder' );
+		ckFinderButton.set( {
+			withText: true,
+			class: 'ck-image-upload__ck-finder-button'
+		} );
+
+		// We want to close the dropdown panel view when user clicks the ckFinderButton.
+		ckFinderButton.delegate( 'execute' ).to( imageUploadUIPlugin, 'cancel' );
+
+		PREDEFINED_INTEGRATIONS.openCKFinder = ckFinderButton;
+	}
+
+	// Creates integrations object of valid views to pass it to the ImageUploadPanelView.
+	return panelItems.reduce( ( object, key ) => {
+		if ( PREDEFINED_INTEGRATIONS[ key ] ) {
+			object[ key ] = PREDEFINED_INTEGRATIONS[ key ];
+		} else if ( editor.ui.componentFactory.has( key ) ) {
+			object[ key ] = editor.ui.componentFactory.create( key );
+		}
+
+		return object;
+	}, {} );
+}
+
+/**
+ * Creates labeled field view.
+ *
+ * @param {module:utils/locale~Locale} locale The localization services instance.
+ *
+ * @returns {module:ui/labeledfield/labeledfieldview~LabeledFieldView}
+ */
+export function createLabeledInputView( locale ) {
+	const t = locale.t;
+	const labeledInputView = new LabeledFieldView( locale, createLabeledInputText );
+
+	labeledInputView.set( {
+		label: t( 'Insert image via URL' )
+	} );
+	labeledInputView.fieldView.placeholder = 'https://example.com/src/image.png';
+	labeledInputView.infoText = t( 'Paste the image source URL.' );
+
+	return labeledInputView;
+}

+ 3 - 2
packages/ckeditor5-image/tests/imageupload/imageuploadcommand.js

@@ -81,9 +81,9 @@ describe( 'ImageUploadCommand', () => {
 			expect( command.isEnabled ).to.be.true;
 		} );
 
-		it( 'should be false when the selection is on other image', () => {
+		it( 'should be true when the selection is on other image', () => {
 			setModelData( model, '[<image></image>]' );
-			expect( command.isEnabled ).to.be.false;
+			expect( command.isEnabled ).to.be.true;
 		} );
 
 		it( 'should be false when the selection is inside other image', () => {
@@ -94,6 +94,7 @@ describe( 'ImageUploadCommand', () => {
 			} );
 			editor.conversion.for( 'downcast' ).elementToElement( { model: 'caption', view: 'figcaption' } );
 			setModelData( model, '<image><caption>[]</caption></image>' );
+
 			expect( command.isEnabled ).to.be.false;
 		} );
 

+ 1 - 1
packages/ckeditor5-image/tests/imageupload/imageuploadediting.js

@@ -157,7 +157,7 @@ describe( 'ImageUploadEditing', () => {
 
 		const command = editor.commands.get( 'imageUpload' );
 
-		expect( command.isEnabled ).to.be.false;
+		expect( command.isEnabled ).to.be.true;
 
 		const targetRange = model.createRange( model.createPositionAt( doc.getRoot(), 0 ), model.createPositionAt( doc.getRoot(), 0 ) );
 		const targetViewRange = editor.editing.mapper.toViewRange( targetRange );

+ 478 - 109
packages/ckeditor5-image/tests/imageupload/imageuploadui.js

@@ -9,6 +9,7 @@ import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor'
 
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import Image from '../../src/image';
+import DropdownView from '@ckeditor/ckeditor5-ui/src/dropdown/dropdownview';
 import FileDialogButtonView from '@ckeditor/ckeditor5-upload/src/ui/filedialogbuttonview';
 import FileRepository from '@ckeditor/ckeditor5-upload/src/filerepository';
 import ImageUploadUI from '../../src/imageupload/imageuploadui';
@@ -16,6 +17,11 @@ 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 EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo';
+import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata';
+import CKFinder from '@ckeditor/ckeditor5-ckfinder/src/ckfinder';
+import LabeledFieldView from '@ckeditor/ckeditor5-ui/src/labeledfield/labeledfieldview';
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
 
 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';
@@ -32,154 +38,517 @@ describe( 'ImageUploadUI', () => {
 		}
 	}
 
-	beforeEach( () => {
-		editorElement = document.createElement( 'div' );
-		document.body.appendChild( editorElement );
-
-		return ClassicEditor
-			.create( editorElement, {
-				plugins: [ Paragraph, Image, ImageUploadEditing, ImageUploadUI, FileRepository, UploadAdapterPluginMock, Clipboard ]
-			} )
-			.then( newEditor => {
-				editor = newEditor;
-				model = editor.model;
-
-				// Hide all notifications (prevent alert() calls).
-				const notification = editor.plugins.get( Notification );
-				notification.on( 'show', evt => evt.stop() );
-			} );
-	} );
+	describe( 'file dialog button', () => {
+		beforeEach( () => {
+			editorElement = document.createElement( 'div' );
+			document.body.appendChild( editorElement );
 
-	afterEach( () => {
-		editorElement.remove();
+			return ClassicEditor
+				.create( editorElement, {
+					plugins: [ Paragraph, Image, ImageUploadEditing, ImageUploadUI, FileRepository, UploadAdapterPluginMock, Clipboard ]
+				} )
+				.then( newEditor => {
+					editor = newEditor;
+					model = editor.model;
 
-		return editor.destroy();
-	} );
+					// Hide all notifications (prevent alert() calls).
+					const notification = editor.plugins.get( Notification );
+					notification.on( 'show', evt => evt.stop() );
+				} );
+		} );
 
-	it( 'should register imageUpload button', () => {
-		const button = editor.ui.componentFactory.create( 'imageUpload' );
+		afterEach( () => {
+			editorElement.remove();
 
-		expect( button ).to.be.instanceOf( FileDialogButtonView );
-	} );
+			return editor.destroy();
+		} );
+		it( 'should register imageUpload file dialog button', () => {
+			const button = editor.ui.componentFactory.create( 'imageUpload' );
 
-	it( 'should set proper accepted mime-types for imageUpload button as defined in configuration', () => {
-		editor.config.set( 'image.upload.types', [ 'svg+xml', 'jpeg', 'vnd.microsoft.icon', 'x-xbitmap' ] );
+			expect( button ).to.be.instanceOf( FileDialogButtonView );
+		} );
 
-		const button = editor.ui.componentFactory.create( 'imageUpload' );
+		it( 'should set proper accepted mime-types for imageUpload button as defined in configuration', () => {
+			editor.config.set( 'image.upload.types', [ 'svg+xml', 'jpeg', 'vnd.microsoft.icon', 'x-xbitmap' ] );
 
-		expect( button.acceptedType ).to.equal( 'image/svg+xml,image/jpeg,image/vnd.microsoft.icon,image/x-xbitmap' );
-	} );
+			const button = editor.ui.componentFactory.create( 'imageUpload' );
 
-	it( 'should be disabled while ImageUploadCommand is disabled', () => {
-		const button = editor.ui.componentFactory.create( 'imageUpload' );
-		const command = editor.commands.get( 'imageUpload' );
+			expect( button.acceptedType ).to.equal( 'image/svg+xml,image/jpeg,image/vnd.microsoft.icon,image/x-xbitmap' );
+		} );
 
-		command.isEnabled = true;
+		it( 'should be disabled while ImageUploadCommand is disabled', () => {
+			const button = editor.ui.componentFactory.create( 'imageUpload' );
+			const command = editor.commands.get( 'imageUpload' );
 
-		expect( button.buttonView.isEnabled ).to.true;
+			command.isEnabled = true;
 
-		command.isEnabled = false;
+			expect( button.buttonView.isEnabled ).to.true;
 
-		expect( button.buttonView.isEnabled ).to.false;
-	} );
+			command.isEnabled = false;
 
-	// ckeditor5-upload/#77
-	it( 'should be properly bound with ImageUploadCommand', () => {
-		const button = editor.ui.componentFactory.create( 'imageUpload' );
-		const command = editor.commands.get( 'imageUpload' );
-		const spy = sinon.spy();
+			expect( button.buttonView.isEnabled ).to.false;
+		} );
 
-		button.render();
+		// ckeditor5-upload/#77
+		it( 'should be properly bound with ImageUploadCommand', () => {
+			const button = editor.ui.componentFactory.create( 'imageUpload' );
+			const command = editor.commands.get( 'imageUpload' );
+			const spy = sinon.spy();
 
-		button.buttonView.on( 'execute', spy );
+			button.render();
 
-		command.isEnabled = false;
+			button.buttonView.on( 'execute', spy );
 
-		button.buttonView.element.dispatchEvent( new Event( 'click' ) );
+			command.isEnabled = false;
 
-		sinon.assert.notCalled( spy );
-	} );
+			button.buttonView.element.dispatchEvent( new Event( 'click' ) );
 
-	it( 'should execute imageUpload command', () => {
-		const executeStub = sinon.stub( editor, 'execute' );
-		const button = editor.ui.componentFactory.create( 'imageUpload' );
-		const files = [ createNativeFileMock() ];
+			sinon.assert.notCalled( spy );
+		} );
 
-		button.fire( 'done', files );
-		sinon.assert.calledOnce( executeStub );
-		expect( executeStub.firstCall.args[ 0 ] ).to.equal( 'imageUpload' );
-		expect( executeStub.firstCall.args[ 1 ].file ).to.deep.equal( files );
-	} );
+		it( 'should execute imageUpload command', () => {
+			const executeStub = sinon.stub( editor, 'execute' );
+			const button = editor.ui.componentFactory.create( 'imageUpload' );
+			const files = [ createNativeFileMock() ];
 
-	it( 'should execute imageUpload command with multiple files', () => {
-		const executeStub = sinon.stub( editor, 'execute' );
-		const button = editor.ui.componentFactory.create( 'imageUpload' );
-		const files = [ createNativeFileMock(), createNativeFileMock(), createNativeFileMock() ];
+			button.fire( 'done', files );
+			sinon.assert.calledOnce( executeStub );
+			expect( executeStub.firstCall.args[ 0 ] ).to.equal( 'imageUpload' );
+			expect( executeStub.firstCall.args[ 1 ].file ).to.deep.equal( files );
+		} );
 
-		button.fire( 'done', files );
-		sinon.assert.calledOnce( executeStub );
-		expect( executeStub.firstCall.args[ 0 ] ).to.equal( 'imageUpload' );
-		expect( executeStub.firstCall.args[ 1 ].file ).to.deep.equal( files );
-	} );
+		it( 'should execute imageUpload command with multiple files', () => {
+			const executeStub = sinon.stub( editor, 'execute' );
+			const button = editor.ui.componentFactory.create( 'imageUpload' );
+			const files = [ createNativeFileMock(), createNativeFileMock(), createNativeFileMock() ];
 
-	it( 'should optimize the insertion position', () => {
-		const button = editor.ui.componentFactory.create( 'imageUpload' );
-		const files = [ createNativeFileMock() ];
+			button.fire( 'done', files );
+			sinon.assert.calledOnce( executeStub );
+			expect( executeStub.firstCall.args[ 0 ] ).to.equal( 'imageUpload' );
+			expect( executeStub.firstCall.args[ 1 ].file ).to.deep.equal( files );
+		} );
 
-		setModelData( model, '<paragraph>f[]oo</paragraph>' );
+		it( 'should optimize the insertion position', () => {
+			const button = editor.ui.componentFactory.create( 'imageUpload' );
+			const files = [ createNativeFileMock() ];
 
-		button.fire( 'done', files );
+			setModelData( model, '<paragraph>f[]oo</paragraph>' );
 
-		const id = fileRepository.getLoader( files[ 0 ] ).id;
+			button.fire( 'done', files );
 
-		expect( getModelData( model ) ).to.equal(
-			`[<image uploadId="${ id }" uploadStatus="reading"></image>]` +
+			const id = fileRepository.getLoader( files[ 0 ] ).id;
+
+			expect( getModelData( model ) ).to.equal(
+				`[<image uploadId="${ id }" uploadStatus="reading"></image>]` +
 			'<paragraph>foo</paragraph>'
-		);
-	} );
+			);
+		} );
 
-	it( 'should correctly insert multiple files', () => {
-		const button = editor.ui.componentFactory.create( 'imageUpload' );
-		const files = [ createNativeFileMock(), createNativeFileMock() ];
+		it( 'should correctly insert multiple files', () => {
+			const button = editor.ui.componentFactory.create( 'imageUpload' );
+			const files = [ createNativeFileMock(), createNativeFileMock() ];
 
-		setModelData( model, '<paragraph>foo[]</paragraph><paragraph>bar</paragraph>' );
+			setModelData( model, '<paragraph>foo[]</paragraph><paragraph>bar</paragraph>' );
 
-		button.fire( 'done', files );
+			button.fire( 'done', files );
 
-		const id1 = fileRepository.getLoader( files[ 0 ] ).id;
-		const id2 = fileRepository.getLoader( files[ 1 ] ).id;
+			const id1 = fileRepository.getLoader( files[ 0 ] ).id;
+			const id2 = fileRepository.getLoader( files[ 1 ] ).id;
 
-		expect( getModelData( model ) ).to.equal(
-			'<paragraph>foo</paragraph>' +
+			expect( getModelData( model ) ).to.equal(
+				'<paragraph>foo</paragraph>' +
 			`<image uploadId="${ id1 }" uploadStatus="reading"></image>` +
 			`[<image uploadId="${ id2 }" uploadStatus="reading"></image>]` +
 			'<paragraph>bar</paragraph>'
-		);
-	} );
+			);
+		} );
+
+		it( 'should not execute imageUpload if the file is not an image', () => {
+			const executeStub = sinon.stub( editor, 'execute' );
+			const button = editor.ui.componentFactory.create( 'imageUpload' );
+			const file = {
+				type: 'media/mp3',
+				size: 1024
+			};
 
-	it( 'should not execute imageUpload if the file is not an image', () => {
-		const executeStub = sinon.stub( editor, 'execute' );
-		const button = editor.ui.componentFactory.create( 'imageUpload' );
-		const file = {
-			type: 'media/mp3',
-			size: 1024
-		};
+			button.fire( 'done', [ file ] );
+			sinon.assert.notCalled( executeStub );
+		} );
 
-		button.fire( 'done', [ file ] );
-		sinon.assert.notCalled( executeStub );
+		it( 'should work even if the FileList does not support iterators', () => {
+			const executeStub = sinon.stub( editor, 'execute' );
+			const button = editor.ui.componentFactory.create( 'imageUpload' );
+			const files = {
+				0: createNativeFileMock(),
+				length: 1
+			};
+
+			button.fire( 'done', files );
+			sinon.assert.calledOnce( executeStub );
+			expect( executeStub.firstCall.args[ 0 ] ).to.equal( 'imageUpload' );
+			expect( executeStub.firstCall.args[ 1 ].file ).to.deep.equal( [ files[ 0 ] ] );
+		} );
+
+		it( 'should add the new image after the selected one, without replacing the selected image', () => {
+			const button = editor.ui.componentFactory.create( 'imageUpload' );
+			const files = [ createNativeFileMock() ];
+
+			setModelData( model, '[<image src="/assets/sample.png"></image>]<paragraph>bar</paragraph>' );
+
+			button.fire( 'done', files );
+
+			const id1 = fileRepository.getLoader( files[ 0 ] ).id;
+
+			expect( getModelData( model ) ).to.equal(
+				'<image src="/assets/sample.png"></image>' +
+				`[<image uploadId="${ id1 }" uploadStatus="reading"></image>]` +
+				'<paragraph>bar</paragraph>'
+			);
+		} );
 	} );
 
-	it( 'should work even if the FileList does not support iterators', () => {
-		const executeStub = sinon.stub( editor, 'execute' );
-		const button = editor.ui.componentFactory.create( 'imageUpload' );
-		const files = {
-			0: createNativeFileMock(),
-			length: 1
-		};
-
-		button.fire( 'done', files );
-		sinon.assert.calledOnce( executeStub );
-		expect( executeStub.firstCall.args[ 0 ] ).to.equal( 'imageUpload' );
-		expect( executeStub.firstCall.args[ 1 ].file ).to.deep.equal( [ files[ 0 ] ] );
+	describe( 'dropdown', () => {
+		beforeEach( () => {
+			editorElement = document.createElement( 'div' );
+			document.body.appendChild( editorElement );
+
+			return ClassicEditor
+				.create( editorElement, {
+					plugins: [ Paragraph, Image, ImageUploadEditing, ImageUploadUI, FileRepository, UploadAdapterPluginMock, Clipboard ],
+					image: {
+						upload: {
+							panel: {
+								items: [
+									'insertImageViaUrl'
+								]
+							}
+						}
+					}
+				} )
+				.then( newEditor => {
+					editor = newEditor;
+					model = editor.model;
+
+					// Hide all notifications (prevent alert() calls).
+					const notification = editor.plugins.get( Notification );
+					notification.on( 'show', evt => evt.stop() );
+				} );
+		} );
+
+		afterEach( () => {
+			editorElement.remove();
+
+			return editor.destroy();
+		} );
+		it( 'should register imageUpload dropdown', () => {
+			const button = editor.ui.componentFactory.create( 'imageUpload' );
+
+			expect( button ).to.be.instanceOf( DropdownView );
+		} );
+
+		it( 'should set proper accepted mime-types for imageUpload button as defined in configuration', () => {
+			editor.config.set( 'image.upload.types', [ 'svg+xml', 'jpeg', 'vnd.microsoft.icon', 'x-xbitmap' ] );
+
+			const plugin = editor.plugins.get( 'ImageUploadUI' );
+			const fileDialogButton = plugin._createFileDialogButtonView( editor.locale );
+
+			expect( fileDialogButton.acceptedType ).to.equal( 'image/svg+xml,image/jpeg,image/vnd.microsoft.icon,image/x-xbitmap' );
+		} );
+
+		it( 'should be disabled while ImageUploadCommand is disabled', () => {
+			const button = editor.ui.componentFactory.create( 'imageUpload' );
+			const command = editor.commands.get( 'imageUpload' );
+
+			command.isEnabled = true;
+
+			expect( button.buttonView.isEnabled ).to.true;
+
+			command.isEnabled = false;
+
+			expect( button.buttonView.isEnabled ).to.false;
+		} );
+
+		// ckeditor5-upload/#77
+		it( 'should be properly bound with ImageUploadCommand', () => {
+			const dropdown = editor.ui.componentFactory.create( 'imageUpload' );
+			const command = editor.commands.get( 'imageUpload' );
+			const spy = sinon.spy();
+
+			dropdown.render();
+
+			dropdown.buttonView.on( 'execute', spy );
+
+			command.isEnabled = false;
+
+			dropdown.buttonView.element.dispatchEvent( new Event( 'click' ) );
+
+			sinon.assert.notCalled( spy );
+		} );
+
+		it( 'should execute imageUpload command', () => {
+			const executeStub = sinon.stub( editor, 'execute' );
+			const plugin = editor.plugins.get( 'ImageUploadUI' );
+			const fileDialogButton = plugin._createFileDialogButtonView( editor.locale );
+			const files = [ createNativeFileMock() ];
+
+			fileDialogButton.fire( 'done', files );
+			sinon.assert.calledOnce( executeStub );
+			expect( executeStub.firstCall.args[ 0 ] ).to.equal( 'imageUpload' );
+			expect( executeStub.firstCall.args[ 1 ].file ).to.deep.equal( files );
+		} );
+
+		it( 'should execute imageUpload command with multiple files', () => {
+			const executeStub = sinon.stub( editor, 'execute' );
+			const plugin = editor.plugins.get( 'ImageUploadUI' );
+			const fileDialogButton = plugin._createFileDialogButtonView( editor.locale );
+			const files = [ createNativeFileMock(), createNativeFileMock(), createNativeFileMock() ];
+
+			fileDialogButton.fire( 'done', files );
+			sinon.assert.calledOnce( executeStub );
+			expect( executeStub.firstCall.args[ 0 ] ).to.equal( 'imageUpload' );
+			expect( executeStub.firstCall.args[ 1 ].file ).to.deep.equal( files );
+		} );
+
+		it( 'should optimize the insertion position', () => {
+			const plugin = editor.plugins.get( 'ImageUploadUI' );
+			const fileDialogButton = plugin._createFileDialogButtonView( editor.locale );
+			const files = [ createNativeFileMock() ];
+
+			setModelData( model, '<paragraph>f[]oo</paragraph>' );
+
+			fileDialogButton.fire( 'done', files );
+
+			const id = fileRepository.getLoader( files[ 0 ] ).id;
+
+			expect( getModelData( model ) ).to.equal(
+				`[<image uploadId="${ id }" uploadStatus="reading"></image>]` +
+			'<paragraph>foo</paragraph>'
+			);
+		} );
+
+		it( 'should correctly insert multiple files', () => {
+			const plugin = editor.plugins.get( 'ImageUploadUI' );
+			const fileDialogButton = plugin._createFileDialogButtonView( editor.locale );
+			const files = [ createNativeFileMock(), createNativeFileMock() ];
+
+			setModelData( model, '<paragraph>foo[]</paragraph><paragraph>bar</paragraph>' );
+
+			fileDialogButton.fire( 'done', files );
+
+			const id1 = fileRepository.getLoader( files[ 0 ] ).id;
+			const id2 = fileRepository.getLoader( files[ 1 ] ).id;
+
+			expect( getModelData( model ) ).to.equal(
+				'<paragraph>foo</paragraph>' +
+			`<image uploadId="${ id1 }" uploadStatus="reading"></image>` +
+			`[<image uploadId="${ id2 }" uploadStatus="reading"></image>]` +
+			'<paragraph>bar</paragraph>'
+			);
+		} );
+
+		it( 'should not execute imageUpload if the file is not an image', () => {
+			const executeStub = sinon.stub( editor, 'execute' );
+			const plugin = editor.plugins.get( 'ImageUploadUI' );
+			const fileDialogButton = plugin._createFileDialogButtonView( editor.locale );
+			const file = {
+				type: 'media/mp3',
+				size: 1024
+			};
+
+			fileDialogButton.fire( 'done', [ file ] );
+			sinon.assert.notCalled( executeStub );
+		} );
+
+		it( 'should work even if the FileList does not support iterators', () => {
+			const executeStub = sinon.stub( editor, 'execute' );
+			const plugin = editor.plugins.get( 'ImageUploadUI' );
+			const fileDialogButton = plugin._createFileDialogButtonView( editor.locale );
+			const files = {
+				0: createNativeFileMock(),
+				length: 1
+			};
+
+			fileDialogButton.fire( 'done', files );
+			sinon.assert.calledOnce( executeStub );
+			expect( executeStub.firstCall.args[ 0 ] ).to.equal( 'imageUpload' );
+			expect( executeStub.firstCall.args[ 1 ].file ).to.deep.equal( [ files[ 0 ] ] );
+		} );
+
+		describe( 'dropdown action button', () => {
+			it( 'should be an instance of FileDialogButtonView', () => {
+				const dropdown = editor.ui.componentFactory.create( 'imageUpload' );
+
+				expect( dropdown.buttonView.actionView ).to.be.instanceOf( FileDialogButtonView );
+			} );
+		} );
+
+		describe( 'dropdown panel buttons', () => {
+			it( 'should have "Update" label on submit button when URL input is already filled', () => {
+				const dropdown = editor.ui.componentFactory.create( 'imageUpload' );
+				const viewDocument = editor.editing.view.document;
+
+				editor.setData( '<figure><img src="/assets/sample.png" /></figure>' );
+
+				editor.editing.view.change( writer => {
+					writer.setSelection( viewDocument.getRoot().getChild( 0 ), 'on' );
+				} );
+
+				const img = viewDocument.selection.getSelectedElement();
+
+				const data = fakeEventData();
+				const eventInfo = new EventInfo( img, 'click' );
+				const domEventDataMock = new DomEventData( viewDocument, eventInfo, data );
+
+				viewDocument.fire( 'click', domEventDataMock );
+
+				dropdown.isOpen = true;
+
+				const inputValue = dropdown.panelView.children.first.imageURLInputValue;
+
+				expect( dropdown.isOpen ).to.be.true;
+				expect( inputValue ).to.equal( '/assets/sample.png' );
+				expect( dropdown.panelView.children.first.insertButtonView.label ).to.equal( 'Update' );
+			} );
+
+			it( 'should have "Insert" label on submit button on uploading a new image', () => {
+				const dropdown = editor.ui.componentFactory.create( 'imageUpload' );
+				const viewDocument = editor.editing.view.document;
+
+				editor.setData( '<p>test</p>' );
+
+				editor.editing.view.change( writer => {
+					writer.setSelection( viewDocument.getRoot().getChild( 0 ), 'end' );
+				} );
+
+				const el = viewDocument.selection.getSelectedElement();
+
+				const data = fakeEventData();
+				const eventInfo = new EventInfo( el, 'click' );
+				const domEventDataMock = new DomEventData( viewDocument, eventInfo, data );
+
+				viewDocument.fire( 'click', domEventDataMock );
+
+				dropdown.isOpen = true;
+
+				const inputValue = dropdown.panelView.children.first.imageURLInputValue;
+
+				expect( dropdown.isOpen ).to.be.true;
+				expect( inputValue ).to.equal( '' );
+				expect( dropdown.panelView.children.first.insertButtonView.label ).to.equal( 'Insert' );
+			} );
+		} );
+
+		it( 'should remove all attributes from model except "src" when updating the image source URL', () => {
+			const viewDocument = editor.editing.view.document;
+			const dropdown = editor.ui.componentFactory.create( 'imageUpload' );
+			const insertButtonView = dropdown.panelView.children.first.insertButtonView;
+			const commandSpy = sinon.spy( editor.commands.get( 'imageInsert' ), 'execute' );
+			const submitSpy = sinon.spy();
+
+			dropdown.isOpen = true;
+
+			editor.setData( '<figure><img src="image-url-800w.jpg"' +
+			'srcset="image-url-480w.jpg 480w,image-url-800w.jpg 800w"' +
+			'sizes="(max-width: 600px) 480px,800px"' +
+			'alt="test-image"></figure>' );
+
+			editor.editing.view.change( writer => {
+				writer.setSelection( viewDocument.getRoot().getChild( 0 ), 'on' );
+			} );
+
+			const selectedElement = editor.model.document.selection.getSelectedElement();
+
+			expect( selectedElement.getAttribute( 'src' ) ).to.equal( 'image-url-800w.jpg' );
+			expect( selectedElement.hasAttribute( 'srcset' ) ).to.be.true;
+
+			dropdown.panelView.children.first.imageURLInputValue = '/assets/sample3.png';
+
+			dropdown.on( 'submit', submitSpy );
+
+			insertButtonView.fire( 'execute' );
+
+			sinon.assert.notCalled( commandSpy );
+			sinon.assert.calledOnce( submitSpy );
+			expect( dropdown.isOpen ).to.be.false;
+			expect( selectedElement.getAttribute( 'src' ) ).to.equal( '/assets/sample3.png' );
+			expect( selectedElement.hasAttribute( 'srcset' ) ).to.be.false;
+			expect( selectedElement.hasAttribute( 'sizes' ) ).to.be.false;
+		} );
+
+		describe( 'events', () => {
+			it( 'should emit "submit" event when clicking on submit button', () => {
+				const dropdown = editor.ui.componentFactory.create( 'imageUpload' );
+				const insertButtonView = dropdown.panelView.children.first.insertButtonView;
+				const commandSpy = sinon.spy( editor.commands.get( 'imageInsert' ), 'execute' );
+				const submitSpy = sinon.spy();
+
+				dropdown.isOpen = true;
+
+				dropdown.on( 'submit', submitSpy );
+
+				insertButtonView.fire( 'execute' );
+
+				expect( dropdown.isOpen ).to.be.false;
+				sinon.assert.calledOnce( commandSpy );
+				sinon.assert.calledOnce( submitSpy );
+			} );
+
+			it( 'should emit "cancel" event when clicking on cancel button', () => {
+				const dropdown = editor.ui.componentFactory.create( 'imageUpload' );
+				const cancelButtonView = dropdown.panelView.children.first.cancelButtonView;
+				const commandSpy = sinon.spy( editor.commands.get( 'imageInsert' ), 'execute' );
+				const cancelSpy = sinon.spy();
+
+				dropdown.isOpen = true;
+
+				dropdown.on( 'cancel', cancelSpy );
+
+				cancelButtonView.fire( 'execute' );
+
+				expect( dropdown.isOpen ).to.be.false;
+				sinon.assert.notCalled( commandSpy );
+				sinon.assert.calledOnce( cancelSpy );
+			} );
+		} );
+
+		it( 'should inject integrations to the dropdown panel view from the config', async () => {
+			const editor = await ClassicEditor
+				.create( editorElement, {
+					plugins: [
+						CKFinder,
+						Paragraph,
+						Image,
+						ImageUploadEditing,
+						ImageUploadUI,
+						FileRepository,
+						UploadAdapterPluginMock,
+						Clipboard
+					],
+					image: {
+						upload: {
+							panel: {
+								items: [
+									'insertImageViaUrl',
+									'openCKFinder'
+								]
+							}
+						}
+					}
+				} );
+
+			const dropdown = editor.ui.componentFactory.create( 'imageUpload' );
+
+			expect( dropdown.panelView.children.first._integrations.length ).to.equal( 2 );
+			expect( dropdown.panelView.children.first._integrations.first ).to.be.instanceOf( LabeledFieldView );
+			expect( dropdown.panelView.children.first._integrations.last ).to.be.instanceOf( ButtonView );
+
+			editor.destroy();
+		} );
 	} );
 } );
+
+function fakeEventData() {
+	return {
+		preventDefault: sinon.spy()
+	};
+}

+ 100 - 0
packages/ckeditor5-image/tests/imageupload/ui/imageuploadformrowview.js

@@ -0,0 +1,100 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import View from '@ckeditor/ckeditor5-ui/src/view';
+import ImageUploadFormRowView from '../../../src/imageupload/ui/imageuploadformrowview';
+import ViewCollection from '@ckeditor/ckeditor5-ui/src/viewcollection';
+
+describe( 'ImageUploadFormRowView', () => {
+	let view, locale;
+
+	beforeEach( () => {
+		locale = { t: val => val };
+		view = new ImageUploadFormRowView( locale );
+		view.render();
+	} );
+
+	afterEach( () => {
+		view.element.remove();
+	} );
+
+	describe( 'constructor()', () => {
+		it( 'should set view#locale', () => {
+			expect( view.locale ).to.equal( locale );
+		} );
+
+		it( 'should create view#children collection', () => {
+			expect( view.children ).to.be.instanceOf( ViewCollection );
+			expect( view.children ).to.have.length( 0 );
+		} );
+
+		it( 'should set view#class', () => {
+			expect( view.class ).to.be.null;
+		} );
+
+		it( 'should set the template', () => {
+			expect( view.element.classList.contains( 'ck' ) ).to.be.true;
+			expect( view.element.classList.contains( 'ck-form__row' ) ).to.be.true;
+		} );
+
+		describe( 'options', () => {
+			it( 'should set view#class when class was passed', () => {
+				const view = new ImageUploadFormRowView( locale, {
+					class: 'foo'
+				} );
+
+				expect( view.class ).to.equal( 'foo' );
+
+				view.destroy();
+			} );
+
+			it( 'should fill view#children when children were passed', () => {
+				const view = new ImageUploadFormRowView( locale, {
+					children: [
+						new View()
+					]
+				} );
+
+				expect( view.children ).to.have.length( 1 );
+
+				view.destroy();
+			} );
+
+			it( 'should use a label view when passed', () => {
+				const labelView = new View();
+				labelView.id = '123';
+
+				const view = new ImageUploadFormRowView( locale, {
+					labelView
+				} );
+
+				view.render();
+
+				expect( view.element.getAttribute( 'role' ) ).to.equal( 'group' );
+				expect( view.element.getAttribute( 'aria-labelledby' ) ).to.equal( '123' );
+
+				view.destroy();
+			} );
+		} );
+
+		describe( 'template bindings', () => {
+			it( 'should bind #class to the template', () => {
+				view.class = 'foo';
+				expect( view.element.classList.contains( 'foo' ) ).to.be.true;
+			} );
+
+			it( 'should bind #children to the template', () => {
+				const child = new View();
+				child.setTemplate( { tag: 'div' } );
+
+				view.children.add( child );
+
+				expect( view.element.firstChild ).to.equal( child.element );
+
+				view.destroy();
+			} );
+		} );
+	} );
+} );

+ 298 - 0
packages/ckeditor5-image/tests/imageupload/ui/imageuploadpanelview.js

@@ -0,0 +1,298 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals Event */
+
+import DropdownView from '@ckeditor/ckeditor5-ui/src/dropdown/dropdownview';
+import LabeledFieldView from '@ckeditor/ckeditor5-ui/src/labeledfield/labeledfieldview';
+
+import ImageUploadPanelView from '../../../src/imageupload/ui/imageuploadpanelview';
+import ImageUploadFormRowView from '../../../src/imageupload/ui/imageuploadformrowview';
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+import SplitButtonView from '@ckeditor/ckeditor5-ui/src/dropdown/button/splitbuttonview';
+import Collection from '@ckeditor/ckeditor5-utils/src/collection';
+
+import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
+import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler';
+import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker';
+import FocusCycler from '@ckeditor/ckeditor5-ui/src/focuscycler';
+import ViewCollection from '@ckeditor/ckeditor5-ui/src/viewcollection';
+import View from '@ckeditor/ckeditor5-ui/src/view';
+
+import { createLabeledInputView } from '../../../src/imageupload/utils';
+
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+
+describe( 'ImageUploadPanelView', () => {
+	let view;
+
+	beforeEach( () => {
+		view = new ImageUploadPanelView( { t: val => val }, {
+			'insertImageViaUrl': createLabeledInputView( { t: val => val } )
+		} );
+		view.render();
+	} );
+
+	afterEach( () => {
+		sinon.restore();
+	} );
+
+	describe( 'constructor()', () => {
+		it( 'should contain instance of ButtonView as #insertButtonView', () => {
+			expect( view.insertButtonView ).to.be.instanceOf( ButtonView );
+			expect( view.insertButtonView.label ).to.equal( 'Insert' );
+		} );
+
+		it( 'should contain instance of ButtonView as #cancelButtonView', () => {
+			expect( view.cancelButtonView ).to.be.instanceOf( ButtonView );
+			expect( view.cancelButtonView.label ).to.equal( 'Cancel' );
+		} );
+
+		it( 'should contain instance of DropdownView as #dropdownView', () => {
+			expect( view.dropdownView ).to.be.instanceOf( DropdownView );
+		} );
+
+		it( 'should contain instance of SplitButtonView for the #dropdownView button', () => {
+			expect( view.dropdownView ).to.be.instanceOf( DropdownView );
+			expect( view.dropdownView.buttonView ).to.be.instanceOf( SplitButtonView );
+		} );
+
+		it( 'should contain #imageURLInputValue', () => {
+			expect( view.imageURLInputValue ).to.equal( '' );
+		} );
+
+		it( 'should contain #_integrations as an instance of Collection', () => {
+			expect( view._integrations ).to.be.instanceOf( Collection );
+		} );
+
+		describe( 'integrations', () => {
+			it( 'should contain 2 integrations when they were passed to the ImageUploadPanelView as integrations object', () => {
+				const view = new ImageUploadPanelView( { t: val => val }, {
+					'integration1': new View(),
+					'integration2': new ButtonView()
+				} );
+
+				expect( view._integrations ).to.be.instanceOf( Collection );
+				expect( view._integrations.length ).to.equal( 2 );
+			} );
+
+			it( 'should contain insertImageViaUrl view when it is passed via integrations object', () => {
+				const view = new ImageUploadPanelView( { t: val => val }, {
+					'insertImageViaUrl': createLabeledInputView( { t: val => val } ),
+					'integration1': new View(),
+					'integration2': new ButtonView()
+				} );
+
+				expect( view._integrations ).to.be.instanceOf( Collection );
+				expect( view._integrations.length ).to.equal( 3 );
+				expect( view._integrations.first ).to.be.instanceOf( LabeledFieldView );
+			} );
+
+			it( 'should contain no integrations when they were not provided', () => {
+				const view = new ImageUploadPanelView( { t: val => val } );
+
+				expect( view._integrations ).to.be.instanceOf( Collection );
+				expect( view._integrations.length ).to.equal( 0 );
+			} );
+		} );
+
+		it( 'should create #focusTracker instance', () => {
+			expect( view.focusTracker ).to.be.instanceOf( FocusTracker );
+		} );
+
+		it( 'should create #keystrokes instance', () => {
+			expect( view.keystrokes ).to.be.instanceOf( KeystrokeHandler );
+		} );
+
+		it( 'should create #_focusCycler instance', () => {
+			expect( view._focusCycler ).to.be.instanceOf( FocusCycler );
+		} );
+
+		it( 'should create #_focusables view collection', () => {
+			expect( view._focusables ).to.be.instanceOf( ViewCollection );
+		} );
+
+		describe( 'events', () => {
+			it( 'should fire "submit" event on insertButtonView#execute', () => {
+				const spy = sinon.spy();
+
+				view.on( 'submit', spy );
+
+				view.insertButtonView.fire( 'execute' );
+
+				expect( spy.calledOnce ).to.true;
+			} );
+
+			it( 'should fire "cancel" event on cancelButtonView#execute', () => {
+				const spy = sinon.spy();
+
+				view.on( 'cancel', spy );
+
+				view.cancelButtonView.fire( 'execute' );
+
+				expect( spy.calledOnce ).to.true;
+			} );
+		} );
+	} );
+
+	describe( 'template', () => {
+		it( 'should create element from the template', () => {
+			expect( view.element.classList.contains( 'ck' ) ).to.true;
+			expect( view.element.classList.contains( 'ck-image-upload-form' ) ).to.true;
+			expect( view.element.getAttribute( 'tabindex' ) ).to.equal( '-1' );
+		} );
+
+		it( 'should have form row view with buttons', () => {
+			expect( view.template.children[ 1 ] ).to.be.instanceOf( ImageUploadFormRowView );
+			expect( view.template.children[ 1 ].children.first ).to.equal( view.insertButtonView );
+			expect( view.template.children[ 1 ].children.last ).to.equal( view.cancelButtonView );
+		} );
+	} );
+
+	describe( 'render()', () => {
+		it( 'should register child views in #_focusables', () => {
+			expect( view._focusables.map( f => f ) ).to.have.members( [
+				...view._integrations,
+				view.insertButtonView,
+				view.cancelButtonView
+			] );
+		} );
+
+		it( 'should register child views\' #element in #focusTracker with no integrations', () => {
+			const spy = testUtils.sinon.spy( FocusTracker.prototype, 'add' );
+
+			view = new ImageUploadPanelView( { t: () => {} } );
+			view.render();
+
+			sinon.assert.calledWithExactly( spy.getCall( 0 ), view.insertButtonView.element );
+			sinon.assert.calledWithExactly( spy.getCall( 1 ), view.cancelButtonView.element );
+		} );
+
+		it( 'should register child views\' #element in #focusTracker with "insertImageViaUrl" integration', () => {
+			const spy = testUtils.sinon.spy( FocusTracker.prototype, 'add' );
+
+			view = new ImageUploadPanelView( { t: () => {} }, {
+				'insertImageViaUrl': createLabeledInputView( { t: val => val } )
+			} );
+			view.render();
+
+			sinon.assert.calledWithExactly( spy.getCall( 0 ), view._integrations.get( 0 ).element );
+			sinon.assert.calledWithExactly( spy.getCall( 1 ), view.insertButtonView.element );
+			sinon.assert.calledWithExactly( spy.getCall( 2 ), view.cancelButtonView.element );
+		} );
+
+		it( 'starts listening for #keystrokes coming from #element', () => {
+			view = new ImageUploadPanelView( { t: () => {} } );
+
+			const spy = sinon.spy( view.keystrokes, 'listenTo' );
+
+			view.render();
+			sinon.assert.calledOnce( spy );
+			sinon.assert.calledWithExactly( spy, view.element );
+		} );
+
+		it( 'intercepts the arrow* events and overrides the default toolbar behavior', () => {
+			const keyEvtData = {
+				stopPropagation: sinon.spy()
+			};
+
+			keyEvtData.keyCode = keyCodes.arrowdown;
+			view.keystrokes.press( keyEvtData );
+			sinon.assert.calledOnce( keyEvtData.stopPropagation );
+
+			keyEvtData.keyCode = keyCodes.arrowup;
+			view.keystrokes.press( keyEvtData );
+			sinon.assert.calledTwice( keyEvtData.stopPropagation );
+
+			keyEvtData.keyCode = keyCodes.arrowleft;
+			view.keystrokes.press( keyEvtData );
+			sinon.assert.calledThrice( keyEvtData.stopPropagation );
+
+			keyEvtData.keyCode = keyCodes.arrowright;
+			view.keystrokes.press( keyEvtData );
+			sinon.assert.callCount( keyEvtData.stopPropagation, 4 );
+		} );
+
+		it( 'intercepts the "selectstart" event of the first integration element with the high priority', () => {
+			const spy = sinon.spy();
+			const event = new Event( 'selectstart', {
+				bubbles: true,
+				cancelable: true
+			} );
+
+			event.stopPropagation = spy;
+
+			view._integrations.get( 0 ).element.dispatchEvent( event );
+			sinon.assert.calledOnce( spy );
+		} );
+
+		describe( 'activates keyboard navigation for the toolbar', () => {
+			it( 'so "tab" focuses the next focusable item', () => {
+				const keyEvtData = {
+					keyCode: keyCodes.tab,
+					preventDefault: sinon.spy(),
+					stopPropagation: sinon.spy()
+				};
+
+				// Mock the url input is focused.
+				view.focusTracker.isFocused = true;
+				view.focusTracker.focusedElement = view._integrations.get( 0 ).element;
+
+				const spy = sinon.spy( view.insertButtonView, 'focus' );
+
+				view.keystrokes.press( keyEvtData );
+				sinon.assert.calledOnce( keyEvtData.preventDefault );
+				sinon.assert.calledOnce( keyEvtData.stopPropagation );
+				sinon.assert.calledOnce( spy );
+			} );
+
+			it( 'so "shift + tab" focuses the previous focusable item', () => {
+				const keyEvtData = {
+					keyCode: keyCodes.tab,
+					shiftKey: true,
+					preventDefault: sinon.spy(),
+					stopPropagation: sinon.spy()
+				};
+
+				// Mock the cancel button is focused.
+				view.focusTracker.isFocused = true;
+				view.focusTracker.focusedElement = view.cancelButtonView.element;
+
+				const spy = sinon.spy( view.insertButtonView, 'focus' );
+
+				view.keystrokes.press( keyEvtData );
+				sinon.assert.calledOnce( keyEvtData.preventDefault );
+				sinon.assert.calledOnce( keyEvtData.stopPropagation );
+				sinon.assert.calledOnce( spy );
+			} );
+		} );
+	} );
+
+	describe( 'focus()', () => {
+		it( 'should focus on the first integration', () => {
+			const spy = sinon.spy( view._integrations.get( 0 ), 'focus' );
+
+			view.focus();
+
+			sinon.assert.calledOnce( spy );
+		} );
+	} );
+
+	describe( 'Insert image via URL integration input', () => {
+		it( 'should be bound with #imageURLInputValue', () => {
+			const form = view._integrations.get( 0 );
+
+			form.fieldView.element.value = 'abc';
+			form.fieldView.fire( 'input' );
+
+			expect( view.imageURLInputValue ).to.equal( 'abc' );
+
+			form.fieldView.element.value = 'xyz';
+			form.fieldView.fire( 'input' );
+
+			expect( view.imageURLInputValue ).to.equal( 'xyz' );
+		} );
+	} );
+} );

+ 147 - 2
packages/ckeditor5-image/tests/imageupload/utils.js

@@ -3,9 +3,20 @@
  * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
-import { createImageTypeRegExp } from '../../src/imageupload/utils';
+/* globals document */
 
-describe( 'upload utils', () => {
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+import Image from '../../src/image';
+import ImageUploadUI from '../../src/imageupload/imageuploadui';
+import ImageUploadEditing from '../../src/imageupload/imageuploadediting';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import Link from '@ckeditor/ckeditor5-link/src/link';
+import CKFinder from '@ckeditor/ckeditor5-ckfinder/src/ckfinder';
+import { createImageTypeRegExp, prepareIntegrations, createLabeledInputView } from '../../src/imageupload/utils';
+
+describe( 'Upload utils', () => {
 	describe( 'createImageTypeRegExp()', () => {
 		it( 'should return RegExp for testing regular mime type', () => {
 			expect( createImageTypeRegExp( [ 'png' ] ).test( 'image/png' ) ).to.be.true;
@@ -31,4 +42,138 @@ describe( 'upload utils', () => {
 			expect( createImageTypeRegExp( [ 'png' ] ).test( 'svg+xml' ) ).to.be.false;
 		} );
 	} );
+
+	describe( 'prepareIntegrations()', () => {
+		it( 'should return "insetImageViaUrl" and "openCKFinder" integrations', async () => {
+			const editorElement = document.createElement( 'div' );
+			document.body.appendChild( editorElement );
+
+			const editor = await ClassicEditor
+				.create( editorElement, {
+					plugins: [
+						CKFinder,
+						Paragraph,
+						Image,
+						ImageUploadEditing,
+						ImageUploadUI
+					],
+					image: {
+						upload: {
+							panel: {
+								items: [
+									'insertImageViaUrl',
+									'openCKFinder'
+								]
+							}
+						}
+					}
+				} );
+
+			const openCKFinderExtendedView = Object.values( prepareIntegrations( editor ) )[ 1 ];
+
+			expect( openCKFinderExtendedView.class ).contains( 'ck-image-upload__ck-finder-button' );
+			expect( openCKFinderExtendedView.label ).to.equal( 'Insert image or file' );
+			expect( openCKFinderExtendedView.withText ).to.be.true;
+
+			editor.destroy();
+			editorElement.remove();
+		} );
+
+		it( 'should return only "insertImageViaUrl" integration and throw warning' +
+			'for "image-upload-integrations-invalid-view" error', async () => {
+			const editorElement = document.createElement( 'div' );
+			document.body.appendChild( editorElement );
+
+			const editor = await ClassicEditor
+				.create( editorElement, {
+					plugins: [
+						Paragraph,
+						Image,
+						ImageUploadEditing,
+						ImageUploadUI
+					],
+					image: {
+						upload: {
+							panel: {
+								items: [
+									'insertImageViaUrl',
+									'openCKFinder'
+								]
+							}
+						}
+					}
+				} );
+
+			expect( Object.values( prepareIntegrations( editor ) ).length ).to.equal( 1 );
+
+			editor.destroy();
+			editorElement.remove();
+		} );
+
+		it( 'should return only "link" integration', async () => {
+			const editorElement = document.createElement( 'div' );
+			document.body.appendChild( editorElement );
+
+			const editor = await ClassicEditor
+				.create( editorElement, {
+					plugins: [
+						Paragraph,
+						Link,
+						Image,
+						ImageUploadEditing,
+						ImageUploadUI
+					],
+					image: {
+						upload: {
+							panel: {
+								items: [
+									'link'
+								]
+							}
+						}
+					}
+				} );
+
+			expect( Object.values( prepareIntegrations( editor ) ).length ).to.equal( 1 );
+			expect( Object.values( prepareIntegrations( editor ) )[ 0 ].label ).to.equal( 'Link' );
+			expect( Object.values( prepareIntegrations( editor ) )[ 0 ] ).to.be.instanceOf( ButtonView );
+
+			editor.destroy();
+			editorElement.remove();
+		} );
+
+		it( 'should return "insertImageViaUrl" integration, when no integrations were configured', async () => {
+			const editorElement = document.createElement( 'div' );
+			document.body.appendChild( editorElement );
+
+			const editor = await ClassicEditor
+				.create( editorElement, {
+					plugins: [
+						Paragraph,
+						Image,
+						ImageUploadEditing,
+						ImageUploadUI
+					]
+				} );
+
+			expect( Object.keys( prepareIntegrations( editor ) ).length ).to.equal( 1 );
+
+			editor.destroy();
+			editorElement.remove();
+		} );
+	} );
+
+	describe( 'createLabeledInputView()', () => {
+		describe( 'image URL input view', () => {
+			it( 'should have placeholder', () => {
+				const view = createLabeledInputView( { t: val => val } );
+				expect( view.fieldView.placeholder ).to.equal( 'https://example.com/src/image.png' );
+			} );
+
+			it( 'should have info text', () => {
+				const view = createLabeledInputView( { t: val => val } );
+				expect( view.infoText ).to.match( /^Paste the image source URL/ );
+			} );
+		} );
+	} );
 } );

+ 28 - 0
packages/ckeditor5-image/tests/manual/imageuploadviaurl.html

@@ -0,0 +1,28 @@
+<head>
+	<meta http-equiv="Content-Security-Policy" content="script-src 'self' https://ckeditor.com 'unsafe-inline' 'unsafe-eval'">
+</head>
+
+<script src="https://ckeditor.com/apps/ckfinder/3.5.0/ckfinder.js"></script>
+
+<style>
+	code {
+		word-break: break-all;
+	}
+</style>
+
+<div id="editor2">
+	<h2>Image upload via URL with CKFinder integration</h2>
+	<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla finibus consequat placerat. Vestibulum id tellus et mauris sagittis tincidunt quis id mauris. Curabitur consectetur lectus sit amet tellus mattis, non lobortis leo interdum.</p>
+</div>
+
+<div id="button-container"></div>
+
+<style>
+	#button-container div {
+		margin-top: 10px;
+	}
+
+	#button-container button {
+		margin-right: 10px;
+	}
+</style>

+ 61 - 0
packages/ckeditor5-image/tests/manual/imageuploadviaurl.js

@@ -0,0 +1,61 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals window, document, console */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
+import ImageUpload from '../../src/imageupload';
+import CKFinder from '@ckeditor/ckeditor5-ckfinder/src/ckfinder';
+
+import { UploadAdapterMock } from '@ckeditor/ckeditor5-upload/tests/_utils/mocks';
+
+ClassicEditor
+	.create( document.querySelector( '#editor2' ), {
+		plugins: [ ArticlePluginSet, ImageUpload, CKFinder ],
+		toolbar: [
+			'heading',
+			'|',
+			'bold',
+			'italic',
+			'link',
+			'bulletedList',
+			'numberedList',
+			'blockQuote',
+			'imageUpload',
+			'insertTable',
+			'mediaEmbed',
+			'undo',
+			'redo'
+		],
+		image: {
+			toolbar: [ 'imageStyle:full', 'imageStyle:side', '|', 'imageTextAlternative' ],
+			upload: {
+				panel: {
+					items: [
+						'insertImageViaUrl',
+						'openCKFinder'
+					]
+				}
+			}
+		},
+		ckfinder: {
+			// eslint-disable-next-line max-len
+			uploadUrl: 'https://ckeditor.com/apps/ckfinder/3.5.0/core/connector/php/connector.php?command=QuickUpload&type=Files&responseType=json'
+		}
+	} )
+	.then( editor => {
+		window.editor2 = editor;
+
+		// Register fake adapter.
+		editor.plugins.get( 'FileRepository' ).createUploadAdapter = loader => {
+			const adapterMock = new UploadAdapterMock( loader );
+
+			return adapterMock;
+		};
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );

+ 18 - 0
packages/ckeditor5-image/tests/manual/imageuploadviaurl.md

@@ -0,0 +1,18 @@
+## Image upload via URL
+
+1. Click on the arrow button in `imageUpload` plugin to reveal the image upload panel.
+1. Paste the URL to the input (eg: `https://ckeditor.com/docs/ckeditor5/latest/assets/img/malta.jpg`).
+1. Click `Insert` button.
+
+## Image replace via URL
+
+1. Click on the image in the editor.
+1. Click on the arrow button in `imageUpload` plugin to reveal the image upload panel.
+1. Edit the value of the input.
+1. Click `Update` button.
+
+## Image upload via integrations
+
+1. In the **Editor 2** click on the arrow button in `imageUpload` plugin to reveal the image upload panel.
+1. Click on the **CKFinder** button.
+1. Choose image and confirm.

+ 16 - 0
packages/ckeditor5-image/theme/imageupload.css

@@ -0,0 +1,16 @@
+/*
+ * Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+.ck.ck-image-upload__panel {
+	padding: var(--ck-spacing-standard);
+}
+
+.ck.ck-image-upload__ck-finder-button {
+	display: block;
+	width: 100%;
+	margin: var(--ck-spacing-standard) auto;
+	border: 1px solid hsl(0, 0%, 80%);
+	border-radius: var(--ck-border-radius);
+}

+ 29 - 0
packages/ckeditor5-image/theme/imageuploadformrowview.css

@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+.ck.ck-form__row {
+	display: flex;
+	flex-direction: row;
+	flex-wrap: nowrap;
+	justify-content: space-between;
+
+	/* Ignore labels that work as fieldset legends */
+	& > *:not(.ck-label) {
+		flex-grow: 1;
+	}
+
+	&.ck-image-upload-form__action-row {
+		margin-top: var(--ck-spacing-standard);
+
+		& .ck-button-save,
+		& .ck-button-cancel {
+			justify-content: center;
+		}
+
+		& .ck-button .ck-button__label {
+			color: var(--ck-color-text);
+		}
+	}
+}

+ 20 - 0
packages/ckeditor5-list/docs/_snippets/features/lists-basic.html

@@ -0,0 +1,20 @@
+<div id="snippet-lists-basic">
+    <h2>Unordered list</h2>
+    <p>Such a list can represent items where the order is not important, e.g. a list of ingredients required for preparing a dish or a drink.</p>
+    <p>Ingredients required for making a coffee:</p>
+    <ul>
+        <li>15g coffee ground</li>
+        <li>a cup</li>
+        <li>water</li>
+    </ul>
+    <h2>Ordered list</h2>
+    <p>The ordered list can be used if the order of the items matters, e.g. when describing an instruction. The order of steps that must be done is important.</p>
+    <p>How to prepare the coffee?</p>
+    <ol>
+        <li>Gather the ingredients</li>
+        <li>Put 15g of the coffee ground into the cup</li>
+        <li>Boil 200ml of water</li>
+        <li>Pour water into a cup</li>
+        <li>Optional: add milk as you wish</li>
+    </ol>
+</div>

+ 57 - 0
packages/ckeditor5-list/docs/_snippets/features/lists-basic.js

@@ -0,0 +1,57 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals window, document, console, ClassicEditor */
+
+import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config';
+
+ClassicEditor
+	.create( document.querySelector( '#snippet-lists-basic' ), {
+		toolbar: {
+			items: [
+				'heading',
+				'|',
+				'bold',
+				'italic',
+				'bulletedList',
+				'numberedList',
+				'|',
+				'outdent',
+				'indent',
+				'|',
+				'link',
+				'imageUpload',
+				'insertTable',
+				'|',
+				'undo',
+				'redo'
+			],
+			viewportTopOffset: window.getViewportTopOffsetConfig()
+		},
+		image: {
+			styles: [
+				'full',
+				'alignLeft',
+				'alignRight'
+			],
+			toolbar: [
+				'imageStyle:alignLeft',
+				'imageStyle:full',
+				'imageStyle:alignRight',
+				'|',
+				'imageTextAlternative'
+			]
+		},
+		table: {
+			contentToolbar: [ 'tableColumn', 'tableRow', 'mergeTableCells' ]
+		},
+		cloudServices: CS_CONFIG
+	} )
+	.then( editor => {
+		window.editorBasic = editor;
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );

+ 0 - 0
packages/ckeditor5-list/docs/_snippets/features/lists-source.html


+ 12 - 0
packages/ckeditor5-list/docs/_snippets/features/lists-source.js

@@ -0,0 +1,12 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals window */
+
+import ClassicEditor from '@ckeditor/ckeditor5-build-classic/src/ckeditor';
+import ListStyle from '@ckeditor/ckeditor5-list/src/liststyle';
+
+window.ClassicEditor = ClassicEditor;
+window.ListStyle = ListStyle;

+ 17 - 0
packages/ckeditor5-list/docs/_snippets/features/lists-style.html

@@ -0,0 +1,17 @@
+<div id="snippet-lists-styles">
+    <p>Let's use the coffee recipe from the demo in section <a href="#demo">Ordered and unordered lists</a> and use the list styles feature.</p>
+    <p>Ingredients required for making a coffee:</p>
+    <ul style="list-style-type:circle;">
+        <li>15g coffee ground</li>
+        <li>a cup</li>
+        <li>water</li>
+    </ul>
+    <p>How to prepare the coffee?</p>
+    <ol style="list-style-type:decimal-leading-zero;">
+        <li>Gather the ingredients</li>
+        <li>Put 15g of the coffee ground into the cup</li>
+        <li>Boil 200ml of water</li>
+        <li>Pour water into a cup</li>
+        <li>Optional: add milk as you wish</li>
+    </ol>
+</div>

+ 58 - 0
packages/ckeditor5-list/docs/_snippets/features/lists-style.js

@@ -0,0 +1,58 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals window, document, console, ClassicEditor, ListStyle */
+
+import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config';
+
+ClassicEditor
+	.create( document.querySelector( '#snippet-lists-styles' ), {
+		extraPlugins: [ ListStyle ],
+		toolbar: {
+			items: [
+				'heading',
+				'|',
+				'bold',
+				'italic',
+				'bulletedList',
+				'numberedList',
+				'|',
+				'outdent',
+				'indent',
+				'|',
+				'link',
+				'imageUpload',
+				'insertTable',
+				'|',
+				'undo',
+				'redo'
+			],
+			viewportTopOffset: window.getViewportTopOffsetConfig()
+		},
+		image: {
+			styles: [
+				'full',
+				'alignLeft',
+				'alignRight'
+			],
+			toolbar: [
+				'imageStyle:alignLeft',
+				'imageStyle:full',
+				'imageStyle:alignRight',
+				'|',
+				'imageTextAlternative'
+			]
+		},
+		table: {
+			contentToolbar: [ 'tableColumn', 'tableRow', 'mergeTableCells' ]
+		},
+		cloudServices: CS_CONFIG
+	} )
+	.then( editor => {
+		window.editorStyles = editor;
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );

+ 93 - 0
packages/ckeditor5-list/docs/features/lists.md

@@ -0,0 +1,93 @@
+---
+category: features
+---
+
+# Lists
+
+The {@link module:list/list~List list} feature allows creating ordered and unordered lists in the editor.
+
+<info-box info>
+	The feature is enabled by default in all CKEditor 5 WYSIWYG editor builds.
+</info-box>
+
+{@snippet features/lists-source}
+
+## Ordered and unordered lists
+
+Use the editor below to see the list feature plugin in action.
+
+### Demo
+
+{@snippet features/lists-basic}
+
+## List styles
+
+The {@link module:list/liststyle~ListStyle list styles} feature allows customizing the list's marker.
+
+### Demo
+
+Use the editor below to see the list styles feature plugin in action.
+
+{@snippet features/lists-style}
+
+### Installation
+
+To add this feature to your editor, install the [`@ckeditor/ckeditor5-list`](https://www.npmjs.com/package/@ckeditor/ckeditor5-list) package:
+
+```bash
+npm install --save @ckeditor/ckeditor5-list
+```
+
+Then add the `ListStyle` plugin to your plugin list and the toolbar configuration:
+
+```js
+import ListStyle from '@ckeditor/ckeditor5-list/src/liststyle';
+
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		plugins: [ ListStyle, ... ],
+		toolbar: [ 'bulletedList', 'numberedList', ... ],
+	} )
+	.then( ... )
+	.catch( ... );
+```
+
+<info-box info>
+	Read more about {@link builds/guides/integration/installing-plugins installing plugins}.
+</info-box>
+
+<info-box warning>
+	The {@link module:list/liststyle~ListStyle} feature overrides UI button implementations from the {@link module:list/listui~ListUI}.
+</info-box>
+
+## To-do list
+
+You can read more about the feature in the {@link features/todo-lists To-do lists} feature guide.
+
+## Common API
+
+The {@link module:list/list~List} plugin registers:
+
+* The {@link module:list/listcommand~ListCommand `'numberedList'`} command.
+* The {@link module:list/listcommand~ListCommand `'bulletedList'`} command.
+* The {@link module:list/indentcommand~IndentCommand `'indentList'`} command.
+* The {@link module:list/indentcommand~IndentCommand `'outdentList'`} command.
+* The `'numberedList'` ui button.
+* The `'bulletedList'` ui button.
+
+The {@link module:list/liststyle~ListStyle} plugin registers:
+
+* The {@link module:list/liststylecommand~ListStyleCommand `'listStyle'`} command that accepts a `type` of a list style to set.
+    ```js
+    editor.execute( 'listStyle', { type: 'decimal' } );
+    ```
+    The available types are:
+
+    * For bulleted lists: `'disc'`, `'circle'`, and `'square'`.
+    * For numbered lists: `'decimal'`, `'decimal-leading-zero'`, `'lower-roman'`, `'upper-roman'`, `'lower-latin'`, and `'upper-latin'`.
+* The `'numberedList'` ui split button (it overrides UI button registered by the `List` plguin.
+* The `'bulletedList'` ui split button (it overrides UI button registered by the `List` plguin.
+
+## Contribute
+
+The source code of the feature is available on GitHub in https://github.com/ckeditor/ckeditor5/tree/master/packages/ckeditor5-list.

+ 4 - 0
packages/ckeditor5-list/docs/features/todo-lists.md

@@ -88,6 +88,10 @@ From the technical point of view, to-do lists are built on top of the {@link mod
 <listItem listType="todo" todoListChecked="true">Bar</listItem>
 ```
 
+## Ordered and unordered lists
+
+You can read more about those features in the {@link features/lists Lists} feature guide.
+
 ## Contribute
 
 The source code of the feature is available on GitHub in https://github.com/ckeditor/ckeditor5/tree/master/packages/ckeditor5-list.

+ 21 - 1
packages/ckeditor5-list/lang/contexts.json

@@ -1,5 +1,25 @@
 {
 	"Numbered List": "Toolbar button tooltip for the Numbered List feature.",
 	"Bulleted List": "Toolbar button tooltip for the Bulleted List feature.",
-	"To-do List": "Toolbar button tooltip for the To-do List feature."
+	"To-do List": "Toolbar button tooltip for the To-do List feature.",
+	"Bulleted list styles toolbar": "The ARIA label of the toolbar displaying buttons allowing users to change the bulleted list style.",
+	"Numbered list styles toolbar": "The ARIA label of the toolbar displaying buttons allowing users to change the numbered list style.",
+	"Toggle the disc list style": "The ARIA label of the button that toggles the \"disc\" list style.",
+	"Toggle the circle list style": "The ARIA label of the button that toggles the \"circle\" list style.",
+	"Toggle the square list style": "The ARIA label of the button that toggles the \"square\" list style.",
+	"Toggle the decimal list style": "The ARIA label of the button that toggles the \"decimal\" list style.",
+	"Toggle the decimal with leading zero list style": "The ARIA label of the button that toggles the \"decimal with leading zero\" list style.",
+	"Toggle the lower–roman list style": "The ARIA label of the button that toggles the \"lower–roman\" list style.",
+	"Toggle the upper–roman list style": "The ARIA label of the button that toggles the \"upper–roman\" list style.",
+	"Toggle the lower–latin list style": "The ARIA label of the button that toggles the \"lower–latin\" list style.",
+	"Toggle the upper–latin list style": "The ARIA label of the button that toggles the \"upper–latin\" list style.",
+	"Disc": "The tooltip text of the button that toggles the \"disc\" list style.",
+	"Circle": "The tooltip text of the button that toggles the \"circle\" list style.",
+	"Square": "The tooltip text of the button that toggles the \"square\" list style.",
+	"Decimal": "The tooltip text of the button that toggles the \"decimal\" list style.",
+	"Decimal with leading zero": "The tooltip text of the button that toggles the \"decimal with leading zero\" list style.",
+	"Lower–roman": "The tooltip text of the button that toggles the \"lower–roman\" list style.",
+	"Upper-roman": "The tooltip text of the button that toggles the \"upper–roman\" list style.",
+	"Lower-latin": "The tooltip text of the button that toggles the \"lower–latin\" list style.",
+	"Upper-latin": "The tooltip text of the button that toggles the \"upper–latin\" list style."
 }

+ 2 - 0
packages/ckeditor5-list/package.json

@@ -20,6 +20,7 @@
     "@ckeditor/ckeditor5-basic-styles": "^21.0.0",
     "@ckeditor/ckeditor5-block-quote": "^21.0.0",
     "@ckeditor/ckeditor5-clipboard": "^21.0.0",
+    "@ckeditor/ckeditor5-essentials": "^21.0.0",
     "@ckeditor/ckeditor5-editor-classic": "^21.0.0",
     "@ckeditor/ckeditor5-enter": "^21.0.0",
     "@ckeditor/ckeditor5-font": "^21.0.0",
@@ -27,6 +28,7 @@
     "@ckeditor/ckeditor5-highlight": "^21.0.0",
     "@ckeditor/ckeditor5-indent": "^21.0.0",
     "@ckeditor/ckeditor5-link": "^21.0.0",
+    "@ckeditor/ckeditor5-remove-format": "^21.0.0",
     "@ckeditor/ckeditor5-table": "^21.0.0",
     "@ckeditor/ckeditor5-typing": "^21.0.0",
     "@ckeditor/ckeditor5-undo": "^21.0.0"

+ 6 - 0
packages/ckeditor5-list/src/converters.js

@@ -611,6 +611,12 @@ export function modelChangePostFixer( model, writer ) {
 					applied = true;
 				}
 
+				if ( item.hasAttribute( 'listStyle' ) ) {
+					writer.removeAttribute( 'listStyle', item );
+
+					applied = true;
+				}
+
 				for ( const innerItem of Array.from( model.createRangeIn( item ) ).filter( e => e.item.is( 'element', 'listItem' ) ) ) {
 					_addListToFix( innerItem.previousPosition );
 				}

+ 12 - 0
packages/ckeditor5-list/src/indentcommand.js

@@ -47,6 +47,7 @@ export default class IndentCommand extends Command {
 	 * Indents or outdents (depending on the {@link #constructor}'s `indentDirection` parameter) selected list items.
 	 *
 	 * @fires execute
+	 * @fires _executeCleanup
 	 */
 	execute() {
 		const model = this.editor.model;
@@ -90,6 +91,17 @@ export default class IndentCommand extends Command {
 					writer.setAttribute( 'listIndent', indent, item );
 				}
 			}
+
+			/**
+			 * Event fired by the {@link #execute} method.
+			 *
+			 * It allows to execute an action after executing the {@link ~IndentCommand#execute} method, e.g. adjusting
+			 * attributes of changed list items.
+			 *
+			 * @protected
+			 * @event _executeCleanup
+			 */
+			this.fire( '_executeCleanup', itemsToChange );
 		} );
 	}
 

+ 11 - 0
packages/ckeditor5-list/src/listcommand.js

@@ -211,6 +211,17 @@ export default class ListCommand extends Command {
 					writer.setAttribute( 'listType', this.type, element );
 				}
 			}
+
+			/**
+			 * Event fired by the {@link #execute} method.
+			 *
+			 * It allows to execute an action after executing the {@link ~ListCommand#execute} method, e.g. adjusting
+			 * attributes of changed blocks.
+			 *
+			 * @protected
+			 * @event _executeCleanup
+			 */
+			this.fire( '_executeCleanup', blocks );
 		} );
 	}
 

+ 36 - 0
packages/ckeditor5-list/src/liststyle.js

@@ -0,0 +1,36 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module list/liststyle
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import ListStyleEditing from './liststyleediting';
+import ListStyleUI from './liststyleui';
+
+/**
+ * The list styles feature.
+ *
+ * This is a "glue" plugin that loads the {@link module:list/liststyleediting~ListStyleEditing list styles editing feature}
+ * and the {@link module:list/liststyleui~ListStyleUI list styles UI feature}.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class ListStyle extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ ListStyleEditing, ListStyleUI ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'ListStyle';
+	}
+}

+ 197 - 0
packages/ckeditor5-list/src/liststylecommand.js

@@ -0,0 +1,197 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module list/liststylecommand
+ */
+
+import Command from '@ckeditor/ckeditor5-core/src/command';
+import TreeWalker from '@ckeditor/ckeditor5-engine/src/model/treewalker';
+
+/**
+ * The list style command. It is used by the {@link module:list/liststyle~ListStyle list styles feature}.
+ *
+ * @extends module:core/command~Command
+ */
+export default class ListStyleCommand extends Command {
+	/**
+	 * Creates an instance of the command.
+	 *
+	 * @param {module:core/editor/editor~Editor} editor The editor instance.
+	 * @param {String} defaultType The list type that will be used by default if the value was not specified during
+	 * the command execution.
+	 */
+	constructor( editor, defaultType ) {
+		super( editor );
+
+		/**
+		 * The default type of the list style.
+		 *
+		 * @protected
+		 * @member {String}
+		 */
+		this._defaultType = defaultType;
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	refresh() {
+		this.value = this._getValue();
+		this.isEnabled = this._checkEnabled();
+	}
+
+	/**
+	 * Executes the command.
+	 *
+	 * @param {Object} options
+	 * @param {String|null} options.type The type of the list styles, e.g. 'disc' or 'square'. If `null` specified, the default
+	 * style will be applied.
+	 * @protected
+	 */
+	execute( options = {} ) {
+		const model = this.editor.model;
+		const document = model.document;
+
+		// For all selected blocks find all list items that are being selected
+		// and update the `listStyle` attribute in those lists.
+		let listItems = [ ...document.selection.getSelectedBlocks() ]
+			.filter( element => element.is( 'element', 'listItem' ) )
+			.map( element => {
+				const position = model.change( writer => writer.createPositionAt( element, 0 ) );
+
+				return [
+					...getSiblingNodes( position, 'backward' ),
+					...getSiblingNodes( position, 'forward' )
+				];
+			} )
+			.flat();
+
+		// Since `getSelectedBlocks()` can return items that belong to the same list, and
+		// `getSiblingNodes()` returns the entire list, we need to remove duplicated items.
+		listItems = [ ...new Set( listItems ) ];
+
+		if ( !listItems.length ) {
+			return;
+		}
+
+		model.change( writer => {
+			for ( const item of listItems ) {
+				writer.setAttribute( 'listStyle', options.type || this._defaultType, item );
+			}
+		} );
+	}
+
+	/**
+	 * Checks the command's {@link #value}.
+	 *
+	 * @private
+	 * @returns {String|null} The current value.
+	 */
+	_getValue() {
+		const listItem = this.editor.model.document.selection.getFirstPosition().parent;
+
+		if ( listItem && listItem.is( 'element', 'listItem' ) ) {
+			return listItem.getAttribute( 'listStyle' );
+		}
+
+		return null;
+	}
+
+	/**
+	 * Checks whether the command can be enabled in the current context.
+	 *
+	 * @private
+	 * @returns {Boolean} Whether the command should be enabled.
+	 */
+	_checkEnabled() {
+		const editor = this.editor;
+
+		const numberedList = editor.commands.get( 'numberedList' );
+		const bulletedList = editor.commands.get( 'bulletedList' );
+
+		return numberedList.isEnabled || bulletedList.isEnabled;
+	}
+}
+
+// Returns an array with all `listItem` elements that represents the same list.
+//
+// It means that values for `listIndent`, `listType`, and `listStyle` for all items
+// are equal.
+//
+// @param {module:engine/model/position~Position} position Starting position.
+// @param {'forward'|'backward'} direction Walking direction.
+// @returns {Array.<module:engine/model/element~Element>
+function getSiblingNodes( position, direction ) {
+	const items = [];
+	const listItem = position.parent;
+	const walkerOptions = {
+		ignoreElementEnd: true,
+		startPosition: position,
+		shallow: true,
+		direction
+	};
+	const limitIndent = listItem.getAttribute( 'listIndent' );
+	const nodes = [ ...new TreeWalker( walkerOptions ) ]
+		.filter( value => value.item.is( 'element' ) )
+		.map( value => value.item );
+
+	for ( const element of nodes ) {
+		// If found something else than `listItem`, we're out of the list scope.
+		if ( !element.is( 'element', 'listItem' ) ) {
+			break;
+		}
+
+		// If current parsed item has lower indent that element that the element that was a starting point,
+		// it means we left a nested list. Abort searching items.
+		//
+		// ■ List item 1.       [listIndent=0]
+		//     ○ List item 2.[] [listIndent=1], limitIndent = 1,
+		//     ○ List item 3.   [listIndent=1]
+		// ■ List item 4.       [listIndent=0]
+		//
+		// Abort searching when leave nested list.
+		if ( element.getAttribute( 'listIndent' ) < limitIndent ) {
+			break;
+		}
+
+		// ■ List item 1.[]     [listIndent=0] limitIndent = 0,
+		//     ○ List item 2.   [listIndent=1]
+		//     ○ List item 3.   [listIndent=1]
+		// ■ List item 4.       [listIndent=0]
+		//
+		// Ignore nested lists.
+		if ( element.getAttribute( 'listIndent' ) > limitIndent ) {
+			continue;
+		}
+
+		// ■ List item 1.[]  [listType=bulleted]
+		// 1. List item 2.   [listType=numbered]
+		// 2.List item 3.    [listType=numbered]
+		//
+		// Abort searching when found a different kind of a list.
+		if ( element.getAttribute( 'listType' ) !== listItem.getAttribute( 'listType' ) ) {
+			break;
+		}
+
+		// ■ List item 1.[]  [listType=bulleted]
+		// ■ List item 2.    [listType=bulleted]
+		// ○ List item 3.    [listType=bulleted]
+		// ○ List item 4.    [listType=bulleted]
+		//
+		// Abort searching when found a different list style.
+		if ( element.getAttribute( 'listStyle' ) !== listItem.getAttribute( 'listStyle' ) ) {
+			break;
+		}
+
+		if ( direction === 'backward' ) {
+			items.unshift( element );
+		} else {
+			items.push( element );
+		}
+	}
+
+	return items;
+}

+ 471 - 0
packages/ckeditor5-list/src/liststyleediting.js

@@ -0,0 +1,471 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module list/liststyleediting
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import ListEditing from './listediting';
+import ListStyleCommand from './liststylecommand';
+import { getSiblingListItem } from './utils';
+
+const DEFAULT_LIST_TYPE = 'default';
+
+/**
+ * The list styles engine feature.
+ *
+ * It sets value for the `listItem` attribute for the {@link module:list/list~List `<listItem>`} element that
+ * allows modifying list style type.
+ *
+ * It registers the `'listStyle'` command.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class ListStyleEditing extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ ListEditing ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'ListStyleEditing';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+		const model = editor.model;
+
+		// Extend schema.
+		model.schema.extend( 'listItem', {
+			allowAttributes: [ 'listStyle' ]
+		} );
+
+		editor.commands.add( 'listStyle', new ListStyleCommand( editor, DEFAULT_LIST_TYPE ) );
+
+		// Fix list attributes when modifying their nesting levels (the `listIndent` attribute).
+		this.listenTo( editor.commands.get( 'indentList' ), '_executeCleanup', fixListAfterIndentListCommand( editor ) );
+		this.listenTo( editor.commands.get( 'outdentList' ), '_executeCleanup', fixListAfterOutdentListCommand( editor ) );
+
+		this.listenTo( editor.commands.get( 'bulletedList' ), '_executeCleanup', restoreDefaultListStyle( editor ) );
+		this.listenTo( editor.commands.get( 'numberedList' ), '_executeCleanup', restoreDefaultListStyle( editor ) );
+
+		// Register a post-fixer that ensures that the `listStyle` attribute is specified in each `listItem` element.
+		model.document.registerPostFixer( fixListStyleAttributeOnListItemElements( editor ) );
+
+		// Set up conversion.
+		editor.conversion.for( 'upcast' ).add( upcastListItemStyle() );
+		editor.conversion.for( 'downcast' ).add( downcastListStyleAttribute() );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	afterInit() {
+		const editor = this.editor;
+
+		// Enable post-fixer that removes the `listStyle` attribute from to-do list items only if the "TodoList" plugin is on.
+		// We need to registry the hook here since the `TodoList` plugin can be added after the `ListStyleEditing`.
+		if ( editor.commands.get( 'todoList' ) ) {
+			editor.model.document.registerPostFixer( removeListStyleAttributeFromTodoList( editor ) );
+		}
+	}
+}
+
+// Returns a converter that consumes the `style` attribute and search for `list-style-type` definition.
+// If not found, the `"default"` value will be used.
+//
+// @private
+// @returns {Function}
+function upcastListItemStyle() {
+	return dispatcher => {
+		dispatcher.on( 'element:li', ( evt, data, conversionApi ) => {
+			const listParent = data.viewItem.parent;
+			const listStyle = listParent.getStyle( 'list-style-type' ) || DEFAULT_LIST_TYPE;
+			const listItem = data.modelRange.start.nodeAfter;
+
+			conversionApi.writer.setAttribute( 'listStyle', listStyle, listItem );
+		}, { priority: 'low' } );
+	};
+}
+
+// Returns a converter that adds the `list-style-type` definition as a value for the `style` attribute.
+// The `"default"` value is removed and not present in the view/data.
+//
+// @private
+// @returns {Function}
+function downcastListStyleAttribute() {
+	return dispatcher => {
+		dispatcher.on( 'attribute:listStyle:listItem', ( evt, data, conversionApi ) => {
+			const viewWriter = conversionApi.writer;
+			const currentElement = data.item;
+			const listStyle = data.attributeNewValue;
+
+			const previousElement = getSiblingListItem( currentElement.previousSibling, {
+				sameIndent: true,
+				listIndent: currentElement.getAttribute( 'listIndent' ),
+				direction: 'backward'
+			} );
+
+			const viewItem = conversionApi.mapper.toViewElement( currentElement );
+
+			// Single item list.
+			if ( !previousElement ) {
+				setListStyle( viewWriter, listStyle, viewItem.parent );
+			} else if ( !areRepresentingSameList( previousElement, currentElement ) ) {
+				viewWriter.breakContainer( viewWriter.createPositionBefore( viewItem ) );
+				viewWriter.breakContainer( viewWriter.createPositionAfter( viewItem ) );
+
+				setListStyle( viewWriter, listStyle, viewItem.parent );
+			}
+		}, { priority: 'low' } );
+	};
+
+	// Checks whether specified list items belong to the same list.
+	//
+	// @param {module:engine/model/element~Element} listItem1 The first list item to check.
+	// @param {module:engine/model/element~Element} listItem2 The second list item to check.
+	// @returns {Boolean}
+	function areRepresentingSameList( listItem1, listItem2 ) {
+		return listItem1.getAttribute( 'listType' ) === listItem2.getAttribute( 'listType' ) &&
+			listItem1.getAttribute( 'listIndent' ) === listItem2.getAttribute( 'listIndent' ) &&
+			listItem1.getAttribute( 'listStyle' ) === listItem2.getAttribute( 'listStyle' );
+	}
+
+	// Updates or removes the `list-style-type` from the `element`.
+	//
+	// @param {module:engine/view/downcastwriter~DowncastWriter} writer
+	// @param {String} listStyle
+	// @param {module:engine/view/element~Element} element
+	function setListStyle( writer, listStyle, element ) {
+		if ( listStyle && listStyle !== DEFAULT_LIST_TYPE ) {
+			writer.setStyle( 'list-style-type', listStyle, element );
+		} else {
+			writer.removeStyle( 'list-style-type', element );
+		}
+	}
+}
+
+// When indenting list, nested list should clear its value for the `listStyle` attribute or inherit from nested lists.
+//
+// ■ List item 1.
+// ■ List item 2.[]
+// ■ List item 3.
+// editor.execute( 'indentList' );
+//
+// ■ List item 1.
+//     ○ List item 2.[]
+// ■ List item 3.
+//
+// @private
+// @param {module:core/editor/editor~Editor} editor
+// @returns {Function}
+function fixListAfterIndentListCommand( editor ) {
+	return ( evt, changedItems ) => {
+		let valueToSet;
+
+		const root = changedItems[ 0 ];
+		const rootIndent = root.getAttribute( 'listIndent' );
+
+		const itemsToUpdate = changedItems.filter( item => item.getAttribute( 'listIndent' ) === rootIndent );
+
+		// A case where a few list items are intended must be checked separately
+		// since `getSiblingListItem()` returns the first changed element.
+		// ■ List item 1.
+		//     ○ [List item 2.
+		//     ○ List item 3.]
+		// ■ List item 4.
+		//
+		// List items: `2` and `3` should be adjusted.
+		if ( root.previousSibling.getAttribute( 'listIndent' ) + 1 === rootIndent ) {
+			// valueToSet = root.previousSibling.getAttribute( 'listStyle' ) || DEFAULT_LIST_TYPE;
+			valueToSet = DEFAULT_LIST_TYPE;
+		} else {
+			const previousSibling = getSiblingListItem( root.previousSibling, {
+				sameIndent: true, direction: 'backward', listIndent: rootIndent
+			} );
+
+			valueToSet = previousSibling.getAttribute( 'listStyle' );
+		}
+
+		editor.model.change( writer => {
+			for ( const item of itemsToUpdate ) {
+				writer.setAttribute( 'listStyle', valueToSet, item );
+			}
+		} );
+	};
+}
+
+// When outdenting a list, a nested list should copy its value for the `listStyle` attribute
+// from the previous sibling list item including the same value for the `listIndent` value.
+//
+// ■ List item 1.
+//     ○ List item 2.[]
+// ■ List item 3.
+//
+// editor.execute( 'outdentList' );
+//
+// ■ List item 1.
+// ■ List item 2.[]
+// ■ List item 3.
+//
+// @private
+// @param {module:core/editor/editor~Editor} editor
+// @returns {Function}
+function fixListAfterOutdentListCommand( editor ) {
+	return ( evt, changedItems ) => {
+		changedItems = changedItems.reverse().filter( item => item.is( 'element', 'listItem' ) );
+
+		if ( !changedItems.length ) {
+			return;
+		}
+
+		const indent = changedItems[ 0 ].getAttribute( 'listIndent' );
+		const listType = changedItems[ 0 ].getAttribute( 'listType' );
+		let listItem = changedItems[ 0 ].previousSibling;
+
+		// ■ List item 1.
+		//     ○ List item 2.
+		//     ○ List item 3.[]
+		// ■ List item 4.
+		//
+		// After outdenting a list, `List item 3` should inherit the `listStyle` attribute from `List item 1`.
+		//
+		// ■ List item 1.
+		//     ○ List item 2.
+		// ■ List item 3.[]
+		// ■ List item 4.
+		if ( listItem.is( 'element', 'listItem' ) ) {
+			while ( listItem.getAttribute( 'listIndent' ) !== indent ) {
+				listItem = listItem.previousSibling;
+			}
+		} else {
+			listItem = null;
+		}
+
+		// Outdenting such a list should restore values based on `List item 4`.
+		// ■ List item 1.[]
+		//     ○ List item 2.
+		//     ○ List item 3.
+		// ■ List item 4.
+		if ( !listItem ) {
+			listItem = changedItems[ changedItems.length - 1 ].nextSibling;
+		}
+
+		// And such a list should not modify anything.
+		// However, `listItem` can indicate a node below the list. Be sure that we have the `listItem` element.
+		// ■ List item 1.[]
+		//     ○ List item 2.
+		//     ○ List item 3.
+		// <paragraph>The later if check.</paragraph>
+		if ( !listItem || !listItem.is( 'element', 'listItem' ) ) {
+			return;
+		}
+
+		// Do not modify the list if found `listItem` represents other type of list than outdented list items.
+		if ( listItem.getAttribute( 'listType' ) !== listType ) {
+			return;
+		}
+
+		editor.model.change( writer => {
+			const itemsToUpdate = changedItems.filter( item => item.getAttribute( 'listIndent' ) === indent );
+
+			for ( const item of itemsToUpdate ) {
+				writer.setAttribute( 'listStyle', listItem.getAttribute( 'listStyle' ), item );
+			}
+		} );
+	};
+}
+
+// Each `listItem` element must have specified the `listStyle` attribute.
+// This post-fixer checks whether inserted elements `listItem` elements should inherit the `listStyle` value from
+// their sibling nodes or should use the default value.
+//
+// Paragraph[]
+// ■ List item 1. // [listStyle="square", listType="bulleted"]
+// ■ List item 2. // ...
+// ■ List item 3. // ...
+//
+// editor.execute( 'bulletedList' )
+//
+// ■ Paragraph[]  // [listStyle="square", listType="bulleted"]
+// ■ List item 1. // [listStyle="square", listType="bulleted"]
+// ■ List item 2.
+// ■ List item 3.
+//
+// It also covers a such change:
+//
+// [Paragraph 1
+// Paragraph 2]
+// ■ List item 1. // [listStyle="square", listType="bulleted"]
+// ■ List item 2. // ...
+// ■ List item 3. // ...
+//
+// editor.execute( 'numberedList' )
+//
+// 1. [Paragraph 1 // [listStyle="default", listType="numbered"]
+// 2. Paragraph 2] // [listStyle="default", listType="numbered"]
+// ■ List item 1.  // [listStyle="square", listType="bulleted"]
+// ■ List item 2.  // ...
+// ■ List item 3.  // ...
+//
+// @private
+// @param {module:core/editor/editor~Editor} editor
+// @returns {Function}
+function fixListStyleAttributeOnListItemElements( editor ) {
+	return writer => {
+		let wasFixed = false;
+		let insertedListItems = [];
+
+		for ( const change of editor.model.document.differ.getChanges() ) {
+			if ( change.type == 'insert' && change.name == 'listItem' ) {
+				insertedListItems.push( change.position.nodeAfter );
+			}
+		}
+
+		// Don't touch todo lists.
+		insertedListItems = insertedListItems.filter( item => item.getAttribute( 'listType' ) !== 'todo' );
+
+		if ( !insertedListItems.length ) {
+			return wasFixed;
+		}
+
+		// Check whether the last inserted element is next to the `listItem` element.
+		//
+		// ■ Paragraph[]  // <-- The inserted item.
+		// ■ List item 1.
+		let existingListItem = insertedListItems[ insertedListItems.length - 1 ].nextSibling;
+
+		// If it doesn't, maybe the `listItem` was inserted at the end of the list.
+		//
+		// ■ List item 1.
+		// ■ Paragraph[]  // <-- The inserted item.
+		if ( !existingListItem || !existingListItem.is( 'element', 'listItem' ) ) {
+			existingListItem = insertedListItems[ insertedListItems.length - 1 ].previousSibling;
+
+			if ( existingListItem ) {
+				const indent = insertedListItems[ 0 ].getAttribute( 'listIndent' );
+
+				// But we need to find a `listItem` with the `listIndent=0` attribute.
+				// If doesn't, maybe the `listItem` was inserted at the end of the list.
+				//
+				// ■ List item 1.
+				//     ○ List item 2.
+				// ■ Paragraph[]  // <-- The inserted item.
+				while ( existingListItem.is( 'element', 'listItem' ) && existingListItem.getAttribute( 'listIndent' ) !== indent ) {
+					existingListItem = existingListItem.previousSibling;
+				}
+			}
+		}
+
+		for ( const item of insertedListItems ) {
+			if ( !item.hasAttribute( 'listStyle' ) ) {
+				if ( shouldInheritListType( existingListItem ) ) {
+					writer.setAttribute( 'listStyle', existingListItem.getAttribute( 'listStyle' ), item );
+				} else {
+					writer.setAttribute( 'listStyle', DEFAULT_LIST_TYPE, item );
+				}
+
+				wasFixed = true;
+			}
+		}
+
+		return wasFixed;
+	};
+
+	// Checks whether the `listStyle` attribute should be copied from the `baseItem` element.
+	//
+	// The attribute should be copied if the inserted element does not have defined it and
+	// the value for the element is other than default in the base element.
+	//
+	// @param {module:engine/model/element~Element|null} baseItem
+	// @returns {Boolean}
+	function shouldInheritListType( baseItem ) {
+		if ( !baseItem ) {
+			return false;
+		}
+
+		const baseListStyle = baseItem.getAttribute( 'listStyle' );
+
+		if ( !baseListStyle ) {
+			return false;
+		}
+
+		if ( baseListStyle === DEFAULT_LIST_TYPE ) {
+			return false;
+		}
+
+		return true;
+	}
+}
+
+// Removes the `listStyle` attribute from "todo" list items.
+//
+// @private
+// @param {module:core/editor/editor~Editor} editor
+// @returns {Function}
+function removeListStyleAttributeFromTodoList( editor ) {
+	return writer => {
+		let todoListItems = [];
+
+		for ( const change of editor.model.document.differ.getChanges() ) {
+			const item = getItemFromChange( change );
+
+			if ( item && item.is( 'element', 'listItem' ) && item.getAttribute( 'listType' ) === 'todo' ) {
+				todoListItems.push( item );
+			}
+		}
+
+		todoListItems = todoListItems.filter( item => item.hasAttribute( 'listStyle' ) );
+
+		if ( !todoListItems.length ) {
+			return false;
+		}
+
+		for ( const item of todoListItems ) {
+			writer.removeAttribute( 'listStyle', item );
+		}
+
+		return true;
+	};
+
+	function getItemFromChange( change ) {
+		if ( change.type === 'attribute' ) {
+			return change.range.start.nodeAfter;
+		}
+
+		if ( change.type === 'insert' ) {
+			return change.position.nodeAfter;
+		}
+
+		return null;
+	}
+}
+
+// Restores the `listStyle` attribute after changing the list type.
+//
+// @private
+// @param {module:core/editor/editor~Editor} editor
+// @returns {Function}
+function restoreDefaultListStyle( editor ) {
+	return ( evt, changedItems ) => {
+		changedItems = changedItems.filter( item => item.is( 'element', 'listItem' ) );
+
+		editor.model.change( writer => {
+			for ( const item of changedItems ) {
+				// Remove the attribute. Post-fixer will restore the proper value.
+				writer.removeAttribute( 'listStyle', item );
+			}
+		} );
+	};
+}

+ 230 - 0
packages/ckeditor5-list/src/liststyleui.js

@@ -0,0 +1,230 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module list/liststyleui
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+import SplitButtonView from '@ckeditor/ckeditor5-ui/src/dropdown/button/splitbuttonview';
+import {
+	createDropdown,
+	addToolbarToDropdown
+} from '@ckeditor/ckeditor5-ui/src/dropdown/utils';
+
+import bulletedListIcon from '../theme/icons/bulletedlist.svg';
+import numberedListIcon from '../theme/icons/numberedlist.svg';
+
+import listStyleDiscIcon from '../theme/icons/liststyledisc.svg';
+import listStyleCircleIcon from '../theme/icons/liststylecircle.svg';
+import listStyleSquareIcon from '../theme/icons/liststylesquare.svg';
+import listStyleDecimalIcon from '../theme/icons/liststyledecimal.svg';
+import listStyleDecimalWithLeadingZeroIcon from '../theme/icons/liststyledecimalleadingzero.svg';
+import listStyleLowerRomanIcon from '../theme/icons/liststylelowerroman.svg';
+import listStyleUpperRomanIcon from '../theme/icons/liststyleupperroman.svg';
+import listStyleLowerLatinIcon from '../theme/icons/liststylelowerlatin.svg';
+import listStyleUpperLatinIcon from '../theme/icons/liststyleupperlatin.svg';
+
+import '../theme/liststyles.css';
+
+/**
+ * The list styles UI plugin. It introduces the extended `'bulletedList'` and `'numberedList'` toolbar
+ * buttons that allow users change styles of individual lists in the content.
+ *
+ * **Note**: Buttons introduces by this plugin override implementations from the {@link module:list/listui~ListUI}
+ * (because they share the same names).
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class ListStyleUI extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'ListStyleUI';
+	}
+
+	init() {
+		const editor = this.editor;
+		const t = editor.locale.t;
+
+		editor.ui.componentFactory.add( 'bulletedList', getSplitButtonCreator( {
+			editor,
+			parentCommandName: 'bulletedList',
+			buttonLabel: t( 'Bulleted List' ),
+			buttonIcon: bulletedListIcon,
+			toolbarAriaLabel: t( 'Bulleted list styles toolbar' ),
+			styleDefinitions: [
+				{
+					label: t( 'Toggle the disc list style' ),
+					tooltip: t( 'Disc' ),
+					type: 'disc',
+					icon: listStyleDiscIcon
+				},
+				{
+					label: t( 'Toggle the circle list style' ),
+					tooltip: t( 'Circle' ),
+					type: 'circle',
+					icon: listStyleCircleIcon
+				},
+				{
+					label: t( 'Toggle the square list style' ),
+					tooltip: t( 'Square' ),
+					type: 'square',
+					icon: listStyleSquareIcon
+				}
+			]
+		} ) );
+
+		editor.ui.componentFactory.add( 'numberedList', getSplitButtonCreator( {
+			editor,
+			parentCommandName: 'numberedList',
+			buttonLabel: t( 'Numbered List' ),
+			buttonIcon: numberedListIcon,
+			toolbarAriaLabel: t( 'Numbered list styles toolbar' ),
+			styleDefinitions: [
+				{
+					label: t( 'Toggle the decimal list style' ),
+					tooltip: t( 'Decimal' ),
+					type: 'decimal',
+					icon: listStyleDecimalIcon
+				},
+				{
+					label: t( 'Toggle the decimal with leading zero list style' ),
+					tooltip: t( 'Decimal with leading zero' ),
+					type: 'decimal-leading-zero',
+					icon: listStyleDecimalWithLeadingZeroIcon
+				},
+				{
+					label: t( 'Toggle the lower–roman list style' ),
+					tooltip: t( 'Lower–roman' ),
+					type: 'lower-roman',
+					icon: listStyleLowerRomanIcon
+				},
+				{
+					label: t( 'Toggle the upper–roman list style' ),
+					tooltip: t( 'Upper-roman' ),
+					type: 'upper-roman',
+					icon: listStyleUpperRomanIcon
+				},
+				{
+					label: t( 'Toggle the lower–latin list style' ),
+					tooltip: t( 'Lower-latin' ),
+					type: 'lower-latin',
+					icon: listStyleLowerLatinIcon
+				},
+				{
+					label: t( 'Toggle the upper–latin list style' ),
+					tooltip: t( 'Upper-latin' ),
+					type: 'upper-latin',
+					icon: listStyleUpperLatinIcon
+				}
+			]
+		} ) );
+	}
+}
+
+// A helper that returns a function that creates a split button with a toolbar in the dropdown,
+// which in turn contains buttons allowing users to change list styles in the context of the current selection.
+//
+// @param {Object} options
+// @param {module:core/editor/editor~Editor} options.editor
+// @param {'bulletedList'|'numberedList'} options.parentCommandName The name of the higher-order editor command associated with
+// the set of particular list styles (e.g. "bulletedList" for "disc", "circle", and "square" styles).
+// @param {String} options.buttonLabel Label of the main part of the split button.
+// @param {String} options.buttonIcon The SVG string of an icon for the main part of the split button.
+// @param {String} options.toolbarAriaLabel The ARIA label for the toolbar in the split button dropdown.
+// @param {Object} options.styleDefinitions Definitions of the style buttons.
+// @returns {Function} A function that can be passed straight into {@link module:ui/componentfactory~ComponentFactory#add}.
+function getSplitButtonCreator( { editor, parentCommandName, buttonLabel, buttonIcon, toolbarAriaLabel, styleDefinitions } ) {
+	const parentCommand = editor.commands.get( parentCommandName );
+	const listStyleCommand = editor.commands.get( 'listStyle' );
+
+	// @param {module:utils/locale~Locale} locale
+	// @returns {module:ui/dropdown/dropdownview~DropdownView}
+	return locale => {
+		const dropdownView = createDropdown( locale, SplitButtonView );
+		const splitButtonView = dropdownView.buttonView;
+		const styleButtonCreator = getStyleButtonCreator( { editor, parentCommandName, listStyleCommand } );
+
+		addToolbarToDropdown( dropdownView, styleDefinitions.map( styleButtonCreator ) );
+
+		dropdownView.bind( 'isEnabled' ).to( parentCommand );
+		dropdownView.toolbarView.ariaLabel = toolbarAriaLabel;
+		dropdownView.class = 'ck-list-styles-dropdown';
+
+		splitButtonView.on( 'execute', () => {
+			editor.execute( parentCommandName );
+			editor.editing.view.focus();
+		} );
+
+		splitButtonView.set( {
+			label: buttonLabel,
+			icon: buttonIcon,
+			tooltip: true,
+			isToggleable: true
+		} );
+
+		splitButtonView.bind( 'isOn' ).to( parentCommand, 'value', value => !!value );
+
+		return dropdownView;
+	};
+}
+
+// A helper that returns a function (factory) that creates individual buttons used by users to change styles
+// of lists.
+//
+// @param {Object} options
+// @param {module:core/editor/editor~Editor} options.editor
+// @param {module:list/liststylecommand~ListStylesCommand} options.listStyleCommand The instance of the `ListStylesCommand` class.
+// @param {'bulletedList'|'numberedList'} options.parentCommandName The name of the higher-order command associated with a
+// particular list style (e.g. "bulletedList" is associated with "square" and "numberedList" is associated with "roman").
+// @returns {Function} A function that can be passed straight into {@link module:ui/componentfactory~ComponentFactory#add}.
+function getStyleButtonCreator( { editor, listStyleCommand, parentCommandName } ) {
+	const locale = editor.locale;
+	const parentCommand = editor.commands.get( parentCommandName );
+
+	// @param {String} label The label of the style button.
+	// @param {String} type The type of the style button (e.g. "roman" or "circle").
+	// @param {String} icon The SVG string of an icon of the style button.
+	// @param {String} tooltip The tooltip text of the button (shorter than verbose label).
+	// @returns {module:ui/button/buttonview~ButtonView}
+	return ( { label, type, icon, tooltip } ) => {
+		const button = new ButtonView( locale );
+
+		button.set( { label, icon, tooltip } );
+
+		listStyleCommand.on( 'change:value', () => {
+			button.isOn = listStyleCommand.value === type;
+		} );
+
+		button.on( 'execute', () => {
+			// If the content the selection is anchored to is a list, let's change its style.
+			if ( parentCommand.value ) {
+				// If the current list style is not set in the model or the style is different than the
+				// one to be applied, simply apply the new style.
+				if ( listStyleCommand.value !== type ) {
+					editor.execute( 'listStyle', { type } );
+				}
+				// If the style was the same, remove it (the button works as an off toggle).
+				else {
+					editor.execute( 'listStyle', { type: listStyleCommand._defaultType } );
+				}
+			}
+			// If the content the selection is anchored to is not a list, let's create a list of a desired style.
+			else {
+				editor.model.change( () => {
+					editor.execute( parentCommandName );
+					editor.execute( 'listStyle', { type } );
+				} );
+			}
+
+			editor.editing.view.focus();
+		} );
+
+		return button;
+	};
+}

+ 21 - 1
packages/ckeditor5-list/tests/indentcommand.js

@@ -37,7 +37,7 @@ describe( 'IndentCommand', () => {
 		);
 	} );
 
-	describe( 'IndentCommand', () => {
+	describe( 'IndentCommand - forward (indent)', () => {
 		let command;
 
 		beforeEach( () => {
@@ -193,6 +193,26 @@ describe( 'IndentCommand', () => {
 					'<listItem listIndent="0" listType="bulleted">g</listItem>'
 				);
 			} );
+
+			it( 'should fire "_executeCleanup" event after finish all operations with all changed items', done => {
+				model.change( writer => {
+					writer.setSelection( root.getChild( 1 ), 0 );
+				} );
+
+				command.on( '_executeCleanup', ( evt, data ) => {
+					expect( data ).to.deep.equal( [
+						root.getChild( 1 ),
+						root.getChild( 2 ),
+						root.getChild( 3 ),
+						root.getChild( 4 ),
+						root.getChild( 5 )
+					] );
+
+					done();
+				} );
+
+				command.execute();
+			} );
 		} );
 	} );
 

+ 29 - 0
packages/ckeditor5-list/tests/listcommand.js

@@ -454,6 +454,35 @@ describe( 'ListCommand', () => {
 					expect( getData( model ) ).to.equal( expectedData );
 				} );
 			} );
+
+			it( 'should fire "_executeCleanup" event after finish all operations with all changed items', done => {
+				setData( model,
+					'<paragraph>Foo 1.</paragraph>' +
+					'<paragraph>[Foo 2.</paragraph>' +
+					'<paragraph>Foo 3.]</paragraph>' +
+					'<paragraph>Foo 4.</paragraph>'
+				);
+
+				command.execute();
+
+				expect( getData( model ) ).to.equal(
+					'<paragraph>Foo 1.</paragraph>' +
+					'<listItem listIndent="0" listType="bulleted">[Foo 2.</listItem>' +
+					'<listItem listIndent="0" listType="bulleted">Foo 3.]</listItem>' +
+					'<paragraph>Foo 4.</paragraph>'
+				);
+
+				command.on( '_executeCleanup', ( evt, data ) => {
+					expect( data ).to.deep.equal( [
+						root.getChild( 2 ),
+						root.getChild( 1 )
+					] );
+
+					done();
+				} );
+
+				command.execute();
+			} );
 		} );
 	} );
 } );

+ 18 - 0
packages/ckeditor5-list/tests/liststyle.js

@@ -0,0 +1,18 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import ListStyle from '../src/liststyle';
+import ListStyleEditing from '../src/liststyleediting';
+import ListStyleUI from '../src/liststyleui';
+
+describe( 'ListStyle', () => {
+	it( 'should be named', () => {
+		expect( ListStyle.pluginName ).to.equal( 'ListStyle' );
+	} );
+
+	it( 'should require ListStyleEditing and ListStyleUI', () => {
+		expect( ListStyle.requires ).to.deep.equal( [ ListStyleEditing, ListStyleUI ] );
+	} );
+} );

+ 391 - 0
packages/ckeditor5-list/tests/liststylecommand.js

@@ -0,0 +1,391 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import ListStyleEditing from '../src/liststyleediting';
+
+describe( 'ListStyleCommand', () => {
+	let editor, model, bulletedListCommand, numberedListCommand, listStyleCommand;
+
+	beforeEach( () => {
+		return VirtualTestEditor
+			.create( {
+				plugins: [ Paragraph, ListStyleEditing ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+
+				bulletedListCommand = editor.commands.get( 'bulletedList' );
+				numberedListCommand = editor.commands.get( 'numberedList' );
+				listStyleCommand = editor.commands.get( 'listStyle' );
+			} );
+	} );
+
+	afterEach( () => {
+		return editor.destroy();
+	} );
+
+	describe( '#isEnabled', () => {
+		it( 'should be true if bulletedList or numberedList is enabled', () => {
+			bulletedListCommand.isEnabled = true;
+			numberedListCommand.isEnabled = false;
+			listStyleCommand.refresh();
+
+			expect( listStyleCommand.isEnabled ).to.equal( true );
+
+			bulletedListCommand.isEnabled = false;
+			numberedListCommand.isEnabled = true;
+			listStyleCommand.refresh();
+
+			expect( listStyleCommand.isEnabled ).to.equal( true );
+		} );
+
+		it( 'should be false if bulletedList and numberedList are enabled', () => {
+			bulletedListCommand.isEnabled = false;
+			numberedListCommand.isEnabled = false;
+
+			listStyleCommand.refresh();
+
+			expect( listStyleCommand.isEnabled ).to.equal( false );
+		} );
+	} );
+
+	describe( '#value', () => {
+		it( 'should return null if selected a paragraph', () => {
+			setData( model, '<paragraph>Foo[]</paragraph>' );
+
+			expect( listStyleCommand.value ).to.equal( null );
+		} );
+
+		it( 'should return null if selection starts in a paragraph and ends in a list item', () => {
+			setData( model,
+				'<paragraph>Fo[o</paragraph>' +
+				'<listItem listIndent="0" listType="bulleted" listStyle="default">Foo]</listItem>'
+			);
+
+			expect( listStyleCommand.value ).to.equal( null );
+		} );
+
+		it( 'should return the value of `listStyle` attribute if selection is inside a listItem (collapsed selection)', () => {
+			setData( model, '<listItem listIndent="0" listType="bulleted" listStyle="default">Foo[]</listItem>' );
+
+			expect( listStyleCommand.value ).to.equal( 'default' );
+		} );
+
+		it( 'should return the value of `listStyle` attribute if selection is inside a listItem (non-collapsed selection)', () => {
+			setData( model, '<listItem listIndent="0" listType="bulleted" listStyle="default">[Foo]</listItem>' );
+
+			expect( listStyleCommand.value ).to.equal( 'default' );
+		} );
+
+		it( 'should return the value of `listStyle` attribute if selected more elements in the same list', () => {
+			setData( model,
+				'<listItem listIndent="0" listType="bulleted" listStyle="square">[1.</listItem>' +
+				'<listItem listIndent="0" listType="bulleted" listStyle="square">2.]</listItem>' +
+				'<listItem listIndent="0" listType="bulleted" listStyle="square">3.</listItem>'
+			);
+
+			expect( listStyleCommand.value ).to.equal( 'square' );
+		} );
+
+		it( 'should return the value of `listStyle` attribute for the selection inside a nested list', () => {
+			setData( model,
+				'<listItem listIndent="0" listType="bulleted" listStyle="square">1.</listItem>' +
+				'<listItem listIndent="0" listType="bulleted" listStyle="disc">1.1.[]</listItem>' +
+				'<listItem listIndent="0" listType="bulleted" listStyle="square">2.</listItem>'
+			);
+
+			expect( listStyleCommand.value ).to.equal( 'disc' );
+		} );
+
+		it( 'should return the value of `listStyle` attribute from a list where the selection starts (selection over nested list)', () => {
+			setData( model,
+				'<listItem listIndent="0" listType="bulleted" listStyle="square">1.</listItem>' +
+				'<listItem listIndent="0" listType="bulleted" listStyle="disc">1.1.[</listItem>' +
+				'<listItem listIndent="0" listType="bulleted" listStyle="square">2.]</listItem>'
+			);
+
+			expect( listStyleCommand.value ).to.equal( 'disc' );
+		} );
+	} );
+
+	describe( 'execute()', () => {
+		it( 'should set the `listStyle` attribute for collapsed selection', () => {
+			setData( model,
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">1.[]</listItem>'
+			);
+
+			listStyleCommand.execute( { type: 'circle' } );
+
+			expect( getData( model ) ).to.equal(
+				'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.[]</listItem>'
+			);
+		} );
+
+		it( 'should set the `listStyle` attribute for non-collapsed selection', () => {
+			setData( model,
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">[1.]</listItem>'
+			);
+
+			listStyleCommand.execute( { type: 'circle' } );
+
+			expect( getData( model ) ).to.equal(
+				'<listItem listIndent="0" listStyle="circle" listType="bulleted">[1.]</listItem>'
+			);
+		} );
+
+		it( 'should set the `listStyle` attribute for all the same list items (collapsed selection)', () => {
+			setData( model,
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">1.[]</listItem>' +
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">2.</listItem>' +
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">3.</listItem>'
+			);
+
+			listStyleCommand.execute( { type: 'circle' } );
+
+			expect( getData( model ) ).to.equal(
+				'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.[]</listItem>' +
+				'<listItem listIndent="0" listStyle="circle" listType="bulleted">2.</listItem>' +
+				'<listItem listIndent="0" listStyle="circle" listType="bulleted">3.</listItem>'
+			);
+		} );
+
+		it( 'should set the `listStyle` attribute for all the same list items and ignores nested lists', () => {
+			setData( model,
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">1.[]</listItem>' +
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">2.</listItem>' +
+				'<listItem listIndent="1" listStyle="default" listType="bulleted">2.1.</listItem>' +
+				'<listItem listIndent="1" listStyle="default" listType="bulleted">2.2.</listItem>' +
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">3.</listItem>' +
+				'<listItem listIndent="1" listStyle="default" listType="bulleted">3.1.</listItem>'
+			);
+
+			listStyleCommand.execute( { type: 'circle' } );
+
+			expect( getData( model ) ).to.equal(
+				'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.[]</listItem>' +
+				'<listItem listIndent="0" listStyle="circle" listType="bulleted">2.</listItem>' +
+				'<listItem listIndent="1" listStyle="default" listType="bulleted">2.1.</listItem>' +
+				'<listItem listIndent="1" listStyle="default" listType="bulleted">2.2.</listItem>' +
+				'<listItem listIndent="0" listStyle="circle" listType="bulleted">3.</listItem>' +
+				'<listItem listIndent="1" listStyle="default" listType="bulleted">3.1.</listItem>'
+			);
+		} );
+
+		it( 'should set the `listStyle` attribute for all the same list items and ignores "parent" list (selection in nested list)', () => {
+			setData( model,
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">1.</listItem>' +
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">2.</listItem>' +
+				'<listItem listIndent="1" listStyle="default" listType="bulleted">2.1.[]</listItem>' +
+				'<listItem listIndent="1" listStyle="default" listType="bulleted">2.2.</listItem>' +
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">3.</listItem>' +
+				'<listItem listIndent="1" listStyle="default" listType="bulleted">3.1.</listItem>'
+			);
+
+			listStyleCommand.execute( { type: 'disc' } );
+
+			expect( getData( model ) ).to.equal(
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">1.</listItem>' +
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">2.</listItem>' +
+				'<listItem listIndent="1" listStyle="disc" listType="bulleted">2.1.[]</listItem>' +
+				'<listItem listIndent="1" listStyle="disc" listType="bulleted">2.2.</listItem>' +
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">3.</listItem>' +
+				'<listItem listIndent="1" listStyle="default" listType="bulleted">3.1.</listItem>'
+			);
+		} );
+
+		it( 'should stop searching for the list items when spotted non-listItem element', () => {
+			setData( model,
+				'<paragraph>Foo.</paragraph>' +
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">1.[]</listItem>' +
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">2.</listItem>' +
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">3.</listItem>'
+			);
+
+			listStyleCommand.execute( { type: 'circle' } );
+
+			expect( getData( model ) ).to.equal(
+				'<paragraph>Foo.</paragraph>' +
+				'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.[]</listItem>' +
+				'<listItem listIndent="0" listStyle="circle" listType="bulleted">2.</listItem>' +
+				'<listItem listIndent="0" listStyle="circle" listType="bulleted">3.</listItem>'
+			);
+		} );
+
+		it( 'should stop searching for the list items when spotted listItem with different listType attribute', () => {
+			setData( model,
+				'<paragraph>Foo.</paragraph>' +
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">1.[]</listItem>' +
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">2.</listItem>' +
+				'<listItem listIndent="0" listStyle="default" listType="numbered">1.</listItem>'
+			);
+
+			listStyleCommand.execute( { type: 'circle' } );
+
+			expect( getData( model ) ).to.equal(
+				'<paragraph>Foo.</paragraph>' +
+				'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.[]</listItem>' +
+				'<listItem listIndent="0" listStyle="circle" listType="bulleted">2.</listItem>' +
+				'<listItem listIndent="0" listStyle="default" listType="numbered">1.</listItem>'
+			);
+		} );
+
+		it( 'should stop searching for the list items when spotted listItem with different listStyle attribute', () => {
+			setData( model,
+				'<paragraph>Foo.</paragraph>' +
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">1.[]</listItem>' +
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">2.</listItem>' +
+				'<listItem listIndent="0" listStyle="disc" listType="bulleted">1.</listItem>'
+			);
+
+			listStyleCommand.execute( { type: 'circle' } );
+
+			expect( getData( model ) ).to.equal(
+				'<paragraph>Foo.</paragraph>' +
+				'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.[]</listItem>' +
+				'<listItem listIndent="0" listStyle="circle" listType="bulleted">2.</listItem>' +
+				'<listItem listIndent="0" listStyle="disc" listType="bulleted">1.</listItem>'
+			);
+		} );
+
+		it( 'should start searching for the list items from starting position (collapsed selection)', () => {
+			setData( model,
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">1.</listItem>' +
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">2.</listItem>' +
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">[3.</listItem>' +
+				'<paragraph>Foo.]</paragraph>'
+			);
+
+			listStyleCommand.execute( { type: 'circle' } );
+
+			expect( getData( model ) ).to.equal(
+				'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+				'<listItem listIndent="0" listStyle="circle" listType="bulleted">2.</listItem>' +
+				'<listItem listIndent="0" listStyle="circle" listType="bulleted">[3.</listItem>' +
+				'<paragraph>Foo.]</paragraph>'
+			);
+		} );
+
+		it( 'should start searching for the list items from ending position (collapsed selection)', () => {
+			setData( model,
+				'<paragraph>[Foo.</paragraph>' +
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">1.]</listItem>' +
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">2.</listItem>' +
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">3.</listItem>'
+			);
+
+			listStyleCommand.execute( { type: 'circle' } );
+
+			expect( getData( model ) ).to.equal(
+				'<paragraph>[Foo.</paragraph>' +
+				'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.]</listItem>' +
+				'<listItem listIndent="0" listStyle="circle" listType="bulleted">2.</listItem>' +
+				'<listItem listIndent="0" listStyle="circle" listType="bulleted">3.</listItem>'
+			);
+		} );
+
+		it( 'should use default type if not specified (no options passed)', () => {
+			setData( model,
+				'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.[]</listItem>'
+			);
+
+			listStyleCommand.execute();
+
+			expect( getData( model ) ).to.equal(
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">1.[]</listItem>'
+			);
+		} );
+
+		it( 'should use default type if not specified (passed an empty object)', () => {
+			setData( model,
+				'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.[]</listItem>'
+			);
+
+			listStyleCommand.execute( {} );
+
+			expect( getData( model ) ).to.equal(
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">1.[]</listItem>'
+			);
+		} );
+
+		it( 'should use default type if not specified (passed null as value)', () => {
+			setData( model,
+				'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.[]</listItem>'
+			);
+
+			listStyleCommand.execute( { type: null } );
+
+			expect( getData( model ) ).to.equal(
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">1.[]</listItem>'
+			);
+		} );
+
+		it( 'should not update anything if no listItem found in the selection', () => {
+			setData( model,
+				'<paragraph>[Foo.]</paragraph>' +
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">1.</listItem>'
+			);
+
+			const modelChangeStub = sinon.stub( model, 'change' ).named( 'model#change' );
+
+			listStyleCommand.execute( { type: 'circle' } );
+
+			expect( getData( model ) ).to.equal(
+				'<paragraph>[Foo.]</paragraph>' +
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">1.</listItem>'
+			);
+
+			expect( modelChangeStub.called ).to.equal( false );
+		} );
+
+		it( 'should update all items that belong to selected elements', () => {
+			// [x] = items that should be updated.
+			// All list items that belong to the same lists that selected items should be updated.
+			// "2." is the most outer list (listIndent=0)
+			// "2.1" a child list of the "2." element (listIndent=1)
+			// "2.1.1" a child list of the "2.1" element (listIndent=2)
+			//
+			// [x] ■ 1.
+			// [x] ■ [2.
+			// [x]     ○ 2.1.
+			// [x]         ▶ 2.1.1.]
+			// [x]         ▶ 2.1.2.
+			// [x]     ○ 2.2.
+			// [x] ■ 3.
+			// [ ]     ○ 3.1.
+			// [ ]         ▶ 3.1.1.
+			//
+			// "3.1" is not selected and this list should not be updated.
+			setData( model,
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">1.</listItem>' +
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">[2.</listItem>' +
+				'<listItem listIndent="1" listStyle="default" listType="bulleted">2.1.</listItem>' +
+				'<listItem listIndent="2" listStyle="default" listType="bulleted">2.1.1.]</listItem>' +
+				'<listItem listIndent="2" listStyle="default" listType="bulleted">2.1.2.</listItem>' +
+				'<listItem listIndent="1" listStyle="default" listType="bulleted">2.2.</listItem>' +
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">3.</listItem>' +
+				'<listItem listIndent="1" listStyle="default" listType="bulleted">3.1.</listItem>' +
+				'<listItem listIndent="2" listStyle="default" listType="bulleted">3.1.1.</listItem>'
+			);
+
+			listStyleCommand.execute( { type: 'disc' } );
+
+			expect( getData( model ) ).to.equal(
+				'<listItem listIndent="0" listStyle="disc" listType="bulleted">1.</listItem>' +
+				'<listItem listIndent="0" listStyle="disc" listType="bulleted">[2.</listItem>' +
+				'<listItem listIndent="1" listStyle="disc" listType="bulleted">2.1.</listItem>' +
+				'<listItem listIndent="2" listStyle="disc" listType="bulleted">2.1.1.]</listItem>' +
+				'<listItem listIndent="2" listStyle="disc" listType="bulleted">2.1.2.</listItem>' +
+				'<listItem listIndent="1" listStyle="disc" listType="bulleted">2.2.</listItem>' +
+				'<listItem listIndent="0" listStyle="disc" listType="bulleted">3.</listItem>' +
+				'<listItem listIndent="1" listStyle="default" listType="bulleted">3.1.</listItem>' +
+				'<listItem listIndent="2" listStyle="default" listType="bulleted">3.1.1.</listItem>'
+			);
+		} );
+	} );
+} );

+ 896 - 0
packages/ckeditor5-list/tests/liststyleediting.js

@@ -0,0 +1,896 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+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 UndoEditing from '@ckeditor/ckeditor5-undo/src/undoediting';
+import ListStyleEditing from '../src/liststyleediting';
+import TodoListEditing from '../src/todolistediting';
+import ListStyleCommand from '../src/liststylecommand';
+
+describe( 'ListStyleEditing', () => {
+	let editor, model, view;
+
+	beforeEach( () => {
+		return VirtualTestEditor
+			.create( {
+				plugins: [ Paragraph, ListStyleEditing, UndoEditing ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+				view = editor.editing.view;
+			} );
+	} );
+
+	afterEach( () => {
+		return editor.destroy();
+	} );
+
+	it( 'should have pluginName', () => {
+		expect( ListStyleEditing.pluginName ).to.equal( 'ListStyleEditing' );
+	} );
+
+	it( 'should be loaded', () => {
+		expect( editor.plugins.get( ListStyleEditing ) ).to.be.instanceOf( ListStyleEditing );
+	} );
+
+	describe( 'schema rules', () => {
+		it( 'should allow set `listStyle` on the `listItem`', () => {
+			expect( model.schema.checkAttribute( [ '$root', 'listItem' ], 'listStyle' ) ).to.be.true;
+		} );
+	} );
+
+	describe( 'command', () => {
+		it( 'should register listStyle command', () => {
+			const command = editor.commands.get( 'listStyle' );
+
+			expect( command ).to.be.instanceOf( ListStyleCommand );
+		} );
+	} );
+
+	describe( 'conversion in data pipeline', () => {
+		describe( 'model to data', () => {
+			it( 'should convert single list (type: bulleted)', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listType="bulleted">Foo</listItem>' +
+					'<listItem listIndent="0" listType="bulleted">Bar</listItem>'
+				);
+
+				expect( editor.getData() ).to.equal( '<ul><li>Foo</li><li>Bar</li></ul>' );
+			} );
+
+			it( 'should convert single list (type: numbered)', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listType="numbered">Foo</listItem>' +
+					'<listItem listIndent="0" listType="numbered">Bar</listItem>'
+				);
+
+				expect( editor.getData() ).to.equal( '<ol><li>Foo</li><li>Bar</li></ol>' );
+			} );
+
+			it( 'should convert single list (type: bulleted, style: default)', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listType="bulleted" listStyle="default">Foo</listItem>' +
+					'<listItem listIndent="0" listType="bulleted" listStyle="default">Bar</listItem>'
+				);
+
+				expect( editor.getData() ).to.equal( '<ul><li>Foo</li><li>Bar</li></ul>' );
+			} );
+
+			it( 'should convert single list (type: numbered, style: default)', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listType="numbered" listStyle="default">Foo</listItem>' +
+					'<listItem listIndent="0" listType="numbered" listStyle="default">Bar</listItem>'
+				);
+
+				expect( editor.getData() ).to.equal( '<ol><li>Foo</li><li>Bar</li></ol>' );
+			} );
+
+			it( 'should convert single list (type: bulleted, style: circle)', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listType="bulleted" listStyle="circle">Foo</listItem>' +
+					'<listItem listIndent="0" listType="bulleted" listStyle="circle">Bar</listItem>'
+				);
+
+				expect( editor.getData() ).to.equal( '<ul style="list-style-type:circle;"><li>Foo</li><li>Bar</li></ul>' );
+			} );
+
+			it( 'should convert single list (type: numbered, style: upper-alpha)', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listType="numbered" listStyle="upper-alpha">Foo</listItem>' +
+					'<listItem listIndent="0" listType="numbered" listStyle="upper-alpha">Bar</listItem>'
+				);
+
+				expect( editor.getData() ).to.equal( '<ol style="list-style-type:upper-alpha;"><li>Foo</li><li>Bar</li></ol>' );
+			} );
+
+			it( 'should convert nested bulleted lists (main: circle, nested: disc)', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listType="bulleted" listStyle="circle">Foo 1</listItem>' +
+					'<listItem listIndent="1" listType="bulleted" listStyle="disc">Bar 1</listItem>' +
+					'<listItem listIndent="1" listType="bulleted" listStyle="disc">Bar 2</listItem>' +
+					'<listItem listIndent="0" listType="bulleted" listStyle="circle">Foo 2</listItem>' +
+					'<listItem listIndent="0" listType="bulleted" listStyle="circle">Foo 3</listItem>'
+				);
+
+				expect( editor.getData() ).to.equal(
+					'<ul style="list-style-type:circle;">' +
+						'<li>Foo 1' +
+							'<ul style="list-style-type:disc;">' +
+								'<li>Bar 1</li>' +
+								'<li>Bar 2</li>' +
+							'</ul>' +
+						'</li>' +
+						'<li>Foo 2</li>' +
+						'<li>Foo 3</li>' +
+					'</ul>'
+				);
+			} );
+
+			it( 'should convert nested numbered lists (main: decimal-leading-zero, nested: lower-latin)', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listType="numbered" listStyle="decimal-leading-zero">Foo 1</listItem>' +
+					'<listItem listIndent="1" listType="numbered" listStyle="lower-latin">Bar 1</listItem>' +
+					'<listItem listIndent="1" listType="numbered" listStyle="lower-latin">Bar 2</listItem>' +
+					'<listItem listIndent="0" listType="numbered" listStyle="decimal-leading-zero">Foo 2</listItem>' +
+					'<listItem listIndent="0" listType="numbered" listStyle="decimal-leading-zero">Foo 3</listItem>'
+				);
+
+				expect( editor.getData() ).to.equal(
+					'<ol style="list-style-type:decimal-leading-zero;">' +
+						'<li>Foo 1' +
+							'<ol style="list-style-type:lower-latin;">' +
+								'<li>Bar 1</li>' +
+								'<li>Bar 2</li>' +
+							'</ol>' +
+						'</li>' +
+						'<li>Foo 2</li>' +
+						'<li>Foo 3</li>' +
+					'</ol>'
+				);
+			} );
+
+			it( 'should convert nested mixed lists (ul>ol, main: square, nested: lower-roman)', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listType="bulleted" listStyle="square">Foo 1</listItem>' +
+					'<listItem listIndent="1" listType="numbered" listStyle="lower-roman">Bar 1</listItem>' +
+					'<listItem listIndent="1" listType="numbered" listStyle="lower-roman">Bar 2</listItem>' +
+					'<listItem listIndent="0" listType="bulleted" listStyle="square">Foo 2</listItem>' +
+					'<listItem listIndent="0" listType="bulleted" listStyle="square">Foo 3</listItem>'
+				);
+
+				expect( editor.getData() ).to.equal(
+					'<ul style="list-style-type:square;">' +
+						'<li>Foo 1' +
+							'<ol style="list-style-type:lower-roman;">' +
+								'<li>Bar 1</li>' +
+								'<li>Bar 2</li>' +
+							'</ol>' +
+						'</li>' +
+						'<li>Foo 2</li>' +
+						'<li>Foo 3</li>' +
+					'</ul>'
+				);
+			} );
+
+			it( 'should produce nested lists (different `listIndent` attribute)', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listType="numbered" listStyle="decimal">Foo 1</listItem>' +
+					'<listItem listIndent="0" listType="numbered" listStyle="decimal">Foo 2</listItem>' +
+					'<listItem listIndent="1" listType="numbered" listStyle="decimal">Bar 1</listItem>' +
+					'<listItem listIndent="1" listType="numbered" listStyle="decimal">Bar 2</listItem>'
+				);
+
+				expect( editor.getData() ).to.equal(
+					'<ol style="list-style-type:decimal;">' +
+						'<li>Foo 1</li>' +
+						'<li>Foo 2' +
+							'<ol style="list-style-type:decimal;">' +
+								'<li>Bar 1</li>' +
+								'<li>Bar 2</li>' +
+							'</ol>' +
+						'</li>' +
+					'</ol>'
+				);
+			} );
+
+			it( 'should produce two different lists (different `listType` attribute)', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listType="numbered" listStyle="decimal">Foo 1</listItem>' +
+					'<listItem listIndent="0" listType="numbered" listStyle="decimal">Foo 2</listItem>' +
+					'<listItem listIndent="0" listType="bulleted" listStyle="disc">Bar 1</listItem>' +
+					'<listItem listIndent="0" listType="bulleted" listStyle="disc">Bar 2</listItem>'
+				);
+
+				expect( editor.getData() ).to.equal(
+					'<ol style="list-style-type:decimal;">' +
+						'<li>Foo 1</li>' +
+						'<li>Foo 2</li>' +
+					'</ol>' +
+					'<ul style="list-style-type:disc;">' +
+						'<li>Bar 1</li>' +
+						'<li>Bar 2</li>' +
+					'</ul>'
+				);
+			} );
+
+			it( 'should produce two different lists (different `listStyle` attribute)', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listType="bulleted" listStyle="disc">Foo 1</listItem>' +
+					'<listItem listIndent="0" listType="bulleted" listStyle="disc">Foo 2</listItem>' +
+					'<listItem listIndent="0" listType="bulleted" listStyle="circle">Bar 1</listItem>' +
+					'<listItem listIndent="0" listType="bulleted" listStyle="circle">Bar 2</listItem>'
+				);
+
+				expect( editor.getData() ).to.equal(
+					'<ul style="list-style-type:disc;">' +
+						'<li>Foo 1</li>' +
+						'<li>Foo 2</li>' +
+					'</ul>' +
+					'<ul style="list-style-type:circle;">' +
+						'<li>Bar 1</li>' +
+						'<li>Bar 2</li>' +
+					'</ul>'
+				);
+			} );
+
+			it( 'should not allow to set the `listStyle` attribute in to-do list item', () => {
+				setModelData( model, '<listItem listIndent="0" listType="todo">Foo</listItem>' );
+
+				const listItem = model.document.getRoot().getChild( 0 );
+
+				expect( listItem.hasAttribute( 'listItem' ) ).to.be.false;
+
+				model.change( writer => {
+					writer.setAttribute( 'listType', 'foo', listItem );
+				} );
+
+				expect( listItem.hasAttribute( 'listItem' ) ).to.be.false;
+			} );
+		} );
+
+		describe( 'view to model', () => {
+			it( 'should convert single list (type: bulleted)', () => {
+				editor.setData( '<ul><li>Foo</li><li>Bar</li></ul>' );
+
+				expect( getModelData( model, { withoutSelection: true } ) ).to.equal(
+					'<listItem listIndent="0" listStyle="default" listType="bulleted">Foo</listItem>' +
+					'<listItem listIndent="0" listStyle="default" listType="bulleted">Bar</listItem>'
+				);
+			} );
+
+			it( 'should convert single list (type: numbered)', () => {
+				editor.setData( '<ol><li>Foo</li><li>Bar</li></ol>' );
+
+				expect( getModelData( model, { withoutSelection: true } ) ).to.equal(
+					'<listItem listIndent="0" listStyle="default" listType="numbered">Foo</listItem>' +
+					'<listItem listIndent="0" listStyle="default" listType="numbered">Bar</listItem>'
+				);
+			} );
+
+			it( 'should convert single list (type: bulleted, style: circle)', () => {
+				editor.setData( '<ul style="list-style-type:circle;"><li>Foo</li><li>Bar</li></ul>' );
+
+				expect( getModelData( model, { withoutSelection: true } ) ).to.equal(
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">Foo</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">Bar</listItem>'
+				);
+			} );
+
+			it( 'should convert single list (type: numbered, style: upper-alpha)', () => {
+				editor.setData( '<ol style="list-style-type:upper-alpha;"><li>Foo</li><li>Bar</li></ol>' );
+
+				expect( getModelData( model, { withoutSelection: true } ) ).to.equal(
+					'<listItem listIndent="0" listStyle="upper-alpha" listType="numbered">Foo</listItem>' +
+					'<listItem listIndent="0" listStyle="upper-alpha" listType="numbered">Bar</listItem>'
+				);
+			} );
+
+			it( 'should convert nested and mixed lists', () => {
+				editor.setData(
+					'<ol style="list-style-type:upper-alpha;">' +
+						'<li>OL 1</li>' +
+						'<li>OL 2' +
+							'<ul style="list-style-type:circle;">' +
+								'<li>UL 1</li>' +
+								'<li>UL 2</li>' +
+							'</ul>' +
+						'</li>' +
+						'<li>OL 3</li>' +
+					'</ol>'
+				);
+
+				expect( getModelData( model, { withoutSelection: true } ) ).to.equal(
+					'<listItem listIndent="0" listStyle="upper-alpha" listType="numbered">OL 1</listItem>' +
+					'<listItem listIndent="0" listStyle="upper-alpha" listType="numbered">OL 2</listItem>' +
+					'<listItem listIndent="1" listStyle="circle" listType="bulleted">UL 1</listItem>' +
+					'<listItem listIndent="1" listStyle="circle" listType="bulleted">UL 2</listItem>' +
+					'<listItem listIndent="0" listStyle="upper-alpha" listType="numbered">OL 3</listItem>'
+				);
+			} );
+
+			it( 'should convert when the list is in the middle of the content', () => {
+				editor.setData(
+					'<p>Paragraph.</p>' +
+					'<ol style="list-style-type:upper-alpha;">' +
+						'<li>Foo</li>' +
+						'<li>Bar</li>' +
+					'</ol>' +
+					'<p>Paragraph.</p>'
+				);
+
+				expect( getModelData( model, { withoutSelection: true } ) ).to.equal(
+					'<paragraph>Paragraph.</paragraph>' +
+					'<listItem listIndent="0" listStyle="upper-alpha" listType="numbered">Foo</listItem>' +
+					'<listItem listIndent="0" listStyle="upper-alpha" listType="numbered">Bar</listItem>' +
+					'<paragraph>Paragraph.</paragraph>'
+				);
+			} );
+		} );
+	} );
+
+	// At this moment editing and data pipelines produce exactly the same content.
+	// Just a few tests will be enough here. `model to data` block contains all cases checked.
+	describe( 'conversion in editing pipeline', () => {
+		describe( 'model to view', () => {
+			it( 'should convert single list (type: bulleted, style: default)', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listType="bulleted" listStyle="default">Foo</listItem>' +
+					'<listItem listIndent="0" listType="bulleted" listStyle="default">Bar</listItem>'
+				);
+
+				expect( getViewData( view, { withoutSelection: true } ) ).to.equal(
+					'<ul><li>Foo</li><li>Bar</li></ul>'
+				);
+			} );
+
+			it( 'should convert single list (type: bulleted, style: circle)', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listType="bulleted" listStyle="circle">Foo</listItem>' +
+					'<listItem listIndent="0" listType="bulleted" listStyle="circle">Bar</listItem>'
+				);
+
+				expect( getViewData( view, { withoutSelection: true } ) ).to.equal(
+					'<ul style="list-style-type:circle"><li>Foo</li><li>Bar</li></ul>'
+				);
+			} );
+
+			it( 'should convert nested bulleted lists (main: circle, nested: disc)', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listType="bulleted" listStyle="circle">Foo 1</listItem>' +
+					'<listItem listIndent="1" listType="bulleted" listStyle="disc">Bar 1</listItem>' +
+					'<listItem listIndent="1" listType="bulleted" listStyle="disc">Bar 2</listItem>' +
+					'<listItem listIndent="0" listType="bulleted" listStyle="circle">Foo 2</listItem>' +
+					'<listItem listIndent="0" listType="bulleted" listStyle="circle">Foo 3</listItem>'
+				);
+
+				expect( getViewData( view, { withoutSelection: true } ) ).to.equal(
+					'<ul style="list-style-type:circle">' +
+						'<li>Foo 1' +
+							'<ul style="list-style-type:disc">' +
+								'<li>Bar 1</li>' +
+								'<li>Bar 2</li>' +
+							'</ul>' +
+						'</li>' +
+						'<li>Foo 2</li>' +
+						'<li>Foo 3</li>' +
+					'</ul>'
+				);
+			} );
+		} );
+	} );
+
+	describe( 'integrations', () => {
+		describe( 'merging a list into a styled list', () => {
+			it( 'should inherit the list style attribute when merging the same kind of lists (from top, merge a single item)', () => {
+				setModelData( model,
+					'<paragraph>Foo Bar.[]</paragraph>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">Foo</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">Bar</listItem>'
+				);
+
+				editor.execute( 'bulletedList' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">Foo Bar.[]</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">Foo</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">Bar</listItem>'
+				);
+			} );
+
+			it( 'should inherit the list style attribute when merging the same kind of lists (from top, merge a few items)', () => {
+				setModelData( model,
+					'<paragraph>[Foo Bar 1.</paragraph>' +
+					'<paragraph>Foo Bar 2.]</paragraph>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">Foo</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">Bar</listItem>'
+				);
+
+				editor.execute( 'bulletedList' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">[Foo Bar 1.</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">Foo Bar 2.]</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">Foo</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">Bar</listItem>'
+				);
+			} );
+
+			it( 'should not inherit anything if there is no list below the inserted list', () => {
+				setModelData( model,
+					'<paragraph>Foo Bar 1.[]</paragraph>' +
+					'<paragraph>Foo Bar 2.</paragraph>'
+				);
+
+				editor.execute( 'bulletedList' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<listItem listIndent="0" listStyle="default" listType="bulleted">Foo Bar 1.[]</listItem>' +
+					'<paragraph>Foo Bar 2.</paragraph>'
+				);
+			} );
+
+			it( 'should not inherit anything if replacing the entire content with a list', () => {
+				setModelData( model,
+					'<paragraph>Foo Bar 1.[]</paragraph>'
+				);
+
+				editor.execute( 'bulletedList' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<listItem listIndent="0" listStyle="default" listType="bulleted">Foo Bar 1.[]</listItem>'
+				);
+			} );
+
+			it( 'should not inherit the list style attribute when merging different kind of lists (from top, merge a single item)', () => {
+				setModelData( model,
+					'<paragraph>Foo Bar.[]</paragraph>' +
+					'<listItem listIndent="0" listStyle="default"  listType="numbered">Foo</listItem>' +
+					'<listItem listIndent="0" listStyle="default"  listType="numbered">Bar</listItem>'
+				);
+
+				editor.execute( 'bulletedList' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<listItem listIndent="0" listStyle="default" listType="bulleted">Foo Bar.[]</listItem>' +
+					'<listItem listIndent="0" listStyle="default" listType="numbered">Foo</listItem>' +
+					'<listItem listIndent="0" listStyle="default" listType="numbered">Bar</listItem>'
+				);
+			} );
+
+			it( 'should inherit the list style attribute when merging the same kind of lists (from bottom, merge a single item)', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">Foo</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">Bar</listItem>' +
+					'<paragraph>Foo Bar.[]</paragraph>'
+				);
+
+				editor.execute( 'bulletedList' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">Foo</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">Bar</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">Foo Bar.[]</listItem>'
+				);
+			} );
+
+			it(
+				'should inherit the list style attribute from listIndent=0 element when merging the same kind of lists (from bottom)',
+				() => {
+					setModelData( model,
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">Foo</listItem>' +
+						'<listItem listIndent="1" listStyle="square" listType="bulleted">Bar</listItem>' +
+						'<listItem listIndent="2" listStyle="disc" listType="bulleted">Foo Bar</listItem>' +
+						'<paragraph>Foo Bar.[]</paragraph>'
+					);
+
+					editor.execute( 'bulletedList' );
+
+					expect( getModelData( model ) ).to.equal(
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">Foo</listItem>' +
+						'<listItem listIndent="1" listStyle="square" listType="bulleted">Bar</listItem>' +
+						'<listItem listIndent="2" listStyle="disc" listType="bulleted">Foo Bar</listItem>' +
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">Foo Bar.[]</listItem>'
+					);
+				}
+			);
+		} );
+
+		describe( 'indenting lists', () => {
+			it( 'should restore the default value for the list style attribute when indenting a single item', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="1" listStyle="default" listType="bulleted">1A.</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">2B.</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">2.[]</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">3.</listItem>'
+				);
+
+				editor.execute( 'indentList' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="1" listStyle="default" listType="bulleted">1A.</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">2B.</listItem>' +
+					'<listItem listIndent="1" listStyle="default" listType="bulleted">2.[]</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">3.</listItem>'
+				);
+			} );
+
+			it( 'should restore the default value for the list style attribute when indenting a few items', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">[2.</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">3.]</listItem>'
+				);
+
+				editor.execute( 'indentList' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="1" listStyle="default" listType="bulleted">[2.</listItem>' +
+					'<listItem listIndent="1" listStyle="default" listType="bulleted">3.]</listItem>'
+				);
+			} );
+
+			it(
+				'should copy the value for the list style attribute when indenting a single item into a nested list (default value)',
+				() => {
+					setModelData( model,
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+						'<listItem listIndent="1" listStyle="default" listType="bulleted">2.</listItem>' +
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">3.[]</listItem>' +
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">4.</listItem>'
+					);
+
+					editor.execute( 'indentList' );
+
+					expect( getModelData( model ) ).to.equal(
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+						'<listItem listIndent="1" listStyle="default" listType="bulleted">2.</listItem>' +
+						'<listItem listIndent="1" listStyle="default" listType="bulleted">3.[]</listItem>' +
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">4.</listItem>'
+					);
+				}
+			);
+
+			it(
+				'should copy the value for the list style attribute when indenting a single item into a nested list (changed value)',
+				() => {
+					setModelData( model,
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+						'<listItem listIndent="1" listStyle="disc" listType="bulleted">2.</listItem>' +
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">3.[]</listItem>' +
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">4.</listItem>'
+					);
+
+					editor.execute( 'indentList' );
+
+					expect( getModelData( model ) ).to.equal(
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+						'<listItem listIndent="1" listStyle="disc" listType="bulleted">2.</listItem>' +
+						'<listItem listIndent="1" listStyle="disc" listType="bulleted">3.[]</listItem>' +
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">4.</listItem>'
+					);
+				}
+			);
+
+			it( 'should copy the value for the list style attribute when indenting a single item into a nested list', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">2.[]</listItem>' +
+					'<listItem listIndent="1" listStyle="square" listType="bulleted">3.</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">4.</listItem>'
+				);
+
+				editor.execute( 'indentList' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="1" listStyle="default" listType="bulleted">2.[]</listItem>' +
+					'<listItem listIndent="2" listStyle="square" listType="bulleted">3.</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">4.</listItem>'
+				);
+			} );
+
+			it(
+				'should copy the value for the list style attribute when indenting a single item into a nested list ' +
+				'(many nested lists check)',
+				() => {
+					setModelData( model,
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+						'<listItem listIndent="1" listStyle="disc" listType="bulleted">2.</listItem>' +
+						'<listItem listIndent="2" listStyle="square" listType="bulleted">3.</listItem>' +
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">4.[]</listItem>'
+					);
+
+					editor.execute( 'indentList' );
+
+					expect( getModelData( model ) ).to.equal(
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+						'<listItem listIndent="1" listStyle="disc" listType="bulleted">2.</listItem>' +
+						'<listItem listIndent="2" listStyle="square" listType="bulleted">3.</listItem>' +
+						'<listItem listIndent="1" listStyle="disc" listType="bulleted">4.[]</listItem>'
+					);
+				}
+			);
+
+			it( 'should inherit the list style attribute from nested list if the `listType` is other than indenting element', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="1" listStyle="decimal" listType="numbered">2.</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">3.[]</listItem>'
+				);
+
+				editor.execute( 'indentList' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="1" listStyle="decimal" listType="numbered">2.</listItem>' +
+					'<listItem listIndent="1" listStyle="decimal" listType="numbered">3.[]</listItem>'
+				);
+			} );
+		} );
+
+		describe( 'outdenting lists', () => {
+			it( 'should inherit the list style attribute from parent list (change the first nested item)', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="1" listStyle="default" listType="bulleted">2.[]</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">3.</listItem>'
+				);
+
+				editor.execute( 'outdentList' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">2.[]</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">3.</listItem>'
+				);
+			} );
+
+			it( 'should inherit the list style attribute from parent list (change the second nested item)', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="1" listStyle="default" listType="bulleted">2.</listItem>' +
+					'<listItem listIndent="1" listStyle="default" listType="bulleted">3.[]</listItem>'
+				);
+
+				editor.execute( 'outdentList' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="1" listStyle="default" listType="bulleted">2.</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">3.[]</listItem>'
+				);
+			} );
+
+			it( 'should inherit the list style attribute from parent list (modifying nested lists)', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="1" listStyle="default" listType="bulleted">[2.</listItem>' +
+					'<listItem listIndent="2" listStyle="square" listType="bulleted">3.]</listItem>'
+				);
+
+				editor.execute( 'outdentList' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">[2.</listItem>' +
+					'<listItem listIndent="1" listStyle="square" listType="bulleted">3.]</listItem>'
+				);
+			} );
+
+			it(
+				'should inherit the list style attribute from parent list (outdenting many items, including the first one in the list)',
+				() => {
+					setModelData( model,
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">[1.</listItem>' +
+						'<listItem listIndent="1" listStyle="default" listType="bulleted">2.</listItem>' +
+						'<listItem listIndent="2" listStyle="square" listType="bulleted">3.]</listItem>' +
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">4.</listItem>'
+					);
+
+					editor.execute( 'outdentList' );
+
+					expect( getModelData( model ) ).to.equal(
+						'<paragraph>[1.</paragraph>' +
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">2.</listItem>' +
+						'<listItem listIndent="1" listStyle="square" listType="bulleted">3.]</listItem>' +
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">4.</listItem>'
+					);
+				}
+			);
+
+			it(
+				'should inherit the list style attribute from parent list (outdenting the first item that is a parent for next list)',
+				() => {
+					setModelData( model,
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.[]</listItem>' +
+						'<listItem listIndent="1" listStyle="default" listType="bulleted">2.</listItem>' +
+						'<listItem listIndent="2" listStyle="square" listType="bulleted">3.</listItem>' +
+						'<listItem listIndent="3" listStyle="disc" listType="bulleted">4.</listItem>' +
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">5.</listItem>'
+					);
+
+					editor.execute( 'outdentList' );
+
+					expect( getModelData( model ) ).to.equal(
+						'<paragraph>1.[]</paragraph>' +
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">2.</listItem>' +
+						'<listItem listIndent="1" listStyle="square" listType="bulleted">3.</listItem>' +
+						'<listItem listIndent="2" listStyle="disc" listType="bulleted">4.</listItem>' +
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">5.</listItem>'
+					);
+				}
+			);
+
+			it( 'should not inherit the list style if outdented the only one item in the list', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.[]</listItem>' +
+					'<listItem listIndent="1" listStyle="disc" listType="bulleted">2.</listItem>' +
+					'<listItem listIndent="2" listStyle="square" listType="bulleted">3.</listItem>'
+				);
+
+				editor.execute( 'outdentList' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<paragraph>1.[]</paragraph>' +
+					'<listItem listIndent="0" listStyle="disc" listType="bulleted">2.</listItem>' +
+					'<listItem listIndent="1" listStyle="square" listType="bulleted">3.</listItem>'
+				);
+			} );
+
+			it( 'should not inherit the list style if outdented the only one item in the list (a paragraph below the list)', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.[]</listItem>' +
+					'<listItem listIndent="1" listStyle="disc" listType="bulleted">2.</listItem>' +
+					'<listItem listIndent="2" listStyle="square" listType="bulleted">3.</listItem>' +
+					'<paragraph>Foo</paragraph>'
+				);
+
+				editor.execute( 'outdentList' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<paragraph>1.[]</paragraph>' +
+					'<listItem listIndent="0" listStyle="disc" listType="bulleted">2.</listItem>' +
+					'<listItem listIndent="1" listStyle="square" listType="bulleted">3.</listItem>' +
+					'<paragraph>Foo</paragraph>'
+				);
+			} );
+
+			it( 'should not inherit the list style attribute from parent list if the `listType` is other than outdenting element', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="1" listStyle="decimal" listType="numbered">2.[]</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">3.</listItem>'
+				);
+
+				editor.execute( 'outdentList' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="0" listStyle="decimal" listType="numbered">2.[]</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">3.</listItem>'
+				);
+			} );
+
+			it( 'should not do anything if there is no list after outdenting', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.[]</listItem>'
+				);
+
+				editor.execute( 'outdentList' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<paragraph>1.[]</paragraph>'
+				);
+			} );
+		} );
+
+		describe( 'indent/outdent + undo', () => {
+			it( 'should use the same batch for indenting a list and updating `listType` attribute', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="1" listStyle="default" listType="bulleted">1A.</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">2B.</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">2.[]</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">3.</listItem>'
+				);
+
+				editor.execute( 'indentList' );
+				editor.execute( 'undo' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="1" listStyle="default" listType="bulleted">1A.</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">2B.</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">2.[]</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">3.</listItem>'
+				);
+			} );
+
+			it( 'should use the same batch for outdenting a list and updating `listType` attribute', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="1" listStyle="default" listType="bulleted">2.[]</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">3.</listItem>'
+				);
+
+				editor.execute( 'outdentList' );
+				editor.execute( 'undo' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="1" listStyle="default" listType="bulleted">2.[]</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">3.</listItem>'
+				);
+			} );
+		} );
+
+		describe( 'todo list', () => {
+			let editor, model;
+
+			beforeEach( () => {
+				return VirtualTestEditor
+					.create( {
+						// TodoListEditing is at the end by design. Check `ListStyleEditing.afterInit()` call.
+						plugins: [ Paragraph, ListStyleEditing, TodoListEditing ]
+					} )
+					.then( newEditor => {
+						editor = newEditor;
+						model = editor.model;
+					} );
+			} );
+
+			afterEach( () => {
+				return editor.destroy();
+			} );
+
+			it( 'should not add the `listStyle` attribute while creating a todo list', () => {
+				setModelData( model, '<paragraph>Foo[]</paragraph>' );
+
+				editor.execute( 'todoList' );
+
+				expect( getModelData( model ), '<listItem listIndent="0" listType="todo">Foo[]</listItem>' );
+			} );
+
+			it( 'should not add the `listStyle` attribute while switching the list type', () => {
+				setModelData( model, '<listItem listIndent="0" listType="bulleted">Foo[]</listItem>' );
+
+				editor.execute( 'todoList' );
+
+				expect( getModelData( model ), '<listItem listIndent="0" listType="todo">Foo[]</listItem>' );
+			} );
+
+			it( 'should remove the `listStyle` attribute while switching the list type that uses the list style feature', () => {
+				setModelData( model, '<listItem listIndent="0" listType="bulleted" listStyle="circle">Foo[]</listItem>' );
+
+				editor.execute( 'todoList' );
+
+				expect( getModelData( model ), '<listItem listIndent="0" listType="todo">Foo[]</listItem>' );
+			} );
+
+			it( 'should  not inherit the list style attribute when inserting a todo list item', () => {
+				setModelData( model,
+					'<paragraph>Foo Bar.[]</paragraph>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">Foo</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">Bar</listItem>'
+				);
+
+				editor.execute( 'todoList' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<listItem listIndent="0" listType="todo">Foo Bar.[]</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">Foo</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">Bar</listItem>'
+				);
+			} );
+		} );
+	} );
+} );

+ 480 - 0
packages/ckeditor5-list/tests/liststyleui.js

@@ -0,0 +1,480 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals document */
+
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import BlockQuote from '@ckeditor/ckeditor5-block-quote/src/blockquote';
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import ListStyle from '../src/liststyle';
+import ListStyleUI from '../src/liststyleui';
+import DropdownView from '@ckeditor/ckeditor5-ui/src/dropdown/dropdownview';
+import UndoEditing from '@ckeditor/ckeditor5-undo/src/undoediting';
+
+import bulletedListIcon from '../theme/icons/bulletedlist.svg';
+import numberedListIcon from '../theme/icons/numberedlist.svg';
+
+import listStyleDiscIcon from '../theme/icons/liststyledisc.svg';
+import listStyleCircleIcon from '../theme/icons/liststylecircle.svg';
+import listStyleSquareIcon from '../theme/icons/liststylesquare.svg';
+import listStyleDecimalIcon from '../theme/icons/liststyledecimal.svg';
+import listStyleDecimalWithLeadingZeroIcon from '../theme/icons/liststyledecimalleadingzero.svg';
+import listStyleLowerRomanIcon from '../theme/icons/liststylelowerroman.svg';
+import listStyleUpperRomanIcon from '../theme/icons/liststyleupperroman.svg';
+import listStyleLowerLatinIcon from '../theme/icons/liststylelowerlatin.svg';
+import listStyleUpperLatinIcon from '../theme/icons/liststyleupperlatin.svg';
+
+describe( 'ListStyleUI', () => {
+	let editorElement, editor, model, listStyleCommand;
+
+	beforeEach( () => {
+		editorElement = document.createElement( 'div' );
+		document.body.appendChild( editorElement );
+
+		return ClassicTestEditor.create( editorElement, { plugins: [ Paragraph, BlockQuote, ListStyle, UndoEditing ] } )
+			.then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+
+				listStyleCommand = editor.commands.get( 'listStyle' );
+			} );
+	} );
+
+	afterEach( () => {
+		editorElement.remove();
+
+		return editor.destroy();
+	} );
+
+	it( 'should be named', () => {
+		expect( ListStyleUI.pluginName ).to.equal( 'ListStyleUI' );
+	} );
+
+	it( 'should be loaded', () => {
+		expect( editor.plugins.get( ListStyleUI ) ).to.be.instanceOf( ListStyleUI );
+	} );
+
+	describe( 'init()', () => {
+		describe( 'bulleted list dropdown', () => {
+			let bulletedListCommand, bulletedListDropdown;
+
+			beforeEach( () => {
+				bulletedListCommand = editor.commands.get( 'bulletedList' );
+				bulletedListDropdown = editor.ui.componentFactory.create( 'bulletedList' );
+			} );
+
+			it( 'should registered as "bulletedList" in the component factory', () => {
+				expect( bulletedListDropdown ).to.be.instanceOf( DropdownView );
+			} );
+
+			it( 'should have #isEnabled bound to the "bulletedList" command state', () => {
+				expect( bulletedListDropdown.isEnabled ).to.be.true;
+
+				bulletedListCommand.isEnabled = true;
+				expect( bulletedListDropdown.isEnabled ).to.be.true;
+
+				bulletedListCommand.isEnabled = false;
+				expect( bulletedListDropdown.isEnabled ).to.be.false;
+			} );
+
+			it( 'should have a specific CSS class', () => {
+				expect( bulletedListDropdown.class ).to.equal( 'ck-list-styles-dropdown' );
+			} );
+
+			describe( 'main split button', () => {
+				let mainButtonView;
+
+				beforeEach( () => {
+					mainButtonView = bulletedListDropdown.buttonView;
+				} );
+
+				it( 'should have a #label', () => {
+					expect( mainButtonView.label ).to.equal( 'Bulleted List' );
+				} );
+
+				it( 'should have an #icon', () => {
+					expect( mainButtonView.icon ).to.equal( bulletedListIcon );
+				} );
+
+				it( 'should have a #tooltip based on a label', () => {
+					expect( mainButtonView.tooltip ).to.be.true;
+				} );
+
+				it( 'should be toggleable', () => {
+					expect( mainButtonView.isToggleable ).to.be.true;
+				} );
+
+				it( 'should have the #isOn state bound to the value of the "bulletedList" command', () => {
+					expect( mainButtonView.isOn ).to.be.false;
+
+					bulletedListCommand.value = 'foo';
+					expect( mainButtonView.isOn ).to.be.true;
+
+					bulletedListCommand.value = null;
+					expect( mainButtonView.isOn ).to.be.false;
+				} );
+
+				it( 'should execute the "bulletedList" command and focus the editing view when clicked', () => {
+					sinon.spy( editor, 'execute' );
+					sinon.spy( editor.editing.view, 'focus' );
+
+					mainButtonView.fire( 'execute' );
+					sinon.assert.calledWithExactly( editor.execute, 'bulletedList' );
+					sinon.assert.calledOnce( editor.editing.view.focus );
+					sinon.assert.callOrder( editor.execute, editor.editing.view.focus );
+				} );
+			} );
+
+			describe( 'toolbar with style buttons', () => {
+				let toolbarView;
+
+				beforeEach( () => {
+					toolbarView = bulletedListDropdown.toolbarView;
+				} );
+
+				it( 'should be in the dropdown panel', () => {
+					expect( bulletedListDropdown.panelView.children.get( 0 ) ).to.equal( toolbarView );
+				} );
+
+				it( 'should have a proper ARIA label', () => {
+					expect( toolbarView.ariaLabel ).to.equal( 'Bulleted list styles toolbar' );
+				} );
+
+				it( 'should bring the "disc" list style button', () => {
+					const buttonView = toolbarView.items.get( 0 );
+
+					expect( buttonView.label ).to.equal( 'Toggle the disc list style' );
+					expect( buttonView.tooltip ).to.equal( 'Disc' );
+					expect( buttonView.icon ).to.equal( listStyleDiscIcon );
+				} );
+
+				it( 'should bring the "circle" list style button', () => {
+					const buttonView = toolbarView.items.get( 1 );
+
+					expect( buttonView.label ).to.equal( 'Toggle the circle list style' );
+					expect( buttonView.tooltip ).to.equal( 'Circle' );
+					expect( buttonView.icon ).to.equal( listStyleCircleIcon );
+				} );
+
+				it( 'should bring the "square" list style button', () => {
+					const buttonView = toolbarView.items.get( 2 );
+
+					expect( buttonView.label ).to.equal( 'Toggle the square list style' );
+					expect( buttonView.tooltip ).to.equal( 'Square' );
+					expect( buttonView.icon ).to.equal( listStyleSquareIcon );
+				} );
+
+				describe( 'style button', () => {
+					let styleButtonView;
+
+					beforeEach( () => {
+						// "circle"
+						styleButtonView = toolbarView.items.get( 1 );
+
+						sinon.spy( editor, 'execute' );
+						sinon.spy( editor.editing.view, 'focus' );
+					} );
+
+					it( 'should be instances of ButtonView', () => {
+						expect( styleButtonView ).to.be.instanceOf( ButtonView );
+					} );
+
+					it( 'should change its #isOn state when the value of the "listStyleCommand" command changes', () => {
+						expect( styleButtonView.isOn ).to.be.false;
+
+						listStyleCommand.value = 'foo';
+						expect( styleButtonView.isOn ).to.be.false;
+
+						listStyleCommand.value = 'circle';
+						expect( styleButtonView.isOn ).to.be.true;
+
+						listStyleCommand.value = null;
+						expect( styleButtonView.isOn ).to.be.false;
+					} );
+
+					it( 'should apply the new style if none was set', () => {
+						setData( model, '<listItem listType="bulleted" listIndent="0">[]foo</listItem>' );
+
+						styleButtonView.fire( 'execute' );
+
+						sinon.assert.calledWithExactly( editor.execute, 'listStyle', { type: 'circle' } );
+						sinon.assert.calledOnce( editor.editing.view.focus );
+						sinon.assert.callOrder( editor.execute, editor.editing.view.focus );
+					} );
+
+					it( 'should apply the new style if a different one was set', () => {
+						setData( model, '<listItem listType="bulleted" listStyle="square" listIndent="0">[]foo</listItem>' );
+
+						styleButtonView.fire( 'execute' );
+
+						sinon.assert.calledWithExactly( editor.execute, 'listStyle', { type: 'circle' } );
+						sinon.assert.calledOnce( editor.editing.view.focus );
+						sinon.assert.callOrder( editor.execute, editor.editing.view.focus );
+					} );
+
+					it( 'should remove (toggle) the style if the same style was set', () => {
+						setData( model, '<listItem listType="bulleted" listStyle="circle" listIndent="0">[]foo</listItem>' );
+
+						styleButtonView.fire( 'execute' );
+
+						sinon.assert.calledWithExactly( editor.execute, 'listStyle', { type: 'default' } );
+						sinon.assert.calledOnce( editor.editing.view.focus );
+						sinon.assert.callOrder( editor.execute, editor.editing.view.focus );
+					} );
+
+					it( 'should execute the "bulletedList" command and apply the style if selection was not anchored in a list', () => {
+						setData( model, '<paragraph>foo[]</paragraph>' );
+
+						styleButtonView.fire( 'execute' );
+
+						sinon.assert.calledWithExactly( editor.execute, 'bulletedList' );
+						sinon.assert.calledWithExactly( editor.execute, 'listStyle', { type: 'circle' } );
+						sinon.assert.calledOnce( editor.editing.view.focus );
+						sinon.assert.callOrder( editor.execute, editor.editing.view.focus );
+					} );
+
+					it( 'should create the single undo step while selection was not anchored in a list', () => {
+						setData( model, '<paragraph>foo[]</paragraph>' );
+
+						styleButtonView.fire( 'execute' );
+
+						expect( getData( model ) ).to.equal(
+							'<listItem listIndent="0" listStyle="circle" listType="bulleted">foo[]</listItem>'
+						);
+
+						editor.execute( 'undo' );
+
+						expect( getData( model ) ).to.equal(
+							'<paragraph>foo[]</paragraph>'
+						);
+					} );
+				} );
+			} );
+		} );
+
+		describe( 'numbered list dropdown', () => {
+			let numberedListCommand, numberedListDropdown;
+
+			beforeEach( () => {
+				numberedListCommand = editor.commands.get( 'numberedList' );
+				numberedListDropdown = editor.ui.componentFactory.create( 'numberedList' );
+			} );
+
+			it( 'should registered as "numberedList" in the component factory', () => {
+				expect( numberedListDropdown ).to.be.instanceOf( DropdownView );
+			} );
+
+			it( 'should have #isEnabled bound to the "numberedList" command state', () => {
+				expect( numberedListDropdown.isEnabled ).to.be.true;
+
+				numberedListCommand.isEnabled = true;
+				expect( numberedListDropdown.isEnabled ).to.be.true;
+
+				numberedListCommand.isEnabled = false;
+				expect( numberedListDropdown.isEnabled ).to.be.false;
+			} );
+
+			it( 'should have a specific CSS class', () => {
+				expect( numberedListDropdown.class ).to.equal( 'ck-list-styles-dropdown' );
+			} );
+
+			describe( 'main split button', () => {
+				let mainButtonView;
+
+				beforeEach( () => {
+					mainButtonView = numberedListDropdown.buttonView;
+				} );
+
+				it( 'should have a #label', () => {
+					expect( mainButtonView.label ).to.equal( 'Numbered List' );
+				} );
+
+				it( 'should have an #icon', () => {
+					expect( mainButtonView.icon ).to.equal( numberedListIcon );
+				} );
+
+				it( 'should have a #tooltip based on a label', () => {
+					expect( mainButtonView.tooltip ).to.be.true;
+				} );
+
+				it( 'should be toggleable', () => {
+					expect( mainButtonView.isToggleable ).to.be.true;
+				} );
+
+				it( 'should have the #isOn state bound to the value of the "numberedList" command', () => {
+					expect( mainButtonView.isOn ).to.be.false;
+
+					numberedListCommand.value = 'foo';
+					expect( mainButtonView.isOn ).to.be.true;
+
+					numberedListCommand.value = null;
+					expect( mainButtonView.isOn ).to.be.false;
+				} );
+
+				it( 'should execute the "numberedList" command and focus the editing view when clicked', () => {
+					sinon.spy( editor, 'execute' );
+					sinon.spy( editor.editing.view, 'focus' );
+
+					mainButtonView.fire( 'execute' );
+					sinon.assert.calledWithExactly( editor.execute, 'numberedList' );
+					sinon.assert.calledOnce( editor.editing.view.focus );
+					sinon.assert.callOrder( editor.execute, editor.editing.view.focus );
+				} );
+			} );
+
+			describe( 'toolbar with style buttons', () => {
+				let toolbarView;
+
+				beforeEach( () => {
+					toolbarView = numberedListDropdown.toolbarView;
+				} );
+
+				it( 'should be in the dropdown panel', () => {
+					expect( numberedListDropdown.panelView.children.get( 0 ) ).to.equal( toolbarView );
+				} );
+
+				it( 'should have a proper ARIA label', () => {
+					expect( toolbarView.ariaLabel ).to.equal( 'Numbered list styles toolbar' );
+				} );
+
+				it( 'should bring the "decimal" list style button', () => {
+					const buttonView = toolbarView.items.get( 0 );
+
+					expect( buttonView.label ).to.equal( 'Toggle the decimal list style' );
+					expect( buttonView.tooltip ).to.equal( 'Decimal' );
+					expect( buttonView.icon ).to.equal( listStyleDecimalIcon );
+				} );
+
+				it( 'should bring the "decimal-leading-zero" list style button', () => {
+					const buttonView = toolbarView.items.get( 1 );
+
+					expect( buttonView.label ).to.equal( 'Toggle the decimal with leading zero list style' );
+					expect( buttonView.tooltip ).to.equal( 'Decimal with leading zero' );
+					expect( buttonView.icon ).to.equal( listStyleDecimalWithLeadingZeroIcon );
+				} );
+
+				it( 'should bring the "lower-roman" list style button', () => {
+					const buttonView = toolbarView.items.get( 2 );
+
+					expect( buttonView.label ).to.equal( 'Toggle the lower–roman list style' );
+					expect( buttonView.tooltip ).to.equal( 'Lower–roman' );
+					expect( buttonView.icon ).to.equal( listStyleLowerRomanIcon );
+				} );
+
+				it( 'should bring the "upper-roman" list style button', () => {
+					const buttonView = toolbarView.items.get( 3 );
+
+					expect( buttonView.label ).to.equal( 'Toggle the upper–roman list style' );
+					expect( buttonView.tooltip ).to.equal( 'Upper-roman' );
+					expect( buttonView.icon ).to.equal( listStyleUpperRomanIcon );
+				} );
+
+				it( 'should bring the "lower–latin" list style button', () => {
+					const buttonView = toolbarView.items.get( 4 );
+
+					expect( buttonView.label ).to.equal( 'Toggle the lower–latin list style' );
+					expect( buttonView.tooltip ).to.equal( 'Lower-latin' );
+					expect( buttonView.icon ).to.equal( listStyleLowerLatinIcon );
+				} );
+
+				it( 'should bring the "upper–latin" list style button', () => {
+					const buttonView = toolbarView.items.get( 5 );
+
+					expect( buttonView.label ).to.equal( 'Toggle the upper–latin list style' );
+					expect( buttonView.tooltip ).to.equal( 'Upper-latin' );
+					expect( buttonView.icon ).to.equal( listStyleUpperLatinIcon );
+				} );
+
+				describe( 'style button', () => {
+					let styleButtonView;
+
+					beforeEach( () => {
+						// "decimal-leading-zero""
+						styleButtonView = toolbarView.items.get( 1 );
+
+						sinon.spy( editor, 'execute' );
+						sinon.spy( editor.editing.view, 'focus' );
+					} );
+
+					it( 'should be instances of ButtonView', () => {
+						expect( styleButtonView ).to.be.instanceOf( ButtonView );
+					} );
+
+					it( 'should change its #isOn state when the value of the "listStyleCommand" command changes', () => {
+						expect( styleButtonView.isOn ).to.be.false;
+
+						listStyleCommand.value = 'foo';
+						expect( styleButtonView.isOn ).to.be.false;
+
+						listStyleCommand.value = 'decimal-leading-zero';
+						expect( styleButtonView.isOn ).to.be.true;
+
+						listStyleCommand.value = null;
+						expect( styleButtonView.isOn ).to.be.false;
+					} );
+
+					it( 'should apply the new style if none was set', () => {
+						setData( model, '<listItem listType="numbered" listIndent="0">[]foo</listItem>' );
+
+						styleButtonView.fire( 'execute' );
+
+						sinon.assert.calledWithExactly( editor.execute, 'listStyle', { type: 'decimal-leading-zero' } );
+						sinon.assert.calledOnce( editor.editing.view.focus );
+						sinon.assert.callOrder( editor.execute, editor.editing.view.focus );
+					} );
+
+					it( 'should apply the new style if a different one was set', () => {
+						setData( model, '<listItem listType="numbered" listStyle="square" listIndent="0">[]foo</listItem>' );
+
+						styleButtonView.fire( 'execute' );
+
+						sinon.assert.calledWithExactly( editor.execute, 'listStyle', { type: 'decimal-leading-zero' } );
+						sinon.assert.calledOnce( editor.editing.view.focus );
+						sinon.assert.callOrder( editor.execute, editor.editing.view.focus );
+					} );
+
+					it( 'should remove (toggle) the style if the same style was set', () => {
+						setData( model, '<listItem listType="numbered" listStyle="decimal-leading-zero" listIndent="0">[]foo</listItem>' );
+
+						styleButtonView.fire( 'execute' );
+
+						sinon.assert.calledWithExactly( editor.execute, 'listStyle', { type: 'default' } );
+						sinon.assert.calledOnce( editor.editing.view.focus );
+						sinon.assert.callOrder( editor.execute, editor.editing.view.focus );
+					} );
+
+					it( 'should execute the "numberedList" command and apply the style if selection was not anchored in a list', () => {
+						setData( model, '<paragraph>foo[]</paragraph>' );
+
+						styleButtonView.fire( 'execute' );
+
+						sinon.assert.calledWithExactly( editor.execute, 'numberedList' );
+						sinon.assert.calledWithExactly( editor.execute, 'listStyle', { type: 'decimal-leading-zero' } );
+						sinon.assert.calledOnce( editor.editing.view.focus );
+						sinon.assert.callOrder( editor.execute, editor.editing.view.focus );
+					} );
+
+					it( 'should create the single undo step while selection was not anchored in a list', () => {
+						setData( model, '<paragraph>foo[]</paragraph>' );
+
+						styleButtonView.fire( 'execute' );
+
+						expect( getData( model ) ).to.equal(
+							'<listItem listIndent="0" listStyle="decimal-leading-zero" listType="numbered">foo[]</listItem>'
+						);
+
+						editor.execute( 'undo' );
+
+						expect( getData( model ) ).to.equal(
+							'<paragraph>foo[]</paragraph>'
+						);
+					} );
+				} );
+			} );
+		} );
+	} );
+} );

+ 182 - 0
packages/ckeditor5-list/tests/manual/list-style.html

@@ -0,0 +1,182 @@
+<h3>List Styles UI</h3>
+<div id="editor">
+    <h2>Default styles</h2>
+    <figure class="table">
+        <table style="width: 600px;">
+            <tbody>
+            <tr>
+                <td colspan="2" style="text-align: center">
+                    <span class="text-big" style="font-weight: bold">Default styles</span>
+                </td>
+            </tr>
+            <tr>
+                <td style="text-align: center"><span class="text-big" style="font-weight: bold">Bulleted</span></td>
+                <td style="text-align: center"><span class="text-big" style="font-weight: bold">Numbered</span></td>
+            </tr>
+            <tr>
+                <td style="vertical-align:top; width: 50%">
+                    <ul>
+                        <li>UL List item 1</li>
+                        <li>UL List item 2</li>
+                        <li>UL List item 3</li>
+                    </ul>
+                </td>
+                <td style="vertical-align:top; width: 50%">
+                    <ol>
+                        <li>OL List item 1</li>
+                        <li>OL List item 2</li>
+                        <li>OL List item 3</li>
+                    </ol>
+                </td>
+            </tr>
+            </tbody>
+        </table>
+    </figure>
+
+    <h2>Bulleted lists</h2>
+    <figure class="table">
+        <table style="width: 600px;">
+            <tbody>
+            <tr>
+                <td colspan="4" style="text-align: center">
+                    <span class="text-big" style="font-weight: bold">Type of list</span>
+                </td>
+            </tr>
+            <tr>
+                <td style="text-align: center"><span class="text-big" style="font-weight: bold">Circle</span></td>
+                <td style="text-align: center"><span class="text-big" style="font-weight: bold">Disc</span></td>
+                <td style="text-align: center"><span class="text-big" style="font-weight: bold">Square</span></td>
+                <td style="text-align: center"><span class="text-big" style="font-weight: bold">'👉'</span></td>
+            </tr>
+            <tr>
+                <td style="vertical-align:top; width: 25%">
+                    <ul style="list-style-type: circle;">
+                        <li>UL List item 1</li>
+                        <li>UL List item 2</li>
+                        <li>UL List item 3</li>
+                    </ul>
+                </td>
+                <td style="vertical-align:top; width: 25%">
+                    <ul style="list-style-type: disc;">
+                        <li>UL List item 1</li>
+                        <li>UL List item 2</li>
+                        <li>UL List item 3</li>
+                    </ul>
+                </td>
+                <td style="vertical-align:top; width: 25%">
+                    <ul style="list-style-type: square;">
+                        <li>UL List item 1</li>
+                        <li>UL List item 2</li>
+                        <li>UL List item 3</li>
+                    </ul>
+                </td>
+                <td style="vertical-align:top; width: 25%">
+                    <ul style="list-style-type: '👉';">
+                        <li>UL List item 1</li>
+                        <li>UL List item 2</li>
+                        <li>UL List item 3</li>
+                    </ul>
+                </td>
+            </tr>
+            </tbody>
+        </table>
+    </figure>
+
+    <h2>Numbered lists</h2>
+    <figure class="table">
+        <table style="width: 800px;">
+            <tbody>
+            <tr>
+                <td colspan="4" style="text-align: center">
+                    <span class="text-big" style="font-weight: bold">Type of list</span>
+                </td>
+            </tr>
+            <tr>
+                <td style="text-align: center"><span class="text-big" style="font-weight: bold">Decimal</span></td>
+                <td style="text-align: center"><span class="text-big" style="font-weight: bold">Decimal-leading-zero</span></td>
+                <td style="text-align: center"><span class="text-big" style="font-weight: bold">Lower-latin</span></td>
+                <td style="text-align: center"><span class="text-big" style="font-weight: bold">Lower-roman</span></td>
+            </tr>
+            <tr>
+                <td style="vertical-align:top; width: 25%">
+                    <ol style="list-style-type: decimal;">
+                        <li>OL List item 1</li>
+                        <li>OL List item 2</li>
+                        <li>OL List item 3</li>
+                    </ol>
+                </td>
+                <td style="vertical-align:top; width: 25%">
+                    <ol style="list-style-type: decimal-leading-zero;">
+                        <li>OL List item 1</li>
+                        <li>OL List item 2</li>
+                        <li>OL List item 3</li>
+                    </ol>
+                </td>
+                <td style="vertical-align:top; width: 25%">
+                    <ol style="list-style-type: lower-latin;">
+                        <li>OL List item 1</li>
+                        <li>OL List item 2</li>
+                        <li>OL List item 3</li>
+                    </ol>
+                </td>
+                <td style="vertical-align:top; width: 25%">
+                    <ol style="list-style-type: lower-roman;">
+                        <li>OL List item 1</li>
+                        <li>OL List item 2</li>
+                        <li>OL List item 3</li>
+                    </ol>
+                </td>
+            </tr>
+            </tbody>
+        </table>
+    </figure>
+
+    <h2>Nested/mixed lists (numbered and bulleted)</h2>
+    <figure class="table">
+        <table style="width: 1200px;">
+            <tr>
+                <td style="width: 50%">
+                    <ol style="list-style-type: lower-roman;">
+                        <li>OL 1.1 <code>OL[style: lower-roman]</code></li>
+                        <li>OL 1.2 <code>OL[style: lower-roman]</code>
+                            <ul style="list-style-type: square;">
+                                <li>UL 1.2.1 <code>OL[style: lower-roman] &gt; UL[style: square]</code></li>
+                                <li>UL 1.2.2 <code>OL[style: lower-roman] &gt; UL[style: square]</code></li>
+                                <li>UL 1.2.3 <code>OL[style: lower-roman] &gt; UL[style: square]</code></li>
+                            </ul>
+                        </li>
+                        <li>OL 1.3 <code>OL[style: lower-roman]</code></li>
+                        <li>OL 1.4 <code>OL[style: lower-roman]</code></li>
+                        <ol style="list-style-type: lower-alpha;">
+                            <li>OL 1.4.1 <code>OL[style: lower-roman] &gt; OL[style: lower-alpha]</code></li>
+                            <li>OL 1.4.2 <code>OL[style: lower-roman] &gt; OL[style: lower-alpha]</code></li>
+                            <li>OL 1.4.3 <code>OL[style: lower-roman] &gt; OL[style: lower-alpha]</code></li>
+                        </ol>
+                        </li>
+                        <li>OL 1.5 <code>OL[style: lower-roman]</code></li>
+                        <li>OL 1.6 <code>OL[style: lower-roman]</code></li>
+                    </ol>
+                </td>
+                <td style="width: 50%">
+                    <ol style="list-style-type: upper-alpha;">
+                        <li>OL 2.1 <code>OL[style: upper-alpha]</code>
+                            <ol style="list-style-type: lower-greek;">
+                                <li>OL 2.2.1 <code>OL[style: lower-alpha] &gt; OL[style: lower-greek]</code>
+                                    <ul style="list-style-type: circle;">
+                                        <li>UL 2.2.1.1 <code>... &gt; OL[style: lower-greek] &gt; UL[style: circle]</code></li>
+                                        <li>UL 2.2.1.2 <code>... &gt; OL[style: lower-greek] &gt; UL[style: circle]</code></li>
+                                        <li>UL 2.2.1.3 <code>... &gt; OL[style: lower-greek] &gt; UL[style: circle]</code></li>
+                                    </ul>
+                                </li>
+                                <li>OL 2.2.2 <code>OL[style: lower-alpha] &gt; OL[style: lower-greek]</code></li>
+                                <li>OL 2.2.3 <code>OL[style: lower-alpha] &gt; OL[style: lower-greek]</code></li>
+                            </ol>
+                        </li>
+                        <li>OL 2.2 <code>OL[style: upper-alpha]</code></li>
+                    </ol>
+                </td>
+            </tr>
+        </table>
+    </figure>
+</div>
+

+ 65 - 0
packages/ckeditor5-list/tests/manual/list-style.js

@@ -0,0 +1,65 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals console, window, document */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials';
+import Code from '@ckeditor/ckeditor5-basic-styles/src/code';
+import Heading from '@ckeditor/ckeditor5-heading/src/heading';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import Table from '@ckeditor/ckeditor5-table/src/table';
+import TablePropertiesEditing from '@ckeditor/ckeditor5-table/src/tableproperties/tablepropertiesediting';
+import TableCellPropertiesEditing from '@ckeditor/ckeditor5-table/src/tablecellproperties/tablecellpropertiesediting';
+import List from '../../src/list';
+import ListStyle from '../../src/liststyle';
+import Indent from '@ckeditor/ckeditor5-indent/src/indent';
+import IndentBlock from '@ckeditor/ckeditor5-indent/src/indentblock';
+import TodoList from '../../src/todolist';
+import RemoveFormat from '@ckeditor/ckeditor5-remove-format/src/removeformat';
+import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
+import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
+
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		plugins: [
+			Essentials,
+			Bold,
+			Italic,
+			Code,
+			Heading,
+			List,
+			TodoList,
+			Paragraph,
+			ListStyle,
+			Table,
+			TablePropertiesEditing,
+			TableCellPropertiesEditing,
+			Indent,
+			IndentBlock,
+			RemoveFormat
+		],
+		toolbar: [
+			'heading',
+			'|',
+			'bold',
+			'italic',
+			'|',
+			'removeFormat',
+			'|',
+			'bulletedList', 'numberedList', 'todoList',
+			'|',
+			'outdent',
+			'indent',
+			'|',
+			'undo', 'redo'
+		]
+	} )
+	.then( editor => {
+		window.editor = editor;
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );

+ 17 - 0
packages/ckeditor5-list/tests/manual/list-style.md

@@ -0,0 +1,17 @@
+# List Style feature
+
+The editor should display 4 headers and tables.
+
+1. **Default styles**<br>
+    A table should contain two columns that display default styles for `<ul>` and `<ol>` lists.
+2. **Bulleted lists**
+    A table should contain four columns with different bulleted lists
+    (`circle`, `disc`, `square`, `'👉'`).
+3. **Numbered lists**
+    A table should contain four columns with different numbered lists
+    (`decimal`, `decimal-leading-zero`, `lower-latin`, `lower-roman`).
+4. **Nested/mixed lists**
+    A table with two columns that contains nested and mixed `<ol>` and `<ul>` elements with
+    different values for `list-style-type`.
+    
+Check whether the `list-style-type` value is the same as the table or the list item describes.

+ 1 - 0
packages/ckeditor5-list/theme/icons/liststylecircle.svg

@@ -0,0 +1 @@
+<svg viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg"><g><path d="M35 29a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17zm0-9a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17zm0-9a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17z" fill-opacity=".163"/><path d="M11 27a3 3 0 1 1 0 6 3 3 0 0 1 0-6zm0 1a2 2 0 1 0 0 4 2 2 0 0 0 0-4zm0-10a3 3 0 1 1 0 6 3 3 0 0 1 0-6zm0 1a2 2 0 1 0 0 4 2 2 0 0 0 0-4zm0-10a3 3 0 1 1 0 6 3 3 0 0 1 0-6zm0 1a2 2 0 1 0 0 4 2 2 0 0 0 0-4z"/></g></svg>

+ 1 - 0
packages/ckeditor5-list/theme/icons/liststyledecimal.svg

@@ -0,0 +1 @@
+<svg viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg"><g><path d="M35 29a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17zm0-9a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17zm0-9a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17z" fill-opacity=".163"/><path d="M10.29 15V8.531H9.286c-.14.393-.4.736-.778 1.03-.378.295-.728.495-1.05.6v1.121a4.257 4.257 0 001.595-.936V15h1.235zm3.343 0v-1.235h-1.235V15h1.235zM11.3 24v-1.147H8.848c.064-.111.148-.226.252-.343.104-.117.351-.354.74-.712.39-.357.66-.631.81-.821.225-.288.39-.562.494-.824.104-.263.156-.539.156-.829 0-.51-.182-.936-.545-1.279-.363-.342-.863-.514-1.499-.514-.58 0-1.063.148-1.45.444-.387.296-.617.784-.69 1.463l1.23.124c.024-.36.112-.619.264-.774.153-.155.358-.233.616-.233.26 0 .465.074.613.222.148.148.222.36.222.635 0 .25-.085.501-.255.756-.126.185-.468.536-1.024 1.055-.692.641-1.155 1.156-1.389 1.544-.234.389-.375.8-.422 1.233H11.3zm2.333 0v-1.235h-1.235V24h1.235zM9.204 34.11c.615 0 1.129-.2 1.542-.598.413-.398.62-.88.62-1.446 0-.39-.11-.722-.332-.997a1.5 1.5 0 00-.886-.532c.619-.337.928-.788.928-1.353 0-.399-.151-.756-.453-1.073-.366-.386-.852-.58-1.459-.58-.354 0-.674.067-.96.2a1.617 1.617 0 00-.668.55c-.16.232-.28.544-.358.933l1.138.194c.032-.282.123-.495.272-.642.15-.146.33-.22.54-.22.215 0 .386.065.515.194s.193.302.193.518c0 .255-.087.46-.263.613-.176.154-.43.227-.765.218l-.136 1.006c.22-.061.409-.092.567-.092.24 0 .444.09.61.272.168.182.251.428.251.739 0 .328-.087.589-.261.782a.833.833 0 01-.644.29.841.841 0 01-.607-.242c-.167-.16-.27-.394-.307-.698l-1.196.145c.062.542.285.98.668 1.316.384.335.868.503 1.45.503zm4.43-.11v-1.235h-1.236V34h1.235z"/></g></svg>

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
packages/ckeditor5-list/theme/icons/liststyledecimalleadingzero.svg


+ 1 - 0
packages/ckeditor5-list/theme/icons/liststyledisc.svg

@@ -0,0 +1 @@
+<svg viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg"><g><path d="M35 29a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17zm0-9a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17zm0-9a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17z" fill-opacity=".163"/><path d="M11 27a3 3 0 1 1 0 6 3 3 0 0 1 0-6zm0-9a3 3 0 1 1 0 6 3 3 0 0 1 0-6zm0-9a3 3 0 1 1 0 6 3 3 0 0 1 0-6z"/></g></svg>

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
packages/ckeditor5-list/theme/icons/liststylelowerlatin.svg


+ 1 - 0
packages/ckeditor5-list/theme/icons/liststylelowerroman.svg

@@ -0,0 +1 @@
+<svg viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg"><g><path d="M35 29a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17zm0-9a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17zm0-9a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17z" fill-opacity=".163"/><path d="M11.88 8.7V7.558h-1.234V8.7h1.234zm0 5.3V9.333h-1.234V14h1.234zm2.5 0v-1.235h-1.234V14h1.235zM9.63 18.7v-1.142H8.395V18.7H9.63zm0 5.3v-4.667H8.395V24H9.63zm2.5-5.3v-1.142h-1.234V18.7h1.235zm0 5.3v-4.667h-1.234V24h1.235zm2.501 0v-1.235h-1.235V24h1.235zM7.38 28.7v-1.142H6.145V28.7H7.38zm0 5.3v-4.667H6.145V34H7.38zm2.5-5.3v-1.142H8.646V28.7H9.88zm0 5.3v-4.667H8.646V34H9.88zm2.5-5.3v-1.142h-1.234V28.7h1.235zm0 5.3v-4.667h-1.234V34h1.235zm2.501 0v-1.235h-1.235V34h1.235z"/></g></svg>

+ 1 - 0
packages/ckeditor5-list/theme/icons/liststylesquare.svg

@@ -0,0 +1 @@
+<svg viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg"><g><path d="M35 29a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17zm0-9a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17zm0-9a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17z" fill-opacity=".163"/><path d="M14 27v6H8v-6h6zm0-9v6H8v-6h6zm0-9v6H8V9h6z"/></g></svg>

+ 1 - 0
packages/ckeditor5-list/theme/icons/liststyleupperlatin.svg

@@ -0,0 +1 @@
+<svg viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg"><g><path d="M35 29a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17zm0-9a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17zm0-9a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17z" fill-opacity=".163"/><path d="M7.88 15l.532-1.463h2.575L11.549 15h1.415l-2.58-6.442H9.01L6.5 15h1.38zm2.69-2.549H8.811l.87-2.39.887 2.39zM14.88 15v-1.235h-1.234V15h1.234zM9.352 25c.83-.006 1.352-.02 1.569-.044.346-.038.636-.14.872-.305.236-.166.422-.387.558-.664.137-.277.205-.562.205-.855 0-.372-.106-.695-.317-.97-.21-.276-.512-.471-.905-.585.278-.126.499-.315.661-.567a1.5 1.5 0 00.244-.83c0-.28-.066-.53-.197-.754a1.654 1.654 0 00-.495-.539 1.676 1.676 0 00-.672-.266c-.25-.042-.63-.063-1.14-.063H7.158V25h2.193zm.142-3.88H8.46v-1.49h.747c.612 0 .983.007 1.112.022.217.026.38.102.49.226.11.125.165.287.165.486a.68.68 0 01-.192.503.86.86 0 01-.525.23 11.47 11.47 0 01-.944.023h.18zm.17 2.795H8.46v-1.723h1.05c.592 0 .977.03 1.154.092.177.062.313.16.406.295a.84.84 0 01.14.492c0 .228-.06.41-.181.547a.806.806 0 01-.473.257c-.126.026-.423.04-.892.04zM14.88 25v-1.235h-1.234V25h1.234zM9.862 34.11c.691 0 1.262-.17 1.711-.512.45-.341.772-.864.965-1.567l-1.261-.4c-.109.472-.287.818-.536 1.037-.25.22-.547.33-.892.33-.47 0-.85-.173-1.143-.519-.293-.345-.44-.925-.44-1.74 0-.767.15-1.322.447-1.665.297-.343.684-.514 1.162-.514.346 0 .64.096.881.29.242.193.4.457.477.79l1.288-.307c-.147-.516-.367-.911-.66-1.187-.492-.465-1.132-.698-1.92-.698-.902 0-1.63.296-2.184.89-.554.593-.83 1.426-.83 2.498 0 1.014.275 1.813.825 2.397.551.585 1.254.877 2.11.877zM14.88 34v-1.235h-1.234V34h1.234z"/></g></svg>

+ 1 - 0
packages/ckeditor5-list/theme/icons/liststyleupperroman.svg

@@ -0,0 +1 @@
+<svg viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg"><g><path d="M35 29a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17zm0-9a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17zm0-9a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17z" fill-opacity=".163"/><path d="M11.916 15V8.558h-1.301V15h1.3zm2.465 0v-1.235h-1.235V15h1.235zM9.665 25v-6.442h-1.3V25h1.3zm2.5 0v-6.442h-1.3V25h1.3zm2.466 0v-1.235h-1.235V25h1.235zM7.415 34v-6.442h-1.3V34h1.3zm2.5 0v-6.442h-1.3V34h1.3zm2.501 0v-6.442h-1.3V34h1.3zm2.465 0v-1.235h-1.235V34h1.235z"/></g></svg>

+ 12 - 0
packages/ckeditor5-list/theme/liststyles.css

@@ -0,0 +1,12 @@
+/*
+ * Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+.ck.ck-list-styles-dropdown > .ck-dropdown__panel > .ck-toolbar > .ck-toolbar__items {
+	/*
+	 * Use the benefits of the toolbar (e.g. out-of-the-box keyboard navigation) but make it look
+	 * like panel with thumbnails (previews).
+	 */
+	display: grid;
+}

+ 6 - 0
packages/ckeditor5-markdown-gfm/LICENSE.md

@@ -11,6 +11,12 @@ Sources of Intellectual Property Included in CKEditor
 
 Where not otherwise indicated, all CKEditor content is authored by CKSource engineers and consists of CKSource-owned intellectual property. In some specific instances, CKEditor will incorporate work done by developers outside of CKSource with their express permission.
 
+The following libraries are included in CKEditor under the [MIT license](https://opensource.org/licenses/MIT):
+
+* Marked - Copyright (c) 2011-2018, Christopher Jeffrey (https://github.com/chjj/).
+* Turndown - Copyright (c) 2017 Dom Christie.
+* Turndown-plugin-gfm - Copyright (c) 2017 Dom Christie.
+
 Trademarks
 ----------
 

+ 4 - 0
packages/ckeditor5-theme-lark/theme/ckeditor5-image/imageupload.css

@@ -0,0 +1,4 @@
+/*
+ * Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */

+ 45 - 0
packages/ckeditor5-theme-lark/theme/ckeditor5-list/liststyles.css

@@ -0,0 +1,45 @@
+/*
+ * Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+:root {
+	--ck-list-style-button-size: 44px;
+}
+
+.ck.ck-list-styles-dropdown > .ck-dropdown__panel > .ck-toolbar {
+	background: none;
+	padding: 0;
+
+	& > .ck-toolbar__items {
+		grid-template-columns: repeat( 3, auto );
+		row-gap: var(--ck-spacing-medium);
+		column-gap: var(--ck-spacing-medium);
+		padding: var(--ck-spacing-medium);
+
+		& .ck-button {
+			/* Make the button look like a thumbnail (the icon "takes it all"). */
+			width: var(--ck-list-style-button-size);
+			height: var(--ck-list-style-button-size);
+			padding: 0;
+
+			/*
+			 * Buttons are aligned by the grid so disable default button margins to not collide with the
+			 * gaps in the grid.
+			 */
+			margin: 0;
+
+			/*
+			 * Make sure the button border (which is displayed on focus, BTW) does not steal pixels
+			 * from the button dimensions and, as a result, decrease the size of the icon
+			 * (which becomes blurry as it scales down).
+			 */
+			box-sizing: content-box;
+
+			& .ck-icon {
+				width: var(--ck-list-style-button-size);
+				height: var(--ck-list-style-button-size);
+			}
+		}
+	}
+}

+ 11 - 13
packages/ckeditor5-ui/tests/componentfactory.js

@@ -39,26 +39,24 @@ describe( 'ComponentFactory', () => {
 	} );
 
 	describe( 'add()', () => {
-		it( 'throws when trying to override already registered component', () => {
-			factory.add( 'foo', () => {} );
+		it( 'does not normalize component names', () => {
+			factory.add( 'FoO', () => {} );
 
-			expectToThrowCKEditorError( () => {
-				factory.add( 'foo', () => {} );
-			}, /^componentfactory-item-exists/, editor );
+			expect( Array.from( factory.names() ) ).to.have.members( [ 'FoO' ] );
 		} );
 
-		it( 'throws when trying to override already registered component added with different case', () => {
-			factory.add( 'Foo', () => {} );
+		it( 'should allow overriding already registered components', () => {
+			factory.add( 'foo', () => 'old' );
+			factory.add( 'foo', () => 'new' );
 
-			expectToThrowCKEditorError( () => {
-				factory.add( 'foo', () => {} );
-			}, /^componentfactory-item-exists/, editor );
+			expect( factory.create( 'foo' ) ).to.equal( 'new' );
 		} );
 
-		it( 'does not normalize component names', () => {
-			factory.add( 'FoO', () => {} );
+		it( 'should allow overriding already registered components (same name, different case)', () => {
+			factory.add( 'foo', () => 'old' );
+			factory.add( 'Foo', () => 'new' );
 
-			expect( Array.from( factory.names() ) ).to.have.members( [ 'FoO' ] );
+			expect( factory.create( 'foo' ) ).to.equal( 'new' );
 		} );
 	} );
 

+ 9 - 1
scripts/continuous-integration-script.js

@@ -91,7 +91,15 @@ for ( const fullPackageName of packages ) {
 }
 
 console.log( 'Uploading combined code coverage report…' );
-childProcess.execSync( 'npx coveralls < .out/combined_lcov.info' );
+
+// Comparing "organization/ckeditor5" and "ckeditor/ckeditor5".
+if ( process.env.TRAVIS_PULL_REQUEST_SLUG === process.env.TRAVIS_REPO_SLUG ) {
+	childProcess.execSync( 'npx coveralls < .out/combined_lcov.info' );
+} else {
+	console.log( 'Since the PR comes from the community, we do not upload code coverage report.' );
+	console.log( 'Read more why: https://github.com/ckeditor/ckeditor5/issues/7745.' );
+}
+
 console.log( 'Done' );
 
 if ( Object.values( failedChecks ).some( checksSet => checksSet.size > 0 ) ) {

+ 8 - 1
tests/manual/all-features.js

@@ -96,7 +96,14 @@ ClassicEditor
 				'imageTextAlternative', '|',
 				'imageStyle:alignLeft', 'imageStyle:alignCenter', 'imageStyle:alignRight', '|',
 				'imageResize'
-			]
+			],
+			upload: {
+				panel: {
+					items: [
+						'insertImageViaUrl'
+					]
+				}
+			}
 		},
 		placeholder: 'Type the content here!',
 		mention: {

Некоторые файлы не были показаны из-за большого количества измененных файлов