@atlaskit/editor-plugin-list
Version:
List plugin for @atlaskit/editor-core
395 lines (385 loc) • 19.5 kB
JavaScript
import _defineProperty from "@babel/runtime/helpers/defineProperty";
function _createForOfIteratorHelper(r, e) { var t = "undefined" != typeof Symbol && r[Symbol.iterator] || r["@@iterator"]; if (!t) { if (Array.isArray(r) || (t = _unsupportedIterableToArray(r)) || e && r && "number" == typeof r.length) { t && (r = t); var _n = 0, F = function F() {}; return { s: F, n: function n() { return _n >= r.length ? { done: !0 } : { done: !1, value: r[_n++] }; }, e: function e(r) { throw r; }, f: F }; } throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } var o, a = !0, u = !1; return { s: function s() { t = t.call(r); }, n: function n() { var r = t.next(); return a = r.done, r; }, e: function e(r) { u = !0, o = r; }, f: function f() { try { a || null == t.return || t.return(); } finally { if (u) throw o; } } }; }
function _unsupportedIterableToArray(r, a) { if (r) { if ("string" == typeof r) return _arrayLikeToArray(r, a); var t = {}.toString.call(r).slice(8, -1); return "Object" === t && r.constructor && (t = r.constructor.name), "Map" === t || "Set" === t ? Array.from(r) : "Arguments" === t || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t) ? _arrayLikeToArray(r, a) : void 0; } }
function _arrayLikeToArray(r, a) { (null == a || a > r.length) && (a = r.length); for (var e = 0, n = Array(a); e < a; e++) n[e] = r[e]; return n; }
function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; }
function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { _defineProperty(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; }
import { SafePlugin } from '@atlaskit/editor-common/safe-plugin';
import { setGapCursorSelection, Side } from '@atlaskit/editor-common/selection';
import { CodeBlockSharedCssClassName, getOrderedListInlineStyles, listItemCounterPadding } from '@atlaskit/editor-common/styles';
import { getItemCounterDigitsSize, isListNode, pluginFactory } from '@atlaskit/editor-common/utils';
import { PluginKey } from '@atlaskit/editor-prosemirror/state';
import { findParentNodeOfType } from '@atlaskit/editor-prosemirror/utils';
import { Decoration, DecorationSet } from '@atlaskit/editor-prosemirror/view';
import { isExperimentEnabled } from '@atlaskit/platform-feature-experiments/is-experiment-enabled';
import { applyListNormalisationFixes } from './transforms';
import { isWrappingPossible } from './utils/selection';
var listPluginKey = new PluginKey('listPlugin');
export var pluginKey = listPluginKey;
var initialState = {
bulletListActive: false,
bulletListDisabled: false,
orderedListActive: false,
orderedListDisabled: false,
decorationSet: DecorationSet.empty,
listStructureToken: 0
};
/**
* Numbered lists whose item counters reach 2+ digits need extra gutter spacing so the counter
* does not collide with the item content.
*/
var getItemCounterPaddingStyle = function getItemCounterPaddingStyle(node) {
var _node$attrs;
if (node.type.name !== 'orderedList') {
return undefined;
}
var digitsSize = getItemCounterDigitsSize({
itemsCount: node === null || node === void 0 ? void 0 : node.childCount,
order: node === null || node === void 0 || (_node$attrs = node.attrs) === null || _node$attrs === void 0 ? void 0 : _node$attrs.order
});
return digitsSize && digitsSize > 1 ? getOrderedListInlineStyles(digitsSize, 'string') : undefined;
};
/**
* Builds the decorations for a range aligned to top-level block boundaries — or for the whole
* document, which is the same thing.
*
* Indentation level is no longer decorated: the `:is(ul, ol)` rules in
* `editor-core/src/ui/EditorContentContainer/styles/list.ts` derive the marker from the list's
* ancestors in CSS instead. All that remains is the ordered-list counter gutter, which depends on
* the item count and start number and so cannot be expressed as a selector.
*
* Textblocks only hold inline content, so they can never contain a list. Skipping their subtrees
* avoids visiting every text node in the range.
*/
var getDecorationsForRange = function getDecorationsForRange(doc, from, to) {
var decorations = [];
doc.nodesBetween(from, to, function (node, currentNodeStartPos) {
if (node.isTextblock) {
return false;
}
var style = getItemCounterPaddingStyle(node);
if (style) {
decorations.push(Decoration.node(currentNodeStartPos, currentNodeStartPos + node.nodeSize, {
style: style
}));
}
return true;
});
return decorations;
};
/**
* Full-document rebuild on the improved path, used when the decoration set has no previous value
* to update. The whole document is just one aligned range.
*/
export var getDecorationsForDocument = function getDecorationsForDocument(doc) {
return DecorationSet.empty.add(doc, getDecorationsForRange(doc, 0, doc.content.size));
};
export var getDecorations = function getDecorations(doc, _state, _featureFlags) {
var decorations = [];
// this stack keeps track of each (nested) list to calculate the indentation level
var processedListsStack = [];
doc.nodesBetween(0, doc.content.size, function (node, currentNodeStartPos) {
if (processedListsStack.length > 0) {
var isOutsideLastList = true;
while (isOutsideLastList && processedListsStack.length > 0) {
var lastList = processedListsStack[processedListsStack.length - 1];
var lastListEndPos = lastList.startPos + lastList.node.nodeSize;
isOutsideLastList = currentNodeStartPos >= lastListEndPos;
// once we finish iterating over each innermost list, pop the stack to
// decrease the indent level attribute accordingly
if (isOutsideLastList) {
processedListsStack.pop();
}
}
}
if (isListNode(node)) {
processedListsStack.push({
node: node,
startPos: currentNodeStartPos
});
var from = currentNodeStartPos;
var to = currentNodeStartPos + node.nodeSize;
var depth = processedListsStack.length;
decorations.push(Decoration.node(from, to, {
'data-indent-level': "".concat(depth)
}));
if (node.type.name === 'orderedList') {
var _node$attrs2;
// If a numbered list has item counters numbering >= 100, we'll need to add special
// spacing to account for the extra digit chars
var digitsSize = getItemCounterDigitsSize({
itemsCount: node === null || node === void 0 ? void 0 : node.childCount,
order: node === null || node === void 0 || (_node$attrs2 = node.attrs) === null || _node$attrs2 === void 0 ? void 0 : _node$attrs2.order
});
if (digitsSize && digitsSize > 1) {
decorations.push(Decoration.node(from, to, {
style: getOrderedListInlineStyles(digitsSize, 'string')
}));
}
}
}
});
return DecorationSet.empty.add(doc, decorations);
};
/**
* The parts of a transaction the decoration update needs, so that both `Transaction` and
* `ReadonlyTransaction` can be passed in.
*/
/**
* Expands the ranges touched by a transaction out to whole top-level blocks. A list decoration
* depends only on the subtree of the top-level block containing it, so recomputing whole blocks is
* enough — and it keeps the depth calculation free of any ancestor accounting.
*/
var getDirtyTopLevelRanges = function getDirtyTopLevelRanges(tr, doc) {
var docSize = doc.content.size;
var wholeDoc = [{
from: 0,
to: docSize
}];
var boundsAt = function boundsAt(pos) {
// resolving a position strictly inside a block gives us its boundaries in O(depth)
var inside = Math.max(1, Math.min(pos, Math.max(1, docSize - 1)));
var $inside = doc.resolve(inside);
if ($inside.depth > 0) {
return {
from: $inside.before(1),
to: $inside.after(1)
};
}
// exactly between two top-level blocks — cover both neighbours
var $before = doc.resolve(Math.max(1, inside - 1));
var $after = doc.resolve(Math.min(Math.max(1, docSize - 1), inside + 1));
return {
from: $before.depth > 0 ? $before.before(1) : 0,
to: $after.depth > 0 ? $after.after(1) : docSize
};
};
var ranges = [];
for (var index = 0; index < tr.steps.length; index++) {
var step = tr.steps[index];
// Duck-typed rather than matched on step class so that unknown step types fall back to a
// full recompute instead of being silently skipped.
// Ignored via go/ees005
// eslint-disable-next-line @typescript-eslint/no-explicit-any
var _ref = step,
from = _ref.from,
to = _ref.to,
pos = _ref.pos;
var positions = typeof from === 'number' && typeof to === 'number' ? [from, to] : typeof pos === 'number' ? [pos] : [];
if (positions.length === 0) {
return wholeDoc;
}
// Step positions are in the document before this step, so map through the later steps. The
// range start is biased left and the end biased right, so that neither slides across a
// block boundary and leaves a dirty block out of the recompute.
var remainder = tr.mapping.slice(index);
var start = boundsAt(remainder.map(Math.min.apply(Math, positions), -1));
var end = boundsAt(remainder.map(Math.max.apply(Math, positions), 1));
ranges.push({
from: Math.min(start.from, end.from),
to: Math.max(start.to, end.to)
});
}
ranges.sort(function (a, b) {
return a.from - b.from;
});
var merged = [];
for (var _i = 0, _ranges = ranges; _i < _ranges.length; _i++) {
var range = _ranges[_i];
var last = merged[merged.length - 1];
if (last && range.from <= last.to) {
last.to = Math.max(last.to, range.to);
} else {
merged.push(_objectSpread({}, range));
}
}
return merged;
};
/**
* Maps the previous decoration set through the transaction and recomputes only the top-level blocks
* it touched, instead of rebuilding the whole set from a full document scan.
*/
export var updateDecorations = function updateDecorations(previousDecorationSet, tr, doc) {
var decorationSet = previousDecorationSet.map(tr.mapping, doc);
var _iterator = _createForOfIteratorHelper(getDirtyTopLevelRanges(tr, doc)),
_step;
try {
var _loop = function _loop() {
var _step$value = _step.value,
from = _step$value.from,
to = _step$value.to;
// find() also returns decorations that merely touch the range, so removal is restricted to
// the ones fully inside it — those are exactly the decorations recomputed below. Removing a
// decoration that only touches the boundary would drop it permanently.
var stale = decorationSet.find(from, to).filter(function (decoration) {
return decoration.from >= from && decoration.to <= to;
});
if (stale.length > 0) {
decorationSet = decorationSet.remove(stale);
}
var fresh = getDecorationsForRange(doc, from, to);
if (fresh.length > 0) {
decorationSet = decorationSet.add(doc, fresh);
}
};
for (_iterator.s(); !(_step = _iterator.n()).done;) {
_loop();
}
} catch (err) {
_iterator.e(err);
} finally {
_iterator.f();
}
return decorationSet;
};
/**
* The selection-derived part of the plugin state. `decorationSet` and `listStructureToken` are
* excluded because they depend on the previous state rather than on the current selection.
*/
var getListState = function getListState(doc, selection) {
var _doc$type$schema$node = doc.type.schema.nodes,
bulletList = _doc$type$schema$node.bulletList,
orderedList = _doc$type$schema$node.orderedList,
taskList = _doc$type$schema$node.taskList;
var listParent = findParentNodeOfType([bulletList, orderedList, taskList])(selection);
var bulletListActive = !!listParent && listParent.node.type === bulletList;
var orderedListActive = !!listParent && listParent.node.type === orderedList;
var bulletListDisabled = !(bulletListActive || orderedListActive || isWrappingPossible(bulletList, selection));
var orderedListDisabled = !(bulletListActive || orderedListActive || isWrappingPossible(orderedList, selection));
return {
bulletListActive: bulletListActive,
bulletListDisabled: bulletListDisabled,
orderedListActive: orderedListActive,
orderedListDisabled: orderedListDisabled
};
};
/**
* Bumps `listStructureToken` only when the selection sits inside a list, so that editing a
* document with no list involvement does not churn toolbar renders.
*/
var withListStructureToken = function withListStructureToken(nextPluginState, pluginState) {
var isInList = nextPluginState.bulletListActive || nextPluginState.orderedListActive || pluginState.bulletListActive || pluginState.orderedListActive;
return isInList ? pluginState.listStructureToken + 1 : pluginState.listStructureToken;
};
var handleDocChangedOld = function handleDocChangedOld(featureFlags) {
return function (tr, pluginState, editorState) {
var nextPluginState = handleSelectionChanged(tr, pluginState);
return _objectSpread(_objectSpread({}, nextPluginState), {}, {
decorationSet: getDecorations(tr.doc, editorState, featureFlags),
listStructureToken: withListStructureToken(nextPluginState, pluginState)
});
};
};
var handleDocChangedNew = function handleDocChangedNew() {
return function (tr, pluginState, editorState) {
var nextPluginState = handleSelectionChanged(tr, pluginState);
return _objectSpread(_objectSpread({}, nextPluginState), {}, {
decorationSet: updateDecorations(pluginState.decorationSet, tr, tr.doc),
listStructureToken: withListStructureToken(nextPluginState, pluginState)
});
};
};
var handleSelectionChanged = function handleSelectionChanged(tr, pluginState) {
var _getListState = getListState(tr.doc, tr.selection),
bulletListActive = _getListState.bulletListActive,
orderedListActive = _getListState.orderedListActive,
bulletListDisabled = _getListState.bulletListDisabled,
orderedListDisabled = _getListState.orderedListDisabled;
if (bulletListActive !== pluginState.bulletListActive || orderedListActive !== pluginState.orderedListActive || bulletListDisabled !== pluginState.bulletListDisabled || orderedListDisabled !== pluginState.orderedListDisabled) {
var nextPluginState = _objectSpread(_objectSpread({}, pluginState), {}, {
bulletListActive: bulletListActive,
orderedListActive: orderedListActive,
bulletListDisabled: bulletListDisabled,
orderedListDisabled: orderedListDisabled
});
return nextPluginState;
}
return pluginState;
};
var reducer = function reducer() {
return function (state) {
return state;
};
};
var createInitialStateOld = function createInitialStateOld(featureFlags, api) {
return function (state) {
var isToolbarAIFCEnabled = Boolean(api === null || api === void 0 ? void 0 : api.toolbar);
return _objectSpread(_objectSpread({}, isToolbarAIFCEnabled ? getListState(state.doc, state.selection) : initialState), {}, {
decorationSet: getDecorations(state.doc, state, featureFlags),
listStructureToken: 0
});
};
};
var createInitialStateNew = function createInitialStateNew(featureFlags, api) {
return function (state) {
var isToolbarAIFCEnabled = Boolean(api === null || api === void 0 ? void 0 : api.toolbar);
return _objectSpread(_objectSpread({}, isToolbarAIFCEnabled ? getListState(state.doc, state.selection) : initialState), {}, {
decorationSet: getDecorationsForDocument(state.doc),
listStructureToken: 0
});
};
};
export var createPlugin = function createPlugin(eventDispatch, featureFlags, api) {
var _pluginFactory = pluginFactory(listPluginKey, reducer(), {
// Resolved once per editor instance rather than per transaction, so the exposure event fires
// once and the decoration hot path stays free of experiment lookups.
onDocChanged: isExperimentEnabled('platform_editor_list_performance_improv') ? handleDocChangedNew() : handleDocChangedOld(featureFlags),
onSelectionChanged: handleSelectionChanged
}),
getPluginState = _pluginFactory.getPluginState,
createPluginState = _pluginFactory.createPluginState;
return new SafePlugin({
state: createPluginState(eventDispatch, isExperimentEnabled('platform_editor_list_performance_improv') ? createInitialStateNew(featureFlags, api) : createInitialStateOld(featureFlags, api)),
key: listPluginKey,
appendTransaction: function appendTransaction(transactions, _oldState, newState) {
if (!transactions.some(function (t) {
return t.docChanged;
})) {
return null;
}
// Efficiently scans only affected list nodes — exits early if none are found.
var tr = applyListNormalisationFixes({
tr: newState.tr,
transactions: transactions,
doc: newState.doc,
schema: newState.schema
});
if (tr.docChanged) {
return tr;
}
return null;
},
props: {
decorations: function decorations(state) {
var _getPluginState = getPluginState(state),
decorationSet = _getPluginState.decorationSet;
return decorationSet;
},
handleClick: function handleClick(view, pos, event) {
var state = view.state;
// Ignored via go/ees005
// eslint-disable-next-line @atlaskit/editor/no-as-casting
if (['LI', 'UL'].includes((event === null || event === void 0 ? void 0 : event.target).tagName)) {
var _nodeAtPos$firstChild;
var nodeAtPos = state.tr.doc.nodeAt(pos);
var _view$state$schema$no = view.state.schema.nodes,
listItem = _view$state$schema$no.listItem,
codeBlock = _view$state$schema$no.codeBlock;
if ((nodeAtPos === null || nodeAtPos === void 0 ? void 0 : nodeAtPos.type) === listItem && (nodeAtPos === null || nodeAtPos === void 0 || (_nodeAtPos$firstChild = nodeAtPos.firstChild) === null || _nodeAtPos$firstChild === void 0 ? void 0 : _nodeAtPos$firstChild.type) === codeBlock) {
var _document;
var bufferPx = 50;
var isCodeBlockNextToListMarker = Boolean( // eslint-disable-next-line @atlaskit/platform/no-direct-document-usage
(_document = document) === null || _document === void 0 || (_document = _document.elementFromPoint(event.clientX + (listItemCounterPadding + bufferPx), event.clientY)) === null || _document === void 0 ? void 0 : _document.closest(".".concat(CodeBlockSharedCssClassName.CODEBLOCK_CONTAINER)));
if (isCodeBlockNextToListMarker) {
// +1 needed to put cursor inside li
// otherwise gap cursor markup will be injected as immediate child of ul resulting in invalid html
setGapCursorSelection(view, pos + 1, Side.LEFT);
return true;
}
}
}
return false;
}
}
});
};