From 3d9be78fbc6f8471c9bb24368b3299b89d914fa1 Mon Sep 17 00:00:00 2001 From: Antoine Guenet Date: Tue, 14 Jun 2022 08:02:43 +0000 Subject: [PATCH] [ADD] web_editor: support gdocs style selection in table This adds the possibility to select whole cells, rectangularly rather than line per line from left to right then top to bottom. task-2858455 Part-of: odoo/odoo#92200 --- .../js/editor/odoo-editor/src/OdooEditor.js | 275 +++++++-- .../odoo-editor/src/commands/commands.js | 66 +- .../src/js/editor/odoo-editor/src/style.scss | 11 + .../js/editor/odoo-editor/src/utils/utils.js | 89 ++- .../odoo-editor/test/spec/editor.test.js | 568 ++++++++++++++++-- 5 files changed, 882 insertions(+), 127 deletions(-) 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 0f80b9e233c..e5e666624dd 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,12 @@ import { getAdjacentNextSiblings, rightLeafOnlyNotBlockPath, isBlock, + getTraversedNodes, + getSelectedNodes, + isVisibleTextNode, + descendants, + hasValidSelection, + hasTableSelection, } from './utils/utils.js'; import { editorCommands } from './commands/commands.js'; import { Powerbox } from './powerbox/Powerbox.js'; @@ -510,8 +518,9 @@ 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.multiselectionRefresh = this.multiselectionRefresh.bind(this); this._resizeObserver = new ResizeObserver(this.multiselectionRefresh); @@ -1145,6 +1154,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 +1168,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 +1520,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 +1588,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 +1596,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) => @@ -1872,6 +1880,119 @@ 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 (!traversedNodes.some(node => !!closestElement(node, 'td'))) { + // There is no table cell in the selection. + 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(); + } + } + // HISTORY // ======= @@ -2007,14 +2128,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_resizing_column') || this.editable.classList.contains('o_resizing_row')) { 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 +2288,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(); @@ -2524,11 +2657,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 +2830,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 +2990,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 +3183,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 +3289,28 @@ 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(); + } } } - _onDoumentMouseup() { + _onDocumentMouseup() { + this.historyUnpauseSteps('handleSelectionInTable'); if (this.toolbar) { this.toolbar.style.pointerEvents = 'auto'; } } + _onDocumentMousemove(ev) { + if (this._currentMouseState === 'mousedown') { + this._handleSelectionInTable(ev); + } + } + /** * Create a Link in the node text based on the given data * 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 b27038fdcf1..690417b5333 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 @@ -310,26 +310,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 +385,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 +439,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') { 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 1786821e525..9bbbe9bb4ab 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 @@ -7,6 +7,17 @@ */ background-color: rgba(117, 167, 249, 0.5) !important; /* #bad3fc equivalent when over white*/ } +.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; + } +} .oe-floating { box-shadow: 0px 3px 18px rgba(0, 0, 0, .23); 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 2e34b8a59af..443b435896f 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 []; + } + }, ); } @@ -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; } 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 85c329b10ac..e31231c0a95 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 () => { @@ -3892,6 +3929,431 @@ X[] }); }); }); + 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('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]
', + }); + }); + }); + }); + }); }); // Note that arrow keys test have a contentAfter that is not reflective of