Skip to content
This repository has been archived by the owner on Jun 26, 2020. It is now read-only.

Commit

Permalink
Merge pull request #58 from ckeditor/t/53a
Browse files Browse the repository at this point in the history
Feature: Split "heading" command into independent commands. Closes #53. Closes #56. Closes #52.

BREAKING CHANGE: The "heading" command is no longer available. Replaced by "heading1", "heading2", "heading3" and "paragraph".
BREAKING CHANGE: `Heading` plugin requires `Paragraph` to work properly (`ParagraphCommand` registered as "paragraph" in `editor.commands`). 
BREAKING CHANGE: `config.heading.options` format has changed. The valid `HeadingOption` syntax is now `{ modelElement: 'heading1', viewElement: 'h1', title: 'Heading 1' }`.
  • Loading branch information
szymonkups authored Mar 13, 2017
2 parents ace86b0 + 65ac56c commit 7a8f6f0
Show file tree
Hide file tree
Showing 6 changed files with 332 additions and 305 deletions.
102 changes: 84 additions & 18 deletions src/heading.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -27,46 +25,114 @@ export default class Heading extends Plugin {
* @inheritDoc
*/
static get requires() {
return [ HeadingEngine ];
return [ Paragraph, HeadingEngine ];
}

/**
* @inheritDoc
*/
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.<module:heading/headingcommand~HeadingOption>}.
*/
_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.<module:core/command/command~Command>} commands
// @param {String} attribute
// @returns {Array.<String>}
function getCommandsBindingTargets( commands, attribute ) {
return Array.prototype.concat( ...commands.map( c => [ c, attribute ] ) );
}
164 changes: 49 additions & 115 deletions src/headingcommand.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -20,176 +21,109 @@ export default class HeadingCommand extends Command {
* Creates an instance of the command.
*
* @param {module:core/editor/editor~Editor} editor Editor instance.
* @param {Array.<module:heading/headingcommand~HeadingOption>} 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
*/
}

/**
* Executes 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 &mdash; 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.
*/
Loading

0 comments on commit 7a8f6f0

Please sign in to comment.