ckeditor/ckeditor5-widget

View on GitHub
src/widgetresize/resizer.js

Summary

Maintainability
A
1 hr
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 widget/widgetresize/resizer
 */

import View from '@ckeditor/ckeditor5-ui/src/view';
import Template from '@ckeditor/ckeditor5-ui/src/template';
import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';

import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin';
import mix from '@ckeditor/ckeditor5-utils/src/mix';

import ResizeState from './resizerstate';

/**
 * Represents a resizer for a single resizable object.
 *
 * @mixes module:utils/observablemixin~ObservableMixin
 */
export default class Resizer {
    /**
     * @param {module:widget/widgetresize~ResizerOptions} options Resizer options.
     */
    constructor( options ) {
        /**
         * Stores the state of the resizable host geometry, such as the original width, the currently proposed height, etc.
         *
         * Note that a new state is created for each resize transaction.
         *
         * @readonly
         * @member {module:widget/widgetresize/resizerstate~ResizerState} #state
         */

        /**
         * A view displaying the proposed new element size during the resizing.
         *
         * @protected
         * @readonly
         * @member {module:widget/widgetresize/resizer~SizeView} #_sizeUI
         */

        /**
         * Options passed to the {@link #constructor}.
         *
         * @private
         * @type {module:widget/widgetresize~ResizerOptions}
         */
        this._options = options;

        /**
         * Container of the entire resize UI.
         *
         * Note that this property is initialized only after the element bound with the resizer is drawn
         * so it will be a `null` when uninitialized.
         *
         * @private
         * @type {HTMLElement|null}
         */
        this._domResizerWrapper = null;

        /**
         * A wrapper that is controlled by the resizer. This is usually a widget element.
         *
         * @private
         * @type {module:engine/view/element~Element|null}
         */
        this._viewResizerWrapper = null;

        /**
         * The width of the resized {@link module:widget/widgetresize~ResizerOptions#viewElement viewElement} before the resizing started.
         *
         * @private
         * @member {Number|String|undefined} #_initialViewWidth
         */

        /**
         * @observable
         */
        this.set( 'isEnabled', true );

        this.decorate( 'begin' );
        this.decorate( 'cancel' );
        this.decorate( 'commit' );
        this.decorate( 'updateSize' );

        this.on( 'commit', event => {
            // State might not be initialized yet. In this case, prevent further handling and make sure that the resizer is
            // cleaned up (#5195).
            if ( !this.state.proposedWidth && !this.state.proposedWidthPercents ) {
                this._cleanup();
                event.stop();
            }
        }, { priority: 'high' } );
    }

    /**
     * Attaches the resizer to the DOM.
     */
    attach() {
        const that = this;
        const widgetElement = this._options.viewElement;
        const editingView = this._options.editor.editing.view;

        editingView.change( writer => {
            const viewResizerWrapper = writer.createUIElement( 'div', {
                class: 'ck ck-reset_all ck-widget__resizer'
            }, function( domDocument ) {
                const domElement = this.toDomElement( domDocument );

                that._appendHandles( domElement );
                that._appendSizeUI( domElement );

                that._domResizerWrapper = domElement;

                that.on( 'change:isEnabled', ( evt, propName, newValue ) => {
                    domElement.style.display = newValue ? '' : 'none';
                } );

                domElement.style.display = that.isEnabled ? '' : 'none';

                return domElement;
            } );

            // Append the resizer wrapper to the widget's wrapper.
            writer.insert( writer.createPositionAt( widgetElement, 'end' ), viewResizerWrapper );
            writer.addClass( 'ck-widget_with-resizer', widgetElement );

            this._viewResizerWrapper = viewResizerWrapper;
        } );
    }

    /**
     * Starts the resizing process.
     *
     * Creates a new {@link #state} for the current process.
     *
     * @fires begin
     * @param {HTMLElement} domResizeHandle Clicked handle.
     */
    begin( domResizeHandle ) {
        this.state = new ResizeState( this._options );

        this._sizeUI.bindToState( this._options, this.state );

        this._initialViewWidth = this._options.viewElement.getStyle( 'width' );

        this.state.begin( domResizeHandle, this._getHandleHost(), this._getResizeHost() );
    }

    /**
     * Updates the proposed size based on `domEventData`.
     *
     * @fires updateSize
     * @param {Event} domEventData
     */
    updateSize( domEventData ) {
        const newSize = this._proposeNewSize( domEventData );
        const editingView = this._options.editor.editing.view;

        editingView.change( writer => {
            const unit = this._options.unit || '%';
            const newWidth = ( unit === '%' ? newSize.widthPercents : newSize.width ) + unit;

            writer.setStyle( 'width', newWidth, this._options.viewElement );
        } );

        // Get an actual image width, and:
        // * reflect this size to the resize wrapper
        // * apply this **real** size to the state
        const domHandleHost = this._getHandleHost();
        const domHandleHostRect = new Rect( domHandleHost );

        newSize.handleHostWidth = Math.round( domHandleHostRect.width );
        newSize.handleHostHeight = Math.round( domHandleHostRect.height );

        // Handle max-width limitation.
        const domResizeHostRect = new Rect( domHandleHost );

        newSize.width = Math.round( domResizeHostRect.width );
        newSize.height = Math.round( domResizeHostRect.height );

        this.redraw( domHandleHostRect );

        this.state.update( newSize );
    }

    /**
     * Applies the geometry proposed with the resizer.
     *
     * @fires commit
     */
    commit() {
        const unit = this._options.unit || '%';
        const newValue = ( unit === '%' ? this.state.proposedWidthPercents : this.state.proposedWidth ) + unit;

        // Both cleanup and onCommit callback are very likely to make view changes. Ensure that it is made in a single step.
        this._options.editor.editing.view.change( () => {
            this._cleanup();
            this._options.onCommit( newValue );
        } );
    }

    /**
     * Cancels and rejects the proposed resize dimensions, hiding the UI.
     *
     * @fires cancel
     */
    cancel() {
        this._cleanup();
    }

    /**
     * Destroys the resizer.
     */
    destroy() {
        this.cancel();
    }

    /**
     * Redraws the resizer.
     *
     * @param {module:utils/dom/rect~Rect} [handleHostRect] Handle host rectangle might be given to improve performance.
     */
    redraw( handleHostRect ) {
        const domWrapper = this._domResizerWrapper;

        if ( existsInDom( domWrapper ) ) {
            this._options.editor.editing.view.change( writer => {
                // Refresh only if resizer exists in the DOM.
                const widgetWrapper = domWrapper.parentElement;
                const handleHost = this._getHandleHost();
                const clientRect = handleHostRect || new Rect( handleHost );

                writer.setStyle( 'width', clientRect.width + 'px', this._viewResizerWrapper );
                writer.setStyle( 'height', clientRect.height + 'px', this._viewResizerWrapper );

                const offsets = {
                    left: handleHost.offsetLeft,
                    top: handleHost.offsetTop,
                    height: handleHost.offsetHeight,
                    width: handleHost.offsetWidth
                };

                // In case a resizing host is not a widget wrapper, we need to compensate
                // for any additional offsets the resize host might have. E.g. wrapper padding
                // or simply another editable. By doing that the border and resizers are shown
                // only around the resize host.
                if ( !widgetWrapper.isSameNode( handleHost ) ) {
                    writer.setStyle( 'left', offsets.left + 'px', this._viewResizerWrapper );
                    writer.setStyle( 'top', offsets.top + 'px', this._viewResizerWrapper );

                    writer.setStyle( 'height', offsets.height + 'px', this._viewResizerWrapper );
                    writer.setStyle( 'width', offsets.width + 'px', this._viewResizerWrapper );
                }
            } );
        }

        function existsInDom( element ) {
            return element && element.ownerDocument && element.ownerDocument.contains( element );
        }
    }

    containsHandle( domElement ) {
        return this._domResizerWrapper.contains( domElement );
    }

    static isResizeHandle( domElement ) {
        return domElement.classList.contains( 'ck-widget__resizer__handle' );
    }

    /**
     * Cleans up the context state.
     *
     * @protected
     */
    _cleanup() {
        this._sizeUI.dismiss();
        this._sizeUI.isVisible = false;

        const editingView = this._options.editor.editing.view;

        editingView.change( writer => {
            writer.setStyle( 'width', this._initialViewWidth, this._options.viewElement );
        } );
    }

    /**
     * Calculates the proposed size as the resize handles are dragged.
     *
     * @private
     * @param {Event} domEventData Event data that caused the size update request. It should be used to calculate the proposed size.
     * @returns {Object} return
     * @returns {Number} return.width Proposed width.
     * @returns {Number} return.height Proposed height.
     */
    _proposeNewSize( domEventData ) {
        const state = this.state;
        const currentCoordinates = extractCoordinates( domEventData );
        const isCentered = this._options.isCentered ? this._options.isCentered( this ) : true;

        // Enlargement defines how much the resize host has changed in a given axis. Naturally it could be a negative number
        // meaning that it has been shrunk.
        //
        // +----------------+--+
        // |                |  |
        // |       img      |  |
        // |  /handle host  |  |
        // +----------------+  | ^
        // |                   | | - enlarge y
        // +-------------------+ v
        //                     <-->
        //                      enlarge x
        const enlargement = {
            x: state._referenceCoordinates.x - ( currentCoordinates.x + state.originalWidth ),
            y: ( currentCoordinates.y - state.originalHeight ) - state._referenceCoordinates.y
        };

        if ( isCentered && state.activeHandlePosition.endsWith( '-right' ) ) {
            enlargement.x = currentCoordinates.x - ( state._referenceCoordinates.x + state.originalWidth );
        }

        // Objects needs to be resized twice as much in horizontal axis if centered, since enlargement is counted from
        // one resized corner to your cursor. It needs to be duplicated to compensate for the other side too.
        if ( isCentered ) {
            enlargement.x *= 2;
        }

        // const resizeHost = this._getResizeHost();

        // The size proposed by the user. It does not consider the aspect ratio.
        const proposedSize = {
            width: Math.abs( state.originalWidth + enlargement.x ),
            height: Math.abs( state.originalHeight + enlargement.y )
        };

        // Dominant determination must take the ratio into account.
        proposedSize.dominant = proposedSize.width / state.aspectRatio > proposedSize.height ? 'width' : 'height';
        proposedSize.max = proposedSize[ proposedSize.dominant ];

        // Proposed size, respecting the aspect ratio.
        const targetSize = {
            width: proposedSize.width,
            height: proposedSize.height
        };

        if ( proposedSize.dominant == 'width' ) {
            targetSize.height = targetSize.width / state.aspectRatio;
        } else {
            targetSize.width = targetSize.height * state.aspectRatio;
        }

        return {
            width: Math.round( targetSize.width ),
            height: Math.round( targetSize.height ),
            widthPercents: Math.min( Math.round( state.originalWidthPercents / state.originalWidth * targetSize.width * 100 ) / 100, 100 )
        };
    }

    /**
     * Obtains the resize host.
     *
     * Resize host is an object that receives dimensions which are the result of resizing.
     *
     * @protected
     * @returns {HTMLElement}
     */
    _getResizeHost() {
        const widgetWrapper = this._domResizerWrapper.parentElement;

        return this._options.getResizeHost( widgetWrapper );
    }

    /**
     * Obtains the handle host.
     *
     * Handle host is an object that the handles are aligned to.
     *
     * Handle host will not always be an entire widget itself. Take an image as an example. The image widget
     * contains an image and a caption. Only the image should be surrounded with handles.
     *
     * @protected
     * @returns {HTMLElement}
     */
    _getHandleHost() {
        const widgetWrapper = this._domResizerWrapper.parentElement;

        return this._options.getHandleHost( widgetWrapper );
    }

    /**
     * Renders the resize handles in the DOM.
     *
     * @private
     * @param {HTMLElement} domElement The resizer wrapper.
     */
    _appendHandles( domElement ) {
        const resizerPositions = [ 'top-left', 'top-right', 'bottom-right', 'bottom-left' ];

        for ( const currentPosition of resizerPositions ) {
            domElement.appendChild( ( new Template( {
                tag: 'div',
                attributes: {
                    class: `ck-widget__resizer__handle ${ getResizerClass( currentPosition ) }`
                }
            } ).render() ) );
        }
    }

    /**
     * Sets up the {@link #_sizeUI} property and adds it to the passed `domElement`.
     *
     * @private
     * @param {HTMLElement} domElement
     */
    _appendSizeUI( domElement ) {
        const sizeUI = new SizeView();

        // Make sure icon#element is rendered before passing to appendChild().
        sizeUI.render();

        this._sizeUI = sizeUI;

        domElement.appendChild( sizeUI.element );
    }

    /**
     * @event begin
     */

    /**
     * @event updateSize
     */

    /**
     * @event commit
     */

    /**
     * @event cancel
     */
}

mix( Resizer, ObservableMixin );

/**
 * A view displaying the proposed new element size during the resizing.
 *
 * @extends {module:ui/view~View}
 */
class SizeView extends View {
    constructor() {
        super();

        const bind = this.bindTemplate;

        this.setTemplate( {
            tag: 'div',
            attributes: {
                class: [
                    'ck',
                    'ck-size-view',
                    bind.to( 'activeHandlePosition', value => value ? `ck-orientation-${ value }` : '' )
                ],
                style: {
                    display: bind.if( 'isVisible', 'none', visible => !visible )
                }
            },
            children: [ {
                text: bind.to( 'label' )
            } ]
        } );
    }

    bindToState( options, resizerState ) {
        this.bind( 'isVisible' ).to( resizerState, 'proposedWidth', resizerState, 'proposedHeight', ( width, height ) =>
            width !== null && height !== null );

        this.bind( 'label' ).to(
            resizerState, 'proposedHandleHostWidth',
            resizerState, 'proposedHandleHostHeight',
            resizerState, 'proposedWidthPercents',
            ( width, height, widthPercents ) => {
                if ( options.unit === 'px' ) {
                    return `${ width }×${ height }`;
                } else {
                    return `${ widthPercents }%`;
                }
            }
        );

        this.bind( 'activeHandlePosition' ).to( resizerState );
    }

    dismiss() {
        this.unbind();
        this.isVisible = false;
    }
}

// @private
// @param {String} resizerPosition Expected resizer position like `"top-left"`, `"bottom-right"`.
// @returns {String} A prefixed HTML class name for the resizer element
function getResizerClass( resizerPosition ) {
    return `ck-widget__resizer__handle-${ resizerPosition }`;
}

function extractCoordinates( event ) {
    return {
        x: event.pageX,
        y: event.pageY
    };
}