@atlaskit/editor-plugin-selection
Version:
Selection plugin for @atlaskit/editor-core
388 lines (369 loc) • 16.5 kB
JavaScript
import _toConsumableArray from "@babel/runtime/helpers/toConsumableArray";
import { isIgnored as isIgnoredByGapCursor, isSelectionAtStartOfNode } from '@atlaskit/editor-common/selection';
import { isEmptyParagraph, isListItemNode } from '@atlaskit/editor-common/utils';
import { AllSelection, NodeSelection, Selection, TextSelection } from '@atlaskit/editor-prosemirror/state';
import { findParentNode, findParentNodeClosestToPos, flatten, hasParentNode } from '@atlaskit/editor-prosemirror/utils';
import { Decoration, DecorationSet } from '@atlaskit/editor-prosemirror/view';
import { akEditorSelectedNodeClassName } from '@atlaskit/editor-shared-styles';
import { editorExperiment } from '@atlaskit/tmp-editor-statsig/experiments';
import { selectionPluginKey } from '../types';
import { createHideCursorDecoration } from './cursor/ui/hide-cursor-decoration';
export var getDecorations = function getDecorations(tr, manualSelection, hideCursor, blockSelection) {
var selection = tr.selection;
var decorations = [];
if (hideCursor) {
decorations.push(createHideCursorDecoration());
}
if (selection instanceof NodeSelection) {
decorations.push(Decoration.node(selection.from, selection.to, {
class: akEditorSelectedNodeClassName
}));
return DecorationSet.create(tr.doc, decorations);
}
if (selection instanceof TextSelection || selection instanceof AllSelection) {
if (manualSelection && manualSelection.anchor >= 0 && manualSelection.head >= 0 && manualSelection.anchor <= tr.doc.nodeSize && manualSelection.head <= tr.doc.nodeSize) {
selection = TextSelection.create(tr.doc, manualSelection.anchor, manualSelection.head);
}
// Only apply node decorations when there is an active block selection.
// When there is no block selection, text selections should use native browser selection appearance.
if (!editorExperiment('platform_editor_block_menu', true, {
exposure: true
}) || blockSelection) {
var selectionDecorations = getNodesToDecorateFromSelection(selection, tr.doc).map(function (_ref) {
var node = _ref.node,
pos = _ref.pos;
return Decoration.node(pos, pos + node.nodeSize, {
class: akEditorSelectedNodeClassName
});
});
decorations.push.apply(decorations, _toConsumableArray(selectionDecorations));
}
return DecorationSet.create(tr.doc, decorations);
}
return decorations.length > 0 ? DecorationSet.create(tr.doc, decorations) : DecorationSet.empty;
};
var topLevelBlockNodesThatHaveSelectionStyles = ['table', 'panel', 'expand', 'layoutSection', 'decisionList', 'decisionItem', 'codeBlock'];
/**
* Use `getNodesToDecorateFromSelection` to collect and return
* a list of nodes within the Selection that should have Selection
* decorations applied. This allows selection styles to be added to
* nested nodes. It will ignore text nodes as decorations are
* applied natively and also ignore nodes that don't completely
* sit within the given `Selection`.
*/
export var getNodesToDecorateFromSelection = function getNodesToDecorateFromSelection(selection, doc) {
var nodes = [];
if (selection.from !== selection.to) {
var from = selection.from,
to = selection.to;
doc.nodesBetween(from, to, function (node, pos) {
var withinSelection = from <= pos && pos + node.nodeSize <= to;
// The reason we need to check for these nodes is to stop
// traversing their children if they are within a selection -
// this is to prevent selection styles from being added to
// the children as well as the parent node.
// Example scenario is if an entire table has been selected
// we should not traverse its children so we can apply the
// selection styles to the table. But if an entire tableRow
// has been selected (but the parent table has not) we should
// traverse it as it could contain other nodes that need
// selection styles. I couldn’t see a clear way to differentiate
// without explicitly stating which nodes should be traversed
// and which shouldn’t.
var isTopLevelNodeThatHasSelectionStyles = topLevelBlockNodesThatHaveSelectionStyles.includes(node.type.name);
// If the node is a top-level block node and completely sits within
// the selection, we do not recurse it's children to prevent selection
// styles being added to its child nodes. The expected behaviour
// is that selection styles are only added to the parent.
if (node && withinSelection && isTopLevelNodeThatHasSelectionStyles) {
nodes.push({
node: node,
pos: pos
});
return false;
// Otherwise we recurse the children and return them so we can apply
// selection styles. Text is handled by the browser.
} else if (node && withinSelection && !node.isText) {
nodes.push({
node: node,
pos: pos
});
}
return true;
});
}
return nodes;
};
export function shouldRecalcDecorations(_ref2) {
var oldEditorState = _ref2.oldEditorState,
newEditorState = _ref2.newEditorState;
var oldSelection = oldEditorState.selection;
var newSelection = newEditorState.selection;
var oldPluginState = selectionPluginKey.getState(oldEditorState);
var newPluginState = selectionPluginKey.getState(newEditorState);
if (!oldPluginState || !newPluginState) {
return false;
}
// If selection is unchanged, no need to recalculate
if (oldSelection.eq(newSelection)) {
// We need this special case for NodeSelection, as Prosemirror still thinks the
// selections are equal when the node has changed
if (oldSelection instanceof NodeSelection && newSelection instanceof NodeSelection) {
var oldDecorations = oldPluginState.decorationSet.find();
var newDecorations = newPluginState.decorationSet.find();
// There might not be old or new decorations if the node selection is for a text node
// This wouldn't have happened intentionally, but we need to handle this case regardless
if (oldDecorations.length > 0 && newDecorations.length > 0) {
return !oldDecorations[0].eq(newDecorations[0]);
}
return !(oldDecorations.length === 0 && newDecorations.length === 0);
}
return false;
}
// There's no point updating decorations if going from one standard TextSelection to another
if (oldSelection instanceof TextSelection && newSelection instanceof TextSelection && oldSelection.from === oldSelection.to && newSelection.from === newSelection.to) {
return false;
}
return true;
}
export var isSelectableContainerNode = function isSelectableContainerNode(node) {
return !!(node && !node.isAtom && NodeSelection.isSelectable(node));
};
export var isSelectableChildNode = function isSelectableChildNode(node) {
return !!(node && (node.isText || isEmptyParagraph(node) || NodeSelection.isSelectable(node)));
};
/**
* Finds closest parent node that is a selectable block container node
* If it finds a parent that is not selectable but supports gap cursor, will
* return undefined
*/
export var findSelectableContainerParent = function findSelectableContainerParent(selection) {
var foundNodeThatSupportsGapCursor = false;
var selectableNode = findParentNode(function (node) {
var isSelectable = isSelectableContainerNode(node);
if (!isSelectable && !isIgnoredByGapCursor(node)) {
foundNodeThatSupportsGapCursor = true;
}
return isSelectable;
})(selection);
if (!foundNodeThatSupportsGapCursor) {
return selectableNode;
}
};
/**
* Finds node before that is a selectable block container node, starting
* from $pos.depth + 1 and working in
* If it finds a node that is not selectable but supports gap cursor, will
* return undefined
*/
export var findSelectableContainerBefore = function findSelectableContainerBefore($pos, doc) {
// prosemirror just returns the same pos from Selection.findFrom when
// parent.inlineContent is true, so we move position back one here
// to counteract that
if ($pos.parent.inlineContent && isSelectableContainerNode($pos.parent)) {
$pos = doc.resolve($pos.start() - 1);
}
var selectionBefore = Selection.findFrom($pos, -1);
if (selectionBefore) {
var $selectionBefore = doc.resolve(selectionBefore.from);
for (var i = $pos.depth + 1; i <= $selectionBefore.depth; i++) {
var node = $selectionBefore.node(i);
if (isSelectableContainerNode(node)) {
return {
node: node,
pos: $selectionBefore.start(i) - 1
};
}
if (i > $pos.depth + 1 && !isIgnoredByGapCursor(node)) {
return;
}
}
/**
* Stick to the default left selection behaviour,
* useful for mediaSingleWithCaption
*/
if (selectionBefore instanceof NodeSelection && NodeSelection.isSelectable(selectionBefore.node)) {
return {
node: selectionBefore.node,
pos: selectionBefore.from
};
}
}
};
/**
* Finds node after that is a selectable block container node, starting
* from $pos.depth + 1 and working in
* If it finds a node that is not selectable but supports gap cursor, will
* return undefined
*/
export var findSelectableContainerAfter = function findSelectableContainerAfter($pos, doc) {
var selectionAfter = Selection.findFrom($pos, 1);
if (selectionAfter) {
var $selectionAfter = doc.resolve(selectionAfter.from);
for (var i = $pos.depth + 1; i <= $selectionAfter.depth; i++) {
var node = $selectionAfter.node(i);
if (isSelectableContainerNode(node)) {
return {
node: node,
pos: $selectionAfter.start(i) - 1
};
}
if (i > $pos.depth + 1 && !isIgnoredByGapCursor(node)) {
return;
}
}
}
};
/**
* Finds first child node that is a selectable block container node OR that
* supports gap cursor
*/
export var findFirstChildNodeToSelect = function findFirstChildNodeToSelect(parent) {
return flatten(parent).find(function (child) {
return isSelectableChildNode(child.node) || !isIgnoredByGapCursor(child.node);
});
};
/**
* Finds last child node that is a selectable block container node OR that
* supports gap cursor
*/
export var findLastChildNodeToSelect = function findLastChildNodeToSelect(parent) {
var child;
parent.descendants(function (node, pos) {
if (isSelectableChildNode(node) || !isIgnoredByGapCursor(node)) {
child = {
node: node,
pos: pos
};
return false;
}
});
if (child) {
return child;
}
};
export var isSelectionAtStartOfParentNode = function isSelectionAtStartOfParentNode($pos, selection) {
var _findSelectableContai;
return isSelectionAtStartOfNode($pos, (_findSelectableContai = findSelectableContainerParent(selection)) === null || _findSelectableContai === void 0 ? void 0 : _findSelectableContai.node);
};
export var isSelectionAtEndOfParentNode = function isSelectionAtEndOfParentNode($pos, selection) {
// If the current position is at the end of its parent node's content.
var isAtTheEndOfCurrentLevel = $pos.parent.content.size === $pos.parentOffset;
if (!isAtTheEndOfCurrentLevel) {
return false;
}
// If at the root or parent is selectable, we're at the end
if ($pos.depth === 0 || NodeSelection.isSelectable($pos.parent)) {
return isAtTheEndOfCurrentLevel;
}
// Handle lists: if in a list inside container and not at the end, return false
if (hasParentNode(isListItemNode)(selection) && isListItemWithinContainerNotAtEnd($pos, selection)) {
return false;
}
// Handle layout columns: if another node follows, not at end
if (isSelectionAtEndOfLayoutColumn($pos)) {
return false;
}
// Default: if at end of parent's parent
var $after = $pos.doc.resolve($pos.after());
return $after.parent.content.size === $after.parentOffset;
};
/**
* Determines if the current selection is inside a list item within a container and not at the end of the parent list.
*
* This is useful for handling edge cases where the selection is within a list inside a container structure,
* and we need to know if the selection is not at the logical end of the parent list node.
*/
export var isListItemWithinContainerNotAtEnd = function isListItemWithinContainerNotAtEnd($pos, selection) {
var isInContainerNode = hasParentNode(isContainerNode)(selection);
if (!isInContainerNode) {
return false;
}
var parentList = findParentNodeClosestToPos($pos, isListItemNode);
if (!parentList) {
return false;
}
var $parentListPos = $pos.doc.resolve(parentList.pos);
var topLevelList = findTopLevelList($pos);
if (!topLevelList) {
return false;
}
var $afterTopLevelList = $pos.doc.resolve(topLevelList.pos + topLevelList.node.nodeSize);
var nodeAfterTopLevelList = $afterTopLevelList.nodeAfter;
var grandParentList = findParentNodeClosestToPos($pos.doc.resolve($parentListPos.before()), isListItemNode);
var grandParentListPos = grandParentList ? $pos.doc.resolve(grandParentList.pos) : undefined;
var isLastListItemInParent =
// Check if the current list item is the last child in its parent list
$parentListPos.index() === $parentListPos.parent.childCount - 1 && (
// Check if there is no grandparent list, or if the grandparent list item is also the last child in its parent
!grandParentList || grandParentListPos && grandParentListPos.index() === grandParentListPos.parent.childCount - 1);
if (!isLastListItemInParent || nodeAfterTopLevelList) {
return true;
}
return false;
};
/**
* Determines if the given node is a Container (layoutColumn, panel, expand) node.
*/
export var isContainerNode = function isContainerNode(node) {
var _node$type;
var _ref3 = (node === null || node === void 0 || (_node$type = node.type) === null || _node$type === void 0 || (_node$type = _node$type.schema) === null || _node$type === void 0 ? void 0 : _node$type.nodes) || {},
layoutColumn = _ref3.layoutColumn,
panel = _ref3.panel,
expand = _ref3.expand;
return Boolean(node && node.type && [panel, expand, layoutColumn].includes(node.type));
};
/**
* Finds the top-level List ancestor of the given position.
* Returns the node and its position if found, otherwise returns undefined.
*/
export var findTopLevelList = function findTopLevelList(pos) {
var _pos$doc$type$schema$ = pos.doc.type.schema.nodes,
bulletList = _pos$doc$type$schema$.bulletList,
orderedList = _pos$doc$type$schema$.orderedList;
var currentDepth = pos.depth;
var topLevelList;
while (currentDepth > 0) {
var node = pos.node(currentDepth);
if ([bulletList, orderedList].includes(node.type)) {
topLevelList = {
node: node,
pos: pos.before(currentDepth)
};
}
currentDepth--;
}
return topLevelList;
};
/**
* Determines whether the current selection position is at the end of a layout column node.
*/
export var isSelectionAtEndOfLayoutColumn = function isSelectionAtEndOfLayoutColumn($pos) {
var layoutColumnParent = findParentNodeClosestToPos($pos, isLayoutColumnNode);
if (!layoutColumnParent) {
return false;
}
var panelOrExpandParent = findParentNodeClosestToPos($pos, isPanelOrExpandNode);
if (panelOrExpandParent && panelOrExpandParent.pos > layoutColumnParent.pos) {
return false;
}
var afterPos = layoutColumnParent.pos + layoutColumnParent.node.nodeSize;
var $after = $pos.doc.resolve(afterPos);
return Boolean($after.nodeAfter);
};
/**
* Determines if the given node is a LayoutColumn node.
*/
export var isLayoutColumnNode = function isLayoutColumnNode(node) {
var _node$type2;
var _ref4 = (node === null || node === void 0 || (_node$type2 = node.type) === null || _node$type2 === void 0 || (_node$type2 = _node$type2.schema) === null || _node$type2 === void 0 ? void 0 : _node$type2.nodes) || {},
layoutColumn = _ref4.layoutColumn;
return Boolean(node && node.type && node.type === layoutColumn);
};
export var isPanelOrExpandNode = function isPanelOrExpandNode(node) {
var _node$type3;
var _ref5 = (node === null || node === void 0 || (_node$type3 = node.type) === null || _node$type3 === void 0 || (_node$type3 = _node$type3.schema) === null || _node$type3 === void 0 ? void 0 : _node$type3.nodes) || {},
panel = _ref5.panel,
expand = _ref5.expand;
return Boolean(node && node.type && (node.type === panel || node.type === expand));
};