@atlaskit/editor-plugin-block-controls
Version:
Block controls plugin for @atlaskit/editor-core
285 lines (279 loc) • 13 kB
JavaScript
import { createElement } from 'react';
// eslint-disable-next-line @atlaskit/platform/prefer-crypto-random-uuid -- Use crypto.randomUUID instead
import uuid from 'uuid';
import { expandSelectionBounds } from '@atlaskit/editor-common/selection';
import { isEmptyParagraph } from '@atlaskit/editor-common/utils';
import { Decoration } from '@atlaskit/editor-prosemirror/view';
import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals';
import { editorExperiment } from '@atlaskit/tmp-editor-statsig/experiments';
import { nodeMargins } from '../ui/consts';
import { DropTarget, EDITOR_BLOCK_CONTROLS_DROP_INDICATOR_GAP, EDITOR_BLOCK_CONTROLS_DROP_INDICATOR_OFFSET } from '../ui/drop-target';
import { DropTargetLayout, DropTargetLayoutNativeAnchorSupport } from '../ui/drop-target-layout';
import { NESTED_DEPTH, TYPE_DROP_TARGET_DEC } from './decorations-common';
import { maxLayoutColumnSupported } from './utils/consts';
import { canMoveNodeToIndex, canMoveSliceToIndex, isInSameLayout } from './utils/validation';
const IGNORE_NODES = ['tableCell', 'tableHeader', 'tableRow', 'layoutColumn', 'listItem', 'caption'];
const PARENT_WITH_END_DROP_TARGET = ['tableCell', 'tableHeader', 'panel', 'layoutColumn', 'expand', 'nestedExpand', 'bodiedExtension'];
const PARENT_WITH_END_DROP_TARGET_NEXT = ['tableCell', 'tableHeader', 'panel', 'layoutColumn', 'expand', 'nestedExpand', 'bodiedExtension', 'bodiedSyncBlock'];
const DISABLE_CHILD_DROP_TARGET = ['orderedList', 'bulletList'];
const shouldDescend = node => {
return !['mediaSingle', 'paragraph', 'heading'].includes(node.type.name);
};
const getNodeMargins = node => {
if (!node) {
return nodeMargins['default'];
}
const nodeTypeName = node.type.name;
if (nodeTypeName === 'heading') {
return nodeMargins[`heading${node.attrs.level}`] || nodeMargins['default'];
}
return nodeMargins[nodeTypeName] || nodeMargins['default'];
};
const shouldCollapseMargin = (prevNode, nextNode) => {
if (((prevNode === null || prevNode === void 0 ? void 0 : prevNode.type.name) === 'mediaSingle' || (nextNode === null || nextNode === void 0 ? void 0 : nextNode.type.name) === 'mediaSingle') && (prevNode === null || prevNode === void 0 ? void 0 : prevNode.type.name) !== (nextNode === null || nextNode === void 0 ? void 0 : nextNode.type.name)) {
return false;
}
return true;
};
const getGapAndOffset = (prevNode, nextNode, parentNode) => {
const isSyncBlockOffsetPatchEnabled = editorExperiment('platform_synced_block', true);
if (!prevNode && nextNode) {
// first node - adjust for bodied containers
let offset = 0;
if (isSyncBlockOffsetPatchEnabled && parentNode !== null && parentNode !== void 0 && parentNode.type.name && parentNode.type.name === 'bodiedSyncBlock') {
offset += 4;
}
return {
gap: 0,
offset
};
} else if (prevNode && !nextNode) {
// last node - adjust for bodied containers
let offset = 0;
if (isSyncBlockOffsetPatchEnabled && parentNode !== null && parentNode !== void 0 && parentNode.type.name && parentNode.type.name === 'bodiedSyncBlock') {
offset -= 4;
}
return {
gap: 0,
offset
};
}
const top = getNodeMargins(nextNode).top || 4;
const bottom = getNodeMargins(prevNode).bottom || 4;
const gap = shouldCollapseMargin(prevNode, nextNode) ? Math.max(top, bottom) : top + bottom;
let offset = top - gap / 2;
if ((prevNode === null || prevNode === void 0 ? void 0 : prevNode.type.name) === 'mediaSingle' && (nextNode === null || nextNode === void 0 ? void 0 : nextNode.type.name) === 'mediaSingle') {
offset = -offset;
} else if (prevNode !== null && prevNode !== void 0 && prevNode.type.name && ['tableCell', 'tableHeader'].includes(prevNode === null || prevNode === void 0 ? void 0 : prevNode.type.name)) {
offset = 0;
}
return {
gap,
offset
};
};
/**
* Find drop target decorations in the pos range between from and to
* @param decorations
* @param from
* @param to
* @returns
*/
export const findDropTargetDecs = (decorations, from, to) => {
return decorations.find(from, to, spec => spec.type === TYPE_DROP_TARGET_DEC);
};
export const createDropTargetDecoration = (pos, props, nodeViewPortalProviderAPI, side, anchorRectCache, isSameLayout) => {
// eslint-disable-next-line @atlaskit/platform/prefer-crypto-random-uuid -- Use crypto.randomUUID instead
const key = uuid();
return Decoration.widget(pos, (_, getPosUnsafe) => {
const getPos = () => {
try {
return getPosUnsafe();
} catch (e) {
return undefined;
}
};
const element = document.createElement('div');
element.setAttribute('data-blocks-drop-target-container', 'true');
element.setAttribute('data-blocks-drop-target-key', key);
element.style.clear = 'unset';
const {
gap,
offset
} = getGapAndOffset(props.prevNode, props.nextNode, props.parentNode);
element.style.setProperty(EDITOR_BLOCK_CONTROLS_DROP_INDICATOR_OFFSET, `${offset}px`);
element.style.setProperty(EDITOR_BLOCK_CONTROLS_DROP_INDICATOR_GAP, `${gap}px`);
element.style.setProperty('display', 'block');
nodeViewPortalProviderAPI.render(() => /*#__PURE__*/createElement(DropTarget, {
...props,
getPos,
anchorRectCache,
isSameLayout
}), element, key, undefined,
// @portal-render-immediately
true);
return element;
}, {
type: TYPE_DROP_TARGET_DEC,
side,
destroy: () => {
nodeViewPortalProviderAPI.remove(key);
}
});
};
export const createLayoutDropTargetDecoration = (pos, props, nodeViewPortalProviderAPI, anchorRectCache) => {
// eslint-disable-next-line @atlaskit/platform/prefer-crypto-random-uuid -- Use crypto.randomUUID instead
const key = uuid();
return Decoration.widget(pos, (_, getPosUnsafe) => {
const getPos = () => {
try {
return getPosUnsafe();
} catch (e) {
return undefined;
}
};
const element = document.createElement('div');
element.setAttribute('data-blocks-drop-target-container', 'true');
element.setAttribute('data-blocks-drop-target-key', key);
element.style.clear = 'unset';
const DropTargetLayoutComponent = expValEquals('platform_editor_native_anchor_with_dnd', 'isEnabled', true) ? DropTargetLayoutNativeAnchorSupport : DropTargetLayout;
nodeViewPortalProviderAPI.render(() => /*#__PURE__*/createElement(DropTargetLayoutComponent, {
...props,
getPos,
anchorRectCache
}), element, key, undefined,
// @portal-render-immediately
true);
return element;
}, {
type: TYPE_DROP_TARGET_DEC,
destroy: () => {
nodeViewPortalProviderAPI.remove(key);
}
});
};
export const dropTargetDecorations = (newState, api, formatMessage, nodeViewPortalProviderAPI, activeNode, anchorRectCache, from, to) => {
const decs = [];
const POS_END_OF_DOC = newState.doc.nodeSize - 2;
const docFrom = from === undefined || from < 0 ? 0 : from;
const docTo = to === undefined || to > POS_END_OF_DOC ? POS_END_OF_DOC : to;
const activeNodePos = activeNode === null || activeNode === void 0 ? void 0 : activeNode.pos;
const $activeNodePos = typeof activeNodePos === 'number' && newState.doc.resolve(activeNodePos);
const activePMNode = $activeNodePos && $activeNodePos.nodeAfter;
const isMultiSelect = editorExperiment('platform_editor_element_drag_and_drop_multiselect', true);
anchorRectCache === null || anchorRectCache === void 0 ? void 0 : anchorRectCache.clear();
const prevNodeStack = [];
const popNodeStack = depth => {
let result;
const toDepth = Math.max(depth, 0);
while (prevNodeStack.length > toDepth) {
result = prevNodeStack.pop();
}
return result;
};
const pushNodeStack = (node, depth) => {
popNodeStack(depth);
prevNodeStack.push(node);
};
const isAdvancedLayoutsPreRelease2 = editorExperiment('advanced_layouts', true);
// For deciding to show drop targets or not when multiple nodes are selected
const selection = newState.selection;
const {
$anchor: expandedAnchor,
$head: expandedHead
} = expandSelectionBounds(selection.$anchor, selection.$head);
const selectionFrom = Math.min(expandedAnchor.pos, expandedHead.pos);
const selectionTo = Math.max(expandedAnchor.pos, expandedHead.pos);
const handleInsideSelection = activeNodePos !== undefined && activeNodePos >= selectionFrom && activeNodePos <= selectionTo;
newState.doc.nodesBetween(docFrom, docTo, (node, pos, parent, index) => {
let depth = 0;
// drop target deco at the end position
let endPos;
const $pos = newState.doc.resolve(pos);
const isSameLayout = $activeNodePos && isInSameLayout($activeNodePos, $pos);
depth = $pos.depth;
if (isAdvancedLayoutsPreRelease2) {
if ((activeNode === null || activeNode === void 0 ? void 0 : activeNode.pos) === pos && activeNode.nodeType !== 'layoutColumn') {
return false;
}
if (node.type.name === 'layoutColumn' && (parent === null || parent === void 0 ? void 0 : parent.type.name) === 'layoutSection' && index !== 0 && (
// Not the first node
(parent === null || parent === void 0 ? void 0 : parent.childCount) < maxLayoutColumnSupported() || isSameLayout)) {
// Add drop target for layout columns
decs.push(createLayoutDropTargetDecoration(pos, {
api,
parent,
formatMessage
}, nodeViewPortalProviderAPI, anchorRectCache));
}
}
if (node.isInline || !parent || DISABLE_CHILD_DROP_TARGET.includes(parent.type.name)) {
pushNodeStack(node, depth);
return false;
}
if (IGNORE_NODES.includes(node.type.name)) {
pushNodeStack(node, depth);
return shouldDescend(node); //skip over, don't consider it a valid depth
}
// When multi select is on, validate all the nodes in the selection instead of just the handle node
if (isMultiSelect) {
const selectionSlice = newState.doc.slice(selectionFrom, selectionTo, false);
const selectionSliceChildCount = selectionSlice.content.childCount;
let canDropSingleNode = true;
let canDropMultipleNodes = true;
// when there is only one node in the slice, use the same logic as when multi select is not on
if (selectionSliceChildCount > 1 && handleInsideSelection) {
canDropMultipleNodes = canMoveSliceToIndex(selectionSlice, selectionFrom, selectionTo, parent, index, $pos);
} else {
canDropSingleNode = !!(activePMNode && canMoveNodeToIndex(parent, index, activePMNode, $pos, node));
}
if (!canDropMultipleNodes || !canDropSingleNode) {
pushNodeStack(node, depth);
return false; //not valid pos, so nested not valid either
}
} else {
const canDrop = activePMNode && canMoveNodeToIndex(parent, index, activePMNode, $pos, node);
//NOTE: This will block drop targets showing for nodes that are valid after transformation (i.e. expand -> nestedExpand)
if (!canDrop) {
pushNodeStack(node, depth);
return false; //not valid pos, so nested not valid either
}
}
const parentTypesWithEndDropTarget = editorExperiment('platform_synced_block', true) ? PARENT_WITH_END_DROP_TARGET_NEXT : PARENT_WITH_END_DROP_TARGET;
if (parent.lastChild === node && !isEmptyParagraph(node) && parentTypesWithEndDropTarget.includes(parent.type.name)) {
endPos = pos + node.nodeSize;
}
const previousNode = popNodeStack(depth); // created scoped variable
// only table and layout need to render full height drop target
const isInSupportedContainer = ['tableCell', 'tableHeader', 'layoutColumn'].includes((parent === null || parent === void 0 ? void 0 : parent.type.name) || '');
const shouldShowFullHeight = isInSupportedContainer && (parent === null || parent === void 0 ? void 0 : parent.lastChild) === node && isEmptyParagraph(node);
decs.push(createDropTargetDecoration(pos, {
api,
prevNode: previousNode,
nextNode: node,
parentNode: parent || undefined,
formatMessage,
dropTargetStyle: shouldShowFullHeight ? 'remainingHeight' : 'default'
}, nodeViewPortalProviderAPI, -1, anchorRectCache, isSameLayout));
if (endPos !== undefined) {
decs.push(createDropTargetDecoration(endPos, {
api,
prevNode: node,
parentNode: parent || undefined,
formatMessage,
dropTargetStyle: 'remainingHeight'
}, nodeViewPortalProviderAPI, -1, anchorRectCache));
}
pushNodeStack(node, depth);
return depth < NESTED_DEPTH && shouldDescend(node);
});
if (docTo === POS_END_OF_DOC) {
decs.push(createDropTargetDecoration(POS_END_OF_DOC, {
api,
formatMessage,
prevNode: newState.doc.lastChild || undefined,
parentNode: newState.doc
}, nodeViewPortalProviderAPI, undefined, anchorRectCache));
}
return decs;
};