UNPKG

@atlaskit/editor-plugin-block-controls

Version:

Block controls plugin for @atlaskit/editor-core

198 lines (192 loc) 8.7 kB
import { bind } from 'bind-event-listener'; import { SafePlugin } from '@atlaskit/editor-common/safe-plugin'; import { PluginKey } from '@atlaskit/editor-prosemirror/state'; import { editorExperiment } from '@atlaskit/tmp-editor-statsig/experiments'; import { RIGHT_MARGIN_ROVO_GAP_PX } from './constants'; import { handleKeyDown } from './handle-key-down'; import { handleMouseEnter, handleMouseLeave, handleMouseMove } from './handle-mouse-move'; /** Elements that extend the editor hover area (block controls, right-edge button, etc.) */ const BLOCK_CONTROLS_HOVER_AREA_SELECTOR = '[data-blocks-right-edge-button-container], [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"]'; const MOUSE_LEAVE_DEBOUNCE_MS = 200; /** ClickAreaBlock overlay that wraps the editor content and covers the right margin. */ const CLICK_AREA_SELECTOR = '[data-editor-click-wrapper]'; const isMovingToBlockControlsArea = target => target instanceof Element && !!target.closest(BLOCK_CONTROLS_HOVER_AREA_SELECTOR); /** * The right margin is covered by the ClickAreaBlock overlay, which sits outside .ak-editor-content-area. * Hovering there should still surface the right-side Remix button, so keep controls alive — but only on * the right (past the content's right edge), and not in the far-right Rovo gap. The left gutter must * dismiss like the experiment-off path, so it is treated as inactive. */ const isOverActiveClickArea = (target, clientX) => { var _clickArea$querySelec, _target$ownerDocument, _target$ownerDocument2; if (!(target instanceof Element)) { return false; } const clickArea = target.closest(CLICK_AREA_SELECTOR); if (!clickArea) { return false; } const contentRight = (_clickArea$querySelec = clickArea.querySelector('.ak-editor-content-area')) === null || _clickArea$querySelec === void 0 ? void 0 : _clickArea$querySelec.getBoundingClientRect().right; if (contentRight !== undefined && clientX <= contentRight) { return false; } const innerWidth = (_target$ownerDocument = (_target$ownerDocument2 = target.ownerDocument.defaultView) === null || _target$ownerDocument2 === void 0 ? void 0 : _target$ownerDocument2.innerWidth) !== null && _target$ownerDocument !== void 0 ? _target$ownerDocument : Number.POSITIVE_INFINITY; return clientX <= innerWidth - RIGHT_MARGIN_ROVO_GAP_PX; }; export const interactionTrackingPluginKey = new PluginKey('interactionTrackingPlugin'); export const createInteractionTrackingPlugin = (rightSideControlsEnabled = false, api) => { return new SafePlugin({ key: interactionTrackingPluginKey, state: { init() { const state = { isEditing: false }; if (editorExperiment('platform_editor_controls', 'variant1')) { state.isMouseOut = false; } return state; }, apply(tr, pluginState) { const meta = tr.getMeta(interactionTrackingPluginKey); const newState = {}; switch (meta === null || meta === void 0 ? void 0 : meta.type) { case 'startEditing': newState.isEditing = true; break; case 'stopEditing': newState.isEditing = false; break; case 'mouseLeave': newState.isMouseOut = true; newState.hoverSide = undefined; break; case 'mouseEnter': newState.isMouseOut = false; break; case 'setHoverSide': newState.hoverSide = meta.side; break; case 'clearHoverSide': newState.hoverSide = undefined; break; } return { ...pluginState, ...newState }; } }, props: { handleKeyDown, handleDOMEvents: { mousemove: (view, event) => handleMouseMove(view, event, rightSideControlsEnabled, api) } }, view: editorExperiment('platform_editor_controls', 'variant1') ? view => { const editorContentArea = view.dom.closest('.ak-editor-content-area'); // rightSideControlsEnabled is the single source of truth (confluence_remix_button_right_side_block_fg from preset) let unbindMouseEnter; let unbindMouseLeave; let unbindDocumentMouseMove; let mouseLeaveTimeoutId = null; let lastMousePosition = { x: 0, y: 0 }; // The active right margin only counts as "still hovering" when our experiment is on; // otherwise leaving the content area (e.g. exiting left) must dismiss as on master. const marginHoverEnabled = editorExperiment('remix_button_right_margin_hover', true); const scheduleMouseLeave = event => { if (mouseLeaveTimeoutId) { clearTimeout(mouseLeaveTimeoutId); mouseLeaveTimeoutId = null; } // Keep controls visible when moving to block controls (or, with the experiment on, // the active right margin — the Rovo gap is excluded so controls still clear there). if (rightSideControlsEnabled && (isMovingToBlockControlsArea(event.relatedTarget) || marginHoverEnabled && isOverActiveClickArea(event.relatedTarget, event.clientX))) { return; } mouseLeaveTimeoutId = setTimeout(() => { mouseLeaveTimeoutId = null; // Re-check after the debounce: keep controls if the cursor landed on block controls // (or, with the experiment on, the active right margin). if (rightSideControlsEnabled && typeof document !== 'undefined') { const el = document.elementFromPoint(lastMousePosition.x, lastMousePosition.y); if (el && (isMovingToBlockControlsArea(el) || marginHoverEnabled && isOverActiveClickArea(el, lastMousePosition.x))) { return; } } handleMouseLeave(view, rightSideControlsEnabled); }, MOUSE_LEAVE_DEBOUNCE_MS); }; const cancelScheduledMouseLeave = () => { if (mouseLeaveTimeoutId) { clearTimeout(mouseLeaveTimeoutId); mouseLeaveTimeoutId = null; } }; if (editorContentArea) { if (rightSideControlsEnabled && typeof document !== 'undefined') { unbindDocumentMouseMove = bind(document, { type: 'mousemove', listener: event => { lastMousePosition = { x: event.clientX, y: event.clientY }; // Catches block controls in portals that handleDOMEvents.mousemove misses. // The right-margin overlay is only relevant with the experiment on. const overClickArea = marginHoverEnabled && event.target instanceof Element && !!event.target.closest(CLICK_AREA_SELECTOR); if (editorContentArea.contains(event.target) || isMovingToBlockControlsArea(event.target) || overClickArea) { handleMouseMove(view, event, rightSideControlsEnabled, api); } }, options: { passive: true } }); } unbindMouseEnter = bind(editorContentArea, { type: 'mouseenter', listener: () => { if (rightSideControlsEnabled) { cancelScheduledMouseLeave(); } handleMouseEnter(view); } }); unbindMouseLeave = bind(editorContentArea, { type: 'mouseleave', listener: event => { const e = event; lastMousePosition = { x: e.clientX, y: e.clientY }; if (rightSideControlsEnabled) { scheduleMouseLeave(e); } else { handleMouseLeave(view, false); } } }); } return { destroy: () => { var _unbindMouseEnter, _unbindMouseLeave; if (rightSideControlsEnabled) { var _unbindDocumentMouseM; cancelScheduledMouseLeave(); (_unbindDocumentMouseM = unbindDocumentMouseMove) === null || _unbindDocumentMouseM === void 0 ? void 0 : _unbindDocumentMouseM(); } (_unbindMouseEnter = unbindMouseEnter) === null || _unbindMouseEnter === void 0 ? void 0 : _unbindMouseEnter(); (_unbindMouseLeave = unbindMouseLeave) === null || _unbindMouseLeave === void 0 ? void 0 : _unbindMouseLeave(); } }; } : undefined }); }; export const getInteractionTrackingState = state => { return interactionTrackingPluginKey.getState(state); };