/**
* @module plugins/select-cells
*/
import type { IBound, IJodit, Nullable } from 'jodit/types';
import { Plugin } from 'jodit/core/plugin';
import { Table } from 'jodit/modules';
import { Dom } from 'jodit/core/dom/dom';
import { $$, alignElement, position } from 'jodit/core/helpers';
import { KEY_TAB } from 'jodit/core/constants';
import { autobind, watch } from 'jodit/core/decorators';
import { pluginSystem } from 'jodit/core/global';
import './config';
const key = 'table_processor_observer';
const MOUSE_MOVE_LABEL = 'onMoveTableSelectCell';
export class selectCells extends Plugin {
override requires = ['select'];
/**
* Shortcut for Table module
*/
private get module(): Table {
return this.j.getInstance
('Table', this.j.o);
}
protected afterInit(jodit: IJodit): void {
if (!jodit.o.tableAllowCellSelection) {
return;
}
jodit.e
.on('keydown.select-cells', (event: KeyboardEvent) => {
if (event.key === KEY_TAB) {
this.unselectCells();
}
})
.on('beforeCommand.select-cells', this.onExecCommand)
.on('afterCommand.select-cells', this.onAfterCommand)
// see `plugins/select.ts`
.on(
[
'clickEditor',
'mousedownTd',
'mousedownTh',
'touchstartTd',
'touchstartTh'
]
.map(e => e + '.select-cells')
.join(' '),
this.onStartSelection
)
// For `clickEditor` correct working. Because `mousedown` on first cell
// and mouseup on another cell call `click` only for `TR` element.
.on('clickTr clickTbody', (): void | false => {
const cellsCount = this.module.getAllSelectedCells().length;
if (cellsCount) {
if (cellsCount > 1) {
this.j.s.sel?.removeAllRanges();
}
return false;
}
});
}
/**
* First selected cell
*/
private selectedCell: Nullable = null;
/**
* User is selecting cells now
*/
private isSelectionMode: boolean = false;
/**
* Mouse click inside the table
*/
@autobind
protected onStartSelection(cell: HTMLTableCellElement): void | false {
if (this.j.o.readonly) {
return;
}
this.unselectCells();
if (cell === this.j.editor) {
return;
}
const table = Dom.closest(
cell,
'table',
this.j.editor
) as HTMLTableElement;
if (!cell || !table) {
return;
}
if (!cell.firstChild) {
cell.appendChild(this.j.createInside.element('br'));
}
this.isSelectionMode = true;
this.selectedCell = cell;
this.module.addSelection(cell);
this.j.e
.on(
table,
'mousemove.select-cells touchmove.select-cells',
// Don't use decorator because need clear label on mouseup
this.j.async.throttle(this.onMove.bind(this, table), {
label: MOUSE_MOVE_LABEL,
timeout: this.j.defaultTimeout / 2
})
)
.on(
table,
'mouseup.select-cells touchend.select-cells',
this.onStopSelection.bind(this, table)
);
return false;
}
@watch(':outsideClick')
protected onOutsideClick(): void {
this.selectedCell = null;
this.onRemoveSelection();
}
@watch(':change')
protected onChange(): void {
if (!this.j.isLocked && !this.isSelectionMode) {
this.onRemoveSelection();
}
}
/**
* Mouse move inside the table
*/
private onMove(table: HTMLTableElement, e: MouseEvent): void {
if (this.j.o.readonly && !this.j.isLocked) {
return;
}
if (this.j.isLockedNotBy(key)) {
return;
}
const node = this.j.ed.elementFromPoint(e.clientX, e.clientY);
if (!node) {
return;
}
const cell = Dom.closest(node, ['td', 'th'], table);
if (!cell || !this.selectedCell) {
return;
}
if (cell !== this.selectedCell) {
this.j.lock(key);
}
this.unselectCells();
const bound = Table.getSelectedBound(table, [cell, this.selectedCell]),
box = Table.formalMatrix(table);
for (let i = bound[0][0]; i <= bound[1][0]; i += 1) {
for (let j = bound[0][1]; j <= bound[1][1]; j += 1) {
this.module.addSelection(box[i][j]);
}
}
const cellsCount = this.module.getAllSelectedCells().length;
if (cellsCount > 1) {
this.j.s.sel?.removeAllRanges();
}
this.j.e.fire('hidePopup');
e.stopPropagation();
// Hack for FireFox for force redraw selection
((): void => {
const n = this.j.createInside.fromHTML(
'
'
);
cell.appendChild(n);
this.j.async.setTimeout(() => {
n.parentNode?.removeChild(n);
}, this.j.defaultTimeout / 5);
})();
}
/**
* On click in outside - remove selection
*/
@autobind
private onRemoveSelection(e?: MouseEvent): void {
if (
!e?.buffer?.actionTrigger &&
!this.selectedCell &&
this.module.getAllSelectedCells().length
) {
this.j.unlock();
this.unselectCells();
this.j.e.fire('hidePopup', 'cells');
return;
}
this.isSelectionMode = false;
this.selectedCell = null;
}
/**
* Stop selection process
*/
@autobind
private onStopSelection(table: HTMLTableElement, e: MouseEvent): void {
if (!this.selectedCell) {
return;
}
this.isSelectionMode = false;
this.j.unlock();
const node = this.j.ed.elementFromPoint(e.clientX, e.clientY);
if (!node) {
return;
}
const cell = Dom.closest(node, ['td', 'th'], table);
if (!cell) {
return;
}
const ownTable = Dom.closest(cell, 'table', table);
if (ownTable && ownTable !== table) {
return; // Nested tables
}
const bound = Table.getSelectedBound(table, [cell, this.selectedCell]),
box = Table.formalMatrix(table);
const max = box[bound[1][0]][bound[1][1]],
min = box[bound[0][0]][bound[0][1]];
this.j.e.fire(
'showPopup',
table,
(): IBound => {
const minOffset: IBound = position(min, this.j),
maxOffset: IBound = position(max, this.j);
return {
left: minOffset.left,
top: minOffset.top,
width: maxOffset.left - minOffset.left + maxOffset.width,
height: maxOffset.top - minOffset.top + maxOffset.height
};
},
'cells'
);
$$('table', this.j.editor).forEach(table => {
this.j.e.off(
table,
'mousemove.select-cells touchmove.select-cells mouseup.select-cells touchend.select-cells'
);
});
this.j.async.clearTimeout(MOUSE_MOVE_LABEL);
}
/**
* Remove selection for all cells
*/
private unselectCells(currentCell?: Nullable): void {
const module = this.module;
const cells = module.getAllSelectedCells();
if (cells.length) {
cells.forEach(cell => {
if (!currentCell || currentCell !== cell) {
module.removeSelection(cell);
}
});
}
}
/**
* Execute custom commands for table
*/
@autobind
private onExecCommand(command: string): false | void {
if (
/table(splitv|splitg|merge|empty|bin|binrow|bincolumn|addcolumn|addrow)/.test(
command
)
) {
command = command.replace('table', '');
const cells = this.module.getAllSelectedCells();
if (cells.length) {
const [cell] = cells;
if (!cell) {
return;
}
const table = Dom.closest(cell, 'table', this.j.editor);
if (!table) {
return;
}
switch (command) {
case 'splitv':
Table.splitVertical(table, this.j);
break;
case 'splitg':
Table.splitHorizontal(table, this.j);
break;
case 'merge':
Table.mergeSelected(table, this.j);
break;
case 'empty':
cells.forEach(td => Dom.detach(td));
break;
case 'bin':
Dom.safeRemove(table);
break;
case 'binrow':
new Set(
cells.map(
td => td.parentNode as HTMLTableRowElement
)
).forEach(row => {
Table.removeRow(table, row.rowIndex);
});
break;
case 'bincolumn':
{
const columnsSet = new Set(),
columns = cells.reduce((acc, td) => {
if (!columnsSet.has(td.cellIndex)) {
acc.push(td);
columnsSet.add(td.cellIndex);
}
return acc;
}, []);
columns.forEach(td => {
Table.removeColumn(table, td.cellIndex);
});
}
break;
case 'addcolumnafter':
case 'addcolumnbefore':
Table.appendColumn(
table,
cell.cellIndex,
command === 'addcolumnafter',
this.j.createInside
);
break;
case 'addrowafter':
case 'addrowbefore':
Table.appendRow(
table,
cell.parentNode as HTMLTableRowElement,
command === 'addrowafter',
this.j.createInside
);
break;
}
}
return false;
}
}
/**
* Add some align after native command
*/
@autobind
private onAfterCommand(command: string): void {
if (/^justify/.test(command)) {
this.module
.getAllSelectedCells()
.forEach(elm => alignElement(command, elm));
}
}
/** @override */
protected beforeDestruct(jodit: IJodit): void {
this.onRemoveSelection();
jodit.e.off('.select-cells');
}
}
pluginSystem.add('selectCells', selectCells);