diff --git a/src/ckeditor/direction/TextDirectionCommand.js b/src/ckeditor/direction/TextDirectionCommand.js new file mode 100644 index 0000000000..0a8453e790 --- /dev/null +++ b/src/ckeditor/direction/TextDirectionCommand.js @@ -0,0 +1,56 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { Command, first } from 'ckeditor5' + +const ATTRIBUTE = 'textDirection' + +/** + * The text direction command. Applies `dir="ltr"` or `dir="rtl"` to selected blocks. + */ +export default class TextDirectionCommand extends Command { + /** + * @inheritDoc + */ + refresh() { + const firstBlock = first(this.editor.model.document.selection.getSelectedBlocks()) + + this.isEnabled = Boolean(firstBlock) && this.editor.model.schema.checkAttribute(firstBlock, ATTRIBUTE) + + if (this.isEnabled && firstBlock.hasAttribute(ATTRIBUTE)) { + this.value = firstBlock.getAttribute(ATTRIBUTE) + } else { + this.value = null + } + } + + /** + * Executes the command. Applies the text direction to the selected blocks. + * + * @param {object} options Command options. + * @param {string} options.value The direction value to apply ('ltr' or 'rtl'). + */ + execute(options = {}) { + const model = this.editor.model + const doc = model.document + const value = options.value + + model.change((writer) => { + const blocks = Array.from(doc.selection.getSelectedBlocks()) + .filter((block) => this.editor.model.schema.checkAttribute(block, ATTRIBUTE)) + + for (const block of blocks) { + const currentDirection = block.getAttribute(ATTRIBUTE) + + // Toggle: if the same direction is applied, remove it + if (currentDirection === value) { + writer.removeAttribute(ATTRIBUTE, block) + } else { + writer.setAttribute(ATTRIBUTE, value, block) + } + } + }) + } +} diff --git a/src/ckeditor/direction/TextDirectionPlugin.js b/src/ckeditor/direction/TextDirectionPlugin.js new file mode 100644 index 0000000000..0ae8f70689 --- /dev/null +++ b/src/ckeditor/direction/TextDirectionPlugin.js @@ -0,0 +1,162 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { ButtonView, Plugin } from 'ckeditor5' +import TextDirectionCommand from './TextDirectionCommand.js' + +const ATTRIBUTE = 'textDirection' + +// https://pictogrammers.com/library/mdi/icon/format-pilcrow-arrow-left/ +const ltrIcon = '' + +// https://pictogrammers.com/library/mdi/icon/format-pilcrow-arrow-right/ +const rtlIcon = '' + +/** + * The text direction plugin. Adds `dir` attribute support to block elements + * and registers toolbar buttons for switching between LTR and RTL directions. + */ +export default class TextDirectionPlugin extends Plugin { + static get pluginName() { + return 'TextDirectionPlugin' + } + + init() { + this._defineSchema() + this._defineConverters() + this._defineCommand() + + // Only register toolbar buttons when the editor has a UI (not in data-only/virtual editors) + if (this.editor.ui) { + this._defineButtons() + } + } + + /** + * Allows the `textDirection` attribute on all block elements. + * + * @private + */ + _defineSchema() { + const schema = this.editor.model.schema + + schema.extend('$block', { allowAttributes: ATTRIBUTE }) + schema.setAttributeProperties(ATTRIBUTE, { isFormatting: true }) + } + + /** + * Defines converters for the `textDirection` attribute. + * Downcasts to `dir` style attribute and upcasts from `dir` HTML attribute. + * + * @private + */ + _defineConverters() { + const editor = this.editor + + // Downcast: model textDirection attribute -> view dir attribute + editor.conversion.for('downcast').attributeToAttribute({ + model: { + key: ATTRIBUTE, + values: ['ltr', 'rtl'], + }, + view: { + ltr: { + key: 'dir', + value: 'ltr', + }, + rtl: { + key: 'dir', + value: 'rtl', + }, + }, + }) + + // Upcast: view dir="ltr" attribute -> model textDirection attribute + editor.conversion.for('upcast').attributeToAttribute({ + view: { + key: 'dir', + value: 'ltr', + }, + model: { + key: ATTRIBUTE, + value: 'ltr', + }, + }) + + // Upcast: view dir="rtl" attribute -> model textDirection attribute + editor.conversion.for('upcast').attributeToAttribute({ + view: { + key: 'dir', + value: 'rtl', + }, + model: { + key: ATTRIBUTE, + value: 'rtl', + }, + }) + } + + /** + * Registers the `textDirection` command. + * + * @private + */ + _defineCommand() { + this.editor.commands.add(ATTRIBUTE, new TextDirectionCommand(this.editor)) + } + + /** + * Registers the `textDirection:ltr` and `textDirection:rtl` toolbar buttons. + * + * @private + */ + _defineButtons() { + const editor = this.editor + const t = editor.t + const command = editor.commands.get(ATTRIBUTE) + + editor.ui.componentFactory.add('textDirection:ltr', (locale) => { + const buttonView = new ButtonView(locale) + + buttonView.set({ + label: t('Left-to-right text'), + icon: ltrIcon, + tooltip: true, + isToggleable: true, + }) + + buttonView.bind('isEnabled').to(command) + buttonView.bind('isOn').to(command, 'value', (value) => value === 'ltr') + + this.listenTo(buttonView, 'execute', () => { + editor.execute(ATTRIBUTE, { value: 'ltr' }) + editor.editing.view.focus() + }) + + return buttonView + }) + + editor.ui.componentFactory.add('textDirection:rtl', (locale) => { + const buttonView = new ButtonView(locale) + + buttonView.set({ + label: t('Right-to-left text'), + icon: rtlIcon, + tooltip: true, + isToggleable: true, + }) + + buttonView.bind('isEnabled').to(command) + buttonView.bind('isOn').to(command, 'value', (value) => value === 'rtl') + + this.listenTo(buttonView, 'execute', () => { + editor.execute(ATTRIBUTE, { value: 'rtl' }) + editor.editing.view.focus() + }) + + return buttonView + }) + } +} diff --git a/src/components/TextEditor.vue b/src/components/TextEditor.vue index d412325288..82908cf751 100644 --- a/src/components/TextEditor.vue +++ b/src/components/TextEditor.vue @@ -52,6 +52,7 @@ import { Underline, } from 'ckeditor5' import { getLinkWithPicker, searchProvider } from '@nextcloud/vue/components/NcRichText' +import TextDirectionPlugin from '../ckeditor/direction/TextDirectionPlugin.js' import MailPlugin from '../ckeditor/mail/MailPlugin.js' import QuotePlugin from '../ckeditor/quote/QuotePlugin.js' import SignaturePlugin from '../ckeditor/signature/SignaturePlugin.js' @@ -148,6 +149,7 @@ export default { RemoveFormat, Base64UploadAdapter, MailPlugin, + TextDirectionPlugin, ]) toolbar.unshift(...[ 'heading', @@ -163,6 +165,8 @@ export default { 'fontBackgroundColor', 'insertImage', 'alignment', + 'textDirection:ltr', + 'textDirection:rtl', 'bulletedList', 'numberedList', 'blockquote', diff --git a/src/tests/unit/components/TextDirectionPlugin.spec.js b/src/tests/unit/components/TextDirectionPlugin.spec.js new file mode 100644 index 0000000000..d0ec64e339 --- /dev/null +++ b/src/tests/unit/components/TextDirectionPlugin.spec.js @@ -0,0 +1,150 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { Paragraph } from 'ckeditor5' +import TextDirectionPlugin from '../../../ckeditor/direction/TextDirectionPlugin.js' +import VirtualTestEditor from '../../virtualtesteditor.js' + +describe('TextDirectionPlugin', () => { + it('upcasts dir="rtl" from HTML', async () => { + const text = '
مرحبا
' + const expected = 'مرحبا
' + + const editor = await VirtualTestEditor.create({ + licenseKey: 'GPL', + initialData: text, + plugins: [Paragraph, TextDirectionPlugin], + }) + + expect(editor.getData()).toEqual(expected) + }) + + it('upcasts dir="ltr" from HTML', async () => { + const text = 'Hello
' + const expected = 'Hello
' + + const editor = await VirtualTestEditor.create({ + licenseKey: 'GPL', + initialData: text, + plugins: [Paragraph, TextDirectionPlugin], + }) + + expect(editor.getData()).toEqual(expected) + }) + + it('does not add dir attribute when none is set', async () => { + const text = 'Hello
' + const expected = 'Hello
' + + const editor = await VirtualTestEditor.create({ + licenseKey: 'GPL', + initialData: text, + plugins: [Paragraph, TextDirectionPlugin], + }) + + expect(editor.getData()).toEqual(expected) + }) + + it('applies RTL direction via command', async () => { + const text = 'Hello
' + + const editor = await VirtualTestEditor.create({ + licenseKey: 'GPL', + initialData: text, + plugins: [Paragraph, TextDirectionPlugin], + }) + + // Select the first block + const root = editor.model.document.getRoot() + editor.model.change((writer) => { + writer.setSelection(root.getChild(0), 'on') + }) + + editor.execute('textDirection', { value: 'rtl' }) + + expect(editor.getData()).toEqual('Hello
') + }) + + it('applies LTR direction via command', async () => { + const text = 'مرحبا
' + + const editor = await VirtualTestEditor.create({ + licenseKey: 'GPL', + initialData: text, + plugins: [Paragraph, TextDirectionPlugin], + }) + + const root = editor.model.document.getRoot() + editor.model.change((writer) => { + writer.setSelection(root.getChild(0), 'on') + }) + + editor.execute('textDirection', { value: 'ltr' }) + + expect(editor.getData()).toEqual('مرحبا
') + }) + + it('toggles off direction when same value is applied', async () => { + const text = 'مرحبا
' + + const editor = await VirtualTestEditor.create({ + licenseKey: 'GPL', + initialData: text, + plugins: [Paragraph, TextDirectionPlugin], + }) + + const root = editor.model.document.getRoot() + editor.model.change((writer) => { + writer.setSelection(root.getChild(0), 'on') + }) + + // Applying the same direction again should toggle it off + editor.execute('textDirection', { value: 'rtl' }) + + expect(editor.getData()).toEqual('مرحبا
') + }) + + it('switches direction from RTL to LTR', async () => { + const text = 'Hello
' + + const editor = await VirtualTestEditor.create({ + licenseKey: 'GPL', + initialData: text, + plugins: [Paragraph, TextDirectionPlugin], + }) + + const root = editor.model.document.getRoot() + editor.model.change((writer) => { + writer.setSelection(root.getChild(0), 'on') + }) + + editor.execute('textDirection', { value: 'ltr' }) + + expect(editor.getData()).toEqual('Hello
') + }) + + it('registers the textDirection command', async () => { + const editor = await VirtualTestEditor.create({ + licenseKey: 'GPL', + initialData: 'test
', + plugins: [Paragraph, TextDirectionPlugin], + }) + + expect(editor.commands.get('textDirection')).toBeDefined() + }) + + it('preserves direction across multiple paragraphs', async () => { + const text = 'مرحبا
Hello
' + const expected = 'مرحبا
Hello
' + + const editor = await VirtualTestEditor.create({ + licenseKey: 'GPL', + initialData: text, + plugins: [Paragraph, TextDirectionPlugin], + }) + + expect(editor.getData()).toEqual(expected) + }) +})