@atlaskit/editor-plugin-block-controls
Version:
Block controls plugin for @atlaskit/editor-core
266 lines (255 loc) • 18.2 kB
JavaScript
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());
}
}
}
}
};