[FIX] web_editor: provide proper support for mixed language directions

This introduces a "direction" option on the editor to set the text
direction of the editor (passed by Odoo based on the localization), and
a Powerbox command to switch said direction on a given block. This
allows users to mix several text directions within the same text, eg.
when quoting Hebrew in an English text.

task-2814004

X-original-commit: 693df325588ccaf6385c9f610709f687ccdf61a8
Part-of: odoo/odoo#90247
This commit is contained in:
Antoine Guenet
2022-05-02 07:43:55 +02:00
parent be3be24737
commit 618d5bc3ca
4 changed files with 66 additions and 6 deletions
@@ -208,6 +208,7 @@ export class OdooEditor extends EventTarget {
},
isHintBlacklisted: () => false,
filterMutationRecords: (records) => records,
direction: 'ltr',
_t: string => string,
allowCommandVideo: true,
},
@@ -271,6 +272,7 @@ export class OdooEditor extends EventTarget {
this._idToNodeMap.set(1, editable);
this.editable = this.options.toSanitize ? sanitize(editable) : editable;
this.editable.classList.add("odoo-editor-editable");
this.editable.setAttribute('dir', this.options.direction);
// Set contenteditable before clone as FF updates the content at this point.
this._activateContenteditable();
@@ -1839,6 +1841,15 @@ export class OdooEditor extends EventTarget {
this.commandbarTablePicker.show();
},
},
{
groupName: 'Basic blocks',
title: this.options._t('Switch direction'),
description: this.options._t('Switch the text\'s direction.'),
fontawesome: 'fa-exchange',
callback: () => {
this.execCommand('switchDirection');
},
},
{
groupName: 'Widgets',
title: '3 Stars',
@@ -1969,7 +1980,7 @@ export class OdooEditor extends EventTarget {
const selectionStartStyle = getComputedStyle(closestStartContainer);
// queryCommandState does not take stylesheets into account
for (const format of ['bold', 'italic', 'underline', 'strikeThrough']) {
for (const format of ['bold', 'italic', 'underline', 'strikeThrough', 'switchDirection']) {
const formatButton = this.toolbar.querySelector(`#${format.toLowerCase()}`);
if (formatButton) {
formatButton.classList.toggle('active', isSelectionFormat(this.editable, format));
@@ -273,6 +273,7 @@ function hasColor(element, mode) {
* array with the style property name and the value to apply to it
* @param {boolean} [shouldApply=true] set to false to undo a style rather than
* apply it.
* @returns {Element[]} the elements on which the style was changed.
*/
export function applyInlineStyle(editor, applyStyle, style, shouldApply=true) {
getDeepRange(editor.editable, { splitText: true, select: true });
@@ -312,6 +313,7 @@ export function applyInlineStyle(editor, applyStyle, style, shouldApply=true) {
}
return shouldApply ? !isApplied : isApplied;
});
const changedElements = [];
for (const textNode of textNodesToFormat) {
// If text node ends after the end of the selection, split it and
// keep the part that is inside.
@@ -344,6 +346,7 @@ export function applyInlineStyle(editor, applyStyle, style, shouldApply=true) {
newParent.appendChild(textNode);
}
applyStyle(textNode.parentElement);
changedElements.push(textNode.parentElement)
}
if (selectedTextNodes[0] && selectedTextNodes[0].textContent === '\u200B') {
setSelection(selectedTextNodes[0], 0);
@@ -356,6 +359,7 @@ export function applyInlineStyle(editor, applyStyle, style, shouldApply=true) {
setSelection(lastNode, lastNode.length, firstNode, 0);
}
}
return changedElements;
}
const styles = {
bold: {
@@ -377,7 +381,10 @@ const styles = {
is: editable => isSelectionFormat(editable, 'strikeThrough'),
name: 'textDecorationLine',
value: 'line-through',
}
},
switchDirection: {
is: editable => isSelectionFormat(editable, 'switchDirection'),
},
}
export function toggleFormat(editor, format) {
const selection = editor.document.getSelection();
@@ -398,6 +405,7 @@ export function toggleFormat(editor, format) {
const selectedTextNodes = getSelectedNodes(editor.editable)
.filter(n => n.nodeType === Node.TEXT_NODE && n.nodeValue.trim().length);
const isAlreadyFormatted = style.is(editor.editable);
let changedElements = [];
if (isAlreadyFormatted && style.name === 'textDecorationLine') {
const decoratedPairs = new Set(selectedTextNodes.map(n => [closestElement(n, `[style*="text-decoration-line: ${style.value}"]`), n]));
for (const [closestDecorated, textNode] of decoratedPairs) {
@@ -407,6 +415,7 @@ export function toggleFormat(editor, format) {
if (!decorationToRemove.style.cssText) {
for (const child of decorationToRemove.childNodes) {
decorationToRemove.before(child);
changedElements.push(child);
}
decorationToRemove.remove();
}
@@ -429,8 +438,18 @@ export function toggleFormat(editor, format) {
} else {
setSelection(anchorNode, anchorOffset, focusNode, focusOffset);
}
} else if (format === 'switchDirection') {
const defaultDirection = editor.options.direction;
for (const block of new Set(selectedTextNodes.map(textNode => closestBlock(textNode)))) {
if (isAlreadyFormatted) {
block.removeAttribute('dir');
} else {
block.setAttribute('dir', defaultDirection === 'ltr' ? 'rtl' : 'ltr');
}
changedElements.push(block);
}
} else {
applyInlineStyle(editor, el => {
changedElements = applyInlineStyle(editor, el => {
if (isAlreadyFormatted) {
const block = closestBlock(el);
el.style[style.name] = style.is(block) ? 'normal' : getComputedStyle(block)[style.name];
@@ -464,6 +483,7 @@ export function toggleFormat(editor, format) {
}
}, format, !isAlreadyFormatted);
}
return changedElements;
}
function addColumn(editor, beforeOrAfter) {
getDeepRange(editor.editable, { select: true }); // Ensure deep range for finding td.
@@ -562,6 +582,17 @@ export const editorCommands = {
italic: editor => toggleFormat(editor, 'italic'),
underline: editor => toggleFormat(editor, 'underline'),
strikeThrough: editor => toggleFormat(editor, 'strikeThrough'),
switchDirection: editor => {
const changedElements = toggleFormat(editor, 'switchDirection');
for (const element of changedElements) {
const style = getComputedStyle(element);
if (style.direction === 'ltr' && style.textAlign === 'right') {
element.style.setProperty('text-align', 'left');
} else if (style.direction === 'rtl' && style.textAlign === 'left') {
element.style.setProperty('text-align', 'right');
}
}
},
removeFormat: editor => {
editor.document.execCommand('removeFormat');
for (const node of getTraversedNodes(editor.editable)) {
@@ -908,11 +908,27 @@ export function isStrikeThrough(node) {
}
return false;
}
/**
* Return true if the given node appears in a different direction than that of
* the editable ('ltr' or 'rtl').
*
* Note: The direction of the editable is set on its "dir" attribute, to the
* value of the "direction" option on instantiation of the editor.
*
* @param {Node} node
* @param {Element} editable
* @returns {boolean}
*/
export function isDirectionSwitched(node, editable) {
const defaultDirection = editable.getAttribute('dir');
return getComputedStyle(closestElement(node)).direction !== defaultDirection;
}
export const isFormat = {
bold: isBold,
italic: isItalic,
underline: isUnderline,
strikeThrough: isStrikeThrough,
switchDirection: isDirectionSwitched,
};
/**
* Return true if the current selection on the editable appears as the given
@@ -920,16 +936,16 @@ export const isFormat = {
* node in it appears as that format.
*
* @param {Element} editable
* @param {String} format 'bold'|'italic'|'underline'|'strikeThrought'
* @param {String} format 'bold'|'italic'|'underline'|'strikeThrough'|'switchDirection'
* @returns {boolean}
*/
export function isSelectionFormat(editable, format) {
const selectedText = getSelectedNodes(editable)
.filter(n => n.nodeType === Node.TEXT_NODE && n.nodeValue.trim().length);
if (selectedText.length) {
return selectedText.every(n => isFormat[format](n.parentElement));
return selectedText.every(n => isFormat[format](n.parentElement, editable))
} else {
return isFormat[format](closestElement(editable.ownerDocument.getSelection().anchorNode));
return isFormat[format](closestElement(editable.ownerDocument.getSelection().anchorNode), editable);
}
}
@@ -12,6 +12,7 @@ const {ColorpickerWidget} = require('web.Colorpicker');
const concurrency = require('web.concurrency');
const { device } = require('web.config');
const weContext = require('web_editor.context');
const { localization } = require('@web/core/l10n/localization');
const OdooEditorLib = require('@web_editor/../lib/odoo-editor/src/OdooEditor');
const snippetsEditor = require('web_editor.snippet.editor');
const Toolbar = require('web_editor.toolbar');
@@ -164,6 +165,7 @@ const Wysiwyg = Widget.extend({
},
commands: commands,
plugins: options.editorPlugins,
direction: localization.direction || 'ltr',
}, editorCollaborationOptions));
this.odooEditor.addEventListener('contentChanged', function () {