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 ) {