diff --git a/docs/_snippets/features/title.html b/docs/_snippets/features/title.html
new file mode 100644
index 0000000..3bd148a
--- /dev/null
+++ b/docs/_snippets/features/title.html
@@ -0,0 +1,48 @@
+
+
+
+Console
+
+''
+''
+''
+
+
diff --git a/docs/_snippets/features/title.js b/docs/_snippets/features/title.js
new file mode 100644
index 0000000..f380831
--- /dev/null
+++ b/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 );
+ }
+}
diff --git a/docs/features/title.md b/docs/features/title.md
new file mode 100644
index 0000000..3d5473d
--- /dev/null
+++ b/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 Tab key and back, using Shift + Tab (when the selection is at the beginning of the first body element), providing form-like experience. You can also use Enter and Backspace 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( ... );
+```
+
+
+ Read more about {@link builds/guides/integration/installing-plugins installing plugins}.
+
+
diff --git a/package.json b/package.json
index 0bf3c2b..9370b24 100644
--- a/package.json
+++ b/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",
diff --git a/src/title.js b/src/title.js
new file mode 100644
index 0000000..847ce6a
--- /dev/null
+++ b/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:
+ //
+ //
+ // The title text
+ //
+ //
+ // 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.
+//
+// ^Foo -> ^Foo
+//
+// @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.} 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;
+}
diff --git a/tests/manual/title.html b/tests/manual/title.html
new file mode 100644
index 0000000..da4ab6d
--- /dev/null
+++ b/tests/manual/title.html
@@ -0,0 +1,2 @@
+
+
diff --git a/tests/manual/title.js b/tests/manual/title.js
new file mode 100644
index 0000000..26b8573
--- /dev/null
+++ b/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 );
+ } );
diff --git a/tests/manual/title.md b/tests/manual/title.md
new file mode 100644
index 0000000..abac556
--- /dev/null
+++ b/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.
diff --git a/tests/title.js b/tests/title.js
new file mode 100644
index 0000000..283bc19
--- /dev/null
+++ b/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,
+ 'Foo' +
+ 'Bar'
+ );
+
+ expect( editor.getData() ).to.equal( 'Foo
Bar
' );
+ } );
+
+ describe( 'model post-fixing', () => {
+ it( 'should set title and content elements', () => {
+ setData( model,
+ 'Foo' +
+ 'Bar'
+ );
+
+ expect( getData( model ) ).to.equal(
+ '[]Foo' +
+ 'Bar'
+ );
+ } );
+
+ it( 'should create a content element when only title has been set', () => {
+ setData( model, 'Foo' );
+
+ expect( getData( model ) ).to.equal(
+ '[]Foo' +
+ ''
+ );
+ } );
+
+ it( 'should create a title and content elements when are missing', () => {
+ setData( model, '' );
+
+ expect( getData( model ) ).to.equal(
+ '[]' +
+ ''
+ );
+ } );
+
+ it( 'should change heading element to title when is set as a first root child', () => {
+ setData( model,
+ 'Foo' +
+ 'Bar'
+ );
+
+ expect( getData( model ) ).to.equal(
+ '[]Foo' +
+ 'Bar'
+ );
+ } );
+
+ it( 'should change paragraph element to title when is set as a first root child', () => {
+ setData( model,
+ 'Foo' +
+ 'Bar'
+ );
+
+ expect( getData( model ) ).to.equal(
+ '[]Foo' +
+ 'Bar'
+ );
+ } );
+
+ it( 'should change paragraph element to title and then change additional title elements to paragraphs', () => {
+ setData( model,
+ 'Foo' +
+ 'Bar'
+ );
+
+ expect( getData( model ) ).to.equal(
+ '[]Foo' +
+ 'Bar'
+ );
+ } );
+
+ it( 'should change title element to a paragraph when is not a first root child #1', () => {
+ setData( model,
+ 'Foo' +
+ 'Bar'
+ );
+
+ expect( getData( model ) ).to.equal(
+ '[]Foo' +
+ 'Bar'
+ );
+ } );
+
+ it( 'should change title element to a paragraph when is not a first root child #2', () => {
+ setData( model,
+ 'Foo' +
+ 'Bar' +
+ 'Biz'
+ );
+
+ expect( getData( model ) ).to.equal(
+ '[]Foo' +
+ 'Bar' +
+ 'Biz'
+ );
+ } );
+
+ it( 'should move title at the beginning of the root when first root child is not allowed to be a title #1', () => {
+ setData( model,
+ 'Foo
' +
+ 'Bar'
+ );
+
+ expect( getData( model ) ).to.equal(
+ '[]Bar' +
+ 'Foo
'
+ );
+ } );
+
+ it( 'should move title at the beginning of the root when first root child is not allowed to be a title #2', () => {
+ setData( model,
+ 'Foo
' +
+ 'Bar
' +
+ 'Biz'
+ );
+
+ expect( getData( model ) ).to.equal(
+ '[]Biz' +
+ 'Foo
' +
+ 'Bar
'
+ );
+ } );
+
+ it( 'should move title at the beginning of the root when first root child is not allowed to be a title #3', () => {
+ setData( model,
+ 'Foo
' +
+ 'Bar' +
+ 'Biz'
+ );
+
+ expect( getData( model ) ).to.equal(
+ '[]Biz' +
+ 'Foo
' +
+ 'Bar'
+ );
+ } );
+
+ it( 'should create a missing title element before an element that cannot to be a title element', () => {
+ setData( model, 'Foo
' );
+
+ expect( getData( model ) ).to.equal(
+ '[]' +
+ 'Foo
'
+ );
+ } );
+
+ 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,
+ 'F<$text foo="true">o$text>o' +
+ 'B<$text foo="true">a$text>r'
+ );
+
+ expect( getData( model ) ).to.equal(
+ '[]Foo' +
+ 'B<$text foo="true">a$text>r'
+ );
+ } );
+ } );
+
+ describe( 'removes extra paragraph', () => {
+ it( 'should remove the extra paragraph when pasting to the editor with body placeholder', () => {
+ setData( model, '[]' );
+
+ const dataTransferMock = {
+ getData: type => {
+ if ( type === 'text/html' ) {
+ return 'Title
Body
';
+ }
+ },
+ types: [],
+ files: []
+ };
+
+ editor.editing.view.document.fire( 'paste', {
+ dataTransfer: dataTransferMock,
+ preventDefault() {}
+ } );
+
+ expect( getData( model ) ).to.equal(
+ 'Title' +
+ 'Body[]'
+ );
+ } );
+
+ it( 'should not remove the extra paragraph when pasting to the editor with directly created body element', () => {
+ setData( model,
+ '[]' +
+ ''
+ );
+
+ const dataTransferMock = {
+ getData: type => {
+ if ( type === 'text/html' ) {
+ return 'Title
Body
';
+ }
+ },
+ types: [],
+ files: []
+ };
+
+ editor.editing.view.document.fire( 'paste', {
+ dataTransfer: dataTransferMock,
+ preventDefault() {}
+ } );
+
+ expect( getData( model ) ).to.equal(
+ 'Title' +
+ 'Body[]' +
+ ''
+ );
+ } );
+
+ it( 'should remove the extra paragraph when pressing enter in the title', () => {
+ setData( model, 'fo[]o' );
+
+ editor.execute( 'enter' );
+
+ expect( getData( model ) ).to.equal(
+ 'fo' +
+ '[]o'
+ );
+ } );
+
+ it( 'should not remove the extra paragraph when pressing enter in the title when body is created directly', () => {
+ setData( model,
+ 'fo[]o' +
+ ''
+ );
+
+ editor.execute( 'enter' );
+
+ expect( getData( model ) ).to.equal(
+ 'fo' +
+ '[]o' +
+ ''
+ );
+ } );
+ } );
+
+ describe( 'setTitle()', () => {
+ it( 'should set new content of a title element', () => {
+ setData( model,
+ '' +
+ 'Bar'
+ );
+
+ editor.plugins.get( 'Title' ).setTitle( 'Biz' );
+
+ expect( getData( model ) ).to.equal(
+ '[]Biz' +
+ 'Bar'
+ );
+ } );
+
+ it( 'should replace old content of a title element', () => {
+ setData( model,
+ 'Foo' +
+ 'Bar'
+ );
+
+ editor.plugins.get( 'Title' ).setTitle( 'Biz' );
+
+ expect( getData( model ) ).to.equal(
+ '[]Biz' +
+ 'Bar'
+ );
+ } );
+
+ it( 'should clear content of a title element', () => {
+ setData( model,
+ 'Foo' +
+ 'Bar'
+ );
+
+ editor.plugins.get( 'Title' ).setTitle( '' );
+
+ expect( getData( model ) ).to.equal(
+ '[]' +
+ 'Bar'
+ );
+ } );
+
+ it( 'should properly handle HTML element', () => {
+ setData( model,
+ '' +
+ 'Bar'
+ );
+
+ editor.plugins.get( 'Title' ).setTitle( 'Foo
' );
+
+ expect( getData( model ) ).to.equal(
+ '[]Foo' +
+ 'Bar'
+ );
+ } );
+
+ it( 'should properly handle multiple HTML elements', () => {
+ setData( model,
+ 'Foo' +
+ 'Bar'
+ );
+
+ editor.plugins.get( 'Title' ).setTitle( 'Foo
bar
' );
+
+ expect( getData( model ) ).to.equal(
+ '[]Foobar' +
+ 'Bar'
+ );
+ } );
+
+ it( 'should do nothing when setting empty content to the empty title', () => {
+ setData( model,
+ '' +
+ 'Bar'
+ );
+
+ editor.plugins.get( 'Title' ).setTitle( '' );
+
+ expect( getData( model ) ).to.equal(
+ '[]' +
+ 'Bar'
+ );
+ } );
+ } );
+
+ describe( 'getTitle()', () => {
+ it( 'should return content of a title element', () => {
+ setData( model,
+ 'Foo' +
+ 'Bar'
+ );
+
+ expect( editor.plugins.get( 'Title' ).getTitle() ).to.equal( 'Foo' );
+ } );
+
+ it( 'should return content of an empty title element', () => {
+ setData( model,
+ '' +
+ 'Bar'
+ );
+
+ expect( editor.plugins.get( 'Title' ).getTitle() ).to.equal( '' );
+ } );
+ } );
+
+ describe( 'setBody()', () => {
+ it( 'should set new content to body', () => {
+ setData( model,
+ 'Foo' +
+ 'Bar'
+ );
+
+ editor.plugins.get( 'Title' ).setBody( 'Biz' );
+
+ expect( getData( model ) ).to.equal(
+ '[]Foo' +
+ 'Biz'
+ );
+ } );
+
+ it( 'should set empty content to body', () => {
+ setData( model,
+ 'Foo' +
+ 'Bar'
+ );
+
+ editor.plugins.get( 'Title' ).setBody( '' );
+
+ expect( getData( model ) ).to.equal(
+ '[]Foo' +
+ ''
+ );
+ } );
+
+ it( 'should set html content to body', () => {
+ setData( model,
+ 'Foo' +
+ 'Bar'
+ );
+
+ editor.plugins.get( 'Title' ).setBody( 'Bar
Biz
' );
+
+ expect( getData( model ) ).to.equal(
+ '[]Foo' +
+ 'Bar
' +
+ 'Biz'
+ );
+ } );
+ } );
+
+ describe( 'getBody()', () => {
+ it( 'should return all data except the title element', () => {
+ setData( model,
+ 'Foo' +
+ 'Bar' +
+ 'Biz'
+ );
+
+ expect( editor.plugins.get( 'Title' ).getBody() ).to.equal( 'Bar
Biz
' );
+ } );
+
+ it( 'should return empty paragraph when body is empty', () => {
+ setData( model, 'Foo' );
+
+ expect( editor.plugins.get( 'Title' ).getBody() ).to.equal( '
' );
+ } );
+ } );
+
+ describe( 'placeholders', () => {
+ let viewRoot;
+
+ beforeEach( () => {
+ viewRoot = editor.editing.view.document.getRoot();
+ } );
+
+ it( 'should attach placeholder placeholder to title and body', () => {
+ setData( model,
+ 'Foo' +
+ 'Bar'
+ );
+
+ 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,
+ '' +
+ ''
+ );
+
+ 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,
+ 'Foo' +
+ '' +
+ ''
+ );
+
+ 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,
+ 'Foo' +
+ ''
+ );
+
+ 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,
+ '' +
+ '[]'
+ );
+
+ 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,
+ 'Foo' +
+ ''
+ );
+
+ 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,
+ '[Foo' +
+ 'Bar' +
+ 'Baz]'
+ );
+ 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,
+ '[]foo' +
+ 'bar'
+ );
+
+ const eventData = getEventData( keyCodes.tab );
+
+ editor.keystrokes.press( eventData );
+
+ sinon.assert.calledOnce( eventData.preventDefault );
+ sinon.assert.calledOnce( eventData.stopPropagation );
+ expect( getData( model ) ).to.equal(
+ 'foo' +
+ '[]bar'
+ );
+ } );
+
+ it( 'should handle tab key when the selection is at the end of the title', () => {
+ setData( model,
+ 'foo[]' +
+ 'bar'
+ );
+
+ const eventData = getEventData( keyCodes.tab );
+
+ editor.keystrokes.press( eventData );
+
+ sinon.assert.calledOnce( eventData.preventDefault );
+ sinon.assert.calledOnce( eventData.stopPropagation );
+ expect( getData( model ) ).to.equal(
+ 'foo' +
+ '[]bar'
+ );
+ } );
+
+ it( 'should not handle tab key when the selection is in the title and body', () => {
+ setData( model,
+ 'fo[o' +
+ 'b]ar'
+ );
+
+ const eventData = getEventData( keyCodes.tab );
+
+ editor.keystrokes.press( eventData );
+
+ sinon.assert.notCalled( eventData.preventDefault );
+ sinon.assert.notCalled( eventData.stopPropagation );
+ expect( getData( model ) ).to.equal(
+ 'fo[o' +
+ 'b]ar'
+ );
+ } );
+
+ it( 'should not handle tab key when the selection is in the body', () => {
+ setData( model,
+ 'foo' +
+ '[]bar'
+ );
+
+ const eventData = getEventData( keyCodes.tab );
+
+ editor.keystrokes.press( eventData );
+
+ sinon.assert.notCalled( eventData.preventDefault );
+ sinon.assert.notCalled( eventData.stopPropagation );
+ expect( getData( model ) ).to.equal(
+ 'foo' +
+ '[]bar'
+ );
+ } );
+ } );
+
+ describe( 'Shift + Tab press handling', () => {
+ it( 'should handle shift + tab keys when the selection is at the beginning of the body', () => {
+ setData( model,
+ 'foo' +
+ '[]bar'
+ );
+
+ 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(
+ '[]foo' +
+ 'bar'
+ );
+ } );
+
+ it( 'should not handle shift + tab keys when the selection is not at the beginning of the body', () => {
+ setData( model,
+ 'foo' +
+ 'b[]ar'
+ );
+
+ 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(
+ 'foo' +
+ 'b[]ar'
+ );
+ } );
+
+ it( 'should not handle shift + tab keys when the selection is not collapsed', () => {
+ setData( model,
+ 'foo' +
+ '[b]ar'
+ );
+
+ 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(
+ 'foo' +
+ '[b]ar'
+ );
+ } );
+
+ it( 'should not handle shift + tab keys when the selection is in the title', () => {
+ setData( model,
+ '[]foo' +
+ 'bar'
+ );
+
+ 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(
+ '[]foo' +
+ 'bar'
+ );
+ } );
+ } );
+} );
+
+function getEventData( keyCode, { shiftKey = false } = {} ) {
+ return {
+ keyCode,
+ shiftKey,
+ preventDefault: sinon.spy(),
+ stopPropagation: sinon.spy()
+ };
+}