[IMP] web_editor: better tabs

Refactor the tabs to be :
* Automatically aligned on a 40px grid.
* A tab only need one delete to remove.
* Arrow navigation around tabs is working as intended.

task-2871848

closes odoo/odoo#99460

Signed-off-by: David Monjoie (dmo) <dmo@odoo.com>
This commit is contained in:
Sébastien Geelen (sge)
2022-09-08 11:44:57 +02:00
parent 3f9eb21571
commit c189fc1b31
7 changed files with 68 additions and 22 deletions
@@ -3031,7 +3031,7 @@ export class OdooEditor extends EventTarget {
} else if (closestTag === 'TABLE') {
this._onTabulationInTable(ev);
} else if (!ev.shiftKey) {
this.execCommand('insert', '\u00A0 \u00A0\u00A0');
this.execCommand('insert', parseHTML('<span class="oe-tabs" contenteditable="false">\u0009</span>\u200B'));
}
ev.preventDefault();
ev.stopPropagation();
@@ -3159,7 +3159,7 @@ export class OdooEditor extends EventTarget {
* @returns {boolean}
*/
isSelectionInEditable(selection) {
selection = selection || this.document.getSelection()
selection = selection || this.document.getSelection();
return selection && selection.anchorNode && this.editable.contains(selection.anchorNode) &&
this.editable.contains(selection.focusNode);
}
@@ -3260,6 +3260,10 @@ export class OdooEditor extends EventTarget {
for (const el of element.querySelectorAll('[contenteditable="false"]')) {
el.setAttribute('data-oe-keep-contenteditable', '');
}
// Flag elements .oe-tabs contenteditable=false.
for (const el of element.querySelectorAll('.oe-tabs')) {
el.setAttribute('contenteditable', 'false');
}
}
cleanForSave(element = this.editable) {
@@ -5,3 +5,9 @@ li.oe-nested {
padding: 0.5rem;
border-color: #c9ccd2;
}
.oe-tabs {
display: inline-block;
white-space: pre-wrap;
max-width: 40px;
width: 40px;
}
@@ -12,18 +12,15 @@ import {
getState,
isBlock,
isEmptyBlock,
isInPre,
isUnbreakable,
isUnremovable,
isVisible,
isVisibleStr,
leftPos,
rightPos,
moveNodes,
nodeSize,
prepareUpdate,
setSelection,
splitTextNode,
isMediaElement,
isVisibleEmpty,
isNotEditableNode,
@@ -71,8 +71,8 @@ Text.prototype.oDeleteForward = function (offset, alreadyMoved = false) {
const parentElement = this.parentElement;
if (offset === this.nodeValue.length) {
// Delete at the end of a text node is not a specific case to
// handle, let the element implementation handle it.
// Delete at the end of a text node is not a specific case to handle,
// let the element implementation handle it.
parentElement.oDeleteForward([...parentElement.childNodes].indexOf(this) + 1);
return;
}
@@ -124,7 +124,14 @@ HTMLElement.prototype.oDeleteForward = function (offset) {
}
if (firstLeafNode && (isFontAwesome(firstLeafNode) || isNotEditableNode(firstLeafNode))) {
const nextSibling = firstLeafNode.nextSibling;
const nextSiblingText = nextSibling ? nextSibling.textContent : '';
firstLeafNode.remove();
if (isEditorTab(firstLeafNode) && nextSiblingText[0] === '\u200B') {
// When deleting an editor tab, we need to ensure it's related ZWS
// il deleted as well.
nextSibling.textContent = nextSiblingText.replace('\u200B', '');
}
return;
}
if (
@@ -1,6 +1,7 @@
/** @odoo-module **/
import {
closestBlock,
closestElement,
endPos,
fillEmpty,
getListMode,
@@ -13,7 +14,8 @@ import {
isMediaElement,
getDeepRange,
isUnbreakable,
closestElement,
isEditorTab,
isZWS,
getUrlsInfosInString,
URL_REGEX,
} from './utils.js';
@@ -107,10 +109,11 @@ class Sanitize {
_parse(node) {
while (node) {
// Merge identical elements together
// Merge identical elements together.
while (
areSimilarElements(node, node.previousSibling) &&
!isUnbreakable(node)
!isUnbreakable(node) &&
!isEditorTab(node)
) {
getDeepRange(this.root, { select: true });
const restoreCursor = node.isConnected &&
@@ -184,6 +187,30 @@ class Sanitize {
node.textContent = '\u200B';
}
// Ensure the editor tabs align on a 40px grid.
if (isEditorTab(node)) {
let tabPreviousSibling = node.previousSibling;
while (isZWS(tabPreviousSibling)) {
tabPreviousSibling = tabPreviousSibling.previousSibling;
}
if (isEditorTab(tabPreviousSibling)) {
node.style.width = '40px';
} else {
const editable = closestElement(node, '.odoo-editor-editable', true);
if (editable && editable.firstElementChild) {
const nodeRect = node.getBoundingClientRect();
const referenceRect = editable.firstElementChild.getBoundingClientRect();
// Values from getBoundingClientRect() are all zeros
// during Editor startup or saving. We cannot
// recalculate the tabs width in thoses cases.
if (nodeRect.width && referenceRect.width) {
const width = (nodeRect.left - referenceRect.left) % 40;
node.style.width = (40 - width) + 'px';
}
}
}
}
// Ensure elements which should not contain any content are tagged
// contenteditable=false to avoid any hiccup.
if (
@@ -1120,6 +1120,13 @@ export function isZWS(node) {
node.textContent === '\u200B'
);
}
export function isEditorTab(node) {
return (
node &&
(node.nodeName === 'SPAN') &&
node.classList.contains('oe-tabs')
);
}
export function isMediaElement(node) {
return (
isFontAwesome(node) ||
@@ -1284,7 +1291,8 @@ export function isInPre(node) {
* Returns whether the given string (or given text node value)
* has at least one visible character or one non colapsed whitespace characters in it.
*/
const nonWhitespacesRegex = /[\S\u00A0]/;
const nonWhitespaces = '\\S\\u00A0\\u0009';
const nonWhitespacesRegex = new RegExp(`[${nonWhitespaces}]`);
export function isVisibleStr(value) {
const str = typeof value === 'string' ? value : value.nodeValue;
return nonWhitespacesRegex.test(str);
@@ -1511,9 +1519,9 @@ export function isColorGradient(value) {
* Note: if split after or before whitespace, that whitespace may become
* invisible, it is up to the caller to replace it by nbsp if needed.
*
* @param {Text} textNode
* @param {Node} textNode
* @param {number} offset
* @param {DIRECTION} originalNodeSide Whether the original node ends up on left
* @param {DIRECTIONS} originalNodeSide Whether the original node ends up on left
* or right after the split
* @returns {number} The parentOffset if the cursor was between the two text
* node parts after the split.
@@ -1835,8 +1843,8 @@ export function prepareUpdate(...args) {
*
* @param {HTMLElement} el
* @param {number} offset
* @param {number} direction @see DIRECTIONS.LEFT @see DIRECTIONS.RIGHT
* @param {CTYPES} leftCType
* @param {DIRECTIONS} direction @see DIRECTIONS.LEFT @see DIRECTIONS.RIGHT
* @param {CTYPES} [leftCType]
* @returns {Object}
*/
export function getState(el, offset, direction, leftCType) {
@@ -1850,11 +1858,11 @@ export function getState(el, offset, direction, leftCType) {
if (direction === DIRECTIONS.LEFT) {
domPath = leftDOMPath(el, offset, reasons);
inverseDOMPath = rightDOMPath(el, offset);
expr = /[^\S\u00A0]$/;
expr = new RegExp(`[^${nonWhitespaces}]$`);
} else {
domPath = rightDOMPath(el, offset, reasons);
inverseDOMPath = leftDOMPath(el, offset);
expr = /^[^\S\u00A0]/;
expr = new RegExp(`^[^${nonWhitespaces}]`);
}
// TODO I think sometimes, the node we have to consider as the
@@ -2092,10 +2100,10 @@ export function enforceWhitespace(el, offset, direction, rule) {
let expr;
if (direction === DIRECTIONS.LEFT) {
domPath = leftLeafOnlyNotBlockPath(el, offset);
expr = /[^\S\u00A0]+$/;
expr = new RegExp(`[^${nonWhitespaces}]+$`);
} else {
domPath = rightLeafOnlyNotBlockPath(el, offset);
expr = /^[^\S\u00A0]+/;
expr = new RegExp(`^[^${nonWhitespaces}]+`);
}
const invisibleSpaceTextNodes = [];
@@ -1,13 +1,10 @@
import { OdooEditor } from '../../src/OdooEditor.js';
import {
BasicEditor,
deleteBackward,
deleteForward,
insertText,
triggerEvent,
redo,
testEditor,
undo,
} from '../utils.js';
describe('Tabs', () => {