diff --git a/packages/ckeditor5-table/src/ui/utils.js b/packages/ckeditor5-table/src/ui/utils.js
index 5e2a4e3bfbc..99bc6d30e79 100644
--- a/packages/ckeditor5-table/src/ui/utils.js
+++ b/packages/ckeditor5-table/src/ui/utils.js
@@ -16,6 +16,7 @@ import { isColor, isLength, isPercentage } from '@ckeditor/ckeditor5-engine/src/
import { getTableWidgetAncestor } from '../utils';
import { findAncestor } from '../commands/utils';
import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';
+import { centeredBalloonPositionForLongWidgets } from '@ckeditor/ckeditor5-widget/src/utils';
const DEFAULT_BALLOON_POSITIONS = BalloonPanelView.defaultPositions;
const BALLOON_POSITIONS = [
@@ -26,6 +27,10 @@ const BALLOON_POSITIONS = [
DEFAULT_BALLOON_POSITIONS.southArrowNorthWest,
DEFAULT_BALLOON_POSITIONS.southArrowNorthEast
];
+const TABLE_PROPERTRIES_BALLOON_POSITIONS = [
+ ...BALLOON_POSITIONS,
+ centeredBalloonPositionForLongWidgets
+];
const isEmpty = val => val === '';
@@ -69,7 +74,7 @@ export function getBalloonTablePositionData( editor ) {
return {
target: editor.editing.view.domConverter.viewToDom( viewTable ),
- positions: BALLOON_POSITIONS
+ positions: TABLE_PROPERTRIES_BALLOON_POSITIONS
};
}
diff --git a/packages/ckeditor5-table/tests/ui/utils.js b/packages/ckeditor5-table/tests/ui/utils.js
index 4701e59fb96..44384b934b3 100644
--- a/packages/ckeditor5-table/tests/ui/utils.js
+++ b/packages/ckeditor5-table/tests/ui/utils.js
@@ -29,6 +29,7 @@ import {
} from '../../src/ui/utils';
import Collection from '@ckeditor/ckeditor5-utils/src/collection';
import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+import { centeredBalloonPositionForLongWidgets } from '@ckeditor/ckeditor5-widget/src/utils';
import { modelTable } from '../_utils/utils';
describe( 'UI Utils', () => {
@@ -139,7 +140,8 @@ describe( 'UI Utils', () => {
defaultPositions.northArrowSouthEast,
defaultPositions.southArrowNorth,
defaultPositions.southArrowNorthWest,
- defaultPositions.southArrowNorthEast
+ defaultPositions.southArrowNorthEast,
+ centeredBalloonPositionForLongWidgets
]
} );
} );
diff --git a/packages/ckeditor5-widget/src/utils.js b/packages/ckeditor5-widget/src/utils.js
index d7526462582..d03847d113d 100644
--- a/packages/ckeditor5-widget/src/utils.js
+++ b/packages/ckeditor5-widget/src/utils.js
@@ -9,6 +9,9 @@
import HighlightStack from './highlightstack';
import IconView from '@ckeditor/ckeditor5-ui/src/icon/iconview';
+import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';
+import BalloonPanelView from '@ckeditor/ckeditor5-ui/src/panel/balloon/balloonpanelview';
+import global from '@ckeditor/ckeditor5-utils/src/dom/global';
import dragHandleIcon from '../theme/icons/drag-handle.svg';
@@ -339,6 +342,58 @@ export function viewToModelPositionOutsideModelElement( model, viewElementMatche
};
}
+/**
+ * A positioning function passed to the {@link module:utils/dom/position~getOptimalPosition} helper as a last resort
+ * when attaching {@link module:ui/panel/balloon/balloonpanelview~BalloonPanelView balloon UI} to widgets.
+ * It comes in handy when a widget is longer than the visual viewport of the web browser and/or upper/lower boundaries
+ * of a widget are off screen because of the web page scroll.
+ *
+ * ┌─┄┄┄┄┄┄┄┄┄Widget┄┄┄┄┄┄┄┄┄┐
+ * ┊ ┊
+ * ┌────────────Viewport───────────┐ ┌──╁─────────Viewport────────╁──┐
+ * │ ┏━━━━━━━━━━Widget━━━━━━━━━┓ │ │ ┃ ^ ┃ │
+ * │ ┃ ^ ┃ │ │ ┃ ╭───────/ \───────╮ ┃ │
+ * │ ┃ ╭───────/ \───────╮ ┃ │ │ ┃ │ Balloon │ ┃ │
+ * │ ┃ │ Balloon │ ┃ │ │ ┃ ╰─────────────────╯ ┃ │
+ * │ ┃ ╰─────────────────╯ ┃ │ │ ┃ ┃ │
+ * │ ┃ ┃ │ │ ┃ ┃ │
+ * │ ┃ ┃ │ │ ┃ ┃ │
+ * │ ┃ ┃ │ │ ┃ ┃ │
+ * │ ┃ ┃ │ │ ┃ ┃ │
+ * │ ┃ ┃ │ │ ┃ ┃ │
+ * │ ┃ ┃ │ │ ┃ ┃ │
+ * └──╀─────────────────────────╀──┘ └──╀─────────────────────────╀──┘
+ * ┊ ┊ ┊ ┊
+ * ┊ ┊ └┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┘
+ * ┊ ┊
+ * └┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┘
+ *
+ * **Note**: Works best if used together with
+ * {@link module:ui/panel/balloon/balloonpanelview~BalloonPanelView.defaultPositions default `BalloonPanelView` positions}
+ * like `northArrowSouth` and `southArrowNorth`; the transition between these two and this position is smooth.
+ *
+ * @param {module:utils/dom/rect~Rect} widgetRect A rect of the widget.
+ * @param {module:utils/dom/rect~Rect} balloonRect A rect of the balloon.
+ * @returns {module:utils/dom/position~Position}
+ */
+export function centeredBalloonPositionForLongWidgets( widgetRect, balloonRect ) {
+ const viewportRect = new Rect( global.window );
+ const viewportWidgetInsersectionRect = viewportRect.getIntersection( widgetRect );
+
+ // Because this is a last resort positioning, to keep things simple we're not playing with positions of the arrow
+ // like, for instance, "south west" or whatever. Just try to keep the balloon in the middle of the visible area of
+ // the widget for as long as it is possible. If the widgets becomes invisible (because cropped by the viewport),
+ // just... place the balloon in the middle of it (because why not?).
+ const targetRect = viewportWidgetInsersectionRect || widgetRect;
+ const left = targetRect.left + targetRect.width / 2 - balloonRect.width / 2;
+
+ return {
+ top: Math.max( widgetRect.top, 0 ) + BalloonPanelView.arrowVerticalOffset,
+ left,
+ name: 'arrow_n'
+ };
+}
+
// Default filler offset function applied to all widget elements.
//
// @returns {null}
diff --git a/packages/ckeditor5-widget/src/widgettoolbarrepository.js b/packages/ckeditor5-widget/src/widgettoolbarrepository.js
index 9a55a73f644..60f673f00c3 100644
--- a/packages/ckeditor5-widget/src/widgettoolbarrepository.js
+++ b/packages/ckeditor5-widget/src/widgettoolbarrepository.js
@@ -11,7 +11,10 @@ import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon';
import ToolbarView from '@ckeditor/ckeditor5-ui/src/toolbar/toolbarview';
import BalloonPanelView from '@ckeditor/ckeditor5-ui/src/panel/balloon/balloonpanelview';
-import { isWidget } from './utils';
+import {
+ isWidget,
+ centeredBalloonPositionForLongWidgets
+} from './utils';
import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
/**
@@ -272,7 +275,8 @@ function getBalloonPositionData( editor, relatedElement ) {
defaultPositions.northArrowSouthEast,
defaultPositions.southArrowNorth,
defaultPositions.southArrowNorthWest,
- defaultPositions.southArrowNorthEast
+ defaultPositions.southArrowNorthEast,
+ centeredBalloonPositionForLongWidgets
]
};
}
diff --git a/packages/ckeditor5-widget/tests/utils.js b/packages/ckeditor5-widget/tests/utils.js
index 8e0c7495cb6..dc0ae5ece4a 100644
--- a/packages/ckeditor5-widget/tests/utils.js
+++ b/packages/ckeditor5-widget/tests/utils.js
@@ -20,7 +20,8 @@ import {
setHighlightHandling,
findOptimalInsertionPosition,
viewToModelPositionOutsideModelElement,
- WIDGET_CLASS_NAME
+ WIDGET_CLASS_NAME,
+ centeredBalloonPositionForLongWidgets
} from '../src/utils';
import UIElement from '@ckeditor/ckeditor5-engine/src/view/uielement';
import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
@@ -29,6 +30,9 @@ import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
import Mapper from '@ckeditor/ckeditor5-engine/src/conversion/mapper';
import ModelElement from '@ckeditor/ckeditor5-engine/src/model/element';
import ModelText from '@ckeditor/ckeditor5-engine/src/model/text';
+import BalloonPanelView from '@ckeditor/ckeditor5-ui/src/panel/balloon/balloonpanelview';
+import global from '@ckeditor/ckeditor5-utils/src/dom/global';
+import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';
describe( 'widget utils', () => {
let element, writer, viewDocument;
@@ -489,4 +493,103 @@ describe( 'widget utils', () => {
expect( modelPosition.path ).to.deep.equal( [ 3, 1 ] );
} );
} );
+
+ describe( 'centeredBalloonPositionForLongWidgets()', () => {
+ const arrowVerticalOffset = BalloonPanelView.arrowVerticalOffset;
+
+ // Balloon is a 10x10 rect.
+ const balloonRect = new Rect( {
+ top: 0,
+ left: 0,
+ right: 10,
+ bottom: 10,
+ width: 10,
+ height: 10
+ } );
+
+ beforeEach( () => {
+ testUtils.sinon.stub( global.window, 'innerWidth' ).value( 100 );
+ testUtils.sinon.stub( global.window, 'innerHeight' ).value( 100 );
+ } );
+
+ it( 'should position the balloon inside a widget – at the top + in the middle', () => {
+ // Widget is a 50x150 rect, translated (25,25) from viewport's beginning (0,0).
+ const widgetRect = new Rect( {
+ top: 25,
+ left: 25,
+ right: 75,
+ bottom: 175,
+ width: 50,
+ height: 150
+ } );
+
+ const position = centeredBalloonPositionForLongWidgets( widgetRect, balloonRect );
+
+ expect( position ).to.deep.equal( {
+ top: 25 + arrowVerticalOffset,
+ left: 45,
+ name: 'arrow_n'
+ } );
+ } );
+
+ it( 'should stick the balloon to the top of the viewport when the top of a widget is off-screen', () => {
+ // Widget is a 50x150 rect, translated (25,-25) from viewport's beginning (0,0).
+ const widgetRect = new Rect( {
+ top: -25,
+ left: 25,
+ right: 75,
+ bottom: 150,
+ width: 50,
+ height: 150
+ } );
+
+ const position = centeredBalloonPositionForLongWidgets( widgetRect, balloonRect );
+
+ expect( position ).to.deep.equal( {
+ top: arrowVerticalOffset,
+ left: 45,
+ name: 'arrow_n'
+ } );
+ } );
+
+ it( 'should horizontally center the balloon in the visible area when the widget is cropped by the viewport', () => {
+ // Widget is a 50x150 rect, translated (25,-25) from viewport's beginning (0,0).
+ const widgetRect = new Rect( {
+ top: 25,
+ left: -25,
+ right: 25,
+ bottom: 175,
+ width: 50,
+ height: 150
+ } );
+
+ const position = centeredBalloonPositionForLongWidgets( widgetRect, balloonRect );
+
+ expect( position ).to.deep.equal( {
+ top: 25 + arrowVerticalOffset,
+ left: 7.5,
+ name: 'arrow_n'
+ } );
+ } );
+
+ it( 'should horizontally center the balloon in the widget when the widget is completely off the viewport', () => {
+ // Widget is a 50x150 rect, translated (0,-100) from viewport's beginning (0,0).
+ const widgetRect = new Rect( {
+ top: 0,
+ left: -100,
+ right: -50,
+ bottom: 150,
+ width: 50,
+ height: 150
+ } );
+
+ const position = centeredBalloonPositionForLongWidgets( widgetRect, balloonRect );
+
+ expect( position ).to.deep.equal( {
+ top: 0 + arrowVerticalOffset,
+ left: -80,
+ name: 'arrow_n'
+ } );
+ } );
+ } );
} );
diff --git a/packages/ckeditor5-widget/tests/widgettoolbarrepository.js b/packages/ckeditor5-widget/tests/widgettoolbarrepository.js
index a204271f8f9..4cad02baeb8 100644
--- a/packages/ckeditor5-widget/tests/widgettoolbarrepository.js
+++ b/packages/ckeditor5-widget/tests/widgettoolbarrepository.js
@@ -7,13 +7,18 @@
import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
import BalloonEditor from '@ckeditor/ckeditor5-editor-balloon/src/ballooneditor';
+import BalloonPanelView from '@ckeditor/ckeditor5-ui/src/panel/balloon/balloonpanelview';
import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
import BlockQuote from '@ckeditor/ckeditor5-block-quote/src/blockquote';
import Widget from '../src/widget';
import WidgetToolbarRepository from '../src/widgettoolbarrepository';
-import { isWidget, toWidget } from '../src/utils';
+import {
+ isWidget,
+ toWidget,
+ centeredBalloonPositionForLongWidgets
+} from '../src/utils';
import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
import View from '@ckeditor/ckeditor5-ui/src/view';
@@ -470,6 +475,40 @@ describe( 'WidgetToolbarRepository', () => {
expect( balloon.view.pin.lastCall.args[ 0 ].target ).to.equal( newFakeDomElement );
} );
+
+ it( 'toolbar should use one of pre-defined positions when attaching to a widget', () => {
+ const editingView = editor.editing.view;
+ const balloonAddSpy = sinon.spy( balloon, 'add' );
+ const defaultPositions = BalloonPanelView.defaultPositions;
+
+ widgetToolbarRepository.register( 'fake', {
+ items: editor.config.get( 'fake.toolbar' ),
+ getRelatedElement: getSelectedFakeWidget
+ } );
+
+ setData( model, 'foo[]' );
+
+ const fakeWidgetToolbarView = widgetToolbarRepository._toolbarDefinitions.get( 'fake' ).view;
+ const widgetViewElement = editingView.document.getRoot().getChild( 1 );
+
+ sinon.assert.calledOnce( balloonAddSpy );
+ sinon.assert.calledWithExactly( balloonAddSpy, {
+ view: fakeWidgetToolbarView,
+ position: {
+ target: editingView.domConverter.mapViewToDom( widgetViewElement ),
+ positions: [
+ defaultPositions.northArrowSouth,
+ defaultPositions.northArrowSouthWest,
+ defaultPositions.northArrowSouthEast,
+ defaultPositions.southArrowNorth,
+ defaultPositions.southArrowNorthWest,
+ defaultPositions.southArrowNorthEast,
+ centeredBalloonPositionForLongWidgets
+ ]
+ },
+ balloonClassName: 'ck-toolbar-container'
+ } );
+ } );
} );
} );