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: + // + // + // <title-content>The title text</title-content> + // + // + // 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-content>Foo</title-content> ->

^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, + '<title-content>Foo</title-content>' + + 'Bar' + ); + + expect( editor.getData() ).to.equal( '

Foo

Bar

' ); + } ); + + describe( 'model post-fixing', () => { + it( 'should set title and content elements', () => { + setData( model, + '<title-content>Foo</title-content>' + + 'Bar' + ); + + expect( getData( model ) ).to.equal( + '<title-content>[]Foo</title-content>' + + 'Bar' + ); + } ); + + it( 'should create a content element when only title has been set', () => { + setData( model, '<title-content>Foo</title-content>' ); + + expect( getData( model ) ).to.equal( + '<title-content>[]Foo</title-content>' + + '' + ); + } ); + + it( 'should create a title and content elements when are missing', () => { + setData( model, '' ); + + expect( getData( model ) ).to.equal( + '<title-content>[]</title-content>' + + '' + ); + } ); + + it( 'should change heading element to title when is set as a first root child', () => { + setData( model, + 'Foo' + + 'Bar' + ); + + expect( getData( model ) ).to.equal( + '<title-content>[]Foo</title-content>' + + '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( + '<title-content>[]Foo</title-content>' + + 'Bar' + ); + } ); + + it( 'should change paragraph element to title and then change additional title elements to paragraphs', () => { + setData( model, + 'Foo' + + '<title-content>Bar</title-content>' + ); + + expect( getData( model ) ).to.equal( + '<title-content>[]Foo</title-content>' + + 'Bar' + ); + } ); + + it( 'should change title element to a paragraph when is not a first root child #1', () => { + setData( model, + '<title-content>Foo</title-content>' + + '<title-content>Bar</title-content>' + ); + + expect( getData( model ) ).to.equal( + '<title-content>[]Foo</title-content>' + + 'Bar' + ); + } ); + + it( 'should change title element to a paragraph when is not a first root child #2', () => { + setData( model, + '<title-content>Foo</title-content>' + + 'Bar' + + '<title-content>Biz</title-content>' + ); + + expect( getData( model ) ).to.equal( + '<title-content>[]Foo</title-content>' + + '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
' + + '<title-content>Bar</title-content>' + ); + + expect( getData( model ) ).to.equal( + '<title-content>[]Bar</title-content>' + + '
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
' + + '<title-content>Biz</title-content>' + ); + + expect( getData( model ) ).to.equal( + '<title-content>[]Biz</title-content>' + + '
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' + + '<title-content>Biz</title-content>' + ); + + expect( getData( model ) ).to.equal( + '<title-content>[]Biz</title-content>' + + '
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( + '<title-content>[]</title-content>' + + '
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">oo' + + 'B<$text foo="true">ar' + ); + + expect( getData( model ) ).to.equal( + '<title-content alignment="justify">[]Foo</title-content>' + + 'B<$text foo="true">ar' + ); + } ); + } ); + + describe( 'removes extra paragraph', () => { + it( 'should remove the extra paragraph when pasting to the editor with body placeholder', () => { + setData( model, '<title-content>[]</title-content>' ); + + 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-content>Title</title-content>' + + 'Body[]' + ); + } ); + + it( 'should not remove the extra paragraph when pasting to the editor with directly created body element', () => { + setData( model, + '<title-content>[]</title-content>' + + '' + ); + + 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-content>Title</title-content>' + + 'Body[]' + + '' + ); + } ); + + it( 'should remove the extra paragraph when pressing enter in the title', () => { + setData( model, '<title-content>fo[]o</title-content>' ); + + editor.execute( 'enter' ); + + expect( getData( model ) ).to.equal( + '<title-content>fo</title-content>' + + '[]o' + ); + } ); + + it( 'should not remove the extra paragraph when pressing enter in the title when body is created directly', () => { + setData( model, + '<title-content>fo[]o</title-content>' + + '' + ); + + editor.execute( 'enter' ); + + expect( getData( model ) ).to.equal( + '<title-content>fo</title-content>' + + '[]o' + + '' + ); + } ); + } ); + + describe( 'setTitle()', () => { + it( 'should set new content of a title element', () => { + setData( model, + '<title-content></title-content>' + + 'Bar' + ); + + editor.plugins.get( 'Title' ).setTitle( 'Biz' ); + + expect( getData( model ) ).to.equal( + '<title-content>[]Biz</title-content>' + + 'Bar' + ); + } ); + + it( 'should replace old content of a title element', () => { + setData( model, + '<title-content>Foo</title-content>' + + 'Bar' + ); + + editor.plugins.get( 'Title' ).setTitle( 'Biz' ); + + expect( getData( model ) ).to.equal( + '<title-content>[]Biz</title-content>' + + 'Bar' + ); + } ); + + it( 'should clear content of a title element', () => { + setData( model, + '<title-content>Foo</title-content>' + + 'Bar' + ); + + editor.plugins.get( 'Title' ).setTitle( '' ); + + expect( getData( model ) ).to.equal( + '<title-content>[]</title-content>' + + 'Bar' + ); + } ); + + it( 'should properly handle HTML element', () => { + setData( model, + '<title-content></title-content>' + + 'Bar' + ); + + editor.plugins.get( 'Title' ).setTitle( '

Foo

' ); + + expect( getData( model ) ).to.equal( + '<title-content>[]Foo</title-content>' + + 'Bar' + ); + } ); + + it( 'should properly handle multiple HTML elements', () => { + setData( model, + '<title-content>Foo</title-content>' + + 'Bar' + ); + + editor.plugins.get( 'Title' ).setTitle( '

Foo

bar

' ); + + expect( getData( model ) ).to.equal( + '<title-content>[]Foobar</title-content>' + + 'Bar' + ); + } ); + + it( 'should do nothing when setting empty content to the empty title', () => { + setData( model, + '<title-content></title-content>' + + 'Bar' + ); + + editor.plugins.get( 'Title' ).setTitle( '' ); + + expect( getData( model ) ).to.equal( + '<title-content>[]</title-content>' + + 'Bar' + ); + } ); + } ); + + describe( 'getTitle()', () => { + it( 'should return content of a title element', () => { + setData( model, + '<title-content>Foo</title-content>' + + 'Bar' + ); + + expect( editor.plugins.get( 'Title' ).getTitle() ).to.equal( 'Foo' ); + } ); + + it( 'should return content of an empty title element', () => { + setData( model, + '<title-content></title-content>' + + 'Bar' + ); + + expect( editor.plugins.get( 'Title' ).getTitle() ).to.equal( '' ); + } ); + } ); + + describe( 'setBody()', () => { + it( 'should set new content to body', () => { + setData( model, + '<title-content>Foo</title-content>' + + 'Bar' + ); + + editor.plugins.get( 'Title' ).setBody( 'Biz' ); + + expect( getData( model ) ).to.equal( + '<title-content>[]Foo</title-content>' + + 'Biz' + ); + } ); + + it( 'should set empty content to body', () => { + setData( model, + '<title-content>Foo</title-content>' + + 'Bar' + ); + + editor.plugins.get( 'Title' ).setBody( '' ); + + expect( getData( model ) ).to.equal( + '<title-content>[]Foo</title-content>' + + '' + ); + } ); + + it( 'should set html content to body', () => { + setData( model, + '<title-content>Foo</title-content>' + + 'Bar' + ); + + editor.plugins.get( 'Title' ).setBody( '
Bar

Biz

' ); + + expect( getData( model ) ).to.equal( + '<title-content>[]Foo</title-content>' + + '
Bar
' + + 'Biz' + ); + } ); + } ); + + describe( 'getBody()', () => { + it( 'should return all data except the title element', () => { + setData( model, + '<title-content>Foo</title-content>' + + 'Bar' + + 'Biz' + ); + + expect( editor.plugins.get( 'Title' ).getBody() ).to.equal( '

Bar

Biz

' ); + } ); + + it( 'should return empty paragraph when body is empty', () => { + setData( model, '<title-content>Foo</title-content>' ); + + 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, + '<title-content>Foo</title-content>' + + '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, + '<title-content></title-content>' + + '' + ); + + 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-content>Foo</title-content>' + + '' + + '' + ); + + 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-content>Foo</title-content>' + + '' + ); + + 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-content></title-content>' + + '[]' + ); + + 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-content>Foo</title-content>' + + '' + ); + + 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-content>[Foo</title-content>' + + '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, + '<title-content>[]foo</title-content>' + + '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( + '<title-content>foo</title-content>' + + '[]bar' + ); + } ); + + it( 'should handle tab key when the selection is at the end of the title', () => { + setData( model, + '<title-content>foo[]</title-content>' + + '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( + '<title-content>foo</title-content>' + + '[]bar' + ); + } ); + + it( 'should not handle tab key when the selection is in the title and body', () => { + setData( model, + '<title-content>fo[o</title-content>' + + '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( + '<title-content>fo[o</title-content>' + + 'b]ar' + ); + } ); + + it( 'should not handle tab key when the selection is in the body', () => { + setData( model, + '<title-content>foo</title-content>' + + '[]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( + '<title-content>foo</title-content>' + + '[]bar' + ); + } ); + } ); + + describe( 'Shift + Tab press handling', () => { + it( 'should handle shift + tab keys when the selection is at the beginning of the body', () => { + setData( model, + '<title-content>foo</title-content>' + + '[]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( + '<title-content>[]foo</title-content>' + + 'bar' + ); + } ); + + it( 'should not handle shift + tab keys when the selection is not at the beginning of the body', () => { + setData( model, + '<title-content>foo</title-content>' + + '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( + '<title-content>foo</title-content>' + + 'b[]ar' + ); + } ); + + it( 'should not handle shift + tab keys when the selection is not collapsed', () => { + setData( model, + '<title-content>foo</title-content>' + + '[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( + '<title-content>foo</title-content>' + + '[b]ar' + ); + } ); + + it( 'should not handle shift + tab keys when the selection is in the title', () => { + setData( model, + '<title-content>[]foo</title-content>' + + '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( + '<title-content>[]foo</title-content>' + + 'bar' + ); + } ); + } ); +} ); + +function getEventData( keyCode, { shiftKey = false } = {} ) { + return { + keyCode, + shiftKey, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; +}