8
0
Эх сурвалжийг харах

Fix: The `getOptimalPosition` utility should consider limiter ancestors with CSS overflow. Closes #148.

T/148: getOptimalPosition utility should consider limiter ancestors with CSS overflow. Closes #148.
Oskar Wróbel 8 жил өмнө
parent
commit
d56e1dbcb2

+ 1 - 1
packages/ckeditor5-utils/src/dom/position.js

@@ -87,7 +87,7 @@ export function getOptimalPosition( { element, target, positions, limiter, fitIn
 	if ( !limiter && !fitInViewport ) {
 		[ name, bestPosition ] = getPosition( positions[ 0 ], targetRect, elementRect );
 	} else {
-		const limiterRect = limiter && new Rect( limiter );
+		const limiterRect = limiter && new Rect( limiter ).getVisible();
 		const viewportRect = fitInViewport && Rect.getViewportRect();
 
 		[ name, bestPosition ] =

+ 59 - 5
packages/ckeditor5-utils/src/dom/rect.js

@@ -37,14 +37,28 @@ export default class Rect {
 	 *		// Rect out of a ClientRect.
 	 *		const rectE = new Rect( document.body.getClientRects().item( 0 ) );
 	 *
-	 * @param {HTMLElement|Range|ClientRect|module:utils/dom/rect~Rect|Object} obj A source object to create the rect.
+	 * @param {HTMLElement|Range|ClientRect|module:utils/dom/rect~Rect|Object} source A source object to create the rect.
 	 */
-	constructor( obj ) {
-		if ( isElement( obj ) || isRange( obj ) ) {
-			obj = obj.getBoundingClientRect();
+	constructor( source ) {
+		/**
+		 * The object this rect is for.
+		 *
+		 * @protected
+		 * @readonly
+		 * @member {HTMLElement|Range|ClientRect|module:utils/dom/rect~Rect|Object} #_source
+		 */
+		Object.defineProperty( this, '_source', {
+			// source._source if already the Rect instance
+			value: source._source || source,
+			writable: false,
+			enumerable: false
+		} );
+
+		if ( isElement( source ) || isRange( source ) ) {
+			source = source.getBoundingClientRect();
 		}
 
-		rectProperties.forEach( p => this[ p ] = obj[ p ] );
+		rectProperties.forEach( p => this[ p ] = source[ p ] );
 
 		/**
 		 * The "top" value of the rect.
@@ -179,6 +193,46 @@ export default class Rect {
 		return this.width * this.height;
 	}
 
+	/**
+	 * Returns a new rect, a part of the original rect, which is actually visible to the user,
+	 * e.g. an original rect cropped by parent element rects which have `overflow` set in CSS
+	 * other than `"visible"`.
+	 *
+	 * If there's no such visible rect, which is when the rect is limited by one or many of
+	 * the ancestors, `null` is returned.
+	 *
+	 * @returns {module:utils/dom/rect~Rect|null} A visible rect instance or `null`, if there's none.
+	 */
+	getVisible() {
+		const source = this._source;
+		let visibleRect = this.clone();
+
+		// There's no ancestor to crop <body> with the overflow.
+		if ( source != global.document.body ) {
+			let parent = source.parentNode || source.commonAncestorContainer;
+
+			// Check the ancestors all the way up to the <body>.
+			while ( parent && parent != global.document.body ) {
+				const parentRect = new Rect( parent );
+				const intersectionRect = visibleRect.getIntersection( parentRect );
+
+				if ( intersectionRect ) {
+					if ( intersectionRect.getArea() < visibleRect.getArea() ) {
+						// Reduce the visible rect to the intersection.
+						visibleRect = intersectionRect;
+					}
+				} else {
+					// There's no intersection, the rect is completely invisible.
+					return null;
+				}
+
+				parent = parent.parentNode;
+			}
+		}
+
+		return visibleRect;
+	}
+
 	/**
 	 * Returns a rect of the web browser viewport.
 	 *

+ 21 - 0
packages/ckeditor5-utils/tests/dom/position.js

@@ -159,6 +159,27 @@ describe( 'getOptimalPosition()', () => {
 				name: 'left'
 			} );
 		} );
+
+		// https://github.com/ckeditor/ckeditor5-utils/issues/148
+		it( 'should return coordinates (#3)', () => {
+			limiter.parentNode = getElement( {
+				top: 100,
+				left: 0,
+				bottom: 110,
+				right: 10,
+				width: 10,
+				height: 10
+			} );
+
+			assertPosition( {
+				element, target, limiter,
+				positions: [ attachRight, attachLeft ]
+			}, {
+				top: 100,
+				left: 10,
+				name: 'right'
+			} );
+		} );
 	} );
 
 	describe( 'with fitInViewport on', () => {

+ 213 - 0
packages/ckeditor5-utils/tests/dom/rect.js

@@ -26,6 +26,13 @@ describe( 'Rect', () => {
 	} );
 
 	describe( 'constructor()', () => {
+		it( 'should store passed object in #_source property', () => {
+			const obj = {};
+			const rect = new Rect( obj );
+
+			expect( rect._source ).to.equal( obj );
+		} );
+
 		it( 'should accept HTMLElement', () => {
 			const element = document.createElement( 'div' );
 
@@ -97,6 +104,14 @@ describe( 'Rect', () => {
 			expect( clone ).not.equal( rect );
 			assertRect( clone, rect );
 		} );
+
+		it( 'should preserve #_source', () => {
+			const rect = new Rect( geometry );
+			const clone = rect.clone();
+
+			expect( clone._source ).to.equal( rect._source );
+			assertRect( clone, rect );
+		} );
 	} );
 
 	describe( 'moveTo()', () => {
@@ -320,6 +335,204 @@ describe( 'Rect', () => {
 		} );
 	} );
 
+	describe( 'getVisible()', () => {
+		let element, range, ancestorA, ancestorB;
+
+		beforeEach( () => {
+			element = document.createElement( 'div' );
+			range = document.createRange();
+			ancestorA = document.createElement( 'div' );
+			ancestorB = document.createElement( 'div' );
+
+			ancestorA.append( element );
+			document.body.appendChild( ancestorA );
+		} );
+
+		afterEach( () => {
+			ancestorA.remove();
+			ancestorB.remove();
+		} );
+
+		it( 'should return a new rect', () => {
+			const rect = new Rect( {} );
+			const visible = rect.getVisible();
+
+			expect( visible ).to.not.equal( rect );
+		} );
+
+		it( 'should not fail when the rect is for document#body', () => {
+			testUtils.sinon.stub( document.body, 'getBoundingClientRect' ).returns( {
+				top: 0,
+				right: 100,
+				bottom: 100,
+				left: 0,
+				width: 100,
+				height: 100
+			} );
+
+			assertRect( new Rect( document.body ).getVisible(), {
+				top: 0,
+				right: 100,
+				bottom: 100,
+				left: 0,
+				width: 100,
+				height: 100
+			} );
+		} );
+
+		it( 'should return the visible rect (HTMLElement), partially cropped', () => {
+			testUtils.sinon.stub( element, 'getBoundingClientRect' ).returns( {
+				top: 0,
+				right: 100,
+				bottom: 100,
+				left: 0,
+				width: 100,
+				height: 100
+			} );
+
+			testUtils.sinon.stub( ancestorA, 'getBoundingClientRect' ).returns( {
+				top: 50,
+				right: 150,
+				bottom: 150,
+				left: 50,
+				width: 100,
+				height: 100
+			} );
+
+			assertRect( new Rect( element ).getVisible(), {
+				top: 50,
+				right: 100,
+				bottom: 100,
+				left: 50,
+				width: 50,
+				height: 50
+			} );
+		} );
+
+		it( 'should return the visible rect (HTMLElement), fully visible', () => {
+			testUtils.sinon.stub( element, 'getBoundingClientRect' ).returns( {
+				top: 0,
+				right: 100,
+				bottom: 100,
+				left: 0,
+				width: 100,
+				height: 100
+			} );
+
+			testUtils.sinon.stub( ancestorA, 'getBoundingClientRect' ).returns( {
+				top: 0,
+				right: 150,
+				bottom: 150,
+				left: 0,
+				width: 150,
+				height: 150
+			} );
+
+			assertRect( new Rect( element ).getVisible(), {
+				top: 0,
+				right: 100,
+				bottom: 100,
+				left: 0,
+				width: 100,
+				height: 100
+			} );
+		} );
+
+		it( 'should return the visible rect (HTMLElement), partially cropped, deep ancestor overflow', () => {
+			ancestorB.append( ancestorA );
+			document.body.appendChild( ancestorB );
+
+			testUtils.sinon.stub( element, 'getBoundingClientRect' ).returns( {
+				top: 0,
+				right: 100,
+				bottom: 100,
+				left: 0,
+				width: 100,
+				height: 100
+			} );
+
+			testUtils.sinon.stub( ancestorA, 'getBoundingClientRect' ).returns( {
+				top: 50,
+				right: 100,
+				bottom: 100,
+				left: 0,
+				width: 50,
+				height: 50
+			} );
+
+			testUtils.sinon.stub( ancestorB, 'getBoundingClientRect' ).returns( {
+				top: 0,
+				right: 150,
+				bottom: 100,
+				left: 50,
+				width: 100,
+				height: 100
+			} );
+
+			assertRect( new Rect( element ).getVisible(), {
+				top: 50,
+				right: 100,
+				bottom: 100,
+				left: 50,
+				width: 50,
+				height: 50
+			} );
+		} );
+
+		it( 'should return the visible rect (Range), partially cropped', () => {
+			range.setStart( ancestorA, 0 );
+
+			testUtils.sinon.stub( range, 'getBoundingClientRect' ).returns( {
+				top: 0,
+				right: 100,
+				bottom: 100,
+				left: 0,
+				width: 100,
+				height: 100
+			} );
+
+			testUtils.sinon.stub( ancestorA, 'getBoundingClientRect' ).returns( {
+				top: 50,
+				right: 150,
+				bottom: 150,
+				left: 50,
+				width: 100,
+				height: 100
+			} );
+
+			assertRect( new Rect( range ).getVisible(), {
+				top: 50,
+				right: 100,
+				bottom: 100,
+				left: 50,
+				width: 50,
+				height: 50
+			} );
+		} );
+
+		it( 'should return null if there\'s no visible rect', () => {
+			testUtils.sinon.stub( element, 'getBoundingClientRect' ).returns( {
+				top: 0,
+				right: 100,
+				bottom: 100,
+				left: 0,
+				width: 100,
+				height: 100
+			} );
+
+			testUtils.sinon.stub( ancestorA, 'getBoundingClientRect' ).returns( {
+				top: 150,
+				right: 200,
+				bottom: 200,
+				left: 150,
+				width: 50,
+				height: 50
+			} );
+
+			expect( new Rect( element ).getVisible() ).to.equal( null );
+		} );
+	} );
+
 	describe( 'getViewportRect()', () => {
 		it( 'should reaturn a rect', () => {
 			expect( Rect.getViewportRect() ).to.be.instanceOf( Rect );

+ 0 - 0
packages/ckeditor5-utils/tests/manual/focustracker.html → packages/ckeditor5-utils/tests/manual/focustracker/focustracker.html


+ 1 - 1
packages/ckeditor5-utils/tests/manual/focustracker.js → packages/ckeditor5-utils/tests/manual/focustracker/focustracker.js

@@ -5,7 +5,7 @@
 
 /* global document */
 
-import FocusTracker from '../../src/focustracker';
+import FocusTracker from '../../../src/focustracker';
 
 const focusTracker = new FocusTracker();
 const counters = document.querySelectorAll( '.status b' );

+ 0 - 0
packages/ckeditor5-utils/tests/manual/focustracker.md → packages/ckeditor5-utils/tests/manual/focustracker/focustracker.md


+ 38 - 0
packages/ckeditor5-utils/tests/manual/tickets/148/1.html

@@ -0,0 +1,38 @@
+<div class="wrapper">
+	<div class="limiter">
+		<div class="target"></div>
+	</div>
+
+	<div class="source"></div>
+</div>
+
+
+<style>
+	.wrapper {
+		overflow: scroll;
+		outline: 1px solid #000;
+		height: 400px;
+		position: relative;
+		padding: 20px;
+		margin-top: 100px;
+	}
+
+	.limiter {
+		height: 500px;
+		overflow: hidden;
+	}
+
+	.target {
+		height: 150px;
+		background: #ccc;
+		width: 50px;
+		margin: 170px auto 0;
+	}
+
+	.source {
+		height: 50px;
+		width: 50px;
+		background: red;
+		position: absolute;
+	}
+</style>

+ 43 - 0
packages/ckeditor5-utils/tests/manual/tickets/148/1.js

@@ -0,0 +1,43 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global document */
+
+import { getOptimalPosition } from '@ckeditor/ckeditor5-utils/src/dom/position';
+
+const source = document.querySelector( '.source' );
+const target = document.querySelector( '.target' );
+const limiter = document.querySelector( '.limiter' );
+const positions = {
+	above: ( targetRect, sourceRect ) => ( {
+		top: targetRect.top - sourceRect.height - 50,
+		left: targetRect.left,
+		name: 'above'
+	} ),
+	below: ( targetRect ) => ( {
+		top: targetRect.bottom + 50,
+		left: targetRect.left,
+		name: 'below'
+	} )
+};
+
+function updateSourcePosition() {
+	const position = getOptimalPosition( {
+		element: source,
+		target: target,
+		positions: [
+			positions.above,
+			positions.below,
+		],
+		limiter: limiter
+	} );
+
+	source.style.top = position.top + 'px';
+	source.style.left = position.left + 'px';
+}
+
+updateSourcePosition();
+
+document.addEventListener( 'scroll', updateSourcePosition, true );

+ 9 - 0
packages/ckeditor5-utils/tests/manual/tickets/148/1.md

@@ -0,0 +1,9 @@
+### `getOptimalPosition()` with the `limiter` resticted by overflowed parents [#146](https://github.com/ckeditor/ckeditor5-utils/issues/148)
+
+Vertically scroll the container up and down.
+
+**Expected**:
+
+1. The red square should **always** remain visible regardless of the position of the scroll.
+2. In the initial position, the shapes should represent a small letter "i", with the red dot above.
+3. In the opposite scroll position, the shapes should represent an exclamation mark "!" with the red dot underneath.