@atlaskit/editor-plugin-list
Version:
List plugin for @atlaskit/editor-core
251 lines (245 loc) • 9.75 kB
JavaScript
import { isListNode } from '@atlaskit/editor-common/utils';
import { Fragment, NodeRange, Slice } from '@atlaskit/editor-prosemirror/model';
import { TextSelection } from '@atlaskit/editor-prosemirror/state';
import { liftTarget, ReplaceAroundStep, ReplaceStep } from '@atlaskit/editor-prosemirror/transform';
import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals';
import { getListLiftTarget } from './utils/indentation';
function liftListItem(selection, tr) {
const {
$from,
$to
} = selection;
const nodeType = tr.doc.type.schema.nodes.listItem;
let range = $from.blockRange($to, node => !!node.childCount && !!node.firstChild && node.firstChild.type === nodeType);
if (!range || range.depth < 2 || $from.node(range.depth - 1).type !== nodeType) {
return tr;
}
const end = range.end;
const endOfList = $to.end(range.depth);
if (end < endOfList) {
tr.step(new ReplaceAroundStep(end - 1, endOfList, end, endOfList, new Slice(Fragment.from(nodeType.create(undefined, range.parent.copy())), 1, 0), 1, true));
range = new NodeRange(tr.doc.resolve($from.pos), tr.doc.resolve(endOfList), range.depth);
}
return tr.lift(range, liftTarget(range)).scrollIntoView();
}
// Function will lift list item following selection to level-1.
export function liftFollowingList(from, to, rootListDepth, tr) {
const {
listItem
} = tr.doc.type.schema.nodes;
let lifted = false;
tr.doc.nodesBetween(from, to, (node, pos) => {
if (!lifted && node.type === listItem && pos > from) {
lifted = true;
let listDepth = rootListDepth + 3;
while (listDepth > rootListDepth + 2) {
const start = tr.doc.resolve(tr.mapping.map(pos));
listDepth = start.depth;
const end = tr.doc.resolve(tr.mapping.map(pos + node.textContent.length));
const sel = new TextSelection(start, end);
tr = liftListItem(sel, tr);
}
}
});
return tr;
}
export function liftNodeSelectionList(selection, tr) {
const {
from
} = selection;
const {
listItem
} = tr.doc.type.schema.nodes;
const mappedPosition = tr.mapping.map(from);
const nodeAtPos = tr.doc.nodeAt(mappedPosition);
const start = tr.doc.resolve(mappedPosition);
if ((start === null || start === void 0 ? void 0 : start.parent.type) !== listItem) {
return tr;
}
const end = tr.doc.resolve(mappedPosition + ((nodeAtPos === null || nodeAtPos === void 0 ? void 0 : nodeAtPos.nodeSize) || 1));
const range = start.blockRange(end);
if (range) {
const liftTarget = getListLiftTarget(start);
tr.lift(range, liftTarget);
}
return tr;
}
// The function will list paragraphs in selection out to level 1 below root list.
export function liftTextSelectionList(selection, tr) {
const {
from,
to
} = selection;
const {
paragraph
} = tr.doc.type.schema.nodes;
const listCol = [];
tr.doc.nodesBetween(from, to, (node, pos) => {
if (node.type === paragraph) {
listCol.push({
node,
pos
});
}
});
for (let i = listCol.length - 1; i >= 0; i--) {
const paragraph = listCol[i];
const start = tr.doc.resolve(tr.mapping.map(paragraph.pos));
if (start.depth > 0) {
let end;
if (paragraph.node.textContent && paragraph.node.textContent.length > 0) {
end = tr.doc.resolve(tr.mapping.map(paragraph.pos + paragraph.node.textContent.length));
} else {
end = tr.doc.resolve(tr.mapping.map(paragraph.pos + 1));
}
const range = start.blockRange(end);
if (range) {
tr.lift(range, getListLiftTarget(start));
}
}
}
return tr;
}
/**
* Finds the top-level list nodes (bulletList/orderedList) that contain the positions
* affected by the given transactions. Returns a map of list node position → list node,
* so callers can scan only the affected subtrees rather than the entire document.
*/
function getAffectedListsFromTransactions(transactions, doc, schema) {
const {
bulletList,
orderedList
} = schema.nodes;
const listTypes = [bulletList, orderedList].filter(Boolean);
if (listTypes.length === 0) {
return new Map();
}
const result = new Map();
for (const tr of transactions) {
for (const step of tr.steps) {
// ReplaceStep and ReplaceAroundStep both have from/to — other step types are skipped.
if (!(step instanceof ReplaceStep) && !(step instanceof ReplaceAroundStep)) {
continue;
}
// Check both the start and end of each changed range, mapped to post-transaction positions.
for (const rawPos of [step.from, step.to]) {
const mappedPos = Math.min(tr.mapping.map(rawPos), doc.content.size - 1);
const $pos = doc.resolve(mappedPos);
// Walk ancestors from inner to outer, recording the outermost list node.
// Once we find a list and then exit list structure (hit a non-list ancestor),
// break early — prevents container nodes (e.g. panel) from causing us to
// return an outer list that is in a different structural context.
// $pos.node(depth) is O(1) array access.
let rootListPos = null;
let rootListNode = null;
for (let depth = $pos.depth; depth >= 0; depth--) {
const node = $pos.node(depth);
if (listTypes.includes(node.type)) {
rootListPos = $pos.before(depth);
rootListNode = node;
} else if (rootListNode !== null && node.type !== schema.nodes.listItem) {
// We've exited the list structure — stop walking.
break;
}
}
if (rootListPos !== null && rootListNode !== null) {
result.set(rootListPos, rootListNode);
}
}
}
}
return result;
}
/**
* Applies list normalisation fixes to the given transaction for all affected list subtrees.
* Processes nodes in reverse document order so that position offsets from insertions/joins
* do not affect earlier positions.
*
* When platform_editor_flexible_list_indentation is off: inserts an empty paragraph before any listItem whose
* first child is a list node, and merges adjacent same-type list nodes within a listItem.
* When platform_editor_flexible_list_indentation is on: only merges adjacent same-type list nodes.
*/
export function applyListNormalisationFixes({
tr,
transactions,
doc,
schema
}) {
const affectedLists = getAffectedListsFromTransactions(transactions, doc, schema);
if (affectedLists.size === 0) {
return tr;
}
const {
listItem,
paragraph,
bulletList,
orderedList,
taskList
} = schema.nodes;
if (!listItem) {
return tr;
}
const nestedListTypes = [bulletList, orderedList, taskList].filter(Boolean);
// Process lists in reverse position order so fixes at higher positions
// don't shift the positions of fixes at lower positions.
const sortedEntries = [...affectedLists.entries()].sort(([posA], [posB]) => posB - posA);
for (const [listPos] of sortedEntries) {
// Re-resolve the list node from the current transaction doc (post-paste state),
// as the original listNode snapshot may be stale after the paste transaction.
const mappedListPos = tr.mapping.map(listPos);
const currentListNode = tr.doc.nodeAt(mappedListPos);
if (!currentListNode) {
continue;
}
// Collect all listItem positions at all depths in document order, then process in
// reverse so that fixes at higher positions don't shift positions of lower ones.
const listItemPositions = [];
currentListNode.descendants((node, offsetPos) => {
if (node.type === listItem) {
listItemPositions.push(mappedListPos + 1 + offsetPos);
}
return true;
});
for (let i = listItemPositions.length - 1; i >= 0; i--) {
const mappedPos = tr.mapping.map(listItemPositions[i]);
const node = tr.doc.nodeAt(mappedPos);
if (!node || node.type !== listItem) {
continue;
}
// Merge adjacent same-type list nodes (highest boundary first within the listItem).
for (let j = node.childCount - 1; j > 0; j--) {
const child = node.child(j);
const prevChild = node.child(j - 1);
if (isListNode(child) && child.type === prevChild.type) {
let offset = 1; // +1 for listItem opening token
for (let k = 0; k < j; k++) {
offset += node.child(k).nodeSize;
}
try {
tr.join(mappedPos + offset);
} catch (e) {
// join may fail if position is invalid after earlier transforms — skip
// eslint-disable-next-line no-console
console.warn('[editor-plugin-list] applyListNormalisationFixes: unexpected join failure', e);
}
}
}
// Insert empty paragraph before a list-type first child when _indentation is off.
// Only list types (bulletList, orderedList, taskList) are invalid as a first child —
// other non-paragraph types (mediaSingle, codeBlock, extension) are valid per the schema.
if (paragraph && !expValEquals('platform_editor_flexible_list_indentation', 'isEnabled', true)) {
// Re-map position after any join steps that may have been added above.
const remappedPos = tr.mapping.map(listItemPositions[i]);
const currentNode = tr.doc.nodeAt(remappedPos);
const firstChild = currentNode === null || currentNode === void 0 ? void 0 : currentNode.firstChild;
if (firstChild && nestedListTypes.includes(firstChild.type)) {
const emptyParagraph = paragraph.createAndFill();
if (emptyParagraph) {
tr.insert(remappedPos + 1, emptyParagraph);
}
}
}
}
}
return tr;
}