ckeditor/ckeditor5-utils

View on GitHub
src/dom/scroll.js

Summary

Maintainability
B
6 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 utils/dom/scroll
 */

import isRange from './isrange';
import Rect from './rect';
import isText from './istext';

const utils = {};

/**
 * Makes any page `HTMLElement` or `Range` (`target`) visible inside the browser viewport.
 * This helper will scroll all `target` ancestors and the web browser viewport to reveal the target to
 * the user. If the `target` is already visible, nothing will happen.
 *
 * @param {HTMLElement|Range} options.target A target, which supposed to become visible to the user.
 * @param {Number} [options.viewportOffset] An offset from the edge of the viewport (in pixels)
 * the `target` will be moved by when the viewport is scrolled. It enhances the user experience
 * by keeping the `target` some distance from the edge of the viewport and thus making it easier to
 * read or edit by the user.
 */
export function scrollViewportToShowTarget( { target, viewportOffset = 0 } ) {
    const targetWindow = getWindow( target );
    let currentWindow = targetWindow;
    let currentFrame = null;

    // Iterate over all windows, starting from target's parent window up to window#top.
    while ( currentWindow ) {
        let firstAncestorToScroll;

        // Let's scroll target's ancestors first to reveal it. Then, once the ancestor scrolls
        // settled down, the algorithm can eventually scroll the viewport of the current window.
        //
        // Note: If the current window is target's **original** window (e.g. the first one),
        // start scrolling the closest parent of the target. If not, scroll the closest parent
        // of an iframe that resides in the current window.
        if ( currentWindow == targetWindow ) {
            firstAncestorToScroll = getParentElement( target );
        } else {
            firstAncestorToScroll = getParentElement( currentFrame );
        }

        // Scroll the target's ancestors first. Once done, scrolling the viewport is easy.
        scrollAncestorsToShowRect( firstAncestorToScroll, () => {
            // Note: If the target does not belong to the current window **directly**,
            // i.e. it resides in an iframe belonging to the window, obtain the target's rect
            // in the coordinates of the current window. By default, a Rect returns geometry
            // relative to the current window's viewport. To make it work in a parent window,
            // it must be shifted.
            return getRectRelativeToWindow( target, currentWindow );
        } );

        // Obtain the rect of the target after it has been scrolled within its ancestors.
        // It's time to scroll the viewport.
        const targetRect = getRectRelativeToWindow( target, currentWindow );

        scrollWindowToShowRect( currentWindow, targetRect, viewportOffset );

        if ( currentWindow.parent != currentWindow ) {
            // Keep the reference to the <iframe> element the "previous current window" was
            // rendered within. It will be useful to re–calculate the rect of the target
            // in the parent window's relative geometry. The target's rect must be shifted
            // by it's iframe's position.
            currentFrame = currentWindow.frameElement;
            currentWindow = currentWindow.parent;

            // If the current window has some parent but frameElement is inaccessible, then they have
            // different domains/ports and, due to security reasons, accessing and scrolling
            // the parent window won't be possible.
            // See https://github.com/ckeditor/ckeditor5/issues/930.
            if ( !currentFrame ) {
                return;
            }
        } else {
            currentWindow = null;
        }
    }
}

/**
 * Makes any page `HTMLElement` or `Range` (target) visible within its scrollable ancestors,
 * e.g. if they have `overflow: scroll` CSS style.
 *
 * @param {HTMLElement|Range} target A target, which supposed to become visible to the user.
 */
export function scrollAncestorsToShowTarget( target ) {
    const targetParent = getParentElement( target );

    scrollAncestorsToShowRect( targetParent, () => {
        return new Rect( target );
    } );
}

// TODO: Using a property value shorthand in the top of the file
// causes JSDoc to throw errors. See https://github.com/cksource/docs-builder/issues/75.
Object.assign( utils, {
    scrollViewportToShowTarget,
    scrollAncestorsToShowTarget
} );

// Makes a given rect visible within its parent window.
//
// Note: Avoid the situation where the caret is still in the viewport, but totally
// at the edge of it. In such situation, if it moved beyond the viewport in the next
// action e.g. after paste, the scrolling would move it to the viewportOffset level
// and it all would look like the caret visually moved up/down:
//
// 1.
//        | foo[]
//        |                                    <--- N px of space below the caret
//        +---------------------------------...
//
// 2. *paste*
// 3.
//        |
//        |
//        +-foo-----------------------------...
//          bar[]                              <--- caret below viewport, scrolling...
//
// 4. *scrolling*
// 5.
//        |
//        | foo
//        | bar[]                              <--- caret precisely at the edge
//        +---------------------------------...
//
// To prevent this, this method checks the rects moved by the viewportOffset to cover
// the upper/lower edge of the viewport. It makes sure if the action repeats, there's
// no twitching – it's a purely visual improvement:
//
// 5. (after fix)
//        |
//        | foo
//        | bar[]
//        |                                    <--- N px of space below the caret
//        +---------------------------------...
//
// @private
// @param {Window} window A window which is scrolled to reveal the rect.
// @param {module:utils/dom/rect~Rect} rect A rect which is to be revealed.
// @param {Number} viewportOffset See scrollViewportToShowTarget.
function scrollWindowToShowRect( window, rect, viewportOffset ) {
    const targetShiftedDownRect = rect.clone().moveBy( 0, viewportOffset );
    const targetShiftedUpRect = rect.clone().moveBy( 0, -viewportOffset );
    const viewportRect = new Rect( window ).excludeScrollbarsAndBorders();

    const rects = [ targetShiftedUpRect, targetShiftedDownRect ];

    if ( !rects.every( rect => viewportRect.contains( rect ) ) ) {
        let { scrollX, scrollY } = window;

        if ( isAbove( targetShiftedUpRect, viewportRect ) ) {
            scrollY -= viewportRect.top - rect.top + viewportOffset;
        } else if ( isBelow( targetShiftedDownRect, viewportRect ) ) {
            scrollY += rect.bottom - viewportRect.bottom + viewportOffset;
        }

        // TODO: Web browsers scroll natively to place the target in the middle
        // of the viewport. It's not a very popular case, though.
        if ( isLeftOf( rect, viewportRect ) ) {
            scrollX -= viewportRect.left - rect.left + viewportOffset;
        } else if ( isRightOf( rect, viewportRect ) ) {
            scrollX += rect.right - viewportRect.right + viewportOffset;
        }

        window.scrollTo( scrollX, scrollY );
    }
}

// Recursively scrolls element ancestors to visually reveal a rect.
//
// @private
// @param {HTMLElement} A parent The first ancestors to start scrolling.
// @param {Function} getRect A function which returns the Rect, which is to be revealed.
function scrollAncestorsToShowRect( parent, getRect ) {
    const parentWindow = getWindow( parent );
    let parentRect, targetRect;

    while ( parent != parentWindow.document.body ) {
        targetRect = getRect();
        parentRect = new Rect( parent ).excludeScrollbarsAndBorders();

        if ( !parentRect.contains( targetRect ) ) {
            if ( isAbove( targetRect, parentRect ) ) {
                parent.scrollTop -= parentRect.top - targetRect.top;
            } else if ( isBelow( targetRect, parentRect ) ) {
                parent.scrollTop += targetRect.bottom - parentRect.bottom;
            }

            if ( isLeftOf( targetRect, parentRect ) ) {
                parent.scrollLeft -= parentRect.left - targetRect.left;
            } else if ( isRightOf( targetRect, parentRect ) ) {
                parent.scrollLeft += targetRect.right - parentRect.right;
            }
        }

        parent = parent.parentNode;
    }
}

// Determines if a given `Rect` extends beyond the bottom edge of the second `Rect`.
//
// @private
// @param {module:utils/dom/rect~Rect} firstRect
// @param {module:utils/dom/rect~Rect} secondRect
function isBelow( firstRect, secondRect ) {
    return firstRect.bottom > secondRect.bottom;
}

// Determines if a given `Rect` extends beyond the top edge of the second `Rect`.
//
// @private
// @param {module:utils/dom/rect~Rect} firstRect
// @param {module:utils/dom/rect~Rect} secondRect
function isAbove( firstRect, secondRect ) {
    return firstRect.top < secondRect.top;
}

// Determines if a given `Rect` extends beyond the left edge of the second `Rect`.
//
// @private
// @param {module:utils/dom/rect~Rect} firstRect
// @param {module:utils/dom/rect~Rect} secondRect
function isLeftOf( firstRect, secondRect ) {
    return firstRect.left < secondRect.left;
}

// Determines if a given `Rect` extends beyond the right edge of the second `Rect`.
//
// @private
// @param {module:utils/dom/rect~Rect} firstRect
// @param {module:utils/dom/rect~Rect} secondRect
function isRightOf( firstRect, secondRect ) {
    return firstRect.right > secondRect.right;
}

// Returns the closest window of an element or range.
//
// @private
// @param {HTMLElement|Range} firstRect
// @returns {Window}
function getWindow( elementOrRange ) {
    if ( isRange( elementOrRange ) ) {
        return elementOrRange.startContainer.ownerDocument.defaultView;
    } else {
        return elementOrRange.ownerDocument.defaultView;
    }
}

// Returns the closest parent of an element or DOM range.
//
// @private
// @param {HTMLElement|Range} firstRect
// @returns {HTMLelement}
function getParentElement( elementOrRange ) {
    if ( isRange( elementOrRange ) ) {
        let parent = elementOrRange.commonAncestorContainer;

        // If a Range is attached to the Text, use the closest element ancestor.
        if ( isText( parent ) ) {
            parent = parent.parentNode;
        }

        return parent;
    } else {
        return elementOrRange.parentNode;
    }
}

// Returns the rect of an element or range residing in an iframe.
// The result rect is relative to the geometry of the passed window instance.
//
// @private
// @param {HTMLElement|Range} target Element or range which rect should be returned.
// @param {Window} relativeWindow A window the rect should be relative to.
// @returns {module:utils/dom/rect~Rect}
function getRectRelativeToWindow( target, relativeWindow ) {
    const targetWindow = getWindow( target );
    const rect = new Rect( target );

    if ( targetWindow === relativeWindow ) {
        return rect;
    } else {
        let currentWindow = targetWindow;

        while ( currentWindow != relativeWindow ) {
            const frame = currentWindow.frameElement;
            const frameRect = new Rect( frame ).excludeScrollbarsAndBorders();

            rect.moveBy( frameRect.left, frameRect.top );

            currentWindow = currentWindow.parent;
        }
    }

    return rect;
}