瀏覽代碼

Merge pull request #498 from ckeditor/cf/2808

Other: Improved UX of ContextualBalloon with multiple stacks by adding fake panels. Closes #501.
Piotr Jasiun 6 年之前
父節點
當前提交
4ae1d5bf46

+ 7 - 1
packages/ckeditor5-ui/src/panel/balloon/balloonpanelview.js

@@ -236,7 +236,13 @@ export default class BalloonPanelView extends View {
 			fitInViewport: true
 		}, options );
 
-		const { top, left, name: position } = BalloonPanelView._getOptimalPosition( positionOptions );
+		const optimalPosition = BalloonPanelView._getOptimalPosition( positionOptions );
+
+		// Usually browsers make some problems with super accurate values like 104.345px
+		// so it is better to use int values.
+		const left = parseInt( optimalPosition.left );
+		const top = parseInt( optimalPosition.top );
+		const position = optimalPosition.name;
 
 		Object.assign( this, { top, left, position } );
 	}

+ 151 - 18
packages/ckeditor5-ui/src/panel/balloon/contextualballoon.js

@@ -13,11 +13,16 @@ import View from '../../view';
 import ButtonView from '../../button/buttonview';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker';
+import toUnit from '@ckeditor/ckeditor5-utils/src/dom/tounit';
+import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';
 
 import prevIcon from '../../../theme/icons/previous-arrow.svg';
 import nextIcon from '../../../theme/icons/next-arrow.svg';
 
 import '../../../theme/components/panel/balloonrotator.css';
+import '../../../theme/components/panel/fakepanel.css';
+
+const toPx = toUnit( 'px' );
 
 /**
  * Provides the common contextual balloon for the editor.
@@ -141,6 +146,14 @@ export default class ContextualBalloon extends Plugin {
 		 * @type {module:ui/panel/balloon/contextualballoon~RotatorView}
 		 */
 		this._rotatorView = this._createRotatorView();
+
+		/**
+		 * Displays fake panels under the balloon panel view when multiple stacks are added to the balloon.
+		 *
+		 * @private
+		 * @type {module:ui/view~View}
+		 */
+		this._fakePanelsView = this._createFakePanelsView();
 	}
 
 	/**
@@ -259,7 +272,7 @@ export default class ContextualBalloon extends Plugin {
 		}
 
 		this.view.pin( this._getBalloonPosition() );
-		this._rotatorView.updateIsNarrow();
+		this._fakePanelsView.updatePosition();
 	}
 
 	/**
@@ -396,6 +409,24 @@ export default class ContextualBalloon extends Plugin {
 	}
 
 	/**
+	 * @returns {module:ui/view~View}
+	 */
+	_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.listenTo( this.view, 'change:top', () => view.updatePosition() );
+		view.listenTo( this.view, 'change:left', () => view.updatePosition() );
+
+		this.editor.ui.view.body.add( view );
+
+		return view;
+	}
+
+	/**
 	 * Sets the view as a content of the balloon and attaches balloon using position
 	 * options of the first view.
 	 *
@@ -412,6 +443,7 @@ export default class ContextualBalloon extends Plugin {
 		this._rotatorView.showView( view );
 		this.visibleView = view;
 		this.view.pin( this._getBalloonPosition() );
+		this._fakePanelsView.updatePosition();
 	}
 
 	/**
@@ -461,13 +493,6 @@ class RotatorView extends View {
 		this.set( 'isNavigationVisible', true );
 
 		/**
-		 * Defines whether balloon should be marked as narrow or not.
-		 *
-		 * @member {Boolean} #isNarrow
-		 */
-		this.set( 'isNarrow', false );
-
-		/**
 		 * Used for checking if view is focused or not.
 		 *
 		 * @type {module:utils/focustracker~FocusTracker}
@@ -521,8 +546,7 @@ class RotatorView extends View {
 
 							attributes: {
 								class: [
-									'ck-balloon-rotator__counter',
-									bind.to( 'isNarrow', value => value ? 'ck-hidden' : '' )
+									'ck-balloon-rotator__counter'
 								]
 							},
 
@@ -556,13 +580,6 @@ class RotatorView extends View {
 	}
 
 	/**
-	 * Checks if view width is narrow and updated {@link ~RotatorView#isNarrow} state.
-	 */
-	updateIsNarrow() {
-		this.isNarrow = this.element.clientWidth <= 200;
-	}
-
-	/**
 	 * Shows given view.
 	 *
 	 * @param {module:ui/view~View} view The view to show.
@@ -570,7 +587,6 @@ class RotatorView extends View {
 	showView( view ) {
 		this.hideView();
 		this.content.add( view );
-		this.updateIsNarrow();
 	}
 
 	/**
@@ -600,3 +616,120 @@ class RotatorView extends View {
 		return view;
 	}
 }
+
+// Displays additional layers under the balloon when multiple stacks are added to the balloon.
+//
+// @private
+// @extends module:ui/view~View
+class FakePanelsView extends View {
+	// @inheritDoc
+	constructor( locale, balloonPanelView ) {
+		super( locale );
+
+		const bind = this.bindTemplate;
+
+		// Fake panels top offset.
+		//
+		// @observable
+		// @member {Number} #top
+		this.set( 'top', 0 );
+
+		// Fake panels left offset.
+		//
+		// @observable
+		// @member {Number} #left
+		this.set( 'left', 0 );
+
+		// Fake panels height.
+		//
+		// @observable
+		// @member {Number} #height
+		this.set( 'height', 0 );
+
+		// Fake panels width.
+		//
+		// @observable
+		// @member {Number} #width
+		this.set( 'width', 0 );
+
+		// Number of rendered fake panels.
+		//
+		// @observable
+		// @member {Number} #numberOfPanels
+		this.set( 'numberOfPanels', 0 );
+
+		// Collection of the child views which creates fake panel content.
+		//
+		// @readonly
+		// @type {module:ui/viewcollection~ViewCollection}
+		this.content = this.createCollection();
+
+		// Context.
+		//
+		// @private
+		// @type {module:ui/panel/balloon/balloonpanelview~BalloonPanelView}
+		this._balloonPanelView = balloonPanelView;
+
+		this.setTemplate( {
+			tag: 'div',
+			attributes: {
+				class: [
+					'ck-fake-panel',
+					bind.to( 'numberOfPanels', number => number ? '' : 'ck-hidden' )
+				],
+				style: {
+					top: bind.to( 'top', toPx ),
+					left: bind.to( 'left', toPx ),
+					width: bind.to( 'width', toPx ),
+					height: bind.to( 'height', toPx )
+				}
+			},
+			children: this.content
+		} );
+
+		this.on( 'change:numberOfPanels', ( evt, name, next, prev ) => {
+			if ( next > prev ) {
+				this._addPanels( next - prev );
+			} else {
+				this._removePanels( prev - next );
+			}
+
+			this.updatePosition();
+		} );
+	}
+
+	// @private
+	// @param {Number} number
+	_addPanels( number ) {
+		while ( number-- ) {
+			const view = new View();
+
+			view.setTemplate( { tag: 'div' } );
+
+			this.content.add( view );
+			this.registerChild( view );
+		}
+	}
+
+	// @private
+	// @param {Number} number
+	_removePanels( number ) {
+		while ( number-- ) {
+			const view = this.content.last;
+
+			this.content.remove( view );
+			this.deregisterChild( view );
+			view.destroy();
+		}
+	}
+
+	// Updates coordinates of fake panels.
+	updatePosition() {
+		if ( this.numberOfPanels ) {
+			const { top, left } = this._balloonPanelView;
+			const { width, height } = new Rect( this._balloonPanelView.element );
+
+			Object.assign( this, { top, left, width, height } );
+		}
+	}
+}

+ 17 - 3
packages/ckeditor5-ui/tests/manual/contextualballoon/contextualballoon.html

@@ -4,7 +4,7 @@
 	<p>Line of text, line of text, line of text, line of text.</p>
 	<p>Line of text, line of text, line of text, line of text.</p>
 	<p>Line of text, line of text, line of text, line of text.</p>
-	<p>Line of text, line of text, line of text, line of text.</p>
+	<p>Line of text, line of [select] text, line of text, line of text.</p>
 	<p>Line of text, line of text, line of text, line of text.</p>
 	<p>Line of text, line of text, line of text, line of text.</p>
 	<p>Line of text, line of text, line of text, line of text.</p>
@@ -21,11 +21,25 @@
 		max-width: 70%;
 	}
 
-	.highlight {
-		background: yellow;
+	.highlight.yellow {
+		background: hsla(55, 100%, 50%, 0.75);
+	}
+
+	.highlight.blue {
+		background: hsla(249, 100%, 50%, 0.75);
+	}
+
+	.highlight.pink {
+		background: hsla(313, 100%, 50%, 0.75);
+	}
+
+	.highlight.green {
+		background: hsla(114, 100%, 50%, 0.75);
 	}
 
 	.ck p.custom-view {
+		width: 300px;
+		text-align: center;
 		font-size: 14px;
 		padding: 10px 10px 9px;
 	}

+ 13 - 9
packages/ckeditor5-ui/tests/manual/contextualballoon/contextualballoon.js

@@ -25,8 +25,10 @@ class CustomStackHighlight {
 	init() {
 		this.editor.conversion.for( 'editingDowncast' ).markerToHighlight( {
 			model: 'highlight',
-			view: () => {
-				return { classes: 'highlight' };
+			view: data => {
+				const color = data.markerName.split( ':' )[ 2 ];
+
+				return { classes: 'highlight ' + color };
 			}
 		} );
 
@@ -51,7 +53,7 @@ class CustomStackHighlight {
 
 		this.editor.plugins.get( ContextualBalloon ).add( {
 			view,
-			stackId: 'custom',
+			stackId: 'custom-' + marker.name.split( ':' )[ 1 ],
 			position: {
 				target: this._getMarkerDomElement( marker )
 			}
@@ -65,9 +67,9 @@ class CustomStackHighlight {
 
 	_getMarkerDomElement( marker ) {
 		const editing = this.editor.editing;
-		const viewElement = Array.from( editing.mapper.markerNameToElements( marker.name ).values() )[ 0 ];
+		const viewRange = editing.mapper.toViewRange( marker.getRange() );
 
-		return editing.view.domConverter.mapViewToDom( viewElement );
+		return editing.view.domConverter.viewRangeToDom( viewRange );
 	}
 }
 
@@ -84,11 +86,13 @@ ClassicEditor
 			const root = editor.model.document.getRoot();
 
 			[
-				{ id: 1, start: [ 1, 5 ], end: [ 1, 26 ] },
-				{ id: 2, start: [ 5, 5 ], end: [ 5, 26 ] },
-				{ id: 3, start: [ 10, 5 ], end: [ 10, 26 ] }
+				{ id: 1, start: [ 1, 5 ], end: [ 1, 26 ], color: 'yellow' },
+				{ id: 2, start: [ 1, 2 ], end: [ 1, 33 ], color: 'green' },
+				{ id: 3, start: [ 5, 20 ], end: [ 5, 35 ], color: 'blue' },
+				{ id: 4, start: [ 5, 15 ], end: [ 5, 40 ], color: 'pink' },
+				{ id: 5, start: [ 5, 10 ], end: [ 5, 45 ], color: 'yellow' }
 			].forEach( data => {
-				writer.addMarker( `highlight:${ data.id }`, {
+				writer.addMarker( `highlight:${ data.id }:${ data.color }`, {
 					range: writer.createRange(
 						writer.createPositionFromPath( root, data.start ),
 						writer.createPositionFromPath( root, data.end )

+ 9 - 1
packages/ckeditor5-ui/tests/manual/contextualballoon/contextualballoon.md

@@ -8,4 +8,12 @@
 ## Multiple stacks
 
 1. Select some highlighted text - "View in separate stack." should show up.
-2. Switch stacks by clicking navigation buttons - you should switch between toolbar and custom view.
+2. Switch stacks by clicking navigation buttons. You should switch between toolbar and custom views.
+
+## Fake panels - min
+
+1. Put the selection before the highlight, start moving selection by right arrow. You should see additional layer under the balloon only when at least 2 stacks are added to the balloon.
+
+## 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.

+ 13 - 0
packages/ckeditor5-ui/tests/panel/balloon/balloonpanelview.js

@@ -192,6 +192,19 @@ describe( 'BalloonPanelView', () => {
 			} ) );
 		} );
 
+		it( 'should parse optimal position offset to int', () => {
+			testUtils.sinon.stub( BalloonPanelView, '_getOptimalPosition' ).returns( {
+				top: 10.345,
+				left: 10.345,
+				name: 'position'
+			} );
+
+			view.attachTo( { target, limiter } );
+
+			expect( view.top ).to.equal( 10 );
+			expect( view.left ).to.equal( 10 );
+		} );
+
 		describe( 'limited by limiter element', () => {
 			beforeEach( () => {
 				// Mock limiter element dimensions.

+ 118 - 27
packages/ckeditor5-ui/tests/panel/balloon/contextualballoon.js

@@ -742,33 +742,6 @@ describe( 'ContextualBalloon', () => {
 			expect( counterElement.textContent ).to.equal( '2 of 2' );
 		} );
 
-		it( 'should hide counter when element width is less then 200px', () => {
-			// To be sure navigation is displayed.
-			balloon.add( {
-				view: viewB,
-				stackId: 'second'
-			} );
-
-			const counterElement = rotatorView.element.querySelector( '.ck-balloon-rotator__counter' );
-			let mockedWidth = 201;
-
-			sinon.stub( rotatorView.element, 'clientWidth' ).get( () => mockedWidth );
-
-			balloon.showStack( 'second' );
-
-			expect( counterElement.classList.contains( 'ck-hidden' ) ).to.equal( false );
-
-			mockedWidth = 200;
-			balloon.showStack( 'main' );
-
-			expect( counterElement.classList.contains( 'ck-hidden' ) ).to.equal( true );
-
-			mockedWidth = 201;
-			balloon.updatePosition();
-
-			expect( counterElement.classList.contains( 'ck-hidden' ) ).to.equal( false );
-		} );
-
 		it( 'should switch stack to the next one after clicking next button', () => {
 			balloon.add( {
 				view: viewB,
@@ -876,5 +849,123 @@ describe( 'ContextualBalloon', () => {
 
 			sinon.assert.calledOnce( editableFocusSpy );
 		} );
+
+		it( 'should add hidden view with fake panels to editor body collection', () => {
+			const fakePanelsView = editor.ui.view.body.last;
+
+			expect( fakePanelsView.element.classList.contains( 'ck-fake-panel' ) ).to.equal( true );
+			expect( fakePanelsView.element.classList.contains( 'ck-hidden' ) ).to.equal( true );
+			expect( fakePanelsView.element.childElementCount ).to.equal( 0 );
+		} );
+
+		it( 'should show fake panels when more than one stack is added to the balloon (max to 2 panels)', () => {
+			const fakePanelsView = editor.ui.view.body.last;
+			const viewD = new View();
+
+			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'
+			} );
+
+			expect( fakePanelsView.element.classList.contains( 'ck-hidden' ) ).to.equal( false );
+			expect( fakePanelsView.element.childElementCount ).to.equal( 2 );
+
+			balloon.add( {
+				view: viewD,
+				stackId: 'fourth'
+			} );
+
+			expect( fakePanelsView.element.classList.contains( 'ck-hidden' ) ).to.equal( false );
+			expect( fakePanelsView.element.childElementCount ).to.equal( 2 );
+
+			balloon.remove( viewD );
+
+			expect( fakePanelsView.element.classList.contains( 'ck-hidden' ) ).to.equal( false );
+			expect( fakePanelsView.element.childElementCount ).to.equal( 2 );
+
+			balloon.remove( viewC );
+
+			expect( fakePanelsView.element.classList.contains( 'ck-hidden' ) ).to.equal( false );
+			expect( fakePanelsView.element.childElementCount ).to.equal( 1 );
+
+			balloon.remove( viewB );
+
+			expect( fakePanelsView.element.classList.contains( 'ck-hidden' ) ).to.equal( true );
+			expect( fakePanelsView.element.childElementCount ).to.equal( 0 );
+		} );
+
+		it( 'should keep position of fake panels up to date with balloon position when panels are visible', () => {
+			const fakePanelsView = editor.ui.view.body.last;
+
+			let width = 30;
+			let height = 40;
+
+			balloon.view.top = 10;
+			balloon.view.left = 20;
+
+			sinon.stub( balloon.view.element, 'getBoundingClientRect' ).callsFake( () => ( { width, height } ) );
+
+			balloon.add( {
+				view: viewB,
+				stackId: 'second'
+			} );
+
+			expect( fakePanelsView.element.style.top ).to.equal( '10px' );
+			expect( fakePanelsView.element.style.left ).to.equal( '20px' );
+			expect( fakePanelsView.element.style.width ).to.equal( '30px' );
+			expect( fakePanelsView.element.style.height ).to.equal( '40px' );
+
+			balloon.view.top = 15;
+			balloon.view.left = 25;
+			width = 35;
+			height = 45;
+
+			balloon.add( {
+				view: viewC,
+				stackId: 'third'
+			} );
+
+			expect( fakePanelsView.element.style.top ).to.equal( '15px' );
+			expect( fakePanelsView.element.style.left ).to.equal( '25px' );
+			expect( fakePanelsView.element.style.width ).to.equal( '35px' );
+			expect( fakePanelsView.element.style.height ).to.equal( '45px' );
+
+			balloon.view.top = 10;
+			balloon.view.left = 20;
+			width = 30;
+			height = 40;
+
+			balloon.updatePosition();
+
+			expect( fakePanelsView.element.style.top ).to.equal( '10px' );
+			expect( fakePanelsView.element.style.left ).to.equal( '20px' );
+			expect( fakePanelsView.element.style.width ).to.equal( '30px' );
+			expect( fakePanelsView.element.style.height ).to.equal( '40px' );
+
+			// Hide fake panels by removing additional stacks.
+			balloon.remove( viewC );
+			balloon.remove( viewB );
+
+			balloon.view.top = 15;
+			balloon.view.left = 25;
+			width = 35;
+			height = 45;
+
+			balloon.updatePosition();
+
+			// Old values because fake panels are hidden.
+			expect( fakePanelsView.element.style.top ).to.equal( '10px' );
+			expect( fakePanelsView.element.style.left ).to.equal( '20px' );
+			expect( fakePanelsView.element.style.width ).to.equal( '30px' );
+			expect( fakePanelsView.element.style.height ).to.equal( '40px' );
+		} );
 	} );
 } );

+ 23 - 0
packages/ckeditor5-ui/theme/components/panel/fakepanel.css

@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+.ck .ck-fake-panel {
+	position: absolute;
+
+	/* Fake panels should be placed under main balloon content. */
+	z-index: calc(var(--ck-z-modal) - 1);
+}
+
+.ck .ck-fake-panel div {
+	position: absolute;
+}
+
+.ck .ck-fake-panel div:nth-child( 1 ) {
+	z-index: 2;
+}
+
+.ck .ck-fake-panel div:nth-child( 2 ) {
+	z-index: 1;
+}