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

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">fo]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

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">rr]

' ); + + 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.]

' + ); + } ); + } ); + + 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]obarxyz

' ); + + 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]obarxyz

' ); + } ); + + it( 'should remove attribute from selected nodes if the command value was not set', () => { + setData( model, '

abc[<$text language="fr:ltr">foo]barxyz

' ); + + expect( command.value ).to.equal( 'fr:ltr' ); + + command.execute(); + + expect( getData( model ) ).to.equal( '

abc[foo]<$text language="fr:ltr">barxyz

' ); + 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[arx]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 language="ar:rtl">arx]yz

' + ); + } ); + + it( 'should remove attribute on selected nodes if execute parameter was set to false', () => { + setData( model, '

a[bc<$text language="fr:ltr">fo]obarxyz

' ); + + command.execute( { languageCode: false } ); + + expect( command.value ).to.be.false; + expect( getData( model ) ).to.equal( '

a[bcfo]<$text language="fr:ltr">obarxyz

' ); + } ); + + it( 'should remove attribute on selected nodes if execute parameter was set to null', () => { + setData( model, '

a[bc<$text language="fr:ltr">fo]obarxyz

' ); + + command.execute( { languageCode: null } ); + + expect( command.value ).to.be.false; + expect( getData( model ) ).to.equal( '

a[bcfo]<$text language="fr:ltr">obarxyz

' ); + } ); + + it( 'should change selection attribute if selection is collapsed in non-empty parent', () => { + setData( model, '

a[]bc<$text language="fr:ltr">foobarxyz

' ); + + 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">foobarxyz

' ); + + 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">foobarxyz

[]

' ); + + 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">foobar' ); + + 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">foobar' ); + + 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">foobar' ); + + expect( editor.getData() ).to.equal( '

foobar

' ); + } ); + } ); + + describe( 'editing pipeline conversion', () => { + it( 'should convert attribute', () => { + setModelData( model, '<$text language="fr:ltr">foobar' ); + + 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' ); + + 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 ); + } ); + } ); + } ); +} );