@atlaskit/editor-plugin-table
Version:
Table plugin for the @atlaskit/editor
506 lines (495 loc) • 21.8 kB
JavaScript
import rafSchedule from 'raf-schd';
import { ACTION_SUBJECT, EVENT_TYPE, TABLE_ACTION } from '@atlaskit/editor-common/analytics';
import { getBrowserInfo } from '@atlaskit/editor-common/browser';
import { getParentOfTypeCount } from '@atlaskit/editor-common/nesting';
import { closestElement, isElementInTableCell, isLastItemMediaGroup, setNodeSelection } from '@atlaskit/editor-common/utils';
import { Selection, TextSelection } from '@atlaskit/editor-prosemirror/state';
import { findParentNodeOfTypeClosestToPos } from '@atlaskit/editor-prosemirror/utils';
import { CellSelection } from '@atlaskit/editor-tables/cell-selection';
import { TableMap } from '@atlaskit/editor-tables/table-map';
import { cellAround, findCellRectClosestToPos, findTable, getSelectionRect, removeTable } from '@atlaskit/editor-tables/utils';
import { addResizeHandleDecorations, clearHoverSelection, hideInsertColumnOrRowButton, hideResizeHandleLine, hoverCell, hoverColumns, selectColumn, setEditorFocus, setTableHovered, showInsertColumnButton, showInsertRowButton, showResizeHandleLine } from '../pm-plugins/commands';
import { getPluginState as getDragDropPluginState } from '../pm-plugins/drag-and-drop/plugin-factory';
import { getPluginState } from '../pm-plugins/plugin-factory';
import { getPluginState as getResizePluginState } from '../pm-plugins/table-resizing/plugin-factory';
import { deleteColumns } from '../pm-plugins/transforms/delete-columns';
import { deleteRows } from '../pm-plugins/transforms/delete-rows';
import { getSelectedCellInfo } from '../pm-plugins/utils/analytics';
import { convertHTMLCellIndexToColumnIndex, getColumnIndexMappedToColumnIndexInFirstRow } from '../pm-plugins/utils/column-controls';
import { getColumnOrRowIndex, getMousePositionHorizontalRelativeByElement, getMousePositionVerticalRelativeByElement, hasResizeHandler, isCell, isColumnControlsDecorations, isCornerButton, isDragColumnFloatingInsertDot, isDragCornerButton, isDragRowFloatingInsertDot, isInsertRowButton, isResizeHandleDecoration, isRowControlsButton, isTableContainerOrWrapper, isTableControlsButton } from '../pm-plugins/utils/dom';
import { getAllowAddColumnCustomStep } from '../pm-plugins/utils/get-allow-add-column-custom-step';
import { TableCssClassName as ClassName, RESIZE_HANDLE_AREA_DECORATION_GAP } from '../types';
const isFocusingCalendar = event => event instanceof FocusEvent && event.relatedTarget instanceof HTMLElement && event.relatedTarget.getAttribute('aria-label') === 'calendar';
const isFocusingModal = event => event instanceof FocusEvent && event.relatedTarget instanceof HTMLElement && event.relatedTarget.closest('[role="dialog"]');
const isFocusingFloatingToolbar = event => event instanceof FocusEvent && event.relatedTarget instanceof HTMLElement && event.relatedTarget.closest('[role="toolbar"]');
const isFocusingDragHandles = event => event instanceof FocusEvent && event.relatedTarget instanceof HTMLElement && event.relatedTarget.closest('button') && event.relatedTarget.getAttribute('draggable') === 'true';
const isFocusingDragHandlesClickableZone = event => event instanceof FocusEvent && event.relatedTarget instanceof HTMLElement && event.relatedTarget.closest('button') && event.relatedTarget.classList.contains(ClassName.DRAG_HANDLE_BUTTON_CLICKABLE_ZONE);
export const handleBlur = (view, event) => {
const {
state,
dispatch
} = view;
// IE version check for ED-4665
// Calendar focus check for ED-10466
if (getBrowserInfo().ie_version !== 11 && !isFocusingCalendar(event) && !isFocusingModal(event) && !isFocusingFloatingToolbar(event) && !isFocusingDragHandles(event) && !isFocusingDragHandlesClickableZone(event)) {
setEditorFocus(false)(state, dispatch);
}
event.preventDefault();
return false;
};
export const handleFocus = (view, event) => {
const {
state,
dispatch
} = view;
setEditorFocus(true)(state, dispatch);
event.preventDefault();
return false;
};
export const handleClick = (view, event) => {
if (!(event.target instanceof HTMLElement)) {
return false;
}
const element = event.target;
// Ignored via go/ees005
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const table = findTable(view.state.selection);
// Ignored via go/ees005
// eslint-disable-next-line @atlaskit/editor/no-as-casting
if (event instanceof MouseEvent && isColumnControlsDecorations(element)) {
// Ignored via go/ees005
// eslint-disable-next-line @atlaskit/editor/no-as-casting
const [startIndex] = getColumnOrRowIndex(element);
const {
state,
dispatch
} = view;
return selectColumn(startIndex, event.shiftKey)(state, dispatch);
}
const matchfn = element.matches ? element.matches : element.msMatchesSelector;
// check if the table cell with an image is clicked and its not the image itself
if (!table ||
// Ignored via go/ees005
// eslint-disable-next-line @atlaskit/editor/no-as-casting
!isElementInTableCell(element) || !matchfn || matchfn.call(element, 'table .image, table p, table .image div')) {
return false;
}
const map = TableMap.get(table.node);
/** Getting the offset of current item clicked */
// Ignored via go/ees005
// eslint-disable-next-line @atlaskit/editor/no-as-casting
const colElement = closestElement(element, 'td') ||
// Ignored via go/ees005
// eslint-disable-next-line @atlaskit/editor/no-as-casting
closestElement(element, 'th');
const colIndex = colElement && colElement.cellIndex;
// Ignored via go/ees005
// eslint-disable-next-line @atlaskit/editor/no-as-casting
const rowElement = closestElement(element, 'tr');
const rowIndex = rowElement && rowElement.rowIndex;
const cellIndex = map.width * rowIndex + colIndex;
const {
dispatch,
state: {
tr,
schema: {
nodes: {
paragraph
}
}
}
} = view;
const cellPos = map.map[cellIndex];
if (isNaN(cellPos) || cellPos === undefined || typeof cellPos !== 'number') {
return false;
}
const editorElement = table.node.nodeAt(cellPos);
/** Only if the last item is media group, insert a paragraph */
if (isLastItemMediaGroup(editorElement)) {
const posInTable = map.map[cellIndex] + editorElement.nodeSize;
tr.insert(posInTable + table.pos, paragraph.create());
dispatch(tr);
setNodeSelection(view, posInTable + table.pos);
}
return true;
};
export const handleMouseOver = (view, mouseEvent) => {
if (!(mouseEvent.target instanceof HTMLElement)) {
return false;
}
const {
state,
dispatch
} = view;
const target = mouseEvent.target;
const {
insertColumnButtonIndex,
insertRowButtonIndex,
isTableHovered
} = getPluginState(state);
if (isInsertRowButton(target)) {
const [startIndex, endIndex] = getColumnOrRowIndex(target);
const positionRow = getMousePositionVerticalRelativeByElement(mouseEvent) === 'bottom' ? endIndex : startIndex;
return showInsertRowButton(positionRow)(state, dispatch);
}
if (isColumnControlsDecorations(target)) {
const [startIndex] = getColumnOrRowIndex(target);
const {
state,
dispatch
} = view;
return hoverColumns([startIndex], false)(state, dispatch);
}
const isNestedTable = getParentOfTypeCount(state.schema.nodes.table)(state.selection.$from) > 1;
if (isNestedTable) {
// if the table is nested inside a table, we only call hideInsertColumnOrRowButton if the table nearest to the mouse target is NOT the parent table
const nearestTable = closestElement(target, 'table');
const nestedTable = findParentNodeOfTypeClosestToPos(state.doc.resolve(state.selection.from), [state.schema.nodes.table]);
const parentTable = findParentNodeOfTypeClosestToPos(state.doc.resolve((nestedTable === null || nestedTable === void 0 ? void 0 : nestedTable.pos) || 0), [state.schema.nodes.table]);
if ((nearestTable === null || nearestTable === void 0 ? void 0 : nearestTable.dataset.tableLocalId) !== (parentTable === null || parentTable === void 0 ? void 0 : parentTable.node.attrs.localId) && (isCell(target) || isCornerButton(target)) && (typeof insertColumnButtonIndex === 'number' || typeof insertRowButtonIndex === 'number')) {
return hideInsertColumnOrRowButton()(state, dispatch);
}
} else if ((isCell(target) || isCornerButton(target)) && (typeof insertColumnButtonIndex === 'number' || typeof insertRowButtonIndex === 'number')) {
return hideInsertColumnOrRowButton()(state, dispatch);
}
if (isResizeHandleDecoration(target)) {
const [startIndex, endIndex] = getColumnOrRowIndex(target);
return showResizeHandleLine({
left: startIndex,
right: endIndex
})(state, dispatch);
}
if (!isTableHovered) {
return setTableHovered(true)(state, dispatch);
}
return false;
};
export const handleMouseUp = (view, mouseEvent) => {
if (!(mouseEvent instanceof MouseEvent)) {
return false;
}
const {
state,
dispatch
} = view;
const {
insertColumnButtonIndex,
tableNode,
tableRef
} = getPluginState(state);
if (insertColumnButtonIndex !== undefined && tableRef && tableRef.parentElement && tableNode) {
const {
width
} = TableMap.get(tableNode);
const newInsertColumnButtonIndex = insertColumnButtonIndex + 1;
if (width === newInsertColumnButtonIndex) {
const tableWidth = tableRef.clientWidth;
tableRef.parentElement.scrollTo(tableWidth, 0);
return showInsertColumnButton(newInsertColumnButtonIndex)(state, dispatch);
}
}
return false;
};
// Ignore any `mousedown` `event` from control and numbered column buttons
// PM end up changing selection during shift selection if not prevented
export const handleMouseDown = (_, event) => {
const isControl = !!(event.target && event.target instanceof HTMLElement && (isTableContainerOrWrapper(event.target) || isColumnControlsDecorations(event.target) || isRowControlsButton(event.target) || isDragCornerButton(event.target)));
if (isControl) {
event.preventDefault();
}
return isControl;
};
export const handleMouseOut = (view, mouseEvent) => {
if (!(mouseEvent instanceof MouseEvent) || !(mouseEvent.target instanceof HTMLElement)) {
return false;
}
const target = mouseEvent.target;
if (isColumnControlsDecorations(target)) {
const {
state,
dispatch
} = view;
return clearHoverSelection()(state, dispatch);
}
const relatedTarget = mouseEvent.relatedTarget;
// In case the user is moving between cell at the same column
// we don't need to hide the resize handle decoration
if (isResizeHandleDecoration(target) && !isResizeHandleDecoration(relatedTarget)) {
const {
state,
dispatch
} = view;
const {
isKeyboardResize
} = getPluginState(state);
if (isKeyboardResize) {
// no need to hide decoration if column resizing started by keyboard
return false;
}
return hideResizeHandleLine()(state, dispatch);
}
return false;
};
export const handleMouseEnter = (view, mouseEvent) => {
const {
state,
dispatch
} = view;
const {
isTableHovered
} = getPluginState(state);
if (!isTableHovered) {
return setTableHovered(true)(state, dispatch);
}
return false;
};
export const handleMouseLeave = (view, event) => {
if (!(event.target instanceof HTMLElement)) {
return false;
}
const {
state,
dispatch
} = view;
const {
insertColumnButtonIndex,
insertRowButtonIndex,
isDragAndDropEnabled,
isTableHovered
} = getPluginState(state);
if (isTableHovered) {
if (isDragAndDropEnabled) {
const {
isDragMenuOpen = false
} = getDragDropPluginState(state);
!isDragMenuOpen && setTableHovered(false)(state, dispatch);
} else {
setTableHovered(false)(state, dispatch);
}
return true;
}
// If this table doesn't have focus then we want to skip everything after this.
if (!isTableInFocus(view)) {
return false;
}
const target = event.target;
if (isTableControlsButton(target)) {
return true;
}
if ((typeof insertColumnButtonIndex !== 'undefined' || typeof insertRowButtonIndex !== 'undefined') && hideInsertColumnOrRowButton()(state, dispatch)) {
return true;
}
return false;
};
// IMPORTANT: The mouse move handler has been setup with RAF schedule to avoid Reflows which will occur as some methods
// need to access the mouse event offset position and also the target clientWidth vallue.
const handleMouseMoveDebounce = nodeViewPortalProviderAPI => rafSchedule((view, event, offsetX) => {
if (!(event.target instanceof HTMLElement)) {
return false;
}
const element = event.target;
if (isColumnControlsDecorations(element) || isDragColumnFloatingInsertDot(element)) {
const {
state,
dispatch
} = view;
const {
insertColumnButtonIndex
} = getPluginState(state);
const [startIndex, endIndex] = getColumnOrRowIndex(element);
const positionColumn = getMousePositionHorizontalRelativeByElement(event, offsetX, undefined) === 'right' ? endIndex : startIndex;
if (positionColumn !== insertColumnButtonIndex) {
return showInsertColumnButton(positionColumn)(state, dispatch);
}
}
if (isRowControlsButton(element) || isDragRowFloatingInsertDot(element)) {
const {
state,
dispatch
} = view;
const {
insertRowButtonIndex
} = getPluginState(state);
const [startIndex, endIndex] = getColumnOrRowIndex(element);
const positionRow = getMousePositionVerticalRelativeByElement(event) === 'bottom' ? endIndex : startIndex;
if (positionRow !== insertRowButtonIndex) {
return showInsertRowButton(positionRow)(state, dispatch);
}
}
if (!isResizeHandleDecoration(element) && isCell(element)) {
const positionColumn = getMousePositionHorizontalRelativeByElement(event, offsetX, RESIZE_HANDLE_AREA_DECORATION_GAP);
if (positionColumn !== null) {
const {
state,
dispatch
} = view;
const {
resizeHandleColumnIndex,
resizeHandleRowIndex
} = getPluginState(state);
const isKeyboardResize = getPluginState(state).isKeyboardResize;
const tableCell = closestElement(element, 'td, th');
const cellStartPosition = view.posAtDOM(tableCell, 0);
const rect = findCellRectClosestToPos(state.doc.resolve(cellStartPosition));
if (rect) {
const columnEndIndexTarget = positionColumn === 'left' ? rect.left : rect.right;
const rowIndexTarget = rect.top;
if ((columnEndIndexTarget !== resizeHandleColumnIndex || rowIndexTarget !== resizeHandleRowIndex || !hasResizeHandler({
target: element,
columnEndIndexTarget
})) && !isKeyboardResize // if initiated by keyboard don't need to react on hover for other resize sliders
) {
return addResizeHandleDecorations(rowIndexTarget, columnEndIndexTarget, true, nodeViewPortalProviderAPI)(state, dispatch);
}
}
}
}
return false;
});
export const handleMouseMove = nodeViewPortalProviderAPI => (view, event) => {
if (!(event.target instanceof HTMLElement)) {
return false;
}
// NOTE: When accessing offsetX in gecko from a deferred callback, it will return 0. However it will be non-zero if accessed
// within the scope of it's initial mouse move handler. Also Chrome does return the correct value, however it could trigger
// a reflow. So for now this will just grab the offsetX value immediately for gecko and chrome will calculate later
// in the deferred callback handler.
// Bug Tracking: https://bugzilla.mozilla.org/show_bug.cgi?id=1882903
handleMouseMoveDebounce(nodeViewPortalProviderAPI)(view, event, getBrowserInfo().gecko ? event.offsetX : NaN);
return false;
};
export function handleTripleClick(view, pos) {
const {
state,
dispatch
} = view;
const $cellPos = cellAround(state.doc.resolve(pos));
if (!$cellPos) {
return false;
}
const cell = state.doc.nodeAt($cellPos.pos);
if (cell) {
const selFrom = Selection.findFrom($cellPos, 1, true);
const selTo = Selection.findFrom(state.doc.resolve($cellPos.pos + cell.nodeSize), -1, true);
if (selFrom && selTo) {
dispatch(state.tr.setSelection(new TextSelection(selFrom.$from, selTo.$to)));
return true;
}
}
return false;
}
export const handleCut = (oldTr, oldState, newState, api, editorAnalyticsAPI, editorView, isTableScalingEnabled = false, isTableFixedColumnWidthsOptionEnabled = false, shouldUseIncreasedScalingPercent = false) => {
const oldSelection = oldState.tr.selection;
let {
tr
} = newState;
if (oldSelection instanceof CellSelection) {
const $anchorCell = oldTr.doc.resolve(oldTr.mapping.map(oldSelection.$anchorCell.pos));
const $headCell = oldTr.doc.resolve(oldTr.mapping.map(oldSelection.$headCell.pos));
const cellSelection = new CellSelection($anchorCell, $headCell);
tr.setSelection(cellSelection);
if (tr.selection instanceof CellSelection) {
const rect = getSelectionRect(cellSelection);
if (rect) {
const {
verticalCells,
horizontalCells,
totalCells,
totalRowCount,
totalColumnCount
} = getSelectedCellInfo(tr.selection);
// Reassigning to make it more obvious and consistent
editorAnalyticsAPI === null || editorAnalyticsAPI === void 0 ? void 0 : editorAnalyticsAPI.attachAnalyticsEvent({
action: TABLE_ACTION.CUT,
actionSubject: ACTION_SUBJECT.TABLE,
actionSubjectId: null,
attributes: {
verticalCells,
horizontalCells,
totalCells,
totalRowCount,
totalColumnCount
},
eventType: EVENT_TYPE.TRACK
})(tr);
// Need this check again since we are overriding the tr in previous statement
if (tr.selection instanceof CellSelection) {
const isTableSelected = tr.selection.isRowSelection() && tr.selection.isColSelection();
if (isTableSelected) {
tr = removeTable(tr);
} else if (tr.selection.isRowSelection()) {
const {
pluginConfig: {
isHeaderRowRequired
}
} = getPluginState(newState);
tr = deleteRows(rect, isHeaderRowRequired)(tr);
} else if (tr.selection.isColSelection()) {
tr = deleteColumns(rect, getAllowAddColumnCustomStep(oldState), api, editorView, isTableScalingEnabled, isTableFixedColumnWidthsOptionEnabled, shouldUseIncreasedScalingPercent)(tr);
}
}
}
}
}
return tr;
};
export const isTableInFocus = view => {
var _getPluginState, _getResizePluginState;
return !!((_getPluginState = getPluginState(view.state)) !== null && _getPluginState !== void 0 && _getPluginState.tableNode) && !((_getResizePluginState = getResizePluginState(view.state)) !== null && _getResizePluginState !== void 0 && _getResizePluginState.dragging);
};
export const whenTableInFocus = (eventHandler, pluginInjectionApi) => (view, mouseEvent) => {
var _pluginInjectionApi$e, _pluginInjectionApi$e2;
if (!isTableInFocus(view)) {
return false;
}
const isViewMode = (pluginInjectionApi === null || pluginInjectionApi === void 0 ? void 0 : (_pluginInjectionApi$e = pluginInjectionApi.editorViewMode) === null || _pluginInjectionApi$e === void 0 ? void 0 : (_pluginInjectionApi$e2 = _pluginInjectionApi$e.sharedState.currentState()) === null || _pluginInjectionApi$e2 === void 0 ? void 0 : _pluginInjectionApi$e2.mode) === 'view';
/**
* Table cannot be in focus if we are in view mode.
* This will prevent an infinite flow of adding and removing
* resize handle decorations in view mode that causes unpredictable
* selections.
*/
if (isViewMode) {
return false;
}
return eventHandler(view, mouseEvent);
};
const trackCellLocation = (view, mouseEvent) => {
var _tableElement$dataset;
const target = mouseEvent.target;
// Ignored via go/ees005
// eslint-disable-next-line @atlaskit/editor/no-as-casting
const maybeTableCell = isElementInTableCell(target);
const {
tableNode,
tableRef
} = getPluginState(view.state);
// Ignored via go/ees005
// eslint-disable-next-line @atlaskit/editor/no-as-casting
const tableElement = closestElement(target, 'table');
// hover will only trigger if target localId is the same with selected localId
if (tableElement !== null && tableElement !== void 0 && (_tableElement$dataset = tableElement.dataset) !== null && _tableElement$dataset !== void 0 && _tableElement$dataset.tableLocalId && tableElement.dataset.tableLocalId !== (tableNode === null || tableNode === void 0 ? void 0 : tableNode.attrs.localId)) {
return;
}
if (!maybeTableCell || !tableRef) {
return;
}
const htmlColIndex = maybeTableCell.cellIndex;
// Ignored via go/ees005
// eslint-disable-next-line @atlaskit/editor/no-as-casting
const rowElement = closestElement(target, 'tr');
const htmlRowIndex = rowElement && rowElement.rowIndex;
const tableMap = tableNode && TableMap.get(tableNode);
let colIndex = htmlColIndex;
if (tableMap) {
const convertedColIndex = convertHTMLCellIndexToColumnIndex(htmlColIndex, htmlRowIndex, tableMap);
colIndex = getColumnIndexMappedToColumnIndexInFirstRow(convertedColIndex, htmlRowIndex, tableMap);
}
hoverCell(htmlRowIndex, colIndex)(view.state, view.dispatch);
};
export const withCellTracking = eventHandler => (view, mouseEvent) => {
if (getPluginState(view.state).isDragAndDropEnabled && getDragDropPluginState(view.state) && !getDragDropPluginState(view.state).isDragging) {
trackCellLocation(view, mouseEvent);
}
return eventHandler(view, mouseEvent);
};