diff --git a/packages/ckeditor5-ui/src/dropdown/dropdownview.ts b/packages/ckeditor5-ui/src/dropdown/dropdownview.ts index 1f0b8301778..01507a3cbf1 100644 --- a/packages/ckeditor5-ui/src/dropdown/dropdownview.ts +++ b/packages/ckeditor5-ui/src/dropdown/dropdownview.ts @@ -269,12 +269,16 @@ export default class DropdownView extends View { // If "auto", find the best position of the panel to fit into the viewport. // Otherwise, simply assign the static position. if ( this.panelPosition === 'auto' ) { - this.panelView.position = DropdownView._getOptimalPosition( { + const optimalPanelPosition = DropdownView._getOptimalPosition( { element: this.panelView.element!, target: this.buttonView.element!, fitInViewport: true, positions: this._panelPositions - } ).name as PanelPosition; + } ); + + this.panelView.position = ( + optimalPanelPosition ? optimalPanelPosition.name : this._panelPositions[ 0 ].name + ) as PanelPosition; } else { this.panelView.position = this.panelPosition; } diff --git a/packages/ckeditor5-ui/src/editorui/poweredby.ts b/packages/ckeditor5-ui/src/editorui/poweredby.ts index 93527b5b23c..d73c41cd93a 100644 --- a/packages/ckeditor5-ui/src/editorui/poweredby.ts +++ b/packages/ckeditor5-ui/src/editorui/poweredby.ts @@ -9,9 +9,8 @@ import type { Editor, UiConfig } from '@ckeditor/ckeditor5-core'; import { - Rect, DomEmitterMixin, - findClosestScrollableAncestor, + Rect, verifyLicense, type PositionOptions, type Locale @@ -30,14 +29,6 @@ const ICON_HEIGHT = 10; const NARROW_ROOT_HEIGHT_THRESHOLD = 50; const NARROW_ROOT_WIDTH_THRESHOLD = 350; const DEFAULT_LABEL = 'Powered by'; -const OFF_THE_SCREEN_POSITION = { - top: -99999, - left: -99999, - name: 'invalid', - config: { - withArrow: false - } -}; type PoweredByConfig = Required[ 'poweredBy' ]; @@ -312,18 +303,13 @@ function getLowerLeftCornerPosition( focusedEditableElement: HTMLElement, config function getLowerCornerPosition( focusedEditableElement: HTMLElement, config: PoweredByConfig, - getBalloonLeft: ( editableElementRect: Rect, balloonRect: Rect ) => number + getBalloonLeft: ( visibleEditableElementRect: Rect, balloonRect: Rect ) => number ) { - return ( editableElementRect: Rect, balloonRect: Rect ) => { - const visibleEditableElementRect = editableElementRect.getVisible(); - - // Root cropped by ancestors. - if ( !visibleEditableElementRect ) { - return OFF_THE_SCREEN_POSITION; - } + return ( visibleEditableElementRect: Rect, balloonRect: Rect ) => { + const editableElementRect = new Rect( focusedEditableElement ); if ( editableElementRect.width < NARROW_ROOT_WIDTH_THRESHOLD || editableElementRect.height < NARROW_ROOT_HEIGHT_THRESHOLD ) { - return OFF_THE_SCREEN_POSITION; + return null; } let balloonTop; @@ -339,31 +325,20 @@ function getLowerCornerPosition( const balloonLeft = getBalloonLeft( editableElementRect, balloonRect ); - if ( config.position === 'inside' ) { - const newBalloonRect = balloonRect.clone().moveTo( balloonLeft, balloonTop ); + // Clone the editable element rect and place it where the balloon would be placed. + // This will allow getVisible() to work from editable element's perspective (rect source). + // and yield a result as if the balloon was on the same (scrollable) layer as the editable element. + const newBalloonPositionRect = visibleEditableElementRect + .clone() + .moveTo( balloonLeft, balloonTop ) + .getIntersection( balloonRect.clone().moveTo( balloonLeft, balloonTop ) )!; - // The watermark cannot be positioned in this corner because the corner is not quite visible. - if ( newBalloonRect.getIntersectionArea( visibleEditableElementRect ) < newBalloonRect.getArea() ) { - return OFF_THE_SCREEN_POSITION; - } - } - else { - const firstScrollableEditableElementAncestor = findClosestScrollableAncestor( focusedEditableElement ); - - if ( firstScrollableEditableElementAncestor ) { - const firstScrollableEditableElementAncestorRect = new Rect( firstScrollableEditableElementAncestor ); - const notVisibleVertically = visibleEditableElementRect.bottom + balloonRect.height / 2 > - firstScrollableEditableElementAncestorRect.bottom; - const notVisibleHorizontally = config.side === 'left' ? - editableElementRect.left < firstScrollableEditableElementAncestorRect.left : - editableElementRect.right > firstScrollableEditableElementAncestorRect.right; - - // The watermark cannot be positioned in this corner because the corner is "not visible enough". - if ( notVisibleVertically || notVisibleHorizontally ) { - return OFF_THE_SCREEN_POSITION; - } - } + const newBalloonPositionVisibleRect = newBalloonPositionRect.getVisible(); + + if ( !newBalloonPositionVisibleRect || newBalloonPositionVisibleRect.getArea() < balloonRect.getArea() ) { + return null; } + return { top: balloonTop, left: balloonLeft, diff --git a/packages/ckeditor5-ui/src/panel/balloon/balloonpanelview.ts b/packages/ckeditor5-ui/src/panel/balloon/balloonpanelview.ts index a0b965916ea..c950a07c8bc 100644 --- a/packages/ckeditor5-ui/src/panel/balloon/balloonpanelview.ts +++ b/packages/ckeditor5-ui/src/panel/balloon/balloonpanelview.ts @@ -17,18 +17,35 @@ import { toUnit, type Locale, type ObservableChangeEvent, + type Position, type PositionOptions, type PositioningFunction, type Rect } from '@ckeditor/ckeditor5-utils'; import { isElement } from 'lodash-es'; - import '../../../theme/components/panel/balloonpanel.css'; const toPx = toUnit( 'px' ); const defaultLimiterElement = global.document.body; +// A static balloon panel positioning function that moves the balloon far off the viewport. +// It is used as a fallback when there is no way to position the balloon using provided +// positioning functions (see: `getOptimalPosition()`), for instance, when the target the +// balloon should be attached to gets obscured by scrollable containers or the viewport. +// +// It prevents the balloon from being attached to the void and possible degradation of the UX. +// At the same time, it keeps the balloon physically visible in the DOM so the focus remains +// uninterrupted. +const POSITION_OFF_SCREEN: Position = { + top: -99999, + left: -99999, + name: 'arrowless', + config: { + withArrow: false + } +}; + /** * The balloon panel view class. * @@ -251,7 +268,7 @@ export default class BalloonPanelView extends View { fitInViewport: true }, options ) as PositionOptions; - const optimalPosition = BalloonPanelView._getOptimalPosition( positionOptions ); + const optimalPosition = BalloonPanelView._getOptimalPosition( positionOptions ) || POSITION_OFF_SCREEN; // Usually browsers make some problems with super accurate values like 104.345px // so it is better to use int values. @@ -1131,13 +1148,21 @@ export function generatePositions( options: { // ------- Sticky - viewportStickyNorth: ( targetRect, balloonRect, viewportRect ) => { - if ( !targetRect.getIntersection( viewportRect! ) ) { + viewportStickyNorth: ( targetRect, balloonRect, viewportRect, limiterRect ) => { + const boundaryRect = limiterRect || viewportRect; + + if ( !targetRect.getIntersection( boundaryRect ) ) { + return null; + } + + // Engage when the target top and bottom edges are close or off the boundary. + // By close, it means there's not enough space for the balloon arrow (offset). + if ( boundaryRect.height - targetRect.height > stickyVerticalOffset ) { return null; } return { - top: viewportRect!.top + stickyVerticalOffset, + top: boundaryRect.top + stickyVerticalOffset, left: targetRect.left + targetRect.width / 2 - balloonRect.width / 2, name: 'arrowless', config: { diff --git a/packages/ckeditor5-ui/src/panel/sticky/stickypanelview.ts b/packages/ckeditor5-ui/src/panel/sticky/stickypanelview.ts index 2073ffc46a1..f77b92a583b 100644 --- a/packages/ckeditor5-ui/src/panel/sticky/stickypanelview.ts +++ b/packages/ckeditor5-ui/src/panel/sticky/stickypanelview.ts @@ -15,14 +15,15 @@ import type ViewCollection from '../../viewcollection'; import { type Locale, type ObservableChangeEvent, - getElementsIntersectionRect, - getScrollableAncestors, global, toUnit, Rect } from '@ckeditor/ckeditor5-utils'; -// @if CK_DEBUG_STICKYPANEL // const RectDrawer = require( '@ckeditor/ckeditor5-utils/tests/_utils/rectdrawer' ).default +// @if CK_DEBUG_STICKYPANEL // const { +// @if CK_DEBUG_STICKYPANEL // default: RectDrawer, +// @if CK_DEBUG_STICKYPANEL // diagonalStylesBlack +// @if CK_DEBUG_STICKYPANEL // } = require( '@ckeditor/ckeditor5-utils/tests/_utils/rectdrawer' ); import '../../../theme/components/panel/stickypanel.css'; @@ -234,8 +235,8 @@ export default class StickyPanelView extends View { this.checkIfShouldBeSticky(); // Update sticky state of the panel as the window and ancestors are being scrolled. - this.listenTo( global.document, 'scroll', ( evt, data ) => { - this.checkIfShouldBeSticky( data.target as HTMLElement | Document ); + this.listenTo( global.document, 'scroll', () => { + this.checkIfShouldBeSticky(); }, { useCapture: true } ); // Synchronize with `model.isActive` because sticking an inactive panel is pointless. @@ -247,10 +248,8 @@ export default class StickyPanelView extends View { /** * Analyzes the environment to decide whether the panel should be sticky or not. * Then handles the positioning of the panel. - * - * @param [scrollTarget] The element which is being scrolled. */ - public checkIfShouldBeSticky( scrollTarget?: HTMLElement | Document ): void { + public checkIfShouldBeSticky(): void { // @if CK_DEBUG_STICKYPANEL // RectDrawer.clear(); if ( !this.limiterElement || !this.isActive ) { @@ -259,17 +258,21 @@ export default class StickyPanelView extends View { return; } - const scrollableAncestors = getScrollableAncestors( this.limiterElement ); + const limiterRect = new Rect( this.limiterElement ); - if ( scrollTarget && !scrollableAncestors.includes( scrollTarget ) ) { - return; - } + let visibleLimiterRect = limiterRect.getVisible(); - const visibleAncestorsRect = getElementsIntersectionRect( scrollableAncestors, this.viewportTopOffset ); - const limiterRect = new Rect( this.limiterElement ); + if ( visibleLimiterRect ) { + const windowRect = new Rect( global.window ); + + windowRect.top += this.viewportTopOffset; + windowRect.height -= this.viewportTopOffset; - // @if CK_DEBUG_STICKYPANEL // if ( visibleAncestorsRect ) { - // @if CK_DEBUG_STICKYPANEL // RectDrawer.draw( visibleAncestorsRect, + visibleLimiterRect = visibleLimiterRect.getIntersection( windowRect ); + } + + // @if CK_DEBUG_STICKYPANEL // if ( visibleLimiterRect ) { + // @if CK_DEBUG_STICKYPANEL // RectDrawer.draw( visibleLimiterRect, // @if CK_DEBUG_STICKYPANEL // { outlineWidth: '3px', opacity: '.8', outlineColor: 'red', outlineOffset: '-3px' }, // @if CK_DEBUG_STICKYPANEL // 'Visible anc' // @if CK_DEBUG_STICKYPANEL // ); @@ -283,48 +286,40 @@ export default class StickyPanelView extends View { // Stick the panel only if // * the limiter's ancestors are intersecting with each other so that some of their rects are visible, // * and the limiter's top edge is above the visible ancestors' top edge. - if ( visibleAncestorsRect && limiterRect.top < visibleAncestorsRect.top ) { - const visibleLimiterRect = limiterRect.getIntersection( visibleAncestorsRect ); - - // Sticky the panel only if the limiter's visible rect is at least partially visible in the - // visible ancestors' rects intersection. - if ( visibleLimiterRect ) { - // @if CK_DEBUG_STICKYPANEL // RectDrawer.draw( visibleLimiterRect, - // @if CK_DEBUG_STICKYPANEL // { outlineWidth: '3px', opacity: '.8', outlineColor: 'fuchsia', outlineOffset: '-3px', - // @if CK_DEBUG_STICKYPANEL // backgroundColor: 'rgba(255, 0, 255, .3)' }, - // @if CK_DEBUG_STICKYPANEL // 'Visible limiter' + if ( visibleLimiterRect && limiterRect.top < visibleLimiterRect.top ) { + // @if CK_DEBUG_STICKYPANEL // RectDrawer.draw( visibleLimiterRect, + // @if CK_DEBUG_STICKYPANEL // { outlineWidth: '3px', opacity: '.8', outlineColor: 'fuchsia', outlineOffset: '-3px', + // @if CK_DEBUG_STICKYPANEL // backgroundColor: 'rgba(255, 0, 255, .3)' }, + // @if CK_DEBUG_STICKYPANEL // 'Visible limiter' + // @if CK_DEBUG_STICKYPANEL // ); + + const visibleLimiterTop = visibleLimiterRect.top; + + // Check if there's a change the panel can be sticky to the bottom of the limiter. + if ( visibleLimiterTop + this._contentPanelRect.height + this.limiterBottomOffset > visibleLimiterRect.bottom ) { + const stickyBottomOffset = Math.max( limiterRect.bottom - visibleLimiterRect.bottom, 0 ) + this.limiterBottomOffset; + // @if CK_DEBUG_STICKYPANEL // const stickyBottomOffsetRect = new Rect( { + // @if CK_DEBUG_STICKYPANEL // top: limiterRect.bottom - stickyBottomOffset, left: 0, right: 2000, + // @if CK_DEBUG_STICKYPANEL // bottom: limiterRect.bottom - stickyBottomOffset, width: 2000, height: 1 + // @if CK_DEBUG_STICKYPANEL // } ); + // @if CK_DEBUG_STICKYPANEL // RectDrawer.draw( stickyBottomOffsetRect, + // @if CK_DEBUG_STICKYPANEL // { outlineWidth: '1px', opacity: '.8', outlineColor: 'black' }, + // @if CK_DEBUG_STICKYPANEL // 'Sticky bottom offset' // @if CK_DEBUG_STICKYPANEL // ); - const visibleAncestorsTop = visibleAncestorsRect.top; - - // Check if there's a change the panel can be sticky to the bottom of the limiter. - if ( visibleAncestorsTop + this._contentPanelRect.height + this.limiterBottomOffset > visibleLimiterRect.bottom ) { - const stickyBottomOffset = Math.max( limiterRect.bottom - visibleAncestorsRect.bottom, 0 ) + this.limiterBottomOffset; - // @if CK_DEBUG_STICKYPANEL // const stickyBottomOffsetRect = new Rect( { - // @if CK_DEBUG_STICKYPANEL // top: limiterRect.bottom - stickyBottomOffset, left: 0, right: 2000, - // @if CK_DEBUG_STICKYPANEL // bottom: limiterRect.bottom - stickyBottomOffset, width: 2000, height: 1 - // @if CK_DEBUG_STICKYPANEL // } ); - // @if CK_DEBUG_STICKYPANEL // RectDrawer.draw( stickyBottomOffsetRect, - // @if CK_DEBUG_STICKYPANEL // { outlineWidth: '1px', opacity: '.8', outlineColor: 'black' }, - // @if CK_DEBUG_STICKYPANEL // 'Sticky bottom offset' - // @if CK_DEBUG_STICKYPANEL // ); - - // Check if sticking the panel to the bottom of the limiter does not cause it to suddenly - // move upwards if there's not enough space for it. - if ( limiterRect.bottom - stickyBottomOffset > limiterRect.top + this._contentPanelRect.height ) { - this._stickToBottomOfLimiter( stickyBottomOffset ); - } else { - this._unstick(); - } + // Check if sticking the panel to the bottom of the limiter does not cause it to suddenly + // move upwards if there's not enough space for it. + if ( limiterRect.bottom - stickyBottomOffset > limiterRect.top + this._contentPanelRect.height ) { + this._stickToBottomOfLimiter( stickyBottomOffset ); } else { - if ( this._contentPanelRect.height + this.limiterBottomOffset < limiterRect.height ) { - this._stickToTopOfAncestors( visibleAncestorsTop ); - } else { - this._unstick(); - } + this._unstick(); } } else { - this._unstick(); + if ( this._contentPanelRect.height + this.limiterBottomOffset < limiterRect.height ) { + this._stickToTopOfAncestors( visibleLimiterTop ); + } else { + this._unstick(); + } } } else { this._unstick(); @@ -335,6 +330,14 @@ export default class StickyPanelView extends View { // @if CK_DEBUG_STICKYPANEL // console.log( '_isStickyToTheBottomOfLimiter', this._isStickyToTheBottomOfLimiter ); // @if CK_DEBUG_STICKYPANEL // console.log( '_stickyTopOffset', this._stickyTopOffset ); // @if CK_DEBUG_STICKYPANEL // console.log( '_stickyBottomOffset', this._stickyBottomOffset ); + // @if CK_DEBUG_STICKYPANEL // if ( visibleLimiterRect ) { + // @if CK_DEBUG_STICKYPANEL // RectDrawer.draw( visibleLimiterRect, + // @if CK_DEBUG_STICKYPANEL // { ...diagonalStylesBlack, + // @if CK_DEBUG_STICKYPANEL // outlineWidth: '3px', opacity: '.8', outlineColor: 'orange', outlineOffset: '-3px', + // @if CK_DEBUG_STICKYPANEL // backgroundColor: 'rgba(0, 0, 255, .2)' }, + // @if CK_DEBUG_STICKYPANEL // 'visibleLimiterRect' + // @if CK_DEBUG_STICKYPANEL // ); + // @if CK_DEBUG_STICKYPANEL // } } /** diff --git a/packages/ckeditor5-ui/src/toolbar/block/blocktoolbar.ts b/packages/ckeditor5-ui/src/toolbar/block/blocktoolbar.ts index 198fff8b194..6c484754284 100644 --- a/packages/ckeditor5-ui/src/toolbar/block/blocktoolbar.ts +++ b/packages/ckeditor5-ui/src/toolbar/block/blocktoolbar.ts @@ -18,7 +18,6 @@ import { import { Rect, ResizeObserver, - getOptimalPosition, toUnit, type ObservableChangeEvent } from '@ckeditor/ckeditor5-utils'; @@ -430,29 +429,21 @@ export default class BlockToolbar extends Plugin { // MDN says that 'normal' == ~1.2 on desktop browsers. const contentLineHeight = parseInt( contentStyles.lineHeight, 10 ) || parseInt( contentStyles.fontSize, 10 ) * 1.2; - const position = getOptimalPosition( { - element: this.buttonView.element!, - target: targetElement, - positions: [ - ( contentRect, buttonRect ) => { - let left; - - if ( this.editor.locale.uiLanguageDirection === 'ltr' ) { - left = editableRect.left - buttonRect.width; - } else { - left = editableRect.right; - } - - return { - top: contentRect.top + contentPaddingTop + ( contentLineHeight - buttonRect.height ) / 2, - left - }; - } - ] - } ); + const buttonRect = new Rect( this.buttonView.element! ).toAbsoluteRect(); + const contentRect = new Rect( targetElement ).toAbsoluteRect(); + + let positionLeft; + + if ( this.editor.locale.uiLanguageDirection === 'ltr' ) { + positionLeft = editableRect.left - buttonRect.width; + } else { + positionLeft = editableRect.right; + } + + const positionTop = contentRect.top + contentPaddingTop + ( contentLineHeight - buttonRect.height ) / 2; - this.buttonView.top = position.top; - this.buttonView.left = position.left; + this.buttonView.top = positionTop; + this.buttonView.left = positionLeft; } /** diff --git a/packages/ckeditor5-ui/tests/dropdown/dropdownview.js b/packages/ckeditor5-ui/tests/dropdown/dropdownview.js index df7630dabc9..0e1739d41a1 100644 --- a/packages/ckeditor5-ui/tests/dropdown/dropdownview.js +++ b/packages/ckeditor5-ui/tests/dropdown/dropdownview.js @@ -184,6 +184,35 @@ describe( 'DropdownView', () => { fitInViewport: true } ) ); } ); + + it( 'fallback when _getOptimalPosition() will return null', () => { + const locale = { + t() {} + }; + + const buttonView = new ButtonView( locale ); + const panelView = new DropdownPanelView( locale ); + + const view = new DropdownView( locale, buttonView, panelView ); + view.render(); + + const parentWithOverflow = global.document.createElement( 'div' ); + parentWithOverflow.style.width = '1px'; + parentWithOverflow.style.height = '1px'; + parentWithOverflow.style.marginTop = '-1000px'; + parentWithOverflow.style.overflow = 'scroll'; + + parentWithOverflow.appendChild( view.element ); + + global.document.body.appendChild( parentWithOverflow ); + + view.isOpen = true; + + expect( view.panelView.position ).is.equal( 'southEast' ); // first position from position list. + + view.element.remove(); + parentWithOverflow.remove(); + } ); } ); } ); diff --git a/packages/ckeditor5-ui/tests/editorui/poweredby.js b/packages/ckeditor5-ui/tests/editorui/poweredby.js index d0c4722cd16..53fec4a7fc7 100644 --- a/packages/ckeditor5-ui/tests/editorui/poweredby.js +++ b/packages/ckeditor5-ui/tests/editorui/poweredby.js @@ -13,7 +13,7 @@ import View from '../../src/view'; import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; -import { Rect } from '@ckeditor/ckeditor5-utils'; +import { Rect, global } from '@ckeditor/ckeditor5-utils'; import SourceEditing from '@ckeditor/ckeditor5-source-editing/src/sourceediting'; import Heading from '@ckeditor/ckeditor5-heading/src/heading'; import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; @@ -45,6 +45,9 @@ describe( 'PoweredBy', () => { width: 1000, height: 1000 } ); + + sinon.stub( global.window, 'innerWidth' ).value( 1000 ); + sinon.stub( global.window, 'innerHeight' ).value( 1000 ); } ); afterEach( async () => { @@ -493,7 +496,7 @@ describe( 'PoweredBy', () => { focusEditor( editor ); expect( editor.ui.poweredBy._balloonView.isVisible ).to.be.true; - expect( editor.ui.poweredBy._balloonView.position ).to.equal( 'invalid' ); + expect( editor.ui.poweredBy._balloonView.position ).to.equal( 'arrowless' ); parentWithOverflow.remove(); } ); @@ -504,7 +507,7 @@ describe( 'PoweredBy', () => { parentWithOverflow.style.overflow = 'scroll'; // Is not enough width to be visible horizontally. - parentWithOverflow.style.width = '390px'; + parentWithOverflow.style.width = '399px'; document.body.appendChild( parentWithOverflow ); parentWithOverflow.appendChild( domRoot ); @@ -512,7 +515,7 @@ describe( 'PoweredBy', () => { focusEditor( editor ); expect( editor.ui.poweredBy._balloonView.isVisible ).to.be.true; - expect( editor.ui.poweredBy._balloonView.position ).to.equal( 'invalid' ); + expect( editor.ui.poweredBy._balloonView.position ).to.equal( 'arrowless' ); parentWithOverflow.remove(); } ); @@ -522,6 +525,15 @@ describe( 'PoweredBy', () => { language: 'ar' } ); + testUtils.sinon.stub( editor.ui.getEditableElement( 'main' ), 'getBoundingClientRect' ).returns( { + top: 0, + left: 0, + right: 400, + width: 400, + bottom: 100, + height: 100 + } ); + focusEditor( editor ); const pinSpy = testUtils.sinon.spy( editor.ui.poweredBy._balloonView, 'pin' ); @@ -584,6 +596,15 @@ describe( 'PoweredBy', () => { } } ); + testUtils.sinon.stub( editor.ui.getEditableElement( 'main' ), 'getBoundingClientRect' ).returns( { + top: 0, + left: 0, + right: 400, + width: 400, + bottom: 100, + height: 100 + } ); + focusEditor( editor ); const pinSpy = testUtils.sinon.spy( editor.ui.poweredBy._balloonView, 'pin' ); @@ -620,6 +641,15 @@ describe( 'PoweredBy', () => { } } ); + testUtils.sinon.stub( editor.ui.getEditableElement( 'main' ), 'getBoundingClientRect' ).returns( { + top: 0, + left: 0, + right: 400, + width: 400, + bottom: 100, + height: 100 + } ); + focusEditor( editor ); const pinSpy = testUtils.sinon.spy( editor.ui.poweredBy._balloonView, 'pin' ); @@ -656,6 +686,15 @@ describe( 'PoweredBy', () => { } } ); + testUtils.sinon.stub( editor.ui.getEditableElement( 'main' ), 'getBoundingClientRect' ).returns( { + top: 0, + left: 0, + right: 400, + width: 400, + bottom: 100, + height: 100 + } ); + focusEditor( editor ); const pinSpy = testUtils.sinon.spy( editor.ui.poweredBy._balloonView, 'pin' ); @@ -707,14 +746,7 @@ describe( 'PoweredBy', () => { const positioningFunction = pinArgs.positions[ 0 ]; expect( pinArgs.target ).to.equal( domRoot ); - expect( positioningFunction( rootRect, balloonRect ) ).to.deep.equal( { - top: -99999, - left: -99999, - name: 'invalid', - config: { - withArrow: false - } - } ); + expect( positioningFunction( rootRect, balloonRect ) ).to.equal( null ); await editor.destroy(); } ); @@ -747,14 +779,7 @@ describe( 'PoweredBy', () => { const positioningFunction = pinArgs.positions[ 0 ]; expect( pinArgs.target ).to.equal( domRoot ); - expect( positioningFunction( rootRect, balloonRect ) ).to.deep.equal( { - top: -99999, - left: -99999, - name: 'invalid', - config: { - withArrow: false - } - } ); + expect( positioningFunction( rootRect, balloonRect ) ).to.equal( null ); await editor.destroy(); } ); @@ -791,14 +816,7 @@ describe( 'PoweredBy', () => { const positioningFunction = pinArgs.positions[ 0 ]; expect( pinArgs.target ).to.equal( domRoot ); - expect( positioningFunction( rootRect, balloonRect ) ).to.deep.equal( { - top: -99999, - left: -99999, - name: 'invalid', - config: { - withArrow: false - } - } ); + expect( positioningFunction( rootRect, balloonRect ) ).to.equal( null ); await editor.destroy(); } ); @@ -813,6 +831,15 @@ describe( 'PoweredBy', () => { } } ); + testUtils.sinon.stub( editor.ui.getEditableElement( 'main' ), 'getBoundingClientRect' ).returns( { + top: 0, + left: 0, + right: 400, + width: 400, + bottom: 100, + height: 100 + } ); + focusEditor( editor ); const pinSpy = testUtils.sinon.spy( editor.ui.poweredBy._balloonView, 'pin' ); @@ -870,7 +897,7 @@ describe( 'PoweredBy', () => { const pinSpy = testUtils.sinon.spy( editor.ui.poweredBy._balloonView, 'pin' ); expect( editor.ui.poweredBy._balloonView.isVisible ).to.be.true; - expect( editor.ui.poweredBy._balloonView.position ).to.equal( 'invalid' ); + expect( editor.ui.poweredBy._balloonView.position ).to.equal( 'arrowless' ); domRoot.getBoundingClientRect.returns( { top: 0, @@ -895,7 +922,7 @@ describe( 'PoweredBy', () => { expect( pinArgs.target ).to.equal( editor.editing.view.getDomRoot() ); expect( positioningFunction( rootRect, balloonRect ) ).to.deep.equal( { top: 95, - left: 375, + left: 325, name: 'position_border-side_right', config: { withArrow: false @@ -933,7 +960,7 @@ describe( 'PoweredBy', () => { const pinSpy = testUtils.sinon.spy( editor.ui.poweredBy._balloonView, 'pin' ); expect( editor.ui.poweredBy._balloonView.isVisible ).to.be.true; - expect( editor.ui.poweredBy._balloonView.position ).to.equal( 'invalid' ); + expect( editor.ui.poweredBy._balloonView.position ).to.equal( 'arrowless' ); domRoot.getBoundingClientRect.returns( { top: 0, @@ -957,8 +984,8 @@ describe( 'PoweredBy', () => { expect( pinArgs.target ).to.equal( editor.editing.view.getDomRoot() ); expect( positioningFunction( rootRect, balloonRect ) ).to.deep.equal( { - top: 95, - left: 375, + top: 45, + left: 975, name: 'position_border-side_right', config: { withArrow: false diff --git a/packages/ckeditor5-ui/tests/manual/poweredby/poweredby.html b/packages/ckeditor5-ui/tests/manual/poweredby/poweredby.html index 5d8dfec9793..585fcc63864 100644 --- a/packages/ckeditor5-ui/tests/manual/poweredby/poweredby.html +++ b/packages/ckeditor5-ui/tests/manual/poweredby/poweredby.html @@ -99,35 +99,37 @@

Padding-less editor

Parent with overflow

-
-
-

The three greatest things you learn from traveling

-

Like all the great things on earth traveling teaches us by example. Here are some of the most precious lessons I’ve - learned over the years of traveling.

- -
-

The real voyage of discovery consists not in seeking new landscapes, but having new eyes.

-

Marcel Proust

-
-

Improvisation

-

Life doesn't allow us to execute every single plan perfectly. This especially seems to be the case when you travel. - You plan it down to every minute with a big checklist. But when it comes to executing it, something always comes up - and you’re left with your improvising skills. You learn to adapt as you go. Here’s how my travel checklist looks - now:

-
    -
  • buy the ticket
  • -
  • start your adventure
  • -
-
Three monks ascending the stairs of an ancient temple. -
Three monks ascending the stairs of an ancient temple.
-
-

Confidence

-

Going to a new place can be quite terrifying. While change and uncertainty make us scared, traveling teaches us how - ridiculous it is to be afraid of something before it happens. The moment you face your fear and see there is nothing - to be afraid of, is the moment you discover bliss.

+
+
+
+

The three greatest things you learn from traveling

+

Like all the great things on earth traveling teaches us by example. Here are some of the most precious lessons I’ve + learned over the years of traveling.

+ +
+

The real voyage of discovery consists not in seeking new landscapes, but having new eyes.

+

Marcel Proust

+
+

Improvisation

+

Life doesn't allow us to execute every single plan perfectly. This especially seems to be the case when you travel. + You plan it down to every minute with a big checklist. But when it comes to executing it, something always comes up + and you’re left with your improvising skills. You learn to adapt as you go. Here’s how my travel checklist looks + now:

+
    +
  • buy the ticket
  • +
  • start your adventure
  • +
+
Three monks ascending the stairs of an ancient temple. +
Three monks ascending the stairs of an ancient temple.
+
+

Confidence

+

Going to a new place can be quite terrifying. While change and uncertainty make us scared, traveling teaches us how + ridiculous it is to be afraid of something before it happens. The moment you face your fear and see there is nothing + to be afraid of, is the moment you discover bliss.

+
diff --git a/packages/ckeditor5-ui/tests/manual/tickets/5328/1.html b/packages/ckeditor5-ui/tests/manual/tickets/5328/1.html new file mode 100644 index 00000000000..e72ed70ddde --- /dev/null +++ b/packages/ckeditor5-ui/tests/manual/tickets/5328/1.html @@ -0,0 +1,662 @@ +
+
+
+
+

Balloon sticks to the TARGET element.

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+   + +   + +   + +   +
+   + +   + +   + +   +
+   + +   + +   + +   +
+   + +   + +   + +   +
+   + +   + +   + +   +
+   + +   + +   + +   +
+   + +   + +   + +   +
+   + +   + +   + +   +
+   + +   + +   + +   +
+   + +   + +   + +   +
+
+

+ Test link +

+
+
+
+
+ +
+
+
+
+

+ Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem + ipsum Lorem + ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem + ipsum Lorem ipsum + Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum  +

+

+ Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem + ipsum Lorem + ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem + ipsum Lorem ipsum + Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum  +

+

+ Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem + ipsum Lorem + ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem + ipsum Lorem ipsum + Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum  +

+

Balloon sticks to the TARGET element.

+

+ Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem + ipsum Lorem + ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem + ipsum Lorem ipsum + Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum  +

+

+ Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem + ipsum Lorem + ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem + ipsum Lorem ipsum + Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum  +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ 1 + + 2 +
+ 3 + + 4 +
+ 5 + + 6 +
+ 7 + + 8 +
+ 1 + + 2 +
+ 3 + + 4 +
+ 5 + + 6 +
+ 7 + + 8 +
+ 1 + + 2 +
+ 3 + + 4 +
+ 5 + + 6 +
+ 7 + + 8 +
+ 1 + + 2 +
+ 3 + + 4 +
+ 5 + + 6 +
+ 7 + + 8 +
+
+ +
Three monks ascending the stairs of an ancient temple. +
Three monks ascending the stairs of an ancient temple.
+
+

+ Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem + ipsum Lorem + ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem + ipsum Lorem ipsum + Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum + Lorem ipsum Lorem + ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem + ipsum Lorem ipsum + Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum + Lorem ipsum Lorem + ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum + Lorem ipsum + Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum + Lorem ipsum Lorem + ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem + ipsum Lorem ipsum + Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum + Lorem ipsum Lorem + ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem + ipsum Lorem ipsum + Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum + Lorem ipsum Lorem + ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem + ipsum Lorem ipsum + Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum + Lorem ipsum Lorem + ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem + ipsum Lorem ipsum + Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum + Lorem ipsum Lorem + ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem + ipsum Lorem ipsum + Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum + Lorem ipsum Lorem + ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum +

+
+
+
+
+ +
+ +
+

+ Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem + ipsum Lorem + ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem + ipsum Lorem ipsum + Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum  +

+

+ Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem + ipsum Lorem + ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem + ipsum Lorem ipsum + Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum  +

+

+ Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem + ipsum Lorem + ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem + ipsum Lorem ipsum + Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum  +

+

Balloon sticks to the TARGET element.

+

+ Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem + ipsum Lorem + ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem + ipsum Lorem ipsum + Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum  +

+

+ Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem + ipsum Lorem + ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem + ipsum Lorem ipsum + Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum  +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ 1 + + 2 +
+ 3 + + 4 +
+ 5 + + 6 +
+ 7 + + 8 +
+ 1 + + 2 +
+ 3 + + 4 +
+ 5 + + 6 +
+ 7 + + 8 +
+ 1 + + 2 +
+ 3 + + 4 +
+ 5 + + 6 +
+ 7 + + 8 +
+ 1 + + 2 +
+ 3 + + 4 +
+ 5 + + 6 +
+ 7 + + 8 +
+
+ +
Three monks ascending the stairs of an ancient temple. +
Three monks ascending the stairs of an ancient temple.
+
+

+ Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem + ipsum Lorem + ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem + ipsum Lorem ipsum + Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum + Lorem ipsum Lorem + ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem + ipsum Lorem ipsum + Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum + Lorem ipsum Lorem + ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum + Lorem ipsum + Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum + Lorem ipsum Lorem + ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem + ipsum Lorem ipsum + Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum + Lorem ipsum Lorem + ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem + ipsum Lorem ipsum + Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum + Lorem ipsum Lorem + ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem + ipsum Lorem ipsum + Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum + Lorem ipsum Lorem + ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem + ipsum Lorem ipsum + Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum + Lorem ipsum Lorem + ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem + ipsum Lorem ipsum + Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum + Lorem ipsum Lorem + ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum Lorem ipsum +

+
+ +


















+


















+


















+


















+ + diff --git a/packages/ckeditor5-ui/tests/manual/tickets/5328/1.js b/packages/ckeditor5-ui/tests/manual/tickets/5328/1.js new file mode 100644 index 00000000000..cdbc37d08f5 --- /dev/null +++ b/packages/ckeditor5-ui/tests/manual/tickets/5328/1.js @@ -0,0 +1,120 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* globals window, document, console:false */ + +import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor'; +import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset'; +import { TableCaption, TableCellProperties, TableColumnResize, TableProperties, TableToolbar } from '@ckeditor/ckeditor5-table'; +import BalloonPanelView from '../../../../src/panel/balloon/balloonpanelview'; + +// Set initial scroll for the outer container element. +document.querySelector( '.container-outer:not( .container-outer--large )' ).scrollTop = 420; +document.querySelector( '.container-outer:not( .container-outer--large )' ).scrollLeft = 460; + +ClassicEditor + .create( document.querySelector( '#editor-stick' ), { + image: { + toolbar: [ 'imageStyle:inline', 'imageStyle:block', 'imageStyle:side', '|', 'imageTextAlternative' ] + }, + plugins: [ ArticlePluginSet, TableToolbar, TableCaption, TableCellProperties, TableColumnResize, TableProperties ], + toolbar: [ 'undo', 'redo', '|', 'bold', 'italic', 'link', '|', 'insertTable' ], + table: { + contentToolbar: [ + 'tableColumn', 'tableRow', 'mergeTableCells', 'tableProperties', 'tableCellProperties', 'toggleTableCaption' + ] + }, + ui: { + poweredBy: { + position: 'inside' + }, + viewportOffset: { + top: 30 + } + } + } ) + .then( editor => { + const panel = new BalloonPanelView(); + + editor.ui.view.body.add( panel ); + panel.element.innerHTML = 'Balloon content.'; + + editor.ui.view.element.querySelector( '.ck-editor__editable' ).scrollTop = 360; + + panel.pin( { + target: editor.ui.view.element.querySelector( '.ck-editor__editable p strong' ), + limiter: editor.ui.getEditableElement() + } ); + + window.stickEditor = editor; + } ) + .catch( err => { + console.error( err.stack ); + } ); + +// Editor with scroll +ClassicEditor + .create( document.querySelector( '#editor-with-scroll' ), { + image: { + toolbar: [ 'imageStyle:inline', 'imageStyle:block', 'imageStyle:side', '|', 'imageTextAlternative' ] + }, + plugins: [ ArticlePluginSet, TableToolbar, TableCaption, TableCellProperties, TableColumnResize, TableProperties ], + toolbar: [ 'undo', 'redo', '|', 'bold', 'italic', 'link', '|', 'insertTable' ], + table: { + contentToolbar: [ + 'tableColumn', 'tableRow', 'mergeTableCells', 'tableProperties', 'tableCellProperties', 'toggleTableCaption' + ] + }, + ui: { + viewportOffset: { + top: 0 + } + } + } ) + .then( editor => { + const panel = new BalloonPanelView(); + + editor.ui.view.body.add( panel ); + panel.element.innerHTML = 'Balloon content.'; + + editor.ui.view.element.querySelector( '.ck-editor__editable' ).scrollTop = 360; + + panel.pin( { + target: editor.ui.view.element.querySelector( '.ck-editor__editable p strong' ), + limiter: editor.ui.getEditableElement() + } ); + + window.scrollEditor = editor; + } ) + .catch( err => { + console.error( err.stack ); + } ); + +// Editor "out of the box" +ClassicEditor + .create( document.querySelector( '#editor-out-of-the-box' ), { + image: { + toolbar: [ 'imageStyle:inline', 'imageStyle:block', 'imageStyle:side', '|', 'imageTextAlternative' ] + }, + plugins: [ ArticlePluginSet, TableToolbar, TableCaption, TableCellProperties, TableColumnResize, TableProperties ], + toolbar: [ 'undo', 'redo', '|', 'bold', 'italic', 'link', '|', 'insertTable' ], + table: { + contentToolbar: [ + 'tableColumn', 'tableRow', 'mergeTableCells', 'tableProperties', 'tableCellProperties', 'toggleTableCaption' + ] + }, + ui: { + viewportOffset: { + top: 0 + } + } + } ) + .then( editor => { + window.outOfTheBoxEditor = editor; + } ) + .catch( err => { + console.error( err.stack ); + } ); + diff --git a/packages/ckeditor5-ui/tests/manual/tickets/5328/1.md b/packages/ckeditor5-ui/tests/manual/tickets/5328/1.md new file mode 100644 index 00000000000..54c051cd105 --- /dev/null +++ b/packages/ckeditor5-ui/tests/manual/tickets/5328/1.md @@ -0,0 +1,3 @@ +## BalloonPanelView + +Scroll editable elements and the container (horizontally as well). The balloon should disappear when elements it should be attached to move off the visible area (fixed balloon, tables, images, links). diff --git a/packages/ckeditor5-ui/tests/panel/balloon/balloonpanelview.js b/packages/ckeditor5-ui/tests/panel/balloon/balloonpanelview.js index c0133a71eaa..89714c67a73 100644 --- a/packages/ckeditor5-ui/tests/panel/balloon/balloonpanelview.js +++ b/packages/ckeditor5-ui/tests/panel/balloon/balloonpanelview.js @@ -452,7 +452,7 @@ describe( 'BalloonPanelView', () => { it( 'should put balloon on the `south east` position when `north east` is limited', () => { mockBoundingBox( limiter, { left: 0, - top: -400, + top: -350, width: 500, height: 500 } ); @@ -469,6 +469,286 @@ describe( 'BalloonPanelView', () => { expect( view.position ).to.equal( 'arrow_nw' ); } ); } ); + + describe( 'limited by parent with overflow', () => { + let parentWithOverflow, limiter, target; + const OFF_THE_SCREEN_POSITION = -99999; + + beforeEach( () => { + parentWithOverflow = document.createElement( 'div' ); + parentWithOverflow.style.overflow = 'scroll'; + parentWithOverflow.style.width = '100px'; + parentWithOverflow.style.height = '100px'; + + limiter = document.createElement( 'div' ); + target = document.createElement( 'div' ); + + // Mock parent dimensions. + mockBoundingBox( parentWithOverflow, { + left: 0, + top: 0, + width: 100, + height: 100 + } ); + + target.style.width = '50px'; + target.style.height = '50px'; + + parentWithOverflow.appendChild( limiter ); + parentWithOverflow.appendChild( target ); + document.body.appendChild( parentWithOverflow ); + } ); + + afterEach( () => { + limiter.remove(); + target.remove(); + parentWithOverflow.remove(); + } ); + + it( 'should not show the balloon if the target is not visible (vertical top)', () => { + mockBoundingBox( target, { + top: -51, + left: 0, + width: 50, + height: 50 + } ); + + view.attachTo( { target, limiter } ); + + expect( view.top ).to.equal( OFF_THE_SCREEN_POSITION ); + expect( view.left ).to.equal( OFF_THE_SCREEN_POSITION ); + } ); + + it( 'should not show the balloon if the target is not visible (vertical bottom)', () => { + mockBoundingBox( target, { + top: 101, + left: 0, + width: 50, + height: 50 + } ); + + view.attachTo( { target, limiter } ); + + expect( view.top ).to.equal( OFF_THE_SCREEN_POSITION ); + expect( view.left ).to.equal( OFF_THE_SCREEN_POSITION ); + } ); + + it( 'should not show the balloon if the target is not visible (horizontal left)', () => { + mockBoundingBox( target, { + top: 0, + left: -100, + width: 50, + height: 50 + } ); + + view.attachTo( { target, limiter } ); + + expect( view.top ).to.equal( OFF_THE_SCREEN_POSITION ); + expect( view.left ).to.equal( OFF_THE_SCREEN_POSITION ); + } ); + + it( 'should not show the balloon if the target is not visible (horizontal right)', () => { + mockBoundingBox( target, { + top: 0, + left: 101, + width: 50, + height: 50 + } ); + + view.attachTo( { target, limiter } ); + + expect( view.top ).to.equal( OFF_THE_SCREEN_POSITION ); + expect( view.left ).to.equal( OFF_THE_SCREEN_POSITION ); + } ); + + it( 'should get proper HTML element when callback is passed as a target', () => { + const callback = sinon.stub().returns( document.createElement( 'a' ) ); + + view.attachTo( { target: callback, limiter } ); + + sinon.assert.called( callback ); + } ); + + it( 'should show the balloon when limiter is not defined', () => { + mockBoundingBox( target, { + top: 50, + left: 50, + width: 50, + height: 50 + } ); + + view.attachTo( { target } ); + + expect( view.left ).to.equal( 25 ); + } ); + } ); + + describe( 'limited by editor with overflow', () => { + let limiter, target; + const OFF_THE_SCREEN_POSITION = -99999; + + beforeEach( () => { + limiter = document.createElement( 'div' ); + limiter.style.overflow = 'scroll'; + limiter.style.width = '100px'; + limiter.style.height = '100px'; + + // Mock parent dimensions. + mockBoundingBox( limiter, { + left: 0, + top: 0, + width: 100, + height: 100 + } ); + + target = document.createElement( 'div' ); + target.style.width = '200px'; + target.style.height = '200px'; + + limiter.appendChild( target ); + document.body.appendChild( limiter ); + } ); + + afterEach( () => { + limiter.remove(); + target.remove(); + } ); + + it( 'should not show the balloon if the target is not visible (vertical top)', () => { + mockBoundingBox( target, { + top: -51, + left: 0, + width: 50, + height: 50 + } ); + + view.attachTo( { target, limiter } ); + + expect( view.top ).to.equal( OFF_THE_SCREEN_POSITION ); + expect( view.left ).to.equal( OFF_THE_SCREEN_POSITION ); + } ); + + it( 'should not show the balloon if the target is not visible (vertical bottom)', () => { + mockBoundingBox( target, { + top: 159, + left: 0, + width: 50, + height: 50 + } ); + + view.attachTo( { target, limiter } ); + + expect( view.top ).to.equal( OFF_THE_SCREEN_POSITION ); + expect( view.left ).to.equal( OFF_THE_SCREEN_POSITION ); + } ); + + it( 'should not show the balloon if the target is not visible (horizontal left)', () => { + mockBoundingBox( target, { + top: 0, + left: -100, + width: 50, + height: 50 + } ); + + view.attachTo( { target, limiter } ); + + expect( view.top ).to.equal( OFF_THE_SCREEN_POSITION ); + expect( view.left ).to.equal( OFF_THE_SCREEN_POSITION ); + } ); + + it( 'should not show the balloon if the target is not visible (horizontal right)', () => { + mockBoundingBox( target, { + top: 0, + left: 101, + width: 50, + height: 50 + } ); + + view.attachTo( { target, limiter } ); + + expect( view.top ).to.equal( OFF_THE_SCREEN_POSITION ); + expect( view.left ).to.equal( OFF_THE_SCREEN_POSITION ); + } ); + + it( 'should show the balloon when limiter is not defined', () => { + mockBoundingBox( target, { + top: 50, + left: 50, + width: 50, + height: 50 + } ); + + view.attachTo( { target } ); + + expect( view.left ).to.equal( 25 ); + } ); + } ); + + describe( 'limited by editor with overflow and a parent with overflow', () => { + let limiter, target, parentWithOverflow, limiterParent; + const OFF_THE_SCREEN_POSITION = -99999; + + beforeEach( () => { + limiter = document.createElement( 'div' ); + limiter.style.overflow = 'scroll'; + + parentWithOverflow = document.createElement( 'div' ); + parentWithOverflow.style.overflow = 'scroll'; + + limiterParent = document.createElement( 'div' ); + + target = document.createElement( 'div' ); + + // Mock parent dimensions. + mockBoundingBox( parentWithOverflow, { + left: 0, + top: 0, + width: 200, + height: 200 + } ); + + // Mock limiter parent dimensions. + mockBoundingBox( limiterParent, { + left: 0, + top: 0, + width: 400, + height: 400 + } ); + + // Mock limiter dimensions. + mockBoundingBox( limiter, { + left: 0, + top: 0, + width: 100, + height: 100 + } ); + + limiter.appendChild( target ); + limiterParent.appendChild( limiter ); + parentWithOverflow.appendChild( limiterParent ); + document.body.appendChild( parentWithOverflow ); + } ); + + afterEach( () => { + limiter.remove(); + target.remove(); + parentWithOverflow.remove(); + } ); + + it( 'should not show the balloon if the target is not visible ( target higher than scrollable ancestor )', () => { + mockBoundingBox( target, { + top: -250, + left: 0, + width: 200, + height: 200 + } ); + + view.attachTo( { target, limiter } ); + + expect( view.top ).to.equal( OFF_THE_SCREEN_POSITION ); + expect( view.left ).to.equal( OFF_THE_SCREEN_POSITION ); + } ); + } ); } ); describe( 'pin() and unpin()', () => { diff --git a/packages/ckeditor5-ui/tests/panel/sticky/stickypanelview.js b/packages/ckeditor5-ui/tests/panel/sticky/stickypanelview.js index 2d02e0643a1..e109fa16cbe 100644 --- a/packages/ckeditor5-ui/tests/panel/sticky/stickypanelview.js +++ b/packages/ckeditor5-ui/tests/panel/sticky/stickypanelview.js @@ -332,7 +332,10 @@ describe( 'StickyPanelView', () => { testUtils.sinon.stub( limiterElement, 'getBoundingClientRect' ).returns( { top: -80, bottom: 60, - height: 140 + height: 140, + width: 100, + left: 0, + right: 100 } ); testUtils.sinon.stub( contentElement, 'getBoundingClientRect' ).returns( { @@ -352,7 +355,10 @@ describe( 'StickyPanelView', () => { testUtils.sinon.stub( limiterElement, 'getBoundingClientRect' ).returns( { top: -10, bottom: 90, - height: 100 + height: 100, + width: 100, + left: 0, + right: 100 } ); testUtils.sinon.stub( contentElement, 'getBoundingClientRect' ).returns( { @@ -418,13 +424,19 @@ describe( 'StickyPanelView', () => { testUtils.sinon.stub( scrollableContainer, 'getBoundingClientRect' ).returns( { top: 40, bottom: 140, - height: 100 + height: 100, + width: 100, + left: 0, + right: 100 } ); testUtils.sinon.stub( limiterElement, 'getBoundingClientRect' ).returns( { top: 20, bottom: 200, - height: 180 + height: 180, + width: 100, + left: 0, + right: 100 } ); testUtils.sinon.stub( contentElement, 'getBoundingClientRect' ).returns( { @@ -449,13 +461,19 @@ describe( 'StickyPanelView', () => { testUtils.sinon.stub( scrollableContainer, 'getBoundingClientRect' ).returns( { top: 40, bottom: 140, - height: 100 + height: 100, + width: 100, + left: 0, + right: 100 } ); testUtils.sinon.stub( limiterElement, 'getBoundingClientRect' ).returns( { top: -80, bottom: 60, - height: 140 + height: 140, + width: 100, + left: 0, + right: 100 } ); testUtils.sinon.stub( contentElement, 'getBoundingClientRect' ).returns( { @@ -485,13 +503,19 @@ describe( 'StickyPanelView', () => { testUtils.sinon.stub( scrollableContainer, 'getBoundingClientRect' ).returns( { top: 20, bottom: 140, - height: 120 + height: 120, + width: 100, + left: 0, + right: 100 } ); testUtils.sinon.stub( limiterElement, 'getBoundingClientRect' ).returns( { top: 20, bottom: 200, - height: 180 + height: 180, + width: 100, + left: 0, + right: 100 } ); testUtils.sinon.stub( contentElement, 'getBoundingClientRect' ).returns( { @@ -518,13 +542,19 @@ describe( 'StickyPanelView', () => { testUtils.sinon.stub( scrollableContainer, 'getBoundingClientRect' ).returns( { top: 40, bottom: 140, - height: 100 + height: 100, + width: 100, + left: 0, + right: 100 } ); testUtils.sinon.stub( limiterElement, 'getBoundingClientRect' ).returns( { top: -80, bottom: 60, - height: 140 + height: 140, + width: 100, + left: 0, + right: 100 } ); testUtils.sinon.stub( contentElement, 'getBoundingClientRect' ).returns( { @@ -549,13 +579,19 @@ describe( 'StickyPanelView', () => { testUtils.sinon.stub( scrollableContainer, 'getBoundingClientRect' ).returns( { top: 120, bottom: 140, - height: 100 + height: 100, + width: 100, + left: 0, + right: 100 } ); testUtils.sinon.stub( limiterElement, 'getBoundingClientRect' ).returns( { top: -80, bottom: 60, - height: 140 + height: 140, + width: 100, + left: 0, + right: 100 } ); testUtils.sinon.stub( contentElement, 'getBoundingClientRect' ).returns( { @@ -606,19 +642,28 @@ describe( 'StickyPanelView', () => { testUtils.sinon.stub( scrollableOuterParent, 'getBoundingClientRect' ).returns( { top: 10, bottom: 160, - height: 150 + height: 150, + width: 100, + left: 0, + right: 100 } ); testUtils.sinon.stub( scrollableInnerParent, 'getBoundingClientRect' ).returns( { top: 20, bottom: 140, - height: 120 + height: 120, + width: 100, + left: 0, + right: 100 } ); testUtils.sinon.stub( limiterElement, 'getBoundingClientRect' ).returns( { top: 40, bottom: 100, - height: 60 + height: 60, + width: 100, + left: 0, + right: 100 } ); testUtils.sinon.stub( contentElement, 'getBoundingClientRect' ).returns( { @@ -643,19 +688,28 @@ describe( 'StickyPanelView', () => { testUtils.sinon.stub( scrollableOuterParent, 'getBoundingClientRect' ).returns( { top: 50, bottom: 160, - height: 150 + height: 150, + width: 100, + left: 0, + right: 100 } ); testUtils.sinon.stub( scrollableInnerParent, 'getBoundingClientRect' ).returns( { top: 20, bottom: 140, - height: 120 + height: 120, + width: 100, + left: 0, + right: 100 } ); testUtils.sinon.stub( limiterElement, 'getBoundingClientRect' ).returns( { top: 40, bottom: 140, - height: 100 + height: 100, + width: 100, + left: 0, + right: 100 } ); testUtils.sinon.stub( contentElement, 'getBoundingClientRect' ).returns( { @@ -680,19 +734,28 @@ describe( 'StickyPanelView', () => { testUtils.sinon.stub( scrollableOuterParent, 'getBoundingClientRect' ).returns( { top: 50, bottom: 160, - height: 150 + height: 150, + width: 100, + left: 0, + right: 100 } ); testUtils.sinon.stub( scrollableInnerParent, 'getBoundingClientRect' ).returns( { top: 20, bottom: 140, - height: 120 + height: 120, + width: 100, + left: 0, + right: 100 } ); testUtils.sinon.stub( limiterElement, 'getBoundingClientRect' ).returns( { top: 40, bottom: 110, - height: 60 + height: 60, + width: 100, + left: 0, + right: 100 } ); testUtils.sinon.stub( contentElement, 'getBoundingClientRect' ).returns( { @@ -717,19 +780,28 @@ describe( 'StickyPanelView', () => { testUtils.sinon.stub( scrollableOuterParent, 'getBoundingClientRect' ).returns( { top: 50, bottom: 160, - height: 150 + height: 150, + width: 100, + left: 0, + right: 100 } ); testUtils.sinon.stub( scrollableInnerParent, 'getBoundingClientRect' ).returns( { top: -20, bottom: 50, - height: 70 + height: 70, + width: 100, + left: 0, + right: 100 } ); testUtils.sinon.stub( limiterElement, 'getBoundingClientRect' ).returns( { top: 0, bottom: 40, - height: 40 + height: 40, + width: 100, + left: 0, + right: 100 } ); testUtils.sinon.stub( contentElement, 'getBoundingClientRect' ).returns( { @@ -755,7 +827,10 @@ describe( 'StickyPanelView', () => { testUtils.sinon.stub( limiterElement, 'getBoundingClientRect' ).returns( { top: -10, bottom: 70, - height: 100 + height: 100, + width: 100, + left: 0, + right: 100 } ); testUtils.sinon.stub( contentElement, 'getBoundingClientRect' ).returns( { @@ -781,7 +856,9 @@ describe( 'StickyPanelView', () => { top: -30, bottom: 50, left: 60, - height: 80 + height: 80, + width: 100, + right: 160 } ); testUtils.sinon.stub( contentElement, 'getBoundingClientRect' ).returns( { diff --git a/packages/ckeditor5-ui/tests/toolbar/block/blocktoolbar.js b/packages/ckeditor5-ui/tests/toolbar/block/blocktoolbar.js index 76fa3080aef..87f9a7774fc 100644 --- a/packages/ckeditor5-ui/tests/toolbar/block/blocktoolbar.js +++ b/packages/ckeditor5-ui/tests/toolbar/block/blocktoolbar.js @@ -456,7 +456,7 @@ describe( 'BlockToolbar', () => { editor.ui.fire( 'update' ); - expect( blockToolbar.buttonView.top ).to.equal( 470 ); + expect( blockToolbar.buttonView.top ).to.equal( 462 ); expect( blockToolbar.buttonView.left ).to.equal( 100 ); } ); @@ -491,7 +491,7 @@ describe( 'BlockToolbar', () => { editor.ui.fire( 'update' ); - expect( blockToolbar.buttonView.top ).to.equal( 472 ); + expect( blockToolbar.buttonView.top ).to.equal( 464 ); expect( blockToolbar.buttonView.left ).to.equal( 100 ); } ); @@ -528,7 +528,7 @@ describe( 'BlockToolbar', () => { editor.ui.fire( 'update' ); - expect( blockToolbar.buttonView.top ).to.equal( 472 ); + expect( blockToolbar.buttonView.top ).to.equal( 464 ); expect( blockToolbar.buttonView.left ).to.equal( 600 ); } ); diff --git a/packages/ckeditor5-utils/src/dom/getelementsintersectionrect.ts b/packages/ckeditor5-utils/src/dom/getelementsintersectionrect.ts deleted file mode 100644 index ddbf509b9a3..00000000000 --- a/packages/ckeditor5-utils/src/dom/getelementsintersectionrect.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license - */ - -/** - * @module utils/dom/getelementsintersectionrect - */ - -import global from './global'; -import Rect from './rect'; - -/** - * Calculates the intersection `Rect` of a given set of elements (and/or a `document`). - * Also, takes into account the viewport top offset configuration. - * - * @internal - * @param elements - * @param viewportTopOffset - */ -export default function getElementsIntersectionRect( - elements: Array, - viewportTopOffset: number = 0 -): Rect | null { - const elementRects = elements.map( element => { - // The document (window) is yet another "element", but cropped by the top offset. - if ( element instanceof Document ) { - const windowRect = new Rect( global.window ); - - windowRect.top += viewportTopOffset; - windowRect.height -= viewportTopOffset; - - return windowRect; - } else { - return new Rect( element ); - } - } ); - - let intersectionRect: Rect | null = elementRects[ 0 ]; - - // @if CK_DEBUG_GETELEMENTSINTERSECTIONRECT // for ( const rect of elementRects ) { - // @if CK_DEBUG_GETELEMENTSINTERSECTIONRECT // RectDrawer.draw( rect, { - // @if CK_DEBUG_GETELEMENTSINTERSECTIONRECT // outlineWidth: '1px', opacity: '.7', outlineStyle: 'dashed' - // @if CK_DEBUG_GETELEMENTSINTERSECTIONRECT // }, 'Scrollable element' ); - // @if CK_DEBUG_GETELEMENTSINTERSECTIONRECT // } - - for ( const rect of elementRects.slice( 1 ) ) { - if ( intersectionRect ) { - intersectionRect = intersectionRect.getIntersection( rect ); - } - } - - return intersectionRect; -} diff --git a/packages/ckeditor5-utils/src/dom/getscrollableancestors.ts b/packages/ckeditor5-utils/src/dom/getscrollableancestors.ts deleted file mode 100644 index 715c475c369..00000000000 --- a/packages/ckeditor5-utils/src/dom/getscrollableancestors.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license - */ - -/** - * @module utils/dom/getscrollableancestors - */ - -import global from './global'; -import findClosestScrollableAncestor from './findclosestscrollableancestor'; - -/** - * Loops over the given element's ancestors to find all the scrollable elements. - * - * **Note**: The `document` is always included in the returned array. - * - * @internal - * @param element - * @returns An array of scrollable element's ancestors (including the `document`). - */ -export default function getScrollableAncestors( element: HTMLElement ): Array { - const scrollableAncestors = []; - let scrollableAncestor = findClosestScrollableAncestor( element ); - - while ( scrollableAncestor && scrollableAncestor !== global.document.body ) { - scrollableAncestors.push( scrollableAncestor ); - scrollableAncestor = findClosestScrollableAncestor( scrollableAncestor! ); - } - - scrollableAncestors.push( global.document ); - - return scrollableAncestors; -} diff --git a/packages/ckeditor5-utils/src/dom/position.ts b/packages/ckeditor5-utils/src/dom/position.ts index ed61055ac22..02d8b9119b3 100644 --- a/packages/ckeditor5-utils/src/dom/position.ts +++ b/packages/ckeditor5-utils/src/dom/position.ts @@ -10,16 +10,49 @@ import global from './global'; import Rect, { type RectSource } from './rect'; import getPositionedAncestor from './getpositionedancestor'; -import getBorderWidths from './getborderwidths'; import { isFunction } from 'lodash-es'; -// @if CK_DEBUG_POSITION // const RectDrawer = require( '@ckeditor/ckeditor5-utils/tests/_utils/rectdrawer' ).default +// @if CK_DEBUG_POSITION // const { +// @if CK_DEBUG_POSITION // default: RectDrawer, +// @if CK_DEBUG_POSITION // diagonalStylesBlack, +// @if CK_DEBUG_POSITION // diagonalStylesGreen, +// @if CK_DEBUG_POSITION // diagonalStylesRed +// @if CK_DEBUG_POSITION // } = require( '@ckeditor/ckeditor5-utils/tests/_utils/rectdrawer' ); +// @if CK_DEBUG_POSITION // const TARGET_RECT_STYLE = { +// @if CK_DEBUG_POSITION // outlineWidth: '2px', outlineStyle: 'dashed', outlineColor: 'blue', outlineOffset: '2px' +// @if CK_DEBUG_POSITION // }; +// @if CK_DEBUG_POSITION // const VISIBLE_TARGET_RECT_STYLE = { +// @if CK_DEBUG_POSITION // ...diagonalStylesBlack, +// @if CK_DEBUG_POSITION // opacity: '1', +// @if CK_DEBUG_POSITION // backgroundColor: '#00000033', +// @if CK_DEBUG_POSITION // outlineWidth: '2px' +// @if CK_DEBUG_POSITION // }; +// @if CK_DEBUG_POSITION // const VIEWPORT_RECT_STYLE = { +// @if CK_DEBUG_POSITION // outlineWidth: '2px', +// @if CK_DEBUG_POSITION // outlineOffset: '-2px', +// @if CK_DEBUG_POSITION // outlineStyle: 'solid', +// @if CK_DEBUG_POSITION // outlineColor: 'red' +// @if CK_DEBUG_POSITION // }; +// @if CK_DEBUG_POSITION // const VISIBLE_LIMITER_RECT_STYLE = { +// @if CK_DEBUG_POSITION // ...diagonalStylesGreen, +// @if CK_DEBUG_POSITION // outlineWidth: '2px', +// @if CK_DEBUG_POSITION // outlineOffset: '-2px' +// @if CK_DEBUG_POSITION // }; +// @if CK_DEBUG_POSITION // const ELEMENT_RECT_STYLE = { +// @if CK_DEBUG_POSITION // outlineWidth: '2px', outlineColor: 'orange', outlineOffset: '-2px' +// @if CK_DEBUG_POSITION // }; +// @if CK_DEBUG_POSITION // const CHOSEN_POSITION_RECT_STYLE = { +// @if CK_DEBUG_POSITION // opacity: .5, outlineColor: 'magenta', backgroundColor: 'magenta' +// @if CK_DEBUG_POSITION // }; /** * Calculates the `position: absolute` coordinates of a given element so it can be positioned with respect to the * target in the visually most efficient way, taking various restrictions like viewport or limiter geometry * into consideration. * + * **Note**: If there are no position coordinates found that meet the requirements (arguments of this helper), + * `null` is returned. + * * ```ts * // The element which is to be positioned. * const element = document.body.querySelector( '#toolbar' ); @@ -80,7 +113,9 @@ import { isFunction } from 'lodash-es'; * * @param options The input data and configuration of the helper. */ -export function getOptimalPosition( { element, target, positions, limiter, fitInViewport, viewportOffsetConfig }: Options ): Position { +export function getOptimalPosition( { + element, target, positions, limiter, fitInViewport, viewportOffsetConfig +}: Options ): Position | null { // If the {@link module:utils/dom/position~Options#target} is a function, use what it returns. // https://github.com/ckeditor/ckeditor5-utils/issues/157 if ( isFunction( target ) ) { @@ -94,41 +129,68 @@ export function getOptimalPosition( { element, target, positions, limiter, fitIn } const positionedElementAncestor = getPositionedAncestor( element ); + const constrainedViewportRect = getConstrainedViewportRect( viewportOffsetConfig ); const elementRect = new Rect( element ); - const targetRect = new Rect( target ); + const visibleTargetRect = getVisibleViewportIntersectionRect( target, constrainedViewportRect ); - let bestPosition: Position; + let bestPosition: Position | null; + // @if CK_DEBUG_POSITION // const targetRect = new Rect( target ); // @if CK_DEBUG_POSITION // RectDrawer.clear(); - // @if CK_DEBUG_POSITION // RectDrawer.draw( targetRect, { outlineWidth: '5px' }, 'Target' ); + // @if CK_DEBUG_POSITION // RectDrawer.draw( targetRect, TARGET_RECT_STYLE, 'Target' ); + // @if CK_DEBUG_POSITION // if ( constrainedViewportRect ) { + // @if CK_DEBUG_POSITION // RectDrawer.draw( constrainedViewportRect, VIEWPORT_RECT_STYLE, 'Viewport' ); + // @if CK_DEBUG_POSITION // } + + // If the target got cropped by ancestors or went off the screen, positioning does not make any sense. + if ( !visibleTargetRect || !constrainedViewportRect.getIntersection( visibleTargetRect ) ) { + return null; + } - const viewportRect = fitInViewport && getConstrainedViewportRect( viewportOffsetConfig ) || null; - const positionOptions = { targetRect, elementRect, positionedElementAncestor, viewportRect }; + // @if CK_DEBUG_POSITION // RectDrawer.draw( visibleTargetRect, VISIBLE_TARGET_RECT_STYLE, 'VisTgt' ); + + const positionOptions: PositionObjectOptions = { + targetRect: visibleTargetRect, + elementRect, + positionedElementAncestor, + viewportRect: constrainedViewportRect + }; // If there are no limits, just grab the very first position and be done with that drama. if ( !limiter && !fitInViewport ) { bestPosition = new PositionObject( positions[ 0 ], positionOptions ); } else { - const limiterRect = limiter && new Rect( limiter ).getVisible(); - - // @if CK_DEBUG_POSITION // if ( viewportRect ) { - // @if CK_DEBUG_POSITION // RectDrawer.draw( viewportRect, { outlineWidth: '5px' }, 'Viewport' ); - // @if CK_DEBUG_POSITION // } + if ( limiter ) { + const visibleLimiterRect = getVisibleViewportIntersectionRect( limiter, constrainedViewportRect ); - // @if CK_DEBUG_POSITION // if ( limiter ) { - // @if CK_DEBUG_POSITION // RectDrawer.draw( limiterRect, { outlineWidth: '5px', outlineColor: 'green' }, 'Visible limiter' ); - // @if CK_DEBUG_POSITION // } - - Object.assign( positionOptions, { limiterRect, viewportRect } ); + if ( visibleLimiterRect ) { + positionOptions.limiterRect = visibleLimiterRect; + // @if CK_DEBUG_POSITION // RectDrawer.draw( visibleLimiterRect, VISIBLE_LIMITER_RECT_STYLE, 'VisLim' ); + } + } // If there's no best position found, i.e. when all intersections have no area because - // rects have no width or height, then just use the first available position. - bestPosition = getBestPosition( positions, positionOptions ) || new PositionObject( positions[ 0 ], positionOptions ); + // rects have no width or height, then just return `null` + bestPosition = getBestPosition( positions, positionOptions ); } return bestPosition; } +/** + * Returns intersection of visible source `Rect` with Viewport `Rect`. In case when source `Rect` is not visible + * or there is no intersection between source `Rect` and Viewport `Rect`, `null` will be returned. + */ +function getVisibleViewportIntersectionRect( source: RectSource, viewportRect: Rect ): Rect | null { + const visibleSourceRect = new Rect( source ).getVisible(); + + if ( !visibleSourceRect ) { + return null; + } + + return visibleSourceRect.getIntersection( viewportRect ); +} + /** * Returns a viewport `Rect` shrunk by the viewport offset config from all sides. */ @@ -172,6 +234,11 @@ function getBestPosition( // If a such position is found that element is fully contained by the limiter then, obviously, // there will be no better one, so finishing. if ( limiterIntersectionArea === elementRectArea ) { + // @if CK_DEBUG_POSITION // RectDrawer.draw( position._rect, CHOSEN_POSITION_RECT_STYLE, [ + // @if CK_DEBUG_POSITION // position.name, + // @if CK_DEBUG_POSITION // '100% fit', + // @if CK_DEBUG_POSITION // ].join( '\n' ) ); + return position; } @@ -179,64 +246,23 @@ function getBestPosition( // and _limiterIntersectionArea plane (without sqrt because we are looking for max value). const fitFactor = viewportIntersectionArea ** 2 + limiterIntersectionArea ** 2; + // @if CK_DEBUG_POSITION // RectDrawer.draw( position._rect, { opacity: .4 }, [ + // @if CK_DEBUG_POSITION // position.name, + // @if CK_DEBUG_POSITION // 'Vi=' + Math.round( viewportIntersectionArea ), + // @if CK_DEBUG_POSITION // 'Li=' + Math.round( limiterIntersectionArea ) + // @if CK_DEBUG_POSITION // ].join( '\n' ) ); + if ( fitFactor > maxFitFactor ) { maxFitFactor = fitFactor; bestPosition = position; } } - return bestPosition; -} + // @if CK_DEBUG_POSITION // if ( bestPosition ) { + // @if CK_DEBUG_POSITION // RectDrawer.draw( bestPosition._rect, CHOSEN_POSITION_RECT_STYLE ); + // @if CK_DEBUG_POSITION // } -/** - * For a given absolute Rect coordinates object and a positioned element ancestor, it updates its - * coordinates that make up for the position and the scroll of the ancestor. - * - * This is necessary because while Rects (and DOMRects) are relative to the browser's viewport, their coordinates - * are used in real–life to position elements with `position: absolute`, which are scoped by any positioned - * (and scrollable) ancestors. - */ -function shiftRectToCompensatePositionedAncestor( rect: Rect, positionedElementAncestor: HTMLElement ): void { - const ancestorPosition = getRectForAbsolutePositioning( new Rect( positionedElementAncestor ) ); - const ancestorBorderWidths = getBorderWidths( positionedElementAncestor ); - - let moveX = 0; - let moveY = 0; - - // (https://github.com/ckeditor/ckeditor5-ui-default/issues/126) - // If there's some positioned ancestor of the panel, then its `Rect` must be taken into - // consideration. `Rect` is always relative to the viewport while `position: absolute` works - // with respect to that positioned ancestor. - moveX -= ancestorPosition.left; - moveY -= ancestorPosition.top; - - // (https://github.com/ckeditor/ckeditor5-utils/issues/139) - // If there's some positioned ancestor of the panel, not only its position must be taken into - // consideration (see above) but also its internal scrolls. Scroll have an impact here because `Rect` - // is relative to the viewport (it doesn't care about scrolling), while `position: absolute` - // must compensate that scrolling. - moveX += positionedElementAncestor.scrollLeft; - moveY += positionedElementAncestor.scrollTop; - - // (https://github.com/ckeditor/ckeditor5-utils/issues/139) - // If there's some positioned ancestor of the panel, then its `Rect` includes its CSS `borderWidth` - // while `position: absolute` positioning does not consider it. - // E.g. `{ position: absolute, top: 0, left: 0 }` means upper left corner of the element, - // not upper-left corner of its border. - moveX -= ancestorBorderWidths.left; - moveY -= ancestorBorderWidths.top; - - rect.moveBy( moveX, moveY ); -} - -/** - * DOMRect (also Rect) works in a scroll–independent geometry but `position: absolute` doesn't. - * This function converts Rect to `position: absolute` coordinates. - */ -function getRectForAbsolutePositioning( rect: Rect ): Rect { - const { scrollX, scrollY } = global.window; - - return rect.clone().moveBy( scrollX, scrollY ); + return bestPosition; } /** @@ -274,6 +300,18 @@ export interface Position { readonly top: number; } +/** + * A position options object which options are passed in the {@link module:utils/dom/position~PositionObject Class constructor}, + * to be used by {@link module:utils/dom/position~PositioningFunction positioning function}. + */ +type PositionObjectOptions = { + elementRect: Rect; + targetRect: Rect; + viewportRect: Rect; + positionedElementAncestor?: HTMLElement | null; + limiterRect?: Rect; +}; + /** * A position class which instances are created and used by the {@link module:utils/dom/position~getOptimalPosition} helper. * @@ -285,7 +323,7 @@ class PositionObject implements Position { public name?: string; public config?: object; - private _positioningFunctionCorrdinates!: { left: number; top: number }; + private _positioningFunctionCoordinates!: { left: number; top: number }; private _options!: ConstructorParameters[ 1 ]; private _cachedRect?: Rect; private _cachedAbsoluteRect?: Rect; @@ -304,15 +342,14 @@ class PositionObject implements Position { */ constructor( positioningFunction: PositioningFunction, - options: { - readonly elementRect: Rect; - readonly targetRect: Rect; - readonly viewportRect: Rect | null; - readonly positionedElementAncestor?: HTMLElement | null; - readonly limiterRect?: Rect; - } + options: Readonly ) { - const positioningFunctionOutput = positioningFunction( options.targetRect, options.elementRect, options.viewportRect ); + const positioningFunctionOutput = positioningFunction( + options.targetRect, + options.elementRect, + options.viewportRect, + options.limiterRect + ); // Nameless position for a function that didn't participate. if ( !positioningFunctionOutput ) { @@ -324,7 +361,7 @@ class PositionObject implements Position { this.name = name; this.config = config; - this._positioningFunctionCorrdinates = { left, top }; + this._positioningFunctionCoordinates = { left, top }; this._options = options; } @@ -351,20 +388,7 @@ class PositionObject implements Position { const limiterRect = this._options.limiterRect; if ( limiterRect ) { - const viewportRect = this._options.viewportRect; - - if ( viewportRect ) { - // Consider only the part of the limiter which is visible in the viewport. So the limiter is getting limited. - const limiterViewportIntersectRect = limiterRect.getIntersection( viewportRect ); - - if ( limiterViewportIntersectRect ) { - // If the limiter is within the viewport, then check the intersection between that part of the - // limiter and actual position. - return limiterViewportIntersectRect.getIntersectionArea( this._rect ); - } - } else { - return limiterRect.getIntersectionArea( this._rect ); - } + return limiterRect.getIntersectionArea( this._rect ); } return 0; @@ -374,13 +398,9 @@ class PositionObject implements Position { * An intersection area between positioned element and viewport. */ public get viewportIntersectionArea(): number { - const viewportRect = this._options.viewportRect; + const viewportRect = this._options.viewportRect!; - if ( viewportRect ) { - return viewportRect.getIntersectionArea( this._rect ); - } - - return 0; + return viewportRect.getIntersectionArea( this._rect ); } /** @@ -393,8 +413,8 @@ class PositionObject implements Position { } this._cachedRect = this._options.elementRect.clone().moveTo( - this._positioningFunctionCorrdinates.left, - this._positioningFunctionCorrdinates.top + this._positioningFunctionCoordinates.left, + this._positioningFunctionCoordinates.top ); return this._cachedRect; @@ -408,13 +428,9 @@ class PositionObject implements Position { return this._cachedAbsoluteRect; } - this._cachedAbsoluteRect = getRectForAbsolutePositioning( this._rect ); - - if ( this._options.positionedElementAncestor ) { - shiftRectToCompensatePositionedAncestor( this._cachedAbsoluteRect, this._options.positionedElementAncestor ); - } + this._cachedAbsoluteRect = this._rect.toAbsoluteRect(); - return this._cachedAbsoluteRect!; + return this._cachedAbsoluteRect; } } @@ -505,7 +521,12 @@ export interface Options { * @param viewportRect The rect of the visual browser viewport. * @returns When the function returns `null`, it will not be considered by {@link module:utils/dom/position~getOptimalPosition}. */ -export type PositioningFunction = ( elementRect: Rect, targetRect: Rect, viewportRect: Rect | null ) => PositioningFunctionResult | null; +export type PositioningFunction = ( + elementRect: Rect, + targetRect: Rect, + viewportRect: Rect, + limiterRect?: Rect +) => PositioningFunctionResult | null; /** * The result of {@link module:utils/dom/position~PositioningFunction}. diff --git a/packages/ckeditor5-utils/src/dom/rect.ts b/packages/ckeditor5-utils/src/dom/rect.ts index d3b8568310a..d82d444e26c 100644 --- a/packages/ckeditor5-utils/src/dom/rect.ts +++ b/packages/ckeditor5-utils/src/dom/rect.ts @@ -11,6 +11,8 @@ import isRange from './isrange'; import isWindow from './iswindow'; import getBorderWidths from './getborderwidths'; import isText from './istext'; +import getPositionedAncestor from './getpositionedancestor'; +import global from './global'; const rectProperties: Array = [ 'top', 'right', 'bottom', 'left', 'width', 'height' ]; @@ -202,7 +204,11 @@ export default class Rect { if ( rect.width < 0 || rect.height < 0 ) { return null; } else { - return new Rect( rect ); + const newRect = new Rect( rect ); + + newRect._source = this._source; + + return newRect; } } @@ -261,16 +267,53 @@ export default class Rect { // Check the ancestors all the way up to the . while ( parent && !isBody( parent ) ) { + const isParentOverflowVisible = getElementOverflow( parent as HTMLElement ) === 'visible'; + if ( child instanceof HTMLElement && getElementPosition( child ) === 'absolute' ) { absolutelyPositionedChildElement = child; } + const parentElementPosition = getElementPosition( parent ); + // The child will be cropped only if it has `position: absolute` and the parent has `position: relative` + some overflow. // Otherwise there's no chance of visual clipping and the parent can be skipped // https://github.com/ckeditor/ckeditor5/issues/14107. + // + // condition: isParentOverflowVisible + // +---------------------------+ + // | #parent | + // | (overflow: visible) | + // | +-----------+---------------+ + // | | child | + // | +-----------+---------------+ + // +---------------------------+ + // + // condition: absolutelyPositionedChildElement && parentElementPosition === 'relative' && isParentOverflowVisible + // +---------------------------+ + // | parent | + // | (position: relative;) | + // | (overflow: visible;) | + // | +-----------+---------------+ + // | | child | + // | | (position: absolute;) | + // | +-----------+---------------+ + // +---------------------------+ + // + // condition: absolutelyPositionedChildElement && parentElementPosition !== 'relative' + // +---------------------------+ + // | parent | + // | (position: static;) | + // | +-----------+---------------+ + // | | child | + // | | (position: absolute;) | + // | +-----------+---------------+ + // +---------------------------+ if ( - absolutelyPositionedChildElement && - ( getElementPosition( parent as HTMLElement ) !== 'relative' || getElementOverflow( parent as HTMLElement ) === 'visible' ) + isParentOverflowVisible || + absolutelyPositionedChildElement && ( + ( parentElementPosition === 'relative' && isParentOverflowVisible ) || + parentElementPosition !== 'relative' + ) ) { child = parent; parent = parent.parentNode; @@ -327,6 +370,24 @@ export default class Rect { return !!( intersectRect && intersectRect.isEqual( anotherRect ) ); } + /** + * Calculates absolute `Rect` coordinates. + */ + public toAbsoluteRect(): Rect { + const { scrollX, scrollY } = global.window; + const absoluteRect = this.clone().moveBy( scrollX, scrollY ); + + if ( isDomElement( absoluteRect._source ) ) { + const positionedAncestor = getPositionedAncestor( absoluteRect._source ); + + if ( positionedAncestor ) { + shiftRectToCompensatePositionedAncestor( absoluteRect, positionedAncestor ); + } + } + + return absoluteRect; + } + /** * Excludes scrollbars and CSS borders from the rect. * @@ -495,13 +556,54 @@ function isDomElement( value: any ): value is Element { /** * Returns the value of the `position` style of an `HTMLElement`. */ -function getElementPosition( element: HTMLElement ): string { - return element.ownerDocument.defaultView!.getComputedStyle( element ).position; +function getElementPosition( element: HTMLElement | Node ): string { + return element instanceof HTMLElement ? element.ownerDocument.defaultView!.getComputedStyle( element ).position : 'static'; +} + +/** + * Returns the value of the `overflow` style of an `HTMLElement` or a `Range`. + */ +function getElementOverflow( element: HTMLElement | Range ): string { + return element instanceof HTMLElement ? element.ownerDocument.defaultView!.getComputedStyle( element ).overflow : 'visible'; } /** - * Returns the value of the `overflow` style of an `HTMLElement`. + * For a given absolute Rect coordinates object and a positioned element ancestor, it updates its + * coordinates that make up for the position and the scroll of the ancestor. + * + * This is necessary because while Rects (and DOMRects) are relative to the browser's viewport, their coordinates + * are used in real–life to position elements with `position: absolute`, which are scoped by any positioned + * (and scrollable) ancestors. */ -function getElementOverflow( element: HTMLElement ): string { - return element.ownerDocument.defaultView!.getComputedStyle( element ).overflow; +function shiftRectToCompensatePositionedAncestor( rect: Rect, positionedElementAncestor: HTMLElement ): void { + const ancestorPosition = new Rect( positionedElementAncestor ).toAbsoluteRect(); + const ancestorBorderWidths = getBorderWidths( positionedElementAncestor ); + + let moveX = 0; + let moveY = 0; + + // (https://github.com/ckeditor/ckeditor5-ui-default/issues/126) + // If there's some positioned ancestor of the panel, then its `Rect` must be taken into + // consideration. `Rect` is always relative to the viewport while `position: absolute` works + // with respect to that positioned ancestor. + moveX -= ancestorPosition.left; + moveY -= ancestorPosition.top; + + // (https://github.com/ckeditor/ckeditor5-utils/issues/139) + // If there's some positioned ancestor of the panel, not only its position must be taken into + // consideration (see above) but also its internal scrolls. Scroll have an impact here because `Rect` + // is relative to the viewport (it doesn't care about scrolling), while `position: absolute` + // must compensate that scrolling. + moveX += positionedElementAncestor.scrollLeft; + moveY += positionedElementAncestor.scrollTop; + + // (https://github.com/ckeditor/ckeditor5-utils/issues/139) + // If there's some positioned ancestor of the panel, then its `Rect` includes its CSS `borderWidth` + // while `position: absolute` positioning does not consider it. + // E.g. `{ position: absolute, top: 0, left: 0 }` means upper left corner of the element, + // not upper-left corner of its border. + moveX -= ancestorBorderWidths.left; + moveY -= ancestorBorderWidths.top; + + rect.moveBy( moveX, moveY ); } diff --git a/packages/ckeditor5-utils/src/index.ts b/packages/ckeditor5-utils/src/index.ts index 3fc6c2f1fae..9f2c77cfcb6 100644 --- a/packages/ckeditor5-utils/src/index.ts +++ b/packages/ckeditor5-utils/src/index.ts @@ -51,8 +51,6 @@ export { default as DomEmitterMixin, type DomEmitter } from './dom/emittermixin' export { default as findClosestScrollableAncestor } from './dom/findclosestscrollableancestor'; export { default as global } from './dom/global'; export { default as getAncestors } from './dom/getancestors'; -export { default as getElementsIntersectionRect } from './dom/getelementsintersectionrect'; -export { default as getScrollableAncestors } from './dom/getscrollableancestors'; export { default as getDataFromElement } from './dom/getdatafromelement'; export { default as isText } from './dom/istext'; export { default as Rect, type RectSource } from './dom/rect'; diff --git a/packages/ckeditor5-utils/tests/_utils/rectdrawer.js b/packages/ckeditor5-utils/tests/_utils/rectdrawer.js index 2ee595bc9ac..0f82b9689ed 100644 --- a/packages/ckeditor5-utils/tests/_utils/rectdrawer.js +++ b/packages/ckeditor5-utils/tests/_utils/rectdrawer.js @@ -88,9 +88,10 @@ export default class RectDrawer { font-family: monospace; background: #000; color: #fff; - font-size: 9px; + font-size: 8px; padding: 1px 3px; pointer-events: none; + white-space: pre; } `; @@ -98,6 +99,33 @@ export default class RectDrawer { } } +// eslint-disable-next-line max-len +const sharedDiagonalBackgroundSvg = 'url("data:image/svg+xml;utf8,")'; +const sharedDiagonalStyles = { + backgroundRepeat: 'no-repeat', + backgroundSize: '100% 100%', + outlineStyle: 'solid', + outlineWidth: '1px' +}; + +export const diagonalStylesBlack = { + backgroundImage: sharedDiagonalBackgroundSvg, + outlineColor: 'black', + ...sharedDiagonalStyles +}; + +export const diagonalStylesGreen = { + backgroundImage: sharedDiagonalBackgroundSvg.replaceAll( 'black', 'green' ), + outlineColor: 'green', + ...sharedDiagonalStyles +}; + +export const diagonalStylesRed = { + backgroundImage: sharedDiagonalBackgroundSvg.replaceAll( 'black', 'red' ), + outlineColor: 'red', + ...sharedDiagonalStyles +}; + /** * @private * @member {Object} module:minimap/utils~RectDrawer._defaultStyles diff --git a/packages/ckeditor5-utils/tests/dom/getelementsintersectionrect.js b/packages/ckeditor5-utils/tests/dom/getelementsintersectionrect.js deleted file mode 100644 index 0c95e8abca4..00000000000 --- a/packages/ckeditor5-utils/tests/dom/getelementsintersectionrect.js +++ /dev/null @@ -1,169 +0,0 @@ -/** - * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license - */ - -/* globals document, window */ - -import { getElementsIntersectionRect } from '../../src'; -import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; - -describe( 'getElementsIntersectionRect()', () => { - let element1, element2, element3; - testUtils.createSinonSandbox(); - - beforeEach( () => { - testUtils.sinon.stub( window, 'getComputedStyle' ); - window.getComputedStyle.callThrough(); - - stubWindow( { - innerWidth: 10000, - innerHeight: 10000, - scrollX: 0, - scrollY: 0 - } ); - } ); - - afterEach( () => { - if ( element1 ) { - element1.remove(); - } - - if ( element2 ) { - element2.remove(); - } - - if ( element3 ) { - element3.remove(); - } - } ); - - it( 'should return intersection from given list of elements', () => { - element1 = getElement( { - top: 0, - right: 100, - bottom: 100, - left: 0, - width: 100, - height: 100 - } ); - - element2 = getElement( { - top: 0, - right: 80, - bottom: 80, - left: 0, - width: 80, - height: 80 - } ); - - element3 = getElement( { - top: 0, - right: 60, - bottom: 60, - left: 0, - width: 60, - height: 60 - } ); - - expect( getElementsIntersectionRect( [ element1, element2, element3 ] ) ).to.deep.equal( { - top: 0, - right: 60, - bottom: 60, - left: 0, - width: 60, - height: 60 - } ); - } ); - - it( 'should return intersection from given list of elements including `document`', () => { - element1 = getElement( { - top: 0, - right: 100, - bottom: 100, - left: 0, - width: 100, - height: 100 - } ); - - element2 = getElement( { - top: 0, - right: 80, - bottom: 80, - left: 0, - width: 80, - height: 80 - } ); - - expect( getElementsIntersectionRect( [ element1, element2, document ] ) ).to.deep.equal( { - top: 0, - right: 80, - bottom: 80, - left: 0, - width: 80, - height: 80 - } ); - } ); - - it( 'should return null when there is no intersection between given elements', () => { - element1 = getElement( { - top: 0, - right: 100, - bottom: 100, - left: 0, - width: 100, - height: 100 - } ); - - element2 = getElement( { - top: 200, - right: 300, - bottom: 300, - left: 200, - width: 100, - height: 100 - } ); - - expect( getElementsIntersectionRect( [ element1, element2, document ] ) ).to.deep.equal( null ); - } ); - - it( 'should return document cropped by top offset', () => { - expect( getElementsIntersectionRect( [ document ], 100 ) ).to.deep.equal( { - bottom: 10000, - height: 9900, - left: 0, - right: 10000, - top: 100, - width: 10000 - } ); - } ); -} ); - -// Returns a synthetic element. -// -// @private -// @param {Object} properties A set of properties for the element. -// @param {Object} styles A set of styles in `window.getComputedStyle()` format. -function getElement( rect = {}, styles = {} ) { - expect( rect.right - rect.left ).to.equal( rect.width, 'getElement incorrect horizontal values' ); - expect( rect.bottom - rect.top ).to.equal( rect.height, 'getElement incorrect vertical values' ); - - const element = document.createElement( 'div' ); - document.body.appendChild( element ); - - sinon.stub( element, 'getBoundingClientRect' ).returns( rect ); - - Object.assign( element.style, styles ); - - return element; -} - -// Stubs the window. -// -// @private -// @param {Object} properties A set of properties the window should have. -function stubWindow( properties ) { - for ( const p in properties ) { - testUtils.sinon.stub( window, p ).value( properties[ p ] ); - } -} diff --git a/packages/ckeditor5-utils/tests/dom/getscrollableancestors.js b/packages/ckeditor5-utils/tests/dom/getscrollableancestors.js deleted file mode 100644 index 85fbe7747bd..00000000000 --- a/packages/ckeditor5-utils/tests/dom/getscrollableancestors.js +++ /dev/null @@ -1,39 +0,0 @@ -/** - * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. - * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license - */ - -/* globals document */ - -import { getScrollableAncestors } from '../../src'; - -describe( 'getScrollableAncestors()', () => { - it( 'should return all parents of given node that are scrollable', () => { - const element = document.createElement( 'div' ); - const parentWithOverflow = document.createElement( 'div' ); - parentWithOverflow.style.overflow = 'scroll'; - const parentWithOverflow2 = document.createElement( 'div' ); - parentWithOverflow2.style.overflow = 'auto'; - const parentWithoutOverflow = document.createElement( 'div' ); - parentWithoutOverflow.style.overflow = 'visible'; - - parentWithOverflow.appendChild( element ); - parentWithOverflow2.appendChild( parentWithOverflow ); - parentWithoutOverflow.appendChild( parentWithOverflow2 ); - document.body.appendChild( parentWithOverflow2 ); - - expect( getScrollableAncestors( element ) ).to.deep.equal( [ parentWithOverflow, parentWithOverflow2, document ] ); - - element.remove(); - parentWithOverflow.remove(); - parentWithOverflow2.remove(); - } ); - - it( 'should return only document when there are no parent elements with overflow', () => { - const element = document.createElement( 'div' ); - - expect( getScrollableAncestors( element ) ).to.deep.equal( [ document ] ); - - element.remove(); - } ); -} ); diff --git a/packages/ckeditor5-utils/tests/dom/position.js b/packages/ckeditor5-utils/tests/dom/position.js index 37af28244b0..e50a96745b0 100644 --- a/packages/ckeditor5-utils/tests/dom/position.js +++ b/packages/ckeditor5-utils/tests/dom/position.js @@ -134,8 +134,13 @@ describe( 'getOptimalPosition()', () => { } ); afterEach( () => { - element.remove(); - target.remove(); + if ( element ) { + element.remove(); + } + + if ( target ) { + target.remove(); + } if ( limiter ) { limiter.remove(); @@ -183,6 +188,24 @@ describe( 'getOptimalPosition()', () => { } ); } ); + it( 'should work when the target is a Range', () => { + setElementTargetPlayground(); + + const range = document.createRange(); + + range.selectNode( document.body ); + + assertPosition( { + element, + target: range, + positions: [ attachLeftBottom ] + }, { + top: 8, + left: -12, + name: 'left-bottom' + } ); + } ); + describe( 'for single position', () => { beforeEach( setElementTargetPlayground ); @@ -320,7 +343,7 @@ describe( 'getOptimalPosition()', () => { it( 'should allow position function to return null to be ignored', () => { assertPosition( { element, target, - positions: [ attachRightBottom, attachNone ] + positions: [ attachRightBottom ] }, { top: 100, left: 110, @@ -343,8 +366,8 @@ describe( 'getOptimalPosition()', () => { positions: [ attachLeftBottom, attachRightBottom ] }, { top: 100, - left: -20, - name: 'left-bottom' + left: 10, + name: 'right-bottom' } ); } ); @@ -355,8 +378,8 @@ describe( 'getOptimalPosition()', () => { positions: [ attachLeftBottom, attachRightBottom ] }, { top: 100, - left: -20, - name: 'left-bottom' + left: 10, + name: 'right-bottom' } ); } ); @@ -366,8 +389,8 @@ describe( 'getOptimalPosition()', () => { positions: [ attachLeftBottom, attachRightBottom ] }, { top: 100, - left: -20, - name: 'left-bottom' + left: 10, + name: 'right-bottom' } ); } ); @@ -377,8 +400,8 @@ describe( 'getOptimalPosition()', () => { positions: [ attachRightBottom, attachLeftBottom ] }, { top: 100, - left: -20, - name: 'left-bottom' + left: 10, + name: 'right-bottom' } ); } ); @@ -422,21 +445,17 @@ describe( 'getOptimalPosition()', () => { positions: [ attachRightBottom, attachLeftBottom ] }, { top: 100, - left: -5, - name: 'left-bottom' + left: 10, + name: 'right-bottom' } ); element.remove(); } ); it( 'should allow position function to return null to be ignored', () => { - assertPosition( { + assertNullPosition( { element, target, limiter, positions: [ attachLeftBottom, attachNone ] - }, { - top: 100, - left: -20, - name: 'left-bottom' } ); } ); } ); @@ -533,7 +552,7 @@ describe( 'getOptimalPosition()', () => { } ); it( 'should return the very first coordinates if no fitting position with a positive intersection has been found', () => { - assertPosition( { + assertNullPosition( { element, target, limiter, positions: [ () => ( { @@ -543,14 +562,10 @@ describe( 'getOptimalPosition()', () => { } ) ], fitInViewport: true - }, { - left: -10000, - top: -10000, - name: 'no-intersect-position' } ); } ); - it( 'should return the very first coordinates if limiter does not fit into the viewport', () => { + it( 'should return the last coordinates if limiter does not fit into the viewport', () => { const limiter = getElement( { top: -100, right: -80, @@ -602,9 +617,9 @@ describe( 'getOptimalPosition()', () => { it( 'should prefer a position with a bigger intersection area (#1)', () => { target = getElement( { top: 90, - right: -10, + right: 10, bottom: 110, - left: -30, + left: -10, width: 20, height: 20 } ); @@ -612,15 +627,15 @@ describe( 'getOptimalPosition()', () => { element, target, limiter, positions: allPositions, fitInViewport: true - }, 'right-bottom' ); + }, 'bottom-right' ); } ); it( 'should prefer a position with a bigger intersection area (#2)', () => { target = getElement( { top: 290, - right: -10, + right: 10, bottom: 310, - left: -30, + left: -10, width: 20, height: 20 } ); @@ -628,7 +643,7 @@ describe( 'getOptimalPosition()', () => { element, target, limiter, positions: allPositions, fitInViewport: true - }, 'right-top' ); + }, 'top-right' ); } ); it( 'should prefer a position with a bigger intersection area (#3)', () => { @@ -662,13 +677,13 @@ describe( 'getOptimalPosition()', () => { fitInViewport: true }, 'left-top' ); } ); + } ); - it( 'should not stick to the first biggest intersection in one area', () => { - // First position intersects more with limiter but little with viewport, - // second position intersects less with limiter but more with viewport and it should not be ignored. - // - // Target is outside viewport to force checking all positions, not only those completely fitting in viewport. - const limiter = getElement( { + describe( 'with scrollable ancestors', () => { + let parentWithOverflow, limiter, target, element, parentAncestorWithOverflow; + + beforeEach( () => { + limiter = getElement( { top: -100, right: 100, bottom: 100, @@ -676,15 +691,17 @@ describe( 'getOptimalPosition()', () => { width: 200, height: 200 } ); - const target = getElement( { - top: -30, - right: 80, - bottom: -10, - left: 60, - width: 20, - height: 20 + + target = getElement( { + top: 0, + right: 50, + bottom: 50, + left: 0, + width: 50, + height: 50 } ); - const element = getElement( { + + element = getElement( { top: 0, right: 200, bottom: 200, @@ -692,18 +709,201 @@ describe( 'getOptimalPosition()', () => { width: 200, height: 200 } ); + + parentWithOverflow = getElement( { + top: 0, + left: 0, + right: 100, + bottom: 100, + width: 100, + height: 100 + } ); + parentWithOverflow.style.overflow = 'scroll'; + } ); + + afterEach( () => { + if ( element ) { + element.remove(); + } + + if ( target ) { + target.remove(); + } + + if ( limiter ) { + limiter.remove(); + } + + if ( parentWithOverflow ) { + parentWithOverflow.remove(); + } + + if ( parentAncestorWithOverflow ) { + parentAncestorWithOverflow.remove(); + } + } ); + + it( 'should return null when target is not visible', () => { + const target = getElement( { + top: -200, + right: -100, + bottom: -100, + left: -200, + width: 100, + height: 100 + } ); + limiter.appendChild( target ); + parentWithOverflow.appendChild( limiter ); + parentWithOverflow.appendChild( element ); + document.body.appendChild( parentWithOverflow ); + + assertNullPosition( { + element, target, limiter, + positions: allPositions, + fitInViewport: true + } ); + + parentWithOverflow.remove(); + } ); + + it( 'should return position when element is fully contained by the limiter', () => { + const limiter = getElement( { + top: 0, + right: 1000, + bottom: 1000, + left: 0, + width: 1000, + height: 1000 + } ); + const target = getElement( { + top: 0, + right: 100, + bottom: 100, + left: 0, + width: 100, + height: 100 + } ); + + const parentWithOverflow = getElement( { + top: 0, + left: 0, + right: 1000, + bottom: 1000, + width: 1000, + height: 1000 + } ); + + limiter.appendChild( target ); + parentWithOverflow.appendChild( limiter ); + parentWithOverflow.appendChild( element ); + document.body.appendChild( parentWithOverflow ); + assertPositionName( { element, target, limiter, - positions: [ - attachLeftBottom, - attachRightBottom - ], + positions: allPositions, fitInViewport: true }, 'right-bottom' ); + parentWithOverflow.remove(); + } ); + + it( 'should return position when element is fully contained by the limiter and cropped by top offset', () => { + const limiter = getElement( { + top: 0, + right: 1000, + bottom: 1000, + left: 0, + width: 1000, + height: 1000 + } ); + const target = getElement( { + top: 0, + right: 100, + bottom: 100, + left: 0, + width: 100, + height: 100 + } ); + + const parentWithOverflow = getElement( { + top: 0, + left: 0, + right: 1000, + bottom: 1000, + width: 1000, + height: 1000 + } ); + + limiter.appendChild( target ); + parentWithOverflow.appendChild( limiter ); + parentWithOverflow.appendChild( element ); + document.body.appendChild( parentWithOverflow ); + + assertPositionName( { + element, target, limiter, + positions: allPositions, + fitInViewport: true, + viewportOffsetConfig: { + top: 100, + left: 0, + right: 0, + bottom: 0 + } + }, 'right-bottom' ); + + parentWithOverflow.remove(); + } ); + + it( 'should return proper calculated position when first ancestor\'s Rect has undefined values', () => { + limiter.appendChild( target ); + parentWithOverflow.appendChild( limiter ); + parentWithOverflow.appendChild( element ); + document.body.appendChild( parentWithOverflow ); + + assertPositionName( { + element, target, limiter, + positions: allPositions, + fitInViewport: true + }, 'right-bottom' ); + + parentWithOverflow.remove(); + } ); + + it( 'should return proper calculated position when `target` is a Range', () => { + const limiter = getElement( { + top: 0, + right: 100, + bottom: 100, + left: 0, + width: 100, + height: 100 + } ); + + const target = document.createRange(); + target.selectNode( document.body ); + + const element = getElement( { + top: 0, + right: 100, + bottom: 100, + left: 0, + width: 100, + height: 100 + } ); + + parentWithOverflow.appendChild( limiter ); + parentWithOverflow.appendChild( element ); + document.body.appendChild( parentWithOverflow ); + + assertPositionName( { + element, target, limiter, + positions: allPositions, + fitInViewport: true + }, 'bottom-right' ); + limiter.remove(); - target.remove(); element.remove(); + parentWithOverflow.remove(); } ); } ); } ); @@ -721,6 +921,12 @@ function assertPositionName( options, expected ) { expect( position.name ).to.equal( expected ); } +function assertNullPosition( options ) { + const position = getOptimalPosition( options ); + + expect( position ).to.be.null; +} + // Returns a synthetic element. // // @private diff --git a/packages/ckeditor5-utils/tests/dom/rect.js b/packages/ckeditor5-utils/tests/dom/rect.js index cb89bf2c65e..52677025836 100644 --- a/packages/ckeditor5-utils/tests/dom/rect.js +++ b/packages/ckeditor5-utils/tests/dom/rect.js @@ -278,6 +278,18 @@ describe( 'Rect', () => { expect( insersect ).to.not.equal( rect ); } ); + it( 'should pass the original Rect source on for further processing', () => { + const elementA = document.createElement( 'div' ); + const elementB = document.createElement( 'div' ); + const rectA = new Rect( elementA ); + const rectB = new Rect( elementB ); + const insersect = rectA.getIntersection( rectB ); + + expect( rectA._source ).to.equal( elementA ); + expect( rectB._source ).to.equal( elementB ); + expect( insersect._source ).to.equal( elementA ); + } ); + it( 'should calculate the geometry (#1)', () => { const rectA = new Rect( { top: 0, @@ -446,10 +458,10 @@ describe( 'Rect', () => { let element, range, ancestorA, ancestorB; beforeEach( () => { - element = document.createElement( 'div' ); + element = document.createElement( 'section' ); range = document.createRange(); - ancestorA = document.createElement( 'div' ); - ancestorB = document.createElement( 'div' ); + ancestorA = document.createElement( 'header' ); + ancestorB = document.createElement( 'main' ); ancestorA.appendChild( element ); document.body.appendChild( ancestorA ); @@ -518,11 +530,11 @@ describe( 'Rect', () => { assertRect( new Rect( element ).getVisible(), { top: 0, - right: 50, - bottom: 50, + right: 100, + bottom: 100, left: 0, - width: 50, - height: 50 + width: 100, + height: 100 } ); iframe.remove(); @@ -533,6 +545,8 @@ describe( 'Rect', () => { } ); it( 'should return the visible rect (HTMLElement), partially cropped', () => { + ancestorA.style.overflow = 'scroll'; + sinon.stub( element, 'getBoundingClientRect' ).returns( { top: 0, right: 100, @@ -593,6 +607,8 @@ describe( 'Rect', () => { it( 'should return the visible rect (HTMLElement), partially cropped, deep ancestor overflow', () => { ancestorB.appendChild( ancestorA ); document.body.appendChild( ancestorB ); + ancestorA.style.overflow = 'scroll'; + ancestorB.style.overflow = 'scroll'; sinon.stub( element, 'getBoundingClientRect' ).returns( { top: 0, @@ -652,7 +668,7 @@ describe( 'Rect', () => { sinon.stub( ancestorA, 'getBoundingClientRect' ).returns( { top: 50, - right: 100, + right: 50, bottom: 100, left: 0, width: 50, @@ -669,18 +685,19 @@ describe( 'Rect', () => { } ); assertRect( new Rect( element ).getVisible(), { - top: 50, + top: 0, right: 100, bottom: 100, left: 50, width: 50, - height: 50 + height: 100 } ); } ); it( 'should return the visible rect (Range), partially cropped', () => { range.setStart( ancestorA, 0 ); range.setEnd( ancestorA, 1 ); + ancestorA.style.overflow = 'scroll'; sinon.stub( range, 'getClientRects' ).returns( [ { top: 0, @@ -710,7 +727,7 @@ describe( 'Rect', () => { } ); } ); - it( 'should return null if there\'s no visible rect', () => { + it( 'should return null if there\'s no visible rect and parent has overflow scroll', () => { sinon.stub( element, 'getBoundingClientRect' ).returns( { top: 0, right: 100, @@ -720,6 +737,8 @@ describe( 'Rect', () => { height: 100 } ); + ancestorA.style.overflow = 'scroll'; + sinon.stub( ancestorA, 'getBoundingClientRect' ).returns( { top: 150, right: 200, diff --git a/packages/ckeditor5-utils/tests/manual/rect/getvisible.html b/packages/ckeditor5-utils/tests/manual/rect/getvisible.html index d502e2189d0..682609e7e83 100644 --- a/packages/ckeditor5-utils/tests/manual/rect/getvisible.html +++ b/packages/ckeditor5-utils/tests/manual/rect/getvisible.html @@ -1,109 +1,143 @@ -