Skip to content
Merged
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
4 changes: 2 additions & 2 deletions REUSE.toml
Original file line number Diff line number Diff line change
Expand Up @@ -138,9 +138,9 @@ SPDX-FileCopyrightText = "2018-2024 Google LLC, 2016-2024 Nextcloud GmbH and Nex
SPDX-License-Identifier = "Apache-2.0"

[[annotations]]
path = ["img/mail.png", "img/mail.svg", "img/mail-dark.svg", "img/important.svg", "img/star.png", "img/star.svg", "img/mail-notification.png", "img/mail-notification.svg", "img/text_snippet.svg"]
path = ["img/mail.png", "img/mail.svg", "img/mail-dark.svg", "img/important.svg", "img/star.png", "img/star.svg", "img/mail-notification.png", "img/mail-notification.svg", "img/text_snippet.svg", "img/format-pilcrow-arrow-right.svg", "img/format-pilcrow-arrow-left.svg"]
precedence = "aggregate"
SPDX-FileCopyrightText = "2018-2025 Google LLC"
SPDX-FileCopyrightText = "2018-2026 Google LLC"
SPDX-License-Identifier = "Apache-2.0"

[[annotations]]
Expand Down
1 change: 1 addition & 0 deletions img/format-pilcrow-arrow-left.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions img/format-pilcrow-arrow-right.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
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: 2026 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)
}
}
})
}
}
158 changes: 158 additions & 0 deletions src/ckeditor/direction/TextDirectionPlugin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { ButtonView, Plugin } from 'ckeditor5'
import ltrIcon from '../../../img/format-pilcrow-arrow-left.svg'
import rtlIcon from '../../../img/format-pilcrow-arrow-right.svg'
import TextDirectionCommand from './TextDirectionCommand.js'

const ATTRIBUTE = 'textDirection'

/**
* 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
Loading
Loading