Kaynağa Gözat

Merge pull request #183 from ckeditor/t/175

Feature: Scrolling DOM utilities should support multi-window scenarios. Closes #175.
Piotrek Koszuliński 8 yıl önce
ebeveyn
işleme
6a9668850e

+ 2 - 3
packages/ckeditor5-utils/src/dom/getborderwidths.js

@@ -7,8 +7,6 @@
  * @module utils/dom/getborderwidths
  */
 
-import global from './global';
-
 /**
  * Returns an object containing CSS border widths of a specified HTML element.
  *
@@ -17,7 +15,8 @@ import global from './global';
  * with numerical values of the `border-[top,left,right,bottom]-width` CSS styles.
  */
 export default function getBorderWidths( element ) {
-	const style = global.window.getComputedStyle( element );
+	// Call getComputedStyle on the window the element document belongs to.
+	const style = element.ownerDocument.defaultView.getComputedStyle( element );
 
 	return {
 		top: parseInt( style.borderTopWidth, 10 ),

+ 18 - 0
packages/ckeditor5-utils/src/dom/iswindow.js

@@ -0,0 +1,18 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module utils/dom/iswindow
+ */
+
+/**
+ * Checks if the object is a native DOM Window.
+ *
+ * @param {*} obj
+ * @returns {Boolean}
+ */
+export default function isWindow( obj ) {
+	return Object.prototype.toString.apply( obj ) == '[object Window]';
+}

+ 3 - 2
packages/ckeditor5-utils/src/dom/position.js

@@ -11,6 +11,7 @@ import global from './global';
 import Rect from './rect';
 import getPositionedAncestor from './getpositionedancestor';
 import getBorderWidths from './getborderwidths';
+import isFunction from '../lib/lodash/isFunction';
 
 /**
  * Calculates the `position: absolute` coordinates of a given element so it can be positioned with respect to the
@@ -79,13 +80,13 @@ import getBorderWidths from './getborderwidths';
 export function getOptimalPosition( { element, target, positions, limiter, fitInViewport } ) {
 	// 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 ( typeof target == 'function' ) {
+	if ( isFunction( target ) ) {
 		target = target();
 	}
 
 	// If the {@link module:utils/dom/position~Options#limiter} is a function, use what it returns.
 	// https://github.com/ckeditor/ckeditor5-ui/issues/260
-	if ( typeof limiter == 'function' ) {
+	if ( isFunction( limiter ) ) {
 		limiter = limiter();
 	}
 

+ 21 - 8
packages/ckeditor5-utils/src/dom/rect.js

@@ -7,8 +7,8 @@
  * @module utils/dom/rect
  */
 
-import global from './global';
 import isRange from './isrange';
+import isWindow from './iswindow';
 import isElement from '../lib/lodash/isElement';
 import getBorderWidths from './getborderwidths';
 import log from '../log';
@@ -90,8 +90,8 @@ export default class Rect {
 			} else {
 				copyRectProperties( this, source.getBoundingClientRect() );
 			}
-		} else if ( source === global.window ) {
-			const { innerWidth, innerHeight } = global.window;
+		} else if ( isWindow( source ) ) {
+			const { innerWidth, innerHeight } = source;
 
 			copyRectProperties( this, {
 				top: 0,
@@ -253,11 +253,11 @@ export default class Rect {
 		let visibleRect = this.clone();
 
 		// There's no ancestor to crop <body> with the overflow.
-		if ( source != global.document.body ) {
+		if ( !isBody( source ) ) {
 			let parent = source.parentNode || source.commonAncestorContainer;
 
 			// Check the ancestors all the way up to the <body>.
-			while ( parent && parent != global.document.body ) {
+			while ( parent && !isBody( parent ) ) {
 				const parentRect = new Rect( parent );
 				const intersectionRect = visibleRect.getIntersection( parentRect );
 
@@ -320,9 +320,9 @@ export default class Rect {
 		const source = this._source;
 		let scrollBarWidth, scrollBarHeight;
 
-		if ( source === global.window ) {
-			scrollBarWidth = global.window.innerWidth - global.document.documentElement.clientWidth;
-			scrollBarHeight = global.window.innerHeight - global.document.documentElement.clientHeight;
+		if ( isWindow( source ) ) {
+			scrollBarWidth = source.innerWidth - source.document.documentElement.clientWidth;
+			scrollBarHeight = source.innerHeight - source.document.documentElement.clientHeight;
 		} else {
 			const borderWidths = getBorderWidths( this._source );
 
@@ -385,3 +385,16 @@ function copyRectProperties( rect, source ) {
 		rect[ p ] = source[ p ];
 	}
 }
+
+// Checks if provided object is a <body> HTML element.
+//
+// @private
+// @param {HTMLElement|Range} elementOrRange
+// @returns {Boolean}
+function isBody( elementOrRange ) {
+	if ( !isElement( elementOrRange ) ) {
+		return false;
+	}
+
+	return elementOrRange === elementOrRange.ownerDocument.body;
+}

+ 200 - 72
packages/ckeditor5-utils/src/dom/scroll.js

@@ -9,7 +9,6 @@
  * @module utils/dom/scroll
  */
 
-import global from './global';
 import isRange from './isrange';
 import Rect from './rect';
 
@@ -27,42 +26,52 @@ const utils = {};
  * read or edit by the user.
  */
 export function scrollViewportToShowTarget( { target, viewportOffset = 0 } ) {
-	// Scroll the ancestors of the target to reveal it first, then focus on scrolling
-	// the viewport, when the position of the target is fixed.
-	utils.scrollAncestorsToShowTarget( target );
-
-	const targetRect = new Rect( target );
-	const targetShiftedDownRect = targetRect.clone().moveBy( 0, viewportOffset );
-	const targetShiftedUpRect = targetRect.clone().moveBy( 0, -viewportOffset );
-	const viewportRect = new Rect( global.window ).excludeScrollbarsAndBorders();
-
-	// Avoid the situation where the caret is still in the viewport, but totally
-	// at the edge. If it moved beyond the viewport in the next action e.g. due to enter,
-	// the scrolling would move it to the viewportOffset level and it all would look like the
-	// caret visually moved up/down.
-	//
-	// To prevent this, we're checking the rects moved by the viewportOffset to cover
-	// the upper/lower edge of the viewport.
-	const rects = [ targetShiftedUpRect, targetShiftedDownRect ];
+	const targetWindow = getWindow( target );
+	let currentWindow = targetWindow;
+	let currentFrame = null;
 
-	if ( !rects.every( rect => viewportRect.contains( rect ) ) ) {
-		let { scrollX, scrollY } = global.window;
+	// Iterate over all windows, starting from target's parent window up to window#top.
+	while ( currentWindow ) {
+		let firstAncestorToScroll;
 
-		if ( isAbove( targetShiftedUpRect, viewportRect ) ) {
-			scrollY -= viewportRect.top - targetRect.top + viewportOffset;
-		} else if ( isBelow( targetShiftedDownRect, viewportRect ) ) {
-			scrollY += targetRect.bottom - viewportRect.bottom + viewportOffset;
+		// Let's scroll target's ancestors first to reveal it. Then, once the ancestor scrolls
+		// settled down, the algorithm can eventually scroll the viewport of the current window.
+		//
+		// Note: If the current window is target's **original** window (e.g. the first one),
+		// start scrolling the closest parent of the target. If not, scroll the closest parent
+		// of an iframe that resides in the current window.
+		if ( currentWindow == targetWindow ) {
+			firstAncestorToScroll = getParentElement( target );
+		} else {
+			firstAncestorToScroll = getParentElement( currentFrame );
 		}
 
-		// TODO: Web browsers scroll natively to place the target in the middle
-		// of the viewport. It's not a very popular case, though.
-		if ( isLeftOf( targetRect, viewportRect ) ) {
-			scrollX -= viewportRect.left - targetRect.left + viewportOffset;
-		} else if ( isRightOf( targetRect, viewportRect ) ) {
-			scrollX += targetRect.right - viewportRect.right + viewportOffset;
-		}
+		// Scroll the target's ancestors first. Once done, scrolling the viewport is easy.
+		scrollAncestorsToShowRect( firstAncestorToScroll, () => {
+			// Note: If the target does not belong to the current window **directly**,
+			// i.e. it resides in an iframe belonging to the window, obtain the target's rect
+			// in the coordinates of the current window. By default, a Rect returns geometry
+			// relative to the current window's viewport. To make it work in a parent window,
+			// it must be shifted.
+			return getRectRelativeToWindow( target, currentWindow );
+		} );
+
+		// Obtain the rect of the target after it has been scrolled within its ancestors.
+		// It's time to scroll the viewport.
+		const targetRect = getRectRelativeToWindow( target, currentWindow );
+
+		scrollWindowToShowRect( currentWindow, targetRect, viewportOffset );
 
-		global.window.scrollTo( scrollX, scrollY );
+		if ( currentWindow.parent != currentWindow ) {
+			// Keep the reference to the <iframe> element the "previous current window" was
+			// rendered within. It will be useful to re–calculate the rect of the target
+			// in the parent window's relative geometry. The target's rect must be shifted
+			// by it's iframe's position.
+			currentFrame = currentWindow.frameElement;
+			currentWindow = currentWindow.parent;
+		} else {
+			currentWindow = null;
+		}
 	}
 }
 
@@ -73,31 +82,11 @@ export function scrollViewportToShowTarget( { target, viewportOffset = 0 } ) {
  * @param {HTMLElement|Range} target A target, which supposed to become visible to the user.
  */
 export function scrollAncestorsToShowTarget( target ) {
-	let parent, parentRect, targetRect;
-
-	if ( isRange( target ) ) {
-		parent = target.commonAncestorContainer;
-
-		// If a Range is attached to the Text, use the closest element ancestor.
-		if ( parent.nodeType == Node.TEXT_NODE ) {
-			parent = parent.parentNode;
-		}
-	} else {
-		parent = target.parentNode;
-	}
-
-	do {
-		if ( parent === global.document.body ) {
-			return;
-		}
-
-		targetRect = new Rect( target );
-		parentRect = new Rect( parent ).excludeScrollbarsAndBorders();
+	const targetParent = getParentElement( target );
 
-		if ( !parentRect.contains( targetRect ) ) {
-			scrollAncestorToShowTarget( { targetRect, parent, parentRect } );
-		}
-	} while ( ( parent = parent.parentNode ) );
+	scrollAncestorsToShowRect( targetParent, () => {
+		return new Rect( target );
+	} );
 }
 
 // TODO: Using a property value shorthand in the top of the file
@@ -107,26 +96,103 @@ Object.assign( utils, {
 	scrollAncestorsToShowTarget
 } );
 
-// For testing purposes (easy helper stubbing).
-export { utils as _test };
-
-// Makes any page `HTMLElement` or `Range` (target) visible within its parent.
+// Makes a given rect visible within its parent window.
+//
+// Note: Avoid the situation where the caret is still in the viewport, but totally
+// at the edge of it. In such situation, if it moved beyond the viewport in the next
+// action e.g. after paste, the scrolling would move it to the viewportOffset level
+// and it all would look like the caret visually moved up/down:
+//
+// 1.
+//		| foo[]
+//		|                                    <--- N px of space below the caret
+//		+---------------------------------...
+//
+// 2. *paste*
+// 3.
+//		|
+//		|
+//		+-foo-----------------------------...
+//		  bar[]                              <--- caret below viewport, scrolling...
+//
+// 4. *scrolling*
+// 5.
+//		|
+//		| foo
+//		| bar[]                              <--- caret precisely at the edge
+//		+---------------------------------...
+//
+// To prevent this, this method checks the rects moved by the viewportOffset to cover
+// the upper/lower edge of the viewport. It makes sure if the action repeats, there's
+// no twitching – it's a purely visual improvement:
+//
+// 5. (after fix)
+//		|
+//		| foo
+//		| bar[]
+//		|                                    <--- N px of space below the caret
+//		+---------------------------------...
 //
 // @private
-// @param {module:utils/dom/rect~Rect} options.targetRect The `Rect` of the `target`.
-// @param {HTMLElement} options.parent The parent element of the `target`.
-// @param {module:utils/dom/rect~Rect} options.parentRect The `Rect` of the parent.
-function scrollAncestorToShowTarget( { targetRect, parent, parentRect } ) {
-	if ( isAbove( targetRect, parentRect ) ) {
-		parent.scrollTop -= parentRect.top - targetRect.top;
-	} else if ( isBelow( targetRect, parentRect ) ) {
-		parent.scrollTop += targetRect.bottom - parentRect.bottom;
+// @param {Window} window A window which is scrolled to reveal the rect.
+// @param {module:utils/dom/rect~Rect} rect A rect which is to be revealed.
+// @param {Number} viewportOffset See scrollViewportToShowTarget.
+function scrollWindowToShowRect( window, rect, viewportOffset ) {
+	const targetShiftedDownRect = rect.clone().moveBy( 0, viewportOffset );
+	const targetShiftedUpRect = rect.clone().moveBy( 0, -viewportOffset );
+	const viewportRect = new Rect( window ).excludeScrollbarsAndBorders();
+
+	const rects = [ targetShiftedUpRect, targetShiftedDownRect ];
+
+	if ( !rects.every( rect => viewportRect.contains( rect ) ) ) {
+		let { scrollX, scrollY } = window;
+
+		if ( isAbove( targetShiftedUpRect, viewportRect ) ) {
+			scrollY -= viewportRect.top - rect.top + viewportOffset;
+		} else if ( isBelow( targetShiftedDownRect, viewportRect ) ) {
+			scrollY += rect.bottom - viewportRect.bottom + viewportOffset;
+		}
+
+		// TODO: Web browsers scroll natively to place the target in the middle
+		// of the viewport. It's not a very popular case, though.
+		if ( isLeftOf( rect, viewportRect ) ) {
+			scrollX -= viewportRect.left - rect.left + viewportOffset;
+		} else if ( isRightOf( rect, viewportRect ) ) {
+			scrollX += rect.right - viewportRect.right + viewportOffset;
+		}
+
+		window.scrollTo( scrollX, scrollY );
 	}
+}
+
+// Recursively scrolls element ancestors to visually reveal a rect.
+//
+// @private
+// @param {HTMLElement} A parent The first ancestors to start scrolling.
+// @param {Function} getRect A function which returns the Rect, which is to be revealed.
+function scrollAncestorsToShowRect( parent, getRect ) {
+	const parentWindow = getWindow( parent );
+	let parentRect, targetRect;
 
-	if ( isLeftOf( targetRect, parentRect ) ) {
-		parent.scrollLeft -= parentRect.left - targetRect.left;
-	} else if ( isRightOf( targetRect, parentRect ) ) {
-		parent.scrollLeft += targetRect.right - parentRect.right;
+	while ( parent != parentWindow.document.body ) {
+		targetRect = getRect();
+		parentRect = new Rect( parent ).excludeScrollbarsAndBorders();
+
+		if ( !parentRect.contains( targetRect ) ) {
+			if ( isAbove( targetRect, parentRect ) ) {
+				parent.scrollTop -= parentRect.top - targetRect.top;
+			} else if ( isBelow( targetRect, parentRect ) ) {
+				parent.scrollTop += targetRect.bottom - parentRect.bottom;
+			}
+
+			if ( isLeftOf( targetRect, parentRect ) ) {
+				parent.scrollLeft -= parentRect.left - targetRect.left;
+			} else if ( isRightOf( targetRect, parentRect ) ) {
+				parent.scrollLeft += targetRect.right - parentRect.right;
+			}
+		}
+
+		parent = parent.parentNode;
 	}
 }
 
@@ -165,3 +231,65 @@ function isLeftOf( firstRect, secondRect ) {
 function isRightOf( firstRect, secondRect ) {
 	return firstRect.right > secondRect.right;
 }
+
+// Returns the closest window of an element or range.
+//
+// @private
+// @param {HTMLElement|Range} firstRect
+// @returns {Window}
+function getWindow( elementOrRange ) {
+	if ( isRange( elementOrRange ) ) {
+		return elementOrRange.startContainer.ownerDocument.defaultView;
+	} else {
+		return elementOrRange.ownerDocument.defaultView;
+	}
+}
+
+// Returns the closest parent of an element or DOM range.
+//
+// @private
+// @param {HTMLElement|Range} firstRect
+// @returns {HTMLelement}
+function getParentElement( elementOrRange ) {
+	if ( isRange( elementOrRange ) ) {
+		let parent = elementOrRange.commonAncestorContainer;
+
+		// If a Range is attached to the Text, use the closest element ancestor.
+		if ( parent.nodeType == Node.TEXT_NODE ) {
+			parent = parent.parentNode;
+		}
+
+		return parent;
+	} else {
+		return elementOrRange.parentNode;
+	}
+}
+
+// Returns the rect of an element or range residing in an iframe.
+// The result rect is relative to the geometry of the passed window instance.
+//
+// @private
+// @param {HTMLElement|Range} target Element or range which rect should be returned.
+// @param {Window} relativeWindow A window the rect should be relative to.
+// @returns {module:utils/dom/rect~Rect}
+function getRectRelativeToWindow( target, relativeWindow ) {
+	const targetWindow = getWindow( target );
+	const rect = new Rect( target );
+
+	if ( targetWindow === relativeWindow ) {
+		return rect;
+	} else {
+		let currentWindow = targetWindow;
+
+		while ( currentWindow != relativeWindow ) {
+			const frame = currentWindow.frameElement;
+			const frameRect = new Rect( frame ).excludeScrollbarsAndBorders();
+
+			rect.moveBy( frameRect.left, frameRect.top );
+
+			currentWindow = currentWindow.parent;
+		}
+	}
+
+	return rect;
+}

+ 14 - 9
packages/ckeditor5-utils/tests/dom/getborderwidths.js

@@ -3,7 +3,6 @@
  * For licensing, see LICENSE.md.
  */
 
-import global from '../../src/dom/global';
 import getBorderWidths from '../../src/dom/getborderwidths';
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 
@@ -11,14 +10,20 @@ testUtils.createSinonSandbox();
 
 describe( 'getBorderWidths()', () => {
 	it( 'returns CSS border widths', () => {
-		testUtils.sinon.stub( global.window, 'getComputedStyle' ).returns( {
-			borderTopWidth: '10px',
-			borderRightWidth: '20px',
-			borderBottomWidth: '30px',
-			borderLeftWidth: '40px'
-		} );
-
-		const elementMock = {};
+		const elementMock = {
+			ownerDocument: {
+				defaultView: {
+					getComputedStyle: () => {
+						return {
+							borderTopWidth: '10px',
+							borderRightWidth: '20px',
+							borderBottomWidth: '30px',
+							borderLeftWidth: '40px'
+						};
+					}
+				}
+			}
+		};
 
 		expect( getBorderWidths( elementMock ) ).to.deep.equal( {
 			top: 10,

+ 19 - 0
packages/ckeditor5-utils/tests/dom/iswindow.js

@@ -0,0 +1,19 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global window */
+
+import isWindow from '../../src/dom/iswindow';
+
+describe( 'isWindow()', () => {
+	it( 'detects DOM Window', () => {
+		expect( isWindow( window ) ).to.be.true;
+		expect( isWindow( {} ) ).to.be.false;
+		expect( isWindow( null ) ).to.be.false;
+		expect( isWindow( undefined ) ).to.be.false;
+		expect( isWindow( new Date() ) ).to.be.false;
+		expect( isWindow( 42 ) ).to.be.false;
+	} );
+} );

+ 13 - 6
packages/ckeditor5-utils/tests/dom/position.js

@@ -3,9 +3,13 @@
  * For licensing, see LICENSE.md.
  */
 
-import global from '../../src/dom/global';
+/* global document, window */
+
 import { getOptimalPosition } from '../../src/dom/position';
 import Rect from '../../src/dom/rect';
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+
+testUtils.createSinonSandbox();
 
 let element, target, limiter;
 
@@ -51,6 +55,8 @@ const attachTop = ( targetRect, elementRect ) => ( {
 
 describe( 'getOptimalPosition()', () => {
 	beforeEach( () => {
+		testUtils.sinon.stub( window, 'getComputedStyle' );
+
 		stubWindow( {
 			innerWidth: 10000,
 			innerHeight: 10000,
@@ -422,7 +428,8 @@ function getElement( properties = {}, styles = {} ) {
 	const element = {
 		tagName: 'div',
 		scrollLeft: 0,
-		scrollTop: 0
+		scrollTop: 0,
+		ownerDocument: document
 	};
 
 	Object.assign( element, properties );
@@ -435,7 +442,7 @@ function getElement( properties = {}, styles = {} ) {
 		styles.borderTopWidth = '0px';
 	}
 
-	global.window.getComputedStyle.withArgs( element ).returns( styles );
+	window.getComputedStyle.withArgs( element ).returns( styles );
 
 	return element;
 }
@@ -445,9 +452,9 @@ function getElement( properties = {}, styles = {} ) {
 // @private
 // @param {Object} properties A set of properties the window should have.
 function stubWindow( properties ) {
-	global.window = Object.assign( {
-		getComputedStyle: sinon.stub()
-	}, properties );
+	for ( const p in properties ) {
+		testUtils.sinon.stub( window, p ).value( properties[ p ] );
+	}
 }
 
 //        <-- 100px ->

+ 126 - 23
packages/ckeditor5-utils/tests/dom/rect.js

@@ -3,9 +3,8 @@
  * For licensing, see LICENSE.md.
  */
 
-/* global document */
+/* global window, document */
 
-import global from '../../src/dom/global';
 import Rect from '../../src/dom/rect';
 import log from '../../src/log';
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
@@ -81,14 +80,12 @@ describe( 'Rect', () => {
 		} );
 
 		it( 'should accept the window (viewport)', () => {
-			testUtils.sinon.stub( global, 'window' ).value( {
-				innerWidth: 1000,
-				innerHeight: 500,
-				scrollX: 100,
-				scrollY: 200
-			} );
+			testUtils.sinon.stub( window, 'innerWidth' ).value( 1000 );
+			testUtils.sinon.stub( window, 'innerHeight' ).value( 500 );
+			testUtils.sinon.stub( window, 'scrollX' ).value( 100 );
+			testUtils.sinon.stub( window, 'scrollY' ).value( 200 );
 
-			assertRect( new Rect( global.window ), {
+			assertRect( new Rect( window ), {
 				top: 0,
 				right: 1000,
 				bottom: 500,
@@ -118,6 +115,28 @@ describe( 'Rect', () => {
 			assertRect( new Rect( geometry ), geometry );
 		} );
 
+		it( 'should accept objects from another window\'s scope', done => {
+			const iframe = document.createElement( 'iframe' );
+
+			iframe.addEventListener( 'load', () => {
+				const iframeWindow = iframe.contentWindow;
+				const element = iframeWindow.document.createElement( 'p' );
+				const range = document.createRange();
+				range.selectNode( iframeWindow.document.body );
+
+				testUtils.sinon.stub( range, 'getClientRects' ).returns( [ geometry ] );
+				assertRect( new Rect( range ), geometry );
+
+				testUtils.sinon.stub( element, 'getBoundingClientRect' ).returns( geometry );
+				assertRect( new Rect( element ), geometry );
+
+				iframe.remove();
+				done();
+			} );
+
+			document.body.appendChild( iframe );
+		} );
+
 		it( 'should copy the properties (Rect)', () => {
 			const sourceGeometry = Object.assign( {}, geometry );
 			const sourceRect = new Rect( geometry );
@@ -426,7 +445,7 @@ describe( 'Rect', () => {
 		} );
 
 		it( 'should return a new rect', () => {
-			const rect = new Rect( {} );
+			const rect = new Rect( element );
 			const visible = rect.getVisible();
 
 			expect( visible ).to.not.equal( rect );
@@ -452,6 +471,51 @@ describe( 'Rect', () => {
 			} );
 		} );
 
+		it( 'should not fail when the rect is for an object in another window\'s scope', done => {
+			const iframe = document.createElement( 'iframe' );
+
+			iframe.addEventListener( 'load', () => {
+				const iframeWindow = iframe.contentWindow;
+				const element = iframeWindow.document.createElement( 'p' );
+				const ancestor = iframeWindow.document.createElement( 'p' );
+
+				ancestor.appendChild( element );
+				iframeWindow.document.body.appendChild( ancestor );
+
+				testUtils.sinon.stub( ancestor, 'getBoundingClientRect' ).returns( {
+					top: 0,
+					right: 50,
+					bottom: 50,
+					left: 0,
+					width: 50,
+					height: 50
+				} );
+
+				testUtils.sinon.stub( element, 'getBoundingClientRect' ).returns( {
+					top: 0,
+					right: 100,
+					bottom: 100,
+					left: 0,
+					width: 100,
+					height: 100
+				} );
+
+				assertRect( new Rect( element ).getVisible(), {
+					top: 0,
+					right: 50,
+					bottom: 50,
+					left: 0,
+					width: 50,
+					height: 50
+				} );
+
+				iframe.remove();
+				done();
+			} );
+
+			document.body.appendChild( iframe );
+		} );
+
 		it( 'should return the visible rect (HTMLElement), partially cropped', () => {
 			testUtils.sinon.stub( element, 'getBoundingClientRect' ).returns( {
 				top: 0,
@@ -755,7 +819,7 @@ describe( 'Rect', () => {
 			const element = document.createElement( 'div' );
 
 			testUtils.sinon.stub( element, 'getBoundingClientRect' ).returns( geometry );
-			testUtils.sinon.stub( global.window, 'getComputedStyle' ).returns( {
+			testUtils.sinon.stub( window, 'getComputedStyle' ).returns( {
 				borderTopWidth: '5px',
 				borderRightWidth: '10px',
 				borderLeftWidth: '5px',
@@ -789,21 +853,17 @@ describe( 'Rect', () => {
 		} );
 
 		it( 'should exclude scrollbars from viewport\'s rect', () => {
-			testUtils.sinon.stub( global, 'window' ).value( {
-				innerWidth: 1000,
-				innerHeight: 500,
-				scrollX: 100,
-				scrollY: 200
-			} );
+			testUtils.sinon.stub( window, 'innerWidth' ).value( 1000 );
+			testUtils.sinon.stub( window, 'innerHeight' ).value( 500 );
+			testUtils.sinon.stub( window, 'scrollX' ).value( 100 );
+			testUtils.sinon.stub( window, 'scrollY' ).value( 200 );
 
-			testUtils.sinon.stub( global, 'document' ).value( {
-				documentElement: {
-					clientWidth: 990,
-					clientHeight: 490
-				}
+			testUtils.sinon.stub( document, 'documentElement' ).value( {
+				clientWidth: 990,
+				clientHeight: 490
 			} );
 
-			assertRect( new Rect( global.window ).excludeScrollbarsAndBorders(), {
+			assertRect( new Rect( window ).excludeScrollbarsAndBorders(), {
 				top: 0,
 				right: 990,
 				bottom: 490,
@@ -812,6 +872,49 @@ describe( 'Rect', () => {
 				height: 490
 			} );
 		} );
+
+		it( 'should work for a window in an iframe', done => {
+			const iframe = document.createElement( 'iframe' );
+
+			// Mock the properties of the top window. Then make sure the ones
+			// from the child are used.
+			testUtils.sinon.stub( window, 'innerWidth' ).value( 1000 );
+			testUtils.sinon.stub( window, 'innerHeight' ).value( 500 );
+			testUtils.sinon.stub( window, 'scrollX' ).value( 100 );
+			testUtils.sinon.stub( window, 'scrollY' ).value( 200 );
+			testUtils.sinon.stub( document, 'documentElement' ).value( {
+				clientWidth: 990,
+				clientHeight: 490
+			} );
+
+			iframe.addEventListener( 'load', () => {
+				const iframeWindow = iframe.contentWindow;
+
+				testUtils.sinon.stub( iframeWindow, 'innerWidth' ).value( 500 );
+				testUtils.sinon.stub( iframeWindow, 'innerHeight' ).value( 250 );
+				testUtils.sinon.stub( iframeWindow, 'scrollX' ).value( 50 );
+				testUtils.sinon.stub( iframeWindow, 'scrollY' ).value( 100 );
+
+				testUtils.sinon.stub( iframeWindow.document, 'documentElement' ).value( {
+					clientWidth: 480,
+					clientHeight: 230
+				} );
+
+				assertRect( new Rect( iframeWindow ).excludeScrollbarsAndBorders(), {
+					top: 0,
+					right: 480,
+					bottom: 230,
+					left: 0,
+					width: 480,
+					height: 230
+				} );
+
+				iframe.remove();
+				done();
+			} );
+
+			document.body.appendChild( iframe );
+		} );
 	} );
 
 	describe( 'getDomRangeRects() ', () => {

+ 126 - 71
packages/ckeditor5-utils/tests/dom/scroll.js

@@ -3,33 +3,28 @@
  * For licensing, see LICENSE.md.
  */
 
-/* global document, Text */
+/* global window, document, Text */
 
-import global from '../../src/dom/global';
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 import isRange from '../../src/dom/isrange';
-import { scrollViewportToShowTarget, scrollAncestorsToShowTarget, _test } from '../../src/dom/scroll';
+import { scrollViewportToShowTarget, scrollAncestorsToShowTarget } from '../../src/dom/scroll';
 
 testUtils.createSinonSandbox();
 
 describe( 'scrollAncestorsToShowTarget()', () => {
-	let target, element, firstAncestor, secondAncestor, body;
+	let target, element, firstAncestor, secondAncestor;
 
 	beforeEach( () => {
 		element = document.createElement( 'p' );
 		firstAncestor = document.createElement( 'blockquote' );
 		secondAncestor = document.createElement( 'div' );
-		body = document.createElement( 'div' );
 
-		testUtils.sinon.stub( global, 'document' ).value( { body } );
-
-		document.body.appendChild( body );
-		body.appendChild( secondAncestor );
+		document.body.appendChild( secondAncestor );
 		secondAncestor.appendChild( firstAncestor );
 		firstAncestor.appendChild( element );
 
 		// Make the element immune to the border-width-* styles in the test environment.
-		testUtils.sinon.stub( global.window, 'getComputedStyle' ).returns( {
+		testUtils.sinon.stub( window, 'getComputedStyle' ).returns( {
 			borderTopWidth: '0px',
 			borderRightWidth: '0px',
 			borderBottomWidth: '0px',
@@ -48,7 +43,7 @@ describe( 'scrollAncestorsToShowTarget()', () => {
 			scrollLeft: 100, scrollTop: 100
 		} );
 
-		stubRect( body, {
+		stubRect( document.body, {
 			top: 1000, right: 2000, bottom: 1000, left: 1000, width: 1000, height: 1000
 		}, {
 			scrollLeft: 1000, scrollTop: 1000
@@ -56,7 +51,7 @@ describe( 'scrollAncestorsToShowTarget()', () => {
 	} );
 
 	afterEach( () => {
-		body.remove();
+		secondAncestor.remove();
 	} );
 
 	describe( 'for an HTMLElement', () => {
@@ -101,7 +96,7 @@ describe( 'scrollAncestorsToShowTarget()', () => {
 			stubRect( target, { top: 25, right: 75, bottom: 75, left: 25, width: 50, height: 50 } );
 
 			scrollAncestorsToShowTarget( target );
-			assertScrollPosition( body, { scrollLeft: 1000, scrollTop: 1000 } );
+			assertScrollPosition( document.body, { scrollLeft: 1000, scrollTop: 1000 } );
 		} );
 
 		it( 'should set #scrollTop and #scrollLeft of the ancestor to show the target (above)', () => {
@@ -163,27 +158,22 @@ describe( 'scrollViewportToShowTarget()', () => {
 			scrollLeft: 100, scrollTop: 100
 		} );
 
-		testUtils.sinon.stub( global, 'window' ).value( {
-			innerWidth: 1000,
-			innerHeight: 500,
-			scrollX: 100,
-			scrollY: 100,
-			scrollTo: sinon.spy(),
-			getComputedStyle: () => ( {
-				borderTopWidth: '0px',
-				borderRightWidth: '0px',
-				borderBottomWidth: '0px',
-				borderLeftWidth: '0px'
-			} )
+		testUtils.sinon.stub( window, 'innerWidth' ).value( 1000 );
+		testUtils.sinon.stub( window, 'innerHeight' ).value( 500 );
+		testUtils.sinon.stub( window, 'scrollX' ).value( 100 );
+		testUtils.sinon.stub( window, 'scrollY' ).value( 100 );
+		testUtils.sinon.stub( window, 'scrollTo' );
+		testUtils.sinon.stub( window, 'getComputedStyle' ).returns( {
+			borderTopWidth: '0px',
+			borderRightWidth: '0px',
+			borderBottomWidth: '0px',
+			borderLeftWidth: '0px'
 		} );
 
 		// Assuming 20px v- and h-scrollbars here.
-		testUtils.sinon.stub( global, 'document' ).value( {
-			body: document.body,
-			documentElement: {
-				clientWidth: 980,
-				clientHeight: 480
-			}
+		testUtils.sinon.stub( window.document, 'documentElement' ).value( {
+			clientWidth: 980,
+			clientHeight: 480
 		} );
 	} );
 
@@ -217,6 +207,89 @@ describe( 'scrollViewportToShowTarget()', () => {
 		} );
 	} );
 
+	describe( 'in an iframe', () => {
+		let iframe, iframeWindow, iframeAncestor, target, targetAncestor;
+
+		beforeEach( done => {
+			iframe = document.createElement( 'iframe' );
+			iframeAncestor = document.createElement( 'div' );
+
+			iframe.addEventListener( 'load', () => {
+				iframeWindow = iframe.contentWindow;
+
+				testUtils.sinon.stub( iframeWindow, 'innerWidth' ).value( 1000 );
+				testUtils.sinon.stub( iframeWindow, 'innerHeight' ).value( 500 );
+				testUtils.sinon.stub( iframeWindow, 'scrollX' ).value( 100 );
+				testUtils.sinon.stub( iframeWindow, 'scrollY' ).value( 100 );
+				testUtils.sinon.stub( iframeWindow, 'scrollTo' );
+				testUtils.sinon.stub( iframeWindow, 'getComputedStyle' ).returns( {
+					borderTopWidth: '0px',
+					borderRightWidth: '0px',
+					borderBottomWidth: '0px',
+					borderLeftWidth: '0px'
+				} );
+
+				// Assuming 20px v- and h-scrollbars here.
+				testUtils.sinon.stub( iframeWindow.document, 'documentElement' ).value( {
+					clientWidth: 980,
+					clientHeight: 480
+				} );
+
+				target = iframeWindow.document.createElement( 'p' );
+				targetAncestor = iframeWindow.document.createElement( 'div' );
+				iframeWindow.document.body.appendChild( targetAncestor );
+				targetAncestor.appendChild( target );
+
+				done();
+			} );
+
+			iframeAncestor.appendChild( iframe );
+			document.body.appendChild( iframeAncestor );
+		} );
+
+		afterEach( () => {
+			iframeAncestor.remove();
+		} );
+
+		it( 'does not scroll the viewport when the target is fully visible', () => {
+			stubRect( target,
+				{ top: 100, right: 200, bottom: 200, left: 100, width: 100, height: 100 } );
+			stubRect( targetAncestor,
+				{ top: 100, right: 300, bottom: 400, left: 0, width: 300, height: 300 },
+				{ scrollLeft: 200, scrollTop: -100 } );
+			stubRect( iframe,
+				{ top: 200, right: 400, bottom: 400, left: 200, width: 200, height: 200 } );
+			stubRect( iframeAncestor,
+				{ top: 0, right: 400, bottom: 400, left: 0, width: 400, height: 400 },
+				{ scrollLeft: 100, scrollTop: 100 } );
+
+			scrollViewportToShowTarget( { target } );
+			assertScrollPosition( targetAncestor, { scrollLeft: 200, scrollTop: -100 } );
+			assertScrollPosition( iframeAncestor, { scrollTop: 100, scrollLeft: 100 } );
+			sinon.assert.notCalled( iframeWindow.scrollTo );
+			sinon.assert.notCalled( window.scrollTo );
+		} );
+
+		it( 'scrolls the viewport to show the target (above)', () => {
+			stubRect( target,
+				{ top: -200, right: 200, bottom: -100, left: 100, width: 100, height: 100 } );
+			stubRect( targetAncestor,
+				{ top: 200, right: 300, bottom: 400, left: 0, width: 300, height: 100 },
+				{ scrollLeft: 200, scrollTop: -100 } );
+			stubRect( iframe,
+				{ top: 2000, right: 2000, bottom: 2500, left: 2500, width: 500, height: 500 } );
+			stubRect( iframeAncestor,
+				{ top: 0, right: 100, bottom: 100, left: 0, width: 100, height: 100 },
+				{ scrollLeft: 100, scrollTop: 100 } );
+
+			scrollViewportToShowTarget( { target } );
+			assertScrollPosition( targetAncestor, { scrollTop: -500, scrollLeft: 200 } );
+			assertScrollPosition( iframeAncestor, { scrollTop: 1900, scrollLeft: 2700 } );
+			sinon.assert.calledWithExactly( iframeWindow.scrollTo, 100, -100 );
+			sinon.assert.calledWithExactly( window.scrollTo, 1820, 1520 );
+		} );
+	} );
+
 	// Note: Because everything is a mock, scrolling the firstAncestor doesn't really change
 	// the getBoundingClientRect geometry of the target. That's why scrolling the viewport
 	// works like the target remained in the original position. It's tricky but much faster
@@ -225,22 +298,12 @@ describe( 'scrollViewportToShowTarget()', () => {
 	// Note: Negative scrollTo arguments make no sense in reality, but in mocks with arbitrary
 	// initial geometry and scroll position they give the right, relative picture of what's going on.
 	function testNoOffset() {
-		it( 'calls scrollAncestorsToShowTarget first', () => {
-			const spy = testUtils.sinon.stub( _test, 'scrollAncestorsToShowTarget' );
-
-			stubRect( target, { top: -200, right: 200, bottom: -100, left: 100, width: 100, height: 100 } );
-
-			scrollViewportToShowTarget( { target } );
-			sinon.assert.calledOnce( spy );
-			sinon.assert.calledWithExactly( spy, target );
-		} );
-
 		it( 'does not scroll the viewport when the target is fully visible', () => {
 			stubRect( target, { top: 0, right: 200, bottom: 100, left: 100, width: 100, height: 100 } );
 
 			scrollViewportToShowTarget( { target } );
 			assertScrollPosition( firstAncestor, { scrollTop: 100, scrollLeft: 200 } );
-			sinon.assert.notCalled( global.window.scrollTo );
+			sinon.assert.notCalled( window.scrollTo );
 		} );
 
 		it( 'scrolls the viewport to show the target (above)', () => {
@@ -248,7 +311,7 @@ describe( 'scrollViewportToShowTarget()', () => {
 
 			scrollViewportToShowTarget( { target } );
 			assertScrollPosition( firstAncestor, { scrollTop: -100, scrollLeft: 200 } );
-			sinon.assert.calledWithExactly( global.window.scrollTo, 100, -100 );
+			sinon.assert.calledWithExactly( window.scrollTo, 100, -100 );
 		} );
 
 		it( 'scrolls the viewport to show the target (partially above)', () => {
@@ -256,7 +319,7 @@ describe( 'scrollViewportToShowTarget()', () => {
 
 			scrollViewportToShowTarget( { target } );
 			assertScrollPosition( firstAncestor, { scrollTop: 50, scrollLeft: 200 } );
-			sinon.assert.calledWithExactly( global.window.scrollTo, 100, 50 );
+			sinon.assert.calledWithExactly( window.scrollTo, 100, 50 );
 		} );
 
 		it( 'scrolls the viewport to show the target (below)', () => {
@@ -264,7 +327,7 @@ describe( 'scrollViewportToShowTarget()', () => {
 
 			scrollViewportToShowTarget( { target } );
 			assertScrollPosition( firstAncestor, { scrollTop: 700, scrollLeft: 200 } );
-			sinon.assert.calledWithExactly( global.window.scrollTo, 100, 320 );
+			sinon.assert.calledWithExactly( window.scrollTo, 100, 320 );
 		} );
 
 		it( 'scrolls the viewport to show the target (partially below)', () => {
@@ -272,7 +335,7 @@ describe( 'scrollViewportToShowTarget()', () => {
 
 			scrollViewportToShowTarget( { target } );
 			assertScrollPosition( firstAncestor, { scrollTop: 550, scrollLeft: 200 } );
-			sinon.assert.calledWithExactly( global.window.scrollTo, 100, 170 );
+			sinon.assert.calledWithExactly( window.scrollTo, 100, 170 );
 		} );
 
 		it( 'scrolls the viewport to show the target (to the left)', () => {
@@ -280,7 +343,7 @@ describe( 'scrollViewportToShowTarget()', () => {
 
 			scrollViewportToShowTarget( { target } );
 			assertScrollPosition( firstAncestor, { scrollTop: 100, scrollLeft: -100 } );
-			sinon.assert.calledWithExactly( global.window.scrollTo, -100, 100 );
+			sinon.assert.calledWithExactly( window.scrollTo, -100, 100 );
 		} );
 
 		it( 'scrolls the viewport to show the target (partially to the left)', () => {
@@ -288,7 +351,7 @@ describe( 'scrollViewportToShowTarget()', () => {
 
 			scrollViewportToShowTarget( { target } );
 			assertScrollPosition( firstAncestor, { scrollTop: 100, scrollLeft: 50 } );
-			sinon.assert.calledWithExactly( global.window.scrollTo, 50, 100 );
+			sinon.assert.calledWithExactly( window.scrollTo, 50, 100 );
 		} );
 
 		it( 'scrolls the viewport to show the target (to the right)', () => {
@@ -296,7 +359,7 @@ describe( 'scrollViewportToShowTarget()', () => {
 
 			scrollViewportToShowTarget( { target } );
 			assertScrollPosition( firstAncestor, { scrollTop: 100, scrollLeft: 1200 } );
-			sinon.assert.calledWithExactly( global.window.scrollTo, 320, 100 );
+			sinon.assert.calledWithExactly( window.scrollTo, 320, 100 );
 		} );
 
 		it( 'scrolls the viewport to show the target (partially to the right)', () => {
@@ -304,7 +367,7 @@ describe( 'scrollViewportToShowTarget()', () => {
 
 			scrollViewportToShowTarget( { target } );
 			assertScrollPosition( firstAncestor, { scrollTop: 100, scrollLeft: 1050 } );
-			sinon.assert.calledWithExactly( global.window.scrollTo, 170, 100 );
+			sinon.assert.calledWithExactly( window.scrollTo, 170, 100 );
 		} );
 	}
 
@@ -316,22 +379,12 @@ describe( 'scrollViewportToShowTarget()', () => {
 	// Note: Negative scrollTo arguments make no sense in reality, but in mocks with arbitrary
 	// initial geometry and scroll position they give the right, relative picture of what's going on.
 	function testWithOffset() {
-		it( 'calls scrollAncestorsToShowTarget first', () => {
-			const spy = testUtils.sinon.stub( _test, 'scrollAncestorsToShowTarget' );
-
-			stubRect( target, { top: -200, right: 200, bottom: -100, left: 100, width: 100, height: 100 } );
-
-			scrollViewportToShowTarget( { target, viewportOffset } );
-			sinon.assert.calledOnce( spy );
-			sinon.assert.calledWithExactly( spy, target );
-		} );
-
 		it( 'does not scroll the viewport when the target is fully visible', () => {
 			stubRect( target, { top: 50, right: 200, bottom: 150, left: 100, width: 100, height: 100 } );
 
 			scrollViewportToShowTarget( { target, viewportOffset } );
 			assertScrollPosition( firstAncestor, { scrollTop: 150, scrollLeft: 200 } );
-			sinon.assert.notCalled( global.window.scrollTo );
+			sinon.assert.notCalled( window.scrollTo );
 		} );
 
 		it( 'scrolls the viewport to show the target (above)', () => {
@@ -339,7 +392,7 @@ describe( 'scrollViewportToShowTarget()', () => {
 
 			scrollViewportToShowTarget( { target, viewportOffset } );
 			assertScrollPosition( firstAncestor, { scrollTop: -100, scrollLeft: 200 } );
-			sinon.assert.calledWithExactly( global.window.scrollTo, 100, -130 );
+			sinon.assert.calledWithExactly( window.scrollTo, 100, -130 );
 		} );
 
 		it( 'scrolls the viewport to show the target (partially above)', () => {
@@ -347,7 +400,7 @@ describe( 'scrollViewportToShowTarget()', () => {
 
 			scrollViewportToShowTarget( { target, viewportOffset } );
 			assertScrollPosition( firstAncestor, { scrollTop: 50, scrollLeft: 200 } );
-			sinon.assert.calledWithExactly( global.window.scrollTo, 100, 20 );
+			sinon.assert.calledWithExactly( window.scrollTo, 100, 20 );
 		} );
 
 		it( 'scrolls the viewport to show the target (below)', () => {
@@ -355,7 +408,7 @@ describe( 'scrollViewportToShowTarget()', () => {
 
 			scrollViewportToShowTarget( { target, viewportOffset } );
 			assertScrollPosition( firstAncestor, { scrollTop: 700, scrollLeft: 200 } );
-			sinon.assert.calledWithExactly( global.window.scrollTo, 100, 350 );
+			sinon.assert.calledWithExactly( window.scrollTo, 100, 350 );
 		} );
 
 		it( 'scrolls the viewport to show the target (partially below)', () => {
@@ -363,7 +416,7 @@ describe( 'scrollViewportToShowTarget()', () => {
 
 			scrollViewportToShowTarget( { target, viewportOffset } );
 			assertScrollPosition( firstAncestor, { scrollTop: 550, scrollLeft: 200 } );
-			sinon.assert.calledWithExactly( global.window.scrollTo, 100, 200 );
+			sinon.assert.calledWithExactly( window.scrollTo, 100, 200 );
 		} );
 
 		it( 'scrolls the viewport to show the target (to the left)', () => {
@@ -371,7 +424,7 @@ describe( 'scrollViewportToShowTarget()', () => {
 
 			scrollViewportToShowTarget( { target, viewportOffset } );
 			assertScrollPosition( firstAncestor, { scrollTop: 100, scrollLeft: -100 } );
-			sinon.assert.calledWithExactly( global.window.scrollTo, -130, 70 );
+			sinon.assert.calledWithExactly( window.scrollTo, -130, 70 );
 		} );
 
 		it( 'scrolls the viewport to show the target (partially to the left)', () => {
@@ -379,7 +432,7 @@ describe( 'scrollViewportToShowTarget()', () => {
 
 			scrollViewportToShowTarget( { target, viewportOffset } );
 			assertScrollPosition( firstAncestor, { scrollTop: 100, scrollLeft: 50 } );
-			sinon.assert.calledWithExactly( global.window.scrollTo, 20, 70 );
+			sinon.assert.calledWithExactly( window.scrollTo, 20, 70 );
 		} );
 
 		it( 'scrolls the viewport to show the target (to the right)', () => {
@@ -387,7 +440,7 @@ describe( 'scrollViewportToShowTarget()', () => {
 
 			scrollViewportToShowTarget( { target, viewportOffset } );
 			assertScrollPosition( firstAncestor, { scrollTop: 100, scrollLeft: 1200 } );
-			sinon.assert.calledWithExactly( global.window.scrollTo, 350, 70 );
+			sinon.assert.calledWithExactly( window.scrollTo, 350, 70 );
 		} );
 
 		it( 'scrolls the viewport to show the target (partially to the right)', () => {
@@ -395,7 +448,7 @@ describe( 'scrollViewportToShowTarget()', () => {
 
 			scrollViewportToShowTarget( { target, viewportOffset } );
 			assertScrollPosition( firstAncestor, { scrollTop: 100, scrollLeft: 1050 } );
-			sinon.assert.calledWithExactly( global.window.scrollTo, 200, 70 );
+			sinon.assert.calledWithExactly( window.scrollTo, 200, 70 );
 		} );
 	}
 } );
@@ -435,7 +488,8 @@ function stubRect( target, geometryStub, scrollStub ) {
 				},
 				set( value ) {
 					scrollLeft = value;
-				}
+				},
+				configurable: true
 			},
 			scrollTop: {
 				get() {
@@ -443,7 +497,8 @@ function stubRect( target, geometryStub, scrollStub ) {
 				},
 				set( value ) {
 					scrollTop = value;
-				}
+				},
+				configurable: true
 			}
 		} );
 	}

+ 43 - 0
packages/ckeditor5-utils/tests/manual/scroll/assets/scroll-iframe-child.html

@@ -0,0 +1,43 @@
+<!DOCTYPE html>
+<html lang="en">
+	<head>
+		<meta charset="utf-8">
+		<title>Child frame</title>
+	</head>
+	<body>
+		<div id="target">Yay, you can see me!</div>
+		<div id="info">
+			There is nothing to do here as it is just a sub-component of another test.
+			<br/>
+			<a href="../scroll-iframe.html">Click here to continue testing</a>.
+		</div>
+	</body>
+	<style>
+		.manual-test-container {
+			padding: 50px;
+			width: 3000px;
+			height: 3000px;
+		}
+
+		.manual-test-sidebar {
+			display: none;
+		}
+
+		#target {
+			background: red;
+			width: 200px;
+			height: 200px;
+			margin-top: 1000px;
+			margin-left: 2000px;
+			border: 10px solid green;
+		}
+
+		#info {
+			position: fixed;
+			top: 50%;
+			left: 50%;
+			transform: translateX( -50% );
+			display: none;
+		}
+	</style>
+</html>

+ 11 - 0
packages/ckeditor5-utils/tests/manual/scroll/assets/scroll-iframe-child.js

@@ -0,0 +1,11 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global window, document */
+
+// Display an info when this file is ran as a standalone test.
+if ( window.top === window ) {
+	document.getElementById( 'info' ).style.display = 'block';
+}

+ 0 - 0
packages/ckeditor5-utils/tests/manual/scroll/assets/scroll-iframe-child.md


+ 36 - 0
packages/ckeditor5-utils/tests/manual/scroll/scroll-iframe.html

@@ -0,0 +1,36 @@
+<button id="scroll">Scroll</button>
+
+<div id="scrollable">
+	<iframe src="assets/scroll-iframe-child.html"></iframe>
+</div>
+
+<style>
+	body {
+		height: 3000px;
+		width: 3000px;
+	}
+
+	button {
+		display: block;
+		position: fixed;
+		top: 1em;
+		right: 1em;
+		padding: 1em;
+	}
+
+	#scrollable {
+		border: 30px solid orange;
+		width: 500px;
+		height: 500px;
+		overflow: scroll;
+		margin-top: 2000px;
+		margin-left: 2000px;
+	}
+
+	iframe {
+		margin: 1000px;
+		border: 20px solid blue;
+		height: 300px;
+		width: 300px;
+	}
+</style>

+ 14 - 0
packages/ckeditor5-utils/tests/manual/scroll/scroll-iframe.js

@@ -0,0 +1,14 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global window, document */
+
+import { scrollViewportToShowTarget } from '../../../src/dom/scroll';
+
+document.getElementById( 'scroll' ).addEventListener( 'click', () => {
+	const target = window.frames[ 0 ].document.querySelector( '#target' );
+
+	scrollViewportToShowTarget( { target } );
+} );

+ 32 - 0
packages/ckeditor5-utils/tests/manual/scroll/scroll-iframe.md

@@ -0,0 +1,32 @@
+## scrollViewportToShowTarget() helper in an iframe
+
+The aim of this test is checking if the helper function works for targets residing in a child document (window) within an `<iframe>`. There are 3 elements that interact in this test:
+
+1. the **target**, which is a <span style="background: red; border: 3px solid green">red square with green border</span>,
+2. the **iframe**, which is a <span style="background: white; border: 3px solid blue">blue–bordered square</span>,
+3. the scrollable **iframe parent** <span style="background: white; border: 3px solid orange">has an orange border</span>.
+
+## Scenario #1
+
+1. Click the "Scroll" button in the upper–right corner of the viewport.
+
+**Expected**:
+
+The viewport should scroll so the red (target) square (and its green border). It should be revealed and completely visible in the viewport. Most certainly, it will land directly in the bottom–right corner of the viewport.
+
+## Scenario #2
+
+1. Click the "Scroll" button again.
+
+**Expected**:
+
+Basically, nothing should happen (nothing should get scrolled).
+
+## Scenario #3
+
+1. Play with the scrollbars of each element (iframe, iframe's parent, top window), e.g. to partially hide the target or to completely hide it to to hide it to the left/right/top/bottom. Be creative.
+2. Click the "Scroll" button.
+
+**Expected**:
+
+The target should always be scrolled back and become completely visible. Mind the green border, it should become visible too. The location of the target on the screen will vary, depending on where you "hide" in its scrollable ancestors.