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

T/53a: Split "heading" command into independent commands. #58

Merged
merged 6 commits into from
Mar 13, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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( {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it have to be a model instance? Can't this be a simple object?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Binding requires observable. We could pass an object here and convert it into observable in createlistdropdown. But this is beyond the scope of this issue as it didn't change this part.

Copy link
Member Author

@oleq oleq Mar 9, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OTOH I'm not sure whether would make more sense. There could be cases when a feature using the dropdown might want to play with models in #items immediately, i.e. bind its attributes to something else.

If we enforce that #items is Collection.<Object>, developers would need to pick those models from the dropdown after createListDropdown was called which is not very convenient.

Besides, what would ListView look like then if #items would be Collection.<Object>? Where to store the "intermediate models" created in createListDropdown()? Because there must be models for Model->ListItemView bindings.

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