[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:
@@ -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 () {
|
||||
|
||||
Reference in New Issue
Block a user