@atlaskit/editor-plugin-selection
Version:
Selection plugin for @atlaskit/editor-core
387 lines (368 loc) • 15.7 kB
JavaScript
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 const getDecorations = (tr, manualSelection, hideCursor, blockSelection) => {
let selection = tr.selection;
const 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) {
const selectionDecorations = getNodesToDecorateFromSelection(selection, tr.doc).map(({
node,
pos
}) => {
return Decoration.node(pos, pos + node.nodeSize, {
class: akEditorSelectedNodeClassName
});
});
decorations.push(...selectionDecorations);
}
return DecorationSet.create(tr.doc, decorations);
}
return decorations.length > 0 ? DecorationSet.create(tr.doc, decorations) : DecorationSet.empty;
};
const 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 const getNodesToDecorateFromSelection = (selection, doc) => {
const nodes = [];
if (selection.from !== selection.to) {
const {
from,
to
} = selection;
doc.nodesBetween(from, to, (node, pos) => {
const 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.
const 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,
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,
pos
});
}
return true;
});
}
return nodes;
};
export function shouldRecalcDecorations({
oldEditorState,
newEditorState
}) {
const oldSelection = oldEditorState.selection;
const newSelection = newEditorState.selection;
const oldPluginState = selectionPluginKey.getState(oldEditorState);
const 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) {
const oldDecorations = oldPluginState.decorationSet.find();
const 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 const isSelectableContainerNode = node => !!(node && !node.isAtom && NodeSelection.isSelectable(node));
export const isSelectableChildNode = node => !!(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 const findSelectableContainerParent = selection => {
let foundNodeThatSupportsGapCursor = false;
const selectableNode = findParentNode(node => {
const 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 const 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);
}
const selectionBefore = Selection.findFrom($pos, -1);
if (selectionBefore) {
const $selectionBefore = doc.resolve(selectionBefore.from);
for (let i = $pos.depth + 1; i <= $selectionBefore.depth; i++) {
const node = $selectionBefore.node(i);
if (isSelectableContainerNode(node)) {
return {
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 const findSelectableContainerAfter = ($pos, doc) => {
const selectionAfter = Selection.findFrom($pos, 1);
if (selectionAfter) {
const $selectionAfter = doc.resolve(selectionAfter.from);
for (let i = $pos.depth + 1; i <= $selectionAfter.depth; i++) {
const node = $selectionAfter.node(i);
if (isSelectableContainerNode(node)) {
return {
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 const findFirstChildNodeToSelect = parent => flatten(parent).find(child => isSelectableChildNode(child.node) || !isIgnoredByGapCursor(child.node));
/**
* Finds last child node that is a selectable block container node OR that
* supports gap cursor
*/
export const findLastChildNodeToSelect = parent => {
let child;
parent.descendants((node, pos) => {
if (isSelectableChildNode(node) || !isIgnoredByGapCursor(node)) {
child = {
node,
pos
};
return false;
}
});
if (child) {
return child;
}
};
export const isSelectionAtStartOfParentNode = ($pos, selection) => {
var _findSelectableContai;
return isSelectionAtStartOfNode($pos, (_findSelectableContai = findSelectableContainerParent(selection)) === null || _findSelectableContai === void 0 ? void 0 : _findSelectableContai.node);
};
export const isSelectionAtEndOfParentNode = ($pos, selection) => {
// If the current position is at the end of its parent node's content.
const 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
const $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 const isListItemWithinContainerNotAtEnd = ($pos, selection) => {
const isInContainerNode = hasParentNode(isContainerNode)(selection);
if (!isInContainerNode) {
return false;
}
const parentList = findParentNodeClosestToPos($pos, isListItemNode);
if (!parentList) {
return false;
}
const $parentListPos = $pos.doc.resolve(parentList.pos);
const topLevelList = findTopLevelList($pos);
if (!topLevelList) {
return false;
}
const $afterTopLevelList = $pos.doc.resolve(topLevelList.pos + topLevelList.node.nodeSize);
const nodeAfterTopLevelList = $afterTopLevelList.nodeAfter;
const grandParentList = findParentNodeClosestToPos($pos.doc.resolve($parentListPos.before()), isListItemNode);
const grandParentListPos = grandParentList ? $pos.doc.resolve(grandParentList.pos) : undefined;
const 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 const isContainerNode = node => {
var _node$type, _node$type$schema;
const {
layoutColumn,
panel,
expand
} = (node === null || node === void 0 ? void 0 : (_node$type = node.type) === null || _node$type === void 0 ? void 0 : (_node$type$schema = _node$type.schema) === null || _node$type$schema === void 0 ? void 0 : _node$type$schema.nodes) || {};
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 const findTopLevelList = pos => {
const {
bulletList,
orderedList
} = pos.doc.type.schema.nodes;
let currentDepth = pos.depth;
let topLevelList;
while (currentDepth > 0) {
const node = pos.node(currentDepth);
if ([bulletList, orderedList].includes(node.type)) {
topLevelList = {
node,
pos: pos.before(currentDepth)
};
}
currentDepth--;
}
return topLevelList;
};
/**
* Determines whether the current selection position is at the end of a layout column node.
*/
export const isSelectionAtEndOfLayoutColumn = $pos => {
const layoutColumnParent = findParentNodeClosestToPos($pos, isLayoutColumnNode);
if (!layoutColumnParent) {
return false;
}
const panelOrExpandParent = findParentNodeClosestToPos($pos, isPanelOrExpandNode);
if (panelOrExpandParent && panelOrExpandParent.pos > layoutColumnParent.pos) {
return false;
}
const afterPos = layoutColumnParent.pos + layoutColumnParent.node.nodeSize;
const $after = $pos.doc.resolve(afterPos);
return Boolean($after.nodeAfter);
};
/**
* Determines if the given node is a LayoutColumn node.
*/
export const isLayoutColumnNode = node => {
var _node$type2, _node$type2$schema;
const {
layoutColumn
} = (node === null || node === void 0 ? void 0 : (_node$type2 = node.type) === null || _node$type2 === void 0 ? void 0 : (_node$type2$schema = _node$type2.schema) === null || _node$type2$schema === void 0 ? void 0 : _node$type2$schema.nodes) || {};
return Boolean(node && node.type && node.type === layoutColumn);
};
export const isPanelOrExpandNode = node => {
var _node$type3, _node$type3$schema;
const {
panel,
expand
} = (node === null || node === void 0 ? void 0 : (_node$type3 = node.type) === null || _node$type3 === void 0 ? void 0 : (_node$type3$schema = _node$type3.schema) === null || _node$type3$schema === void 0 ? void 0 : _node$type3$schema.nodes) || {};
return Boolean(node && node.type && (node.type === panel || node.type === expand));
};