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