UNPKG

@atlaskit/editor-plugin-block-controls

Version:

Block controls plugin for @atlaskit/editor-core

266 lines (255 loc) 18.2 kB
import memoizeOne from 'memoize-one'; import { isMultiBlockSelection } from '@atlaskit/editor-common/selection'; import { areToolbarFlagsEnabled } from '@atlaskit/editor-common/toolbar-flag-check'; import { fg } from '@atlaskit/platform-feature-flags'; import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals'; import { editorExperiment } from '@atlaskit/tmp-editor-statsig/experiments'; import { getAnchorAttrName, getTypeNameAttrName, getTypeNameFromDom, NODE_ANCHOR_ATTR_NAME } from '../ui/utils/dom-attr-name'; import { IGNORE_NODE_DESCENDANTS_ADVANCED_LAYOUT, IGNORE_NODES_NEXT } from './decorations-anchor'; import { selectionPreservationPluginKey } from './selection-preservation/plugin-key'; const isEmptyNestedParagraphOrHeading = target => { if (target instanceof HTMLHeadingElement || target instanceof HTMLParagraphElement) { var _target$parentElement; return !((_target$parentElement = target.parentElement) !== null && _target$parentElement !== void 0 && _target$parentElement.classList.contains('ProseMirror')) && target.textContent === ''; } return false; }; const getNodeSelector = (ignoreNodes, ignoreNodeDescendants) => { const baseSelector = `[${NODE_ANCHOR_ATTR_NAME}]`; if (ignoreNodes.length === 0 && ignoreNodeDescendants.length === 0) { return baseSelector; } const ignoreNodeSelectorList = ignoreNodes.map(node => `[data-prosemirror-node-name="${node}"]`); const ignoreNodeDescendantsSelectorList = ignoreNodeDescendants.map(node => { if (node === 'table') { // Special case for table to exclude its direct descendants return [`[data-prosemirror-node-name="tableCell"] > [data-node-anchor]`, `[data-prosemirror-node-name="tableCell"] > *:not([data-node-anchor]) > [data-node-anchor]`, `[data-prosemirror-node-name="tableHeader"] > [data-node-anchor]`, `[data-prosemirror-node-name="tableHeader"] > *:not([data-node-anchor]) > [data-node-anchor]`]; } return `[data-prosemirror-node-name="${node}"] [data-node-anchor]`; }); const ignoreSelector = [...ignoreNodeSelectorList, ...ignoreNodeDescendantsSelectorList.flat(), '[data-prosemirror-node-inline="true"]'].join(', '); return `${baseSelector}:not(${ignoreSelector})`; }; const getDefaultNodeSelector = memoizeOne(() => { // we don't show handler for node nested in table return getNodeSelector([...IGNORE_NODES_NEXT, 'media'], [...IGNORE_NODE_DESCENDANTS_ADVANCED_LAYOUT, 'table']); }); // Block marks (e.g. font-size) wrap paragraphs in an extra div, making parentRootElement // the mark wrapper instead of the panel content container. When that happens, recalculate // the index relative to the panel content so the first-child check stays accurate. const getBlockMarkPanelIndexAdjustment = (parentRootElement, index) => { if (expValEquals('platform_editor_small_font_size', 'isEnabled', true) && parentRootElement.classList.contains('fabric-editor-block-mark') && parentRootElement.parentElement) { return Array.from(parentRootElement.parentElement.childNodes).indexOf(parentRootElement); } return index; }; export const handleMouseOver = (view, event, api) => { var _api$blockControls, _api$editorDisabled, _api$editorViewMode, _api$editorViewMode$s, _api$blockControls$sh, _api$blockControls2, _api$blockControls2$s, _target$classList; const { isDragging, activeNode, isMenuOpen, menuTriggerBy: originalAnchorName, blockMenuOptions } = (api === null || api === void 0 ? void 0 : (_api$blockControls = api.blockControls) === null || _api$blockControls === void 0 ? void 0 : _api$blockControls.sharedState.currentState()) || {}; const { editorDisabled } = (api === null || api === void 0 ? void 0 : (_api$editorDisabled = api.editorDisabled) === null || _api$editorDisabled === void 0 ? void 0 : _api$editorDisabled.sharedState.currentState()) || { editorDisabled: false }; const editorViewMode = api === null || api === void 0 ? void 0 : (_api$editorViewMode = api.editorViewMode) === null || _api$editorViewMode === void 0 ? void 0 : (_api$editorViewMode$s = _api$editorViewMode.sharedState.currentState()) === null || _api$editorViewMode$s === void 0 ? void 0 : _api$editorViewMode$s.mode; const isViewMode = editorViewMode === 'view'; const toolbarFlagsEnabled = areToolbarFlagsEnabled(Boolean(api === null || api === void 0 ? void 0 : api.toolbar)); // We shouldn't be firing mouse over transactions when the editor is disabled, // except in view mode when right-side controls are enabled (show controls on block hover) const rightSideControlsEnabled = (_api$blockControls$sh = api === null || api === void 0 ? void 0 : (_api$blockControls2 = api.blockControls) === null || _api$blockControls2 === void 0 ? void 0 : (_api$blockControls2$s = _api$blockControls2.sharedState.currentState()) === null || _api$blockControls2$s === void 0 ? void 0 : _api$blockControls2$s.rightSideControlsEnabled) !== null && _api$blockControls$sh !== void 0 ? _api$blockControls$sh : false; if (editorDisabled && (!isViewMode || !(rightSideControlsEnabled && fg('confluence_remix_button_right_side_block_fg')))) { return false; } // If the editor view is not in focus when the block menu is open, do not update the drag handle if (!view.hasFocus() && isMenuOpen && editorExperiment('platform_editor_block_menu', true, { exposure: true })) { return false; } // Most mouseover events don't fire during drag but some can slip through // when the drag begins. This prevents those. if (isDragging) { return false; } // Ignored via go/ees005 // eslint-disable-next-line @atlaskit/editor/no-as-casting const target = event.target; const isNativeAnchorSupported = expValEquals('platform_editor_native_anchor_with_dnd', 'isEnabled', true); if (target !== null && target !== void 0 && (_target$classList = target.classList) !== null && _target$classList !== void 0 && _target$classList.contains('ProseMirror')) { return false; } let rootElement = target === null || target === void 0 ? void 0 : target.closest(isNativeAnchorSupported ? getDefaultNodeSelector() : `[data-drag-handler-anchor-name]`); // When hovering over the right-edge button (rendered in a portal outside the block), resolve the // block from the container's anchor so activeNode stays set and the button remains visible. if (!rootElement && rightSideControlsEnabled && fg('confluence_remix_button_right_side_block_fg')) { const rightEdgeContainer = target === null || target === void 0 ? void 0 : target.closest('[data-blocks-right-edge-button-container]'); if (rightEdgeContainer) { const anchor = rightEdgeContainer.getAttribute('data-blocks-right-edge-button-anchor'); if (anchor) { rootElement = view.dom.querySelector(`[${getAnchorAttrName()}="${CSS.escape(anchor)}"]`); } } } if (rootElement) { var _rootElement$parentEl; // We want to exlude handles from showing for empty paragraph and heading nodes if (isEmptyNestedParagraphOrHeading(rootElement)) { return false; } if (rootElement.getAttribute(getTypeNameAttrName()) === 'media' && editorExperiment('advanced_layouts', true) && !isNativeAnchorSupported) { rootElement = rootElement.closest(`[${getAnchorAttrName()}][${getTypeNameAttrName()}="mediaSingle"]`); if (!rootElement) { return false; } } const parentElement = (_rootElement$parentEl = rootElement.parentElement) === null || _rootElement$parentEl === void 0 ? void 0 : _rootElement$parentEl.closest(`[${getAnchorAttrName()}]`); const parentElementType = isNativeAnchorSupported ? getTypeNameFromDom(parentElement) : parentElement === null || parentElement === void 0 ? void 0 : parentElement.getAttribute('data-drag-handler-node-type'); if (editorExperiment('advanced_layouts', true)) { // We want to exclude handles from showing for direct descendant of table nodes (i.e. nodes in cells) if (parentElement && parentElementType === 'table') { rootElement = parentElement; } else if (parentElement && parentElementType === 'tableRow') { var _parentElement$parent; const grandparentElement = parentElement === null || parentElement === void 0 ? void 0 : (_parentElement$parent = parentElement.parentElement) === null || _parentElement$parent === void 0 ? void 0 : _parentElement$parent.closest(`[${getAnchorAttrName()}]`); const grandparentElementType = isNativeAnchorSupported ? getTypeNameFromDom(grandparentElement) : grandparentElement === null || grandparentElement === void 0 ? void 0 : grandparentElement.getAttribute('data-drag-handler-node-type'); if (grandparentElement && grandparentElementType === 'table') { rootElement = grandparentElement; } } } else { // We want to exclude handles from showing for direct descendant of table nodes (i.e. nodes in cells) if (parentElement && parentElementType === 'table') { rootElement = parentElement; } } // When platform_editor_fix_selection_wrapped_media_embed is enabled, a wrapped mediaSingle/embedCard and the // paragraph(s) it floats next to both have anchor decorations. If the hovered rootElement // is a paragraph whose adjacent DOM sibling is a wrapped mediaSingle/embedCard with an // anchor, redirect the handle to the media node so only one handle shows (ED-26959). // We check previousElementSibling (wrap-left: media precedes text in DOM) and // nextElementSibling (wrap-right: media follows text in DOM). // This must happen before anchorName is read so the correct node's handle is shown. if (editorExperiment('platform_editor_fix_selection_wrapped_media_embed', true, { exposure: true })) { const rootNodeType = isNativeAnchorSupported ? getTypeNameFromDom(rootElement) : rootElement.getAttribute('data-drag-handler-node-type'); if (rootNodeType === 'paragraph') { const isWrappedMediaEmbedSibling = sibling => { if (!sibling || !sibling.getAttribute(getAnchorAttrName())) { return false; } const siblingType = isNativeAnchorSupported ? getTypeNameFromDom(sibling) : sibling.getAttribute('data-drag-handler-node-type'); const siblingLayout = sibling.getAttribute('layout') || ''; return (siblingType === 'mediaSingle' || siblingType === 'embedCard') && ['wrap-left', 'wrap-right'].includes(siblingLayout); }; const prevSibling = rootElement.previousElementSibling; const nextSibling = rootElement.nextElementSibling; if (prevSibling && isWrappedMediaEmbedSibling(prevSibling)) { rootElement = prevSibling; } else if (nextSibling && isWrappedMediaEmbedSibling(nextSibling)) { rootElement = nextSibling; } } } let anchorName; if (expValEquals('platform_editor_native_anchor_with_dnd', 'isEnabled', true)) { anchorName = rootElement.getAttribute(getAnchorAttrName()); // don't show handles if we can't find an anchor if (!anchorName) { return false; } } else { // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion anchorName = rootElement.getAttribute(getAnchorAttrName()); } // No need to update handle position if its already there if ((activeNode === null || activeNode === void 0 ? void 0 : activeNode.anchorName) === anchorName) { return false; } if (!editorExperiment('platform_editor_fix_selection_wrapped_media_embed', true, { exposure: true })) { // We want to exclude handles from showing for wrapped nodes // TODO: ED-26959 - We should be able remove these check if we decided to // go we not decoration for wrapped image solution. if (['wrap-right', 'wrap-left'].includes(rootElement.getAttribute('layout') || '')) { return false; } } const parentRootElement = rootElement.parentElement; let pos; if (parentRootElement) { var _parentRootElement$ch; const childNodes = Array.from(parentRootElement.childNodes); const index = childNodes.indexOf(rootElement); pos = view.posAtDOM(parentRootElement, index); const panelIndex = getBlockMarkPanelIndexAdjustment(parentRootElement, index); // We want to exlude handles showing for first element in a Panel, ignoring widgets like gapcursor const firstChildIsWidget = parentRootElement === null || parentRootElement === void 0 ? void 0 : (_parentRootElement$ch = parentRootElement.children[0]) === null || _parentRootElement$ch === void 0 ? void 0 : _parentRootElement$ch.classList.contains('ProseMirror-widget'); if (parentElement && parentElementType === 'panel' && !parentElement.classList.contains('ak-editor-panel__no-icon') && (panelIndex === 0 || firstChildIsWidget && panelIndex === 1)) { return false; } } else { pos = view.posAtDOM(rootElement, 0); } if (parentRootElement && parentRootElement.getAttribute('data-layout-section') === 'true' && parentRootElement.querySelectorAll('[data-layout-column]').length === 1 && editorExperiment('advanced_layouts', true)) { // Don't show drag handle for layout column in a single column layout return false; } const targetPos = view.state.doc.resolve(pos).pos; let rootAnchorName; let rootNodeType; let rootPos; // platform_editor_controls note: enables quick insert if (toolbarFlagsEnabled) { rootPos = view.state.doc.resolve(pos).before(1); if (targetPos !== rootPos) { const rootDOM = view.nodeDOM(rootPos); if (rootDOM instanceof HTMLElement) { var _rootDOM$getAttribute; rootAnchorName = (_rootDOM$getAttribute = rootDOM.getAttribute(getAnchorAttrName())) !== null && _rootDOM$getAttribute !== void 0 ? _rootDOM$getAttribute : undefined; rootNodeType = isNativeAnchorSupported ? getTypeNameFromDom(rootDOM) : rootDOM.getAttribute('data-drag-handler-node-type'); } } } const nodeType = isNativeAnchorSupported ? getTypeNameFromDom(rootElement) : rootElement.getAttribute('data-drag-handler-node-type'); if (nodeType) { // platform_editor_controls note: enables quick insert if (toolbarFlagsEnabled) { if (editorExperiment('platform_editor_block_menu', true)) { var _selectionPreservatio; const preservedSelection = (_selectionPreservatio = selectionPreservationPluginKey.getState(view.state)) === null || _selectionPreservatio === void 0 ? void 0 : _selectionPreservatio.preservedSelection; const selection = preservedSelection || view.state.selection; const isMultipleSelected = selection && isMultiBlockSelection(selection); // Only execute when selection is not a multi-selection, block menu is open, and menu is opened via keyboard // as when it is a multi-selection, the showDragHandleAt command interfere with selection // sometimes makes the multi-selection not continous after block menu is opened with keyboard if (!(isMultipleSelected && isMenuOpen && blockMenuOptions !== null && blockMenuOptions !== void 0 && blockMenuOptions.openedViaKeyboard)) { var _api$core, _api$blockControls3, _rootPos, _rootAnchorName, _rootNodeType; api === null || api === void 0 ? void 0 : (_api$core = api.core) === null || _api$core === void 0 ? void 0 : _api$core.actions.execute(api === null || api === void 0 ? void 0 : (_api$blockControls3 = api.blockControls) === null || _api$blockControls3 === void 0 ? void 0 : _api$blockControls3.commands.showDragHandleAt(targetPos, anchorName, nodeType, undefined, (_rootPos = rootPos) !== null && _rootPos !== void 0 ? _rootPos : targetPos, (_rootAnchorName = rootAnchorName) !== null && _rootAnchorName !== void 0 ? _rootAnchorName : anchorName, (_rootNodeType = rootNodeType) !== null && _rootNodeType !== void 0 ? _rootNodeType : nodeType)); } } else { var _api$core2, _api$blockControls4, _rootPos2, _rootAnchorName2, _rootNodeType2; api === null || api === void 0 ? void 0 : (_api$core2 = api.core) === null || _api$core2 === void 0 ? void 0 : _api$core2.actions.execute(api === null || api === void 0 ? void 0 : (_api$blockControls4 = api.blockControls) === null || _api$blockControls4 === void 0 ? void 0 : _api$blockControls4.commands.showDragHandleAt(targetPos, anchorName, nodeType, undefined, (_rootPos2 = rootPos) !== null && _rootPos2 !== void 0 ? _rootPos2 : targetPos, (_rootAnchorName2 = rootAnchorName) !== null && _rootAnchorName2 !== void 0 ? _rootAnchorName2 : anchorName, (_rootNodeType2 = rootNodeType) !== null && _rootNodeType2 !== void 0 ? _rootNodeType2 : nodeType)); } } else { var _api$core3, _api$blockControls5; 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$blockControls5 = api.blockControls) === null || _api$blockControls5 === void 0 ? void 0 : _api$blockControls5.commands.showDragHandleAt(targetPos, anchorName, nodeType)); } if (editorExperiment('platform_editor_block_menu', true)) { var _api$userIntent, _api$userIntent$share; if (isMenuOpen && originalAnchorName && (api === null || api === void 0 ? void 0 : (_api$userIntent = api.userIntent) === null || _api$userIntent === void 0 ? void 0 : (_api$userIntent$share = _api$userIntent.sharedState.currentState()) === null || _api$userIntent$share === void 0 ? void 0 : _api$userIntent$share.currentUserIntent) === 'blockMenuOpen') { var _api$core4, _api$blockControls6; api === null || api === void 0 ? void 0 : (_api$core4 = api.core) === null || _api$core4 === void 0 ? void 0 : _api$core4.actions.execute(api === null || api === void 0 ? void 0 : (_api$blockControls6 = api.blockControls) === null || _api$blockControls6 === void 0 ? void 0 : _api$blockControls6.commands.toggleBlockMenu()); } } } } };