From 49de251a7bc164206f10ac6b6ad6f628f5647a56 Mon Sep 17 00:00:00 2001 From: Antoine Guenet Date: Mon, 22 Aug 2022 11:11:38 +0000 Subject: [PATCH 01/14] [FIX] web_editor: ensure editor is properly destroyed after each test If an error was thrown during a call to `testEditor` (eg, a test failed), we never went through the code that destroys the editor and cleans the DOM. This catches any error to throw it only at the end of the function. Part-of: odoo/odoo#92200 --- .../src/js/editor/odoo-editor/test/utils.js | 115 ++++++++++-------- 1 file changed, 65 insertions(+), 50 deletions(-) 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..ad6571e5c98 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 @@ -279,53 +279,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 +338,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; + } } /** From 2b7b279218140f3305357afdfd44f1dadd738b2f Mon Sep 17 00:00:00 2001 From: Antoine Guenet Date: Mon, 22 Aug 2022 11:31:34 +0000 Subject: [PATCH 02/14] [FIX] web_editor: prevent wrong DOM operations when editable not in DOM `getDeepRange` fetched a range on potentially the wrong document and `getTraversedNodes` created a node iterator on potentially the wrong document, when the editable was not in the DOM. Part-of: odoo/odoo#92200 --- .../static/src/js/editor/odoo-editor/src/utils/utils.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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..2e34b8a59af 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 @@ -670,8 +670,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; From d2200e10883ee042485571c17caa68576fe185ab Mon Sep 17 00:00:00 2001 From: Antoine Guenet Date: Mon, 22 Aug 2022 14:56:36 +0000 Subject: [PATCH 03/14] [FIX] web_editor: trigger selectionchange in tests When the editor's test utils set the selection, they did not trigger the selectionchange event, which means the editor's listener didn't get called. Part-of: odoo/odoo#92200 --- .../web_editor/static/src/js/editor/odoo-editor/test/utils.js | 2 ++ 1 file changed, 2 insertions(+) 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 ad6571e5c98..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'); } /** @@ -545,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.'); From d3d4443e1335e78bcab344a38087cc147b62d0b2 Mon Sep 17 00:00:00 2001 From: Antoine Guenet Date: Mon, 22 Aug 2022 14:58:28 +0000 Subject: [PATCH 04/14] [FIX] web_editor: always apply checklist class on convert p to checklist When converting a paragraph to a checklist, we first apply the checklist class and then take the paragraph's attributes and apply it to the new list. If said attributes included the "class" attribute, it wrote over the checklist class. Part-of: odoo/odoo#92200 --- .../src/js/editor/odoo-editor/src/commands/toggleList.js | 4 ++++ 1 file changed, 4 insertions(+) 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]])); From 68999cd95f8e2e98f1cce60bcfd83e84d6d48add Mon Sep 17 00:00:00 2001 From: Antoine Guenet Date: Tue, 24 May 2022 13:25:06 +0000 Subject: [PATCH 05/14] [IMP] web_editor: restyle tables in editor - reduce the default row height to get a padding of .5rem instead of .75 - use a darker grey for table border color task-2858455 Part-of: odoo/odoo#92200 --- .../static/src/js/editor/odoo-editor/src/OdooEditor.js | 2 +- .../static/src/js/editor/odoo-editor/src/base_style.scss | 4 ++++ .../static/src/js/editor/odoo-editor/src/commands/commands.js | 2 +- 3 files changed, 6 insertions(+), 2 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 68c8de379ab..0f80b9e233c 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 @@ -2224,7 +2224,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]) { 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..2261e4bc1cf 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 @@ -834,7 +834,7 @@ export const editorCommands = { 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); From 3da301fcb24998333039a7b636cec0e5be8694c6 Mon Sep 17 00:00:00 2001 From: Antoine Guenet Date: Tue, 24 May 2022 13:25:17 +0000 Subject: [PATCH 06/14] [IMP] web_editor: change selection color in editable Update the blue text selection color to #bad3fc. task-2858455 Part-of: odoo/odoo#92200 --- .../static/src/js/editor/odoo-editor/src/style.scss | 10 ++++++++++ 1 file changed, 10 insertions(+) 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..1786821e525 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,13 @@ +.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*/ +} + .oe-floating { box-shadow: 0px 3px 18px rgba(0, 0, 0, .23); border-radius: 4px; From 7da2b1f2a803e24ed51bc1c82b304914586e69b8 Mon Sep 17 00:00:00 2001 From: Antoine Guenet Date: Tue, 24 May 2022 13:31:21 +0000 Subject: [PATCH 07/14] [FIX] web_editor: properly size new rows added with TAB New rows added with TAB in a table were missing a
in each cell, making them appear less tall. task-2858455 Part-of: odoo/odoo#92200 --- .../js/editor/odoo-editor/src/commands/commands.js | 6 +++++- .../js/editor/odoo-editor/test/spec/editor.test.js | 12 ++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) 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 2261e4bc1cf..b27038fdcf1 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 @@ -536,7 +536,11 @@ function addRow(editor, beforeOrAfter) { if (!row) return; const newRow = document.createElement('tr'); const cells = row.querySelectorAll('td'); - newRow.append(...Array.from(Array(cells.length)).map(() => document.createElement('td'))); + newRow.append(...Array.from(Array(cells.length)).map(() => { + const td = document.createElement('td'); + td.append(document.createElement('br')); + return td; + })); row[beforeOrAfter](newRow); } function deleteTable(editor, table) { 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..85c329b10ac 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 @@ -3882,6 +3882,18 @@ X[] }); }); + describe('tables', () => { + 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
[]


', + }); + }); + }); + }); + // 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 From 3d9be78fbc6f8471c9bb24368b3299b89d914fa1 Mon Sep 17 00:00:00 2001 From: Antoine Guenet Date: Tue, 14 Jun 2022 08:02:43 +0000 Subject: [PATCH 08/14] [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 From d8ec51f47a0c75fd5d0461cdcdff481c3c6a5e97 Mon Sep 17 00:00:00 2001 From: Antoine Guenet Date: Tue, 14 Jun 2022 08:08:13 +0000 Subject: [PATCH 09/14] [ADD] web_editor: when hovering a table border, use the resize cursors This adds the feature of showing the appropriate resize cursor depending on which border (vertical or horizontal) is hovered in a table. task-2858455 Part-of: odoo/odoo#92200 --- .../js/editor/odoo-editor/src/OdooEditor.js | 45 ++++++++++++++++++- .../src/js/editor/odoo-editor/src/style.scss | 32 +++++++++---- 2 files changed, 68 insertions(+), 9 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 e5e666624dd..c3b1c3e04e5 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 @@ -1992,6 +1992,47 @@ export class OdooEditor extends EventTarget { 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_resizing_row'); + } + } // HISTORY // ======= @@ -2129,7 +2170,7 @@ export class OdooEditor extends EventTarget { const sel = this.document.getSelection(); if (!hasTableSelection(this.editable)) { - if (this.editable.classList.contains('o_resizing_column') || this.editable.classList.contains('o_resizing_row')) { + if (this.editable.classList.contains('o_col_resize') || this.editable.classList.contains('o_row_resize')) { show = false; } if (!sel.anchorNode) { @@ -3309,6 +3350,8 @@ export class OdooEditor extends EventTarget { if (this._currentMouseState === 'mousedown') { this._handleSelectionInTable(ev); } + const sideToDirection = {top: 'row', right: 'col', bottom: 'row', left: 'col'}; + this._toggleTableResizeCursor(sideToDirection[this._isHoveringTdBorder(ev)] || false); } /** 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 9bbbe9bb4ab..201ffd2ec44 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,11 +1,27 @@ -.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*/ +.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; From f3cff78472808727827ee3faef225d73c6aab303 Mon Sep 17 00:00:00 2001 From: Antoine Guenet Date: Tue, 14 Jun 2022 08:09:35 +0000 Subject: [PATCH 10/14] [ADD] web_editor: allow drag and drop table resizing This allows the user to grab table borders in order to resize its rows and columns. task-2858455 Part-of: odoo/odoo#92200 --- .../js/editor/odoo-editor/src/OdooEditor.js | 178 +++++++++++++++++- .../js/editor/odoo-editor/src/utils/utils.js | 10 + 2 files changed, 182 insertions(+), 6 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 c3b1c3e04e5..c5ae7abc294 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 @@ -64,6 +64,7 @@ import { descendants, hasValidSelection, hasTableSelection, + pxToFloat, } from './utils/utils.js'; import { editorCommands } from './commands/commands.js'; import { Powerbox } from './powerbox/Powerbox.js'; @@ -1895,8 +1896,7 @@ export class OdooEditor extends EventTarget { _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. + if (this._isResizingTable || !traversedNodes.some(node => !!closestElement(node, 'td'))) { return false; } const selection = this.document.getSelection(); @@ -2030,7 +2030,128 @@ export class OdooEditor extends EventTarget { if (direction === 'col') { this.editable.classList.add('o_col_resize'); } else if (direction === 'row') { - this.editable.classList.add('o_resizing_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; + } } } @@ -3337,6 +3458,49 @@ export class OdooEditor extends EventTarget { 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); + } } _onDocumentMouseup() { @@ -3347,11 +3511,13 @@ export class OdooEditor extends EventTarget { } _onDocumentMousemove(ev) { - if (this._currentMouseState === 'mousedown') { + if (this._currentMouseState === 'mousedown' && !this._isResizingTable) { this._handleSelectionInTable(ev); } - const sideToDirection = {top: 'row', right: 'col', bottom: 'row', left: 'col'}; - this._toggleTableResizeCursor(sideToDirection[this._isHoveringTdBorder(ev)] || false); + const direction = {top: 'row', right: 'col', bottom: 'row', left: 'col'}[this._isHoveringTdBorder(ev)] || false; + if (direction || !this._isResizingTable) { + this._toggleTableResizeCursor(direction); + } } /** 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 443b435896f..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 @@ -2180,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; From aaea489f6d30334fd8de9b0b71e4bf608fceb3c2 Mon Sep 17 00:00:00 2001 From: Antoine Guenet Date: Tue, 14 Jun 2022 08:13:53 +0000 Subject: [PATCH 11/14] [ADD] web_editor: apply colors to whole selected table cells With this, when whole table cells are selected and the user picks a color in the toolbar's colorpicker, that color is applied to the whole cell rather than to the text only. task-2858455 Part-of: odoo/odoo#92200 --- .../odoo-editor/src/commands/commands.js | 15 +- .../odoo-editor/test/spec/editor.test.js | 262 ++++++++++++++++++ .../static/src/js/wysiwyg/wysiwyg.js | 42 ++- 3 files changed, 302 insertions(+), 17 deletions(-) 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 690417b5333..fece7d19fc9 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 @@ -778,13 +778,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; } @@ -792,7 +799,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); @@ -850,7 +857,7 @@ export const editorCommands = { newSelection.removeAllRanges(); newSelection.addRange(range); } - return fonts; + return [...fonts, ...coloredTds]; }, // Table insertTable: (editor, { rowNumber = 2, colNumber = 2 } = {}) => { 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 e31231c0a95..9d47da9183e 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 @@ -4101,6 +4101,119 @@ X[] }); }); }); + 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', () => { @@ -4352,6 +4465,155 @@ X[] }); }); }); + 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
', + }); + }); + }); }); }); }); diff --git a/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js index 156fb2ee820..a7d396512f0 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,6 +1577,12 @@ 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.on('color_hover', null, ev => { @@ -1584,7 +1591,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 +1622,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({ From 7a64a04421171469c58bb94bbe8c55b1e2fdf3f2 Mon Sep 17 00:00:00 2001 From: Antoine Guenet Date: Fri, 8 Jul 2022 09:42:36 +0000 Subject: [PATCH 12/14] [ADD] web_editor: add UI for tables This adds a menu to manipulate table rows and columns when hovering over them. Hovering over the first row shows the menu for the hovered column. Hovering over the first column shows the menu for the hovered row. The buttons in the menu allow the user to add/move/remove rows/columns. task-2858455 Part-of: odoo/odoo#92200 --- .../js/editor/odoo-editor/src/OdooEditor.js | 240 ++++++++++++++++- .../odoo-editor/src/commands/commands.js | 111 ++++++-- .../src/js/editor/odoo-editor/src/style.scss | 52 ++++ .../odoo-editor/test/spec/editor.test.js | 251 +++++++++++++++++- 4 files changed, 611 insertions(+), 43 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 c5ae7abc294..eb5a287519e 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 @@ -167,6 +167,16 @@ export const CLIPBOARD_WHITELISTS = { } }; +// Commands that don't require a DOM selection but take an argument instead. +const SELECTIONLESS_COMMANDS = [ + 'addRowAbove', + 'addRowBelow', + 'addColumnLeft', + 'addColumnRight', + 'removeRow', + 'removeColumn', +]; + function defaultOptions(defaultObject, object) { const newObject = Object.assign({}, defaultObject, object); for (const [key, value] of Object.entries(object)) { @@ -316,7 +326,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, @@ -328,7 +342,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('addColumnLeft', this._columnUiTarget)); + this.addDomListener(uiMenu.querySelector('.o_insert_right'), 'click', () => this.execCommand('addColumnRight', 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('addRowAbove', this._rowUiTarget)); + this.addDomListener(uiMenu.querySelector('.o_insert_below'), 'click', () => this.execCommand('addRowBelow', 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, @@ -493,14 +578,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 // ----------- @@ -522,6 +599,7 @@ export class OdooEditor extends EventTarget { 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); @@ -593,6 +671,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() { @@ -1764,8 +1845,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; @@ -2154,6 +2237,58 @@ export class OdooEditor extends EventTarget { } } } + /** + * 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 // ======= @@ -3514,12 +3649,29 @@ export class OdooEditor extends EventTarget { 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 * @@ -3796,6 +3948,70 @@ export class OdooEditor extends EventTarget { 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/commands/commands.js b/addons/web_editor/static/src/js/editor/odoo-editor/src/commands/commands.js index fece7d19fc9..ad847413fa9 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; @@ -540,26 +543,72 @@ 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 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'; } -function addRow(editor, beforeOrAfter) { - getDeepRange(editor.editable, { select: true }); // Ensure deep range for finding tr. - const row = getInSelection(editor.document, 'tr'); - if (!row) return; +function 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'); - const cells = row.querySelectorAll('td'); + 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; })); - row[beforeOrAfter](newRow); + 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++; + } + } } function deleteTable(editor, table) { table = table || getInSelection(editor.document, 'table'); @@ -880,22 +929,24 @@ export const editorCommands = { const [table] = editorCommands.insertHTML(editor, tableHtml); setCursorStart(table.querySelector('td')); }, - addColumnLeft: editor => { - addColumn(editor, 'before'); + addColumnLeft: (editor, referenceColumn) => { + return addColumn(editor, 'before', referenceColumn); }, - addColumnRight: editor => { - addColumn(editor, 'after'); + addColumnRight: (editor, referenceColumn) => { + return addColumn(editor, 'after', referenceColumn); }, - addRowAbove: editor => { - addRow(editor, 'before'); + addRowAbove: (editor, referenceRow) => { + return addRow(editor, 'before', referenceRow); }, - addRowBelow: editor => { - addRow(editor, 'after'); + addRowBelow: (editor, referenceRow) => { + return addRow(editor, 'after', referenceRow); }, - 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); @@ -903,10 +954,12 @@ export const editorCommands = { table.querySelectorAll(`tr td:nth-of-type(${index + 1})`).forEach(td => td.remove()); siblingCell ? setSelection(...startPos(siblingCell)) : 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); 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 201ffd2ec44..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 @@ -34,6 +34,58 @@ 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); 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 9d47da9183e..4d54c0ca085 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 @@ -3920,12 +3920,259 @@ 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('addRowAbove'), + contentAfter: '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '



abcdef[]
', + }); + }); + it('should add a row above the middle row', async () => { + await testEditor(BasicEditor, { + contentBefore: '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
abcdef
abcdef[]
', + stepFunction: async editor => editor.execCommand('addRowAbove'), + 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('addRowBelow'), + contentAfter: '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
abcdef[]



', + }); + }); + it('should add a row below the middle row', async () => { + await testEditor(BasicEditor, { + contentBefore: '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
abcdef[]
abcdef
', + stepFunction: async editor => editor.execCommand('addRowBelow'), + 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('addColumnLeft'), + 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('addColumnLeft'), + 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('addColumnRight'), + 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('addColumnRight'), + 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[]
', + contentBefore: '
abcdef[]
', stepFunction: async editor => triggerEvent(editor.editable, 'keydown', { key: 'Tab'}), - contentAfter: '
abcdef
[]


', + contentAfter: '
abcdef
[]


', }); }); }); From aca5aea475e33c7d363999ce9afc2a058035970c Mon Sep 17 00:00:00 2001 From: Antoine Guenet Date: Thu, 25 Aug 2022 08:23:41 +0000 Subject: [PATCH 13/14] [FIX] web_editor: prevent triggering color_leave after color_picked When the user picks a color, the colorpicker closes, triggering color_leave. This is conceptually wrong and can cause issues as it reverts the current history step. Part-of: odoo/odoo#92200 --- addons/web_editor/static/src/js/wysiwyg/wysiwyg.js | 1 + 1 file changed, 1 insertion(+) diff --git a/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js index a7d396512f0..6eca81e396f 100644 --- a/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js +++ b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js @@ -1584,6 +1584,7 @@ const Wysiwyg = Widget.extend({ this.odooEditor.document.getSelection().collapseToStart(); } this._updateEditorUI(); + colorpicker.off('color_leave'); }); colorpicker.on('color_hover', null, ev => { if (hadNonCollapsedSelection) { From f35f62599d9e0ac4bab0cfc4a681b0c66068a353 Mon Sep 17 00:00:00 2001 From: Antoine Guenet Date: Thu, 25 Aug 2022 10:01:36 +0000 Subject: [PATCH 14/14] [REF] web_editor: remove add(Row|Column)(Left|Right|Above|Below) The commands to add rows and columns in certain positions were superfluous as commands to add rows and columns suffice if you pass them a position argument. Part-of: odoo/odoo#92200 --- .../js/editor/odoo-editor/src/OdooEditor.js | 19 +- .../odoo-editor/src/commands/commands.js | 165 ++++++++---------- .../odoo-editor/test/spec/editor.test.js | 16 +- addons/web_editor/static/src/xml/editor.xml | 16 +- 4 files changed, 98 insertions(+), 118 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 eb5a287519e..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 @@ -168,14 +168,7 @@ export const CLIPBOARD_WHITELISTS = { }; // Commands that don't require a DOM selection but take an argument instead. -const SELECTIONLESS_COMMANDS = [ - 'addRowAbove', - 'addRowBelow', - 'addColumnLeft', - 'addColumnRight', - 'removeRow', - 'removeColumn', -]; +const SELECTIONLESS_COMMANDS = ['addRow', 'addColumn', 'removeRow', 'removeColumn']; function defaultOptions(defaultObject, object) { const newObject = Object.assign({}, defaultObject, object); @@ -385,15 +378,15 @@ export class OdooEditor extends EventTarget {
Insert left
Insert right
`, 'text/html').body.children); - this.addDomListener(uiMenu.querySelector('.o_insert_left'), 'click', () => this.execCommand('addColumnLeft', this._columnUiTarget)); - this.addDomListener(uiMenu.querySelector('.o_insert_right'), 'click', () => this.execCommand('addColumnRight', this._columnUiTarget)); + 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('addRowAbove', this._rowUiTarget)); - this.addDomListener(uiMenu.querySelector('.o_insert_below'), 'click', () => this.execCommand('addRowBelow', this._rowUiTarget)); + 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. @@ -3944,7 +3937,7 @@ 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); } } 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 ad847413fa9..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 @@ -543,82 +543,6 @@ export function toggleFormat(editor, format) { } return changedElements; } -function 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'; -} -function 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++; - } - } -} -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 @@ -929,17 +853,72 @@ export const editorCommands = { const [table] = editorCommands.insertHTML(editor, tableHtml); setCursorStart(table.querySelector('td')); }, - addColumnLeft: (editor, referenceColumn) => { - return addColumn(editor, 'before', referenceColumn); + 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, referenceColumn) => { - return addColumn(editor, 'after', referenceColumn); - }, - addRowAbove: (editor, referenceRow) => { - return addRow(editor, 'before', referenceRow); - }, - addRowBelow: (editor, referenceRow) => { - return addRow(editor, 'after', referenceRow); + 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++; + } + } }, removeColumn: (editor, cell) => { if (!cell) { @@ -952,7 +931,7 @@ export const editorCommands = { 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, row) => { if (!row) { @@ -965,9 +944,17 @@ export const editorCommands = { 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/test/spec/editor.test.js b/addons/web_editor/static/src/js/editor/odoo-editor/test/spec/editor.test.js index 4d54c0ca085..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 @@ -3930,7 +3930,7 @@ X[] 'cd' + 'ef[]' + '', - stepFunction: async editor => editor.execCommand('addRowAbove'), + stepFunction: async editor => editor.execCommand('addRow', 'before'), contentAfter: '' + '' + '' + @@ -3955,7 +3955,7 @@ X[] '' + '' + '


cdef[]
', - stepFunction: async editor => editor.execCommand('addRowAbove'), + stepFunction: async editor => editor.execCommand('addRow', 'before'), contentAfter: '' + '' + '' + @@ -3982,7 +3982,7 @@ X[] '' + '' + '
abcdcdef[]
', - stepFunction: async editor => editor.execCommand('addRowBelow'), + stepFunction: async editor => editor.execCommand('addRow', 'after'), contentAfter: '' + '' + '' + @@ -4007,7 +4007,7 @@ X[] '' + '' + '
abcdcdef
', - stepFunction: async editor => editor.execCommand('addRowBelow'), + stepFunction: async editor => editor.execCommand('addRow', 'after'), contentAfter: '' + '' + '' + @@ -4041,7 +4041,7 @@ X[] '' + '' + '
abcdcdef
', - stepFunction: async editor => editor.execCommand('addColumnLeft'), + stepFunction: async editor => editor.execCommand('addColumn', 'before'), contentAfter: '' + '' + '' + @@ -4073,7 +4073,7 @@ X[] '' + '' + '

ab[]cdef
', - stepFunction: async editor => editor.execCommand('addColumnLeft'), + stepFunction: async editor => editor.execCommand('addColumn', 'before'), contentAfter: '' + '' + '' + @@ -4108,7 +4108,7 @@ X[] '' + '' + '
ab
cdef
', - stepFunction: async editor => editor.execCommand('addColumnRight'), + stepFunction: async editor => editor.execCommand('addColumn', 'after'), contentAfter: '' + '' + '' + @@ -4143,7 +4143,7 @@ X[] '' + '' + '
abcdcdef
', - stepFunction: async editor => editor.execCommand('addColumnRight'), + stepFunction: async editor => editor.execCommand('addColumn', 'after'), contentAfter: '' + '' + '' + 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 @@
abcd