UNPKG

@atlaskit/editor-plugin-table

Version:

Table plugin for the @atlaskit/editor

472 lines (465 loc) 17.8 kB
import { createElement } from 'react'; import { RawIntlProvider } from 'react-intl'; // eslint-disable-next-line @atlaskit/platform/prefer-crypto-random-uuid -- Use crypto.randomUUID instead import uuid from 'uuid/v4'; import { nonNullable } from '@atlaskit/editor-common/utils'; // @ts-ignore -- ReadonlyTransaction is a local declaration and will cause a TS2305 error in CCFE typecheck import { Decoration } from '@atlaskit/editor-prosemirror/view'; import { TableMap } from '@atlaskit/editor-tables/table-map'; import { findTable, getCellsInRow, getSelectionRect } from '@atlaskit/editor-tables/utils'; import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals'; import { TableCssClassName as ClassName, TableDecorations } from '../../types'; import { ColumnResizeWidget } from '../../ui/ColumnResizeWidget'; const filterDecorationByKey = (key, decorationSet) => decorationSet.find(undefined, undefined, spec => spec.key.indexOf(key) > -1); export const findColumnControlSelectedDecoration = decorationSet => filterDecorationByKey(TableDecorations.COLUMN_SELECTED, decorationSet); export const findControlsHoverDecoration = decorationSet => filterDecorationByKey(TableDecorations.ALL_CONTROLS_HOVER, decorationSet); export const createCellHoverDecoration = cells => cells.map(cell => Decoration.node(cell.pos, cell.pos + cell.node.nodeSize, { class: ClassName.HOVERED_CELL_WARNING }, { key: TableDecorations.CELL_CONTROLS_HOVER })); export const createControlsHoverDecoration = (cells, type, tr, isDragAndDropEnable, hoveredIndexes, danger, selected) => { const table = findTable(tr.selection); if (!table) { return []; } const map = TableMap.get(table.node); const [min, max] = cells.reduce(([min, max], cell) => { if (min === null || cell.pos < min) { min = cell.pos; } if (max === null || cell.pos > max) { max = cell.pos; } return [min, max]; }, [null, null]); if (min === null || max === null) { return []; } let updatedCells = cells.map(x => x.pos); // ED-15246 fixed trello card table overflow issue // If columns / rows have been merged the hovered selection is different to the actual selection // So If the table cells are in danger we want to create a "rectangle" selection // to match the "clicked" selection if (danger && type !== 'table') { const { selection } = tr; const table = findTable(selection); const rect = getSelectionRect(selection); if (table && rect) { updatedCells = map.cellsInRect(rect).map(x => x + table.start); } } return updatedCells.map(pos => { const cell = tr.doc.nodeAt(pos); const classes = [ClassName.HOVERED_CELL]; if (danger) { classes.push(ClassName.HOVERED_CELL_IN_DANGER); } if (selected) { classes.push(ClassName.SELECTED_CELL); } if (isDragAndDropEnable) { if (type === 'column' || type === 'row') { classes.pop(); classes.push(ClassName.HOVERED_NO_HIGHLIGHT); } } else { classes.push(type === 'column' ? ClassName.HOVERED_COLUMN : type === 'row' ? ClassName.HOVERED_ROW : ClassName.HOVERED_TABLE); } let key; switch (type) { case 'row': key = TableDecorations.ROW_CONTROLS_HOVER; break; case 'column': key = TableDecorations.COLUMN_CONTROLS_HOVER; break; default: key = TableDecorations.TABLE_CONTROLS_HOVER; break; } return Decoration.node(pos, // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion pos + cell.nodeSize, { class: classes.join(' ') }, { key }); }); }; export const createColumnSelectedDecoration = tr => { const { selection, doc } = tr; const table = findTable(selection); const rect = getSelectionRect(selection); if (!table || !rect) { return []; } const map = TableMap.get(table.node); const cellPositions = map.cellsInRect(rect); return cellPositions.map((pos, index) => { const cell = doc.nodeAt(pos + table.start); return Decoration.node(pos + table.start, // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion pos + table.start + cell.nodeSize, { class: ClassName.COLUMN_SELECTED }, { key: `${TableDecorations.COLUMN_SELECTED}_${index}` }); }); }; export const createColumnControlsDecoration = selection => { const cells = getCellsInRow(0)(selection) || []; let index = 0; return cells.map(cell => { const colspan = cell.node.attrs.colspan || 1; // It's important these values are scoped locally as the widget callback could be executed anytime in the future // and we want to avoid value leak const startIndex = index; const endIndex = startIndex + colspan; // The next cell start index will commence from the current cell end index. index = endIndex; return Decoration.widget(cell.pos + 1, () => { const element = document.createElement('div'); element.classList.add(ClassName.COLUMN_CONTROLS_DECORATIONS); element.dataset.startIndex = `${startIndex}`; element.dataset.endIndex = `${endIndex}`; return element; }, { key: `${TableDecorations.COLUMN_CONTROLS_DECORATIONS}_${endIndex}`, // this decoration should be the first one, even before gap cursor. side: -100 }); }); }; export const updateDecorations = (node, decorationSet, decorations, key) => { const filteredDecorations = filterDecorationByKey(key, decorationSet); const decorationSetFiltered = decorationSet.remove(filteredDecorations); return decorationSetFiltered.add(node, decorations); }; const makeArray = n => Array.from(Array(n).keys()); /* * This function will create two specific decorations for each cell in a column index target, * for example given that table: * * ``` * 0 1 2 3 * _____________________ _______ * | | | | * | B1 | C1 | A1 | * |______|______ ______|______| * | | | | * | B2 | | A2 | * |______ ______| |______| * | | | D1 | | * | B3 | C2 | | A3 | * |______|______|______|______| * ^ ^ ^ ^ * | | | | * | | | | * | | | | * 0 1 3 4 * \ | | / * \ | | / * \ | | / * \ | | / * \ | | / * columnEndIndexTarget === CellColumnPositioning.right * ``` * * When a user wants to resize a cell, * they need to grab and hold the end of that column, * and this will be the `columnEndIndexTarget` using * the CellColumnPositioning interface. * * Let's say the `columnEndIndexTarget.right` is 3, * so this function will return two types of decorations for each cell on that column, * that means 2 `resizerHandle` and 2 `lastCellElement`, * here is the explanation for each one of them : * * - resizerHandle: * * Given the cell C1, this decoration will add a div to create this area * ``` * ▁▁▁▁▁▁▁▁▁▁▁▁▁ * | ▒▒| * | C1 ▒▒| * | ▒▒| * ▔▔▔▔▔▔▔▔▔▔▔▔▔ * ``` * This ▒ represents the area where table resizing will start, * and you can follow that using checking the class name `ClassName.RESIZE_HANDLE_DECORATION` on the code * * - lastCellElementDecoration * * Given the content of the cell C1 * ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ * | | * | _____________ | * | | | | * | | <p> | | * | |_____________| | * | | * | _____________ | * | | | | * | | <media> | | * | |_____________| | * | | * | _____________ | * | | | | * | | <media> | | * | |_____________| | * | | * ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ * Currently, we are removing the margin-bottom from the last media using this kind of CSS rule: * `div:last-of-type`; This is quite unstable, and after we create the `resizerHandle` div, * that logic will apply the margin in the wrong element, to avoid that, * we will add a new class on the last item for each cell, * hence the second media will receive this class `ClassName.LAST_ITEM_IN_CELL` */ export const createResizeHandleDecoration = (tr, rowIndexTarget, columnEndIndexTarget, includeTooltip = false, getIntl, nodeViewPortalProviderAPI) => { const emptyResult = [[], []]; const table = findTable(tr.selection); if (!table || !table.node) { return emptyResult; } const map = TableMap.get(table.node); if (!map.width) { return emptyResult; } const createResizerHandleDecoration = (cellColumnPositioning, columnIndex, rowIndex, cellPos, cellNode) => { // eslint-disable-next-line @atlaskit/platform/prefer-crypto-random-uuid -- Use crypto.randomUUID instead const decorationRenderKey = uuid(); const position = cellPos + cellNode.nodeSize - 1; return Decoration.widget(position, () => { const element = document.createElement('div'); nodeViewPortalProviderAPI.render(() => /*#__PURE__*/createElement(RawIntlProvider, { value: getIntl() }, /*#__PURE__*/createElement(ColumnResizeWidget, { startIndex: cellColumnPositioning.left, endIndex: cellColumnPositioning.right, includeTooltip })), element, decorationRenderKey); return element; }, { key: `${TableDecorations.COLUMN_RESIZING_HANDLE_WIDGET}_${rowIndex}_${columnIndex}_${includeTooltip ? 'with' : 'no'}-tooltip`, destroy: _node => { nodeViewPortalProviderAPI.remove(decorationRenderKey); } }); }; const createLastCellElementDecoration = (cellColumnPositioning, cellPos, cellNode) => { if (expValEquals('platform_editor_table_remove_last_cell_decoration', 'isEnabled', true)) { // no longer need to add the last cell decoration to override marginBottom as media wrapper doesn't have margin bottom. This will avoid unnecessary decoration computation/mutation and improve performance // consider clean up ClassName.LAST_ITEM_IN_CELL with platform_editor_table_remove_last_cell_decoration experiment return null; } let lastItemPositions; cellNode.forEach((childNode, offset, index) => { if (index === cellNode.childCount - 1) { const from = offset + cellPos + 1; lastItemPositions = { from, to: from + childNode.nodeSize }; } }); if (!lastItemPositions) { return null; } return Decoration.node(lastItemPositions.from, lastItemPositions.to, { class: ClassName.LAST_ITEM_IN_CELL }, { key: `${TableDecorations.LAST_CELL_ELEMENT}_${cellColumnPositioning.left}_${cellColumnPositioning.right}` }); }; const resizeHandleCellDecorations = []; const lastCellElementsDecorations = []; for (let rowIndex = 0; rowIndex < map.height; rowIndex++) { const seen = {}; if (rowIndex !== rowIndexTarget) { continue; } for (let columnIndex = 0; columnIndex < map.width; columnIndex++) { const cellPosition = map.map[map.width * rowIndex + columnIndex]; if (seen[cellPosition]) { continue; } seen[cellPosition] = true; const cellPos = table.start + cellPosition; const cell = tr.doc.nodeAt(cellPos); if (!cell) { continue; } const colspan = cell.attrs.colspan || 1; const startIndex = columnIndex; const endIndex = colspan + startIndex; if (endIndex !== columnEndIndexTarget.right) { continue; } const resizerHandleDec = createResizerHandleDecoration({ left: startIndex, right: endIndex }, columnIndex, rowIndex, cellPos, cell); const lastCellDec = createLastCellElementDecoration({ left: startIndex, right: endIndex }, cellPos, cell); resizeHandleCellDecorations.push(resizerHandleDec); lastCellElementsDecorations.push(lastCellDec); } } return [resizeHandleCellDecorations, lastCellElementsDecorations.filter(nonNullable)]; }; /* * This function will create a decoration for each cell using the right position on the CellColumnPositioning * for example given that table: * * ``` * 0 1 2 3 <--- column indexes * _____________________ _______ * | | | | * | B1 | C1 | A1 | * |______|______ ______|______| * | | | | * | B2 | D1 | A2 | * |______ ______|______|______| * | | | | * | B3 | C2 | D2 | * |______|______|_____________| * ``` * * and given the left and right represents the C1 cell: * * ``` * left right * 1 3 * | | * | | * | | * _______∨_____________∨_______ * | | | | * | B1 | C1 | A1 | * |______|______ ______|______| * | | | | * | B2 | D1 | A2 | * |______ ______|______|______| * | | | | * | B3 | C2 | D2 | * |______|______|_____________| * ``` * * Taking that table, and the right as parameters, * this function will return two decorations applying a new class `ClassName.WITH_RESIZE_LINE` * only on the cells: `C1` and `D1`. */ export const createColumnLineResize = (selection, cellColumnPositioning, isDragAndDropEnabled) => { const table = findTable(selection); if (!table || cellColumnPositioning.right === null) { return []; } let columnIndex = cellColumnPositioning.right; const map = TableMap.get(table.node); const isLastColumn = columnIndex === map.width; if (isLastColumn) { columnIndex -= 1; } const decorationClassName = isDragAndDropEnabled ? isLastColumn ? ClassName.WITH_DRAG_RESIZE_LINE_LAST_COLUMN : ClassName.WITH_DRAG_RESIZE_LINE : isLastColumn ? ClassName.WITH_RESIZE_LINE_LAST_COLUMN : ClassName.WITH_RESIZE_LINE; const cellPositions = makeArray(map.height).map(rowIndex => map.map[map.width * rowIndex + columnIndex]).filter((cellPosition, rowIndex) => { if (isLastColumn) { return true; // If is the last column no filter applied } const nextPosition = map.map[map.width * rowIndex + columnIndex - 1]; return cellPosition !== nextPosition; // Removed it if next position is merged }); const cells = cellPositions.map(pos => ({ pos: pos + table.start, node: table.node.nodeAt(pos) })); return cells.map((cell, index) => { if (!cell || !cell.node) { return; } return Decoration.node(cell.pos, cell.pos + cell.node.nodeSize, { class: decorationClassName }, { key: `${TableDecorations.COLUMN_RESIZING_HANDLE_LINE}_${cellColumnPositioning.right}_${index}` }); }).filter(nonNullable); }; export const createColumnInsertLine = (columnIndex, selection, hasMergedCells) => { const table = findTable(selection); if (!table) { return []; } const map = TableMap.get(table.node); const isFirstColumn = columnIndex === 0; const isLastColumn = columnIndex === map.width; if (isLastColumn) { columnIndex -= 1; } let decorationClassName; if (hasMergedCells) { decorationClassName = isFirstColumn ? ClassName.WITH_FIRST_COLUMN_INSERT_LINE_INACTIVE : isLastColumn ? ClassName.WITH_LAST_COLUMN_INSERT_LINE_INACTIVE : ClassName.WITH_COLUMN_INSERT_LINE_INACTIVE; } else { decorationClassName = isFirstColumn ? ClassName.WITH_FIRST_COLUMN_INSERT_LINE : isLastColumn ? ClassName.WITH_LAST_COLUMN_INSERT_LINE : ClassName.WITH_COLUMN_INSERT_LINE; } const cellPositions = makeArray(map.height).map(rowIndex => map.map[map.width * rowIndex + columnIndex]).filter((cellPosition, rowIndex) => { if (isLastColumn) { return true; // If is the last column no filter applied } const nextPosition = map.map[map.width * rowIndex + columnIndex - 1]; return cellPosition !== nextPosition; // Removed it if next position is merged }); const cells = cellPositions.map(pos => ({ pos: pos + table.start, node: table.node.nodeAt(pos) })); return cells.map((cell, index) => { if (!cell || !cell.node) { return; } return Decoration.node(cell.pos, cell.pos + cell.node.nodeSize, { class: decorationClassName }, { key: `${TableDecorations.COLUMN_INSERT_LINE}_${index}` }); }).filter(nonNullable); }; export const createRowInsertLine = (rowIndex, selection, hasMergedCells) => { const table = findTable(selection); if (!table) { return []; } const map = TableMap.get(table.node); const isLastRow = rowIndex === map.height; if (isLastRow) { rowIndex -= 1; } const cells = getCellsInRow(rowIndex)(selection); if (!cells) { return []; } let decorationClassName; if (hasMergedCells) { decorationClassName = isLastRow ? ClassName.WITH_LAST_ROW_INSERT_LINE_INACTIVE : ClassName.WITH_ROW_INSERT_LINE_INACTIVE; } else { decorationClassName = isLastRow ? ClassName.WITH_LAST_ROW_INSERT_LINE : ClassName.WITH_ROW_INSERT_LINE; } return cells.map((cell, index) => { if (!cell || !cell.node) { return; } return Decoration.node(cell.pos, cell.pos + cell.node.nodeSize, { class: decorationClassName }, { key: `${TableDecorations.ROW_INSERT_LINE}_${index}` }); }).filter(nonNullable); };