diff --git a/src/balloonpanel/balloonpanelview.js b/src/balloonpanel/balloonpanelview.js index c3567f2..8867078 100644 --- a/src/balloonpanel/balloonpanelview.js +++ b/src/balloonpanel/balloonpanelview.js @@ -7,15 +7,16 @@ * @module ui/balloonpanel/balloonpanelview */ -/* globals window, Range, HTMLElement */ +/* globals document */ import View from '../view.js'; import Template from '../template.js'; +import { getOptimalPosition } from '../../utils/dom/position.js'; import toUnit from '../../utils/dom/tounit.js'; const toPx = toUnit( 'px' ); -const arrowLeftOffset = 30; -const arrowTopOffset = 15; +const arrowHOffset = 30; +const arrowVOffset = 15; /** * The balloon panel view class. @@ -50,13 +51,14 @@ export default class BalloonPanelView extends View { this.set( 'left', 0 ); /** - * Balloon panel arrow direction. + * Balloon panel's current position. Must correspond with + * {@link module:ui/balloonpanel/balloonpanelview~BalloonPanelView.defaultPositions}. * * @observable * @default 'se' - * @member {'se'|'sw'|'ne'|'nw'} #arrow + * @member {'se'|'sw'|'ne'|'nw'} #position */ - this.set( 'arrow', 'se' ); + this.set( 'position', 'se' ); /** * Controls whether the balloon panel is visible or not. @@ -87,7 +89,7 @@ export default class BalloonPanelView extends View { attributes: { class: [ 'ck-balloon-panel', - bind.to( 'arrow', ( value ) => `ck-balloon-panel_arrow_${ value }` ), + bind.to( 'position', ( value ) => `ck-balloon-panel_arrow_${ value }` ), bind.if( 'isVisible', 'ck-balloon-panel_visible' ) ], @@ -126,283 +128,105 @@ export default class BalloonPanelView extends View { /** * Attaches the balloon panel to a specified DOM element or range with a smart heuristics. * - * **Notes**: + * See {@link @link module:utils/dom/position~getOptimalPosition}. * - * * The algorithm takes the geometry of the "limiter element" into consideration so, - * if possible, the balloon is positioned within the rect of that element. - * * If possible, the balloon is positioned within the area of the "limiter element" - * fitting into the browser viewport visible to the user. It prevents the panel from - * appearing off screen. + * TODO: More docs and examples. * - * The heuristics chooses from among 4 available positions relative to the target DOM element or range: - * - * * South east: - * - * [ Target ] - * ^ - * +-----------------+ - * | | - * +-----------------+ - * - * - * * South west: - * - * [ Target ] - * ^ - * +-----------------+ - * | | - * +-----------------+ - * - * - * * North east: - * - * +-----------------+ - * | | - * +-----------------+ - * V - * [ Target ] - * - * - * * North west: - * - * +-----------------+ - * | | - * +-----------------+ - * V - * [ Target ] - * - * See {@link #arrow}. - * - * @param {HTMLElement|Range} elementOrRange Target DOM element or range to which the balloon will be attached. - * @param {HTMLElement|Object} limiterElementOrRect The DOM element or element rect - * beyond which area the balloon panel should not be positioned, if possible. + * @param {module:utils/dom/position~Options} options Positioning options compatible with + * {@link module:utils/dom/position~getOptimalPosition}. Default `positions` array is + * {@link module:ui/balloonpanel/balloonpanelview~BalloonPanelView.defaultPositions}. */ - attachTo( elementOrRange, limiterElementOrRect ) { + attachTo( options ) { this.show(); - const elementOrRangeRect = new AbsoluteDomRect( elementOrRange ); - const panelRect = new AbsoluteDomRect( this.element ); - const limiterVisibleRect = getAbsoluteRectVisibleInTheViewport( limiterElementOrRect ); - - // Create a rect for each of the possible balloon positions and feed them to _smartAttachTo, - // which will use whichever is the optimal. Position are ordered from most to less desired. - const possiblePanelRects = { - // The absolute rect for "South east" position. - se: panelRect.clone().moveTo( { - top: elementOrRangeRect.bottom + arrowTopOffset, - left: elementOrRangeRect.left + elementOrRangeRect.width / 2 - arrowLeftOffset - } ), - - // The absolute rect for "South west" position. - sw: panelRect.clone().moveTo( { - top: elementOrRangeRect.bottom + arrowTopOffset, - left: elementOrRangeRect.left + elementOrRangeRect.width / 2 - panelRect.width + arrowLeftOffset - } ), - - // The absolute rect for "North east" position. - ne: panelRect.clone().moveTo( { - top: elementOrRangeRect.top - panelRect.height - arrowTopOffset, - left: elementOrRangeRect.left + elementOrRangeRect.width / 2 - arrowLeftOffset - } ), - - // The absolute rect for "North west" position. - nw: panelRect.clone().moveTo( { - top: elementOrRangeRect.top - panelRect.height - arrowTopOffset, - left: elementOrRangeRect.left + elementOrRangeRect.width / 2 - panelRect.width + arrowLeftOffset - } ) - }; - - this._smartAttachTo( possiblePanelRects, limiterVisibleRect, panelRect.width * panelRect.height ); - } - - /** - * For the given set of possible rects, chooses the one which fits the best into both - browser viewport and - * `visibleContainerRect`, which is when their intersection has the biggest area. Note that priority is a possible - * highest intersection area with browser viewport. - * - * @private - * @param {Object} rects Set of positions where balloon can be placed. - * @param {module:ui/balloonpanel/balloonpanelview~AbsoluteDomRect} visibleContainerRect The absolute rect of the - * visible part of container element. - * @param {Number} panelSurfaceArea Panel surface area. - */ - _smartAttachTo( rects, visibleContainerRect, panelSurfaceArea ) { - const viewportRect = new AbsoluteDomRect( getAbsoluteViewportRect() ); - const positionedAncestor = getPositionedAncestor( this.element.parentElement ); - - let maxIntersectRectPos; - let maxContainerIntersectArea = -1; - let maxViewportIntersectArea = -1; - - // Find the best place. Stop searching when the position with fully visible panel has been found. - Object.keys( rects ).some( ( rectPos ) => { - const containerIntersectArea = rects[ rectPos ].getIntersectArea( visibleContainerRect ); - const viewportIntersectArea = rects[ rectPos ].getIntersectArea( viewportRect ); - - if ( viewportIntersectArea >= maxViewportIntersectArea && containerIntersectArea > maxContainerIntersectArea ) { - maxIntersectRectPos = rectPos; - maxContainerIntersectArea = containerIntersectArea; - maxViewportIntersectArea = viewportIntersectArea; - } - - return maxContainerIntersectArea === panelSurfaceArea; - } ); - - // Move the balloon panel. - this.arrow = maxIntersectRectPos; - - let { top, left } = rects[ maxIntersectRectPos ]; - - // (#126) If there's some positioned ancestor of the panel, then its positioned rect must be taken into - // consideration because `AbsoluteDomRect` is always relative to the viewport. - if ( positionedAncestor ) { - const positionedAncestorRect = positionedAncestor.getBoundingClientRect(); - - top -= positionedAncestorRect.top; - left -= positionedAncestorRect.left; - } - - this.top = top; - this.left = left; + const defaultPositions = BalloonPanelView.defaultPositions; + const positionOptions = Object.assign( {}, { + element: this.element, + positions: [ + defaultPositions.se, + defaultPositions.sw, + defaultPositions.ne, + defaultPositions.nw + ], + limiter: document.body, + fitInViewport: true + }, options ); + + const { top, left, name: position } = getOptimalPosition( positionOptions ); + + Object.assign( this, { top, left, position } ); } } /** - * A class which represents a client rect of an HTMLElement or a Range in DOM. + * A default set of positioning functions used by the balloon panel view + * when attaching using {@link #attachTo} method. + * + * The available positioning functions are as follows: + * + * * South east: + * + * [ Target ] + * ^ + * +-----------------+ + * | Balloon | + * +-----------------+ + * + * + * * South west: * - * @private + * [ Target ] + * ^ + * +-----------------+ + * | Balloon | + * +-----------------+ + * + * + * * North east: + * + * +-----------------+ + * | Balloon | + * +-----------------+ + * V + * [ Target ] + * + * + * * North west: + * + * +-----------------+ + * | Balloon | + * +-----------------+ + * V + * [ Target ] + * + * See {@link #attachTo}. + * + * Positioning functions must be compatible with {@link module:utils/dom/position~Position}. + * + * @member {Object} module:ui/balloonpanel/balloonpanelview~BalloonPanelView.defaultPositions */ -class AbsoluteDomRect { - // Create instance of AbsoluteDomRect class. - // - // @param {HTMLElement|Range|Object} elementOrRangeOrRect Source object to create the rect. - constructor( elementOrRangeOrRect ) { - Object.assign( this, getAbsoluteRect( elementOrRangeOrRect ) ); - } - - // Clone instance of this class. - // - // @returns {AbsoluteDomRect} - clone() { - return new AbsoluteDomRect( this ); - } - - // Move current box to specified position. - // - // @param {Number} top New to position. - // @param {Number} left New left position. - // @returns {AbsoluteDomRect} - moveTo( { top, left } ) { - this.top = top; - this.right = left + this.width; - this.bottom = top + this.height; - this.left = left; - - return this; - } - - // Get intersect surface area of this AbsoluteDomRect and other AbsoluteDomRect. - // - // @param {AbsoluteDomRect} rect - // @returns {Number} Overlap surface area. - getIntersectArea( rect ) { - const hOverlap = Math.max( 0, Math.min( this.right, rect.right ) - Math.max( this.left, rect.left ) ); - const vOverlap = Math.max( 0, Math.min( this.bottom, rect.bottom ) - Math.max( this.top, rect.top ) ); - - return hOverlap * vOverlap; - } -} - -// Returns the client rect of an HTMLElement, Range, or rect. The obtained geometry of the rect -// corresponds with `position: absolute` relative to the `` (`document.body`). -// -// @private -// @param {HTMLElement|Range|Object} elementOrRangeOrRect Target object witch rect is to be determined. -// @returns {Object} Client rect object. -function getAbsoluteRect( elementOrRangeOrRect ) { - if ( elementOrRangeOrRect instanceof HTMLElement || elementOrRangeOrRect instanceof Range ) { - let { top, right, bottom, left, width, height } = elementOrRangeOrRect.getBoundingClientRect(); - - return { top, right, bottom, left, width, height }; - } - // A rect has been passed. - else { - const absoluteRect = Object.assign( {}, elementOrRangeOrRect ); - - if ( absoluteRect.width === undefined ) { - absoluteRect.width = absoluteRect.right - absoluteRect.left; - } - - if ( absoluteRect.height === undefined ) { - absoluteRect.height = absoluteRect.bottom - absoluteRect.top; - } - - return absoluteRect; - } -} - -// For a given element, returns the nearest ancestor element which position is not "static". -// -// @private -// @param {HTMLElement} element Element which ancestors are checked. -// @returns {HTMLElement|null} -function getPositionedAncestor( element ) { - while ( element && element.tagName.toLowerCase() != 'html' ) { - if ( window.getComputedStyle( element ).position != 'static' ) { - return element; - } - - element = element.parentNode; - } - - return null; -} - -// Returns the client rect of the element limited by the visible (to the user) -// viewport of the browser window. -// -// [Browser viewport] -// +---------------------------------------+ -// | [Element] | -// | +----------------------+ -// | |##############| | -// | |##############| | -// | |#######^######| | -// | +-------|--------------+ -// | | | -// +--------------------------------|------+ -// | -// \- [Element rect visible in the viewport] -// -// @private -// @param {HTMLElement|Object} element Object which visible area rect is to be determined. -// @returns {AbsoluteDomRect} An absolute rect of the area visible in the viewport. -function getAbsoluteRectVisibleInTheViewport( element ) { - const elementRect = getAbsoluteRect( element ); - const viewportRect = getAbsoluteViewportRect(); - - return new AbsoluteDomRect( { - top: Math.max( elementRect.top, viewportRect.top ), - left: Math.max( elementRect.left, viewportRect.left ), - right: Math.min( elementRect.right, viewportRect.right ), - bottom: Math.min( elementRect.bottom, viewportRect.bottom ) - } ); -} - -// Get browser viewport rect. -// -// @private -// @returns {Object} Viewport rect. -function getAbsoluteViewportRect() { - const windowScrollX = window.scrollX; - const windowScrollY = window.scrollY; - const windowWidth = window.innerWidth; - const windowHeight = window.innerHeight; - - return { - top: windowScrollY, - right: windowWidth + windowScrollX, - bottom: windowHeight + windowScrollY, - left: windowScrollX - }; -} +BalloonPanelView.defaultPositions = { + se: ( targetRect ) => ( { + top: targetRect.bottom + arrowVOffset, + left: targetRect.left + targetRect.width / 2 - arrowHOffset, + name: 'se' + } ), + + sw: ( targetRect, balloonRect ) => ( { + top: targetRect.bottom + arrowVOffset, + left: targetRect.left + targetRect.width / 2 - balloonRect.width + arrowHOffset, + name: 'sw' + } ), + + ne: ( targetRect, balloonRect ) => ( { + top: targetRect.top - balloonRect.height - arrowVOffset, + left: targetRect.left + targetRect.width / 2 - arrowHOffset, + name: 'ne' + } ), + + nw: ( targetRect, balloonRect ) => ( { + top: targetRect.top - balloonRect.height - arrowVOffset, + left: targetRect.left + targetRect.width / 2 - balloonRect.width + arrowHOffset, + name: 'nw' + } ) +}; diff --git a/tests/balloonpanel/balloonpanelview.js b/tests/balloonpanel/balloonpanelview.js index ea3e0e8..07bed26 100644 --- a/tests/balloonpanel/balloonpanelview.js +++ b/tests/balloonpanel/balloonpanelview.js @@ -10,6 +10,7 @@ import ViewCollection from 'ckeditor5/ui/viewcollection.js'; import BalloonPanelView from 'ckeditor5/ui/balloonpanel/balloonpanelview.js'; import ButtonView from 'ckeditor5/ui/button/buttonview.js'; import testUtils from 'tests/core/_utils/utils.js'; +import * as positionUtils from 'ckeditor5/utils/dom/position.js'; testUtils.createSinonSandbox(); @@ -24,6 +25,11 @@ describe( 'BalloonPanelView', () => { return view.init(); } ); + afterEach( () => { + // Tests require stable viewport environment. + window.scrollY = window.scrollX = 0; + } ); + describe( 'constructor()', () => { it( 'should create element from template', () => { expect( view.element.tagName ).to.equal( 'DIV' ); @@ -34,7 +40,7 @@ describe( 'BalloonPanelView', () => { it( 'should set default values', () => { expect( view.top ).to.equal( 0 ); expect( view.left ).to.equal( 0 ); - expect( view.arrow ).to.equal( 'se' ); + expect( view.position ).to.equal( 'se' ); expect( view.isVisible ).to.equal( false ); } ); @@ -45,10 +51,10 @@ describe( 'BalloonPanelView', () => { describe( 'DOM bindings', () => { describe( 'arrow', () => { - it( 'should react on view#arrow', () => { + it( 'should react on view#position', () => { expect( view.element.classList.contains( 'ck-balloon-panel_arrow_se' ) ).to.true; - view.set( 'arrow', 'sw' ); + view.position = 'sw'; expect( view.element.classList.contains( 'ck-balloon-panel_arrow_sw' ) ).to.true; } ); @@ -133,11 +139,11 @@ describe( 'BalloonPanelView', () => { } ); describe( 'attachTo()', () => { - let targetEl, limiterEl; + let target, limiter; beforeEach( () => { - limiterEl = document.createElement( 'div' ); - targetEl = document.createElement( 'div' ); + limiter = document.createElement( 'div' ); + target = document.createElement( 'div' ); // Mock balloon panel element dimensions. mockBoundingBox( view.element, { @@ -147,15 +153,34 @@ describe( 'BalloonPanelView', () => { height: 100 } ); - // Make sure that limiterEl is fully visible in viewport. + // Make sure that limiter is fully visible in viewport. testUtils.sinon.stub( window, 'innerWidth', 500 ); testUtils.sinon.stub( window, 'innerHeight', 500 ); } ); + it( 'should use default options', () => { + const spy = testUtils.sinon.spy( positionUtils, 'getOptimalPosition' ); + + view.attachTo( { target } ); + + sinon.assert.calledWithExactly( spy, sinon.match( { + element: view.element, + target: target, + positions: [ + BalloonPanelView.defaultPositions.se, + BalloonPanelView.defaultPositions.sw, + BalloonPanelView.defaultPositions.ne, + BalloonPanelView.defaultPositions.nw + ], + limiter: document.body, + fitInViewport: true + } ) ); + } ); + describe( 'limited by limiter element', () => { beforeEach( () => { // Mock limiter element dimensions. - mockBoundingBox( limiterEl, { + mockBoundingBox( limiter, { left: 0, top: 0, width: 500, @@ -164,70 +189,69 @@ describe( 'BalloonPanelView', () => { } ); it( 'should put balloon on the `south east` side of the target element at default', () => { - // Place target element at the center of the limiterEl. - mockBoundingBox( targetEl, { + // Place target element at the center of the limiter. + mockBoundingBox( target, { top: 225, left: 225, width: 50, height: 50 } ); - view.attachTo( targetEl, limiterEl ); + view.attachTo( { target, limiter } ); - expect( view.arrow ).to.equal( 'se' ); + expect( view.position ).to.equal( 'se' ); } ); - it( 'should put balloon on the `south east` side of the target element when target is on the top left side of the limiterEl', () => { - // Place target element at the center of the limiterEl. - mockBoundingBox( targetEl, { + it( 'should put balloon on the `south east` side of the target element when target is on the top left side of the limiter', () => { + mockBoundingBox( target, { top: 0, left: 0, width: 50, height: 50 } ); - view.attachTo( targetEl, limiterEl ); + view.attachTo( { target, limiter } ); - expect( view.arrow ).to.equal( 'se' ); + expect( view.position ).to.equal( 'se' ); } ); - it( 'should put balloon on the `south west` side of the target element when target is on the right side of the limiterEl', () => { - mockBoundingBox( targetEl, { + it( 'should put balloon on the `south west` side of the target element when target is on the right side of the limiter', () => { + mockBoundingBox( target, { top: 0, left: 450, width: 50, height: 50 } ); - view.attachTo( targetEl, limiterEl ); + view.attachTo( { target, limiter } ); - expect( view.arrow ).to.equal( 'sw' ); + expect( view.position ).to.equal( 'sw' ); } ); - it( 'should put balloon on the `north east` side of the target element when target is on the bottom of the limiterEl ', () => { - mockBoundingBox( targetEl, { + it( 'should put balloon on the `north east` side of the target element when target is on the bottom of the limiter ', () => { + mockBoundingBox( target, { top: 450, left: 0, width: 50, height: 50 } ); - view.attachTo( targetEl, limiterEl ); + view.attachTo( { target, limiter } ); - expect( view.arrow ).to.equal( 'ne' ); + expect( view.position ).to.equal( 'ne' ); } ); - it( 'should put balloon on the `north west` side of the target element when target is on the bottom right of the limiterEl', () => { - mockBoundingBox( targetEl, { + it( 'should put balloon on the `north west` side of the target element when target is on the bottom right of the limiter', () => { + mockBoundingBox( target, { top: 450, left: 450, width: 50, height: 50 } ); - view.attachTo( targetEl, limiterEl ); + view.attachTo( { target, limiter } ); - expect( view.arrow ).to.equal( 'nw' ); + expect( view.position ).to.equal( 'nw' ); } ); // #126 @@ -242,14 +266,14 @@ describe( 'BalloonPanelView', () => { document.body.appendChild( positionedAncestor ); positionedAncestor.appendChild( view.element ); - mockBoundingBox( targetEl, { + mockBoundingBox( target, { top: 0, left: 0, width: 100, height: 100 } ); - view.attachTo( targetEl, limiterEl ); + view.attachTo( { target, limiter } ); expect( view.top ).to.equal( 15 ); expect( view.left ).to.equal( -80 ); @@ -265,14 +289,14 @@ describe( 'BalloonPanelView', () => { document.body.appendChild( positionedAncestor ); positionedAncestor.appendChild( view.element ); - mockBoundingBox( targetEl, { + mockBoundingBox( target, { top: 0, left: 0, width: 100, height: 100 } ); - view.attachTo( targetEl, limiterEl ); + view.attachTo( { target, limiter } ); expect( view.top ).to.equal( 115 ); expect( view.left ).to.equal( 20 ); @@ -281,14 +305,14 @@ describe( 'BalloonPanelView', () => { describe( 'limited by viewport', () => { it( 'should put balloon on the `south west` position when `south east` is limited', () => { - mockBoundingBox( limiterEl, { + mockBoundingBox( limiter, { left: 0, top: 0, width: 500, height: 500 } ); - mockBoundingBox( targetEl, { + mockBoundingBox( target, { top: 0, left: 225, width: 50, @@ -297,42 +321,40 @@ describe( 'BalloonPanelView', () => { testUtils.sinon.stub( window, 'innerWidth', 275 ); - view.attachTo( targetEl, limiterEl ); + view.attachTo( { target, limiter } ); - expect( view.arrow ).to.equal( 'sw' ); + expect( view.position ).to.equal( 'sw' ); } ); it( 'should put balloon on the `south east` position when `south west` is limited', () => { - mockBoundingBox( limiterEl, { - left: -400, + mockBoundingBox( limiter, { top: 0, + left: -400, width: 500, height: 500 } ); - mockBoundingBox( targetEl, { + mockBoundingBox( target, { top: 0, left: 0, width: 50, height: 50 } ); - testUtils.sinon.stub( window, 'scrollX', 400 ); + view.attachTo( { target, limiter } ); - view.attachTo( targetEl, limiterEl ); - - expect( view.arrow ).to.equal( 'se' ); + expect( view.position ).to.equal( 'se' ); } ); it( 'should put balloon on the `north east` position when `south east` is limited', () => { - mockBoundingBox( limiterEl, { + mockBoundingBox( limiter, { left: 0, top: 0, width: 500, height: 500 } ); - mockBoundingBox( targetEl, { + mockBoundingBox( target, { top: 225, left: 0, width: 50, @@ -341,31 +363,29 @@ describe( 'BalloonPanelView', () => { testUtils.sinon.stub( window, 'innerHeight', 275 ); - view.attachTo( targetEl, limiterEl ); + view.attachTo( { target, limiter } ); - expect( view.arrow ).to.equal( 'ne' ); + expect( view.position ).to.equal( 'ne' ); } ); it( 'should put balloon on the `south east` position when `north east` is limited', () => { - mockBoundingBox( limiterEl, { + mockBoundingBox( limiter, { left: 0, top: -400, width: 500, height: 500 } ); - mockBoundingBox( targetEl, { + mockBoundingBox( target, { top: 0, left: 0, width: 50, height: 50 } ); - testUtils.sinon.stub( window, 'scrollY', 400 ); - - view.attachTo( targetEl, limiterEl ); + view.attachTo( { target, limiter } ); - expect( view.arrow ).to.equal( 'se' ); + expect( view.position ).to.equal( 'se' ); } ); } ); } ); diff --git a/tests/manual/tickets/126/1.html b/tests/manual/tickets/126/1.html index a2eebee..eafa02d 100644 --- a/tests/manual/tickets/126/1.html +++ b/tests/manual/tickets/126/1.html @@ -2,17 +2,50 @@ -
-
-
-
+ + +
+
+
+
+
+ + +
+ +