import Quill from 'quill'; import Delta from 'quill-delta'; import merge from 'lodash.merge'; import type { LinkedList } from 'parchment'; import type { CorrectBound, Props, QuillTableBetter, TableCellMap, TableColgroup, TableContainer, UseLanguageHandler } from '../types'; import { createTooltip, getAlign, getCellFormats, getCorrectBounds, getComputeBounds, getComputeSelectedCols, getComputeSelectedTds, setElementProperty, getElementStyle, updateTableWidth } from '../utils'; import columnIcon from '../assets/icon/column.svg'; import rowIcon from '../assets/icon/row.svg'; import mergeIcon from '../assets/icon/merge.svg'; import tableIcon from '../assets/icon/table.svg'; import cellIcon from '../assets/icon/cell.svg'; import wrapIcon from '../assets/icon/wrap.svg'; import downIcon from '../assets/icon/down.svg'; import deleteIcon from '../assets/icon/delete.svg'; import copyIcon from '../assets/icon/copy.svg'; import { TableCell, tableId, TableTh, TableRow, TableThRow, TableThead, TableBody } from '../formats/table'; import TablePropertiesForm from './table-properties-form'; import { CELL_DEFAULT_VALUES, CELL_DEFAULT_WIDTH, CELL_PROPERTIES, DEVIATION, TABLE_PROPERTIES } from '../config'; interface Children { [propName: string]: { content: string; handler: () => void; divider?: boolean; createSwitch?: boolean; } } interface Menu { content: string; icon: string; handler: (list: HTMLUListElement, tooltip: HTMLDivElement) => void; children?: Children; } interface CustomMenu extends Menu { name: 'column' | 'row' | 'merge' | 'table' | 'cell' | 'wrap' | 'delete' | 'copy'; } interface MenusDefaults { [propName: string]: Menu } enum Alignment { left = 'margin-left', right = 'margin-right' } function getMenusConfig(useLanguage: UseLanguageHandler, menus?: string[]): MenusDefaults { const DEFAULT: MenusDefaults = { column: { content: useLanguage('col'), icon: columnIcon, handler(list: HTMLUListElement, tooltip: HTMLDivElement) { this.toggleAttribute(list, tooltip); }, children: { left: { content: useLanguage('insColL'), handler() { const { leftTd } = this.getSelectedTdsInfo(); const bounds = this.table.getBoundingClientRect(); this.insertColumn(leftTd, 0); updateTableWidth(this.table, bounds, CELL_DEFAULT_WIDTH); this.updateMenus(); } }, right: { content: useLanguage('insColR'), handler() { const { rightTd } = this.getSelectedTdsInfo(); const bounds = this.table.getBoundingClientRect(); this.insertColumn(rightTd, 1); updateTableWidth(this.table, bounds, CELL_DEFAULT_WIDTH); this.updateMenus(); } }, delete: { content: useLanguage('delCol'), handler() { this.deleteColumn(); } }, select: { content: useLanguage('selCol'), handler() { this.selectColumn(); } } } }, row: { content: useLanguage('row'), icon: rowIcon, handler(list: HTMLUListElement, tooltip: HTMLDivElement, e?: PointerEvent) { this.toggleAttribute(list, tooltip, e); }, children: { header: { content: useLanguage('headerRow'), divider: true, createSwitch: true, handler() { this.toggleHeaderRow(); this.toggleHeaderRowSwitch(); } }, above: { content: useLanguage('insRowAbv'), handler() { const { leftTd } = this.getSelectedTdsInfo(); this.insertRow(leftTd, 0); this.updateMenus(); } }, below: { content: useLanguage('insRowBlw'), handler() { const { rightTd } = this.getSelectedTdsInfo(); this.insertRow(rightTd, 1); this.updateMenus(); } }, delete: { content: useLanguage('delRow'), handler() { this.deleteRow(); } }, select: { content: useLanguage('selRow'), handler() { this.selectRow(); } } } }, merge: { content: useLanguage('mCells'), icon: mergeIcon, handler(list: HTMLUListElement, tooltip: HTMLDivElement) { this.toggleAttribute(list, tooltip); }, children: { merge: { content: useLanguage('mCells'), handler() { this.mergeCells(); this.updateMenus(); } }, split: { content: useLanguage('sCell'), handler() { this.splitCell(); this.updateMenus(); } } } }, table: { content: useLanguage('tblProps'), icon: tableIcon, handler(list: HTMLUListElement, tooltip: HTMLDivElement) { const attribute = { ...getElementStyle(this.table, TABLE_PROPERTIES), 'align': this.getTableAlignment(this.table) }; this.toggleAttribute(list, tooltip); this.tablePropertiesForm = new TablePropertiesForm(this, { attribute, type: 'table' }); this.hideMenus(); } }, cell: { content: useLanguage('cellProps'), icon: cellIcon, handler(list: HTMLUListElement, tooltip: HTMLDivElement) { const { selectedTds } = this.tableBetter.cellSelection; const attribute = selectedTds.length > 1 ? this.getSelectedTdsAttrs(selectedTds) : this.getSelectedTdAttrs(selectedTds[0]); this.toggleAttribute(list, tooltip); this.tablePropertiesForm = new TablePropertiesForm(this, { attribute, type: 'cell' }); this.hideMenus(); } }, wrap: { content: useLanguage('insParaOTbl'), icon: wrapIcon, handler(list: HTMLUListElement, tooltip: HTMLDivElement) { this.toggleAttribute(list, tooltip); }, children: { before: { content: useLanguage('insB4'), handler() { this.insertParagraph(-1); } }, after: { content: useLanguage('insAft'), handler() { this.insertParagraph(1); } } } }, delete: { content: useLanguage('delTable'), icon: deleteIcon, handler() { this.deleteTable(); } } }; const EXTRA: MenusDefaults = { copy: { content: useLanguage('copyTable'), icon: copyIcon, handler() { this.copyTable(); } } }; if (menus?.length) { return Object.values(menus).reduce((config: MenusDefaults, menu: string | CustomMenu) => { const ALL_MENUS = Object.assign({}, DEFAULT, EXTRA); if (typeof menu === 'string') { config[menu] = ALL_MENUS[menu]; } if (menu != null && typeof menu === 'object' && menu.name) { config[menu.name] = merge(ALL_MENUS[menu.name], menu); } return config; }, {}); } return DEFAULT; } class TableMenus { quill: Quill; table: HTMLElement | null; root: HTMLElement; prevList: HTMLUListElement | null; prevTooltip: HTMLDivElement | null; scroll: boolean; tableBetter: QuillTableBetter; tablePropertiesForm: TablePropertiesForm; tableHeaderRow: HTMLElement | null; constructor(quill: Quill, tableBetter?: QuillTableBetter) { this.quill = quill; this.table = null; this.prevList = null; this.prevTooltip = null; this.scroll = false; this.tableBetter = tableBetter; this.tablePropertiesForm = null; this.tableHeaderRow = null; this.quill.root.addEventListener('click', this.handleClick.bind(this)); this.root = this.createMenus(); } convertToRow() { const tableBlot = Quill.find(this.table) as TableContainer; const tbody = tableBlot.tbody(); const correctTbody = tbody || this.quill.scroll.create(TableBody.blotName) as TableBody; const ref = correctTbody?.children?.head; const rows = this.getCorrectRows(); const convertRows: [TableRow, TableRow | null][] = []; let row = rows[0].next; while (row) { rows.unshift(row); row = row.next; } for (const row of rows) { const tdRow = this.quill.scroll.create(TableRow.blotName) as TableRow; row.children.forEach(th => { const tdFormats = th.formats()[th.statics.blotName]; const domNode = th.domNode.cloneNode(true); const td = this.quill.scroll.create(domNode).replaceWith(TableCell.blotName, tdFormats); tdRow.insertBefore(td, null); }); convertRows.unshift([tdRow, ref]); row.remove(); } for (const [row, ref] of convertRows) { correctTbody.insertBefore(row, ref); } if (!tbody) tableBlot.insertBefore(correctTbody, null); // @ts-expect-error const [td] = correctTbody.descendant(TableCell); this.tableBetter.cellSelection.setSelected(td.domNode); } convertToHeaderRow() { const tableBlot = Quill.find(this.table) as TableContainer; let thead = tableBlot.thead(); if (!thead) { const tbody = tableBlot.tbody(); thead = this.quill.scroll.create(TableThead.blotName) as TableThead; tableBlot.insertBefore(thead, tbody); } const rows = this.getCorrectRows(); let row = rows[0].prev; while (row) { rows.unshift(row); row = row.prev; } for (const row of rows) { const thRow = this.quill.scroll.create(TableThRow.blotName) as TableThRow; row.children.forEach(td => { const tdFormats = td.formats()[td.statics.blotName]; const domNode = td.domNode.cloneNode(true); const th = this.quill.scroll.create(domNode).replaceWith(TableTh.blotName, tdFormats); thRow.insertBefore(th, null); }); thead.insertBefore(thRow, null); row.remove(); } // @ts-expect-error const [th] = thead.descendant(TableTh); this.tableBetter.cellSelection.setSelected(th.domNode); } async copyTable() { if (!this.table) return; const tableBlot = Quill.find(this.table) as TableContainer; if (!tableBlot) return; const html = '


' + tableBlot.getCopyTable(); const text = this.tableBetter.cellSelection.getText(html); const clipboardItem = new ClipboardItem({ 'text/html': new Blob([html], { type: 'text/html' }), 'text/plain': new Blob([text], { type: 'text/plain' }) }); try { await navigator.clipboard.write([clipboardItem]); const index = this.quill.getIndex(tableBlot); const length = tableBlot.length(); this.quill.setSelection(index + length, Quill.sources.SILENT); this.tableBetter.hideTools(); this.quill.scrollSelectionIntoView(); } catch (error) { console.error('Failed to copy table:', error); } } createList(children: Children) { if (!children) return null; const container = document.createElement('ul'); for (const [, child] of Object.entries(children)) { const { content, divider, createSwitch, handler } = child; const list = document.createElement('li'); if (createSwitch) { list.classList.add('ql-table-header-row'); list.appendChild(this.createSwitch(content)); this.tableHeaderRow = list; } else { list.innerText = content; } list.addEventListener('click', handler.bind(this)); container.appendChild(list); if (divider) { const dividerLine = document.createElement('li'); dividerLine.classList.add('ql-table-divider'); container.appendChild(dividerLine); } } container.classList.add('ql-table-dropdown-list', 'ql-hidden'); return container; } createMenu(left: string, right: string, isDropDown: boolean, category: string) { const container = document.createElement('div'); const dropDown = document.createElement('span'); if (isDropDown) { dropDown.innerHTML = left + right; } else { dropDown.innerHTML = left; } container.classList.add('ql-table-dropdown'); dropDown.classList.add('ql-table-tooltip-hover'); container.setAttribute('data-category', category); container.appendChild(dropDown); return container; } createMenus() { const { language, options = {} } = this.tableBetter; const { menus } = options; const useLanguage = language.useLanguage.bind(language); const container = document.createElement('div'); container.classList.add('ql-table-menus-container', 'ql-hidden'); for (const [category, val] of Object.entries(getMenusConfig(useLanguage, menus))) { const { content, icon, children, handler } = val; const list = this.createList(children); const tooltip = createTooltip(content); const menu = this.createMenu(icon, downIcon, !!children, category); menu.appendChild(tooltip); list && menu.appendChild(list); container.appendChild(menu); menu.addEventListener('click', handler.bind(this, list, tooltip)); } this.quill.container.appendChild(container); return container; } createSwitch(content: string) { const fragment = document.createDocumentFragment(); const title = document.createElement('span'); const switchContainer = document.createElement('span'); const switchInner = document.createElement('span'); title.innerText = content; switchContainer.classList.add('ql-table-switch'); switchInner.classList.add('ql-table-switch-inner'); switchInner.setAttribute('aria-checked', 'false'); switchContainer.appendChild(switchInner); fragment.append(title, switchContainer); return fragment; } deleteColumn(isKeyboard: boolean = false) { const { computeBounds, leftTd, rightTd } = this.getSelectedTdsInfo(); const bounds = this.table.getBoundingClientRect(); const selectTds = getComputeSelectedTds(computeBounds, this.table, this.quill.container, 'column'); const deleteCols = getComputeSelectedCols(computeBounds, this.table, this.quill.container); const tableBlot = (Quill.find(leftTd) as TableCell).table(); const { changeTds, selTds } = this.getCorrectTds(selectTds, computeBounds, leftTd, rightTd); if (isKeyboard && selTds.length !== this.tableBetter.cellSelection.selectedTds.length) return; this.tableBetter.cellSelection.updateSelected('column'); tableBlot.deleteColumn(changeTds, selTds, this.deleteTable.bind(this), deleteCols); updateTableWidth(this.table, bounds, computeBounds.left - computeBounds.right); this.updateMenus(); } deleteRow(isKeyboard: boolean = false) { const selectedTds = this.tableBetter.cellSelection.selectedTds; const rows = this.getCorrectRows(); if (isKeyboard) { const sum = rows.reduce((sum: number, row: TableRow) => { return sum += row.children.length; }, 0); if (sum !== selectedTds.length) return; } this.tableBetter.cellSelection.updateSelected('row'); const tableBlot = (Quill.find(selectedTds[0]) as TableCell).table(); tableBlot.deleteRow(rows, this.deleteTable.bind(this)); this.updateMenus(); } deleteTable() { const tableBlot = Quill.find(this.table) as TableContainer; if (!tableBlot) return; const offset = tableBlot.offset(this.quill.scroll); tableBlot.remove(); this.tableBetter.hideTools(); this.quill.setSelection(offset - 1, 0, Quill.sources.USER); } destroyTablePropertiesForm() { if (!this.tablePropertiesForm) return; this.tablePropertiesForm.removePropertiesForm(); this.tablePropertiesForm = null; } disableMenu(category: string, disabled?: boolean) { if (!this.root) return; const menu = this.root.querySelector(`[data-category=${category}]`); if (!menu) return; if (disabled) { menu.classList.add('ql-table-disabled'); } else { menu.classList.remove('ql-table-disabled'); } } getCellsOffset( computeBounds: CorrectBound, bounds: CorrectBound, leftColspan: number, rightColspan: number ) { const tableBlot = Quill.find(this.table) as TableContainer; const cells = tableBlot.descendants(TableCell); const _left = Math.max(bounds.left, computeBounds.left); const _right = Math.min(bounds.right, computeBounds.right); const map: TableCellMap = new Map(); const leftMap: TableCellMap = new Map(); const rightMap: TableCellMap = new Map(); for (const cell of cells) { const { left, right } = getCorrectBounds(cell.domNode, this.quill.container); if (left + DEVIATION >= _left && right <= _right + DEVIATION) { this.setCellsMap(cell, map); } else if ( left + DEVIATION >= computeBounds.left && right <= bounds.left + DEVIATION ) { this.setCellsMap(cell, leftMap); } else if ( left + DEVIATION >= bounds.right && right <= computeBounds.right + DEVIATION ) { this.setCellsMap(cell, rightMap); } } return this.getDiffOffset(map) || this.getDiffOffset(leftMap, leftColspan) + this.getDiffOffset(rightMap, rightColspan); } getColsOffset( colgroup: TableColgroup, computeBounds: CorrectBound, bounds: CorrectBound ) { let col = colgroup.children.head; const _left = Math.max(bounds.left, computeBounds.left); const _right = Math.min(bounds.right, computeBounds.right); let colLeft = null; let colRight = null; let offset = 0; while (col) { const { width } = col.domNode.getBoundingClientRect(); if (!colLeft && !colRight) { const colBounds = getCorrectBounds(col.domNode, this.quill.container); colLeft = colBounds.left; colRight = colLeft + width; } else { colLeft = colRight; colRight += width; } if (colLeft > _right) break; if (colLeft >= _left && colRight <= _right) { offset--; } col = col.next; } return offset; } getCorrectBounds(table: HTMLElement): CorrectBound[] { const bounds = this.quill.container.getBoundingClientRect(); const tableBounds = getCorrectBounds(table, this.quill.container); return ( tableBounds.width >= bounds.width ? [{ ...tableBounds, left: 0, right: bounds.width }, bounds] : [tableBounds, bounds] ); } getCorrectTds( selectTds: Element[], computeBounds: CorrectBound, leftTd: Element, rightTd: Element ) { const changeTds: [Element, number][] = []; const selTds = []; const colgroup = (Quill.find(leftTd) as TableCell).table().colgroup() as TableColgroup; const leftColspan = (~~leftTd.getAttribute('colspan') || 1); const rightColspan = (~~rightTd.getAttribute('colspan') || 1); if (colgroup) { for (const td of selectTds) { const bounds = getCorrectBounds(td, this.quill.container); if ( bounds.left + DEVIATION >= computeBounds.left && bounds.right <= computeBounds.right + DEVIATION ) { selTds.push(td); } else { const offset = this.getColsOffset(colgroup, computeBounds, bounds); changeTds.push([td, offset]); } } } else { for (const td of selectTds) { const bounds = getCorrectBounds(td, this.quill.container); if ( bounds.left + DEVIATION >= computeBounds.left && bounds.right <= computeBounds.right + DEVIATION ) { selTds.push(td); } else { const offset = this.getCellsOffset( computeBounds, bounds, leftColspan, rightColspan ); changeTds.push([td, offset]); } } } return { changeTds, selTds }; } getCorrectRows() { const selectedTds = this.tableBetter.cellSelection.selectedTds; const map: { [propName: string]: TableRow } = {}; for (const td of selectedTds) { let rowspan = ~~td.getAttribute('rowspan') || 1; let row = Quill.find(td.parentElement) as TableRow; if (rowspan > 1) { while (row && rowspan) { const id = row.children.head.domNode.getAttribute('data-row'); if (!map[id]) map[id] = row; row = row.next; rowspan--; } } else { const id = td.getAttribute('data-row'); if (!map[id]) map[id] = row; } } return Object.values(map); } getDiffOffset(map: TableCellMap, colspan?: number) { let offset = 0; const tds = this.getTdsFromMap(map); if (tds.length) { if (colspan) { for (const td of tds) { offset += (~~td.getAttribute('colspan') || 1); } offset -= colspan; } else { for (const td of tds) { offset -= (~~td.getAttribute('colspan') || 1); } } } return offset; } getRefInfo(row: TableRow, right: number) { let ref = null; if (!row) return { id: tableId(), ref }; let td = row.children.head; const id = td.domNode.getAttribute('data-row'); while (td) { const { left } = td.domNode.getBoundingClientRect(); if (Math.abs(left - right) <= DEVIATION) { return { id, ref: td }; // The nearest cell of a multi-row cell } else if (Math.abs(left - right) >= DEVIATION && !ref) { ref = td; } td = td.next; } return { id, ref }; } getSelectedTdAttrs(td: HTMLElement) { const cellBlot = Quill.find(td) as TableCell; const align = getAlign(cellBlot); const attr: Props = align ? { ...getElementStyle(td, CELL_PROPERTIES), 'text-align': align } : getElementStyle(td, CELL_PROPERTIES); return attr; } getSelectedTdsAttrs(selectedTds: HTMLElement[]) { const map = new Map(); let attribute = null; for (const td of selectedTds) { const attr = this.getSelectedTdAttrs(td); if (!attribute) { attribute = attr; continue; } for (const key of Object.keys(attribute)) { if (map.has(key)) continue; if (attr[key] !== attribute[key]) { map.set(key, false); } } } for (const key of Object.keys(attribute)) { if (map.has(key)) { attribute[key] = CELL_DEFAULT_VALUES[key]; } } return attribute; } getSelectedTdsInfo() { const { startTd, endTd } = this.tableBetter.cellSelection; const startCorrectBounds = getCorrectBounds(startTd, this.quill.container); const endCorrectBounds = getCorrectBounds(endTd, this.quill.container); const computeBounds = getComputeBounds(startCorrectBounds, endCorrectBounds); if ( startCorrectBounds.left <= endCorrectBounds.left && startCorrectBounds.top <= endCorrectBounds.top ) { return { computeBounds, leftTd: startTd, rightTd: endTd }; } return { computeBounds, leftTd: endTd, rightTd: startTd }; } getTableAlignment(table: HTMLTableElement) { const align = table.getAttribute('align'); if (!align) { const { [Alignment.left]: left, [Alignment.right]: right } = getElementStyle(table, [Alignment.left, Alignment.right]); if (left === 'auto') { if (right === 'auto') return 'center'; return 'right'; } return 'left'; } return align || 'center'; } getTdsFromMap(map: TableCellMap) { return Object.values(Object.fromEntries(map)) .reduce((tds: HTMLTableCellElement[], item: HTMLTableCellElement[]) => { return tds.length > item.length ? tds : item; }, []); } handleClick(e: MouseEvent) { if (!this.quill.isEnabled()) return; const table = (e.target as Element).closest('table'); if (table && !this.quill.root.contains(table)) return; this.prevList && this.prevList.classList.add('ql-hidden'); this.prevTooltip && this.prevTooltip.classList.remove('ql-table-tooltip-hidden'); this.prevList = null; this.prevTooltip = null; if (!table && !this.tableBetter.cellSelection.selectedTds.length) { this.hideMenus(); this.destroyTablePropertiesForm(); return; } else { if (this.tablePropertiesForm) return; this.showMenus(); this.updateMenus(table); if ( (table && !table.isEqualNode(this.table)) || this.scroll ) { this.updateScroll(false); } this.table = table; } } hideMenus() { this.root.classList.add('ql-hidden'); } insertColumn(td: HTMLTableColElement, offset: number) { const { left, right, width } = td.getBoundingClientRect(); const tdBlot = Quill.find(td) as TableCell; const tableBlot = tdBlot.table(); const isLast = td.parentElement.lastChild.isEqualNode(td); const position = offset > 0 ? right : left; tableBlot.insertColumn(position, isLast, width, offset); this.quill.scrollSelectionIntoView(); } insertParagraph(offset: number) { const blot = Quill.find(this.table) as TableContainer; const index = this.quill.getIndex(blot); const length = offset > 0 ? blot.length() : 0; const delta = new Delta() .retain(index + length) .insert('\n'); this.quill.updateContents(delta, Quill.sources.USER); this.quill.setSelection(index + length, Quill.sources.SILENT); this.tableBetter.hideTools(); this.quill.scrollSelectionIntoView(); } insertRow(td: HTMLTableColElement, offset: number) { const tdBlot = Quill.find(td) as TableCell; const index = tdBlot.rowOffset(); const tableBlot = tdBlot.table(); const isTh = tdBlot.statics.blotName === TableTh.blotName; if (offset > 0) { const rowspan = ~~td.getAttribute('rowspan') || 1; tableBlot.insertRow(index + offset + rowspan - 1, offset, isTh); } else { tableBlot.insertRow(index + offset, offset, isTh); } this.quill.scrollSelectionIntoView(); } mergeCells() { const { selectedTds } = this.tableBetter.cellSelection; const { computeBounds, leftTd } = this.getSelectedTdsInfo(); const leftTdBlot = Quill.find(leftTd) as TableCell; const [formats, cellId] = getCellFormats(leftTdBlot); const head = leftTdBlot.children.head; const tableBlot = leftTdBlot.table(); const rows = tableBlot.tbody().children as LinkedList; const row = leftTdBlot.row(); const colspan = row.children.reduce((colspan: number, td: TableCell) => { const tdCorrectBounds = getCorrectBounds(td.domNode, this.quill.container); if ( tdCorrectBounds.left >= computeBounds.left && tdCorrectBounds.right <= computeBounds.right ) { colspan += ~~td.domNode.getAttribute('colspan') || 1; } return colspan; }, 0); const rowspan = rows.reduce((rowspan: number, row: TableRow) => { const rowCorrectBounds = getCorrectBounds(row.domNode, this.quill.container); if ( rowCorrectBounds.top >= computeBounds.top && rowCorrectBounds.bottom <= computeBounds.bottom ) { let minRowspan = Number.MAX_VALUE; row.children.forEach((td: TableCell) => { const rowspan = ~~td.domNode.getAttribute('rowspan') || 1; minRowspan = Math.min(minRowspan, rowspan); }); rowspan += minRowspan; } return rowspan; }, 0); let offset = 0; for (const td of selectedTds) { if (leftTd.isEqualNode(td)) continue; const blot = Quill.find(td) as TableCell; blot.moveChildren(leftTdBlot); blot.remove(); if (!blot.parent?.children?.length) offset++; } if (offset) { // Subtract the number of rows deleted by the merge row.children.forEach((child: TableCell) => { if (child.domNode.isEqualNode(leftTd)) return; const rowspan = child.domNode.getAttribute('rowspan'); const [formats] = getCellFormats(child); // @ts-expect-error child.replaceWith(child.statics.blotName, { ...formats, rowspan: rowspan - offset }); }); } leftTdBlot.setChildrenId(cellId); // @ts-expect-error head.format(leftTdBlot.statics.blotName, { ...formats, colspan, rowspan: rowspan - offset }); this.tableBetter.cellSelection.setSelected(head.parent.domNode); this.quill.scrollSelectionIntoView(); } selectColumn() { const { computeBounds, leftTd, rightTd } = this.getSelectedTdsInfo(); const selectTds = getComputeSelectedTds(computeBounds, this.table, this.quill.container, 'column'); const { selTds } = this.getCorrectTds(selectTds, computeBounds, leftTd, rightTd); this.tableBetter.cellSelection.setSelectedTds(selTds); this.updateMenus(); } selectRow() { const rows = this.getCorrectRows(); const selectTds = rows.reduce((selTds: Element[], row: TableRow) => { selTds.push(...Array.from(row.domNode.children)); return selTds; }, []); this.tableBetter.cellSelection.setSelectedTds(selectTds); this.updateMenus(); } setCellsMap(cell: TableCell, map: TableCellMap) { const key: string = cell.domNode.getAttribute('data-row'); if (map.has(key)) { map.set(key, [...map.get(key), cell.domNode]); } else { map.set(key, [cell.domNode]); } } showMenus() { this.root.classList.remove('ql-hidden'); } splitCell() { const { selectedTds } = this.tableBetter.cellSelection; const { leftTd } = this.getSelectedTdsInfo(); const leftTdBlot = Quill.find(leftTd) as TableCell; const head = leftTdBlot.children.head; for (const td of selectedTds) { const colspan = ~~td.getAttribute('colspan') || 1; const rowspan = ~~td.getAttribute('rowspan') || 1; if (colspan === 1 && rowspan === 1) continue; const columnCells: [TableRow, string, TableCell | null][] = []; const { width, right } = td.getBoundingClientRect(); const blot = Quill.find(td) as TableCell; const tableBlot = blot.table(); const nextBlot = blot.next; const rowBlot = blot.row(); if (rowspan > 1) { if (colspan > 1) { let nextRowBlot = rowBlot.next; for (let i = 1; i < rowspan; i++) { const { ref, id } = this.getRefInfo(nextRowBlot, right); for (let j = 0; j < colspan; j++) { columnCells.push([nextRowBlot, id, ref]); } nextRowBlot && (nextRowBlot = nextRowBlot.next); } } else { let nextRowBlot = rowBlot.next; for (let i = 1; i < rowspan; i++) { const { ref, id } = this.getRefInfo(nextRowBlot, right); columnCells.push([nextRowBlot, id, ref]); nextRowBlot && (nextRowBlot = nextRowBlot.next); } } } if (colspan > 1) { const id = td.getAttribute('data-row'); for (let i = 1; i < colspan; i++) { columnCells.push([rowBlot, id, nextBlot]); } } for (const [row, id, ref] of columnCells) { tableBlot.insertColumnCell(row, id, ref); } const [formats] = getCellFormats(blot); blot.replaceWith(blot.statics.blotName, { ...formats, width: ~~(width / colspan), colspan: null, rowspan: null }); } this.tableBetter.cellSelection.setSelected(head.parent.domNode); this.quill.scrollSelectionIntoView(); } toggleAttribute(list: HTMLUListElement, tooltip: HTMLDivElement, e?: PointerEvent) { // @ts-expect-error if (e && e.target.closest('li.ql-table-header-row')) return; if (this.prevList && !this.prevList.isEqualNode(list)) { this.prevList.classList.add('ql-hidden'); this.prevTooltip.classList.remove('ql-table-tooltip-hidden'); } if (!list) return; list.classList.toggle('ql-hidden'); tooltip.classList.toggle('ql-table-tooltip-hidden'); this.prevList = list; this.prevTooltip = tooltip; } toggleHeaderRow() { const { selectedTds, hasTdTh } = this.tableBetter.cellSelection; const { hasTd, hasTh } = hasTdTh(selectedTds); if (!hasTd && hasTh) { this.convertToRow(); } else { this.convertToHeaderRow(); } } toggleHeaderRowSwitch(value?: string) { if (!this.tableHeaderRow) return; const switchInner = this.tableHeaderRow.querySelector('.ql-table-switch-inner'); if (!value) { const ariaChecked = switchInner.getAttribute('aria-checked'); value = ariaChecked === 'false' ? 'true' : 'false'; } switchInner.setAttribute('aria-checked', value); } updateMenus(table: HTMLElement = this.table) { if (!table) return; requestAnimationFrame(() => { this.root.classList.remove('ql-table-triangle-none'); const [tableBounds, containerBounds] = this.getCorrectBounds(table); const { left, right, top, bottom } = tableBounds; const { height, width } = this.root.getBoundingClientRect(); const toolbar = this.quill.getModule('toolbar'); // @ts-expect-error const computedStyle = getComputedStyle(toolbar.container); let correctTop = top - height - 10; let correctLeft = (left + right - width) >> 1; if (correctTop > -parseInt(computedStyle.paddingBottom)) { this.root.classList.add('ql-table-triangle-up'); this.root.classList.remove('ql-table-triangle-down'); } else { if (bottom > containerBounds.height) { correctTop = containerBounds.height + 10; } else { correctTop = bottom + 10; } this.root.classList.add('ql-table-triangle-down'); this.root.classList.remove('ql-table-triangle-up'); } if (correctLeft < containerBounds.left) { correctLeft = 0; this.root.classList.add('ql-table-triangle-none'); } else if (correctLeft + width > containerBounds.right) { correctLeft = containerBounds.right - width; this.root.classList.add('ql-table-triangle-none'); } setElementProperty(this.root, { left: `${correctLeft}px`, top: `${correctTop}px` }); }); } updateScroll(scroll: boolean) { this.scroll = scroll; } updateTable(table: HTMLElement) { this.table = table; } } export default TableMenus;