[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
This commit is contained in:
@@ -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(`<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('addColumnLeft', this._columnUiTarget));
|
||||
this.addDomListener(uiMenu.querySelector('.o_insert_right'), 'click', () => this.execCommand('addColumnRight', 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('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(`<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,
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: '<table><tbody><tr style="height: 20px;">' +
|
||||
'<td style="width: 20px;">ab</td>' +
|
||||
'<td style="width: 25px;">cd</td>' +
|
||||
'<td style="width: 30px;">ef[]</td>' +
|
||||
'</tr></tbody></table>',
|
||||
stepFunction: async editor => editor.execCommand('addRowAbove'),
|
||||
contentAfter: '<table><tbody><tr style="height: 20px;">' +
|
||||
'<td style="width: 20px;"><br></td>' +
|
||||
'<td style="width: 25px;"><br></td>' +
|
||||
'<td style="width: 30px;"><br></td>' +
|
||||
'</tr>' +
|
||||
'<tr style="height: 20px;">' +
|
||||
'<td style="">ab</td>' +
|
||||
'<td style="">cd</td>' +
|
||||
'<td style="">ef[]</td>' +
|
||||
'</tr></tbody></table>',
|
||||
});
|
||||
});
|
||||
it('should add a row above the middle row', async () => {
|
||||
await testEditor(BasicEditor, {
|
||||
contentBefore: '<table><tbody><tr style="height: 20px;">' +
|
||||
'<td style="width: 20px;">ab</td>' +
|
||||
'<td style="width: 25px;">cd</td>' +
|
||||
'<td style="width: 30px;">ef</td>' +
|
||||
'</tr>' +
|
||||
'<tr style="height: 30px;">' +
|
||||
'<td>ab</td>' +
|
||||
'<td>cd</td>' +
|
||||
'<td>ef[]</td>' +
|
||||
'</tr></tbody></table>',
|
||||
stepFunction: async editor => editor.execCommand('addRowAbove'),
|
||||
contentAfter: '<table><tbody><tr style="height: 20px;">' +
|
||||
'<td style="width: 20px;">ab</td>' +
|
||||
'<td style="width: 25px;">cd</td>' +
|
||||
'<td style="width: 30px;">ef</td>' +
|
||||
'</tr>' +
|
||||
'<tr style="height: 30px;">' +
|
||||
'<td><br></td>' +
|
||||
'<td><br></td>' +
|
||||
'<td><br></td>' +
|
||||
'</tr>' +
|
||||
'<tr style="height: 30px;">' +
|
||||
'<td>ab</td>' +
|
||||
'<td>cd</td>' +
|
||||
'<td>ef[]</td>' +
|
||||
'</tr></tbody></table>',
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('below', () => {
|
||||
it('should add a row below the bottom row', async () => {
|
||||
await testEditor(BasicEditor, {
|
||||
contentBefore: '<table><tbody><tr style="height: 20px;">' +
|
||||
'<td style="width: 20px;">ab</td>' +
|
||||
'<td style="width: 25px;">cd</td>' +
|
||||
'<td style="width: 30px;">ef[]</td>' +
|
||||
'</tr></tbody></table>',
|
||||
stepFunction: async editor => editor.execCommand('addRowBelow'),
|
||||
contentAfter: '<table><tbody><tr style="height: 20px;">' +
|
||||
'<td style="width: 20px;">ab</td>' +
|
||||
'<td style="width: 25px;">cd</td>' +
|
||||
'<td style="width: 30px;">ef[]</td>' +
|
||||
'</tr>' +
|
||||
'<tr style="height: 20px;">' +
|
||||
'<td><br></td>' +
|
||||
'<td><br></td>' +
|
||||
'<td><br></td>' +
|
||||
'</tr></tbody></table>',
|
||||
});
|
||||
});
|
||||
it('should add a row below the middle row', async () => {
|
||||
await testEditor(BasicEditor, {
|
||||
contentBefore: '<table><tbody><tr style="height: 20px;">' +
|
||||
'<td style="width: 20px;">ab</td>' +
|
||||
'<td style="width: 25px;">cd</td>' +
|
||||
'<td style="width: 30px;">ef[]</td>' +
|
||||
'</tr>' +
|
||||
'<tr style="height: 30px;">' +
|
||||
'<td>ab</td>' +
|
||||
'<td>cd</td>' +
|
||||
'<td>ef</td>' +
|
||||
'</tr></tbody></table>',
|
||||
stepFunction: async editor => editor.execCommand('addRowBelow'),
|
||||
contentAfter: '<table><tbody><tr style="height: 20px;">' +
|
||||
'<td style="width: 20px;">ab</td>' +
|
||||
'<td style="width: 25px;">cd</td>' +
|
||||
'<td style="width: 30px;">ef[]</td>' +
|
||||
'</tr>' +
|
||||
'<tr style="height: 20px;">' +
|
||||
'<td><br></td>' +
|
||||
'<td><br></td>' +
|
||||
'<td><br></td>' +
|
||||
'</tr>' +
|
||||
'<tr style="height: 30px;">' +
|
||||
'<td>ab</td>' +
|
||||
'<td>cd</td>' +
|
||||
'<td>ef</td>' +
|
||||
'</tr></tbody></table>',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('column', () => {
|
||||
describe('left', () => {
|
||||
it('should add a column left of the leftmost column', async () => {
|
||||
await testEditor(BasicEditor, {
|
||||
contentBefore: '<table style="width: 150px;"><tbody><tr style="height: 20px;">' +
|
||||
'<td style="width: 40px;">ab[]</td>' +
|
||||
'<td style="width: 50px;">cd</td>' +
|
||||
'<td style="width: 60px;">ef</td>' +
|
||||
'</tr>' +
|
||||
'<tr style="height: 30px;">' +
|
||||
'<td>ab</td>' +
|
||||
'<td>cd</td>' +
|
||||
'<td>ef</td>' +
|
||||
'</tr></tbody></table>',
|
||||
stepFunction: async editor => editor.execCommand('addColumnLeft'),
|
||||
contentAfter: '<table style="width: 150px;"><tbody><tr style="height: 20px;">' +
|
||||
'<td style="width: 32px;"><br></td>' +
|
||||
'<td style="width: 32px;">ab[]</td>' +
|
||||
'<td style="width: 40px;">cd</td>' +
|
||||
'<td style="width: 45px;">ef</td>' +
|
||||
'</tr>' +
|
||||
'<tr style="height: 30px;">' +
|
||||
'<td><br></td>' +
|
||||
'<td>ab</td>' +
|
||||
'<td>cd</td>' +
|
||||
'<td>ef</td>' +
|
||||
'</tr></tbody></table>',
|
||||
});
|
||||
});
|
||||
it('should add a column left of the middle column', async () => {
|
||||
await testEditor(BasicEditor, {
|
||||
contentBefore: '<table style="width: 200px;"><tbody><tr style="height: 20px;">' +
|
||||
'<td style="width: 50px;">ab</td>' +
|
||||
'<td style="width: 65px;">cd</td>' +
|
||||
'<td style="width: 85px;">ef</td>' +
|
||||
'</tr>' +
|
||||
'<tr style="height: 30px;">' +
|
||||
'<td>ab</td>' +
|
||||
'<td>cd[]</td>' +
|
||||
'<td>ef</td>' +
|
||||
'</tr>' +
|
||||
'<tr style="height: 40px;">' +
|
||||
'<td>ab</td>' +
|
||||
'<td>cd</td>' +
|
||||
'<td>ef</td>' +
|
||||
'</tr></tbody></table>',
|
||||
stepFunction: async editor => editor.execCommand('addColumnLeft'),
|
||||
contentAfter: '<table style="width: 200px;"><tbody><tr style="height: 20px;">' +
|
||||
'<td style="width: 38px;">ab</td>' +
|
||||
'<td style="width: 50px;"><br></td>' +
|
||||
'<td style="width: 50px;">cd</td>' +
|
||||
'<td style="width: 61px;">ef</td>' +
|
||||
'</tr>' +
|
||||
'<tr style="height: 30px;">' +
|
||||
'<td>ab</td>' +
|
||||
'<td><br></td>' +
|
||||
'<td>cd[]</td>' +
|
||||
'<td>ef</td>' +
|
||||
'</tr>' +
|
||||
'<tr style="height: 40px;">' +
|
||||
'<td>ab</td>' +
|
||||
'<td><br></td>' +
|
||||
'<td>cd</td>' +
|
||||
'<td>ef</td>' +
|
||||
'</tr></tbody></table>',
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('right', () => {
|
||||
it('should add a column right of the rightmost column', async () => {
|
||||
await testEditor(BasicEditor, {
|
||||
contentBefore: '<table style="width: 150px;"><tbody><tr style="height: 20px;">' +
|
||||
'<td style="width: 40px;">ab</td>' +
|
||||
'<td style="width: 50px;">cd</td>' +
|
||||
'<td style="width: 60px;">ef[]</td>' +
|
||||
'</tr>' +
|
||||
'<tr style="height: 30px;">' +
|
||||
'<td>ab</td>' +
|
||||
'<td>cd</td>' +
|
||||
'<td>ef</td>' +
|
||||
'</tr></tbody></table>',
|
||||
stepFunction: async editor => editor.execCommand('addColumnRight'),
|
||||
contentAfter: '<table style="width: 150px;"><tbody><tr style="height: 20px;">' +
|
||||
'<td style="width: 29px;">ab</td>' +
|
||||
'<td style="width: 36px;">cd</td>' +
|
||||
'<td style="width: 41px;">ef[]</td>' +
|
||||
// size was slightly adjusted to
|
||||
// preserve table width in view on
|
||||
// fractional division results
|
||||
'<td style="width: 43px;"><br></td>' +
|
||||
'</tr>' +
|
||||
'<tr style="height: 30px;">' +
|
||||
'<td>ab</td>' +
|
||||
'<td>cd</td>' +
|
||||
'<td>ef</td>' +
|
||||
'<td><br></td>' +
|
||||
'</tr></tbody></table>',
|
||||
});
|
||||
});
|
||||
it('should add a column right of the middle column', async () => {
|
||||
await testEditor(BasicEditor, {
|
||||
contentBefore: '<table style="width: 200px;"><tbody><tr style="height: 20px;">' +
|
||||
'<td style="width: 50px;">ab</td>' +
|
||||
'<td style="width: 65px;">cd</td>' +
|
||||
'<td style="width: 85px;">ef</td>' +
|
||||
'</tr>' +
|
||||
'<tr style="height: 30px;">' +
|
||||
'<td>ab</td>' +
|
||||
'<td>cd[]</td>' +
|
||||
'<td>ef</td>' +
|
||||
'</tr>' +
|
||||
'<tr style="height: 40px;">' +
|
||||
'<td>ab</td>' +
|
||||
'<td>cd</td>' +
|
||||
'<td>ef</td>' +
|
||||
'</tr></tbody></table>',
|
||||
stepFunction: async editor => editor.execCommand('addColumnRight'),
|
||||
contentAfter: '<table style="width: 200px;"><tbody><tr style="height: 20px;">' +
|
||||
'<td style="width: 38px;">ab</td>' +
|
||||
'<td style="width: 50px;">cd</td>' +
|
||||
'<td style="width: 50px;"><br></td>' +
|
||||
'<td style="width: 61px;">ef</td>' +
|
||||
'</tr>' +
|
||||
'<tr style="height: 30px;">' +
|
||||
'<td>ab</td>' +
|
||||
'<td>cd[]</td>' +
|
||||
'<td><br></td>' +
|
||||
'<td>ef</td>' +
|
||||
'</tr>' +
|
||||
'<tr style="height: 40px;">' +
|
||||
'<td>ab</td>' +
|
||||
'<td>cd</td>' +
|
||||
'<td><br></td>' +
|
||||
'<td>ef</td>' +
|
||||
'</tr></tbody></table>',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('tab', () => {
|
||||
it('should add a new row on press tab at the end of a table', async () => {
|
||||
await testEditor(BasicEditor, {
|
||||
contentBefore: '<table><tbody><tr><td>ab</td><td>cd</td><td>ef[]</td></tr></tbody></table>',
|
||||
contentBefore: '<table><tbody><tr style="height: 20px;"><td style="width: 20px;">ab</td><td>cd</td><td>ef[]</td></tr></tbody></table>',
|
||||
stepFunction: async editor => triggerEvent(editor.editable, 'keydown', { key: 'Tab'}),
|
||||
contentAfter: '<table><tbody><tr><td>ab</td><td>cd</td><td>ef</td></tr><tr><td>[]<br></td><td><br></td><td><br></td></tr></tbody></table>',
|
||||
contentAfter: '<table><tbody><tr style="height: 20px;"><td style="width: 20px;">ab</td><td>cd</td><td>ef</td></tr><tr style="height: 20px;"><td>[]<br></td><td><br></td><td><br></td></tr></tbody></table>',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user