diff --git a/src/heading.js b/src/heading.js index 55ca31b..0712c09 100644 --- a/src/heading.js +++ b/src/heading.js @@ -7,13 +7,11 @@ * @module heading/heading */ +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import HeadingEngine from './headingengine'; - import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; - import Model from '@ckeditor/ckeditor5-ui/src/model'; import createListDropdown from '@ckeditor/ckeditor5-ui/src/dropdown/list/createlistdropdown'; - import Collection from '@ckeditor/ckeditor5-utils/src/collection'; /** @@ -27,7 +25,7 @@ export default class Heading extends Plugin { * @inheritDoc */ static get requires() { - return [ HeadingEngine ]; + return [ Paragraph, HeadingEngine ]; } /** @@ -35,38 +33,106 @@ export default class Heading extends Plugin { */ init() { const editor = this.editor; - const command = editor.commands.get( 'heading' ); - const options = command.options; - const collection = new Collection(); - - // Add options to collection. - for ( const { id, label } of options ) { - collection.add( new Model( { - id, label + const dropdownItems = new Collection(); + const options = this._getLocalizedOptions(); + const commands = []; + let defaultOption; + + for ( let option of options ) { + // Add the option to the collection. + dropdownItems.add( new Model( { + commandName: option.modelElement, + label: option.title } ) ); + + commands.push( editor.commands.get( option.modelElement ) ); + + if ( !defaultOption && option.modelElement == 'paragraph' ) { + defaultOption = option; + } } // Create dropdown model. const dropdownModel = new Model( { withText: true, - items: collection + items: dropdownItems } ); - // Bind dropdown model to command. - dropdownModel.bind( 'isEnabled' ).to( command, 'isEnabled' ); - dropdownModel.bind( 'label' ).to( command, 'value', option => option.label ); + dropdownModel.bind( 'isEnabled' ).to( + // Bind to #isEnabled of each command... + ...getCommandsBindingTargets( commands, 'isEnabled' ), + // ...and set it true if any command #isEnabled is true. + ( ...areEnabled ) => areEnabled.some( isEnabled => isEnabled ) + ); + + dropdownModel.bind( 'label' ).to( + // Bind to #value of each command... + ...getCommandsBindingTargets( commands, 'value' ), + // ...and chose the title of the first one which #value is true. + ( ...areActive ) => { + const index = areActive.findIndex( value => value ); + + // If none of the commands is active, display the first one. + return ( options[ index ] || defaultOption ).title; + } + ); // Register UI component. editor.ui.componentFactory.add( 'headings', ( locale ) => { const dropdown = createListDropdown( dropdownModel, locale ); // Execute command when an item from the dropdown is selected. - this.listenTo( dropdown, 'execute', ( { source: { id } } ) => { - editor.execute( 'heading', { id } ); + this.listenTo( dropdown, 'execute', ( evt ) => { + editor.execute( evt.source.commandName ); editor.editing.view.focus(); } ); return dropdown; } ); } + + /** + * Returns heading options as defined in `config.heading.options` but processed to consider + * editor localization, i.e. to display {@link module:heading/headingcommand~HeadingOption} + * in the correct language. + * + * Note: The reason behind this method is that there's no way to use {@link module:utils/locale~Locale#t} + * when the user config is defined because the editor does not exist yet. + * + * @private + * @returns {Array.}. + */ + _getLocalizedOptions() { + const editor = this.editor; + const t = editor.t; + const localizedTitles = { + Paragraph: t( 'Paragraph' ), + 'Heading 1': t( 'Heading 1' ), + 'Heading 2': t( 'Heading 2' ), + 'Heading 3': t( 'Heading 3' ) + }; + + return editor.config.get( 'heading.options' ).map( option => { + const title = localizedTitles[ option.title ]; + + if ( title && title != option.title ) { + // Clone the option to avoid altering the original `config.heading.options`. + option = Object.assign( {}, option, { title } ); + } + + return option; + } ); + } +} + +// Returns an array of binding components for +// {@link module:utils/observablemixin~Observable#bind} from a set of iterable +// commands. +// +// @private +// @param {Iterable.} commands +// @param {String} attribute +// @returns {Array.} +function getCommandsBindingTargets( commands, attribute ) { + return Array.prototype.concat( ...commands.map( c => [ c, attribute ] ) ); } diff --git a/src/headingcommand.js b/src/headingcommand.js index 90ba257..846a01c 100644 --- a/src/headingcommand.js +++ b/src/headingcommand.js @@ -7,8 +7,9 @@ * @module heading/headingcommand */ +import Range from '@ckeditor/ckeditor5-engine/src/model/range'; import Command from '@ckeditor/ckeditor5-core/src/command/command'; -import RootElement from '@ckeditor/ckeditor5-engine/src/model/rootelement'; +import Selection from '@ckeditor/ckeditor5-engine/src/model/selection'; /** * The heading command. It is used by the {@link module:heading/heading~Heading heading feature} to apply headings. @@ -20,49 +21,49 @@ export default class HeadingCommand extends Command { * Creates an instance of the command. * * @param {module:core/editor/editor~Editor} editor Editor instance. - * @param {Array.} options Heading options to be used by the command instance. + * @param {module:heading/headingcommand~HeadingOption} option An option to be used by the command instance. */ - constructor( editor, options, defaultOptionId ) { + constructor( editor, option ) { super( editor ); + Object.assign( this, option ); + /** - * Heading options used by this command. + * Value of the command, indicating whether it is applied in the context + * of current {@link module:engine/model/document~Document#selection selection}. * * @readonly - * @member {module:heading/headingcommand~HeadingOption} + * @observable + * @member {Boolean} */ - this.options = options; + this.set( 'value', false ); + + // Update current value each time changes are done on document. + this.listenTo( editor.document, 'changesDone', () => this._updateValue() ); /** - * The id of the default option among {@link #options}. + * Unique identifier of the command, also element's name in the model. + * See {@link module:heading/headingcommand~HeadingOption}. * * @readonly - * @private - * @member {module:heading/headingcommand~HeadingOption#id} + * @member {String} #modelElement */ - this._defaultOptionId = defaultOptionId; /** - * The currently selected heading option. + * Element this command creates in the view. + * See {@link module:heading/headingcommand~HeadingOption}. * * @readonly - * @observable - * @member {module:heading/headingcommand~HeadingOption} #value + * @member {String} #viewElement */ - this.set( 'value', this.defaultOption ); - - // Update current value each time changes are done on document. - this.listenTo( editor.document, 'changesDone', () => this._updateValue() ); - } - /** - * The default option. - * - * @member {module:heading/headingcommand~HeadingOption} #defaultOption - */ - get defaultOption() { - // See https://github.com/ckeditor/ckeditor5/issues/98. - return this._getOptionById( this._defaultOptionId ); + /** + * User-readable title of the command. + * See {@link module:heading/headingcommand~HeadingOption}. + * + * @readonly + * @member {String} #title + */ } /** @@ -70,126 +71,59 @@ export default class HeadingCommand extends Command { * * @protected * @param {Object} [options] Options for executed command. - * @param {String} [options.id] The identifier of the heading option that should be applied. It should be one of the - * {@link module:heading/headingcommand~HeadingOption heading options} provided to the command constructor. If this parameter is not - * provided, - * the value from {@link #defaultOption defaultOption} will be used. * @param {module:engine/model/batch~Batch} [options.batch] Batch to collect all the change steps. * New batch will be created if this option is not set. */ _doExecute( options = {} ) { - // TODO: What should happen if option is not found? - const id = options.id || this.defaultOption.id; - const doc = this.editor.document; - const selection = doc.selection; - const startPosition = selection.getFirstPosition(); - const elements = []; - // Storing selection ranges and direction to fix selection after renaming. See ckeditor5-engine#367. - const ranges = [ ...selection.getRanges() ]; - const isSelectionBackward = selection.isBackward; - // If current option is same as new option - toggle already applied option back to default one. - const shouldRemove = ( id === this.value.id ); + const editor = this.editor; + const document = editor.document; - // Collect elements to change option. - // This implementation may not be future proof but it's satisfactory at this stage. - if ( selection.isCollapsed ) { - const block = findTopmostBlock( startPosition ); - - if ( block ) { - elements.push( block ); - } - } else { - for ( let range of ranges ) { - let startBlock = findTopmostBlock( range.start ); - const endBlock = findTopmostBlock( range.end, false ); - - elements.push( startBlock ); - - while ( startBlock !== endBlock ) { - startBlock = startBlock.nextSibling; - elements.push( startBlock ); - } - } - } + // If current option is same as new option - toggle already applied option back to default one. + const shouldRemove = this.value; - doc.enqueueChanges( () => { - const batch = options.batch || doc.batch(); + document.enqueueChanges( () => { + const batch = options.batch || document.batch(); - for ( let element of elements ) { + for ( let block of document.selection.getSelectedBlocks() ) { // When removing applied option. if ( shouldRemove ) { - if ( element.name === id ) { - batch.rename( element, this.defaultOption.id ); + if ( block.is( this.modelElement ) ) { + // Apply paragraph to the selection withing that particular block only instead + // of working on the entire document selection. + const selection = new Selection(); + selection.addRange( Range.createIn( block ) ); + + // Share the batch with the paragraph command. + editor.execute( 'paragraph', { selection, batch } ); } } // When applying new option. - else { - batch.rename( element, id ); + else if ( !block.is( this.modelElement ) ) { + batch.rename( block, this.modelElement ); } } - - // If range's selection start/end is placed directly in renamed block - we need to restore it's position - // after renaming, because renaming puts new element there. - doc.selection.setRanges( ranges, isSelectionBackward ); } ); } - /** - * Returns the option by a given ID. - * - * @private - * @param {String} id - * @returns {module:heading/headingcommand~HeadingOption} - */ - _getOptionById( id ) { - return this.options.find( item => item.id === id ) || this.defaultOption; - } - /** * Updates command's {@link #value value} based on current selection. * * @private */ _updateValue() { - const position = this.editor.document.selection.getFirstPosition(); - const block = findTopmostBlock( position ); + const block = this.editor.document.selection.getSelectedBlocks().next().value; if ( block ) { - this.value = this._getOptionById( block.name ); + this.value = block.is( this.modelElement ); } } } -// Looks for the topmost element in the position's ancestor (up to an element in the root). -// -// NOTE: This method does not check the schema directly — it assumes that only block elements can be placed directly inside -// the root. -// -// @private -// @param {engine.model.Position} position -// @param {Boolean} [nodeAfter=true] When the position is placed inside the root element, this will determine if the element before -// or after a given position will be returned. -// @returns {engine.model.Element} -function findTopmostBlock( position, nodeAfter = true ) { - let parent = position.parent; - - // If position is placed inside root - get element after/before it. - if ( parent instanceof RootElement ) { - return nodeAfter ? position.nodeAfter : position.nodeBefore; - } - - while ( !( parent.parent instanceof RootElement ) ) { - parent = parent.parent; - } - - return parent; -} - /** * Heading option descriptor. * * @typedef {Object} module:heading/headingcommand~HeadingOption - * @property {String} id Option identifier. It will be used as the element's name in the model. - * @property {String} element The name of the view element that will be used to represent the model element in the view. - * @property {String} label The display name of the option. + * @property {String} modelElement Element's name in the model. + * @property {String} viewElement The name of the view element that will be used to represent the model element in the view. + * @property {String} title The user-readable title of the option. */ diff --git a/src/headingengine.js b/src/headingengine.js index 764fd71..e7dcd7b 100644 --- a/src/headingengine.js +++ b/src/headingengine.js @@ -13,7 +13,7 @@ import buildViewConverter from '@ckeditor/ckeditor5-engine/src/conversion/buildv import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import HeadingCommand from './headingcommand'; -const defaultOptionId = 'paragraph'; +const defaultModelElement = 'paragraph'; /** * The headings engine feature. It handles switching between block formats – headings and paragraph. @@ -28,12 +28,14 @@ export default class HeadingEngine extends Plugin { constructor( editor ) { super( editor ); + // TODO: This needs proper documentation, i.e. why paragraph entry does not need + // more properties (https://github.com/ckeditor/ckeditor5/issues/403). editor.config.define( 'heading', { options: [ - { id: 'paragraph', element: 'p', label: 'Paragraph' }, - { id: 'heading1', element: 'h2', label: 'Heading 1' }, - { id: 'heading2', element: 'h3', label: 'Heading 2' }, - { id: 'heading3', element: 'h4', label: 'Heading 3' } + { modelElement: 'paragraph', title: 'Paragraph' }, + { modelElement: 'heading1', viewElement: 'h2', title: 'Heading 1' }, + { modelElement: 'heading2', viewElement: 'h3', title: 'Heading 2' }, + { modelElement: 'heading3', viewElement: 'h4', title: 'Heading 3' } ] } ); } @@ -52,29 +54,28 @@ export default class HeadingEngine extends Plugin { const editor = this.editor; const data = editor.data; const editing = editor.editing; - const options = this._getLocalizedOptions(); + const options = editor.config.get( 'heading.options' ); for ( let option of options ) { // Skip paragraph - it is defined in required Paragraph feature. - if ( option.id !== defaultOptionId ) { + if ( option.modelElement !== defaultModelElement ) { // Schema. - editor.document.schema.registerItem( option.id, '$block' ); + editor.document.schema.registerItem( option.modelElement, '$block' ); // Build converter from model to view for data and editing pipelines. buildModelConverter().for( data.modelToView, editing.modelToView ) - .fromElement( option.id ) - .toElement( option.element ); + .fromElement( option.modelElement ) + .toElement( option.viewElement ); // Build converter from view to model for data pipeline. buildViewConverter().for( data.viewToModel ) - .fromElement( option.element ) - .toElement( option.id ); + .fromElement( option.viewElement ) + .toElement( option.modelElement ); + + // Register the heading command for this option. + editor.commands.set( option.modelElement, new HeadingCommand( editor, option ) ); } } - - // Register the heading command. - const command = new HeadingCommand( editor, options, defaultOptionId ); - editor.commands.set( 'heading', command ); } /** @@ -84,68 +85,19 @@ export default class HeadingEngine extends Plugin { // If the enter command is added to the editor, alter its behavior. // Enter at the end of a heading element should create a paragraph. const editor = this.editor; - const command = editor.commands.get( 'heading' ); const enterCommand = editor.commands.get( 'enter' ); - const options = this._getLocalizedOptions(); + const options = editor.config.get( 'heading.options' ); if ( enterCommand ) { this.listenTo( enterCommand, 'afterExecute', ( evt, data ) => { const positionParent = editor.document.selection.getFirstPosition().parent; const batch = data.batch; - const isHeading = options.some( option => option.id == positionParent.name ); + const isHeading = options.some( option => positionParent.is( option.modelElement ) ); - if ( isHeading && positionParent.name != command.defaultOption.id && positionParent.childCount === 0 ) { - batch.rename( positionParent, command.defaultOption.id ); + if ( isHeading && !positionParent.is( defaultModelElement ) && positionParent.childCount === 0 ) { + batch.rename( positionParent, defaultModelElement ); } } ); } } - - /** - * Returns heading options as defined in `config.heading.options` but processed to consider - * editor localization, i.e. to display {@link module:heading/headingcommand~HeadingOption#label} - * in the correct language. - * - * Note: The reason behind this method is that there's no way to use {@link utils/locale~Locale#t} - * when the user config is defined because the editor does not exist yet. - * - * @private - * @returns {Array.}. - */ - _getLocalizedOptions() { - if ( this._cachedLocalizedOptions ) { - return this._cachedLocalizedOptions; - } - - const editor = this.editor; - const t = editor.t; - const localizedLabels = { - Paragraph: t( 'Paragraph' ), - 'Heading 1': t( 'Heading 1' ), - 'Heading 2': t( 'Heading 2' ), - 'Heading 3': t( 'Heading 3' ) - }; - - /** - * Cached localized version of `config.heading.options` generated by - * {@link module:heading/headingengine~HeadingEngine#_localizedOptions}. - * - * @private - * @readonly - * @member {Array.} #_cachedLocalizedOptions - */ - this._cachedLocalizedOptions = editor.config.get( 'heading.options' ) - .map( option => { - if ( localizedLabels[ option.label ] ) { - // Clone the option to avoid altering the original `config.heading.options`. - option = Object.assign( {}, option, { - label: localizedLabels[ option.label ] - } ); - } - - return option; - } ); - - return this._cachedLocalizedOptions; - } } diff --git a/tests/heading.js b/tests/heading.js index ea216d1..c0ae1c8 100644 --- a/tests/heading.js +++ b/tests/heading.js @@ -63,72 +63,73 @@ describe( 'Heading', () => { const executeSpy = testUtils.sinon.spy( editor, 'execute' ); const dropdown = editor.ui.componentFactory.create( 'headings' ); - dropdown.id = 'foo'; + dropdown.commandName = 'paragraph'; dropdown.fire( 'execute' ); sinon.assert.calledOnce( executeSpy ); - sinon.assert.calledWithExactly( executeSpy, 'heading', { id: 'foo' } ); + sinon.assert.calledWithExactly( executeSpy, 'paragraph' ); } ); it( 'should focus view after command execution', () => { const focusSpy = testUtils.sinon.spy( editor.editing.view, 'focus' ); const dropdown = editor.ui.componentFactory.create( 'headings' ); + dropdown.commandName = 'paragraph'; dropdown.fire( 'execute' ); sinon.assert.calledOnce( focusSpy ); } ); describe( 'model to command binding', () => { - let command; + let commands; beforeEach( () => { - command = editor.commands.get( 'heading' ); + commands = {}; + + editor.config.get( 'heading.options' ).forEach( ( { modelElement } ) => { + commands[ modelElement ] = editor.commands.get( modelElement ); + } ); } ); it( 'isEnabled', () => { - expect( dropdown.buttonView.isEnabled ).to.be.true; - command.isEnabled = false; + for ( let name in commands ) { + commands[ name ].isEnabled = false; + } + expect( dropdown.buttonView.isEnabled ).to.be.false; + + commands.heading2.isEnabled = true; + expect( dropdown.buttonView.isEnabled ).to.be.true; } ); it( 'label', () => { + for ( let name in commands ) { + commands[ name ].value = false; + } + expect( dropdown.buttonView.label ).to.equal( 'Paragraph' ); - command.value = command.options[ 1 ]; - expect( dropdown.buttonView.label ).to.equal( 'Heading 1' ); + + commands.heading2.value = true; + expect( dropdown.buttonView.label ).to.equal( 'Heading 2' ); } ); } ); describe( 'localization', () => { - let command; + let commands; beforeEach( () => { - const editorElement = document.createElement( 'div' ); - - return ClassicTestEditor.create( editorElement, { - plugins: [ Heading ], - toolbar: [ 'heading' ], - lang: 'pl', - heading: { - options: [ - { id: 'paragraph', element: 'p', label: 'Paragraph' }, - { id: 'heading1', element: 'h2', label: 'Heading 1' }, - { id: 'heading2', element: 'h3', label: 'Not automatically localized' } - ] - } - } ) - .then( newEditor => { - editor = newEditor; - dropdown = editor.ui.componentFactory.create( 'headings' ); - command = editor.commands.get( 'heading' ); - } ); + return localizedEditor( [ + { modelElement: 'paragraph', title: 'Paragraph' }, + { modelElement: 'heading1', viewElement: 'h2', title: 'Heading 1' }, + { modelElement: 'heading2', viewElement: 'h3', title: 'Heading 2' } + ] ); } ); it( 'does not alter the original config', () => { expect( editor.config.get( 'heading.options' ) ).to.deep.equal( [ - { id: 'paragraph', element: 'p', label: 'Paragraph' }, - { id: 'heading1', element: 'h2', label: 'Heading 1' }, - { id: 'heading2', element: 'h3', label: 'Not automatically localized' } + { modelElement: 'paragraph', title: 'Paragraph' }, + { modelElement: 'heading1', viewElement: 'h2', title: 'Heading 1' }, + { modelElement: 'heading2', viewElement: 'h3', title: 'Heading 2' } ] ); } ); @@ -136,7 +137,7 @@ describe( 'Heading', () => { const buttonView = dropdown.buttonView; expect( buttonView.label ).to.equal( 'Akapit' ); - command.value = command.options[ 1 ]; + commands.heading1.value = true; expect( buttonView.label ).to.equal( 'Nagłówek 1' ); } ); @@ -146,9 +147,56 @@ describe( 'Heading', () => { expect( listView.items.map( item => item.label ) ).to.deep.equal( [ 'Akapit', 'Nagłówek 1', - 'Not automatically localized' + 'Nagłówek 2' ] ); } ); + + it( 'allows custom titles', () => { + return localizedEditor( [ + { modelElement: 'paragraph', title: 'Custom paragraph title' }, + { modelElement: 'heading1', title: 'Custom heading1 title' } + ] ).then( () => { + const listView = dropdown.listView; + + expect( listView.items.map( item => item.label ) ).to.deep.equal( [ + 'Custom paragraph title', + 'Custom heading1 title', + ] ); + } ); + } ); + + it( 'translates default using the the locale', () => { + return localizedEditor( [ + { modelElement: 'paragraph', title: 'Paragraph' } + ] ).then( () => { + const listView = dropdown.listView; + + expect( listView.items.map( item => item.label ) ).to.deep.equal( [ + 'Akapit' + ] ); + } ); + } ); + + function localizedEditor( options ) { + const editorElement = document.createElement( 'div' ); + + return ClassicTestEditor.create( editorElement, { + plugins: [ Heading ], + toolbar: [ 'heading' ], + lang: 'pl', + heading: { + options: options + } + } ) + .then( newEditor => { + editor = newEditor; + dropdown = editor.ui.componentFactory.create( 'headings' ); + commands = {}; + editor.config.get( 'heading.options' ).forEach( ( { modelElement } ) => { + commands[ modelElement ] = editor.commands.get( modelElement ); + } ); + } ); + } } ); } ); } ); diff --git a/tests/headingcommand.js b/tests/headingcommand.js index 821eb0b..26dcdb0 100644 --- a/tests/headingcommand.js +++ b/tests/headingcommand.js @@ -4,29 +4,33 @@ */ import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor'; +import ParagraphCommand from '@ckeditor/ckeditor5-paragraph/src/paragraphcommand'; import HeadingCommand from '../src/headingcommand'; import Range from '@ckeditor/ckeditor5-engine/src/model/range'; import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; const options = [ - { id: 'paragraph', element: 'p' }, - { id: 'heading1', element: 'h2' }, - { id: 'heading2', element: 'h3' }, - { id: 'heading3', element: 'h4' } + { modelElement: 'heading1', viewElement: 'h2', title: 'H2' }, + { modelElement: 'heading2', viewElement: 'h3', title: 'H3' }, + { modelElement: 'heading3', viewElement: 'h4', title: 'H4' } ]; describe( 'HeadingCommand', () => { - let editor, document, command, root, schema; + let editor, document, commands, root, schema; beforeEach( () => { return ModelTestEditor.create().then( newEditor => { editor = newEditor; document = editor.document; - command = new HeadingCommand( editor, options, 'paragraph' ); + commands = {}; schema = document.schema; + editor.commands.set( 'paragraph', new ParagraphCommand( editor ) ); + schema.registerItem( 'paragraph', '$block' ); + for ( let option of options ) { - schema.registerItem( option.id, '$block' ); + commands[ option.modelElement ] = new HeadingCommand( editor, option ); + schema.registerItem( option.modelElement, '$block' ); } root = document.getRoot(); @@ -34,7 +38,23 @@ describe( 'HeadingCommand', () => { } ); afterEach( () => { - command.destroy(); + for ( let modelElement in commands ) { + commands[ modelElement ].destroy(); + } + } ); + + describe( 'basic properties', () => { + for ( let option of options ) { + test( option ); + } + + function test( { modelElement, viewElement, title } ) { + it( `are set for option.modelElement = ${ modelElement }`, () => { + expect( commands[ modelElement ].modelElement ).to.equal( modelElement ); + expect( commands[ modelElement ].viewElement ).to.equal( viewElement ); + expect( commands[ modelElement ].title ).to.equal( title ); + } ); + } } ); describe( 'value', () => { @@ -42,40 +62,33 @@ describe( 'HeadingCommand', () => { test( option ); } - function test( option ) { - it( `equals ${ option.id } when collapsed selection is placed inside ${ option.id } element`, () => { - setData( document, `<${ option.id }>foobar` ); + function test( { modelElement } ) { + it( `equals ${ modelElement } when collapsed selection is placed inside ${ modelElement } element`, () => { + setData( document, `<${ modelElement }>foobar` ); const element = root.getChild( 0 ); document.selection.addRange( Range.createFromParentsAndOffsets( element, 3, element, 3 ) ); - expect( command.value ).to.equal( option ); + expect( commands[ modelElement ].value ).to.be.true; } ); } - - it( 'should be equal to #defaultOption if option has not been found', () => { - schema.registerItem( 'div', '$block' ); - setData( document, '
xyz
' ); - const element = root.getChild( 0 ); - document.selection.addRange( Range.createFromParentsAndOffsets( element, 1, element, 1 ) ); - - expect( command.value ).to.equal( command.defaultOption ); - } ); } ); describe( '_doExecute', () => { it( 'should update value after execution', () => { + const command = commands.heading1; + setData( document, '[]' ); - command._doExecute( { id: 'heading1' } ); + command._doExecute(); expect( getData( document ) ).to.equal( '[]' ); - expect( command.value ).to.be.object; - expect( command.value.id ).to.equal( 'heading1' ); - expect( command.value.element ).to.equal( 'h2' ); + expect( command.value ).to.be.true; } ); describe( 'custom options', () => { it( 'should use provided batch', () => { const batch = editor.document.batch(); + const command = commands.heading1; + setData( document, 'foo[]bar' ); expect( batch.deltas.length ).to.equal( 0 ); @@ -84,6 +97,19 @@ describe( 'HeadingCommand', () => { expect( batch.deltas.length ).to.be.above( 0 ); } ); + + it( 'should use provided batch (converting to default option)', () => { + const batch = editor.document.batch(); + const command = commands.heading1; + + setData( document, 'foo[]bar' ); + + expect( batch.deltas.length ).to.equal( 0 ); + + command._doExecute( { batch } ); + + expect( batch.deltas.length ).to.be.above( 0 ); + } ); } ); describe( 'collapsed selection', () => { @@ -94,16 +120,11 @@ describe( 'HeadingCommand', () => { convertTo = option; } - it( 'uses paragraph as default value', () => { - setData( document, 'foo[]bar' ); - command._doExecute(); - - expect( getData( document ) ).to.equal( 'foo[]bar' ); - } ); - it( 'converts to default option when executed with already applied option', () => { + const command = commands.heading1; + setData( document, 'foo[]bar' ); - command._doExecute( { id: 'heading1' } ); + command._doExecute(); expect( getData( document ) ).to.equal( 'foo[]bar' ); } ); @@ -112,18 +133,18 @@ describe( 'HeadingCommand', () => { schema.registerItem( 'inlineImage', '$inline' ); schema.allow( { name: '$text', inside: 'inlineImage' } ); - setData( document, 'foo[]bar' ); - command._doExecute( { id: 'heading1' } ); + setData( document, 'foo[]bar' ); + commands.heading1._doExecute(); - expect( getData( document ) ).to.equal( 'foo[]bar' ); + expect( getData( document ) ).to.equal( 'foo[]bar' ); } ); function test( from, to ) { - it( `converts ${ from.id } to ${ to.id } on collapsed selection`, () => { - setData( document, `<${ from.id }>foo[]bar` ); - command._doExecute( { id: to.id } ); + it( `converts ${ from.modelElement } to ${ to.modelElement } on collapsed selection`, () => { + setData( document, `<${ from.modelElement }>foo[]bar` ); + commands[ to.modelElement ]._doExecute(); - expect( getData( document ) ).to.equal( `<${ to.id }>foo[]bar` ); + expect( getData( document ) ).to.equal( `<${ to.modelElement }>foo[]bar` ); } ); } } ); @@ -137,29 +158,35 @@ describe( 'HeadingCommand', () => { } it( 'converts all elements where selection is applied', () => { - setData( document, 'foo[bar]baz' ); - command._doExecute( { id: 'paragraph' } ); + setData( document, 'foo[bar]baz' ); + commands.heading3._doExecute(); expect( getData( document ) ).to.equal( - 'foo[bar]baz' + 'foo[bar]baz' ); } ); it( 'resets to default value all elements with same option', () => { setData( document, 'foo[barbaz]' ); - command._doExecute( { id: 'heading1' } ); + commands.heading1._doExecute(); expect( getData( document ) ).to.equal( 'foo[barbaz]' ); } ); - function test( from, to ) { - it( `converts ${ from.id } to ${ to.id } on non-collapsed selection`, () => { - setData( document, `<${ from.id }>foo[bar<${ from.id }>baz]qux` ); - command._doExecute( { id: to.id } ); + function test( { modelElement: fromElement }, { modelElement: toElement } ) { + it( `converts ${ fromElement } to ${ toElement } on non-collapsed selection`, () => { + setData( + document, + `<${ fromElement }>foo[bar<${ fromElement }>baz]qux` + ); + + commands[ toElement ]._doExecute(); - expect( getData( document ) ).to.equal( `<${ to.id }>foo[bar<${ to.id }>baz]qux` ); + expect( getData( document ) ).to.equal( + `<${ toElement }>foo[bar<${ toElement }>baz]qux` + ); } ); } } ); diff --git a/tests/headingengine.js b/tests/headingengine.js index 6a3ec24..fc4647e 100644 --- a/tests/headingengine.js +++ b/tests/headingengine.js @@ -4,20 +4,13 @@ */ import HeadingEngine from '../src/headingengine'; +import HeadingCommand from '../src/headingcommand'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import ParagraphCommand from '@ckeditor/ckeditor5-paragraph/src/paragraphcommand'; import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; -import HeadingCommand from '../src/headingcommand'; import Enter from '@ckeditor/ckeditor5-enter/src/enter'; -import { add } from '@ckeditor/ckeditor5-utils/src/translation-service'; import { getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; -add( 'pl', { - 'Paragraph': 'Akapit', - 'Heading 1': 'Nagłówek 1', - 'Heading 2': 'Nagłówek 2', - 'Heading 3': 'Nagłówek 3', -} ); - describe( 'HeadingEngine', () => { let editor, document; @@ -54,11 +47,11 @@ describe( 'HeadingEngine', () => { expect( document.schema.check( { name: '$inline', inside: 'heading3' } ) ).to.be.true; } ); - it( 'should register option command', () => { - expect( editor.commands.has( 'heading' ) ).to.be.true; - const command = editor.commands.get( 'heading' ); - - expect( command ).to.be.instanceOf( HeadingCommand ); + it( 'should register #commands', () => { + expect( editor.commands.get( 'paragraph' ) ).to.be.instanceOf( ParagraphCommand ); + expect( editor.commands.get( 'heading1' ) ).to.be.instanceOf( HeadingCommand ); + expect( editor.commands.get( 'heading2' ) ).to.be.instanceOf( HeadingCommand ); + expect( editor.commands.get( 'heading3' ) ).to.be.instanceOf( HeadingCommand ); } ); it( 'should convert heading1', () => { @@ -101,23 +94,29 @@ describe( 'HeadingEngine', () => { expect( getData( document ) ).to.equal( 'foo[]bar' ); } ); + it( 'should not blow up if there\'s no enter command in the editor', () => { + return VirtualTestEditor.create( { + plugins: [ HeadingEngine ] + } ); + } ); + describe( 'config', () => { describe( 'options', () => { describe( 'default value', () => { it( 'should be set', () => { expect( editor.config.get( 'heading.options' ) ).to.deep.equal( [ - { id: 'paragraph', element: 'p', label: 'Paragraph' }, - { id: 'heading1', element: 'h2', label: 'Heading 1' }, - { id: 'heading2', element: 'h3', label: 'Heading 2' }, - { id: 'heading3', element: 'h4', label: 'Heading 3' } + { modelElement: 'paragraph', title: 'Paragraph' }, + { modelElement: 'heading1', viewElement: 'h2', title: 'Heading 1' }, + { modelElement: 'heading2', viewElement: 'h3', title: 'Heading 2' }, + { modelElement: 'heading3', viewElement: 'h4', title: 'Heading 3' } ] ); } ); } ); it( 'should customize options', () => { const options = [ - { id: 'paragraph', element: 'p', label: 'Paragraph' }, - { id: 'h4', element: 'h4', label: 'H4' } + { modelElement: 'paragraph', title: 'Paragraph' }, + { modelElement: 'h4', viewElement: 'h4', title: 'H4' } ]; return VirtualTestEditor.create( { @@ -129,7 +128,8 @@ describe( 'HeadingEngine', () => { .then( editor => { document = editor.document; - expect( editor.commands.get( 'heading' ).options ).to.deep.equal( options ); + expect( editor.commands.get( 'h4' ) ).to.be.instanceOf( HeadingCommand ); + expect( editor.commands.get( 'paragraph' ) ).to.be.instanceOf( ParagraphCommand ); expect( document.schema.hasItem( 'paragraph' ) ).to.be.true; expect( document.schema.hasItem( 'h4' ) ).to.be.true;