diff --git a/src/panel/balloon/balloonpanelview.js b/src/panel/balloon/balloonpanelview.js
index 948bf301..21d62b68 100644
--- a/src/panel/balloon/balloonpanelview.js
+++ b/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();
+ }
}
/**
diff --git a/tests/manual/tickets/170/1.html b/tests/manual/tickets/170/1.html
new file mode 100644
index 00000000..ee75c1c5
--- /dev/null
+++ b/tests/manual/tickets/170/1.html
@@ -0,0 +1,51 @@
+
+
+
+
+
Balloon is attached to the TARGET element.
+
+
+
+
+
+
Balloon sticks to the TARGET element.
+
+
+
+
+
+
diff --git a/tests/manual/tickets/170/1.js b/tests/manual/tickets/170/1.js
new file mode 100644
index 00000000..ccfa0362
--- /dev/null
+++ b/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 );
+} );
diff --git a/tests/manual/tickets/170/1.md b/tests/manual/tickets/170/1.md
new file mode 100644
index 00000000..904f1286
--- /dev/null
+++ b/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.
diff --git a/tests/panel/balloon/balloonpanelview.js b/tests/panel/balloon/balloonpanelview.js
index 3922c7b8..89ebe225 100644
--- a/tests/panel/balloon/balloonpanelview.js
+++ b/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 ) {