UNPKG

@atlaskit/editor-plugin-table

Version:

Table plugin for the @atlaskit/editor

506 lines (495 loc) 21.8 kB
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); };