UNPKG

@ckeditor/ckeditor5-language

Version:

Text part language feature for CKEditor 5.

403 lines (396 loc) • 15.6 kB
/** * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options */ import { Command, Plugin } from '@ckeditor/ckeditor5-core/dist/index.js'; import { getLanguageDirection, Collection } from '@ckeditor/ckeditor5-utils/dist/index.js'; import { createDropdown, addListToDropdown, MenuBarMenuView, MenuBarMenuListView, ListSeparatorView, MenuBarMenuListItemView, MenuBarMenuListItemButtonView, ViewModel } from '@ckeditor/ckeditor5-ui/dist/index.js'; /** * Returns the language attribute value in a human-readable text format: * * ``` * <languageCode>:<textDirection> * ``` * * * `languageCode` - The language code used for the `lang` attribute in the [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 the {@link module:core/editor/editorconfig~LanguageConfig#textPartLanguage text part language configuration} * for more information about language properties. * * If the `textDirection` argument is omitted, it will be automatically detected based on `languageCode`. * * @param languageCode The language code in the ISO 639-1 format. * @param textDirection The language text direction. Automatically detected if omitted. */ function stringifyLanguageAttribute(languageCode, textDirection) { textDirection = textDirection || getLanguageDirection(languageCode); return `${languageCode}:${textDirection}`; } /** * Retrieves language properties converted to attribute value by the * {@link module:language/utils~stringifyLanguageAttribute stringifyLanguageAttribute} function. * * @param str The attribute value. * @returns The object with properties: * * languageCode - The language code in the ISO 639 format. * * textDirection - The language text direction. */ function parseLanguageAttribute(str) { const [languageCode, textDirection] = str.split(':'); return { languageCode, textDirection }; } /** * The text part language command plugin. */ class TextPartLanguageCommand extends Command { /** * @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 a `null` value, it will remove attributes. Otherwise, it will set * the attribute in the `{@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 options Command options. * @param options.languageCode The language code to be applied to the model. * @param options.textDirection The 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 a collapsed selection it returns the selection attribute. * * @returns 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; } } /** * The text part language editing. * * Introduces the `'textPartLanguage'` command and the `'language'` model element attribute. */ class TextPartLanguageEditing extends Plugin { /** * @inheritDoc */ static get pluginName() { return 'TextPartLanguageEditing'; } /** * @inheritDoc */ static get isOfficialPlugin() { return true; } /** * @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 }, data)=>{ if (!attributeValue) { return; } if (!data.item.is('$textProxy') && !data.item.is('documentSelection')) { return; } const { languageCode, textDirection } = parseLanguageAttribute(attributeValue); return writer.createAttributeElement('span', { lang: languageCode, dir: textDirection }); } }); } } /** * The text part language UI plugin. * * It introduces the `'language'` dropdown. */ class TextPartLanguageUI extends Plugin { /** * @inheritDoc */ static get pluginName() { return 'TextPartLanguageUI'; } /** * @inheritDoc */ static get isOfficialPlugin() { return true; } /** * @inheritDoc */ init() { const editor = this.editor; const t = editor.t; const defaultTitle = t('Choose language'); const accessibleLabel = t('Language'); // Register UI component. editor.ui.componentFactory.add('textPartLanguage', (locale)=>{ const { definitions, titles } = this._getItemMetadata(); const languageCommand = editor.commands.get('textPartLanguage'); const dropdownView = createDropdown(locale); addListToDropdown(dropdownView, definitions, { ariaLabel: accessibleLabel, role: 'menu' }); dropdownView.buttonView.set({ ariaLabel: accessibleLabel, ariaLabelledBy: undefined, isOn: false, withText: true, tooltip: accessibleLabel }); dropdownView.extendTemplate({ attributes: { class: [ 'ck-text-fragment-language-dropdown' ] } }); dropdownView.bind('isEnabled').to(languageCommand, 'isEnabled'); dropdownView.buttonView.bind('label').to(languageCommand, 'value', (value)=>{ return value && titles[value] || defaultTitle; }); dropdownView.buttonView.bind('ariaLabel').to(languageCommand, 'value', (value)=>{ const selectedLanguageTitle = value && titles[value]; if (!selectedLanguageTitle) { return accessibleLabel; } return `${selectedLanguageTitle}, ${accessibleLabel}`; }); // 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; }); // Register menu bar UI component. editor.ui.componentFactory.add('menuBar:textPartLanguage', (locale)=>{ const { definitions } = this._getItemMetadata(); const languageCommand = editor.commands.get('textPartLanguage'); const menuView = new MenuBarMenuView(locale); menuView.buttonView.set({ label: accessibleLabel }); const listView = new MenuBarMenuListView(locale); listView.set({ ariaLabel: t('Language'), role: 'menu' }); for (const definition of definitions){ if (definition.type != 'button') { listView.items.add(new ListSeparatorView(locale)); continue; } const listItemView = new MenuBarMenuListItemView(locale, menuView); const buttonView = new MenuBarMenuListItemButtonView(locale); buttonView.set({ role: 'menuitemradio', isToggleable: true }); buttonView.bind(...Object.keys(definition.model)).to(definition.model); buttonView.delegate('execute').to(menuView); listItemView.children.add(buttonView); listView.items.add(listItemView); } menuView.bind('isEnabled').to(languageCommand, 'isEnabled'); menuView.panelView.children.add(listView); menuView.on('execute', (evt)=>{ languageCommand.execute({ languageCode: evt.source.languageCode, textDirection: evt.source.textDirection }); editor.editing.view.focus(); }); return menuView; }); } /** * Returns metadata for dropdown and menu items. */ _getItemMetadata() { const editor = this.editor; const itemDefinitions = new Collection(); const titles = {}; const languageCommand = editor.commands.get('textPartLanguage'); const options = editor.config.get('language.textPartLanguage'); const t = editor.locale.t; const removeTitle = t('Remove language'); // Item definition with false `languageCode` will behave as remove lang button. itemDefinitions.add({ type: 'button', model: new ViewModel({ label: removeTitle, languageCode: false, withText: true }) }); itemDefinitions.add({ type: 'separator' }); for (const option of options){ const def = { type: 'button', model: new ViewModel({ label: option.title, languageCode: option.languageCode, role: 'menuitemradio', 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; } return { definitions: itemDefinitions, titles }; } } /** * The text part language feature. * * This feature allows setting a language of the document'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 the editor's UI language, refer to the {@glink getting-started/setup/ui-language Setting the UI language} guide. * * For more information about this feature, check the {@glink api/language package page} as well as the {@glink features/language * Text part language} feature guide. * * This is a "glue" plugin which loads the * {@link module:language/textpartlanguageediting~TextPartLanguageEditing text part language editing feature} * and the {@link module:language/textpartlanguageui~TextPartLanguageUI text part language UI feature}. */ class TextPartLanguage extends Plugin { /** * @inheritDoc */ static get requires() { return [ TextPartLanguageEditing, TextPartLanguageUI ]; } /** * @inheritDoc */ static get pluginName() { return 'TextPartLanguage'; } /** * @inheritDoc */ static get isOfficialPlugin() { return true; } } export { TextPartLanguage, TextPartLanguageEditing, TextPartLanguageUI }; //# sourceMappingURL=index.js.map