diff --git a/docs/features/blocktoolbar.md b/docs/features/blocktoolbar.md index 1a4ebbee..dbd9ba73 100644 --- a/docs/features/blocktoolbar.md +++ b/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 diff --git a/src/dropdown/dropdownview.js b/src/dropdown/dropdownview.js index a6cea036..909d2ceb 100644 --- a/src/dropdown/dropdownview.js +++ b/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; diff --git a/src/editableui/editableuiview.js b/src/editableui/editableuiview.js index fae5e0ef..0b9d0325 100644 --- a/src/editableui/editableuiview.js +++ b/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 } } ); diff --git a/src/editorui/boxed/boxededitoruiview.js b/src/editorui/boxed/boxededitoruiview.js index fc188312..89e94290 100644 --- a/src/editorui/boxed/boxededitoruiview.js +++ b/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 }` }, diff --git a/src/editorui/editoruiview.js b/src/editorui/editoruiview.js index b3048b3c..cadeb3b7 100644 --- a/src/editorui/editoruiview.js +++ b/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(); diff --git a/src/toolbar/block/blocktoolbar.js b/src/toolbar/block/blocktoolbar.js index 5d581736..5dead3ad 100644 --- a/src/toolbar/block/blocktoolbar.js +++ b/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 }; } ] diff --git a/tests/dropdown/dropdownview.js b/tests/dropdown/dropdownview.js index 40e3ffd6..68abd339 100644 --- a/tests/dropdown/dropdownview.js +++ b/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' + } ); + } ); + } ); +} ); diff --git a/tests/editableui/editableuiview.js b/tests/editableui/editableuiview.js index 7bb6e405..ba1ac73d 100644 --- a/tests/editableui/editableuiview.js +++ b/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(); diff --git a/tests/editorui/boxed/boxededitoruiview.js b/tests/editorui/boxed/boxededitoruiview.js index 2f7e5796..eec49b67 100644 --- a/tests/editorui/boxed/boxededitoruiview.js +++ b/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(); + } ); } ); } ); diff --git a/tests/editorui/editoruiview.js b/tests/editorui/editoruiview.js index dec30647..134d3822 100644 --- a/tests/editorui/editoruiview.js +++ b/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()', () => { diff --git a/tests/manual/blocktoolbar/blocktoolbar.html b/tests/manual/blocktoolbar/blocktoolbar.html index 596cebac..e296e10c 100644 --- a/tests/manual/blocktoolbar/blocktoolbar.html +++ b/tests/manual/blocktoolbar/blocktoolbar.html @@ -56,4 +56,8 @@

Confidence

.ck-block-toolbar-button { transform: translateX( -10px ); } + + [dir="rtl"] .ck-block-toolbar-button { + transform: translateX( 10px ); + } diff --git a/tests/toolbar/block/blocktoolbar.js b/tests/toolbar/block/blocktoolbar.js index 9e0ff794..2d92a82d 100644 --- a/tests/toolbar/block/blocktoolbar.js +++ b/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, 'foo[]bar' ); + + 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; diff --git a/theme/mixins/_dir.css b/theme/mixins/_dir.css new file mode 100644 index 00000000..4440b2fc --- /dev/null +++ b/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; + } +}