@atlaskit/editor-plugin-list
Version:
List plugin for @atlaskit/editor-core
373 lines (363 loc) • 16.7 kB
JavaScript
import { ACTION, ACTION_SUBJECT, ACTION_SUBJECT_ID, EVENT_TYPE, INPUT_METHOD } from '@atlaskit/editor-common/analytics';
import { findCutBefore } from '@atlaskit/editor-common/commands';
import { getBlockMarkAttrs, getCommonListAnalyticsAttributes, getFirstParagraphBlockMarkAttrs, moveTargetIntoList, reconcileBlockMarkForContainerAtPos, reconcileBlockMarkForParagraphAtPos } from '@atlaskit/editor-common/lists';
import { editorCommandToPMCommand } from '@atlaskit/editor-common/preset';
import { GapCursorSelection } from '@atlaskit/editor-common/selection';
import { filterCommand as filter, hasVisibleContent, isEmptySelectionAtStart } from '@atlaskit/editor-common/utils';
import { chainCommands } from '@atlaskit/editor-prosemirror/commands';
import { Fragment, Slice } from '@atlaskit/editor-prosemirror/model';
import { NodeSelection, Selection, TextSelection } from '@atlaskit/editor-prosemirror/state';
import { findParentNodeOfTypeClosestToPos, findPositionOfNodeBefore, hasParentNodeOfType } from '@atlaskit/editor-prosemirror/utils';
import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals';
import { convertListType } from '../actions/conversions';
import { wrapInListAndJoin } from '../actions/wrap-and-join-lists';
import { liftFollowingList, liftNodeSelectionList, liftTextSelectionList } from '../transforms';
import { sanitiseMarksInSelection } from '../utils/mark';
import { canJoinToPreviousListItem, isInsideListItem, selectionContainsList } from '../utils/selection';
import { isFirstChildOfParent } from './isFirstChildOfParent';
import { joinListItemForward } from './join-list-item-forward';
import { listBackspace } from './listBackspace';
import { outdentList } from './outdent-list';
export const enterKeyCommand = editorAnalyticsAPI => () => (state, dispatch) => {
const {
selection
} = state;
if (selection.empty) {
const {
$from
} = selection;
const {
listItem,
codeBlock
} = state.schema.nodes;
// the list item is the parent of the gap cursor
// while for text, list item is the grandparent of the text node
const isGapCursorSelection = selection instanceof GapCursorSelection;
const wrapper = isGapCursorSelection ? $from.parent : $from.node($from.depth - 1);
if (wrapper && wrapper.type === listItem) {
/** Check if the wrapper has any visible content */
const wrapperHasContent = hasVisibleContent(wrapper);
if (!wrapperHasContent) {
return editorCommandToPMCommand(outdentList(editorAnalyticsAPI)(INPUT_METHOD.KEYBOARD))(state, dispatch);
} else if (!hasParentNodeOfType(codeBlock)(selection)) {
return splitListItem(listItem)(state, dispatch);
}
}
}
return false;
};
export const backspaceKeyCommand = editorAnalyticsAPI => () => (state, dispatch) => {
return chainCommands(listBackspace(editorAnalyticsAPI),
// if we're at the start of a list item, we need to either backspace
// directly to an empty list item above, or outdent this node
filter([isEmptySelectionAtStart,
// list items might have multiple paragraphs; only do this at the first one
isFirstChildOfParent, state => isInsideListItem(state.tr)], chainCommands(deletePreviousEmptyListItem, editorCommandToPMCommand(outdentList(editorAnalyticsAPI)(INPUT_METHOD.KEYBOARD)))),
// if we're just inside a paragraph node (or gapcursor is shown) and backspace, then try to join
// the text to the previous list item, if one exists
filter([isEmptySelectionAtStart, state => canJoinToPreviousListItem(state.tr)], joinToPreviousListItem))(state, dispatch);
};
export const deleteKeyCommand = editorAnalyticsAPI => joinListItemForward(editorAnalyticsAPI);
// Get the depth of the nearest ancestor list
export const rootListDepth = (pos, nodes) => {
const {
bulletList,
orderedList,
listItem
} = nodes;
let depth;
for (let i = pos.depth - 1; i > 0; i--) {
const node = pos.node(i);
if (node.type === bulletList || node.type === orderedList) {
depth = i;
}
if (node.type !== bulletList && node.type !== orderedList && node.type !== listItem) {
break;
}
}
return depth;
};
function untoggleSelectedList(tr) {
const {
selection
} = tr;
const depth = rootListDepth(selection.$to, tr.doc.type.schema.nodes);
tr = liftFollowingList(selection.$to.pos, selection.$to.end(depth), depth || 0, tr);
if (selection instanceof NodeSelection || selection instanceof GapCursorSelection) {
return liftNodeSelectionList(selection, tr);
}
return liftTextSelectionList(selection, tr);
}
export const toggleList = editorAnalyticsAPI => (inputMethod, listType) => {
return function ({
tr
}) {
const {
taskList
} = tr.doc.type.schema.nodes;
if (hasParentNodeOfType(taskList)(tr.selection)) {
return tr;
}
const listInsideSelection = selectionContainsList(tr);
const listNodeType = tr.doc.type.schema.nodes[listType];
const actionSubjectId = listType === 'bulletList' ? ACTION_SUBJECT_ID.FORMAT_LIST_BULLET : ACTION_SUBJECT_ID.FORMAT_LIST_NUMBER;
if (listInsideSelection) {
const {
selection
} = tr;
// for gap cursor or node selection - list is expected 1 level up (listItem -> list)
// for text selection - list is expected 2 levels up (paragraph -> listItem -> list)
const positionDiff = selection instanceof GapCursorSelection || selection instanceof NodeSelection ? 1 : 2;
const fromNode = selection.$from.node(selection.$from.depth - positionDiff);
const toNode = selection.$to.node(selection.$to.depth - positionDiff);
const transformedFrom = listInsideSelection.type.name === 'bulletList' ? ACTION_SUBJECT_ID.FORMAT_LIST_BULLET : ACTION_SUBJECT_ID.FORMAT_LIST_NUMBER;
if ((fromNode === null || fromNode === void 0 ? void 0 : fromNode.type.name) === listType && (toNode === null || toNode === void 0 ? void 0 : toNode.type.name) === listType) {
const commonAttributes = getCommonListAnalyticsAttributes(tr);
untoggleSelectedList(tr);
editorAnalyticsAPI === null || editorAnalyticsAPI === void 0 ? void 0 : editorAnalyticsAPI.attachAnalyticsEvent({
action: ACTION.CONVERTED,
actionSubject: ACTION_SUBJECT.LIST,
actionSubjectId: ACTION_SUBJECT_ID.TEXT,
eventType: EVENT_TYPE.TRACK,
attributes: {
...commonAttributes,
transformedFrom,
inputMethod
}
})(tr);
return tr;
}
convertListType({
tr,
nextListNodeType: listNodeType
});
editorAnalyticsAPI === null || editorAnalyticsAPI === void 0 ? void 0 : editorAnalyticsAPI.attachAnalyticsEvent({
action: ACTION.CONVERTED,
actionSubject: ACTION_SUBJECT.LIST,
actionSubjectId,
eventType: EVENT_TYPE.TRACK,
attributes: {
...getCommonListAnalyticsAttributes(tr),
transformedFrom,
inputMethod
}
})(tr);
} else {
// Need to have this before wrapInList so the wrapping is done with valid content
// For example, if trying to convert centre or right aligned paragraphs to lists
sanitiseMarksInSelection(tr, listNodeType);
wrapInListAndJoin(listNodeType, tr);
editorAnalyticsAPI === null || editorAnalyticsAPI === void 0 ? void 0 : editorAnalyticsAPI.attachAnalyticsEvent({
action: ACTION.INSERTED,
actionSubject: ACTION_SUBJECT.LIST,
actionSubjectId,
eventType: EVENT_TYPE.TRACK,
attributes: {
inputMethod
}
})(tr);
}
// If document wasn't changed, return false from the command to indicate that the
// editing action failed
if (!tr.docChanged) {
return null;
}
return tr;
};
};
export const toggleBulletList = editorAnalyticsAPI => (inputMethod = INPUT_METHOD.TOOLBAR) => {
return toggleList(editorAnalyticsAPI)(inputMethod, 'bulletList');
};
export const toggleOrderedList = editorAnalyticsAPI => (inputMethod = INPUT_METHOD.TOOLBAR) => {
return toggleList(editorAnalyticsAPI)(inputMethod, 'orderedList');
};
/**
* Implementation taken and modified for our needs from PM
* @param itemType Node
* Splits the list items, specific implementation take from PM
*/
function splitListItem(itemType) {
return function (state, dispatch) {
const ref = state.selection;
const $from = ref.$from;
const $to = ref.$to;
const node = ref.node;
if (node && node.isBlock || $from.depth < 2 || !$from.sameParent($to)) {
return false;
}
// list item is the parent of the gap cursor instead of grandparent
const isGapCursorSelection = ref instanceof GapCursorSelection;
const wrapperListItem = isGapCursorSelection ? $from.parent : $from.node(-1);
if (wrapperListItem.type !== itemType) {
return false;
}
/** --> The following line changed from the original PM implementation to allow list additions with multiple paragraphs */
if (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
wrapperListItem.content.content.length <= 1 && $from.parent.content.size === 0 && !(wrapperListItem.content.size === 0)) {
// In an empty block. If this is a nested list, the wrapping
// list item should be split. Otherwise, bail out and let next
// command handle lifting.
if ($from.depth === 2 || $from.node(-3).type !== itemType || $from.index(-2) !== $from.node(-2).childCount - 1) {
return false;
}
if (dispatch) {
let wrap = Fragment.empty;
const keepItem = $from.index(-1) > 0;
// Build a fragment containing empty versions of the structure
// from the outer list item to the parent node of the cursor
for (let d = $from.depth - (keepItem ? 1 : 2); d >= $from.depth - 3; d--) {
wrap = Fragment.from($from.node(d).copy(wrap));
}
// Add a second list item with an empty default start node
// Ignored via go/ees005
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
wrap = wrap.append(Fragment.from(itemType.createAndFill()));
const tr$1 = state.tr.replace($from.before(keepItem ? undefined : -1), $from.after(-3), new Slice(wrap, keepItem ? 3 : 2, 2));
tr$1.setSelection(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
state.selection.constructor.near(tr$1.doc.resolve($from.pos + (keepItem ? 3 : 2))));
dispatch(tr$1.scrollIntoView());
}
return true;
}
const nextType = $to.pos === $from.end() ? wrapperListItem.contentMatchAt(0).defaultType : null;
const tr = state.tr.delete($from.pos, $to.pos);
const types = nextType && [null, {
type: nextType
}];
const fontSize = state.schema.marks.fontSize;
const isFontSizeSupported = fontSize && expValEquals('platform_editor_small_font_size', 'isEnabled', true);
const currentFontSizeAttrs = isFontSizeSupported ? getBlockMarkAttrs($from.parent, fontSize) : false;
if (dispatch) {
var _$from$nodeBefore;
if (ref instanceof TextSelection) {
const splitTr = tr.split($from.pos, 2, types !== null && types !== void 0 ? types : undefined);
if (isFontSizeSupported) {
reconcileBlockMarkForParagraphAtPos(splitTr, splitTr.selection.from, fontSize, currentFontSizeAttrs);
}
dispatch(splitTr.scrollIntoView());
return true;
}
// create new list item with empty paragraph when user clicks enter on gap cursor
if (isGapCursorSelection && (_$from$nodeBefore = $from.nodeBefore) !== null && _$from$nodeBefore !== void 0 && _$from$nodeBefore.isBlock) {
// For gap cursor selection, we cannot split the list item directly
// We need to insert a new list item after the current list item to simulate the split behaviour
const {
listItem,
paragraph
} = state.schema.nodes;
const newListItem = listItem.createChecked({}, paragraph.createChecked({}, undefined, currentFontSizeAttrs && isFontSizeSupported ? [fontSize.create(currentFontSizeAttrs)] : undefined));
dispatch(tr.insert($from.pos, newListItem).setSelection(Selection.near(tr.doc.resolve($to.pos + 1))).scrollIntoView());
return true;
}
}
return false;
};
}
const deletePreviousEmptyListItem = (state, dispatch) => {
const {
$from
} = state.selection;
const {
listItem
} = state.schema.nodes;
const $cut = findCutBefore($from);
if (!$cut || !$cut.nodeBefore || !($cut.nodeBefore.type === listItem)) {
return false;
}
const nodeBeforeIsExtension = $cut.nodeBefore.firstChild && $cut.nodeBefore.firstChild.type.name === 'extension';
const previousListItemEmpty =
// Ignored via go/ees005
$cut.nodeBefore.childCount === 1 && $cut.nodeBefore.firstChild && $cut.nodeBefore.firstChild.nodeSize <= 2 && !nodeBeforeIsExtension;
if (previousListItemEmpty) {
const {
tr
} = state;
if (dispatch) {
dispatch(tr.delete($cut.pos - $cut.nodeBefore.nodeSize, $from.pos).scrollIntoView());
}
return true;
}
return false;
};
const joinToPreviousListItem = (state, dispatch) => {
const {
$from
} = state.selection;
const {
paragraph,
listItem,
codeBlock,
bulletList,
orderedList
} = state.schema.nodes;
const {
fontSize
} = state.schema.marks;
const isGapCursorShown = state.selection instanceof GapCursorSelection;
const $cutPos = isGapCursorShown ? state.doc.resolve($from.pos + 1) : $from;
const $cut = findCutBefore($cutPos);
if (!$cut) {
return false;
}
// see if the containing node is a list
if ($cut.nodeBefore && [bulletList, orderedList].indexOf($cut.nodeBefore.type) > -1) {
// and the node after this is a paragraph or a codeBlock
if ($cut.nodeAfter && ($cut.nodeAfter.type === paragraph || $cut.nodeAfter.type === codeBlock)) {
// find the nearest paragraph that precedes this node
let $lastNode = $cut.doc.resolve($cut.pos - 1);
while ($lastNode.parent.type !== paragraph && $lastNode.pos > 1) {
$lastNode = state.doc.resolve($lastNode.pos - 1);
}
let {
tr
} = state;
if (isGapCursorShown) {
const nodeBeforePos = findPositionOfNodeBefore(tr.selection);
if (typeof nodeBeforePos !== 'number') {
return false;
}
// append the codeblock to the list node
const list = $cut.nodeBefore.copy($cut.nodeBefore.content.append(Fragment.from(listItem.createChecked({}, $cut.nodeAfter))));
tr.replaceWith(nodeBeforePos, $from.pos + $cut.nodeAfter.nodeSize, list);
} else {
const step = moveTargetIntoList({
insertPosition: $lastNode.pos,
$target: $cut
});
// ED-13966: check if the step will cause an ProseMirror error
// if there's an error don't apply the step as it will might lead into a data loss.
// It doesn't play well with media being a leaf node.
const stepResult = state.tr.maybeStep(step);
if (stepResult.failed) {
return false;
} else {
tr = state.tr.step(step);
}
}
// find out if there's now another list following and join them
// as in, [list, p, list] => [list with p, list], and we want [joined list]
const $postCut = tr.doc.resolve(tr.mapping.map($cut.pos + $cut.nodeAfter.nodeSize));
if ($postCut.nodeBefore && $postCut.nodeAfter && $postCut.nodeBefore.type === $postCut.nodeAfter.type && [bulletList, orderedList].indexOf($postCut.nodeBefore.type) > -1) {
tr = tr.join($postCut.pos);
}
if (fontSize && expValEquals('platform_editor_small_font_size', 'isEnabled', true)) {
const prevListFontSizeAttrs = getFirstParagraphBlockMarkAttrs($cut.nodeBefore, fontSize);
const containingList = findParentNodeOfTypeClosestToPos(tr.doc.resolve(tr.mapping.map($cut.pos)), [bulletList, orderedList]);
if (containingList) {
reconcileBlockMarkForContainerAtPos(tr, containingList.pos, fontSize, prevListFontSizeAttrs);
}
}
if (dispatch) {
var _tr$doc$resolve$nodeB;
if (!((_tr$doc$resolve$nodeB = tr.doc.resolve($lastNode.pos).nodeBefore) !== null && _tr$doc$resolve$nodeB !== void 0 && _tr$doc$resolve$nodeB.isBlock) || tr.doc.resolve($lastNode.pos).nodeBefore === null) {
tr = tr.setSelection(TextSelection.near(tr.doc.resolve(tr.mapping.map($cut.pos)), -1));
}
dispatch(tr.scrollIntoView());
}
return true;
}
}
return false;
};