[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
This commit is contained in:
Antoine Guenet
2022-08-26 17:16:32 +02:00
parent d8ec51f47a
commit f3cff78472
2 changed files with 182 additions and 6 deletions
@@ -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);
}
}
/**
@@ -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;