diff --git a/package.json b/package.json index b263b905..f07b3ed3 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@ckeditor/ckeditor5-image": "^13.0.1", "@ckeditor/ckeditor5-link": "^11.0.1", "@ckeditor/ckeditor5-list": "^12.0.1", + "@ckeditor/ckeditor5-mention": "^10.0.0", "@ckeditor/ckeditor5-paragraph": "^11.0.1", "@ckeditor/ckeditor5-typing": "^12.0.1", "eslint": "^5.5.0", diff --git a/src/panel/balloon/contextualballoon.js b/src/panel/balloon/contextualballoon.js index 99172e38..997c9dcd 100644 --- a/src/panel/balloon/contextualballoon.js +++ b/src/panel/balloon/contextualballoon.js @@ -47,6 +47,9 @@ const toPx = toUnit( 'px' ); * If there are no views in the current stack, the balloon panel will try to switch to the next stack. If there are no * panels in any stack then balloon panel will be hidden. * + * **Note**: To force balloon panel to show only one view - even if there are other stacks - use `singleViewMode=true` option + * when {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon#add adding} view to a panel. + * * From the implementation point of view, contextual ballon plugin is reusing a single * {@link module:ui/panel/balloon/balloonpanelview~BalloonPanelView} instance to display multiple contextual balloon * panels in the editor. It also creates a special {@link module:ui/panel/balloon/contextualballoon~RotatorView rotator view}, @@ -138,6 +141,16 @@ export default class ContextualBalloon extends Plugin { */ this.set( '_numberOfStacks', 0 ); + /** + * Flag that controls the single view mode. + * + * @private + * @readonly + * @observable + * @member {Boolean} #_singleViewMode + */ + this.set( '_singleViewMode', false ); + /** * Rotator view embedded in the contextual balloon. * Displays currently visible view in the balloon and provides navigation for switching stacks. @@ -176,6 +189,7 @@ export default class ContextualBalloon extends Plugin { * @param {module:utils/dom/position~Options} [data.position] Positioning options. * @param {String} [data.balloonClassName] Additional CSS class added to the {@link #view balloon} when visible. * @param {Boolean} [data.withArrow=true] Whether the {@link #view balloon} should be rendered with an arrow. + * @param {Boolean} [data.singleViewMode=false] Whether the view should be only visible view - even if other stacks were added. */ add( data ) { if ( this.hasView( data.view ) ) { @@ -195,7 +209,7 @@ export default class ContextualBalloon extends Plugin { this._viewToStack.set( data.view, this._idToStack.get( stackId ) ); this._numberOfStacks = this._idToStack.size; - if ( !this._visibleStack ) { + if ( !this._visibleStack || data.singleViewMode ) { this.showStack( stackId ); } @@ -204,6 +218,10 @@ export default class ContextualBalloon extends Plugin { const stack = this._idToStack.get( stackId ); + if ( data.singleViewMode ) { + this.showStack( stackId ); + } + // Add new view to the stack. stack.set( data.view, data ); this._viewToStack.set( data.view, stack ); @@ -234,6 +252,10 @@ export default class ContextualBalloon extends Plugin { const stack = this._viewToStack.get( view ); + if ( this._singleViewMode && this.visibleView === view ) { + this._singleViewMode = false; + } + // When visible view will be removed we need to show a preceding view or next stack // if a view is the only view in the stack. if ( this.visibleView === view ) { @@ -281,6 +303,7 @@ export default class ContextualBalloon extends Plugin { * @param {String} id */ showStack( id ) { + this.visibleStack = id; const stack = this._idToStack.get( id ); if ( !stack ) { @@ -368,13 +391,15 @@ export default class ContextualBalloon extends Plugin { this.view.content.add( view ); - // Hide navigation when there is only a one stack. - view.bind( 'isNavigationVisible' ).to( this, '_numberOfStacks', value => value > 1 ); + // Hide navigation when there is only a one stack & not in single view mode. + view.bind( 'isNavigationVisible' ).to( this, '_numberOfStacks', this, '_singleViewMode', ( value, isSingleViewMode ) => { + return !isSingleViewMode && value > 1; + } ); // Update balloon position after toggling navigation. view.on( 'change:isNavigationVisible', () => ( this.updatePosition() ), { priority: 'low' } ); - // Show stacks counter. + // Update stacks counter value. view.bind( 'counter' ).to( this, 'visibleView', this, '_numberOfStacks', ( visibleView, numberOfStacks ) => { if ( numberOfStacks < 2 ) { return ''; @@ -414,8 +439,10 @@ export default class ContextualBalloon extends Plugin { _createFakePanelsView() { const view = new FakePanelsView( this.editor.locale, this.view ); - view.bind( 'numberOfPanels' ).to( this, '_numberOfStacks', number => { - return number < 2 ? 0 : Math.min( number - 1, 2 ); + view.bind( 'numberOfPanels' ).to( this, '_numberOfStacks', this, '_singleViewMode', ( number, isSingleViewMode ) => { + const showPanels = !isSingleViewMode && number >= 2; + + return showPanels ? Math.min( number - 1, 2 ) : 0; } ); view.listenTo( this.view, 'change:top', () => view.updatePosition() ); @@ -436,7 +463,7 @@ export default class ContextualBalloon extends Plugin { * @param {String} [data.balloonClassName=''] Additional class name which will be added to the {@link #view balloon}. * @param {Boolean} [data.withArrow=true] Whether the {@link #view balloon} should be rendered with an arrow. */ - _showView( { view, balloonClassName = '', withArrow = true } ) { + _showView( { view, balloonClassName = '', withArrow = true, singleViewMode = false } ) { this.view.class = balloonClassName; this.view.withArrow = withArrow; @@ -444,6 +471,10 @@ export default class ContextualBalloon extends Plugin { this.visibleView = view; this.view.pin( this._getBalloonPosition() ); this._fakePanelsView.updatePosition(); + + if ( singleViewMode ) { + this._singleViewMode = true; + } } /** diff --git a/tests/manual/contextualballoon/contextualballoon.js b/tests/manual/contextualballoon/contextualballoon.js index 707f155f..e15cd9f8 100644 --- a/tests/manual/contextualballoon/contextualballoon.js +++ b/tests/manual/contextualballoon/contextualballoon.js @@ -7,6 +7,7 @@ import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor'; import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset'; +import Mention from '@ckeditor/ckeditor5-mention/src/mention'; import BalloonToolbar from '../../../src/toolbar/balloon/balloontoolbar'; import ContextualBalloon from '../../../src/panel/balloon/contextualballoon'; import View from '../../../src/view'; @@ -75,9 +76,17 @@ class CustomStackHighlight { ClassicEditor .create( document.querySelector( '#editor' ), { - plugins: [ ArticlePluginSet, BalloonToolbar, CustomStackHighlight ], + plugins: [ ArticlePluginSet, BalloonToolbar, CustomStackHighlight, Mention ], toolbar: [ 'bold', 'link' ], - balloonToolbar: [ 'bold', 'link' ] + balloonToolbar: [ 'bold', 'link' ], + mention: { + feeds: [ + { + marker: '@', + feed: [ '@Barney', '@Lily', '@Marshall', '@Robin', '@Ted' ] + } + ] + } } ) .then( editor => { window.editor = editor; diff --git a/tests/manual/contextualballoon/contextualballoon.md b/tests/manual/contextualballoon/contextualballoon.md index b387a1df..6eef76ee 100644 --- a/tests/manual/contextualballoon/contextualballoon.md +++ b/tests/manual/contextualballoon/contextualballoon.md @@ -17,3 +17,9 @@ ## Fake panels - max 1. Select text `[select]` (by non-collapsed selection) from the lower highlight. You should see `1 of 4` status of pagination but only 2 additional layers under the balloon should be visible. + +## Force single view - Mention + +1. Select text `[select]` (by non-collapsed selection). +2. Type space + `@` to open mention panel. You should see mention panel with no layers under the balloon and without any counter. +3. Move selection around `@` when leaving mention suggestions the balloon should be displayed as in above cases (layers, navigation buttons, etc). diff --git a/tests/panel/balloon/contextualballoon.js b/tests/panel/balloon/contextualballoon.js index 621b8609..c6f28480 100644 --- a/tests/panel/balloon/contextualballoon.js +++ b/tests/panel/balloon/contextualballoon.js @@ -17,7 +17,8 @@ import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; /* global document, Event */ describe( 'ContextualBalloon', () => { - let editor, editorElement, balloon, viewA, viewB, viewC; + let editor, editorElement, balloon, viewA, viewB, viewC, viewD; + testUtils.createSinonSandbox(); before( () => { @@ -58,6 +59,7 @@ describe( 'ContextualBalloon', () => { viewA = new View(); viewB = new View(); viewC = new View(); + viewD = new View(); // Add viewA to the pane and init viewB. balloon.add( { @@ -1031,5 +1033,184 @@ describe( 'ContextualBalloon', () => { expect( rotatorView.buttonNextView.labelView.element.textContent ).to.equal( 'Następny' ); } ); } ); + + describe( 'singleViewMode', () => { + it( 'should not display navigation when there is more than one stack', () => { + const navigationElement = rotatorView.element.querySelector( '.ck-balloon-rotator__navigation' ); + + expect( navigationElement.classList.contains( 'ck-hidden' ) ).to.be.true; + + balloon.add( { + view: viewB, + stackId: 'second', + singleViewMode: true + } ); + + expect( navigationElement.classList.contains( 'ck-hidden' ) ).to.be.true; + } ); + + it( 'should hide display navigation after adding view', () => { + const navigationElement = rotatorView.element.querySelector( '.ck-balloon-rotator__navigation' ); + + expect( navigationElement.classList.contains( 'ck-hidden' ) ).to.be.true; + + balloon.add( { + view: viewB, + stackId: 'second' + } ); + + expect( navigationElement.classList.contains( 'ck-hidden' ) ).to.be.false; + + balloon.add( { + view: viewC, + stackId: 'third', + singleViewMode: true + } ); + + expect( navigationElement.classList.contains( 'ck-hidden' ) ).to.be.true; + } ); + + it( 'should display navigation after removing a view', () => { + const navigationElement = rotatorView.element.querySelector( '.ck-balloon-rotator__navigation' ); + + balloon.add( { + view: viewB, + stackId: 'second' + } ); + + balloon.add( { + view: viewC, + stackId: 'third', + singleViewMode: true + } ); + + expect( navigationElement.classList.contains( 'ck-hidden' ) ).to.be.true; + + balloon.remove( viewC ); + + expect( navigationElement.classList.contains( 'ck-hidden' ) ).to.be.false; + } ); + + it( 'should not display navigation after removing a view if there is still some view with singleViewMode', () => { + const navigationElement = rotatorView.element.querySelector( '.ck-balloon-rotator__navigation' ); + + balloon.add( { + view: viewB, + stackId: 'second' + } ); + + balloon.add( { + view: viewC, + stackId: 'third', + singleViewMode: true + } ); + + balloon.add( { + view: viewD, + stackId: 'third', + singleViewMode: true + } ); + + expect( navigationElement.classList.contains( 'ck-hidden' ) ).to.be.true; + + balloon.remove( viewD ); + + expect( navigationElement.classList.contains( 'ck-hidden' ) ).to.be.true; + + balloon.remove( viewC ); + + expect( navigationElement.classList.contains( 'ck-hidden' ) ).to.be.false; + } ); + + it( 'should not show fake panels when more than one stack is added to the balloon (max to 2 panels)', () => { + const fakePanelsView = editor.ui.view.body.last; + + balloon.add( { + view: viewB, + stackId: 'second' + } ); + + expect( fakePanelsView.element.classList.contains( 'ck-hidden' ) ).to.equal( false ); + expect( fakePanelsView.element.childElementCount ).to.equal( 1 ); + + balloon.add( { + view: viewC, + stackId: 'third', + singleViewMode: true + } ); + + expect( fakePanelsView.element.classList.contains( 'ck-hidden' ) ).to.be.true; + expect( fakePanelsView.element.childElementCount ).to.equal( 0 ); + + balloon.remove( viewC ); + + expect( fakePanelsView.element.classList.contains( 'ck-hidden' ) ).to.equal( false ); + expect( fakePanelsView.element.childElementCount ).to.equal( 1 ); + + balloon.remove( viewB ); + } ); + + it( 'should switch visible view when adding a view to new stack', () => { + const navigationElement = rotatorView.element.querySelector( '.ck-balloon-rotator__navigation' ); + + expect( navigationElement.classList.contains( 'ck-hidden' ) ).to.be.true; + + balloon.add( { + view: viewB, + stackId: 'second' + } ); + + expect( balloon.visibleView ).to.equal( viewA ); + + balloon.add( { + view: viewC, + stackId: 'third', + singleViewMode: true + } ); + + expect( balloon.visibleView ).to.equal( viewC ); + + const viewD = new View(); + + balloon.add( { + view: viewD, + stackId: 'fifth', + singleViewMode: true + } ); + + expect( balloon.visibleView ).to.equal( viewD ); + } ); + + it( 'should switch visible view when adding a view to the same stack', () => { + const navigationElement = rotatorView.element.querySelector( '.ck-balloon-rotator__navigation' ); + + expect( navigationElement.classList.contains( 'ck-hidden' ) ).to.be.true; + + balloon.add( { + view: viewB, + stackId: 'second' + } ); + + expect( balloon.visibleView ).to.equal( viewA ); + + balloon.add( { + view: viewC, + stackId: 'third', + singleViewMode: true + } ); + + expect( balloon.visibleView ).to.equal( viewC ); + + const viewD = new View(); + + balloon.add( { + view: viewD, + stackId: 'third', + singleViewMode: true + } ); + + expect( balloon.visibleView ).to.equal( viewD ); + } ); + } ); } ); } );