@atlaskit/editor-plugin-block-controls
Version:
Block controls plugin for @atlaskit/editor-core
326 lines (315 loc) • 14.8 kB
JavaScript
import { bind } from 'bind-event-listener';
import { DRAG_HANDLE_WIDTH } from '@atlaskit/editor-common/styles/drag-handle';
import { akEditorFullPageNarrowBreakout, akEditorGutterPaddingDynamic, akEditorGutterPaddingReduced } from '@atlaskit/editor-shared-styles/constants';
import { isExperimentEnabled } from '@atlaskit/platform-feature-experiments/is-experiment-enabled';
import { expValEqualsNoExposure } from '@atlaskit/tmp-editor-statsig/exp-val-equals-no-exposure';
import { BLOCK_CONTROLS_SURFACE_SELECTOR, DRAG_HANDLE_MAX_WIDTH_PLUS_GAP } from '../ui/consts';
import { handleSparseHoverTarget, isSparseHoverSuppressed } from './handle-sparse-hover-target';
import { mouseEnter, setHoverSide, stopEditing } from './interaction-tracking/commands';
import { handleMouseLeave } from './interaction-tracking/handle-mouse-move';
import { getInteractionTrackingState } from './interaction-tracking/pm-plugin';
import { getVisibleBox } from './utils/visible-box';
const BLOCK_SELECTOR = '[data-prosemirror-node-block="true"]';
const TABLE_STRUCTURE_NODES = new Set(['table', 'tableRow', 'tableCell', 'tableHeader']);
const LAYOUT_SECTION_SELECTOR = '[data-prosemirror-node-name="layoutSection"]';
const LAYOUT_COLUMN_SELECTOR = '[data-prosemirror-node-name="layoutColumn"]';
// `layoutColumnExtendedHoverZone` is a DRAG_HANDLE_WIDTH-tall strip centred on the column's top edge.
const LAYOUT_COLUMN_STRIP_REACH = DRAG_HANDLE_WIDTH / 2;
const isBlockElement = element => element.getAttribute('data-prosemirror-node-block') === 'true';
const getVisibleLeft = block => {
var _block$getAttribute;
return getVisibleBox(block, (_block$getAttribute = block.getAttribute('data-prosemirror-node-name')) !== null && _block$getAttribute !== void 0 ? _block$getAttribute : '').getBoundingClientRect().left;
};
/**
* Whether the element under the pointer can be part of a block's left gutter: a container block's
* own padding (panel, layout, list…) or an editor wrapper around the content. Text and leaf blocks
* have no gutter inside them. Table and cell padding shows the table's controls, but containers
* nested inside cells can own gutters for their children, as in the legacy hover zones. Elements
* rendered beside the editor (title, popups, toolbars) cover any gutter beneath them.
*/
export const isPossibleGutterElement = (view, hoveredElement) => {
if (!(hoveredElement instanceof Element)) {
return false;
}
const owner = hoveredElement.closest(`${BLOCK_SELECTOR}, ${BLOCK_CONTROLS_SURFACE_SELECTOR}`);
if (owner !== null && owner !== void 0 && owner.matches(BLOCK_CONTROLS_SURFACE_SELECTOR)) {
return false;
}
if (owner && view.dom.contains(owner)) {
var _owner$getAttribute, _owner$getAttribute2;
if (TABLE_STRUCTURE_NODES.has((_owner$getAttribute = owner.getAttribute('data-prosemirror-node-name')) !== null && _owner$getAttribute !== void 0 ? _owner$getAttribute : '')) {
return false;
}
const nodeType = view.state.schema.nodes[(_owner$getAttribute2 = owner.getAttribute('data-prosemirror-node-name')) !== null && _owner$getAttribute2 !== void 0 ? _owner$getAttribute2 : ''];
return !(nodeType !== null && nodeType !== void 0 && nodeType.isTextblock) && !(nodeType !== null && nodeType !== void 0 && nodeType.isLeaf);
}
return view.dom.contains(hoveredElement) || hoveredElement.contains(view.dom);
};
const getGutterWidths = () => ({
topLevel: akEditorGutterPaddingDynamic(),
narrowTopLevel: expValEqualsNoExposure('platform_editor_preview_panel_responsiveness', 'isEnabled', true) ? akEditorGutterPaddingReduced : undefined
});
const getTopLevelGutterWidth = (api, widths) => {
var _api$width, _api$width$sharedStat;
const editorWidth = api === null || api === void 0 ? void 0 : (_api$width = api.width) === null || _api$width === void 0 ? void 0 : (_api$width$sharedStat = _api$width.sharedState.currentState()) === null || _api$width$sharedStat === void 0 ? void 0 : _api$width$sharedStat.width;
return widths.narrowTopLevel !== undefined && editorWidth !== undefined && editorWidth <= akEditorFullPageNarrowBreakout ? widths.narrowTopLevel : widths.topLevel;
};
const getEditorElementAt = (view, clientX, clientY) => {
const element = view.dom.ownerDocument.elementFromPoint(clientX, clientY);
return element && element !== view.dom && view.dom.contains(element) ? element : null;
};
/** The outermost block around `element` that does not contain the pointer owns that gutter. */
const findGutterOwner = (view, element, pointerTarget) => {
let owner = null;
for (let current = element; current && current !== view.dom;) {
if (isBlockElement(current) && !current.contains(pointerTarget) &&
// Layout columns have a top hover strip instead of a left gutter.
current.getAttribute('data-prosemirror-node-name') !== 'layoutColumn') {
owner = current;
}
current = current.parentElement;
}
return owner;
};
const findBlockBesidePointer = (view, api, event, pointerTarget, widths) => {
const pointerBlock = pointerTarget.closest(BLOCK_SELECTOR);
const gutterWidth = pointerBlock && view.dom.contains(pointerBlock) ? DRAG_HANDLE_MAX_WIDTH_PLUS_GAP : getTopLevelGutterWidth(api, widths);
const sample = getEditorElementAt(view, event.clientX + gutterWidth, event.clientY);
const owner = sample && findGutterOwner(view, sample, pointerTarget);
if (!owner) {
return null;
}
const left = getVisibleLeft(owner);
return event.clientX < left && left - event.clientX <= gutterWidth ? owner : null;
};
const findColumnBelowPointer = (view, event, pointerTarget) => {
if (pointerTarget.closest(LAYOUT_COLUMN_SELECTOR) || pointerTarget !== view.dom && !pointerTarget.closest(LAYOUT_SECTION_SELECTOR)) {
return null;
}
const sample = getEditorElementAt(view, event.clientX, event.clientY + LAYOUT_COLUMN_STRIP_REACH);
const column = sample === null || sample === void 0 ? void 0 : sample.closest(LAYOUT_COLUMN_SELECTOR);
return column && view.dom.contains(column) && event.clientY < column.getBoundingClientRect().top ? column : null;
};
/** Returns a function that finds the block whose legacy hover zone contains the pointer. */
export const createGutterBlockFinder = (view, api) => {
const widths = getGutterWidths();
return event => {
var _findBlockBesidePoint;
if (!(event.target instanceof Element)) {
return null;
}
return (_findBlockBesidePoint = findBlockBesidePointer(view, api, event, event.target, widths)) !== null && _findBlockBesidePoint !== void 0 ? _findBlockBesidePoint : view.dom.contains(event.target) ? findColumnBelowPointer(view, event, event.target) : null;
};
};
/** The latest gutter event waiting to be handled after the frame renders. */
/** What gutter hover does with a pointer position. */
/**
* Leaving block controls does not count as reaching a new element: a nested block's drag handle
* overhangs the block, so leaving the handle can put the pointer on the container with no block
* beside it, and that exit must keep the controls on the block.
*/
const isLeavingBlockControls = event => event.relatedTarget instanceof Element && Boolean(event.relatedTarget.closest(BLOCK_CONTROLS_SURFACE_SELECTOR));
const classifyGutterHover = (view, findGutterBlock, {
event,
reachedNewElement
}) => {
const block = findGutterBlock(event);
if (block) {
return {
kind: 'block',
element: block
};
}
const hoveredElement = event.target;
if (reachedNewElement && hoveredElement instanceof Element && view.dom.contains(hoveredElement)) {
return {
kind: 'container',
element: hoveredElement
};
}
return {
kind: 'none'
};
};
/**
* Runs `callback` once after the next frame renders (`requestAnimationFrame`, then `setTimeout`).
* Layout is clean then, so reading positions never forces a synchronous layout. Calls made while a
* run is already scheduled are ignored.
*/
const createAfterRenderScheduler = (win, callback) => {
let frame;
let timeout;
return {
schedule: () => {
if (frame !== undefined || timeout !== undefined) {
return;
}
frame = win.requestAnimationFrame(() => {
frame = undefined;
timeout = win.setTimeout(() => {
timeout = undefined;
callback();
}, 0);
});
},
cancel: () => {
if (frame !== undefined) {
win.cancelAnimationFrame(frame);
frame = undefined;
}
if (timeout !== undefined) {
win.clearTimeout(timeout);
timeout = undefined;
}
}
};
};
/**
* Keeps interaction tracking in step with gutter hover, as the legacy hover zones did. They sat
* inside the block tree, so hovering one cleared the typing state, counted as inside the editor even
* where the zone reached past the content area, and put the pointer on the left side of the
* right-side controls split.
*/
const createInteractionTrackingSync = (view, api, contentArea) => {
// Set after gutter hover calls mouseEnter for a gutter outside the content area, so that leaving
// that gutter calls mouseLeave.
let isHoldingMouseEnter = false;
return (event, isOverGutter) => {
var _api$blockControls$sh;
const state = getInteractionTrackingState(view.state);
const rightSideControlsEnabled = api === null || api === void 0 ? void 0 : (_api$blockControls$sh = api.blockControls.sharedState.currentState()) === null || _api$blockControls$sh === void 0 ? void 0 : _api$blockControls$sh.rightSideControlsEnabled;
const side = rightSideControlsEnabled ? 'left' : undefined;
if (isOverGutter && state !== null && state !== void 0 && state.isEditing) {
stopEditing(view);
}
if (event.target instanceof Node && contentArea !== null && contentArea !== void 0 && contentArea.contains(event.target)) {
isHoldingMouseEnter = false;
} else if (isOverGutter && state !== null && state !== void 0 && state.isMouseOut) {
isHoldingMouseEnter = true;
mouseEnter(view, side);
return;
} else if (!isOverGutter && isHoldingMouseEnter) {
isHoldingMouseEnter = false;
handleMouseLeave(view, rightSideControlsEnabled);
return;
}
if (isOverGutter && side && (state === null || state === void 0 ? void 0 : state.hoverSide) !== side) {
setHoverSide(view, side);
}
};
};
/**
* Shows block controls for the block the pointer hovers under sparse surfaces, including the empty
* space to a block's left (the gutter). This is the only hover handler for sparse surfaces.
*
* Reaching text or a leaf block shows its controls straight away. Over an element that can be part
* of a gutter, only the latest pointer event is kept, and it is resolved once after the frame
* renders so that reading positions never forces a layout.
*
* Returns an unbind function, or `undefined` when the editor's document has no window.
*/
export const bindSparseGutterHover = ({
api,
view
}) => {
var _ref, _ref2, _view$dom$closest;
const win = view.dom.ownerDocument.defaultView;
if (!win) {
return undefined;
}
const contentArea = view.dom.closest('.ak-editor-content-area');
// A wide block's gutter can sit outside the padded content area. Editors without a scroll
// container or content area still get hover, over as much of the gutter as their wrapper covers.
const eventArea = (_ref = (_ref2 = (_view$dom$closest = view.dom.closest('[data-editor-scroll-container="true"]')) !== null && _view$dom$closest !== void 0 ? _view$dom$closest : contentArea) !== null && _ref2 !== void 0 ? _ref2 : view.dom.parentElement) !== null && _ref !== void 0 ? _ref : view.dom;
const findGutterBlock = createGutterBlockFinder(view, api);
const syncInteractionTracking = createInteractionTrackingSync(view, api, contentArea);
let pending;
const handlePendingPointer = () => {
const pointer = pending;
pending = undefined;
if (!pointer || isSparseHoverSuppressed(view, api)) {
return;
}
const hover = classifyGutterHover(view, findGutterBlock, pointer);
if (hover.kind !== 'none') {
handleSparseHoverTarget(view, hover.element, api);
}
syncInteractionTracking(pointer.event, hover.kind === 'block');
};
const scheduler = createAfterRenderScheduler(win, handlePendingPointer);
const queueGutterEvent = (event, reachedNewElement) => {
var _pending;
pending = {
event,
reachedNewElement: Boolean((_pending = pending) === null || _pending === void 0 ? void 0 : _pending.reachedNewElement) || reachedNewElement
};
scheduler.schedule();
};
// Classified on every mouseover and reused while the pointer moves within that element, so
// movement over text costs one comparison.
let hovered;
const classifyHoveredElement = element => {
hovered = {
element,
isPossibleGutter: isPossibleGutterElement(view, element)
};
return hovered.isPossibleGutter;
};
const onMouseOver = event => {
if (classifyHoveredElement(event.target)) {
queueGutterEvent(event, !isLeavingBlockControls(event));
return;
}
pending = undefined;
const hoveredElement = event.target;
if (hoveredElement instanceof Element && view.dom.contains(hoveredElement) && !isSparseHoverSuppressed(view, api)) {
handleSparseHoverTarget(view, hoveredElement, api);
}
};
const onMouseMove = event => {
var _hovered;
const isPossibleGutter = ((_hovered = hovered) === null || _hovered === void 0 ? void 0 : _hovered.element) === event.target ? hovered.isPossibleGutter : classifyHoveredElement(event.target);
if (isPossibleGutter) {
queueGutterEvent(event, false);
}
};
const bindMouseListener = (type, listener) => bind(eventArea, {
type,
listener: event => {
if (event instanceof win.MouseEvent) {
listener(event);
}
},
options: {
passive: true
}
});
let unbindMouseOver;
let unbindMouseMove;
if (isExperimentEnabled('platform_editor_block_controls_cls_fix')) {
// Match the legacy ignoreRestingPointerHover guard until the pointer first moves.
let pointerHasMoved = false;
unbindMouseOver = bindMouseListener('mouseover', event => {
if (!pointerHasMoved) {
return;
}
onMouseOver(event);
});
unbindMouseMove = bindMouseListener('mousemove', event => {
if (!pointerHasMoved) {
pointerHasMoved = true;
onMouseOver(event);
}
onMouseMove(event);
});
} else {
unbindMouseOver = bindMouseListener('mouseover', onMouseOver);
unbindMouseMove = bindMouseListener('mousemove', onMouseMove);
}
return () => {
unbindMouseOver();
unbindMouseMove();
scheduler.cancel();
pending = undefined;
};
};