@atlaskit/editor-plugin-find-replace
Version:
find replace plugin for @atlaskit/editor-core
442 lines (432 loc) • 16.5 kB
JavaScript
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) {
let text = '';
const selectedContent = selection.content().content;
for (let i = 0; i < selectedContent.childCount; i++) {
text += selectedContent.child(i).textContent;
}
return text;
}
export const createDecorations = (selectedIndex, matches) => matches.map(({
start,
end,
canReplace,
nodeType
}, i) => createDecoration({
start,
end,
canReplace,
nodeType
}, i === selectedIndex));
const isElement = nodeType => ['blockCard', 'embedCard', 'inlineCard', 'status', 'mention', 'date'].includes(nodeType || '');
const isExpandTitle = match => ['expand', 'nestedExpand'].includes(match.nodeType || '') && !match.canReplace;
export const createDecoration = (match, isSelected) => {
const {
start,
end,
nodeType
} = match;
if (expValEquals('platform_editor_find_and_replace_improvements', 'isEnabled', true)) {
const {
colorMode
} = getGlobalTheme();
if (isElement(nodeType)) {
const className = classnames(blockSearchMatchClass, {
[selectedBlockSearchMatchClass]: isSelected,
[darkModeSearchMatchClass]: colorMode === 'dark'
});
return Decoration.node(start, end, {
class: className
});
} else if (isExpandTitle(match)) {
const className = classnames(searchMatchExpandTitleClass, {
[selectedSearchMatchClass]: isSelected,
[darkModeSearchMatchClass]: colorMode === 'dark'
});
return Decoration.node(start, end, {
class: className
});
} else {
const className = classnames(searchMatchTextClass, {
[selectedSearchMatchClass]: isSelected,
[darkModeSearchMatchClass]: colorMode === 'dark'
});
return Decoration.inline(start, end, {
class: className
});
}
} else {
let className = searchMatchClass;
if (isSelected) {
className += ` ${selectedSearchMatchClass}`;
}
return Decoration.inline(start, end, {
class: className
});
}
};
export function findMatches({
content,
searchText,
shouldMatchCase,
contentIndex = 0,
getIntl,
api
}) {
const matches = [];
const searchTextLength = searchText.length;
let textGrouping = null;
const collectTextMatch = textGrouping => {
if (!textGrouping) {
return;
}
let {
text
} = textGrouping;
const {
pos: relativePos
} = textGrouping;
const pos = contentIndex + relativePos;
if (!shouldMatchCase) {
searchText = searchText.toLowerCase();
text = text.toLowerCase();
}
let index = text.indexOf(searchText);
while (index !== -1) {
// Find the next substring from the end of the first, so that they don't overlap
const 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);
}
};
const collectNodeMatch = (textGrouping, node) => {
if (!textGrouping) {
return;
}
const {
pos
} = textGrouping;
let {
text
} = textGrouping;
if (node.type.name === 'status' && shouldMatchCase) {
text = text.toUpperCase();
} else if (!shouldMatchCase) {
text = text.toLowerCase();
searchText = searchText.toLowerCase();
}
const index = text.indexOf(searchText);
if (index !== -1) {
matches.push({
start: pos,
end: pos + node.nodeSize,
canReplace: false,
nodeType: node.type.name
});
}
};
const collectCardTitleMatch = (node, pos) => {
var _api$card, _api$card$sharedState;
const cards = api === null || api === void 0 ? void 0 : (_api$card = api.card) === null || _api$card === void 0 ? void 0 : (_api$card$sharedState = _api$card.sharedState.currentState()) === null || _api$card$sharedState === void 0 ? void 0 : _api$card$sharedState.cards;
if (cards) {
const relevantCard = cards.find(card => card.url === node.attrs.url);
const title = relevantCard === null || relevantCard === void 0 ? void 0 : relevantCard.title;
if (relevantCard) {
if (title) {
collectNodeMatch({
text: title,
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
}, node);
}
}
}
}
};
if (searchTextLength > 0) {
content.descendants((node, pos) => {
if (node.isText) {
if (textGrouping === null) {
textGrouping = {
text: node.text,
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
}, node);
break;
case 'date':
collectNodeMatch({
text: timestampToString(node.attrs.timestamp, getIntl ? getIntl() : null),
pos
}, node);
break;
case 'expand':
case 'nestedExpand':
collectNodeMatch({
text: node.attrs.title,
pos
}, node);
break;
case 'mention':
let text;
if (node.attrs.text) {
text = node.attrs.text;
} else {
var _api$mention, _api$mention$sharedSt;
// the text may be sanitised from the node for privacy reasons
// so we need to use the mentionProvider to resolve it
const mentionProvider = api === null || api === void 0 ? void 0 : (_api$mention = api.mention) === null || _api$mention === void 0 ? void 0 : (_api$mention$sharedSt = _api$mention.sharedState.currentState()) === null || _api$mention$sharedSt === void 0 ? void 0 : _api$mention$sharedSt.mentionProvider;
if (isResolvingMentionProvider(mentionProvider)) {
const nameDetail = mentionProvider.resolveMentionName(node.attrs.id);
if (isPromise(nameDetail)) {
text = '@...';
} else {
if (nameDetail.status === MentionNameStatus.OK) {
text = `@${nameDetail.name || ''}`;
} else {
text = '@_|unknown|_';
}
}
}
}
if (text) {
collectNodeMatch({
text,
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) {
const forwardMatchIndex = matches.findIndex(match => 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;
}
const backwardMatchIndex = forwardMatchIndex - 1;
const forwardMatchPos = matches[forwardMatchIndex].start;
const 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, backward = false) {
if (backward) {
let matchIndex = matches.findIndex(match => match.start >= selectionPos) - 1;
if (matchIndex < 0) {
matchIndex = matches.length - 1; // wrap around from the end
}
return matchIndex;
}
return Math.max(matches.findIndex(match => match.start >= selectionPos), 0);
}
export const nextIndex = (currentIndex, total) => (currentIndex + 1) % total;
export const prevIndex = (currentIndex, total) => (currentIndex - 1 + total) % total;
export const getSelectionForMatch = (selection, doc, index, matches, offset = 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 const findDecorationFromMatch = (decorationSet, match) => {
if (!match) {
return;
}
const 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
decoration => decoration.from === match.start && decoration.to === match.end) : undefined;
};
export const removeDecorationsFromSet = (decorationSet, decorationsToRemove, doc) => {
const 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(decoration =>
// copy exists but isn't on the type definition
decoration.copy(decoration.from, decoration.to)));
const 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
const lostDecorations = findLostAdjacentDecorations(decorationsToRemove, prevDecorations, newDecorations);
if (lostDecorations.length > 0) {
decorationSet = decorationSet.add(doc, lostDecorations);
}
return decorationSet;
};
export const removeMatchesFromSet = (decorationSet, matches, doc) => {
const decorationsToRemove = matches.filter(match => !!match).map(match => findDecorationFromMatch(decorationSet, match));
decorationsToRemove.forEach(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 const findLostAdjacentDecorations = (decorationsToRemove, prevDecorations, newDecorations) => {
let lostDecorations = [];
if (prevDecorations.length - decorationsToRemove.length > newDecorations.length) {
const position = decorationsToRemove.length > 0 ? decorationsToRemove[0].from : 0;
const prevDecorationsStartIdx = findIndexBeforePosition(prevDecorations, position);
const newDecorationsStartIdx = findIndexBeforePosition(newDecorations, position);
const startIdx = Math.min(prevDecorationsStartIdx, newDecorationsStartIdx);
const prevDecorationsToCheck = prevDecorations.slice(startIdx);
const newDecorationsToCheck = newDecorations.slice(startIdx);
const uniqueInPrev = [];
const numToFind = prevDecorationsToCheck.length - newDecorationsToCheck.length;
let foundAll = false;
let newDecorationsIdxOffset = 0;
for (let i = 0; i < prevDecorationsToCheck.length; i++) {
const prevDecoration = prevDecorationsToCheck[i];
// this was a legit removal, skip and continue
if (decorationsToRemove.find(decoration => decoration.from === prevDecoration.from)) {
newDecorationsIdxOffset -= 1;
continue;
}
let 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++) {
const 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) {
break;
}
}
// make sure we ignore any that we wanted to delete
lostDecorations = uniqueInPrev.filter(decoration => !decorationsToRemove.find(decorationToRemove => 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 const findIndexBeforePosition = (items, position) => {
// jump in batches to cope with arrays with thousands of decorations
const increment = 100;
let index = 0;
for (let 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 (let 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 const isMatchAffectedByStep = (match, step, tr) => {
const {
from,
to,
slice
} = step;
const 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(firstItem => checkWith.findIndex(secondItem => comparator ? comparator(firstItem, secondItem) : firstItem === secondItem) === -1);
}