Преглед изворни кода

Feature: Added keepAttachedTo() method to the BalloonPanelView. Closes #170.

Oskar Wróbel пре 8 година
родитељ
комит
b7ee307e1d

+ 61 - 3
packages/ckeditor5-ui/src/panel/balloon/balloonpanelview.js

@@ -7,14 +7,16 @@
  * @module ui/panel/balloon/balloonpanelview
  */
 
-/* globals document */
-
 import View from '../../view';
 import Template from '../../template';
 import { getOptimalPosition } from '@ckeditor/ckeditor5-utils/src/dom/position';
+import isRange from '@ckeditor/ckeditor5-utils/src/dom/isrange';
+import isElement from '@ckeditor/ckeditor5-utils/src/lib/lodash/isElement';
 import toUnit from '@ckeditor/ckeditor5-utils/src/dom/tounit';
+import global from '@ckeditor/ckeditor5-utils/src/dom/global';
 
 const toPx = toUnit( 'px' );
+const defaultLimiterElement = global.document.body;
 
 /**
  * The balloon panel view class.
@@ -151,7 +153,7 @@ export default class BalloonPanelView extends View {
 				defaultPositions.ne,
 				defaultPositions.nw
 			],
-			limiter: document.body,
+			limiter: defaultLimiterElement,
 			fitInViewport: true
 		}, options );
 
@@ -159,6 +161,62 @@ export default class BalloonPanelView extends View {
 
 		Object.assign( this, { top, left, position } );
 	}
+
+	/**
+	 * Works the same way as {module:ui/panel/balloon/balloonpanelview~BalloonPanelView.attachTo}
+	 * except that the position of the panel is continuously updated when any ancestor of the
+	 * {@link module:utils/dom/position~Options#target} or {@link module:utils/dom/position~Options#limiter}
+	 * is being scrolled or when the browser window is being resized.
+	 *
+	 * Thanks to this, the panel always sticks to the {@link module:utils/dom/position~Options#target}.
+	 *
+	 * See https://github.com/ckeditor/ckeditor5-ui/issues/170.
+	 *
+	 * @param {module:utils/dom/position~Options} options Positioning options compatible with
+	 * {@link module:utils/dom/position~getOptimalPosition}. Default `positions` array is
+	 * {@link module:ui/panel/balloon/balloonpanelview~BalloonPanelView.defaultPositions}.
+	 */
+	keepAttachedTo( options ) {
+		// First we need to attach the balloon panel to the target element.
+		this.attachTo( options );
+
+		const limiter = options.limiter || defaultLimiterElement;
+		let target = null;
+
+		// We need to take HTMLElement related to the target if it is possible.
+		if ( isElement( options.target ) ) {
+			target = options.target;
+		} else if ( isRange( options.target ) ) {
+			target = options.target.commonAncestorContainer;
+		}
+
+		// Then we need to listen on scroll event of eny element in the document.
+		this.listenTo( global.document, 'scroll', ( evt, domEvt ) => {
+			// We need to update position if scrolled element contains related to the balloon elements.
+			if ( ( target && domEvt.target.contains( target ) ) || domEvt.target.contains( limiter ) ) {
+				this.attachTo( options );
+			}
+		}, { useCapture: true } );
+
+		// We need to listen on window resize event and update position.
+		this.listenTo( global.window, 'resize', () => this.attachTo( options ) );
+
+		// After all we need to clean up the listeners.
+		this.once( 'change:isVisible', () => {
+			this.stopListening( global.document, 'scroll' );
+			this.stopListening( global.window, 'resize' );
+		} );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	destroy() {
+		this.stopListening( global.document, 'scroll' );
+		this.stopListening( global.window, 'resize' );
+
+		return super.destroy();
+	}
 }
 
 /**

+ 51 - 0
packages/ckeditor5-ui/tests/manual/tickets/170/1.html

@@ -0,0 +1,51 @@
+<div class="container-outer">
+	<div class="container-inner">
+		<div>
+			<div id="editor-attach">
+				<p>Balloon is attached to the <strong>TARGET</strong> element.</p>
+			</div>
+		</div>
+
+		<div>
+			<div id="editor-stick">
+				<p>Balloon sticks to the <strong>TARGET</strong> element.</p>
+			</div>
+		</div>
+	</div>
+</div>
+
+<style>
+	body {
+		height: 100%;
+	}
+
+	.ck-balloon-panel {
+		padding: 2em;
+	}
+
+	.ck-editor__editable {
+		max-height: 300px;
+	}
+
+	.ck-editor__editable p {
+		margin: 470px 0;
+	}
+
+	.container-outer {
+		height: 500px;
+		width: 750px;
+		overflow: scroll;
+	}
+
+	.container-inner {
+		padding: 500px 30px 30px;
+		display: flex;
+		width: 1000px;
+		height: 2000px;
+		background: #e1e1e1;
+	}
+
+	.container-inner > div {
+		margin: 0 20px;
+	}
+</style>

+ 65 - 0
packages/ckeditor5-ui/tests/manual/tickets/170/1.js

@@ -0,0 +1,65 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals window, document, console:false */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classic';
+import ArticlePresets from '@ckeditor/ckeditor5-presets/src/article';
+import BalloonPanelView from '@ckeditor/ckeditor5-ui/src/panel/balloon/balloonpanelview';
+
+// Set initial scroll for the outer container element.
+document.querySelector( '.container-outer' ).scrollTop = 450;
+
+// Init editor with balloon attached to the target element.
+ClassicEditor.create( document.querySelector( '#editor-attach' ), {
+	plugins: [ ArticlePresets ],
+	toolbar: [ 'bold', 'italic', 'undo', 'redo' ]
+} )
+.then( editor => {
+	const panel = new BalloonPanelView();
+
+	panel.element.innerHTML = 'Balloon content.';
+	editor.ui.view.body.add( panel );
+
+	editor.ui.view.element.querySelector( '.ck-editor__editable' ).scrollTop = 360;
+
+	panel.init().then( () => {
+		panel.attachTo( {
+			target: editor.ui.view.element.querySelector( '.ck-editor__editable p strong' ),
+			limiter: editor.ui.view.editableElement
+		} );
+	} );
+
+	window.attachEditor = editor;
+} )
+.catch( err => {
+	console.error( err.stack );
+} );
+
+// Init editor with balloon sticked to the target element.
+ClassicEditor.create( document.querySelector( '#editor-stick' ), {
+	plugins: [ ArticlePresets ],
+	toolbar: [ 'bold', 'italic', 'undo', 'redo' ]
+} )
+.then( editor => {
+	const panel = new BalloonPanelView();
+
+	panel.element.innerHTML = 'Balloon content.';
+	editor.ui.view.body.add( panel );
+
+	editor.ui.view.element.querySelector( '.ck-editor__editable' ).scrollTop = 360;
+
+	panel.init().then( () => {
+		panel.keepAttachedTo( {
+			target: editor.ui.view.element.querySelector( '.ck-editor__editable p strong' ),
+			limiter: editor.ui.view.editableElement
+		} );
+	} );
+
+	window.stickEditor = editor;
+} )
+.catch( err => {
+	console.error( err.stack );
+} );

+ 4 - 0
packages/ckeditor5-ui/tests/manual/tickets/170/1.md

@@ -0,0 +1,4 @@
+## BalloonPanelView `attachTo` vs `keepAttachedTo`
+
+Scroll editable elements and container (horizontally as well). Balloon in the left editor should float but balloon in the
+right editor should stick to the target element.

+ 140 - 19
packages/ckeditor5-ui/tests/panel/balloon/balloonpanelview.js

@@ -3,7 +3,7 @@
  * For licensing, see LICENSE.md.
  */
 
-/* global window, document */
+/* global window, document, Event */
 
 import global from '@ckeditor/ckeditor5-utils/src/dom/global';
 import ViewCollection from '../../../src/viewcollection';
@@ -22,18 +22,6 @@ describe( 'BalloonPanelView', () => {
 
 		view.set( 'maxWidth', 200 );
 
-		windowStub = {
-			innerWidth: 1000,
-			innerHeight: 1000,
-			scrollX: 0,
-			scrollY: 0,
-			getComputedStyle: ( el ) => {
-				return window.getComputedStyle( el );
-			}
-		};
-
-		testUtils.sinon.stub( global, 'window', windowStub );
-
 		return view.init();
 	} );
 
@@ -160,11 +148,18 @@ describe( 'BalloonPanelView', () => {
 				height: 100
 			} );
 
-			// Make sure that limiter is fully visible in viewport.
-			Object.assign( windowStub, {
+			// Mock window dimensions.
+			windowStub = {
 				innerWidth: 500,
-				innerHeight: 500
-			} );
+				innerHeight: 500,
+				scrollX: 0,
+				scrollY: 0,
+				getComputedStyle: ( el ) => {
+					return window.getComputedStyle( el );
+				}
+			};
+
+			testUtils.sinon.stub( global, 'window', windowStub );
 		} );
 
 		it( 'should use default options', () => {
@@ -263,7 +258,7 @@ describe( 'BalloonPanelView', () => {
 				expect( view.position ).to.equal( 'nw' );
 			} );
 
-			// #126
+			// https://github.com/ckeditor/ckeditor5-ui-default/issues/126
 			it( 'works in a positioned ancestor (position: absolute)', () => {
 				const positionedAncestor = document.createElement( 'div' );
 
@@ -295,7 +290,7 @@ describe( 'BalloonPanelView', () => {
 				expect( view.left ).to.equal( -80 );
 			} );
 
-			// #126
+			// https://github.com/ckeditor/ckeditor5-ui-default/issues/126
 			it( 'works in a positioned ancestor (position: static)', () => {
 				const positionedAncestor = document.createElement( 'div' );
 
@@ -409,6 +404,132 @@ describe( 'BalloonPanelView', () => {
 			} );
 		} );
 	} );
+
+	describe( 'keepAttachedTo()', () => {
+		let attachToSpy, target, targetParent, limiter, notRelatedElement;
+
+		beforeEach( () => {
+			attachToSpy = testUtils.sinon.spy( view, 'attachTo' );
+			limiter = document.createElement( 'div' );
+			targetParent = document.createElement( 'div' );
+			target = document.createElement( 'div' );
+			notRelatedElement = document.createElement( 'div' );
+
+			targetParent.appendChild( target );
+			document.body.appendChild( targetParent );
+			document.body.appendChild( limiter );
+			document.body.appendChild( notRelatedElement );
+		} );
+
+		afterEach( () => {
+			attachToSpy.restore();
+			limiter.remove();
+			notRelatedElement.remove();
+		} );
+
+		it( 'should keep the balloon attached to the target when any of the related elements is scrolled', () => {
+			view.keepAttachedTo( { target, limiter } );
+
+			sinon.assert.calledOnce( attachToSpy );
+			sinon.assert.calledWith( attachToSpy.lastCall, { target, limiter } );
+
+			targetParent.dispatchEvent( new Event( 'scroll' ) );
+
+			sinon.assert.calledTwice( attachToSpy );
+			sinon.assert.calledWith( attachToSpy.lastCall, { target, limiter } );
+
+			limiter.dispatchEvent( new Event( 'scroll' ) );
+
+			sinon.assert.calledThrice( attachToSpy );
+			sinon.assert.calledWith( attachToSpy.lastCall, { target, limiter } );
+
+			notRelatedElement.dispatchEvent( new Event( 'scroll' ) );
+
+			// Nothing's changed.
+			sinon.assert.calledThrice( attachToSpy );
+			sinon.assert.calledWith( attachToSpy.lastCall, { target, limiter } );
+		} );
+
+		it( 'should keep the balloon attached to the target when the browser window is being resized', () => {
+			view.keepAttachedTo( { target, limiter } );
+
+			sinon.assert.calledOnce( attachToSpy );
+			sinon.assert.calledWith( attachToSpy.lastCall, { target, limiter } );
+
+			window.dispatchEvent( new Event( 'resize' ) );
+
+			sinon.assert.calledTwice( attachToSpy );
+			sinon.assert.calledWith( attachToSpy.lastCall, { target, limiter } );
+		} );
+
+		it( 'should stop attaching when the balloon is hidden', () => {
+			view.keepAttachedTo( { target, limiter } );
+
+			sinon.assert.calledOnce( attachToSpy );
+
+			view.hide();
+
+			window.dispatchEvent( new Event( 'resize' ) );
+			window.dispatchEvent( new Event( 'scroll' ) );
+
+			// Still once.
+			sinon.assert.calledOnce( attachToSpy );
+		} );
+
+		it( 'should stop attaching once the view is destroyed', () => {
+			view.keepAttachedTo( { target, limiter } );
+
+			sinon.assert.calledOnce( attachToSpy );
+
+			view.destroy();
+
+			window.dispatchEvent( new Event( 'resize' ) );
+			window.dispatchEvent( new Event( 'scroll' ) );
+
+			// Still once.
+			sinon.assert.calledOnce( attachToSpy );
+		} );
+
+		it( 'should set document.body as the default limiter', () => {
+			view.keepAttachedTo( { target } );
+
+			sinon.assert.calledOnce( attachToSpy );
+
+			document.body.dispatchEvent( new Event( 'scroll' ) );
+
+			sinon.assert.calledTwice( attachToSpy );
+		} );
+
+		it( 'should work for Range as a target', () => {
+			const element = document.createElement( 'div' );
+			const range = document.createRange();
+
+			element.appendChild( document.createTextNode( 'foo bar' ) );
+			document.body.appendChild( element );
+			range.selectNodeContents( element );
+
+			view.keepAttachedTo( { target: range } );
+
+			sinon.assert.calledOnce( attachToSpy );
+
+			element.dispatchEvent( new Event( 'scroll' ) );
+
+			sinon.assert.calledTwice( attachToSpy );
+		} );
+
+		it( 'should work for rect as a target', () => {
+			// Just check if this normally works without errors.
+			const rect = {};
+
+			view.keepAttachedTo( { target: rect, limiter } );
+
+			sinon.assert.calledOnce( attachToSpy );
+
+			limiter.dispatchEvent( new Event( 'scroll' ) );
+
+			sinon.assert.calledTwice( attachToSpy );
+		} );
+	} );
 } );
 
 function mockBoundingBox( element, data ) {