Procházet zdrojové kódy

Introduced Title plugin.

Oskar Wróbel před 6 roky
rodič
revize
898dca1d42

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

@@ -0,0 +1,323 @@
+/**
+ * @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 Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import Enter from '@ckeditor/ckeditor5-enter/src/enter';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+
+import {
+	needsPlaceholder,
+	showPlaceholder,
+	hidePlaceholder,
+	enablePlaceholder
+} from '@ckeditor/ckeditor5-engine/src/view/placeholder';
+
+import ViewWriter from '@ckeditor/ckeditor5-engine/src/view/downcastwriter';
+
+const allowedToBeTitle = new Set( [ 'heading1', 'heading2', 'heading3', 'paragraph' ] );
+
+/**
+ * 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, Enter ];
+	}
+
+	/**
+	 * @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.
+		 *
+		 * @private
+		 * @type {null|module:engine/model/element~Element}
+		 */
+		this._bodyPlaceholder = null;
+
+		// Schema.
+		model.schema.register( 'title', { inheritAllFrom: '$block' } );
+
+		// Allow title only directly inside a root.
+		model.schema.addChildCheck( ( context, childDefinition ) => {
+			if ( !context.endsWith( '$root' ) && childDefinition.name === 'title' ) {
+				return false;
+			}
+		} );
+
+		editor.conversion.elementToElement( { model: 'title', view: 'h1' } );
+
+		// Create and take care about proper position of a title element.
+		model.document.registerPostFixer( writer => this._fixTitleElement( writer ) );
+
+		// Prevent from adding extra paragraph after paste or enter.
+		model.document.registerPostFixer( writer => this._preventExtraParagraphing( 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.
+	 *
+	 * @param {String} data Data to be set as a document title.
+	 */
+	setTitle( data ) {
+		const editor = this.editor;
+		const title = editor.model.document.getRoot().getChild( 0 );
+
+		editor.model.insertContent( editor.data.parse( data ), title, 'in' );
+	}
+
+	/**
+	 * Returns the title of the document.
+	 *
+	 * @returns {String} Title of the document.
+	 */
+	getTitle() {
+		const title = this.editor.model.document.getRoot().getChild( 0 );
+
+		return this.editor.data.stringify( title );
+	}
+
+	/**
+	 * 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 );
+	}
+
+	/**
+	 * Model post-fixer callback that takes care about creating and keeping `title` element as a first child in a root.
+	 *
+	 * @private
+	 * @param {module:engine/model/writer~Writer} writer
+	 * @returns {Boolean}
+	 */
+	_fixTitleElement( writer ) {
+		const model = this.editor.model;
+		const modelRoot = model.document.getRoot();
+		let hasChanged = false;
+		let index = 0;
+
+		// Loop through root children and take care about a proper position of a title element.
+		// Title always has to be the first element in the root.
+		for ( const rootChild of modelRoot.getChildren() ) {
+			// If the first element is not a title we need to fix it.
+			if ( index === 0 && !rootChild.is( 'title' ) ) {
+				const title = Array.from( modelRoot.getChildren() ).find( item => item.is( 'title' ) );
+
+				// Change first element to the title if it can be a title.
+				if ( allowedToBeTitle.has( rootChild.name ) ) {
+					writer.rename( rootChild, 'title' );
+					// After changing element to the title is has to be filtered out from disallowed attributes.
+					model.schema.removeDisallowedAttributes( Array.from( rootChild.getChildren() ), writer );
+
+				// If the first element cannot be a title but title is already in the root
+				// than move the first element after a title.
+				// It may happen e.g. when an image has been dropped before the title element.
+				} else if ( title ) {
+					writer.move( writer.createRangeOn( rootChild ), title, 'after' );
+
+				// If there is no title or any element that could be a title then create an empty title.
+				} else {
+					writer.insertElement( 'title', modelRoot );
+				}
+
+				hasChanged = true;
+
+			// If there is a title in the content then change ot to a paragraph.
+			} else if ( index > 0 && rootChild.is( 'title' ) ) {
+				writer.rename( rootChild, 'paragraph' );
+				hasChanged = true;
+			}
+
+			index++;
+		}
+
+		// Attach `Body` placeholder when there is no element after a title.
+		if ( modelRoot.childCount < 2 ) {
+			this._bodyPlaceholder = writer.createElement( 'paragraph' );
+			writer.insert( this._bodyPlaceholder, modelRoot, 1 );
+			hasChanged = true;
+		}
+
+		return hasChanged;
+	}
+
+	/**
+	 * Prevents editor from adding extra paragraphs after pasting to the title element
+	 * or pressing an enter in the title element.
+	 *
+	 * @private
+	 * @param {module:engine/model/writer~Writer} writer
+	 * @returns {Boolean}
+	 */
+	_preventExtraParagraphing( writer ) {
+		const root = this.editor.model.document.getRoot();
+		const placeholder = this._bodyPlaceholder;
+
+		const shouldDeleteLastParagraph = (
+			placeholder && placeholder.is( 'paragraph' ) && placeholder.childCount === 0 &&
+			root.childCount > 2 && root.getChild( root.childCount - 1 ) === placeholder
+		);
+
+		if ( shouldDeleteLastParagraph ) {
+			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 view = editor.editing.view;
+		const viewRoot = view.document.getRoot();
+
+		// Attach placeholder to the view title element.
+		editor.editing.downcastDispatcher.on( 'insert:title', ( evt, data, conversionApi ) => {
+			enablePlaceholder( {
+				view,
+				element: conversionApi.mapper.toViewElement( data.item ),
+				text: 'Title'
+			} );
+		} );
+
+		// 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', 'Body', 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 ].name === 'title' ) {
+					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 = Array.from( selection.getSelectedBlocks() )[ 0 ];
+				const selectionPosition = selection.getFirstPosition();
+
+				if ( selectedElement === root.getChild( 1 ) && selectionPosition.isAtStart ) {
+					writer.setSelection( root.getChild( 0 ), 0 );
+					cancel();
+				}
+			} );
+		} );
+	}
+}

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

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

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

@@ -0,0 +1,31 @@
+/**
+ * @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';
+
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		plugins: [ Enter, Typing, Undo, Heading, Title, Clipboard, Image, ImageUpload ],
+		toolbar: [ 'heading', '|', 'undo', 'redo', 'imageUpload' ]
+	} )
+	.then( editor => {
+		window.editor = editor;
+
+		editor.plugins.get( 'FileRepository' ).createUploadAdapter = loader => new UploadAdapterMock( loader );
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );

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

@@ -0,0 +1,36 @@
+## Title feature
+
+- you should see `Title` and `Body` placeholders when there is no text in any of sections.
+- you should be able to put selection to 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`.
+
+### Uploading image to the title
+
+- type something in the title
+- put selection in the middle of the title
+- upload image using toolbar button
+
+Image should land after the title element and should not split the title.
+
+### Uploading image to the title (drag&drop)
+
+- type something in the title
+- try to drop image to the title or before the title
+
+Image should always land after the title element.

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

@@ -0,0 +1,538 @@
+/**
+ * @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 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 Heading from '@ckeditor/ckeditor5-heading/src/heading';
+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 DataTransfer from '@ckeditor/ckeditor5-clipboard/src/datatransfer';
+import { UploadAdapterMock, createNativeFileMock } from '@ckeditor/ckeditor5-upload/tests/_utils/mocks';
+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 certain plugins', () => {
+		expect( Title.requires ).to.have.members( [ Paragraph, Enter ] );
+	} );
+
+	it( 'should have plugin name property', () => {
+		expect( Title.pluginName ).to.equal( 'Title' );
+	} );
+
+	it( 'should set proper schema rules', () => {
+		expect( model.schema.isRegistered( 'title', '$text' ) ).to.equal( true );
+		expect( model.schema.checkChild( [ 'title' ], '$text' ) ).to.equal( true );
+		expect( model.schema.checkChild( [ '$root' ], 'title' ) ).to.equal( true );
+		expect( model.schema.checkChild( [ '$root', 'blockQuote' ], 'title' ) ).to.equal( false );
+		expect( model.schema.checkChild( [ '$root', 'paragraph' ], 'title' ) ).to.equal( false );
+	} );
+
+	it( 'should convert title to h1', () => {
+		setData( model, '<title>Foo</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>Foo</title><paragraph>Bar</paragraph>' );
+
+			expect( getData( model ) ).to.equal( '<title>[]Foo</title><paragraph>Bar</paragraph>' );
+		} );
+
+		it( 'should create a content element when only title has been set', () => {
+			setData( model, '<title>Foo</title>' );
+
+			expect( getData( model ) ).to.equal( '<title>[]Foo</title><paragraph></paragraph>' );
+		} );
+
+		it( 'should create a title and content elements when are missing', () => {
+			setData( model, '' );
+
+			expect( getData( model ) ).to.equal( '<title>[]</title><paragraph></paragraph>' );
+		} );
+
+		it( 'should change heading1 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>[]Foo</title><heading1>Bar</heading1>' );
+		} );
+
+		it( 'should change heading2 element to title when is set as a first root child', () => {
+			setData( model, '<heading2>Foo</heading2><heading2>Bar</heading2>' );
+
+			expect( getData( model ) ).to.equal( '<title>[]Foo</title><heading2>Bar</heading2>' );
+		} );
+
+		it( 'should change heading3 element to title when is set as a first root child', () => {
+			setData( model, '<heading3>Foo</heading3><heading3>Bar</heading3>' );
+
+			expect( getData( model ) ).to.equal( '<title>[]Foo</title><heading3>Bar</heading3>' );
+		} );
+
+		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>[]Foo</title><paragraph>Bar</paragraph>' );
+		} );
+
+		it( 'should change title element to a paragraph when is not a first root child #1', () => {
+			setData( model, '<title>Foo</title><title>Bar</title>' );
+
+			expect( getData( model ) ).to.equal( '<title>[]Foo</title><paragraph>Bar</paragraph>' );
+		} );
+
+		it( 'should change title element to a paragraph when is not a first root child #2', () => {
+			setData( model, '<title>Foo</title><paragraph>Bar</paragraph><title>Biz</title>' );
+
+			expect( getData( model ) ).to.equal( '<title>[]Foo</title><paragraph>Bar</paragraph><paragraph>Biz</paragraph>' );
+		} );
+
+		it( 'should move element after a title element when is not allowed to be a title', () => {
+			setData( model, '<blockQuote><paragraph>Foo</paragraph></blockQuote><title>Bar</title>' );
+
+			expect( getData( model ) ).to.equal( '<title>[]Bar</title><blockQuote><paragraph>Foo</paragraph></blockQuote>' );
+		} );
+
+		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><blockQuote><paragraph>Foo</paragraph></blockQuote>' );
+		} );
+
+		it( 'should clear element from disallowed attributes when changing to title element', () => {
+			model.schema.extend( '$text', { allowAttributes: 'bold' } );
+			model.schema.addAttributeCheck( ( context, attributeName ) => {
+				if ( context.endsWith( 'title $text' ) && attributeName === 'bold' ) {
+					return false;
+				}
+			} );
+
+			setData( model,
+				'<paragraph>F<$text bold="true">o</$text>o</paragraph><paragraph>B<$text bold="true">a</$text>r</paragraph>'
+			);
+
+			expect( getData( model ) ).to.equal( '<title>[]Foo</title><paragraph>B<$text bold="true">a</$text>r</paragraph>' );
+		} );
+	} );
+
+	describe( 'setTitle()', () => {
+		it( 'should set new content of a title element', () => {
+			setData( model, '<title>Foo</title><paragraph>Bar</paragraph>' );
+
+			editor.plugins.get( 'Title' ).setTitle( 'Biz' );
+
+			expect( getData( model ) ).to.equal( '<title>[]Biz</title><paragraph>Bar</paragraph>' );
+		} );
+
+		it( 'should clear content of a title element', () => {
+			setData( model, '<title>Foo</title><paragraph>Bar</paragraph>' );
+
+			editor.plugins.get( 'Title' ).setTitle( '' );
+
+			expect( getData( model ) ).to.equal( '<title>[]</title><paragraph>Bar</paragraph>' );
+		} );
+	} );
+
+	describe( 'getTitle()', () => {
+		it( 'should return content of a title element', () => {
+			setData( model, '<title>Foo</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><paragraph>Bar</paragraph>' );
+
+			expect( editor.plugins.get( 'Title' ).getTitle() ).to.equal( '' );
+		} );
+	} );
+
+	describe( 'setBody()', () => {
+		it( 'should set new content to body', () => {
+			setData( model, '<title>Foo</title><paragraph>Bar</paragraph>' );
+
+			editor.plugins.get( 'Title' ).setBody( 'Biz' );
+
+			expect( getData( model ) ).to.equal( '<title>[]Foo</title><paragraph>Biz</paragraph>' );
+		} );
+
+		it( 'should set empty content to body', () => {
+			setData( model, '<title>Foo</title><paragraph>Bar</paragraph>' );
+
+			editor.plugins.get( 'Title' ).setBody( '' );
+
+			expect( getData( model ) ).to.equal( '<title>[]Foo</title><paragraph></paragraph>' );
+		} );
+
+		it( 'should set html content to body', () => {
+			setData( model, '<title>Foo</title><paragraph>Bar</paragraph>' );
+
+			editor.plugins.get( 'Title' ).setBody( '<blockQuote>Bar</blockQuote><p>Biz</p>' );
+
+			expect( getData( model ) ).to.equal(
+				'<title>[]Foo</title><blockQuote><paragraph>Bar</paragraph></blockQuote><paragraph>Biz</paragraph>'
+			);
+		} );
+	} );
+
+	describe( 'getBody()', () => {
+		it( 'should return all data except the title element', () => {
+			setData( model, '<title>Foo</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>Foo</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>Foo</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><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>Foo</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>Foo</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><paragraph>[]</paragraph>' );
+
+			expect( viewRoot.getChild( 0 ).hasClass( 'ck-placeholder' ) ).to.equal( true );
+
+			model.change( writer => {
+				writer.appendText( 'Bar', null, model.document.getRoot().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>Foo</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>[Foo</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.be.true;
+
+			editor.execute( 'undo' );
+
+			bodyDomElement = domConverter.mapViewToDom( viewRoot.getChild( 1 ) );
+
+			expect( bodyDomElement.dataset.placeholder ).to.equal( 'Body' );
+			expect( bodyDomElement.classList.contains( 'ck-placeholder' ) ).to.be.false;
+		} );
+	} );
+
+	describe( 'Tab press handling', () => {
+		it( 'should handle tab key when the selection is at the beginning of the title', () => {
+			setData( model, '<title>[]foo</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>foo</title><paragraph>[]bar</paragraph>' );
+		} );
+
+		it( 'should handle tab key when the selection is at the end of the title', () => {
+			setData( model, '<title>foo[]</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>foo</title><paragraph>[]bar</paragraph>' );
+		} );
+
+		it( 'should not handle tab key when the selection is in the title and body', () => {
+			setData( model, '<title>fo[o</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>fo[o</title><paragraph>b]ar</paragraph>' );
+		} );
+
+		it( 'should not handle tab key when the selection is in the body', () => {
+			setData( model, '<title>foo</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>foo</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>foo</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>[]foo</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>foo</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>foo</title><paragraph>b[]ar</paragraph>' );
+		} );
+
+		it( 'should not handle shift + tab keys when the selection is not collapsed', () => {
+			setData( model, '<title>foo</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>foo</title><paragraph>[b]ar</paragraph>' );
+		} );
+
+		it( 'should not handle shift + tab keys when the selection is in the title', () => {
+			setData( model, '<title>[]foo</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>[]foo</title><paragraph>bar</paragraph>' );
+		} );
+	} );
+
+	describe( 'prevent extra paragraphing', () => {
+		it( 'should remove the extra paragraph when pasting to the editor with body placeholder', () => {
+			setData( model, '<title>[]</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</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><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</title><paragraph>Body[]</paragraph><paragraph></paragraph>' );
+		} );
+
+		it( 'should remove the extra paragraph when pressing enter in the title', () => {
+			setData( model, '<title>fo[]o</title>' );
+
+			editor.execute( 'enter' );
+
+			expect( getData( model ) ).to.equal( '<title>fo</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>fo[]o</title><paragraph></paragraph>' );
+
+			editor.execute( 'enter' );
+
+			expect( getData( model ) ).to.equal( '<title>fo</title><paragraph>[]o</paragraph><paragraph></paragraph>' );
+		} );
+	} );
+
+	describe( 'upload to title - integration', () => {
+		let file, fileRepository;
+
+		beforeEach( () => {
+			fileRepository = editor.plugins.get( 'FileRepository' );
+			fileRepository.createUploadAdapter = newLoader => new UploadAdapterMock( newLoader );
+			file = createNativeFileMock();
+		} );
+
+		it( 'should move image after the title when was dropped before it', () => {
+			setData( model, '<title>Foo</title><paragraph>Bar</paragraph>' );
+
+			const dataTransfer = new DataTransfer( { files: [ file ], types: [ 'Files' ] } );
+
+			const targetRange = model.createRange( model.createPositionAt( model.document.getRoot(), 0 ) );
+			const targetViewRange = editor.editing.mapper.toViewRange( targetRange );
+
+			editor.editing.view.document.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } );
+
+			const { id } = fileRepository.getLoader( file );
+
+			expect( getData( model ) ).to.equal(
+				'<title>Foo</title>' +
+				`[<image uploadId="${ id }" uploadStatus="reading"></image>]` +
+				'<paragraph>Bar</paragraph>'
+			);
+		} );
+
+		it( 'should move image after the title when was dropped to it', () => {
+			setData( model, '<title>Foo</title><paragraph>Bar</paragraph>' );
+
+			const dataTransfer = new DataTransfer( { files: [ file ], types: [ 'Files' ] } );
+
+			const title = model.document.getRoot().getChild( 0 );
+
+			const targetRange = model.createRange( model.createPositionAt( title, 1 ) );
+			const targetViewRange = editor.editing.mapper.toViewRange( targetRange );
+
+			editor.editing.view.document.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } );
+
+			const { id } = fileRepository.getLoader( file );
+
+			expect( getData( model ) ).to.equal(
+				'<title>Foo</title>' +
+				`[<image uploadId="${ id }" uploadStatus="reading"></image>]` +
+				'<paragraph>Bar</paragraph>'
+			);
+		} );
+	} );
+} );
+
+function getEventData( keyCode, { shiftKey = false } = {} ) {
+	return {
+		keyCode,
+		shiftKey,
+		preventDefault: sinon.spy(),
+		stopPropagation: sinon.spy()
+	};
+}