diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/src/OdooEditor.js b/addons/web_editor/static/src/js/editor/odoo-editor/src/OdooEditor.js
index 68c8de379ab..15be69e02cf 100644
--- a/addons/web_editor/static/src/js/editor/odoo-editor/src/OdooEditor.js
+++ b/addons/web_editor/static/src/js/editor/odoo-editor/src/OdooEditor.js
@@ -37,6 +37,8 @@ import {
isFontAwesome,
getInSelection,
getDeepRange,
+ getRowIndex,
+ getColumnIndex,
ancestors,
firstLeaf,
previousLeaf,
@@ -56,6 +58,13 @@ import {
getAdjacentNextSiblings,
rightLeafOnlyNotBlockPath,
isBlock,
+ getTraversedNodes,
+ getSelectedNodes,
+ isVisibleTextNode,
+ descendants,
+ hasValidSelection,
+ hasTableSelection,
+ pxToFloat,
} from './utils/utils.js';
import { editorCommands } from './commands/commands.js';
import { Powerbox } from './powerbox/Powerbox.js';
@@ -158,6 +167,9 @@ export const CLIPBOARD_WHITELISTS = {
}
};
+// Commands that don't require a DOM selection but take an argument instead.
+const SELECTIONLESS_COMMANDS = ['addRow', 'addColumn', 'removeRow', 'removeColumn'];
+
function defaultOptions(defaultObject, object) {
const newObject = Object.assign({}, defaultObject, object);
for (const [key, value] of Object.entries(object)) {
@@ -307,7 +319,11 @@ export class OdooEditor extends EventTarget {
this._pluginCall('sanitizeElement', [editable]);
- // Create the Powerbox's table picker.
+ // ------
+ // Tables
+ // ------
+
+ // Create the table picker for the Powerbox.
this.powerboxTablePicker = new TablePicker({
document: this.document,
floating: true,
@@ -319,7 +335,78 @@ export class OdooEditor extends EventTarget {
colNumber: ev.detail.colNumber,
});
});
- // Initialize the Powerbox.
+ // Create the table picker for the toolbar.
+ this.toolbarTablePicker = new TablePicker({ document: this.document });
+ this.toolbarTablePicker.addEventListener('cell-selected', ev => {
+ this.execCommand('insertTable', {
+ rowNumber: ev.detail.rowNumber,
+ colNumber: ev.detail.colNumber,
+ });
+ });
+ // Create the table UI.
+ const parser = new DOMParser();
+ for (const direction of ['row', 'column']) {
+ // Create the containers and the menu toggler.
+ const ui = parser.parseFromString(`
`, 'text/html').body.firstElementChild;
+ const uiMenu = ui.querySelector('.o_table_ui_menu');
+
+ // Create the move buttons.
+ if (direction === 'column') {
+ uiMenu.append(...parser.parseFromString(`
+ Move left
+ Move right
+ `, 'text/html').body.children);
+ this.addDomListener(uiMenu.querySelector('.o_move_left'), 'click', this._onTableMoveLeftClick);
+ this.addDomListener(uiMenu.querySelector('.o_move_right'), 'click', this._onTableMoveRightClick);
+ } else {
+ uiMenu.append(...parser.parseFromString(`
+ Move up
+ Move down
+ `, 'text/html').body.children);
+ this.addDomListener(uiMenu.querySelector('.o_move_up'), 'click', this._onTableMoveUpClick);
+ this.addDomListener(uiMenu.querySelector('.o_move_down'), 'click', this._onTableMoveDownClick);
+ }
+
+ // Create the add buttons.
+ if (direction === 'column') {
+ uiMenu.append(...parser.parseFromString(`
+ Insert left
+ Insert right
+ `, 'text/html').body.children);
+ this.addDomListener(uiMenu.querySelector('.o_insert_left'), 'click', () => this.execCommand('addColumn', 'before', this._columnUiTarget));
+ this.addDomListener(uiMenu.querySelector('.o_insert_right'), 'click', () => this.execCommand('addColumn', 'after', this._columnUiTarget));
+ } else {
+ uiMenu.append(...parser.parseFromString(`
+ Insert above
+ Insert below
+ `, 'text/html').body.children);
+ this.addDomListener(uiMenu.querySelector('.o_insert_above'), 'click', () => this.execCommand('addRow', 'before', this._rowUiTarget));
+ this.addDomListener(uiMenu.querySelector('.o_insert_below'), 'click', () => this.execCommand('addRow', 'after', this._rowUiTarget));
+ }
+
+ // Add the delete button.
+ if (direction === 'column') {
+ uiMenu.append(parser.parseFromString(`Delete
`, 'text/html').body.firstChild)
+ this.addDomListener(uiMenu.querySelector('.o_delete_column'), 'click', this._onTableDeleteColumnClick);
+ } else {
+ uiMenu.append(parser.parseFromString(`Delete
`, 'text/html').body.firstChild)
+ this.addDomListener(uiMenu.querySelector('.o_delete_row'), 'click', this._onTableDeleteRowClick);
+ }
+
+ this[`_${direction}Ui`] = ui;
+ this.document.body.append(ui);
+ this.addDomListener(ui.querySelector('.o_table_ui_menu_toggler'), 'click', this._onTableMenuTogglerClick);
+ }
+
+ // --------
+ // Powerbox
+ // --------
+
let beforeStepIndex;
this.powerbox = new Powerbox({
editable: this.editable,
@@ -484,14 +571,6 @@ export class OdooEditor extends EventTarget {
],
});
- this.toolbarTablePicker = new TablePicker({ document: this.document });
- this.toolbarTablePicker.addEventListener('cell-selected', ev => {
- this.execCommand('insertTable', {
- rowNumber: ev.detail.rowNumber,
- colNumber: ev.detail.colNumber,
- });
- });
-
// -----------
// Bind events
// -----------
@@ -510,8 +589,10 @@ export class OdooEditor extends EventTarget {
this.addDomListener(this.document, 'selectionchange', this._handleCommandHint);
this.addDomListener(this.document, 'keydown', this._onDocumentKeydown);
this.addDomListener(this.document, 'keyup', this._onDocumentKeyup);
- this.addDomListener(this.document, 'mousedown', this._onDoumentMousedown);
- this.addDomListener(this.document, 'mouseup', this._onDoumentMouseup);
+ this.addDomListener(this.document, 'mousedown', this._onDocumentMousedown);
+ this.addDomListener(this.document, 'mouseup', this._onDocumentMouseup);
+ this.addDomListener(this.document, 'mousemove', this._onDocumentMousemove);
+ this.addDomListener(this.document, 'click', this._onDocumentClick);
this.multiselectionRefresh = this.multiselectionRefresh.bind(this);
this._resizeObserver = new ResizeObserver(this.multiselectionRefresh);
@@ -583,6 +664,9 @@ export class OdooEditor extends EventTarget {
clearInterval(this._snapshotInterval);
this._pluginCall('destroy', []);
this.isDestroyed = true;
+ // Remove table UI
+ this._rowUi.remove();
+ this._columnUi.remove();
}
sanitize() {
@@ -1145,6 +1229,7 @@ export class OdooEditor extends EventTarget {
}
}
historySetSelection(step) {
+ this.deselectTable();
if (step.selection && step.selection.anchorNodeOid) {
const anchorNode = this.idFind(step.selection.anchorNodeOid);
const focusNode = this.idFind(step.selection.focusNodeOid) || anchorNode;
@@ -1158,6 +1243,8 @@ export class OdooEditor extends EventTarget {
: step.selection.anchorOffset,
false,
);
+ // If a table must be selected, ensure it's in the same tick.
+ this._handleSelectionInTable();
}
}
}
@@ -1508,6 +1595,23 @@ export class OdooEditor extends EventTarget {
}
}
+ /**
+ * Remove any custom table selection from the editor.
+ *
+ * @returns {boolean} true if a table was deselected
+ */
+ deselectTable() {
+ let didDeselectTable = false;
+ for (const td of this.editable.querySelectorAll('.o_selected_table, .o_selected_td')) {
+ td.classList.remove('o_selected_td', 'o_selected_table');
+ if (!td.classList.length) {
+ td.removeAttribute('class');
+ }
+ didDeselectTable = true;
+ }
+ return didDeselectTable;
+ }
+
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
@@ -1559,7 +1663,6 @@ export class OdooEditor extends EventTarget {
closestBlock(start) !== closestBlock(range.commonAncestorContainer) ||
closestBlock(end) !== closestBlock(range.commonAncestorContainer) ;
let next = nextLeaf(end, this.editable);
- const splitEndTd = closestElement(end, 'td') && end.nextSibling;
const contents = range.extractContents();
setSelection(start, nodeSize(start));
range = getDeepRange(this.editable, { sel });
@@ -1568,26 +1671,6 @@ export class OdooEditor extends EventTarget {
closestBlock(range.endContainer).after(n);
n.textContent = '';
});
- // Restore table contents removed by extractContents.
- const tds = [...contents.querySelectorAll('td')].filter(n => !closestElement(n, 'table'));
- let currentFragmentTr, currentTr;
- const currentTd = closestElement(range.endContainer, 'td');
- tds.forEach((td, i) => {
- const parentFragmentTr = closestElement(td, 'tr');
- // Skip the first and the last partially selected TD.
- if (i && !(splitEndTd && i === tds.length - 1)) {
- if (parentFragmentTr !== currentFragmentTr) {
- currentTr = currentTr
- ? currentTr.nextElementSibling
- : closestElement(range.endContainer, 'tr').nextElementSibling;
- }
- currentTr ? currentTr.prepend(td) : currentTd.after(td);
- }
- currentFragmentTr = parentFragmentTr;
- td.textContent = '';
- });
- this.observerFlush();
- this._toRollback = false; // Errors caught with observerFlush were already handled.
// If the end container was fully selected, extractContents may have
// emptied it without removing it. Ensure it's gone.
const isRemovableInvisible = (node, noBlocks = true) =>
@@ -1755,8 +1838,10 @@ export class OdooEditor extends EventTarget {
_applyRawCommand(method, ...args) {
const sel = this.document.getSelection();
if (
- !this.editable.contains(sel.anchorNode) ||
- (sel.anchorNode !== sel.focusNode && !this.editable.contains(sel.focusNode))
+ !(SELECTIONLESS_COMMANDS.includes(method) && args.length) && (
+ !this.editable.contains(sel.anchorNode) ||
+ (sel.anchorNode !== sel.focusNode && !this.editable.contains(sel.focusNode))
+ )
) {
// Do not apply commands out of the editable area.
return false;
@@ -1872,6 +1957,332 @@ export class OdooEditor extends EventTarget {
this.observerActive('_stopContenteditable');
}
+ // TABLE MANAGEMENT
+ // ================
+
+ /**
+ * Handle the selection of table cells rectangularly (as opposed to line by
+ * line from left to right then top to bottom). If such a special selection
+ * was indeed applied, return true (and false otherwise).
+ *
+ * @private
+ * @param {MouseEvent|undefined} [ev]
+ * @returns {boolean}
+ */
+ _handleSelectionInTable(ev=undefined) {
+ this.deselectTable();
+ const traversedNodes = getTraversedNodes(this.editable);
+ if (this._isResizingTable || !traversedNodes.some(node => !!closestElement(node, 'td'))) {
+ return false;
+ }
+ const selection = this.document.getSelection();
+ let range;
+ if (selection.rangeCount > 1) {
+ // Firefox selection in table works with multiple ranges.
+ const startRange = getDeepRange(this.editable, {range: selection.getRangeAt(0)});
+ const endRange = getDeepRange(this.editable, {range: selection.getRangeAt(selection.rangeCount - 1)});
+ range = this.document.createRange();
+ range.setStart(startRange.startContainer, 0);
+ range.setEnd(endRange.startContainer, 0);
+ } else {
+ range = getDeepRange(this.editable);
+ }
+ const startTd = closestElement(range.startContainer, 'td');
+ const endTd = closestElement(range.endContainer, 'td');
+ let appliedCustomSelection = false;
+ const startTable = closestElement(range.startContainer, 'table');
+ const endTable = closestElement(range.endContainer, 'table');
+ if (startTd !== endTd && startTable === endTable) {
+ // The selection goes through at least two different cells -> select
+ // cells.
+ this._selectTableCells(range);
+ appliedCustomSelection = true;
+ } else if (!traversedNodes.every(node => !!closestElement(node, 'td'))) {
+ // The selection goes through a table but also outside of it ->
+ // select the whole table.
+ this.historyPauseSteps('handleSelectionInTable');
+ for (const table of new Set(traversedNodes.map(node => closestElement(node, 'table')))) {
+ if (table) {
+ table.classList.toggle('o_selected_table', true);
+ for (const td of table.querySelectorAll('td')) {
+ td.classList.toggle('o_selected_td', true);
+ }
+ appliedCustomSelection = true;
+ }
+ }
+ } else if (ev) {
+ // We're redirected from a mousemove event.
+ const selectedNodes = getSelectedNodes(this.editable);
+ const areCellContentsFullySelected = !!startTd && descendants(startTd).filter(d => !isBlock(d)).every(child => selectedNodes.includes(child));
+ if (areCellContentsFullySelected) {
+ const SENSITIVITY = 5;
+ const rangeRect = range.getBoundingClientRect();
+ const isMovingAwayFromSelection = ev.clientX > rangeRect.x + rangeRect.width + SENSITIVITY // moving right
+ || ev.clientX < rangeRect.x - SENSITIVITY; // moving left
+ if (isMovingAwayFromSelection) {
+ // A cell is fully selected and the mouse is moving away
+ // from the selection, within said cell -> select the cell.
+ this._selectTableCells(range);
+ appliedCustomSelection = true;
+ }
+ } else if (!descendants(startTd).some(child => isVisibleTextNode(child) && child.textContent !== '\u200B') &&
+ ev.clientX - (this._lastMouseClickPosition ? this._lastMouseClickPosition[0] : ev.clientX) >= 15
+ ) {
+ // Handle selecting an empty cell.
+ this._selectTableCells(range);
+ appliedCustomSelection = true;
+ }
+ }
+ return appliedCustomSelection;
+ }
+ /**
+ * Helper function to `_handleSelectionInTable`. Do the actual selection of
+ * cells in a table based on the current range.
+ *
+ * @private
+ * @see _handleSelectionInTable
+ * @param {Range} range
+ */
+ _selectTableCells(range) {
+ this.historyPauseSteps('handleSelectionInTable');
+ const table = closestElement(range.startContainer, 'table');
+ const alreadyHadSelection = table.classList.contains('o_selected_table');
+ this.deselectTable(); // Undo previous selection.
+ table.classList.toggle('o_selected_table', true);
+ const columns = table.querySelectorAll('td');
+ const startCol = closestElement(range.startContainer, 'td') || columns[0];
+ const endCol = closestElement(range.endContainer, 'td') || columns[columns.length - 1];
+ const [startRow, endRow] = [closestElement(startCol, 'tr'), closestElement(range.endContainer, 'tr')];
+ const [startColIndex, endColIndex] = [getColumnIndex(startCol), getColumnIndex(endCol)];
+ const [startRowIndex, endRowIndex] = [getRowIndex(startRow), getRowIndex(endRow)];
+ const [minRowIndex, maxRowIndex] = [Math.min(startRowIndex, endRowIndex), Math.max(startRowIndex, endRowIndex)];
+ const [minColIndex, maxColIndex] = [Math.min(startColIndex, endColIndex), Math.max(startColIndex, endColIndex)];
+ // Create an array of arrays of tds (each of which is a row).
+ const grid = [...table.querySelectorAll('tr')].map(tr => [...tr.children].filter(child => child.nodeName === 'TD'));
+ for (const tds of grid.filter((_, index) => index >= minRowIndex && index <= maxRowIndex)) {
+ for (const td of tds.filter((_, index) => index >= minColIndex && index <= maxColIndex)) {
+ td.classList.toggle('o_selected_td', true);
+ }
+ }
+ if (!alreadyHadSelection) {
+ this.toolbarShow();
+ }
+ }
+ /**
+ * If the mouse is hovering over one of the borders of a table cell element,
+ * return the side of that border ('left'|'top'|'right'|'bottom').
+ * Otherwise, return false.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ * @returns {boolean}
+ */
+ _isHoveringTdBorder(ev) {
+ if (ev.target && ev.target.nodeName === 'TD') {
+ const SENSITIVITY = 5;
+ const targetRect = ev.target.getBoundingClientRect();
+ if (ev.clientX <= targetRect.x + SENSITIVITY) {
+ return 'left';
+ } else if (ev.clientY <= targetRect.y + SENSITIVITY) {
+ return 'top';
+ } else if (ev.clientX >= targetRect.x + ev.target.clientWidth - SENSITIVITY) {
+ return 'right';
+ } else if (ev.clientY >= targetRect.y + ev.target.clientHeight - SENSITIVITY) {
+ return 'bottom';
+ }
+ }
+ return false;
+ }
+ /**
+ * Change the cursor to a resizing cursor, in the direction specified. If no
+ * direction is specified, return the cursor to its default.
+ *
+ * @private
+ * @param {'col'|'row'|false} direction 'col'/'row' to hint column/row,
+ * false to remove the hints
+ */
+ _toggleTableResizeCursor(direction) {
+ this.editable.classList.remove('o_col_resize', 'o_row_resize');
+ if (direction === 'col') {
+ this.editable.classList.add('o_col_resize');
+ } else if (direction === 'row') {
+ this.editable.classList.add('o_row_resize');
+ }
+ }
+ /**
+ * Resizes a table in the given direction, by "pulling" the border between
+ * the given targets (ordered left to right or top to bottom).
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _resizeTable(ev, direction, target1, target2) {
+ ev.preventDefault();
+ const position = target1 ? (target2 ? 'middle' : 'last') : 'first';
+ let [item, neighbor] = [target1 || target2, target2];
+ const table = closestElement(item, 'table');
+ const [sizeProp, positionProp, clientPositionProp] = direction === 'col' ? ['width', 'x', 'clientX'] : ['height', 'y', 'clientY'];
+
+ // Preserve current sizes.
+ const tableRect = table.getBoundingClientRect();
+ table.style[sizeProp] = tableRect[sizeProp] + 'px';
+ const unsizedItemsSelector = `${direction === 'col' ? 'td' : 'tr'}:not([style*=${sizeProp}])`;
+ for (const unsizedItem of table.querySelectorAll(unsizedItemsSelector)) {
+ unsizedItem.style[sizeProp] = unsizedItem.getBoundingClientRect()[sizeProp] + 'px';
+ }
+
+ // TD widths should only be applied in the first row. Change targets and
+ // clean the rest.
+ if (direction === 'col') {
+ let hostCell = closestElement(table, 'td');
+ const hostCells = [];
+ while (hostCell) {
+ hostCells.push(hostCell);
+ hostCell = closestElement(hostCell.parentElement, 'td');
+ }
+ const nthColumn = getColumnIndex(item);
+ const firstRow = [...table.querySelector('tr').children];
+ [item, neighbor] = [firstRow[nthColumn], firstRow[nthColumn + 1]];
+ for (const td of hostCells) {
+ if (td !== item && td !== neighbor && closestElement(td, 'table') === table && getColumnIndex(td) !== 0) {
+ td.style.removeProperty(sizeProp);
+ }
+ }
+ }
+
+ const MIN_SIZE = 33; // TODO: ideally, find this value programmatically.
+ switch (position) {
+ case 'first': {
+ const marginProp = direction === 'col' ? 'marginLeft' : 'marginTop';
+ const itemRect = item.getBoundingClientRect();
+ const tableStyle = getComputedStyle(table);
+ const currentMargin = pxToFloat(tableStyle[marginProp]);
+ const sizeDelta = itemRect[positionProp] - ev[clientPositionProp];
+ const newMargin = currentMargin - sizeDelta;
+ const currentSize = itemRect[sizeProp];
+ const newSize = currentSize + sizeDelta;
+ if (newMargin >= 0 && newSize > MIN_SIZE) {
+ const tableRect = table.getBoundingClientRect();
+ // Check if a nested table would overflow its parent cell.
+ const hostCell = closestElement(table.parentElement, 'td');
+ const childTable = item.querySelector('table');
+ if (direction === 'col' &&
+ (hostCell && tableRect.right + sizeDelta > hostCell.getBoundingClientRect().right - 5 ||
+ childTable && childTable.getBoundingClientRect().right > itemRect.right + sizeDelta - 5)) {
+ break;
+ }
+ table.style[marginProp] = newMargin + 'px';
+ item.style[sizeProp] = newSize + 'px';
+ table.style[sizeProp] = tableRect[sizeProp] + sizeDelta + 'px';
+ }
+ break;
+ }
+ case 'middle': {
+ const [itemRect, neighborRect] = [item.getBoundingClientRect(), neighbor.getBoundingClientRect()];
+ const [currentSize, newSize] = [itemRect[sizeProp], ev[clientPositionProp] - itemRect[positionProp]];
+ const editableStyle = getComputedStyle(this.editable);
+ const sizeDelta = newSize - currentSize;
+ const currentNeighborSize = neighborRect[sizeProp];
+ const newNeighborSize = currentNeighborSize - sizeDelta;
+ const maxWidth = this.editable.clientWidth - pxToFloat(editableStyle.paddingLeft) - pxToFloat(editableStyle.paddingRight);
+ const tableRect = table.getBoundingClientRect();
+ if (newSize > MIN_SIZE &&
+ // prevent resizing horizontally beyond the bounds of
+ // the editable:
+ (direction === 'row' ||
+ newNeighborSize > MIN_SIZE ||
+ tableRect[sizeProp] + sizeDelta < maxWidth)) {
+
+ // Check if a nested table would overflow its parent cell.
+ const childTable = item.querySelector('table');
+ if (direction === 'col' &&
+ childTable && childTable.getBoundingClientRect().right > itemRect.right + sizeDelta - 5) {
+ break
+ }
+ item.style[sizeProp] = newSize + 'px';
+ if (direction === 'col') {
+ neighbor.style[sizeProp] = (newNeighborSize > MIN_SIZE ? newNeighborSize : currentNeighborSize) + 'px';
+ } else {
+ table.style[sizeProp] = tableRect[sizeProp] + sizeDelta + 'px';
+ }
+ }
+ break;
+ }
+ case 'last': {
+ const itemRect = item.getBoundingClientRect();
+ const sizeDelta = ev[clientPositionProp] - (itemRect[positionProp] + itemRect[sizeProp]); // todo: rephrase
+ const currentSize = itemRect[sizeProp];
+ const newSize = currentSize + sizeDelta;
+ if ((newSize >= 0 || direction === 'row') && newSize > MIN_SIZE) {
+ const tableRect = table.getBoundingClientRect();
+ // Check if a nested table would overflow its parent cell.
+ const hostCell = closestElement(table.parentElement, 'td');
+ const childTable = item.querySelector('table');
+ if (direction === 'col' &&
+ (hostCell && tableRect.right + sizeDelta > hostCell.getBoundingClientRect().right - 5 ||
+ childTable && childTable.getBoundingClientRect().right > itemRect.right + sizeDelta - 5)) {
+ break
+ }
+ table.style[sizeProp] = tableRect[sizeProp] + sizeDelta + 'px';
+ item.style[sizeProp] = newSize + 'px';
+ }
+ break;
+ }
+ }
+ }
+ /**
+ * Show/hide and position the table row/column manipulation UI.
+ *
+ * @private
+ * @param {HTMLTableRowElement} [row=false]
+ * @param {HTMLTableCellElement} [column=false]
+ */
+ _toggleTableUi(row=false, column=false) {
+ if (row) {
+ this._rowUi.style.visibility = 'visible';
+ this._rowUiTarget = row;
+ this._positionTableUi(row);
+ } else {
+ this._rowUi.style.visibility = 'hidden';
+ }
+ if (column) {
+ this._columnUi.style.visibility = 'visible';
+ this._columnUiTarget = column;
+ this._positionTableUi(column);
+ } else {
+ this._columnUi.style.visibility = 'hidden';
+ }
+ }
+ /**
+ * Position the table row/column tools (depending on whether a row or a cell
+ * is passed as argument).
+ *
+ * @private
+ * @param {HTMLTableRowElement|HTMLTableCellElement} element
+ */
+ _positionTableUi(element) {
+ const isRow = element.nodeName === 'TR';
+ const ui = isRow ? this._rowUi : this._columnUi;
+ const elementRect = element.getBoundingClientRect();
+ const tableRect = closestElement(element, 'table').getBoundingClientRect();
+ const wrappedUi = ui.firstElementChild;
+ const togglerRect = ui.querySelector('.o_table_ui_menu_toggler').getBoundingClientRect();
+ const props = {
+ xy: {left: 'x', top: 'y'},
+ size: {left: 'width', top: 'height'}
+ };
+
+ const side1 = isRow ? 'left' : 'top';
+ ui.style[side1] = (isRow ? elementRect : tableRect)[props.xy[side1]] - togglerRect[props.size[side1]] + 'px';
+ wrappedUi.style[side1] = (togglerRect[props.size[side1]] / 2) + 'px';
+ ui.style[props.size[side1]] = togglerRect[props.size[side1]] + 'px';
+
+ const side2 = isRow ? 'top' : 'left';
+ ui.style[side2] = elementRect[props.xy[side2]] + 'px';
+ wrappedUi.style[side2] = (elementRect[props.size[side2]] / 2) - (togglerRect[props.size[side2]] / 2) + 'px';
+ ui.style[props.size[side2]] = elementRect[props.size[side2]] + 'px';
+ }
+
// HISTORY
// =======
@@ -2007,14 +2418,19 @@ export class OdooEditor extends EventTarget {
}
const sel = this.document.getSelection();
- if (!sel.anchorNode) {
- show = false;
- } else {
- const selAncestors = [sel.anchorNode, ...ancestors(sel.anchorNode, this.editable)];
- const isInStars = selAncestors.some(node => node.classList && node.classList.contains('o_stars'));
- if (isInStars) {
+ if (!hasTableSelection(this.editable)) {
+ if (this.editable.classList.contains('o_col_resize') || this.editable.classList.contains('o_row_resize')) {
show = false;
}
+ if (!sel.anchorNode) {
+ show = false;
+ } else {
+ const selAncestors = [sel.anchorNode, ...ancestors(sel.anchorNode, this.editable)];
+ const isInStars = selAncestors.some(node => node.classList && node.classList.contains('o_stars'));
+ if (isInStars) {
+ show = false;
+ }
+ }
}
if (this.options.autohideToolbar) {
if (show !== undefined && !this.isMobile) {
@@ -2162,7 +2578,14 @@ export class OdooEditor extends EventTarget {
// height to be 0. In that case, use the rect of the startContainer if
// possible.
const isSelectionPotentiallyBugged = [selRect.x, selRect.y, selRect.width, selRect.height].every( x => x === 0 );
- const correctedSelectionRect = isSelectionPotentiallyBugged && startRect ? startRect : selRect;
+ let correctedSelectionRect = isSelectionPotentiallyBugged && startRect ? startRect : selRect;
+ const selAncestors = [sel.anchorNode, ...ancestors(sel.anchorNode, this.editable)];
+ // If a table is selected, we want to position the toolbar in function
+ // of the table, rather than follow the DOM selection.
+ const selectedTable = selAncestors.find(node => node.classList && node.classList.contains('o_selected_table'));
+ if (selectedTable) {
+ correctedSelectionRect = selectedTable.getBoundingClientRect();
+ }
const toolbarWidth = this.toolbar.offsetWidth;
const toolbarHeight = this.toolbar.offsetHeight;
const editorRect = this.editable.getBoundingClientRect();
@@ -2224,7 +2647,7 @@ export class OdooEditor extends EventTarget {
container.innerHTML = clipboardData;
for (const tableElement of container.querySelectorAll('table')) {
- tableElement.classList.add('table', 'table-bordered');
+ tableElement.classList.add('table', 'table-bordered', 'o_table');
}
for (const child of [...container.childNodes]) {
@@ -2524,11 +2947,52 @@ export class OdooEditor extends EventTarget {
this.deleteRange(selection);
}
}
- if (ev.key === 'Backspace') {
+ const selectedTds = this.editable.querySelectorAll('.o_selected_td');
+ if ((ev.key === 'Backspace' || ev.key === 'Delete') && selectedTds.length) {
+ // backspace/delete with custom selected cells
+ ev.preventDefault();
+ this.historyPauseSteps();
+ const rows = [...closestElement(selectedTds[0], 'tr').parentElement.children].filter(child => child.nodeName === 'TR');
+ const firstRowCells = [...rows[0].children].filter(child => child.nodeName === 'TD' || child.nodeName === 'TH');
+ const areFullColumnsSelected = getRowIndex(selectedTds[0]) === 0 && getRowIndex(selectedTds[selectedTds.length - 1]) === rows.length - 1;
+ const areFullRowsSelected = getColumnIndex(selectedTds[0]) === 0 && getColumnIndex(selectedTds[selectedTds.length - 1]) === firstRowCells.length - 1;
+ if (areFullColumnsSelected || areFullRowsSelected) {
+ // If some full columns are selected, remove them.
+ if (areFullColumnsSelected) {
+ const startIndex = getColumnIndex(selectedTds[0]);
+ let endIndex = getColumnIndex(selectedTds[selectedTds.length - 1]);
+ let currentIndex = startIndex;
+ while (currentIndex <= endIndex) {
+ this.execCommand('removeColumn', firstRowCells[currentIndex]);
+ currentIndex++;
+ }
+ }
+ // If some full rows are selected, remove them.
+ if (areFullRowsSelected) {
+ const startIndex = getRowIndex(selectedTds[0]);
+ let endIndex = getRowIndex(selectedTds[selectedTds.length - 1]);
+ let currentIndex = startIndex;
+ while (currentIndex <= endIndex) {
+ this.execCommand('removeRow', rows[currentIndex]);
+ currentIndex++;
+ }
+ }
+ // If all rows, remove the table.
+ if (rows.every(row => !row.parentElement)) {
+ this.execCommand('deleteTable', this.editable.querySelector('.o_selected_table'));
+ }
+ }
+ this.deleteRange();
+ if (this.deselectTable() && hasValidSelection(this.editable)) {
+ this.document.getSelection().collapseToStart();
+ }
+ this.historyUnpauseSteps();
+ this.historyStep();
+ } else if (ev.key === 'Backspace') {
// backspace
const selection = this.document.getSelection();
if (!ev.ctrlKey && !ev.metaKey) {
- if (selection.isCollapsed) {
+ if (selection.isCollapsed) {
// We need to hijack it because firefox doesn't trigger a
// deleteBackward input event with a collapsed selection in
// front of a contentEditable="false" (eg: font awesome).
@@ -2656,17 +3120,19 @@ export class OdooEditor extends EventTarget {
this._computeHistorySelection();
const selection = this.document.getSelection();
- this._updateToolbar(!selection.isCollapsed && this.isSelectionInEditable(selection));
if (this._currentMouseState === 'mouseup') {
this._fixFontAwesomeSelection();
}
- if (
- selection.rangeCount &&
- selection.getRangeAt(0) &&
- this.options.onCollaborativeSelectionChange
- ) {
- this.options.onCollaborativeSelectionChange(this.getCurrentCollaborativeSelection());
+ let appliedCustomSelection = false;
+ if (selection.rangeCount && selection.getRangeAt(0)) {
+ appliedCustomSelection = this._handleSelectionInTable();
+ if (this.options.onCollaborativeSelectionChange) {
+ this.options.onCollaborativeSelectionChange(this.getCurrentCollaborativeSelection());
+ }
+ }
+ if (!appliedCustomSelection) {
+ this._updateToolbar(!selection.isCollapsed && this.isSelectionInEditable(selection));
}
}
@@ -2814,12 +3280,16 @@ export class OdooEditor extends EventTarget {
el.removeAttribute('data-oe-keep-contenteditable');
}
- // Remove Zero Width Spzces on Font awesome elements
+ // Remove Zero Width Spaces on Font awesome elements
const faSelector = 'i.fa,span.fa,i.fab,span.fab,i.fad,span.fad,i.far,span.far';
for (const el of element.querySelectorAll(faSelector)) {
el.textContent = el.textContent.replace('\u200B', '');
}
+ // Clean custom selections
+ if (this.deselectTable() && hasValidSelection(this.editable)) {
+ this.document.getSelection().collapseToStart();
+ }
}
/**
* Handle the hint preview for the Powerbox.
@@ -3003,6 +3473,7 @@ export class OdooEditor extends EventTarget {
_onMouseDown(ev) {
this._currentMouseState = ev.type;
+ this._lastMouseClickPosition = [ev.x, ev.y];
// When selecting all the text within a link then triggering delete or
// inserting a character, the cursor and insertion is outside the link.
@@ -3108,18 +3579,92 @@ export class OdooEditor extends EventTarget {
this._fixSelectionOnContenteditableFalse();
}
- _onDoumentMousedown(event) {
+ _onDocumentMousedown(event) {
if (this.toolbar && !ancestors(event.target, this.editable).includes(this.toolbar)) {
this.toolbar.style.pointerEvents = 'none';
+ if (this.deselectTable() && hasValidSelection(this.editable)) {
+ this.document.getSelection().collapseToStart();
+ }
+ }
+ // Handle table resizing.
+ const isHoveringTdBorder = this._isHoveringTdBorder(event);
+ if (isHoveringTdBorder) {
+ event.preventDefault();
+ const direction = {top: 'row', right: 'col', bottom: 'row', left: 'col'}[isHoveringTdBorder] || false;
+ let target1, target2;
+ switch (isHoveringTdBorder) {
+ case 'top': {
+ target1 = getAdjacentPreviousSiblings(closestElement(event.target, 'tr')).find(node => node.nodeName === 'TR');
+ target2 = closestElement(event.target, 'tr');
+ break;
+ }
+ case 'right': {
+ target1 = event.target;
+ target2 = getAdjacentNextSiblings(event.target).find(node => node.nodeName === 'TD');
+ break;
+ }
+ case 'bottom': {
+ target1 = closestElement(event.target, 'tr');
+ target2 = getAdjacentNextSiblings(closestElement(event.target, 'tr')).find(node => node.nodeName === 'TR');
+ break;
+ }
+ case 'left': {
+ target1 = getAdjacentPreviousSiblings(event.target).find(node => node.nodeName === 'TD');
+ target2 = event.target;
+ break;
+ }
+ }
+ this._isResizingTable = true;
+ this._toggleTableResizeCursor(direction);
+ const resizeTable = ev => this._resizeTable(ev, direction, target1, target2);
+ const stopResizing = ev => {
+ ev.preventDefault();
+ this._isResizingTable = false;
+ this._toggleTableResizeCursor(false);
+ this.document.removeEventListener('mousemove', resizeTable);
+ this.document.removeEventListener('mouseup', stopResizing);
+ this.document.removeEventListener('mouseleave', stopResizing);
+ };
+ this.document.addEventListener('mousemove', resizeTable);
+ this.document.addEventListener('mouseup', stopResizing);
+ this.document.addEventListener('mouseleave', stopResizing);
}
}
- _onDoumentMouseup() {
+ _onDocumentMouseup() {
+ this.historyUnpauseSteps('handleSelectionInTable');
if (this.toolbar) {
this.toolbar.style.pointerEvents = 'auto';
}
}
+ _onDocumentMousemove(ev) {
+ if (this._currentMouseState === 'mousedown' && !this._isResizingTable) {
+ this._handleSelectionInTable(ev);
+ }
+ if (!this._rowUi.classList.contains('o_open') && !this._columnUi.classList.contains('o_open')) {
+ const column = closestElement(ev.target, 'td');
+ if (this._isResizingTable || !column || !ev.target || ev.target.nodeType !== Node.ELEMENT_NODE) {
+ this._toggleTableUi(false, false);
+ } else {
+ const row = closestElement(column, 'tr');
+ const isFirstColumn = column === row.querySelector('td');
+ const isFirstRow = row === closestElement(column, 'table').querySelector('tr');
+ this._toggleTableUi(isFirstColumn && row, isFirstRow && column);
+ }
+ }
+ const direction = {top: 'row', right: 'col', bottom: 'row', left: 'col'}[this._isHoveringTdBorder(ev)] || false;
+ if (direction || !this._isResizingTable) {
+ this._toggleTableResizeCursor(direction);
+ }
+ }
+
+ _onDocumentClick(ev) {
+ // Close Table UI.
+ this._rowUi.classList.remove('o_open');
+ this._columnUi.classList.remove('o_open');
+ }
+
/**
* Create a Link in the node text based on the given data
*
@@ -3392,10 +3937,74 @@ export class OdooEditor extends EventTarget {
if (cursorDestination) {
setSelection(...startPos(cursorDestination), ...endPos(cursorDestination), true);
} else if (direction === DIRECTIONS.RIGHT) {
- this.execCommand('addRowBelow');
+ this.execCommand('addRow', 'after');
this._onTabulationInTable(ev);
}
}
+ _onTableMenuTogglerClick(ev) {
+ const uiWrapper = closestElement(ev.target, '.o_table_ui');
+ uiWrapper.classList.toggle('o_open');
+ if (uiWrapper.classList.contains('o_column_ui')) {
+ const columnIndex = getColumnIndex(this._columnUiTarget);
+ uiWrapper.querySelector('.o_move_left').classList.toggle('o_hide', columnIndex === 0);
+ const shouldHideRight = columnIndex === [...this._columnUiTarget.parentElement.children].filter(child => child.nodeName === 'TD').length - 1;
+ uiWrapper.querySelector('.o_move_right').classList.toggle('o_hide', shouldHideRight);
+ } else {
+ const rowIndex = getRowIndex(this._rowUiTarget);
+ uiWrapper.querySelector('.o_move_up').classList.toggle('o_hide', rowIndex === 0);
+ const shouldHideDown = rowIndex === [...this._rowUiTarget.parentElement.children].filter(child => child.nodeName === 'TR').length - 1;
+ uiWrapper.querySelector('.o_move_down').classList.toggle('o_hide', shouldHideDown);
+ }
+ ev.stopPropagation();
+ }
+ _onTableMoveUpClick() {
+ if (this._rowUiTarget.previousSibling) {
+ this._rowUiTarget.previousSibling.before(this._rowUiTarget);
+ }
+ }
+ _onTableMoveDownClick() {
+ if (this._rowUiTarget.nextSibling) {
+ this._rowUiTarget.nextSibling.after(this._rowUiTarget);
+ }
+ }
+ _onTableMoveRightClick() {
+ const trs = [...this._columnUiTarget.parentElement.parentElement.children].filter(child => child.nodeName === 'TR');
+ const columnIndex = getColumnIndex(this._columnUiTarget);
+ const tdsToMove = trs.map(tr => [...tr.children].filter(child => child.nodeName === 'TD')[columnIndex]);
+ for (const tdToMove of tdsToMove) {
+ const target = [...tdToMove.parentElement.children].filter(child => child.nodeName === 'TD')[columnIndex + 1];
+ target.after(tdToMove);
+ }
+ }
+ _onTableMoveLeftClick() {
+ const trs = [...this._columnUiTarget.parentElement.parentElement.children].filter(child => child.nodeName === 'TR');
+ const columnIndex = getColumnIndex(this._columnUiTarget);
+ const tdsToMove = trs.map(tr => [...tr.children].filter(child => child.nodeName === 'TD')[columnIndex]);
+ for (const tdToMove of tdsToMove) {
+ const target = [...tdToMove.parentElement.children].filter(child => child.nodeName === 'TD')[columnIndex - 1];
+ target.before(tdToMove);
+ }
+ }
+ _onTableDeleteColumnClick() {
+ this.historyPauseSteps();
+ const rows = [...closestElement(this._columnUiTarget, 'tr').parentElement.children].filter(child => child.nodeName === 'TR');
+ this.execCommand('removeColumn', this._columnUiTarget);
+ if (rows.every(row => !row.parentElement)) {
+ this.execCommand('deleteTable', this.editable.querySelector('.o_selected_table'));
+ }
+ this.historyUnpauseSteps();
+ this.historyStep();
+ }
+ _onTableDeleteRowClick() {
+ this.historyPauseSteps();
+ const rows = [...this._rowUiTarget.parentElement.children].filter(child => child.nodeName === 'TR');
+ this.execCommand('removeRow', this._rowUiTarget);
+ if (rows.every(row => !row.parentElement)) {
+ this.execCommand('deleteTable', this.editable.querySelector('.o_selected_table'));
+ }
+ this.historyUnpauseSteps();
+ this.historyStep();
+ }
/**
* Fix the current selection range in case the range start or end inside a fontAwesome node
diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/src/base_style.scss b/addons/web_editor/static/src/js/editor/odoo-editor/src/base_style.scss
index 04cfa68c98e..76dcd592032 100644
--- a/addons/web_editor/static/src/js/editor/odoo-editor/src/base_style.scss
+++ b/addons/web_editor/static/src/js/editor/odoo-editor/src/base_style.scss
@@ -1,3 +1,7 @@
li.oe-nested {
display: block;
}
+.o_table tr td {
+ padding: 0.5rem;
+ border-color: #c9ccd2;
+}
diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/src/commands/commands.js b/addons/web_editor/static/src/js/editor/odoo-editor/src/commands/commands.js
index f56a7d774fb..9f5ee0fdc47 100644
--- a/addons/web_editor/static/src/js/editor/odoo-editor/src/commands/commands.js
+++ b/addons/web_editor/static/src/js/editor/odoo-editor/src/commands/commands.js
@@ -42,6 +42,9 @@ import {
isUnbreakable,
makeContentsInline,
unwrapContents,
+ getColumnIndex,
+ pxToFloat,
+ getRowIndex,
} from '../utils/utils.js';
const TEXT_CLASSES_REGEX = /\btext-[^\s]*\b/g;
@@ -310,26 +313,35 @@ function hasColor(element, mode) {
* @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 });
- const sel = editor.document.getSelection();
- const { startContainer, startOffset, endContainer, endOffset } = sel.getRangeAt(0);
- const { anchorNode, anchorOffset, focusNode, focusOffset } = sel;
- const direction = getCursorDirection(anchorNode, anchorOffset, focusNode, focusOffset);
- const [
- normalizedStartContainer,
- normalizedStartOffset
- ] = getNormalizedCursorPosition(startContainer, startOffset);
- const [
- normalizedEndContainer,
- normalizedEndOffset
- ] = getNormalizedCursorPosition(endContainer, endOffset);
- const selectedTextNodes = getSelectedNodes(editor.editable).filter(node => {
- const atLeastOneCharFromNodeInSelection = !(
- (node === normalizedEndContainer && normalizedEndOffset === 0) ||
- (node === normalizedStartContainer && normalizedStartOffset === node.textContent.length)
- );
- return isContentTextNode(node) && atLeastOneCharFromNodeInSelection;
- });
+ let selectedTextNodes, direction, startContainer, startOffset, endContainer, endOffset;
+ if (editor.editable.querySelector('.o_selected_td')) {
+ const selectedNodes = getSelectedNodes(editor.editable);
+ selectedTextNodes = selectedNodes.filter(node => node.nodeType === Node.TEXT_NODE);
+ direction = DIRECTIONS.RIGHT;
+ [startContainer, startOffset] = [selectedNodes[0], 0];
+ [endContainer, endOffset] = [selectedNodes[selectedNodes.length - 1], selectedNodes[selectedNodes.length - 1].childNodes.length];
+ } else {
+ getDeepRange(editor.editable, { splitText: true, select: true });
+ const sel = editor.document.getSelection();
+ const { startContainer, startOffset, endContainer, endOffset } = sel.getRangeAt(0);
+ const { anchorNode, anchorOffset, focusNode, focusOffset } = sel;
+ direction = getCursorDirection(anchorNode, anchorOffset, focusNode, focusOffset);
+ const [
+ normalizedStartContainer,
+ normalizedStartOffset
+ ] = getNormalizedCursorPosition(startContainer, startOffset);
+ const [
+ normalizedEndContainer,
+ normalizedEndOffset
+ ] = getNormalizedCursorPosition(endContainer, endOffset);
+ selectedTextNodes = getSelectedNodes(editor.editable).filter(node => {
+ const atLeastOneCharFromNodeInSelection = !(
+ (node === normalizedEndContainer && normalizedEndOffset === 0) ||
+ (node === normalizedStartContainer && normalizedStartOffset === node.textContent.length)
+ );
+ return isContentTextNode(node) && atLeastOneCharFromNodeInSelection;
+ });
+ }
const textNodesToFormat = selectedTextNodes.filter(node => {
let isApplied;
if (Array.isArray(style) && style[style[0]]) {
@@ -376,8 +388,16 @@ export function applyInlineStyle(editor, applyStyle, style, shouldApply=true) {
textNode.parentElement.tagName === 'A'
) {
const newParent = document.createElement('span');
- textNode.after(newParent);
- newParent.appendChild(textNode);
+ textNode.before(newParent);
+ // Group selected text node siblings:
+ const newChildren = [textNode];
+ let currentTextNode = textNode.nextSibling;
+ while (currentTextNode && textNodesToFormat.includes(currentTextNode)) {
+ newChildren.push(currentTextNode);
+ textNodesToFormat.splice(textNodesToFormat.indexOf(currentTextNode), 1);
+ currentTextNode = currentTextNode.nextSibling;
+ }
+ newParent.append(...newChildren);
}
applyStyle(textNode.parentElement);
changedElements.push(textNode.parentElement);
@@ -422,9 +442,10 @@ const styles = {
};
export function toggleFormat(editor, format) {
+ const selectedTableCells = editor.editable.querySelectorAll('.o_selected_td');
const selection = editor.document.getSelection();
- if (!selection.rangeCount) return;
- const wasCollapsed = selection.getRangeAt(0).collapsed;
+ if (!selection.rangeCount && !selectedTableCells.length) return;
+ const wasCollapsed = selection.getRangeAt(0).collapsed && !selectedTableCells.length;
let zws;
if (wasCollapsed) {
if (selection.anchorNode.nodeType === Node.TEXT_NODE && selection.anchorNode.textContent === '\u200b') {
@@ -522,32 +543,6 @@ export function toggleFormat(editor, format) {
}
return changedElements;
}
-function addColumn(editor, beforeOrAfter) {
- getDeepRange(editor.editable, { select: true }); // Ensure deep range for finding td.
- const c = getInSelection(editor.document, 'td');
- if (!c) return;
- const i = [...closestElement(c, 'tr').querySelectorAll('th, td')].findIndex(td => td === c);
- const column = closestElement(c, 'table').querySelectorAll(`tr td:nth-of-type(${i + 1})`);
- column.forEach(row => row[beforeOrAfter](document.createElement('td')));
-}
-function addRow(editor, beforeOrAfter) {
- getDeepRange(editor.editable, { select: true }); // Ensure deep range for finding tr.
- const row = getInSelection(editor.document, 'tr');
- if (!row) return;
- const newRow = document.createElement('tr');
- const cells = row.querySelectorAll('td');
- newRow.append(...Array.from(Array(cells.length)).map(() => document.createElement('td')));
- row[beforeOrAfter](newRow);
-}
-function deleteTable(editor, table) {
- table = table || getInSelection(editor.document, 'table');
- if (!table) return;
- const p = document.createElement('p');
- p.appendChild(document.createElement('br'));
- table.before(p);
- table.remove();
- setSelection(p, 0);
-}
// This is a whitelist of the commands that are implemented by the
// editor itself rather than the node prototypes. It might be
@@ -756,13 +751,20 @@ export const editorCommands = {
* @param {Element} [element]
*/
applyColor: (editor, color, mode, element) => {
- if (element) {
+ const selectedTds = editor.editable.querySelectorAll('td.o_selected_td');
+ let coloredTds = [];
+ if (selectedTds.length) {
+ for (const td of selectedTds) {
+ colorElement(td, color, mode);
+ }
+ coloredTds = [...selectedTds];
+ } else if (element) {
colorElement(element, color, mode);
return [element];
}
const selection = editor.document.getSelection();
let wasCollapsed = false;
- if (selection.getRangeAt(0).collapsed) {
+ if (selection.getRangeAt(0).collapsed && !selectedTds.length) {
insertAndSelectZws(selection);
wasCollapsed = true;
}
@@ -770,7 +772,7 @@ export const editorCommands = {
if (!range) return;
const restoreCursor = preserveCursor(editor.document);
// Get the nodes to color
- const selectedNodes = getSelectedNodes(editor.editable);
+ const selectedNodes = getSelectedNodes(editor.editable).filter(node => !closestElement(node, 'table.o_selected_table'));
const fonts = selectedNodes.flatMap(node => {
let font = closestElement(node, 'font');
const children = font && descendants(font);
@@ -828,13 +830,13 @@ export const editorCommands = {
newSelection.removeAllRanges();
newSelection.addRange(range);
}
- return fonts;
+ return [...fonts, ...coloredTds];
},
// Table
insertTable: (editor, { rowNumber = 2, colNumber = 2 } = {}) => {
const tdsHtml = new Array(colNumber).fill('
| ').join('');
const trsHtml = new Array(rowNumber).fill(`${tdsHtml}
`).join('');
- const tableHtml = ``;
+ const tableHtml = ``;
const sel = editor.document.getSelection();
if (!sel.isCollapsed) {
editor.deleteRange(sel);
@@ -851,41 +853,108 @@ export const editorCommands = {
const [table] = editorCommands.insertHTML(editor, tableHtml);
setCursorStart(table.querySelector('td'));
},
- addColumnLeft: editor => {
- addColumn(editor, 'before');
+ addColumn: (editor, beforeOrAfter, referenceCell) => {
+ if (!referenceCell) {
+ getDeepRange(editor.editable, { select: true }); // Ensure deep range for finding td.
+ referenceCell = getInSelection(editor.document, 'td');
+ if (!referenceCell) return;
+ }
+ const columnIndex = getColumnIndex(referenceCell);
+ const table = closestElement(referenceCell, 'table');
+ const tableWidth = table.style.width ? pxToFloat(table.style.width) : table.clientWidth;
+ const referenceColumn = table.querySelectorAll(`tr td:nth-of-type(${columnIndex + 1})`);
+ const referenceCellWidth = referenceCell.style.width ? pxToFloat(referenceCell.style.width) : referenceCell.clientWidth;
+ // Temporarily set widths so proportions are respected.
+ const firstRow = table.querySelector('tr');
+ const firstRowCells = [...firstRow.children].filter(child => child.nodeName === 'TD' || child.nodeName === 'TH');
+ let totalWidth = 0;
+ for (const cell of firstRowCells) {
+ const width = cell.style.width ? pxToFloat(cell.style.width) : cell.clientWidth;
+ cell.style.width = width + 'px';
+ // Spread the widths to preserve proportions.
+ // -1 for the width of the border of the new column.
+ const newWidth = Math.max(Math.round((width * tableWidth) / (tableWidth + referenceCellWidth - 1)), 13);
+ cell.style.width = newWidth + 'px';
+ totalWidth += newWidth;
+ }
+ referenceColumn.forEach((cell, rowIndex) => {
+ const newCell = document.createElement('td');
+ newCell.append(document.createElement('br'));
+ cell[beforeOrAfter](newCell);
+ if (rowIndex === 0) {
+ newCell.style.width = cell.style.width;
+ totalWidth += pxToFloat(cell.style.width);
+ }
+ });
+ if (totalWidth !== tableWidth - 1) { // -1 for the width of the border of the new column.
+ firstRowCells[firstRowCells.length - 1].style.width = pxToFloat(firstRowCells[firstRowCells.length - 1].style.width) + (tableWidth - totalWidth - 1) + 'px';
+ }
+ // Fix the table and row's width so it doesn't change.
+ table.style.width = tableWidth + 'px';
},
- addColumnRight: editor => {
- addColumn(editor, 'after');
+ addRow: (editor, beforeOrAfter, referenceRow) => {
+ if (!referenceRow) {
+ getDeepRange(editor.editable, { select: true }); // Ensure deep range for finding tr.
+ referenceRow = getInSelection(editor.document, 'tr');
+ if (!referenceRow) return;
+ }
+ const referenceRowHeight = referenceRow.style.height ? pxToFloat(referenceRow.style.height) : referenceRow.clientHeight;
+ const newRow = document.createElement('tr');
+ newRow.style.height = referenceRowHeight + 'px';
+ const cells = referenceRow.querySelectorAll('td');
+ const referenceRowWidths = [...cells].map(cell => cell.style.width || cell.clientWidth + 'px');
+ newRow.append(...Array.from(Array(cells.length)).map(() => {
+ const td = document.createElement('td');
+ td.append(document.createElement('br'));
+ return td;
+ }));
+ referenceRow[beforeOrAfter](newRow);
+ newRow.style.height = referenceRowHeight + 'px';
+ // Preserve the width of the columns (applied only on the first row).
+ if (getRowIndex(newRow) === 0) {
+ let columnIndex = 0;
+ for (const column of newRow.children) {
+ column.style.width = referenceRowWidths[columnIndex];
+ cells[columnIndex].style.width = '';
+ columnIndex++;
+ }
+ }
},
- addRowAbove: editor => {
- addRow(editor, 'before');
- },
- addRowBelow: editor => {
- addRow(editor, 'after');
- },
- removeColumn: editor => {
- getDeepRange(editor.editable, { select: true }); // Ensure deep range for finding td.
- const cell = getInSelection(editor.document, 'td');
- if (!cell) return;
+ removeColumn: (editor, cell) => {
+ if (!cell) {
+ getDeepRange(editor.editable, { select: true }); // Ensure deep range for finding td.
+ cell = getInSelection(editor.document, 'td');
+ if (!cell) return;
+ }
const table = closestElement(cell, 'table');
const cells = [...closestElement(cell, 'tr').querySelectorAll('th, td')];
const index = cells.findIndex(td => td === cell);
const siblingCell = cells[index - 1] || cells[index + 1];
table.querySelectorAll(`tr td:nth-of-type(${index + 1})`).forEach(td => td.remove());
- siblingCell ? setSelection(...startPos(siblingCell)) : deleteTable(editor, table);
+ siblingCell ? setSelection(...startPos(siblingCell)) : editorCommands.deleteTable(editor, table);
},
- removeRow: editor => {
- getDeepRange(editor.editable, { select: true }); // Ensure deep range for finding tr.
- const row = getInSelection(editor.document, 'tr');
- if (!row) return;
+ removeRow: (editor, row) => {
+ if (!row) {
+ getDeepRange(editor.editable, { select: true }); // Ensure deep range for finding tr.
+ row = getInSelection(editor.document, 'tr');
+ if (!row) return;
+ }
const table = closestElement(row, 'table');
const rows = [...table.querySelectorAll('tr')];
const rowIndex = rows.findIndex(tr => tr === row);
const siblingRow = rows[rowIndex - 1] || rows[rowIndex + 1];
row.remove();
- siblingRow ? setSelection(...startPos(siblingRow)) : deleteTable(editor, table);
+ siblingRow ? setSelection(...startPos(siblingRow)) : editorCommands.deleteTable(editor, table);
+ },
+ deleteTable: (editor, table) => {
+ table = table || getInSelection(editor.document, 'table');
+ if (!table) return;
+ const p = document.createElement('p');
+ p.appendChild(document.createElement('br'));
+ table.before(p);
+ table.remove();
+ setSelection(p, 0);
},
- deleteTable: (editor, table) => deleteTable(editor, table),
// Structure
columnize: (editor, numberOfColumns, addParagraphAfter=true) => {
const sel = editor.document.getSelection();
diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/src/commands/toggleList.js b/addons/web_editor/static/src/js/editor/odoo-editor/src/commands/toggleList.js
index 63cbbea2752..a82ed5b9607 100644
--- a/addons/web_editor/static/src/js/editor/odoo-editor/src/commands/toggleList.js
+++ b/addons/web_editor/static/src/js/editor/odoo-editor/src/commands/toggleList.js
@@ -40,9 +40,13 @@ HTMLElement.prototype.oToggleList = function (offset, mode = 'UL') {
HTMLParagraphElement.prototype.oToggleList = function (offset, mode = 'UL') {
const restoreCursor = preserveCursor(this.ownerDocument);
const list = insertListAfter(this, mode, [[...this.childNodes]]);
+ const classList = [...list.classList];
for (const attribute of this.attributes) {
list.setAttribute(attribute.name, attribute.value);
}
+ for (const className of classList) {
+ list.classList.toggle(className, true); // restore list classes
+ }
this.remove();
restoreCursor(new Map([[this, list.firstChild]]));
diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/src/style.scss b/addons/web_editor/static/src/js/editor/odoo-editor/src/style.scss
index c396993024f..fe1cccb22c5 100644
--- a/addons/web_editor/static/src/js/editor/odoo-editor/src/style.scss
+++ b/addons/web_editor/static/src/js/editor/odoo-editor/src/style.scss
@@ -1,3 +1,92 @@
+.odoo-editor-editable {
+ ::selection {
+ /* For color conversion over white background, use X = (Y-(1-P)*255)/P where
+ X = converted color component (R, G, B) (0 <= X <= 255)
+ Y = desired apparent color component (R, G, B) (0 <= Y <= 255)
+ P = opacity (0 <= P <=1)
+ (limitation: Y + 255P >= 255)
+ */
+ background-color: rgba(117, 167, 249, 0.5) !important; /* #bad3fc equivalent when over white*/
+ }
+ &.o_col_resize {
+ cursor: col-resize;
+
+ ::selection {
+ background-color: transparent;
+ }
+ }
+ &.o_row_resize {
+ cursor: row-resize;
+
+ ::selection {
+ background-color: transparent;
+ }
+ }
+}
+.o_selected_table {
+ caret-color: transparent;
+
+ ::selection {
+ background-color: transparent !important;
+ }
+ .o_selected_td {
+ background-color: rgba(117, 167, 249, 0.5) !important; /* #bad3fc equivalent when over white*/
+ cursor: pointer !important;
+ }
+}
+.o_table_ui {
+ background-color: transparent;
+ position: absolute;
+ z-index: 10;
+ padding: 0;
+
+ &:hover {
+ visibility: visible !important;
+ }
+ > div {
+ position: absolute;
+ }
+ .o_table_ui_menu_toggler {
+ cursor: pointer;
+ background-color: white;
+ color: #6e727a;
+ border: 1px solid #C9CCD2;
+ border-radius: 5px;
+ padding: 2px 3px;
+ }
+ .o_table_ui_menu {
+ display: none;
+ cursor: pointer;
+ background-color: white;
+ width: fit-content;
+ border: 1px solid #C9CCD2;
+ padding: 5px 0;
+ white-space: nowrap;
+
+ > div:hover {
+ background-color: #ebebeb;
+ }
+ span {
+ margin-right: 8px;
+ color: #6e727a;
+ }
+ div {
+ padding: 0 8px;
+ }
+ }
+ &.o_open {
+ visibility: visible !important;
+
+ .o_table_ui_menu {
+ display: block;
+
+ > div.o_hide {
+ display: none;
+ }
+ }
+ }
+}
+
.oe-floating {
box-shadow: 0px 3px 18px rgba(0, 0, 0, .23);
border-radius: 4px;
diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/src/utils/utils.js b/addons/web_editor/static/src/js/editor/odoo-editor/src/utils/utils.js
index 5a3fa2e88f9..747df41e908 100644
--- a/addons/web_editor/static/src/js/editor/odoo-editor/src/utils/utils.js
+++ b/addons/web_editor/static/src/js/editor/odoo-editor/src/utils/utils.js
@@ -437,6 +437,26 @@ export function getAdjacents(node, predicate = n => !!n) {
// Cursor management
//------------------------------------------------------------------------------
+/**
+ * Returns true if the given editable area contains a table with selected cells.
+ *
+ * @param {Element} editable
+ * @returns {boolean}
+ */
+export function hasTableSelection(editable) {
+ return !!editable.querySelector('.o_selected_table');
+}
+/**
+ * Returns true if the given editable area contains a "valid" selection, by
+ * which we mean a browser selection whose elements are defined, or a table with
+ * selected cells.
+ *
+ * @param {Element} editable
+ * @returns {boolean}
+ */
+export function hasValidSelection(editable) {
+ return hasTableSelection(editable) || editable.ownerDocument.getSelection().rangeCount > 0;
+}
/**
* From a given position, returns the normalized version.
*
@@ -624,19 +644,29 @@ export function getCursorDirection(anchorNode, anchorOffset, focusNode, focusOff
* @returns {Node[]}
*/
export function getTraversedNodes(editable, range = getDeepRange(editable)) {
+ const selectedTableCells = editable.querySelectorAll('.o_selected_td');
const document = editable.ownerDocument;
if (!range) return [];
const iterator = document.createNodeIterator(range.commonAncestorContainer);
let node;
do {
node = iterator.nextNode();
- } while (node && node !== range.startContainer);
- const traversedNodes = [node];
+ } while (node && node !== range.startContainer && !(selectedTableCells.length && node === selectedTableCells[0]));
+ const traversedNodes = new Set([node]);
while (node && node !== range.endContainer) {
node = iterator.nextNode();
- node && traversedNodes.push(node);
+ if (node) {
+ const selectedTable = closestElement(node, '.o_selected_table');
+ if (selectedTable) {
+ for (const selectedTd of selectedTable.querySelectorAll('.o_selected_td')) {
+ traversedNodes.add(selectedTd, ...descendants(selectedTd));
+ }
+ } else {
+ traversedNodes.add(node);
+ }
+ }
}
- return traversedNodes;
+ return [...traversedNodes];
}
/**
* Returns an array containing all the nodes fully contained in the selection.
@@ -645,14 +675,24 @@ export function getTraversedNodes(editable, range = getDeepRange(editable)) {
* @returns {Node[]}
*/
export function getSelectedNodes(editable) {
+ const selectedTableCells = editable.querySelectorAll('.o_selected_td');
const document = editable.ownerDocument;
const sel = document.getSelection();
- if (!sel.rangeCount) {
+ if (!sel.rangeCount && !selectedTableCells.length) {
return [];
}
const range = sel.getRangeAt(0);
- return getTraversedNodes(editable).filter(
- node => range.isPointInRange(node, 0) && range.isPointInRange(node, nodeSize(node)),
+ return getTraversedNodes(editable).flatMap(
+ node => {
+ const td = closestElement(node, '.o_selected_td');
+ if (td) {
+ return descendants(td);
+ } else if (range.isPointInRange(node, 0) && range.isPointInRange(node, nodeSize(node))) {
+ return node;
+ } else {
+ return [];
+ }
+ },
);
}
@@ -670,8 +710,8 @@ export function getSelectedNodes(editable) {
* @returns {Range}
*/
export function getDeepRange(editable, { range, sel, splitText, select, correctTripleClick } = {}) {
- sel = sel || editable.ownerDocument.getSelection();
- range = range ? range.cloneRange() : sel.rangeCount && sel.getRangeAt(0).cloneRange();
+ sel = sel || editable.parentElement && editable.ownerDocument.getSelection();
+ range = range ? range.cloneRange() : sel && sel.rangeCount && sel.getRangeAt(0).cloneRange();
if (!range) return;
let start = range.startContainer;
let startOffset = range.startOffset;
@@ -1098,6 +1138,39 @@ export function getInSelection(document, selector) {
);
}
+/**
+ * Get the index of the given table row/cell.
+ *
+ * @private
+ * @param {HTMLTableRowElement|HTMLTableCellElement} trOrTd
+ * @returns {number}
+ */
+export function getRowIndex(trOrTd) {
+ const tr = closestElement(trOrTd, 'tr');
+ const trParent = tr && tr.parentElement;
+ if (!trParent) {
+ return -1;
+ }
+ const trSiblings = [...trParent.children].filter(child => child.nodeName === 'TR');
+ return trSiblings.findIndex(child => child === tr);
+}
+
+/**
+ * Get the index of the given table cell.
+ *
+ * @private
+ * @param {HTMLTableCellElement} td
+ * @returns {number}
+ */
+export function getColumnIndex(td) {
+ const tdParent = td.parentElement;
+ if (!tdParent) {
+ return -1;
+ }
+ const tdSiblings = [...tdParent.children].filter(child => child.nodeName === 'TD' || child.nodeName === 'TH');
+ return tdSiblings.findIndex(child => child === td);
+}
+
// This is a list of "paragraph-related elements", defined as elements that
// behave like paragraphs.
const paragraphRelatedElements = [
@@ -1237,7 +1310,7 @@ export function isVisible(node, areBlocksAlwaysVisible = true) {
return [...node.childNodes].some(n => isVisible(n));
}
-function isVisibleTextNode(testedNode) {
+export function isVisibleTextNode(testedNode) {
if (!testedNode.length) {
return false;
}
@@ -2107,6 +2180,16 @@ export function rgbToHex(rgb = '') {
}
}
+/**
+ * Take a string containing a size in pixels, return that size as a float.
+ *
+ * @param {string} sizeString
+ * @returns {number}
+ */
+export function pxToFloat(sizeString) {
+ return parseFloat(sizeString.replace('px', ''));
+}
+
export function getRangePosition(el, document, options = {}) {
const selection = document.getSelection();
if (!selection.rangeCount) return;
diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/editor.test.js b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/editor.test.js
index 8566470d559..b77b4e0d6d7 100644
--- a/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/editor.test.js
+++ b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/editor.test.js
@@ -255,11 +255,13 @@ describe('Editor', () => {
});
it('should not break unbreakables', async () => {
await testEditor(BasicEditor, {
- contentBefore:
- '',
+ contentBefore: ``,
stepFunction: deleteForward,
- contentAfter:
- '',
+ contentAfter: ``,
});
});
it('should remove empty unbreakable', async () => {
@@ -1140,10 +1142,13 @@ X[]
});
it('should not break unbreakables', async () => {
await testEditor(BasicEditor, {
- contentBefore:
- '',
+ contentBefore: ``,
stepFunction: deleteForward,
- contentAfter: '',
+ contentAfter: ``,
});
await testEditor(BasicEditor, {
contentBefore:
@@ -1357,64 +1362,94 @@ X[]
});
it('should not break unbreakables', async () => {
await testEditor(BasicEditor, {
- contentBefore:
- '',
+ contentBefore: ``,
stepFunction: deleteBackward,
- contentAfter:
- '',
- });
- });
- it('should not break a table', async () => {
- await testEditor(BasicEditor, {
- contentBefore:
- '',
- stepFunction: deleteBackward,
- contentAfter:
- '',
+ contentAfter: ``,
});
await testEditor(BasicEditor, {
- contentBefore:
- '',
+ contentBefore: ``,
stepFunction: deleteBackward,
- contentAfter:
- '',
+ contentAfter: ``,
});
await testEditor(BasicEditor, {
- contentBefore:
- '',
+ contentBefore: ``,
stepFunction: deleteBackward,
- contentAfter:
- '',
+ contentAfter: ``,
});
await testEditor(BasicEditor, {
- contentBefore:
- '',
+ contentBefore: ``,
stepFunction: deleteBackward,
- contentAfter:
- '',
- });
- });
- it('should not break a table (cross rows)', async () => {
- await testEditor(BasicEditor, {
- contentBefore:
- '',
- stepFunction: deleteBackward,
- contentAfter:
- '',
+ contentAfter: ``,
});
await testEditor(BasicEditor, {
- contentBefore:
- '',
+ contentBefore: ``,
stepFunction: deleteBackward,
- contentAfter:
- '',
+ contentAfter: ``,
});
await testEditor(BasicEditor, {
- contentBefore:
- '',
+ contentBefore: ``,
stepFunction: deleteBackward,
- contentAfter:
- '',
+ contentAfter: ``,
+ });
+ await testEditor(BasicEditor, {
+ contentBefore: ``,
+ stepFunction: deleteBackward,
+ contentAfter: ``,
+ });
+ await testEditor(BasicEditor, {
+ contentBefore: ``,
+ stepFunction: editor => deleteBackward(editor),
+ contentAfter: ``,
});
});
it('should merge the following inline text node', async () => {
@@ -1915,11 +1950,13 @@ X[]
});
it('should not break unbreakables', async () => {
await testEditor(BasicEditor, {
- contentBefore:
- '',
+ contentBefore: ``,
stepFunction: deleteBackward,
- contentAfter:
- '',
+ contentAfter: ``,
});
});
it('should merge a text preceding a paragraph (removing the paragraph)', async () => {
@@ -3882,6 +3919,952 @@ X[]
});
});
+ describe('tables', () => {
+ describe('add children', () => {
+ describe('row', () => {
+ describe('above', async () => {
+ it('should add a row above the top row', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef[] | ' +
+ '
',
+ stepFunction: async editor => editor.execCommand('addRow', 'before'),
+ contentAfter: '' +
+ '
| ' +
+ '
| ' +
+ '
| ' +
+ '
' +
+ '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef[] | ' +
+ '
',
+ });
+ });
+ it('should add a row above the middle row', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef[] | ' +
+ '
',
+ stepFunction: async editor => editor.execCommand('addRow', 'before'),
+ contentAfter: '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ '' +
+ '
| ' +
+ '
| ' +
+ '
| ' +
+ '
' +
+ '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef[] | ' +
+ '
',
+ });
+ });
+ });
+ describe('below', () => {
+ it('should add a row below the bottom row', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef[] | ' +
+ '
',
+ stepFunction: async editor => editor.execCommand('addRow', 'after'),
+ contentAfter: '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef[] | ' +
+ '
' +
+ '' +
+ '
| ' +
+ '
| ' +
+ '
| ' +
+ '
',
+ });
+ });
+ it('should add a row below the middle row', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef[] | ' +
+ '
' +
+ '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
',
+ stepFunction: async editor => editor.execCommand('addRow', 'after'),
+ contentAfter: '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef[] | ' +
+ '
' +
+ '' +
+ '
| ' +
+ '
| ' +
+ '
| ' +
+ '
' +
+ '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
',
+ });
+ });
+ });
+ });
+ describe('column', () => {
+ describe('left', () => {
+ it('should add a column left of the leftmost column', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: '' +
+ '| ab[] | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
',
+ stepFunction: async editor => editor.execCommand('addColumn', 'before'),
+ contentAfter: '' +
+ '
| ' +
+ 'ab[] | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ '' +
+ '
| ' +
+ 'ab | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
',
+ });
+ });
+ it('should add a column left of the middle column', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ '' +
+ '| ab | ' +
+ 'cd[] | ' +
+ 'ef | ' +
+ '
' +
+ '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
',
+ stepFunction: async editor => editor.execCommand('addColumn', 'before'),
+ contentAfter: '' +
+ '| ab | ' +
+ '
| ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ '' +
+ '| ab | ' +
+ '
| ' +
+ 'cd[] | ' +
+ 'ef | ' +
+ '
' +
+ '' +
+ '| ab | ' +
+ '
| ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
',
+ });
+ });
+ });
+ describe('right', () => {
+ it('should add a column right of the rightmost column', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef[] | ' +
+ '
' +
+ '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
',
+ stepFunction: async editor => editor.execCommand('addColumn', 'after'),
+ contentAfter: '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef[] | ' +
+ // size was slightly adjusted to
+ // preserve table width in view on
+ // fractional division results
+ '
| ' +
+ '
' +
+ '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
| ' +
+ '
',
+ });
+ });
+ it('should add a column right of the middle column', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ '' +
+ '| ab | ' +
+ 'cd[] | ' +
+ 'ef | ' +
+ '
' +
+ '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
',
+ stepFunction: async editor => editor.execCommand('addColumn', 'after'),
+ contentAfter: '' +
+ '| ab | ' +
+ 'cd | ' +
+ '
| ' +
+ 'ef | ' +
+ '
' +
+ '' +
+ '| ab | ' +
+ 'cd[] | ' +
+ '
| ' +
+ 'ef | ' +
+ '
' +
+ '' +
+ '| ab | ' +
+ 'cd | ' +
+ '
| ' +
+ 'ef | ' +
+ '
',
+ });
+ });
+ });
+ });
+ });
+ describe('tab', () => {
+ it('should add a new row on press tab at the end of a table', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: '',
+ stepFunction: async editor => triggerEvent(editor.editable, 'keydown', { key: 'Tab'}),
+ contentAfter: '',
+ });
+ });
+ });
+ describe('rectangular selections', () => {
+ describe('select a full table on cross over', () => {
+ describe('select', () => {
+ it('should select some characters and a table', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: 'a[bc
',
+ contentAfterEdit: 'a[bc
' +
+ '' +
+ '| a]b | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
',
+ });
+ });
+ it('should select a table and some characters', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: 'a]bc
',
+ contentAfterEdit: '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'e[f |
a]bc
',
+ });
+ });
+ it('should select some characters, a table and some more characters', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: 'a[bc
a]bc
',
+ contentAfterEdit: 'a[bc
' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef |
a]bc
',
+ });
+ });
+ it('should select some characters, a table, some more characters and another table', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: 'a[bc
abc
',
+ contentAfterEdit: 'a[bc
' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef |
' +
+ 'abc
' +
+ '| a]b | ' +
+ 'cd | ' +
+ 'ef |
',
+ });
+ });
+ it('should select some characters, a table, some more characters, another table and some more characters', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: 'a[bc
abc
a]bc
',
+ contentAfterEdit: 'a[bc
' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef |
' +
+ 'abc
' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef |
a]bc
',
+ });
+ });
+ });
+ describe('toggleFormat', () => {
+ it('should apply bold to some characters and a table', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: 'a[bc
' +
+ '| a]b | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
',
+ stepFunction: async editor => editor.execCommand('bold'),
+ contentAfterEdit: 'a[bc
' +
+ '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef] | ' +
+ '
',
+ });
+ });
+ it('should apply bold to a table and some characters', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'e[f | ' +
+ '
a]bc
',
+ stepFunction: async editor => editor.execCommand('bold'),
+ contentAfterEdit: '' +
+ '| [ab | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ 'a]bc
',
+ });
+ });
+ it('should apply bold to some characters, a table and some more characters', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: 'a[bc
' +
+ '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ 'a]bc
',
+ stepFunction: async editor => editor.execCommand('bold'),
+ contentAfterEdit: 'a[bc
' +
+ '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ 'a]bc
',
+ });
+ });
+ it('should apply bold to some characters, a table, some more characters and another table', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: 'a[bc
' +
+ '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ 'abc
' +
+ '' +
+ '| a]b | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
',
+ stepFunction: async editor => editor.execCommand('bold'),
+ contentAfterEdit: 'a[bc
' +
+ '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ 'abc
' +
+ '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef] | ' +
+ '
',
+ });
+ });
+ it('should apply bold to some characters, a table, some more characters, another table and some more characters', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: 'a[bc
' +
+ '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ 'abc
' +
+ '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ 'a]bc
',
+ stepFunction: async editor => editor.execCommand('bold'),
+ contentAfterEdit: 'a[bc
' +
+ '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ 'abc
' +
+ '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ 'a]bc
',
+ });
+ });
+ });
+ describe('color', () => {
+ it('should apply a color to some characters and a table', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: 'a[bc
' +
+ '| a]b | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
',
+ stepFunction: async editor => editor.execCommand('applyColor', 'aquamarine', 'color'),
+ contentAfterEdit: 'a[bc
' +
+ '' +
+ '| a]b | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
',
+ });
+ });
+ it('should apply a color to a table and some characters', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'e[f | ' +
+ '
a]bc
',
+ stepFunction: async editor => editor.execCommand('applyColor', 'aquamarine', 'color'),
+ contentAfterEdit: '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'e[f | ' +
+ '
' +
+ 'a]bc
',
+ });
+ });
+ it('should apply a color to some characters, a table and some more characters', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: 'a[bc
' +
+ '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ 'a]bc
',
+ stepFunction: async editor => editor.execCommand('applyColor', 'aquamarine', 'color'),
+ contentAfterEdit: 'a[bc
' +
+ '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ 'a]bc
',
+ });
+ });
+ it('should apply a color to some characters, a table, some more characters and another table', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: 'a[bc
' +
+ '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ 'abc
' +
+ '' +
+ '| a]b | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
',
+ stepFunction: async editor => editor.execCommand('applyColor', 'aquamarine', 'color'),
+ contentAfterEdit: 'a[bc
' +
+ '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ 'abc
' +
+ '' +
+ '| a]b | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
',
+ });
+ });
+ it('should apply a color to some characters, a table, some more characters, another table and some more characters', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: 'a[bc
' +
+ '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ 'abc
' +
+ '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ 'a]bc
',
+ stepFunction: async editor => editor.execCommand('applyColor', 'aquamarine', 'color'),
+ contentAfterEdit: 'a[bc
' +
+ '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ 'abc
' +
+ '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ 'a]bc
',
+ });
+ });
+ });
+ });
+ describe('select columns on cross over', () => {
+ describe('select', () => {
+ it('should select two columns', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: '',
+ contentAfterEdit: '' +
+ '| a[b | ' +
+ 'c]d | ' +
+ 'ef | ' +
+ '
',
+ });
+ });
+ it('should select a whole row', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: '',
+ contentAfterEdit: '' +
+ '| a[b | ' +
+ 'cd | ' +
+ 'e]f | ' +
+ '
| ab | cd | ef |
',
+ });
+ });
+ it('should select a whole column', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: '' +
+ '| a[b | cd | ef |
' +
+ '| ab | cd | ef |
' +
+ '| a]b | cd | ef |
' +
+ '
',
+ contentAfterEdit: '' +
+ '' +
+ '| a[b | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ '' +
+ '| a]b | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ '
',
+ });
+ });
+ it('should select from (0,0) to (1,1) in a 3x3 table', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: '' +
+ '| a[b | cd | ef |
' +
+ '| ab | c]d | ef |
' +
+ '| ab | cd | ef |
' +
+ '
',
+ contentAfterEdit: '' +
+ '' +
+ '| a[b | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ '' +
+ '| ab | ' +
+ 'c]d | ' +
+ 'ef | ' +
+ '
' +
+ '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ '
',
+ });
+ });
+ it('should select a whole table', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: '' +
+ '| a[b | cd | ef |
' +
+ '| ab | cd | ef |
' +
+ '| ab | cd | e]f |
' +
+ '
',
+ contentAfterEdit: '' +
+ '' +
+ '| a[b | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'e]f | ' +
+ '
' +
+ '
',
+ });
+ });
+ });
+ describe('toggleFormat', () => {
+ it('should apply bold to two columns', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: '' +
+ '| a[b | ' +
+ 'c]d | ' +
+ 'ef | ' +
+ '
',
+ stepFunction: async editor => editor.execCommand('bold'),
+ contentAfterEdit: '' +
+ '| [ab | ' +
+ 'cd] | ' +
+ 'ef | ' +
+ '
',
+ });
+ });
+ it('should apply bold to a whole row', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: '' +
+ '| a[b | ' +
+ 'cd | ' +
+ 'e]f | ' +
+ '
| ab | cd | ef |
',
+ stepFunction: async editor => editor.execCommand('bold'),
+ contentAfterEdit: '' +
+ '| [ab | ' +
+ 'cd | ' +
+ 'ef] | ' +
+ '
| ab | cd | ef |
',
+ });
+ });
+ it('should apply bold to a whole column', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: '' +
+ '' +
+ '| a[b | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ '' +
+ '| a]b | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ '
',
+ stepFunction: async editor => editor.execCommand('bold'),
+ contentAfterEdit: '' +
+ '' +
+ '| [ab | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ '' +
+ '| ab] | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ '
',
+ });
+ });
+ it('should apply bold from (0,0) to (1,1) in a 3x3 table', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: '' +
+ '' +
+ '| a[b | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ '' +
+ '| ab | ' +
+ 'c]d | ' +
+ 'ef | ' +
+ '
' +
+ '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ '
',
+ stepFunction: async editor => editor.execCommand('bold'),
+ contentAfterEdit: '' +
+ '' +
+ '| [ab | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ '' +
+ '| ab | ' +
+ 'cd] | ' +
+ 'ef | ' +
+ '
' +
+ '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ '
',
+ });
+ });
+ it('should apply bold to a whole table', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: '' +
+ '' +
+ '| a[b | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'e]f | ' +
+ '
' +
+ '
',
+ stepFunction: async editor => editor.execCommand('bold'),
+ contentAfterEdit: '' +
+ '' +
+ '| [ab | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef] | ' +
+ '
' +
+ '
',
+ });
+ });
+ });
+ describe('color', () => {
+ it('should apply a color to two columns', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: '' +
+ '| a[b | ' +
+ 'c]d | ' +
+ 'ef | ' +
+ '
',
+ stepFunction: async editor => editor.execCommand('applyColor', 'aquamarine', 'color'),
+ contentAfterEdit: '' +
+ '| a[b | ' +
+ 'c]d | ' +
+ 'ef | ' +
+ '
',
+ });
+ });
+ it('should apply a color to a whole row', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: '' +
+ '| a[b | ' +
+ 'cd | ' +
+ 'e]f | ' +
+ '
| ab | cd | ef |
',
+ stepFunction: async editor => editor.execCommand('applyColor', 'aquamarine', 'color'),
+ contentAfterEdit: '' +
+ '| a[b | ' +
+ 'cd | ' +
+ 'e]f | ' +
+ '
| ab | cd | ef |
',
+ });
+ });
+ it('should apply a color to a whole column', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: '' +
+ '' +
+ '| a[b | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ '' +
+ '| a]b | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ '
',
+ stepFunction: async editor => editor.execCommand('applyColor', 'aquamarine', 'color'),
+ contentAfterEdit: '' +
+ '' +
+ '| a[b | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ '' +
+ '| a]b | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ '
',
+ });
+ });
+ it('should apply a color from (0,0) to (1,1) in a 3x3 table', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: '' +
+ '' +
+ '| a[b | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ '' +
+ '| ab | ' +
+ 'c]d | ' +
+ 'ef | ' +
+ '
' +
+ '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ '
',
+ stepFunction: async editor => editor.execCommand('applyColor', 'aquamarine', 'color'),
+ contentAfterEdit: '' +
+ '' +
+ '| a[b | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ '' +
+ '| ab | ' +
+ 'c]d | ' +
+ 'ef | ' +
+ '
' +
+ '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ '
',
+ });
+ });
+ it('should apply a color to a whole table', async () => {
+ await testEditor(BasicEditor, {
+ contentBefore: '' +
+ '' +
+ '| a[b | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'e]f | ' +
+ '
' +
+ '
',
+ stepFunction: async editor => editor.execCommand('applyColor', 'aquamarine', 'color'),
+ contentAfterEdit: '' +
+ '' +
+ '| a[b | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'ef | ' +
+ '
' +
+ '' +
+ '| ab | ' +
+ 'cd | ' +
+ 'e]f | ' +
+ '
' +
+ '
',
+ });
+ });
+ });
+ });
+ });
+ });
+
// Note that arrow keys test have a contentAfter that is not reflective of
// reality. The browser doesn't apply the selection change after triggering
// an event programmatically so what we are testing here is that if a custom
diff --git a/addons/web_editor/static/src/js/editor/odoo-editor/test/utils.js b/addons/web_editor/static/src/js/editor/odoo-editor/test/utils.js
index 4345111c8f0..de34e8a1e54 100644
--- a/addons/web_editor/static/src/js/editor/odoo-editor/test/utils.js
+++ b/addons/web_editor/static/src/js/editor/odoo-editor/test/utils.js
@@ -156,6 +156,7 @@ export function setTestSelection(selection, doc = document) {
} catch {
// Firefox yells not happy when setting selection on elem with contentEditable=false.
}
+ triggerEvent(selection.anchorNode, 'selectionchange');
}
/**
@@ -279,53 +280,58 @@ export async function testEditor(Editor = OdooEditor, spec, options = {}) {
const selection = parseTextualSelection(testNode);
const editor = new Editor(testNode, Object.assign({ toSanitize: false }, options));
- editor.keyboardType = 'PHYSICAL';
- editor.testMode = true;
- if (selection) {
- setTestSelection(selection);
- editor._recordHistorySelection();
- } else {
- document.getSelection().removeAllRanges();
- }
-
- // we have to sanitize after having put the cursor
- sanitize(editor.editable);
-
- if (spec.contentBeforeEdit) {
- renderTextualSelection();
- const beforeEditValue = testNode.innerHTML;
- window.chai.expect(beforeEditValue).to.be.equal(
- spec.contentBeforeEdit,
- customErrorMessage('contentBeforeEdit', beforeEditValue, spec.contentBeforeEdit));
- const selection = parseTextualSelection(testNode);
+ let firefoxExecCommandError = false;
+ let error = false;
+ try {
+ editor.keyboardType = 'PHYSICAL';
+ editor.testMode = true;
if (selection) {
setTestSelection(selection);
+ editor._recordHistorySelection();
+ } else {
+ document.getSelection().removeAllRanges();
}
- }
- let firefoxExecCommandError = false;
- if (spec.stepFunction) {
- try {
- await spec.stepFunction(editor);
- } catch (err) {
- if (typeof err === 'object' && err.name === 'NS_ERROR_FAILURE') {
- firefoxExecCommandError = true;
- } else {
- throw err;
+ // we have to sanitize after having put the cursor
+ sanitize(editor.editable);
+
+ if (spec.contentBeforeEdit) {
+ renderTextualSelection();
+ const beforeEditValue = testNode.innerHTML;
+ window.chai.expect(beforeEditValue).to.be.equal(
+ spec.contentBeforeEdit,
+ customErrorMessage('contentBeforeEdit', beforeEditValue, spec.contentBeforeEdit));
+ const selection = parseTextualSelection(testNode);
+ if (selection) {
+ setTestSelection(selection);
}
}
- }
- if (spec.contentAfterEdit && !firefoxExecCommandError) {
- renderTextualSelection();
- const afterEditValue = testNode.innerHTML;
- window.chai.expect(afterEditValue).to.be.equal(
- spec.contentAfterEdit,
- customErrorMessage('contentAfterEdit', afterEditValue, spec.contentAfterEdit));
- const selection = parseTextualSelection(testNode);
- if (selection) {
- setTestSelection(selection);
+ if (spec.stepFunction) {
+ try {
+ await spec.stepFunction(editor);
+ } catch (err) {
+ if (typeof err === 'object' && err.name === 'NS_ERROR_FAILURE') {
+ firefoxExecCommandError = true;
+ } else {
+ throw err;
+ }
+ }
}
+
+ if (spec.contentAfterEdit && !firefoxExecCommandError) {
+ renderTextualSelection();
+ const afterEditValue = testNode.innerHTML;
+ window.chai.expect(afterEditValue).to.be.equal(
+ spec.contentAfterEdit,
+ customErrorMessage('contentAfterEdit', afterEditValue, spec.contentAfterEdit));
+ const selection = parseTextualSelection(testNode);
+ if (selection) {
+ setTestSelection(selection);
+ }
+ }
+ } catch (err) {
+ error = err;
}
await editor.clean();
@@ -333,27 +339,37 @@ export async function testEditor(Editor = OdooEditor, spec, options = {}) {
// reading the "[]" markers would broke the test.
await editor.destroy();
- if (spec.contentAfter && !firefoxExecCommandError) {
- renderTextualSelection();
+ if (!error) {
+ try {
+ if (spec.contentAfter && !firefoxExecCommandError) {
+ renderTextualSelection();
- // remove all check-ids (checklists, stars)
- if (spec.removeCheckIds) {
- for (const li of document.querySelectorAll('#editor-test-container li[id^=checkId-')) {
- li.removeAttribute('id');
+ // remove all check-ids (checklists, stars)
+ if (spec.removeCheckIds) {
+ for (const li of document.querySelectorAll('#editor-test-container li[id^=checkId-')) {
+ li.removeAttribute('id');
+ }
+ }
+
+ const value = testNode.innerHTML;
+ window.chai.expect(value).to.be.equal(
+ spec.contentAfter,
+ customErrorMessage('contentAfter', value, spec.contentAfter));
}
+ } catch (err) {
+ error = err;
}
-
- const value = testNode.innerHTML;
- window.chai.expect(value).to.be.equal(
- spec.contentAfter,
- customErrorMessage('contentAfter', value, spec.contentAfter));
}
+
await testNode.remove();
if (firefoxExecCommandError) {
// FIXME
throw new Error('Firefox was not able to test this case because of an execCommand error');
}
+ if (error) {
+ throw error;
+ }
}
/**
@@ -530,6 +546,7 @@ function getEventConstructor(win, type) {
'paste': win.ClipboardEvent,
'touchstart': win.TouchEvent,
'touchend': win.TouchEvent,
+ 'selectionchange': win.Event,
};
if (!eventTypes[type]) {
throw new Error('The event "' + type + '" is not implemented for the tests.');
diff --git a/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js
index 156fb2ee820..6eca81e396f 100644
--- a/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js
+++ b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js
@@ -36,6 +36,7 @@ const preserveCursor = OdooEditorLib.preserveCursor;
const closestElement = OdooEditorLib.closestElement;
const setSelection = OdooEditorLib.setSelection;
const endPos = OdooEditorLib.endPos;
+const hasValidSelection = OdooEditorLib.hasValidSelection;
var id = 0;
const basicMediaSelector = 'img, .fa, .o_image, .media_iframe_video';
@@ -1576,7 +1577,14 @@ const Wysiwyg = Widget.extend({
// Unstash the mutations now that the color is picked.
this.odooEditor.historyUnstash();
this._processAndApplyColor(eventName, ev.data.color);
+ // Deselect tables so the applied color can be seen
+ // without using `!important` (otherwise the selection
+ // hides it).
+ if (this.odooEditor.deselectTable() && hasValidSelection(this.odooEditor.editable)) {
+ this.odooEditor.document.getSelection().collapseToStart();
+ }
this._updateEditorUI();
+ colorpicker.off('color_leave');
});
colorpicker.on('color_hover', null, ev => {
if (hadNonCollapsedSelection) {
@@ -1584,7 +1592,7 @@ const Wysiwyg = Widget.extend({
}
this.odooEditor.historyPauseSteps();
try {
- this._processAndApplyColor(eventName, ev.data.color);
+ this._processAndApplyColor(eventName, ev.data.color, true);
} finally {
this.odooEditor.historyUnpauseSteps();
}
@@ -1615,25 +1623,34 @@ const Wysiwyg = Widget.extend({
});
}
},
- _processAndApplyColor: function (eventName, color) {
+ _processAndApplyColor: function (eventName, color, previewMode) {
if (!color) {
color = 'inherit';
} else if (!ColorpickerWidget.isCSSColor(color) && !weUtils.isColorGradient(color)) {
color = (eventName === "foreColor" ? 'text-' : 'bg-') + color;
}
- const fonts = this.odooEditor.execCommand('applyColor', color, eventName === 'foreColor' ? 'color' : 'backgroundColor', this.lastMediaClicked);
+ const coloredElements = this.odooEditor.execCommand('applyColor', color, eventName === 'foreColor' ? 'color' : 'backgroundColor', this.lastMediaClicked);
- // Ensure the selection in the fonts tags, otherwise an undetermined
- // race condition could generate a wrong selection later.
- const first = fonts[0];
- const last = fonts[fonts.length - 1];
+ const coloredTds = coloredElements.filter(coloredElement => coloredElement.classList.contains('o_selected_td'));
+ if (coloredTds.length) {
+ const propName = eventName === 'foreColor' ? 'color' : 'background-color';
+ for (const td of coloredTds) {
+ // Make it important so it has priority over selection color.
+ td.style.setProperty(propName, td.style[propName], previewMode ? 'important' : '');
+ }
+ } else {
+ // Ensure the selection in the fonts tags, otherwise an undetermined
+ // race condition could generate a wrong selection later.
+ const first = coloredElements[0];
+ const last = coloredElements[coloredElements.length - 1];
- const sel = this.odooEditor.document.getSelection();
- sel.removeAllRanges();
- const range = new Range();
- range.setStart(first, 0);
- range.setEnd(...endPos(last));
- sel.addRange(range);
+ const sel = this.odooEditor.document.getSelection();
+ sel.removeAllRanges();
+ const range = new Range();
+ range.setStart(first, 0);
+ range.setEnd(...endPos(last));
+ sel.addRange(range);
+ }
const hexColor = this._colorToHex(color);
this.odooEditor.updateColorpickerLabels({
diff --git a/addons/web_editor/static/src/xml/editor.xml b/addons/web_editor/static/src/xml/editor.xml
index 8b12c8718b2..c5ff258057e 100644
--- a/addons/web_editor/static/src/xml/editor.xml
+++ b/addons/web_editor/static/src/xml/editor.xml
@@ -145,10 +145,10 @@