Explorar el Código

Merge pull request #130 from ckeditor/t/ckeditor5/2003

Feature: Introduced Title plugin. Closes https://github.com/ckeditor/ckeditor5/issues/2003. Closes https://github.com/ckeditor/ckeditor5/issues/2005.
Maciej hace 6 años
padre
commit
df545bc771

+ 48 - 0
packages/ckeditor5-heading/docs/_snippets/features/title.html

@@ -0,0 +1,48 @@
+<div id="snippet-title">
+</div>
+
+<h3>Console</h3>
+
+<pre><code class="title-console title-console__title">''</code></pre>
+<pre><code class="title-console title-console__body">''</code></pre>
+<pre><code class="title-console title-console__data">''</code></pre>
+
+<style>
+	#title-console {
+		max-height: 300px;
+		overflow: auto;
+		white-space: normal;
+		background: #2b2c26;
+		margin-top: 20px;
+		transition: background-color 500ms;
+	}
+
+	#title-console.updated {
+		background: green;
+	}
+
+	.title-console__title,
+	.title-console__body {
+		margin-bottom: 10px;
+	}
+
+	.title-console__title::before,
+	.title-console__body::before,
+	.title-console__data::before {
+		display: inline-block;
+		margin-right: 5px;
+		opacity: 0.5;
+	}
+
+	.title-console__title::before {
+		content: 'Title:'
+	}
+
+	.title-console__body::before {
+		content: 'Body:'
+	}
+
+	.title-console__data::before {
+		content: 'Data:';
+	}
+</style>

+ 64 - 0
packages/ckeditor5-heading/docs/_snippets/features/title.js

@@ -0,0 +1,64 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals console, window, document, setTimeout */
+
+import ClassicEditor from '@ckeditor/ckeditor5-build-classic/src/ckeditor';
+import Title from '@ckeditor/ckeditor5-heading/src/title';
+
+import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config';
+
+ClassicEditor.builtinPlugins.push( Title );
+
+ClassicEditor
+	.create( document.querySelector( '#snippet-title' ), {
+		cloudServices: CS_CONFIG
+	} )
+	.then( editor => {
+		window.editor = editor;
+
+		const titlePlugin = editor.plugins.get( 'Title' );
+		const titleConsole = new Console( document.querySelector( '.title-console__title' ) );
+		const bodyConsole = new Console( document.querySelector( '.title-console__body' ) );
+		const dataConsole = new Console( document.querySelector( '.title-console__data' ) );
+
+		editor.model.document.on( 'change:data', () => {
+			titleConsole.update( titlePlugin.getTitle() );
+			bodyConsole.update( titlePlugin.getBody() );
+			dataConsole.update( editor.getData() );
+		} );
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );
+
+class Console {
+	constructor( element ) {
+		this.element = element;
+		this.consoleUpdates = 0;
+		this.previousData = '';
+	}
+
+	update( data ) {
+		if ( this.previousData == data ) {
+			return;
+		}
+
+		this.previousData = data;
+
+		const element = this.element;
+
+		this.consoleUpdates++;
+
+		element.classList.add( 'updated' );
+		element.textContent = `'${ data }'`;
+
+		setTimeout( () => {
+			if ( --this.consoleUpdates == 0 ) {
+				element.classList.remove( 'updated' );
+			}
+		}, 500 );
+	}
+}

+ 45 - 0
packages/ckeditor5-heading/docs/features/title.md

@@ -0,0 +1,45 @@
+---
+category: features
+---
+
+# Title
+
+The {@link module:heading/title~Title title} feature adds support for the title field to your document. It makes sure that there will be always a single title field at the beginning of your document.
+
+## Demo
+
+{@snippet features/title}
+
+## Keyboard navigation
+
+Title plugin lets you navigate between title and body elements using <kbd>Tab</kbd> key and back, using <kbd>Shift</kbd> + <kbd>Tab</kbd> (when the selection is at the beginning of the first body element), providing form-like experience. You can also use <kbd>Enter</kbd> and <kbd>Backspace</kbd> keys to move caret between title and body.
+
+## Placeholder integration
+
+Title plugin is integrated with the {@link features/editor-placeholder placeholder} configuration. If you define it, it will be used as the placeholder for the body element.
+
+## Installation
+
+To add this feature to your editor, install the [`@ckeditor/ckeditor5-heading`](https://www.npmjs.com/package/@ckeditor/ckeditor5-heading) package:
+
+```bash
+npm install --save @ckeditor/ckeditor5-heading
+```
+
+Then add the `Title` plugin to your plugin list:
+
+```js
+import Title from '@ckeditor/ckeditor5-heading/src/title';
+
+ClassicEditor
+    .create( document.querySelector( '#editor' ), {
+        plugins: [ Title, ... ]
+    } )
+    .then( ... )
+    .catch( ... );
+```
+
+<info-box info>
+    Read more about {@link builds/guides/integration/installing-plugins installing plugins}.
+</info-box>
+

+ 5 - 0
packages/ckeditor5-heading/package.json

@@ -16,12 +16,17 @@
     "@ckeditor/ckeditor5-utils": "^14.0.0"
   },
   "devDependencies": {
+    "@ckeditor/ckeditor5-alignment": "^11.2.0",
+    "@ckeditor/ckeditor5-basic-styles": "^11.1.4",
+    "@ckeditor/ckeditor5-block-quote": "^11.1.3",
+    "@ckeditor/ckeditor5-clipboard": "^12.0.2",
     "@ckeditor/ckeditor5-editor-classic": "^12.1.4",
     "@ckeditor/ckeditor5-engine": "^14.0.0",
     "@ckeditor/ckeditor5-enter": "^11.1.0",
     "@ckeditor/ckeditor5-image": "^14.0.0",
     "@ckeditor/ckeditor5-typing": "^12.2.0",
     "@ckeditor/ckeditor5-undo": "^11.0.5",
+    "@ckeditor/ckeditor5-upload": "^12.0.0",
     "eslint": "^5.5.0",
     "eslint-config-ckeditor5": "^2.0.0",
     "husky": "^1.3.1",

+ 516 - 0
packages/ckeditor5-heading/src/title.js

@@ -0,0 +1,516 @@
+/**
+ * @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 heading/title
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+
+import ViewWriter from '@ckeditor/ckeditor5-engine/src/view/downcastwriter';
+import {
+	needsPlaceholder,
+	showPlaceholder,
+	hidePlaceholder,
+	enablePlaceholder
+} from '@ckeditor/ckeditor5-engine/src/view/placeholder';
+import first from '@ckeditor/ckeditor5-utils/src/first';
+
+// A list of element names which should be treated by the Title plugin as title-like.
+// This means that element of a type from this list will be changed to a title element
+// when it is the first element in the root.
+const titleLikeElements = new Set( [ 'paragraph', 'heading1', 'heading2', 'heading3', 'heading4', 'heading5', 'heading6' ] );
+
+/**
+ * The Title plugin.
+ *
+ * It splits the document into `Title` and `Body` sections.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class Title extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'Title';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ Paragraph ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+		const model = editor.model;
+
+		/**
+		 * Reference to the empty paragraph in the body
+		 * created when there's no element in the body for the placeholder purpose.
+		 *
+		 * @private
+		 * @type {null|module:engine/model/element~Element}
+		 */
+		this._bodyPlaceholder = null;
+
+		// To use Schema for disabling some features when selection is inside the title element
+		// it's needed to create the following structure:
+		//
+		// <title>
+		//     <title-content>The title text</title-content>
+		// </title>
+		//
+		// See: https://github.com/ckeditor/ckeditor5/issues/2005.
+		model.schema.register( 'title', { isBlock: true, allowIn: '$root' } );
+		model.schema.register( 'title-content', { isBlock: true, allowIn: 'title', allowAttributes: [ 'alignment' ] } );
+		model.schema.extend( '$text', { allowIn: 'title-content' } );
+
+		// Disallow all attributes in `title-content`.
+		model.schema.addAttributeCheck( context => {
+			if ( context.endsWith( 'title-content $text' ) ) {
+				return false;
+			}
+		} );
+
+		// Because of `title` is represented by two elements in the model
+		// but only one in the view it's needed to adjust Mapper.
+		editor.editing.mapper.on( 'modelToViewPosition', mapModelPositionToView( editor.editing.view ) );
+		editor.data.mapper.on( 'modelToViewPosition', mapModelPositionToView( editor.editing.view ) );
+
+		// `title-content` <-> `h1` conversion.
+		editor.conversion.elementToElement( { model: 'title-content', view: 'h1' } );
+
+		// Take care about correct `title` element structure.
+		model.document.registerPostFixer( writer => this._fixTitleContent( writer ) );
+
+		// Create and take care of correct position of a `title` element.
+		model.document.registerPostFixer( writer => this._fixTitleElement( writer ) );
+
+		// Create element for `Body` placeholder if it is missing.
+		model.document.registerPostFixer( writer => this._fixBodyElement( writer ) );
+
+		// Prevent from adding extra at the end of the document.
+		model.document.registerPostFixer( writer => this._fixExtraParagraph( writer ) );
+
+		// Attach `Title` and `Body` placeholders to the empty title and/or content.
+		this._attachPlaceholders();
+
+		// Attach Tab handling.
+		this._attachTabPressHandling();
+	}
+
+	/**
+	 * Sets the title of the document. This methods does not change any content outside the title element.
+	 *
+	 * @param {String} data Data to be set as a document title.
+	 */
+	setTitle( data ) {
+		const editor = this.editor;
+		const titleElement = this._getTitleElement();
+		const titleContentElement = titleElement.getChild( 0 );
+
+		editor.model.insertContent( editor.data.parse( data, 'title-content' ), titleContentElement, 'in' );
+	}
+
+	/**
+	 * Returns the title of the document. Note, that because this plugin does not allow any formatting inside
+	 * the title element, the output of this method will be a plain text, with no HTML tags. However, it
+	 * may contain some markers, like comments or suggestions. In such case, a special tag for the
+	 * marker will be included in the title text.
+	 *
+	 * @returns {String} Title of the document.
+	 */
+	getTitle() {
+		const titleElement = this._getTitleElement();
+		const titleContentElement = titleElement.getChild( 0 );
+
+		return this.editor.data.stringify( titleContentElement );
+	}
+
+	/**
+	 * Sets the body of the document.
+	 *
+	 * @returns {String} data Data to be set as a body of the document.
+	 */
+	setBody( data ) {
+		const editor = this.editor;
+		const root = editor.model.document.getRoot();
+		const range = editor.model.createRange(
+			editor.model.createPositionAt( root.getChild( 0 ), 'after' ),
+			editor.model.createPositionAt( root, 'end' )
+		);
+
+		editor.model.insertContent( editor.data.parse( data ), range );
+	}
+
+	/**
+	 * Returns the body of the document.
+	 *
+	 * @returns {String} Body of the document.
+	 */
+	getBody() {
+		const root = this.editor.model.document.getRoot();
+		const viewWriter = new ViewWriter();
+
+		// model -> view
+		const viewDocumentFragment = this.editor.data.toView( root );
+
+		// Remove title.
+		viewWriter.remove( viewWriter.createRangeOn( viewDocumentFragment.getChild( 0 ) ) );
+
+		// view -> data
+		return this.editor.data.processor.toData( viewDocumentFragment );
+	}
+
+	/**
+	 * Returns `title` element when it is in the document. Returns `undefined` otherwise.
+	 *
+	 * @private
+	 * @returns {module:engine/model/element~Element|undefined}
+	 */
+	_getTitleElement() {
+		const root = this.editor.model.document.getRoot();
+
+		for ( const child of root.getChildren() ) {
+			if ( isTitle( child ) ) {
+				return child;
+			}
+		}
+	}
+
+	/**
+	 * Model post-fixer callback that ensures `title` has only one `title-content` child.
+	 * All additional children should be moved after the `title` element and renamed to a paragraph.
+	 *
+	 * @private
+	 * @param {module:engine/model/writer~Writer} writer
+	 * @returns {Boolean}
+	 */
+	_fixTitleContent( writer ) {
+		const title = this._getTitleElement();
+
+		// There's no title in the content - it will be created by _fixTitleElement post-fixer.
+		if ( !title || title.maxOffset === 1 ) {
+			return false;
+		}
+
+		const titleChildren = Array.from( title.getChildren() );
+
+		// Skip first child because it is an allowed element.
+		titleChildren.shift();
+
+		for ( const titleChild of titleChildren ) {
+			writer.move( writer.createRangeOn( titleChild ), title, 'after' );
+			writer.rename( titleChild, 'paragraph' );
+		}
+
+		return true;
+	}
+
+	/**
+	 * Model post-fixer callback that creates a title element when it is missing,
+	 * takes care of the correct position of it and removes additional title elements.
+	 *
+	 * @private
+	 * @param {module:engine/model/writer~Writer} writer
+	 * @returns {Boolean}
+	 */
+	_fixTitleElement( writer ) {
+		const model = this.editor.model;
+		const modelRoot = model.document.getRoot();
+
+		const titleElements = Array.from( modelRoot.getChildren() ).filter( isTitle );
+		const firstTitleElement = titleElements[ 0 ];
+		const firstRootChild = modelRoot.getChild( 0 );
+
+		// When title element is at the beginning of the document then try to fix additional
+		// title elements (if there are any) and stop post-fixer as soon as possible.
+		if ( firstRootChild.is( 'title' ) ) {
+			return fixAdditionalTitleElements( titleElements, writer, model );
+		}
+
+		// When there is no title in the document and first element in the document cannot be changed
+		// to the title then create an empty title element at the beginning of the document.
+		if ( !firstTitleElement && !titleLikeElements.has( firstRootChild.name ) ) {
+			const title = writer.createElement( 'title' );
+
+			writer.insert( title, modelRoot );
+			writer.insertElement( 'title-content', title );
+
+			return true;
+		}
+
+		// At this stage, we are sure the title is somewhere in the content. It has to be fixed.
+
+		// Change the first element in the document to the title if it can be changed (is title-like).
+		if ( titleLikeElements.has( firstRootChild.name ) ) {
+			changeElementToTitle( firstRootChild, writer, model );
+		// Otherwise, move the first occurrence of the title element to the beginning of the document.
+		} else {
+			writer.move( writer.createRangeOn( firstTitleElement ), modelRoot, 0 );
+		}
+
+		fixAdditionalTitleElements( titleElements, writer, model );
+
+		return true;
+	}
+
+	/**
+	 * Model post-fixer callback that adds an empty paragraph at the end of the document
+	 * when it is needed for the placeholder purpose.
+	 *
+	 * @private
+	 * @param {module:engine/model/writer~Writer} writer
+	 * @returns {Boolean}
+	 */
+	_fixBodyElement( writer ) {
+		const modelRoot = this.editor.model.document.getRoot();
+
+		if ( modelRoot.childCount < 2 ) {
+			this._bodyPlaceholder = writer.createElement( 'paragraph' );
+			writer.insert( this._bodyPlaceholder, modelRoot, 1 );
+
+			return true;
+		}
+
+		return false;
+	}
+
+	/**
+	 * Model post-fixer callback that removes a paragraph from the end of the document
+	 * if it was created for the placeholder purpose and it is not needed anymore.
+	 *
+	 * @private
+	 * @param {module:engine/model/writer~Writer} writer
+	 * @returns {Boolean}
+	 */
+	_fixExtraParagraph( writer ) {
+		const root = this.editor.model.document.getRoot();
+		const placeholder = this._bodyPlaceholder;
+
+		if ( shouldRemoveLastParagraph( placeholder, root ) ) {
+			this._bodyPlaceholder = null;
+			writer.remove( placeholder );
+
+			return true;
+		}
+
+		return false;
+	}
+
+	/**
+	 * Attaches `Title` and `Body` placeholders to the title and/or content.
+	 *
+	 * @private
+	 */
+	_attachPlaceholders() {
+		const editor = this.editor;
+		const t = editor.t;
+		const view = editor.editing.view;
+		const viewRoot = view.document.getRoot();
+
+		const bodyPlaceholder = editor.config.get( 'placeholder' ) || t( 'Body' );
+		const titlePlaceholder = t( 'Title' );
+
+		// Attach placeholder to the view title element.
+		editor.editing.downcastDispatcher.on( 'insert:title-content', ( evt, data, conversionApi ) => {
+			enablePlaceholder( {
+				view,
+				element: conversionApi.mapper.toViewElement( data.item ),
+				text: titlePlaceholder
+			} );
+		} );
+
+		// Attach placeholder to first element after a title element and remove it if it's not needed anymore.
+		// First element after title can change so we need to observe all changes keep placeholder in sync.
+		let oldBody;
+
+		// This post-fixer runs after the model post-fixer so we can assume that
+		// the second child in view root will always exist.
+		view.document.registerPostFixer( writer => {
+			const body = viewRoot.getChild( 1 );
+			let hasChanged = false;
+
+			// If body element has changed we need to disable placeholder on the previous element
+			// and enable on the new one.
+			if ( body !== oldBody ) {
+				if ( oldBody ) {
+					hidePlaceholder( writer, oldBody );
+					writer.removeAttribute( 'data-placeholder', oldBody );
+				}
+
+				writer.setAttribute( 'data-placeholder', bodyPlaceholder, body );
+				oldBody = body;
+				hasChanged = true;
+			}
+
+			// Then we need to display placeholder if it is needed.
+			if ( needsPlaceholder( body ) && viewRoot.childCount === 2 && body.name === 'p' ) {
+				hasChanged = showPlaceholder( writer, body ) ? true : hasChanged;
+			// Or hide if it is not needed.
+			} else {
+				hasChanged = hidePlaceholder( writer, body ) ? true : hasChanged;
+			}
+
+			return hasChanged;
+		} );
+	}
+
+	/**
+	 * Creates navigation between Title and Body sections using `Tab` and `Shift+Tab` keys.
+	 *
+	 * @private
+	 */
+	_attachTabPressHandling() {
+		const editor = this.editor;
+		const model = editor.model;
+
+		// Pressing `Tab` inside the title should move the caret to the body.
+		editor.keystrokes.set( 'TAB', ( data, cancel ) => {
+			model.change( writer => {
+				const selection = model.document.selection;
+				const selectedElements = Array.from( selection.getSelectedBlocks() );
+
+				if ( selectedElements.length === 1 && selectedElements[ 0 ].is( 'title-content' ) ) {
+					const firstBodyElement = model.document.getRoot().getChild( 1 );
+					writer.setSelection( firstBodyElement, 0 );
+					cancel();
+				}
+			} );
+		} );
+
+		// Pressing `Shift+Tab` at the beginning of the body should move the caret to the title.
+		editor.keystrokes.set( 'SHIFT + TAB', ( data, cancel ) => {
+			model.change( writer => {
+				const selection = model.document.selection;
+
+				if ( !selection.isCollapsed ) {
+					return;
+				}
+
+				const root = editor.model.document.getRoot();
+				const selectedElement = first( selection.getSelectedBlocks() );
+				const selectionPosition = selection.getFirstPosition();
+
+				const title = root.getChild( 0 );
+				const body = root.getChild( 1 );
+
+				if ( selectedElement === body && selectionPosition.isAtStart ) {
+					writer.setSelection( title.getChild( 0 ), 0 );
+					cancel();
+				}
+			} );
+		} );
+	}
+}
+
+// Maps position from the beginning of the model `title` element to the beginning of the view `h1` element.
+//
+// <title>^<title-content>Foo</title-content></title> -> <h1>^Foo</h1>
+//
+// @param {module:editor/view/view~View} editingView
+function mapModelPositionToView( editingView ) {
+	return ( evt, data ) => {
+		const positionParent = data.modelPosition.parent;
+
+		if ( !positionParent.is( 'title' ) ) {
+			return;
+		}
+
+		const modelTitleElement = positionParent.parent;
+		const viewElement = data.mapper.toViewElement( modelTitleElement );
+
+		data.viewPosition = editingView.createPositionAt( viewElement, 0 );
+		evt.stop();
+	};
+}
+
+// Returns true when given element is a title. Returns false otherwise.
+//
+// @param {module:engine/model/element~Element} element
+// @returns {Boolean}
+function isTitle( element ) {
+	return element.is( 'title' );
+}
+
+// Changes the given element to the title element.
+//
+// @param {module:engine/model/element~Element} element
+// @param {module:engine/model/writer~Writer} writer
+// @param {module:engine/model/model~Model} model
+function changeElementToTitle( element, writer, model ) {
+	const title = writer.createElement( 'title' );
+
+	writer.insert( title, element, 'before' );
+	writer.insert( element, title, 0 );
+	writer.rename( element, 'title-content' );
+	model.schema.removeDisallowedAttributes( [ element ], writer );
+}
+
+// Loops over the list of title elements and fixes additional ones.
+//
+// @param {Array.<module:engine/model/element~Element>} titleElements
+// @param {module:engine/model/writer~Writer} writer
+// @param {module:engine/model/model~Model} model
+// @returns {Boolean} Returns true when there was any change. Returns false otherwise.
+function fixAdditionalTitleElements( titleElements, writer, model ) {
+	let hasChanged = false;
+
+	for ( const title of titleElements ) {
+		if ( title.index !== 0 ) {
+			fixTitleElement( title, writer, model );
+			hasChanged = true;
+		}
+	}
+
+	return hasChanged;
+}
+
+// Changes given title element to a paragraph or removes it when it is empty.
+//
+// @param {module:engine/model/element~Element} title
+// @param {module:engine/model/writer~Writer} writer
+// @param {module:engine/model/model~Model} model
+function fixTitleElement( title, writer, model ) {
+	const child = title.getChild( 0 );
+
+	// Empty title should be removed.
+	// It is created as a result of pasting to the title element.
+	if ( child.isEmpty ) {
+		writer.remove( title );
+
+		return;
+	}
+
+	writer.move( writer.createRangeOn( child ), title, 'before' );
+	writer.rename( child, 'paragraph' );
+	writer.remove( title );
+	model.schema.removeDisallowedAttributes( [ child ], writer );
+}
+
+// Returns true when the last paragraph in the document was created only for the placeholder
+// purpose and it's not needed anymore. Returns false otherwise.
+//
+// @param {module:engine/model/rootelement~RootElement} root
+// @param {module:engine/model/element~Element} placeholder
+// @returns {Boolean}
+function shouldRemoveLastParagraph( placeholder, root ) {
+	if ( !placeholder || !placeholder.is( 'paragraph' ) || placeholder.childCount ) {
+		return false;
+	}
+
+	if ( root.childCount <= 2 || root.getChild( root.childCount - 1 ) !== placeholder ) {
+		return false;
+	}
+
+	return true;
+}

+ 2 - 0
packages/ckeditor5-heading/tests/manual/title.html

@@ -0,0 +1,2 @@
+<div id="editor">
+</div>

+ 33 - 0
packages/ckeditor5-heading/tests/manual/title.js

@@ -0,0 +1,33 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals console, document, window */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import Enter from '@ckeditor/ckeditor5-enter/src/enter';
+import Typing from '@ckeditor/ckeditor5-typing/src/typing';
+import Title from '../../src/title';
+import Heading from '../../src/heading';
+import Undo from '@ckeditor/ckeditor5-undo/src/undo';
+import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
+import { UploadAdapterMock } from '@ckeditor/ckeditor5-upload/tests/_utils/mocks';
+import Image from '@ckeditor/ckeditor5-image/src/image';
+import ImageUpload from '@ckeditor/ckeditor5-image/src/imageupload';
+import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
+import Alignment from '@ckeditor/ckeditor5-alignment/src/alignment';
+
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		plugins: [ Enter, Typing, Undo, Heading, Title, Clipboard, Image, ImageUpload, Bold, Alignment ],
+		toolbar: [ 'heading', '|', 'undo', 'redo', 'bold', 'imageUpload', 'alignment' ]
+	} )
+	.then( editor => {
+		window.editor = editor;
+
+		editor.plugins.get( 'FileRepository' ).createUploadAdapter = loader => new UploadAdapterMock( loader );
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );

+ 29 - 0
packages/ckeditor5-heading/tests/manual/title.md

@@ -0,0 +1,29 @@
+## Title feature
+
+- you should see `Title` and `Body` placeholders when there is no text in any of the sections.
+- you should be able to put the selection to the both sections and jump between them using `Shift` and `Tab+Shift`.
+
+### Prevent extra paragraphing (typing)
+
+- type `FooBar` in the title
+- place selection in the middle of the title `Foo{}Bar`
+- press `Enter`
+
+There should be no empty paragraph at the end of document, title should contains `Foo` body `Bar`.
+
+### Prevent extra paragraphing (pasting)
+
+- type `Foo` in the title
+- type `Bar` in the body
+- select and cut all text (you should see an empty document with selection in the title element)
+- paste text
+
+There should be no empty paragraph at the end of document, title should contains `Foo` body `Bar`.
+
+### Changing title
+
+- type something in the title
+- put selection in the middle of the title
+
+Heading dropdown, upload and bold icons should be disabled as long as selection stay in the title element.
+Alignment feature should be enabled.

+ 785 - 0
packages/ckeditor5-heading/tests/title.js

@@ -0,0 +1,785 @@
+/**
+ * @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 ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+
+import Title from '../src/title';
+import Heading from '../src/heading';
+import Enter from '@ckeditor/ckeditor5-enter/src/enter';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import BlockQuote from '@ckeditor/ckeditor5-block-quote/src/blockquote';
+import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
+import Image from '@ckeditor/ckeditor5-image/src/image';
+import ImageUpload from '@ckeditor/ckeditor5-image/src/imageupload';
+import Undo from '@ckeditor/ckeditor5-undo/src/undo';
+
+import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
+
+describe( 'Title', () => {
+	let element, editor, model;
+
+	beforeEach( () => {
+		element = document.createElement( 'div' );
+		document.body.appendChild( element );
+
+		return ClassicTestEditor.create( element, {
+			plugins: [ Title, Heading, BlockQuote, Clipboard, Image, ImageUpload, Enter, Undo ]
+		} ).then( _editor => {
+			editor = _editor;
+			model = editor.model;
+		} );
+	} );
+
+	afterEach( () => {
+		return editor.destroy().then( () => element.remove() );
+	} );
+
+	it( 'should requires Paragraph plugin', () => {
+		expect( Title.requires ).to.have.members( [ Paragraph ] );
+	} );
+
+	it( 'should have plugin name property', () => {
+		expect( Title.pluginName ).to.equal( 'Title' );
+	} );
+
+	it( 'should set proper schema rules', () => {
+		expect( model.schema.isRegistered( 'title' ) ).to.equal( true );
+		expect( model.schema.isBlock( 'title' ) ).to.equal( true );
+		expect( model.schema.isRegistered( 'title-content' ) ).to.equal( true );
+		expect( model.schema.isBlock( 'title-content' ) ).to.equal( true );
+
+		expect( model.schema.checkChild( 'title', '$text' ) ).to.equal( false );
+		expect( model.schema.checkChild( 'title', '$block' ) ).to.equal( false );
+		expect( model.schema.checkChild( 'title', 'title-content' ) ).to.equal( true );
+		expect( model.schema.checkChild( '$root', 'title' ) ).to.equal( true );
+		expect( model.schema.checkChild( '$root', 'title-content' ) ).to.equal( false );
+		expect( model.schema.checkChild( '$block', 'title-content' ) ).to.equal( false );
+		expect( model.schema.checkChild( 'title-content', '$text' ) ).to.equal( true );
+		expect( model.schema.checkChild( 'title-content', '$block' ) ).to.equal( false );
+
+		expect( model.schema.checkAttribute( [ 'title-content' ], 'alignment' ) ).to.equal( true );
+
+		model.schema.extend( '$text', { allowAttributes: [ 'bold' ] } );
+		expect( model.schema.checkAttribute( [ 'title-content', '$text' ], 'bold' ) ).to.equal( false );
+	} );
+
+	it( 'should convert title to h1', () => {
+		setData( model,
+			'<title><title-content>Foo</title-content></title>' +
+			'<paragraph>Bar</paragraph>'
+		);
+
+		expect( editor.getData() ).to.equal( '<h1>Foo</h1><p>Bar</p>' );
+	} );
+
+	describe( 'model post-fixing', () => {
+		it( 'should set title and content elements', () => {
+			setData( model,
+				'<title><title-content>Foo</title-content></title>' +
+				'<paragraph>Bar</paragraph>'
+			);
+
+			expect( getData( model ) ).to.equal(
+				'<title><title-content>[]Foo</title-content></title>' +
+				'<paragraph>Bar</paragraph>'
+			);
+		} );
+
+		it( 'should create a content element when only title has been set', () => {
+			setData( model, '<title><title-content>Foo</title-content></title>' );
+
+			expect( getData( model ) ).to.equal(
+				'<title><title-content>[]Foo</title-content></title>' +
+				'<paragraph></paragraph>'
+			);
+		} );
+
+		it( 'should create a title and content elements when are missing', () => {
+			setData( model, '' );
+
+			expect( getData( model ) ).to.equal(
+				'<title><title-content>[]</title-content></title>' +
+				'<paragraph></paragraph>'
+			);
+		} );
+
+		it( 'should change heading element to title when is set as a first root child', () => {
+			setData( model,
+				'<heading1>Foo</heading1>' +
+				'<heading1>Bar</heading1>'
+			);
+
+			expect( getData( model ) ).to.equal(
+				'<title><title-content>[]Foo</title-content></title>' +
+				'<heading1>Bar</heading1>'
+			);
+		} );
+
+		it( 'should change paragraph element to title when is set as a first root child', () => {
+			setData( model,
+				'<paragraph>Foo</paragraph>' +
+				'<paragraph>Bar</paragraph>'
+			);
+
+			expect( getData( model ) ).to.equal(
+				'<title><title-content>[]Foo</title-content></title>' +
+				'<paragraph>Bar</paragraph>'
+			);
+		} );
+
+		it( 'should change paragraph element to title and then change additional title elements to paragraphs', () => {
+			setData( model,
+				'<paragraph>Foo</paragraph>' +
+				'<title><title-content>Bar</title-content></title>'
+			);
+
+			expect( getData( model ) ).to.equal(
+				'<title><title-content>[]Foo</title-content></title>' +
+				'<paragraph>Bar</paragraph>'
+			);
+		} );
+
+		it( 'should change title element to a paragraph when is not a first root child #1', () => {
+			setData( model,
+				'<title><title-content>Foo</title-content></title>' +
+				'<title><title-content>Bar</title-content></title>'
+			);
+
+			expect( getData( model ) ).to.equal(
+				'<title><title-content>[]Foo</title-content></title>' +
+				'<paragraph>Bar</paragraph>'
+			);
+		} );
+
+		it( 'should change title element to a paragraph when is not a first root child #2', () => {
+			setData( model,
+				'<title><title-content>Foo</title-content></title>' +
+				'<paragraph>Bar</paragraph>' +
+				'<title><title-content>Biz</title-content></title>'
+			);
+
+			expect( getData( model ) ).to.equal(
+				'<title><title-content>[]Foo</title-content></title>' +
+				'<paragraph>Bar</paragraph>' +
+				'<paragraph>Biz</paragraph>'
+			);
+		} );
+
+		it( 'should move title at the beginning of the root when first root child is not allowed to be a title #1', () => {
+			setData( model,
+				'<blockQuote><paragraph>Foo</paragraph></blockQuote>' +
+				'<title><title-content>Bar</title-content></title>'
+			);
+
+			expect( getData( model ) ).to.equal(
+				'<title><title-content>[]Bar</title-content></title>' +
+				'<blockQuote><paragraph>Foo</paragraph></blockQuote>'
+			);
+		} );
+
+		it( 'should move title at the beginning of the root when first root child is not allowed to be a title #2', () => {
+			setData( model,
+				'<blockQuote><paragraph>Foo</paragraph></blockQuote>' +
+				'<blockQuote><paragraph>Bar</paragraph></blockQuote>' +
+				'<title><title-content>Biz</title-content></title>'
+			);
+
+			expect( getData( model ) ).to.equal(
+				'<title><title-content>[]Biz</title-content></title>' +
+				'<blockQuote><paragraph>Foo</paragraph></blockQuote>' +
+				'<blockQuote><paragraph>Bar</paragraph></blockQuote>'
+			);
+		} );
+
+		it( 'should move title at the beginning of the root when first root child is not allowed to be a title #3', () => {
+			setData( model,
+				'<blockQuote><paragraph>Foo</paragraph></blockQuote>' +
+				'<paragraph>Bar</paragraph>' +
+				'<title><title-content>Biz</title-content></title>'
+			);
+
+			expect( getData( model ) ).to.equal(
+				'<title><title-content>[]Biz</title-content></title>' +
+				'<blockQuote><paragraph>Foo</paragraph></blockQuote>' +
+				'<paragraph>Bar</paragraph>'
+			);
+		} );
+
+		it( 'should create a missing title element before an element that cannot to be a title element', () => {
+			setData( model, '<blockQuote><paragraph>Foo</paragraph></blockQuote>' );
+
+			expect( getData( model ) ).to.equal(
+				'<title><title-content>[]</title-content></title>' +
+				'<blockQuote><paragraph>Foo</paragraph></blockQuote>'
+			);
+		} );
+
+		it( 'should clear element from attributes when changing to title element', () => {
+			model.schema.extend( '$text', { allowAttributes: 'foo' } );
+			model.schema.extend( 'paragraph', { allowAttributes: [ 'foo', 'alignment' ] } );
+
+			setData( model,
+				'<paragraph alignment="justify" foo="true">F<$text foo="true">o</$text>o</paragraph>' +
+				'<paragraph foo="true">B<$text foo="true">a</$text>r</paragraph>'
+			);
+
+			expect( getData( model ) ).to.equal(
+				'<title><title-content alignment="justify">[]Foo</title-content></title>' +
+				'<paragraph foo="true">B<$text foo="true">a</$text>r</paragraph>'
+			);
+		} );
+	} );
+
+	describe( 'removes extra paragraph', () => {
+		it( 'should remove the extra paragraph when pasting to the editor with body placeholder', () => {
+			setData( model, '<title><title-content>[]</title-content></title>' );
+
+			const dataTransferMock = {
+				getData: type => {
+					if ( type === 'text/html' ) {
+						return '<h1>Title</h1><p>Body</p>';
+					}
+				},
+				types: [],
+				files: []
+			};
+
+			editor.editing.view.document.fire( 'paste', {
+				dataTransfer: dataTransferMock,
+				preventDefault() {}
+			} );
+
+			expect( getData( model ) ).to.equal(
+				'<title><title-content>Title</title-content></title>' +
+				'<paragraph>Body[]</paragraph>'
+			);
+		} );
+
+		it( 'should not remove the extra paragraph when pasting to the editor with directly created body element', () => {
+			setData( model,
+				'<title><title-content>[]</title-content></title>' +
+				'<paragraph></paragraph>'
+			);
+
+			const dataTransferMock = {
+				getData: type => {
+					if ( type === 'text/html' ) {
+						return '<h1>Title</h1><p>Body</p>';
+					}
+				},
+				types: [],
+				files: []
+			};
+
+			editor.editing.view.document.fire( 'paste', {
+				dataTransfer: dataTransferMock,
+				preventDefault() {}
+			} );
+
+			expect( getData( model ) ).to.equal(
+				'<title><title-content>Title</title-content></title>' +
+				'<paragraph>Body[]</paragraph>' +
+				'<paragraph></paragraph>'
+			);
+		} );
+
+		it( 'should remove the extra paragraph when pressing enter in the title', () => {
+			setData( model, '<title><title-content>fo[]o</title-content></title>' );
+
+			editor.execute( 'enter' );
+
+			expect( getData( model ) ).to.equal(
+				'<title><title-content>fo</title-content></title>' +
+				'<paragraph>[]o</paragraph>'
+			);
+		} );
+
+		it( 'should not remove the extra paragraph when pressing enter in the title when body is created directly', () => {
+			setData( model,
+				'<title><title-content>fo[]o</title-content></title>' +
+				'<paragraph></paragraph>'
+			);
+
+			editor.execute( 'enter' );
+
+			expect( getData( model ) ).to.equal(
+				'<title><title-content>fo</title-content></title>' +
+				'<paragraph>[]o</paragraph>' +
+				'<paragraph></paragraph>'
+			);
+		} );
+	} );
+
+	describe( 'setTitle()', () => {
+		it( 'should set new content of a title element', () => {
+			setData( model,
+				'<title><title-content></title-content></title>' +
+				'<paragraph>Bar</paragraph>'
+			);
+
+			editor.plugins.get( 'Title' ).setTitle( 'Biz' );
+
+			expect( getData( model ) ).to.equal(
+				'<title><title-content>[]Biz</title-content></title>' +
+				'<paragraph>Bar</paragraph>'
+			);
+		} );
+
+		it( 'should replace old content of a title element', () => {
+			setData( model,
+				'<title><title-content>Foo</title-content></title>' +
+				'<paragraph>Bar</paragraph>'
+			);
+
+			editor.plugins.get( 'Title' ).setTitle( 'Biz' );
+
+			expect( getData( model ) ).to.equal(
+				'<title><title-content>[]Biz</title-content></title>' +
+				'<paragraph>Bar</paragraph>'
+			);
+		} );
+
+		it( 'should clear content of a title element', () => {
+			setData( model,
+				'<title><title-content>Foo</title-content></title>' +
+				'<paragraph>Bar</paragraph>'
+			);
+
+			editor.plugins.get( 'Title' ).setTitle( '' );
+
+			expect( getData( model ) ).to.equal(
+				'<title><title-content>[]</title-content></title>' +
+				'<paragraph>Bar</paragraph>'
+			);
+		} );
+
+		it( 'should properly handle HTML element', () => {
+			setData( model,
+				'<title><title-content></title-content></title>' +
+				'<paragraph>Bar</paragraph>'
+			);
+
+			editor.plugins.get( 'Title' ).setTitle( '<p>Foo</p>' );
+
+			expect( getData( model ) ).to.equal(
+				'<title><title-content>[]Foo</title-content></title>' +
+				'<paragraph>Bar</paragraph>'
+			);
+		} );
+
+		it( 'should properly handle multiple HTML elements', () => {
+			setData( model,
+				'<title><title-content>Foo</title-content></title>' +
+				'<paragraph>Bar</paragraph>'
+			);
+
+			editor.plugins.get( 'Title' ).setTitle( '<p>Foo</p><p>bar</p>' );
+
+			expect( getData( model ) ).to.equal(
+				'<title><title-content>[]Foobar</title-content></title>' +
+				'<paragraph>Bar</paragraph>'
+			);
+		} );
+
+		it( 'should do nothing when setting empty content to the empty title', () => {
+			setData( model,
+				'<title><title-content></title-content></title>' +
+				'<paragraph>Bar</paragraph>'
+			);
+
+			editor.plugins.get( 'Title' ).setTitle( '' );
+
+			expect( getData( model ) ).to.equal(
+				'<title><title-content>[]</title-content></title>' +
+				'<paragraph>Bar</paragraph>'
+			);
+		} );
+	} );
+
+	describe( 'getTitle()', () => {
+		it( 'should return content of a title element', () => {
+			setData( model,
+				'<title><title-content>Foo</title-content></title>' +
+				'<paragraph>Bar</paragraph>'
+			);
+
+			expect( editor.plugins.get( 'Title' ).getTitle() ).to.equal( 'Foo' );
+		} );
+
+		it( 'should return content of an empty title element', () => {
+			setData( model,
+				'<title><title-content></title-content></title>' +
+				'<paragraph>Bar</paragraph>'
+			);
+
+			expect( editor.plugins.get( 'Title' ).getTitle() ).to.equal( '' );
+		} );
+	} );
+
+	describe( 'setBody()', () => {
+		it( 'should set new content to body', () => {
+			setData( model,
+				'<title><title-content>Foo</title-content></title>' +
+				'<paragraph>Bar</paragraph>'
+			);
+
+			editor.plugins.get( 'Title' ).setBody( 'Biz' );
+
+			expect( getData( model ) ).to.equal(
+				'<title><title-content>[]Foo</title-content></title>' +
+				'<paragraph>Biz</paragraph>'
+			);
+		} );
+
+		it( 'should set empty content to body', () => {
+			setData( model,
+				'<title><title-content>Foo</title-content></title>' +
+				'<paragraph>Bar</paragraph>'
+			);
+
+			editor.plugins.get( 'Title' ).setBody( '' );
+
+			expect( getData( model ) ).to.equal(
+				'<title><title-content>[]Foo</title-content></title>' +
+				'<paragraph></paragraph>'
+			);
+		} );
+
+		it( 'should set html content to body', () => {
+			setData( model,
+				'<title><title-content>Foo</title-content>' +
+				'</title><paragraph>Bar</paragraph>'
+			);
+
+			editor.plugins.get( 'Title' ).setBody( '<blockQuote>Bar</blockQuote><p>Biz</p>' );
+
+			expect( getData( model ) ).to.equal(
+				'<title><title-content>[]Foo</title-content></title>' +
+				'<blockQuote><paragraph>Bar</paragraph></blockQuote>' +
+				'<paragraph>Biz</paragraph>'
+			);
+		} );
+	} );
+
+	describe( 'getBody()', () => {
+		it( 'should return all data except the title element', () => {
+			setData( model,
+				'<title><title-content>Foo</title-content></title>' +
+				'<paragraph>Bar</paragraph>' +
+				'<paragraph>Biz</paragraph>'
+			);
+
+			expect( editor.plugins.get( 'Title' ).getBody() ).to.equal( '<p>Bar</p><p>Biz</p>' );
+		} );
+
+		it( 'should return empty paragraph when body is empty', () => {
+			setData( model, '<title><title-content>Foo</title-content></title>' );
+
+			expect( editor.plugins.get( 'Title' ).getBody() ).to.equal( '<p>&nbsp;</p>' );
+		} );
+	} );
+
+	describe( 'placeholders', () => {
+		let viewRoot;
+
+		beforeEach( () => {
+			viewRoot = editor.editing.view.document.getRoot();
+		} );
+
+		it( 'should attach placeholder placeholder to title and body', () => {
+			setData( model,
+				'<title><title-content>Foo</title-content></title>' +
+				'<paragraph>Bar</paragraph>'
+			);
+
+			const title = viewRoot.getChild( 0 );
+			const body = viewRoot.getChild( 1 );
+
+			expect( title.getAttribute( 'data-placeholder' ) ).to.equal( 'Title' );
+			expect( body.getAttribute( 'data-placeholder' ) ).to.equal( 'Body' );
+
+			expect( title.hasClass( 'ck-placeholder' ) ).to.equal( false );
+			expect( body.hasClass( 'ck-placeholder' ) ).to.equal( false );
+		} );
+
+		it( 'should show placeholder in empty title and body', () => {
+			setData( model,
+				'<title><title-content></title-content></title>' +
+				'<paragraph></paragraph>'
+			);
+
+			const title = viewRoot.getChild( 0 );
+			const body = viewRoot.getChild( 1 );
+
+			expect( title.getAttribute( 'data-placeholder' ) ).to.equal( 'Title' );
+			expect( body.getAttribute( 'data-placeholder' ) ).to.equal( 'Body' );
+
+			expect( title.hasClass( 'ck-placeholder' ) ).to.equal( true );
+			expect( body.hasClass( 'ck-placeholder' ) ).to.equal( true );
+		} );
+
+		it( 'should hide placeholder from body with more than one child elements', () => {
+			setData( editor.model,
+				'<title><title-content>Foo</title-content></title>' +
+				'<paragraph></paragraph>' +
+				'<paragraph></paragraph>'
+			);
+
+			const body = viewRoot.getChild( 1 );
+
+			expect( body.getAttribute( 'data-placeholder' ) ).to.equal( 'Body' );
+			expect( body.hasClass( 'ck-placeholder' ) ).to.equal( false );
+		} );
+
+		it( 'should hide placeholder from body with element other than paragraph', () => {
+			setData( editor.model,
+				'<title><title-content>Foo</title-content></title>' +
+				'<heading1></heading1>'
+			);
+
+			const body = viewRoot.getChild( 1 );
+
+			expect( body.hasAttribute( 'data-placeholder' ) ).to.equal( true );
+			expect( body.hasClass( 'ck-placeholder' ) ).to.equal( false );
+		} );
+
+		it( 'should hide placeholder when title element become not empty', () => {
+			setData( model,
+				'<title><title-content></title-content></title>' +
+				'<paragraph>[]</paragraph>'
+			);
+
+			expect( viewRoot.getChild( 0 ).hasClass( 'ck-placeholder' ) ).to.equal( true );
+
+			model.change( writer => {
+				writer.appendText( 'Bar', null, model.document.getRoot().getChild( 0 ).getChild( 0 ) );
+			} );
+
+			expect( viewRoot.getChild( 0 ).hasClass( 'ck-placeholder' ) ).to.equal( false );
+		} );
+
+		it( 'should hide placeholder when body element become not empty', () => {
+			setData( model,
+				'<title><title-content>Foo</title-content></title>' +
+				'<paragraph></paragraph>'
+			);
+
+			expect( viewRoot.getChild( 1 ).hasClass( 'ck-placeholder' ) ).to.equal( true );
+
+			model.change( writer => {
+				writer.appendText( 'Bar', null, model.document.getRoot().getChild( 1 ) );
+			} );
+
+			expect( viewRoot.getChild( 1 ).hasClass( 'ck-placeholder' ) ).to.equal( false );
+		} );
+
+		it( 'should properly map the body placeholder in DOM when undoing', () => {
+			const viewRoot = editor.editing.view.document.getRoot();
+			const domConverter = editor.editing.view.domConverter;
+			let bodyDomElement;
+
+			setData( editor.model,
+				'<title><title-content>[Foo</title-content></title>' +
+				'<paragraph>Bar</paragraph>' +
+				'<paragraph>Baz]</paragraph>'
+			);
+			editor.model.deleteContent( editor.model.document.selection );
+
+			bodyDomElement = domConverter.mapViewToDom( viewRoot.getChild( 1 ) );
+
+			expect( bodyDomElement.dataset.placeholder ).to.equal( 'Body' );
+			expect( bodyDomElement.classList.contains( 'ck-placeholder' ) ).to.equal( true );
+
+			editor.execute( 'undo' );
+
+			bodyDomElement = domConverter.mapViewToDom( viewRoot.getChild( 1 ) );
+
+			expect( bodyDomElement.dataset.placeholder ).to.equal( 'Body' );
+			expect( bodyDomElement.classList.contains( 'ck-placeholder' ) ).to.equal( false );
+		} );
+
+		it( 'should use placeholder defined through EditorConfig as a Body placeholder', () => {
+			const element = document.createElement( 'div' );
+			document.body.appendChild( element );
+
+			return ClassicTestEditor.create( element, {
+				plugins: [ Title ],
+				placeholder: 'Custom editor placeholder'
+			} ).then( editor => {
+				const viewRoot = editor.editing.view.document.getRoot();
+				const title = viewRoot.getChild( 0 );
+				const body = viewRoot.getChild( 1 );
+
+				expect( title.getAttribute( 'data-placeholder' ) ).to.equal( 'Title' );
+				expect( title.hasClass( 'ck-placeholder' ) ).to.equal( true );
+
+				expect( body.getAttribute( 'data-placeholder' ) ).to.equal( 'Custom editor placeholder' );
+				expect( body.hasClass( 'ck-placeholder' ) ).to.equal( true );
+
+				return editor.destroy().then( () => element.remove() );
+			} );
+		} );
+	} );
+
+	describe( 'Tab press handling', () => {
+		it( 'should handle tab key when the selection is at the beginning of the title', () => {
+			setData( model,
+				'<title><title-content>[]foo</title-content></title>' +
+				'<paragraph>bar</paragraph>'
+			);
+
+			const eventData = getEventData( keyCodes.tab );
+
+			editor.keystrokes.press( eventData );
+
+			sinon.assert.calledOnce( eventData.preventDefault );
+			sinon.assert.calledOnce( eventData.stopPropagation );
+			expect( getData( model ) ).to.equal(
+				'<title><title-content>foo</title-content></title>' +
+				'<paragraph>[]bar</paragraph>'
+			);
+		} );
+
+		it( 'should handle tab key when the selection is at the end of the title', () => {
+			setData( model,
+				'<title><title-content>foo[]</title-content></title>' +
+				'<paragraph>bar</paragraph>'
+			);
+
+			const eventData = getEventData( keyCodes.tab );
+
+			editor.keystrokes.press( eventData );
+
+			sinon.assert.calledOnce( eventData.preventDefault );
+			sinon.assert.calledOnce( eventData.stopPropagation );
+			expect( getData( model ) ).to.equal(
+				'<title><title-content>foo</title-content></title>' +
+				'<paragraph>[]bar</paragraph>'
+			);
+		} );
+
+		it( 'should not handle tab key when the selection is in the title and body', () => {
+			setData( model,
+				'<title><title-content>fo[o</title-content></title>' +
+				'<paragraph>b]ar</paragraph>'
+			);
+
+			const eventData = getEventData( keyCodes.tab );
+
+			editor.keystrokes.press( eventData );
+
+			sinon.assert.notCalled( eventData.preventDefault );
+			sinon.assert.notCalled( eventData.stopPropagation );
+			expect( getData( model ) ).to.equal(
+				'<title><title-content>fo[o</title-content></title>' +
+				'<paragraph>b]ar</paragraph>'
+			);
+		} );
+
+		it( 'should not handle tab key when the selection is in the body', () => {
+			setData( model,
+				'<title><title-content>foo</title-content></title>' +
+				'<paragraph>[]bar</paragraph>'
+			);
+
+			const eventData = getEventData( keyCodes.tab );
+
+			editor.keystrokes.press( eventData );
+
+			sinon.assert.notCalled( eventData.preventDefault );
+			sinon.assert.notCalled( eventData.stopPropagation );
+			expect( getData( model ) ).to.equal(
+				'<title><title-content>foo</title-content></title>' +
+				'<paragraph>[]bar</paragraph>'
+			);
+		} );
+	} );
+
+	describe( 'Shift + Tab press handling', () => {
+		it( 'should handle shift + tab keys when the selection is at the beginning of the body', () => {
+			setData( model,
+				'<title><title-content>foo</title-content></title>' +
+				'<paragraph>[]bar</paragraph>'
+			);
+
+			const eventData = getEventData( keyCodes.tab, { shiftKey: true } );
+
+			editor.keystrokes.press( eventData );
+
+			sinon.assert.calledOnce( eventData.preventDefault );
+			sinon.assert.calledOnce( eventData.stopPropagation );
+			expect( getData( model ) ).to.equal(
+				'<title><title-content>[]foo</title-content></title>' +
+				'<paragraph>bar</paragraph>'
+			);
+		} );
+
+		it( 'should not handle shift + tab keys when the selection is not at the beginning of the body', () => {
+			setData( model,
+				'<title><title-content>foo</title-content></title>' +
+				'<paragraph>b[]ar</paragraph>'
+			);
+
+			const eventData = getEventData( keyCodes.tab, { shiftKey: true } );
+
+			editor.keystrokes.press( eventData );
+
+			sinon.assert.notCalled( eventData.preventDefault );
+			sinon.assert.notCalled( eventData.stopPropagation );
+			expect( getData( model ) ).to.equal(
+				'<title><title-content>foo</title-content></title>' +
+				'<paragraph>b[]ar</paragraph>'
+			);
+		} );
+
+		it( 'should not handle shift + tab keys when the selection is not collapsed', () => {
+			setData( model,
+				'<title><title-content>foo</title-content></title>' +
+				'<paragraph>[b]ar</paragraph>'
+			);
+
+			const eventData = getEventData( keyCodes.tab, { shiftKey: true } );
+
+			editor.keystrokes.press( eventData );
+
+			sinon.assert.notCalled( eventData.preventDefault );
+			sinon.assert.notCalled( eventData.stopPropagation );
+			expect( getData( model ) ).to.equal(
+				'<title><title-content>foo</title-content></title>' +
+				'<paragraph>[b]ar</paragraph>'
+			);
+		} );
+
+		it( 'should not handle shift + tab keys when the selection is in the title', () => {
+			setData( model,
+				'<title><title-content>[]foo</title-content></title>' +
+				'<paragraph>bar</paragraph>'
+			);
+
+			const eventData = getEventData( keyCodes.tab, { shiftKey: true } );
+
+			editor.keystrokes.press( eventData );
+
+			sinon.assert.notCalled( eventData.preventDefault );
+			sinon.assert.notCalled( eventData.stopPropagation );
+			expect( getData( model ) ).to.equal(
+				'<title><title-content>[]foo</title-content></title>' +
+				'<paragraph>bar</paragraph>'
+			);
+		} );
+	} );
+} );
+
+function getEventData( keyCode, { shiftKey = false } = {} ) {
+	return {
+		keyCode,
+		shiftKey,
+		preventDefault: sinon.spy(),
+		stopPropagation: sinon.spy()
+	};
+}