diff --git a/src/contextualballoon.js b/src/contextualballoon.js new file mode 100644 index 00000000..9586821c --- /dev/null +++ b/src/contextualballoon.js @@ -0,0 +1,192 @@ +/** + * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/** + * @module ui/contextualballoon + */ + +import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; +import BalloonPanelView from './panel/balloon/balloonpanelview'; +import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; + +/** + * Provides the common contextual balloon panel for the editor. + * + * This plugin allows reusing a single {module:ui/panel/balloon/balloonpanelview~BalloonPanelView} instance + * to display multiple contextual balloon panels in the editor. + * + * Child views of such a panel are stored in the stack and the last one in the stack is visible. When the + * visible view is removed from the stack, the previous view becomes visible, etc. If there are no more + * views in the stack, the balloon panel will hide. + * + * It simplifies managing the views and helps + * avoid the unnecessary complexity of handling multiple {module:ui/panel/balloon/balloonpanelview~BalloonPanelView} + * instances in the editor. + * + * @extends module:core/plugin~Plugin + */ +export default class ContextualBalloon extends Plugin { + /** + * @inheritDoc + */ + init() { + /** + * The common balloon panel view. + * + * @readonly + * @member {module:ui/panel/balloon/balloonpanelview~BalloonPanelView} #view + */ + this.view = new BalloonPanelView(); + + /** + * Stack of the views injected into the balloon. Last one in the stack is displayed + * as a content of {@link module:ui/contextualballoon~ContextualBalloon#view}. + * + * @private + * @member {Map} #_stack + */ + this._stack = new Map(); + + // Add balloon panel view to editor `body` collection. + this.editor.ui.view.body.add( this.view ); + } + + static get pluginName() { + return 'contextualballoon'; + } + + /** + * Returns the currently visible view or `null` when there are no + * views in the stack. + * + * @returns {module:ui/view~View|null} + */ + get visibleView() { + const item = this._stack.get( this.view.content.get( 0 ) ); + + return item ? item.view : null; + } + + /** + * Returns `true` when the given view is in the stack. Otherwise returns `false`. + * + * @param {module:ui/view~View} view + * @returns {Boolean} + */ + hasView( view ) { + return this._stack.has( view ); + } + + /** + * Adds a new view to the stack and makes it visible. + * + * @param {Object} data Configuration of the view. + * @param {module:ui/view~View} view Content of the balloon. + * @param {module:utils/dom/position~Options} position Positioning options. + */ + add( data ) { + if ( this.hasView( data.view ) ) { + /** + * Trying to add configuration of the same view more than once. + * + * @error contextualballoon-add-view-exist + */ + throw new CKEditorError( 'contextualballoon-add-view-exist: Cannot add configuration of the same view twice.' ); + } + + // When adding view to the not empty balloon. + if ( this.visibleView ) { + // Remove displayed content from the view. + this.view.content.remove( this.visibleView ); + } + + // Add new view to the stack. + this._stack.set( data.view, data ); + // And display it. + this._show( data.view ); + } + + /** + * Removes the given view from the stack. If the removed view was visible, + * then the view preceding it in the stack will become visible instead. + * When there is no view in the stack then balloon will hide. + * + * @param {module:ui/view~View} view A view to be removed from the balloon. + */ + remove( view ) { + if ( !this.hasView( view ) ) { + /** + * Trying to remove configuration of the view not defined in the stack. + * + * @error contextualballoon-remove-view-not-exist + */ + throw new CKEditorError( 'contextualballoon-remove-view-not-exist: Cannot remove configuration of not existing view.' ); + } + + // When visible view is being removed. + if ( this.visibleView === view ) { + // We need to remove it from the view content. + this.view.content.remove( view ); + + // And then remove from the stack. + this._stack.delete( view ); + + // Next we need to check if there is other view in stack to show. + const last = Array.from( this._stack.values() ).pop(); + + // If it is some other view. + if ( last ) { + // Just show it. + this._show( last.view ); + } else { + // Hide the balloon panel. + this.view.hide(); + } + } else { + // Just remove given view from the stack. + this._stack.delete( view ); + } + } + + /** + * Updates the position of the balloon panel according to position data + * of the first view in the stack. + */ + updatePosition() { + this.view.attachTo( this._getBalloonPosition() ); + } + + /** + * Sets the view as a content of the balloon and attaches balloon using position + * options of the first view. + * + * @private + * @param {module:ui/view~View} view View to show in the balloon. + */ + _show( view ) { + this.view.content.add( view ); + this.view.attachTo( this._getBalloonPosition() ); + } + + /** + * Returns position options of the first view in the stack. + * This keeps the balloon in the same position when view is changed. + * + * @private + * @returns {module:utils/dom/position~Options} + */ + _getBalloonPosition() { + return Array.from( this._stack.values() )[ 0 ].position; + } + + /** + * @inheritDoc + */ + destroy() { + this.editor.ui.view.body.remove( this.view ); + this.view.destroy(); + super.destroy(); + } +} diff --git a/tests/contextualballoon.js b/tests/contextualballoon.js new file mode 100644 index 00000000..3b339638 --- /dev/null +++ b/tests/contextualballoon.js @@ -0,0 +1,332 @@ +/** + * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; +import ContextualBalloon from '../src/contextualballoon'; +import BalloonPanelView from '../src/panel/balloon/balloonpanelview'; +import View from '../src/view'; +import Template from '../src/template'; +import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; +import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; + +/* global document */ + +describe( 'ContextualBalloon', () => { + let editor, editorElement, balloon, viewA, viewB; + + beforeEach( () => { + editorElement = document.createElement( 'div' ); + document.body.appendChild( editorElement ); + + return ClassicTestEditor.create( editorElement, { + plugins: [ ContextualBalloon ] + } ) + .then( newEditor => { + editor = newEditor; + balloon = editor.plugins.get( ContextualBalloon ); + + viewA = new ViewA(); + viewB = new ViewB(); + + // We don't need to test attachTo method of BalloonPanel it's enough to check if was called with proper data. + sinon.stub( balloon.view, 'attachTo', () => {} ); + } ); + } ); + + afterEach( () => { + editor.destroy(); + } ); + + it( 'should be a plugin instance', () => { + expect( balloon ).to.instanceof( Plugin ); + } ); + + describe( 'pluginName', () => { + it( 'should return plugin by name', () => { + expect( editor.plugins.get( 'contextualballoon' ) ).to.instanceof( ContextualBalloon ); + } ); + } ); + + describe( 'init()', () => { + it( 'should create a plugin instance with properties', () => { + expect( balloon.view ).to.instanceof( BalloonPanelView ); + } ); + + it( 'should add balloon panel view to editor `body` collection', () => { + expect( editor.ui.view.body.getIndex( balloon.view ) ).to.above( -1 ); + } ); + } ); + + describe( 'hasView()', () => { + it( 'should return true when given view is in stack', () => { + balloon.add( { + view: viewA, + position: { target: 'fake' } + } ); + + expect( balloon.hasView( viewA ) ).to.true; + } ); + + it( 'should return true when given view is in stack but is not visible', () => { + balloon.add( { + view: viewA, + position: { target: 'fake' } + } ); + + balloon.add( { + view: viewB, + position: { target: 'fake' } + } ); + + expect( balloon.visibleView ).to.equal( viewB ); + expect( balloon.hasView( viewA ) ).to.true; + } ); + + it( 'should return false when given view is not in stack', () => { + expect( balloon.hasView( viewA ) ).to.false; + } ); + } ); + + describe( 'add()', () => { + it( 'should add view to the stack and display in balloon', () => { + balloon.add( { + view: viewA, + position: { target: 'fake' } + } ); + + expect( balloon.view.content.length ).to.equal( 1 ); + expect( balloon.view.content.get( 0 ) ).to.deep.equal( viewA ); + expect( balloon.view.attachTo.calledOnce ).to.true; + expect( balloon.view.attachTo.firstCall.args[ 0 ] ).to.deep.equal( { target: 'fake' } ); + } ); + + it( 'should throw an error when try to add the same view more than once', () => { + balloon.add( { + view: viewA, + position: { target: 'fake' } + } ); + + expect( () => { + balloon.add( { + view: viewA, + position: { target: 'fake' } + } ); + } ).to.throw( CKEditorError, /^contextualballoon-add-view-exist/ ); + } ); + + it( 'should add multiple views to he stack and display last one', () => { + balloon.add( { + view: viewA, + position: { target: 'fake' } + } ); + + balloon.add( { + view: viewB, + position: { target: 'fake' } + } ); + + expect( balloon.view.content.length ).to.equal( 1 ); + expect( balloon.view.content.get( 0 ) ).to.deep.equal( viewB ); + } ); + + it( 'should add multiple views to the stack and keep balloon in the same position', () => { + balloon.add( { + view: viewA, + position: { target: 'fake', foo: 'bar' } + } ); + + balloon.add( { + view: viewB, + position: { target: 'fake', bar: 'biz' } + } ); + + expect( balloon.view.attachTo.calledTwice ).to.true; + + expect( balloon.view.attachTo.firstCall.args[ 0 ] ).to.deep.equal( { + target: 'fake', + foo: 'bar' + } ); + + expect( balloon.view.attachTo.secondCall.args[ 0 ] ).to.deep.equal( { + target: 'fake', + foo: 'bar' + } ); + } ); + } ); + + describe( 'visibleView', () => { + it( 'should return data of currently visible view', () => { + balloon.add( { + view: viewA, + position: { target: 'fake' } + } ); + + expect( balloon.visibleView ).to.equal( viewA ); + } ); + + it( 'should return data of currently visible view when there is more than one in the stack', () => { + balloon.add( { + view: viewA, + position: { target: 'fake' } + } ); + + balloon.add( { + view: viewB, + position: { target: 'fake' } + } ); + + expect( balloon.visibleView ).to.equal( viewB ); + } ); + + it( 'should return `null` when the stack is empty', () => { + expect( balloon.visibleView ).to.null; + } ); + } ); + + describe( 'remove()', () => { + it( 'should remove given view and hide balloon when there is no other view to display', () => { + balloon.add( { + view: viewA, + position: { target: 'fake' } + } ); + + balloon.remove( viewA ); + + expect( balloon.visibleView ).to.null; + } ); + + it( 'should remove given view and set previous in the stack as visible when removed view was visible', () => { + balloon.add( { + view: viewA, + position: { target: 'fake' } + } ); + + balloon.add( { + view: viewB, + position: { target: 'fake' } + } ); + + balloon.remove( viewB ); + + expect( balloon.visibleView ).to.equal( viewA ); + } ); + + it( 'should remove given view from the stack when view is not visible', () => { + balloon.add( { + view: viewA, + position: { target: 'fake' } + } ); + + balloon.add( { + view: viewB, + position: { target: 'fake' } + } ); + + balloon.remove( viewA ); + + expect( balloon.visibleView ).to.equal( viewB ); + } ); + + it( 'should throw an error when there is no given view in the stack', () => { + expect( () => { + balloon.remove( viewA ); + } ).to.throw( CKEditorError, /^contextualballoon-remove-view-not-exist/ ); + } ); + } ); + + describe( 'updatePosition()', () => { + it( 'should attach balloon to the target using the same position options as currently set', () => { + balloon.add( { + view: viewA, + position: { target: 'fake' } + } ); + + balloon.view.attachTo.reset(); + + balloon.updatePosition(); + + expect( balloon.view.attachTo.calledOnce ); + expect( balloon.view.attachTo.firstCall.args[ 0 ] ).to.deep.equal( { target: 'fake' } ); + } ); + + it( 'should attach balloon to the target using the same position options as currently set when there is more than one view', () => { + balloon.add( { + view: viewA, + position: { + target: 'fake', + foo: 'bar' + } + } ); + + balloon.add( { + view: viewB, + position: { + target: 'fake', + bar: 'biz' + } + } ); + + balloon.view.attachTo.reset(); + + balloon.updatePosition(); + + expect( balloon.view.attachTo.calledOnce ); + expect( balloon.view.attachTo.firstCall.args[ 0 ] ).to.deep.equal( { + target: 'fake', + foo: 'bar' + } ); + } ); + + it( 'should remove given view from the stack when view is not visible', () => { + balloon.add( { + view: viewA, + position: { target: 'fake' } + } ); + + balloon.add( { + view: viewB, + position: { target: 'fake' } + } ); + + balloon.remove( viewA ); + + expect( balloon.visibleView ).to.equal( viewB ); + } ); + + it( 'should throw an error when there is no given view in the stack', () => { + expect( () => { + balloon.remove( viewA ); + } ).to.throw( CKEditorError, /^contextualballoon-remove-view-not-exist/ ); + } ); + } ); + + describe( 'destroy()', () => { + it( 'should balloon panel remove view from editor body collection', () => { + balloon.destroy(); + + expect( editor.ui.view.body.getIndex( balloon.view ) ).to.equal( -1 ); + } ); + } ); +} ); + +class ViewA extends View { + constructor( locale ) { + super( locale ); + + this.template = new Template( { + tag: 'div' + } ); + } +} + +class ViewB extends View { + constructor( locale ) { + super( locale ); + + this.template = new Template( { + tag: 'div' + } ); + } +} diff --git a/tests/manual/contextualballoon/contextualballoon.html b/tests/manual/contextualballoon/contextualballoon.html new file mode 100644 index 00000000..19509e53 --- /dev/null +++ b/tests/manual/contextualballoon/contextualballoon.html @@ -0,0 +1,67 @@ +
Line of text
+Line of text
+Line of text
+Line of text
+Line of text
+Line of text
+Line of text
+Line of text
+Line of text
+Line of text
+Line of text
+Line of text
+Line of text
+Line of text
+Line of text
+Line of text
+Line of text
+