ckeditor/ckeditor5-block-quote

View on GitHub
src/blockquotecommand.js

Summary

Maintainability
A
25 mins
Test Coverage
/**
 * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
 * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
 */

/**
 * @module block-quote/blockquotecommand
 */

import Command from '@ckeditor/ckeditor5-core/src/command';

import first from '@ckeditor/ckeditor5-utils/src/first';

/**
 * The block quote command plugin.
 *
 * @extends module:core/command~Command
 */
export default class BlockQuoteCommand extends Command {
    /**
     * Whether the selection starts in a block quote.
     *
     * @observable
     * @readonly
     * @member {Boolean} #value
     */

    /**
     * @inheritDoc
     */
    refresh() {
        this.value = this._getValue();
        this.isEnabled = this._checkEnabled();
    }

    /**
     * Executes the command. When the command {@link #value is on}, all top-most block quotes within
     * the selection will be removed. If it is off, all selected blocks will be wrapped with
     * a block quote.
     *
     * @fires execute
     * @param {Object} [options] Command options.
     * @param {Boolean} [options.forceValue] If set, it will force the command behavior. If `true`, the command will apply a block quote,
     * otherwise the command will remove the block quote. If not set, the command will act basing on its current value.
     */
    execute( options = {} ) {
        const model = this.editor.model;
        const schema = model.schema;
        const selection = model.document.selection;

        const blocks = Array.from( selection.getSelectedBlocks() );

        const value = ( options.forceValue === undefined ) ? !this.value : options.forceValue;

        model.change( writer => {
            if ( !value ) {
                this._removeQuote( writer, blocks.filter( findQuote ) );
            } else {
                const blocksToQuote = blocks.filter( block => {
                    // Already quoted blocks needs to be considered while quoting too
                    // in order to reuse their <bQ> elements.
                    return findQuote( block ) || checkCanBeQuoted( schema, block );
                } );

                this._applyQuote( writer, blocksToQuote );
            }
        } );
    }

    /**
     * Checks the command's {@link #value}.
     *
     * @private
     * @returns {Boolean} The current value.
     */
    _getValue() {
        const selection = this.editor.model.document.selection;

        const firstBlock = first( selection.getSelectedBlocks() );

        // In the current implementation, the block quote must be an immediate parent of a block element.
        return !!( firstBlock && findQuote( firstBlock ) );
    }

    /**
     * Checks whether the command can be enabled in the current context.
     *
     * @private
     * @returns {Boolean} Whether the command should be enabled.
     */
    _checkEnabled() {
        if ( this.value ) {
            return true;
        }

        const selection = this.editor.model.document.selection;
        const schema = this.editor.model.schema;

        const firstBlock = first( selection.getSelectedBlocks() );

        if ( !firstBlock ) {
            return false;
        }

        return checkCanBeQuoted( schema, firstBlock );
    }

    /**
     * Removes the quote from given blocks.
     *
     * If blocks which are supposed to be "unquoted" are in the middle of a quote,
     * start it or end it, then the quote will be split (if needed) and the blocks
     * will be moved out of it, so other quoted blocks remained quoted.
     *
     * @private
     * @param {module:engine/model/writer~Writer} writer
     * @param {Array.<module:engine/model/element~Element>} blocks
     */
    _removeQuote( writer, blocks ) {
        // Unquote all groups of block. Iterate in the reverse order to not break following ranges.
        getRangesOfBlockGroups( writer, blocks ).reverse().forEach( groupRange => {
            if ( groupRange.start.isAtStart && groupRange.end.isAtEnd ) {
                writer.unwrap( groupRange.start.parent );

                return;
            }

            // The group of blocks are at the beginning of an <bQ> so let's move them left (out of the <bQ>).
            if ( groupRange.start.isAtStart ) {
                const positionBefore = writer.createPositionBefore( groupRange.start.parent );

                writer.move( groupRange, positionBefore );

                return;
            }

            // The blocks are in the middle of an <bQ> so we need to split the <bQ> after the last block
            // so we move the items there.
            if ( !groupRange.end.isAtEnd ) {
                writer.split( groupRange.end );
            }

            // Now we are sure that groupRange.end.isAtEnd is true, so let's move the blocks right.

            const positionAfter = writer.createPositionAfter( groupRange.end.parent );

            writer.move( groupRange, positionAfter );
        } );
    }

    /**
     * Applies the quote to given blocks.
     *
     * @private
     * @param {module:engine/model/writer~Writer} writer
     * @param {Array.<module:engine/model/element~Element>} blocks
     */
    _applyQuote( writer, blocks ) {
        const quotesToMerge = [];

        // Quote all groups of block. Iterate in the reverse order to not break following ranges.
        getRangesOfBlockGroups( writer, blocks ).reverse().forEach( groupRange => {
            let quote = findQuote( groupRange.start );

            if ( !quote ) {
                quote = writer.createElement( 'blockQuote' );

                writer.wrap( groupRange, quote );
            }

            quotesToMerge.push( quote );
        } );

        // Merge subsequent <bQ> elements. Reverse the order again because this time we want to go through
        // the <bQ> elements in the source order (due to how merge works – it moves the right element's content
        // to the first element and removes the right one. Since we may need to merge a couple of subsequent `<bQ>` elements
        // we want to keep the reference to the first (furthest left) one.
        quotesToMerge.reverse().reduce( ( currentQuote, nextQuote ) => {
            if ( currentQuote.nextSibling == nextQuote ) {
                writer.merge( writer.createPositionAfter( currentQuote ) );

                return currentQuote;
            }

            return nextQuote;
        } );
    }
}

function findQuote( elementOrPosition ) {
    return elementOrPosition.parent.name == 'blockQuote' ? elementOrPosition.parent : null;
}

// Returns a minimal array of ranges containing groups of subsequent blocks.
//
// content:         abcdefgh
// blocks:          [ a, b, d, f, g, h ]
// output ranges:   [ab]c[d]e[fgh]
//
// @param {Array.<module:engine/model/element~Element>} blocks
// @returns {Array.<module:engine/model/range~Range>}
function getRangesOfBlockGroups( writer, blocks ) {
    let startPosition;
    let i = 0;
    const ranges = [];

    while ( i < blocks.length ) {
        const block = blocks[ i ];
        const nextBlock = blocks[ i + 1 ];

        if ( !startPosition ) {
            startPosition = writer.createPositionBefore( block );
        }

        if ( !nextBlock || block.nextSibling != nextBlock ) {
            ranges.push( writer.createRange( startPosition, writer.createPositionAfter( block ) ) );
            startPosition = null;
        }

        i++;
    }

    return ranges;
}

// Checks whether <bQ> can wrap the block.
function checkCanBeQuoted( schema, block ) {
    // TMP will be replaced with schema.checkWrap().
    const isBQAllowed = schema.checkChild( block.parent, 'blockQuote' );
    const isBlockAllowedInBQ = schema.checkChild( [ '$root', 'blockQuote' ], block );

    return isBQAllowed && isBlockAllowedInBQ;
}