@atlaskit/editor-plugin-table
Version:
Table plugin for the @atlaskit/editor
637 lines (626 loc) • 23.3 kB
JavaScript
import isEqual from 'lodash/isEqual';
import { getTableContainerWidth } from '@atlaskit/editor-common/node-width';
import { closestElement, isParagraph, isTextSelection, mapSlice } from '@atlaskit/editor-common/utils';
import { TextSelection } from '@atlaskit/editor-prosemirror/state';
import { CellSelection } from '@atlaskit/editor-tables/cell-selection';
import { TableMap } from '@atlaskit/editor-tables/table-map';
import { findCellClosestToPos, findTable, getCellsInColumn, getCellsInRow, getSelectionRect, isSelectionType, isTableSelected, removeTable, selectColumns as selectColumnsTransform, selectColumn as selectColumnTransform, selectionCell, selectRows as selectRowsTransform, selectRow as selectRowTransform, setCellAttrs } from '@atlaskit/editor-tables/utils';
import { TableCssClassName as ClassName, TableDecorations } from '../../types';
import { getDecorations } from '../decorations/plugin';
import { buildColumnResizingDecorations, clearColumnResizingDecorations } from '../decorations/utils/column-resizing';
import { createCommand, getPluginState } from '../plugin-factory';
import { fixAutoSizedTable } from '../transforms/fix-tables';
import { createColumnControlsDecoration, createColumnSelectedDecoration } from '../utils/decoration';
import { checkIfHeaderColumnEnabled, checkIfHeaderRowEnabled, checkIfNumberColumnEnabled, isIsolating } from '../utils/nodes';
import { updatePluginStateDecorations } from '../utils/update-plugin-state-decorations';
const DARK_MODE_CELL_COLOR = '#1f1f21';
const DARK_MODE_HEADER_COLOR = '#303134';
export const setEditorFocus = editorHasFocus => createCommand({
type: 'SET_EDITOR_FOCUS',
data: {
editorHasFocus
}
});
export const setTableRef = ref => createCommand(state => {
const tableRef = ref;
const foundTable = findTable(state.selection);
const tableNode = ref && foundTable ? foundTable.node : undefined;
const tablePos = ref && foundTable ? foundTable.pos : undefined;
const tableWrapperTarget = closestElement(tableRef, `.${ClassName.TABLE_NODE_WRAPPER}`) || undefined;
const {
isDragAndDropEnabled
} = getPluginState(state);
return {
type: 'SET_TABLE_REF',
data: {
tableRef,
tableNode,
tablePos,
tableWrapperTarget,
isNumberColumnEnabled: checkIfNumberColumnEnabled(state.selection),
isHeaderRowEnabled: checkIfHeaderRowEnabled(state.selection),
isHeaderColumnEnabled: checkIfHeaderColumnEnabled(state.selection),
// decoration set is drawn by the decoration plugin, skip this for DnD as all controls are floating
decorationSet: !isDragAndDropEnabled ? updatePluginStateDecorations(state, createColumnControlsDecoration(state.selection), TableDecorations.COLUMN_CONTROLS_DECORATIONS) : undefined,
resizeHandleRowIndex: undefined,
resizeHandleColumnIndex: undefined
}
};
}, tr => tr.setMeta('addToHistory', false));
export const setCellAttr = (name, value) => (state, dispatch) => {
const {
tr,
selection
} = state;
if (selection instanceof CellSelection) {
let updated = false;
selection.forEachCell((cell, pos) => {
if (cell.attrs[name] !== value) {
tr.setNodeMarkup(pos, cell.type, {
...cell.attrs,
[name]: value
});
updated = true;
}
});
if (updated) {
if (dispatch) {
dispatch(tr);
}
return true;
}
} else {
const cell = selectionCell(state.selection);
if (cell) {
if (dispatch) {
var _cell$nodeAfter, _cell$nodeAfter2;
dispatch(tr.setNodeMarkup(cell.pos, (_cell$nodeAfter = cell.nodeAfter) === null || _cell$nodeAfter === void 0 ? void 0 : _cell$nodeAfter.type, {
...((_cell$nodeAfter2 = cell.nodeAfter) === null || _cell$nodeAfter2 === void 0 ? void 0 : _cell$nodeAfter2.attrs),
[name]: value
}));
}
return true;
}
}
return false;
};
export const triggerUnlessTableHeader = command => (state, dispatch, view) => {
const {
selection,
schema: {
nodes: {
tableHeader
}
}
} = state;
if (selection instanceof TextSelection) {
const cell = findCellClosestToPos(selection.$from);
if (cell && cell.node.type !== tableHeader) {
return command(state, dispatch, view);
}
}
if (selection instanceof CellSelection) {
const rect = getSelectionRect(selection);
if (!checkIfHeaderRowEnabled(selection) || rect && rect.top > 0) {
return command(state, dispatch, view);
}
}
return false;
};
export const transformSliceRemoveCellBackgroundColor = (slice, schema) => {
const {
tableCell,
tableHeader
} = schema.nodes;
return mapSlice(slice, maybeCell => {
if (maybeCell.type === tableCell || maybeCell.type === tableHeader) {
const cellAttrs = {
...maybeCell.attrs
};
cellAttrs.background = undefined;
return maybeCell.type.createChecked(cellAttrs, maybeCell.content, maybeCell.marks);
}
return maybeCell;
});
};
export const transformSliceToFixDarkModeDefaultBackgroundColor = (slice, schema) => {
// the background attr in adf should always store the light mode value of the background color
// and tables which have been created without a background color set will have background as undefined
// in the undefined case, when pasting from renderer, we get a background color which is the dark mode color
// we need to convert it back to undefined, otherwise it will be interpreted as a light mode value and be inverted
const {
tableCell,
tableHeader
} = schema.nodes;
return mapSlice(slice, maybeCell => {
if (maybeCell.type === tableCell || maybeCell.type === tableHeader) {
const cellAttrs = {
...maybeCell.attrs
};
if (maybeCell.type === tableCell && cellAttrs.background === DARK_MODE_CELL_COLOR || maybeCell.type === tableHeader && cellAttrs.background === DARK_MODE_HEADER_COLOR) {
cellAttrs.background = undefined;
}
return maybeCell.type.createChecked(cellAttrs, maybeCell.content, maybeCell.marks);
}
return maybeCell;
});
};
export const transformSliceToAddTableHeaders = (slice, schema) => {
const {
table,
tableHeader,
tableRow
} = schema.nodes;
return mapSlice(slice, maybeTable => {
if (maybeTable.type === table) {
const firstRow = maybeTable.firstChild;
if (firstRow) {
const headerCols = [];
firstRow.forEach(oldCol => {
headerCols.push(tableHeader.createChecked(oldCol.attrs, oldCol.content, oldCol.marks));
});
const headerRow = tableRow.createChecked(firstRow.attrs, headerCols, firstRow.marks);
return maybeTable.copy(maybeTable.content.replaceChild(0, headerRow));
}
}
return maybeTable;
});
};
export const transformSliceToRemoveColumnsWidths = (slice, schema) => {
const {
tableHeader,
tableCell
} = schema.nodes;
return mapSlice(slice, maybeCell => {
if (maybeCell.type === tableCell || maybeCell.type === tableHeader) {
if (!maybeCell.attrs.colwidth) {
return maybeCell;
}
return maybeCell.type.createChecked({
...maybeCell.attrs,
colwidth: undefined
}, maybeCell.content, maybeCell.marks);
}
return maybeCell;
});
};
export const countCellsInSlice = (slice, schema, type) => {
const {
tableHeader,
tableCell
} = schema.nodes;
let count = 0;
if (!type) {
return count;
}
slice.content.descendants(maybeCell => {
if (maybeCell.type === tableCell || maybeCell.type === tableHeader) {
count += type === 'row' ? maybeCell.attrs.colspan : maybeCell.attrs.rowspan;
return false;
}
});
return count;
};
export const getTableSelectionType = selection => {
if (selection instanceof CellSelection) {
return selection.isRowSelection() ? 'row' : selection.isColSelection() ? 'column' : undefined;
}
};
export const getTableElementMoveTypeBySlice = (slice, state) => {
const {
schema: {
nodes: {
tableRow,
table
}
}
} = state;
const currentTable = findTable(state.tr.selection);
// check if copied slice is a table or table row
if (!slice.content.firstChild || slice.content.firstChild.type !== table && slice.content.firstChild.type !== tableRow || !currentTable) {
return undefined;
}
// if the slice only contains one table row, assume it's a row
if (slice.content.childCount === 1 && slice.content.firstChild.type === tableRow) {
return 'row';
}
// `TableMap.get` can throw if the content is invalid - in which case we should just
// return undefined
try {
const map = TableMap.get(currentTable.node);
const slicedMap = TableMap.get(slice.content.firstChild);
return map.width === slicedMap.width ? 'row' : map.height === slicedMap.height ? 'column' : undefined;
} catch {
return undefined;
}
};
export const isInsideFirstCellOfRowOrColumn = (selection, type) => {
const table = findTable(selection);
if (!table || !type) {
return false;
}
// Ignored via go/ees005
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const map = TableMap.get(table.node);
const cell = findCellClosestToPos(selection.$anchor);
if (!cell) {
return false;
}
const pos = cell.pos - table.pos - 1;
// cell positions in table map always start at 1, as they're offsets not positions
const index = map.map.findIndex(value => value === pos);
return type === 'row' ? index % map.width === 0 : index < map.width;
};
export const deleteTable = (state, dispatch) => {
if (dispatch) {
dispatch(removeTable(state.tr));
}
return true;
};
export const deleteTableIfSelected = (state, dispatch) => {
if (isTableSelected(state.selection)) {
return deleteTable(state, dispatch);
}
return false;
};
export const convertFirstRowToHeader = schema => tr => {
// Ignored via go/ees005
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const table = findTable(tr.selection);
const map = TableMap.get(table.node);
for (let i = 0; i < map.width; i++) {
const cell = table.node.child(0).child(i);
tr.setNodeMarkup(table.start + map.map[i], schema.nodes.tableHeader, cell.attrs);
}
return tr;
};
export const moveCursorBackward = (state, dispatch) => {
const {
$cursor
} = state.selection;
// if cursor is in the middle of a text node, do nothing
if (!$cursor || $cursor.parentOffset > 0) {
return false;
}
// find the node before the cursor
let before;
let cut;
if (!isIsolating($cursor.parent)) {
for (let i = $cursor.depth - 1; !before && i >= 0; i--) {
if ($cursor.index(i) > 0) {
cut = $cursor.before(i + 1);
before = $cursor.node(i).child($cursor.index(i) - 1);
}
if (isIsolating($cursor.node(i))) {
break;
}
}
}
// if the node before is not a table node - do nothing
if (!before || before.type !== state.schema.nodes.table) {
return false;
}
/*
ensure we're just at a top level paragraph
otherwise, perform regular backspace behaviour
*/
const grandparent = $cursor.node($cursor.depth - 1);
if ($cursor.parent.type !== state.schema.nodes.paragraph || grandparent && grandparent.type !== state.schema.nodes.doc) {
return false;
}
const {
tr
} = state;
const lastCellPos = (cut || 0) - 4;
// need to move cursor inside the table to be able to calculate table's offset
tr.setSelection(new TextSelection(state.doc.resolve(lastCellPos)));
const {
$from
} = tr.selection;
const start = $from.start(-1);
const pos = start + $from.parent.nodeSize - 1;
// move cursor to the last cell
// it doesn't join node before (last cell) with node after (content after the cursor)
// due to ridiculous amount of PM code that would have been required to overwrite
tr.setSelection(new TextSelection(state.doc.resolve(pos)));
// if we are inside an empty paragraph not at the end of the doc we delete it
const cursorNode = $cursor.node();
const docEnd = state.doc.content.size;
const paragraphWrapStart = $cursor.pos - 1;
const paragraphWrapEnd = $cursor.pos + 1;
if (cursorNode.content.size === 0 && $cursor.pos + 1 !== docEnd) {
tr.delete(paragraphWrapStart, paragraphWrapEnd);
}
if (dispatch) {
dispatch(tr);
}
return true;
};
export const setMultipleCellAttrs = (attrs, editorView) => (state, dispatch) => {
let cursorPos;
let {
tr
} = state;
const {
targetCellPosition
} = getPluginState(state);
if (isSelectionType(tr.selection, 'cell')) {
// Ignored via go/ees005
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const selection = tr.selection;
selection.forEachCell((_cell, pos) => {
const $pos = tr.doc.resolve(tr.mapping.map(pos + 1));
// Ignored via go/ees005
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
tr = setCellAttrs(findCellClosestToPos($pos), attrs)(tr);
});
cursorPos = selection.$headCell.pos;
} else if (targetCellPosition) {
// Ignored via go/ees005
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const cell = findCellClosestToPos(tr.doc.resolve(targetCellPosition + 1));
tr = setCellAttrs(cell, attrs)(tr);
cursorPos = cell.pos;
}
if (tr.docChanged && cursorPos !== undefined) {
if (dispatch) {
editorView === null || editorView === void 0 ? void 0 : editorView.focus();
dispatch(tr);
}
return true;
}
return false;
};
export const selectColumn = (column, expand, triggeredByKeyboard = false) => createCommand(state => {
const cells = getCellsInColumn(column)(state.tr.selection);
if (!cells || !cells.length || typeof cells[0].pos !== 'number') {
return false;
}
const decorations = createColumnSelectedDecoration(selectColumnTransform(column, expand)(state.tr));
const decorationSet = updatePluginStateDecorations(state, decorations, TableDecorations.COLUMN_SELECTED);
const targetCellPosition = cells[0].pos;
return {
type: 'SELECT_COLUMN',
data: {
targetCellPosition,
decorationSet
}
};
}, tr => selectColumnTransform(column, expand)(tr).setMeta('addToHistory', false).setMeta('selectedColumnViaKeyboard', triggeredByKeyboard));
export const selectColumns = columnIndexes => createCommand(state => {
if (!columnIndexes) {
return false;
}
const cells = columnIndexes.map(column => getCellsInColumn(column)(state.tr.selection)).flat();
if (!cells || !cells.length || cells.some(cell => cell && typeof cell.pos !== 'number')) {
return false;
}
const decorations = createColumnSelectedDecoration(selectColumnsTransform(columnIndexes)(state.tr));
const decorationSet = updatePluginStateDecorations(state, decorations, TableDecorations.COLUMN_SELECTED);
const cellsInFirstColumn = getCellsInColumn(Math.min(...columnIndexes))(state.tr.selection);
if (!cellsInFirstColumn || cellsInFirstColumn.length === 0) {
return false;
}
const targetCellPosition = cellsInFirstColumn[0].pos;
return {
type: 'SELECT_COLUMN',
data: {
targetCellPosition,
decorationSet
}
};
}, tr => {
return selectColumnsTransform(columnIndexes)(tr).setMeta('addToHistory', false);
});
export const selectRow = (row, expand, triggeredByKeyboard = false) => createCommand(state => {
let targetCellPosition;
const cells = getCellsInRow(row)(state.tr.selection);
if (cells && cells.length) {
targetCellPosition = cells[0].pos;
}
return {
type: 'SET_TARGET_CELL_POSITION',
data: {
targetCellPosition
}
};
}, tr => selectRowTransform(row, expand)(tr).setMeta('addToHistory', false).setMeta('selectedRowViaKeyboard', triggeredByKeyboard));
export const selectRows = rowIndexes => createCommand(state => {
if (rowIndexes.length === 0) {
return false;
}
const cells = rowIndexes.map(row => getCellsInRow(row)(state.tr.selection)).flat();
if (!cells || !cells.length || cells.some(cell => cell && typeof cell.pos !== 'number')) {
return false;
}
const cellsInFirstRow = getCellsInRow(Math.min(...rowIndexes))(state.tr.selection);
if (!cellsInFirstRow || cellsInFirstRow.length === 0) {
return false;
}
const targetCellPosition = cellsInFirstRow[0].pos;
return {
type: 'SET_TARGET_CELL_POSITION',
data: {
targetCellPosition
}
};
}, tr => selectRowsTransform(rowIndexes)(tr).setMeta('addToHistory', false));
export const showInsertColumnButton = columnIndex => createCommand(_ => columnIndex > -1 ? {
type: 'SHOW_INSERT_COLUMN_BUTTON',
data: {
insertColumnButtonIndex: columnIndex
}
} : false, tr => tr.setMeta('addToHistory', false));
export const showInsertRowButton = rowIndex => createCommand(_ => rowIndex > -1 ? {
type: 'SHOW_INSERT_ROW_BUTTON',
data: {
insertRowButtonIndex: rowIndex
}
} : false, tr => tr.setMeta('addToHistory', false));
export const hideInsertColumnOrRowButton = () => createCommand({
type: 'HIDE_INSERT_COLUMN_OR_ROW_BUTTON'
}, tr => tr.setMeta('addToHistory', false));
export const addResizeHandleDecorations = (rowIndex, columnIndex, includeTooltip, nodeViewPortalProviderAPI, isKeyboardResize) => createCommand(state => {
const tableNode = findTable(state.selection);
const {
pluginConfig: {
allowColumnResizing
},
getIntl
} = getPluginState(state);
if (!tableNode || !allowColumnResizing) {
return false;
}
return {
type: 'ADD_RESIZE_HANDLE_DECORATIONS',
data: {
decorationSet: buildColumnResizingDecorations(rowIndex, columnIndex, includeTooltip, getIntl, nodeViewPortalProviderAPI)({
tr: state.tr,
decorationSet: getDecorations(state)
}),
resizeHandleRowIndex: rowIndex,
resizeHandleColumnIndex: columnIndex,
resizeHandleIncludeTooltip: includeTooltip,
isKeyboardResize: isKeyboardResize || false
}
};
}, tr => tr.setMeta('addToHistory', false));
export const updateResizeHandleDecorations = (nodeViewPortalProviderAPI, rowIndex, columnIndex, includeTooltip) => createCommand(state => {
const tableNode = findTable(state.selection);
const {
resizeHandleRowIndex,
resizeHandleColumnIndex,
resizeHandleIncludeTooltip,
pluginConfig: {
allowColumnResizing
},
getIntl
} = getPluginState(state);
if (!tableNode || !allowColumnResizing) {
return false;
}
const resolvedRowIndex = rowIndex !== null && rowIndex !== void 0 ? rowIndex : resizeHandleRowIndex;
const resolvedColumnIndex = columnIndex !== null && columnIndex !== void 0 ? columnIndex : resizeHandleColumnIndex;
const resolvedIncludeTooltip = includeTooltip !== null && includeTooltip !== void 0 ? includeTooltip : resizeHandleIncludeTooltip;
if (resolvedRowIndex === undefined || resolvedColumnIndex === undefined || resolvedIncludeTooltip === undefined) {
return false;
}
return {
type: 'UPDATE_RESIZE_HANDLE_DECORATIONS',
data: {
decorationSet: buildColumnResizingDecorations(resolvedRowIndex, resolvedColumnIndex, resolvedIncludeTooltip, getIntl, nodeViewPortalProviderAPI)({
tr: state.tr,
decorationSet: getDecorations(state)
}),
resizeHandleRowIndex: rowIndex,
resizeHandleColumnIndex: columnIndex,
resizeHandleIncludeTooltip: includeTooltip
}
};
}, tr => tr.setMeta('addToHistory', false));
export const removeResizeHandleDecorations = () => createCommand(state => ({
type: 'REMOVE_RESIZE_HANDLE_DECORATIONS',
data: {
decorationSet: clearColumnResizingDecorations()({
tr: state.tr,
decorationSet: getDecorations(state)
})
}
}), tr => tr.setMeta('addToHistory', false));
export const autoSizeTable = (view, node, table, basePos, opts) => {
if (typeof basePos !== 'number') {
return false;
}
view.dispatch(fixAutoSizedTable(view, node, table, basePos, opts));
return true;
};
export const addBoldInEmptyHeaderCells = tableCellHeader => (state, dispatch) => {
const {
tr
} = state;
if (
// Avoid infinite loop when the current selection is not a TextSelection
isTextSelection(tr.selection) && tr.selection.$cursor &&
// When storedMark is null that means this is the initial state
// if the user press to remove the mark storedMark will be an empty array
// and we shouldn't apply the strong mark
tr.storedMarks == null &&
// Check if the current node is a direct child from paragraph
tr.selection.$from.depth === tableCellHeader.depth + 1 &&
// this logic is applied only for empty paragraph
tableCellHeader.node.nodeSize === 4 && isParagraph(tableCellHeader.node.firstChild, state.schema)) {
const {
strong
} = state.schema.marks;
tr.setStoredMarks([strong === null || strong === void 0 ? void 0 : strong.create()]).setMeta('addToHistory', false);
if (dispatch) {
dispatch(tr);
}
return true;
}
return false;
};
export const updateWidthToWidest = widthToWidest => createCommand(state => {
const {
widthToWidest: prevWidthToWidest
} = getPluginState(state);
if (isEqual(widthToWidest, prevWidthToWidest)) {
return false;
}
return {
type: 'UPDATE_TABLE_WIDTH_TO_WIDEST',
data: {
widthToWidest: {
...prevWidthToWidest,
...widthToWidest
}
}
};
});
export const setTableAlignment = (newAlignment, isCommentEditor) => ({
tr
}) => {
const tableObject = findTable(tr.selection);
if (!tableObject) {
return null;
}
const nextTableAttrs = {
...tableObject.node.attrs,
layout: newAlignment
};
// table uses old breakout values in layout attribute to determine width
// but that information is lost when alignment changes, so we need to ensure we retain that info
// If table width is not set in the Comment editor, it means that the table width is inherited from the editor and is "full width".
// In that case when switching between alignment options in the Comment editor we should keep the table width unset.
if (!tableObject.node.attrs.width && !isCommentEditor) {
const tableWidth = getTableContainerWidth(tableObject.node);
nextTableAttrs.width = tableWidth;
}
tr.setNodeMarkup(tableObject.pos, undefined, nextTableAttrs).setMeta('scrollIntoView', false);
return tr;
};
export const setTableAlignmentWithTableContentWithPos = (newAlignment, tableNodeWithPos) => ({
tr
}) => {
const table = tableNodeWithPos.node;
const nextTableAttrs = {
...table.attrs,
layout: newAlignment
};
// table uses old breakout values in layout attribute to determine width
// but that information is lost when alignment changes, so we need to ensure we retain that info
if (!table.attrs.width) {
const tableWidth = getTableContainerWidth(table);
nextTableAttrs.width = tableWidth;
}
tr.setNodeMarkup(tableNodeWithPos.pos, undefined, nextTableAttrs).setMeta('scrollIntoView', false);
return tr;
};
export const setFocusToCellMenu = (isCellMenuOpenByKeyboard = true, originalTr) => createCommand(() => {
return {
type: 'SET_CELL_MENU_OPEN',
data: {
isCellMenuOpenByKeyboard: isCellMenuOpenByKeyboard
}
};
}, tr => (originalTr || tr).setMeta('addToHistory', false));