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' + } ); + } ); } ); } );