UNPKG

@atlaskit/editor-plugin-block-controls

Version:

Block controls plugin for @atlaskit/editor-core

198 lines (188 loc) 11.2 kB
import _defineProperty from "@babel/runtime/helpers/defineProperty"; function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; } function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { _defineProperty(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; } import { bind } from 'bind-event-listener'; import { getDocument } from '@atlaskit/browser-apis'; import { SafePlugin } from '@atlaskit/editor-common/safe-plugin'; import { DRAG_HANDLE_SELECTOR } from '@atlaskit/editor-common/styles'; import { key } from '../main'; import { createPreservedSelection, mapPreservedSelection } from '../utils/selection'; import { stopPreservingSelection } from './editor-commands'; import { selectionPreservationPluginKey } from './plugin-key'; import { compareSelections, getSelectionPreservationMeta, hasUserSelectionChange, syncDOMSelection } from './utils'; /** * Selection Preservation Plugin * * Used to ensure the selection remains stable across selected nodes during specific UI operations, * such as when block menus are open or during drag-and-drop actions. * * We use a TextSelection to span multi-node selections, however there is a ProseMirror limitation * where TextSelection cannot include non inline positions at node boundaries (like media/images). * * When a selection spans text + media nodes, subsequent transactions cause ProseMirror to collapse * the selection to the nearest inline position, excluding the media node. This is problematic for * features like block menus and drag-and-drop that need stable multi-node selections while performing * operations. * * The plugin works in three phases: * (1) Explicitly save a selection via startPreservingSelection() when opening block menus or starting drag operations. * (2) Map the saved selection through document changes to keep positions valid. * (3) Detect when transactions collapse the selection and restore it via appendTransaction(). * * Stops preserving via stopPreservingSelection() when the menu closes or operation completes. * * Commands: startPreservingSelection() to begin preservation, stopPreservingSelection() to end it. * * NOTE: Only use when the UI blocks user selection changes. For example: when a block menu overlay * is open (editor becomes non-interactive), during drag-and-drop operations (user is mid-drag), or * when modal dialogs are active. In these states, any selection changes are from ProseMirror's * internal behavior (not user input) and should be prevented. Do not use during normal editing. * * https://hello.atlassian.net/wiki/spaces/egcuc/pages/6170822503/Block+Menu+Solution+for+multi-select+and+selection+preservation */ export var createSelectionPreservationPlugin = function createSelectionPreservationPlugin(api) { return function () { return new SafePlugin({ key: selectionPreservationPluginKey, state: { init: function init() { return { preservedSelection: undefined }; }, apply: function apply(tr, pluginState) { var meta = getSelectionPreservationMeta(tr); var newState = _objectSpread({}, pluginState); if ((meta === null || meta === void 0 ? void 0 : meta.type) === 'startPreserving') { newState.preservedSelection = createPreservedSelection(tr.doc.resolve(tr.selection.from), tr.doc.resolve(tr.selection.to)); } else if ((meta === null || meta === void 0 ? void 0 : meta.type) === 'stopPreserving') { newState.preservedSelection = undefined; } else if (newState.preservedSelection && tr.docChanged) { newState.preservedSelection = mapPreservedSelection(newState.preservedSelection, tr); } if (!compareSelections(newState.preservedSelection, pluginState.preservedSelection)) { if (newState.preservedSelection) { var _api$selection; api === null || api === void 0 || api.core.actions.execute(api === null || api === void 0 || (_api$selection = api.selection) === null || _api$selection === void 0 || (_api$selection = _api$selection.commands) === null || _api$selection === void 0 ? void 0 : _api$selection.setBlockSelection(newState.preservedSelection)); } else { var _api$selection2; api === null || api === void 0 || api.core.actions.execute(api === null || api === void 0 || (_api$selection2 = api.selection) === null || _api$selection2 === void 0 || (_api$selection2 = _api$selection2.commands) === null || _api$selection2 === void 0 ? void 0 : _api$selection2.clearBlockSelection()); } } return newState; } }, appendTransaction: function appendTransaction(transactions, _oldState, newState) { var pluginState = selectionPreservationPluginKey.getState(newState); var preservedSel = pluginState === null || pluginState === void 0 ? void 0 : pluginState.preservedSelection; var stateSel = newState.selection; if (!preservedSel) { return null; } // Auto-stop if user explicitly changes selection if (hasUserSelectionChange(transactions)) { return stopPreservingSelection({ tr: newState.tr }); } var selectionUnchanged = stateSel.from === preservedSel.from && stateSel.to === preservedSel.to; var selectionInvalid = preservedSel.from < 0 || preservedSel.to > newState.doc.content.size; if (selectionUnchanged || selectionInvalid) { return null; } var newSelection = createPreservedSelection(newState.doc.resolve(preservedSel.from), newState.doc.resolve(preservedSel.to)); // If selection becomes invalid, stop preserving if (!newSelection) { return stopPreservingSelection({ tr: newState.tr }); } return newState.tr.setSelection(newSelection); }, view: function view(initialView) { var view = initialView; var doc = getDocument(); if (!doc) { return { update: function update() {}, destroy: function destroy() {} }; } var unbindDocumentMouseDown = bind(doc, { type: 'mousedown', listener: function listener(e) { if (!(e.target instanceof HTMLElement)) { return; } var _ref = selectionPreservationPluginKey.getState(view.state) || {}, preservedSelection = _ref.preservedSelection; // If there is no current preserved selection or the editor is not focused, do nothing if (!preservedSelection) { return; } var clickedDragHandle = !!e.target.closest(DRAG_HANDLE_SELECTOR); // When mouse down on a drag handle we continue preserving the selection if (clickedDragHandle) { return; } var clickedOutsideEditor = !e.target.closest('.ProseMirror'); // When mouse down outside the editor continue to preserve the selection if (clickedOutsideEditor) { return; } // Otherwise mouse down anywhere else in the editor stops preserving the selection var tr = view.state.tr; stopPreservingSelection({ tr: tr }); view.dispatch(tr); }, // Use capture phase to stop preservation before appendTransaction runs, // preventing unwanted selection restoration when the user clicks into the editor. options: { capture: true } }); return { update: function update(updateView, prevState) { var _selectionPreservatio, _selectionPreservatio2, _key$getState, _key$getState2; view = updateView; var prevPreservedSelection = (_selectionPreservatio = selectionPreservationPluginKey.getState(prevState)) === null || _selectionPreservatio === void 0 ? void 0 : _selectionPreservatio.preservedSelection; var currPreservedSelection = (_selectionPreservatio2 = selectionPreservationPluginKey.getState(view.state)) === null || _selectionPreservatio2 === void 0 ? void 0 : _selectionPreservatio2.preservedSelection; var prevActiveNode = (_key$getState = key.getState(prevState)) === null || _key$getState === void 0 ? void 0 : _key$getState.activeNode; var currActiveNode = (_key$getState2 = key.getState(view.state)) === null || _key$getState2 === void 0 ? void 0 : _key$getState2.activeNode; // Sync DOM selection when the preserved selection or active node changes // AND the document has changed (e.g., nodes moved) // This prevents stealing focus during menu navigation while still fixing ghost highlighting var hasPreservedSelection = !!currPreservedSelection; var preservedSelectionChanged = !compareSelections(prevPreservedSelection, currPreservedSelection); var activeNodeChanged = prevActiveNode !== currActiveNode; var docChanged = prevState.doc !== view.state.doc; var shouldSyncDOMSelection = hasPreservedSelection && (preservedSelectionChanged || activeNodeChanged) && docChanged; if (shouldSyncDOMSelection) { syncDOMSelection(view.state.selection, view); } }, destroy: function destroy() { unbindDocumentMouseDown(); } }; }, props: { handleKeyDown: function handleKeyDown(view, event) { var _api$core, _api$blockControls; var _ref2 = selectionPreservationPluginKey.getState(view.state) || {}, preservedSelection = _ref2.preservedSelection; // If there is no current preserved selection, do nothing if (!preservedSelection) { return false; } api === null || api === void 0 || (_api$core = api.core) === null || _api$core === void 0 || _api$core.actions.execute(api === null || api === void 0 || (_api$blockControls = api.blockControls) === null || _api$blockControls === void 0 || (_api$blockControls = _api$blockControls.commands) === null || _api$blockControls === void 0 ? void 0 : _api$blockControls.handleKeyDownWithPreservedSelection(event)); // While preserving selection, if user presses delete/backspace, prevent event from being // handled by ProseMirror natively so that we can apply logic using the preserved selection. return ['backspace', 'delete'].includes(event.key.toLowerCase()); } } }); }; };