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 = `${trsHtml}
`; + const tableHtml = `${trsHtml}
`; 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: - '
[]
abc
', + contentBefore: `
` + + `
[]
` + + `
abc
`, stepFunction: deleteForward, - contentAfter: - '
[]
abc
', + contentAfter: `
` + + `
[]
` + + `
abc
`, }); }); it('should remove empty unbreakable', async () => { @@ -1140,10 +1142,13 @@ X[] }); it('should not break unbreakables', async () => { await testEditor(BasicEditor, { - contentBefore: - '
a[bcde]f
', + contentBefore: `
` + + `
a[bc
` + + `
de]f
`, stepFunction: deleteForward, - contentAfter: '
a[]f
', + contentAfter: `
` + + `
a[]
` + + `
f
`, }); await testEditor(BasicEditor, { contentBefore: @@ -1357,64 +1362,94 @@ X[] }); it('should not break unbreakables', async () => { await testEditor(BasicEditor, { - contentBefore: - '
[]
abc
', + contentBefore: `
` + + `
[]
` + + `
abc
`, stepFunction: deleteBackward, - contentAfter: - '
[]
abc
', - }); - }); - it('should not break a table', async () => { - await testEditor(BasicEditor, { - contentBefore: - '
[abcde]f
', - stepFunction: deleteBackward, - contentAfter: - '
[]
f
', + contentAfter: `
` + + `
[]
` + + `
abc
`, }); await testEditor(BasicEditor, { - contentBefore: - '
a[bcde]f
', + contentBefore: `
` + + `
[ab
` + + `
cd
` + + `
e]f
`, stepFunction: deleteBackward, - contentAfter: - '
a[]f
', + contentAfter: `
` + + `
[]
` + + `
f
`, }); await testEditor(BasicEditor, { - contentBefore: - '
a[bcdef]
', + contentBefore: `
` + + `
a[b
` + + `
cd
` + + `
e]f
`, stepFunction: deleteBackward, - contentAfter: - '
a[]
', + contentAfter: `
` + + `
a[]
` + + `
f
`, }); await testEditor(BasicEditor, { - contentBefore: - '
[abcdef]
', + contentBefore: `
` + + `
a[b
` + + `
cd
` + + `
ef]
`, stepFunction: deleteBackward, - contentAfter: - '
[]
', - }); - }); - it('should not break a table (cross rows)', async () => { - await testEditor(BasicEditor, { - contentBefore: - '
[abcdef
ghijk]l
', - stepFunction: deleteBackward, - contentAfter: - '
[]
l
', + contentAfter: `
` + + `
a[]
` + + `
`, }); await testEditor(BasicEditor, { - contentBefore: - '
a[bcdef
ghijk]l
', + contentBefore: `
` + + `
[ab
` + + `
cd
` + + `
ef]
`, stepFunction: deleteBackward, - contentAfter: - '
a[]
l
', + contentAfter: `
` + + `
[]
` + + `
`, }); await testEditor(BasicEditor, { - contentBefore: - '
a[bcdef
ghijkl]
', + contentBefore: `
` + + `
[ab
` + + `
cd
` + + `
ef
` + + `
gh
` + + `
ij
` + + `
k]l
`, stepFunction: deleteBackward, - contentAfter: - '
a[]
', + contentAfter: `
` + + `
[]
` + + `
` + + `
l
`, + }); + await testEditor(BasicEditor, { + contentBefore: `
` + + `
a[b
` + + `
cd
` + + `
ef
` + + `
gh
` + + `
ij
` + + `
k]l
`, + stepFunction: deleteBackward, + contentAfter: `
` + + `
a[]
` + + `
` + + `
l
`, + }); + await testEditor(BasicEditor, { + contentBefore: `
` + + `
a[b
` + + `
cd
` + + `
ef
` + + `
gh
` + + `
ij
` + + `
kl]
`, + stepFunction: editor => deleteBackward(editor), + contentAfter: `
` + + `
a[]
` + + `
`, }); }); it('should merge the following inline text node', async () => { @@ -1915,11 +1950,13 @@ X[] }); it('should not break unbreakables', async () => { await testEditor(BasicEditor, { - contentBefore: - '

[]abc
', + contentBefore: `
` + + `

` + + `
[]abc
`, stepFunction: deleteBackward, - contentAfter: - '

[]abc
', + contentAfter: `
` + + `

` + + `
[]abc
`, }); }); 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: '' + + '' + + '' + + '' + + '
abcdef[]
', + stepFunction: async editor => editor.execCommand('addRow', 'before'), + contentAfter: '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '



abcdef[]
', + }); + }); + it('should add a row above the middle row', async () => { + await testEditor(BasicEditor, { + contentBefore: '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
abcdef
abcdef[]
', + stepFunction: async editor => editor.execCommand('addRow', 'before'), + contentAfter: '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
abcdef



abcdef[]
', + }); + }); + }); + describe('below', () => { + it('should add a row below the bottom row', async () => { + await testEditor(BasicEditor, { + contentBefore: '' + + '' + + '' + + '' + + '
abcdef[]
', + stepFunction: async editor => editor.execCommand('addRow', 'after'), + contentAfter: '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
abcdef[]



', + }); + }); + it('should add a row below the middle row', async () => { + await testEditor(BasicEditor, { + contentBefore: '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
abcdef[]
abcdef
', + stepFunction: async editor => editor.execCommand('addRow', 'after'), + contentAfter: '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
abcdef[]



abcdef
', + }); + }); + }); + }); + describe('column', () => { + describe('left', () => { + it('should add a column left of the leftmost column', async () => { + await testEditor(BasicEditor, { + contentBefore: '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
ab[]cdef
abcdef
', + stepFunction: async editor => editor.execCommand('addColumn', 'before'), + contentAfter: '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '

ab[]cdef

abcdef
', + }); + }); + it('should add a column left of the middle column', async () => { + await testEditor(BasicEditor, { + contentBefore: '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
abcdef
abcd[]ef
abcdef
', + stepFunction: async editor => editor.execCommand('addColumn', 'before'), + contentAfter: '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
ab
cdef
ab
cd[]ef
ab
cdef
', + }); + }); + }); + describe('right', () => { + it('should add a column right of the rightmost column', async () => { + await testEditor(BasicEditor, { + contentBefore: '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
abcdef[]
abcdef
', + stepFunction: async editor => editor.execCommand('addColumn', 'after'), + contentAfter: '' + + '' + + '' + + '' + + // size was slightly adjusted to + // preserve table width in view on + // fractional division results + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
abcdef[]
abcdef
', + }); + }); + it('should add a column right of the middle column', async () => { + await testEditor(BasicEditor, { + contentBefore: '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
abcdef
abcd[]ef
abcdef
', + stepFunction: async editor => editor.execCommand('addColumn', 'after'), + contentAfter: '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
abcd
ef
abcd[]
ef
abcd
ef
', + }); + }); + }); + }); + }); + describe('tab', () => { + it('should add a new row on press tab at the end of a table', async () => { + await testEditor(BasicEditor, { + contentBefore: '
abcdef[]
', + stepFunction: async editor => triggerEvent(editor.editable, 'keydown', { key: 'Tab'}), + contentAfter: '
abcdef
[]


', + }); + }); + }); + 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

a]bcdef
', + contentAfterEdit: '

a[bc

' + + '' + + '' + + '' + + '' + + '
a]bcdef
', + }); + }); + it('should select a table and some characters', async () => { + await testEditor(BasicEditor, { + contentBefore: '
abcde[f

a]bc

', + contentAfterEdit: '' + + '' + + '' + + '
abcde[f

a]bc

', + }); + }); + it('should select some characters, a table and some more characters', async () => { + await testEditor(BasicEditor, { + contentBefore: '

a[bc

abcdef

a]bc

', + contentAfterEdit: '

a[bc

' + + '' + + '' + + '
abcdef

a]bc

', + }); + }); + it('should select some characters, a table, some more characters and another table', async () => { + await testEditor(BasicEditor, { + contentBefore: '

a[bc

abcdef

abc

a]bcdef
', + contentAfterEdit: '

a[bc

' + + '' + + '' + + '
abcdef
' + + '

abc

' + + '' + + '' + + '
a]bcdef
', + }); + }); + it('should select some characters, a table, some more characters, another table and some more characters', async () => { + await testEditor(BasicEditor, { + contentBefore: '

a[bc

abcdef

abc

abcdef

a]bc

', + contentAfterEdit: '

a[bc

' + + '' + + '' + + '
abcdef
' + + '

abc

' + + '' + + '' + + '
abcdef

a]bc

', + }); + }); + }); + describe('toggleFormat', () => { + it('should apply bold to some characters and a table', async () => { + await testEditor(BasicEditor, { + contentBefore: '

a[bc

' + + '' + + '' + + '' + + '
a]bcdef
', + stepFunction: async editor => editor.execCommand('bold'), + contentAfterEdit: '

a[bc

' + + '' + + '' + + '' + + '' + + '
abcdef]
', + }); + }); + it('should apply bold to a table and some characters', async () => { + await testEditor(BasicEditor, { + contentBefore: '' + + '' + + '' + + '' + + '
abcde[f

a]bc

', + stepFunction: async editor => editor.execCommand('bold'), + contentAfterEdit: '' + + '' + + '' + + '' + + '
[abcdef
' + + '

a]bc

', + }); + }); + it('should apply bold to some characters, a table and some more characters', async () => { + await testEditor(BasicEditor, { + contentBefore: '

a[bc

' + + '' + + '' + + '' + + '' + + '
abcdef
' + + '

a]bc

', + stepFunction: async editor => editor.execCommand('bold'), + contentAfterEdit: '

a[bc

' + + '' + + '' + + '' + + '' + + '
abcdef
' + + '

a]bc

', + }); + }); + it('should apply bold to some characters, a table, some more characters and another table', async () => { + await testEditor(BasicEditor, { + contentBefore: '

a[bc

' + + '' + + '' + + '' + + '' + + '
abcdef
' + + '

abc

' + + '' + + '' + + '' + + '' + + '
a]bcdef
', + stepFunction: async editor => editor.execCommand('bold'), + contentAfterEdit: '

a[bc

' + + '' + + '' + + '' + + '' + + '
abcdef
' + + '

abc

' + + '' + + '' + + '' + + '' + + '
abcdef]
', + }); + }); + 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

' + + '' + + '' + + '' + + '' + + '
abcdef
' + + '

abc

' + + '' + + '' + + '' + + '' + + '
abcdef
' + + '

a]bc

', + stepFunction: async editor => editor.execCommand('bold'), + contentAfterEdit: '

a[bc

' + + '' + + '' + + '' + + '' + + '
abcdef
' + + '

abc

' + + '' + + '' + + '' + + '' + + '
abcdef
' + + '

a]bc

', + }); + }); + }); + describe('color', () => { + it('should apply a color to some characters and a table', async () => { + await testEditor(BasicEditor, { + contentBefore: '

a[bc

' + + '' + + '' + + '' + + '
a]bcdef
', + stepFunction: async editor => editor.execCommand('applyColor', 'aquamarine', 'color'), + contentAfterEdit: '

a[bc

' + + '' + + '' + + '' + + '' + + '
a]bcdef
', + }); + }); + it('should apply a color to a table and some characters', async () => { + await testEditor(BasicEditor, { + contentBefore: '' + + '' + + '' + + '' + + '
abcde[f

a]bc

', + stepFunction: async editor => editor.execCommand('applyColor', 'aquamarine', 'color'), + contentAfterEdit: '' + + '' + + '' + + '' + + '
abcde[f
' + + '

a]bc

', + }); + }); + it('should apply a color to some characters, a table and some more characters', async () => { + await testEditor(BasicEditor, { + contentBefore: '

a[bc

' + + '' + + '' + + '' + + '' + + '
abcdef
' + + '

a]bc

', + stepFunction: async editor => editor.execCommand('applyColor', 'aquamarine', 'color'), + contentAfterEdit: '

a[bc

' + + '' + + '' + + '' + + '' + + '
abcdef
' + + '

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

' + + '' + + '' + + '' + + '' + + '
abcdef
' + + '

abc

' + + '' + + '' + + '' + + '' + + '
a]bcdef
', + stepFunction: async editor => editor.execCommand('applyColor', 'aquamarine', 'color'), + contentAfterEdit: '

a[bc

' + + '' + + '' + + '' + + '' + + '
abcdef
' + + '

abc

' + + '' + + '' + + '' + + '' + + '
a]bcdef
', + }); + }); + 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

' + + '' + + '' + + '' + + '' + + '
abcdef
' + + '

abc

' + + '' + + '' + + '' + + '' + + '
abcdef
' + + '

a]bc

', + stepFunction: async editor => editor.execCommand('applyColor', 'aquamarine', 'color'), + contentAfterEdit: '

a[bc

' + + '' + + '' + + '' + + '' + + '
abcdef
' + + '

abc

' + + '' + + '' + + '' + + '' + + '
abcdef
' + + '

a]bc

', + }); + }); + }); + }); + describe('select columns on cross over', () => { + describe('select', () => { + it('should select two columns', async () => { + await testEditor(BasicEditor, { + contentBefore: '
a[bc]def
', + contentAfterEdit: '' + + '' + + '' + + '' + + '
a[bc]def
', + }); + }); + it('should select a whole row', async () => { + await testEditor(BasicEditor, { + contentBefore: '
a[bcde]f
abcdef
', + contentAfterEdit: '' + + '' + + '' + + '' + + '
a[bcde]f
abcdef
', + }); + }); + it('should select a whole column', async () => { + await testEditor(BasicEditor, { + contentBefore: '' + + '' + + '' + + '' + + '
a[bcdef
abcdef
a]bcdef
', + contentAfterEdit: '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
a[bcdef
abcdef
a]bcdef
', + }); + }); + it('should select from (0,0) to (1,1) in a 3x3 table', async () => { + await testEditor(BasicEditor, { + contentBefore: '' + + '' + + '' + + '' + + '
a[bcdef
abc]def
abcdef
', + contentAfterEdit: '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
a[bcdef
abc]def
abcdef
', + }); + }); + it('should select a whole table', async () => { + await testEditor(BasicEditor, { + contentBefore: '' + + '' + + '' + + '' + + '
a[bcdef
abcdef
abcde]f
', + contentAfterEdit: '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
a[bcdef
abcdef
abcde]f
', + }); + }); + }); + describe('toggleFormat', () => { + it('should apply bold to two columns', async () => { + await testEditor(BasicEditor, { + contentBefore: '' + + '' + + '' + + '' + + '
a[bc]def
', + stepFunction: async editor => editor.execCommand('bold'), + contentAfterEdit: '' + + '' + + '' + + '' + + '
[abcd]ef
', + }); + }); + it('should apply bold to a whole row', async () => { + await testEditor(BasicEditor, { + contentBefore: '' + + '' + + '' + + '' + + '
a[bcde]f
abcdef
', + stepFunction: async editor => editor.execCommand('bold'), + contentAfterEdit: '' + + '' + + '' + + '' + + '
[abcdef]
abcdef
', + }); + }); + it('should apply bold to a whole column', async () => { + await testEditor(BasicEditor, { + contentBefore: '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
a[bcdef
abcdef
a]bcdef
', + stepFunction: async editor => editor.execCommand('bold'), + contentAfterEdit: '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
[abcdef
abcdef
ab]cdef
', + }); + }); + it('should apply bold from (0,0) to (1,1) in a 3x3 table', async () => { + await testEditor(BasicEditor, { + contentBefore: '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
a[bcdef
abc]def
abcdef
', + stepFunction: async editor => editor.execCommand('bold'), + contentAfterEdit: '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
[abcdef
abcd]ef
abcdef
', + }); + }); + it('should apply bold to a whole table', async () => { + await testEditor(BasicEditor, { + contentBefore: '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
a[bcdef
abcdef
abcde]f
', + stepFunction: async editor => editor.execCommand('bold'), + contentAfterEdit: '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
[abcdef
abcdef
abcdef]
', + }); + }); + }); + describe('color', () => { + it('should apply a color to two columns', async () => { + await testEditor(BasicEditor, { + contentBefore: '' + + '' + + '' + + '' + + '
a[bc]def
', + stepFunction: async editor => editor.execCommand('applyColor', 'aquamarine', 'color'), + contentAfterEdit: '' + + '' + + '' + + '' + + '
a[bc]def
', + }); + }); + it('should apply a color to a whole row', async () => { + await testEditor(BasicEditor, { + contentBefore: '' + + '' + + '' + + '' + + '
a[bcde]f
abcdef
', + stepFunction: async editor => editor.execCommand('applyColor', 'aquamarine', 'color'), + contentAfterEdit: '' + + '' + + '' + + '' + + '
a[bcde]f
abcdef
', + }); + }); + it('should apply a color to a whole column', async () => { + await testEditor(BasicEditor, { + contentBefore: '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
a[bcdef
abcdef
a]bcdef
', + stepFunction: async editor => editor.execCommand('applyColor', 'aquamarine', 'color'), + contentAfterEdit: '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
a[bcdef
abcdef
a]bcdef
', + }); + }); + it('should apply a color from (0,0) to (1,1) in a 3x3 table', async () => { + await testEditor(BasicEditor, { + contentBefore: '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
a[bcdef
abc]def
abcdef
', + stepFunction: async editor => editor.execCommand('applyColor', 'aquamarine', 'color'), + contentAfterEdit: '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
a[bcdef
abc]def
abcdef
', + }); + }); + it('should apply a color to a whole table', async () => { + await testEditor(BasicEditor, { + contentBefore: '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
a[bcdef
abcdef
abcde]f
', + stepFunction: async editor => editor.execCommand('applyColor', 'aquamarine', 'color'), + contentAfterEdit: '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
a[bcdef
abcdef
abcde]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 @@