8
0
Просмотр исходного кода

Merge pull request #520 from ckeditor/t/ckeditor5/1151

Feature: Brought support for right–to–left (RTL) languages to various UI components. See ckeditor/ckeditor5#1151.
Piotrek Koszuliński 6 лет назад
Родитель
Сommit
99b7d26466

+ 8 - 0
packages/ckeditor5-ui/docs/features/blocktoolbar.md

@@ -31,6 +31,14 @@ To adjust the position of the block toolbar button to match the styles of your w
 }
 ```
 
+If you plan to run the editor in a right–to–left (RTL) language, keep in mind the button will be attached to the **right** boundary of the editable area. In that case, make sure the CSS position adjustment works properly by adding the following styles:
+
+```css
+.ck[dir="rtl"] .ck-block-toolbar-button {
+	transform: translateX( 10px );
+}
+```
+
 ## Installation
 
 <info-box hint>

+ 28 - 9
packages/ckeditor5-ui/src/dropdown/dropdownview.js

@@ -253,18 +253,11 @@ export default class DropdownView extends View {
 			// If "auto", find the best position of the panel to fit into the viewport.
 			// Otherwise, simply assign the static position.
 			if ( this.panelPosition === 'auto' ) {
-				const defaultPanelPositions = DropdownView.defaultPanelPositions;
-
-				this.panelView.position = getOptimalPosition( {
+				this.panelView.position = DropdownView._getOptimalPosition( {
 					element: this.panelView.element,
 					target: this.buttonView.element,
 					fitInViewport: true,
-					positions: [
-						defaultPanelPositions.southEast,
-						defaultPanelPositions.southWest,
-						defaultPanelPositions.northEast,
-						defaultPanelPositions.northWest
-					]
+					positions: this._panelPositions
 				} ).name;
 			} else {
 				this.panelView.position = this.panelPosition;
@@ -312,6 +305,24 @@ export default class DropdownView extends View {
 	focus() {
 		this.buttonView.focus();
 	}
+
+	/**
+	 * Returns {@link #panelView panel} positions to be used by the
+	 * {@link module:utils/dom/position~getOptimalPosition `getOptimalPosition()`}
+	 * utility considering the direction of the language the UI of the editor is displayed in.
+	 *
+	 * @type {module:utils/dom/position~Options#positions}
+	 * @private
+	 */
+	get _panelPositions() {
+		const { southEast, southWest, northEast, northWest } = DropdownView.defaultPanelPositions;
+
+		if ( this.locale.uiLanguageDirection === 'ltr' ) {
+			return [ southEast, southWest, northEast, northWest ];
+		} else {
+			return [ southWest, southEast, northWest, northEast ];
+		}
+	}
 }
 
 /**
@@ -392,3 +403,11 @@ DropdownView.defaultPanelPositions = {
 		};
 	}
 };
+
+/**
+ * A function used to calculate the optimal position for the dropdown panel.
+ *
+ * @protected
+ * @member {Function} module:ui/dropdown/dropdownview~DropdownView._getOptimalPosition
+ */
+DropdownView._getOptimalPosition = getOptimalPosition;

+ 3 - 1
packages/ckeditor5-ui/src/editableui/editableuiview.js

@@ -34,7 +34,9 @@ export default class EditableUIView extends View {
 					'ck-content',
 					'ck-editor__editable',
 					'ck-rounded-corners'
-				]
+				],
+				lang: locale.contentLanguage,
+				dir: locale.contentLanguageDirection
 			}
 		} );
 

+ 2 - 2
packages/ckeditor5-ui/src/editorui/boxed/boxededitoruiview.js

@@ -66,8 +66,8 @@ export default class BoxedEditorUIView extends EditorUIView {
 					'ck-rounded-corners'
 				],
 				role: 'application',
-				dir: 'ltr',
-				lang: locale.language,
+				dir: locale.uiLanguageDirection,
+				lang: locale.uiLanguage,
 				'aria-labelledby': `ck-editor__aria-label_${ ariaLabelUid }`
 			},
 

+ 3 - 1
packages/ckeditor5-ui/src/editorui/editoruiview.js

@@ -69,6 +69,7 @@ export default class EditorUIView extends View {
 	 * @private
 	 */
 	_renderBodyCollection() {
+		const locale = this.locale;
 		const bodyElement = this._bodyCollectionContainer = new Template( {
 			tag: 'div',
 			attributes: {
@@ -77,7 +78,8 @@ export default class EditorUIView extends View {
 					'ck-reset_all',
 					'ck-body',
 					'ck-rounded-corners'
-				]
+				],
+				dir: locale.uiLanguageDirection,
 			},
 			children: this.body
 		} ).render();

+ 18 - 2
packages/ckeditor5-ui/src/toolbar/block/blocktoolbar.js

@@ -53,6 +53,14 @@ import iconPilcrow from '@ckeditor/ckeditor5-core/theme/icons/pilcrow.svg';
  * 		      |  block of content that the button is
  * 		      |  attached to.
  *
+ * **Note**: If you plan to run the editor in a right–to–left (RTL) language, keep in mind the button
+ * will be attached to the **right** boundary of the editable area. In that case, make sure the
+ * CSS position adjustment works properly by adding the following styles:
+ *
+ * 		.ck[dir="rtl"] .ck-block-toolbar-button {
+ * 			transform: translateX( 10px );
+ * 		}
+ *
  * @extends module:core/plugin~Plugin
  */
 export default class BlockToolbar extends Plugin {
@@ -361,9 +369,17 @@ export default class BlockToolbar extends Plugin {
 			target: targetElement,
 			positions: [
 				( contentRect, buttonRect ) => {
+					let left;
+
+					if ( this.editor.locale.uiLanguageDirection === 'ltr' ) {
+						left = editableRect.left - buttonRect.width;
+					} else {
+						left = editableRect.right;
+					}
+
 					return {
-						top: contentRect.top + contentPaddingTop + ( ( contentLineHeight - buttonRect.height ) / 2 ),
-						left: editableRect.left - buttonRect.width
+						top: contentRect.top + contentPaddingTop + ( contentLineHeight - buttonRect.height ) / 2,
+						left
 					};
 				}
 			]

+ 88 - 82
packages/ckeditor5-ui/tests/dropdown/dropdownview.js

@@ -10,7 +10,6 @@ import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
 import ButtonView from '../../src/button/buttonview';
 import DropdownPanelView from '../../src/dropdown/dropdownpanelview';
 import global from '@ckeditor/ckeditor5-utils/src/dom/global';
-import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 
 describe( 'DropdownView', () => {
@@ -19,7 +18,10 @@ describe( 'DropdownView', () => {
 	testUtils.createSinonSandbox();
 
 	beforeEach( () => {
-		locale = { t() {} };
+		locale = {
+			uiLanguageDirection: 'ltr',
+			t() {}
+		};
 
 		buttonView = new ButtonView( locale );
 		panelView = new DropdownPanelView( locale );
@@ -116,7 +118,7 @@ describe( 'DropdownView', () => {
 			} );
 
 			describe( 'view.panelView#position to view#panelPosition', () => {
-				it( 'does not update until the dropdown is opened', () => {
+				it( 'does not update until the dropdown is open', () => {
 					view.isOpen = false;
 					view.panelPosition = 'nw';
 
@@ -128,86 +130,37 @@ describe( 'DropdownView', () => {
 				} );
 
 				describe( 'in "auto" mode', () => {
-					beforeEach( () => {
-						// Bloat the panel a little to give the positioning algorithm something to
-						// work with. If the panel was empty, any smart positioning is pointless.
-						// Placing an empty element in the viewport isn't that hard, right?
-						panelView.element.style.width = '200px';
-						panelView.element.style.height = '200px';
-					} );
-
-					it( 'defaults to "south-east" when there is a plenty of space around', () => {
-						const windowRect = new Rect( global.window );
-
-						// "Put" the dropdown in the middle of the viewport.
-						stubElementClientRect( view.buttonView.element, {
-							top: windowRect.height / 2,
-							left: windowRect.width / 2,
-							width: 10,
-							height: 10
-						} );
-
-						view.isOpen = true;
-
-						expect( panelView.position ).to.equal( 'se' );
-					} );
-
-					it( 'when the dropdown in the north-west corner of the viewport', () => {
-						stubElementClientRect( view.buttonView.element, {
-							top: 0,
-							left: 0,
-							width: 100,
-							height: 10
-						} );
+					it( 'uses _getOptimalPosition() and a dedicated set of positions (LTR)', () => {
+						const spy = testUtils.sinon.spy( DropdownView, '_getOptimalPosition' );
+						const { southEast, southWest, northEast, northWest } = DropdownView.defaultPanelPositions;
 
 						view.isOpen = true;
 
-						expect( panelView.position ).to.equal( 'se' );
+						sinon.assert.calledWithExactly( spy, sinon.match( {
+							element: panelView.element,
+							target: buttonView.element,
+							positions: [
+								southEast, southWest, northEast, northWest
+							],
+							fitInViewport: true
+						} ) );
 					} );
 
-					it( 'when the dropdown in the north-east corner of the viewport', () => {
-						const windowRect = new Rect( global.window );
-
-						stubElementClientRect( view.buttonView.element, {
-							top: 0,
-							left: windowRect.right - 100,
-							width: 100,
-							height: 10
-						} );
+					it( 'uses _getOptimalPosition() and a dedicated set of positions (RTL)', () => {
+						const spy = testUtils.sinon.spy( DropdownView, '_getOptimalPosition' );
+						const { southEast, southWest, northEast, northWest } = DropdownView.defaultPanelPositions;
 
+						view.locale.uiLanguageDirection = 'rtl';
 						view.isOpen = true;
 
-						expect( panelView.position ).to.equal( 'sw' );
-					} );
-
-					it( 'when the dropdown in the south-west corner of the viewport', () => {
-						const windowRect = new Rect( global.window );
-
-						stubElementClientRect( view.buttonView.element, {
-							top: windowRect.bottom - 10,
-							left: 0,
-							width: 100,
-							height: 10
-						} );
-
-						view.isOpen = true;
-
-						expect( panelView.position ).to.equal( 'ne' );
-					} );
-
-					it( 'when the dropdown in the south-east corner of the viewport', () => {
-						const windowRect = new Rect( global.window );
-
-						stubElementClientRect( view.buttonView.element, {
-							top: windowRect.bottom - 10,
-							left: windowRect.right - 100,
-							width: 100,
-							height: 10
-						} );
-
-						view.isOpen = true;
-
-						expect( panelView.position ).to.equal( 'nw' );
+						sinon.assert.calledWithExactly( spy, sinon.match( {
+							element: panelView.element,
+							target: buttonView.element,
+							positions: [
+								southWest, southEast, northWest, northEast
+							],
+							fitInViewport: true
+						} ) );
 					} );
 				} );
 			} );
@@ -372,13 +325,66 @@ describe( 'DropdownView', () => {
 			sinon.assert.calledOnce( spy );
 		} );
 	} );
-} );
 
-function stubElementClientRect( element, data ) {
-	const clientRect = Object.assign( {}, data );
+	describe( 'DropdownView.defaultPanelPositions', () => {
+		let positions, buttonRect, panelRect;
+
+		beforeEach( () => {
+			positions = DropdownView.defaultPanelPositions;
+
+			buttonRect = {
+				top: 100,
+				bottom: 200,
+				left: 100,
+				right: 200,
+				width: 100,
+				height: 100
+			};
+
+			panelRect = {
+				top: 0,
+				bottom: 0,
+				left: 0,
+				right: 0,
+				width: 50,
+				height: 50
+			};
+		} );
+
+		it( 'should have a proper length', () => {
+			expect( Object.keys( positions ) ).to.have.length( 4 );
+		} );
+
+		it( 'should define the "southEast" position', () => {
+			expect( positions.southEast( buttonRect, panelRect ) ).to.deep.equal( {
+				top: 200,
+				left: 100,
+				name: 'se'
+			} );
+		} );
+
+		it( 'should define the "southWest" position', () => {
+			expect( positions.southWest( buttonRect, panelRect ) ).to.deep.equal( {
+				top: 200,
+				left: 150,
+				name: 'sw'
+			} );
+		} );
 
-	clientRect.right = clientRect.left + clientRect.width;
-	clientRect.bottom = clientRect.top + clientRect.height;
+		it( 'should define the "northEast" position', () => {
+			expect( positions.northEast( buttonRect, panelRect ) ).to.deep.equal( {
+				top: 50,
+				left: 100,
+				name: 'ne'
+			} );
+		} );
 
-	testUtils.sinon.stub( element, 'getBoundingClientRect' ).returns( clientRect );
-}
+		it( 'should define the "northWest" position', () => {
+			expect( positions.northWest( buttonRect, panelRect ) ).to.deep.equal( {
+				top: 150,
+				left: 150,
+				name: 'nw'
+			} );
+		} );
+	} );
+} );

+ 58 - 5
packages/ckeditor5-ui/tests/editableui/editableuiview.js

@@ -18,7 +18,7 @@ describe( 'EditableUIView', () => {
 	testUtils.createSinonSandbox();
 
 	beforeEach( () => {
-		locale = new Locale( 'en' );
+		locale = new Locale();
 		editableElement = document.createElement( 'div' );
 
 		editingView = new EditingView();
@@ -31,14 +31,21 @@ describe( 'EditableUIView', () => {
 		view.render();
 	} );
 
+	afterEach( () => {
+		view.destroy();
+		editableElement.remove();
+	} );
+
 	describe( 'constructor()', () => {
 		it( 'sets initial values of attributes', () => {
-			view = new EditableUIView( locale, editingView );
+			const view = new EditableUIView( locale, editingView );
 
 			expect( view.isFocused ).to.be.false;
 			expect( view.name ).to.be.null;
 			expect( view._externalElement ).to.be.undefined;
 			expect( view._editingView ).to.equal( editingView );
+
+			view.destroy();
 		} );
 
 		it( 'renders element from template when no editableElement', () => {
@@ -47,22 +54,58 @@ describe( 'EditableUIView', () => {
 			expect( view.element.classList.contains( 'ck-content' ) ).to.be.true;
 			expect( view.element.classList.contains( 'ck-editor__editable' ) ).to.be.true;
 			expect( view.element.classList.contains( 'ck-rounded-corners' ) ).to.be.true;
+			expect( view.element.getAttribute( 'lang' ) ).to.equal( 'en' );
+			expect( view.element.getAttribute( 'dir' ) ).to.equal( 'ltr' );
 			expect( view._externalElement ).to.be.undefined;
 			expect( view.isRendered ).to.be.true;
 		} );
 
 		it( 'accepts editableElement as an argument', () => {
-			view = new EditableUIView( locale, editingView, editableElement );
+			const view = new EditableUIView( locale, editingView, editableElement );
 			view.name = editingViewRoot.rootName;
 
 			view.render();
+
 			expect( view.element ).to.equal( editableElement );
 			expect( view.element ).to.equal( view._editableElement );
 			expect( view.element.classList.contains( 'ck' ) ).to.be.true;
 			expect( view.element.classList.contains( 'ck-editor__editable' ) ).to.be.true;
 			expect( view.element.classList.contains( 'ck-rounded-corners' ) ).to.be.true;
+			expect( view.element.getAttribute( 'lang' ) ).to.equal( 'en' );
+			expect( view.element.getAttribute( 'dir' ) ).to.equal( 'ltr' );
 			expect( view._hasExternalElement ).to.be.true;
 			expect( view.isRendered ).to.be.true;
+
+			view.destroy();
+		} );
+
+		it( 'sets proper lang and dir attributes (implicit content language)', () => {
+			const locale = new Locale( { uiLanguage: 'ar' } );
+			const view = new EditableUIView( locale, editingView );
+			view.name = editingViewRoot.rootName;
+
+			view.render();
+
+			expect( view.element.getAttribute( 'lang' ) ).to.equal( 'ar' );
+			expect( view.element.getAttribute( 'dir' ) ).to.equal( 'rtl' );
+
+			view.destroy();
+		} );
+
+		it( 'sets proper lang and dir attributes (explicit content language)', () => {
+			const locale = new Locale( {
+				uiLanguage: 'pl',
+				contentLanguage: 'ar'
+			} );
+			const view = new EditableUIView( locale, editingView );
+			view.name = editingViewRoot.rootName;
+
+			view.render();
+
+			expect( view.element.getAttribute( 'lang' ) ).to.equal( 'ar' );
+			expect( view.element.getAttribute( 'dir' ) ).to.equal( 'rtl' );
+
+			view.destroy();
 		} );
 	} );
 
@@ -123,6 +166,7 @@ describe( 'EditableUIView', () => {
 				expect( secondEditingViewRoot.hasClass( 'ck-blurred' ), 12 ).to.be.false;
 
 				secondEditableElement.remove();
+				secondView.destroy();
 			} );
 		} );
 	} );
@@ -130,12 +174,21 @@ describe( 'EditableUIView', () => {
 	describe( 'destroy()', () => {
 		it( 'calls super#destroy()', () => {
 			const spy = testUtils.sinon.spy( View.prototype, 'destroy' );
+			const view = new EditableUIView( locale, editingView );
+			view.name = editingViewRoot.rootName;
 
+			view.render();
 			view.destroy();
+
 			sinon.assert.calledOnce( spy );
 		} );
 
 		it( 'can be called multiple times', () => {
+			const view = new EditableUIView( locale, editingView );
+			view.name = editingViewRoot.rootName;
+
+			view.render();
+
 			expect( () => {
 				view.destroy();
 				view.destroy();
@@ -144,11 +197,11 @@ describe( 'EditableUIView', () => {
 
 		describe( 'when #editableElement as an argument', () => {
 			it( 'reverts the template of editableElement', () => {
-				editableElement = document.createElement( 'div' );
+				const editableElement = document.createElement( 'div' );
 				editableElement.classList.add( 'foo' );
 				editableElement.contentEditable = false;
 
-				view = new EditableUIView( locale, editingView, editableElement );
+				const view = new EditableUIView( locale, editingView, editableElement );
 				view.name = editingViewRoot.rootName;
 
 				view.render();

+ 12 - 1
packages/ckeditor5-ui/tests/editorui/boxed/boxededitoruiview.js

@@ -11,7 +11,7 @@ describe( 'BoxedEditorUIView', () => {
 	let view, element;
 
 	beforeEach( () => {
-		view = new BoxedEditorUIView( new Locale( 'en' ) );
+		view = new BoxedEditorUIView( new Locale() );
 		view.render();
 		element = view.element;
 	} );
@@ -31,6 +31,7 @@ describe( 'BoxedEditorUIView', () => {
 			expect( view.element.classList.contains( 'ck-editor' ) ).to.be.true;
 			expect( view.element.classList.contains( 'ck-reset' ) ).to.be.true;
 			expect( view.element.classList.contains( 'ck-rounded-corners' ) ).to.be.true;
+			expect( view.element.getAttribute( 'dir' ) ).to.equal( 'ltr' );
 			expect( element.attributes[ 'aria-labelledby' ].value )
 				.to.equal( view.element.firstChild.id )
 				.to.match( /^ck-editor__aria-label_\w+$/ );
@@ -59,5 +60,15 @@ describe( 'BoxedEditorUIView', () => {
 			expect( element.childNodes[ 1 ].attributes.getNamedItem( 'role' ).value ).to.equal( 'presentation' );
 			expect( element.childNodes[ 2 ].attributes.getNamedItem( 'role' ).value ).to.equal( 'presentation' );
 		} );
+
+		it( 'sets the proper "dir" attribute value when using RTL language', () => {
+			const view = new BoxedEditorUIView( new Locale( { uiLanguage: 'ar' } ) );
+
+			view.render();
+
+			expect( view.element.getAttribute( 'dir' ) ).to.equal( 'rtl' );
+
+			view.destroy();
+		} );
 	} );
 } );

+ 20 - 1
packages/ckeditor5-ui/tests/editorui/editoruiview.js

@@ -16,7 +16,7 @@ describe( 'EditorUIView', () => {
 	testUtils.createSinonSandbox();
 
 	beforeEach( () => {
-		locale = new Locale( 'en' );
+		locale = new Locale();
 		view = new EditorUIView( locale );
 
 		view.render();
@@ -46,6 +46,25 @@ describe( 'EditorUIView', () => {
 			expect( el.classList.contains( 'ck-rounded-corners' ) ).to.be.true;
 			expect( el.classList.contains( 'ck-reset_all' ) ).to.be.true;
 		} );
+
+		it( 'sets the right dir attribute to the body region (LTR)', () => {
+			const el = view._bodyCollectionContainer;
+
+			expect( el.getAttribute( 'dir' ) ).to.equal( 'ltr' );
+		} );
+
+		it( 'sets the right dir attribute to the body region (RTL)', () => {
+			const locale = new Locale( { uiLanguage: 'ar' } );
+			const view = new EditorUIView( locale );
+
+			view.render();
+
+			const el = view._bodyCollectionContainer;
+
+			expect( el.getAttribute( 'dir' ) ).to.equal( 'rtl' );
+
+			view.destroy();
+		} );
 	} );
 
 	describe( 'destroy()', () => {

+ 4 - 0
packages/ckeditor5-ui/tests/manual/blocktoolbar/blocktoolbar.html

@@ -56,4 +56,8 @@
 	.ck-block-toolbar-button {
 		transform: translateX( -10px );
 	}
+
+	[dir="rtl"] .ck-block-toolbar-button {
+		transform: translateX( 10px );
+	}
 </style>

+ 37 - 0
packages/ckeditor5-ui/tests/toolbar/block/blocktoolbar.js

@@ -345,6 +345,43 @@ describe( 'BlockToolbar', () => {
 			expect( blockToolbar.buttonView.left ).to.equal( 100 );
 		} );
 
+		it( 'should attach the left side of the button to the right side of the editable when language direction is RTL', () => {
+			editor.locale.uiLanguageDirection = 'rtl';
+
+			setData( editor.model, '<paragraph>foo[]bar</paragraph>' );
+
+			const target = editor.ui.getEditableElement().querySelector( 'p' );
+			const styleMock = testUtils.sinon.stub( window, 'getComputedStyle' );
+
+			styleMock.withArgs( target ).returns( {
+				lineHeight: 'normal',
+				fontSize: '20px',
+				paddingTop: '10px'
+			} );
+
+			styleMock.callThrough();
+
+			testUtils.sinon.stub( editor.ui.getEditableElement(), 'getBoundingClientRect' ).returns( {
+				left: 200,
+				right: 600
+			} );
+
+			testUtils.sinon.stub( target, 'getBoundingClientRect' ).returns( {
+				top: 500,
+				left: 300
+			} );
+
+			testUtils.sinon.stub( blockToolbar.buttonView.element, 'getBoundingClientRect' ).returns( {
+				width: 100,
+				height: 100
+			} );
+
+			editor.ui.fire( 'update' );
+
+			expect( blockToolbar.buttonView.top ).to.equal( 472 );
+			expect( blockToolbar.buttonView.left ).to.equal( 600 );
+		} );
+
 		it( 'should reposition the #panelView when open on ui#update', () => {
 			blockToolbar.panelView.isVisible = false;
 

+ 10 - 0
packages/ckeditor5-ui/theme/mixins/_dir.css

@@ -0,0 +1,10 @@
+/*
+ * Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+@define-mixin ck-dir $direction {
+	@nest [dir="$(direction)"] & {
+		@mixin-content;
+	}
+}