UNPKG

@atlaskit/editor-plugin-block-controls

Version:

Block controls plugin for @atlaskit/editor-core

197 lines (187 loc) 10.1 kB
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 const createSelectionPreservationPlugin = api => () => { return new SafePlugin({ key: selectionPreservationPluginKey, state: { init() { return { preservedSelection: undefined }; }, apply(tr, pluginState) { const meta = getSelectionPreservationMeta(tr); const newState = { ...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$selection$comman; api === null || api === void 0 ? void 0 : api.core.actions.execute(api === null || api === void 0 ? void 0 : (_api$selection = api.selection) === null || _api$selection === void 0 ? void 0 : (_api$selection$comman = _api$selection.commands) === null || _api$selection$comman === void 0 ? void 0 : _api$selection$comman.setBlockSelection(newState.preservedSelection)); } else { var _api$selection2, _api$selection2$comma; api === null || api === void 0 ? void 0 : api.core.actions.execute(api === null || api === void 0 ? void 0 : (_api$selection2 = api.selection) === null || _api$selection2 === void 0 ? void 0 : (_api$selection2$comma = _api$selection2.commands) === null || _api$selection2$comma === void 0 ? void 0 : _api$selection2$comma.clearBlockSelection()); } } return newState; } }, appendTransaction(transactions, _oldState, newState) { const pluginState = selectionPreservationPluginKey.getState(newState); const preservedSel = pluginState === null || pluginState === void 0 ? void 0 : pluginState.preservedSelection; const stateSel = newState.selection; if (!preservedSel) { return null; } // Auto-stop if user explicitly changes selection if (hasUserSelectionChange(transactions)) { return stopPreservingSelection({ tr: newState.tr }); } const selectionUnchanged = stateSel.from === preservedSel.from && stateSel.to === preservedSel.to; const selectionInvalid = preservedSel.from < 0 || preservedSel.to > newState.doc.content.size; if (selectionUnchanged || selectionInvalid) { return null; } const 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(initialView) { let view = initialView; const doc = getDocument(); if (!doc) { return { update() {}, destroy() {} }; } const unbindDocumentMouseDown = bind(doc, { type: 'mousedown', listener: e => { if (!(e.target instanceof HTMLElement)) { return; } const { preservedSelection } = selectionPreservationPluginKey.getState(view.state) || {}; // If there is no current preserved selection or the editor is not focused, do nothing if (!preservedSelection) { return; } const clickedDragHandle = !!e.target.closest(DRAG_HANDLE_SELECTOR); // When mouse down on a drag handle we continue preserving the selection if (clickedDragHandle) { return; } const 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 const tr = view.state.tr; stopPreservingSelection({ 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(updateView, prevState) { var _selectionPreservatio, _selectionPreservatio2, _key$getState, _key$getState2; view = updateView; const prevPreservedSelection = (_selectionPreservatio = selectionPreservationPluginKey.getState(prevState)) === null || _selectionPreservatio === void 0 ? void 0 : _selectionPreservatio.preservedSelection; const currPreservedSelection = (_selectionPreservatio2 = selectionPreservationPluginKey.getState(view.state)) === null || _selectionPreservatio2 === void 0 ? void 0 : _selectionPreservatio2.preservedSelection; const prevActiveNode = (_key$getState = key.getState(prevState)) === null || _key$getState === void 0 ? void 0 : _key$getState.activeNode; const 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 const hasPreservedSelection = !!currPreservedSelection; const preservedSelectionChanged = !compareSelections(prevPreservedSelection, currPreservedSelection); const activeNodeChanged = prevActiveNode !== currActiveNode; const docChanged = prevState.doc !== view.state.doc; const shouldSyncDOMSelection = hasPreservedSelection && (preservedSelectionChanged || activeNodeChanged) && docChanged; if (shouldSyncDOMSelection) { syncDOMSelection(view.state.selection, view); } }, destroy() { unbindDocumentMouseDown(); } }; }, props: { handleKeyDown: (view, event) => { var _api$core, _api$blockControls, _api$blockControls$co; const { preservedSelection } = selectionPreservationPluginKey.getState(view.state) || {}; // If there is no current preserved selection, do nothing if (!preservedSelection) { return false; } 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$blockControls = api.blockControls) === null || _api$blockControls === void 0 ? void 0 : (_api$blockControls$co = _api$blockControls.commands) === null || _api$blockControls$co === void 0 ? void 0 : _api$blockControls$co.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()); } } }); };