diff --git a/docs/features/headings.md b/docs/features/headings.md index 83ad635..3da7e84 100644 --- a/docs/features/headings.md +++ b/docs/features/headings.md @@ -122,7 +122,7 @@ import Heading from '@ckeditor/ckeditor5-heading/src/heading'; ClassicEditor .create( document.querySelector( '#editor' ), { plugins: [ Heading, ... ], - toolbar: [ 'headings', ... ] + toolbar: [ 'heading', ... ] } ) .then( ... ) .catch( ... ); @@ -132,13 +132,13 @@ ClassicEditor The {@link module:heading/heading~Heading} plugin registers: -* The `'headings'` dropdown. -* The `'heading1'`, `'heading2'`, ..., `'headingN'` commands based on the {@link module:heading/heading~HeadingConfig#options `heading.options`} configuration option. +* The `'heading'` dropdown. +* The `'heading'` command that accepts value based on the {@link module:heading/heading~HeadingConfig#options `heading.options`} configuration option. You can turn the currently selected block(s) to headings by executing one of these commands: ```js - editor.execute( 'heading2' ); + editor.execute( 'heading', { value: 'heading2' } ); ``` ## Contribute diff --git a/src/heading.js b/src/heading.js index 63fe424..fae35b1 100644 --- a/src/heading.js +++ b/src/heading.js @@ -95,10 +95,10 @@ export default class Heading extends Plugin { * That's assumption is used by features like {@link module:autoformat/autoformat~Autoformat} to know which element * they should use when applying the first level heading. * - * The defined headings are also available in {@link module:core/commandcollection~CommandCollection} under their model names. + * The defined headings are also available as values passed to `heading` command under their model names. * For example, the below code will apply `` to the current selection: * - * editor.execute( 'heading1' ); + * editor.execute( 'heading', { value: 'heading1' } ); * * @member {Array.} module:heading/heading~HeadingConfig#options */ diff --git a/src/headingcommand.js b/src/headingcommand.js index c02791f..02883a5 100644 --- a/src/headingcommand.js +++ b/src/headingcommand.js @@ -20,27 +20,29 @@ export default class HeadingCommand extends Command { * Creates an instance of the command. * * @param {module:core/editor/editor~Editor} editor Editor instance. - * @param {String} modelElement Name of the element which this command will apply in the model. + * @param {Array.} modelElements Names of the element which this command can apply in the model. */ - constructor( editor, modelElement ) { + constructor( editor, modelElements ) { super( editor ); /** - * Whether the selection starts in a heading of {@link #modelElement this level}. + * If the selection starts in a heading (which {@link #modelElements is supported by this command}) + * the value is set to the name of that heading model element. + * It is set to `false` otherwise. * * @observable * @readonly - * @member {Boolean} #value + * @member {Boolean|String} #value */ /** - * Unique identifier of the command, also element's name in the model. + * Set of defined model's elements names that this command support. * See {@link module:heading/heading~HeadingOption}. * * @readonly - * @member {String} + * @member {Array.} */ - this.modelElement = modelElement; + this.modelElements = modelElements; } /** @@ -49,29 +51,37 @@ export default class HeadingCommand extends Command { refresh() { const block = first( this.editor.model.document.selection.getSelectedBlocks() ); - this.value = !!block && block.is( this.modelElement ); - this.isEnabled = !!block && checkCanBecomeHeading( block, this.modelElement, this.editor.model.schema ); + this.value = !!block && this.modelElements.includes( block.name ) && block.name; + this.isEnabled = !!block && this.modelElements.some( heading => checkCanBecomeHeading( block, heading, this.editor.model.schema ) ); } /** * Executes the command. Applies the heading to the selected blocks or, if the first selected * block is a heading already, turns selected headings (of this level only) to paragraphs. * + * @param {Object} options + * @param {String} options.value Name of the element which this command will apply in the model. * @fires execute */ - execute() { + execute( options = {} ) { const model = this.editor.model; const document = model.document; + const modelElement = options.value; + + if ( !this.modelElements.includes( modelElement ) ) { + return; + } + model.change( writer => { const blocks = Array.from( document.selection.getSelectedBlocks() ) .filter( block => { - return checkCanBecomeHeading( block, this.modelElement, model.schema ); + return checkCanBecomeHeading( block, modelElement, model.schema ); } ); for ( const block of blocks ) { - if ( !block.is( this.modelElement ) ) { - writer.rename( block, this.modelElement ); + if ( !block.is( modelElement ) ) { + writer.rename( block, modelElement ); } } } ); diff --git a/src/headingediting.js b/src/headingediting.js index dedee4f..a3372fc 100644 --- a/src/headingediting.js +++ b/src/headingediting.js @@ -51,6 +51,8 @@ export default class HeadingEditing extends Plugin { const editor = this.editor; const options = editor.config.get( 'heading.options' ); + const modelElements = []; + for ( const option of options ) { // Skip paragraph - it is defined in required Paragraph feature. if ( option.model !== defaultModelElement ) { @@ -61,10 +63,12 @@ export default class HeadingEditing extends Plugin { editor.conversion.elementToElement( option ); - // Register the heading command for this option. - editor.commands.add( option.model, new HeadingCommand( editor, option.model ) ); + modelElements.push( option.model ); } } + + // Register the heading command for this option. + editor.commands.add( 'heading', new HeadingCommand( editor, modelElements ) ); } /** diff --git a/src/headingui.js b/src/headingui.js index ae6a197..e8d706f 100644 --- a/src/headingui.js +++ b/src/headingui.js @@ -33,24 +33,37 @@ export default class HeadingUI extends Plugin { const dropdownTooltip = t( 'Heading' ); // Register UI component. - editor.ui.componentFactory.add( 'headings', locale => { - const commands = []; + editor.ui.componentFactory.add( 'heading', locale => { + const titles = {}; const dropdownItems = new Collection(); + const headingCommand = editor.commands.get( 'heading' ); + const paragraphCommand = editor.commands.get( 'paragraph' ); + + const commands = [ headingCommand ]; + for ( const option of options ) { - const command = editor.commands.get( option.model ); const itemModel = new Model( { - commandName: option.model, label: option.title, class: option.class } ); - itemModel.bind( 'isActive' ).to( command, 'value' ); + if ( option.model === 'paragraph' ) { + itemModel.bind( 'isActive' ).to( paragraphCommand, 'value' ); + itemModel.set( 'commandName', 'paragraph' ); + commands.push( paragraphCommand ); + } else { + itemModel.bind( 'isActive' ).to( headingCommand, 'value', value => value === option.model ); + itemModel.set( { + commandName: 'heading', + commandValue: option.model + } ); + } // Add the option to the collection. dropdownItems.add( itemModel ); - commands.push( command ); + titles[ option.model ] = option.title; } const dropdownView = createDropdown( locale ); @@ -74,16 +87,15 @@ export default class HeadingUI extends Plugin { return areEnabled.some( isEnabled => isEnabled ); } ); - dropdownView.buttonView.bind( 'label' ).toMany( commands, 'value', ( ...areActive ) => { - const index = areActive.findIndex( value => value ); - + dropdownView.buttonView.bind( 'label' ).to( headingCommand, 'value', paragraphCommand, 'value', ( value, para ) => { + const whichModel = value || para && 'paragraph'; // If none of the commands is active, display default title. - return options[ index ] ? options[ index ].title : defaultTitle; + return titles[ whichModel ] ? titles[ whichModel ] : defaultTitle; } ); // Execute command when an item from the dropdown is selected. this.listenTo( dropdownView, 'execute', evt => { - editor.execute( evt.source.commandName ); + editor.execute( evt.source.commandName, evt.source.commandValue ? { value: evt.source.commandValue } : undefined ); editor.editing.view.focus(); } ); diff --git a/tests/headingcommand.js b/tests/headingcommand.js index aa94916..be6fcad 100644 --- a/tests/headingcommand.js +++ b/tests/headingcommand.js @@ -16,24 +16,29 @@ const options = [ ]; describe( 'HeadingCommand', () => { - let editor, model, document, commands, root, schema; + let editor, model, document, command, root, schema; beforeEach( () => { return ModelTestEditor.create().then( newEditor => { editor = newEditor; model = editor.model; document = model.document; - commands = {}; schema = model.schema; editor.commands.add( 'paragraph', new ParagraphCommand( editor ) ); schema.register( 'paragraph', { inheritAllFrom: '$block' } ); + const modelElements = []; + for ( const option of options ) { - commands[ option.model ] = new HeadingCommand( editor, option.model ); + modelElements.push( option.model ); schema.register( option.model, { inheritAllFrom: '$block' } ); } + command = new HeadingCommand( editor, modelElements ); + editor.commands.add( 'heading', command ); + schema.register( 'heading', { inheritAllFrom: '$block' } ); + schema.register( 'notBlock' ); schema.extend( 'notBlock', { allowIn: '$root' } ); schema.extend( '$text', { allowIn: 'notBlock' } ); @@ -42,15 +47,9 @@ describe( 'HeadingCommand', () => { } ); } ); - afterEach( () => { - for ( const modelElement in commands ) { - commands[ modelElement ].destroy(); - } - } ); - - describe( 'modelElement', () => { + describe( 'modelElements', () => { it( 'is set', () => { - expect( commands.heading1.modelElement ).to.equal( 'heading1' ); + expect( command.modelElements ).to.deep.equal( [ 'heading1', 'heading2', 'heading3' ] ); } ); } ); @@ -70,13 +69,13 @@ describe( 'HeadingCommand', () => { ]; writer.setSelection( ranges ); } ); - expect( commands[ modelElement ].value ).to.be.true; + expect( command.value ).to.equal( modelElement ); } ); it( 'equals false if inside to non-block element', () => { setData( model, '[foo]' ); - expect( commands[ modelElement ].value ).to.be.false; + expect( command.value ).to.be.false; } ); it( `equals false if moved from ${ modelElement } to non-block element`, () => { @@ -87,18 +86,17 @@ describe( 'HeadingCommand', () => { writer.setSelection( Range.createIn( element ) ); } ); - expect( commands[ modelElement ].value ).to.be.false; + expect( command.value ).to.be.false; } ); it( 'should be refreshed after calling refresh()', () => { - const command = commands[ modelElement ]; setData( model, `<${ modelElement }>[foo]foo` ); const element = document.getRoot().getChild( 1 ); model.change( writer => { writer.setSelection( Range.createIn( element ) ); - expect( command.value ).to.be.true; + expect( command.value ).to.equal( modelElement ); command.refresh(); expect( command.value ).to.be.false; } ); @@ -108,13 +106,11 @@ describe( 'HeadingCommand', () => { describe( 'execute()', () => { it( 'should update value after execution', () => { - const command = commands.heading1; - setData( model, '[]' ); - command.execute(); + command.execute( { value: 'heading1' } ); expect( getData( model ) ).to.equal( '[]' ); - expect( command.value ).to.be.true; + expect( command.value ).to.equal( 'heading1' ); } ); // https://github.com/ckeditor/ckeditor5-heading/issues/73 @@ -134,7 +130,7 @@ describe( 'HeadingCommand', () => { 'de]f' ); - commands.heading1.execute(); + command.execute( { value: 'heading1' } ); expect( getData( model ) ).to.equal( 'a[bc' + @@ -157,7 +153,7 @@ describe( 'HeadingCommand', () => { 'de]f' ); - commands.heading1.execute(); + command.execute( { value: 'heading1' } ); expect( getData( model ) ).to.equal( 'a[bc' + @@ -167,19 +163,33 @@ describe( 'HeadingCommand', () => { } ); it( 'should use parent batch', () => { - const command = commands.heading1; - setData( model, 'foo[]bar' ); model.change( writer => { expect( writer.batch.deltas.length ).to.equal( 0 ); - command.execute(); + command.execute( { value: 'heading1' } ); expect( writer.batch.deltas.length ).to.be.above( 0 ); } ); } ); + it( 'should do nothing on non-registered model elements', () => { + setData( model, '[]' ); + command.execute( { value: 'paragraph' } ); + + expect( getData( model ) ).to.equal( '[]' ); + expect( command.value ).to.equal( 'heading1' ); + } ); + + it( 'should do nothing when empty value is passed', () => { + setData( model, '[]' ); + command.execute(); + + expect( getData( model ) ).to.equal( '[]' ); + expect( command.value ).to.equal( 'heading1' ); + } ); + describe( 'collapsed selection', () => { let convertTo = options[ options.length - 1 ]; @@ -191,7 +201,7 @@ describe( 'HeadingCommand', () => { it( 'does nothing when executed with already applied option', () => { setData( model, 'foo[]bar' ); - commands.heading1.execute(); + command.execute( { value: 'heading1' } ); expect( getData( model ) ).to.equal( 'foo[]bar' ); } ); @@ -200,7 +210,7 @@ describe( 'HeadingCommand', () => { schema.extend( '$text', { allowIn: 'inlineImage' } ); setData( model, 'foo[]bar' ); - commands.heading1.execute(); + command.execute( { value: 'heading1' } ); expect( getData( model ) ).to.equal( 'foo[]bar' ); } ); @@ -208,7 +218,7 @@ describe( 'HeadingCommand', () => { function test( from, to ) { it( `converts ${ from.model } to ${ to.model } on collapsed selection`, () => { setData( model, `<${ from.model }>foo[]bar` ); - commands[ to.model ].execute(); + command.execute( { value: to.model } ); expect( getData( model ) ).to.equal( `<${ to.model }>foo[]bar` ); } ); @@ -226,7 +236,7 @@ describe( 'HeadingCommand', () => { it( 'converts all elements where selection is applied', () => { setData( model, 'foo[barbaz]' ); - commands.heading3.execute(); + command.execute( { value: 'heading3' } ); expect( getData( model ) ).to.equal( 'foo[barbaz]' @@ -235,7 +245,7 @@ describe( 'HeadingCommand', () => { it( 'does nothing to the elements with same option (#1)', () => { setData( model, '[foobar]' ); - commands.heading1.execute(); + command.execute( { value: 'heading1' } ); expect( getData( model ) ).to.equal( '[foobar]' @@ -244,7 +254,7 @@ describe( 'HeadingCommand', () => { it( 'does nothing to the elements with same option (#2)', () => { setData( model, '[foobarbaz]' ); - commands.heading1.execute(); + command.execute( { value: 'heading1' } ); expect( getData( model ) ).to.equal( '[foobarbaz]' @@ -258,7 +268,7 @@ describe( 'HeadingCommand', () => { `<${ fromElement }>foo[bar<${ fromElement }>baz]qux` ); - commands[ toElement ].execute(); + command.execute( { value: toElement } ); expect( getData( model ) ).to.equal( `<${ toElement }>foo[bar<${ toElement }>baz]qux` @@ -274,12 +284,6 @@ describe( 'HeadingCommand', () => { } function test( modelElement ) { - let command; - - beforeEach( () => { - command = commands[ modelElement ]; - } ); - describe( `${ modelElement } command`, () => { it( 'should be enabled when inside another block', () => { setData( model, 'f{}oo' ); diff --git a/tests/headingediting.js b/tests/headingediting.js index 49db89b..5526235 100644 --- a/tests/headingediting.js +++ b/tests/headingediting.js @@ -49,9 +49,7 @@ describe( 'HeadingEditing', () => { 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 ); + expect( editor.commands.get( 'heading' ) ).to.be.instanceOf( HeadingCommand ); } ); it( 'should convert heading1', () => { @@ -154,9 +152,6 @@ describe( 'HeadingEditing', () => { .then( editor => { model = editor.model; - expect( editor.commands.get( 'h4' ) ).to.be.instanceOf( HeadingCommand ); - expect( editor.commands.get( 'paragraph' ) ).to.be.instanceOf( ParagraphCommand ); - expect( model.schema.isRegistered( 'paragraph' ) ).to.be.true; expect( model.schema.isRegistered( 'h4' ) ).to.be.true; diff --git a/tests/headingui.js b/tests/headingui.js index ebdf197..a092a40 100644 --- a/tests/headingui.js +++ b/tests/headingui.js @@ -52,7 +52,7 @@ describe( 'HeadingUI', () => { } ) .then( newEditor => { editor = newEditor; - dropdown = editor.ui.componentFactory.create( 'headings' ); + dropdown = editor.ui.componentFactory.create( 'heading' ); // Set data so the commands will be enabled. setData( editor.model, 'f{}oo' ); @@ -67,7 +67,7 @@ describe( 'HeadingUI', () => { describe( 'init()', () => { it( 'should register options feature component', () => { - const dropdown = editor.ui.componentFactory.create( 'headings' ); + const dropdown = editor.ui.componentFactory.create( 'heading' ); expect( dropdown ).to.be.instanceOf( DropdownView ); expect( dropdown.buttonView.isEnabled ).to.be.true; @@ -76,20 +76,32 @@ describe( 'HeadingUI', () => { expect( dropdown.buttonView.tooltip ).to.equal( 'Heading' ); } ); - it( 'should execute format command on model execute event', () => { + it( 'should execute format command on model execute event for paragraph', () => { const executeSpy = testUtils.sinon.spy( editor, 'execute' ); - const dropdown = editor.ui.componentFactory.create( 'headings' ); + const dropdown = editor.ui.componentFactory.create( 'heading' ); dropdown.commandName = 'paragraph'; dropdown.fire( 'execute' ); sinon.assert.calledOnce( executeSpy ); - sinon.assert.calledWithExactly( executeSpy, 'paragraph' ); + sinon.assert.calledWithExactly( executeSpy, 'paragraph', undefined ); + } ); + + it( 'should execute format command on model execute event for heading', () => { + const executeSpy = testUtils.sinon.spy( editor, 'execute' ); + const dropdown = editor.ui.componentFactory.create( 'heading' ); + + dropdown.commandName = 'heading'; + dropdown.commandValue = 'heading1'; + dropdown.fire( 'execute' ); + + sinon.assert.calledOnce( executeSpy ); + sinon.assert.calledWithExactly( executeSpy, 'heading', { value: 'heading1' } ); } ); it( 'should focus view after command execution', () => { const focusSpy = testUtils.sinon.spy( editor.editing.view, 'focus' ); - const dropdown = editor.ui.componentFactory.create( 'headings' ); + const dropdown = editor.ui.componentFactory.create( 'heading' ); dropdown.commandName = 'paragraph'; dropdown.fire( 'execute' ); @@ -98,7 +110,7 @@ describe( 'HeadingUI', () => { } ); it( 'should add custom CSS class to dropdown', () => { - const dropdown = editor.ui.componentFactory.create( 'headings' ); + const dropdown = editor.ui.componentFactory.create( 'heading' ); dropdown.render(); @@ -106,41 +118,46 @@ describe( 'HeadingUI', () => { } ); describe( 'model to command binding', () => { - let commands; + let command, paragraphCommand; beforeEach( () => { - commands = {}; - - editor.config.get( 'heading.options' ).forEach( ( { model } ) => { - commands[ model ] = editor.commands.get( model ); - } ); + command = editor.commands.get( 'heading' ); + paragraphCommand = editor.commands.get( 'paragraph' ); } ); it( 'isEnabled', () => { - for ( const name in commands ) { - commands[ name ].isEnabled = false; - } + command.isEnabled = false; + paragraphCommand.isEnabled = false; expect( dropdown.buttonView.isEnabled ).to.be.false; - commands.heading2.isEnabled = true; + command.isEnabled = true; + expect( dropdown.buttonView.isEnabled ).to.be.true; + + command.isEnabled = false; + expect( dropdown.buttonView.isEnabled ).to.be.false; + + paragraphCommand.isEnabled = true; expect( dropdown.buttonView.isEnabled ).to.be.true; } ); it( 'label', () => { - for ( const name in commands ) { - commands[ name ].value = false; - } + command.value = false; + paragraphCommand.value = false; expect( dropdown.buttonView.label ).to.equal( 'Choose heading' ); - commands.heading2.value = true; + command.value = 'heading2'; expect( dropdown.buttonView.label ).to.equal( 'Heading 2' ); + command.value = false; + + paragraphCommand.value = true; + expect( dropdown.buttonView.label ).to.equal( 'Paragraph' ); } ); } ); describe( 'localization', () => { - let commands, editor, dropdown; + let command, paragraphCommand, editor, dropdown; beforeEach( () => { return localizedEditor( [ @@ -163,15 +180,15 @@ describe( 'HeadingUI', () => { // Setting manually paragraph.value to `false` because there might be some content in editor // after initialisation (for example empty

inserted when editor is empty). - commands.paragraph.value = false; + paragraphCommand.value = false; expect( buttonView.label ).to.equal( 'Wybierz nagłówek' ); expect( buttonView.tooltip ).to.equal( 'Nagłówek' ); - commands.paragraph.value = true; + paragraphCommand.value = true; expect( buttonView.label ).to.equal( 'Akapit' ); - commands.paragraph.value = false; - commands.heading1.value = true; + paragraphCommand.value = false; + command.value = 'heading1'; expect( buttonView.label ).to.equal( 'Nagłówek 1' ); } ); @@ -226,12 +243,9 @@ describe( 'HeadingUI', () => { } ) .then( newEditor => { editor = newEditor; - dropdown = editor.ui.componentFactory.create( 'headings' ); - commands = {}; - - editor.config.get( 'heading.options' ).forEach( ( { model } ) => { - commands[ model ] = editor.commands.get( model ); - } ); + dropdown = editor.ui.componentFactory.create( 'heading' ); + command = editor.commands.get( 'heading' ); + paragraphCommand = editor.commands.get( 'paragraph' ); editorElement.remove(); diff --git a/tests/integration.js b/tests/integration.js index b9eed11..b775623 100644 --- a/tests/integration.js +++ b/tests/integration.js @@ -78,7 +78,7 @@ describe( 'Heading integration', () => { 'b]ar' ); - editor.execute( 'heading1' ); + editor.execute( 'heading', { value: 'heading1' } ); expect( getModelData( model ) ).to.equal( 'fo[o' + @@ -94,7 +94,7 @@ describe( 'Heading integration', () => { it( 'does not create undo steps when applied to an existing heading (collapsed selection)', () => { setModelData( model, 'foo[]bar' ); - editor.execute( 'heading1' ); + editor.execute( 'heading', { value: 'heading1' } ); expect( getModelData( model ) ).to.equal( 'foo[]bar' ); expect( editor.commands.get( 'undo' ).isEnabled ).to.be.false; @@ -103,7 +103,7 @@ describe( 'Heading integration', () => { it( 'does not create undo steps when applied to an existing heading (non–collapsed selection)', () => { setModelData( model, '[foobar]' ); - editor.execute( 'heading1' ); + editor.execute( 'heading', { value: 'heading1' } ); expect( getModelData( model ) ).to.equal( '[foobar]' ); expect( editor.commands.get( 'undo' ).isEnabled ).to.be.false; diff --git a/tests/manual/heading.js b/tests/manual/heading.js index 03da4f6..dc088c1 100644 --- a/tests/manual/heading.js +++ b/tests/manual/heading.js @@ -15,7 +15,7 @@ import Undo from '@ckeditor/ckeditor5-undo/src/undo'; ClassicEditor .create( document.querySelector( '#editor' ), { plugins: [ Enter, Typing, Undo, Heading, Paragraph ], - toolbar: [ 'headings', '|', 'undo', 'redo' ] + toolbar: [ 'heading', '|', 'undo', 'redo' ] } ) .then( editor => { window.editor = editor;