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