ckeditor/ckeditor5-engine

View on GitHub
src/view/placeholder.js

Summary

Maintainability
A
35 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 engine/view/placeholder
 */

import '../../theme/placeholder.css';

// Each document stores information about its placeholder elements and check functions.
const documentPlaceholders = new WeakMap();

/**
 * A helper that enables a placeholder on the provided view element (also updates its visibility).
 * The placeholder is a CSS pseudo–element (with a text content) attached to the element.
 *
 * To change the placeholder text, simply call this method again with new options.
 *
 * To disable the placeholder, use {@link module:engine/view/placeholder~disablePlaceholder `disablePlaceholder()`} helper.
 *
 * @param {Object} [options] Configuration options of the placeholder.
 * @param {module:engine/view/view~View} options.view Editing view instance.
 * @param {module:engine/view/element~Element} options.element Element that will gain a placeholder.
 * See `options.isDirectHost` to learn more.
 * @param {String} options.text Placeholder text.
 * @param {Boolean} [options.isDirectHost=true] If set `false`, the placeholder will not be enabled directly
 * in the passed `element` but in one of its children (selected automatically, i.e. a first empty child element).
 * Useful when attaching placeholders to elements that can host other elements (not just text), for instance,
 * editable root elements.
 */
export function enablePlaceholder( options ) {
    const { view, element, text, isDirectHost = true } = options;
    const doc = view.document;

    // Use a single a single post fixer per—document to update all placeholders.
    if ( !documentPlaceholders.has( doc ) ) {
        documentPlaceholders.set( doc, new Map() );

        // If a post-fixer callback makes a change, it should return `true` so other post–fixers
        // can re–evaluate the document again.
        doc.registerPostFixer( writer => updateDocumentPlaceholders( doc, writer ) );
    }

    // Store information about the element placeholder under its document.
    documentPlaceholders.get( doc ).set( element, {
        text,
        isDirectHost
    } );

    // Update the placeholders right away.
    view.change( writer => updateDocumentPlaceholders( doc, writer ) );
}

/**
 * Disables the placeholder functionality from a given element.
 *
 * See {@link module:engine/view/placeholder~enablePlaceholder `enablePlaceholder()`} to learn more.
 *
 * @param {module:engine/view/view~View} view
 * @param {module:engine/view/element~Element} element
 */
export function disablePlaceholder( view, element ) {
    const doc = element.document;

    view.change( writer => {
        if ( !documentPlaceholders.has( doc ) ) {
            return;
        }

        const placeholders = documentPlaceholders.get( doc );
        const config = placeholders.get( element );

        writer.removeAttribute( 'data-placeholder', config.hostElement );
        hidePlaceholder( writer, config.hostElement );

        placeholders.delete( element );
    } );
}

/**
 * Shows a placeholder in the provided element by changing related attributes and CSS classes.
 *
 * **Note**: This helper will not update the placeholder visibility nor manage the
 * it in any way in the future. What it does is a one–time state change of an element. Use
 * {@link module:engine/view/placeholder~enablePlaceholder `enablePlaceholder()`} and
 * {@link module:engine/view/placeholder~disablePlaceholder `disablePlaceholder()`} for full
 * placeholder functionality.
 *
 * **Note**: This helper will blindly show the placeholder directly in the root editable element if
 * one is passed, which could result in a visual clash if the editable element has some children
 * (for instance, an empty paragraph). Use {@link module:engine/view/placeholder~enablePlaceholder `enablePlaceholder()`}
 * in that case or make sure the correct element is passed to the helper.
 *
 * @param {module:engine/view/downcastwriter~DowncastWriter} writer
 * @param {module:engine/view/element~Element} element
 * @returns {Boolean} `true`, if any changes were made to the `element`.
 */
export function showPlaceholder( writer, element ) {
    if ( !element.hasClass( 'ck-placeholder' ) ) {
        writer.addClass( 'ck-placeholder', element );

        return true;
    }

    return false;
}

/**
 * Hides a placeholder in the element by changing related attributes and CSS classes.
 *
 * **Note**: This helper will not update the placeholder visibility nor manage the
 * it in any way in the future. What it does is a one–time state change of an element. Use
 * {@link module:engine/view/placeholder~enablePlaceholder `enablePlaceholder()`} and
 * {@link module:engine/view/placeholder~disablePlaceholder `disablePlaceholder()`} for full
 * placeholder functionality.
 *
 * @param {module:engine/view/downcastwriter~DowncastWriter} writer
 * @param {module:engine/view/element~Element} element
 * @returns {Boolean} `true`, if any changes were made to the `element`.
 */
export function hidePlaceholder( writer, element ) {
    if ( element.hasClass( 'ck-placeholder' ) ) {
        writer.removeClass( 'ck-placeholder', element );

        return true;
    }

    return false;
}

/**
 * Checks if a placeholder should be displayed in the element.
 *
 * **Note**: This helper will blindly check the possibility of showing a placeholder directly in the
 * root editable element if one is passed, which may not be the expected result. If an element can
 * host other elements (not just text), most likely one of its children should be checked instead
 * because it will be the final host for the placeholder. Use
 * {@link module:engine/view/placeholder~enablePlaceholder `enablePlaceholder()`} in that case or make
 * sure the correct element is passed to the helper.
 *
 * @param {module:engine/view/element~Element} element
 * @returns {Boolean}
 */
export function needsPlaceholder( element ) {
    if ( !element.isAttached() ) {
        return false;
    }

    // The element is empty only as long as it contains nothing but uiElements.
    const isEmptyish = !Array.from( element.getChildren() )
        .some( element => !element.is( 'uiElement' ) );

    const doc = element.document;

    // If the element is empty and the document is blurred.
    if ( !doc.isFocused && isEmptyish ) {
        return true;
    }

    const viewSelection = doc.selection;
    const selectionAnchor = viewSelection.anchor;

    // If document is focused and the element is empty but the selection is not anchored inside it.
    if ( isEmptyish && selectionAnchor && selectionAnchor.parent !== element ) {
        return true;
    }

    return false;
}

// Updates all placeholders associated with a document in a post–fixer callback.
//
// @private
// @param { module:engine/view/document~Document} doc
// @param {module:engine/view/downcastwriter~DowncastWriter} writer
// @returns {Boolean} True if any changes were made to the view document.
function updateDocumentPlaceholders( doc, writer ) {
    const placeholders = documentPlaceholders.get( doc );
    let wasViewModified = false;

    for ( const [ element, config ] of placeholders ) {
        if ( updatePlaceholder( writer, element, config ) ) {
            wasViewModified = true;
        }
    }

    return wasViewModified;
}

// Updates a single placeholder in a post–fixer callback.
//
// @private
// @param {module:engine/view/downcastwriter~DowncastWriter} writer
// @param {module:engine/view/element~Element} element
// @param {Object} config Configuration of the placeholder
// @param {String} config.text
// @param {Boolean} config.isDirectHost
// @returns {Boolean} True if any changes were made to the view document.
function updatePlaceholder( writer, element, config ) {
    const { text, isDirectHost } = config;

    const hostElement = isDirectHost ? element : getChildPlaceholderHostSubstitute( element );
    let wasViewModified = false;

    // When not a direct host, it could happen that there is no child element
    // capable of displaying a placeholder.
    if ( !hostElement ) {
        return false;
    }

    // Cache the host element. It will be necessary for disablePlaceholder() to know
    // which element should have class and attribute removed because, depending on
    // the config.isDirectHost value, it could be the element or one of its descendants.
    config.hostElement = hostElement;

    // This may be necessary when updating the placeholder text to something else.
    if ( hostElement.getAttribute( 'data-placeholder' ) !== text ) {
        writer.setAttribute( 'data-placeholder', text, hostElement );
        wasViewModified = true;
    }

    if ( needsPlaceholder( hostElement ) ) {
        if ( showPlaceholder( writer, hostElement ) ) {
            wasViewModified = true;
        }
    } else if ( hidePlaceholder( writer, hostElement ) ) {
        wasViewModified = true;
    }

    return wasViewModified;
}

// Gets a child element capable of displaying a placeholder if a parent element can host more
// than just text (for instance, when it is a root editable element). The child element
// can then be used in other placeholder helpers as a substitute of its parent.
//
// @private
// @param {module:engine/view/element~Element} parent
// @returns {module:engine/view/element~Element|null}
function getChildPlaceholderHostSubstitute( parent ) {
    if ( parent.childCount === 1 ) {
        const firstChild = parent.getChild( 0 );

        if ( firstChild.is( 'element' ) && !firstChild.is( 'uiElement' ) ) {
            return firstChild;
        }
    }

    return null;
}