Jelajahi Sumber

Made scrollViewportToShowTarget work when target is in an iframe.

Aleksander Nowodzinski 8 tahun lalu
induk
melakukan
5382cb5136

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

@@ -9,7 +9,6 @@
  * @module utils/dom/scroll
  * @module utils/dom/scroll
  */
  */
 
 
-import global from './global';
 import isRange from './isrange';
 import isRange from './isrange';
 import Rect from './rect';
 import Rect from './rect';
 
 
@@ -27,42 +26,52 @@ const utils = {};
  * read or edit by the user.
  * read or edit by the user.
  */
  */
 export function scrollViewportToShowTarget( { target, viewportOffset = 0 } ) {
 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.
  * @param {HTMLElement|Range} target A target, which supposed to become visible to the user.
  */
  */
 export function scrollAncestorsToShowTarget( target ) {
 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
 // TODO: Using a property value shorthand in the top of the file
@@ -107,26 +96,103 @@ Object.assign( utils, {
 	scrollAncestorsToShowTarget
 	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
 // @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 ) {
 function isRightOf( firstRect, secondRect ) {
 	return firstRect.right > secondRect.right;
 	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;
+}

+ 83 - 0
packages/ckeditor5-utils/tests/dom/scroll.js

@@ -207,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
 	// 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
 	// 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
 	// works like the target remained in the original position. It's tricky but much faster