UNPKG

@ckeditor/ckeditor5-utils

Version:

Miscellaneous utilities used by CKEditor 5.

399 lines (398 loc) 19.2 kB
/** * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options */ /** * @module utils/dom/scroll */ import isRange from './isrange.js'; import Rect from './rect.js'; import isText from './istext.js'; /** * 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 options Additional configuration of the scrolling behavior. * @param options.target A target, which supposed to become visible to the user. * @param options.viewportOffset An offset from the edge of the viewport (in pixels) * the `target` will be moved by if 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. * @param options.ancestorOffset An offset from the boundary of scrollable ancestors (if any) * the `target` will be moved by if the viewport is scrolled. It enhances the user experience * by keeping the `target` some distance from the edge of the ancestors and thus making it easier to * read or edit by the user. * @param options.alignToTop When set `true`, the helper will make sure the `target` is scrolled up * to the top boundary of the viewport and/or scrollable ancestors if scrolled up. When not set * (default), the `target` will be revealed by scrolling as little as possible. This option will * not affect `targets` that must be scrolled down because they will appear at the top of the boundary * anyway. * * ``` * scrollViewportToShowTarget() with scrollViewportToShowTarget() with * Initial state alignToTop unset (default) alignToTop = true * * ┌────────────────────────────────┬─┐ ┌────────────────────────────────┬─┐ ┌────────────────────────────────┬─┐ * │ │▲│ │ │▲│ │ [ Target to be revealed ] │▲│ * │ │ │ │ │ │ │ │ │ * │ │█│ │ │ │ │ │ │ * │ │█│ │ │ │ │ │ │ * │ │ │ │ │█│ │ │ │ * │ │ │ │ │█│ │ │█│ * │ │ │ │ │ │ │ │█│ * │ │▼│ │ [ Target to be revealed ] │▼│ │ │▼│ * └────────────────────────────────┴─┘ └────────────────────────────────┴─┘ └────────────────────────────────┴─┘ * * * [ Target to be revealed ] *``` * * @param options.forceScroll When set `true`, the `target` will be aligned to the top of the viewport * and scrollable ancestors whether it is already visible or not. This option will only work when `alignToTop` * is `true` */ export function scrollViewportToShowTarget({ target, viewportOffset = 0, ancestorOffset = 0, alignToTop, forceScroll }) { const targetWindow = getWindow(target); let currentWindow = targetWindow; let currentFrame = null; viewportOffset = normalizeViewportOffset(viewportOffset); // 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({ parent: firstAncestorToScroll, getRect: () => { // 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); }, alignToTop, ancestorOffset, forceScroll }); // Obtain the rect of the target after it has been scrolled within its ancestors. // It's time to scroll the viewport. let targetRect = getRectRelativeToWindow(target, currentWindow); // Detect situation where the target is higher than the first scrollable ancestor. // In such case scrolling the viewport to reveal the target might be malfunctioning because // the target `.top` position is lower than the ancestor's `.top` position. If it's large enough it can be negative. // It causes the `scrollWindowToShowRect` to scroll the viewport to the negative top position which is not possible // and leads to the viewport being scrolled to the absolute top of the document. To prevent this, the target's rect // must be shifted to the ancestor's top position. It should not affect the target's visibility because the ancestor // is already scrolled to reveal the target. // See more: https://github.com/ckeditor/ckeditor5/issues/17079 const ancestorWindowRelativeRect = getRectRelativeToWindow(firstAncestorToScroll, currentWindow); if (targetRect.height > ancestorWindowRelativeRect.height) { const ancestorTargetIntersection = targetRect.getIntersection(ancestorWindowRelativeRect); if (ancestorTargetIntersection) { targetRect = ancestorTargetIntersection; } } scrollWindowToShowRect({ window: currentWindow, rect: targetRect, viewportOffset, alignToTop, forceScroll }); 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 target A target, which supposed to become visible to the user. * @param ancestorOffset An offset between the target and the boundary of scrollable ancestors * to be maintained while scrolling. * @param limiterElement The outermost ancestor that should be scrolled. If specified, it can prevent * scrolling the whole page. */ export function scrollAncestorsToShowTarget(target, ancestorOffset, limiterElement) { const targetParent = getParentElement(target); scrollAncestorsToShowRect({ parent: targetParent, getRect: () => new Rect(target), ancestorOffset, limiterElement }); } /** * 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 * +---------------------------------... * ``` * * @param options Additional configuration of the scrolling behavior. * @param options.window A window which is scrolled to reveal the rect. * @param options.rect A rect which is to be revealed. * @param options.viewportOffset An offset from the edge of the viewport (in pixels) the `rect` will be * moved by if the viewport is scrolled. * @param options.alignToTop When set `true`, the helper will make sure the `rect` is scrolled up * to the top boundary of the viewport if scrolled up. When not set (default), the `rect` will be * revealed by scrolling as little as possible. This option will not affect rects that must be scrolled * down because they will appear at the top of the boundary anyway. * @param options.forceScroll When set `true`, the `rect` will be aligned to the top of the viewport * whether it is already visible or not. This option will only work when `alignToTop` is `true` */ function scrollWindowToShowRect({ window, rect, alignToTop, forceScroll, viewportOffset }) { const targetShiftedDownRect = rect.clone().moveBy(0, viewportOffset.bottom); const targetShiftedUpRect = rect.clone().moveBy(0, -viewportOffset.top); const viewportRect = new Rect(window).excludeScrollbarsAndBorders(); const rects = [targetShiftedUpRect, targetShiftedDownRect]; const forceScrollToTop = alignToTop && forceScroll; const allRectsFitInViewport = rects.every(rect => viewportRect.contains(rect)); let { scrollX, scrollY } = window; const initialScrollX = scrollX; const initialScrollY = scrollY; if (forceScrollToTop) { scrollY -= (viewportRect.top - rect.top) + viewportOffset.top; } else if (!allRectsFitInViewport) { if (isAbove(targetShiftedUpRect, viewportRect)) { scrollY -= viewportRect.top - rect.top + viewportOffset.top; } else if (isBelow(targetShiftedDownRect, viewportRect)) { if (alignToTop) { scrollY += rect.top - viewportRect.top - viewportOffset.top; } else { scrollY += rect.bottom - viewportRect.bottom + viewportOffset.bottom; } } } if (!allRectsFitInViewport) { // 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.left; } else if (isRightOf(rect, viewportRect)) { scrollX += rect.right - viewportRect.right + viewportOffset.right; } } if (scrollX != initialScrollX || scrollY !== initialScrollY) { window.scrollTo(scrollX, scrollY); } } /** * Recursively scrolls element ancestors to visually reveal a rect. * * @param options Additional configuration of the scrolling behavior. * @param options.parent The first parent ancestor to start scrolling. * @param options.getRect A function which returns the Rect, which is to be revealed. * @param options.ancestorOffset An offset from the boundary of scrollable ancestors (if any) * the `Rect` instance will be moved by if the viewport is scrolled. * @param options.alignToTop When set `true`, the helper will make sure the `Rect` instance is scrolled up * to the top boundary of the scrollable ancestors if scrolled up. When not set (default), the `rect` * will be revealed by scrolling as little as possible. This option will not affect rects that must be * scrolled down because they will appear at the top of the boundary * anyway. * @param options.forceScroll When set `true`, the `rect` will be aligned to the top of scrollable ancestors * whether it is already visible or not. This option will only work when `alignToTop` is `true` * @param options.limiterElement The outermost ancestor that should be scrolled. Defaults to the `<body>` element. */ function scrollAncestorsToShowRect({ parent, getRect, alignToTop, forceScroll, ancestorOffset = 0, limiterElement }) { const parentWindow = getWindow(parent); const forceScrollToTop = alignToTop && forceScroll; let parentRect, targetRect, targetFitsInTarget; const limiter = limiterElement || parentWindow.document.body; while (parent != limiter) { targetRect = getRect(); parentRect = new Rect(parent).excludeScrollbarsAndBorders(); targetFitsInTarget = parentRect.contains(targetRect); if (forceScrollToTop) { parent.scrollTop -= (parentRect.top - targetRect.top) + ancestorOffset; } else if (!targetFitsInTarget) { if (isAbove(targetRect, parentRect)) { parent.scrollTop -= parentRect.top - targetRect.top + ancestorOffset; } else if (isBelow(targetRect, parentRect)) { if (alignToTop) { parent.scrollTop += targetRect.top - parentRect.top - ancestorOffset; } else { parent.scrollTop += targetRect.bottom - parentRect.bottom + ancestorOffset; } } } if (!targetFitsInTarget) { if (isLeftOf(targetRect, parentRect)) { parent.scrollLeft -= parentRect.left - targetRect.left + ancestorOffset; } else if (isRightOf(targetRect, parentRect)) { parent.scrollLeft += targetRect.right - parentRect.right + ancestorOffset; } } parent = parent.parentNode; } } /** * Determines if a given `Rect` extends beyond the bottom edge of the second `Rect`. */ function isBelow(firstRect, secondRect) { return firstRect.bottom > secondRect.bottom; } /** * Determines if a given `Rect` extends beyond the top edge of the second `Rect`. */ function isAbove(firstRect, secondRect) { return firstRect.top < secondRect.top; } /** * Determines if a given `Rect` extends beyond the left edge of the second `Rect`. */ function isLeftOf(firstRect, secondRect) { return firstRect.left < secondRect.left; } /** * Determines if a given `Rect` extends beyond the right edge of the second `Rect`. */ function isRightOf(firstRect, secondRect) { return firstRect.right > secondRect.right; } /** * Returns the closest window of an element or range. */ 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. */ 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. * * @param target Element or range which rect should be returned. * @param relativeWindow A window the rect should be relative to. */ 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; } /** * A helper that explodes the `viewportOffset` configuration if defined as a plain number into an object * with `top`, `bottom`, `left`, and `right` properties. * * If an object value is passed, this helper will pass it through. * * @param viewportOffset Viewport offset to be normalized. */ function normalizeViewportOffset(viewportOffset) { if (typeof viewportOffset === 'number') { return { top: viewportOffset, bottom: viewportOffset, left: viewportOffset, right: viewportOffset }; } return viewportOffset; }