Skip to content

Commit

Permalink
Merge pull request #14630 from ckeditor/ck/5328-ballon-panel-sticks-o…
Browse files Browse the repository at this point in the history
…ut-of-the-limiter-generic-approach

Fix (ui,utils): A balloon panel should hide when its target (anchor) becomes invisible due to scrolling or cropping. Closes #5328.

MINOR BREAKING CHANGE (utils): The `getOptimalPosition()` helper will now return `null` (previously first available position) if the positioning criteria cannot be met, for instance, if the `target` is off the visible viewport.
  • Loading branch information
oleq authored Jul 25, 2023
2 parents b070a03 + 0628bbf commit 2f728ce
Show file tree
Hide file tree
Showing 17 changed files with 1,732 additions and 138 deletions.
8 changes: 6 additions & 2 deletions packages/ckeditor5-ui/src/dropdown/dropdownview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,12 +269,16 @@ export default class DropdownView extends View<HTMLDivElement> {
// 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;
}
Expand Down
21 changes: 19 additions & 2 deletions packages/ckeditor5-ui/src/panel/balloon/balloonpanelview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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.
Expand Down
69 changes: 6 additions & 63 deletions packages/ckeditor5-ui/src/panel/sticky/stickypanelview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@ import Template from '../../template';
import type ViewCollection from '../../viewcollection';

import {
global,
toUnit,
type Locale,
type ObservableChangeEvent,
findClosestScrollableAncestor,
getElementsIntersectionRect,
getScrollableAncestors,
global,
toUnit,
Rect
} from '@ckeditor/ckeditor5-utils';

Expand Down Expand Up @@ -258,13 +259,13 @@ export default class StickyPanelView extends View {
return;
}

const scrollableAncestors = _getScrollableAncestors( this.limiterElement );
const scrollableAncestors = getScrollableAncestors( this.limiterElement );

if ( scrollTarget && !scrollableAncestors.includes( scrollTarget ) ) {
return;
}

const visibleAncestorsRect = _getVisibleAncestorsRect( scrollableAncestors, this.viewportTopOffset );
const visibleAncestorsRect = getElementsIntersectionRect( scrollableAncestors, this.viewportTopOffset );
const limiterRect = new Rect( this.limiterElement );

// @if CK_DEBUG_STICKYPANEL // if ( visibleAncestorsRect ) {
Expand Down Expand Up @@ -386,61 +387,3 @@ export default class StickyPanelView extends View {
return new Rect( this._contentPanel );
}
}

// Loops over the given element's ancestors to find all the scrollable elements.
//
// @private
// @param element
// @returns Array<HTMLElement> An array of scrollable element's ancestors.
function _getScrollableAncestors( element: HTMLElement ) {
const scrollableAncestors = [];
let scrollableAncestor = findClosestScrollableAncestor( element );

while ( scrollableAncestor && scrollableAncestor !== global.document.body ) {
scrollableAncestors.push( scrollableAncestor );
scrollableAncestor = findClosestScrollableAncestor( scrollableAncestor! );
}

scrollableAncestors.push( global.document );

return scrollableAncestors;
}

// Calculates the intersection rectangle of the given element and its scrollable ancestors (including window).
// Also, takes into account the passed viewport top offset.
//
// @private
// @param scrollableAncestors
// @param viewportTopOffset
// @returns Rect
function _getVisibleAncestorsRect( scrollableAncestors: Array<HTMLElement | Document>, viewportTopOffset: number ) {
const scrollableAncestorsRects = scrollableAncestors.map( ancestor => {
// The document (window) is yet another scrollable ancestor, but cropped by the top offset.
if ( ancestor instanceof Document ) {
const windowRect = new Rect( global.window );

windowRect.top += viewportTopOffset;
windowRect.height -= viewportTopOffset;

return windowRect;
} else {
return new Rect( ancestor );
}
} );

let scrollableAncestorsIntersectionRect: Rect | null = scrollableAncestorsRects[ 0 ];

// @if CK_DEBUG_STICKYPANEL // for ( const scrollableAncestorRect of scrollableAncestorsRects ) {
// @if CK_DEBUG_STICKYPANEL // RectDrawer.draw( scrollableAncestorRect, {
// @if CK_DEBUG_STICKYPANEL // outlineWidth: '1px', opacity: '.7', outlineStyle: 'dashed'
// @if CK_DEBUG_STICKYPANEL // }, 'Scrollable ancestor' );
// @if CK_DEBUG_STICKYPANEL // }

for ( const scrollableAncestorRect of scrollableAncestorsRects.slice( 1 ) ) {
if ( scrollableAncestorsIntersectionRect ) {
scrollableAncestorsIntersectionRect = scrollableAncestorsIntersectionRect.getIntersection( scrollableAncestorRect );
}
}

return scrollableAncestorsIntersectionRect;
}
37 changes: 14 additions & 23 deletions packages/ckeditor5-ui/src/toolbar/block/blocktoolbar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import {
import {
Rect,
ResizeObserver,
getOptimalPosition,
toUnit,
type ObservableChangeEvent
} from '@ckeditor/ckeditor5-utils';
Expand Down Expand Up @@ -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! );
const contentRect = new Rect( targetElement );

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;
}

/**
Expand Down
28 changes: 28 additions & 0 deletions packages/ckeditor5-ui/tests/dropdown/dropdownview.js
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,34 @@ 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 = '100px';
parentWithOverflow.style.height = '1px';
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();
} );
} );
} );

Expand Down
13 changes: 8 additions & 5 deletions packages/ckeditor5-ui/tests/editorui/poweredby.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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();
} );
Expand All @@ -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();
} );
Expand Down Expand Up @@ -870,7 +873,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,
Expand Down Expand Up @@ -933,7 +936,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,
Expand Down
Loading

0 comments on commit 2f728ce

Please sign in to comment.