Skip to content

Commit 21fe23c

Browse files
authored
Merge pull request #12458 from nextcloud/feat/dir-buttons
feat: add rtl/ltr buttons to ckeditor toolbar
2 parents 329472a + 4ac9c06 commit 21fe23c

7 files changed

Lines changed: 372 additions & 2 deletions

File tree

REUSE.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,9 +138,9 @@ SPDX-FileCopyrightText = "2018-2024 Google LLC, 2016-2024 Nextcloud GmbH and Nex
138138
SPDX-License-Identifier = "Apache-2.0"
139139

140140
[[annotations]]
141-
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"]
141+
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"]
142142
precedence = "aggregate"
143-
SPDX-FileCopyrightText = "2018-2025 Google LLC"
143+
SPDX-FileCopyrightText = "2018-2026 Google LLC"
144144
SPDX-License-Identifier = "Apache-2.0"
145145

146146
[[annotations]]

img/format-pilcrow-arrow-left.svg

Lines changed: 1 addition & 0 deletions
Loading

img/format-pilcrow-arrow-right.svg

Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { Command, first } from 'ckeditor5'
7+
8+
const ATTRIBUTE = 'textDirection'
9+
10+
/**
11+
* The text direction command. Applies `dir="ltr"` or `dir="rtl"` to selected blocks.
12+
*/
13+
export default class TextDirectionCommand extends Command {
14+
/**
15+
* @inheritDoc
16+
*/
17+
refresh() {
18+
const firstBlock = first(this.editor.model.document.selection.getSelectedBlocks())
19+
20+
this.isEnabled = Boolean(firstBlock) && this.editor.model.schema.checkAttribute(firstBlock, ATTRIBUTE)
21+
22+
if (this.isEnabled && firstBlock.hasAttribute(ATTRIBUTE)) {
23+
this.value = firstBlock.getAttribute(ATTRIBUTE)
24+
} else {
25+
this.value = null
26+
}
27+
}
28+
29+
/**
30+
* Executes the command. Applies the text direction to the selected blocks.
31+
*
32+
* @param {object} options Command options.
33+
* @param {string} options.value The direction value to apply ('ltr' or 'rtl').
34+
*/
35+
execute(options = {}) {
36+
const model = this.editor.model
37+
const doc = model.document
38+
const value = options.value
39+
40+
model.change((writer) => {
41+
const blocks = Array.from(doc.selection.getSelectedBlocks())
42+
.filter((block) => this.editor.model.schema.checkAttribute(block, ATTRIBUTE))
43+
44+
for (const block of blocks) {
45+
const currentDirection = block.getAttribute(ATTRIBUTE)
46+
47+
// Toggle: if the same direction is applied, remove it
48+
if (currentDirection === value) {
49+
writer.removeAttribute(ATTRIBUTE, block)
50+
} else {
51+
writer.setAttribute(ATTRIBUTE, value, block)
52+
}
53+
}
54+
})
55+
}
56+
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { ButtonView, Plugin } from 'ckeditor5'
7+
import ltrIcon from '../../../img/format-pilcrow-arrow-left.svg'
8+
import rtlIcon from '../../../img/format-pilcrow-arrow-right.svg'
9+
import TextDirectionCommand from './TextDirectionCommand.js'
10+
11+
const ATTRIBUTE = 'textDirection'
12+
13+
/**
14+
* The text direction plugin. Adds `dir` attribute support to block elements
15+
* and registers toolbar buttons for switching between LTR and RTL directions.
16+
*/
17+
export default class TextDirectionPlugin extends Plugin {
18+
static get pluginName() {
19+
return 'TextDirectionPlugin'
20+
}
21+
22+
init() {
23+
this._defineSchema()
24+
this._defineConverters()
25+
this._defineCommand()
26+
27+
// Only register toolbar buttons when the editor has a UI (not in data-only/virtual editors)
28+
if (this.editor.ui) {
29+
this._defineButtons()
30+
}
31+
}
32+
33+
/**
34+
* Allows the `textDirection` attribute on all block elements.
35+
*
36+
* @private
37+
*/
38+
_defineSchema() {
39+
const schema = this.editor.model.schema
40+
41+
schema.extend('$block', { allowAttributes: ATTRIBUTE })
42+
schema.setAttributeProperties(ATTRIBUTE, { isFormatting: true })
43+
}
44+
45+
/**
46+
* Defines converters for the `textDirection` attribute.
47+
* Downcasts to `dir` style attribute and upcasts from `dir` HTML attribute.
48+
*
49+
* @private
50+
*/
51+
_defineConverters() {
52+
const editor = this.editor
53+
54+
// Downcast: model textDirection attribute -> view dir attribute
55+
editor.conversion.for('downcast').attributeToAttribute({
56+
model: {
57+
key: ATTRIBUTE,
58+
values: ['ltr', 'rtl'],
59+
},
60+
view: {
61+
ltr: {
62+
key: 'dir',
63+
value: 'ltr',
64+
},
65+
rtl: {
66+
key: 'dir',
67+
value: 'rtl',
68+
},
69+
},
70+
})
71+
72+
// Upcast: view dir="ltr" attribute -> model textDirection attribute
73+
editor.conversion.for('upcast').attributeToAttribute({
74+
view: {
75+
key: 'dir',
76+
value: 'ltr',
77+
},
78+
model: {
79+
key: ATTRIBUTE,
80+
value: 'ltr',
81+
},
82+
})
83+
84+
// Upcast: view dir="rtl" attribute -> model textDirection attribute
85+
editor.conversion.for('upcast').attributeToAttribute({
86+
view: {
87+
key: 'dir',
88+
value: 'rtl',
89+
},
90+
model: {
91+
key: ATTRIBUTE,
92+
value: 'rtl',
93+
},
94+
})
95+
}
96+
97+
/**
98+
* Registers the `textDirection` command.
99+
*
100+
* @private
101+
*/
102+
_defineCommand() {
103+
this.editor.commands.add(ATTRIBUTE, new TextDirectionCommand(this.editor))
104+
}
105+
106+
/**
107+
* Registers the `textDirection:ltr` and `textDirection:rtl` toolbar buttons.
108+
*
109+
* @private
110+
*/
111+
_defineButtons() {
112+
const editor = this.editor
113+
const t = editor.t
114+
const command = editor.commands.get(ATTRIBUTE)
115+
116+
editor.ui.componentFactory.add('textDirection:ltr', (locale) => {
117+
const buttonView = new ButtonView(locale)
118+
119+
buttonView.set({
120+
label: t('Left-to-right text'),
121+
icon: ltrIcon,
122+
tooltip: true,
123+
isToggleable: true,
124+
})
125+
126+
buttonView.bind('isEnabled').to(command)
127+
buttonView.bind('isOn').to(command, 'value', (value) => value === 'ltr')
128+
129+
this.listenTo(buttonView, 'execute', () => {
130+
editor.execute(ATTRIBUTE, { value: 'ltr' })
131+
editor.editing.view.focus()
132+
})
133+
134+
return buttonView
135+
})
136+
137+
editor.ui.componentFactory.add('textDirection:rtl', (locale) => {
138+
const buttonView = new ButtonView(locale)
139+
140+
buttonView.set({
141+
label: t('Right-to-left text'),
142+
icon: rtlIcon,
143+
tooltip: true,
144+
isToggleable: true,
145+
})
146+
147+
buttonView.bind('isEnabled').to(command)
148+
buttonView.bind('isOn').to(command, 'value', (value) => value === 'rtl')
149+
150+
this.listenTo(buttonView, 'execute', () => {
151+
editor.execute(ATTRIBUTE, { value: 'rtl' })
152+
editor.editing.view.focus()
153+
})
154+
155+
return buttonView
156+
})
157+
}
158+
}

src/components/TextEditor.vue

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import {
5252
Underline,
5353
} from 'ckeditor5'
5454
import { getLinkWithPicker, searchProvider } from '@nextcloud/vue/components/NcRichText'
55+
import TextDirectionPlugin from '../ckeditor/direction/TextDirectionPlugin.js'
5556
import MailPlugin from '../ckeditor/mail/MailPlugin.js'
5657
import QuotePlugin from '../ckeditor/quote/QuotePlugin.js'
5758
import SignaturePlugin from '../ckeditor/signature/SignaturePlugin.js'
@@ -148,6 +149,7 @@ export default {
148149
RemoveFormat,
149150
Base64UploadAdapter,
150151
MailPlugin,
152+
TextDirectionPlugin,
151153
])
152154
toolbar.unshift(...[
153155
'heading',
@@ -163,6 +165,8 @@ export default {
163165
'fontBackgroundColor',
164166
'insertImage',
165167
'alignment',
168+
'textDirection:ltr',
169+
'textDirection:rtl',
166170
'bulletedList',
167171
'numberedList',
168172
'blockquote',

0 commit comments

Comments
 (0)