/** * @license Copyright (c) 2003-2020, 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, stringify } 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

' ); } ); it( 'should convert h1 to the title if it is the first root child', () => { editor.setData( '

Foo

Bar

' ); expect( getData( model ) ).to.equal( '<title-content>[]Foo</title-content>' + 'Bar' ); } ); it( 'should avoid calling post-fixers to parse view to correct model (h1)', () => { const modelFrag = editor.data.parse( '

Foo

Bar

' ); expect( stringify( modelFrag ) ).to.equal( '<title-content>Foo</title-content>' + 'Bar' ); } ); it( 'should avoid calling post-fixers to parse view to correct model (h2)', () => { const modelFrag = editor.data.parse( '

Foo

Bar

' ); expect( stringify( modelFrag ) ).to.equal( '<title-content>Foo</title-content>' + 'Bar' ); } ); it( 'should avoid calling post-fixers to parse view to correct model (h3)', () => { const modelFrag = editor.data.parse( '

Foo

Bar

' ); expect( stringify( modelFrag ) ).to.equal( '<title-content>Foo</title-content>' + 'Bar' ); } ); it( 'should allow to override custom v->m title converter', () => { const spy = sinon.spy(); editor.data.upcastDispatcher.on( 'element:h1', ( evt, data, api ) => { api.consumable.consume( data.viewItem, { name: true } ); spy(); }, { priority: 'highest' } ); editor.setData( '

Foo

Bar

' ); sinon.assert.called( spy ); } ); 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, stopPropagation() {}, 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, stopPropagation() {}, 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( '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( '' ); } ); it( 'should return marker - starts and ends inside a title', () => { editor.conversion.for( 'downcast' ).markerToElement( { model: 'comment', view: 'comment' } ); setData( model, '<title-content>Foo Bar</title-content>' ); const title = model.document.getRoot().getChild( 0 ).getChild( 0 ); model.change( writer => { writer.addMarker( 'comment', { range: model.createRange( model.createPositionAt( title, 1 ), model.createPositionAt( title, 5 ) ), usingOperation: true } ); } ); expect( editor.plugins.get( 'Title' ).getTitle() ).to.equal( 'Foo Bar' ); } ); it( 'should return marker - starts inside a title ends inside a body', () => { editor.conversion.for( 'downcast' ).markerToElement( { model: 'comment', view: 'comment' } ); setData( model, '<title-content>Foo Bar</title-content>' + 'Biz' ); const title = model.document.getRoot().getChild( 0 ).getChild( 0 ); const body = model.document.getRoot().getChild( 1 ); model.change( writer => { writer.addMarker( 'comment', { range: model.createRange( model.createPositionAt( title, 1 ), model.createPositionAt( body, 3 ) ), usingOperation: true } ); } ); expect( editor.plugins.get( 'Title' ).getTitle() ).to.equal( 'Foo Bar' ); } ); } ); 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( '

 

' ); } ); it( 'should return marker - starts and ends inside a body', () => { editor.conversion.for( 'downcast' ).markerToElement( { model: 'comment', view: 'comment' } ); setData( model, '<title-content></title-content>' + 'Foo Bar' ); const body = model.document.getRoot().getChild( 1 ); model.change( writer => { writer.addMarker( 'comment', { range: model.createRange( model.createPositionAt( body, 1 ), model.createPositionAt( body, 5 ) ), usingOperation: true } ); } ); expect( editor.plugins.get( 'Title' ).getBody() ).to.equal( '

Foo Bar

' ); } ); it( 'should return marker - starts inside a title ends inside a body', () => { editor.conversion.for( 'downcast' ).markerToElement( { model: 'comment', view: 'comment' } ); setData( model, '<title-content>Foo</title-content>' + 'Bar' ); const title = model.document.getRoot().getChild( 0 ).getChild( 0 ); const body = model.document.getRoot().getChild( 1 ); model.change( writer => { writer.addMarker( 'comment', { range: model.createRange( model.createPositionAt( title, 1 ), model.createPositionAt( body, 2 ) ), usingOperation: true } ); } ); expect( editor.plugins.get( 'Title' ).getBody() ).to.equal( '

Bar

' ); } ); it( 'should return marker - starts at the beginning of the body ends inside the body', () => { editor.conversion.for( 'downcast' ).markerToElement( { model: 'comment', view: 'comment' } ); setData( model, '<title-content>Foo</title-content>' + 'Bar' ); const body = model.document.getRoot().getChild( 1 ); model.change( writer => { writer.addMarker( 'comment', { range: model.createRange( model.createPositionAt( body, 0 ), model.createPositionAt( body, 2 ) ), usingOperation: true } ); } ); expect( editor.plugins.get( 'Title' ).getBody() ).to.equal( '

Bar

' ); } ); it( 'should do nothing when marker is fully out of the body range', () => { editor.conversion.for( 'downcast' ).markerToElement( { model: 'comment', view: 'comment' } ); setData( model, '<title-content>Foo</title-content>' + 'Bar' ); const title = model.document.getRoot().getChild( 0 ).getChild( 0 ); model.change( writer => { writer.addMarker( 'comment', { range: model.createRange( model.createPositionAt( title, 1 ), model.createPositionAt( title, 2 ) ), usingOperation: true } ); } ); expect( editor.plugins.get( 'Title' ).getBody() ).to.equal( '

Bar

' ); } ); } ); 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( 'Type your title' ); expect( body.getAttribute( 'data-placeholder' ) ).to.equal( 'Type or paste your content here.' ); 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( 'Type your title' ); expect( body.getAttribute( 'data-placeholder' ) ).to.equal( 'Type or paste your content here.' ); 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( 'Type or paste your content here.' ); 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( 'Type or paste your content here.' ); expect( bodyDomElement.classList.contains( 'ck-placeholder' ) ).to.equal( true ); editor.execute( 'undo' ); bodyDomElement = domConverter.mapViewToDom( viewRoot.getChild( 1 ) ); expect( bodyDomElement.dataset.placeholder ).to.equal( 'Type or paste your content here.' ); expect( bodyDomElement.classList.contains( 'ck-placeholder' ) ).to.equal( false ); } ); describe( 'custom placeholder defined using configuration', () => { 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 ], title: { placeholder: 'foo' }, placeholder: 'bar' } ).then( _editor => { editor = _editor; model = editor.model; } ); } ); afterEach( () => { return editor.destroy().then( () => element.remove() ); } ); it( 'should show custom placeholder in title and body', () => { const viewRoot = editor.editing.view.document.getRoot(); setData( model, '<title-content></title-content>' + '' ); const title = viewRoot.getChild( 0 ); const body = viewRoot.getChild( 1 ); expect( title.getAttribute( 'data-placeholder' ) ).to.equal( 'foo' ); expect( body.getAttribute( 'data-placeholder' ) ).to.equal( 'bar' ); expect( title.hasClass( 'ck-placeholder' ) ).to.equal( true ); expect( body.hasClass( 'ck-placeholder' ) ).to.equal( true ); } ); } ); describe( 'custom placeholder defined using textarea attribte', () => { let element, editor, model; beforeEach( () => { element = document.createElement( 'textarea' ); element.setAttribute( 'placeholder', 'bom' ); document.body.appendChild( element ); return ClassicTestEditor.create( element, { plugins: [ Title, Heading, BlockQuote, Clipboard, Image, ImageUpload, Enter, Undo ], title: { placeholder: 'foo' } } ).then( _editor => { editor = _editor; model = editor.model; } ); } ); afterEach( () => { return editor.destroy().then( () => element.remove() ); } ); it( 'should show custom placeholder in title and body', () => { const viewRoot = editor.editing.view.document.getRoot(); setData( model, '<title-content></title-content>' + '' ); const title = viewRoot.getChild( 0 ); const body = viewRoot.getChild( 1 ); expect( title.getAttribute( 'data-placeholder' ) ).to.equal( 'foo' ); expect( body.getAttribute( 'data-placeholder' ) ).to.equal( 'bom' ); expect( title.hasClass( 'ck-placeholder' ) ).to.equal( true ); expect( body.hasClass( 'ck-placeholder' ) ).to.equal( true ); } ); } ); } ); 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() }; }