@atlaskit/editor-plugin-find-replace
Version:
find replace plugin for @atlaskit/editor-core
467 lines (457 loc) • 18 kB
JavaScript
import _defineProperty from "@babel/runtime/helpers/defineProperty";
import classnames from 'classnames';
import { timestampToString } from '@atlaskit/editor-common/utils';
import { NodeSelection, TextSelection } from '@atlaskit/editor-prosemirror/state';
import { Decoration } from '@atlaskit/editor-prosemirror/view';
import { isResolvingMentionProvider } from '@atlaskit/mention/resource';
import { isPromise, MentionNameStatus } from '@atlaskit/mention/types';
import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals';
import { getGlobalTheme } from '@atlaskit/tokens';
import { searchMatchClass, selectedSearchMatchClass, blockSearchMatchClass, darkModeSearchMatchClass, selectedBlockSearchMatchClass, searchMatchExpandTitleClass, searchMatchTextClass } from '../../ui/styles';
export function getSelectedText(selection) {
var text = '';
var selectedContent = selection.content().content;
for (var i = 0; i < selectedContent.childCount; i++) {
text += selectedContent.child(i).textContent;
}
return text;
}
export var createDecorations = function createDecorations(selectedIndex, matches) {
return matches.map(function (_ref, i) {
var start = _ref.start,
end = _ref.end,
canReplace = _ref.canReplace,
nodeType = _ref.nodeType;
return createDecoration({
start: start,
end: end,
canReplace: canReplace,
nodeType: nodeType
}, i === selectedIndex);
});
};
var isElement = function isElement(nodeType) {
return ['blockCard', 'embedCard', 'inlineCard', 'status', 'mention', 'date'].includes(nodeType || '');
};
var isExpandTitle = function isExpandTitle(match) {
return ['expand', 'nestedExpand'].includes(match.nodeType || '') && !match.canReplace;
};
export var createDecoration = function createDecoration(match, isSelected) {
var start = match.start,
end = match.end,
nodeType = match.nodeType;
if (expValEquals('platform_editor_find_and_replace_improvements', 'isEnabled', true)) {
var _getGlobalTheme = getGlobalTheme(),
colorMode = _getGlobalTheme.colorMode;
if (isElement(nodeType)) {
var className = classnames(blockSearchMatchClass, _defineProperty(_defineProperty({}, selectedBlockSearchMatchClass, isSelected), darkModeSearchMatchClass, colorMode === 'dark'));
return Decoration.node(start, end, {
class: className
});
} else if (isExpandTitle(match)) {
var _className = classnames(searchMatchExpandTitleClass, _defineProperty(_defineProperty({}, selectedSearchMatchClass, isSelected), darkModeSearchMatchClass, colorMode === 'dark'));
return Decoration.node(start, end, {
class: _className
});
} else {
var _className2 = classnames(searchMatchTextClass, _defineProperty(_defineProperty({}, selectedSearchMatchClass, isSelected), darkModeSearchMatchClass, colorMode === 'dark'));
return Decoration.inline(start, end, {
class: _className2
});
}
} else {
var _className3 = searchMatchClass;
if (isSelected) {
_className3 += " ".concat(selectedSearchMatchClass);
}
return Decoration.inline(start, end, {
class: _className3
});
}
};
export function findMatches(_ref2) {
var content = _ref2.content,
searchText = _ref2.searchText,
shouldMatchCase = _ref2.shouldMatchCase,
_ref2$contentIndex = _ref2.contentIndex,
contentIndex = _ref2$contentIndex === void 0 ? 0 : _ref2$contentIndex,
getIntl = _ref2.getIntl,
api = _ref2.api;
var matches = [];
var searchTextLength = searchText.length;
var textGrouping = null;
var collectTextMatch = function collectTextMatch(textGrouping) {
if (!textGrouping) {
return;
}
var text = textGrouping.text;
var relativePos = textGrouping.pos;
var pos = contentIndex + relativePos;
if (!shouldMatchCase) {
searchText = searchText.toLowerCase();
text = text.toLowerCase();
}
var index = text.indexOf(searchText);
while (index !== -1) {
// Find the next substring from the end of the first, so that they don't overlap
var end = index + searchTextLength;
// Add the substring index to the position of the node
matches.push({
start: pos + index,
end: pos + end,
canReplace: expValEquals('platform_editor_find_and_replace_improvements', 'isEnabled', true) ? true : undefined,
nodeType: expValEquals('platform_editor_find_and_replace_improvements', 'isEnabled', true) ? 'text' : undefined
});
index = text.indexOf(searchText, end);
}
};
var collectNodeMatch = function collectNodeMatch(textGrouping, node) {
if (!textGrouping) {
return;
}
var pos = textGrouping.pos;
var text = textGrouping.text;
if (node.type.name === 'status' && shouldMatchCase) {
text = text.toUpperCase();
} else if (!shouldMatchCase) {
text = text.toLowerCase();
searchText = searchText.toLowerCase();
}
var index = text.indexOf(searchText);
if (index !== -1) {
matches.push({
start: pos,
end: pos + node.nodeSize,
canReplace: false,
nodeType: node.type.name
});
}
};
var collectCardTitleMatch = function collectCardTitleMatch(node, pos) {
var _api$card;
var cards = api === null || api === void 0 || (_api$card = api.card) === null || _api$card === void 0 || (_api$card = _api$card.sharedState.currentState()) === null || _api$card === void 0 ? void 0 : _api$card.cards;
if (cards) {
var relevantCard = cards.find(function (card) {
return card.url === node.attrs.url;
});
var title = relevantCard === null || relevantCard === void 0 ? void 0 : relevantCard.title;
if (relevantCard) {
if (title) {
collectNodeMatch({
text: title,
pos: pos
}, node);
} else {
// when there is no title, e.g. in an error case like unauthorized
// the link card just shows the entire url as the title in inline card
if (node.type.name === 'inlineCard') {
collectNodeMatch({
text: node.attrs.url,
pos: pos
}, node);
}
}
}
}
};
if (searchTextLength > 0) {
content.descendants(function (node, pos) {
if (node.isText) {
if (textGrouping === null) {
textGrouping = {
text: node.text,
pos: pos
};
} else {
textGrouping.text = textGrouping.text + node.text;
}
} else {
collectTextMatch(textGrouping);
textGrouping = null;
if (expValEquals('platform_editor_find_and_replace_improvements', 'isEnabled', true)) {
switch (node.type.name) {
case 'status':
collectNodeMatch({
text: node.attrs.text,
pos: pos
}, node);
break;
case 'date':
collectNodeMatch({
text: timestampToString(node.attrs.timestamp, getIntl ? getIntl() : null),
pos: pos
}, node);
break;
case 'expand':
case 'nestedExpand':
collectNodeMatch({
text: node.attrs.title,
pos: pos
}, node);
break;
case 'mention':
var text;
if (node.attrs.text) {
text = node.attrs.text;
} else {
var _api$mention;
// the text may be sanitised from the node for privacy reasons
// so we need to use the mentionProvider to resolve it
var mentionProvider = api === null || api === void 0 || (_api$mention = api.mention) === null || _api$mention === void 0 || (_api$mention = _api$mention.sharedState.currentState()) === null || _api$mention === void 0 ? void 0 : _api$mention.mentionProvider;
if (isResolvingMentionProvider(mentionProvider)) {
var nameDetail = mentionProvider.resolveMentionName(node.attrs.id);
if (isPromise(nameDetail)) {
text = '@...';
} else {
if (nameDetail.status === MentionNameStatus.OK) {
text = "@".concat(nameDetail.name || '');
} else {
text = '@_|unknown|_';
}
}
}
}
if (text) {
collectNodeMatch({
text: text,
pos: pos
}, node);
}
break;
case 'inlineCard':
case 'blockCard':
case 'embedCard':
collectCardTitleMatch(node, pos);
break;
default:
break;
}
}
}
});
// if there's a dangling text grouping and no non-text node to trigger collectTextMatch, manually collectTextMatch
if (textGrouping) {
collectTextMatch(textGrouping);
}
}
return matches;
}
export function findClosestMatch(selectionPos, matches) {
var forwardMatchIndex = matches.findIndex(function (match) {
return match.start >= selectionPos;
});
if (forwardMatchIndex === -1) {
// if there are no forward matches, it must be the last match
return matches.length > 0 ? matches.length - 1 : 0;
} else if (forwardMatchIndex === 0) {
return forwardMatchIndex;
}
var backwardMatchIndex = forwardMatchIndex - 1;
var forwardMatchPos = matches[forwardMatchIndex].start;
var backwardMatchPos = matches[backwardMatchIndex].start;
if (forwardMatchPos - selectionPos < selectionPos - backwardMatchPos) {
return forwardMatchIndex;
} else {
return backwardMatchIndex;
}
}
/**
* Finds index of first item in matches array that comes after user's cursor pos.
* If `backward` is `true`, finds index of first item that comes before instead.
*/
export function findSearchIndex(selectionPos, matches) {
var backward = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false;
if (backward) {
var matchIndex = matches.findIndex(function (match) {
return match.start >= selectionPos;
}) - 1;
if (matchIndex < 0) {
matchIndex = matches.length - 1; // wrap around from the end
}
return matchIndex;
}
return Math.max(matches.findIndex(function (match) {
return match.start >= selectionPos;
}), 0);
}
export var nextIndex = function nextIndex(currentIndex, total) {
return (currentIndex + 1) % total;
};
export var prevIndex = function prevIndex(currentIndex, total) {
return (currentIndex - 1 + total) % total;
};
export var getSelectionForMatch = function getSelectionForMatch(selection, doc, index, matches) {
var offset = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : 0;
if (matches[index]) {
if (isExpandTitle(matches[index])) {
return NodeSelection.create(doc, matches[index].start);
}
return TextSelection.create(doc, matches[index].start + offset);
}
return selection;
};
export var findDecorationFromMatch = function findDecorationFromMatch(decorationSet, match) {
if (!match) {
return;
}
var decorations = decorationSet.find(match.start, match.end);
return decorations.length ? decorations.find(
// decorationSet.find() returns any decorations that touch the specifided
// positions, but we want to be stricter
function (decoration) {
return decoration.from === match.start && decoration.to === match.end;
}) : undefined;
};
export var removeDecorationsFromSet = function removeDecorationsFromSet(decorationSet, decorationsToRemove, doc) {
var prevDecorations = decorationSet.find();
// it is essential that we copy the decorations otherwise in some rare cases
// prosemirror-view will update our decorationsToRemove array to contain nulls
// instead of Decorations which ruins our check for lost decorations below
decorationSet = decorationSet.remove(decorationsToRemove.map(function (decoration) {
return (
// copy exists but isn't on the type definition
decoration.copy(decoration.from, decoration.to)
);
}));
var newDecorations = decorationSet.find();
// there is a bug in prosemirror-view where it can't cope with deleting inline
// decorations from a set in some cases (where there are multiple levels of nested
// children arrays), and it deletes more decorations than it should
var lostDecorations = findLostAdjacentDecorations(decorationsToRemove, prevDecorations, newDecorations);
if (lostDecorations.length > 0) {
decorationSet = decorationSet.add(doc, lostDecorations);
}
return decorationSet;
};
export var removeMatchesFromSet = function removeMatchesFromSet(decorationSet, matches, doc) {
var decorationsToRemove = matches.filter(function (match) {
return !!match;
}).map(function (match) {
return findDecorationFromMatch(decorationSet, match);
});
decorationsToRemove.forEach(function (decoration) {
if (decoration) {
decorationSet = removeDecorationsFromSet(decorationSet, [decoration], doc);
}
});
return decorationSet;
};
/**
* Finds decorations in prevDecorations that are not in newDecorations or decorationsToRemove
* These decorations have been lost by Prosemirror during an over eager decoration removal
* We need to be smart to cope with thousands of decorations without crashing everything
*/
export var findLostAdjacentDecorations = function findLostAdjacentDecorations(decorationsToRemove, prevDecorations, newDecorations) {
var lostDecorations = [];
if (prevDecorations.length - decorationsToRemove.length > newDecorations.length) {
var position = decorationsToRemove.length > 0 ? decorationsToRemove[0].from : 0;
var prevDecorationsStartIdx = findIndexBeforePosition(prevDecorations, position);
var newDecorationsStartIdx = findIndexBeforePosition(newDecorations, position);
var startIdx = Math.min(prevDecorationsStartIdx, newDecorationsStartIdx);
var prevDecorationsToCheck = prevDecorations.slice(startIdx);
var newDecorationsToCheck = newDecorations.slice(startIdx);
var uniqueInPrev = [];
var numToFind = prevDecorationsToCheck.length - newDecorationsToCheck.length;
var foundAll = false;
var newDecorationsIdxOffset = 0;
var _loop = function _loop() {
var prevDecoration = prevDecorationsToCheck[i];
// this was a legit removal, skip and continue
if (decorationsToRemove.find(function (decoration) {
return decoration.from === prevDecoration.from;
})) {
newDecorationsIdxOffset -= 1;
return 0; // continue
}
var j = i + newDecorationsIdxOffset;
// this is a lost decoration
if (j >= newDecorationsToCheck.length) {
uniqueInPrev.push(prevDecoration);
if (uniqueInPrev.length === numToFind) {
foundAll = true;
}
}
for (; j < newDecorationsToCheck.length; j++) {
var newDecoration = newDecorationsToCheck[j];
// decoration found in both arrays, skip and continue
if (prevDecoration.from === newDecoration.from) {
break;
}
// this is a lost decoration
if (newDecoration.from > prevDecoration.from || j === newDecorationsToCheck.length - 1) {
uniqueInPrev.push(prevDecoration);
newDecorationsIdxOffset -= 1;
if (uniqueInPrev.length === numToFind) {
foundAll = true;
}
break;
}
}
if (foundAll) {
return 1; // break
}
},
_ret;
for (var i = 0; i < prevDecorationsToCheck.length; i++) {
_ret = _loop();
if (_ret === 0) continue;
if (_ret === 1) break;
}
// make sure we ignore any that we wanted to delete
lostDecorations = uniqueInPrev.filter(function (decoration) {
return !decorationsToRemove.find(function (decorationToRemove) {
return decoration.from === decorationToRemove.from;
});
});
}
return lostDecorations;
};
/**
* Searches through array in bumps of 100 to return the index of the first
* decoration whose 'from' value is before or equal to the position
*/
export var findIndexBeforePosition = function findIndexBeforePosition(items, position) {
// jump in batches to cope with arrays with thousands of decorations
var increment = 100;
var index = 0;
for (var i = items.length - 1; i >= 0; i -= increment) {
if (items[i].from < position) {
// now we have found the 100 range, we can narrow it down to exact index
index = i;
for (var j = i; j <= items.length - 1; j++) {
if (items[j].from <= position) {
index = j;
} else {
break;
}
}
break;
}
if (i < 100 && i > 0) {
i = 100;
}
}
return index;
};
/**
* Determines whether a find/replace text Match will be changed as a result
* of a Step modification to the document. This is evaluated by checking
* both mapped and unmapped versions of the Step as in different cases the
* matches will match.
*
* **Note:** Match state received here is after step has been applied.
*/
export var isMatchAffectedByStep = function isMatchAffectedByStep(match, step, tr) {
var from = step.from,
to = step.to,
slice = step.slice;
var sliceSize = slice.content.size;
return from + sliceSize >= match.start && to - sliceSize <= match.end || tr.mapping.map(from) + sliceSize >= match.start && tr.mapping.map(to) - sliceSize <= match.end;
};
export function findUniqueItemsIn(findIn, checkWith, comparator) {
return findIn.filter(function (firstItem) {
return checkWith.findIndex(function (secondItem) {
return comparator ? comparator(firstItem, secondItem) : firstItem === secondItem;
}) === -1;
});
}