Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions src/ckeditor/direction/TextDirectionCommand.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hello 2026

* 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)
}
}
})
}
}
162 changes: 162 additions & 0 deletions src/ckeditor/direction/TextDirectionPlugin.js
Original file line number Diff line number Diff line change
@@ -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 = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>format-pilcrow-arrow-left</title><path d="M8,17V14L4,18L8,22V19H20V17M10,10V15H12V4H14V15H16V4H18V2H10A4,4 0 0,0 6,6A4,4 0 0,0 10,10Z" /></svg>'

// https://pictogrammers.com/library/mdi/icon/format-pilcrow-arrow-right/
const rtlIcon = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>format-pilcrow-arrow-right</title><path d="M21,18L17,14V17H5V19H17V22M9,10V15H11V4H13V15H15V4H17V2H9A4,4 0 0,0 5,6A4,4 0 0,0 9,10Z" /></svg>'

/**
* 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
})
}
}
4 changes: 4 additions & 0 deletions src/components/TextEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -148,6 +149,7 @@ export default {
RemoveFormat,
Base64UploadAdapter,
MailPlugin,
TextDirectionPlugin,
])
toolbar.unshift(...[
'heading',
Expand All @@ -163,6 +165,8 @@ export default {
'fontBackgroundColor',
'insertImage',
'alignment',
'textDirection:ltr',
'textDirection:rtl',
'bulletedList',
'numberedList',
'blockquote',
Expand Down
150 changes: 150 additions & 0 deletions src/tests/unit/components/TextDirectionPlugin.spec.js
Original file line number Diff line number Diff line change
@@ -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 = '<p dir="rtl">مرحبا</p>'
const expected = '<p dir="rtl">مرحبا</p>'

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 = '<p dir="ltr">Hello</p>'
const expected = '<p dir="ltr">Hello</p>'

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 = '<p>Hello</p>'
const expected = '<p>Hello</p>'

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 = '<p>Hello</p>'

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('<p dir="rtl">Hello</p>')
})

it('applies LTR direction via command', async () => {
const text = '<p>مرحبا</p>'

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('<p dir="ltr">مرحبا</p>')
})

it('toggles off direction when same value is applied', async () => {
const text = '<p dir="rtl">مرحبا</p>'

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('<p>مرحبا</p>')
})

it('switches direction from RTL to LTR', async () => {
const text = '<p dir="rtl">Hello</p>'

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('<p dir="ltr">Hello</p>')
})

it('registers the textDirection command', async () => {
const editor = await VirtualTestEditor.create({
licenseKey: 'GPL',
initialData: '<p>test</p>',
plugins: [Paragraph, TextDirectionPlugin],
})

expect(editor.commands.get('textDirection')).toBeDefined()
})

it('preserves direction across multiple paragraphs', async () => {
const text = '<p dir="rtl">مرحبا</p><p dir="ltr">Hello</p>'
const expected = '<p dir="rtl">مرحبا</p><p dir="ltr">Hello</p>'

const editor = await VirtualTestEditor.create({
licenseKey: 'GPL',
initialData: text,
plugins: [Paragraph, TextDirectionPlugin],
})

expect(editor.getData()).toEqual(expected)
})
})
Loading