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

+
+ + diff --git a/tests/manual/contextualballoon/contextualballoon.js b/tests/manual/contextualballoon/contextualballoon.js new file mode 100644 index 00000000..7ecbc4e5 --- /dev/null +++ b/tests/manual/contextualballoon/contextualballoon.js @@ -0,0 +1,285 @@ +/** + * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/* globals window, document, console:false */ + +// This test implements contextual toolbar and few plugins which can be opened inside of the toolbar. + +// Code of this manual test should be successfully reduced when more of +// CKE5 plugins will be integrated with ContextualBalloon and when +// ContextualToolbar plugin will land as CKE5 plugin. + +import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classic'; +import EssentialsPresets from '@ckeditor/ckeditor5-presets/src/essentials'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; + +import ContextualBalloon from '../../../src/contextualballoon'; +import ToolbarView from '../../../src/toolbar/toolbarview'; +import ButtonView from '../../../src/button/buttonview'; +import Template from '../../../src/template'; +import View from '../../../src/view'; +import clickOutsideHandler from '../../../src/bindings/clickoutsidehandler'; +import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler'; + +// Plugin view which displays toolbar with component to open next +// plugin inside and cancel button to close currently visible plugin. +class ViewA extends View { + constructor( locale ) { + super( locale ); + + this.keystrokes = new KeystrokeHandler(); + + this.toolbar = this.createCollection(); + + this.keystrokes.set( 'Esc', ( data, cancel ) => { + this.fire( 'cancel' ); + cancel(); + } ); + + this.cancel = new ButtonView( locale ); + this.cancel.label = 'Cancel'; + this.cancel.withText = true; + this.cancel.on( 'execute', () => this.fire( 'cancel' ) ); + + this.template = new Template( { + tag: 'div', + + attributes: { + class: [ 'plugin', this.bindTemplate.to( 'label' ) ], + tabindex: '-1' + }, + + children: [ + { + tag: 'div', + + children: [ + { + tag: 'h2', + + children: [ + { text: this.bindTemplate.to( 'label' ) }, + ] + }, + { + tag: 'div', + attributes: { + class: [ 'toolbar' ] + }, + children: [ + { + tag: 'h3', + children: [ + { text: 'Open:' }, + ] + }, + { + tag: 'div', + children: this.toolbar + }, + ] + }, + this.cancel + ] + } + ] + } ); + } + + init() { + this.keystrokes.listenTo( this.element ); + + return super.init(); + } +} + +// Generic plugin class. +class PluginGeneric extends Plugin { + static get requires() { + return [ ContextualBalloon ]; + } + + init() { + this._balloon = this.editor.plugins.get( ContextualBalloon ); + + this.editor.editing.view.on( 'selectionChange', () => this._hidePanel() ); + + this.view.bind( 'label' ).to( this ); + + this.view.on( 'cancel', () => { + if ( this._isVisible ) { + this._hidePanel(); + } + } ); + + this.editor.keystrokes.set( 'Esc', ( data, cancel ) => { + if ( this._isVisible ) { + this._hidePanel(); + } + + cancel(); + } ); + + this.editor.ui.componentFactory.add( this.label, ( locale ) => { + const button = new ButtonView( locale ); + + button.label = this.label; + button.withText = true; + this.listenTo( button, 'execute', () => this._showPanel() ); + + return button; + } ); + + clickOutsideHandler( { + emitter: this.view, + activator: () => this._isVisible, + contextElement: this.view.element, + callback: () => this._hidePanel() + } ); + } + + get _isVisible() { + return this._balloon.visibleView === this.view; + } + + afterInit() { + if ( this.buttons ) { + const toolbar = new ToolbarView(); + this.view.toolbar.add( toolbar ); + + return toolbar.fillFromConfig( this.buttons, this.editor.ui.componentFactory ); + } + + return Promise.resolve(); + } + + _showPanel() { + if ( this._balloon.hasView( this.view ) ) { + return; + } + + const viewDocument = this.editor.editing.view; + + this._balloon.add( { + view: this.view, + position: { + target: viewDocument.domConverter.viewRangeToDom( viewDocument.selection.getFirstRange() ) + } + } ); + } + + _hidePanel() { + if ( !this._balloon.hasView( this.view ) ) { + return; + } + + this._balloon.remove( this.view ); + } +} + +class PluginA extends PluginGeneric { + init() { + this.label = 'PluginA'; + this.view = new ViewA( this.editor.locale ); + this.buttons = [ 'PluginB' ]; + + super.init(); + } +} + +class PluginB extends PluginGeneric { + init() { + this.label = 'PluginB'; + this.view = new ViewA( this.editor.locale ); + this.buttons = [ 'PluginC' ]; + + super.init(); + } +} + +class PluginC extends PluginGeneric { + init() { + this.label = 'PluginC'; + this.view = new ViewA( this.editor.locale ); + this.buttons = [ 'PluginD' ]; + + super.init(); + } +} + +class PluginD extends PluginGeneric { + init() { + this.label = 'PluginD'; + this.view = new ViewA( this.editor.locale ); + + super.init(); + } +} + +// Create contextual toolbar. +function createContextualToolbar( editor ) { + const balloon = editor.plugins.get( ContextualBalloon ); + const toolbar = new ToolbarView(); + const editingView = editor.editing.view; + const arrowVOffset = 20; + + const positions = { + forwardSelection: ( targetRect, balloonRect ) => ( { + top: targetRect.bottom + arrowVOffset, + left: targetRect.right - balloonRect.width / 2, + name: 's' + } ), + + backwardSelection: ( targetRect, balloonRect ) => ( { + top: targetRect.top - balloonRect.height - arrowVOffset, + left: targetRect.left - balloonRect.width / 2, + name: 'n' + } ) + }; + + // Add plugins to the toolbar. + toolbar.fillFromConfig( [ 'PluginA', 'PluginB' ], editor.ui.componentFactory ); + + // Close toolbar when selection is changing. + editor.listenTo( editingView, 'selectionChange', () => close() ); + + // Handle when selection stop changing. + editor.listenTo( editingView, 'selectionChangeDone', () => { + // This implementation assumes that only non–collapsed selections gets the contextual toolbar. + if ( !editingView.selection.isCollapsed ) { + const isBackward = editingView.selection.isBackward; + const rangeRects = editingView.domConverter.viewRangeToDom( editingView.selection.getFirstRange() ).getClientRects(); + + balloon.add( { + view: toolbar, + position: { + target: isBackward ? rangeRects.item( 0 ) : rangeRects.item( rangeRects.length - 1 ), + positions: [ positions[ isBackward ? 'backwardSelection' : 'forwardSelection' ] ] + } + } ); + } + } ); + + // Remove toolbar from balloon. + function close() { + if ( balloon.hasView( toolbar ) ) { + balloon.remove( toolbar ); + } + } +} + +// Finally the editor. +ClassicEditor.create( document.querySelector( '#editor' ), { + plugins: [ EssentialsPresets, Paragraph, PluginA, PluginB, PluginC, PluginD ], + toolbar: [ 'PluginA', 'PluginB' ] +} ) +.then( editor => { + window.editor = editor; + createContextualToolbar( editor ); +} ) +.catch( err => { + console.error( err.stack ); +} ); diff --git a/tests/manual/contextualballoon/contextualballoon.md b/tests/manual/contextualballoon/contextualballoon.md new file mode 100644 index 00000000..a984a0b6 --- /dev/null +++ b/tests/manual/contextualballoon/contextualballoon.md @@ -0,0 +1,5 @@ +1. Select some of text and play with the plugins. +2. Keep opening panels inside the same balloon. +3. Check if `Cancel` button closes plugins one by one. +4. Check if `Esc` press closes plugins one by one (only when plugin or editable is focused). +5. Check if panels stay opened in the same position as initial when more panels are open in the same balloon.