@atlaskit/editor-plugin-block-controls
Version:
Block controls plugin for @atlaskit/editor-core
198 lines (188 loc) • 11.2 kB
JavaScript
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());
}
}
});
};
};