UNPKG

@atlaskit/editor-plugin-block-controls

Version:

Block controls plugin for @atlaskit/editor-core

285 lines (279 loc) 13 kB
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; };