@atlaskit/editor-plugin-block-controls
Version:
Block controls plugin for @atlaskit/editor-core
184 lines (171 loc) • 7.57 kB
JavaScript
import { editorExperiment } from '@atlaskit/tmp-editor-statsig/experiments';
import { handleMouseOver } from '../handle-mouse-over';
import { clearHoverSide, mouseEnter, mouseLeave, setHoverSide, stopEditing } from './commands';
import { RIGHT_MARGIN_ROVO_GAP_PX } from './constants';
import { getInteractionTrackingState } from './pm-plugin';
/** Per-view pending hover state; avoids cross-editor singleton. */
const pendingByView = new WeakMap();
/** Per-view RAF handle so clearPendingHoverSide cancels only that view's callback. */
const rafIdByView = new WeakMap();
const cancelScheduledProcessForView = view => {
const id = rafIdByView.get(view);
if (id !== undefined) {
cancelAnimationFrame(id);
rafIdByView.delete(view);
}
};
const clearPendingHoverSide = view => {
pendingByView.delete(view);
cancelScheduledProcessForView(view);
};
const BLOCK_SELECTORS = '[data-node-anchor], [data-drag-handler-anchor-name]';
const RIGHT_EDGE_SELECTOR = '[data-blocks-right-edge-button-container]';
// Top-level blocks (no block ancestor), matched by anchor attribute so it works under both the
// legacy and native-anchor schemes.
const getRootBlocks = view => Array.from(view.dom.querySelectorAll(BLOCK_SELECTORS)).filter(el => {
var _el$parentElement;
return !((_el$parentElement = el.parentElement) !== null && _el$parentElement !== void 0 && _el$parentElement.closest(BLOCK_SELECTORS));
});
// Find the root block whose vertical bounds contain clientY. Returns the measured rect so callers
// can reuse it without re-measuring.
const findBlockAtY = (view, clientY) => {
for (const el of getRootBlocks(view)) {
const rect = el.getBoundingClientRect();
if (clientY >= rect.top && clientY <= rect.bottom) {
return {
block: el,
rect
};
}
}
return null;
};
const getRightMarginBoundary = view => {
var _view$dom$ownerDocume, _view$dom$ownerDocume2;
return ((_view$dom$ownerDocume = (_view$dom$ownerDocume2 = view.dom.ownerDocument.defaultView) === null || _view$dom$ownerDocume2 === void 0 ? void 0 : _view$dom$ownerDocume2.innerWidth) !== null && _view$dom$ownerDocume !== void 0 ? _view$dom$ownerDocume : Number.POSITIVE_INFINITY) - RIGHT_MARGIN_ROVO_GAP_PX;
};
// not in the right margin
// Classify where the cursor sits in the right margin beside the block at its height, running the
// block lookup once so the caller doesn't traverse the DOM twice per mousemove.
const classifyRightMarginPosition = (view, event) => {
const found = findBlockAtY(view, event.clientY);
if (!found) {
return null;
}
if (event.clientX <= found.rect.right) {
return null;
}
return event.clientX > getRightMarginBoundary(view) ? {
type: 'gap'
} : {
type: 'active',
block: found.block
};
};
/**
* Process hover position and set left/right side. Only invoked when right-side controls are
* enabled (confluence_remix_button_right_side_block_fg); handleMouseMove returns early otherwise.
*/
const processHoverSide = (view, api) => {
const event = pendingByView.get(view);
if (!event) {
return;
}
pendingByView.delete(view);
rafIdByView.delete(view);
const editorContentArea = view.dom.closest('.ak-editor-content-area');
if (!(editorContentArea instanceof HTMLElement)) {
return;
}
const state = getInteractionTrackingState(view.state);
const target = event.target instanceof HTMLElement ? event.target : null;
// When hovering over block controls directly, infer side from which control we're over.
// This is more reliable than bounds when controls are in portals outside the editor DOM.
const rightEdgeElement = target === null || target === void 0 ? void 0 : target.closest(RIGHT_EDGE_SELECTOR);
if (rightEdgeElement) {
if ((state === null || state === void 0 ? void 0 : state.hoverSide) !== 'right') {
setHoverSide(view, 'right');
}
return;
}
const leftControlElement = target === null || target === void 0 ? void 0 : target.closest('[data-blocks-drag-handle-container], [data-testid="block-ctrl-drag-handle"], [data-testid="block-ctrl-drag-handle-container"], [data-testid="block-ctrl-decorator-widget"], [data-testid="block-ctrl-quick-insert-button"]');
if (leftControlElement) {
if ((state === null || state === void 0 ? void 0 : state.hoverSide) !== 'left') {
setHoverSide(view, 'left');
}
return;
}
// Added right-margin hover, gated so it can be rolled back. When off, fall through to midpoint.
if (editorExperiment('remix_button_right_margin_hover', true)) {
const closestBlock = target === null || target === void 0 ? void 0 : target.closest(BLOCK_SELECTORS);
const blockElement = closestBlock instanceof HTMLElement ? closestBlock : null;
// Not over a block: the cursor may be in the right margin beside content.
const marginZone = blockElement ? null : classifyRightMarginPosition(view, event);
if ((marginZone === null || marginZone === void 0 ? void 0 : marginZone.type) === 'active' && api) {
// handleMouseOver only reads event.target, so a target-only stand-in is enough here.
handleMouseOver(view, {
target: marginZone.block
}, api);
// mouseenter doesn't fire over the click overlay, so clear isMouseOut here to re-show.
if (state !== null && state !== void 0 && state.isMouseOut) {
mouseEnter(view);
}
if ((state === null || state === void 0 ? void 0 : state.hoverSide) !== 'right') {
setHoverSide(view, 'right');
}
return;
}
// In the Rovo gap, dismiss the controls so the button doesn't linger over the Rovo button.
if ((marginZone === null || marginZone === void 0 ? void 0 : marginZone.type) === 'gap' || event.clientX > getRightMarginBoundary(view)) {
if (!(state !== null && state !== void 0 && state.isMouseOut)) {
clearHoverSide(view);
mouseLeave(view);
}
return;
}
// Over a block: fall through to the midpoint split so the left half keeps the drag handle and
// the right half shows the Remix button, matching the experiment-off behaviour.
}
// Pick the side from the content midpoint, keeping the original left/right halves.
const {
left,
right
} = editorContentArea.getBoundingClientRect();
const midpoint = (left + right) / 2;
const nextHoverSide = event.clientX > midpoint ? 'right' : 'left';
if ((state === null || state === void 0 ? void 0 : state.hoverSide) !== nextHoverSide) {
setHoverSide(view, nextHoverSide);
}
};
export const handleMouseMove = (view, event, rightSideControlsEnabled = false, api) => {
const state = getInteractionTrackingState(view.state);
// if user has stopped editing and moved their mouse, show block controls again
if (state !== null && state !== void 0 && state.isEditing) {
stopEditing(view);
}
// Only track hover side when right-side controls are enabled (single source: confluence_remix_button_right_side_block_fg via config)
if (!rightSideControlsEnabled) {
return false;
}
if (!(event instanceof MouseEvent)) {
return false;
}
pendingByView.set(view, event);
cancelScheduledProcessForView(view);
const id = requestAnimationFrame(() => {
processHoverSide(view, api);
});
rafIdByView.set(view, id);
return false;
};
export const handleMouseLeave = (view, rightSideControlsEnabled = false) => {
if (rightSideControlsEnabled) {
clearPendingHoverSide(view);
}
mouseLeave(view);
return false;
};
export const handleMouseEnter = view => {
mouseEnter(view);
return false;
};