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 #57 from ckeditor/t/33
Browse files Browse the repository at this point in the history
Feature: Enabled configuration and localization of available headings (see `config.heading.options`). Closes #33.

BREAKING CHANGES: The `heading` command now accepts `id` option, not `formatId`.
  • Loading branch information
Reinmar authored Mar 1, 2017
2 parents be20797 + f8a04a3 commit de07a0c
Show file tree
Hide file tree
Showing 6 changed files with 286 additions and 112 deletions.
16 changes: 7 additions & 9 deletions src/heading.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,35 +36,33 @@ export default class Heading extends Plugin {
init() {
const editor = this.editor;
const command = editor.commands.get( 'heading' );
const formats = command.formats;
const options = command.options;
const collection = new Collection();

// Add formats to collection.
for ( let format of formats ) {
// Add options to collection.
for ( const { id, label } of options ) {
collection.add( new Model( {
formatId: format.id,
label: format.label
id, label
} ) );
}

// Create dropdown model.
const dropdownModel = new Model( {
label: 'Heading',
withText: true,
items: collection
} );

// Bind dropdown model to command.
dropdownModel.bind( 'isEnabled' ).to( command, 'isEnabled' );
dropdownModel.bind( 'label' ).to( command, 'value', format => format.label );
dropdownModel.bind( 'label' ).to( command, 'value', option => option.label );

// 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: { formatId } } ) => {
editor.execute( 'heading', { formatId } );
this.listenTo( dropdown, 'execute', ( { source: { id } } ) => {
editor.execute( 'heading', { id } );
editor.editing.view.focus();
} );

Expand Down
79 changes: 44 additions & 35 deletions src/headingcommand.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,68 +20,77 @@ 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~HeadingFormat>} formats Heading formats to be used by the command instance.
* @param {Array.<module:heading/headingcommand~HeadingOption>} options Heading options to be used by the command instance.
*/
constructor( editor, formats ) {
constructor( editor, options, defaultOptionId ) {
super( editor );

/**
* Heading formats used by this command.
* Heading options used by this command.
*
* @readonly
* @member {module:heading/headingcommand~HeadingFormat}
* @member {module:heading/headingcommand~HeadingOption}
*/
this.formats = formats;
this.options = options;

/**
* The currently selected heading format.
* The id of the default option among {@link #options}.
*
* @readonly
* @private
* @member {module:heading/headingcommand~HeadingOption#id}
*/
this._defaultOptionId = defaultOptionId;

/**
* The currently selected heading option.
*
* @readonly
* @observable
* @member {module:heading/headingcommand~HeadingFormat} #value
* @member {module:heading/headingcommand~HeadingOption} #value
*/
this.set( 'value', this.defaultFormat );
this.set( 'value', this.defaultOption );

// Update current value each time changes are done on document.
this.listenTo( editor.document, 'changesDone', () => this._updateValue() );
}

/**
* The default format.
* The default option.
*
* @member {module:heading/headingcommand~HeadingFormat} #defaultFormat
* @member {module:heading/headingcommand~HeadingOption} #defaultOption
*/
get defaultFormat() {
get defaultOption() {
// See https://github.com/ckeditor/ckeditor5/issues/98.
return this._getFormatById( 'paragraph' );
return this._getOptionById( this._defaultOptionId );
}

/**
* Executes command.
*
* @protected
* @param {Object} [options] Options for executed command.
* @param {String} [options.formatId] The identifier of the heading format that should be applied. It should be one of the
* {@link module:heading/headingcommand~HeadingFormat heading formats} provided to the command constructor. If this parameter is not
* @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 #defaultFormat defaultFormat} will be used.
* 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 format is not found?
const formatId = options.formatId || this.defaultFormat.id;
// 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 format is same as new format - toggle already applied format back to default one.
const shouldRemove = ( formatId === this.value.id );
// If current option is same as new option - toggle already applied option back to default one.
const shouldRemove = ( id === this.value.id );

// Collect elements to change format.
// 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 );
Expand All @@ -107,15 +116,15 @@ export default class HeadingCommand extends Command {
const batch = options.batch || doc.batch();

for ( let element of elements ) {
// When removing applied format.
// When removing applied option.
if ( shouldRemove ) {
if ( element.name === formatId ) {
batch.rename( element, this.defaultFormat.id );
if ( element.name === id ) {
batch.rename( element, this.defaultOption.id );
}
}
// When applying new format.
// When applying new option.
else {
batch.rename( element, formatId );
batch.rename( element, id );
}
}

Expand All @@ -126,14 +135,14 @@ export default class HeadingCommand extends Command {
}

/**
* Returns the format by a given ID.
* Returns the option by a given ID.
*
* @private
* @param {String} id
* @returns {module:heading/headingcommand~HeadingFormat}
* @returns {module:heading/headingcommand~HeadingOption}
*/
_getFormatById( id ) {
return this.formats.find( item => item.id === id ) || this.defaultFormat;
_getOptionById( id ) {
return this.options.find( item => item.id === id ) || this.defaultOption;
}

/**
Expand All @@ -146,7 +155,7 @@ export default class HeadingCommand extends Command {
const block = findTopmostBlock( position );

if ( block ) {
this.value = this._getFormatById( block.name );
this.value = this._getOptionById( block.name );
}
}
}
Expand Down Expand Up @@ -177,10 +186,10 @@ function findTopmostBlock( position, nodeAfter = true ) {
}

/**
* Heading format descriptor.
* Heading option descriptor.
*
* @typedef {Object} module:heading/headingcommand~HeadingFormat
* @property {String} id Format identifier. It will be used as the 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} label The display name of the format.
* @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.
*/
96 changes: 78 additions & 18 deletions src/headingengine.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,7 @@ import buildViewConverter from '@ckeditor/ckeditor5-engine/src/conversion/buildv
import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
import HeadingCommand from './headingcommand';

const formats = [
{ id: 'paragraph', viewElement: 'p', label: 'Paragraph' },
{ id: 'heading1', viewElement: 'h2', label: 'Heading 1' },
{ id: 'heading2', viewElement: 'h3', label: 'Heading 2' },
{ id: 'heading3', viewElement: 'h4', label: 'Heading 3' }
];
const defaultOptionId = 'paragraph';

/**
* The headings engine feature. It handles switching between block formats &ndash; headings and paragraph.
Expand All @@ -27,6 +22,22 @@ const formats = [
* @extends modules:core/plugin~Plugin
*/
export default class HeadingEngine extends Plugin {
/**
* @inheritDoc
*/
constructor( editor ) {
super( editor );

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' }
]
} );
}

/**
* @inheritDoc
*/
Expand All @@ -41,27 +52,28 @@ export default class HeadingEngine extends Plugin {
const editor = this.editor;
const data = editor.data;
const editing = editor.editing;
const options = this._getLocalizedOptions();

for ( let format of formats ) {
for ( let option of options ) {
// Skip paragraph - it is defined in required Paragraph feature.
if ( format.id !== 'paragraph' ) {
if ( option.id !== defaultOptionId ) {
// Schema.
editor.document.schema.registerItem( format.id, '$block' );
editor.document.schema.registerItem( option.id, '$block' );

// Build converter from model to view for data and editing pipelines.
buildModelConverter().for( data.modelToView, editing.modelToView )
.fromElement( format.id )
.toElement( format.viewElement );
.fromElement( option.id )
.toElement( option.element );

// Build converter from view to model for data pipeline.
buildViewConverter().for( data.viewToModel )
.fromElement( format.viewElement )
.toElement( format.id );
.fromElement( option.element )
.toElement( option.id );
}
}

// Register the heading command.
const command = new HeadingCommand( editor, formats );
const command = new HeadingCommand( editor, options, defaultOptionId );
editor.commands.set( 'heading', command );
}

Expand All @@ -71,21 +83,69 @@ export default class HeadingEngine extends Plugin {
afterInit() {
// 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();

if ( enterCommand ) {
this.listenTo( enterCommand, 'afterExecute', ( evt, data ) => {
const positionParent = editor.document.selection.getFirstPosition().parent;
const batch = data.batch;
const isHeading = formats.some( ( format ) => format.id == positionParent.name );
const isHeading = options.some( option => option.id == positionParent.name );

if ( isHeading && positionParent.name != command.defaultFormat.id && positionParent.childCount === 0 ) {
batch.rename( positionParent, command.defaultFormat.id );
if ( isHeading && positionParent.name != command.defaultOption.id && positionParent.childCount === 0 ) {
batch.rename( positionParent, command.defaultOption.id );
}
} );
}
}

/**
* 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.<module:heading/headingcommand~HeadingOption>}.
*/
_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.<module:heading/headingcommand~HeadingOption>} #_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;
}
}
Loading

0 comments on commit de07a0c

Please sign in to comment.