UNPKG

suneditor

Version:

Vanilla JavaScript based WYSIWYG web editor

466 lines (385 loc) 13.9 kB
import { dom, numbers } from '../../../../helper'; import { SelectMenu } from '../../../../modules/ui'; import { CloneTable, CreateCellsHTML, InvalidateTableCache } from '../shared/table.utils'; import { CreateSplitMenu } from '../render/table.menu'; /** * @description Handles table cell operations including merging, splitting, * - and multi-cell selection management. */ export class TableCellService { #main; #$; #state; /** * @param {import('../index').default} main Table index * @param {{ * mergeButton: HTMLButtonElement, * unmergeButton: HTMLButtonElement, * splitButton: HTMLButtonElement, * openCellMenuFunc: ()=>void, * closeCellMenuFunc: ()=>void * }} options Options for table service */ constructor(main, { mergeButton, unmergeButton, splitButton, openCellMenuFunc, closeCellMenuFunc }) { this.#main = main; this.#$ = main.$; this.#state = main.state; this.mergeButton = mergeButton; this.unmergeButton = unmergeButton; this.splitButton = splitButton; // members - SelectMenu - split const splitMenu = CreateSplitMenu(this.#$.lang); this.selectMenu_split = new SelectMenu(this.#$, { checkList: false, position: 'bottom-center', openMethod: openCellMenuFunc, closeMethod: closeCellMenuFunc }); this.selectMenu_split.on(this.splitButton, this._OnSplitCells.bind(this)); this.selectMenu_split.create(splitMenu.items, splitMenu.menus); } get #selectionService() { return this.#main.selectionService; } /** * @description Opens the split menu. */ openSplitMenu() { this.selectMenu_split.open(); } /** * @description Merges the selected table cells into one cell by combining their contents and adjusting their row and column spans. * - This method removes the selected cells, consolidates their contents, and applies the appropriate row and column spans to the merged cell. * @param {HTMLTableCellElement[]} selectedCells Cells array * @param {boolean} [skipPostProcess=false] - If `true`, skips table cloning, cell re-selection, history stack push, and rendering. */ mergeCells(selectedCells, skipPostProcess = false) { const originTable = selectedCells[0].closest('table'); InvalidateTableCache(originTable); const { clonedTable, clonedSelectedCells } = skipPostProcess ? { clonedTable: originTable, clonedSelectedCells: selectedCells } : CloneTable(originTable, selectedCells); this.#main.setTableInfo(clonedTable); selectedCells = clonedSelectedCells; this.#main.setState('ref', null); this.#selectionService.setMultiCells(selectedCells[0], dom.query.findVisualLastCell(selectedCells)); const ref = this.#state.ref; const mergeCell = selectedCells[0]; let emptyRowFirst = null; let emptyRowLast = null; const cs = ref.ce - ref.cs + 1; let rs = ref.re - ref.rs + 1; let mergeHTML = ''; let row = null; for (let i = 1, len = selectedCells.length, cell, ch; i < len; i++) { cell = selectedCells[i]; if (row !== cell.parentNode) row = /** @type {HTMLTableRowElement} */ (cell.parentNode); ch = cell.children; for (let c = 0, cLen = ch.length; c < cLen; c++) { if (this.#$.format.isLine(ch[c]) && dom.check.isZeroWidth(ch[c].textContent)) { dom.utils.removeItem(ch[c]); } } mergeHTML += cell.innerHTML; dom.utils.removeItem(cell); if (row.cells.length === 0) { if (!emptyRowFirst) emptyRowFirst = row; else emptyRowLast = row; rs -= 1; } } if (emptyRowFirst) { const rows = this.#state.trElements; const rowIndexFirst = dom.utils.getArrayIndex(rows, emptyRowFirst); const rowIndexLast = dom.utils.getArrayIndex(rows, emptyRowLast || emptyRowFirst); const removeRows = []; for (let i = 0, cells; i <= rowIndexLast; i++) { cells = rows[i].cells; if (cells.length === 0) { removeRows.push(rows[i]); continue; } for (let c = 0, cLen = cells.length, cell, rs2; c < cLen; c++) { cell = cells[c]; rs2 = cell.rowSpan - 1; if (rs2 > 0 && i + rs2 >= rowIndexFirst) { cell.rowSpan -= numbers.getOverlapRangeAtIndex(rowIndexFirst, rowIndexLast, i, i + rs2); } } } for (let i = 0, len = removeRows.length; i < len; i++) { dom.utils.removeItem(removeRows[i]); } } mergeCell.innerHTML += mergeHTML; mergeCell.colSpan = cs; mergeCell.rowSpan = rs; if (skipPostProcess) return; // replace table originTable.replaceWith(clonedTable); this.#main._closeTableSelectInfo(); this.#main._setController(mergeCell); this.#selectionService.focusCellEdge(mergeCell); this.#selectionService.initCellSelection(mergeCell); this.#main.setState('ref', null); this.#main.setState('selectedCells', [mergeCell]); this.#main.setCellInfo(mergeCell, true); this.setUnMergeButton(); this.setMergeSplitButton(); // history push this.#main.historyPush(); } /** * @description Unmerges a table cell that has been merged using rowspan and/or colspan. * @param {HTMLTableCellElement[]} selectedCells - Cells array * @param {boolean} [skipPostProcess=false] - If `true`, skips table cloning, cell re-selection, history stack push, and rendering. */ unmergeCells(selectedCells, skipPostProcess = false) { if (!selectedCells?.length) return; const originTable = selectedCells[0].closest('table'); InvalidateTableCache(originTable); const { clonedTable, clonedSelectedCells } = skipPostProcess ? { clonedTable: originTable, clonedSelectedCells: selectedCells } : CloneTable(originTable, selectedCells); this.#main.setState('ref', null); this.#main.setTableInfo(clonedTable); selectedCells = clonedSelectedCells; let firstCell = selectedCells[0]; let lastCell = dom.query.findVisualLastCell(selectedCells); let newLastCell = null; const table = firstCell.closest('table'); const rows = table.rows; for (const cell of selectedCells) { const tr = /** @type {HTMLTableRowElement} */ (cell.parentElement); const rowIndex = tr.rowIndex; const colIndex = cell.cellIndex; const rowspan = cell.rowSpan; const colspan = cell.colSpan; if (rowspan === 1 && colspan === 1) continue; this.#main.setCellInfo(cell, true); const originalHTML = cell.innerHTML; cell.remove(); for (let r = 0; r < rowspan; r++) { const targetRow = rows[rowIndex + r]; for (let c = 0; c < colspan; c++) { const newCell = CreateCellsHTML('td'); if (r === 0 && c === 0) { if (firstCell === cell) firstCell = newCell; if (lastCell === cell) lastCell = newCell; newCell.innerHTML = originalHTML; targetRow.insertBefore(newCell, targetRow.cells[colIndex]); } else { targetRow.insertBefore(newCell, targetRow.cells[colIndex + c]); newLastCell = newCell; } } } } this.#main.setState('selectedCells', null); if (skipPostProcess) return; // replace table originTable.replaceWith(clonedTable); this.#main._closeTableSelectInfo(); this.#main.setTableInfo(clonedTable); // set info if (firstCell !== lastCell) { lastCell = !newLastCell || lastCell.closest('tr').rowIndex > newLastCell.closest('tr').rowIndex || lastCell.cellIndex > newLastCell.cellIndex ? lastCell : newLastCell; this.#selectionService.setMultiCells(firstCell, lastCell); this.#main.setState('selectedCells', Array.from(table.querySelectorAll('.se-selected-table-cell'))); } else { this.#main.setCellInfo(lastCell, true); } this.#selectionService.initCellSelection(firstCell); this.#main.setState('selectedCell', lastCell); this.setUnMergeButton(); this.#main.controller_cell.resetPosition(lastCell); // history push this.#main.historyPush(); } /** * @description Find merged cells * @param {HTMLTableCellElement[]} cells - Cells array */ findMergedCells(cells) { const mergedCells = []; cells?.forEach((cell) => { if (cell && (cell.rowSpan > 1 || cell.colSpan > 1)) { mergedCells.push(cell); } }); return mergedCells; } /** * @description Sets the merge/split button visibility. */ setMergeSplitButton() { if (!this.#state.ref) { this.splitButton.style.display = 'block'; this.mergeButton.style.display = 'none'; } else { this.splitButton.style.display = 'none'; this.mergeButton.style.display = 'block'; } } /** * @description Sets the unmerge button visibility. */ setUnMergeButton() { if (this.findMergedCells(!this.#state.selectedCells?.length ? [this.#state.fixedCell] : this.#state.selectedCells).length > 0) { this.unmergeButton.disabled = false; } else { this.unmergeButton.disabled = true; } } /** * @internal * @description Splits a table cell either vertically or horizontally. * @param {"vertical"|"horizontal"} direction The direction to split the cell. */ _OnSplitCells(direction) { InvalidateTableCache(this.#main._element); const vertical = direction === 'vertical'; const currentCell = this.#state.tdElement; const rows = this.#state.trElements; const currentRow = this.#state.trElement; const index = this.#state.logical_cellIndex; const rowIndex = this.#state.rowIndex; const newCell = CreateCellsHTML(currentCell.nodeName); // vertical if (vertical) { const currentColSpan = currentCell.colSpan; newCell.rowSpan = currentCell.rowSpan; // colspan > 1 if (currentColSpan > 1) { newCell.colSpan = Math.floor(currentColSpan / 2); currentCell.colSpan = currentColSpan - newCell.colSpan; currentRow.insertBefore(newCell, currentCell.nextElementSibling); } else { // colspan - 1 let rowSpanArr = []; let spanIndex = []; for (let i = 0, len = this.#state.rowCnt, cells, colSpan; i < len; i++) { cells = rows[i].cells; colSpan = 0; for (let c = 0, cLen = cells.length, cell, cs, rs, logcalIndex; c < cLen; c++) { cell = cells[c]; cs = cell.colSpan - 1; rs = cell.rowSpan - 1; logcalIndex = c + colSpan; if (spanIndex.length > 0) { for (let r = 0, arr; r < spanIndex.length; r++) { arr = spanIndex[r]; if (arr.row > i) continue; if (logcalIndex >= arr.index) { colSpan += arr.cs; logcalIndex += arr.cs; arr.rs -= 1; arr.row = i + 1; if (arr.rs < 1) { spanIndex.splice(r, 1); r--; } } else if (c === cLen - 1) { arr.rs -= 1; arr.row = i + 1; if (arr.rs < 1) { spanIndex.splice(r, 1); r--; } } } } if (logcalIndex <= index && rs > 0) { rowSpanArr.push({ index: logcalIndex, cs: cs + 1, rs: rs, row: -1, }); } if (cell !== currentCell && logcalIndex <= index && logcalIndex + cs >= index + currentColSpan - 1) { cell.colSpan += 1; break; } if (logcalIndex > index) break; colSpan += cs; } spanIndex = spanIndex.concat(rowSpanArr).sort(function (a, b) { return a.index - b.index; }); rowSpanArr = []; } currentRow.insertBefore(newCell, currentCell.nextElementSibling); } } else { // horizontal const currentRowSpan = currentCell.rowSpan; newCell.colSpan = currentCell.colSpan; // rowspan > 1 if (currentRowSpan > 1) { newCell.rowSpan = Math.floor(currentRowSpan / 2); const newRowSpan = currentRowSpan - newCell.rowSpan; const rowSpanArr = []; const nextRowIndex = dom.utils.getArrayIndex(rows, currentRow) + newRowSpan; for (let i = 0, cells, colSpan; i < nextRowIndex; i++) { cells = rows[i].cells; colSpan = 0; for (let c = 0, cLen = cells.length, cell, cs, logcalIndex; c < cLen; c++) { logcalIndex = c + colSpan; if (logcalIndex >= index) break; cell = cells[c]; cs = cell.rowSpan - 1; if (cs > 0 && cs + i >= nextRowIndex && logcalIndex < index) { rowSpanArr.push({ index: logcalIndex, cs: cell.colSpan, }); } colSpan += cell.colSpan - 1; } } const nextRow = rows[nextRowIndex]; const nextCells = nextRow.cells; let rs = rowSpanArr.shift(); for (let c = 0, cLen = nextCells.length, colSpan = 0, cell, cs, logcalIndex, insertIndex; c < cLen; c++) { logcalIndex = c + colSpan; cell = nextCells[c]; cs = cell.colSpan - 1; insertIndex = logcalIndex + cs + 1; if (rs && insertIndex >= rs.index) { colSpan += rs.cs; insertIndex += rs.cs; rs = rowSpanArr.shift(); } if (insertIndex >= index || c === cLen - 1) { nextRow.insertBefore(newCell, cell.nextElementSibling); break; } colSpan += cs; } currentCell.rowSpan = newRowSpan; } else { // rowspan - 1 newCell.rowSpan = currentCell.rowSpan; const newRow = dom.utils.createElement('TR'); newRow.appendChild(newCell); for (let i = 0, cells; i < rowIndex; i++) { cells = rows[i].cells; if (cells.length === 0) return; for (let c = 0, cLen = cells.length; c < cLen; c++) { if (i + cells[c].rowSpan - 1 >= rowIndex) { cells[c].rowSpan += 1; } } } const physicalIndex = this.#state.physical_cellIndex; const cells = currentRow.cells; for (let c = 0, cLen = cells.length; c < cLen; c++) { if (c === physicalIndex) continue; cells[c].rowSpan += 1; } currentRow.parentNode.insertBefore(newRow, currentRow.nextElementSibling); } } this.selectMenu_split.close(); this.#selectionService.focusCellEdge(currentCell); this.#selectionService.deleteStyleSelectedCells(); this.#$.history.push(false); this.#main._setController(currentCell); this.#main.setState('selectedCell', currentCell); this.#main.setState('fixedCell', currentCell); if (!this.#state.selectedCells?.length) this.#main.setState('selectedCells', [currentCell]); } } export default TableCellService;