浏览代码

Introduced ContextualBalloon class for managing contextual balloons.

Oskar Wróbel 8 年之前
父节点
当前提交
d6825fc7ac
共有 2 个文件被更改,包括 437 次插入0 次删除
  1. 160 0
      packages/ckeditor5-ui/src/contextualballoon.js
  2. 277 0
      packages/ckeditor5-ui/tests/contextualballoon.js

+ 160 - 0
packages/ckeditor5-ui/src/contextualballoon.js

@@ -0,0 +1,160 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module ui/contextualballoon
+ */
+
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+
+/**
+ * Controller of common contextual balloon.
+ *
+ * This class reuses the same {module:ui/view~View} for each contextual balloon panel in the editor UI, makes
+ * possible to add multiple views to the same balloon panel (stored in the stack, last one in the stack is visible)
+ * and prevents of displaying more than one contextual balloon panel at the same time.
+ */
+export default class ContextualBalloon {
+	/**
+	 * Creates ContextualBalloon instance.
+	 *
+	 * @constructor
+	 */
+	constructor() {
+		/**
+		 * Stack of panels injected to the balloon. Last one in the stack is displayed
+		 * as content of {@link module:ui/contextualballoon~ContextualBalloon#view}.
+		 *
+		 * @private
+		 * @member {Map} #_stack
+		 */
+		this._stack = new Map();
+
+		/**
+		 * Balloon panel view.
+		 *
+		 * @member {module:ui:view~View} #view
+		 */
+	}
+
+	/**
+	 * Returns configuration of currently visible panel or `null` when there is no panel in the balloon.
+	 *
+	 * @returns {module:ui/contextualballoon~Panel|null}
+	 */
+	get visible() {
+		return this._stack.get( this.view.content.get( 0 ) ) || null;
+	}
+
+	/**
+	 * Adds panel to the stack of opened panels and makes this panel currently visible.
+	 *
+	 * @param {module:ui/contextualballoon~Panel} panelData Configuration of the panel.
+	 */
+	add( panelData ) {
+		if ( this._stack.get( panelData.view ) ) {
+			/**
+			 * Trying to add configuration of the same panel more than once.
+			 *
+			 * @error contextualballoon-add-item-exist
+			 */
+			throw new CKEditorError( 'contextualballoon-add-panel-exist: Cannot add configuration of existing panel.' );
+		}
+
+		// When adding panel to the not empty balloon.
+		if ( this.visible ) {
+			// Remove displayed content from the view.
+			this.view.content.remove( this.visible.view );
+		}
+
+		// Add new panel to the stack.
+		this._stack.set( panelData.view, panelData );
+		// And display it.
+		this._showPanel( panelData );
+	}
+
+	/**
+	 * Removes panel of given {@link: module:ui/view~View} from the stack of panels.
+	 * If removed panel was currently displayed then the panel before in the stack will be displayed instead.
+	 * When there is no panel in the stack then balloon will hide.
+	 *
+	 * @param {module:ui/view~View} view View of panel which will be removed from the balloon.
+	 */
+	remove( view ) {
+		if ( !this._stack.get( view ) ) {
+			/**
+			 * Trying to remove configuration of the panel not defined in the stack.
+			 *
+			 * @error contextualballoon-remove-panel-not-exist
+			 */
+			throw new CKEditorError( 'contextualballoon-remove-panel-not-exist: Cannot remove configuration of not existing panel.' );
+		}
+
+		// When visible panel is been removed.
+		if ( this.visible.view === 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 panel in stack to show.
+			const lastPanel = Array.from( this._stack ).pop();
+
+			// If it is.
+			if ( lastPanel ) {
+				// Just show it.
+				this._showPanel( lastPanel[ 1 ] );
+			// Otherwise.
+			} else {
+				// Hide balloon panel.
+				this.view.hide();
+			}
+		// Otherwise.
+		} else {
+			// Just remove given panel from the stack.
+			this._stack.delete( view );
+		}
+	}
+
+	/**
+	 * Updates position of balloon panel according to position data
+	 * of the first panel in the {#_stack}.
+	 */
+	updatePosition() {
+		this.view.attachTo( this._getBalloonPosition() );
+	}
+
+	/**
+	 * Sets panel as a content of the balloon and attach balloon using position options of the first panel.
+	 *
+	 * @private
+	 * @param {module:ui/contextualballoon~Panel} panelData Configuration of the panel.
+	 */
+	_showPanel( panelData ) {
+		this.view.content.add( panelData.view );
+		this.view.attachTo( this._getBalloonPosition() );
+	}
+
+	/**
+	 * Returns position options of the first panel in the stack.
+	 * This helps to keep panel in the same position.
+	 *
+	 * @private
+	 * @returns {module:utils/dom/position~Options}
+	 */
+	_getBalloonPosition() {
+		return Array.from( this._stack )[ 0 ][ 1 ].position;
+	}
+}
+
+/**
+ * An object describing configuration of single panel added to the balloon.
+ *
+ * @typedef {Object} module:ui/contextualballoon~Panel
+ *
+ * @property {module:ui/view~View} view Panel content view.
+ * @property {module:utils/dom/position~Options} position Positioning options.
+ */

+ 277 - 0
packages/ckeditor5-ui/tests/contextualballoon.js

@@ -0,0 +1,277 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+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';
+
+describe( 'ContextualBalloon', () => {
+	let balloon, viewA, viewB;
+
+	beforeEach( () => {
+		balloon = new ContextualBalloon();
+		balloon.view = new BalloonPanelView();
+
+		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( () => {
+		balloon.view.attachTo.restore();
+	} );
+
+	describe( 'add()', () => {
+		it( 'should add panel 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 panel more than once', () => {
+			balloon.add( {
+				view: viewA,
+				position: { target: 'fake' }
+			} );
+
+			expect( () => {
+				balloon.add( {
+					view: viewA,
+					position: { target: 'fake' }
+				} );
+			} ).to.throw( CKEditorError, /^contextualballoon-add-panel-exist/ );
+		} );
+
+		it( 'should add multiple panels 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 panels 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( 'visible', () => {
+		it( 'should return data of currently visible panel', () => {
+			balloon.add( {
+				view: viewA,
+				position: { target: 'fake' }
+			} );
+
+			expect( balloon.visible ).to.deep.equal( {
+				view: viewA,
+				position: { target: 'fake' }
+			} );
+		} );
+
+		it( 'should return data of currently visible panel 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.visible ).to.deep.equal( {
+				view: viewB,
+				position: { target: 'fake' }
+			} );
+		} );
+
+		it( 'should return `null` when the stack is empty', () => {
+			expect( balloon.visible ).to.null;
+		} );
+	} );
+
+	describe( 'remove()', () => {
+		it( 'should remove panel of given view and hide balloon when there is no other panel to display', () => {
+			balloon.add( {
+				view: viewA,
+				position: { target: 'fake' }
+			} );
+
+			balloon.remove( viewA );
+
+			expect( balloon.visible ).to.null;
+		} );
+
+		it( 'should remove panel of given view and set previous in the stack as visible when removed panel was visible', () => {
+			balloon.add( {
+				view: viewA,
+				position: { target: 'fake' }
+			} );
+
+			balloon.add( {
+				view: viewB,
+				position: { target: 'fake' }
+			} );
+
+			balloon.remove( viewB );
+
+			expect( balloon.visible ).to.deep.equal( {
+				view: viewA,
+				position: { target: 'fake' }
+			} );
+		} );
+
+		it( 'should remove given panel from the stack when panel is not visible', () => {
+			balloon.add( {
+				view: viewA,
+				position: { target: 'fake' }
+			} );
+
+			balloon.add( {
+				view: viewB,
+				position: { target: 'fake' }
+			} );
+
+			balloon.remove( viewA );
+
+			expect( balloon.visible ).to.deep.equal( {
+				view: viewB,
+				position: { target: 'fake' }
+			} );
+		} );
+
+		it( 'should throw an error when there is no panel of given view in the stack', () => {
+			expect( () => {
+				balloon.remove( viewA );
+			} ).to.throw( CKEditorError, /^contextualballoon-remove-panel-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 panel', () => {
+			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 panel from the stack when panel is not visible', () => {
+			balloon.add( {
+				view: viewA,
+				position: { target: 'fake' }
+			} );
+
+			balloon.add( {
+				view: viewB,
+				position: { target: 'fake' }
+			} );
+
+			balloon.remove( viewA );
+
+			expect( balloon.visible ).to.deep.equal( {
+				view: viewB,
+				position: { target: 'fake' }
+			} );
+		} );
+
+		it( 'should throw an error when there is no panel of given view in the stack', () => {
+			expect( () => {
+				balloon.remove( viewA );
+			} ).to.throw( CKEditorError, /^contextualballoon-remove-panel-not-exist/ );
+		} );
+	} );
+} );
+
+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'
+		} );
+	}
+}