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