diff --git a/packages/ckeditor5-core/src/editor/editorconfig.jsdoc b/packages/ckeditor5-core/src/editor/editorconfig.jsdoc
index 33eea4a764c..a12de5103ac 100644
--- a/packages/ckeditor5-core/src/editor/editorconfig.jsdoc
+++ b/packages/ckeditor5-core/src/editor/editorconfig.jsdoc
@@ -176,6 +176,25 @@
* @member {Array.|Object} module:core/editor/editorconfig~EditorConfig#toolbar
*/
+/**
+ * The configuration of the editor language.
+ *
+ * ClassicEditor
+ * .create( document.querySelector( '#editor' ), {
+ * language: ... // Editor language configuration.
+ * } )
+ * .then( editor => {
+ * console.log( editor );
+ * } )
+ * .catch( error => {
+ * console.error( error );
+ * } );
+ *
+ * See {@link module:core/editor/editorconfig~EditorConfig all editor options}.
+ *
+ * @interface LanguageConfig
+ */
+
/**
* The language of the editor UI and its content.
*
@@ -197,7 +216,7 @@
* console.error( error );
* } );
*
- * Use different languages for the UI and the content using the object syntax:
+ * Use different languages for the UI and the content using the {@link module:core/editor/editorconfig~LanguageConfig configuration} syntax:
*
* ClassicEditor
* .create( document.querySelector( '#editor' ), {
@@ -231,7 +250,23 @@
*
* Check the {@glink features/ui-language UI language guide} for more information about the localization options and translation process.
*
- * @member {String|Object} module:core/editor/editorconfig~EditorConfig#language
+ * @member {String|module:core/editor/editorconfig~LanguageConfig} module:core/editor/editorconfig~EditorConfig#language
+ */
+
+/**
+ * Allows to use different language for the editor UI.
+ *
+ * The language codes are defined in the [ISO 639-1](https://en.wikipedia.org/wiki/ISO_639-1) standard.
+ *
+ * @member {String} module:core/editor/editorconfig~LanguageConfig#ui
+ */
+
+/**
+ * Allows to use different language of the editor content.
+ *
+ * The language codes are defined in the [ISO 639-1](https://en.wikipedia.org/wiki/ISO_639-1) standard.
+ *
+ * @member {String} module:core/editor/editorconfig~LanguageConfig#content
*/
/**
diff --git a/packages/ckeditor5-language/CONTRIBUTING.md b/packages/ckeditor5-language/CONTRIBUTING.md
new file mode 100644
index 00000000000..95e8a028382
--- /dev/null
+++ b/packages/ckeditor5-language/CONTRIBUTING.md
@@ -0,0 +1,4 @@
+Contributing
+========================================
+
+See the [official contributors' guide to CKEditor 5](https://ckeditor.com/docs/ckeditor5/latest/framework/guides/contributing/contributing.html) to learn more.
diff --git a/packages/ckeditor5-language/LICENSE.md b/packages/ckeditor5-language/LICENSE.md
new file mode 100644
index 00000000000..89e7a37ef1f
--- /dev/null
+++ b/packages/ckeditor5-language/LICENSE.md
@@ -0,0 +1,17 @@
+Software License Agreement
+==========================
+
+**CKEditor 5 Language feature** – https://github.com/ckeditor/ckeditor5-language
+Copyright (c) 2003-2021, [CKSource](http://cksource.com) Frederico Knabben. All rights reserved.
+
+Licensed under the terms of [GNU General Public License Version 2 or later](http://www.gnu.org/licenses/gpl.html).
+
+Sources of Intellectual Property Included in CKEditor
+-----------------------------------------------------
+
+Where not otherwise indicated, all CKEditor content is authored by CKSource engineers and consists of CKSource-owned intellectual property. In some specific instances, CKEditor will incorporate work done by developers outside of CKSource with their express permission.
+
+Trademarks
+----------
+
+**CKEditor** is a trademark of [CKSource](http://cksource.com) Frederico Knabben. All other brand and product names are trademarks, registered trademarks or service marks of their respective holders.
diff --git a/packages/ckeditor5-language/README.md b/packages/ckeditor5-language/README.md
new file mode 100644
index 00000000000..a2205b3881e
--- /dev/null
+++ b/packages/ckeditor5-language/README.md
@@ -0,0 +1,16 @@
+CKEditor 5 language feature
+========================================
+
+[![npm version](https://badge.fury.io/js/%40ckeditor%2Fckeditor5-language.svg)](https://www.npmjs.com/package/@ckeditor/ckeditor5-language)
+[![Dependency Status](https://david-dm.org/ckeditor/ckeditor5-language/status.svg)](https://david-dm.org/ckeditor/ckeditor5-language)
+[![devDependency Status](https://david-dm.org/ckeditor/ckeditor5-language/dev-status.svg)](https://david-dm.org/ckeditor/ckeditor5-language?type=dev)
+
+This package implements language support for CKEditor 5.
+
+## Documentation
+
+See the [`@ckeditor/ckeditor5-language` package](https://ckeditor.com/docs/ckeditor5/latest/api/language.html) page in [CKEditor 5 documentation](https://ckeditor.com/docs/ckeditor5/latest/).
+
+## License
+
+Licensed under the terms of [GNU General Public License Version 2 or later](http://www.gnu.org/licenses/gpl.html). For full details about the license, please check the `LICENSE.md` file or [https://ckeditor.com/legal/ckeditor-oss-license](https://ckeditor.com/legal/ckeditor-oss-license).
diff --git a/packages/ckeditor5-language/docs/api/language.md b/packages/ckeditor5-language/docs/api/language.md
new file mode 100644
index 00000000000..9f2cf24b95a
--- /dev/null
+++ b/packages/ckeditor5-language/docs/api/language.md
@@ -0,0 +1,26 @@
+---
+category: api-reference
+---
+
+# CKEditor 5 Language feature
+
+[![npm version](https://badge.fury.io/js/%40ckeditor%2Fckeditor5-language.svg)](https://www.npmjs.com/package/@ckeditor/ckeditor5-language)
+
+This package implements language features support for CKEditor 5.
+
+## Installation
+
+```
+npm install --save @ckeditor/ckeditor5-language
+```
+
+## Contribute
+
+The source code of this package is available on GitHub in https://github.com/ckeditor/ckeditor5/tree/master/packages/ckeditor5-language.
+
+## External links
+
+* [`@ckeditor/ckeditor5-language` on npm](https://www.npmjs.com/package/@ckeditor/ckeditor5-language)
+* [`ckeditor/ckeditor5-language` on GitHub](https://github.com/ckeditor/ckeditor5/tree/master/packages/ckeditor5-language)
+* [Issue tracker](https://github.com/ckeditor/ckeditor5/issues)
+* [Changelog](https://github.com/ckeditor/ckeditor5/blob/master/CHANGELOG.md)
diff --git a/packages/ckeditor5-language/lang/contexts.json b/packages/ckeditor5-language/lang/contexts.json
new file mode 100644
index 00000000000..6354c22ec7c
--- /dev/null
+++ b/packages/ckeditor5-language/lang/contexts.json
@@ -0,0 +1,5 @@
+{
+ "Language": "Toolbar button tooltip for the text part language feature.",
+ "Choose language": "Default label for the text part language dropdown.",
+ "Remove language": "The label of the remove language option for the text part language dropdown."
+}
diff --git a/packages/ckeditor5-language/package.json b/packages/ckeditor5-language/package.json
new file mode 100644
index 00000000000..ec12367fe49
--- /dev/null
+++ b/packages/ckeditor5-language/package.json
@@ -0,0 +1,49 @@
+{
+ "name": "@ckeditor/ckeditor5-language",
+ "version": "26.0.0",
+ "description": "Language feature for CKEditor 5.",
+ "keywords": [
+ "ckeditor",
+ "ckeditor5",
+ "ckeditor 5",
+ "ckeditor5-feature",
+ "ckeditor5-plugin"
+ ],
+ "main": "src/index.js",
+ "dependencies": {
+ "ckeditor5": "^26.0.0"
+ },
+ "devDependencies": {
+ "@ckeditor/ckeditor5-core": "^26.0.0",
+ "@ckeditor/ckeditor5-dev-utils": "^24.0.0",
+ "@ckeditor/ckeditor5-editor-classic": "^26.0.0",
+ "@ckeditor/ckeditor5-engine": "^26.0.0",
+ "@ckeditor/ckeditor5-paragraph": "^26.0.0",
+ "@ckeditor/ckeditor5-theme-lark": "^26.0.0",
+ "@ckeditor/ckeditor5-ui": "^26.0.0",
+ "webpack": "^4.43.0",
+ "webpack-cli": "^3.3.11"
+ },
+ "engines": {
+ "node": ">=12.0.0",
+ "npm": ">=5.7.1"
+ },
+ "author": "CKSource (http://cksource.com/)",
+ "license": "GPL-2.0-or-later",
+ "homepage": "https://ckeditor.com/ckeditor-5",
+ "bugs": "https://github.com/ckeditor/ckeditor5/issues",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/ckeditor/ckeditor5.git",
+ "directory": "packages/ckeditor5-language"
+ },
+ "files": [
+ "lang",
+ "src",
+ "theme",
+ "build"
+ ],
+ "scripts": {
+ "dll:build": "webpack"
+ }
+}
diff --git a/packages/ckeditor5-language/src/index.js b/packages/ckeditor5-language/src/index.js
new file mode 100644
index 00000000000..c8efa458dbe
--- /dev/null
+++ b/packages/ckeditor5-language/src/index.js
@@ -0,0 +1,18 @@
+/**
+ * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module language
+ */
+
+import TextPartLanguage from './textpartlanguage';
+import TextPartLanguageEditing from './textpartlanguageediting';
+import TextPartLanguageUI from './textpartlanguageui';
+
+export default {
+ TextPartLanguage,
+ TextPartLanguageEditing,
+ TextPartLanguageUI
+};
diff --git a/packages/ckeditor5-language/src/textpartlanguage.js b/packages/ckeditor5-language/src/textpartlanguage.js
new file mode 100644
index 00000000000..c05b35ac1a8
--- /dev/null
+++ b/packages/ckeditor5-language/src/textpartlanguage.js
@@ -0,0 +1,85 @@
+/**
+ * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module language/textpartlanguage
+ */
+
+import { Plugin } from 'ckeditor5/src/core';
+
+import TextPartLanguageEditing from './textpartlanguageediting';
+import TextPartLanguageUI from './textpartlanguageui';
+
+/**
+ * The text part language feature.
+ *
+ * This feature allows setting a language of the editor's text part to support
+ * [WCAG 3.1.2 Language of Parts](https://www.w3.org/TR/UNDERSTANDING-WCAG20/meaning-other-lang-id.html) specification.
+ *
+ * To change UI editor language, refer to {@glink features/ui-language setting the UI language} guide.
+ *
+ * For more information about this feature check the {@glink api/language package page}.
+ *
+ * This is a "glue" plugin which loads the
+ * {@link module:language/textpartlanguageediting~TextPartLanguageEditing text part editing feature}
+ * and {@link module:language/textpartlanguageui~TextPartLanguageUI text part language UI feature}.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class TextPartLanguage extends Plugin {
+ /**
+ * @inheritDoc
+ */
+ static get requires() {
+ return [ TextPartLanguageEditing, TextPartLanguageUI ];
+ }
+
+ /**
+ * @inheritDoc
+ */
+ static get pluginName() {
+ return 'TextPartLanguage';
+ }
+}
+
+/**
+ * The available {@link module:language/textpartlanguage~TextPartLanguage}
+ * options allowing setting language of parts of the content.
+ *
+ * This configuration option is available only with {@glink api/language language feature} enabled.
+ *
+ * Refer to [WCAG 3.1.2 Language of Parts](https://www.w3.org/TR/UNDERSTANDING-WCAG20/meaning-other-lang-id.html) specification
+ * to learn more.
+ *
+ * To change UI editor language, refer to {@glink features/ui-language setting the UI language} guide.
+ *
+ * The default value is:
+ *
+ * const config = [
+ * { title: 'Arabic', languageCode: 'ar' },
+ * { title: 'French', languageCode: 'fr' },
+ * { title: 'Spanish', languageCode: 'es' }
+ * ];
+ *
+ * The `title` property will be used by the text part language dropdown to render available options.
+ *
+ * The `languageCode` property is used for the lang attribute in [ISO 639-1](https://en.wikipedia.org/wiki/ISO_639-1) format.
+ *
+ * You can also specify optional `textDirection` property indicating the reading direction of the language.
+ * Correct values are `ltr` and `rtl`. When `textDirection` property is missing, the text part language feature will
+ * specify text direction by itself.
+ *
+ * @member {Array.}
+ * module:core/editor/editorconfig~LanguageConfig#textPartLanguage
+ */
+
+/**
+ * Text part language feature option descriptor.
+ *
+ * @typedef {Object} module:language/textpartlanguage~TextPartLanguageOption
+ * @property {String} title The user-readable title of the option.
+ * @property {String} languageCode The language code in ISO 639 format.
+ * @property {'ltr'|'rtl'} [textDirection] The language text direction. Automatically detected if omitted.
+ */
diff --git a/packages/ckeditor5-language/src/textpartlanguagecommand.js b/packages/ckeditor5-language/src/textpartlanguagecommand.js
new file mode 100644
index 00000000000..c277697d682
--- /dev/null
+++ b/packages/ckeditor5-language/src/textpartlanguagecommand.js
@@ -0,0 +1,123 @@
+/**
+ * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module language/textpartlanguagecommand
+ */
+
+import { Command } from 'ckeditor5/src/core';
+import { stringifyLanguageAttribute } from './utils';
+
+/**
+ * The text part language command plugin.
+ *
+ * @extends module:core/command~Command
+ */
+export default class TextPartLanguageCommand extends Command {
+ /**
+ * If the selection starts in a language attribute the value is set to
+ * the value of that language in a format:
+ *
+ * :
+ *
+ * * `languageCode` - The language code used for the lang attribute in [ISO 639-1](https://en.wikipedia.org/wiki/ISO_639-1) format.
+ * * `textDirection` - One of the following values: `rtl` or `ltr`, indicating the reading direction of the language.
+ *
+ * See {@link module:core/editor/editorconfig~LanguageConfig#textPartLanguage text part language config}
+ * for more information about language properties.
+ *
+ * It is set to `false` otherwise.
+ *
+ * @observable
+ * @readonly
+ * @member {Boolean|String} #value
+ */
+
+ /**
+ * @inheritDoc
+ */
+ refresh() {
+ const model = this.editor.model;
+ const doc = model.document;
+
+ this.value = this._getValueFromFirstAllowedNode();
+ this.isEnabled = model.schema.checkAttributeInSelection( doc.selection, 'language' );
+ }
+
+ /**
+ * Executes the command. Applies the attribute to the selection or removes it from the selection.
+ *
+ * If `languageCode` is set to `false` or `null` value, it will remove attributes. Otherwise, it will set
+ * attribute in `{@link #value value}` format.
+ *
+ * The execution result differs, depending on the {@link module:engine/model/document~Document#selection}:
+ *
+ * * If the selection is on a range, the command applies the attribute to all nodes in that range
+ * (if they are allowed to have this attribute by the {@link module:engine/model/schema~Schema schema}).
+ * * If the selection is collapsed in a non-empty node, the command applies the attribute to the
+ * {@link module:engine/model/document~Document#selection} itself (note that typed characters copy attributes from the selection).
+ * * If the selection is collapsed in an empty node, the command applies the attribute to the parent node of the selection (note
+ * that the selection inherits all attributes from a node if it is in an empty node).
+ *
+ * @fires execute
+ * @param {Object} [options] Command options.
+ * @param {String|Boolean} [options.languageCode] Language code to be applied to the model.
+ * @param {String} [options.textDirection] Language text direction.
+ */
+ execute( { languageCode, textDirection } = {} ) {
+ const model = this.editor.model;
+ const doc = model.document;
+ const selection = doc.selection;
+
+ const value = languageCode ? stringifyLanguageAttribute( languageCode, textDirection ) : false;
+
+ model.change( writer => {
+ if ( selection.isCollapsed ) {
+ if ( value ) {
+ writer.setSelectionAttribute( 'language', value );
+ } else {
+ writer.removeSelectionAttribute( 'language' );
+ }
+ } else {
+ const ranges = model.schema.getValidRanges( selection.getRanges(), 'language' );
+
+ for ( const range of ranges ) {
+ if ( value ) {
+ writer.setAttribute( 'language', value, range );
+ } else {
+ writer.removeAttribute( 'language', range );
+ }
+ }
+ }
+ } );
+ }
+
+ /**
+ * Returns the attribute value of the first node in the selection that allows the attribute.
+ * For the collapsed selection returns the selection attribute.
+ *
+ * @private
+ * @returns {Boolean|String} The attribute value.
+ */
+ _getValueFromFirstAllowedNode() {
+ const model = this.editor.model;
+ const schema = model.schema;
+ const selection = model.document.selection;
+
+ if ( selection.isCollapsed ) {
+ return selection.getAttribute( 'language' ) || false;
+ }
+
+ for ( const range of selection.getRanges() ) {
+ for ( const item of range.getItems() ) {
+ if ( schema.checkAttribute( item, 'language' ) ) {
+ return item.getAttribute( 'language' ) || false;
+ }
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/packages/ckeditor5-language/src/textpartlanguageediting.js b/packages/ckeditor5-language/src/textpartlanguageediting.js
new file mode 100644
index 00000000000..2791a33f36f
--- /dev/null
+++ b/packages/ckeditor5-language/src/textpartlanguageediting.js
@@ -0,0 +1,101 @@
+/**
+ * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module language/textpartlanguageediting
+ */
+
+import { Plugin } from 'ckeditor5/src/core';
+import TextPartLanguageCommand from './textpartlanguagecommand';
+import { stringifyLanguageAttribute, parseLanguageAttribute } from './utils';
+
+/**
+ * The text part language editing.
+ *
+ * Introduces the `'textPartLanguage'` command and the `'language'` model element attribute.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class TextPartLanguageEditing extends Plugin {
+ /**
+ * @inheritDoc
+ */
+ static get pluginName() {
+ return 'TextPartLanguageEditing';
+ }
+
+ /**
+ * @inheritDoc
+ */
+ constructor( editor ) {
+ super( editor );
+
+ // Text part language options are only used to ensure that the feature works by default.
+ // In the real usage it should be reconfigured by a developer. We are not providing
+ // translations for `title` properties on purpose, as it's only an example configuration.
+ editor.config.define( 'language', {
+ textPartLanguage: [
+ { title: 'Arabic', languageCode: 'ar' },
+ { title: 'French', languageCode: 'fr' },
+ { title: 'Spanish', languageCode: 'es' }
+ ]
+ } );
+ }
+
+ /**
+ * @inheritDoc
+ */
+ init() {
+ const editor = this.editor;
+
+ editor.model.schema.extend( '$text', { allowAttributes: 'language' } );
+ editor.model.schema.setAttributeProperties( 'language', {
+ copyOnEnter: true
+ } );
+
+ this._defineConverters();
+
+ editor.commands.add( 'textPartLanguage', new TextPartLanguageCommand( editor ) );
+ }
+
+ /**
+ * @private
+ */
+ _defineConverters() {
+ const conversion = this.editor.conversion;
+
+ conversion.for( 'upcast' ).elementToAttribute( {
+ model: {
+ key: 'language',
+ value: viewElement => {
+ const languageCode = viewElement.getAttribute( 'lang' );
+ const textDirection = viewElement.getAttribute( 'dir' );
+
+ return stringifyLanguageAttribute( languageCode, textDirection );
+ }
+ },
+ view: {
+ name: 'span',
+ attributes: { lang: /[\s\S]+/ }
+ }
+ } );
+
+ conversion.for( 'downcast' ).attributeToElement( {
+ model: 'language',
+ view: ( attributeValue, { writer } ) => {
+ if ( !attributeValue ) {
+ return;
+ }
+
+ const { languageCode, textDirection } = parseLanguageAttribute( attributeValue );
+
+ return writer.createAttributeElement( 'span', {
+ lang: languageCode,
+ dir: textDirection
+ } );
+ }
+ } );
+ }
+}
diff --git a/packages/ckeditor5-language/src/textpartlanguageui.js b/packages/ckeditor5-language/src/textpartlanguageui.js
new file mode 100644
index 00000000000..3607b632d1f
--- /dev/null
+++ b/packages/ckeditor5-language/src/textpartlanguageui.js
@@ -0,0 +1,119 @@
+/**
+ * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module language/textpartlanguageui
+ */
+
+import { Plugin } from 'ckeditor5/src/core';
+import { Model, createDropdown, addListToDropdown } from 'ckeditor5/src/ui';
+import { Collection } from 'ckeditor5/src/utils';
+import { stringifyLanguageAttribute } from './utils';
+
+import '../theme/language.css';
+
+/**
+ * The text part language UI plugin.
+ *
+ * It introduces the `'language'` dropdown.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class TextPartLanguageUI extends Plugin {
+ /**
+ * @inheritDoc
+ */
+ static get pluginName() {
+ return 'TextPartLanguageUI';
+ }
+
+ /**
+ * @inheritDoc
+ */
+ init() {
+ const editor = this.editor;
+ const t = editor.t;
+ const options = editor.config.get( 'language.textPartLanguage' );
+ const defaultTitle = t( 'Choose language' );
+ const removeTitle = t( 'Remove language' );
+ const dropdownTooltip = t( 'Language' );
+
+ // Register UI component.
+ editor.ui.componentFactory.add( 'textPartLanguage', locale => {
+ const itemDefinitions = new Collection();
+ const titles = {};
+
+ const languageCommand = editor.commands.get( 'textPartLanguage' );
+
+ for ( const option of options ) {
+ const def = {
+ type: 'button',
+ model: new Model( {
+ label: option.title,
+ languageCode: option.languageCode,
+ textDirection: option.textDirection,
+ withText: true
+ } )
+ };
+
+ const language = stringifyLanguageAttribute( option.languageCode, option.textDirection );
+
+ def.model.bind( 'isOn' ).to( languageCommand, 'value', value => value === language );
+
+ itemDefinitions.add( def );
+
+ titles[ language ] = option.title;
+ }
+
+ itemDefinitions.add( {
+ type: 'separator'
+ } );
+
+ // Item definition with false `languageCode` will behave as remove lang button.
+ itemDefinitions.add( {
+ type: 'button',
+ model: new Model( {
+ label: removeTitle,
+ languageCode: false,
+ withText: true
+ } )
+ } );
+
+ const dropdownView = createDropdown( locale );
+ addListToDropdown( dropdownView, itemDefinitions );
+
+ dropdownView.buttonView.set( {
+ isOn: false,
+ withText: true,
+ tooltip: dropdownTooltip
+ } );
+
+ dropdownView.extendTemplate( {
+ attributes: {
+ class: [
+ 'ck-text-fragment-language-dropdown'
+ ]
+ }
+ } );
+
+ dropdownView.bind( 'isEnabled' ).to( languageCommand, 'isEnabled' );
+ dropdownView.buttonView.bind( 'label' ).to( languageCommand, 'value', value => {
+ return titles[ value ] || defaultTitle;
+ } );
+
+ // Execute command when an item from the dropdown is selected.
+ this.listenTo( dropdownView, 'execute', evt => {
+ languageCommand.execute( {
+ languageCode: evt.source.languageCode,
+ textDirection: evt.source.textDirection
+ } );
+
+ editor.editing.view.focus();
+ } );
+
+ return dropdownView;
+ } );
+ }
+}
diff --git a/packages/ckeditor5-language/src/utils.js b/packages/ckeditor5-language/src/utils.js
new file mode 100644
index 00000000000..f50e4f2e602
--- /dev/null
+++ b/packages/ckeditor5-language/src/utils.js
@@ -0,0 +1,47 @@
+/**
+ * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module language/utils
+ */
+
+import { getLanguageDirection } from 'ckeditor5/src/utils';
+
+/**
+ * Returns language attribute value in a human-readable text format:
+ *
+ * :
+ *
+ * * `languageCode` - The language code used for the lang attribute in [ISO 639-1](https://en.wikipedia.org/wiki/ISO_639-1) format.
+ * * `textDirection` - One of the following values: `rtl` or `ltr`, indicating the reading direction of the language.
+ *
+ * See {@link module:core/editor/editorconfig~LanguageConfig#textPartLanguage text part config}
+ * for more information about language properties.
+ *
+ * If `textDirection` argument is omitted, it will be automatically detected based on `languageCode`.
+ *
+ * @param {String} languageCode The language code in ISO 639-1 format.
+ * @param {'ltr'|'rtl'} [textDirection] Language text direction. Automatically detected if omitted.
+ * @returns {String}
+ */
+export function stringifyLanguageAttribute( languageCode, textDirection ) {
+ textDirection = textDirection || getLanguageDirection( languageCode );
+ return `${ languageCode }:${ textDirection }`;
+}
+
+/**
+ * Retrieves language properties converted to attribute value by
+ * {@link module:language/utils~stringifyLanguageAttribute stringifyLanguageAttribute} function.
+ *
+ * @param {String} str Attribute value.
+ * @returns {Object} result
+ * @returns {String} result.languageCode The language code in ISO 639 format.
+ * @returns {String} result.textDirection Language text direction.
+ */
+export function parseLanguageAttribute( str ) {
+ const [ languageCode, textDirection ] = str.split( ':' );
+
+ return { languageCode, textDirection };
+}
diff --git a/packages/ckeditor5-language/tests/manual/textpartlanguage.html b/packages/ckeditor5-language/tests/manual/textpartlanguage.html
new file mode 100644
index 00000000000..542973d48f9
--- /dev/null
+++ b/packages/ckeditor5-language/tests/manual/textpartlanguage.html
@@ -0,0 +1,27 @@
+
+
+ Language is the human ability to acquire and use complex systems of communication,
+ and a language is any specific example of such a system. The scientific study of language
+ is called linguistics.
+
+
+
+ Un lenguaje (del provenzal lenguatge y este del latín lingua) es un sistema de comunicación
+ estructurado para el que existe un contexto de uso y ciertos principios combinatorios formales. Existen
+ contextos tanto naturales como artificiales.
+
+
+
+
+ اللغة نسق من الإشارات والرموز، يشكل أداة من أدوات المعرفة، وتعتبر اللغة أهم وسائل التفاهم
+ والاحتكاك بين أفراد امجتمع في جميع ميادين الحياة. وبدون اللغة يتعذر نشاط الناس المعرفي.
+
+
+
+
+ La langue est la capacité humaine à acquérir et à utiliser des systèmes complexes de communication,
+ et une langue est un exemple spécifique d'un tel système. L'étude scientifique de la langue
+ s'appelle la linguistique.
+
+
+
diff --git a/packages/ckeditor5-language/tests/manual/textpartlanguage.js b/packages/ckeditor5-language/tests/manual/textpartlanguage.js
new file mode 100644
index 00000000000..2a00e82cdf3
--- /dev/null
+++ b/packages/ckeditor5-language/tests/manual/textpartlanguage.js
@@ -0,0 +1,25 @@
+/**
+ * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* global document, console, window */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
+import TextPartLanguage from '../../src/textpartlanguage';
+
+ClassicEditor
+ .create( document.querySelector( '#editor' ), {
+ plugins: [
+ ArticlePluginSet,
+ TextPartLanguage
+ ],
+ toolbar: [ 'textPartLanguage', '|', 'bold', 'italic', 'link', 'bulletedList', 'numberedList', 'blockQuote', 'undo', 'redo' ]
+ } )
+ .then( editor => {
+ window.editor = editor;
+ } )
+ .catch( err => {
+ console.error( err.stack );
+ } );
diff --git a/packages/ckeditor5-language/tests/manual/textpartlanguage.md b/packages/ckeditor5-language/tests/manual/textpartlanguage.md
new file mode 100644
index 00000000000..8798aa43a9f
--- /dev/null
+++ b/packages/ckeditor5-language/tests/manual/textpartlanguage.md
@@ -0,0 +1,8 @@
+## Text part language feature
+
+1. The data should be loaded with four paragraph. Each paragraph style with applied language should be italic.
+2. Put selection in each paragraph and check if language dropdown label is changing properly.
+3. Play with languages:
+ * Change language of the text selection.
+ * Put selection that spans across multiple blocks and switch languages.
+ * Remove language from selection.
diff --git a/packages/ckeditor5-language/tests/textpartlanguage.js b/packages/ckeditor5-language/tests/textpartlanguage.js
new file mode 100644
index 00000000000..2635237b635
--- /dev/null
+++ b/packages/ckeditor5-language/tests/textpartlanguage.js
@@ -0,0 +1,18 @@
+/**
+ * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import TextPartLanguage from '../src/textpartlanguage';
+import TextPartLanguageEditing from '../src/textpartlanguageediting';
+import TextPartLanguageUI from '../src/textpartlanguageui';
+
+describe( 'TextPartLanguage', () => {
+ it( 'should require TextPartLanguageEditing and TextPartLanguageUI', () => {
+ expect( TextPartLanguage.requires ).to.deep.equal( [ TextPartLanguageEditing, TextPartLanguageUI ] );
+ } );
+
+ it( 'should be named', () => {
+ expect( TextPartLanguage.pluginName ).to.equal( 'TextPartLanguage' );
+ } );
+} );
diff --git a/packages/ckeditor5-language/tests/textpartlanguagecommand.js b/packages/ckeditor5-language/tests/textpartlanguagecommand.js
new file mode 100644
index 00000000000..662945e9ab6
--- /dev/null
+++ b/packages/ckeditor5-language/tests/textpartlanguagecommand.js
@@ -0,0 +1,388 @@
+/**
+ * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+import TextPartLanguageCommand from '../src/textpartlanguagecommand';
+
+describe( 'TextPartLanguageCommand', () => {
+ let editor, command, model, doc, root;
+
+ testUtils.createSinonSandbox();
+
+ beforeEach( () => {
+ return ModelTestEditor
+ .create()
+ .then( newEditor => {
+ editor = newEditor;
+ model = editor.model;
+ doc = model.document;
+ root = doc.getRoot();
+
+ command = new TextPartLanguageCommand( editor );
+
+ model.schema.register( 'p', { inheritAllFrom: '$block' } );
+ model.schema.register( 'h1', { inheritAllFrom: '$block' } );
+ model.schema.register( 'img', {
+ allowWhere: [ '$block', '$text' ],
+ isObject: true
+ } );
+
+ model.schema.addAttributeCheck( ( ctx, attributeName ) => {
+ // Allow 'language' on p>$text.
+ if ( ctx.endsWith( 'p $text' ) && attributeName == 'language' ) {
+ return true;
+ }
+ } );
+ } );
+ } );
+
+ afterEach( () => {
+ command.destroy();
+
+ return editor.destroy();
+ } );
+
+ describe( 'value', () => {
+ it( 'includes language when collapsed selection has the attribute', () => {
+ model.change( writer => {
+ writer.setSelectionAttribute( 'language', 'fr:ltr' );
+ } );
+
+ expect( command.value ).to.equal( 'fr:ltr' );
+ } );
+
+ it( 'is false when collapsed selection does not have the attribute', () => {
+ model.change( writer => {
+ writer.setSelectionAttribute( 'language', 'fr:ltr' );
+ } );
+
+ model.change( writer => {
+ writer.removeSelectionAttribute( 'language' );
+ } );
+
+ expect( command.value ).to.be.false;
+ } );
+
+ it( 'includes language when the first item that allows attribute has the attribute #1', () => {
+ setData( model, '<$text language="fr:ltr">fo[o$text>
b]ar
' );
+
+ expect( command.value ).to.equal( 'fr:ltr' );
+ } );
+
+ it( 'includes language when the first item that allows attribute has the attribute #2', () => {
+ setData( model, 'fo[o
<$text language="fr:ltr">f$text>o]o
' );
+
+ expect( command.value ).to.equal( 'fr:ltr' );
+ } );
+
+ it( 'is false when the selection does not have the attribute', () => {
+ setData( model, '[foo]bar
' );
+
+ expect( command.value ).to.be.false;
+ } );
+
+ it( 'is false when the first item that allows attribute does not have the attribute #1', () => {
+ setData( model, 'b[a<$text language="fr:ltr">r$text>
fo]o
' );
+
+ expect( command.value ).to.be.false;
+ } );
+
+ it( 'is false when the first item that allows attribute does not have the attribute #2', () => {
+ setData( model, 'fo[o
b<$text language="fr:ltr">r$text>r]
' );
+
+ expect( command.value ).to.be.false;
+ } );
+
+ it( 'includes language when the first item that allows attribute has the attribute - object with nested editable', () => {
+ model.schema.register( 'caption', {
+ allowContentOf: '$block',
+ allowIn: 'img',
+ isLimit: true
+ } );
+ model.schema.extend( '$text', {
+ allowIn: 'caption',
+ allowAttributes: 'language'
+ } );
+
+ setData( model, '[
Some caption inside the image.]
' );
+
+ expect( command.value ).to.be.false;
+ command.execute( { languageCode: 'fr', textDirection: 'ltr' } );
+
+ expect( command.value ).to.equal( 'fr:ltr' );
+
+ expect( getData( model ) ).to.equal(
+ '[
<$text language="fr:ltr">Some caption inside the image.$text>]'
+ );
+ } );
+ } );
+
+ describe( 'isEnabled', () => {
+ // This test doesn't tests every possible case.
+ // Method `refresh()` uses `checkAttributeInSelection()` which is fully tested in its own test.
+
+ beforeEach( () => {
+ model.schema.register( 'x', { inheritAllFrom: '$block' } );
+ } );
+
+ describe( 'when selection is collapsed', () => {
+ it( 'should return true if characters with the attribute can be placed at caret position', () => {
+ setData( model, 'f[]oo
' );
+ expect( command.isEnabled ).to.be.true;
+ } );
+
+ it( 'should return false if characters with the attribute cannot be placed at caret position', () => {
+ setData( model, 'fo[]o' );
+ expect( command.isEnabled ).to.be.false;
+ } );
+ } );
+
+ describe( 'when selection is not collapsed', () => {
+ it( 'should return true if there is at least one node in selection that can have the attribute', () => {
+ setData( model, '[foo]
' );
+ expect( command.isEnabled ).to.be.true;
+ } );
+
+ it( 'should return false if there are no nodes in selection that can have the attribute', () => {
+ setData( model, '[foo]' );
+ expect( command.isEnabled ).to.be.false;
+ } );
+ } );
+
+ it( 'should be disabled in a readonly mode', () => {
+ editor.isReadOnly = true;
+ setData( model, 'f[]oo
' );
+ expect( command.isEnabled ).to.be.false;
+ } );
+ } );
+
+ describe( 'execute()', () => {
+ it( 'should do nothing if the command is disabled', () => {
+ setData( model, 'fo[ob]ar
' );
+
+ command.isEnabled = false;
+
+ command.execute( { languageCode: 'fr', textDirection: 'ltr' } );
+
+ expect( getData( model ) ).to.equal( 'fo[ob]ar
' );
+ } );
+
+ it( 'should add attribute on selected nodes if the command value was not set', () => {
+ setData( model, 'a[bc<$text language="fr:ltr">fo]obar$text>xyz
' );
+
+ expect( command.value ).to.be.false;
+
+ command.execute( { languageCode: 'fr', textDirection: 'ltr' } );
+
+ expect( command.value ).to.equal( 'fr:ltr' );
+ expect( getData( model ) ).to.equal( 'a[<$text language="fr:ltr">bcfo]obar$text>xyz
' );
+ } );
+
+ it( 'should remove attribute from selected nodes if the command value was not set', () => {
+ setData( model, 'abc[<$text language="fr:ltr">foo]bar$text>xyz
' );
+
+ expect( command.value ).to.equal( 'fr:ltr' );
+
+ command.execute();
+
+ expect( getData( model ) ).to.equal( 'abc[foo]<$text language="fr:ltr">bar$text>xyz
' );
+ expect( command.value ).to.be.false;
+ } );
+
+ it( 'should replace attribute on selected nodes if execute parameter was set', () => {
+ setData( model, 'abc<$text language="fr:ltr">foob[ar$text>x]yz
' );
+
+ expect( command.value ).to.equal( 'fr:ltr' );
+
+ command.execute( { languageCode: 'ar', textDirection: 'rtl' } );
+
+ expect( command.value ).to.equal( 'ar:rtl' );
+ expect( getData( model ) ).to.equal(
+ 'abc<$text language="fr:ltr">foob$text>[<$text language="ar:rtl">arx$text>]yz
'
+ );
+ } );
+
+ it( 'should remove attribute on selected nodes if execute parameter was set to false', () => {
+ setData( model, 'a[bc<$text language="fr:ltr">fo]obar$text>xyz
' );
+
+ command.execute( { languageCode: false } );
+
+ expect( command.value ).to.be.false;
+ expect( getData( model ) ).to.equal( 'a[bcfo]<$text language="fr:ltr">obar$text>xyz
' );
+ } );
+
+ it( 'should remove attribute on selected nodes if execute parameter was set to null', () => {
+ setData( model, 'a[bc<$text language="fr:ltr">fo]obar$text>xyz
' );
+
+ command.execute( { languageCode: null } );
+
+ expect( command.value ).to.be.false;
+ expect( getData( model ) ).to.equal( 'a[bcfo]<$text language="fr:ltr">obar$text>xyz
' );
+ } );
+
+ it( 'should change selection attribute if selection is collapsed in non-empty parent', () => {
+ setData( model, 'a[]bc<$text language="fr:ltr">foobar$text>xyz
' );
+
+ expect( command.value ).to.be.false;
+
+ command.execute( { languageCode: 'ar', textDirection: 'rtl' } );
+
+ expect( command.value ).to.equal( 'ar:rtl' );
+ expect( doc.selection.getAttribute( 'language' ) ).to.equal( 'ar:rtl' );
+
+ command.execute( { languageCode: false } );
+
+ expect( command.value ).to.be.false;
+ expect( doc.selection.hasAttribute( 'language' ) ).to.be.false;
+ } );
+
+ it( 'should not store attribute change on selection if selection is collapsed in non-empty parent', () => {
+ setData( model, 'a[]bc<$text language="fr:ltr">foobar$text>xyz
' );
+
+ command.execute( { languageCode: 'ar', textDirection: 'rtl' } );
+
+ // It should not save that language was executed at position ( root, [ 0, 1 ] ).
+
+ model.change( writer => {
+ // Simulate clicking right arrow key by changing selection ranges.
+ writer.setSelection( writer.createRange(
+ writer.createPositionAt( root.getNodeByPath( [ 0 ] ), 2 )
+ ) );
+
+ // Get back to previous selection.
+ writer.setSelection( writer.createRange(
+ writer.createPositionAt( root.getNodeByPath( [ 0 ] ), 1 )
+ ) );
+ } );
+
+ expect( command.value ).to.be.false;
+ } );
+
+ it( 'should change selection attribute and store it if selection is collapsed in empty parent', () => {
+ setData( model, 'abc<$text language="fr:ltr">foobar$text>xyz
[]
' );
+
+ expect( command.value ).to.be.false;
+
+ command.execute( { languageCode: 'ar', textDirection: 'rtl' } );
+
+ expect( command.value ).to.equal( 'ar:rtl' );
+ expect( doc.selection.getAttribute( 'language' ) ).to.equal( 'ar:rtl' );
+
+ // Attribute should be stored.
+ // Simulate clicking somewhere else in the editor.
+ model.change( writer => {
+ writer.setSelection( root.getNodeByPath( [ 0 ] ), 2 );
+ } );
+
+ expect( command.value ).to.be.false;
+
+ // Go back to where attribute was stored.
+ model.change( writer => {
+ writer.setSelection( root.getNodeByPath( [ 1 ] ), 0 );
+ } );
+
+ // Attribute should be restored.
+ expect( command.value ).to.equal( 'ar:rtl' );
+
+ command.execute( { languageCode: false } );
+
+ expect( command.value ).to.be.false;
+ expect( doc.selection.hasAttribute( 'language' ) ).to.be.false;
+ } );
+
+ it( 'should force language text direction if textDirection was set', () => {
+ setData( model, 'x[]y
' );
+
+ command.execute( { languageCode: 'ar', textDirection: 'ltr' } );
+
+ expect( command.value ).to.equal( 'ar:ltr' );
+ expect( doc.selection.getAttribute( 'language' ) ).to.equal( 'ar:ltr' );
+ } );
+
+ it( 'should detect language text direction if textDirection was not set', () => {
+ setData( model, 'x[]y
' );
+
+ command.execute( { languageCode: 'ar' } );
+
+ expect( command.value ).to.equal( 'ar:rtl' );
+ expect( doc.selection.getAttribute( 'language' ) ).to.equal( 'ar:rtl' );
+ } );
+
+ describe( 'model change event', () => {
+ let spy;
+
+ beforeEach( () => {
+ spy = sinon.spy();
+ } );
+
+ describe( 'should be fired when execute parameter was set to language', () => {
+ it( 'collapsed selection in non-empty parent', () => {
+ setData( model, 'x[]y
' );
+
+ model.document.on( 'change', spy );
+
+ command.execute( { languageCode: 'fr' } );
+
+ expect( spy.called ).to.be.true;
+ } );
+
+ it( 'non-collapsed selection', () => {
+ setData( model, '[xy]
' );
+
+ model.document.on( 'change', spy );
+
+ command.execute( { languageCode: 'fr' } );
+
+ expect( spy.called ).to.be.true;
+ } );
+
+ it( 'in empty parent', () => {
+ setData( model, '[]
' );
+
+ model.document.on( 'change', spy );
+
+ command.execute( { languageCode: 'fr' } );
+
+ expect( spy.called ).to.be.true;
+ } );
+ } );
+
+ describe( 'should not be fired when execute parameter was set to false', () => {
+ it( 'collapsed selection in non-empty parent', () => {
+ setData( model, 'x[]y
' );
+
+ model.document.on( 'change', spy );
+
+ command.execute( { languageCode: false } );
+
+ expect( spy.called ).to.be.false;
+ } );
+
+ it( 'non-collapsed selection', () => {
+ setData( model, '[xy]
' );
+
+ model.document.on( 'change', spy );
+
+ command.execute( { languageCode: false } );
+
+ expect( spy.called ).to.be.false;
+ } );
+
+ it( 'in empty parent', () => {
+ setData( model, '[]
' );
+
+ model.document.on( 'change', spy );
+
+ command.execute( { languageCode: false } );
+
+ expect( spy.called ).to.be.false;
+ } );
+ } );
+ } );
+ } );
+} );
diff --git a/packages/ckeditor5-language/tests/textpartlanguageediting.js b/packages/ckeditor5-language/tests/textpartlanguageediting.js
new file mode 100644
index 00000000000..0d3a829b11a
--- /dev/null
+++ b/packages/ckeditor5-language/tests/textpartlanguageediting.js
@@ -0,0 +1,125 @@
+/**
+ * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
+
+import TextPartLanguageEditing from '../src/textpartlanguageediting';
+import TextPartLanguageCommand from '../src/textpartlanguagecommand';
+
+describe( 'TextPartLanguageEditing', () => {
+ let editor, model;
+
+ beforeEach( () => {
+ return VirtualTestEditor
+ .create( {
+ plugins: [ TextPartLanguageEditing, Paragraph ]
+ } )
+ .then( newEditor => {
+ editor = newEditor;
+ model = editor.model;
+ } );
+ } );
+
+ afterEach( async () => {
+ await editor.destroy();
+ } );
+
+ it( 'should have pluginName', () => {
+ expect( TextPartLanguageEditing.pluginName ).to.equal( 'TextPartLanguageEditing' );
+ } );
+
+ it( 'should be loaded', () => {
+ expect( editor.plugins.get( TextPartLanguageEditing ) ).to.be.instanceOf( TextPartLanguageEditing );
+ } );
+
+ it( 'should set proper schema rules', () => {
+ expect( model.schema.checkAttribute( [ '$root', '$block', '$text' ], 'language' ) ).to.be.true;
+ expect( model.schema.checkAttribute( [ '$clipboardHolder', '$text' ], 'language' ) ).to.be.true;
+ } );
+
+ it( 'its attribute is marked with a copyOnEnter property', () => {
+ expect( model.schema.getAttributeProperties( 'language' ) ).to.include( {
+ copyOnEnter: true
+ } );
+ } );
+
+ describe( 'command', () => {
+ it( 'should register textPartLanguage command', () => {
+ const command = editor.commands.get( 'textPartLanguage' );
+ expect( command ).to.be.instanceOf( TextPartLanguageCommand );
+ } );
+ } );
+
+ describe( 'data pipeline conversions', () => {
+ it( 'should convert lang to language attribute', () => {
+ editor.setData( 'foobar
' );
+
+ expect( getModelData( model, { withoutSelection: true } ) )
+ .to.equal( '<$text language="fr:ltr">foo$text>bar' );
+
+ expect( editor.getData() ).to.equal( 'foobar
' );
+ } );
+
+ it( 'should respect dir attribute', () => {
+ editor.setData( 'foobar
' );
+
+ expect( getModelData( model, { withoutSelection: true } ) )
+ .to.equal( '<$text language="fr:rtl">foo$text>bar' );
+
+ expect( editor.getData() ).to.equal( 'foobar
' );
+ } );
+
+ it( 'should be integrated with autoparagraphing', () => {
+ editor.setData( 'foobar' );
+
+ expect( getModelData( model, { withoutSelection: true } ) )
+ .to.equal( '<$text language="fr:ltr">foo$text>bar' );
+
+ expect( editor.getData() ).to.equal( 'foobar
' );
+ } );
+ } );
+
+ describe( 'editing pipeline conversion', () => {
+ it( 'should convert attribute', () => {
+ setModelData( model, '<$text language="fr:ltr">foo$text>bar' );
+
+ expect( getViewData( editor.editing.view, { withoutSelection: true } ) )
+ .to.equal( 'foobar
' );
+ } );
+ } );
+
+ describe( 'config', () => {
+ it( 'should be set', () => {
+ expect( editor.config.get( 'language.textPartLanguage' ) ).to.deep.equal( [
+ { title: 'Arabic', languageCode: 'ar' },
+ { title: 'French', languageCode: 'fr' },
+ { title: 'Spanish', languageCode: 'es' }
+ ] );
+ } );
+
+ it( 'should be customizable', async () => {
+ const languageConfig = {
+ ui: 'pl',
+ content: 'pl',
+ textPartLanguage: [
+ { title: 'Hebrew', languageCode: 'he' },
+ { title: 'Polish', languageCode: 'pl' }
+ ]
+ };
+
+ const customEditor = await VirtualTestEditor.create( {
+ plugins: [ TextPartLanguageEditing ],
+ language: languageConfig
+ } );
+
+ expect( customEditor.config.get( 'language' ) ).to.deep.equal( languageConfig );
+
+ customEditor.destroy();
+ } );
+ } );
+} );
diff --git a/packages/ckeditor5-language/tests/textpartlanguageui.js b/packages/ckeditor5-language/tests/textpartlanguageui.js
new file mode 100644
index 00000000000..fd082feb25c
--- /dev/null
+++ b/packages/ckeditor5-language/tests/textpartlanguageui.js
@@ -0,0 +1,145 @@
+/**
+ * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals document */
+
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import DropdownView from '@ckeditor/ckeditor5-ui/src/dropdown/dropdownview';
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+import TextPartLanguageEditing from '../src/textpartlanguageediting';
+import TextPartLanguageUI from '../src/textpartlanguageui';
+
+describe( 'TextPartLanguageUI', () => {
+ let editor, editorElement, dropdown, command;
+
+ testUtils.createSinonSandbox();
+
+ beforeEach( () => {
+ editorElement = document.createElement( 'div' );
+ document.body.appendChild( editorElement );
+
+ return ClassicTestEditor
+ .create( editorElement, {
+ plugins: [ TextPartLanguageUI, TextPartLanguageEditing, Paragraph ],
+ toolbar: [ 'textPartLanguage' ]
+ } )
+ .then( newEditor => {
+ editor = newEditor;
+ dropdown = editor.ui.componentFactory.create( 'textPartLanguage' );
+
+ command = editor.commands.get( 'textPartLanguage' );
+
+ // Set data so the commands will be enabled.
+ setData( editor.model, '[foo]' );
+ } );
+ } );
+
+ afterEach( () => {
+ editorElement.remove();
+
+ return editor.destroy();
+ } );
+
+ describe( 'init()', () => {
+ it( 'should register options feature component', () => {
+ const dropdown = editor.ui.componentFactory.create( 'textPartLanguage' );
+
+ expect( dropdown ).to.be.instanceOf( DropdownView );
+ expect( dropdown.buttonView.isEnabled ).to.be.true;
+ expect( dropdown.buttonView.isOn ).to.be.false;
+ expect( dropdown.buttonView.label ).to.equal( 'Choose language' );
+ expect( dropdown.buttonView.tooltip ).to.equal( 'Language' );
+ } );
+
+ it( 'should execute textPartLanguage command on model (no language selected)', () => {
+ const executeSpy = testUtils.sinon.spy( command, 'execute' );
+ const dropdown = editor.ui.componentFactory.create( 'textPartLanguage' );
+
+ dropdown.fire( 'execute' );
+
+ sinon.assert.calledOnce( executeSpy );
+ sinon.assert.calledWithExactly( executeSpy,
+ { languageCode: undefined, textDirection: undefined } );
+ } );
+
+ it( 'should execute textPartLanguage command on model (language selected)', () => {
+ const executeSpy = testUtils.sinon.spy( command, 'execute' );
+ const dropdown = editor.ui.componentFactory.create( 'textPartLanguage' );
+
+ dropdown.languageCode = 'fr';
+ dropdown.textDirection = 'ltr';
+ dropdown.fire( 'execute' );
+
+ sinon.assert.calledOnce( executeSpy );
+ sinon.assert.calledWithExactly( executeSpy,
+ { languageCode: 'fr', textDirection: 'ltr' } );
+ } );
+
+ it( 'should focus view after command execution', () => {
+ const focusSpy = testUtils.sinon.spy( editor.editing.view, 'focus' );
+ const dropdown = editor.ui.componentFactory.create( 'textPartLanguage' );
+
+ dropdown.languageCode = 'fr';
+ dropdown.fire( 'execute' );
+
+ sinon.assert.calledOnce( focusSpy );
+ } );
+
+ it( 'should add custom CSS class to dropdown', () => {
+ const dropdown = editor.ui.componentFactory.create( 'textPartLanguage' );
+
+ dropdown.render();
+
+ expect( dropdown.element.classList.contains( 'ck-text-fragment-language-dropdown' ) ).to.be.true;
+ } );
+
+ describe( 'model to command binding', () => {
+ it( 'isEnabled', () => {
+ command.isEnabled = false;
+
+ expect( dropdown.buttonView.isEnabled ).to.be.false;
+
+ command.isEnabled = true;
+ expect( dropdown.buttonView.isEnabled ).to.be.true;
+
+ command.isEnabled = false;
+ expect( dropdown.buttonView.isEnabled ).to.be.false;
+ } );
+
+ it( 'label', () => {
+ command.value = false;
+
+ expect( dropdown.buttonView.label ).to.equal( 'Choose language' );
+
+ command.value = 'fr:ltr';
+ expect( dropdown.buttonView.label ).to.equal( 'French' );
+
+ command.value = 'ar:rtl';
+ expect( dropdown.buttonView.label ).to.equal( 'Arabic' );
+ } );
+
+ it( 'reflects the #value of the command', () => {
+ const listView = dropdown.listView;
+
+ setData( editor.model, '[<$text language="fr:ltr">te]xt$text>' );
+
+ expect( getListViewItems( listView ).map( item => item.children.first.isOn ) ).to.deep.equal( [
+ false,
+ true,
+ false,
+ false
+ ] );
+ } );
+ } );
+ } );
+
+ function getListViewItems( listView ) {
+ // Let's drop separator.
+ return listView.items.filter( item => item.children );
+ }
+} );
diff --git a/packages/ckeditor5-language/theme/language.css b/packages/ckeditor5-language/theme/language.css
new file mode 100644
index 00000000000..73c85a8f4cc
--- /dev/null
+++ b/packages/ckeditor5-language/theme/language.css
@@ -0,0 +1,3 @@
+.ck-content span[lang] {
+ font-style: italic;
+}
diff --git a/packages/ckeditor5-language/webpack.config.js b/packages/ckeditor5-language/webpack.config.js
new file mode 100644
index 00000000000..fcaa78a2eed
--- /dev/null
+++ b/packages/ckeditor5-language/webpack.config.js
@@ -0,0 +1,17 @@
+/**
+ * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+'use strict';
+
+/* eslint-env node */
+
+const { builds } = require( '@ckeditor/ckeditor5-dev-utils' );
+
+module.exports = builds.getDllPluginWebpackConfig( {
+ themePath: require.resolve( '@ckeditor/ckeditor5-theme-lark' ),
+ packagePath: __dirname,
+ manifestPath: require.resolve( 'ckeditor5/build/ckeditor5-dll.manifest.json' ),
+ isDevelopmentMode: process.argv.includes( '--dev' )
+} );
diff --git a/packages/ckeditor5-utils/src/index.js b/packages/ckeditor5-utils/src/index.js
index 13b48fccb05..7b635e11d87 100644
--- a/packages/ckeditor5-utils/src/index.js
+++ b/packages/ckeditor5-utils/src/index.js
@@ -28,6 +28,7 @@ export { default as setDataInElement } from './dom/setdatainelement';
export { default as toUnit } from './dom/tounit';
export * from './keyboard';
+export * from './language';
export { default as Locale } from './locale';
export { default as Collection } from './collection';
export { default as first } from './first';
diff --git a/packages/ckeditor5-utils/src/language.js b/packages/ckeditor5-utils/src/language.js
new file mode 100644
index 00000000000..ab5bd0032df
--- /dev/null
+++ b/packages/ckeditor5-utils/src/language.js
@@ -0,0 +1,26 @@
+/**
+ * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module utils/language
+ */
+
+const RTL_LANGUAGE_CODES = [
+ 'ar', 'ara', // Arabic
+ 'fa', 'per', 'fas', // Persian
+ 'he', 'heb', // Hebrew
+ 'ku', 'kur', // Kurdish
+ 'ug', 'uig' // Uighur, Uyghur
+];
+
+/**
+ * Helps determine whether a language text direction is LTR or RTL.
+ *
+ * @param {String} language The ISO 639-1 or ISO 639-2 language code.
+ * @returns {'ltr'|'rtl'}
+ */
+export function getLanguageDirection( languageCode ) {
+ return RTL_LANGUAGE_CODES.includes( languageCode ) ? 'rtl' : 'ltr';
+}
diff --git a/packages/ckeditor5-utils/src/locale.js b/packages/ckeditor5-utils/src/locale.js
index 1e5c9413a34..e0ab46cbdcb 100644
--- a/packages/ckeditor5-utils/src/locale.js
+++ b/packages/ckeditor5-utils/src/locale.js
@@ -11,8 +11,7 @@
import toArray from './toarray';
import { _translate } from './translation-service';
-
-const RTL_LANGUAGE_CODES = [ 'ar', 'fa', 'he', 'ku', 'ug' ];
+import { getLanguageDirection } from './language';
/**
* Represents the localization services.
@@ -175,11 +174,3 @@ function interpolateString( string, values ) {
return ( index < values.length ) ? values[ index ] : match;
} );
}
-
-// Helps determine whether a language is LTR or RTL.
-//
-// @param {String} language The ISO 639-1 language code.
-// @returns {String} 'ltr' or 'rtl
-function getLanguageDirection( languageCode ) {
- return RTL_LANGUAGE_CODES.includes( languageCode ) ? 'rtl' : 'ltr';
-}
diff --git a/packages/ckeditor5-utils/tests/language.js b/packages/ckeditor5-utils/tests/language.js
new file mode 100644
index 00000000000..e7433864e05
--- /dev/null
+++ b/packages/ckeditor5-utils/tests/language.js
@@ -0,0 +1,42 @@
+/**
+ * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import { getLanguageDirection } from '../src/language';
+
+describe( 'language', () => {
+ describe( 'getLanguageDirection()', () => {
+ [
+ // Common LTR languages.
+ { code: 'en', textDirection: 'ltr' },
+ { code: 'pl', textDirection: 'ltr' },
+ { code: 'fr', textDirection: 'ltr' },
+
+ // Arabic
+ { code: 'ar', textDirection: 'rtl' },
+ { code: 'ara', textDirection: 'rtl' },
+
+ // Persian
+ { code: 'fa', textDirection: 'rtl' },
+ { code: 'per', textDirection: 'rtl' },
+ { code: 'fas', textDirection: 'rtl' },
+
+ // Hebrew
+ { code: 'he', textDirection: 'rtl' },
+ { code: 'heb', textDirection: 'rtl' },
+
+ // Kurdish
+ { code: 'ku', textDirection: 'rtl' },
+ { code: 'kur', textDirection: 'rtl' },
+
+ // Uighur, Uyghur
+ { code: 'ug', textDirection: 'rtl' },
+ { code: 'uig', textDirection: 'rtl' }
+ ].forEach( ( { code, textDirection } ) => {
+ it( `determines the "${ code }" language direction`, () => {
+ expect( getLanguageDirection( code ) ).to.equal( textDirection );
+ } );
+ } );
+ } );
+} );