Răsfoiți Sursa

Merge pull request #1 from ckeditor/t/ckeditor5/436

Feature: Initial implementation of the code block feature. Closes ckeditor/ckeditor5#436. Closes ckeditor/ckeditor5#5664. Closes ckeditor/ckeditor5#5666.
Maciej 6 ani în urmă
părinte
comite
43d91806ec
25 a modificat fișierele cu 3950 adăugiri și 3 ștergeri
  1. 17 1
      packages/ckeditor5-code-block/README.md
  2. 4 0
      packages/ckeditor5-code-block/lang/contexts.json
  3. 13 2
      packages/ckeditor5-code-block/package.json
  4. 166 0
      packages/ckeditor5-code-block/src/codeblock.js
  5. 162 0
      packages/ckeditor5-code-block/src/codeblockcommand.js
  6. 409 0
      packages/ckeditor5-code-block/src/codeblockediting.js
  7. 110 0
      packages/ckeditor5-code-block/src/codeblockui.js
  8. 246 0
      packages/ckeditor5-code-block/src/converters.js
  9. 98 0
      packages/ckeditor5-code-block/src/indentcodeblockcommand.js
  10. 187 0
      packages/ckeditor5-code-block/src/outdentcodeblockcommand.js
  11. 206 0
      packages/ckeditor5-code-block/src/utils.js
  12. 18 0
      packages/ckeditor5-code-block/tests/codeblock.js
  13. 281 0
      packages/ckeditor5-code-block/tests/codeblockcommand.js
  14. 1146 0
      packages/ckeditor5-code-block/tests/codeblockediting.js
  15. 224 0
      packages/ckeditor5-code-block/tests/codeblockui.js
  16. 171 0
      packages/ckeditor5-code-block/tests/indentcodeblockcommand.js
  17. 24 0
      packages/ckeditor5-code-block/tests/manual/codeblock.html
  18. 29 0
      packages/ckeditor5-code-block/tests/manual/codeblock.js
  19. 50 0
      packages/ckeditor5-code-block/tests/manual/codeblock.md
  20. 8 0
      packages/ckeditor5-code-block/tests/manual/rtl.html
  21. 24 0
      packages/ckeditor5-code-block/tests/manual/rtl.js
  22. 5 0
      packages/ckeditor5-code-block/tests/manual/rtl.md
  23. 311 0
      packages/ckeditor5-code-block/tests/outdentcodeblockcommand.js
  24. 40 0
      packages/ckeditor5-code-block/theme/codeblock.css
  25. 1 0
      packages/ckeditor5-code-block/theme/icons/codeblock.svg

+ 17 - 1
packages/ckeditor5-code-block/README.md

@@ -1,4 +1,20 @@
 CKEditor 5 code block feature
 CKEditor 5 code block feature
 ========================================
 ========================================
 
 
-This is an initial package for development purposes. It does not contain code yet.
+[![npm version](https://badge.fury.io/js/%40ckeditor%2Fckeditor5-code-block.svg)](https://www.npmjs.com/package/@ckeditor/ckeditor5-code-block)
+[![npm version](https://badge.fury.io/js/%40ckeditor%2Fckeditor5-code-block.svg)](https://www.npmjs.com/package/@ckeditor/ckeditor5-code-block)
+[![Build Status](https://travis-ci.org/ckeditor/ckeditor5-code-block.svg?branch=master)](https://travis-ci.org/ckeditor/ckeditor5-code-block)
+[![Coverage Status](https://coveralls.io/repos/github/ckeditor/ckeditor5-code-block/badge.svg?branch=master)](https://coveralls.io/github/ckeditor/ckeditor5-code-block?branch=master)
+<br>
+[![Dependency Status](https://david-dm.org/ckeditor/ckeditor5-code-block/status.svg)](https://david-dm.org/ckeditor/ckeditor5-code-block)
+[![devDependency Status](https://david-dm.org/ckeditor/ckeditor5-code-block/dev-status.svg)](https://david-dm.org/ckeditor/ckeditor5-code-block?type=dev)
+
+This package implements the code block feature for CKEditor 5.
+
+## Documentation
+
+See the [`@ckeditor/ckeditor5-code-block` package](https://ckeditor.com/docs/ckeditor5/latest/api/code-block.html) page as well as the [Code block feature guide](https://ckeditor.com/docs/ckeditor5/latest/features/code-block.html) in [CKEditor 5 documentation](https://ckeditor.com/docs/ckeditor5/latest/).
+
+## License
+
+Licensed under the terms of [GNU General Public License Version 2 or later](http://www.gnu.org/licenses/gpl.html). For full details about the license, please check the `LICENSE.md` file or [https://ckeditor.com/legal/ckeditor-oss-license](https://ckeditor.com/legal/ckeditor-oss-license).

+ 4 - 0
packages/ckeditor5-code-block/lang/contexts.json

@@ -0,0 +1,4 @@
+{
+	"Insert code block": "A label of the button that allows inserting a new code block into the editor content.",
+	"Plain text": "A language of the code block in the editor content when no specific programming language is associated with it."
+}

+ 13 - 2
packages/ckeditor5-code-block/package.json

@@ -10,11 +10,22 @@
     "ckeditor5-plugin"
     "ckeditor5-plugin"
   ],
   ],
   "dependencies": {
   "dependencies": {
-    "@ckeditor/ckeditor5-core": "^12.3.0",
-    "@ckeditor/ckeditor5-utils": "^14.0.0",
+    "@ckeditor/ckeditor5-core": "^15.0.0",
+    "@ckeditor/ckeditor5-enter": "^15.0.0",
+    "@ckeditor/ckeditor5-ui": "^15.0.0",
+    "@ckeditor/ckeditor5-utils": "^15.0.0",
     "lodash-es": "^4.17.10"
     "lodash-es": "^4.17.10"
   },
   },
   "devDependencies": {
   "devDependencies": {
+    "@ckeditor/ckeditor5-alignment": "^15.0.0",
+    "@ckeditor/ckeditor5-autoformat": "^15.0.0",
+    "@ckeditor/ckeditor5-basic-styles": "^15.0.0",
+    "@ckeditor/ckeditor5-block-quote": "^15.0.0",
+    "@ckeditor/ckeditor5-editor-classic": "^15.0.0",
+    "@ckeditor/ckeditor5-engine": "^15.0.0",
+    "@ckeditor/ckeditor5-indent": "^15.0.0",
+    "@ckeditor/ckeditor5-paragraph": "^15.0.0",
+    "@ckeditor/ckeditor5-undo": "^15.0.0",
     "eslint": "^5.5.0",
     "eslint": "^5.5.0",
     "eslint-config-ckeditor5": "^2.0.0",
     "eslint-config-ckeditor5": "^2.0.0",
     "husky": "^1.3.1",
     "husky": "^1.3.1",

+ 166 - 0
packages/ckeditor5-code-block/src/codeblock.js

@@ -0,0 +1,166 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module code-block/codeblock
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import CodeBlockEditing from './codeblockediting';
+import CodeBlockUI from './codeblockui';
+
+/**
+ * The code block plugin.
+ *
+ * For more information about this feature check the package page.
+ *
+ * This is a "glue" plugin which loads the {@link module:code-block/codeblockediting~CodeBlockEditing code block editing feature}
+ * and {@link module:code-block/codeblockui~CodeBlockUI code block UI feature}.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class CodeBlock extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ CodeBlockEditing, CodeBlockUI ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'CodeBlock';
+	}
+}
+
+/**
+ * The configuration of the {@link module:code-block/codeblock~CodeBlock} feature.
+ *
+ * Read more in {@link module:code-block/codeblock~CodeBlockConfig}.
+ *
+ * @member {module:code-block/codeblock~CodeBlockConfig} module:core/editor/editorconfig~EditorConfig#codeBlock
+ */
+
+/**
+ * The configuration of the {@link module:code-block/codeblock~CodeBlock code block feature}.
+ *
+ *		ClassicEditor
+ *			.create( editorElement, {
+ * 				codeBlock:  ... // Code block feature configuration.
+ *			} )
+ *			.then( ... )
+ *			.catch( ... );
+ *
+ * See {@link module:core/editor/editorconfig~EditorConfig all editor options}.
+ *
+ * @interface CodeBlockConfig
+ */
+
+/**
+ * The code block language descriptor. See {@link module:code-block/codeblock~CodeBlockConfig#languages} to learn more.
+ *
+ *		{
+ *			 language: 'javascript',
+ *			 label: 'JavaScript'
+ *		}
+ *
+ * @typedef {Object} module:code-block/codeblock~CodeBlockLanguageDefinition
+ * @property {String} language The name of the language that will be stored in the model attribute. Also, when `class`
+ * is not specified, it will also be used to create the CSS class associated with the language (prefixed by "language-").
+ * @property {String} label The human–readable label associated with the language and displayed in the UI.
+ * @property {String} [class] The CSS class associated with the language. When not specified the `language`
+ * property is used to create a class prefixed by "language-".
+ */
+
+/**
+ * The list of code languages available in the user interface to choose for a particular code block.
+ *
+ * The language of the code block is represented as a CSS class (by default prefixed by "language-") set on the
+ * `<code>` element, both when editing and in the editor data. The CSS class associated with the language
+ * can be used by third–party code syntax highlighters to detect and apply the correct highlighting.
+ *
+ * For instance, this language configuration:
+ *
+ *		ClassicEditor
+ *			.create( editorElement, {
+ *				codeBlock: {
+ *					languages: [
+ *						// ...
+ *						{ language: 'javascript', label: 'JavaScript' },
+ *						// ...
+ *					]
+ *				}
+ *		} )
+ *		.then( ... )
+ *		.catch( ... );
+ *
+ * will result in the following structure of JavaScript code blocks in the editor editing and data:
+ *
+ *		<pre><code class="language-javascript">window.alert( 'Hello world!' )</code></pre>
+ *
+ * You can customize the CSS class by specifying an optional `class` property in a language definition:
+ *
+ *		ClassicEditor
+ *			.create( editorElement, {
+ *				codeBlock: {
+ *					languages: [
+ *						// Do not render CSS class for the plain text code blocks.
+ * 						{ language: 'plaintext', label: 'Plain text', class: '' },
+ *
+ *						// Use the "php-code" class for PHP code blocks.
+ *						{ language: 'php', label: 'PHP', class: 'php-code' },
+ *
+ *						// Use the "js" class for JavaScript code blocks.
+ *						{ language: 'javascript', label: 'JavaScript', class: 'js' },
+ *
+ *						// Python code blocks will have the default "language-python" CSS class.
+ *						{ language: 'python', label: 'Python' }
+ *					]
+ *				}
+ *		} )
+ *		.then( ... )
+ *		.catch( ... );
+ *
+ * The default value of the language configuration is as follows:
+ *
+ *		languages: [
+ *			{ language: 'plaintext', label: 'Plain text' }, // The default language.
+ *			{ language: 'c', label: 'C' },
+ *			{ language: 'cs', label: 'C#' },
+ *			{ language: 'cpp', label: 'C++' },
+ *			{ language: 'css', label: 'CSS' },
+ *			{ language: 'diff', label: 'Diff' },
+ *			{ language: 'xml', label: 'HTML/XML' },
+ *			{ language: 'java', label: 'Java' },
+ *			{ language: 'javascript', label: 'JavaScript' },
+ *			{ language: 'php', label: 'PHP' },
+ *			{ language: 'python', label: 'Python' },
+ *			{ language: 'ruby', label: 'Ruby' },
+ *			{ language: 'typescript', label: 'TypeScript' },
+ *		]
+ *
+ * **Note**: The first language defined in the configuration is considered the default one. This means it will be
+ * applied to code blocks loaded from data that have no CSS `class` specified (or no matching `class` in the config).
+ * It will also be used when creating new code blocks using the main UI button. By default it is "Plain text".
+ *
+ * @member {Array.<module:code-block/codeblock~CodeBlockLanguageDefinition>} module:code-block/codeblock~CodeBlockConfig#languages
+ */
+
+/**
+ * A sequence of characters inserted or removed from the code block lines when its indentation
+ * is changed by the user, for instance, using <kbd>Tab</kbd> and <kbd>Shift</kbd>+<kbd>Tab</kbd> keys.
+ *
+ * The default value is a single tab character ("	", `\u0009` in Unicode).
+ *
+ * This configuration is used by `indentCodeBlock` and `outdentCodeBlock` commands (instances of
+ * {@link module:code-block/indentcodeblockcommand~IndentCodeBlockCommand}).
+ *
+ * **Note**: Setting this configuration to `false` will disable the code block indentation commands
+ * and associated keystrokes.
+ *
+ * @member {String} module:code-block/codeblock~CodeBlockConfig#indentSequence
+ */

+ 162 - 0
packages/ckeditor5-code-block/src/codeblockcommand.js

@@ -0,0 +1,162 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module code-block/codeblockcommand
+ */
+
+import Command from '@ckeditor/ckeditor5-core/src/command';
+import first from '@ckeditor/ckeditor5-utils/src/first';
+import { getNormalizedAndLocalizedLanguageDefinitions } from './utils';
+
+/**
+ * The code block command plugin.
+ *
+ * @extends module:core/command~Command
+ */
+export default class CodeBlockCommand extends Command {
+	/**
+	 * Whether the selection starts in a code block.
+	 *
+	 * @observable
+	 * @readonly
+	 * @member {Boolean} #value
+	 */
+
+	/**
+	 * @inheritDoc
+	 */
+	refresh() {
+		this.value = this._getValue();
+		this.isEnabled = this._checkEnabled();
+	}
+
+	/**
+	 * Executes the command. When the command {@link #value is on}, all top-most code blocks within
+	 * the selection will be removed. If it is off, all selected blocks will be flattened and
+	 * wrapped by a code block.
+	 *
+	 * @fires execute
+	 * @param {Object} [options] Command options.
+	 * @param {Boolean} [options.forceValue] If set, it will force the command behavior. If `true`, the command will apply a code block,
+	 * otherwise the command will remove the code block. If not set, the command will act basing on its current value.
+	 */
+	execute( options = {} ) {
+		const editor = this.editor;
+		const model = editor.model;
+		const selection = model.document.selection;
+		const normalizedLanguagesDefs = getNormalizedAndLocalizedLanguageDefinitions( editor );
+		const firstLanguageInConfig = normalizedLanguagesDefs[ 0 ];
+
+		const blocks = Array.from( selection.getSelectedBlocks() );
+		const value = ( options.forceValue === undefined ) ? !this.value : options.forceValue;
+		const language = options.language || firstLanguageInConfig.language;
+
+		model.change( writer => {
+			if ( value ) {
+				this._applyCodeBlock( writer, blocks, language );
+			} else {
+				this._removeCodeBlock( writer, blocks );
+			}
+		} );
+	}
+
+	/**
+	 * Checks the command's {@link #value}.
+	 *
+	 * @private
+	 * @returns {Boolean} The current value.
+	 */
+	_getValue() {
+		const selection = this.editor.model.document.selection;
+		const firstBlock = first( selection.getSelectedBlocks() );
+		const isCodeBlock = !!( firstBlock && firstBlock.is( 'codeBlock' ) );
+
+		return isCodeBlock ? firstBlock.getAttribute( 'language' ) : false;
+	}
+
+	/**
+	 * Checks whether the command can be enabled in the current context.
+	 *
+	 * @private
+	 * @returns {Boolean} Whether the command should be enabled.
+	 */
+	_checkEnabled() {
+		if ( this.value ) {
+			return true;
+		}
+
+		const selection = this.editor.model.document.selection;
+		const schema = this.editor.model.schema;
+
+		const firstBlock = first( selection.getSelectedBlocks() );
+
+		if ( !firstBlock ) {
+			return false;
+		}
+
+		return canBeCodeBlock( schema, firstBlock );
+	}
+
+	/**
+	 * @private
+	 * @param {module:engine/model/writer~Writer} writer
+	 * @param {Array.<module:engine/model/element~Element>} blocks
+	 * @param {String} [language]
+	 */
+	_applyCodeBlock( writer, blocks, language ) {
+		const schema = this.editor.model.schema;
+		const allowedBlocks = blocks.filter( block => canBeCodeBlock( schema, block ) );
+
+		for ( const block of allowedBlocks ) {
+			writer.rename( block, 'codeBlock' );
+			writer.setAttribute( 'language', language, block );
+			schema.removeDisallowedAttributes( [ block ], writer );
+		}
+
+		allowedBlocks.reverse().forEach( ( currentBlock, i ) => {
+			const nextBlock = allowedBlocks[ i + 1 ];
+
+			if ( currentBlock.previousSibling === nextBlock ) {
+				writer.appendElement( 'softBreak', nextBlock );
+				writer.merge( writer.createPositionBefore( currentBlock ) );
+			}
+		} );
+	}
+
+	/**
+	 * @private
+	 * @param {module:engine/model/writer~Writer} writer
+	 * @param {Array.<module:engine/model/element~Element>} blocks
+	 */
+	_removeCodeBlock( writer, blocks ) {
+		const codeBlocks = blocks.filter( block => block.is( 'codeBlock' ) );
+
+		for ( const block of codeBlocks ) {
+			const range = writer.createRangeOn( block );
+
+			for ( const item of Array.from( range.getItems() ).reverse() ) {
+				if ( item.is( 'softBreak' ) && item.parent.is( 'codeBlock' ) ) {
+					const { position } = writer.split( writer.createPositionBefore( item ) );
+
+					writer.rename( position.nodeAfter, 'paragraph' );
+					writer.removeAttribute( 'language', position.nodeAfter );
+					writer.remove( item );
+				}
+			}
+
+			writer.rename( block, 'paragraph' );
+			writer.removeAttribute( 'language', block );
+		}
+	}
+}
+
+function canBeCodeBlock( schema, element ) {
+	if ( element.is( 'rootElement' ) || schema.isLimit( element ) ) {
+		return false;
+	}
+
+	return schema.checkChild( element.parent, 'codeBlock' );
+}

+ 409 - 0
packages/ckeditor5-code-block/src/codeblockediting.js

@@ -0,0 +1,409 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module code-block/codeblockediting
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import ShiftEnter from '@ckeditor/ckeditor5-enter/src/shiftenter';
+import CodeBlockCommand from './codeblockcommand';
+import IndentCodeBlockCommand from './indentcodeblockcommand';
+import OutdentCodeBlockCommand from './outdentcodeblockcommand';
+import {
+	getNormalizedAndLocalizedLanguageDefinitions,
+	getLeadingWhiteSpaces,
+	rawSnippetTextToModelDocumentFragment
+} from './utils';
+import {
+	modelToViewCodeBlockInsertion,
+	modelToDataViewSoftBreakInsertion,
+	dataViewToModelCodeBlockInsertion
+} from './converters';
+
+const DEFAULT_ELEMENT = 'paragraph';
+
+/**
+ * The editing part of the code block feature.
+ *
+ * Introduces the `'codeBlock'` command and the `'codeBlock'` model element.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class CodeBlockEditing extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'CodeBlockEditing';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ ShiftEnter ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	constructor( editor ) {
+		super( editor );
+
+		editor.config.define( 'codeBlock', {
+			languages: [
+				{ language: 'plaintext', label: 'Plain text' },
+				{ language: 'c', label: 'C' },
+				{ language: 'cs', label: 'C#' },
+				{ language: 'cpp', label: 'C++' },
+				{ language: 'css', label: 'CSS' },
+				{ language: 'diff', label: 'Diff' },
+				{ language: 'xml', label: 'HTML/XML' },
+				{ language: 'java', label: 'Java' },
+				{ language: 'javascript', label: 'JavaScript' },
+				{ language: 'php', label: 'PHP' },
+				{ language: 'python', label: 'Python' },
+				{ language: 'ruby', label: 'Ruby' },
+				{ language: 'typescript', label: 'TypeScript' },
+			],
+
+			// A single tab.
+			indentSequence: '\t'
+		} );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+		const schema = editor.model.schema;
+		const model = editor.model;
+
+		const normalizedLanguagesDefs = getNormalizedAndLocalizedLanguageDefinitions( editor );
+
+		// The main command.
+		editor.commands.add( 'codeBlock', new CodeBlockCommand( editor ) );
+
+		// Commands that change the indentation.
+		editor.commands.add( 'indentCodeBlock', new IndentCodeBlockCommand( editor ) );
+		editor.commands.add( 'outdentCodeBlock', new OutdentCodeBlockCommand( editor ) );
+
+		const getCommandExecuter = commandName => {
+			return ( data, cancel ) => {
+				const command = this.editor.commands.get( commandName );
+
+				if ( command.isEnabled ) {
+					this.editor.execute( commandName );
+					cancel();
+				}
+			};
+		};
+
+		editor.keystrokes.set( 'Tab', getCommandExecuter( 'indentCodeBlock' ) );
+		editor.keystrokes.set( 'Shift+Tab', getCommandExecuter( 'outdentCodeBlock' ) );
+
+		schema.register( 'codeBlock', {
+			allowWhere: '$block',
+			isBlock: true,
+			allowAttributes: [ 'language' ]
+		} );
+
+		schema.extend( '$text', {
+			allowIn: 'codeBlock'
+		} );
+
+		// Disallow all attributes on $text inside `codeBlock`.
+		schema.addAttributeCheck( context => {
+			if ( context.endsWith( 'codeBlock $text' ) ) {
+				return false;
+			}
+		} );
+
+		// Conversion.
+		editor.editing.downcastDispatcher.on( 'insert:codeBlock', modelToViewCodeBlockInsertion( model, normalizedLanguagesDefs, true ) );
+		editor.data.downcastDispatcher.on( 'insert:codeBlock', modelToViewCodeBlockInsertion( model, normalizedLanguagesDefs ) );
+		editor.data.downcastDispatcher.on( 'insert:softBreak', modelToDataViewSoftBreakInsertion( model ), { priority: 'high' } );
+		editor.data.upcastDispatcher.on( 'element:pre', dataViewToModelCodeBlockInsertion( editor.data, normalizedLanguagesDefs ) );
+
+		// Intercept the clipboard input (paste) when the selection is anchored in the code block and force the clipboard
+		// data to be pasted as a single plain text. Otherwise, the code lines will split the code block and
+		// "spill out" as separate paragraphs.
+		this.listenTo( editor.editing.view.document, 'clipboardInput', ( evt, data ) => {
+			const modelSelection = model.document.selection;
+
+			if ( !modelSelection.anchor.parent.is( 'codeBlock' ) ) {
+				return;
+			}
+
+			const text = data.dataTransfer.getData( 'text/plain' );
+
+			model.change( writer => {
+				model.insertContent( rawSnippetTextToModelDocumentFragment( writer, text ), modelSelection );
+				evt.stop();
+			} );
+		} );
+
+		// Make sure multi–line selection is always wrapped in a code block when `getSelectedContent()`
+		// is used (e.g. clipboard copy). Otherwise, only the raw text will be copied to the clipboard and,
+		// upon next paste, this bare text will not be inserted as a code block, which is not the best UX.
+		// Similarly, when the selection in a single line, the selected content should be an inline code
+		// so it can be pasted later on and retain it's preformatted nature.
+		this.listenTo( model, 'getSelectedContent', ( evt, [ selection ] ) => {
+			const anchor = selection.anchor;
+
+			if ( selection.isCollapsed || !anchor.parent.is( 'codeBlock' ) || !anchor.hasSameParentAs( selection.focus ) ) {
+				return;
+			}
+
+			model.change( writer => {
+				const docFragment = evt.return;
+
+				// fo[o<softBreak></softBreak>b]ar  ->   <codeBlock language="...">[o<softBreak></softBreak>b]<codeBlock>
+				if ( docFragment.childCount > 1 || selection.containsEntireContent( anchor.parent ) ) {
+					const codeBlock = writer.createElement( 'codeBlock', anchor.parent.getAttributes() );
+					writer.append( docFragment, codeBlock );
+
+					const newDocumentFragment = writer.createDocumentFragment();
+					writer.append( codeBlock, newDocumentFragment );
+
+					evt.return = newDocumentFragment;
+				}
+
+				// "f[oo]"                          ->   <$text code="true">oo</text>
+				else {
+					const textNode = docFragment.getChild( 0 );
+
+					if ( schema.checkAttribute( textNode, 'code' ) ) {
+						writer.setAttribute( 'code', true, textNode );
+					}
+				}
+			} );
+		} );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	afterInit() {
+		const editor = this.editor;
+		const commands = editor.commands;
+		const indent = commands.get( 'indent' );
+		const outdent = commands.get( 'outdent' );
+
+		if ( indent ) {
+			indent.registerChildCommand( commands.get( 'indentCodeBlock' ) );
+		}
+
+		if ( outdent ) {
+			outdent.registerChildCommand( commands.get( 'outdentCodeBlock' ) );
+		}
+
+		// Customize the response to the <kbd>Enter</kbd> and <kbd>Shift</kbd>+<kbd>Enter</kbd>
+		// key press when the selection is in the code block. Upon enter key press we can either
+		// leave the block if it's "two enters" in a row or create a new code block line, preserving
+		// previous line's indentation.
+		this.listenTo( editor.editing.view.document, 'enter', ( evt, data ) => {
+			const positionParent = editor.model.document.selection.getLastPosition().parent;
+
+			if ( !positionParent.is( 'codeBlock' ) ) {
+				return;
+			}
+
+			leaveBlockStartOnEnter( editor, data.isSoft ) ||
+			leaveBlockEndOnEnter( editor, data.isSoft ) ||
+			breakLineOnEnter( editor );
+
+			data.preventDefault();
+			evt.stop();
+		} );
+	}
+}
+
+// Normally, when the Enter (or Shift+Enter) key is pressed, a soft line break is to be added to the
+// code block. Let's try to follow the indentation of the previous line when possible, for instance:
+//
+//		// Before pressing enter (or shift enter)
+//		<codeBlock>
+//		"    foo()"[]                   // Indent of 4 spaces.
+//		</codeBlock>
+//
+//		// After pressing:
+//		<codeBlock>
+//		"    foo()"                 // Indent of 4 spaces.
+//		<softBreak></softBreak>     // A new soft break created by pressing enter.
+//		"    "[]                    // Retain the indent of 4 spaces.
+//		</codeBlock>
+//
+// @param {module:core/editor/editor~Editor} editor
+function breakLineOnEnter( editor ) {
+	const model = editor.model;
+	const modelDoc = model.document;
+	const lastSelectionPosition = modelDoc.selection.getLastPosition();
+	const node = lastSelectionPosition.nodeBefore || lastSelectionPosition.textNode;
+	let leadingWhiteSpaces;
+
+	// Figure out the indentation (white space chars) at the beginning of the line.
+	if ( node && node.is( 'text' ) ) {
+		leadingWhiteSpaces = getLeadingWhiteSpaces( node );
+	}
+
+	// Keeping everything in a change block for a single undo step.
+	editor.model.change( writer => {
+		editor.execute( 'shiftEnter' );
+
+		// If the line before being broken in two had some indentation, let's retain it
+		// in the new line.
+		if ( leadingWhiteSpaces ) {
+			writer.insertText( leadingWhiteSpaces, modelDoc.selection.anchor );
+		}
+	} );
+}
+
+// Leave the code block when Enter (but NOT Shift+Enter) has been pressed twice at the beginning
+// of the code block:
+//
+//		// Before:
+//		<codeBlock>[]<softBreak></softBreak>foo</codeBlock>
+//
+//		// After pressing:
+//		<paragraph>[]</paragraph><codeBlock>foo</codeBlock>
+//
+// @param {module:core/editor/editor~Editor} editor
+// @param {Boolean} isSoftEnter When `true`, enter was pressed along with <kbd>Shift</kbd>.
+// @returns {Boolean} `true` when selection left the block. `false` if stayed.
+function leaveBlockStartOnEnter( editor, isSoftEnter ) {
+	const model = editor.model;
+	const modelDoc = model.document;
+	const view = editor.editing.view;
+	const lastSelectionPosition = modelDoc.selection.getLastPosition();
+	const nodeAfter = lastSelectionPosition.nodeAfter;
+
+	if ( isSoftEnter || !modelDoc.selection.isCollapsed || !lastSelectionPosition.isAtStart ) {
+		return false;
+	}
+
+	if ( !nodeAfter || !nodeAfter.is( 'softBreak' ) ) {
+		return false;
+	}
+
+	// We're doing everything in a single change block to have a single undo step.
+	editor.model.change( writer => {
+		// "Clone" the <codeBlock> in the standard way.
+		editor.execute( 'enter' );
+
+		// The cloned block exists now before the original code block.
+		const newBlock = modelDoc.selection.anchor.parent.previousSibling;
+
+		// Make the cloned <codeBlock> a regular <paragraph> (with clean attributes, so no language).
+		writer.rename( newBlock, DEFAULT_ELEMENT );
+		writer.setSelection( newBlock, 'in' );
+		editor.model.schema.removeDisallowedAttributes( [ newBlock ], writer );
+
+		// Remove the <softBreak> that originally followed the selection position.
+		writer.remove( nodeAfter );
+	} );
+
+	// Eye candy.
+	view.scrollToTheSelection();
+
+	return true;
+}
+
+// Leave the code block when Enter (but NOT Shift+Enter) has been pressed twice at the end
+// of the code block:
+//
+//		// Before:
+//		<codeBlock>foo[]</codeBlock>
+//
+//		// After first press:
+//		<codeBlock>foo<softBreak></softBreak>[]</codeBlock>
+//
+//		// After second press:
+//		<codeBlock>foo</codeBlock><paragraph>[]</paragraph>
+//
+// @param {module:core/editor/editor~Editor} editor
+// @param {Boolean} isSoftEnter When `true`, enter was pressed along with <kbd>Shift</kbd>.
+// @returns {Boolean} `true` when selection left the block. `false` if stayed.
+function leaveBlockEndOnEnter( editor, isSoftEnter ) {
+	const model = editor.model;
+	const modelDoc = model.document;
+	const view = editor.editing.view;
+	const lastSelectionPosition = modelDoc.selection.getLastPosition();
+	const nodeBefore = lastSelectionPosition.nodeBefore;
+
+	let emptyLineRangeToRemoveOnEnter;
+
+	if ( isSoftEnter || !modelDoc.selection.isCollapsed || !lastSelectionPosition.isAtEnd || !nodeBefore ) {
+		return false;
+	}
+
+	// When the position is directly preceded by a soft break
+	//
+	//		<codeBlock>foo<softBreak></softBreak>[]</codeBlock>
+	//
+	// it creates the following range that will be cleaned up before leaving:
+	//
+	//		<codeBlock>foo[<softBreak></softBreak>]</codeBlock>
+	//
+	if ( nodeBefore.is( 'softBreak' ) ) {
+		emptyLineRangeToRemoveOnEnter = model.createRangeOn( nodeBefore );
+	}
+
+	// When there's some text before the position made purely of white–space characters
+	//
+	//		<codeBlock>foo<softBreak></softBreak>    []</codeBlock>
+	//
+	// but NOT when it's the first one of the kind
+	//
+	//		<codeBlock>    []</codeBlock>
+	//
+	// it creates the following range to clean up before leaving:
+	//
+	//		<codeBlock>foo[<softBreak></softBreak>    ]</codeBlock>
+	//
+	else if (
+		nodeBefore.is( 'text' ) &&
+		!nodeBefore.data.match( /\S/ ) &&
+		nodeBefore.previousSibling &&
+		nodeBefore.previousSibling.is( 'softBreak' )
+	) {
+		emptyLineRangeToRemoveOnEnter = model.createRange(
+			model.createPositionBefore( nodeBefore.previousSibling ), model.createPositionAfter( nodeBefore )
+		);
+	}
+
+	// Not leaving the block in the following cases:
+	//
+	//		<codeBlock>    []</codeBlock>
+	//		<codeBlock>  a []</codeBlock>
+	//		<codeBlock>foo<softBreak></softBreak>bar[]</codeBlock>
+	//		<codeBlock>foo<softBreak></softBreak> a []</codeBlock>
+	//
+	else {
+		return false;
+	}
+
+	// We're doing everything in a single change block to have a single undo step.
+	editor.model.change( writer => {
+		// Remove the last <softBreak> and all white space characters that followed it.
+		writer.remove( emptyLineRangeToRemoveOnEnter );
+
+		// "Clone" the <codeBlock> in the standard way.
+		editor.execute( 'enter' );
+
+		const newBlock = modelDoc.selection.anchor.parent;
+
+		// Make the cloned <codeBlock> a regular <paragraph> (with clean attributes, so no language).
+		writer.rename( newBlock, DEFAULT_ELEMENT );
+		editor.model.schema.removeDisallowedAttributes( [ newBlock ], writer );
+	} );
+
+	// Eye candy.
+	view.scrollToTheSelection();
+
+	return true;
+}

+ 110 - 0
packages/ckeditor5-code-block/src/codeblockui.js

@@ -0,0 +1,110 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module code-block/codeblockui
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import Collection from '@ckeditor/ckeditor5-utils/src/collection';
+import Model from '@ckeditor/ckeditor5-ui/src/model';
+import SplitButtonView from '@ckeditor/ckeditor5-ui/src/dropdown/button/splitbuttonview';
+import { createDropdown, addListToDropdown } from '@ckeditor/ckeditor5-ui/src/dropdown/utils';
+import { getNormalizedAndLocalizedLanguageDefinitions } from './utils';
+
+import codeBlockIcon from '../theme/icons/codeblock.svg';
+import '../theme/codeblock.css';
+
+/**
+ * The code block UI plugin.
+ *
+ * Introduces the `'codeBlock'` dropdown.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class CodeBlockUI extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+		const t = editor.t;
+		const componentFactory = editor.ui.componentFactory;
+		const normalizedLanguageDefs = getNormalizedAndLocalizedLanguageDefinitions( editor );
+		const defaultLanguageDefinition = normalizedLanguageDefs[ 0 ];
+
+		componentFactory.add( 'codeBlock', locale => {
+			const command = editor.commands.get( 'codeBlock' );
+			const dropdownView = createDropdown( locale, SplitButtonView );
+			const splitButtonView = dropdownView.buttonView;
+
+			splitButtonView.set( {
+				label: t( 'Insert code block' ),
+				tooltip: true,
+				icon: codeBlockIcon,
+				isToggleable: true
+			} );
+
+			splitButtonView.bind( 'isOn' ).to( command, 'value', value => !!value );
+
+			splitButtonView.on( 'execute', () => {
+				editor.execute( 'codeBlock', {
+					language: defaultLanguageDefinition.language,
+				} );
+
+				editor.editing.view.focus();
+			} );
+
+			dropdownView.on( 'execute', evt => {
+				editor.execute( 'codeBlock', {
+					language: evt.source._codeBlockLanguage,
+					forceValue: true
+				} );
+
+				editor.editing.view.focus();
+			} );
+
+			dropdownView.class = 'ck-code-block-dropdown';
+			dropdownView.bind( 'isEnabled' ).to( command );
+
+			addListToDropdown( dropdownView, this._getLanguageListItemDefinitions( normalizedLanguageDefs ) );
+
+			return dropdownView;
+		} );
+	}
+
+	/**
+	 * A helper returning a collection of the `codeBlock` dropdown items representing languages
+	 * available for the user to choose from.
+	 *
+	 * @private
+	 * @param {Array.<module:code-block/codeblock~CodeBlockLanguageDefinition>} normalizedLanguageDefs
+	 * @returns {Iterable.<module:ui/dropdown/utils~ListDropdownItemDefinition>}
+	 */
+	_getLanguageListItemDefinitions( normalizedLanguageDefs ) {
+		const editor = this.editor;
+		const command = editor.commands.get( 'codeBlock' );
+		const itemDefinitions = new Collection();
+
+		for ( const languageDef of normalizedLanguageDefs ) {
+			const definition = {
+				type: 'button',
+				model: new Model( {
+					_codeBlockLanguage: languageDef.language,
+					label: languageDef.label,
+					withText: true
+				} )
+			};
+
+			definition.model.bind( 'isOn' ).to( command, 'value', value => {
+				return value === definition.model._codeBlockLanguage;
+			} );
+
+			itemDefinitions.add( definition );
+		}
+
+		return itemDefinitions;
+	}
+}

+ 246 - 0
packages/ckeditor5-code-block/src/converters.js

@@ -0,0 +1,246 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module code-block/converters
+ */
+
+import {
+	rawSnippetTextToModelDocumentFragment,
+	getPropertyAssociation
+} from './utils';
+
+/**
+ * A model → view (both editing and data) converter for the `codeBlock` element.
+ *
+ * Sample input:
+ *
+ *		<codeBlock language="javascript">foo();<softBreak></softBreak>bar();</codeBlock>
+ *
+ * Sample output (editing):
+ *
+ *		<pre data-language="JavaScript"><code class="language-javascript">foo();<br />bar();</code></pre>
+ *
+ * Sample output (data, see {@link module:code-block/converters~modelToDataViewSoftBreakInsertion}):
+ *
+ *		<pre><code class="language-javascript">foo();\nbar();</code></pre>
+ *
+ * @param {module:engine/model/model~Model} model
+ * @param {Array.<module:code-block/codeblock~CodeBlockLanguageDefinition>} languageDefs The normalized language
+ * configuration passed to the feature.
+ * @param {Boolean} [useLabels=false] When `true`, the `<pre>` will get a `data-language` attribute with a
+ * human–readable label of the language. Used only in the editing.
+ * @returns {Function} Returns a conversion callback.
+ */
+export function modelToViewCodeBlockInsertion( model, languageDefs, useLabels = false ) {
+	// Language CSS classes:
+	//
+	//		{
+	//			php: 'language-php',
+	//			python: 'language-python',
+	//			javascript: 'js',
+	//			...
+	//		}
+	const languagesToClasses = getPropertyAssociation( languageDefs, 'language', 'class' );
+
+	// Language labels:
+	//
+	//		{
+	//			php: 'PHP',
+	//			python: 'Python',
+	//			javascript: 'JavaScript',
+	//			...
+	//		}
+	const languagesToLabels = getPropertyAssociation( languageDefs, 'language', 'label' );
+
+	return ( evt, data, conversionApi ) => {
+		const { writer, mapper, consumable } = conversionApi;
+
+		if ( !consumable.consume( data.item, 'insert' ) ) {
+			return;
+		}
+
+		const codeBlockLanguage = data.item.getAttribute( 'language' );
+		const targetViewPosition = mapper.toViewPosition( model.createPositionBefore( data.item ) );
+		const preAttributes = {};
+
+		// The attribute added only in the editing view.
+		if ( useLabels ) {
+			preAttributes[ 'data-language' ] = languagesToLabels[ codeBlockLanguage ];
+		}
+
+		const pre = writer.createContainerElement( 'pre', preAttributes );
+		const code = writer.createContainerElement( 'code', {
+			class: languagesToClasses[ codeBlockLanguage ] || null
+		} );
+
+		writer.insert( writer.createPositionAt( pre, 0 ), code );
+		writer.insert( targetViewPosition, pre );
+		mapper.bindElements( data.item, code );
+	};
+}
+
+/**
+ * A model → data-view converter for the new line (`softBreak`) separator.
+ *
+ * Sample input:
+ *
+ *		<codeBlock ...>foo();<softBreak></softBreak>bar();</codeBlock>
+ *
+ * Sample output:
+ *
+ *		<pre><code ...>foo();\nbar();</code></pre>
+ *
+ * @param {module:engine/model/model~Model} model
+ * @returns {Function} Returns a conversion callback.
+ */
+export function modelToDataViewSoftBreakInsertion( model ) {
+	return ( evt, data, conversionApi ) => {
+		if ( data.item.parent.name !== 'codeBlock' ) {
+			return;
+		}
+
+		const { writer, mapper, consumable } = conversionApi;
+
+		if ( !consumable.consume( data.item, 'insert' ) ) {
+			return;
+		}
+
+		const position = mapper.toViewPosition( model.createPositionBefore( data.item ) );
+
+		writer.insert( position, writer.createText( '\n' ) );
+	};
+}
+
+/**
+ * A view → model converter for `<pre>` with `<code>` html.
+ *
+ * Sample input:
+ *
+ *		<pre><code class="language-javascript">foo();\nbar();</code></pre>
+ *
+ * Sample output:
+ *
+ *		<codeBlock language="javascript">foo();<softBreak></softBreak>bar();</codeBlock>
+ *
+ * @param {module:engine/controller/datacontroller~DataController} dataController
+ * @param {Array.<module:code-block/codeblock~CodeBlockLanguageDefinition>} languageDefs The normalized language
+ * configuration passed to the feature.
+ * @returns {Function} Returns a conversion callback.
+ */
+export function dataViewToModelCodeBlockInsertion( dataController, languageDefs ) {
+	// Language names associated with CSS classes:
+	//
+	//		{
+	//			'language-php': 'php',
+	//			'language-python': 'python',
+	//			js: 'javascript',
+	//			...
+	//		}
+	const classesToLanguages = getPropertyAssociation( languageDefs, 'class', 'language' );
+	const defaultLanguageName = languageDefs[ 0 ].language;
+
+	return ( evt, data, conversionApi ) => {
+		const viewItem = data.viewItem;
+		const viewChild = viewItem.getChild( 0 );
+
+		if ( !viewChild || !viewChild.is( 'code' ) ) {
+			return;
+		}
+
+		const { consumable, writer } = conversionApi;
+
+		if ( !consumable.test( viewItem, { name: true } ) || !consumable.test( viewChild, { name: true } ) ) {
+			return;
+		}
+
+		const codeBlock = writer.createElement( 'codeBlock' );
+		const viewChildClasses = [ ...viewChild.getClassNames() ];
+
+		// As we're to associate each class with a model language, a lack of class (empty class) can be
+		// also associated with a language if the language definition was configured so. Pushing an empty
+		// string to make sure the association will work.
+		if ( !viewChildClasses.length ) {
+			viewChildClasses.push( '' );
+		}
+
+		// Figure out if any of the <code> element's class names is a valid programming
+		// language class. If so, use it on the model element (becomes the language of the entire block).
+		for ( const className of viewChildClasses ) {
+			const language = classesToLanguages[ className ];
+
+			if ( language ) {
+				writer.setAttribute( 'language', language, codeBlock );
+				break;
+			}
+		}
+
+		// If no language value was set, use the default language from the config.
+		if ( !codeBlock.hasAttribute( 'language' ) ) {
+			writer.setAttribute( 'language', defaultLanguageName, codeBlock );
+		}
+
+		const stringifiedElement = dataController.processor.toData( viewChild );
+		const textData = extractDataFromCodeElement( stringifiedElement );
+		const fragment = rawSnippetTextToModelDocumentFragment( writer, textData );
+
+		writer.append( fragment, codeBlock );
+
+		// Let's see if the codeBlock can be inserted the current modelCursor.
+		const splitResult = conversionApi.splitToAllowedParent( codeBlock, data.modelCursor );
+
+		// When there is no split result it means that we can't insert element to model tree,
+		// so let's skip it.
+		if ( !splitResult ) {
+			return;
+		}
+
+		// Insert element on allowed position.
+		writer.insert( codeBlock, splitResult.position );
+
+		consumable.consume( viewItem, { name: true } );
+		consumable.consume( viewChild, { name: true } );
+
+		const parts = conversionApi.getSplitParts( codeBlock );
+
+		// Set conversion result range.
+		data.modelRange = writer.createRange(
+			conversionApi.writer.createPositionBefore( codeBlock ),
+			conversionApi.writer.createPositionAfter( parts[ parts.length - 1 ] )
+		);
+
+		// If we had to split parent to insert our element then we want to continue conversion inside
+		// the split parent.
+		//
+		// before split:
+		//
+		//		<allowed><notAllowed>[]</notAllowed></allowed>
+		//
+		// after split:
+		//
+		//		<allowed>
+		//			<notAllowed></notAllowed>
+		//			<converted></converted>
+		//			<notAllowed>[]</notAllowed>
+		//		</allowed>
+		if ( splitResult.cursorParent ) {
+			data.modelCursor = writer.createPositionAt( splitResult.cursorParent, 0 );
+		} else {
+			// Otherwise just continue after the inserted element.
+			data.modelCursor = data.modelRange.end;
+		}
+	};
+}
+
+// Returns content of `<pre></pre>` with unescaped html inside.
+//
+// @param {String} stringifiedElement
+function extractDataFromCodeElement( stringifiedElement ) {
+	const data = new RegExp( /^<code[^>]*>([\S\s]*)<\/code>$/ ).exec( stringifiedElement )[ 1 ];
+
+	return data
+		.replace( /&lt;/g, '<' )
+		.replace( /&gt;/g, '>' );
+}

+ 98 - 0
packages/ckeditor5-code-block/src/indentcodeblockcommand.js

@@ -0,0 +1,98 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module code-block/indentcodeblockcommand
+ */
+
+import Command from '@ckeditor/ckeditor5-core/src/command';
+import {
+	getIndentOutdentPositions,
+	isModelSelectionInCodeBlock
+} from './utils';
+
+/**
+ * The code block indentation increase command plugin.
+ *
+ * @extends module:core/command~Command
+ */
+export default class IndentCodeBlockCommand extends Command {
+	constructor( editor ) {
+		super( editor );
+
+		/**
+		 * A sequence of characters added to the line when the command is executed.
+		 *
+		 * @readonly
+		 * @private
+		 * @member {String}
+		 */
+		this._indentSequence = editor.config.get( 'codeBlock.indentSequence' );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	refresh() {
+		this.isEnabled = this._checkEnabled();
+	}
+
+	/**
+	 * Executes the command. When the command {@link #isEnabled is enabled}, the indentation of the
+	 * code lines in the selection will be increased.
+	 *
+	 * @fires execute
+	 */
+	execute() {
+		const editor = this.editor;
+		const model = editor.model;
+
+		model.change( writer => {
+			const positions = getIndentOutdentPositions( model );
+
+			// Indent all positions, for instance assuming the indent sequence is 4x space ("    "):
+			//
+			//		<codeBlock>^foo</codeBlock>        ->       <codeBlock>    foo</codeBlock>
+			//
+			//		<codeBlock>foo^bar</codeBlock>     ->       <codeBlock>foo    bar</codeBlock>
+			//
+			// Also, when there is more than one position:
+			//
+			//		<codeBlock>
+			//			^foobar
+			//			<softBreak></softBreak>
+			//			^bazqux
+			//		</codeBlock>
+			//
+			//		->
+			//
+			//		<codeBlock>
+			//			    foobar
+			//			<softBreak></softBreak>
+			//			    bazqux
+			//		</codeBlock>
+			//
+			for ( const position of positions ) {
+				writer.insertText( this._indentSequence, position );
+			}
+		} );
+	}
+
+	/**
+	 * Checks whether the command can be enabled in the current context.
+	 *
+	 * @private
+	 * @returns {Boolean} Whether the command should be enabled.
+	 */
+	_checkEnabled() {
+		if ( !this._indentSequence ) {
+			return false;
+		}
+
+		// Indent (forward) command is always enabled when there's any code block in the selection
+		// because you can always indent code lines.
+		return isModelSelectionInCodeBlock( this.editor.model.document.selection );
+	}
+}

+ 187 - 0
packages/ckeditor5-code-block/src/outdentcodeblockcommand.js

@@ -0,0 +1,187 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module code-block/outdentcodeblockcommand
+ */
+
+import Command from '@ckeditor/ckeditor5-core/src/command';
+import {
+	getLeadingWhiteSpaces,
+	getIndentOutdentPositions,
+	isModelSelectionInCodeBlock
+} from './utils';
+
+/**
+ * The code block indentation decrease command plugin.
+ *
+ * @extends module:core/command~Command
+ */
+export default class OutdentCodeBlockCommand extends Command {
+	constructor( editor ) {
+		super( editor );
+
+		/**
+		 * A sequence of characters or removed from the line when the command is executed.
+		 *
+		 * @readonly
+		 * @private
+		 * @member {String}
+		 */
+		this._indentSequence = editor.config.get( 'codeBlock.indentSequence' );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	refresh() {
+		this.isEnabled = this._checkEnabled();
+	}
+
+	/**
+	 * Executes the command. When the command {@link #isEnabled is enabled}, the indentation of the
+	 * code lines in the selection will be decreased.
+	 *
+	 * @fires execute
+	 */
+	execute() {
+		const editor = this.editor;
+		const model = editor.model;
+
+		model.change( writer => {
+			const positions = getIndentOutdentPositions( model );
+
+			// Outdent all positions, for instance assuming the indent sequence is 4x space ("    "):
+			//
+			//		<codeBlock>^foo</codeBlock>         ->       <codeBlock>foo</codeBlock>
+			//
+			//		<codeBlock>    ^bar</codeBlock>     ->       <codeBlock>bar</codeBlock>
+			//
+			// Also, when there is more than one position:
+			//
+			//		<codeBlock>
+			//			    ^foobar
+			//			<softBreak></softBreak>
+			//			    ^bazqux
+			//		</codeBlock>
+			//
+			//		->
+			//
+			//		<codeBlock>
+			//			foobar
+			//			<softBreak></softBreak>
+			//			bazqux
+			//		</codeBlock>
+			for ( const position of positions ) {
+				const range = getLastOutdentableSequenceRange( this.editor.model, position, this._indentSequence );
+
+				if ( range ) {
+					writer.remove( range );
+				}
+			}
+		} );
+	}
+
+	/**
+	 * Checks whether the command can be enabled in the current context.
+	 *
+	 * @private
+	 * @returns {Boolean} Whether the command should be enabled.
+	 */
+	_checkEnabled() {
+		if ( !this._indentSequence ) {
+			return false;
+		}
+
+		const model = this.editor.model;
+
+		if ( !isModelSelectionInCodeBlock( model.document.selection ) ) {
+			return false;
+		}
+
+		// Outdent command can execute only when there is an indent character sequence
+		// in some of the lines.
+		return getIndentOutdentPositions( model ).some( position => {
+			return getLastOutdentableSequenceRange( model, position, this._indentSequence );
+		} );
+	}
+}
+
+// For a position coming from `getIndentOutdentPositions()`, it returns the range representing
+// the last occurrence of the indent sequence among the leading whitespaces of the code line the
+// position represents.
+//
+// For instance, assuming the indent sequence is 4x space ("    "):
+//
+//		<codeBlock>foo^</codeBlock>                                 ->          null
+//		<codeBlock>foo^<softBreak></softBreak>bar</codeBlock>       ->          null
+//		<codeBlock>  ^foo</codeBlock>                               ->          null
+//		<codeBlock>        ^foo</codeBlock>                         ->          <codeBlock>    [    ]foo</codeBlock>
+//		<codeBlock>    ^foo    bar</codeBlock>                      ->          <codeBlock>[    ]foo    bar</codeBlock>
+//
+// @param {<module:engine/model/model~Model>} model
+// @param {<module:engine/model/position~Position>} position
+// @param {String} sequence
+// @returns {<module:engine/model/range~Range>|null}
+function getLastOutdentableSequenceRange( model, position, sequence ) {
+	// Positions start before each text node (code line). Get the node corresponding to the position.
+	const nodeAtPosition = getCodeLineTextNodeAtPosition( position );
+
+	if ( !nodeAtPosition ) {
+		return null;
+	}
+
+	const leadingWhiteSpaces = getLeadingWhiteSpaces( nodeAtPosition );
+	const lastIndexOfSequence = leadingWhiteSpaces.lastIndexOf( sequence );
+
+	// For instance, assuming the indent sequence is 4x space ("    "):
+	//
+	//		<codeBlock>    	^foo</codeBlock>           ->             null
+	//
+	if ( lastIndexOfSequence + sequence.length !== leadingWhiteSpaces.length ) {
+		return null;
+	}
+
+	// For instance, assuming the indent sequence is 4x space ("    "):
+	//
+	//		<codeBlock>  ^foo</codeBlock>           ->             null
+	//
+	if ( lastIndexOfSequence === -1 ) {
+		return null;
+	}
+
+	const { parent, startOffset } = nodeAtPosition;
+
+	// Create a range that contains the **last** indent sequence among the leading whitespaces
+	// of the line.
+	//
+	// For instance, assuming the indent sequence is 4x space ("    "):
+	//
+	//		<codeBlock>        ^foo</codeBlock>      ->     <codeBlock>    [    ]foo</codeBlock>
+	//
+	return model.createRange(
+		model.createPositionAt( parent, startOffset + lastIndexOfSequence ),
+		model.createPositionAt( parent, startOffset + lastIndexOfSequence + sequence.length )
+	);
+}
+
+function getCodeLineTextNodeAtPosition( position ) {
+	// Positions start before each text node (code line). Get the node corresponding to the position.
+	let nodeAtPosition = position.parent.getChild( position.index );
+
+	// <codeBlock>foo^</codeBlock>
+	// <codeBlock>foo^<softBreak></softBreak>bar</codeBlock>
+	if ( !nodeAtPosition || nodeAtPosition.is( 'softBreak' ) ) {
+		nodeAtPosition = position.nodeBefore;
+	}
+
+	// <codeBlock>^</codeBlock>
+	// <codeBlock>foo^<softBreak></softBreak>bar</codeBlock>
+	if ( !nodeAtPosition || nodeAtPosition.is( 'softBreak' ) ) {
+		return null;
+	}
+
+	return nodeAtPosition;
+}

+ 206 - 0
packages/ckeditor5-code-block/src/utils.js

@@ -0,0 +1,206 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module code-block/utils
+ */
+
+import first from '@ckeditor/ckeditor5-utils/src/first';
+
+/**
+ * Returns code block languages as defined in `config.codeBlock.languages` but processed:
+ *
+ * * to consider the editor localization, i.e. to display {@link module:code-block/codeblock~CodeBlockLanguageDefinition}
+ * in the correct language — there is no way to use {@link module:utils/locale~Locale#t} when the user
+ * configuration is defined because the editor does not exist yet.
+ * * to make sure each definition has a CSS class associated with it even if not specified
+ * in the original config.
+ *
+ * @param {module:core/editor/editor~Editor} editor
+ * @returns {Array.<module:code-block/codeblock~CodeBlockLanguageDefinition>}.
+ */
+export function getNormalizedAndLocalizedLanguageDefinitions( editor ) {
+	const t = editor.t;
+	const languageDefs = editor.config.get( 'codeBlock.languages' );
+
+	for ( const def of languageDefs ) {
+		if ( def.label === 'Plain text' ) {
+			def.label = t( 'Plain text' );
+		}
+
+		if ( def.class === undefined ) {
+			def.class = `language-${ def.language }`;
+		}
+	}
+
+	return languageDefs;
+}
+
+/**
+ * Returns an object associating certain language definition properties with another. For instance:
+ *
+ * For:
+ *
+ *		const definitions = {
+ *			{ language: 'php', class: 'language-php', label: 'PHP' },
+ *			{ language: 'javascript', class: 'js', label: 'JavaScript' },
+ *		};
+ *
+ *		getPropertyAssociation( definitions, 'class', 'language' );
+ *
+ * returns:
+ *
+ *		{
+ *			'language-php': 'php'
+ *			'js': 'javascript'
+ *		}
+ *
+ * and
+ *
+ *		getPropertyAssociation( definitions, 'language', 'label' );
+ *
+ * returns:
+ *
+ *		{
+ *			'php': 'PHP'
+ *			'javascript': 'JavaScript'
+ *		}
+ *
+ * @param {Array.<module:code-block/codeblock~CodeBlockLanguageDefinition>}
+ * @param {String} key
+ * @param {String} value
+ * @param {Object.<String,String>}
+ */
+export function getPropertyAssociation( languageDefs, key, value ) {
+	return Object.assign( {}, ...languageDefs.map( def => ( { [ def[ key ] ]: def[ value ] } ) ) );
+}
+
+/**
+ * For a given model text node, it returns white spaces that precede other characters in that node.
+ * This corresponds to the indentation part of the code block line.
+ *
+ * @param {module:engine/model/text~Text} codeLineNodes
+ * @returns {String}
+ */
+export function getLeadingWhiteSpaces( textNode ) {
+	return textNode.data.match( /^(\s*)/ )[ 0 ];
+}
+
+/**
+ * For a plain text containing the code (snippet), it returns a document fragment containing
+ * model text nodes separated by soft breaks (in place of new line characters "\n"), for instance:
+ *
+ * Input:
+ *
+ *		"foo()\n
+ *		bar()"
+ *
+ * Output:
+ *
+ *		<DocumentFragment>
+ *			"foo()"
+ *			<softBreak></softBreak>
+ *			"bar()"
+ *		</DocumentFragment>
+ *
+ * @param {module:engine/model/writer~Writer} writer
+ * @param {String} text A raw code text to be converted.
+ */
+export function rawSnippetTextToModelDocumentFragment( writer, text ) {
+	const fragment = writer.createDocumentFragment();
+	const textLines = text.split( '\n' ).map( data => writer.createText( data ) );
+	const lastLine = textLines[ textLines.length - 1 ];
+
+	for ( const node of textLines ) {
+		writer.append( node, fragment );
+
+		if ( node !== lastLine ) {
+			writer.appendElement( 'softBreak', fragment );
+		}
+	}
+
+	return fragment;
+}
+
+/**
+ * Returns an array of all model positions within the selection that represent code block lines.
+ *
+ * If the selection is collapsed, it returns the exact selection anchor position:
+ *
+ *		<codeBlock>[]foo</codeBlock>        ->     <codeBlock>^foo</codeBlock>
+ *		<codeBlock>foo[]bar</codeBlock>     ->     <codeBlock>foo^bar</codeBlock>
+ *
+ * Otherwise, it returns positions **before** each text node belonging to all code blocks contained by the selection:
+ *
+ *		<codeBlock>                                <codeBlock>
+ *		    foo[bar                                   ^foobar
+ *		    <softBreak></softBreak>         ->        <softBreak></softBreak>
+ *		    baz]qux                                   ^bazqux
+ *		</codeBlock>                               </codeBlock>
+ *
+ * it also works across other non–code blocks:
+ *
+ *		<codeBlock>                                <codeBlock>
+ *		    foo[bar                                   ^foobar
+ *		</codeBlock>                               </codeBlock>
+ *		<paragraph>text</paragraph>         ->     <paragraph>text</paragraph>
+ *		<codeBlock>                                <codeBlock>
+ *		    baz]qux                                   ^bazqux
+ *		</codeBlock>                               </codeBlock>
+ *
+ * **Note:** The positions are in the reverse order so they do not get outdated when iterating over them and
+ * the writer inserts or removes things.
+ *
+ * **Note:** The position is situated after the leading white spaces in the text node.
+ *
+ * @param {module:engine/model/model~Model} model
+ * @returns {Array.<module:engine/model/position~Position>}
+ */
+export function getIndentOutdentPositions( model ) {
+	const selection = model.document.selection;
+	const positions = [];
+
+	// When the selection is collapsed, there's only one position we can indent or outdent.
+	if ( selection.isCollapsed ) {
+		positions.push( selection.anchor );
+	}
+
+	// When the selection is NOT collapsed, collect all positions starting before text nodes
+	// (code lines) in any <codeBlock> within the selection.
+	else {
+		// Walk backward so positions we're about to collect here do not get outdated when
+		// inserting or deleting using the writer.
+		const walker = selection.getFirstRange().getWalker( {
+			ignoreElementEnd: true,
+			direction: 'backward'
+		} );
+
+		for ( const { item } of walker ) {
+			if ( item.is( 'textProxy' ) && item.parent.is( 'codeBlock' ) ) {
+				const leadingWhiteSpaces = getLeadingWhiteSpaces( item.textNode );
+				const { parent, startOffset } = item.textNode;
+
+				// Make sure the position is after all leading whitespaces in the text node.
+				const position = model.createPositionAt( parent, startOffset + leadingWhiteSpaces.length );
+
+				positions.push( position );
+			}
+		}
+	}
+
+	return positions;
+}
+
+/**
+ * Checks if any of the blocks within model selection is a code block.
+ *
+ * @param {module:engine/model/selection~Selection} selection
+ * @returns {Boolean}
+ */
+export function isModelSelectionInCodeBlock( selection ) {
+	const firstBlock = first( selection.getSelectedBlocks() );
+
+	return firstBlock && firstBlock.is( 'codeBlock' );
+}

+ 18 - 0
packages/ckeditor5-code-block/tests/codeblock.js

@@ -0,0 +1,18 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import CodeBlock from '../src/codeblock';
+import CodeBlockEditing from '../src/codeblockediting';
+import CodeBlockUI from '../src/codeblockui';
+
+describe( 'CodeBlock', () => {
+	it( 'should require CodeBlockEditing and CodeBlockUI plugins', () => {
+		expect( CodeBlock.requires ).to.have.members( [ CodeBlockEditing, CodeBlockUI ] );
+	} );
+
+	it( 'should define pluginName', () => {
+		expect( CodeBlock.pluginName ).to.equal( 'CodeBlock' );
+	} );
+} );

+ 281 - 0
packages/ckeditor5-code-block/tests/codeblockcommand.js

@@ -0,0 +1,281 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import CodeBlockEditing from '../src/codeblockediting';
+import CodeBlockCommand from '../src/codeblockcommand';
+
+import AlignmentEditing from '@ckeditor/ckeditor5-alignment/src/alignmentediting';
+import BoldEditing from '@ckeditor/ckeditor5-basic-styles/src/bold/boldediting';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import BlockQuoteEditing from '@ckeditor/ckeditor5-block-quote/src/blockquoteediting';
+
+import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+describe( 'CodeBlockCommand', () => {
+	let editor, model, command;
+
+	beforeEach( () => {
+		return ModelTestEditor
+			.create( {
+				plugins: [ CodeBlockEditing, Paragraph, BlockQuoteEditing, AlignmentEditing, BoldEditing ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+				command = new CodeBlockCommand( editor );
+			} );
+	} );
+
+	afterEach( () => {
+		return editor.destroy();
+	} );
+
+	describe( '#value', () => {
+		it( 'should be true when the first selected element is a codeBlock element (selection inside code block)', () => {
+			setModelData( model, '<codeBlock language="foo">f[]oo</codeBlock>' );
+
+			expect( command.value ).to.equal( 'foo' );
+		} );
+
+		it( 'should be true when the first selected element is a codeBlock element (other blocks in selection are not code block)', () => {
+			setModelData( model, '<codeBlock language="foo">f[oo</codeBlock><paragraph>ba]r</paragraph>' );
+
+			expect( command.value ).to.equal( 'foo' );
+		} );
+
+		it( 'should be false when the first selected element is not a code block (all blocks are not code block)', () => {
+			setModelData( model, '<paragraph>f[]oo</paragraph>' );
+
+			expect( command.value ).to.equal( false );
+		} );
+
+		it( 'should be false when the first selected element is not a code block (selection ends in code block)', () => {
+			setModelData( model, '<paragraph>f[oo</paragraph><codeBlock language="foo">ba]r</codeBlock>' );
+
+			expect( command.value ).to.equal( false );
+		} );
+	} );
+
+	describe( '#isEnabled', () => {
+		it( 'should be true when the first selected block is a codeBlock (selection inside code block)', () => {
+			setModelData( model, '<codeBlock language="foo">f[]oo</codeBlock>' );
+
+			expect( command.isEnabled ).to.equal( true );
+		} );
+
+		it( 'should be true when the first selected block is a codeBlock (other blocks in selection are not code block)', () => {
+			setModelData( model, '<codeBlock language="foo">f[oo</codeBlock><paragraph>ba]r</paragraph>' );
+
+			expect( command.isEnabled ).to.equal( true );
+		} );
+
+		it( 'should be true when the first selected block can be a codeBlock (collapsed selection)', () => {
+			setModelData( model, '<paragraph>f[]oo</paragraph>' );
+
+			expect( command.isEnabled ).to.equal( true );
+		} );
+
+		it( 'should be true when the first selected block can be a codeBlock (non-collapsed selection, ends in code block)', () => {
+			setModelData( model, '<paragraph>f[oo</paragraph><codeBlock language="foo">ba]r</codeBlock>' );
+
+			expect( command.isEnabled ).to.equal( true );
+		} );
+
+		it( 'should be false when selected element is a limit element (selection on element)', () => {
+			model.schema.register( 'limit', {
+				inheritAllFrom: '$block',
+				isLimit: true
+			} );
+
+			setModelData( model, '[<limit>foo</limit>]' );
+
+			expect( command.isEnabled ).to.equal( false );
+		} );
+
+		it( 'should be false when selection starts in a blockless space', () => {
+			model.schema.extend( '$text', { allowIn: '$root' } );
+
+			setModelData( model, 'x[]x' );
+
+			expect( command.isEnabled ).to.equal( false );
+		} );
+
+		it( 'should be false when selected element is a limit element (selection has mixed limit and non-limit elements)', () => {
+			model.schema.register( 'limit', {
+				inheritAllFrom: '$block',
+				isLimit: true
+			} );
+
+			setModelData( model, '<limit>f[oo</limit><paragraph>ba]r</paragraph>' );
+
+			expect( command.isEnabled ).to.equal( false );
+		} );
+
+		it( 'should be true when limit element is not the first selected element', () => {
+			model.schema.register( 'limit', {
+				inheritAllFrom: '$block',
+				isLimit: true
+			} );
+
+			setModelData( model, '<paragraph>f[oo</paragraph><limit>bar</limit><paragraph>bi]z</paragraph>' );
+
+			expect( command.isEnabled ).to.equal( true );
+		} );
+
+		it( 'should make it possible to disallow codeBlock using schema', () => {
+			model.schema.addChildCheck( ( context, childDef ) => {
+				if ( context.endsWith( 'blockQuote' ) && childDef.name === 'codeBlock' ) {
+					return false;
+				}
+			} );
+
+			setModelData( model, '<blockQuote><paragraph>f[o]o</paragraph></blockQuote>' );
+
+			expect( command.isEnabled ).to.equal( false );
+		} );
+	} );
+
+	describe( 'execute()', () => {
+		it( 'should change selected empty block to codeBlock', () => {
+			setModelData( model, '<paragraph>[]</paragraph>' );
+
+			command.execute();
+
+			expect( getModelData( model ) ).to.equal( '<codeBlock language="plaintext">[]</codeBlock>' );
+		} );
+
+		it( 'should change selected block to codeBlock', () => {
+			setModelData( model, '<paragraph>fo[]o</paragraph>' );
+
+			command.execute();
+
+			expect( getModelData( model ) ).to.equal( '<codeBlock language="plaintext">fo[]o</codeBlock>' );
+		} );
+
+		it( 'should change multiple selected block to codeBlock', () => {
+			setModelData( model, '<paragraph>f[oo</paragraph><paragraph>ba]r</paragraph>' );
+
+			command.execute();
+
+			expect( getModelData( model ) ).to.equal(
+				'<codeBlock language="plaintext">f[oo<softBreak></softBreak>ba]r</codeBlock>' );
+		} );
+
+		it( 'should merge selected blocks with selected codeBlocks', () => {
+			setModelData( model, '<paragraph>f[oo</paragraph><codeBlock language="plaintext">ba]r</codeBlock>' );
+
+			command.execute();
+
+			expect( getModelData( model ) ).to.equal(
+				'<codeBlock language="plaintext">f[oo<softBreak></softBreak>ba]r</codeBlock>' );
+		} );
+
+		it( 'should not merge codeBlock with siblings when siblings are not selected', () => {
+			setModelData( model,
+				'<codeBlock language="plaintext">foo</codeBlock>' +
+				'<paragraph>b[a]r</paragraph>' +
+				'<codeBlock language="plaintext">biz</codeBlock>'
+			);
+
+			command.execute();
+
+			expect( getModelData( model ) ).to.equal(
+				'<codeBlock language="plaintext">foo</codeBlock>' +
+				'<codeBlock language="plaintext">b[a]r</codeBlock>' +
+				'<codeBlock language="plaintext">biz</codeBlock>'
+			);
+		} );
+
+		it( 'should change selected empty codeBlock to paragraph', () => {
+			setModelData( model, '<codeBlock language="plaintext">[]</codeBlock>' );
+
+			command.execute();
+
+			expect( getModelData( model ) ).to.equal( '<paragraph>[]</paragraph>' );
+		} );
+
+		it( 'should change selected codeBlock to paragraph', () => {
+			setModelData( model, '<codeBlock language="plaintext">f[o]o</codeBlock>' );
+
+			command.execute();
+
+			expect( getModelData( model ) ).to.equal( '<paragraph>f[o]o</paragraph>' );
+		} );
+
+		it( 'should change selected multi-line codeBlock to paragraphs', () => {
+			setModelData( model,
+				'<codeBlock language="plaintext">foo<softBreak></softBreak>b[]ar<softBreak></softBreak>biz</codeBlock>'
+			);
+
+			command.execute();
+
+			expect( getModelData( model ) ).to.equal(
+				'<paragraph>foo</paragraph>' +
+				'<paragraph>b[]ar</paragraph>' +
+				'<paragraph>biz</paragraph>'
+			);
+		} );
+
+		it( 'should filter out attributes from nodes changed to codeBlock', () => {
+			setModelData( model, '<paragraph alignment="right"><$text bold="true">f[o]o</$text></paragraph>' );
+
+			command.execute();
+
+			expect( getModelData( model ) ).to.equal( '<codeBlock language="plaintext">f[o]o</codeBlock>' );
+		} );
+
+		it( 'should use forceValue parameter', () => {
+			setModelData( model, '<codeBlock language="plaintext">f[o]o</codeBlock>' );
+
+			command.execute( { forceValue: true } );
+
+			expect( getModelData( model ) ).to.equal( '<codeBlock language="plaintext">f[o]o</codeBlock>' );
+		} );
+
+		it( 'should allow setting the language of the new block', () => {
+			setModelData( model, '<paragraph>f[o]o</paragraph>' );
+
+			command.execute( { language: 'css' } );
+
+			expect( getModelData( model ) ).to.equal( '<codeBlock language="css">f[o]o</codeBlock>' );
+		} );
+
+		it( 'should allow changing the language of the existing block', () => {
+			setModelData( model, '<codeBlock language="plaintext">f[o]o</codeBlock>' );
+
+			command.execute( { language: 'css', forceValue: true } );
+
+			expect( getModelData( model ) ).to.equal( '<codeBlock language="css">f[o]o</codeBlock>' );
+		} );
+	} );
+
+	describe( 'BlockQuote integration', () => {
+		it( 'should change a paragraph inside a blockQuote to codeBlock', () => {
+			setModelData( model, '<blockQuote><paragraph>f[o]o</paragraph></blockQuote>' );
+
+			command.execute();
+
+			expect( getModelData( model ) ).to.equal(
+				'<blockQuote><codeBlock language="plaintext">f[o]o</codeBlock></blockQuote>' );
+		} );
+
+		it( 'should change a paragraph inside a blockQuote to codeBlock when blockQuote is selected with siblings', () => {
+			setModelData( model,
+				'<paragraph>f[oo</paragraph>' +
+				'<blockQuote><paragraph>bar</paragraph></blockQuote>' +
+				'<paragraph>bi]z</paragraph>'
+			);
+
+			command.execute();
+
+			expect( getModelData( model ) ).to.equal(
+				'<codeBlock language="plaintext">f[oo</codeBlock>' +
+				'<blockQuote><codeBlock language="plaintext">bar</codeBlock></blockQuote>' +
+				'<codeBlock language="plaintext">bi]z</codeBlock>'
+			);
+		} );
+	} );
+} );

+ 1146 - 0
packages/ckeditor5-code-block/tests/codeblockediting.js

@@ -0,0 +1,1146 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* global document */
+
+import CodeBlockEditing from '../src/codeblockediting';
+import CodeBlockCommand from '../src/codeblockcommand';
+import IndentCodeBlockCommand from '../src/indentcodeblockcommand';
+import OutdentCodeBlockCommand from '../src/outdentcodeblockcommand';
+
+import AlignmentEditing from '@ckeditor/ckeditor5-alignment/src/alignmentediting';
+import BoldEditing from '@ckeditor/ckeditor5-basic-styles/src/bold/boldediting';
+import Enter from '@ckeditor/ckeditor5-enter/src/enter';
+import ShiftEnter from '@ckeditor/ckeditor5-enter/src/shiftenter';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import Undo from '@ckeditor/ckeditor5-undo/src/undo';
+import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata';
+import IndentEditing from '@ckeditor/ckeditor5-indent/src/indentediting';
+
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+import { getCode } from '@ckeditor/ckeditor5-utils/src/keyboard';
+import { getData as getModelData, setData as setModelData, stringify } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
+
+import { _clear as clearTranslations, add as addTranslations } from '@ckeditor/ckeditor5-utils/src/translation-service';
+
+describe( 'CodeBlockEditing', () => {
+	let editor, element, model, view, viewDoc;
+
+	before( () => {
+		addTranslations( 'en', {
+			'Plain text': 'Plain text'
+		} );
+
+		addTranslations( 'pl', {
+			'Plain text': 'Zwykły tekst'
+		} );
+	} );
+
+	after( () => {
+		clearTranslations();
+	} );
+
+	beforeEach( () => {
+		element = document.createElement( 'div' );
+		document.body.appendChild( element );
+
+		return ClassicTestEditor
+			.create( element, {
+				language: 'en',
+				plugins: [ CodeBlockEditing, AlignmentEditing, BoldEditing, Enter, Paragraph, Undo ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+				view = editor.editing.view;
+				viewDoc = view.document;
+			} );
+	} );
+
+	afterEach( () => {
+		return editor.destroy().then( () => element.remove() );
+	} );
+
+	it( 'defines plugin name', () => {
+		expect( CodeBlockEditing.pluginName ).to.equal( 'CodeBlockEditing' );
+	} );
+
+	it( 'defines plugin dependencies', () => {
+		expect( CodeBlockEditing.requires ).to.have.members( [ ShiftEnter ] );
+	} );
+
+	describe( 'config', () => {
+		describe( 'languages', () => {
+			describe( 'default value', () => {
+				it( 'should be set', () => {
+					expect( editor.config.get( 'codeBlock.languages' ) ).to.deep.equal( [
+						{ language: 'plaintext', label: 'Plain text' },
+						{ language: 'c', label: 'C' },
+						{ language: 'cs', label: 'C#' },
+						{ language: 'cpp', label: 'C++' },
+						{ language: 'css', label: 'CSS' },
+						{ language: 'diff', label: 'Diff' },
+						{ language: 'xml', label: 'HTML/XML' },
+						{ language: 'java', label: 'Java' },
+						{ language: 'javascript', label: 'JavaScript' },
+						{ language: 'php', label: 'PHP' },
+						{ language: 'python', label: 'Python' },
+						{ language: 'ruby', label: 'Ruby' },
+						{ language: 'typescript', label: 'TypeScript' }
+					] );
+				} );
+			} );
+		} );
+
+		describe( 'indentSequence', () => {
+			describe( 'default value', () => {
+				it( 'should be set', () => {
+					expect( editor.config.get( 'codeBlock.indentSequence' ) ).to.equal( '	' );
+				} );
+			} );
+		} );
+	} );
+
+	it( 'adds a "codeBlock" command', () => {
+		expect( editor.commands.get( 'codeBlock' ) ).to.be.instanceOf( CodeBlockCommand );
+	} );
+
+	it( 'adds an "indentCodeBlock" command', () => {
+		expect( editor.commands.get( 'indentCodeBlock' ) ).to.be.instanceOf( IndentCodeBlockCommand );
+	} );
+
+	it( 'adds an "outdentCodeBlock" command', () => {
+		expect( editor.commands.get( 'outdentCodeBlock' ) ).to.be.instanceOf( OutdentCodeBlockCommand );
+	} );
+
+	it( 'allows for codeBlock in the $root', () => {
+		expect( model.schema.checkChild( [ '$root' ], 'codeBlock' ) ).to.be.true;
+	} );
+
+	it( 'disallows for codeBlock in the other codeBlock', () => {
+		expect( model.schema.checkChild( [ '$root', 'codeBlock' ], 'codeBlock' ) ).to.be.false;
+	} );
+
+	it( 'allows only for $text in codeBlock', () => {
+		expect( model.schema.checkChild( [ '$root', 'codeBlock' ], '$text' ) ).to.equal( true );
+		expect( model.schema.checkChild( [ '$root', 'codeBlock' ], '$block' ) ).to.equal( false );
+		expect( model.schema.checkChild( [ '$root', 'codeBlock' ], 'codeBlock' ) ).to.equal( false );
+	} );
+
+	it( 'disallows all attributes (except "language") for codeBlock', () => {
+		setModelData( model, '<codeBlock language="css">f[o]o</codeBlock>' );
+
+		editor.execute( 'alignment', { value: 'right' } );
+		editor.execute( 'bold' );
+
+		expect( getModelData( model ) ).to.equal( '<codeBlock language="css">f[o]o</codeBlock>' );
+	} );
+
+	describe( 'tab key handling', () => {
+		let domEvtDataStub;
+
+		beforeEach( () => {
+			domEvtDataStub = {
+				keyCode: getCode( 'Tab' ),
+				preventDefault: sinon.spy(),
+				stopPropagation: sinon.spy()
+			};
+
+			sinon.spy( editor, 'execute' );
+		} );
+
+		afterEach( () => {
+			editor.execute.restore();
+		} );
+
+		it( 'should execute indentCodeBlock command on tab key', () => {
+			setModelData( model, '<codeBlock language="plaintext">[]foo</codeBlock>' );
+
+			editor.editing.view.document.fire( 'keydown', domEvtDataStub );
+
+			sinon.assert.calledOnce( editor.execute );
+			sinon.assert.calledWithExactly( editor.execute, 'indentCodeBlock' );
+			sinon.assert.calledOnce( domEvtDataStub.preventDefault );
+			sinon.assert.calledOnce( domEvtDataStub.stopPropagation );
+		} );
+
+		it( 'should execute outdentCodeBlock command on Shift+Tab keystroke', () => {
+			domEvtDataStub.keyCode += getCode( 'Shift' );
+
+			setModelData( model, '<codeBlock language="plaintext">[]foo</codeBlock>' );
+
+			// '<codeBlock language="plaintext">	[]foo</codeBlock>
+			model.change( writer => {
+				writer.insertText( '	', model.document.getRoot().getChild( 0 ) );
+			} );
+
+			editor.editing.view.document.fire( 'keydown', domEvtDataStub );
+
+			sinon.assert.calledOnce( editor.execute );
+			sinon.assert.calledWithExactly( editor.execute, 'outdentCodeBlock' );
+			sinon.assert.calledOnce( domEvtDataStub.preventDefault );
+			sinon.assert.calledOnce( domEvtDataStub.stopPropagation );
+		} );
+
+		it( 'should not indent if command is disabled', () => {
+			setModelData( model, '<paragraph>[]foo</paragraph>' );
+
+			editor.editing.view.document.fire( 'keydown', domEvtDataStub );
+
+			expect( editor.execute.called ).to.be.false;
+			sinon.assert.notCalled( domEvtDataStub.preventDefault );
+			sinon.assert.notCalled( domEvtDataStub.stopPropagation );
+		} );
+
+		it( 'should not indent or outdent if alt+tab is pressed', () => {
+			domEvtDataStub.keyCode += getCode( 'alt' );
+
+			setModelData( model, '<codeBlock language="plaintext">[]foo</codeBlock>' );
+
+			editor.editing.view.document.fire( 'keydown', domEvtDataStub );
+
+			expect( editor.execute.called ).to.be.false;
+			sinon.assert.notCalled( domEvtDataStub.preventDefault );
+			sinon.assert.notCalled( domEvtDataStub.stopPropagation );
+		} );
+	} );
+
+	describe( 'enter key handling', () => {
+		it( 'should force shiftEnter command when pressing enter inside a codeBlock', () => {
+			const enterCommand = editor.commands.get( 'enter' );
+			const shiftEnterCommand = editor.commands.get( 'shiftEnter' );
+
+			sinon.spy( enterCommand, 'execute' );
+			sinon.spy( shiftEnterCommand, 'execute' );
+
+			setModelData( model, '<codeBlock>foo[]bar</codeBlock>' );
+
+			viewDoc.fire( 'enter', getEvent() );
+
+			expect( getModelData( model ) ).to.equal( '<codeBlock>foo<softBreak></softBreak>[]bar</codeBlock>' );
+			sinon.assert.calledOnce( shiftEnterCommand.execute );
+			sinon.assert.notCalled( enterCommand.execute );
+		} );
+
+		it( 'should execute enter command when pressing enter out of codeBlock', () => {
+			const enterCommand = editor.commands.get( 'enter' );
+			const shiftEnterCommand = editor.commands.get( 'shiftEnter' );
+
+			sinon.spy( enterCommand, 'execute' );
+			sinon.spy( shiftEnterCommand, 'execute' );
+
+			setModelData( model, '<paragraph>foo[]bar</paragraph>' );
+
+			viewDoc.fire( 'enter', getEvent() );
+
+			expect( getModelData( model ) ).to.equal( '<paragraph>foo</paragraph><paragraph>[]bar</paragraph>' );
+			sinon.assert.calledOnce( enterCommand.execute );
+			sinon.assert.notCalled( shiftEnterCommand.execute );
+		} );
+
+		describe( 'indentation retention', () => {
+			it( 'should work when indentation is with spaces', () => {
+				setModelData( model, '<codeBlock language="css">foo[]</codeBlock>' );
+
+				model.change( writer => {
+					// <codeBlock language="css">  foo[]</codeBlock>
+					writer.insertText( '  ', model.document.getRoot().getChild( 0 ), 0 );
+				} );
+
+				viewDoc.fire( 'enter', getEvent() );
+
+				expect( getModelData( model ) ).to.equal(
+					'<codeBlock language="css">  foo<softBreak></softBreak>  []</codeBlock>' );
+
+				editor.execute( 'undo' );
+
+				expect( getModelData( model ) ).to.equal( '<codeBlock language="css">  foo[]</codeBlock>' );
+			} );
+
+			it( 'should work when indentation is with tabs', () => {
+				setModelData( model, '<codeBlock language="css">foo[]</codeBlock>' );
+
+				model.change( writer => {
+					// <codeBlock language="css">	foo[]</codeBlock>
+					writer.insertText( '	', model.document.getRoot().getChild( 0 ), 0 );
+				} );
+
+				viewDoc.fire( 'enter', getEvent() );
+
+				expect( getModelData( model ) ).to.equal(
+					'<codeBlock language="css">	foo<softBreak></softBreak>	[]</codeBlock>' );
+
+				editor.execute( 'undo' );
+
+				expect( getModelData( model ) ).to.equal( '<codeBlock language="css">	foo[]</codeBlock>' );
+			} );
+
+			it( 'should retain only the last line', () => {
+				setModelData( model, '<codeBlock language="css">foo<softBreak></softBreak>bar[]</codeBlock>' );
+
+				model.change( writer => {
+					// <codeBlock language="css">  foo<softBreak></softBreak>	bar[]</codeBlock>
+					writer.insertText( '	', model.document.getRoot().getChild( 0 ), 4 );
+					writer.insertText( '  ', model.document.getRoot().getChild( 0 ), 0 );
+				} );
+
+				viewDoc.fire( 'enter', getEvent() );
+
+				expect( getModelData( model ) ).to.equal(
+					'<codeBlock language="css">  foo<softBreak></softBreak>	bar<softBreak></softBreak>	[]</codeBlock>' );
+
+				editor.execute( 'undo' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<codeBlock language="css">  foo<softBreak></softBreak>	bar[]</codeBlock>' );
+			} );
+
+			it( 'should retain when the selection is non–collapsed', () => {
+				setModelData( model, '<codeBlock language="css">f[o]o</codeBlock>' );
+
+				model.change( writer => {
+					// <codeBlock language="css">    f[o]o</codeBlock>
+					writer.insertText( '    ', model.document.getRoot().getChild( 0 ), 0 );
+				} );
+
+				viewDoc.fire( 'enter', getEvent() );
+
+				expect( getModelData( model ) ).to.equal(
+					'<codeBlock language="css">    f<softBreak></softBreak>    []o</codeBlock>' );
+
+				editor.execute( 'undo' );
+
+				expect( getModelData( model ) ).to.equal( '<codeBlock language="css">    f[o]o</codeBlock>' );
+			} );
+
+			it( 'should consider only leading white-spaces', () => {
+				setModelData( model, '<codeBlock language="css">foo[]</codeBlock>' );
+
+				model.change( writer => {
+					// <codeBlock language="css">  foo []</codeBlock>
+					writer.insertText( ' ', model.document.getRoot().getChild( 0 ), 3 );
+					writer.insertText( '  ', model.document.getRoot().getChild( 0 ), 0 );
+				} );
+
+				viewDoc.fire( 'enter', getEvent() );
+
+				expect( getModelData( model ) ).to.equal(
+					'<codeBlock language="css">  foo <softBreak></softBreak>  []</codeBlock>' );
+
+				editor.execute( 'undo' );
+
+				expect( getModelData( model ) ).to.equal( '<codeBlock language="css">  foo []</codeBlock>' );
+			} );
+
+			it( 'should not work when there is some non-whitespace character', () => {
+				setModelData( model, '<codeBlock language="css">foo[]</codeBlock>' );
+
+				model.change( writer => {
+					// <codeBlock language="css">foo   []</codeBlock>
+					writer.insertText( '   ', model.document.getRoot().getChild( 0 ), 3 );
+				} );
+
+				viewDoc.fire( 'enter', getEvent() );
+
+				expect( getModelData( model ) ).to.equal(
+					'<codeBlock language="css">foo   <softBreak></softBreak>[]</codeBlock>' );
+			} );
+		} );
+
+		describe( 'leaving block using the enter key', () => {
+			describe( 'leaving the block end', () => {
+				it( 'should leave the block when pressed twice at the end', () => {
+					const spy = sinon.spy( editor.editing.view, 'scrollToTheSelection' );
+
+					setModelData( model, '<codeBlock language="css">foo[]</codeBlock>' );
+
+					viewDoc.fire( 'enter', getEvent() );
+
+					expect( getModelData( model ) ).to.equal(
+						'<codeBlock language="css">foo<softBreak></softBreak>[]</codeBlock>' );
+
+					viewDoc.fire( 'enter', getEvent() );
+
+					expect( getModelData( model ) ).to.equal(
+						'<codeBlock language="css">foo</codeBlock>' +
+						'<paragraph>[]</paragraph>'
+					);
+
+					sinon.assert.calledOnce( spy );
+
+					editor.execute( 'undo' );
+					expect( getModelData( model ) ).to.equal(
+						'<codeBlock language="css">foo<softBreak></softBreak>[]</codeBlock>' );
+
+					editor.execute( 'undo' );
+					expect( getModelData( model ) ).to.equal( '<codeBlock language="css">foo[]</codeBlock>' );
+				} );
+
+				it( 'should not leave the block when the selection is not collapsed', () => {
+					setModelData( model, '<codeBlock language="css">f[oo<softBreak></softBreak>]</codeBlock>' );
+
+					viewDoc.fire( 'enter', getEvent() );
+
+					expect( getModelData( model ) ).to.equal(
+						'<codeBlock language="css">f<softBreak></softBreak>[]</codeBlock>' );
+				} );
+
+				it( 'should not leave the block when pressed twice when in the middle of the code', () => {
+					setModelData( model, '<codeBlock language="css">fo[]o</codeBlock>' );
+
+					viewDoc.fire( 'enter', getEvent() );
+
+					expect( getModelData( model ) ).to.equal(
+						'<codeBlock language="css">fo<softBreak></softBreak>[]o</codeBlock>' );
+
+					viewDoc.fire( 'enter', getEvent() );
+
+					expect( getModelData( model ) ).to.equal(
+						'<codeBlock language="css">fo<softBreak></softBreak><softBreak></softBreak>[]o</codeBlock>' );
+				} );
+
+				it( 'should not leave the block when pressed twice at the beginning of the code', () => {
+					setModelData( model, '<codeBlock language="css">[]foo</codeBlock>' );
+
+					viewDoc.fire( 'enter', getEvent() );
+
+					expect( getModelData( model ) ).to.equal(
+						'<codeBlock language="css"><softBreak></softBreak>[]foo</codeBlock>' );
+
+					viewDoc.fire( 'enter', getEvent() );
+
+					expect( getModelData( model ) ).to.equal(
+						'<codeBlock language="css"><softBreak></softBreak><softBreak></softBreak>[]foo</codeBlock>' );
+				} );
+
+				it( 'should not leave the block when pressed shift+enter twice at the end of the code', () => {
+					setModelData( model, '<codeBlock language="css">foo<softBreak></softBreak>[]</codeBlock>' );
+
+					viewDoc.fire( 'enter', getEvent( { isSoft: true } ) );
+
+					expect( getModelData( model ) ).to.equal(
+						'<codeBlock language="css">foo<softBreak></softBreak><softBreak></softBreak>[]</codeBlock>' );
+				} );
+
+				it( 'should clean up the last line if has white–space characters only', () => {
+					setModelData( model, '<codeBlock language="css">foo<softBreak></softBreak>[]</codeBlock>' );
+
+					model.change( writer => {
+						// <codeBlock language="css">foo<softBreak></softBreak>  []</codeBlock>
+						writer.insertText( '  ', model.document.getRoot().getChild( 0 ), 4 );
+					} );
+
+					viewDoc.fire( 'enter', getEvent() );
+
+					expect( getModelData( model ) ).to.equal(
+						'<codeBlock language="css">foo</codeBlock><paragraph>[]</paragraph>' );
+				} );
+			} );
+
+			describe( 'leaving the block at the beginning', () => {
+				it( 'should leave the block when pressed at the beginning in a new line', () => {
+					const spy = sinon.spy( editor.editing.view, 'scrollToTheSelection' );
+
+					setModelData( model, '<codeBlock language="css">[]<softBreak></softBreak>foo</codeBlock>' );
+
+					viewDoc.fire( 'enter', getEvent() );
+
+					expect( getModelData( model ) ).to.equal(
+						'<paragraph>[]</paragraph>' +
+						'<codeBlock language="css">foo</codeBlock>'
+					);
+
+					sinon.assert.calledOnce( spy );
+
+					editor.execute( 'undo' );
+					expect( getModelData( model ) ).to.equal(
+						'<codeBlock language="css">[]<softBreak></softBreak>foo</codeBlock>' );
+				} );
+
+				it( 'should not leave the block when the selection is not collapsed (#1)', () => {
+					setModelData( model, '<codeBlock language="css">[f]<softBreak></softBreak>oo</codeBlock>' );
+
+					viewDoc.fire( 'enter', getEvent() );
+
+					expect( getModelData( model ) ).to.equal(
+						'<codeBlock language="css"><softBreak></softBreak>[]<softBreak></softBreak>oo</codeBlock>' );
+				} );
+
+				it( 'should not leave the block when the selection is not collapsed (#2)', () => {
+					setModelData( model, '<codeBlock language="css">[<softBreak></softBreak>oo]</codeBlock>' );
+
+					viewDoc.fire( 'enter', getEvent() );
+
+					expect( getModelData( model ) ).to.equal(
+						'<codeBlock language="css"><softBreak></softBreak>[]</codeBlock>' );
+				} );
+
+				it( 'should not leave the block when pressed shift+enter at the beginning of the code', () => {
+					setModelData( model, '<codeBlock language="css">[]<softBreak></softBreak>foo</codeBlock>' );
+
+					viewDoc.fire( 'enter', getEvent( { isSoft: true } ) );
+
+					expect( getModelData( model ) ).to.equal(
+						'<codeBlock language="css"><softBreak></softBreak>[]<softBreak></softBreak>foo</codeBlock>' );
+				} );
+
+				it( 'should not leave the block when there is some text after the selection', () => {
+					setModelData( model, '<codeBlock language="css">[]foo<softBreak></softBreak>foo</codeBlock>' );
+
+					viewDoc.fire( 'enter', getEvent() );
+
+					expect( getModelData( model ) ).to.equal(
+						'<codeBlock language="css"><softBreak></softBreak>[]foo<softBreak></softBreak>foo</codeBlock>' );
+				} );
+
+				it( 'should not leave the block when there is some text before the selection', () => {
+					setModelData( model, '<codeBlock language="css">[]<softBreak></softBreak>foo</codeBlock>' );
+
+					// <codeBlock language="css">    []<softBreak></softBreak>foo</codeBlock>
+					model.change( writer => {
+						writer.insertText( '    ', model.document.getRoot().getChild( 0 ), 0 );
+					} );
+
+					viewDoc.fire( 'enter', getEvent() );
+
+					// Extra spaces before "[]" come from the indentation retention mechanism.
+					expect( getModelData( model ) ).to.equal(
+						'<codeBlock language="css">    <softBreak></softBreak>    []<softBreak></softBreak>foo</codeBlock>' );
+				} );
+			} );
+		} );
+
+		function getEvent( data = {} ) {
+			return new DomEventData( viewDoc, {
+				preventDefault: sinon.spy()
+			}, data );
+		}
+	} );
+
+	describe( 'indent plugin integration', () => {
+		it( 'should add indent code block command to indent command', () => {
+			const element = document.createElement( 'div' );
+			document.body.appendChild( element );
+
+			return ClassicTestEditor
+				.create( element, {
+					plugins: [ CodeBlockEditing, AlignmentEditing, BoldEditing, Enter, Paragraph, Undo, IndentEditing ]
+				} )
+				.then( newEditor => {
+					const editor = newEditor;
+
+					const indentCodeBlockCommand = editor.commands.get( 'indentCodeBlock' );
+					const indentCommand = editor.commands.get( 'indent' );
+					const spy = sinon.spy( indentCodeBlockCommand, 'execute' );
+
+					indentCodeBlockCommand.isEnabled = true;
+					indentCommand.execute();
+
+					sinon.assert.calledOnce( spy );
+
+					element.remove();
+
+					return editor.destroy();
+				} );
+		} );
+
+		it( 'should add outdent code block command to outdent command', () => {
+			return ClassicTestEditor
+				.create( element, {
+					plugins: [ CodeBlockEditing, AlignmentEditing, BoldEditing, Enter, Paragraph, Undo, IndentEditing ]
+				} )
+				.then( newEditor => {
+					const editor = newEditor;
+
+					const outdentCodeBlockCommand = editor.commands.get( 'outdentCodeBlock' );
+					const outdentCommand = editor.commands.get( 'outdent' );
+					const spy = sinon.spy( outdentCodeBlockCommand, 'execute' );
+
+					outdentCodeBlockCommand.isEnabled = true;
+					outdentCommand.execute();
+
+					sinon.assert.calledOnce( spy );
+
+					element.remove();
+
+					return editor.destroy();
+				} );
+		} );
+	} );
+
+	describe( 'editing pipeline m -> v', () => {
+		it( 'should convert empty codeBlock to empty pre tag', () => {
+			setModelData( model, '<codeBlock language="plaintext"></codeBlock>' );
+
+			expect( getViewData( view ) ).to.equal( '<pre data-language="Plain text"><code class="language-plaintext">[]</code></pre>' );
+		} );
+
+		it( 'should convert non-empty codeBlock to pre tag', () => {
+			setModelData( model, '<codeBlock language="plaintext">Foo</codeBlock>' );
+
+			expect( getViewData( view ) ).to.equal( '<pre data-language="Plain text"><code class="language-plaintext">{}Foo</code></pre>' );
+		} );
+
+		it( 'should convert codeBlock with softBreaks to pre tag #1', () => {
+			setModelData( model,
+				'<codeBlock language="plaintext">' +
+					'Foo<softBreak></softBreak>' +
+					'Bar<softBreak></softBreak>' +
+					'Biz' +
+				'</codeBlock>'
+			);
+
+			expect( getViewData( view ) ).to.equal(
+				'<pre data-language="Plain text">' +
+					'<code class="language-plaintext">{}Foo<br></br>Bar<br></br>Biz</code>' +
+				'</pre>' );
+		} );
+
+		it( 'should convert codeBlock with softBreaks to pre tag #2', () => {
+			setModelData( model,
+				'<codeBlock language="plaintext">' +
+					'<softBreak></softBreak>' +
+					'<softBreak></softBreak>' +
+					'Foo' +
+					'<softBreak></softBreak>' +
+					'<softBreak></softBreak>' +
+				'</codeBlock>'
+			);
+
+			expect( getViewData( view ) ).to.equal(
+				'<pre data-language="Plain text">' +
+					'<code class="language-plaintext">[]<br></br><br></br>Foo<br></br><br></br></code>' +
+				'</pre>' );
+		} );
+
+		it( 'should use localized "Plain text" label', () => {
+			const element = document.createElement( 'div' );
+			document.body.appendChild( element );
+
+			return ClassicTestEditor
+				.create( element, {
+					language: 'pl',
+					plugins: [ CodeBlockEditing, AlignmentEditing, BoldEditing, Enter, Paragraph ]
+				} )
+				.then( newEditor => {
+					const editor = newEditor;
+					const model = editor.model;
+					const view = editor.editing.view;
+
+					setModelData( model,
+						'<codeBlock language="plaintext">foo</codeBlock>'
+					);
+
+					expect( getViewData( view ) ).to.equal(
+						'<pre data-language="Zwykły tekst">' +
+							'<code class="language-plaintext">{}foo</code>' +
+						'</pre>' );
+
+					element.remove();
+
+					return editor.destroy();
+				} );
+		} );
+
+		it( 'should not set the class on the <code> if it was configured so', () => {
+			const element = document.createElement( 'div' );
+			document.body.appendChild( element );
+
+			return ClassicTestEditor
+				.create( element, {
+					plugins: [ CodeBlockEditing, AlignmentEditing, BoldEditing, Enter, Paragraph ],
+					codeBlock: {
+						languages: [
+							{ language: 'cpp', label: 'C++', class: '' },
+						]
+					}
+				} )
+				.then( newEditor => {
+					const editor = newEditor;
+					const model = editor.model;
+					const view = editor.editing.view;
+
+					setModelData( model, '<codeBlock language="cpp">foo</codeBlock>' );
+
+					expect( getViewData( view ) ).to.equal(
+						'<pre data-language="C++">' +
+							'<code>{}foo</code>' +
+						'</pre>' );
+
+					element.remove();
+
+					return editor.destroy();
+				} );
+		} );
+	} );
+
+	describe( 'data pipeline m -> v conversion ', () => {
+		it( 'should convert empty codeBlock to empty pre tag', () => {
+			setModelData( model, '<codeBlock language="plaintext"></codeBlock>' );
+
+			expect( editor.getData( { trim: 'none' } ) ).to.equal( '<pre><code class="language-plaintext">&nbsp;</code></pre>' );
+		} );
+
+		it( 'should convert non-empty codeBlock to pre tag', () => {
+			setModelData( model, '<codeBlock language="plaintext">Foo</codeBlock>' );
+
+			expect( editor.getData() ).to.equal( '<pre><code class="language-plaintext">Foo</code></pre>' );
+		} );
+
+		it( 'should convert codeBlock with softBreaks to pre tag #1', () => {
+			setModelData( model,
+				'<codeBlock language="plaintext">' +
+					'Foo<softBreak></softBreak>' +
+					'Bar<softBreak></softBreak>' +
+					'Biz' +
+				'</codeBlock>' +
+				'<paragraph>A<softBreak></softBreak>B</paragraph>'
+			);
+
+			expect( editor.getData() ).to.equal( '<pre><code class="language-plaintext">Foo\nBar\nBiz</code></pre><p>A<br>B</p>' );
+		} );
+
+		it( 'should convert codeBlock with softBreaks to pre tag #2', () => {
+			setModelData( model,
+				'<codeBlock language="plaintext">' +
+					'<softBreak></softBreak>' +
+					'<softBreak></softBreak>' +
+					'Foo' +
+					'<softBreak></softBreak>' +
+					'<softBreak></softBreak>' +
+				'</codeBlock>'
+			);
+
+			expect( editor.getData() ).to.equal( '<pre><code class="language-plaintext">\n\nFoo\n\n</code></pre>' );
+		} );
+
+		it( 'should convert codeBlock with html content', () => {
+			setModelData( model, '<codeBlock language="plaintext">[]</codeBlock>' );
+
+			model.change( writer => writer.insertText( '<div><p>Foo</p></div>', model.document.selection.getFirstPosition() ) );
+
+			expect( editor.getData() ).to.equal(
+				'<pre>' +
+					'<code class="language-plaintext">&lt;div&gt;&lt;p&gt;Foo&lt;/p&gt;&lt;/div&gt;</code>' +
+				'</pre>' );
+		} );
+
+		it( 'should be overridable', () => {
+			editor.data.downcastDispatcher.on( 'insert:codeBlock', ( evt, data, api ) => {
+				const targetViewPosition = api.mapper.toViewPosition( model.createPositionBefore( data.item ) );
+				const code = api.writer.createContainerElement( 'code' );
+
+				api.consumable.consume( data.item, 'insert' );
+				api.writer.insert( targetViewPosition, code );
+				api.mapper.bindElements( data.item, code );
+			}, { priority: 'high' } );
+
+			editor.data.downcastDispatcher.on( 'insert:softBreak', ( evt, data, api ) => {
+				const position = api.mapper.toViewPosition( model.createPositionBefore( data.item ) );
+
+				api.consumable.consume( data.item, 'insert' );
+				api.writer.insert( position, api.writer.createText( '\n' ) );
+			}, { priority: 'highest' } );
+
+			setModelData( model, '<codeBlock language="plaintext">Foo<softBreak></softBreak>Bar</codeBlock>' );
+
+			expect( editor.getData() ).to.equal( '<code>Foo\nBar</code>' );
+		} );
+
+		it( 'should not set the class on the <code> if it was configured so', () => {
+			const element = document.createElement( 'div' );
+			document.body.appendChild( element );
+
+			return ClassicTestEditor
+				.create( element, {
+					plugins: [ CodeBlockEditing, AlignmentEditing, BoldEditing, Enter, Paragraph ],
+					codeBlock: {
+						languages: [
+							{ language: 'cpp', label: 'C++', class: '' },
+						]
+					}
+				} )
+				.then( newEditor => {
+					const editor = newEditor;
+					const model = editor.model;
+
+					setModelData( model, '<codeBlock language="cpp">foo</codeBlock>' );
+					expect( editor.getData() ).to.equal( '<pre><code>foo</code></pre>' );
+
+					element.remove();
+
+					return editor.destroy();
+				} );
+		} );
+	} );
+
+	describe( 'data pipeline v -> m conversion ', () => {
+		it( 'should not convert empty pre tag to code block', () => {
+			editor.setData( '<pre></pre>' );
+
+			expect( getModelData( model ) ).to.equal( '<paragraph>[]</paragraph>' );
+		} );
+
+		it( 'should not convert pre with no code child to code block', () => {
+			editor.setData( '<pre><samp></samp></pre>' );
+
+			expect( getModelData( model ) ).to.equal( '<paragraph>[]</paragraph>' );
+		} );
+
+		it( 'should convert pre > code to code block', () => {
+			editor.setData( '<pre><code></code></pre>' );
+
+			expect( getModelData( model ) ).to.equal( '<codeBlock language="plaintext">[]</codeBlock>' );
+		} );
+
+		it( 'should convert pre > code with multi-line text to code block #1', () => {
+			editor.setData( '<pre><code>foo\nbar</code></pre>' );
+
+			expect( getModelData( model ) ).to.equal(
+				'<codeBlock language="plaintext">[]' +
+					'foo' +
+					'<softBreak></softBreak>' +
+					'bar' +
+				'</codeBlock>'
+			);
+		} );
+
+		it( 'should convert pre > code with multi-line text to code block #2', () => {
+			editor.setData( '<pre><code>\n\nfoo\n\n</code></pre>' );
+
+			expect( getModelData( model ) ).to.equal(
+				'<codeBlock language="plaintext">[]' +
+					'<softBreak></softBreak>' +
+					'<softBreak></softBreak>' +
+					'foo' +
+					'<softBreak></softBreak>' +
+					'<softBreak></softBreak>' +
+				'</codeBlock>'
+			);
+		} );
+
+		it( 'should convert pre > code with HTML inside', () => {
+			editor.setData( '<pre><code><p>Foo</p>\n<p>Bar</p></code></pre>' );
+
+			expect( getModelData( model ) ).to.equal(
+				'<codeBlock language="plaintext">[]' +
+					'<p>Foo</p>' +
+					'<softBreak></softBreak>' +
+					'<p>Bar</p>' +
+				'</codeBlock>'
+			);
+		} );
+
+		it( 'should convert pre > code tag with HTML and nested pre > code tag', () => {
+			editor.setData( '<pre><code><p>Foo</p><pre>Bar</pre><p>Biz</p></code></pre>' );
+
+			expect( getModelData( model ) ).to.equal(
+				'<codeBlock language="plaintext">[]<p>Foo</p><pre>Bar</pre><p>Biz</p></codeBlock>' );
+		} );
+
+		it( 'should convert pre > code tag with escaped html content', () => {
+			editor.setData( '<pre><code>&lt;div&gt;&lt;p&gt;Foo&lt;/p&gt;&lt;/div&gt;</code></pre>' );
+
+			expect( getModelData( model ) ).to.equal( '<codeBlock language="plaintext">[]<div><p>Foo</p></div></codeBlock>' );
+		} );
+
+		it( 'should be overridable', () => {
+			editor.data.upcastDispatcher.on( 'element:pre', ( evt, data, api ) => {
+				const modelItem = api.writer.createElement( 'codeBlock' );
+
+				api.writer.appendText( 'Hello World!', modelItem );
+				api.writer.insert( modelItem, data.modelCursor );
+				api.consumable.consume( data.viewItem, { name: true } );
+
+				data.modelCursor = api.writer.createPositionAfter( modelItem );
+				data.modelRange = api.writer.createRangeOn( modelItem );
+			}, { priority: 'high' } );
+
+			editor.setData( '<pre><code>Foo Bar</code></pre>' );
+
+			expect( getModelData( model ) ).to.equal( '<codeBlock>[]Hello World!</codeBlock>' );
+		} );
+
+		it( 'should split parents to correctly upcast the code block', () => {
+			editor.setData( '<p>foo<pre><code>x</code></pre>bar</p>' );
+
+			// Note: The empty <paragraph> should not be here. It's a conversion/auto–paragraphing bug.
+			expect( getModelData( model ) ).to.equal(
+				'<paragraph>[]foo</paragraph>' +
+				'<codeBlock language="plaintext">x</codeBlock>' +
+				'<paragraph>bar</paragraph>' +
+				'<paragraph></paragraph>' );
+		} );
+
+		it( 'should upcast two code blocks in a row (#1)', () => {
+			editor.setData( '<pre><code>foo</code></pre><pre><code>bar</code></pre>' );
+
+			expect( getModelData( model ) ).to.equal(
+				'<codeBlock language="plaintext">[]foo</codeBlock><codeBlock language="plaintext">bar</codeBlock>' );
+		} );
+
+		it( 'should upcast two code blocks in a row (#2)', () => {
+			editor.setData( `<pre><code>foo</code></pre>
+				<pre><code>bar</code></pre>` );
+
+			// Note: The empty <paragraph> in between should not be here. It's a conversion/auto–paragraphing bug.
+			expect( getModelData( model ) ).to.equal(
+				'<codeBlock language="plaintext">[]foo</codeBlock>' +
+				'<paragraph> </paragraph>' +
+				'<codeBlock language="plaintext">bar</codeBlock>' );
+		} );
+
+		it( 'should not convert when modelCursor and its ancestors disallow to insert codeBlock', () => {
+			model.document.createRoot( '$title', 'title' );
+
+			model.schema.register( '$title', {
+				disallow: '$block',
+				allow: 'inline'
+			} );
+
+			editor.data.set( { title: '<pre><code>foo</code></pre>' } );
+
+			expect( getModelData( model, { rootName: 'title', withoutSelection: true } ) ).to.equal( '' );
+		} );
+
+		describe( 'config.codeBlock.languages', () => {
+			it( 'should be respected when upcasting', () => {
+				return ClassicTestEditor.create(
+					'<pre><code class="language-foo">bar</code></pre>' +
+					'<pre><code class="language-bar">baz</code></pre>' +
+					'<pre><code class="qux">qux</code></pre>',
+					{
+						plugins: [ CodeBlockEditing ],
+						codeBlock: {
+							languages: [
+								{ language: 'foo', label: 'Foo' },
+								{ language: 'bar', label: 'Bar' },
+								{ language: 'qux', label: 'Qux', class: 'qux' },
+							]
+						}
+					} )
+					.then( editor => {
+						model = editor.model;
+
+						expect( getModelData( model ) ).to.equal(
+							'<codeBlock language="foo">[]bar</codeBlock>' +
+							'<codeBlock language="bar">baz</codeBlock>' +
+							'<codeBlock language="qux">qux</codeBlock>'
+						);
+
+						return editor.destroy();
+					} );
+			} );
+
+			it( 'should be respected when upcasting and a language has an empty class configured', () => {
+				return ClassicTestEditor.create(
+					'<pre><code class="language-foo">bar</code></pre>' +
+					'<pre><code class="language-bar">baz</code></pre>' +
+					'<pre><code>qux</code></pre>',
+					{
+						plugins: [ CodeBlockEditing ],
+						codeBlock: {
+							languages: [
+								{ language: 'foo', label: 'Foo' },
+								{ language: 'bar', label: 'Bar' },
+								{ language: 'qux', label: 'Qux', class: '' },
+							]
+						}
+					} )
+					.then( editor => {
+						model = editor.model;
+
+						expect( getModelData( model ) ).to.equal(
+							'<codeBlock language="foo">[]bar</codeBlock>' +
+							'<codeBlock language="bar">baz</codeBlock>' +
+							'<codeBlock language="qux">qux</codeBlock>'
+						);
+
+						return editor.destroy();
+					} );
+			} );
+
+			it( 'should upcast using the first language if the code in data has no language', () => {
+				return ClassicTestEditor
+					.create( '<pre><code>bar</code></pre>', {
+						plugins: [ CodeBlockEditing ],
+						codeBlock: {
+							languages: [
+								{ language: 'foo', label: 'Foo' },
+								{ language: 'bar', label: 'Bar' }
+							]
+						}
+					} )
+					.then( editor => {
+						model = editor.model;
+
+						expect( getModelData( model ) ).to.equal( '<codeBlock language="foo">[]bar</codeBlock>' );
+
+						return editor.destroy();
+					} );
+			} );
+
+			it( 'should upast using the first language if the code in data has an invalid language', () => {
+				return ClassicTestEditor
+					.create( '<pre><code class="baz">bar</code></pre>', {
+						plugins: [ CodeBlockEditing ],
+						codeBlock: {
+							languages: [
+								{ language: 'foo', label: 'Foo' },
+								{ language: 'bar', label: 'Bar' }
+							]
+						}
+					} )
+					.then( editor => {
+						model = editor.model;
+
+						expect( getModelData( model ) ).to.equal( '<codeBlock language="foo">[]bar</codeBlock>' );
+
+						return editor.destroy();
+					} );
+			} );
+		} );
+	} );
+
+	describe( 'clipboard integration', () => {
+		it( 'should not intercept input when selection anchored outside any code block', () => {
+			setModelData( model, '<paragraph>f[]oo</paragraph>' );
+
+			const dataTransferMock = {
+				getData: sinon.stub().withArgs( 'text/plain' ).returns( 'bar' )
+			};
+
+			viewDoc.fire( 'clipboardInput', {
+				dataTransfer: dataTransferMock,
+				stop: sinon.spy()
+			} );
+
+			expect( getModelData( model ) ).to.equal( '<paragraph>f[]oo</paragraph>' );
+			sinon.assert.notCalled( dataTransferMock.getData );
+		} );
+
+		it( 'should intercept input when selection anchored in the code block', () => {
+			setModelData( model, '<codeBlock language="css">f[o]o</codeBlock>' );
+
+			const dataTransferMock = {
+				getData: sinon.stub().withArgs( 'text/plain' ).returns( 'bar\nbaz\n' )
+			};
+
+			viewDoc.fire( 'clipboardInput', {
+				dataTransfer: dataTransferMock,
+				stop: sinon.spy()
+			} );
+
+			expect( getModelData( model ) ).to.equal(
+				'<codeBlock language="css">' +
+					'fbar' +
+					'<softBreak></softBreak>' +
+					'baz' +
+					'<softBreak></softBreak>' +
+					'[]o' +
+				'</codeBlock>' );
+
+			sinon.assert.calledOnce( dataTransferMock.getData );
+		} );
+
+		describe( 'getSelectedContent()', () => {
+			it( 'should not engage when there is nothing selected', () => {
+				setModelData( model, '<codeBlock language="css">fo[]o<softBreak></softBreak>bar</codeBlock>' );
+
+				expect( stringify( model.getSelectedContent( model.document.selection ) ) ).to.equal( '' );
+			} );
+
+			it( 'should wrap a partial multi-line selection into a code block (#1)', () => {
+				setModelData( model, '<codeBlock language="css">fo[o<softBreak></softBreak>b]ar</codeBlock>' );
+
+				expect( stringify( model.getSelectedContent( model.document.selection ) ) ).to.equal(
+					'<codeBlock language="css">o<softBreak></softBreak>b</codeBlock>'
+				);
+			} );
+
+			it( 'should wrap a partial multi-line selection into a code block (#2)', () => {
+				setModelData( model, '<codeBlock language="css">fo[o<softBreak></softBreak>]bar</codeBlock>' );
+
+				expect( stringify( model.getSelectedContent( model.document.selection ) ) ).to.equal(
+					'<codeBlock language="css">o<softBreak></softBreak></codeBlock>'
+				);
+			} );
+
+			it( 'should wrap a partial multi-line selection into a code block (#3)', () => {
+				setModelData( model, '<codeBlock language="css">[foo<softBreak></softBreak>bar]</codeBlock>' );
+
+				expect( stringify( model.getSelectedContent( model.document.selection ) ) ).to.equal(
+					'<codeBlock language="css">foo<softBreak></softBreak>bar</codeBlock>'
+				);
+			} );
+
+			it( 'should wrap a complete single-line selection into a code block', () => {
+				setModelData( model, '<codeBlock language="css">[foo]</codeBlock>' );
+
+				expect( stringify( model.getSelectedContent( model.document.selection ) ) ).to.equal(
+					'<codeBlock language="css">foo</codeBlock>'
+				);
+			} );
+
+			it( 'should wrap a partial single-line selection into an inline code (#1)', () => {
+				model.schema.extend( '$text', {
+					allowAttributes: 'code'
+				} );
+
+				setModelData( model, '<codeBlock language="css">[fo]o<softBreak></softBreak>bar</codeBlock>' );
+
+				expect( stringify( model.getSelectedContent( model.document.selection ) ) ).to.equal(
+					'<$text code="true">fo</$text>'
+				);
+			} );
+
+			it( 'should wrap a partial single-line selection into an inline code (#2)', () => {
+				model.schema.extend( '$text', {
+					allowAttributes: 'code'
+				} );
+
+				setModelData( model, '<codeBlock language="css">foo<softBreak></softBreak>b[a]r</codeBlock>' );
+
+				expect( stringify( model.getSelectedContent( model.document.selection ) ) ).to.equal(
+					'<$text code="true">a</$text>'
+				);
+			} );
+
+			it( 'should now wrap a partial single-line selection into an inline code when the attribute is disallowed', () => {
+				setModelData( model, '<codeBlock language="css">foo<softBreak></softBreak>b[a]r</codeBlock>' );
+
+				expect( stringify( model.getSelectedContent( model.document.selection ) ) ).to.equal( 'a' );
+			} );
+
+			it( 'should preserve a code block in a cross-selection (#1)', () => {
+				setModelData( model,
+					'<paragraph>[x</paragraph><codeBlock language="css">fo]o<softBreak></softBreak>bar</codeBlock>' );
+
+				expect( stringify( model.getSelectedContent( model.document.selection ) ) ).to.equal(
+					'<paragraph>x</paragraph><codeBlock language="css">fo</codeBlock>'
+				);
+			} );
+
+			it( 'should preserve a code block in a cross-selection (#2)', () => {
+				setModelData( model,
+					'<paragraph>[x</paragraph><codeBlock language="css">foo<softBreak></softBreak>b]ar</codeBlock>' );
+
+				expect( stringify( model.getSelectedContent( model.document.selection ) ) ).to.equal(
+					'<paragraph>x</paragraph><codeBlock language="css">foo<softBreak></softBreak>b</codeBlock>'
+				);
+			} );
+
+			it( 'should preserve a code block in a cross-selection (#3)', () => {
+				setModelData( model,
+					'<codeBlock language="css">foo<softBreak></softBreak>b[ar</codeBlock><paragraph>x]</paragraph>' );
+
+				expect( stringify( model.getSelectedContent( model.document.selection ) ) ).to.equal(
+					'<codeBlock language="css">ar</codeBlock><paragraph>x</paragraph>'
+				);
+			} );
+		} );
+	} );
+} );

+ 224 - 0
packages/ckeditor5-code-block/tests/codeblockui.js

@@ -0,0 +1,224 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* global document */
+
+import CodeBlockEditing from '../src/codeblockediting';
+import CodeBlockUI from '../src/codeblockui';
+
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+import codeBlockIcon from '../theme/icons/codeblock.svg';
+import { _clear as clearTranslations, add as addTranslations } from '@ckeditor/ckeditor5-utils/src/translation-service';
+
+describe( 'CodeBlockUI', () => {
+	let editor, command, element;
+
+	before( () => {
+		addTranslations( 'en', {
+			'Plain text': 'Plain text'
+		} );
+
+		addTranslations( 'pl', {
+			'Plain text': 'Zwykły tekst'
+		} );
+	} );
+
+	after( () => {
+		clearTranslations();
+	} );
+
+	beforeEach( () => {
+		element = document.createElement( 'div' );
+		document.body.appendChild( element );
+
+		return ClassicTestEditor
+			.create( element, {
+				plugins: [ CodeBlockEditing, CodeBlockUI ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+				command = editor.commands.get( 'codeBlock' );
+			} );
+	} );
+
+	afterEach( () => {
+		element.remove();
+
+		return editor.destroy();
+	} );
+
+	describe( 'codeBlock dropdown', () => {
+		it( 'has #class set', () => {
+			const dropdown = editor.ui.componentFactory.create( 'codeBlock' );
+
+			expect( dropdown.class ).to.equal( 'ck-code-block-dropdown' );
+		} );
+
+		it( 'has isEnabled bound to command\'s isEnabled', () => {
+			const dropdown = editor.ui.componentFactory.create( 'codeBlock' );
+
+			command.isEnabled = true;
+			expect( dropdown ).to.have.property( 'isEnabled', true );
+
+			command.isEnabled = false;
+			expect( dropdown ).to.have.property( 'isEnabled', false );
+		} );
+
+		it( 'executes the command when executed one of the available language buttons from the list', () => {
+			const dropdown = editor.ui.componentFactory.create( 'codeBlock' );
+			const executeSpy = sinon.stub( editor, 'execute' );
+			const focusSpy = sinon.stub( editor.editing.view, 'focus' );
+			const listView = dropdown.panelView.children.first;
+			const cSharpButton = listView.items.get( 2 ).children.first;
+
+			expect( cSharpButton.label ).to.equal( 'C#' );
+			cSharpButton.fire( 'execute' );
+
+			sinon.assert.calledOnce( executeSpy );
+			sinon.assert.calledOnce( focusSpy );
+			sinon.assert.calledWithExactly( executeSpy.firstCall, 'codeBlock', {
+				language: 'cs',
+				forceValue: true
+			} );
+		} );
+
+		describe( 'language list', () => {
+			it( 'corresponds to the config', () => {
+				const dropdown = editor.ui.componentFactory.create( 'codeBlock' );
+				const listView = dropdown.panelView.children.first;
+
+				expect( listView.items
+					.map( item => {
+						const { label, withText } = item.children.first;
+
+						return { label, withText };
+					} ) )
+					.to.deep.equal( [
+						{
+							label: 'Plain text',
+							withText: true
+						},
+						{
+							label: 'C',
+							withText: true
+						},
+						{
+							label: 'C#',
+							withText: true
+						},
+						{
+							label: 'C++',
+							withText: true
+						},
+						{
+							label: 'CSS',
+							withText: true
+						},
+						{
+							label: 'Diff',
+							withText: true
+						},
+						{
+							label: 'HTML/XML',
+							withText: true
+						},
+						{
+							label: 'Java',
+							withText: true
+						},
+						{
+							label: 'JavaScript',
+							withText: true
+						},
+						{
+							label: 'PHP',
+							withText: true
+						},
+						{
+							label: 'Python',
+							withText: true
+						},
+						{
+							label: 'Ruby',
+							withText: true
+						},
+						{
+							label: 'TypeScript',
+							withText: true
+						}
+					] );
+			} );
+
+			it( 'sets item\'s #isOn depending on the value of the CodeBlockCommand', () => {
+				const dropdown = editor.ui.componentFactory.create( 'codeBlock' );
+				const listView = dropdown.panelView.children.first;
+
+				expect( listView.items.get( 2 ).children.first.isOn ).to.be.false;
+
+				command.value = 'cs';
+				expect( listView.items.get( 2 ).children.first.isOn ).to.be.true;
+			} );
+
+			it( 'uses localized "Plain text" label', () => {
+				const element = document.createElement( 'div' );
+				document.body.appendChild( element );
+
+				return ClassicTestEditor
+					.create( element, {
+						language: 'pl',
+						plugins: [ CodeBlockEditing, CodeBlockUI ]
+					} )
+					.then( newEditor => {
+						const editor = newEditor;
+
+						const dropdown = editor.ui.componentFactory.create( 'codeBlock' );
+						const listView = dropdown.panelView.children.first;
+
+						expect( listView.items.first.children.first.label ).to.equal( 'Zwykły tekst' );
+
+						return editor.destroy();
+					} );
+			} );
+		} );
+
+		describe( 'button', () => {
+			it( 'has the base properties', () => {
+				const dropdown = editor.ui.componentFactory.create( 'codeBlock' );
+				const button = dropdown.buttonView;
+
+				expect( button ).to.have.property( 'label', 'Insert code block' );
+				expect( button ).to.have.property( 'icon', codeBlockIcon );
+				expect( button ).to.have.property( 'tooltip', true );
+				expect( button ).to.have.property( 'isToggleable', true );
+			} );
+
+			it( 'has #isOn bound to command\'s value', () => {
+				const dropdown = editor.ui.componentFactory.create( 'codeBlock' );
+				const button = dropdown.buttonView;
+
+				command.value = false;
+				expect( button ).to.have.property( 'isOn', false );
+
+				command.value = true;
+				expect( button ).to.have.property( 'isOn', true );
+			} );
+
+			it( 'should execute the command with the first configured language', () => {
+				const dropdown = editor.ui.componentFactory.create( 'codeBlock' );
+				const button = dropdown.buttonView;
+				const executeSpy = sinon.stub( editor, 'execute' );
+				const focusSpy = sinon.stub( editor.editing.view, 'focus' );
+
+				button.fire( 'execute' );
+
+				sinon.assert.calledOnce( executeSpy );
+				sinon.assert.calledOnce( focusSpy );
+				sinon.assert.calledWithExactly( executeSpy.firstCall, 'codeBlock', {
+					language: 'plaintext'
+				} );
+			} );
+		} );
+	} );
+} );

+ 171 - 0
packages/ckeditor5-code-block/tests/indentcodeblockcommand.js

@@ -0,0 +1,171 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import CodeBlockEditing from '../src/codeblockediting';
+import IndentCodeBlockCommand from '../src/indentcodeblockcommand';
+
+import AlignmentEditing from '@ckeditor/ckeditor5-alignment/src/alignmentediting';
+import BoldEditing from '@ckeditor/ckeditor5-basic-styles/src/bold/boldediting';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import BlockQuoteEditing from '@ckeditor/ckeditor5-block-quote/src/blockquoteediting';
+
+import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+describe( 'IndentCodeBlockCommand', () => {
+	let editor, model, indentCommand;
+
+	beforeEach( () => {
+		return ModelTestEditor
+			.create( {
+				plugins: [ CodeBlockEditing, Paragraph, BlockQuoteEditing, AlignmentEditing, BoldEditing ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+				indentCommand = new IndentCodeBlockCommand( editor, 'forward' );
+			} );
+	} );
+
+	afterEach( () => {
+		return editor.destroy();
+	} );
+
+	describe( '#isEnabled', () => {
+		it( 'should be true when the first selected block is a codeBlock #1', () => {
+			setModelData( model, '<codeBlock language="foo">f[]oo</codeBlock>' );
+
+			expect( indentCommand.isEnabled ).to.be.true;
+		} );
+
+		it( 'should be true when the first selected block is a codeBlock #2', () => {
+			setModelData( model, '<codeBlock language="foo">f[oo</codeBlock><paragraph>ba]r</paragraph>' );
+
+			expect( indentCommand.isEnabled ).to.be.true;
+		} );
+
+		it( 'should be false when there is no code block in the selection', () => {
+			setModelData( model, '<paragraph>foo[]</paragraph>' );
+
+			expect( indentCommand.isEnabled ).to.be.false;
+		} );
+
+		it( 'should be false when the selection is not anchored in the code block', () => {
+			setModelData( model, '<paragraph>f[oo</paragraph><codeBlock language="foo">bar</codeBlock><paragraph>ba]z</paragraph>' );
+
+			expect( indentCommand.isEnabled ).to.be.false;
+		} );
+
+		describe( 'config.codeBlock.indentSequence', () => {
+			it( 'should disable the command when the config is not set', () => {
+				return ModelTestEditor
+					.create( {
+						plugins: [ CodeBlockEditing, Paragraph, BlockQuoteEditing, AlignmentEditing, BoldEditing ],
+						codeBlock: {
+							indentSequence: false
+						}
+					} )
+					.then( newEditor => {
+						const editor = newEditor;
+						const model = editor.model;
+						const indentCommand = new IndentCodeBlockCommand( editor, 'forward' );
+
+						setModelData( model, '<codeBlock language="foo">[]foo</codeBlock>' );
+
+						expect( indentCommand.isEnabled ).to.be.false;
+
+						return editor.destroy();
+					} );
+			} );
+		} );
+	} );
+
+	describe( 'execute()', () => {
+		it( 'should indent when a selection is collapsed in an empty code block', () => {
+			setModelData( model, '<codeBlock language="foo">[]</codeBlock>' );
+
+			indentCommand.execute();
+
+			expect( getModelData( model ) ).to.equal( '<codeBlock language="foo">	[]</codeBlock>' );
+		} );
+
+		it( 'should indent when a selection is collapsed', () => {
+			setModelData( model, '<codeBlock language="foo">f[]oo</codeBlock>' );
+
+			indentCommand.execute();
+
+			expect( getModelData( model ) ).to.equal( '<codeBlock language="foo">f	[]oo</codeBlock>' );
+		} );
+
+		it( 'should indent a whole line when a selection is expanded', () => {
+			setModelData( model, '<codeBlock language="foo">f[o]o</codeBlock>' );
+
+			indentCommand.execute();
+
+			expect( getModelData( model ) ).to.equal( '<codeBlock language="foo">	f[o]o</codeBlock>' );
+		} );
+
+		it( 'should indent multiple lines when a selection is expanded', () => {
+			setModelData( model, '<codeBlock language="foo">f[oo<softBreak></softBreak>b]ar</codeBlock>' );
+
+			indentCommand.execute();
+
+			expect( getModelData( model ) ).to.equal( '<codeBlock language="foo">	f[oo<softBreak></softBreak>	b]ar</codeBlock>' );
+		} );
+
+		it( 'should append the indentation to the line\'s leading white spaces (#1)', () => {
+			setModelData( model, '<codeBlock language="foo">[]foo</codeBlock>' );
+
+			// <codeBlock language="foo">    []foo</codeBlock>
+			model.change( writer => {
+				writer.insertText( '    ', model.document.getRoot().getChild( 0 ) );
+			} );
+
+			indentCommand.execute();
+
+			expect( getModelData( model ) ).to.equal( '<codeBlock language="foo">    	[]foo</codeBlock>' );
+		} );
+
+		it( 'should append the indentation to the line\'s leading white spaces (#2)', () => {
+			setModelData( model, '<codeBlock language="foo">f[oo<softBreak></softBreak>b]ar</codeBlock>' );
+
+			// <codeBlock language="foo">    f[oo<softBreak></softBreak>    b]ar</codeBlock>
+			model.change( writer => {
+				writer.insertText( '    ', model.document.getRoot().getChild( 0 ), 4 );
+				writer.insertText( '    ', model.document.getRoot().getChild( 0 ), 0 );
+			} );
+
+			indentCommand.execute();
+
+			expect( getModelData( model ) ).to.equal(
+				'<codeBlock language="foo">    	f[oo<softBreak></softBreak>    	b]ar</codeBlock>' );
+		} );
+
+		describe( 'config.codeBlock.indentSequence', () => {
+			it( 'should be used when indenting', () => {
+				return ModelTestEditor
+					.create( {
+						plugins: [ CodeBlockEditing, Paragraph, BlockQuoteEditing, AlignmentEditing, BoldEditing ],
+						codeBlock: {
+							indentSequence: '  '
+						}
+					} )
+					.then( newEditor => {
+						const editor = newEditor;
+						const model = editor.model;
+						const indentCommand = new IndentCodeBlockCommand( editor, 'forward' );
+
+						setModelData( model, '<codeBlock language="foo">f[o]o</codeBlock>' );
+
+						indentCommand.execute();
+
+						expect( getModelData( model ) ).to.equal( '<codeBlock language="foo">  f[o]o</codeBlock>' );
+
+						return editor.destroy();
+					} );
+			} );
+		} );
+	} );
+} );

+ 24 - 0
packages/ckeditor5-code-block/tests/manual/codeblock.html

@@ -0,0 +1,24 @@
+<div id="editor">
+<pre><code class="language-plaintext">Plain text</code></pre>
+	<p>Getting used to an entirely different culture can be challenging. While it’s also nice to learn about cultures online or from books, nothing comes close to experiencing cultural diversity in person.</p>
+	<p>You learn to appreciate each and every single one of the differences while you become more culturally fluid.</p>
+<pre><code class="language-javascript">function foo() {
+	console.log( 'indented using 1 tab' );
+}
+
+function bar() {
+    console.log( 'indented using spaces' );
+}</code></pre>
+	<p>Getting used to an entirely different culture can be challenging. While it’s also nice to learn about cultures online or from books, nothing comes close to experiencing cultural diversity in person. You learn to appreciate each and every single one of the differences while you become more culturally fluid.</p>
+<pre><code class="language-css">body { color: red }</code></pre>
+<p>You learn to appreciate each and every single one of the differences while you become more culturally fluid.</p>
+<pre><code class="language-css">p { font-size: 10px; }</code></pre>
+<p>Some ASCII art:</p>
+<pre><code> ________________
+/                \
+| How about moo? |  ^__^
+\________________/  (oo)\_______
+                  \ (__)\       )\/\
+                        ||----w |
+                        ||     ||</code></pre>
+</div>

+ 29 - 0
packages/ckeditor5-code-block/tests/manual/codeblock.js

@@ -0,0 +1,29 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* global document, console, window */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
+import CodeBlock from '../../src/codeblock';
+import Code from '@ckeditor/ckeditor5-basic-styles/src/code';
+import Autoformat from '@ckeditor/ckeditor5-autoformat/src/autoformat';
+import Indent from '@ckeditor/ckeditor5-indent/src/indent';
+
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		plugins: [ Code, CodeBlock, Autoformat, Indent, ArticlePluginSet ],
+		toolbar: [
+			'heading', '|',
+			'bold', 'italic', 'code', 'blockQuote', 'codeBlock', 'undo', 'redo', 'insertTable', '|',
+			'outdent', 'indent',
+		]
+	} )
+	.then( editor => {
+		window.editor = editor;
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );

+ 50 - 0
packages/ckeditor5-code-block/tests/manual/codeblock.md

@@ -0,0 +1,50 @@
+## Creating
+
+- Put selection in the single paragraph, and press code block button. Paragraph should change to code block.
+
+- Select multiple paragraphs and press code block icon. All blocks should be changed into one code block element with a multiple lines.
+
+- Change the middle paragraph into code block, select the preceding and the following paragraph, and press code block button. All selected blocks should be merged into one code block.
+
+- Try to create code block into block quote - it should work fine.
+
+- Try to create create a block quote into a table - it should work fine.
+
+## Leaving the code block using enter
+
+### Block end
+
+- Create an empty line at the end of the block and put the selection there.
+- Press <kbd>Enter</kbd> again.
+- The new line created in the code block should no longer be there.
+- A new empty paragraph should be created after the code block.
+- The selection should be in that paragraph.
+
+### Block beginning
+
+- Similarly, create an empty line at the beginning of the block and put the selection there.
+- The operation should be mirrored upon enter (empty paragraph created before, not after, etc.).
+
+### <kbd>Shift</kbd>+<kbd>Enter</kbd>
+
+- The above scenarios should **not** work when using <kbd>Shift</kbd>+<kbd>Enter</kbd>.
+
+## Indenting and outdenting
+
+- Use <kbd>Tab</kbd> to insert tab (selection in the middle of the line) or indent entire line (selection before line's text).
+- Use <kbd>Shift</kbd>+<kbd>Tab</kbd> to outdent the line.
+- Try both with different selections, e.g. containing multiple lines.
+- It should act similarly to native code editors.
+- Use the "Increase indent" and "Decrease indent" buttons in the toolbar – their actions should correspond to the keystrokes.
+
+## Preserving indent on enter
+
+- Having indented some line put the caret at the end of it.
+- Press <kbd>Enter</kbd>.
+- The new line should have the same indentation level as the previous one.
+
+## Integration with Autoformat
+
+- Type `` ``` `` in an empty paragraph.
+- A new empty code block should be inserted with the selection inside of it.
+- Try to undo. There should be 2 undo steps (code block removal, and `` ``` ``).

+ 8 - 0
packages/ckeditor5-code-block/tests/manual/rtl.html

@@ -0,0 +1,8 @@
+<div id="editor">
+<pre><code class="language-plaintext">Plain text</code></pre>
+	<p>اللغة نسق من الإشارات والرموز، يشكل أداة من أدوات المعرفة، وتعتبر اللغة أهم وسائل التفاهم والاحتكاك بين أفراد المجتمع في جميع ميادين الحياة. وبدون اللغة يتعذر نشاط الناس المعرفي. وترتبط اللغة بالتفكير ارتباطًا وثيقًا؛ فأفكار الإنسان تصاغ دومًا في قالب لغوي، حتى في حال تفكيره الباطني. ومن خلال اللغة فقط تحصل الفكرة على وجودها الواقعي. كما ترمز اللغة إلى الأشياء المنعكسة فيها.</p>
+<pre><code class="language-javascript">foo()
+bar()</code></pre>
+	<p>اللغة نسق من الإشارات والرموز، يشكل أداة من أدوات المعرفة، وتعتبر اللغة أهم وسائل التفاهم والاحتكاك بين أفراد المجتمع في جميع ميادين الحياة. وبدون اللغة يتعذر نشاط الناس المعرفي. وترتبط اللغة بالتفكير ارتباطًا وثيقًا؛ فأفكار الإنسان تصاغ دومًا في قالب لغوي، حتى في حال تفكيره الباطني. ومن خلال اللغة فقط تحصل الفكرة على وجودها الواقعي. كما ترمز اللغة إلى الأشياء المنعكسة فيها.</p>
+<pre><code class="language-css">body { color: red }</code></pre>
+</div>

+ 24 - 0
packages/ckeditor5-code-block/tests/manual/rtl.js

@@ -0,0 +1,24 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* global document, console, window */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
+import CodeBlock from '../../src/codeblock';
+import Code from '@ckeditor/ckeditor5-basic-styles/src/code';
+
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		language: 'ar',
+		plugins: [ Code, CodeBlock, ArticlePluginSet ],
+		toolbar: [ 'heading', '|', 'bold', 'italic', 'code', 'blockQuote', 'codeBlock', 'undo', 'redo', 'insertTable' ]
+	} )
+	.then( editor => {
+		window.editor = editor;
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );

+ 5 - 0
packages/ckeditor5-code-block/tests/manual/rtl.md

@@ -0,0 +1,5 @@
+## Code blocks and RTL content
+
+1. The code in all blocks should be left aligned.
+2. The code should have a left–to–right direction (so `foo()`, not `()foo`).
+3. The label describing the used language should be in the upper right corner of each block.

+ 311 - 0
packages/ckeditor5-code-block/tests/outdentcodeblockcommand.js

@@ -0,0 +1,311 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import CodeBlockEditing from '../src/codeblockediting';
+import OutdentCodeBlockCommand from '../src/outdentcodeblockcommand';
+
+import AlignmentEditing from '@ckeditor/ckeditor5-alignment/src/alignmentediting';
+import BoldEditing from '@ckeditor/ckeditor5-basic-styles/src/bold/boldediting';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import BlockQuoteEditing from '@ckeditor/ckeditor5-block-quote/src/blockquoteediting';
+
+import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+describe( 'OutdentCodeBlockCommand', () => {
+	let editor, model, outdentCommand;
+
+	beforeEach( () => {
+		return ModelTestEditor
+			.create( {
+				plugins: [ CodeBlockEditing, Paragraph, BlockQuoteEditing, AlignmentEditing, BoldEditing ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+				outdentCommand = new OutdentCodeBlockCommand( editor, 'backward' );
+			} );
+	} );
+
+	afterEach( () => {
+		return editor.destroy();
+	} );
+
+	describe( '#isEnabled', () => {
+		it( 'should be true when the selection is in a line containing the indent sequence', () => {
+			setModelData( model, '<codeBlock language="foo">f[]oo</codeBlock>' );
+
+			// <codeBlock language="foo">	f[]oo</codeBlock>
+			model.change( writer => {
+				writer.insertText( '	', model.document.getRoot().getChild( 0 ) );
+			} );
+
+			expect( outdentCommand.isEnabled ).to.be.true;
+		} );
+
+		it( 'should be true when any line in the selection contains more than the indent sequence', () => {
+			setModelData( model, '<codeBlock language="foo">f[oo</codeBlock><paragraph>ba]r</paragraph>' );
+
+			// <codeBlock language="foo">	f[oo</codeBlock><paragraph>ba]r</paragraph>
+			model.change( writer => {
+				writer.insertText( '	', model.document.getRoot().getChild( 0 ) );
+			} );
+
+			expect( outdentCommand.isEnabled ).to.be.true;
+		} );
+
+		it( 'should be true when any line in the selection contains the indent sequence', () => {
+			setModelData( model, '<codeBlock language="foo">f[oo</codeBlock><codeBlock language="foo">ba]r</codeBlock>' );
+
+			// <codeBlock language="foo">f[oo</codeBlock><codeBlock language="foo">	ba]r</codeBlock>
+			model.change( writer => {
+				writer.insertText( '	', model.document.getRoot().getChild( 1 ) );
+			} );
+
+			expect( outdentCommand.isEnabled ).to.be.true;
+		} );
+
+		it( 'should be false when the indent sequence is in other element', () => {
+			setModelData( model, '<paragraph>foo[]</paragraph>' );
+
+			// <paragraph>	foo[]</paragraph>
+			model.change( writer => {
+				writer.insertText( '	', model.document.getRoot().getChild( 0 ) );
+			} );
+
+			expect( outdentCommand.isEnabled ).to.be.false;
+		} );
+
+		it( 'should be false when there is no indent sequence in the line (caret inside text)', () => {
+			setModelData( model, '<codeBlock language="foo">f[]oo</codeBlock>' );
+
+			expect( outdentCommand.isEnabled ).to.be.false;
+		} );
+
+		it( 'should be false when there is no indent sequence in the line (empty line)', () => {
+			setModelData( model, '<codeBlock language="foo">[]</codeBlock>' );
+
+			expect( outdentCommand.isEnabled ).to.be.false;
+		} );
+
+		it( 'should be false when there is no indent sequence in the line (caret at the end of a block)', () => {
+			setModelData( model, '<codeBlock language="foo">foo[]</codeBlock>' );
+
+			expect( outdentCommand.isEnabled ).to.be.false;
+		} );
+
+		it( 'should be false when there is no corrent sequence in the line', () => {
+			setModelData( model, '<codeBlock language="foo">foo[]</codeBlock>' );
+
+			// <codeBlock language="foo">    foo[]</codeBlock>
+			model.change( writer => {
+				writer.insertText( '    ', model.document.getRoot().getChild( 0 ) );
+			} );
+
+			expect( outdentCommand.isEnabled ).to.be.false;
+		} );
+
+		it( 'should be false when the sequence is not in leading characters of the line', () => {
+			setModelData( model, '<codeBlock language="foo">barfoo[]</codeBlock>' );
+
+			// <codeBlock language="foo">bar	foo[]</codeBlock>
+			model.change( writer => {
+				writer.insertText( '	', model.document.getRoot().getChild( 0 ), 3 );
+			} );
+
+			expect( outdentCommand.isEnabled ).to.be.false;
+		} );
+
+		it( 'should be false when the sequence is not in leading characters of the line (after other white-space characters)', () => {
+			setModelData( model, '<codeBlock language="foo">foo[]</codeBlock>' );
+
+			// <codeBlock language="foo">foo[]</codeBlock>
+			model.change( writer => {
+				writer.insertText( '    	    ', model.document.getRoot().getChild( 0 ), 0 );
+			} );
+
+			expect( outdentCommand.isEnabled ).to.be.false;
+		} );
+
+		describe( 'config.codeBlock.indentSequence', () => {
+			it( 'should be respected (#1)', () => {
+				return ModelTestEditor
+					.create( {
+						plugins: [ CodeBlockEditing, Paragraph, BlockQuoteEditing, AlignmentEditing, BoldEditing ],
+						codeBlock: {
+							indentSequence: '  '
+						}
+					} )
+					.then( newEditor => {
+						const editor = newEditor;
+						const model = editor.model;
+						const outdentCommand = new OutdentCodeBlockCommand( editor );
+
+						setModelData( model, '<codeBlock language="foo">foo[]</codeBlock>' );
+
+						// <codeBlock language="foo">    foo[]</codeBlock>
+						model.change( writer => {
+							writer.insertText( '    ', model.document.getRoot().getChild( 0 ) );
+						} );
+
+						expect( outdentCommand.isEnabled ).to.be.true;
+
+						return editor.destroy();
+					} );
+			} );
+
+			it( 'should be respected (#2)', () => {
+				return ModelTestEditor
+					.create( {
+						plugins: [ CodeBlockEditing, Paragraph, BlockQuoteEditing, AlignmentEditing, BoldEditing ],
+						codeBlock: {
+							indentSequence: '  '
+						}
+					} )
+					.then( newEditor => {
+						const editor = newEditor;
+						const model = editor.model;
+						const outdentCommand = new OutdentCodeBlockCommand( editor );
+
+						setModelData( model, '<codeBlock language="foo"> foo[]</codeBlock>' );
+
+						// <codeBlock language="foo"> foo[]</codeBlock>
+						model.change( writer => {
+							writer.insertText( ' ', model.document.getRoot().getChild( 0 ) );
+						} );
+
+						expect( outdentCommand.isEnabled ).to.be.false;
+
+						return editor.destroy();
+					} );
+			} );
+
+			it( 'should disable the command when the config is not set', () => {
+				return ModelTestEditor
+					.create( {
+						plugins: [ CodeBlockEditing, Paragraph, BlockQuoteEditing, AlignmentEditing, BoldEditing ],
+						codeBlock: {
+							indentSequence: false
+						}
+					} )
+					.then( newEditor => {
+						const editor = newEditor;
+						const model = editor.model;
+						const outdentCommand = new OutdentCodeBlockCommand( editor );
+
+						setModelData( model, '<codeBlock language="foo">foo[]</codeBlock>' );
+
+						// <codeBlock language="foo">	foo[]</codeBlock>
+						model.change( writer => {
+							writer.insertText( '	', model.document.getRoot().getChild( 0 ) );
+						} );
+
+						expect( outdentCommand.isEnabled ).to.be.false;
+
+						return editor.destroy();
+					} );
+			} );
+		} );
+	} );
+
+	describe( 'execute()', () => {
+		it( 'should outdent a single line', () => {
+			setModelData( model, '<codeBlock language="foo">f[]oo</codeBlock>' );
+
+			// <codeBlock language="foo">	f[]oo</codeBlock>
+			model.change( writer => {
+				writer.insertText( '	', model.document.getRoot().getChild( 0 ) );
+			} );
+
+			outdentCommand.execute();
+
+			expect( getModelData( model ) ).to.equal( '<codeBlock language="foo">f[]oo</codeBlock>' );
+		} );
+
+		it( 'should outdent only one level in a single line', () => {
+			setModelData( model, '<codeBlock language="foo">f[]oo</codeBlock>' );
+
+			// <codeBlock language="foo">		f[]oo</codeBlock>
+			model.change( writer => {
+				writer.insertText( '		', model.document.getRoot().getChild( 0 ) );
+			} );
+
+			outdentCommand.execute();
+
+			expect( getModelData( model ) ).to.equal( '<codeBlock language="foo">	f[]oo</codeBlock>' );
+		} );
+
+		it( 'should outdent multiple lines', () => {
+			setModelData( model, '<codeBlock language="foo">f[oo<softBreak></softBreak>ba]r</codeBlock>' );
+
+			// <codeBlock language="foo">	f[oo<softBreak></softBreak>	ba]r</codeBlock>
+			model.change( writer => {
+				writer.insertText( '	', model.document.getRoot().getChild( 0 ), 4 );
+				writer.insertText( '	', model.document.getRoot().getChild( 0 ), 0 );
+			} );
+
+			outdentCommand.execute();
+
+			expect( getModelData( model ) ).to.equal( '<codeBlock language="foo">f[oo<softBreak></softBreak>ba]r</codeBlock>' );
+		} );
+
+		it( 'should outdent only one level across multiple lines', () => {
+			setModelData( model, '<codeBlock language="foo">f[oo<softBreak></softBreak>ba]r</codeBlock>' );
+
+			// <codeBlock language="foo">	f[oo<softBreak></softBreak>		ba]r</codeBlock>
+			model.change( writer => {
+				writer.insertText( '		', model.document.getRoot().getChild( 0 ), 4 );
+				writer.insertText( '	', model.document.getRoot().getChild( 0 ), 0 );
+			} );
+
+			outdentCommand.execute();
+
+			expect( getModelData( model ) ).to.equal( '<codeBlock language="foo">f[oo<softBreak></softBreak>	ba]r</codeBlock>' );
+		} );
+
+		it( 'should outdent some lines', () => {
+			setModelData( model, '<codeBlock language="foo">f[oo<softBreak></softBreak>ba]r</codeBlock>' );
+
+			// <codeBlock language="foo">f[oo<softBreak></softBreak>	ba]r</codeBlock>
+			model.change( writer => {
+				writer.insertText( '	', model.document.getRoot().getChild( 0 ), 4 );
+			} );
+
+			outdentCommand.execute();
+
+			expect( getModelData( model ) ).to.equal( '<codeBlock language="foo">f[oo<softBreak></softBreak>ba]r</codeBlock>' );
+		} );
+
+		describe( 'config.codeBlock.indentSequence', () => {
+			it( 'should be respected', () => {
+				return ModelTestEditor
+					.create( {
+						plugins: [ CodeBlockEditing, Paragraph, BlockQuoteEditing, AlignmentEditing, BoldEditing ],
+						codeBlock: {
+							indentSequence: '  '
+						}
+					} )
+					.then( newEditor => {
+						const editor = newEditor;
+						const model = editor.model;
+						const outdentCommand = new OutdentCodeBlockCommand( editor );
+
+						setModelData( model, '<codeBlock language="foo">f[]oo</codeBlock>' );
+
+						// <codeBlock language="foo">  f[]oo</codeBlock>
+						model.change( writer => {
+							writer.insertText( '  ', model.document.getRoot().getChild( 0 ) );
+						} );
+
+						outdentCommand.execute();
+
+						expect( getModelData( model ) ).to.equal( '<codeBlock language="foo">f[]oo</codeBlock>' );
+
+						return editor.destroy();
+					} );
+			} );
+		} );
+	} );
+} );

+ 40 - 0
packages/ckeditor5-code-block/theme/codeblock.css

@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+.ck-content pre {
+	padding: 1em;
+	color: #353535;
+	background: hsla(0, 0%, 78%, 0.3);
+	border: 1px solid hsl(0, 0%, 77%);
+	border-radius: 2px;
+
+	/* Code block are language direction–agnostic. */
+	text-align: left;
+	direction: ltr;
+
+	tab-size: 4;
+	white-space: pre-wrap;
+
+	/* Don't inherit the style, e.g. when in a block quote. */
+	font-style: normal;
+
+	/* Don't let the code be squashed e.g. when in a table cell. */
+	min-width: 200px;
+
+	& code {
+		background: unset;
+		padding: 0;
+		border-radius: 0;
+	}
+}
+
+.ck.ck-editor__editable pre {
+	position: relative;
+
+	&[data-language]::after {
+		content: attr(data-language);
+		position: absolute;
+	}
+}

+ 1 - 0
packages/ckeditor5-code-block/theme/icons/codeblock.svg

@@ -0,0 +1 @@
+<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M12.87 12.61a.75.75 0 01-.089.976l-.085.07-3.154 2.254 3.412 2.414a.75.75 0 01.237.95l-.057.095a.75.75 0 01-.95.237l-.096-.058-4.272-3.022-.003-1.223 4.01-2.867a.75.75 0 011.047.174zm2.795-.231l.095.057 4.011 2.867-.003 1.223-4.272 3.022-.095.058a.75.75 0 01-.88-.151l-.07-.086-.058-.095a.75.75 0 01.15-.88l.087-.07 3.412-2.414-3.154-2.253-.085-.071a.75.75 0 01.862-1.207zM16 0a2 2 0 012 2v9.354l-.663-.492-.837-.001V2a.5.5 0 00-.5-.5H2a.5.5 0 00-.5.5v15a.5.5 0 00.5.5h3.118L7.156 19H2a2 2 0 01-2-2V2a2 2 0 012-2h14zM5.009 15l.003 1H3v-1h2.009zm2.188-2l-1.471 1H5v-1h2.197zM10 11v.095L8.668 12H7v-1h3zm4-2v1H7V9h7zm0-2v1H7V7h7zm-4-2v1H5V5h5zM6 3v1H3V3h3z"/></svg>