ckeditor/ckeditor5-list

View on GitHub
src/todolistediting.js

Summary

Maintainability
A
2 hrs
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 list/todolistediting
 */

import ListCommand from './listcommand';
import ListEditing from './listediting';
import TodoListCheckCommand from './todolistcheckcommand';

import Plugin from '@ckeditor/ckeditor5-core/src/plugin';

import {
    dataModelViewInsertion,
    dataModelViewTextInsertion,
    dataViewModelCheckmarkInsertion,
    mapModelToViewZeroOffsetPosition,
    modelViewChangeChecked,
    modelViewChangeType,
    modelViewInsertion
} from './todolistconverters';

/**
 * The engine of the to-do list feature. It handles creating, editing and removing to-do lists and their items.
 *
 * It registers the entire functionality of the {@link module:list/listediting~ListEditing list editing plugin} and extends
 * it with the commands:
 *
 * - `'todoList'`,
 * - `'todoListCheck'`.
 *
 * @extends module:core/plugin~Plugin
 */
export default class TodoListEditing extends Plugin {
    /**
     * @inheritDoc
     */
    static get pluginName() {
        return 'TodoListEditing';
    }

    /**
     * @inheritDoc
     */
    static get requires() {
        return [ ListEditing ];
    }

    /**
     * @inheritDoc
     */
    init() {
        const editor = this.editor;
        const { editing, data, model } = editor;

        // Extend schema.
        model.schema.extend( 'listItem', {
            allowAttributes: [ 'todoListChecked' ]
        } );

        // Disallow todoListChecked attribute on other nodes than listItem with to-do listType.
        model.schema.addAttributeCheck( ( context, attributeName ) => {
            const item = context.last;

            if ( attributeName == 'todoListChecked' && item.name == 'listItem' && item.getAttribute( 'listType' ) != 'todo' ) {
                return false;
            }
        } );

        // Register commands.
        editor.commands.add( 'todoList', new ListCommand( editor, 'todo' ) );
        editor.commands.add( 'todoListCheck', new TodoListCheckCommand( editor ) );

        // Define converters.
        data.downcastDispatcher.on( 'insert:listItem', dataModelViewInsertion( model ), { priority: 'high' } );
        data.downcastDispatcher.on( 'insert:$text', dataModelViewTextInsertion, { priority: 'high' } );

        editing.downcastDispatcher.on(
            'insert:listItem',
            modelViewInsertion( model, listItem => this._handleCheckmarkChange( listItem ) ),
            { priority: 'high' }
        );
        editing.downcastDispatcher.on(
            'attribute:listType:listItem',
            modelViewChangeType( listItem => this._handleCheckmarkChange( listItem ), editing.view )
        );
        editing.downcastDispatcher.on(
            'attribute:todoListChecked:listItem',
            modelViewChangeChecked( listItem => this._handleCheckmarkChange( listItem ) )
        );

        editing.mapper.on( 'modelToViewPosition', mapModelToViewZeroOffsetPosition( editing.view, editing.mapper ) );

        data.upcastDispatcher.on( 'element:input', dataViewModelCheckmarkInsertion, { priority: 'high' } );

        // Jump at the end of the previous node on left arrow key press, when selection is after the checkbox.
        //
        // <blockquote><p>Foo</p></blockquote>
        // <ul><li><checkbox/>{}Bar</li></ul>
        //
        // press: `<-`
        //
        // <blockquote><p>Foo{}</p></blockquote>
        // <ul><li><checkbox/>Bar</li></ul>
        //
        // Note: When content language direction is RTL, the behaviour is mirrored.
        const localizedJumpOverCheckmarkKey = editor.locale.contentLanguageDirection === 'ltr' ? 'arrowleft' : 'arrowright';

        editor.keystrokes.set( localizedJumpOverCheckmarkKey, ( evt, stop ) => jumpOverCheckmarkOnSideArrowKeyPress( stop, model ) );

        // Toggle check state of selected to-do list items on keystroke.
        editor.keystrokes.set( 'Ctrl+space', () => editor.execute( 'todoListCheck' ) );

        // Remove `todoListChecked` attribute when a host element is no longer a to-do list item.
        const listItemsToFix = new Set();

        this.listenTo( model, 'applyOperation', ( evt, args ) => {
            const operation = args[ 0 ];

            if ( operation.type == 'rename' && operation.oldName == 'listItem' ) {
                const item = operation.position.nodeAfter;

                if ( item.hasAttribute( 'todoListChecked' ) ) {
                    listItemsToFix.add( item );
                }
            } else if ( operation.type == 'changeAttribute' && operation.key == 'listType' && operation.oldValue === 'todo' ) {
                for ( const item of operation.range.getItems() ) {
                    if ( item.hasAttribute( 'todoListChecked' ) && item.getAttribute( 'listType' ) !== 'todo' ) {
                        listItemsToFix.add( item );
                    }
                }
            }
        } );

        model.document.registerPostFixer( writer => {
            let hasChanged = false;

            for ( const listItem of listItemsToFix ) {
                writer.removeAttribute( 'todoListChecked', listItem );
                hasChanged = true;
            }

            listItemsToFix.clear();

            return hasChanged;
        } );
    }

    /**
     * Handles the checkbox element change, moves the selection to the corresponding model item to make it possible
     * to toggle the `todoListChecked` attribute using the command, and restores the selection position.
     *
     * Some say it's a hack :) Moving the selection only for executing the command on a certain node and restoring it after,
     * is not a clear solution. We need to design an API for using commands beyond the selection range.
     * See https://github.com/ckeditor/ckeditor5/issues/1954.
     *
     * @private
     * @param {module:engine/model/element~Element} listItem
     */
    _handleCheckmarkChange( listItem ) {
        const editor = this.editor;
        const model = editor.model;
        const previousSelectionRanges = Array.from( model.document.selection.getRanges() );

        model.change( writer => {
            writer.setSelection( listItem, 'end' );
            editor.execute( 'todoListCheck' );
            writer.setSelection( previousSelectionRanges );
        } );
    }
}

// Handles the left/right (LTR/RTL content) arrow key and moves the selection at the end of the previous block element
// if the selection is just after the checkbox element. In other words, it jumps over the checkbox element when
// moving the selection to the left/right (LTR/RTL).
//
// @private
// @param {Function} stopKeyEvent
// @param {module:engine/model/model~Model} model
function jumpOverCheckmarkOnSideArrowKeyPress( stopKeyEvent, model ) {
    const schema = model.schema;
    const selection = model.document.selection;

    if ( !selection.isCollapsed ) {
        return;
    }

    const position = selection.getFirstPosition();
    const parent = position.parent;

    if ( parent.name === 'listItem' && parent.getAttribute( 'listType' ) == 'todo' && position.isAtStart ) {
        const newRange = schema.getNearestSelectionRange( model.createPositionBefore( parent ), 'backward' );

        if ( newRange ) {
            stopKeyEvent();
            model.change( writer => writer.setSelection( newRange ) );
        }
    }
}