+ Sticky header. Table balloon toolbar should not overlap with this header at any point when scrolling down.
+
+
+
+
Other webpage content
+
Lorem ipsum dolor sit amet consectetur, adipisicing elit. Officia itaque eum necessitatibus possimus adipisci mollitia dolor quia molestias voluptate dignissimos? Optio architecto dicta dolorum laborum nam nulla minus aspernatur iste.
+
+
Short table
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Very long table
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Short image
+
+
+
Very tall image table
+
+
+
+
+
+
+
diff --git a/packages/ckeditor5-ui/tests/manual/tickets/9892/1.js b/packages/ckeditor5-ui/tests/manual/tickets/9892/1.js
new file mode 100644
index 00000000000..fb20b6be929
--- /dev/null
+++ b/packages/ckeditor5-ui/tests/manual/tickets/9892/1.js
@@ -0,0 +1,37 @@
+/**
+ * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. 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 TableProperties from '@ckeditor/ckeditor5-table/src/tableproperties';
+import TableCellProperties from '@ckeditor/ckeditor5-table/src/tablecellproperties';
+
+ClassicEditor
+ .create( document.querySelector( '#editor' ), {
+ image: { toolbar: [ 'toggleImageCaption', 'imageTextAlternative' ] },
+ plugins: [ ArticlePluginSet, TableProperties, TableCellProperties ],
+ toolbar: {
+ items: [ 'heading', '|', 'insertTable', '|', 'bold', 'italic', 'bulletedList', 'numberedList', 'blockQuote', 'undo', 'redo' ],
+ viewportTopOffset: 100
+ },
+ table: {
+ contentToolbar: [ 'tableColumn', 'tableRow', 'mergeTableCells', '|', 'tableProperties', 'tableCellProperties' ],
+ tableToolbar: [ 'bold', 'italic' ]
+ },
+ ui: {
+ viewportOffset: {
+ top: 100,
+ bottom: 100
+ }
+ }
+ } )
+ .then( editor => {
+ window.editor = editor;
+ } )
+ .catch( err => {
+ console.error( err.stack );
+ } );
diff --git a/packages/ckeditor5-ui/tests/manual/tickets/9892/1.md b/packages/ckeditor5-ui/tests/manual/tickets/9892/1.md
new file mode 100644
index 00000000000..3b2e4344d9c
--- /dev/null
+++ b/packages/ckeditor5-ui/tests/manual/tickets/9892/1.md
@@ -0,0 +1,5 @@
+## Table toolbar does not respect viewportTopOffset configuration [#9892](https://github.com/ckeditor/ckeditor5/issues/9892)
+
+- select the long table
+- scroll down when balloon toolbar visible
+- toolbar shouldn't go above the main toolbar and overlap with sticky header
diff --git a/packages/ckeditor5-ui/tests/manual/tickets/9892/sample-very-tall.jpg b/packages/ckeditor5-ui/tests/manual/tickets/9892/sample-very-tall.jpg
new file mode 100644
index 00000000000..e29fd2adfb3
Binary files /dev/null and b/packages/ckeditor5-ui/tests/manual/tickets/9892/sample-very-tall.jpg differ
diff --git a/packages/ckeditor5-ui/tests/manual/tickets/9892/sample.jpg b/packages/ckeditor5-ui/tests/manual/tickets/9892/sample.jpg
new file mode 100644
index 00000000000..b77d07e7bff
Binary files /dev/null and b/packages/ckeditor5-ui/tests/manual/tickets/9892/sample.jpg differ
diff --git a/packages/ckeditor5-ui/tests/panel/balloon/balloonpanelview.js b/packages/ckeditor5-ui/tests/panel/balloon/balloonpanelview.js
index 8ad02bdf38f..375fcd238cd 100644
--- a/packages/ckeditor5-ui/tests/panel/balloon/balloonpanelview.js
+++ b/packages/ckeditor5-ui/tests/panel/balloon/balloonpanelview.js
@@ -9,6 +9,7 @@ import ViewCollection from '../../../src/viewcollection';
import BalloonPanelView from '../../../src/panel/balloon/balloonpanelview';
import ButtonView from '../../../src/button/buttonview';
import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+import { Rect } from '@ckeditor/ckeditor5-utils';
describe( 'BalloonPanelView', () => {
let view;
@@ -189,7 +190,8 @@ describe( 'BalloonPanelView', () => {
BalloonPanelView.defaultPositions.northArrowSouthMiddleWest,
BalloonPanelView.defaultPositions.northArrowSouthMiddleEast,
BalloonPanelView.defaultPositions.northArrowSouthWest,
- BalloonPanelView.defaultPositions.northArrowSouthEast
+ BalloonPanelView.defaultPositions.northArrowSouthEast,
+ BalloonPanelView.defaultPositions.viewportStickyNorth
],
limiter: document.body,
fitInViewport: true
@@ -688,34 +690,43 @@ describe( 'BalloonPanelView', () => {
} );
describe( 'defaultPositions', () => {
- let positions, balloonRect, targetRect, arrowHOffset, arrowVOffset;
+ let positions, balloonRect, targetRect, viewportRect, arrowHOffset, arrowVOffset;
beforeEach( () => {
positions = BalloonPanelView.defaultPositions;
arrowHOffset = BalloonPanelView.arrowHorizontalOffset;
arrowVOffset = BalloonPanelView.arrowVerticalOffset;
- targetRect = {
+ viewportRect = new Rect( {
+ top: 0,
+ bottom: 0,
+ left: 0,
+ right: 0,
+ width: 200,
+ height: 200
+ } );
+
+ targetRect = new Rect( {
top: 100,
bottom: 200,
left: 100,
right: 200,
width: 100,
height: 100
- };
+ } );
- balloonRect = {
+ balloonRect = new Rect( {
top: 0,
bottom: 0,
left: 0,
right: 0,
width: 50,
height: 50
- };
+ } );
} );
it( 'should have a proper length', () => {
- expect( Object.keys( positions ) ).to.have.length( 30 );
+ expect( Object.keys( positions ) ).to.have.length( 31 );
} );
// ------- North
@@ -969,6 +980,57 @@ describe( 'BalloonPanelView', () => {
name: 'arrow_nmw'
} );
} );
+
+ // ------- South east
+
+ it( 'should define the "viewportStickyNorth" position', () => {
+ expect( positions.viewportStickyNorth( targetRect, balloonRect, viewportRect ) ).to.equal( null );
+ } );
+ } );
+
+ describe( 'stickyPositions', () => {
+ let positions, balloonRect, targetRect, viewportRect, stickyOffset;
+
+ beforeEach( () => {
+ positions = BalloonPanelView.defaultPositions;
+ stickyOffset = BalloonPanelView.stickyVerticalOffset;
+
+ viewportRect = new Rect( {
+ top: 300,
+ bottom: 800,
+ left: 0,
+ right: 200,
+ width: 0,
+ height: 0
+ } );
+
+ balloonRect = new Rect( {
+ top: 0,
+ bottom: 0,
+ left: 0,
+ right: 0,
+ width: 50,
+ height: 50
+ } );
+ } );
+
+ it( 'should stick position to the top when target element is above viewport', () => {
+ targetRect = new Rect( {
+ top: 200,
+ bottom: 400,
+ left: 50,
+ right: 100,
+ width: 0,
+ height: 0
+ } );
+
+ expect( positions.viewportStickyNorth( targetRect, balloonRect, viewportRect ) ).to.deep.equal( {
+ top: 300 + stickyOffset,
+ left: 25,
+ name: 'arrowless',
+ withArrow: false
+ } );
+ } );
} );
} );
diff --git a/packages/ckeditor5-utils/src/dom/position.js b/packages/ckeditor5-utils/src/dom/position.js
index 969000cc40a..cbe22c13c22 100644
--- a/packages/ckeditor5-utils/src/dom/position.js
+++ b/packages/ckeditor5-utils/src/dom/position.js
@@ -13,6 +13,8 @@ import getPositionedAncestor from './getpositionedancestor';
import getBorderWidths from './getborderwidths';
import { isFunction } from 'lodash-es';
+// @if CK_DEBUG_POSITION // import { RectDrawer } from '@ckeditor/ckeditor5-minimap/src/utils';
+
/**
* 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
@@ -77,7 +79,7 @@ import { isFunction } from 'lodash-es';
* @param {module:utils/dom/position~Options} options Positioning options object.
* @returns {module:utils/dom/position~Position}
*/
-export function getOptimalPosition( { element, target, positions, limiter, fitInViewport } ) {
+export function getOptimalPosition( { element, target, positions, limiter, fitInViewport, viewportOffsetConfig } ) {
// 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,33 +96,143 @@ export function getOptimalPosition( { element, target, positions, limiter, fitIn
const elementRect = new Rect( element );
const targetRect = new Rect( target );
- let bestPositionRect;
- let bestPositionName;
+ let bestPosition;
+
+ // @if CK_DEBUG_POSITION // RectDrawer.clear();
+ // @if CK_DEBUG_POSITION // RectDrawer.draw( targetRect, { outlineWidth: '5px' }, 'Target' );
+
+ const positionOptions = { targetRect, elementRect, positionedElementAncestor };
// If there are no limits, just grab the very first position and be done with that drama.
if ( !limiter && !fitInViewport ) {
- [ bestPositionName, bestPositionRect ] = getPositionNameAndRect( positions[ 0 ], targetRect, elementRect );
+ bestPosition = new Position( positions[ 0 ], positionOptions );
} else {
const limiterRect = limiter && new Rect( limiter ).getVisible();
- const viewportRect = fitInViewport && new Rect( global.window );
- const bestPosition = getBestPositionNameAndRect( positions, { targetRect, elementRect, limiterRect, viewportRect } );
+ const viewportRect = fitInViewport && getConstrainedViewportRect( viewportOffsetConfig );
+
+ // @if CK_DEBUG_POSITION // if ( viewportRect ) {
+ // @if CK_DEBUG_POSITION // RectDrawer.draw( viewportRect, { outlineWidth: '5px' }, 'Viewport' );
+ // @if CK_DEBUG_POSITION // }
+
+ // @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 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.
- [ bestPositionName, bestPositionRect ] = bestPosition || getPositionNameAndRect( positions[ 0 ], targetRect, elementRect );
+ bestPosition = getBestPosition( positions, positionOptions ) || new Position( positions[ 0 ], positionOptions );
+ }
+
+ return bestPosition;
+}
+
+class Position {
+ constructor( positioningFunction, options ) {
+ const positioningFunctionOutput = positioningFunction( options.targetRect, options.elementRect, options.viewportRect );
+
+ // Nameless position for a function that didn't participate.
+ if ( !positioningFunctionOutput ) {
+ return;
+ }
+
+ const { left, top } = positioningFunctionOutput;
+
+ delete positioningFunctionOutput.left;
+ delete positioningFunctionOutput.top;
+
+ Object.assign( this, positioningFunctionOutput );
+
+ this._positioningFunctionCorrdinates = { left, top };
+ this._options = options;
}
- let absoluteRectCoordinates = getAbsoluteRectCoordinates( bestPositionRect );
+ get left() {
+ return this._absoluteRect.left;
+ }
- if ( positionedElementAncestor ) {
- absoluteRectCoordinates = shiftRectCoordinatesDueToPositionedAncestor( absoluteRectCoordinates, positionedElementAncestor );
+ get top() {
+ return this._absoluteRect.top;
}
- return {
- left: absoluteRectCoordinates.left,
- top: absoluteRectCoordinates.top,
- name: bestPositionName
- };
+ // TODO
+ get limiterIntersectionArea() {
+ 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 0;
+ }
+
+ // TODO
+ get viewportIntersectionArea() {
+ const viewportRect = this._options.viewportRect;
+
+ if ( viewportRect ) {
+ return viewportRect.getIntersectionArea( this._rect );
+ }
+
+ return 0;
+ }
+
+ get _rect() {
+ if ( this._cachedRect ) {
+ return this._cachedRect;
+ }
+
+ this._cachedRect = this._options.elementRect.clone().moveTo(
+ this._positioningFunctionCorrdinates.left,
+ this._positioningFunctionCorrdinates.top
+ );
+
+ return this._cachedRect;
+ }
+
+ get _absoluteRect() {
+ // Speed optimization.
+ if ( this._cachedAbsoluteRect ) {
+ return this._cachedAbsoluteRect;
+ }
+
+ this._cachedAbsoluteRect = getRectForAbsolutePositioning( this._rect );
+
+ if ( this._options.positionedAncestor ) {
+ shiftRectToCompensatePositionedAncestor( this._cachedAbsoluteRect, this._options.positionedAncestor );
+ }
+
+ return this._cachedAbsoluteRect;
+ }
+}
+
+// TODO
+// @private
+function getConstrainedViewportRect( viewportOffsetConfig ) {
+ viewportOffsetConfig = Object.assign( { top: 0, bottom: 0, left: 0, right: 0 }, viewportOffsetConfig );
+
+ const viewportRect = new Rect( global.window );
+
+ viewportRect.top += viewportOffsetConfig.top;
+ viewportRect.height -= viewportOffsetConfig.top;
+ viewportRect.bottom -= viewportOffsetConfig.bottom;
+ viewportRect.height -= viewportOffsetConfig.bottom;
+
+ return viewportRect;
}
// For given position function, returns a corresponding `Rect` instance.
@@ -130,17 +242,17 @@ export function getOptimalPosition( { element, target, positions, limiter, fitIn
// @param {utils/dom/rect~Rect} targetRect A rect of the target.
// @param {utils/dom/rect~Rect} elementRect A rect of positioned element.
// @returns {Array|null} An array containing position name and its Rect (or null if position should be ignored).
-function getPositionNameAndRect( position, targetRect, elementRect ) {
- const positionData = position( targetRect, elementRect );
+// function getPositionNameAndRect( position, targetRect, elementRect ) {
+// const positionData = position( targetRect, elementRect );
- if ( !positionData ) {
- return null;
- }
+// if ( !positionData ) {
+// return null;
+// }
- const { left, top, name } = positionData;
+// const { left, top, name } = positionData;
- return [ name, elementRect.clone().moveTo( left, top ) ];
-}
+// return [ name, elementRect.clone().moveTo( left, top ) ];
+// }
// For a given array of positioning functions, returns such that provides the best
// fit of the `elementRect` into the `limiterRect` and `viewportRect`.
@@ -157,148 +269,71 @@ function getPositionNameAndRect( position, targetRect, elementRect ) {
// @param {utils/dom/rect~Rect} options.viewportRect A rect of the viewport.
//
// @returns {Array} An array containing the name of the position and it's rect.
-function getBestPositionNameAndRect( positions, options ) {
+function getBestPosition( positions, options ) {
const { elementRect, viewportRect } = options;
// This is when element is fully visible.
const elementRectArea = elementRect.getArea();
// Let's calculate intersection areas for positions. It will end early if best match is found.
- const processedPositions = processPositionsToAreas( positions, options );
+ // const processedPositions = processPositionsToAreas( positions, options );
+
+ const _positions = positions
+ .map( positioningFunction => new Position( positioningFunction, options ) )
+ // Some positioning functions may return `null` if they don't want to participate.
+ .filter( position => !!position.name );
// First let's check all positions that fully fit in the viewport.
if ( viewportRect ) {
- const processedPositionsInViewport = processedPositions.filter( ( { viewportIntersectArea } ) => {
- return viewportIntersectArea === elementRectArea;
+ const positionsInViewport = _positions.filter( position => {
+ return position.viewportIntersectionArea === elementRectArea;
} );
// Try to find best position from those which fit completely in viewport.
- const bestPositionData = getBestOfProcessedPositions( processedPositionsInViewport, elementRectArea );
+ const bestPosition = getBestConstrainedPosition( positionsInViewport, elementRectArea );
- if ( bestPositionData ) {
- return bestPositionData;
+ if ( bestPosition ) {
+ return bestPosition;
}
}
// Either there is no viewportRect or there is no position that fits completely in the viewport.
- return getBestOfProcessedPositions( processedPositions, elementRectArea );
-}
-
-// For a given array of positioning functions, calculates intersection areas for them.
-//
-// Note: If some position fully fits into the `limiterRect`, it will be returned early, without further consideration
-// of other positions.
-//
-// @private
-//
-// @param {module:utils/dom/position~Options#positions} positions Functions returning {@link module:utils/dom/position~Position}
-// to be checked, in the order of preference.
-// @param {Object} options
-// @param {utils/dom/rect~Rect} options.targetRect A rect of the {@link module:utils/dom/position~Options#target}.
-// @param {utils/dom/rect~Rect} options.elementRect A rect of positioned {@link module:utils/dom/position~Options#element}.
-// @param {utils/dom/rect~Rect} options.limiterRect A rect of the {@link module:utils/dom/position~Options#limiter}.
-// @param {utils/dom/rect~Rect} options.viewportRect A rect of the viewport.
-//
-// @returns {Array.