UNPKG

@atlaskit/editor-plugin-block-controls

Version:

Block controls plugin for @atlaskit/editor-core

486 lines (477 loc) 24.7 kB
import { ACTION, ACTION_SUBJECT, ACTION_SUBJECT_ID, EVENT_TYPE, INPUT_METHOD } from '@atlaskit/editor-common/analytics'; import { expandedState } from '@atlaskit/editor-common/expand'; import { blockControlsMessages } from '@atlaskit/editor-common/messages'; import { expandSelectionBounds, GapCursorSelection } from '@atlaskit/editor-common/selection'; import { transformSliceNestedExpandToExpand } from '@atlaskit/editor-common/transforms'; import { DIRECTION } from '@atlaskit/editor-common/types'; import { isEmptyParagraph } from '@atlaskit/editor-common/utils'; import { Fragment, Slice } from '@atlaskit/editor-prosemirror/model'; import { NodeSelection, Selection } from '@atlaskit/editor-prosemirror/state'; import { Mapping, StepMap } from '@atlaskit/editor-prosemirror/transform'; import { findChildrenByType, findParentNodeOfType, findParentNodeOfTypeClosestToPos } from '@atlaskit/editor-prosemirror/utils'; import { findTable, isInTable, isTableSelected } from '@atlaskit/editor-tables/utils'; import { fg } from '@atlaskit/platform-feature-flags'; import { editorExperiment } from '@atlaskit/tmp-editor-statsig/experiments'; import { key } from '../pm-plugins/main'; import { attachMoveNodeAnalytics, getMultiSelectAnalyticsAttributes } from '../pm-plugins/utils/analytics'; import { getNestedNodePosition } from '../pm-plugins/utils/getNestedNodePosition'; import { selectNode, setCursorPositionAtMovedNode } from '../pm-plugins/utils/getSelection'; import { removeFromSource } from '../pm-plugins/utils/remove-from-source'; import { getSelectedSlicePosition } from '../pm-plugins/utils/selection'; import { getInsertLayoutStep, updateSelection } from '../pm-plugins/utils/update-selection'; import { canMoveNodeToIndex, isInsideTable, transformFragmentExpandToNestedExpand, transformSliceExpandToNestedExpand } from '../pm-plugins/utils/validation'; import { getPosWhenMoveNodeDown, getPosWhenMoveNodeUp } from './utils/move-node-utils'; /** * This function transforms the slice to move * @param nodeCopy The slice contains the node to be moved * @param destType The type of the destiation node * @returns transformed slice or null if unable to */ function transformSourceSlice(nodeCopy, destType) { const srcNode = nodeCopy.content.firstChild; const schema = srcNode === null || srcNode === void 0 ? void 0 : srcNode.type.schema; if (!schema) { return nodeCopy; } const { doc, layoutColumn } = schema.nodes; const destTypeInTable = isInsideTable(destType); const destTypeInDocOrLayoutCol = [doc, layoutColumn].includes(destType); // No need to loop over slice content if destination requires no transformations if (!destTypeInTable && !destTypeInDocOrLayoutCol) { return nodeCopy; } let containsExpand = false; let containsNestedExpand = false; for (let i = 0; i < nodeCopy.content.childCount; i++) { const node = nodeCopy.content.child(i); if (node.type === schema.nodes.expand) { containsExpand = true; } else if (node.type === schema.nodes.nestedExpand) { containsNestedExpand = true; } if (containsExpand && containsNestedExpand) { break; } } if (containsExpand && destTypeInTable) { return transformSliceExpandToNestedExpand(nodeCopy); } else if (containsNestedExpand && destTypeInDocOrLayoutCol) { return transformSliceNestedExpandToExpand(nodeCopy, schema); } return nodeCopy; } const nodesSupportDragLayoutColumnInto = ['tableCell', 'tableHeader', 'panel', 'expand', 'nestedExpand']; const isDragLayoutColumnIntoSupportedNodes = ($from, $to) => { var _$from$nodeAfter; const isTopLevel = $to.depth === 0; const isDragIntoNodes = nodesSupportDragLayoutColumnInto.includes($to.parent.type.name); const supportedCondition = isDragIntoNodes || isTopLevel; return ((_$from$nodeAfter = $from.nodeAfter) === null || _$from$nodeAfter === void 0 ? void 0 : _$from$nodeAfter.type.name) === 'layoutColumn' && $from.parent.type.name === 'layoutSection' && supportedCondition; }; /** * * @returns the start position of a node if the node can be moved, otherwise -1 */ const getCurrentNodePos = state => { const { selection } = state; let currentNodePos = -1; // There are 3 cases when a node can be moved const focusedHandle = getFocusedHandle(state); if (focusedHandle) { // 1. drag handle of the node is focused currentNodePos = focusedHandle.pos; } else if (isInTable(state)) { if (isTableSelected(selection)) { var _findTable$pos, _findTable; // We only move table node if it's fully selected // to avoid shortcut collision with table drag and drop currentNodePos = (_findTable$pos = (_findTable = findTable(selection)) === null || _findTable === void 0 ? void 0 : _findTable.pos) !== null && _findTable$pos !== void 0 ? _findTable$pos : currentNodePos; } } else if (!(state.selection instanceof GapCursorSelection)) { // 2. caret cursor is inside the node // 3. the start of the selection is inside the node currentNodePos = selection.$from.before(1); if (selection.$from.depth > 0) { currentNodePos = getNestedNodePosition({ selection, schema: state.schema, resolve: state.doc.resolve.bind(state.doc) }); } } return currentNodePos; }; const getFocusedHandle = state => { var _activeNode$handleOpt; const { activeNode } = key.getState(state) || {}; return activeNode && (_activeNode$handleOpt = activeNode.handleOptions) !== null && _activeNode$handleOpt !== void 0 && _activeNode$handleOpt.isFocused ? activeNode : undefined; }; export const moveNodeViaShortcut = (api, direction, formatMessage) => { return state => { var _hoistedPos; const { selection } = state; const isParentNodeOfTypeLayout = !!findParentNodeOfType([state.schema.nodes.layoutSection])(state.selection); const isMultiSelectEnabled = editorExperiment('platform_editor_element_drag_and_drop_multiselect', true); const expandedSelection = expandSelectionBounds(selection.$anchor, selection.$head); const expandedAnchor = expandedSelection.$anchor.pos; const expandedHead = expandedSelection.$head.pos; let hoistedPos; const from = Math.min(expandedAnchor, expandedHead); // Nodes like lists nest within themselves, we need to find the top most position if (isParentNodeOfTypeLayout) { const LAYOUT_COL_DEPTH = 3; hoistedPos = state.doc.resolve(from).before(LAYOUT_COL_DEPTH); } const currentNodePos = isMultiSelectEnabled && !getFocusedHandle(state) && !selection.empty ? (_hoistedPos = hoistedPos) !== null && _hoistedPos !== void 0 ? _hoistedPos : from : getCurrentNodePos(state); if (currentNodePos > -1) { var _state$doc$nodeAt; const $currentNodePos = state.doc.resolve(currentNodePos); const nodeAfterPos = isMultiSelectEnabled && !getFocusedHandle(state) ? Math.max(expandedAnchor, expandedHead) : $currentNodePos.posAtIndex($currentNodePos.index() + 1); const isTopLevelNode = $currentNodePos.depth === 0; let moveToPos = -1; const isLayoutColumnSelected = selection instanceof NodeSelection && selection.node.type.name === 'layoutColumn'; if (direction === DIRECTION.LEFT) { if (isTopLevelNode && editorExperiment('advanced_layouts', true)) { var _api$core, _api$core2; const nodeBefore = $currentNodePos.nodeBefore; if (nodeBefore) { moveToPos = currentNodePos - nodeBefore.nodeSize; } if (moveToPos < 0) { return false; } api === null || api === void 0 ? void 0 : (_api$core = api.core) === null || _api$core === void 0 ? void 0 : _api$core.actions.execute(({ tr }) => { var _api$blockControls, _api$blockControls$co; api === null || api === void 0 ? void 0 : (_api$blockControls = api.blockControls) === null || _api$blockControls === void 0 ? void 0 : (_api$blockControls$co = _api$blockControls.commands) === null || _api$blockControls$co === void 0 ? void 0 : _api$blockControls$co.moveToLayout(currentNodePos, moveToPos, { moveToEnd: true, moveNodeAtCursorPos: true })({ tr }); const insertColumnStep = getInsertLayoutStep(tr); const mappedTo = insertColumnStep === null || insertColumnStep === void 0 ? void 0 : insertColumnStep.from; updateSelection(tr, mappedTo, true); return tr; }); api === null || api === void 0 ? void 0 : (_api$core2 = api.core) === null || _api$core2 === void 0 ? void 0 : _api$core2.actions.focus(); return true; } else if (isLayoutColumnSelected) { var _$currentNodePos$node, _api$core3, _api$blockControls2, _api$blockControls2$c; moveToPos = selection.from - (((_$currentNodePos$node = $currentNodePos.nodeBefore) === null || _$currentNodePos$node === void 0 ? void 0 : _$currentNodePos$node.nodeSize) || 1); api === null || api === void 0 ? void 0 : (_api$core3 = api.core) === null || _api$core3 === void 0 ? void 0 : _api$core3.actions.execute(api === null || api === void 0 ? void 0 : (_api$blockControls2 = api.blockControls) === null || _api$blockControls2 === void 0 ? void 0 : (_api$blockControls2$c = _api$blockControls2.commands) === null || _api$blockControls2$c === void 0 ? void 0 : _api$blockControls2$c.moveToLayout(currentNodePos, moveToPos, { selectMovedNode: true })); return true; } else { if ($currentNodePos.depth < 2 || !isParentNodeOfTypeLayout) { return false; } // get the previous layoutSection node const index = $currentNodePos.index($currentNodePos.depth - 1); const grandParent = $currentNodePos.node($currentNodePos.depth - 1); const previousNode = grandParent ? grandParent.maybeChild(index - 1) : null; moveToPos = $currentNodePos.start() - ((previousNode === null || previousNode === void 0 ? void 0 : previousNode.nodeSize) || 1); } } else if (direction === DIRECTION.RIGHT) { if (isTopLevelNode && editorExperiment('advanced_layouts', true)) { var _api$core4, _api$core5; const endOfDoc = $currentNodePos.end(); moveToPos = $currentNodePos.posAtIndex($currentNodePos.index() + 1); if (moveToPos >= endOfDoc) { return false; } api === null || api === void 0 ? void 0 : (_api$core4 = api.core) === null || _api$core4 === void 0 ? void 0 : _api$core4.actions.execute(({ tr }) => { var _api$blockControls3, _api$blockControls3$c; api === null || api === void 0 ? void 0 : (_api$blockControls3 = api.blockControls) === null || _api$blockControls3 === void 0 ? void 0 : (_api$blockControls3$c = _api$blockControls3.commands) === null || _api$blockControls3$c === void 0 ? void 0 : _api$blockControls3$c.moveToLayout(currentNodePos, moveToPos, { moveNodeAtCursorPos: true })({ tr }); const insertColumnStep = getInsertLayoutStep(tr); const mappedTo = insertColumnStep === null || insertColumnStep === void 0 ? void 0 : insertColumnStep.from; updateSelection(tr, mappedTo); return tr; }); api === null || api === void 0 ? void 0 : (_api$core5 = api.core) === null || _api$core5 === void 0 ? void 0 : _api$core5.actions.focus(); return true; } else if (isLayoutColumnSelected) { var _api$core6, _api$blockControls4, _api$blockControls4$c; const index = $currentNodePos.index($currentNodePos.depth); const parent = $currentNodePos.node($currentNodePos.depth); // get the next layoutColumn node const nextNode = parent ? parent.maybeChild(index + 1) : null; // if the current node is the last node, don't do anything if (index >= parent.childCount - 1) { // prevent event propagation to avoid moving the cursor and still select the node return true; } const moveToEnd = index === parent.childCount - 2; moveToPos = moveToEnd ? $currentNodePos.before() : selection.to + ((nextNode === null || nextNode === void 0 ? void 0 : nextNode.nodeSize) || 1); api === null || api === void 0 ? void 0 : (_api$core6 = api.core) === null || _api$core6 === void 0 ? void 0 : _api$core6.actions.execute(api === null || api === void 0 ? void 0 : (_api$blockControls4 = api.blockControls) === null || _api$blockControls4 === void 0 ? void 0 : (_api$blockControls4$c = _api$blockControls4.commands) === null || _api$blockControls4$c === void 0 ? void 0 : _api$blockControls4$c.moveToLayout(currentNodePos, moveToPos, { moveToEnd, selectMovedNode: true })); return true; } else { if ($currentNodePos.depth < 2 || !isParentNodeOfTypeLayout) { return false; } moveToPos = $currentNodePos.after($currentNodePos.depth) + 1; } } else if (direction === DIRECTION.UP) { if (isLayoutColumnSelected) { moveToPos = $currentNodePos.start() - 1; } else { moveToPos = getPosWhenMoveNodeUp($currentNodePos, currentNodePos); } } else { const endOfDoc = $currentNodePos.end(); if (nodeAfterPos > endOfDoc) { return false; } if (isLayoutColumnSelected) { moveToPos = state.selection.$from.end() + 1; } else { moveToPos = getPosWhenMoveNodeDown({ $currentNodePos, nodeAfterPos, tr: state.tr }); } } const nodeType = (_state$doc$nodeAt = state.doc.nodeAt(currentNodePos)) === null || _state$doc$nodeAt === void 0 ? void 0 : _state$doc$nodeAt.type.name; let shouldMoveNode = false; if (moveToPos > -1) { const isDestDepthSameAsSource = $currentNodePos.depth === state.doc.resolve(moveToPos).depth; const isSourceLayoutColumn = nodeType === 'layoutColumn'; shouldMoveNode = isDestDepthSameAsSource || isSourceLayoutColumn; } const { $anchor: $newAnchor, $head: $newHead } = expandSelectionBounds($currentNodePos, selection.$to); if (shouldMoveNode) { var _api$core7; api === null || api === void 0 ? void 0 : (_api$core7 = api.core) === null || _api$core7 === void 0 ? void 0 : _api$core7.actions.execute(({ tr }) => { api === null || api === void 0 ? void 0 : api.blockControls.commands.setMultiSelectPositions($newAnchor.pos, $newHead.pos)({ tr }); moveNode(api)(currentNodePos, moveToPos, INPUT_METHOD.SHORTCUT, formatMessage)({ tr }); tr.scrollIntoView(); return tr; }); return true; } else if (nodeType && !isMultiSelectEnabled) { var _api$core8; // If the node is first/last one, only select the node api === null || api === void 0 ? void 0 : (_api$core8 = api.core) === null || _api$core8 === void 0 ? void 0 : _api$core8.actions.execute(({ tr }) => { selectNode(tr, currentNodePos, nodeType, api); tr.scrollIntoView(); return tr; }); return true; } else if (isMultiSelectEnabled) { var _api$core9; api === null || api === void 0 ? void 0 : (_api$core9 = api.core) === null || _api$core9 === void 0 ? void 0 : _api$core9.actions.execute(({ tr }) => { api === null || api === void 0 ? void 0 : api.blockControls.commands.setMultiSelectPositions($newAnchor.pos, $newHead.pos)({ tr }); tr.scrollIntoView(); return tr; }); return true; } } return false; }; }; export const moveNode = api => (start, to, inputMethod = INPUT_METHOD.DRAG_AND_DROP, formatMessage) => ({ tr }) => { var _api$blockControls$sh, _convertedNodeSlice, _api$accessibilityUti; if (!api || start < 0 || to < 0) { return tr; } const handleNode = tr.doc.nodeAt(start); if (!handleNode) { return tr; } let sliceFrom = start; let sliceTo; let sourceNodeTypes, hasSelectedMultipleNodes; const isMultiSelect = editorExperiment('platform_editor_element_drag_and_drop_multiselect', true); if (fg('platform_editor_ease_of_use_metrics')) { var _api$metrics; api === null || api === void 0 ? void 0 : (_api$metrics = api.metrics) === null || _api$metrics === void 0 ? void 0 : _api$metrics.commands.setContentMoved()({ tr }); } const preservedSelection = editorExperiment('platform_editor_block_menu', true) ? api === null || api === void 0 ? void 0 : (_api$blockControls$sh = api.blockControls.sharedState.currentState()) === null || _api$blockControls$sh === void 0 ? void 0 : _api$blockControls$sh.preservedSelection : undefined; if (preservedSelection) { const $from = tr.doc.resolve(Math.min(start, preservedSelection.from)); const expandedRange = $from.blockRange(preservedSelection.$to); sliceFrom = expandedRange ? expandedRange.start : preservedSelection.from; sliceTo = expandedRange ? expandedRange.end : preservedSelection.to; const attributes = getMultiSelectAnalyticsAttributes(tr, sliceFrom, sliceTo); hasSelectedMultipleNodes = attributes.hasSelectedMultipleNodes; sourceNodeTypes = attributes.nodeTypes; } else if (isMultiSelect) { const slicePosition = getSelectedSlicePosition(start, tr, api); sliceFrom = slicePosition.from; sliceTo = slicePosition.to; const attributes = getMultiSelectAnalyticsAttributes(tr, sliceFrom, sliceTo); hasSelectedMultipleNodes = attributes.hasSelectedMultipleNodes; sourceNodeTypes = attributes.nodeTypes; } else { var _handleNode$nodeSize; const size = (_handleNode$nodeSize = handleNode === null || handleNode === void 0 ? void 0 : handleNode.nodeSize) !== null && _handleNode$nodeSize !== void 0 ? _handleNode$nodeSize : 1; sliceTo = sliceFrom + size; } const { expand, nestedExpand } = tr.doc.type.schema.nodes; const $to = tr.doc.resolve(to); const $handlePos = tr.doc.resolve(start); const nodeCopy = tr.doc.slice(sliceFrom, sliceTo, false); // cut the content const destNode = $to.node(); const destType = destNode.type; const destParent = $to.node($to.depth); const sourceNode = $handlePos.nodeAfter; //TODO: ED-26959 - Does this need to be updated with new selection logic above? ^ // Move a layout column to top level, or table cell, or panel, or expand, only moves the content into them if (sourceNode && isDragLayoutColumnIntoSupportedNodes($handlePos, $to)) { // need update after we support single column layout. const layoutColumnContent = sourceNode.content; let fragment; // if drop into table, and layout column contains expand, transform it to nestedExpand if (['tableCell', 'tableHeader'].includes($to.parent.type.name)) { const contentContainsExpand = findChildrenByType(sourceNode, expand).length > 0; fragment = contentContainsExpand ? transformFragmentExpandToNestedExpand(Fragment.from(layoutColumnContent)) : Fragment.from(layoutColumnContent); if (!fragment) { return tr; } } else { fragment = Fragment.from(layoutColumnContent); } removeFromSource(tr, $handlePos, $handlePos.pos + sourceNode.nodeSize); const mappedTo = tr.mapping.map(to); tr.insert(mappedTo, fragment).setSelection(Selection.near(tr.doc.resolve(mappedTo))).scrollIntoView(); return tr; } if (!canMoveNodeToIndex(destParent, $to.index(), $handlePos.node().child($handlePos.index()), $to)) { return tr; } let convertedNodeSlice = transformSourceSlice(nodeCopy, destType); let convertedNode = (_convertedNodeSlice = convertedNodeSlice) === null || _convertedNodeSlice === void 0 ? void 0 : _convertedNodeSlice.content; if (!convertedNode) { return tr; } // Currently we don't support breakout mark for children nodes of bodiedSyncBlock node // Hence strip out the mark for now if (destNode.type.name === 'bodiedSyncBlock' && editorExperiment('platform_synced_block', true)) { var _convertedNodeSlice2; const nodes = []; (_convertedNodeSlice2 = convertedNodeSlice) === null || _convertedNodeSlice2 === void 0 ? void 0 : _convertedNodeSlice2.content.forEach(node => { nodes.push(node.mark(node.marks.filter(mark => mark.type.name !== 'breakout'))); }); convertedNodeSlice = new Slice(Fragment.from(nodes), 0, 0); convertedNode = convertedNodeSlice.content; } // delete the content from the original position tr.delete(sliceFrom, sliceTo); const mappedTo = tr.mapping.map(to); const isDestNestedLoneEmptyParagraph = destParent.type.name !== 'doc' && destParent.childCount === 1 && isEmptyParagraph($to.nodeAfter); if (convertedNodeSlice && isDestNestedLoneEmptyParagraph) { // if only a single empty paragraph within container, replace it tr.replace(mappedTo, mappedTo + 1, convertedNodeSlice); } else { // otherwise just insert the content at the new position tr.insert(mappedTo, convertedNode); } const sliceSize = sliceTo - sliceFrom; if (inputMethod === INPUT_METHOD.DRAG_AND_DROP) { tr = setCursorPositionAtMovedNode(tr, mappedTo, api); } else if (preservedSelection) { const currMeta = tr.getMeta(key); const nodeMovedOffset = mappedTo - sliceFrom; tr.setMeta(key, { ...currMeta, preservedSelectionMapping: new Mapping([new StepMap([0, 0, nodeMovedOffset])]) }); } else if (isMultiSelect) { var _api$blockControls$co2; tr = (_api$blockControls$co2 = api === null || api === void 0 ? void 0 : api.blockControls.commands.setMultiSelectPositions(mappedTo, mappedTo + sliceSize)({ tr })) !== null && _api$blockControls$co2 !== void 0 ? _api$blockControls$co2 : tr; } else { tr = selectNode(tr, mappedTo, handleNode.type.name, api); } const currMeta = tr.getMeta(key); tr.setMeta(key, { ...currMeta, nodeMoved: true }); if ( // when move node via block menu, we need to keep the focus on block menu popup, so don't move focus to editor in this scenario !(inputMethod === INPUT_METHOD.BLOCK_MENU && editorExperiment('platform_editor_block_menu', true))) { api === null || api === void 0 ? void 0 : api.core.actions.focus(); } const $mappedTo = tr.doc.resolve(mappedTo); const expandAncestor = findParentNodeOfTypeClosestToPos($to, [expand, nestedExpand]); if (expandAncestor) { const wasExpandExpanded = expandedState.get(expandAncestor.node); const updatedExpandAncestor = findParentNodeOfTypeClosestToPos($mappedTo, [expand, nestedExpand]); if (wasExpandExpanded !== undefined && updatedExpandAncestor) { expandedState.set(updatedExpandAncestor.node, wasExpandExpanded); } } if (editorExperiment('advanced_layouts', true)) { attachMoveNodeAnalytics(tr, inputMethod, $handlePos.depth, handleNode.type.name, $mappedTo === null || $mappedTo === void 0 ? void 0 : $mappedTo.depth, $mappedTo === null || $mappedTo === void 0 ? void 0 : $mappedTo.parent.type.name, $handlePos.sameParent($mappedTo), api, sourceNodeTypes, hasSelectedMultipleNodes); } else { var _api$analytics; api === null || api === void 0 ? void 0 : (_api$analytics = api.analytics) === null || _api$analytics === void 0 ? void 0 : _api$analytics.actions.attachAnalyticsEvent({ eventType: EVENT_TYPE.TRACK, action: ACTION.MOVED, actionSubject: ACTION_SUBJECT.ELEMENT, actionSubjectId: ACTION_SUBJECT_ID.ELEMENT_DRAG_HANDLE, attributes: { nodeDepth: $handlePos.depth, nodeType: handleNode.type.name, destinationNodeDepth: $mappedTo === null || $mappedTo === void 0 ? void 0 : $mappedTo.depth, destinationNodeType: $mappedTo === null || $mappedTo === void 0 ? void 0 : $mappedTo.parent.type.name, inputMethod, ...(isMultiSelect && { sourceNodeTypes, hasSelectedMultipleNodes }) } })(tr); } const movedMessage = to > sliceFrom ? blockControlsMessages.movedDown : blockControlsMessages.movedup; api === null || api === void 0 ? void 0 : (_api$accessibilityUti = api.accessibilityUtils) === null || _api$accessibilityUti === void 0 ? void 0 : _api$accessibilityUti.actions.ariaNotify(formatMessage ? formatMessage(movedMessage) : movedMessage.defaultMessage, { priority: 'important' }); return tr; };