UNPKG

@atlaskit/editor-plugin-find-replace

Version:

find replace plugin for @atlaskit/editor-core

290 lines (287 loc) 10.4 kB
import { TextSelection } from '@atlaskit/editor-prosemirror/state'; import { DecorationSet } from '@atlaskit/editor-prosemirror/view'; import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals'; import { FindReplaceActionTypes } from './actions'; import { createCommand, getPluginState } from './plugin-factory'; import { createDecoration, findClosestMatch, findDecorationFromMatch, findMatches, findSearchIndex, getSelectedText, getSelectionForMatch, nextIndex, prevIndex, removeDecorationsFromSet, removeMatchesFromSet } from './utils'; import batchDecorations from './utils/batch-decorations'; import { withScrollIntoView } from './utils/commands'; export const activate = () => createCommand(state => { const { selection } = state; let findText; let matches; let index; // if user has selected text and hit cmd-f, set that as the keyword if (selection instanceof TextSelection && !selection.empty) { findText = getSelectedText(selection); const { shouldMatchCase, getIntl, api } = getPluginState(state); matches = findMatches({ content: state.doc, searchText: findText, shouldMatchCase, getIntl, api }); index = expValEquals('platform_editor_find_and_replace_improvements', 'isEnabled', true) ? findClosestMatch(selection.from, matches) : findSearchIndex(selection.from, matches); } return { type: FindReplaceActionTypes.ACTIVATE, findText, matches, index }; }); export const find = (editorView, containerElement, keyword) => withScrollIntoView(createCommand(state => { const { selection } = state; const { shouldMatchCase, getIntl, api } = getPluginState(state); const matches = keyword !== undefined ? findMatches({ content: state.doc, searchText: keyword, shouldMatchCase, getIntl, api }) : []; const index = expValEquals('platform_editor_find_and_replace_improvements', 'isEnabled', true) ? findClosestMatch(selection.from, matches) : findSearchIndex(selection.from, matches); // we can't just apply all the decorations to highlight the search results at once // as if there are a lot ProseMirror cries :'( batchDecorations.applyAllSearchDecorations(editorView, containerElement, decorations => addDecorations(decorations)(editorView.state, editorView.dispatch), decorations => removeDecorations(decorations)(editorView.state, editorView.dispatch)); return { type: FindReplaceActionTypes.FIND, findText: keyword || '', matches, index }; }, (tr, state) => { const { selection } = state; const { shouldMatchCase, getIntl, api } = getPluginState(state); const matches = keyword !== undefined ? findMatches({ content: state.doc, searchText: keyword, shouldMatchCase, getIntl, api }) : []; if (matches.length > 0) { var _api$expand; const index = expValEquals('platform_editor_find_and_replace_improvements', 'isEnabled', true) ? findClosestMatch(selection.from, matches) : findSearchIndex(selection.from, matches); const newSelection = getSelectionForMatch(tr.selection, tr.doc, index, matches); api === null || api === void 0 ? void 0 : (_api$expand = api.expand) === null || _api$expand === void 0 ? void 0 : _api$expand.commands.toggleExpandWithMatch(newSelection)({ tr }); return tr.setSelection(newSelection); } return tr; })); export const findNext = editorView => withScrollIntoView(createCommand(state => findInDirection(state, 'next'), (tr, state) => { var _api$expand2; const { matches, index, api } = getPluginState(state); // can't use index from plugin state because if the cursor has moved, it will still be the // OLD index (the find next operation should look for the first match forward starting // from the current cursor position) let searchIndex = findSearchIndex(state.selection.from, matches); if (searchIndex === index) { // cursor has not moved, so we just want to find the next in matches array searchIndex = nextIndex(searchIndex, matches.length); } const newSelection = getSelectionForMatch(tr.selection, tr.doc, searchIndex, matches); api === null || api === void 0 ? void 0 : (_api$expand2 = api.expand) === null || _api$expand2 === void 0 ? void 0 : _api$expand2.commands.toggleExpandWithMatch(newSelection)({ tr }); return tr.setSelection(newSelection); })); export const findPrevious = editorView => withScrollIntoView(createCommand(state => findInDirection(state, 'previous'), (tr, state) => { var _api$expand3; const { matches, api } = getPluginState(state); // can't use index from plugin state because if the cursor has moved, it will still be the // OLD index (the find prev operation should look for the first match backward starting // from the current cursor position) const searchIndex = findSearchIndex(state.selection.from, matches, true); const newSelection = getSelectionForMatch(tr.selection, tr.doc, searchIndex, matches); api === null || api === void 0 ? void 0 : (_api$expand3 = api.expand) === null || _api$expand3 === void 0 ? void 0 : _api$expand3.commands.toggleExpandWithMatch(newSelection)({ tr }); return tr.setSelection(newSelection); })); const findInDirection = (state, dir) => { const pluginState = getPluginState(state); const { matches, findText } = pluginState; let { decorationSet, index } = pluginState; if (findText) { const searchIndex = findSearchIndex(state.selection.from, matches, dir === 'previous'); // compare index from plugin state and index of first match forward from cursor position if (index === searchIndex) { // normal case, cycling through matches index = dir === 'next' ? nextIndex(index, matches.length) : prevIndex(index, matches.length); } else { // cursor has moved index = searchIndex; } decorationSet = updateSelectedHighlight(state, index); } return { type: dir === 'next' ? FindReplaceActionTypes.FIND_NEXT : FindReplaceActionTypes.FIND_PREVIOUS, index, decorationSet }; }; export const replace = replaceText => withScrollIntoView(createCommand(state => { const pluginState = getPluginState(state); const { findText, matches } = pluginState; let { decorationSet, index } = pluginState; decorationSet = updateSelectedHighlight(state, nextIndex(index, matches.length)); if (replaceText.toLowerCase().indexOf(findText.toLowerCase()) === -1) { decorationSet = removeMatchesFromSet(decorationSet, [matches[index]], state.doc); matches.splice(index, 1); if (index > matches.length - 1) { index = 0; } } else { index = nextIndex(index, matches.length); } return { type: FindReplaceActionTypes.REPLACE, replaceText, decorationSet, matches, index }; }, (tr, state) => { const { matches, index, findText } = getPluginState(state); if (matches[index]) { if (!matches[index].canReplace && expValEquals('platform_editor_find_and_replace_improvements', 'isEnabled', true)) { return tr; } const { start, end } = matches[index]; const newIndex = nextIndex(index, matches.length); tr.insertText(replaceText, start, end).setSelection(getSelectionForMatch(tr.selection, tr.doc, newIndex, matches, newIndex === 0 ? 0 : replaceText.length - findText.length)); } return tr; })); export const replaceAll = replaceText => createCommand({ type: FindReplaceActionTypes.REPLACE_ALL, replaceText: replaceText, decorationSet: DecorationSet.empty, matches: [], index: 0 }, (tr, state) => { const pluginState = getPluginState(state); pluginState.matches.forEach(match => { if (!match.canReplace && expValEquals('platform_editor_find_and_replace_improvements', 'isEnabled', true)) { return tr; } tr.insertText(replaceText, tr.mapping.map(match.start), tr.mapping.map(match.end)); }); tr.setMeta('scrollIntoView', false); return tr; }); export const addDecorations = decorations => createCommand(state => { const { decorationSet } = getPluginState(state); return { type: FindReplaceActionTypes.UPDATE_DECORATIONS, decorationSet: decorationSet.add(state.doc, decorations) }; }); export const removeDecorations = decorations => createCommand(state => { const { decorationSet } = getPluginState(state); return { type: FindReplaceActionTypes.UPDATE_DECORATIONS, decorationSet: removeDecorationsFromSet(decorationSet, decorations, state.doc) }; }); export const cancelSearch = () => createCommand(() => { batchDecorations.stop(); return { type: FindReplaceActionTypes.CANCEL }; }); export const blur = () => createCommand({ type: FindReplaceActionTypes.BLUR }); export const toggleMatchCase = () => createCommand({ type: FindReplaceActionTypes.TOGGLE_MATCH_CASE }); const updateSelectedHighlight = (state, nextSelectedIndex) => { const { index, matches } = getPluginState(state); let { decorationSet } = getPluginState(state); const currentSelectedMatch = matches[index]; const nextSelectedMatch = matches[nextSelectedIndex]; if (index === nextSelectedIndex) { return decorationSet; } const currentSelectedDecoration = findDecorationFromMatch(decorationSet, currentSelectedMatch); const nextSelectedDecoration = findDecorationFromMatch(decorationSet, nextSelectedMatch); // Update decorations so the current selected match becomes a normal match // and the next selected gets the selected styling const decorationsToRemove = []; if (currentSelectedDecoration) { decorationsToRemove.push(currentSelectedDecoration); } if (nextSelectedDecoration) { decorationsToRemove.push(nextSelectedDecoration); } if (decorationsToRemove.length > 0) { // removeDecorationsFromSet depends on decorations being pre-sorted decorationsToRemove.sort((a, b) => a.from < b.from ? -1 : 1); decorationSet = removeDecorationsFromSet(decorationSet, decorationsToRemove, state.doc); } if (currentSelectedMatch) { decorationSet = decorationSet.add(state.doc, [createDecoration(currentSelectedMatch)]); } if (nextSelectedMatch) { decorationSet = decorationSet.add(state.doc, [createDecoration(nextSelectedMatch, true)]); } return decorationSet; };