@atlaskit/editor-plugin-table
Version:
Table plugin for the @atlaskit/editor
368 lines (358 loc) • 18.9 kB
JavaScript
import { bind } from 'bind-event-listener';
import { ACTION, ACTION_SUBJECT, EVENT_TYPE, INPUT_METHOD } from '@atlaskit/editor-common/analytics';
import { getBrowserInfo } from '@atlaskit/editor-common/browser';
import { insideTable } from '@atlaskit/editor-common/core-utils';
import { isNestedTablesSupported } from '@atlaskit/editor-common/nesting';
import { SafePlugin } from '@atlaskit/editor-common/safe-plugin';
import { transformSliceToRemoveOpenBodiedExtension, transformSliceToRemoveOpenExpand, transformSliceToRemoveOpenLayoutNodes, transformSliceToRemoveOpenMultiBodiedExtension, transformSliceToRemoveOpenNestedExpand } from '@atlaskit/editor-common/transforms';
import { closestElement } from '@atlaskit/editor-common/utils';
import { findParentDomRefOfType, findParentNodeOfType } from '@atlaskit/editor-prosemirror/utils';
import { TableMap } from '@atlaskit/editor-tables';
import { findTable } from '@atlaskit/editor-tables/utils';
import { fg } from '@atlaskit/platform-feature-flags';
import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals';
import { tableCellView, tableHeaderView, tableRowView, tableView } from '../nodeviews/table-node-views';
import { pluginKey as decorationsPluginKey } from '../pm-plugins/decorations/plugin';
import { TableCssClassName as ClassName } from '../types';
import { handleBlur, handleClick, handleCut, handleFocus, handleMouseDown, handleMouseEnter, handleMouseLeave, handleMouseMove, handleMouseUp, handleTripleClick, whenTableInFocus, withCellTracking } from '../ui/event-handlers';
import { addBoldInEmptyHeaderCells, clearHoverSelection, setTableRef } from './commands';
import { stopKeyboardColumnResizing } from './commands/column-resize';
import { removeResizeHandleDecorations, transformSliceRemoveCellBackgroundColor, transformSliceToFixDarkModeDefaultBackgroundColor, transformSliceToAddTableHeaders, transformSliceToRemoveColumnsWidths } from './commands/misc';
import { defaultHoveredCell, defaultTableSelection } from './default-table-selection';
import { createPluginState, getPluginState } from './plugin-factory';
import { pluginKey } from './plugin-key';
import { fixTables } from './transforms/fix-tables';
import { replaceSelectedTable } from './transforms/replace-table';
import { findControlsHoverDecoration } from './utils/decoration';
import { transformSliceToCorrectEmptyTableCells, transformSliceToFixHardBreakProblemOnCopyFromCell, transformSliceToRemoveOpenTable, transformSliceToRemoveNestedTables, isHeaderRowRequired, transformSliceTableLayoutDefaultToCenter } from './utils/paste';
import { applyMeasuredWidthToAllTables, isContentModeSupported } from './utils/tableMode';
export const createPlugin = (dispatchAnalyticsEvent, dispatch, portalProviderAPI, nodeViewPortalProviderAPI, eventDispatcher, pluginConfig, getEditorContainerWidth, getEditorFeatureFlags, getIntl, fullWidthModeEnabled, previousFullWidthModeEnabled, editorAnalyticsAPI, pluginInjectionApi, isTableScalingEnabled, shouldUseIncreasedScalingPercent, isCommentEditor, isChromelessEditor, allowFixedColumnWidthOption) => {
var _accessibilityUtils;
const state = createPluginState(dispatch, {
pluginConfig,
isCommentEditor,
isChromelessEditor,
isTableHovered: false,
insertColumnButtonIndex: undefined,
insertRowButtonIndex: undefined,
isFullWidthModeEnabled: fullWidthModeEnabled,
wasFullWidthModeEnabled: previousFullWidthModeEnabled,
isHeaderRowEnabled: !!pluginConfig.allowHeaderRow,
isHeaderColumnEnabled: false,
isDragAndDropEnabled: true,
isTableScalingEnabled: isTableScalingEnabled,
...defaultHoveredCell,
...defaultTableSelection,
getIntl
});
// Used to prevent invalid table cell spans being reported more than once per editor/document
const invalidTableIds = [];
let editorViewRef = null;
const ariaNotifyPlugin = pluginInjectionApi === null || pluginInjectionApi === void 0 ? void 0 : (_accessibilityUtils = pluginInjectionApi.accessibilityUtils) === null || _accessibilityUtils === void 0 ? void 0 : _accessibilityUtils.actions.ariaNotify;
const getCurrentEditorState = () => {
const editorView = editorViewRef;
if (!editorView) {
return null;
}
return editorView.state;
};
const getNodeView = () => {
return {
table: tableView({
portalProviderAPI,
eventDispatcher,
getEditorContainerWidth,
getEditorFeatureFlags,
dispatchAnalyticsEvent,
pluginInjectionApi,
isCommentEditor,
isChromelessEditor,
allowFixedColumnWidthOption
}),
tableRow: tableRowView({
eventDispatcher,
pluginInjectionApi,
isDragAndDropEnabled: true
}),
tableCell: tableCellView({
eventDispatcher,
pluginInjectionApi
}),
tableHeader: tableHeaderView({
eventDispatcher,
pluginInjectionApi
})
};
};
const nodeViews = getNodeView();
return new SafePlugin({
state: state,
key: pluginKey,
appendTransaction: (transactions, oldState, newState) => {
const tr = transactions.find(tr => tr.getMeta('uiEvent') === 'cut');
function reportInvalidTableCellSpanAttrs(invalidNodeAttr) {
if (invalidTableIds.find(id => id === invalidNodeAttr.tableLocalId)) {
return;
}
invalidTableIds.push(invalidNodeAttr.tableLocalId);
dispatchAnalyticsEvent({
action: ACTION.INVALID_DOCUMENT_ENCOUNTERED,
actionSubject: ACTION_SUBJECT.EDITOR,
eventType: EVENT_TYPE.OPERATIONAL,
attributes: {
nodeType: invalidNodeAttr.nodeType,
reason: `${invalidNodeAttr.attribute}: ${invalidNodeAttr.reason}`,
tableLocalId: invalidNodeAttr.tableLocalId,
spanValue: invalidNodeAttr.spanValue
}
});
}
if (tr) {
const {
tableWithFixedColumnWidthsOption = false
} = getEditorFeatureFlags();
// "fixTables" removes empty rows as we don't allow that in schema
const updatedTr = handleCut(tr, oldState, newState, pluginInjectionApi, editorAnalyticsAPI, editorViewRef || undefined, isTableScalingEnabled, tableWithFixedColumnWidthsOption, shouldUseIncreasedScalingPercent);
return fixTables(updatedTr) || updatedTr;
}
if (transactions.find(tr => tr.docChanged)) {
return fixTables(newState.tr, reportInvalidTableCellSpanAttrs);
}
},
view: editorView => {
var _pluginInjectionApi$e, _pluginInjectionApi$e2;
const domAtPos = editorView.domAtPos.bind(editorView);
editorViewRef = editorView;
let contentModeSizeTableId = null;
let focusListenerBinding = null;
if ((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' && isContentModeSupported({
allowColumnResizing: !!pluginConfig.allowColumnResizing,
allowTableResizing: !!pluginConfig.allowTableResizing,
isFullPageEditor: !isChromelessEditor && !isCommentEditor
}) && expValEquals('platform_editor_table_fit_to_content_auto_convert', 'isEnabled', true)) {
focusListenerBinding = bind(editorView.dom, {
type: 'focus',
listener: () => {
if (contentModeSizeTableId) {
return;
}
contentModeSizeTableId = requestAnimationFrame(() => {
if (!editorViewRef) {
return;
}
applyMeasuredWidthToAllTables(editorViewRef, pluginInjectionApi);
});
},
options: {
once: true
}
});
}
return {
update: (view, prevState) => {
const {
state,
dispatch
} = view;
const {
selection
} = state;
const pluginState = getPluginState(state);
let tableRef;
if (fg('platform_editor_enable_table_dnd')) {
var _pluginInjectionApi$e3, _pluginInjectionApi$e4;
const parent = findParentDomRefOfType(state.schema.nodes.table, domAtPos)(selection);
let shouldSetTableRef = fg('platform_editor_enable_table_dnd_patch_1') ? parent && (pluginInjectionApi === null || pluginInjectionApi === void 0 ? void 0 : (_pluginInjectionApi$e3 = pluginInjectionApi.editorViewMode) === null || _pluginInjectionApi$e3 === void 0 ? void 0 : (_pluginInjectionApi$e4 = _pluginInjectionApi$e3.sharedState.currentState()) === null || _pluginInjectionApi$e4 === void 0 ? void 0 : _pluginInjectionApi$e4.mode) !== 'view' : parent;
if (expValEquals('platform_editor_table_update_table_ref', 'isEnabled', true) && fg('platform_editor_update_table_ref_fix')) {
var _pluginInjectionApi$e5, _pluginInjectionApi$e6, _pluginInjectionApi$i, _pluginInjectionApi$i2;
shouldSetTableRef = parent && (pluginInjectionApi === null || pluginInjectionApi === void 0 ? void 0 : (_pluginInjectionApi$e5 = pluginInjectionApi.editorViewMode) === null || _pluginInjectionApi$e5 === void 0 ? void 0 : (_pluginInjectionApi$e6 = _pluginInjectionApi$e5.sharedState.currentState()) === null || _pluginInjectionApi$e6 === void 0 ? void 0 : _pluginInjectionApi$e6.mode) !== 'view' && (pluginInjectionApi === null || pluginInjectionApi === void 0 ? void 0 : (_pluginInjectionApi$i = pluginInjectionApi.interaction) === null || _pluginInjectionApi$i === void 0 ? void 0 : (_pluginInjectionApi$i2 = _pluginInjectionApi$i.sharedState.currentState()) === null || _pluginInjectionApi$i2 === void 0 ? void 0 : _pluginInjectionApi$i2.interactionState) !== 'hasNotHadInteraction';
}
if (shouldSetTableRef) {
tableRef =
// Ignored via go/ees005
// eslint-disable-next-line @atlaskit/editor/no-as-casting
parent.querySelector('table') || undefined;
}
}
if (pluginState.editorHasFocus) {
if (!fg('platform_editor_enable_table_dnd')) {
const parent = findParentDomRefOfType(state.schema.nodes.table, domAtPos)(selection);
if (parent) {
tableRef =
// Ignored via go/ees005
// eslint-disable-next-line @atlaskit/editor/no-as-casting
parent.querySelector('table') || undefined;
}
}
const tableNode = findTable(state.selection);
// when keyboard cursor leaves the table we need to stop column resizing
const pluginPrevState = getPluginState(prevState);
const isStopKeyboardColumResizing = pluginPrevState.isResizeHandleWidgetAdded && pluginPrevState.isKeyboardResize;
if (isStopKeyboardColumResizing) {
const isTableNodesDifferent = (pluginPrevState === null || pluginPrevState === void 0 ? void 0 : pluginPrevState.tableNode) !== (tableNode === null || tableNode === void 0 ? void 0 : tableNode.node);
if (pluginPrevState !== null && pluginPrevState !== void 0 && pluginPrevState.tableNode && tableNode && isTableNodesDifferent) {
const oldRowsNumber = TableMap.get(pluginPrevState.tableNode).height;
const newRowsNumber = TableMap.get(tableNode.node).height;
if (oldRowsNumber !== newRowsNumber ||
// Add/delete row
tableNode.node.attrs.localId !== pluginPrevState.tableNode.attrs.localId) {
// Jump to another table
stopKeyboardColumnResizing({
ariaNotify: ariaNotifyPlugin,
getIntl: getIntl
})(state, dispatch);
}
} else if (!tableNode) {
// selection outside of table
stopKeyboardColumnResizing({
ariaNotify: ariaNotifyPlugin,
getIntl: getIntl
})(state, dispatch);
}
}
}
if (pluginState.tableRef !== tableRef) {
setTableRef(tableRef)(state, dispatch);
}
if (pluginState.editorHasFocus && pluginState.tableRef) {
const {
$cursor
} = state.selection;
if ($cursor) {
// Only update bold when it's a cursor
const tableCellHeader = findParentNodeOfType(state.schema.nodes.tableHeader)(state.selection);
if (tableCellHeader) {
addBoldInEmptyHeaderCells(tableCellHeader)(state, dispatch);
}
}
} else if (pluginState.isResizeHandleWidgetAdded) {
removeResizeHandleDecorations()(state, dispatch);
}
},
destroy: () => {
contentModeSizeTableId && cancelAnimationFrame(contentModeSizeTableId);
focusListenerBinding && focusListenerBinding();
}
};
},
props: {
transformPasted(slice) {
const editorState = getCurrentEditorState();
if (!editorState) {
return slice;
}
const {
schema
} = editorState;
// if we're pasting to outside a table or outside a table
// header, ensure that we apply any table headers to the first
// row of content we see, if required
if (!insideTable(editorState) && isHeaderRowRequired(editorState)) {
slice = transformSliceToAddTableHeaders(slice, schema);
}
// This fixes pasting a table with default layout into comment editor
// table lose width and expand to full width
if (!insideTable(editorState) && isCommentEditor && pluginConfig.allowTableAlignment && isTableScalingEnabled) {
slice = transformSliceTableLayoutDefaultToCenter(slice, schema);
}
slice = transformSliceToFixHardBreakProblemOnCopyFromCell(slice, schema);
// We do this separately, so it also applies to drag/drop events
// This needs to go before `transformSliceToRemoveOpenExpand`
slice = transformSliceToRemoveOpenLayoutNodes(slice, schema);
// If a partial paste of expand, paste only the content
// This needs to go before `transformSliceToRemoveOpenTable`
slice = transformSliceToRemoveOpenExpand(slice, schema);
// transformSliceToRemoveOpenTable() transforms based on the depth of the root node, assuming that the tables will be at the root
// Bodied extensions will contribute to the depth of the table selection so we need to remove them first
/** If a partial paste of bodied extension, paste only text */
slice = transformSliceToRemoveOpenBodiedExtension(slice, schema);
/** If a partial paste of table, paste only table's content */
slice = transformSliceToRemoveOpenTable(slice, schema);
/** If a partial paste of multi bodied extension, paste only children */
slice = transformSliceToRemoveOpenMultiBodiedExtension(slice, schema);
slice = transformSliceToCorrectEmptyTableCells(slice, schema);
if (!pluginConfig.allowColumnResizing) {
slice = transformSliceToRemoveColumnsWidths(slice, schema);
}
// If we don't allow background on cells, we need to remove it
// from the paste slice
if (!pluginConfig.allowBackgroundColor) {
slice = transformSliceRemoveCellBackgroundColor(slice, schema);
} else {
slice = transformSliceToFixDarkModeDefaultBackgroundColor(slice, schema);
}
slice = transformSliceToRemoveOpenNestedExpand(slice, schema);
if (isNestedTablesSupported(schema)) {
slice = transformSliceToRemoveNestedTables(slice, schema, editorState.selection);
}
return slice;
},
handleClick: ({
state,
dispatch
}, _pos, event) => {
const decorationSet = decorationsPluginKey.getState(state);
const browser = getBrowserInfo();
if (findControlsHoverDecoration(decorationSet).length) {
clearHoverSelection()(state, dispatch);
}
// ED-6069: workaround for Chrome given a regression introduced in prosemirror-view@1.6.8
// Returning true prevents that updateSelection() is getting called in the commit below:
// @see https://github.com/ProseMirror/prosemirror-view/commit/33fe4a8b01584f6b4103c279033dcd33e8047b95
if (browser.chrome && event.target) {
// Ignored via go/ees005
// eslint-disable-next-line @atlaskit/editor/no-as-casting
const targetClassList = event.target.classList;
if (targetClassList.contains(ClassName.CONTROLS_BUTTON) || targetClassList.contains(ClassName.CONTEXTUAL_MENU_BUTTON) || targetClassList.contains(ClassName.DRAG_HANDLE_BUTTON_CLICKABLE_ZONE) || targetClassList.contains(ClassName.DRAG_HANDLE_BUTTON_CONTAINER)) {
return true;
}
}
return false;
},
handleScrollToSelection: view => {
// when typing into a sticky header cell, we don't want to scroll
// back to the top of the table if the user has already scrolled down
const {
tableHeader
} = view.state.schema.nodes;
const domRef = findParentDomRefOfType(tableHeader, view.domAtPos.bind(view))(view.state.selection);
const maybeTr = closestElement(domRef, 'tr');
return maybeTr ? maybeTr.classList.contains('sticky') : false;
},
handleTextInput: (view, _from, _to, text) => {
const {
state,
dispatch
} = view;
const {
isKeyboardResize
} = getPluginState(state);
if (isKeyboardResize) {
stopKeyboardColumnResizing({
ariaNotify: ariaNotifyPlugin,
getIntl: getIntl
})(state, dispatch);
return false;
}
const tr = replaceSelectedTable(state, text, INPUT_METHOD.KEYBOARD, editorAnalyticsAPI);
if (tr.selectionSet) {
dispatch(tr);
return true;
}
return false;
},
nodeViews,
handleDOMEvents: {
focus: handleFocus,
blur: handleBlur,
mousedown: withCellTracking(handleMouseDown),
mouseleave: handleMouseLeave,
mousemove: whenTableInFocus(handleMouseMove(nodeViewPortalProviderAPI), pluginInjectionApi),
mouseenter: handleMouseEnter,
mouseup: whenTableInFocus(handleMouseUp),
click: withCellTracking(whenTableInFocus(handleClick))
},
handleTripleClick
}
});
};